View: Hide White Space between pages in Print Layout (#645) (#684)

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-12 06:34:30 +02:00 committed by GitHub
parent 19684cff26
commit 8bd3427012
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 164 additions and 4 deletions

View File

@ -130,6 +130,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
## Progress log
| Date | What landed |
| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged |
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|---|---|
| 2026-10-11 | Security review fixes: `ui.render`/`ui.screenshot` without a `path` (and the MCP bridge's `render_page`/`screenshot`) return the PNG in the reply instead of writing a fixed name into the shared temporary folder; exported HTML/Markdown write `href` only for web, mail, bookmark and relative links, never `javascript:` and other schemes (one allow-list in `wordcraft_doc::link`) |

View File

@ -27,6 +27,10 @@ pub fn specs() -> Vec<CommandSpec> {
// The body as an outline of its headings, with the Outlining tab (`outline.*`).
CommandSpec::new("view.outline", "Outline", "View › Views", |s, _| mode(s, ViewMode::Outline, false)).key("Mod+Alt+O").pure(),
CommandSpec::new("view.readMode", "Read Mode", "View › Views", |s, _| mode(s, ViewMode::Print, true)).pure(),
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
.params(r#"{"value"?: bool}"#)
.pure(),
CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| toggle(s, v, |x| &mut x.focus_mode)).pure(),
CommandSpec::new("view.ruler", "Ruler", "View › Show", |s, v| toggle(s, v, |x| &mut x.ruler)).pure(),
// The drawing grid over the page (screen only; never printed or exported).

View File

@ -95,6 +95,11 @@ pub struct ViewState {
/// Show field codes instead of their results (`view.fieldCodes`, Alt+F9).
#[serde(default)]
pub field_codes: bool,
/// Print Layout › Hide White Space: pages run together, the top and bottom margins (with
/// headers and footers) between pages collapse to a thin line. On screen only; the layout
/// and page count are unchanged.
#[serde(default)]
pub hide_white_space: bool,
/// Outline view: collapsed headings, Show Level, first lines, text formatting.
#[serde(default)]
pub outline: crate::cmd::outline::OutlineState,
@ -136,6 +141,7 @@ impl Default for ViewState {
hide_ink: false,
draw: Default::default(),
field_codes: false,
hide_white_space: false,
outline: Default::default(),
design_mode: false,
}

View File

@ -3588,6 +3588,17 @@ fn note_options_per_section_and_convert_notes() {
assert!(s.run("references.convertNotes", &json!({"to": "sideways"})).is_err());
}
#[test]
fn hide_white_space_toggles_view_state_only() {
let mut s = s();
let before = s.doc.body.len();
assert!(!s.view.hide_white_space);
assert_eq!(run(&mut s, "view.hideWhiteSpace", json!({}))["value"], true);
assert!(s.view.hide_white_space);
assert_eq!(run(&mut s, "view.hideWhiteSpace", json!({"value": false}))["value"], false);
assert_eq!(s.doc.body.len(), before, "a view option never edits the document");
}
/// The first SmartArt (or other graphic) in the body, selected.
fn select_graphic(s: &mut Session) {
let (path, off) = (0..s.doc.body.len() as u32)

View File

@ -181,6 +181,62 @@ pub struct Geometry {
pub scale: f32,
/// Pages per row.
pub cols: usize,
/// Per page, the vertical range (pixels from the page rect's top) that is on screen. The
/// whole page, except with Hide White Space, which cuts the margins between pages.
pub bands: Vec<(f32, f32)>,
}
/// Room kept above the first line and below the last line of a page when white space is hidden (points).
const WHITE_SPACE_PAD: f32 = 6.0;
/// The line drawn between two pages when white space is hidden (pixels).
const SEAM: f32 = 1.0;
/// Hide White Space: stack single-column pages (`rects`, full pages) so that only each page's
/// text area (`bodies`: top and bottom of the body in points, plus a little room) shows, with a
/// [`SEAM`] between pages. The first page keeps its top and the last its bottom. Rects keep the
/// full page size, so page coordinates still map as `(p - rect.min) / scale`; neighbouring rects
/// overlap in the hidden margins. Returns the moved rects, the visible band of each, and the
/// content height.
fn collapse_white_space(rects: &[Rect], bodies: &[(f32, f32)], scale: f32, avail_h: f32) -> (Vec<Rect>, Vec<(f32, f32)>, f32) {
let n = rects.len();
let scale = if scale.is_finite() { scale.clamp(0.01, 10.0) } else { PX_PER_PT };
let avail_h = if avail_h.is_finite() { avail_h } else { 0.0 };
let mut out = Vec::with_capacity(n);
let mut bands = Vec::with_capacity(n);
let Some(first) = rects.first() else { return (out, bands, avail_h) };
let mut cursor = first.min.y;
for (i, r) in rects.iter().enumerate() {
let h = if r.height().is_finite() { r.height().max(0.0) } else { 0.0 };
let (bt, bb) = bodies.get(i).copied().unwrap_or((0.0, h));
let fin = |v: f32, d: f32| if v.is_finite() { v } else { d };
let top = if i == 0 { 0.0 } else { (fin(bt, 0.0) * scale - WHITE_SPACE_PAD * scale).clamp(0.0, h) };
let bot = if i + 1 == n { h } else { (fin(bb, 0.0) * scale + WHITE_SPACE_PAD * scale).clamp(top, h) };
let y = cursor - top;
out.push(Rect::from_min_size(pos2(r.min.x, y), r.size()));
bands.push((top, bot));
cursor = y + bot + SEAM;
}
(out, bands, (cursor - SEAM + GAP).max(avail_h))
}
/// True when any page is cut to a band (white space is hidden).
fn white_space_hidden(rects: &[Rect], bands: &[(f32, f32)]) -> bool {
rects.iter().zip(bands).any(|(r, b)| b.0 > 0.5 || b.1 < r.height() - 0.5)
}
/// True when a double-click at `p` is on the space between two pages (or on the seam when white
/// space is hidden): Word's way to toggle Hide White Space.
fn on_page_gap(rects: &[Rect], bands: &[(f32, f32)], p: Pos2) -> bool {
let hidden = white_space_hidden(rects, bands);
rects.windows(2).enumerate().any(|(i, w)| {
let (a, b) = (w[0], w[1]);
let in_x = p.x >= a.min.x.min(b.min.x) && p.x <= a.max.x.max(b.max.x);
let (y0, y1) = match bands.get(i) {
Some(&(_, bot)) if hidden => (a.min.y + bot - 3.0, a.min.y + bot + SEAM + 3.0),
_ => (a.max.y, b.min.y),
};
in_x && p.y >= y0 && p.y <= y1
})
}
/// Width of the markup area beside each page for comment balloons (points), or 0.
@ -281,8 +337,16 @@ pub fn geometry(app: &WordApp, l: &DocLayout, avail: egui::Vec2) -> Geometry {
};
let cols = if v.read_mode { 2 } else { columns_for(avail.x, maxw * scale, l.pages.len(), min_cols, auto) };
let sizes: Vec<(f32, f32)> = l.pages.iter().map(|p| (p.w, p.h)).collect();
let (rects, size) = place_pages(&sizes, markup, scale, cols, avail);
Geometry { rects, size, scale, cols }
let (mut rects, mut size) = place_pages(&sizes, markup, scale, cols, avail);
let mut bands: Vec<(f32, f32)> = rects.iter().map(|r| (0.0, r.height())).collect();
let editing_part = matches!(app.session.sel.focus.story, StoryRef::Part(_));
if v.hide_white_space && v.mode == wordcraft_layout::ViewMode::Print && !v.read_mode && cols == 1 && !editing_part && rects.len() > 1 {
let bodies: Vec<(f32, f32)> = l.pages.iter().map(|p| (p.body.y, p.body.bottom())).collect();
let (r, b, h) = collapse_white_space(&rects, &bodies, scale, avail.y);
(rects, bands) = (r, b);
size.y = h;
}
Geometry { rects, size, scale, cols, bands }
}
/// While a fit mode (Page Width, One Page, Multiple Pages) sizes the page, keep the session's zoom
@ -476,6 +540,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
for (i, pr) in geo.rects.iter().enumerate() {
let sr = pr.translate(content_origin.to_vec2());
let Some(page) = layout.pages.get(i) else { continue };
let (band_top, band_bot) = geo.bands.get(i).copied().unwrap_or((0.0, sr.height()));
let scale_px = (geo.scale * ppp).min(max_tex / page.w.max(1.0)).min(max_tex / page.h.clamp(1.0, 1e6)).max(0.05);
let key = page_key(app, page, scale_px, dim_body);
let fresh = app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key);
@ -486,9 +551,17 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
sr
};
rects.push(visual_sr);
if !sr.intersects(ui.clip_rect().expand(200.0)) {
// Hide White Space shows only a band of the page; everything below paints into it.
let band =
Rect::from_min_max(pos2(visual_sr.min.x - 4.0, visual_sr.min.y + band_top), pos2(visual_sr.max.x + 4.0, visual_sr.min.y + band_bot));
if !band.intersects(ui.clip_rect().expand(200.0)) {
continue;
}
let painter = if band_top <= 0.0 && band_bot >= sr.height() {
painter.clone()
} else {
painter.with_clip_rect(painter.clip_rect().intersect(band))
};
// Shadow and paper.
painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow);
painter.rect_filled(visual_sr, 0.0, if dark_page { Color32::from_gray(dark_paper) } else { Color32::WHITE });
@ -581,6 +654,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
}
}
// Hide White Space: a thin line where one page ends and the next begins.
for (i, pair) in rects.windows(2).enumerate() {
let Some(&(_, bot)) = geo.bands.get(i) else { continue };
if white_space_hidden(&geo.rects, &geo.bands) {
let y = pair[0].min.y + bot;
painter.rect_filled(Rect::from_min_max(pos2(pair[0].min.x, y), pos2(pair[0].max.x, y + SEAM)), 0.0, t.border_strong);
}
}
if rendered > 0 {
app.canvas.render_ms = crate::now_ms() - t0;
}
@ -749,6 +830,15 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
app.canvas.focused = resp.has_focus();
// Screen readers: the document under the canvas, with the caret (#488).
crate::a11y::expose(app, ui.ctx(), resp.id, &layout, &rects, area);
// Double-click the space between two pages (or the line between them) to hide or show it.
if resp.double_clicked()
&& app.session.view.mode == wordcraft_layout::ViewMode::Print
&& !app.session.view.read_mode
&& let Some(p) = resp.interact_pointer_pos()
&& on_page_gap(&rects, &geo.bands, p)
{
let _ = app.run("view.hideWhiteSpace", json!({"value": !app.session.view.hide_white_space}));
} else
// Draw Table / Eraser own the mouse while on (no caret moves); else the usual editing.
if !crate::table_pen::pointer(app, ui, &resp, &rects, &layout, geo.scale) {
mouse(app, ui, &resp, &rects, &layout, geo.scale);
@ -1123,7 +1213,8 @@ pub fn nearest_page(rects: &[Rect], p: Pos2) -> Option<usize> {
let mut best: Option<(usize, f32)> = None;
for (i, r) in rects.iter().enumerate() {
let d = if r.contains(p) {
0.0
// Pages overlap in the margins Hide White Space cuts: the one whose middle is nearer wins.
1e-3 * (p.y - r.center().y).abs() / r.height().max(1.0)
} else {
let dx = (r.min.x - p.x).max(p.x - r.max.x).max(0.0);
let dy = (r.min.y - p.y).max(p.y - r.max.y).max(0.0);
@ -2223,4 +2314,36 @@ mod tests {
assert_eq!(visual, layout);
}
#[test]
fn hiding_white_space_stacks_page_bands_and_keeps_hit_testing() {
let scale = 1.0;
let sizes = [(612.0, 792.0); 3];
let (rects, _) = place_pages(&sizes, 0.0, scale, 1, vec2(800.0, 600.0));
// Body from 72 to 720 pt on every page.
let bodies = [(72.0, 720.0); 3];
let (moved, bands, h) = collapse_white_space(&rects, &bodies, scale, 600.0);
assert_eq!(bands[0].0, 0.0, "first page keeps its top");
assert_eq!(bands[2].1, 792.0, "last page keeps its bottom");
assert_eq!(bands[1], (66.0, 726.0));
// Bands abut with one seam, so the content is much shorter than three full pages.
assert_eq!(moved[1].min.y + bands[1].0, moved[0].min.y + bands[0].1 + SEAM);
assert!(h < rects[2].bottom() - 2.0 * 66.0 * 2.0);
// A point inside page 2's band maps to that page and the right page coordinates.
let p = pos2(moved[1].center().x, moved[1].min.y + 300.0);
assert_eq!(page_at_index(&moved, p), Some(1));
// Just above the seam is page 1's text area, just below is page 2's, though the rects overlap.
let seam = moved[0].min.y + bands[0].1;
assert_eq!(nearest_page(&moved, pos2(moved[0].center().x, seam - 2.0)), Some(0));
assert_eq!(nearest_page(&moved, pos2(moved[0].center().x, seam + SEAM + 2.0)), Some(1));
assert!(on_page_gap(&moved, &bands, pos2(moved[0].center().x, seam + 0.5)));
assert!(!on_page_gap(&moved, &bands, pos2(moved[0].center().x, seam - 40.0)));
// Hostile numbers never panic.
let _ = collapse_white_space(&rects, &[(f32::NAN, f32::INFINITY), (-1e9, 1e9)], f32::NAN, f32::NAN);
let _ = collapse_white_space(&[], &[], 1.0, 1.0);
}
fn page_at_index(rects: &[Rect], p: Pos2) -> Option<usize> {
nearest_page(rects, p)
}
}

View File

@ -1698,6 +1698,7 @@
Continuous numbering Numeración continua
Apply Aplicar
Convert Notes Convertir notas
Hide White Space Ocultar espacio en blanco
All pages Todas las páginas
Current page Página actual
Selected pages Páginas seleccionadas

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1659,6 +1659,7 @@
Continuous numbering Pidev nummerdus
Apply Rakenda
Convert Notes Teisenda märkused
Hide White Space Peida tühi ala lehtede vahel
All pages Kõik leheküljed
Current page Praegune lehekülg
Selected pages Valitud leheküljed

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -1682,6 +1682,7 @@
Continuous numbering 通し番号
Apply 適用
Convert Notes 注の変換
Hide White Space 余白を非表示
All pages すべてのページ
Current page 現在のページ
Selected pages 選択したページ

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -1694,6 +1694,7 @@
Continuous numbering Numeração contínua
Apply Aplicar
Convert Notes Converter notas
Hide White Space Ocultar espaço em branco
All pages Todas as páginas
Current page Página atual
Selected pages Páginas selecionadas

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -2127,6 +2127,7 @@ preview Title Пример
Continuous numbering Сквозная нумерация
Apply Применить
Convert Notes Преобразовать сноски
Hide White Space Скрыть пустое место между страницами
All pages Все страницы
Current page Текущая страница
Selected pages Выбранные страницы

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -1692,6 +1692,7 @@
Continuous numbering Neprekidna numeracija
Apply Primeni
Convert Notes Pretvori napomene
Hide White Space Sakrij prazan prostor
All pages Sve stranice
Current page Trenutna stranica
Selected pages Izabrane stranice

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1696,6 +1696,7 @@
Continuous numbering Непрекидна нумерација
Apply Примени
Convert Notes Претвори напомене
Hide White Space Сакриј празан простор
All pages Све странице
Current page Тренутна страница
Selected pages Изабране странице

Can't render this file because it contains an unexpected character in line 11 and column 25.

View File

@ -1749,6 +1749,7 @@
Continuous numbering Sürekli numaralandırma
Apply Uygula
Convert Notes Notları Dönüştür
Hide White Space Boşluğu gizle
All pages Tüm sayfalar
Current page Geçerli sayfa
Selected pages Seçili sayfalar

Can't render this file because it has a wrong number of fields in line 18.

View File

@ -1694,6 +1694,7 @@
Continuous numbering Наскрізна нумерація
Apply Застосувати
Convert Notes Перетворити примітки
Hide White Space Приховати порожній простір
All pages Усі сторінки
Current page Поточна сторінка
Selected pages Вибрані сторінки

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -1694,6 +1694,7 @@
Continuous numbering 连续编号
Apply 应用
Convert Notes 转换注释
Hide White Space 隐藏空白
All pages 所有页面
Current page 当前页
Selected pages 所选页面

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -1683,6 +1683,7 @@
Continuous numbering 連續編號
Apply 套用
Convert Notes 轉換附註
Hide White Space 隱藏空白
All pages 所有頁面
Current page 目前頁面
Selected pages 所選頁面

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -1501,6 +1501,10 @@ fn view(app: &mut WordApp, ui: &mut Ui) {
if ui.checkbox(&mut n, tl!("Navigation Pane")).changed() {
let _ = app.run("view.navigationPane", json!({"value": n}));
}
let mut w = v.hide_white_space;
if ui.checkbox(&mut w, tl!("Hide White Space")).changed() {
let _ = app.run("view.hideWhiteSpace", json!({"value": w}));
}
});
});
group(ui, "Zoom", None, app, |ui, app| {