From 98619947d716c95e222814394fe4b9898bed0a0b Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Thu, 12 Nov 2026 13:45:53 -0300 Subject: [PATCH 01/12] M2.1: Layout: add Fit One Page (#6433) Add layout.fitOnePage, a speculative typography search that removes one page from the document while preserving relative font hierarchy. Every size receives the same half-point-rounded reduction; line spacing never falls below 0.94 of its original and body text never below 8.5 pt. The command refuses on explicit page breaks, excessive final-page content, formatting protection, tracked formatting and the cumulative 1 pt budget, and leaves the document unchanged when it refuses. Add the Layout ribbon button, KeyTip and a code-drawn icon, cover the command with 41 tests, and regenerate the parity checklist. --- ROADMAP.md | 3 +- crates/doc/src/document.rs | 3 + crates/doc/src/lib.rs | 5 + crates/doc/src/shrink.rs | 686 ++++++++++++++ crates/engine/src/catalog.rs | 1 + crates/engine/src/cmd/page.rs | 4 + crates/engine/src/cmd/page/fit_page.rs | 671 ++++++++++++++ crates/engine/src/cmd/protect.rs | 1 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/fit_one_page.rs | 1091 +++++++++++++++++++++++ crates/layout/src/notes.rs | 17 +- crates/layout/src/placed.rs | 21 + crates/ui-egui/src/icons.rs | 18 + crates/ui-egui/src/keytips.rs | 1 + crates/ui-egui/src/keytips/tests.rs | 16 + crates/ui-egui/src/ribbon/layout.rs | 1 + crates/ui-egui/src/widgets/tests.rs | 21 + docs/layout-parity.md | 3 +- docs/parity-checklist.md | 6 +- 19 files changed, 2550 insertions(+), 20 deletions(-) create mode 100644 crates/doc/src/shrink.rs create mode 100644 crates/engine/src/cmd/page/fit_page.rs create mode 100644 crates/engine/src/tests/fit_one_page.rs diff --git a/ROADMAP.md b/ROADMAP.md index 36984dd36d..abc346d26c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # WordCraft roadmap -> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (#3398: persistent screen paper colors; parity estimates unchanged); previously minor (#3693: additional Delete Previous Sentence command and shortcut; Word-parity estimates unchanged) +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-11-12 · **Change:** minor (added layout.fitOnePage and its Layout › Page Setup ribbon button to condense a multi-page document by one page while preserving typography hierarchy); previously minor (#3398: persistent screen paper colors; parity estimates unchanged); previously minor (#3693: additional Delete Previous Sentence command and shortcut; Word-parity estimates unchanged) > **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes. > **Floating anchor row segments:** 2026-11-07 · A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Readiness estimates unchanged. @@ -1435,3 +1435,4 @@ MATH glyphs that reference the same construction now share its immutable variant > 2026-10-29 · Port #2437 to split layout modules: finite border extents and terminal-break keep preflight. Recursive keep lookahead remains outside scope. Source port only; no test execution or new native measurements. | 2026-11-03 | trivial | Progress log: a line breaks after a hyphen before a slash (#3408) | +| 2026-11-12 | minor | Progress log: layout.fitOnePage condenses a multi-page document by one page while preserving typography hierarchy (#6433) | diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 87b922b0c0..b03ab1690c 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -78,6 +78,7 @@ impl Document { media_index: MediaIndex::default(), kept: Default::default(), missing_pictures: Default::default(), + fit_state: None, } } @@ -156,6 +157,7 @@ impl Document { media_index: _, kept: _, missing_pictures: _, + fit_state: _, } = self; let Document { body: other_body, @@ -177,6 +179,7 @@ impl Document { media_index: _, kept: _, missing_pictures: _, + fit_state: _, } = other; same_blocks(body, other_body) && same_parts(parts, other_parts) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index e8b84b17eb..c759f84b04 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -44,6 +44,7 @@ pub mod picfx; pub mod props; pub mod resolve; pub mod section; +pub mod shrink; pub mod smart_art; pub mod styles; pub mod symbol_fonts; @@ -64,6 +65,7 @@ pub use numbering::{ListKind, Numbering}; pub use para::{InlineObject, Paragraph, Run}; pub use props::{Align, CharProps, ParaProps, Rgb, TextColor}; pub use section::SectionProps; +pub use shrink::FitState; pub use styles::{Style, StyleKind, StyleSheet}; pub use table::{Cell, Row, Table}; @@ -144,6 +146,9 @@ pub struct Document { /// export reports them); never saved, never drawn, ignored by `==`. #[serde(skip)] pub missing_pictures: MissingPictures, + /// Cumulative fit-to-page typography reduction state; internal tracking outside user-visible custom properties. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fit_state: Option, } #[cfg(test)] diff --git a/crates/doc/src/shrink.rs b/crates/doc/src/shrink.rs new file mode 100644 index 0000000000..9cef2d5081 --- /dev/null +++ b/crates/doc/src/shrink.rs @@ -0,0 +1,686 @@ +//! Fit One Page typography helper. +//! +//! Adjusts font sizes and paragraph spacing across styles and editable stories +//! while preserving visual hierarchy, clamping to safe bounds, and excluding +//! fixed framing stories like headers and footers. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use crate::edit::{each_para, each_para_mut}; +use crate::para::Paragraph; +use crate::parts::PartKind; +use crate::props::{CharProps, LineSpacing, ParaProps}; +use crate::styles::StyleSheet; +use crate::{Block, Document}; + +/// Minimum font size allowed in points (WordCraft floor). +const MIN_SHRINK_FONT_SIZE: f32 = 4.0; + +/// Maximum font size allowed in points. +const MAX_SHRINK_FONT_SIZE: f32 = 1638.0; + +/// Safe line spacing floor factor (preventing line collision / crowding). +const MIN_LINE_SPACING_FACTOR: f32 = 0.94; + +/// Cumulative fit-to-page typography reduction state. +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct FitState { + /// Cumulative font reduction delta in points. + pub cum_delta: f32, + /// Cumulative line spacing scale factor. + pub cum_space: f32, + /// Unique effective body font sizes present immediately after the reduction was applied. + pub reduced_font_sizes: Vec, + /// Unique effective line spacing values present immediately after the reduction was applied. + pub reduced_line_spacings: Vec, +} + +/// Scale a font size down by `delta_pt`. +#[inline] +fn scale_size(size: f32, delta_pt: f32) -> f32 { + if delta_pt == 0.0 { + return size; + } + // Round up so the reduction never exceeds `delta_pt`, and never enlarge. + let target = (size - delta_pt).clamp(MIN_SHRINK_FONT_SIZE, MAX_SHRINK_FONT_SIZE); + ((target * 2.0).ceil() / 2.0).min(size) +} + +/// Scale a spacing value (e.g. `space_before`, `space_after`) by `space_factor`. +/// +/// Rounds to half-points while capping at the original nonnegative spacing value so reduction never increases spacing. +#[inline] +fn scale_spacing(val: f32, space_factor: f32) -> f32 { + let non_neg = val.max(0.0); + let scaled = (non_neg * space_factor).clamp(0.0, 1584.0); + ((scaled * 2.0).round() / 2.0).min(non_neg) +} + +/// Scale fixed line spacing in points respecting the safe readability floor and rounding upward to half-points. +/// +/// Rounds upward to half-points so the result never falls below the 0.94 floor, and caps at `pts` so +/// reduction never increases fixed line spacing. +#[inline] +fn scale_fixed_line_spacing(pts: f32, space_factor: f32) -> f32 { + let floor = pts * MIN_LINE_SPACING_FACTOR; + let scaled = (pts * space_factor).max(floor).clamp(0.0, 1584.0); + // Round upward to half-points (0.5 pt) so the resulting spacing never falls below the 0.94 floor. + ((scaled * 2.0).ceil() / 2.0).min(pts) +} + +/// Scale line spacing by `space_factor` respecting the safe readability floor. +#[inline] +fn scale_line_spacing(ls: LineSpacing, space_factor: f32) -> LineSpacing { + match ls { + LineSpacing::Multiple(m) => { + // Guard against ascender/descender collisions: keep line spacing at or above + // safe readability floor of 0.94 factor (~6% max compression). + let floor = m * MIN_LINE_SPACING_FACTOR; + let new_m = (m * space_factor).max(floor).clamp(0.5, 132.0); + // Round upward to a hundredth so the resulting multiple never falls below the 0.94 floor. + LineSpacing::Multiple(((new_m * 100.0).ceil() / 100.0).min(m)) + } + LineSpacing::AtLeast(pts) => LineSpacing::AtLeast(scale_fixed_line_spacing(pts, space_factor)), + LineSpacing::Exactly(pts) => LineSpacing::Exactly(scale_fixed_line_spacing(pts, space_factor)), + } +} + +/// Scale explicit font sizes on a `CharProps`. +#[inline] +fn scale_char_props(c: &mut CharProps, delta_pt: f32) { + if delta_pt > 0.0 { + if let Some(s) = c.size.as_mut() { + *s = scale_size(*s, delta_pt); + } + if let Some(s) = c.size_cs.as_mut() { + *s = scale_size(*s, delta_pt); + } + } +} + +/// Scale explicit paragraph spacing on a `ParaProps`. +#[inline] +fn scale_para_props(p: &mut ParaProps, space_factor: f32) { + if space_factor < 1.0 - f32::EPSILON { + if let Some(sb) = p.space_before.as_mut() { + *sb = scale_spacing(*sb, space_factor); + } + if let Some(sa) = p.space_after.as_mut() { + *sa = scale_spacing(*sa, space_factor); + } + if let Some(ls) = p.line_spacing.as_mut() { + *ls = scale_line_spacing(*ls, space_factor); + } + } +} + +/// Finds all text box IDs transitively reachable from Header and Footer parts +/// (including linked text box chains and nested text boxes). +pub fn header_footer_text_boxes(doc: &Document) -> BTreeSet { + let mut hf_boxes: BTreeSet = BTreeSet::new(); + let mut queue: Vec = Vec::new(); + + for part in doc.parts.values() { + if matches!(part.kind, PartKind::Header | PartKind::Footer) { + for b in &part.blocks { + each_para(b, 0, &mut |p| { + for obj in &p.objects { + for box_id in obj.text_boxes() { + if hf_boxes.insert(box_id) { + queue.push(box_id); + } + } + } + }); + } + } + } + + while let Some(box_id) = queue.pop() { + if let Some(part) = doc.parts.get(&box_id) { + if let Some(next_id) = part.body.next + && hf_boxes.insert(next_id) + { + queue.push(next_id); + } + for b in &part.blocks { + each_para(b, 0, &mut |p| { + for obj in &p.objects { + for nested_id in obj.text_boxes() { + if hf_boxes.insert(nested_id) { + queue.push(nested_id); + } + } + } + }); + } + } + } + hf_boxes +} + +/// Freezes resolved character and paragraph formatting on a single paragraph using the provided stylesheet and optional table-style context. +fn freeze_para_formatting( + p: &mut Paragraph, + styles: &StyleSheet, + table_chr: Option<&CharProps>, + table_para: Option<&ParaProps>, + delta_pt: f32, + space_factor: f32, +) { + let style = p.props.style.clone(); + if delta_pt > 0.0 { + for r in &mut p.runs { + if r.props.size.is_none() { + let resolved = styles.resolve_char_in(style.as_deref(), table_chr, &r.props).size; + r.props.size = Some(resolved); + } + if r.props.size_cs.is_none() { + let resolved_cs = styles.resolve_char_in(style.as_deref(), table_chr, &r.props).size_cs; + r.props.size_cs = Some(resolved_cs); + } + } + if p.mark.size.is_none() { + let resolved = styles.resolve_char_in(style.as_deref(), table_chr, &p.mark).size; + p.mark.size = Some(resolved); + } + if p.mark.size_cs.is_none() { + let resolved_cs = styles.resolve_char_in(style.as_deref(), table_chr, &p.mark).size_cs; + p.mark.size_cs = Some(resolved_cs); + } + } + if space_factor < 1.0 - f32::EPSILON { + let resolved_p = styles.resolve_para_in(&p.props, table_para); + if p.props.space_before.is_none() { + p.props.space_before = Some(resolved_p.space_before); + } + if p.props.space_after.is_none() { + p.props.space_after = Some(resolved_p.space_after); + } + if p.props.line_spacing.is_none() { + p.props.line_spacing = Some(resolved_p.line_spacing); + } + } +} + +/// Recursively freezes resolved formatting on blocks while preserving table-style context. +fn freeze_blocks_formatting( + blocks: &mut [Arc], + styles: &StyleSheet, + table_chr: Option<&CharProps>, + table_para: Option<&ParaProps>, + delta_pt: f32, + space_factor: f32, +) { + use crate::styles::PartSet; + + for b in blocks { + let b = Arc::make_mut(b); + match b { + Block::Para(p) => { + freeze_para_formatting(p, styles, table_chr, table_para, delta_pt, space_factor); + } + Block::Table(tbl) => { + let style = tbl.props.style.as_deref().and_then(|id| styles.table_style(id)); + let cell_para = style.as_ref().map(|st| &st.para); + let nrows = tbl.rows.len(); + let ncols = tbl.cols(); + let cell_grids: Vec> = + (0..nrows).map(|ri| (0..tbl.rows[ri].cells.len()).map(|ci| tbl.grid_col(ri, ci)).collect()).collect(); + for (ri, row) in tbl.rows.iter_mut().enumerate() { + for (ci, cell) in row.cells.iter_mut().enumerate() { + let mut cell_chr = CharProps::default(); + if let Some(st) = &style { + let sizes = (st.parts.band_size.unwrap_or(0), st.parts.col_band_size.unwrap_or(0)); + let g = cell_grids.get(ri).and_then(|r| r.get(ci)).copied().unwrap_or(0); + let span = cell.span(); + let region = PartSet::of_cell(&tbl.props.look, sizes, (ri, nrows), (g, span, ncols)); + cell_chr.overlay(&st.chr); + for part in region.iter() { + let f = st.part(part); + cell_chr.overlay(&f.chr); + } + } + freeze_blocks_formatting(&mut cell.blocks, styles, Some(&cell_chr), cell_para, delta_pt, space_factor); + } + } + } + } + } +} + +/// Fits font sizes across the document by `delta_pt` points and scales paragraph +/// spacing by `space_factor`. +/// +/// Clean-room typographical helper for Fit One Page. Preserves relative typography +/// hierarchies between headings, body text, and direct run formatting. +/// Clamps font sizes to `[4.0, 1638.0]` pt and rounds to 0.5 pt (half-points). +/// Headers and footers are explicitly excluded and retain their exact original sizes. +/// Footnotes are included and shrink alongside body text. +pub fn fit_document_typography(doc: &mut Document, delta_pt: f32, space_factor: f32) { + if !delta_pt.is_finite() || !space_factor.is_finite() { + return; + } + let delta_pt = delta_pt.max(0.0); + let space_factor = space_factor.clamp(0.1, 1.0); + + if delta_pt == 0.0 && (space_factor - 1.0).abs() < f32::EPSILON { + return; + } + + // 1. Find all text boxes reachable from Header and Footer parts (including nested text boxes and chains). + let hf_boxes = header_footer_text_boxes(doc); + + // Freeze header and footer font sizes and spacing (and reachable text boxes) before modifying styles, + // preserving table-style context so header/footer table cells retain their conditional table formatting. + let old_styles = doc.styles.clone(); + for (id, part) in doc.parts.iter_mut() { + if matches!(part.kind, PartKind::Header | PartKind::Footer) || hf_boxes.contains(id) { + freeze_blocks_formatting(&mut part.blocks, &old_styles, None, None, delta_pt, space_factor); + } + } + + // 2. Adjust styles: document defaults, character styles, paragraph styles, table style regions. + let st = Arc::make_mut(&mut doc.styles); + + if delta_pt > 0.0 { + // Default character formatting + let default_size = st.default_chr.size.unwrap_or(crate::props::DEFAULT_FONT_SIZE); + st.default_chr.size = Some(scale_size(default_size, delta_pt)); + if let Some(cs) = st.default_chr.size_cs.as_mut() { + *cs = scale_size(*cs, delta_pt); + } + } + + // Default paragraph formatting + scale_para_props(&mut st.default_para, space_factor); + + // All named styles (excluding header/footer styles if any) + for style in &mut st.styles { + let is_hf = style.id.eq_ignore_ascii_case("header") + || style.id.eq_ignore_ascii_case("footer") + || style.name.eq_ignore_ascii_case("header") + || style.name.eq_ignore_ascii_case("footer"); + if is_hf { + continue; + } + + if delta_pt > 0.0 { + scale_char_props(&mut style.chr, delta_pt); + } + scale_para_props(&mut style.para, space_factor); + + if let Some(t) = style.table.as_mut() + && delta_pt > 0.0 + { + for c in [&mut t.header_chr, &mut t.band_chr, &mut t.first_col_chr, &mut t.last_col_chr, &mut t.total_chr, &mut t.col_band_chr] { + scale_char_props(c, delta_pt); + } + for part in t.regions.values_mut() { + scale_char_props(&mut part.chr, delta_pt); + } + } + } + + // 3. Helper to shrink editable paragraph content (runs, mark, spacing). + let mut shrink_paragraph = |p: &mut Paragraph| { + scale_para_props(&mut p.props, space_factor); + + if delta_pt > 0.0 { + // Scale explicit paragraph mark size + scale_char_props(&mut p.mark, delta_pt); + + // Scale explicit run sizes + for r in &mut p.runs { + scale_char_props(&mut r.props, delta_pt); + } + } + + p.touch(); + }; + + // 4. Traverse main body story. + for b in &mut doc.body { + each_para_mut(Arc::make_mut(b), 0, &mut shrink_paragraph); + } + + // 5. Traverse secondary editable stories (footnotes, body text boxes), excluding headers, footers, + // comments, and text boxes reachable from headers or footers. + for (id, part) in doc.parts.iter_mut() { + if matches!(part.kind, PartKind::Header | PartKind::Footer | PartKind::Comment) || hf_boxes.contains(id) { + continue; + } + for b in &mut part.blocks { + each_para_mut(Arc::make_mut(b), 0, &mut shrink_paragraph); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::block::Block; + use crate::para::Paragraph; + use crate::props::{CharProps, LineSpacing}; + use crate::table::Table; + use crate::{Document, para_block}; + + #[test] + fn test_shrink_preserves_hierarchy() { + let mut doc = Document::new(); + // Setup Heading 1 paragraph and Normal paragraph + let h1_para = Paragraph::with_text("Heading 1 Title", CharProps::default()).styled("Heading1"); + let normal_para = Paragraph::with_text("Body text in Normal style", CharProps::default()).styled("Normal"); + let mut custom_run_para = Paragraph::with_text("Normal prefix ", CharProps::default()).styled("Normal"); + custom_run_para.insert_text(custom_run_para.len(), "Special large text", &CharProps { size: Some(24.0), ..Default::default() }).unwrap(); + + doc.body = vec![para_block(h1_para), para_block(normal_para), para_block(custom_run_para)]; + + let h1_before = doc.styles.resolve_char(Some("Heading1"), &CharProps::default()).size; + let normal_before = doc.styles.resolve_char(Some("Normal"), &CharProps::default()).size; + + fit_document_typography(&mut doc, 0.5, 1.0); + + let h1_after = doc.styles.resolve_char(Some("Heading1"), &CharProps::default()).size; + let normal_after = doc.styles.resolve_char(Some("Normal"), &CharProps::default()).size; + + // Both styles scaled down by exactly 0.5 pt + assert_eq!(h1_after, h1_before - 0.5); + assert_eq!(normal_after, normal_before - 0.5); + // Relative hierarchy preserved + assert_eq!(h1_after - normal_after, h1_before - normal_before); + + // Explicit run in paragraph 3 was 24.0 pt, now 23.5 pt + let p3 = doc.body[2].as_para().unwrap(); + assert_eq!(p3.runs[1].props.size, Some(23.5)); + } + + #[test] + fn test_shrink_excludes_headers_and_footers() { + let mut doc = Document::new(); + let body_para = Paragraph::with_text("Main story body text", CharProps::default()); + doc.body = vec![para_block(body_para)]; + + let header_para = Paragraph::with_text("Header Text", CharProps { size: Some(10.0), ..Default::default() }); + let footer_para = Paragraph::with_text("Footer Text Page 1", CharProps::default()); // inherits + + let h_id = doc.add_part(PartKind::Header, vec![para_block(header_para)]); + let f_id = doc.add_part(PartKind::Footer, vec![para_block(footer_para)]); + + let footer_before_size = doc.styles.resolve_char(None, &CharProps::default()).size; + + fit_document_typography(&mut doc, 0.5, 1.0); + + // Body default style shrank + let body_after_size = doc.styles.resolve_char(None, &CharProps::default()).size; + assert_eq!(body_after_size, footer_before_size - 0.5); + + // Header retained its explicit 10.0 pt size + let h_part = doc.parts.get(&h_id).unwrap(); + let h_p = h_part.blocks[0].as_para().unwrap(); + assert_eq!(h_p.runs[0].props.size, Some(10.0)); + + // Footer retained its original resolved size + let f_part = doc.parts.get(&f_id).unwrap(); + let f_p = f_part.blocks[0].as_para().unwrap(); + assert_eq!(f_p.runs[0].props.size, Some(footer_before_size)); + } + + #[test] + fn test_shrink_scales_footnotes() { + let mut doc = Document::new(); + let body_para = Paragraph::with_text("Main body with note", CharProps::default()); + doc.body = vec![para_block(body_para)]; + + let fn_para = Paragraph::with_text("Footnote content", CharProps { size: Some(10.0), ..Default::default() }); + let fn_id = doc.add_part(PartKind::Footnote, vec![para_block(fn_para)]); + + fit_document_typography(&mut doc, 0.5, 1.0); + + let fn_part = doc.parts.get(&fn_id).unwrap(); + let fn_p = fn_part.blocks[0].as_para().unwrap(); + assert_eq!(fn_p.runs[0].props.size, Some(9.5)); + } + + #[test] + fn test_shrink_clamps_to_min_floor() { + let mut doc = Document::new(); + let mut p = Paragraph::with_text("Very tiny text", CharProps { size: Some(4.2), ..Default::default() }); + p.insert_text(p.len(), "Floor text", &CharProps { size: Some(4.0), ..Default::default() }).unwrap(); + doc.body = vec![para_block(p)]; + + fit_document_typography(&mut doc, 1.0, 1.0); + + let p_after = doc.body[0].as_para().unwrap(); + assert_eq!(p_after.runs[0].props.size, Some(4.0)); + assert_eq!(p_after.runs[1].props.size, Some(4.0)); + } + + #[test] + fn test_shrink_zero_delta_identity() { + let mut doc = Document::new(); + let h1_para = Paragraph::with_text("Heading 1 Title", CharProps::default()).styled("Heading1"); + let normal_para = Paragraph::with_text("Body text", CharProps::default()); + doc.body = vec![para_block(h1_para), para_block(normal_para)]; + doc.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Header", CharProps::default()))]); + doc.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Note", CharProps::default()))]); + + let before = doc.clone(); + fit_document_typography(&mut doc, 0.0, 1.0); + assert_eq!(doc, before); + } + + #[test] + fn test_shrink_paragraph_spacing() { + let mut doc = Document::new(); + let mut p = Paragraph::with_text("Spaced paragraph", CharProps::default()); + p.props.space_before = Some(18.0); + p.props.space_after = Some(12.0); + p.props.line_spacing = Some(LineSpacing::Multiple(1.15)); + doc.body = vec![para_block(p)]; + + fit_document_typography(&mut doc, 0.5, 0.9); + + let p_after = doc.body[0].as_para().unwrap(); + // 18.0 * 0.9 = 16.2 -> round to 0.5 pt = 16.0 + assert_eq!(p_after.props.space_before, Some(16.0)); + // 12.0 * 0.9 = 10.8 -> round to 0.5 pt = 11.0 + assert_eq!(p_after.props.space_after, Some(11.0)); + // 1.15 * 0.9 = 1.035 -> clamped to safe line spacing floor (1.15 * 0.94 = 1.081) -> ceil hundredth 1.09 + assert_eq!(p_after.props.line_spacing, Some(LineSpacing::Multiple(1.09))); + } + + #[test] + fn test_line_spacing_floor_prevents_overlap() { + let mut doc = Document::new(); + let mut p = Paragraph::with_text("Single spaced text", CharProps::default()); + p.props.line_spacing = Some(LineSpacing::Multiple(1.0)); + doc.body = vec![para_block(p)]; + + // Even with aggressive space_factor = 0.5, line spacing multiple must not fall below safe floor (0.94) + fit_document_typography(&mut doc, 0.5, 0.5); + + let p_after = doc.body[0].as_para().unwrap(); + assert_eq!(p_after.props.line_spacing, Some(LineSpacing::Multiple(0.94))); + } + + #[test] + fn test_header_text_boxes_are_frozen_and_do_not_shrink() { + let mut doc = Document::new(); + let body_p = Paragraph::with_text("Body text", CharProps { size: Some(12.0), ..Default::default() }); + doc.body = vec![para_block(body_p)]; + + let header_box_id = doc + .add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Header box", CharProps { size: Some(14.0), ..Default::default() }))]); + let mut header_p = Paragraph::with_text("Header prefix ", CharProps { size: Some(10.0), ..Default::default() }); + let shape = crate::para::InlineObject::Shape { + kind: crate::para::ShapeKind::TextBox, + w: 144.0, + h: 72.0, + fill: None, + stroke: None, + stroke_width: 0.75, + float: Default::default(), + story: Some(header_box_id), + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + header_p.objects.push(shape); + doc.add_part(PartKind::Header, vec![para_block(header_p)]); + + let body_box_id = + doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Body box", CharProps { size: Some(16.0), ..Default::default() }))]); + + fit_document_typography(&mut doc, 2.0, 1.0); + + let b_p = doc.body[0].as_para().unwrap(); + assert_eq!(b_p.runs[0].props.size, Some(10.0)); + + let bb_part = doc.parts.get(&body_box_id).unwrap(); + let bb_p = bb_part.blocks[0].as_para().unwrap(); + assert_eq!(bb_p.runs[0].props.size, Some(14.0)); + + let hb_part = doc.parts.get(&header_box_id).unwrap(); + let hb_p = hb_part.blocks[0].as_para().unwrap(); + assert_eq!(hb_p.runs[0].props.size, Some(14.0)); + } + + #[test] + fn test_shrink_table_cells() { + let mut doc = Document::new(); + let mut table = Table::new(1, 1, 100.0); + let cell_p = Paragraph::with_text("Cell text", CharProps { size: Some(12.0), ..Default::default() }); + table.rows[0].cells[0].blocks = vec![para_block(cell_p)]; + doc.body = vec![Arc::new(Block::Table(table))]; + + fit_document_typography(&mut doc, 0.5, 1.0); + + let tbl = doc.body[0].as_table().unwrap(); + let cell_p_after = tbl.rows[0].cells[0].blocks[0].as_para().unwrap(); + assert_eq!(cell_p_after.runs[0].props.size, Some(11.5)); + } + + #[test] + fn test_exact_line_spacing_preserves_floor_upward() { + // LineSpacing::Exactly(7.5) with space_factor = 0.90 (below MIN_LINE_SPACING_FACTOR 0.94) + // Floor = 7.5 * 0.94 = 7.05 pt. Rounding upward to half-points yields 7.5 pt (>= 7.05 pt). + let ls = scale_line_spacing(LineSpacing::Exactly(7.5), 0.90); + assert_eq!(ls, LineSpacing::Exactly(7.5)); + + // LineSpacing::Exactly(10.0) with space_factor = 0.90: + // Floor = 10.0 * 0.94 = 9.4 pt. Rounding upward to half-points yields 9.5 pt (>= 9.4 pt). + let ls2 = scale_line_spacing(LineSpacing::Exactly(10.0), 0.90); + assert_eq!(ls2, LineSpacing::Exactly(9.5)); + } + + #[test] + fn test_spacing_only_leaves_unrounded_font_sizes_unchanged() { + let mut doc = Document::new(); + // A run with fractional 9.3 pt font + let p = Paragraph::with_text("Unrounded size text", CharProps { size: Some(9.3), ..Default::default() }); + doc.body = vec![para_block(p)]; + + // Spacing-only trial (delta_pt = 0.0, space_factor = 0.94) + fit_document_typography(&mut doc, 0.0, 0.94); + + let p_after = doc.body[0].as_para().unwrap(); + assert_eq!(p_after.runs[0].props.size, Some(9.3), "spacing-only trial must not round or scale font size"); + } + + #[test] + fn test_scale_size_never_enlarges_sub_floor_font() { + // 2.0 pt run (below 4.0 pt floor) with delta 0.5 pt must not be enlarged to 4.0 pt + assert_eq!(scale_size(2.0, 0.5), 2.0); + // 3.0 pt run with delta 0.5 pt must not be enlarged to 4.0 pt + assert_eq!(scale_size(3.0, 0.5), 3.0); + // 5.0 pt run with delta 0.5 pt shrinks to 4.5 pt + assert_eq!(scale_size(5.0, 0.5), 4.5); + // 12.0 pt run with delta 0.3 pt rounds up to 12.0 pt so reduction (0.0 pt) never exceeds delta (0.3 pt) + assert_eq!(scale_size(12.0, 0.3), 12.0); + // Zero delta leaves size unchanged + assert_eq!(scale_size(2.0, 0.0), 2.0); + } + + #[test] + fn test_shrink_excludes_comment_stories() { + let mut doc = Document::new(); + let comment_p = Paragraph::with_text("Comment text", CharProps { size: Some(8.0), ..Default::default() }); + let comment_id = doc.add_part(PartKind::Comment, vec![para_block(comment_p)]); + + // Shrink with 1.0 pt delta + fit_document_typography(&mut doc, 1.0, 0.94); + + let comment_part = doc.parts.get(&comment_id).unwrap(); + let p_after = comment_part.blocks[0].as_para().unwrap(); + assert_eq!(p_after.runs[0].props.size, Some(8.0), "comments must be excluded from typography shrink pass"); + } + + #[test] + fn test_header_table_text_inherits_size_from_table_style() { + let mut doc = Document::new(); + // Setup a custom table style with 9.0 pt font + let st = crate::styles::Style { + id: "HeaderTableStyle".into(), + name: "HeaderTableStyle".into(), + kind: crate::styles::StyleKind::Table, + chr: CharProps { size: Some(9.0), ..Default::default() }, + table: Some(crate::styles::TableStyleParts::default()), + ..Default::default() + }; + Arc::make_mut(&mut doc.styles).upsert(st); + + // Create a table in a Header part that uses HeaderTableStyle + let mut table = crate::Table::new(1, 1, 100.0); + table.props.style = Some("HeaderTableStyle".to_string()); + // Cell paragraph with no explicit font size on run (inherits from table style) + let cell_p = Paragraph::with_text("Header cell text", CharProps::default()); + table.rows[0].cells[0].blocks = vec![crate::para_block(cell_p)]; + + let header_id = doc.add_part(PartKind::Header, vec![Arc::new(Block::Table(table))]); + + // Run shrink with delta_pt = 1.0 (body font would shrink) + fit_document_typography(&mut doc, 1.0, 0.95); + + // Verify that the header table cell's run preserved the 9.0 pt size from the table style, + // and was not frozen to the document default (11.0 pt) + let header_part = doc.parts.get(&header_id).unwrap(); + if let Block::Table(tbl) = header_part.blocks[0].as_ref() { + let p = tbl.rows[0].cells[0].blocks[0].as_para().unwrap(); + assert_eq!(p.runs[0].props.size, Some(9.0), "header table text must inherit and freeze table style font size"); + } else { + panic!("expected table in header part"); + } + } + + #[test] + fn test_scale_line_spacing_never_increases_fixed_or_multiple() { + // LineSpacing::Exactly(7.45) with space_factor = 0.94: + // 7.45 * 0.94 = 7.003 -> ceil to half-point would yield 7.5 pt, but capping at 7.45 ensures no enlargement + assert_eq!(scale_fixed_line_spacing(7.45, 0.94), 7.45); + // Regular scaling: 10.0 pt with 0.94 -> 9.4 -> ceil to half-point = 9.5 pt <= 10.0 pt + assert_eq!(scale_fixed_line_spacing(10.0, 0.94), 9.5); + + // LineSpacing::Multiple with small factor never enlarges + let ls = LineSpacing::Multiple(0.942); + let scaled = scale_line_spacing(ls, 0.94); + if let LineSpacing::Multiple(m) = scaled { + assert!(m <= 0.942); + } else { + panic!("expected Multiple"); + } + } + + #[test] + fn test_scale_spacing_never_increases_value() { + // Spacing 0.3 pt scaled by 0.94 yields 0.282 pt. + // Rounding to half-points would yield 0.5 pt, but capping at 0.3 pt ensures no increase. + assert_eq!(scale_spacing(0.3, 0.94), 0.3); + // Regular spacing: 12.0 pt * 0.94 = 11.28 pt -> rounds to 11.5 pt <= 12.0 pt. + assert_eq!(scale_spacing(12.0, 0.94), 11.5); + // Zero spacing remains zero. + assert_eq!(scale_spacing(0.0, 0.94), 0.0); + } +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 1028d84be1..69564e6ea0 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -224,6 +224,7 @@ Layout|Page Setup|Columns|layout.columns Layout|Page Setup|Breaks|layout.break Layout|Page Setup|Line Numbers|layout.lineNumbers Layout|Page Setup|Hyphenation|layout.hyphenation +Layout|Page Setup|Fit One Page|layout.fitOnePage Layout|Page Setup|Page Setup Dialog|layout.pageSetup Layout|Page Setup|Vertical Alignment|layout.verticalAlign Layout|Page Setup|Document Grid|layout.documentGrid diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 14630876e8..8a917e329e 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -39,6 +39,8 @@ pub fn specs() -> Vec { CommandSpec::new("layout.manualHyphenation", "Manual Hyphenation", "Layout › Page Setup › Hyphenation", manual_hyphenation) .params(r#"{"from"?: Pos (default: the caret)} → the next word at a line end that could be hyphenated: {"word", "pos", "points": [char index], "suggest": char index} or {"done": true}"#) .pure(), + CommandSpec::new("layout.fitOnePage", "Fit One Page", "Layout › Page Setup", fit_page::fit_one_page) + .params(r#"{"maxDeltaPt"?: pt (default: 2.0), "stepPt"?: pt (default: the built-in discrete candidate table; when given, up to 20 fine steps), "adjustSpacing"?: bool (default: true)}"#), CommandSpec::new("layout.pageSetup", "Page Setup", "Layout › Page Setup", |s, v| { if let Some(props) = v.get("section") { let mut new: SectionProps = serde_json::from_value(props.clone()).map_err(|e| CmdError::Params(e.to_string()))?; @@ -765,5 +767,7 @@ fn shifted_by_split(p: &Pos, at: &Pos, new: &Pos) -> Pos { q } +mod fit_page; + #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/page/fit_page.rs b/crates/engine/src/cmd/page/fit_page.rs new file mode 100644 index 0000000000..52bf7128ff --- /dev/null +++ b/crates/engine/src/cmd/page/fit_page.rs @@ -0,0 +1,671 @@ +//! Fit One Page command implementation (`layout.fitOnePage`). +//! +//! Automatically condenses a multi-page document by one page using speculative +//! layout evaluation, minimal typographical perturbation, and strict readability +//! safeguards (line spacing floor and font size floor). + +use std::sync::Arc; + +use serde_json::Value; +use wordcraft_doc::Document; +use wordcraft_doc::section::SectionStart; +use wordcraft_doc::shrink::fit_document_typography; +use wordcraft_layout::{LayoutCache, LayoutOptions, Placed, ViewMode}; + +use crate::cmd::sel_result; +use crate::{CmdError, CmdResult, Session, p}; + +/// Minimum comfortable body font size before refusing further reduction (Anti-Compound Guard). +pub const MIN_COMFORTABLE_BODY_FONT_SIZE: f32 = 8.5; + +/// Maximum number of fine-grained trial steps allowed when `stepPt` is specified. +pub const MAX_FINE_STEPS: usize = 20; + +/// Pre-calculated candidate steps `(delta_font_pt, space_factor)`. +/// +/// Evaluated in ascending order of aesthetic perturbation: +/// 1. Tries paragraph spacing adjustment first with zero font size perturbation. +/// 2. Then tests micro font adjustments with minimal spacing changes. +/// 3. Respects a line-spacing floor (>= 0.94) and paragraph spacing floor (>= 0.85) +/// to guarantee that lines and paragraphs never crowd or collide. +const CANDIDATE_STEPS: [(f32, f32); 10] = [ + (0.0, 0.94), // 1. Spacing-only mild (font sizes untouched) + (0.0, 0.92), // 2. Spacing-only moderate (font sizes untouched) + (0.0, 0.90), // 3. Spacing-only tighter paragraph spacing (font sizes untouched) + (0.0, 0.88), // 4. Spacing-only compact (font sizes untouched) + (0.5, 0.96), // 5. Micro font reduction (-0.5 pt) with near-normal spacing + (0.5, 0.90), // 6. Micro font reduction (-0.5 pt) with gentle spacing reduction + (1.0, 0.96), // 7. Moderate font reduction (-1.0 pt) with near-normal spacing + (1.0, 0.90), // 8. Moderate font reduction (-1.0 pt) with gentle spacing reduction + (1.0, 0.85), // 9. Moderate font reduction (-1.0 pt) with tighter paragraph spacing + (2.0, 0.88), // 10. Maximum safe font reduction (-2.0 pt) +]; + +/// Calculates the minimum effective font size in points across a single paragraph. +/// +/// Resolves character formatting using `resolve_char_in` against `table_chr` (when inside a table cell) +/// and inspects both standard and complex-script text properties while ignoring superscript/subscript characters. +/// When `hide_deleted` is true, runs with deletion markup (`props.del.is_some()`) are skipped because they +/// are not rendered in Print view when revision markup is hidden. +/// When `show_hidden` is false, runs with hidden text formatting are skipped because they are omitted from layout. +fn min_para_font_size( + doc: &Document, + p: &wordcraft_doc::para::Paragraph, + table_chr: Option<&wordcraft_doc::CharProps>, + hide_deleted: bool, + show_hidden: bool, +) -> f32 { + let style_id = p.props.style.as_deref(); + let mut min_size = if p.runs.is_empty() { + let mark_resolved = doc.styles.resolve_char_in(style_id, table_chr, &p.mark); + if (mark_resolved.hidden && !show_hidden) || (hide_deleted && p.mark.del.is_some()) { f32::INFINITY } else { mark_resolved.size } + } else { + f32::INFINITY + }; + let mut run_start: usize = 0; + for r in &p.runs { + let run_end = run_start.saturating_add(r.len).min(p.text.len()); + let run_text = p.text.get(run_start..run_end).unwrap_or(""); + run_start = run_end; + if hide_deleted && r.props.del.is_some() { + continue; + } + + let resolved = doc.styles.resolve_char_in(style_id, table_chr, &r.props); + if resolved.hidden && !show_hidden { + continue; + } + if matches!(resolved.vert_align, wordcraft_doc::props::VertAlign::Superscript | wordcraft_doc::props::VertAlign::Subscript) { + continue; + } + let uses_cs = resolved.cs || resolved.rtl || run_text.chars().any(|c| resolved.uses_complex(c)); + if uses_cs { + min_size = min_size.min(resolved.size_cs); + } + let has_standard = run_text.is_empty() || run_text.chars().any(|c| !resolved.uses_complex(c)); + if has_standard { + min_size = min_size.min(resolved.size); + } + } + min_size +} + +/// Recursively calculates the minimum effective font size in points across blocks, resolving table-style conditional formatting. +/// +/// When `hide_deleted` is true, deleted text runs hidden in Print view are skipped. +/// When `show_hidden` is false, hidden text runs omitted from Print view are skipped. +fn min_blocks_font_size( + doc: &Document, + blocks: &[Arc], + table_chr: Option<&wordcraft_doc::CharProps>, + hide_deleted: bool, + show_hidden: bool, +) -> f32 { + use wordcraft_doc::styles::PartSet; + + let mut min_sz = f32::INFINITY; + for b in blocks { + if let Some(p) = b.as_para() { + min_sz = min_sz.min(min_para_font_size(doc, p, table_chr, hide_deleted, show_hidden)); + } else if let Some(tbl) = b.as_table() { + let style = tbl.props.style.as_deref().and_then(|id| doc.styles.table_style(id)); + let nrows = tbl.rows.len(); + let ncols = tbl.cols(); + for (ri, row) in tbl.rows.iter().enumerate() { + for (ci, cell) in row.cells.iter().enumerate() { + let mut cell_chr = wordcraft_doc::CharProps::default(); + if let Some(st) = &style { + let sizes = (st.parts.band_size.unwrap_or(0), st.parts.col_band_size.unwrap_or(0)); + let g = tbl.grid_col(ri, ci); + let span = cell.span(); + let region = PartSet::of_cell(&tbl.props.look, sizes, (ri, nrows), (g, span, ncols)); + cell_chr.overlay(&st.chr); + for part in region.iter() { + let f = st.part(part); + cell_chr.overlay(&f.chr); + } + } + min_sz = min_sz.min(min_blocks_font_size(doc, &cell.blocks, Some(&cell_chr), hide_deleted, show_hidden)); + } + } + } + } + min_sz +} + +/// Returns true if the document contains explicit page or section breaks that +/// physically force content onto additional pages. +fn has_explicit_breaks(doc: &Document) -> bool { + let sections = doc.sections(); + if sections.len() > 1 { + for (_, sec) in §ions[1..] { + if matches!(sec.start, SectionStart::NextPage | SectionStart::EvenPage | SectionStart::OddPage) { + return true; + } + } + } + for b in &doc.body { + if let Some(p) = b.as_para() + && p.text.contains(wordcraft_doc::para::PAGE_BREAK) + { + return true; + } else if let Some(tbl) = b.as_table() { + for row in &tbl.rows { + for cell in &row.cells { + for cb in &cell.blocks { + if let Some(p) = cb.as_para() + && p.text.contains(wordcraft_doc::para::PAGE_BREAK) + { + return true; + } + } + } + } + } + } + false +} + +/// True when `item` is a line-number decoration: a `DECOR_STORY` line with no block path. +/// +/// Note separators and continuation notices also use `DECOR_STORY`, but carry their block path. +/// Line numbers sit outside the body column and must not count as page content. +fn is_line_number_decoration(item: &Placed) -> bool { + matches!(item, Placed::Lines { story, path, .. } if *story == wordcraft_layout::DECOR_STORY && path.0.is_empty()) +} + +/// True when `story` is a footnote part, whose text sits at the bottom of its page. +fn is_footnote_story(doc: &Document, story: &wordcraft_doc::StoryRef) -> bool { + match story { + wordcraft_doc::StoryRef::Part(id) => doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::Footnote), + _ => false, + } +} + +/// The height covered by the union of `spans`, so overlapping items (side-by-side table cells) +/// count once. +fn union_height(spans: &[(f32, f32)]) -> f32 { + let mut spans: Vec<(f32, f32)> = spans.iter().copied().filter(|(a, b)| a.is_finite() && b.is_finite() && *b > *a).collect(); + spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + let mut total = 0.0; + let mut iter = spans.into_iter(); + if let Some((mut start, mut end)) = iter.next() { + for (a, b) in iter { + if a <= end + 0.01 { + end = end.max(b); + } else { + total += end - start; + start = a; + end = b; + } + } + total += end - start; + } + total +} + +/// Calculates the minimum effective body font size in points across the document body and editable text boxes. +/// +/// When `hide_deleted` is true, deleted text runs hidden in Print view are skipped. +/// When `show_hidden` is false, hidden text runs omitted from Print view are skipped. +fn min_body_font_size(doc: &Document, hide_deleted: bool, show_hidden: bool) -> f32 { + let hf_boxes = wordcraft_doc::shrink::header_footer_text_boxes(doc); + let mut min_sz = min_blocks_font_size(doc, &doc.body, None, hide_deleted, show_hidden); + for (id, part) in &doc.parts { + if !matches!(part.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer | wordcraft_doc::PartKind::Comment) + && !hf_boxes.contains(id) + { + min_sz = min_sz.min(min_blocks_font_size(doc, &part.blocks, None, hide_deleted, show_hidden)); + } + } + if min_sz.is_finite() { min_sz } else { wordcraft_doc::props::DEFAULT_FONT_SIZE } +} + +/// Checks whether effective body font sizes are at or below the minimum readable font size (8.5 pt). +/// +/// When `hide_deleted` is true, deleted text runs hidden in Print view are skipped. +/// When `show_hidden` is false, hidden text runs omitted from Print view are skipped. +fn doc_has_unreadable_body_font(doc: &Document, hide_deleted: bool, show_hidden: bool) -> bool { + min_body_font_size(doc, hide_deleted, show_hidden) < MIN_COMFORTABLE_BODY_FONT_SIZE +} + +/// Collects all effective font sizes in points across a slice of blocks. +fn collect_blocks_font_sizes(doc: &Document, blocks: &[Arc], table_chr: Option<&wordcraft_doc::CharProps>, out: &mut Vec) { + use wordcraft_doc::styles::PartSet; + + for b in blocks { + if let Some(p) = b.as_para() { + let style_id = p.props.style.as_deref(); + if p.runs.is_empty() { + let mark_resolved = doc.styles.resolve_char_in(style_id, table_chr, &p.mark); + out.push(mark_resolved.size); + out.push(mark_resolved.size_cs); + } + for r in &p.runs { + let resolved = doc.styles.resolve_char_in(style_id, table_chr, &r.props); + if !matches!(resolved.vert_align, wordcraft_doc::props::VertAlign::Superscript | wordcraft_doc::props::VertAlign::Subscript) { + out.push(resolved.size); + out.push(resolved.size_cs); + } + } + } else if let Some(tbl) = b.as_table() { + let style = tbl.props.style.as_deref().and_then(|id| doc.styles.table_style(id)); + let nrows = tbl.rows.len(); + let ncols = tbl.cols(); + for (ri, row) in tbl.rows.iter().enumerate() { + for (ci, cell) in row.cells.iter().enumerate() { + let mut cell_chr = wordcraft_doc::CharProps::default(); + if let Some(st) = &style { + let sizes = (st.parts.band_size.unwrap_or(0), st.parts.col_band_size.unwrap_or(0)); + let g = tbl.grid_col(ri, ci); + let span = cell.span(); + let region = PartSet::of_cell(&tbl.props.look, sizes, (ri, nrows), (g, span, ncols)); + cell_chr.overlay(&st.chr); + for part in region.iter() { + let f = st.part(part); + cell_chr.overlay(&f.chr); + } + } + collect_blocks_font_sizes(doc, &cell.blocks, Some(&cell_chr), out); + } + } + } + } +} + +/// Collects unique effective body font sizes across the document body and editable parts. +fn collect_body_font_sizes(doc: &Document) -> Vec { + let mut sizes = Vec::new(); + collect_blocks_font_sizes(doc, &doc.body, None, &mut sizes); + let hf_boxes = wordcraft_doc::shrink::header_footer_text_boxes(doc); + for (id, part) in &doc.parts { + if !matches!(part.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer | wordcraft_doc::PartKind::Comment) + && !hf_boxes.contains(id) + { + collect_blocks_font_sizes(doc, &part.blocks, None, &mut sizes); + } + } + sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + sizes.dedup_by(|a, b| (*a - *b).abs() < 1e-4); + sizes +} + +/// Collects all effective line spacings across a slice of blocks while preserving table-style context. +fn collect_blocks_line_spacings( + doc: &Document, + blocks: &[Arc], + table_para: Option<&wordcraft_doc::ParaProps>, + out: &mut Vec, +) { + for b in blocks { + if let Some(p) = b.as_para() { + let resolved = doc.styles.resolve_para_in(&p.props, table_para); + out.push(resolved.line_spacing); + } else if let Some(tbl) = b.as_table() { + let style = tbl.props.style.as_deref().and_then(|id| doc.styles.table_style(id)); + let cell_para = style.as_ref().map(|st| &st.para); + for row in &tbl.rows { + for cell in &row.cells { + collect_blocks_line_spacings(doc, &cell.blocks, cell_para, out); + } + } + } + } +} + +/// Collects unique effective line spacings across body paragraphs and editable parts. +fn collect_body_line_spacings(doc: &Document) -> Vec { + let mut spacings = Vec::new(); + collect_blocks_line_spacings(doc, &doc.body, None, &mut spacings); + let hf_boxes = wordcraft_doc::shrink::header_footer_text_boxes(doc); + for (id, part) in &doc.parts { + if !matches!(part.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer | wordcraft_doc::PartKind::Comment) + && !hf_boxes.contains(id) + { + collect_blocks_line_spacings(doc, &part.blocks, None, &mut spacings); + } + } + spacings.sort_by(|a, b| match (a, b) { + (wordcraft_doc::props::LineSpacing::Multiple(x), wordcraft_doc::props::LineSpacing::Multiple(y)) + | (wordcraft_doc::props::LineSpacing::AtLeast(x), wordcraft_doc::props::LineSpacing::AtLeast(y)) + | (wordcraft_doc::props::LineSpacing::Exactly(x), wordcraft_doc::props::LineSpacing::Exactly(y)) => { + x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal) + } + (wordcraft_doc::props::LineSpacing::Multiple(_), _) => std::cmp::Ordering::Less, + (_, wordcraft_doc::props::LineSpacing::Multiple(_)) => std::cmp::Ordering::Greater, + (wordcraft_doc::props::LineSpacing::AtLeast(_), _) => std::cmp::Ordering::Less, + (_, wordcraft_doc::props::LineSpacing::AtLeast(_)) => std::cmp::Ordering::Greater, + }); + spacings.dedup_by(|a, b| match (a, b) { + (wordcraft_doc::props::LineSpacing::Multiple(x), wordcraft_doc::props::LineSpacing::Multiple(y)) + | (wordcraft_doc::props::LineSpacing::AtLeast(x), wordcraft_doc::props::LineSpacing::AtLeast(y)) + | (wordcraft_doc::props::LineSpacing::Exactly(x), wordcraft_doc::props::LineSpacing::Exactly(y)) => (*x - *y).abs() < 1e-4, + _ => false, + }); + spacings +} + +/// Checks whether previously reduced text from an earlier fit is still present in the document. +fn has_previously_reduced_text(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { + let mut font_reduced_present = false; + if fit.cum_delta > 0.0 { + let current_sizes = collect_body_font_sizes(doc); + for &cur in ¤t_sizes { + if fit.reduced_font_sizes.iter().any(|&red| cur <= red + 1e-4) { + font_reduced_present = true; + break; + } + } + } + + let mut space_reduced_present = false; + if fit.cum_space < 1.0 - 1e-4 { + let current_spacings = collect_body_line_spacings(doc); + for cur in ¤t_spacings { + if fit.reduced_line_spacings.iter().any(|red| match (cur, red) { + (wordcraft_doc::props::LineSpacing::Multiple(c), wordcraft_doc::props::LineSpacing::Multiple(r)) + | (wordcraft_doc::props::LineSpacing::AtLeast(c), wordcraft_doc::props::LineSpacing::AtLeast(r)) + | (wordcraft_doc::props::LineSpacing::Exactly(c), wordcraft_doc::props::LineSpacing::Exactly(r)) => *c <= *r + 1e-4, + _ => false, + }) { + space_reduced_present = true; + break; + } + } + } + + if fit.cum_delta > 0.0 && fit.cum_space < 1.0 - 1e-4 { + font_reduced_present || space_reduced_present + } else if fit.cum_delta > 0.0 { + font_reduced_present + } else if fit.cum_space < 1.0 - 1e-4 { + space_reduced_present + } else { + false + } +} + +/// Reads the cumulative Fit One Page reduction state from the document's internal fit tracking. +/// +/// Preserves the reduction budget when reopening a fitted document where `doc.fit_state` was serialized, +/// while maintaining the full budget for documents with no prior reductions. +fn cumulative_shrink_state(doc: &Document) -> (f32, f32) { + if let Some(fit) = &doc.fit_state + && has_previously_reduced_text(doc, fit) + { + return (fit.cum_delta, fit.cum_space); + } + (0.0, 1.0) +} + +/// Records the cumulative Fit One Page reduction state into the document's internal fit tracking. +fn set_cumulative_shrink_state(doc: &mut Document, delta: f32, space: f32) { + let sizes = collect_body_font_sizes(doc); + let spacings = collect_body_line_spacings(doc); + doc.fit_state = Some(wordcraft_doc::FitState { cum_delta: delta, cum_space: space, reduced_font_sizes: sizes, reduced_line_spacings: spacings }); +} + +/// Generates candidate font-reduction delta and line-spacing alpha pairs respecting parameter bounds. +fn generate_candidate_steps(max_delta: f32, step_pt_opt: Option, adjust_spacing: bool) -> Vec<(f32, f32)> { + let mut candidates: Vec<(f32, f32)> = Vec::new(); + if let Some(step_pt) = step_pt_opt { + if step_pt > 0.0 && step_pt.is_finite() { + if adjust_spacing { + candidates.push((0.0, 0.94)); + } + let count = if max_delta <= 0.0 { 0 } else { ((max_delta / step_pt).floor()).min(MAX_FINE_STEPS as f32).max(0.0) as usize }; + let mut last_d = 0.0f32; + for i in 1..=count { + let d = i as f32 * step_pt; + if d > max_delta { + break; + } + if d <= 0.0 || (d - last_d).abs() < 1e-4 { + continue; + } + last_d = d; + if adjust_spacing { + candidates.push((d, 0.95)); + candidates.push((d, 0.94)); + } else { + candidates.push((d, 1.0)); + } + } + let mut coarse_d = ((last_d / 0.5).floor() + 1.0) * 0.5; + while coarse_d <= max_delta { + if adjust_spacing { + candidates.push((coarse_d, 0.95)); + candidates.push((coarse_d, 0.94)); + } else { + candidates.push((coarse_d, 1.0)); + } + coarse_d += 0.5; + } + } + } else { + for &(delta, alpha) in &CANDIDATE_STEPS { + if delta <= max_delta { + let alpha = if adjust_spacing { alpha } else { 1.0 }; + if delta > 0.0 || (alpha - 1.0).abs() >= f32::EPSILON { + candidates.push((delta, alpha)); + } + } + } + // If maxDeltaPt was explicitly configured above 2.0 pt, test higher reductions up to max_delta + if max_delta > 2.0 { + let mut extra_delta = 2.5; + while extra_delta <= max_delta { + if adjust_spacing { + candidates.push((extra_delta, 0.95)); + candidates.push((extra_delta, 0.94)); + } else { + candidates.push((extra_delta, 1.0)); + } + extra_delta += 0.5; + } + } + } + candidates +} + +/// Executes the `layout.fitOnePage` command. +/// +/// Condenses the document by one page if feasible, preserving typographical +/// hierarchies, protecting line spacing, and clamping to readability bounds. +/// Refuses while Track Changes and Track Formatting are on, because the style +/// reductions it needs cannot be recorded as formatting revisions. +pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { + // 0. Refuse while formatting changes must be tracked: fitting rescales styles, and the + // formatting-recording path compares direct run/paragraph properties only, so the + // inherited reductions could not be represented as revisions. + if s.doc.settings.track_changes && s.prefs.markup.track_formatting { + return Err(CmdError::Disabled( + "the document tracks formatting changes: Fit One Page cannot record its style changes as formatting revisions".into(), + )); + } + + // 1. Anti-compound guard: refuse if cumulative reduction already reached 1.0 pt or spacing factor <= 0.94 + let (cum_delta, cum_space) = cumulative_shrink_state(&s.doc); + if cum_delta >= 1.0 - 1e-4 || cum_space <= 0.94 + 1e-4 { + return Err(CmdError::Failed("Unable to shrink document further without reducing text beyond readability limits.".into())); + } + + // 2. Build equivalent Print layout options + let shown_hidden = s.view.marks || s.prefs.marks.hidden; + let hide_deleted = !s.view.show_markup; + let (fn_name, fn_path) = s.resolved_filenames(); + let print_opts = LayoutOptions { + view: ViewMode::Print, + field_codes: s.view.field_codes, + show_hidden: shown_hidden, + hide_deleted, + proofing: false, + proof: wordcraft_layout::ProofOptions { grammar: false, spelling: Default::default() }, + filename: Some(Arc::from(fn_name.as_str())), + filepath: Some(Arc::from(fn_path.as_str())), + ..LayoutOptions::default() + }; + + // 3. Initial page count evaluation + let mut initial_cache = LayoutCache::new(); + let initial_layout = wordcraft_layout::layout(&s.doc, &mut initial_cache, &print_opts); + let p0 = initial_layout.pages.len(); + + if p0 <= 1 { + return Err(CmdError::Failed("The document already fits on one page.".into())); + } + + // 4. Pre-flight check on final page content volume + // + // Line-number decorations sit outside the body column, and note separators belong to the + // notes at the page's foot: neither is body overflow. Footnotes can be condensed too, so + // their occupied height (the union of their items, side-by-side table cells counted once) + // reduces the body area available on the page rather than counting as spilled body text. + let last_page = &initial_layout.pages[p0 - 1]; + let mut last_page_lines = 0usize; + let mut body_min_y = f32::INFINITY; + let mut body_max_y: f32 = 0.0; + let mut note_spans: Vec<(f32, f32)> = Vec::new(); + for item in &last_page.items { + if is_line_number_decoration(item) { + continue; + } + let is_note_separator = matches!(item, Placed::Lines { story, .. } if *story == wordcraft_layout::DECOR_STORY); + let is_note = matches!( + item, + Placed::Lines { story, .. } + | Placed::Cell { story, .. } + | Placed::Image { story, .. } + | Placed::Graphic { story, .. } + | Placed::Object { story, .. } + if is_footnote_story(&s.doc, story) + ); + if is_note || is_note_separator { + note_spans.push(item.vertical_span()); + continue; + } + if let Placed::Lines { l0, l1, y, para, .. } = item { + last_page_lines = last_page_lines.saturating_add(l1.saturating_sub(*l0)); + let h: f32 = para.lines.get(*l0..*l1).map(|slice| slice.iter().map(|l| l.height).sum()).unwrap_or(0.0); + body_min_y = body_min_y.min(*y); + body_max_y = body_max_y.max(y + h); + } + } + let body_spill_h = if body_max_y > body_min_y { body_max_y - body_min_y } else { 0.0 }; + let note_h = union_height(¬e_spans); + let body_area_h = (last_page.body.h - note_h).max(0.0); + let fill_ratio = if body_area_h > 0.0 { + body_spill_h / body_area_h + } else if body_spill_h > 0.0 { + 1.0 + } else { + 0.0 + }; + if last_page_lines > 12 || fill_ratio > 0.30 { + return Err(CmdError::Failed( + "Unable to fit document to one page: content on the final page exceeds limits for aesthetic page reduction.".into(), + )); + } + let target_pages = p0.saturating_sub(1); + + // 5. Parse parameters safely + let max_delta = p::f32(v, "maxDeltaPt").unwrap_or(2.0).clamp(0.0, 10.0); + let step_pt_opt = p::f32(v, "stepPt"); + let adjust_spacing = p::bool(v, "adjustSpacing").unwrap_or(true); + + // 6. Generate candidate steps: either bounded fine-grained stepPt or pre-calculated steps + let candidates = generate_candidate_steps(max_delta, step_pt_opt, adjust_spacing); + + // 7. Speculative search loop using ephemeral cache and equivalent print options + let mut successful_doc = None; + let mut applied_step = None; + let mut final_pages = target_pages; + + for (delta, alpha) in candidates { + // Enforce strict upper bound on delta + if delta > max_delta { + continue; + } + // Anti-compound budget across runs: cap cumulative font reduction at 1.0 pt + if cum_delta > 0.0 && cum_delta + delta > 1.0 + 1e-4 { + continue; + } + // Anti-compound budget across runs: cap cumulative line spacing compression at 0.94 floor + let cand_line_space = alpha.max(0.94); + let prior_line_space = cum_space.max(0.94); + if cum_space < 1.0 - 1e-4 && prior_line_space * cand_line_space < 0.94 - 1e-4 { + continue; + } + // Anti-compound guard: refuse font reduction if body font is already at or below the readability floor, + // while allowing spacing-only candidates (delta == 0.0) to run. + if delta > 0.0 && doc_has_unreadable_body_font(&s.doc, hide_deleted, shown_hidden) { + continue; + } + + let mut trial_doc = s.doc.clone(); + fit_document_typography(&mut trial_doc, delta, alpha); + + // Validate the trial document's effective body font sizes against 8.5 pt minimum before accepting + if delta > 0.0 && doc_has_unreadable_body_font(&trial_doc, hide_deleted, shown_hidden) { + continue; + } + + let mut trial_cache = LayoutCache::new(); + let trial_layout = wordcraft_layout::layout(&trial_doc, &mut trial_cache, &print_opts); + + if trial_layout.pages.len() <= target_pages { + final_pages = trial_layout.pages.len(); + successful_doc = Some(trial_doc); + applied_step = Some((delta, alpha)); + break; + } + } + + // 8. Success commit or clean rollback + match (successful_doc, applied_step) { + (Some(mut new_doc), Some((d, a))) => { + set_cumulative_shrink_state(&mut new_doc, cum_delta + d, cum_space * a); + s.doc = new_doc; + s.clamp_selection(); + let page_word = if final_pages == 1 { "page" } else { "pages" }; + s.status = if d > 0.0 { + format!("Document fitted to {} {} (font: -{:.1} pt, spacing: {:.0}%).", final_pages, page_word, d, a * 100.0) + } else { + format!("Document fitted to {} {} (spacing: {:.0}%).", final_pages, page_word, a * 100.0) + }; + sel_result(s) + } + _ => { + if has_explicit_breaks(&s.doc) { + Err(CmdError::Failed("Unable to fit document to one page: explicit page or section breaks prevent page reduction.".into())) + } else if min_body_font_size(&s.doc, hide_deleted, shown_hidden) <= MIN_COMFORTABLE_BODY_FONT_SIZE { + Err(CmdError::Failed("Unable to shrink document further without reducing text beyond readability limits.".into())) + } else { + Err(CmdError::Failed("Unable to fit document to one page: no permitted typography adjustment could remove a page.".into())) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn candidate_steps_bounded_by_max_fine_steps() { + // step_pt is 0.001 (above the 1e-4 deduplication threshold). + // Ratio 2.0 / 0.001 = 2000 requests 2000 steps, which must be clamped by MAX_FINE_STEPS (20). + let candidates = generate_candidate_steps(2.0, Some(0.001), false); + let fine_steps_count = candidates.iter().filter(|(d, _)| *d < 0.5).count(); + assert_eq!(fine_steps_count, MAX_FINE_STEPS); + } + + #[test] + fn union_height_counts_overlaps_and_stacked_spans_once() { + assert_eq!(union_height(&[]), 0.0); + // Side-by-side cells overlap; the stacked span below adds its own height. + assert_eq!(union_height(&[(0.0, 20.0), (5.0, 20.0), (20.0, 30.0)]), 30.0); + // Malformed input (reversed, NaN, infinite) is ignored rather than poisoning the total. + assert_eq!(union_height(&[(10.0, 5.0), (f32::NAN, 1.0), (0.0, f32::INFINITY), (2.0, 4.0)]), 2.0); + } +} diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index 8ddf62cd8d..eb607d157e 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -395,6 +395,7 @@ fn check_formatting(s: &Session, id: &str, params: &Value) -> Result<(), CmdErro || matches!( id, "edit.pasteFormat" + | "layout.fitOnePage" | "table.shading" | "table.borders" | "table.borderPainter" diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6439b3de9e..d33d82e32d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -13,6 +13,7 @@ use crate::{Session, cmd}; mod bidi; mod documents; mod find_apostrophes; +mod fit_one_page; mod font_sizes; mod fonts; mod ime_noop; diff --git a/crates/engine/src/tests/fit_one_page.rs b/crates/engine/src/tests/fit_one_page.rs new file mode 100644 index 0000000000..d875b4beb4 --- /dev/null +++ b/crates/engine/src/tests/fit_one_page.rs @@ -0,0 +1,1091 @@ +use serde_json::json; +use wordcraft_doc::Document; +use wordcraft_doc::para::PAGE_BREAK; +use wordcraft_doc::props::CharProps; + +use crate::Session; + +/// Creates a document with `lines` lines of text. +fn create_multiline_doc(lines: usize) -> Session { + let mut text = String::new(); + for i in 1..=lines { + text.push_str(&format!("Line {i}: The quick brown fox jumps over the lazy dog.\n")); + } + Session::new(Document::from_text(&text)) +} + +#[test] +fn fit_one_page_fails_on_single_page() { + let mut s = create_multiline_doc(5); + let before = s.doc.clone(); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("already fits on one page"), "unexpected error: {err}"); + assert_eq!(s.doc, before, "document must not change on failure"); +} + +#[test] +fn fit_one_page_fails_on_hard_page_break() { + let text = format!("Page 1 content\n{PAGE_BREAK}Page 2 content\n"); + let mut s = Session::new(Document::from_text(&text)); + let before = s.doc.clone(); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("explicit page or section breaks"), "unexpected error: {err}"); + assert_eq!(s.doc, before, "document must not change on failure"); +} + +#[test] +fn fit_one_page_fails_on_next_page_section_break() { + let mut s = Session::new(Document::from_text("First section body text")); + s.run("layout.break", &json!({"kind": "nextPage"})).unwrap(); + s.run("text.insert", &json!({"text": "Second section text on next page"})).unwrap(); + assert_eq!(s.layout().pages.len(), 2, "break must cause 2 pages"); + + let before = s.doc.clone(); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("explicit page or section breaks"), "unexpected error: {err}"); + assert_eq!(s.doc, before, "document must not change on failure"); +} + +#[test] +fn fit_one_page_condenses_slight_overflow_and_undoes() { + // Find the boundary where the document just spills onto page 2 + let mut lines = 30; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 2; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2, "must start as a 2-page document with {lines} lines"); + + let doc_before = s.doc.clone(); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "fit should succeed on slight overflow: {res:?}"); + assert_eq!(s.layout().pages.len(), 1, "document must now fit on 1 page"); + assert!(s.status.contains("Document fitted to 1 page"), "status was: {}", s.status); + + // Test single atomic undo + let undo_res = s.run("edit.undo", &json!({})); + assert!(undo_res.is_ok(), "undo must succeed"); + assert_eq!(s.layout().pages.len(), 2, "undo must restore 2-page layout"); + assert_eq!(s.doc, doc_before, "undo must restore exact pre-fit document state"); + + // Test redo + let redo_res = s.run("edit.redo", &json!({})); + assert!(redo_res.is_ok(), "redo must succeed"); + assert_eq!(s.layout().pages.len(), 1, "redo must restore 1-page layout"); +} + +#[test] +fn fit_one_page_respects_max_delta() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2, "must start as 2-page document"); + + let doc_backup = s.doc.clone(); + let ok_res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(ok_res.is_ok(), "must succeed when positive font delta 0.5 is permitted: {ok_res:?}"); + assert_eq!(s.layout().pages.len(), 1); + + s.doc = doc_backup; + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": false})); + assert!(res.is_err(), "must fail when font delta is capped at 0.0 and spacing is disabled"); +} + +#[test] +fn fit_one_page_respects_step_pt() { + let mut text = String::new(); + for i in 1..=28 { + text.push_str(&format!("Line {i}: The quick brown fox jumps over the lazy dog.\n")); + } + let mut s = Session::new(Document::from_text(&text)); + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.mark.size = Some(11.4); + for r in &mut p.runs { + r.props.size = Some(11.4); + } + } + } + assert_eq!(s.layout().pages.len(), 2, "must start as 2-page document at 11.4 pt"); + + // Both calls hold maxDeltaPt constant at 0.4 and disable spacing adjustments. + // Default 0.5 pt step generates no candidates <= 0.4 pt, so it must fail. + let doc_backup = s.doc.clone(); + let default_res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.4, "adjustSpacing": false})); + assert!(default_res.is_err(), "default step 0.5 must skip all candidates when maxDelta is 0.4"); + + // With stepPt = 0.1 under the same maxDeltaPt = 0.4, fine-grained steps (0.1..0.4) succeed (11.4 -> 11.0 pt). + s.doc = doc_backup; + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.4, "stepPt": 0.1, "adjustSpacing": false})); + assert!(res.is_ok(), "stepPt=0.1 must generate fine-grained candidates under maxDelta 0.4: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_allows_spacing_only_at_font_floor() { + let mut lines = 35; + let mut s = create_multiline_doc(lines); + let set_font = |s: &mut Session| { + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + for r in &mut p.runs { + r.props = CharProps { size: Some(8.5), ..Default::default() }; + } + } + } + s.touch(); + s.relayout(); + }; + set_font(&mut s); + while s.layout().pages.len() < 2 && lines < 120 { + lines += 1; + s = create_multiline_doc(lines); + set_font(&mut s); + } + assert_eq!(s.layout().pages.len(), 2); + + // Spacing-only candidate must be permitted to run and succeed + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "spacing-only candidate must succeed at font floor: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); + assert!(s.status.contains("spacing: "), "status: {}", s.status); + assert!(!s.status.contains("font: -"), "font must remain untouched: {}", s.status); +} + +#[test] +fn fit_one_page_fails_when_body_font_already_at_minimum_readable_size() { + let mut lines = 35; + let mut s = create_multiline_doc(lines); + let set_font = |s: &mut Session| { + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + for r in &mut p.runs { + r.props = CharProps { size: Some(8.0), ..Default::default() }; + } + } + } + s.touch(); + s.relayout(); + }; + set_font(&mut s); + while s.layout().pages.len() < 2 && lines < 120 { + lines += 1; + s = create_multiline_doc(lines); + set_font(&mut s); + } + assert_eq!(s.layout().pages.len(), 2); + + // When spacing adjustment is disabled, no candidate can run without reducing font + let res = s.run("layout.fitOnePage", &json!({"adjustSpacing": false})); + assert!(res.is_err(), "must refuse to shrink font when already <= 8.5 pt"); + let err = res.unwrap_err().to_string(); + assert!(err.contains("Unable to shrink document further without reducing text beyond readability limits."), "unexpected error message: {err}"); +} + +#[test] +fn fit_one_page_bounds_fine_step_loop() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2, "fixture must reach candidate generation on 2 pages"); + + // With stepPt = 0.001 and maxDeltaPt = 2.0 (ratio = 2000), fine-step candidate count + // is clamped by MAX_FINE_STEPS (20), executing quickly without doing thousands of iterations. + let res = s.run("layout.fitOnePage", &json!({"stepPt": 0.001, "maxDeltaPt": 2.0, "adjustSpacing": false})); + assert!(res.is_ok(), "fitOnePage with bounded fine steps succeeds or terminates gracefully: {res:?}"); +} + +#[test] +fn fit_one_page_supports_max_delta_above_2pt() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + // Add 8 lines of spill (within the 12-line spill limit) + for i in 1..=8 { + s.run("text.insert", &json!({"text": format!("Spill paragraph line {i}\n")})).unwrap(); + } + assert_eq!(s.layout().pages.len(), 2); + + // With maxDeltaPt = 3.0, candidate above 2 pt fits the document + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 3.0})); + assert!(res.is_ok(), "fitOnePage with maxDeltaPt=3.0 must succeed: {res:?}"); + assert_eq!(s.layout().pages.len(), 1, "must fit on 1 page"); +} + +#[test] +fn fit_one_page_anti_compound_guard_prevents_repeated_invocations() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // First invocation succeeds + let res1 = s.run("layout.fitOnePage", &json!({})); + assert!(res1.is_ok(), "first invocation must succeed: {res1:?}"); + assert_eq!(s.layout().pages.len(), 1); + + // Add content to push it onto page 2 again + for i in 1..=5 { + s.run("text.insert", &json!({"text": format!("New spill line {i}\n")})).unwrap(); + } + assert_eq!(s.layout().pages.len(), 2); + let doc_before_second = s.doc.clone(); + + // Second invocation must be rejected by the anti-compound guard without mutating doc + let res2 = s.run("layout.fitOnePage", &json!({})); + assert!(res2.is_err(), "second invocation must be rejected by anti-compound guard"); + let err = res2.unwrap_err().to_string(); + assert!(err.contains("Unable to shrink document further without reducing text beyond readability limits."), "unexpected error message: {err}"); + assert_eq!(s.doc, doc_before_second, "document must not be mutated on rejected invocation"); +} + +#[test] +fn fit_one_page_checks_complex_script_font_floor() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // Insert an Arabic run with size 12.0 pt but size_cs 9.0 pt + let arabic_p = wordcraft_doc::Paragraph::with_text( + "نص تجريبي باللغة العربية", + CharProps { size: Some(12.0), size_cs: Some(9.0), cs: Some(true), ..Default::default() }, + ); + s.doc.body.insert(0, wordcraft_doc::para_block(arabic_p)); + assert_eq!(s.layout().pages.len(), 2); + + // A 1.0 pt reduction would reduce size_cs from 9.0 to 8.0 (< 8.5 pt floor). + // The trial must not commit. + let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); + let p = s.doc.body[0].as_para().unwrap(); + let cs_size = p.runs[0].props.size_cs.unwrap_or(9.0); + assert!(cs_size >= 8.5, "complex-script size must remain >= 8.5 pt, was {cs_size}"); +} + +#[test] +fn fit_one_page_checks_editable_text_box_font_floor() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // Add a body-anchored text box with 9.0 pt font + let box_id = s.doc.add_part( + wordcraft_doc::PartKind::TextBox, + vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text( + "Text box body text", + CharProps { size: Some(9.0), ..Default::default() }, + ))], + ); + // Link it into body + let mut shape_p = wordcraft_doc::Paragraph::with_text("Before box ", CharProps::default()); + let shape = wordcraft_doc::InlineObject::Shape { + kind: wordcraft_doc::para::ShapeKind::TextBox, + w: 100.0, + h: 50.0, + fill: None, + stroke: None, + stroke_width: 0.5, + float: Default::default(), + story: Some(box_id), + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + shape_p.objects.push(shape); + s.doc.body.insert(0, wordcraft_doc::para_block(shape_p)); + assert_eq!(s.layout().pages.len(), 2); + + // With maxDeltaPt = 1.0 and adjustSpacing = false, 9.0 pt would reduce to 8.0 pt. + // The trial must not be committed. + let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); + let part = s.doc.parts.get(&box_id).unwrap(); + let box_p = part.blocks[0].as_para().unwrap(); + let sz = box_p.runs[0].props.size.unwrap(); + assert!(sz >= 8.5, "editable text box font must remain >= 8.5 pt, was {sz}"); +} + +#[test] +fn fit_one_page_fails_when_overflow_exceeds_limits() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 { + lines += 1; + s = create_multiline_doc(lines); + } + // Add 16 extra lines to page 2: this overflow is too large to fit into page 1 with maxDeltaPt = 0.5 + for i in 1..=16 { + s.run("text.insert", &json!({"text": format!("Overflow line {i}\n")})).unwrap(); + } + assert_eq!(s.layout().pages.len(), 2, "must be a 2-page document"); + + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5})); + assert!(res.is_err(), "must refuse when overflow exceeds aesthetic reduction limits"); + let err = res.unwrap_err().to_string(); + assert!(err.contains("exceeds limits for aesthetic page reduction"), "unexpected error message: {err}"); +} + +#[test] +fn fit_one_page_validates_effective_font_in_tables_before_committing() { + let mut s = Session::new(Document::new()); + let mut table = wordcraft_doc::Table::new(1, 1, 400.0); + let cell_p = wordcraft_doc::Paragraph::with_text("Cell text", CharProps { size: Some(9.0), ..Default::default() }); + table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cell_p)]; + s.doc.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table))]; + + let mut text = String::new(); + for i in 1..=45 { + text.push_str(&format!("Line {i} following table: The quick brown fox jumps over the lazy dog.\n")); + } + s.doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps::default()))); + assert_eq!(s.layout().pages.len(), 2); + + // If maxDeltaPt is 1.0, 9.0 pt would reduce to 8.0 pt (violating 8.5 pt floor). + // The candidate must not be committed. + let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); + if let Some(tbl) = s.doc.body[0].as_table() { + let p = tbl.rows[0].cells[0].blocks[0].as_para().unwrap(); + let sz = p.runs[0].props.size.unwrap(); + assert!(sz >= 8.5, "table cell font size must remain >= 8.5 pt, was {sz}"); + } +} + +#[test] +fn fit_one_page_validates_effective_font_in_table_styles_before_committing() { + let mut s = Session::new(Document::new()); + // Create a custom table style with 9.0 pt font + let st = wordcraft_doc::styles::Style { + id: "CustomTableStyle".into(), + name: "CustomTableStyle".into(), + kind: wordcraft_doc::styles::StyleKind::Table, + chr: CharProps { size: Some(9.0), ..Default::default() }, + table: Some(wordcraft_doc::styles::TableStyleParts::default()), + ..Default::default() + }; + std::sync::Arc::make_mut(&mut s.doc.styles).upsert(st); + + let mut table = wordcraft_doc::Table::new(1, 1, 400.0); + table.props.style = Some("CustomTableStyle".to_string()); + let cell_p = wordcraft_doc::Paragraph::with_text("Cell text inheriting table style", CharProps::default()); + table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cell_p)]; + s.doc.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table))]; + + let mut text = String::new(); + for i in 1..=45 { + text.push_str(&format!("Line {i} following table: The quick brown fox jumps over the lazy dog.\n")); + } + s.doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps::default()))); + assert_eq!(s.layout().pages.len(), 2); + + // If maxDeltaPt is 1.0, 9.0 pt in the table style would reduce to 8.0 pt (violating 8.5 pt floor). + // The candidate must not be committed. + let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); + let style = s.doc.styles.table_style("CustomTableStyle").unwrap(); + let sz = style.chr.size.unwrap(); + assert!(sz >= 8.5, "table style font size must remain >= 8.5 pt, was {sz}"); +} + +#[test] +fn fit_one_page_measures_only_final_page_placed_lines() { + let mut s = Session::new(Document::new()); + let words = "The quick brown fox jumps over the lazy dog and runs across the wide open fields in the sunshine. "; + let mut reps = 20; + loop { + let text = words.repeat(reps); + s.doc.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps::default()))]; + s.touch(); + s.relayout(); + if s.layout().pages.len() >= 2 { + break; + } + reps += 1; + } + assert_eq!(s.layout().pages.len(), 2); + + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "fitOnePage must succeed when single paragraph slightly spills onto page 2: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_exempts_style_inherited_superscript() { + let mut s = Session::new(Document::new()); + // Create a Character style with Superscript alignment and 7.0 pt font + let st = wordcraft_doc::styles::Style { + id: "SuperStyle".into(), + name: "SuperStyle".into(), + kind: wordcraft_doc::styles::StyleKind::Character, + chr: CharProps { vert_align: Some(wordcraft_doc::props::VertAlign::Superscript), size: Some(7.0), ..Default::default() }, + ..Default::default() + }; + std::sync::Arc::make_mut(&mut s.doc.styles).upsert(st); + + let words = "Regular readable text that fills up lines in the document body. "; + let mut reps = 20; + loop { + let mut p = wordcraft_doc::Paragraph::with_text(&words.repeat(reps), CharProps { size: Some(11.0), ..Default::default() }); + // Add a run with style-inherited superscript + p.runs.push(wordcraft_doc::Run { len: 3, props: CharProps { style: Some("SuperStyle".into()), ..Default::default() } }); + p.text.push_str("123"); + s.doc.body = vec![wordcraft_doc::para_block(p)]; + s.touch(); + s.relayout(); + if s.layout().pages.len() >= 2 { + break; + } + reps += 1; + } + assert_eq!(s.layout().pages.len(), 2); + + // fitOnePage must not be blocked by the 7.0 pt superscript run because it is exempt + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "style-inherited superscript must be exempt from font floor: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_refused_when_protect_formatting_enabled() { + let mut s = Session::new(Document::new()); + s.doc.settings.protection = Some("none".into()); + s.doc.settings.protect_formatting = true; + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("formatting is limited to the document's styles"), "unexpected error: {err}"); +} + +#[test] +fn fit_one_page_zero_max_delta_never_reduces_font() { + let mut s = Session::new(Document::new()); + let words = "Content line with fractional font size for zero delta testing. "; + let mut reps = 20; + loop { + let text = words.repeat(reps); + s.doc.body = + vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps { size: Some(10.3), ..Default::default() }))]; + s.touch(); + s.relayout(); + if s.layout().pages.len() >= 2 { + break; + } + reps += 1; + } + assert_eq!(s.layout().pages.len(), 2); + + // Call fitOnePage with maxDeltaPt = 0.0 and a tiny stepPt (which should not produce any positive candidate) + let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "stepPt": 0.001})); + let p = s.doc.body[0].as_para().unwrap(); + assert_eq!(p.runs[0].props.size, Some(10.3), "font size must remain completely unchanged when maxDeltaPt is 0"); +} + +#[test] +fn fit_one_page_fails_with_failed_cmd_error() { + let mut s = create_multiline_doc(5); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(matches!(res, Err(crate::CmdError::Failed(_))), "expected CmdError::Failed, got {res:?}"); +} + +#[test] +fn fit_one_page_never_populates_custom_props() { + let mut lines = 30; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 2; + s = create_multiline_doc(lines); + } + assert!(s.doc.custom_props.is_empty()); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok()); + assert!(s.doc.custom_props.is_empty(), "custom_props must not be populated with internal fit state"); +} + +#[test] +fn fit_one_page_allows_subsequent_fit_after_typography_reset() { + use std::sync::Arc; + + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // First invocation succeeds + let res1 = s.run("layout.fitOnePage", &json!({})); + assert!(res1.is_ok()); + assert_eq!(s.layout().pages.len(), 1); + + // Reset styles back to Normal with 12 pt font and 1.15 line spacing + if let Some(st) = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|st| st.id == "Normal") { + st.chr.size = Some(12.0); + st.para.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.15)); + } + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(b) { + p.props.line_spacing = None; + for r in &mut p.runs { + r.props.size = None; + } + } + } + s.touch(); + s.relayout(); + + // Now add lines to make it 2 pages again + while s.layout().pages.len() < 2 { + s.run("text.insert", &json!({"text": "Additional fresh paragraph after style reset.\n"})).unwrap(); + } + assert_eq!(s.layout().pages.len(), 2); + + // A subsequent fitOnePage run must NOT be permanently blocked and should succeed + let res2 = s.run("layout.fitOnePage", &json!({})); + assert!(res2.is_ok(), "fitOnePage must succeed after typography reset: {res2:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_small_paragraph_mark_with_runs_never_blocks_fit() { + let mut lines = 30; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 2; + s = create_multiline_doc(lines); + } + // Set a 1.0 pt paragraph mark on a body paragraph that has normal 11 pt text runs + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { + p.mark.size = Some(1.0); + } + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "small mark with normal text runs must not block fit: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_small_comment_font_never_blocks_body_fit() { + let mut lines = 30; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 2; + s = create_multiline_doc(lines); + } + // Add a comment part with 6.0 pt text + let comment_p = wordcraft_doc::Paragraph::with_text("Small comment text", CharProps { size: Some(6.0), ..Default::default() }); + s.doc.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(comment_p)]); + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "small comment font must not block body fit: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_anti_compound_caps_total_reduction_across_runs() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + + // Initial fit with -0.5 pt font reduction (spacing disabled so font budget is exercised) + let res1 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res1.is_ok()); + assert_eq!(s.layout().pages.len(), 1); + assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); + + // Add content to spill to 2 pages again + for i in 1..=12 { + s.run("text.insert", &json!({"text": format!("Spill paragraph line {i}\n")})).unwrap(); + } + assert_eq!(s.layout().pages.len(), 2); + + // Candidate requiring > 0.5 pt more (e.g. total > 1.0 pt) must be rejected + let res2 = s.run("layout.fitOnePage", &json!({})); + assert!(res2.is_err(), "second run must not exceed cumulative 1.0 pt reduction budget"); +} + +#[test] +fn fit_one_page_tracks_reduced_text_provenance_without_global_minimum() { + let mut s = create_multiline_doc(27); + // Add a 9.0 pt run to the first paragraph alongside 11.0 pt runs + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { + p.runs.push(wordcraft_doc::para::Run { len: 5, props: CharProps { size: Some(9.0), ..Default::default() } }); + p.text.push_str("extra"); + } + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + // Initial fit with -0.5 pt: 9.0 pt -> 8.5 pt, 11.0 pt -> 10.5 pt (spacing disabled) + let res1 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res1.is_ok()); + assert_eq!(s.layout().pages.len(), 1); + assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); + + // Reset only the 8.5 pt run back to 12.0 pt (so current global minimum font becomes 10.5 pt) + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { + p.runs.last_mut().unwrap().props.size = Some(12.0); + } + s.touch(); + s.relayout(); + + // Add content to spill to 2 pages again + for i in 1..=12 { + s.run("text.insert", &json!({"text": format!("Spill paragraph line {i}\n")})).unwrap(); + } + assert_eq!(s.layout().pages.len(), 2); + + // Because the 10.5 pt runs are still reduced, fit state must NOT be discarded, + // and a candidate requiring > 0.5 pt more font reduction must be rejected by the 1.0 pt cumulative cap + let res2 = s.run("layout.fitOnePage", &json!({})); + assert!(res2.is_err(), "cumulative budget must still hold even when the original minimum font was changed"); +} + +#[test] +fn fit_one_page_allows_spill_with_bottom_placed_footnote() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // Add a single-line footnote to the document (reference on page 1) + let note_p = wordcraft_doc::Paragraph::with_text("Short footnote text.", CharProps::default()); + let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note_p)]); + let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; + s.doc.insert_object(&wordcraft_doc::Pos::body(0, 5), noteref, &Default::default()).unwrap(); + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + // Even though the footnote is placed at the bottom of the page (large y), + // fill_ratio must measure only actual content height so fitOnePage is not falsely refused + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "bottom-placed footnote must not drive fill ratio above 30%: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_with_final_page_footnote() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // Insert note reference into a paragraph on the final page (page 2) + let last_para_idx = s.doc.body.len().saturating_sub(1); + let note_p = wordcraft_doc::Paragraph::with_text("Final page footnote text.", CharProps::default()); + let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note_p)]); + let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; + s.doc.insert_object(&wordcraft_doc::Pos::body(last_para_idx, 0), noteref, &Default::default()).unwrap(); + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + // Verify footnote is assigned to the final page (page index 1) + let page2_has_footnote = s.layout().pages[1] + .items + .iter() + .any(|item| matches!(item, wordcraft_layout::Placed::Lines { story: wordcraft_doc::StoryRef::Part(id), .. } if *id == note_id)); + assert!(page2_has_footnote, "footnote must be assigned to page 2"); + + // With modest spill and small footnote within the 30% fill threshold, fitOnePage succeeds + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "final-page footnote within 30% fill limit must allow fitOnePage to succeed: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_skips_deleted_text_when_markup_hidden() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + + // Insert a deleted text run with small font (e.g. 6.0 pt, below the 8.5 pt floor) + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { + let mut del_props = CharProps { size: Some(6.0), ..Default::default() }; + del_props.del = Some(1); + p.runs.push(wordcraft_doc::para::Run { len: 8, props: del_props }); + p.text.push_str("deleted!"); + } + s.touch(); + s.relayout(); + + // When show_markup is false (the default in print view), deleted run is hidden + // and skipped during font-floor scan, allowing fitOnePage to succeed + s.view.show_markup = false; + let doc_backup = s.doc.clone(); + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res.is_ok(), "deleted text below floor must be skipped when markup is hidden: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); + + // When show_markup is true, deleted run is drawn and evaluated in font-floor scan, + // blocking font reduction because effective font is below 8.5 pt floor + s.doc = doc_backup; + s.view.show_markup = true; + let res_markup = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res_markup.is_err(), "deleted text below floor must block font reduction when markup is shown"); +} + +#[test] +fn fit_one_page_tracks_reduced_complex_script_font_sizes() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + let styles_before = s.doc.styles.clone(); + + // Set complex script font size on body run while keeping standard font size default (12 pt) + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { + p.runs[0].props.size_cs = Some(14.0); + } + s.touch(); + s.relayout(); + + // First fit reduces font size by 0.5 pt (size -> 11.5, size_cs -> 13.5) + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res.is_ok(), "first fit must succeed: {res:?}"); + assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); + + // User restores every other size (the styles the fit rescaled), leaving only this run's + // explicit complex-script size reduced at 13.5 pt. + s.doc.styles = styles_before; + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + // Because size_cs is still reduced, cumulative tracking retains the 0.5 pt delta provenance + assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); + + // Second fit within remaining 0.5 pt budget succeeds up to 1.0 pt cap + let res2 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res2.is_ok(), "second fit within remaining budget must succeed: {res2:?}"); + assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(1.0)); + + // Third fit exceeding cumulative 1.0 limit must be refused + let res3 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res3.is_err(), "fit exceeding cumulative limit must be refused"); +} + +#[test] +fn fit_one_page_preserves_budget_across_document_serialization() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + + // Apply fit + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res.is_ok(), "fit must succeed: {res:?}"); + assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); + + // Serialize and deserialize document (e.g. saving and reopening) + let json_bytes = serde_json::to_vec(&s.doc).expect("serialization must succeed"); + let reopened_doc: wordcraft_doc::Document = serde_json::from_slice(&json_bytes).expect("deserialization must succeed"); + + // Verify fit_state is preserved outside custom properties + assert_eq!(reopened_doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); + assert!(reopened_doc.custom_props.is_empty(), "custom_props must remain empty"); + + // Reopen into a new session with additional spill + let mut s2 = Session::new(reopened_doc); + let extra_p = wordcraft_doc::Paragraph::with_text("Extra overflow paragraph.", CharProps::default()); + s2.doc.body.push(wordcraft_doc::para_block(extra_p)); + s2.touch(); + s2.relayout(); + + // Second fit on reopened document preserves previous 0.5 delta, allowing only remaining 0.5 pt up to 1.0 cap + let res2 = s2.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res2.is_ok(), "second fit within remaining budget must succeed"); + assert_eq!(s2.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(1.0)); + + // Third fit exceeding the cumulative 1.0 limit must be refused + let res3 = s2.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res3.is_err(), "fit exceeding cumulative limit must be refused"); +} + +#[test] +fn fit_one_page_enforces_durable_limit_when_fit_state_missing() { + // A document with body font already at 8.5 pt readability floor and fit_state == None + let set_font = |s: &mut Session| { + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + for r in &mut p.runs { + r.props.size = Some(8.5); + } + } + } + s.touch(); + s.relayout(); + }; + let mut lines = 27; + let mut s = create_multiline_doc(lines); + set_font(&mut s); + while s.layout().pages.len() < 2 && lines < 120 { + lines += 1; + s = create_multiline_doc(lines); + set_font(&mut s); + } + assert_eq!(s.layout().pages.len(), 2); + s.doc.fit_state = None; + s.touch(); + s.relayout(); + let before = s.doc.clone(); + + // With adjustSpacing = false, font reduction is bounded by the durable 8.5 pt floor and refused + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res.is_err(), "fit must be refused when typography is at durable readability floor"); + let err = res.unwrap_err().to_string(); + assert!(err.contains("Unable to shrink document further without reducing text beyond readability limits."), "unexpected error: {err}"); + assert_eq!(s.doc, before, "a refused fit must leave the document unchanged"); +} + +#[test] +fn fit_one_page_skips_hidden_text_when_hidden_marks_off() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + + // Insert a hidden text run with small font (6.0 pt, below the 8.5 pt floor) into an existing paragraph + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { + let mut hidden_props = CharProps { size: Some(6.0), ..Default::default() }; + hidden_props.hidden = Some(true); + p.runs.push(wordcraft_doc::para::Run { len: 7, props: hidden_props }); + p.text.push_str("secret!"); + } + s.touch(); + s.relayout(); + + // When marks are off, hidden text is skipped from layout and floor checks + s.view.marks = false; + s.prefs.marks.hidden = false; + let doc_backup = s.doc.clone(); + + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res.is_ok(), "hidden text with small font must be skipped when hidden marks are off: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); + + // Reset document and test with marks turned on + s.doc = doc_backup; + s.view.marks = true; + s.touch(); + s.relayout(); + + let res_with_marks = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res_with_marks.is_err(), "hidden text with small font must block fit when marks are visible"); +} + +#[test] +fn fit_one_page_accounts_for_vertical_alignment_padding() { + let mut s = create_multiline_doc(26); + let extra_p = wordcraft_doc::Paragraph::with_text("Spill line on page 2.", CharProps::default()); + s.doc.body.push(wordcraft_doc::para_block(extra_p)); + + // Set page vertical alignment to Bottom + s.doc.last_section.valign = wordcraft_doc::section::PageVAlign::Bottom; + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + // Fit must succeed because the actual spill height (1 line) is well under 30% of page height, + // even though the line was translated to the bottom of page 2 by valign padding. + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res.is_ok(), "fit must succeed with vertical alignment padding: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_accounts_for_note_separator_decor_story() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // Set custom footnote separator story so layout produces DECOR_STORY + s.doc.footnote_separators.separator = Some(vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("---", CharProps::default()))]); + + // Insert note reference into a paragraph on the final page (page 2) + let last_para_idx = s.doc.body.len().saturating_sub(1); + let note_p = wordcraft_doc::Paragraph::with_text("Final page footnote text.", CharProps::default()); + let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note_p)]); + let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; + s.doc.insert_object(&wordcraft_doc::Pos::body(last_para_idx, 0), noteref, &Default::default()).unwrap(); + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + // Verify layout produces DECOR_STORY on page 2 + let page2 = &s.layout().pages[1]; + let has_decor = + page2.items.iter().any(|item| matches!(item, wordcraft_layout::Placed::Lines { story, .. } if *story == wordcraft_layout::DECOR_STORY)); + assert!(has_decor, "footnote on page 2 must generate DECOR_STORY separator"); + + // Fit must succeed without DECOR_STORY inflating body_max_y + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "fit with final page footnote and DECOR_STORY must succeed: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_tracks_fixed_spacing_independent_of_paragraph_mark_size() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + let set_spacing = |s: &mut Session| { + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(16.0)); + } + } + s.touch(); + s.relayout(); + }; + set_spacing(&mut s); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + set_spacing(&mut s); + } + assert_eq!(s.layout().pages.len(), 2); + + // Fit with spacing reduction + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": true})); + assert!(res.is_ok(), "spacing reduction must succeed: {res:?}"); + assert!(s.doc.fit_state.as_ref().is_some_and(|f| f.cum_space < 1.0)); + + // Change paragraph mark font size on paragraphs (e.g. from 12 pt to 24 pt) + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.mark.size = Some(24.0); + } + } + s.touch(); + s.relayout(); + + // Second fit with spacing only must be refused because spacing floor 0.94 was reached + // and tracked provenance was retained despite paragraph mark size change + let res2 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": true})); + assert!(res2.is_err(), "anti-compound guard must detect reduced fixed spacing and reject repeat compression"); +} + +#[test] +fn fit_one_page_reports_search_exhaustion_when_candidates_cannot_remove_page() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + + // Calling with zero maxDeltaPt and adjustSpacing: false produces no candidate steps + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": false})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!( + err.contains("no permitted typography adjustment could remove a page"), + "error message must distinguish search exhaustion from overflow limits: {err}" + ); +} + +#[test] +fn fit_one_page_excludes_line_number_decorations_from_final_page_limits() { + let mut lines = 33; + let enable_line_numbers = |s: &mut Session| { + s.doc.last_section.line_numbers = Some(wordcraft_doc::section::LineNumbering::default()); + s.touch(); + s.relayout(); + }; + let mut s = create_multiline_doc(lines); + enable_line_numbers(&mut s); + while s.layout().pages.len() < 2 && lines < 60 { + lines += 1; + s = create_multiline_doc(lines); + enable_line_numbers(&mut s); + } + assert_eq!(s.layout().pages.len(), 2); + + // Every body line on the final page also places a line-number decoration. Counting those as + // content would double the final-page line count and refuse before any candidate is tried. + let decorated = s.layout().pages[1] + .items + .iter() + .filter(|item| matches!(item, wordcraft_layout::Placed::Lines { story, .. } if *story == wordcraft_layout::DECOR_STORY)) + .count(); + assert!(decorated > 0, "the final page must carry line-number decorations"); + + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 4.0})); + assert!(res.is_ok(), "line-number decorations must not count as final-page content: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_counts_side_by_side_footnote_cells_once() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + + // A footnote table whose row's cells sit side by side: their text occupies one vertical + // interval, so their heights must not be summed as if stacked. + let mut table = wordcraft_doc::Table::new(1, 3, 420.0); + let cell_text = "The quick brown fox jumps over the lazy dog again and again."; + for cell in &mut table.rows[0].cells { + cell.blocks = + vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(cell_text, CharProps { size: Some(21.0), ..Default::default() }))]; + } + let note_blocks: Vec> = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table))]; + let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, note_blocks); + + let last_para_idx = s.doc.body.len().saturating_sub(1); + let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; + s.doc.insert_object(&wordcraft_doc::Pos::body(last_para_idx, 0), noteref, &Default::default()).unwrap(); + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + let note_cells = s.layout().pages[1] + .items + .iter() + .filter(|item| matches!(item, wordcraft_layout::Placed::Cell { story: wordcraft_doc::StoryRef::Part(id), .. } if *id == note_id)) + .count(); + assert!(note_cells >= 2, "the footnote table must place its cells side by side on the final page"); + + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 4.0})); + assert!(res.is_ok(), "side-by-side footnote cells must count once in the fill height: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_refuses_when_formatting_changes_cannot_be_tracked() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + s.doc.settings.track_changes = true; + s.prefs.markup.track_formatting = true; + let before = s.doc.clone(); + + // Style reductions cannot be recorded as formatting revisions, so the command must refuse + // rather than change typography that rejecting all changes cannot undo. + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err(), "layout.fitOnePage must refuse while formatting is tracked"); + let err = res.unwrap_err().to_string(); + assert!(err.contains("tracks formatting changes"), "unexpected error: {err}"); + assert_eq!(s.doc, before, "a refused fit must leave the document unchanged"); +} diff --git a/crates/layout/src/notes.rs b/crates/layout/src/notes.rs index e34f1aff30..c5bc11b766 100644 --- a/crates/layout/src/notes.rs +++ b/crates/layout/src/notes.rs @@ -1,7 +1,7 @@ use super::boxes::{BoxInput, BoxLayout, layout_box}; use super::ctx::Ctx; use super::floats::left_out_objects; -use super::page::{PageBuilder, item_bottom}; +use super::page::PageBuilder; use super::placed::Placed; use super::{ Block, Blocks, Border, Document, HashMap, HideDeleted, InlineObject, Page, ParaLayout, Paragraph, Rect, StoryRef, TextDirection, fields, para, @@ -176,20 +176,7 @@ pub(crate) struct NoteBox { /// The vertical extent of an item laid out in a horizontal frame. pub(crate) fn v_span(it: &Placed) -> (f32, f32) { - if let Some(r) = it.turned_bounds() { - return (r.y, r.y + r.h); - } - match it { - Placed::Lines { para, l0, l1, y, .. } => (*y, item_bottom(*y, para, *l0, *l1).unwrap_or(*y)), - Placed::Fill { rect, .. } - | Placed::Image { rect, .. } - | Placed::Shape { rect, .. } - | Placed::Graphic { rect, .. } - | Placed::Clip { rect, .. } - | Placed::Cell { rect, .. } - | Placed::Object { rect, .. } => (rect.y, rect.y + rect.h), - Placed::Rule { y0, y1, .. } => (y0.min(*y1), y0.max(*y1)), - } + it.vertical_span() } impl NoteBox { diff --git a/crates/layout/src/placed.rs b/crates/layout/src/placed.rs index cc9ab49cbb..83d28cb5de 100644 --- a/crates/layout/src/placed.rs +++ b/crates/layout/src/placed.rs @@ -157,6 +157,27 @@ impl Placed { Some(turn_rect(*turn, *x, *y, Rect::new(0.0, 0.0, len, last.top + last.height - first.top))) } + /// The vertical extent `(top, bottom)` the item occupies on its page. + /// + /// Items side by side (a table row's cells, say) report overlapping extents; callers that sum + /// them take the union instead. + pub fn vertical_span(&self) -> (f32, f32) { + if let Some(r) = self.turned_bounds() { + return (r.y, r.y + r.h); + } + match self { + Placed::Lines { para, l0, l1, y, .. } => (*y, super::page::item_bottom(*y, para, *l0, *l1).unwrap_or(*y)), + Placed::Fill { rect, .. } + | Placed::Image { rect, .. } + | Placed::Shape { rect, .. } + | Placed::Graphic { rect, .. } + | Placed::Clip { rect, .. } + | Placed::Cell { rect, .. } + | Placed::Object { rect, .. } => (rect.y, rect.y + rect.h), + Placed::Rule { y0, y1, .. } => (y0.min(*y1), y0.max(*y1)), + } + } + /// Move an item laid out in a turned frame (a table cell's text running `turn`) onto the /// page, the frame's origin landing on page point (`x`, `y`). pub(crate) fn turn(&mut self, turn: TextDirection, x: f32, y: f32) { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 33ccaec873..4246d82b21 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -726,6 +726,12 @@ fn paint_draw_to_review(pen: &Pen, name: &str) -> bool { pen.text(7.0, 7.0, 8.0, "ab-", c, false); pen.text(9.0, 14.5, 8.0, "cd", a, false); } + "fitOnePage" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.lines(6.5, 13.5, &[6.0, 8.5, 11.0]); + pen.line_c(&[(10.0, 16.5), (10.0, 13.0)], a); + pen.line_c(&[(8.0, 14.5), (10.0, 13.0), (12.0, 14.5)], a); + } "position" | "wrapText" => { pen.lines(3.0, 17.0, &[3.0, 17.0]); pen.lines(3.0, 6.0, &[7.0, 10.0, 13.0]); @@ -1729,4 +1735,16 @@ mod tests { assert!(has_default_highlight_bar("shading")); assert!(!has_default_highlight_bar("shadingNoBar")); } + + /// Verify that the fitOnePage vector icon renders without panics and emits shapes. + #[test] + fn fit_one_page_icon_paints_without_panic() { + let ctx = egui::Context::default(); + let key = "fitOnePage"; + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, egui::vec2(20.0, 20.0)), key, Color32::BLACK, Color32::BLUE); + }); + assert!(!output.shapes.is_empty(), "{key} icon must emit shapes"); + output.drop_without_applying_deltas(); + } } diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index 1ebd6679a7..80af79561f 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -292,6 +292,7 @@ fn controls(tab: &str) -> Vec { m("Breaks", "layout.break"), m("Line Numbers", "layout.lineNumbers"), m("Hyphenation", "layout.hyphenation"), + c("Fit One Page", "layout.fitOnePage"), c("Position", "arrange.position"), c("Wrap Text", "arrange.wrap"), c("Bring Forward", "arrange.bringForward"), diff --git a/crates/ui-egui/src/keytips/tests.rs b/crates/ui-egui/src/keytips/tests.rs index f9cc3e894f..582197669d 100644 --- a/crates/ui-egui/src/keytips/tests.rs +++ b/crates/ui-egui/src/keytips/tests.rs @@ -126,6 +126,22 @@ fn every_keytip_command_is_registered() { assert!(missing.is_empty(), "unregistered: {missing:#?}"); } +/// layout.fitOnePage has a KeyTip badge on the Layout tab. +#[test] +fn layout_tab_includes_fit_one_page_keytip() { + let a = app(); + let (_, cmds) = letters(&a); + let layout_cmds = cmds.iter().find(|(t, _)| t == "Layout").expect("Layout tab exists"); + let ctrls = controls("Layout"); + let fit_idx = ctrls.iter().position(|c| match &c.target { + Target::Cmd { id, .. } => *id == "layout.fitOnePage", + _ => false, + }); + assert!(fit_idx.is_some(), "layout.fitOnePage must be present in Layout controls"); + let badge = layout_cmds.1.get(fit_idx.unwrap()).expect("badge exists"); + assert!(!badge.is_empty(), "fitOnePage badge must not be empty"); +} + #[test] fn letters_are_unique_inside_every_tab() { let a = app(); diff --git a/crates/ui-egui/src/ribbon/layout.rs b/crates/ui-egui/src/ribbon/layout.rs index 39e1654e28..c2ff99857b 100644 --- a/crates/ui-egui/src/ribbon/layout.rs +++ b/crates/ui-egui/src/ribbon/layout.rs @@ -119,6 +119,7 @@ pub(super) fn layout(app: &mut WordApp, ui: &mut Ui) { }, ); }); + big(ui, app, "fitOnePage", "Fit\nOne Page", "layout.fitOnePage", json!({}), false); }); group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| { let rp = app.session.doc.para_at(&app.session.sel.focus).map(|_| wordcraft_engine::cmd::para::selection_para(&app.session)); diff --git a/crates/ui-egui/src/widgets/tests.rs b/crates/ui-egui/src/widgets/tests.rs index b1896f34c1..1e01352025 100644 --- a/crates/ui-egui/src/widgets/tests.rs +++ b/crates/ui-egui/src/widgets/tests.rs @@ -551,3 +551,24 @@ fn dialog_tabs_expose_the_selected_state_and_report_clicks() { h.run_steps(2); assert_eq!(h.state().1, Some(1)); } + +/// Fit One Page button renders in the Layout ribbon across light and dark themes. +#[test] +fn layout_fit_one_page_button_renders_in_light_and_dark_themes() { + for theme in [crate::theme::Appearance::Light, crate::theme::Appearance::Dark] { + let a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + app.ui.tab = "Layout".to_string(); + app.ui.theme = theme; + let ctx = ui.ctx().clone(); + app.logic(&ctx); + let resp = crate::widgets::buttons::big(ui, app, "fitOnePage", "Fit\nOne Page", "layout.fitOnePage", serde_json::json!({}), false); + assert!(resp.rect.width() >= 40.0, "button must have allocated width in {theme:?}"); + assert!(resp.rect.height() >= 40.0, "button must have allocated height in {theme:?}"); + }, + a, + ); + h.run_steps(4); + } +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 0a39f59cca..034e6cd8cb 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** trivial (from the second section on, headers, footers and note separators are measured with their own section's document grid and SECTION/SECTIONPAGES values, #4683); previously trivial (a turned inline picture whose file keeps no room for the turn keeps its line to its frame, the frame turned a quarter in Word 2013+ layout); previously trivial (a picture lowered further than its own height lowers its line as far, its top on the baseline); previously trivial (a float anchored right after a page break, before text, is drawn on the new page, #4262); previously trivial (a float anchored on a later page of a long paragraph is drawn there, #4250); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a line of only pictures is as tall as they reach, never less than its font's height; a legacy check box sizes its line as text of its size, #3984); previously trivial (spaces before a line's first character keep their width in justified and distributed lines, as in Word); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (Times and Courier take their Windows counterparts' line spacing; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial (a click just outside a floating table, by a cell whose text is turned, goes to the text beside or after it, #4181; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial (PR #4545: eligible cell floats keep in-bounds offsets; overflow confinement retains the front-of-text, turned-cell and native-acceptance limits); previously trivial (MATH construction reuse, #3630; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial (ODT documents: a line of only pictures keeps its font's height; no setting for it any more); previously trivial (ODT documents: padding is the standard's box model always; picture-only line height is an ODF interpretation setting); previously trivial (行頭禁則 also covers the small kana and the prolonged sound mark at ordinary break opportunities, as JIS X 4051 §4 specifies, inferred from the standard and synthetic tests rather than checked in Word); previously trivial (a missing font is drawn in an installed metric twin before any other substitute: Arial for Helvetica on Windows, Tinos before macOS Times for Times New Roman); previously trivial (ODT documents: padding without a border takes room); previously trivial (ODT documents: a line of only pictures is as tall as they are); previously trivial (ODT documents: a table's own space above and below; header and footer spacing kept past a tall header); previously trivial (a page break before a table's first row moves the table; emphasis marks drawn with room in the line); previously trivial (text in a turned text box turns with it; previously trivial: an in-line picture, shape or chart moves with its run's raise or lowering, Word's `w:position`; previously trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (added layout.fitOnePage to condense a multi-page document by one page while preserving typography hierarchy); previously trivial (from the second section on, headers, footers and note separators are measured with their own section's document grid and SECTION/SECTIONPAGES values, #4683); previously trivial (a turned inline picture whose file keeps no room for the turn keeps its line to its frame, the frame turned a quarter in Word 2013+ layout); previously trivial (a picture lowered further than its own height lowers its line as far, its top on the baseline); previously trivial (a float anchored right after a page break, before text, is drawn on the new page, #4262); previously trivial (a float anchored on a later page of a long paragraph is drawn there, #4250); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a line of only pictures is as tall as they reach, never less than its font's height; a legacy check box sizes its line as text of its size, #3984); previously trivial (spaces before a line's first character keep their width in justified and distributed lines, as in Word); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (Times and Courier take their Windows counterparts' line spacing; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial (a click just outside a floating table, by a cell whose text is turned, goes to the text beside or after it, #4181; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial (PR #4545: eligible cell floats keep in-bounds offsets; overflow confinement retains the front-of-text, turned-cell and native-acceptance limits); previously trivial (MATH construction reuse, #3630; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial (ODT documents: a line of only pictures keeps its font's height; no setting for it any more); previously trivial (ODT documents: padding is the standard's box model always; picture-only line height is an ODF interpretation setting); previously trivial (行頭禁則 also covers the small kana and the prolonged sound mark at ordinary break opportunities, as JIS X 4051 §4 specifies, inferred from the standard and synthetic tests rather than checked in Word); previously trivial (a missing font is drawn in an installed metric twin before any other substitute: Arial for Helvetica on Windows, Tinos before macOS Times for Times New Roman); previously trivial (ODT documents: padding without a border takes room); previously trivial (ODT documents: a line of only pictures is as tall as they are); previously trivial (ODT documents: a table's own space above and below; header and footer spacing kept past a tall header); previously trivial (a page break before a table's first row moves the table; emphasis marks drawn with room in the line); previously trivial (text in a turned text box turns with it; previously trivial: an in-line picture, shape or chart moves with its run's raise or lowering, Word's `w:position`; previously trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Floating anchor row segments:** 2026-11-07 · A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Readiness estimates unchanged. @@ -527,6 +527,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-11-02 | trivial | For ODT documents, LibreOffice's layout: a line holding only in-line pictures is as tall as they are, without its font's ascent and descent (`Settings::odf_layout`, set when an ODT opens; Word keeps the font's, so other documents are unchanged). Measured in LibreOffice: a 3.6 pt rule's line is 3.6 pt; WordCraft's had been 15.5 pt (`tests::a_line_of_only_pictures_fits_them_in_odt_documents`) | | 2026-11-02 | trivial | Beyond Word, for ODT documents: a table's own space above and below (`TableProps::space_before`/`space_after`) moves the text around it, and a header (footer) taller than its margin keeps its spacing between it and the body (`SectionProps::header_gap`/`footer_gap`; Word keeps none, so DOCX documents are unchanged) (`tests::a_tables_own_space_above_and_below_moves_the_text`, `tests::body_starts_right_below_a_tall_header`) | | 2026-11-02 | trivial | A page break before the paragraph in a table's first row moves the whole table to the next page, as in Word (it was ignored); emphasis marks (Font › Emphasis mark) are drawn over or under each character and make the line taller | +| 2026-11-12 | minor | Added layout.fitOnePage: heuristic search over discrete font half-point steps and spacing compression with anti-clutter safeguards (line-spacing floor and font-size floor) to condense multi-page documents by one page while preserving hierarchy | Maintenance on 2026-10-12 retains current tracked-deletion, endnote, footer-total, text-box and balloon layout while supplying explicit cell identity at every box call. The native Word diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 998d62e09c..a5b2332e76 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,18 +2,18 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**508 of 520 catalog features have commands (97.7%).** +**510 of 522 catalog features have commands (97.7%).** | Tab | Live | Total | |---|---|---| | File | 23 | 23 | | Quick Access | 5 | 5 | | Title Bar | 1 | 1 | -| Home | 104 | 105 | +| Home | 105 | 106 | | Insert | 47 | 53 | | Draw | 10 | 11 | | Design | 10 | 10 | -| Layout | 22 | 22 | +| Layout | 23 | 23 | | References | 25 | 25 | | Mailings | 23 | 23 | | Review | 29 | 31 | From ed09a2158f9d81d432a0d622f360707f81c4719b Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Thu, 12 Nov 2026 15:01:22 -0300 Subject: [PATCH 02/12] M2.1: Fix Fit One Page review findings (#6433) - Scale paragraph spacing without rounding a small value to zero or compressing past the requested factor. - Detect resolved page_break_before and nested table paragraphs as explicit breaks, and refuse before evaluating candidates. - Count non-text body items in the final-page volume limit. - Give the ribbon button an accessible label and test it through the rendered Layout ribbon in both themes. --- crates/doc/src/shrink.rs | 33 ++-- crates/engine/src/cmd/page/fit_page.rs | 211 ++++++++++++++++-------- crates/engine/src/tests/fit_one_page.rs | 19 +++ crates/ui-egui/src/ribbon/layout.rs | 4 +- crates/ui-egui/src/widgets/tests.rs | 16 +- 5 files changed, 194 insertions(+), 89 deletions(-) diff --git a/crates/doc/src/shrink.rs b/crates/doc/src/shrink.rs index 9cef2d5081..3de611230f 100644 --- a/crates/doc/src/shrink.rs +++ b/crates/doc/src/shrink.rs @@ -50,12 +50,14 @@ fn scale_size(size: f32, delta_pt: f32) -> f32 { /// Scale a spacing value (e.g. `space_before`, `space_after`) by `space_factor`. /// -/// Rounds to half-points while capping at the original nonnegative spacing value so reduction never increases spacing. +/// Caps the result at the original nonnegative spacing value and rounds down to hundredths (with a +/// tiny epsilon for floating-point noise), so a trial never removes more spacing than its factor +/// permits and never increases spacing. #[inline] fn scale_spacing(val: f32, space_factor: f32) -> f32 { let non_neg = val.max(0.0); let scaled = (non_neg * space_factor).clamp(0.0, 1584.0); - ((scaled * 2.0).round() / 2.0).min(non_neg) + ((scaled * 100.0 + 1e-4).floor() / 100.0).min(non_neg) } /// Scale fixed line spacing in points respecting the safe readability floor and rounding upward to half-points. @@ -484,14 +486,24 @@ mod tests { fit_document_typography(&mut doc, 0.5, 0.9); let p_after = doc.body[0].as_para().unwrap(); - // 18.0 * 0.9 = 16.2 -> round to 0.5 pt = 16.0 - assert_eq!(p_after.props.space_before, Some(16.0)); - // 12.0 * 0.9 = 10.8 -> round to 0.5 pt = 11.0 - assert_eq!(p_after.props.space_after, Some(11.0)); + // 18.0 * 0.9 = 16.2, capped at the requested reduction + assert_eq!(p_after.props.space_before, Some(16.2)); + // 12.0 * 0.9 = 10.8, capped at the requested reduction + assert_eq!(p_after.props.space_after, Some(10.8)); // 1.15 * 0.9 = 1.035 -> clamped to safe line spacing floor (1.15 * 0.94 = 1.081) -> ceil hundredth 1.09 assert_eq!(p_after.props.line_spacing, Some(LineSpacing::Multiple(1.09))); } + #[test] + fn spacing_scaling_never_exceeds_requested_compression() { + // 0.26 * 0.94 = 0.2444 pt: it must not round down to zero and remove all spacing. + let tiny = scale_spacing(0.26, 0.94); + assert!(tiny > 0.0 && tiny <= 0.26, "tiny spacing must shrink without vanishing: {tiny}"); + // 18.0 * 0.9 = 16.2 pt: the reduction stays at the requested 1.8 pt. + assert_eq!(scale_spacing(18.0, 0.9), 16.2); + assert_eq!(scale_spacing(0.0, 0.9), 0.0); + } + #[test] fn test_line_spacing_floor_prevents_overlap() { let mut doc = Document::new(); @@ -675,11 +687,10 @@ mod tests { #[test] fn test_scale_spacing_never_increases_value() { - // Spacing 0.3 pt scaled by 0.94 yields 0.282 pt. - // Rounding to half-points would yield 0.5 pt, but capping at 0.3 pt ensures no increase. - assert_eq!(scale_spacing(0.3, 0.94), 0.3); - // Regular spacing: 12.0 pt * 0.94 = 11.28 pt -> rounds to 11.5 pt <= 12.0 pt. - assert_eq!(scale_spacing(12.0, 0.94), 11.5); + // Spacing 0.3 pt scaled by 0.94 yields 0.282 pt, rounded down to 0.28 and never above 0.3 pt. + assert_eq!(scale_spacing(0.3, 0.94), 0.28); + // 12.0 pt * 0.94 = 11.28 pt, capped at the requested reduction. + assert_eq!(scale_spacing(12.0, 0.94), 11.28); // Zero spacing remains zero. assert_eq!(scale_spacing(0.0, 0.94), 0.0); } diff --git a/crates/engine/src/cmd/page/fit_page.rs b/crates/engine/src/cmd/page/fit_page.rs index 52bf7128ff..ae18d23327 100644 --- a/crates/engine/src/cmd/page/fit_page.rs +++ b/crates/engine/src/cmd/page/fit_page.rs @@ -7,10 +7,10 @@ use std::sync::Arc; use serde_json::Value; -use wordcraft_doc::Document; use wordcraft_doc::section::SectionStart; use wordcraft_doc::shrink::fit_document_typography; -use wordcraft_layout::{LayoutCache, LayoutOptions, Placed, ViewMode}; +use wordcraft_doc::{Document, ParaProps, StoryRef}; +use wordcraft_layout::{LayoutCache, LayoutOptions, Page, Placed, ViewMode}; use crate::cmd::sel_result; use crate::{CmdError, CmdResult, Session, p}; @@ -133,6 +133,29 @@ fn min_blocks_font_size( min_sz } +/// Returns true when these blocks contain an explicit page break or a resolved `page_break_before`, +/// including inside table cells (and nested tables). +fn blocks_have_explicit_breaks(doc: &Document, blocks: &[Arc], table_para: Option<&ParaProps>) -> bool { + for b in blocks { + if let Some(p) = b.as_para() { + if p.text.contains(wordcraft_doc::para::PAGE_BREAK) || doc.styles.resolve_para_in(&p.props, table_para).page_break_before { + return true; + } + } else if let Some(tbl) = b.as_table() { + let style = tbl.props.style.as_deref().and_then(|id| doc.styles.table_style(id)); + let cell_para = style.as_ref().map(|st| &st.para); + for row in &tbl.rows { + for cell in &row.cells { + if blocks_have_explicit_breaks(doc, &cell.blocks, cell_para) { + return true; + } + } + } + } + } + false +} + /// Returns true if the document contains explicit page or section breaks that /// physically force content onto additional pages. fn has_explicit_breaks(doc: &Document) -> bool { @@ -144,26 +167,7 @@ fn has_explicit_breaks(doc: &Document) -> bool { } } } - for b in &doc.body { - if let Some(p) = b.as_para() - && p.text.contains(wordcraft_doc::para::PAGE_BREAK) - { - return true; - } else if let Some(tbl) = b.as_table() { - for row in &tbl.rows { - for cell in &row.cells { - for cb in &cell.blocks { - if let Some(p) = cb.as_para() - && p.text.contains(wordcraft_doc::para::PAGE_BREAK) - { - return true; - } - } - } - } - } - } - false + blocks_have_explicit_breaks(doc, &doc.body, None) } /// True when `item` is a line-number decoration: a `DECOR_STORY` line with no block path. @@ -204,6 +208,59 @@ fn union_height(spans: &[(f32, f32)]) -> f32 { total } +/// The story an item belongs to, when it carries one. +fn item_story(item: &Placed) -> Option<&StoryRef> { + match item { + Placed::Lines { story, .. } + | Placed::Cell { story, .. } + | Placed::Image { story, .. } + | Placed::Graphic { story, .. } + | Placed::Object { story, .. } => Some(story), + Placed::Shape { object, .. } => object.as_ref().map(|pos| &pos.story), + Placed::Fill { .. } | Placed::Rule { .. } | Placed::Clip { .. } => None, + } +} + +/// Body line count and occupied body height on the final page, for the pre-flight volume limit. +/// +/// Line-number decorations sit outside the body column and note content sits at the page's foot, so +/// neither counts as body spill. The remaining items contribute the union of their vertical spans: +/// a tall image or an image-only table cell counts at its real height, and side-by-side cells +/// count once. +fn final_page_volume(doc: &Document, page: &Page) -> (usize, f32) { + let mut last_page_lines = 0usize; + let mut body_spans: Vec<(f32, f32)> = Vec::new(); + let mut note_spans: Vec<(f32, f32)> = Vec::new(); + for item in &page.items { + if is_line_number_decoration(item) { + continue; + } + let is_note_separator = matches!(item, Placed::Lines { story, .. } if *story == wordcraft_layout::DECOR_STORY); + let is_note = item_story(item).is_some_and(|story| is_footnote_story(doc, story)); + if is_note || is_note_separator { + note_spans.push(item.vertical_span()); + continue; + } + if let Placed::Lines { l0, l1, .. } = item { + last_page_lines = last_page_lines.saturating_add(l1.saturating_sub(*l0)); + } + if !matches!(item, Placed::Fill { .. } | Placed::Rule { .. } | Placed::Clip { .. }) { + body_spans.push(item.vertical_span()); + } + } + let body_spill_h = union_height(&body_spans); + let note_h = union_height(¬e_spans); + let body_area_h = (page.body.h - note_h).max(0.0); + let fill_ratio = if body_area_h > 0.0 { + body_spill_h / body_area_h + } else if body_spill_h > 0.0 { + 1.0 + } else { + 0.0 + }; + (last_page_lines, fill_ratio) +} + /// Calculates the minimum effective body font size in points across the document body and editable text boxes. /// /// When `hide_deleted` is true, deleted text runs hidden in Print view are skipped. @@ -515,52 +572,15 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { return Err(CmdError::Failed("The document already fits on one page.".into())); } - // 4. Pre-flight check on final page content volume - // - // Line-number decorations sit outside the body column, and note separators belong to the - // notes at the page's foot: neither is body overflow. Footnotes can be condensed too, so - // their occupied height (the union of their items, side-by-side table cells counted once) - // reduces the body area available on the page rather than counting as spilled body text. - let last_page = &initial_layout.pages[p0 - 1]; - let mut last_page_lines = 0usize; - let mut body_min_y = f32::INFINITY; - let mut body_max_y: f32 = 0.0; - let mut note_spans: Vec<(f32, f32)> = Vec::new(); - for item in &last_page.items { - if is_line_number_decoration(item) { - continue; - } - let is_note_separator = matches!(item, Placed::Lines { story, .. } if *story == wordcraft_layout::DECOR_STORY); - let is_note = matches!( - item, - Placed::Lines { story, .. } - | Placed::Cell { story, .. } - | Placed::Image { story, .. } - | Placed::Graphic { story, .. } - | Placed::Object { story, .. } - if is_footnote_story(&s.doc, story) - ); - if is_note || is_note_separator { - note_spans.push(item.vertical_span()); - continue; - } - if let Placed::Lines { l0, l1, y, para, .. } = item { - last_page_lines = last_page_lines.saturating_add(l1.saturating_sub(*l0)); - let h: f32 = para.lines.get(*l0..*l1).map(|slice| slice.iter().map(|l| l.height).sum()).unwrap_or(0.0); - body_min_y = body_min_y.min(*y); - body_max_y = body_max_y.max(y + h); - } + // Explicit breaks force pages regardless of typography; refuse before any trial can appear to + // succeed by moving text around them. + if has_explicit_breaks(&s.doc) { + return Err(CmdError::Failed("Unable to fit document to one page: explicit page or section breaks prevent page reduction.".into())); } - let body_spill_h = if body_max_y > body_min_y { body_max_y - body_min_y } else { 0.0 }; - let note_h = union_height(¬e_spans); - let body_area_h = (last_page.body.h - note_h).max(0.0); - let fill_ratio = if body_area_h > 0.0 { - body_spill_h / body_area_h - } else if body_spill_h > 0.0 { - 1.0 - } else { - 0.0 - }; + + // 4. Pre-flight check on final page content volume + let last_page = &initial_layout.pages[p0 - 1]; + let (last_page_lines, fill_ratio) = final_page_volume(&s.doc, last_page); if last_page_lines > 12 || fill_ratio > 0.30 { return Err(CmdError::Failed( "Unable to fit document to one page: content on the final page exceeds limits for aesthetic page reduction.".into(), @@ -636,9 +656,7 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } _ => { - if has_explicit_breaks(&s.doc) { - Err(CmdError::Failed("Unable to fit document to one page: explicit page or section breaks prevent page reduction.".into())) - } else if min_body_font_size(&s.doc, hide_deleted, shown_hidden) <= MIN_COMFORTABLE_BODY_FONT_SIZE { + if min_body_font_size(&s.doc, hide_deleted, shown_hidden) <= MIN_COMFORTABLE_BODY_FONT_SIZE { Err(CmdError::Failed("Unable to shrink document further without reducing text beyond readability limits.".into())) } else { Err(CmdError::Failed("Unable to fit document to one page: no permitted typography adjustment could remove a page.".into())) @@ -668,4 +686,57 @@ mod tests { // Malformed input (reversed, NaN, infinite) is ignored rather than poisoning the total. assert_eq!(union_height(&[(10.0, 5.0), (f32::NAN, 1.0), (0.0, f32::INFINITY), (2.0, 4.0)]), 2.0); } + + #[test] + fn final_page_volume_counts_non_text_body_items() { + let page = Page { + body: wordcraft_geom::Rect::new(0.0, 0.0, 500.0, 700.0), + items: vec![Placed::Cell { + rect: wordcraft_geom::Rect::new(0.0, 0.0, 500.0, 300.0), + covered: false, + table: wordcraft_doc::Path::top(0), + row: 0, + cell: 0, + story: StoryRef::Body, + clip: false, + }], + ..Default::default() + }; + let (lines, ratio) = final_page_volume(&Document::new(), &page); + assert_eq!(lines, 0); + assert!(ratio > 0.30, "a 300 pt image-only cell on a 700 pt body must count: {ratio}"); + } + + #[test] + fn explicit_breaks_include_resolved_page_break_before_and_nested_tables() { + let mut doc = Document::new(); + doc.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("body", Default::default()))]; + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { + p.props.page_break_before = Some(true); + } + assert!(has_explicit_breaks(&doc), "a direct page_break_before is an explicit break"); + + let mut doc = Document::new(); + doc.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("body", Default::default()))]; + let style = wordcraft_doc::Style { + id: "BreakBefore".into(), + name: "Break Before".into(), + kind: wordcraft_doc::StyleKind::Paragraph, + para: ParaProps { page_break_before: Some(true), ..Default::default() }, + ..Default::default() + }; + Arc::make_mut(&mut doc.styles).upsert(style); + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { + p.props.style = Some("BreakBefore".into()); + } + assert!(has_explicit_breaks(&doc), "a style-inherited page_break_before is an explicit break"); + + let mut doc = Document::new(); + let mut table = wordcraft_doc::Table::new(1, 1, 100.0); + let mut cell_p = wordcraft_doc::Paragraph::with_text("cell", Default::default()); + cell_p.props.page_break_before = Some(true); + table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cell_p)]; + doc.body = vec![Arc::new(wordcraft_doc::Block::Table(table))]; + assert!(has_explicit_breaks(&doc), "a page_break_before inside a table cell is an explicit break"); + } } diff --git a/crates/engine/src/tests/fit_one_page.rs b/crates/engine/src/tests/fit_one_page.rs index d875b4beb4..93020dae8f 100644 --- a/crates/engine/src/tests/fit_one_page.rs +++ b/crates/engine/src/tests/fit_one_page.rs @@ -37,6 +37,25 @@ fn fit_one_page_fails_on_hard_page_break() { assert_eq!(s.doc, before, "document must not change on failure"); } +#[test] +fn fit_one_page_refuses_page_break_before_before_search() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[1]) { + p.props.page_break_before = Some(true); + } + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + let before = s.doc.clone(); + + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("explicit page or section breaks"), "page_break_before must be reported as an explicit break: {err}"); + assert_eq!(s.doc, before, "document must not change on refusal"); +} + #[test] fn fit_one_page_fails_on_next_page_section_break() { let mut s = Session::new(Document::from_text("First section body text")); diff --git a/crates/ui-egui/src/ribbon/layout.rs b/crates/ui-egui/src/ribbon/layout.rs index c2ff99857b..8b457c8d5b 100644 --- a/crates/ui-egui/src/ribbon/layout.rs +++ b/crates/ui-egui/src/ribbon/layout.rs @@ -119,7 +119,9 @@ pub(super) fn layout(app: &mut WordApp, ui: &mut Ui) { }, ); }); - big(ui, app, "fitOnePage", "Fit\nOne Page", "layout.fitOnePage", json!({}), false); + let fit_resp = big(ui, app, "fitOnePage", "Fit\nOne Page", "layout.fitOnePage", json!({}), false); + let fit_label = tl!("Fit\nOne Page").replace('\n', " "); + fit_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, fit_label.clone())); }); group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| { let rp = app.session.doc.para_at(&app.session.sel.focus).map(|_| wordcraft_engine::cmd::para::selection_para(&app.session)); diff --git a/crates/ui-egui/src/widgets/tests.rs b/crates/ui-egui/src/widgets/tests.rs index 1e01352025..971ecbc52c 100644 --- a/crates/ui-egui/src/widgets/tests.rs +++ b/crates/ui-egui/src/widgets/tests.rs @@ -555,20 +555,22 @@ fn dialog_tabs_expose_the_selected_state_and_report_clicks() { /// Fit One Page button renders in the Layout ribbon across light and dark themes. #[test] fn layout_fit_one_page_button_renders_in_light_and_dark_themes() { + use egui_kittest::kittest::Queryable; for theme in [crate::theme::Appearance::Light, crate::theme::Appearance::Dark] { - let a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + app.ui.tab = "Layout".to_string(); + app.ui.theme = theme; let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( |ui, app: &mut WordApp| { - app.ui.tab = "Layout".to_string(); - app.ui.theme = theme; let ctx = ui.ctx().clone(); app.logic(&ctx); - let resp = crate::widgets::buttons::big(ui, app, "fitOnePage", "Fit\nOne Page", "layout.fitOnePage", serde_json::json!({}), false); - assert!(resp.rect.width() >= 40.0, "button must have allocated width in {theme:?}"); - assert!(resp.rect.height() >= 40.0, "button must have allocated height in {theme:?}"); + app.ui(ui); }, - a, + app, ); h.run_steps(4); + let button = h.get_by_label("Fit One Page"); + assert!(button.rect().width() >= 40.0, "button must have allocated width in {theme:?}"); + assert!(button.rect().height() >= 40.0, "button must have allocated height in {theme:?}"); } } From d87e6f793060f0c90c53eaeb8463090a5e351627 Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Thu, 12 Nov 2026 15:14:13 -0300 Subject: [PATCH 03/12] M2.1: Fix remaining Fit One Page review findings (#6433) - Round paragraph spacing up to hundredths, so a very small value is never reduced by more than the requested factor. - Ignore page-break characters in hidden or tracked-deleted runs, matching Print layout visibility, so a hidden break cannot force a refusal. --- crates/doc/src/shrink.rs | 12 ++-- crates/engine/src/cmd/page/fit_page.rs | 87 ++++++++++++++++++++++---- 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/crates/doc/src/shrink.rs b/crates/doc/src/shrink.rs index 3de611230f..8b25987460 100644 --- a/crates/doc/src/shrink.rs +++ b/crates/doc/src/shrink.rs @@ -50,14 +50,14 @@ fn scale_size(size: f32, delta_pt: f32) -> f32 { /// Scale a spacing value (e.g. `space_before`, `space_after`) by `space_factor`. /// -/// Caps the result at the original nonnegative spacing value and rounds down to hundredths (with a +/// Caps the result at the original nonnegative spacing value and rounds up to hundredths (with a /// tiny epsilon for floating-point noise), so a trial never removes more spacing than its factor -/// permits and never increases spacing. +/// permits and never increases spacing. Rounding down would over-compress very small values. #[inline] fn scale_spacing(val: f32, space_factor: f32) -> f32 { let non_neg = val.max(0.0); let scaled = (non_neg * space_factor).clamp(0.0, 1584.0); - ((scaled * 100.0 + 1e-4).floor() / 100.0).min(non_neg) + ((scaled * 100.0 - 1e-4).ceil() / 100.0).min(non_neg) } /// Scale fixed line spacing in points respecting the safe readability floor and rounding upward to half-points. @@ -499,6 +499,8 @@ mod tests { // 0.26 * 0.94 = 0.2444 pt: it must not round down to zero and remove all spacing. let tiny = scale_spacing(0.26, 0.94); assert!(tiny > 0.0 && tiny <= 0.26, "tiny spacing must shrink without vanishing: {tiny}"); + // 0.019 * 0.94 = 0.01786 pt: rounding down to hundredths would remove 47%, so round up. + assert_eq!(scale_spacing(0.019, 0.94), 0.019); // 18.0 * 0.9 = 16.2 pt: the reduction stays at the requested 1.8 pt. assert_eq!(scale_spacing(18.0, 0.9), 16.2); assert_eq!(scale_spacing(0.0, 0.9), 0.0); @@ -687,8 +689,8 @@ mod tests { #[test] fn test_scale_spacing_never_increases_value() { - // Spacing 0.3 pt scaled by 0.94 yields 0.282 pt, rounded down to 0.28 and never above 0.3 pt. - assert_eq!(scale_spacing(0.3, 0.94), 0.28); + // Spacing 0.3 pt scaled by 0.94 yields 0.282 pt, rounded up to 0.29 and never above 0.3 pt. + assert_eq!(scale_spacing(0.3, 0.94), 0.29); // 12.0 pt * 0.94 = 11.28 pt, capped at the requested reduction. assert_eq!(scale_spacing(12.0, 0.94), 11.28); // Zero spacing remains zero. diff --git a/crates/engine/src/cmd/page/fit_page.rs b/crates/engine/src/cmd/page/fit_page.rs index ae18d23327..e5eae21f44 100644 --- a/crates/engine/src/cmd/page/fit_page.rs +++ b/crates/engine/src/cmd/page/fit_page.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use serde_json::Value; use wordcraft_doc::section::SectionStart; use wordcraft_doc::shrink::fit_document_typography; -use wordcraft_doc::{Document, ParaProps, StoryRef}; +use wordcraft_doc::{CharProps, Document, ParaProps, StoryRef}; use wordcraft_layout::{LayoutCache, LayoutOptions, Page, Placed, ViewMode}; use crate::cmd::sel_result; @@ -133,20 +133,63 @@ fn min_blocks_font_size( min_sz } -/// Returns true when these blocks contain an explicit page break or a resolved `page_break_before`, -/// including inside table cells (and nested tables). -fn blocks_have_explicit_breaks(doc: &Document, blocks: &[Arc], table_para: Option<&ParaProps>) -> bool { +/// True when a paragraph has a page-break character in a run that Print layout draws. +fn paragraph_has_visible_page_break( + doc: &Document, + p: &wordcraft_doc::para::Paragraph, + table_chr: Option<&CharProps>, + hide_deleted: bool, + show_hidden: bool, +) -> bool { + let style_id = p.props.style.as_deref(); + if p.runs.is_empty() { + let mark = doc.styles.resolve_char_in(style_id, table_chr, &p.mark); + let visible = !(mark.hidden && !show_hidden) && !(hide_deleted && p.mark.del.is_some()); + return visible && p.text.contains(wordcraft_doc::para::PAGE_BREAK); + } + let mut run_start = 0usize; + for r in &p.runs { + let run_end = run_start.saturating_add(r.len).min(p.text.len()); + let run_text = p.text.get(run_start..run_end).unwrap_or(""); + run_start = run_end; + if hide_deleted && r.props.del.is_some() { + continue; + } + let resolved = doc.styles.resolve_char_in(style_id, table_chr, &r.props); + if resolved.hidden && !show_hidden { + continue; + } + if run_text.contains(wordcraft_doc::para::PAGE_BREAK) { + return true; + } + } + false +} + +/// Returns true when these blocks contain a visible explicit page break or a resolved +/// `page_break_before`, including inside table cells (and nested tables). +fn blocks_have_explicit_breaks( + doc: &Document, + blocks: &[Arc], + table_para: Option<&ParaProps>, + table_chr: Option<&CharProps>, + hide_deleted: bool, + show_hidden: bool, +) -> bool { for b in blocks { if let Some(p) = b.as_para() { - if p.text.contains(wordcraft_doc::para::PAGE_BREAK) || doc.styles.resolve_para_in(&p.props, table_para).page_break_before { + if doc.styles.resolve_para_in(&p.props, table_para).page_break_before + || paragraph_has_visible_page_break(doc, p, table_chr, hide_deleted, show_hidden) + { return true; } } else if let Some(tbl) = b.as_table() { let style = tbl.props.style.as_deref().and_then(|id| doc.styles.table_style(id)); let cell_para = style.as_ref().map(|st| &st.para); + let cell_chr = style.as_ref().map(|st| &st.chr); for row in &tbl.rows { for cell in &row.cells { - if blocks_have_explicit_breaks(doc, &cell.blocks, cell_para) { + if blocks_have_explicit_breaks(doc, &cell.blocks, cell_para, cell_chr, hide_deleted, show_hidden) { return true; } } @@ -158,7 +201,7 @@ fn blocks_have_explicit_breaks(doc: &Document, blocks: &[Arc bool { +fn has_explicit_breaks(doc: &Document, hide_deleted: bool, show_hidden: bool) -> bool { let sections = doc.sections(); if sections.len() > 1 { for (_, sec) in §ions[1..] { @@ -167,7 +210,7 @@ fn has_explicit_breaks(doc: &Document) -> bool { } } } - blocks_have_explicit_breaks(doc, &doc.body, None) + blocks_have_explicit_breaks(doc, &doc.body, None, None, hide_deleted, show_hidden) } /// True when `item` is a line-number decoration: a `DECOR_STORY` line with no block path. @@ -574,7 +617,7 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { // Explicit breaks force pages regardless of typography; refuse before any trial can appear to // succeed by moving text around them. - if has_explicit_breaks(&s.doc) { + if has_explicit_breaks(&s.doc, hide_deleted, shown_hidden) { return Err(CmdError::Failed("Unable to fit document to one page: explicit page or section breaks prevent page reduction.".into())); } @@ -714,7 +757,7 @@ mod tests { if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { p.props.page_break_before = Some(true); } - assert!(has_explicit_breaks(&doc), "a direct page_break_before is an explicit break"); + assert!(has_explicit_breaks(&doc, false, false), "a direct page_break_before is an explicit break"); let mut doc = Document::new(); doc.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("body", Default::default()))]; @@ -729,7 +772,7 @@ mod tests { if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { p.props.style = Some("BreakBefore".into()); } - assert!(has_explicit_breaks(&doc), "a style-inherited page_break_before is an explicit break"); + assert!(has_explicit_breaks(&doc, false, false), "a style-inherited page_break_before is an explicit break"); let mut doc = Document::new(); let mut table = wordcraft_doc::Table::new(1, 1, 100.0); @@ -737,6 +780,26 @@ mod tests { cell_p.props.page_break_before = Some(true); table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cell_p)]; doc.body = vec![Arc::new(wordcraft_doc::Block::Table(table))]; - assert!(has_explicit_breaks(&doc), "a page_break_before inside a table cell is an explicit break"); + assert!(has_explicit_breaks(&doc, false, false), "a page_break_before inside a table cell is an explicit break"); + } + + #[test] + fn hidden_and_deleted_page_breaks_follow_print_visibility() { + let text = format!("before{}after", wordcraft_doc::para::PAGE_BREAK); + let mut doc = Document::new(); + doc.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, Default::default()))]; + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { + p.runs[0].props.hidden = Some(true); + } + assert!(!has_explicit_breaks(&doc, false, false), "hidden break is omitted in Print view"); + assert!(has_explicit_breaks(&doc, false, true), "hidden break counts when hidden marks are shown"); + + let mut doc = Document::new(); + doc.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, Default::default()))]; + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { + p.runs[0].props.del = Some(0); + } + assert!(!has_explicit_breaks(&doc, true, false), "deleted break is omitted while markup is hidden"); + assert!(has_explicit_breaks(&doc, false, false), "deleted break counts when markup is shown"); } } From b71f257f1bdb55edcb9b98799b596f5fb42d60ef Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Thu, 12 Nov 2026 15:24:10 -0300 Subject: [PATCH 04/12] M2.1: Match table region formatting in Fit One Page break scan (#6433) Resolve each cell's conditional table-style character formatting before checking whether a page break is hidden, matching min_blocks_font_size and layout's region selection. A first-row or banded hidden override now agrees with Print layout. Add a regression with a hidden first-row override. --- crates/engine/src/cmd/page/fit_page.rs | 45 +++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/page/fit_page.rs b/crates/engine/src/cmd/page/fit_page.rs index e5eae21f44..bd3fb5ef73 100644 --- a/crates/engine/src/cmd/page/fit_page.rs +++ b/crates/engine/src/cmd/page/fit_page.rs @@ -184,12 +184,24 @@ fn blocks_have_explicit_breaks( return true; } } else if let Some(tbl) = b.as_table() { + use wordcraft_doc::styles::PartSet; + let style = tbl.props.style.as_deref().and_then(|id| doc.styles.table_style(id)); let cell_para = style.as_ref().map(|st| &st.para); - let cell_chr = style.as_ref().map(|st| &st.chr); - for row in &tbl.rows { - for cell in &row.cells { - if blocks_have_explicit_breaks(doc, &cell.blocks, cell_para, cell_chr, hide_deleted, show_hidden) { + let nrows = tbl.rows.len(); + let ncols = tbl.cols(); + for (ri, row) in tbl.rows.iter().enumerate() { + for (ci, cell) in row.cells.iter().enumerate() { + let mut cell_chr = CharProps::default(); + if let Some(st) = &style { + let sizes = (st.parts.band_size.unwrap_or(0), st.parts.col_band_size.unwrap_or(0)); + let region = PartSet::of_cell(&tbl.props.look, sizes, (ri, nrows), (tbl.grid_col(ri, ci), cell.span(), ncols)); + cell_chr.overlay(&st.chr); + for part in region.iter() { + cell_chr.overlay(&st.part(part).chr); + } + } + if blocks_have_explicit_breaks(doc, &cell.blocks, cell_para, Some(&cell_chr), hide_deleted, show_hidden) { return true; } } @@ -802,4 +814,29 @@ mod tests { assert!(!has_explicit_breaks(&doc, true, false), "deleted break is omitted while markup is hidden"); assert!(has_explicit_breaks(&doc, false, false), "deleted break counts when markup is shown"); } + + #[test] + fn conditional_table_style_hidden_override_hides_page_break() { + let text = format!("before{}after", wordcraft_doc::para::PAGE_BREAK); + let mut table = wordcraft_doc::Table::new(1, 1, 100.0); + table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, Default::default()))]; + table.props.style = Some("HiddenHeader".into()); + + let mut doc = Document::new(); + doc.body = vec![Arc::new(wordcraft_doc::Block::Table(table))]; + let style = wordcraft_doc::Style { + id: "HiddenHeader".into(), + name: "Hidden Header".into(), + kind: wordcraft_doc::StyleKind::Table, + table: Some(wordcraft_doc::styles::TableStyleParts { + header_chr: CharProps { hidden: Some(true), ..Default::default() }, + ..Default::default() + }), + ..Default::default() + }; + Arc::make_mut(&mut doc.styles).upsert(style); + + assert!(!has_explicit_breaks(&doc, false, false), "a first-row hidden override must hide the break"); + assert!(has_explicit_breaks(&doc, false, true), "the hidden break counts when hidden marks are shown"); + } } From a45e37b90d153b5b6e2390064bd4ea0843d7741e Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Thu, 12 Nov 2026 15:42:08 -0300 Subject: [PATCH 05/12] M2.1: Fix Rust 1.100 Clippy lint in LaTeX table test (#6433) The gate's Clippy flags needless_range_loop in the borderless/bordered table fixtures. Iterate the rows and cells mutably instead; no behaviour change. --- crates/formats/src/latex/tests.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/formats/src/latex/tests.rs b/crates/formats/src/latex/tests.rs index 3097eaea1f..2b81895a9c 100644 --- a/crates/formats/src/latex/tests.rs +++ b/crates/formats/src/latex/tests.rs @@ -391,9 +391,9 @@ fn table_borders_and_borderless_round_trip() { let mut doc = Document::new(); let mut t_borderless = Table::new(2, 2, 400.0); t_borderless.props.style = None; - for r in 0..2 { - for c in 0..2 { - t_borderless.rows[r].cells[c].blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))]; + for row in &mut t_borderless.rows { + for cell in &mut row.cells { + cell.blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))]; } } doc.body = vec![Arc::new(Block::Table(t_borderless))]; @@ -407,9 +407,9 @@ fn table_borders_and_borderless_round_trip() { let mut doc_bordered = Document::new(); let mut t_bordered = Table::new(2, 2, 400.0); t_bordered.props.style = Some("TableGrid".to_string()); - for r in 0..2 { - for c in 0..2 { - t_bordered.rows[r].cells[c].blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))]; + for row in &mut t_bordered.rows { + for cell in &mut row.cells { + cell.blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))]; } } doc_bordered.body = vec![Arc::new(Block::Table(t_bordered))]; From 48d9c2a46c08b115e9da4d88a0d91df62e094672 Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Thu, 12 Nov 2026 15:57:34 -0300 Subject: [PATCH 06/12] M2.1: Fix remaining Rust 1.100 Clippy lints (#6433) Replace indexed row/cell loops with mutable iterators in the layout table tests and the table eraser test. Test-only; no behaviour change. --- crates/engine/src/cmd/table_draw.rs | 6 +++--- crates/layout/src/tests.rs | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/table_draw.rs b/crates/engine/src/cmd/table_draw.rs index a5102ea7cf..ed43933881 100644 --- a/crates/engine/src/cmd/table_draw.rs +++ b/crates/engine/src/cmd/table_draw.rs @@ -765,9 +765,9 @@ mod tests { s.run("table.eraser", &json!({"page": 0, "x": a.right() + 0.5, "y": a.y + a.h / 2.0})).unwrap(); let t = first_table(&s); assert_eq!(shape(&t), shape(&start), "no merge"); - for r in 0..2 { - let right = t.rows[r].cells[0].props.borders.and_then(|b| b.right); - let left = t.rows[r].cells[1].props.borders.and_then(|b| b.left); + for (r, row) in t.rows.iter().take(2).enumerate() { + let right = row.cells[0].props.borders.and_then(|b| b.right); + let left = row.cells[1].props.borders.and_then(|b| b.left); assert!(right.is_some_and(|b| !b.is_visible()) && left.is_some_and(|b| !b.is_visible()), "row {r}"); } assert!(t.rows[2].cells[0].props.borders.is_none(), "rows outside the edge are untouched"); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index b83e5bf3fb..a15f2f672b 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -8640,8 +8640,8 @@ fn picture_row(room: f32, min: f32, pic_h: f32, lines: usize) -> (DocLayout, Vec t.rows[1].props.height = Some(min); t.rows[1].props.height_rule = HeightRule::AtLeast; } - for c in 1..3 { - t.rows[1].cells[c].blocks = lines_in_cell(lines, "N/A"); + for cell in &mut t.rows[1].cells[1..3] { + cell.blocks = lines_in_cell(lines, "N/A"); } set_at_least_row_spacing(&mut t); let mut p = wordcraft_doc::Paragraph::with_text("", Default::default()); @@ -10327,8 +10327,8 @@ fn a_hidden_cell_mark_takes_no_room() { let mut d = Document::from_text("Above"); let mut t = Table::new(3, 2, 300.0); for (r, text) in [(0, "R1"), (2, "R3")] { - for c in 0..2 { - t.rows[r].cells[c].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))]; + for cell in &mut t.rows[r].cells { + cell.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))]; } } for (cell, hide) in t.rows[1].cells.iter_mut().zip(hide) { From 1c3839aa16dec797266b87e5b8e91d02e8945daf Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Thu, 12 Nov 2026 18:17:45 -0300 Subject: [PATCH 07/12] M2.1: Fix Fit One Page provenance and Print options (#6433) - Anchor cumulative budget provenance to the original sizes and spacings recorded before the first fit, instead of matching every current value against every recorded value. Restoring body and heading sizes now resets the budget. - Build the initial and trial layouts from the session's hide_marked, deletions_in_balloons and resolved numeral settings, so hidden or ballooned deletions and numeral shaping paginate as in Session::layout. - Add regressions for mixed body/heading sizes, mixed line spacings, and Session::layout parity under non-default visibility and numeral settings. --- crates/doc/src/shrink.rs | 6 + crates/engine/src/cmd/page/fit_page.rs | 221 +++++++++++++++++++------ 2 files changed, 173 insertions(+), 54 deletions(-) diff --git a/crates/doc/src/shrink.rs b/crates/doc/src/shrink.rs index 8b25987460..c943de737e 100644 --- a/crates/doc/src/shrink.rs +++ b/crates/doc/src/shrink.rs @@ -35,6 +35,12 @@ pub struct FitState { pub reduced_font_sizes: Vec, /// Unique effective line spacing values present immediately after the reduction was applied. pub reduced_line_spacings: Vec, + /// Unique effective body font sizes before the first Fit One Page reduction was applied. + #[serde(default)] + pub original_font_sizes: Vec, + /// Unique effective line spacing values before the first Fit One Page reduction was applied. + #[serde(default)] + pub original_line_spacings: Vec, } /// Scale a font size down by `delta_pt`. diff --git a/crates/engine/src/cmd/page/fit_page.rs b/crates/engine/src/cmd/page/fit_page.rs index bd3fb5ef73..35148d0132 100644 --- a/crates/engine/src/cmd/page/fit_page.rs +++ b/crates/engine/src/cmd/page/fit_page.rs @@ -458,33 +458,13 @@ fn collect_body_line_spacings(doc: &Document) -> Vec bool { - let mut font_reduced_present = false; - if fit.cum_delta > 0.0 { - let current_sizes = collect_body_font_sizes(doc); - for &cur in ¤t_sizes { - if fit.reduced_font_sizes.iter().any(|&red| cur <= red + 1e-4) { - font_reduced_present = true; - break; - } - } - } - - let mut space_reduced_present = false; - if fit.cum_space < 1.0 - 1e-4 { - let current_spacings = collect_body_line_spacings(doc); - for cur in ¤t_spacings { - if fit.reduced_line_spacings.iter().any(|red| match (cur, red) { - (wordcraft_doc::props::LineSpacing::Multiple(c), wordcraft_doc::props::LineSpacing::Multiple(r)) - | (wordcraft_doc::props::LineSpacing::AtLeast(c), wordcraft_doc::props::LineSpacing::AtLeast(r)) - | (wordcraft_doc::props::LineSpacing::Exactly(c), wordcraft_doc::props::LineSpacing::Exactly(r)) => *c <= *r + 1e-4, - _ => false, - }) { - space_reduced_present = true; - break; - } - } - } + let font_reduced_present = fit.cum_delta > 0.0 && font_reduction_present(doc, fit); + let space_reduced_present = fit.cum_space < 1.0 - 1e-4 && spacing_reduction_present(doc, fit); if fit.cum_delta > 0.0 && fit.cum_space < 1.0 - 1e-4 { font_reduced_present || space_reduced_present @@ -497,24 +477,55 @@ fn has_previously_reduced_text(doc: &Document, fit: &wordcraft_doc::shrink::FitS } } -/// Reads the cumulative Fit One Page reduction state from the document's internal fit tracking. -/// -/// Preserves the reduction budget when reopening a fitted document where `doc.fit_state` was serialized, -/// while maintaining the full budget for documents with no prior reductions. -fn cumulative_shrink_state(doc: &Document) -> (f32, f32) { - if let Some(fit) = &doc.fit_state - && has_previously_reduced_text(doc, fit) - { - return (fit.cum_delta, fit.cum_space); +fn font_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { + if fit.original_font_sizes.is_empty() { + return false; } - (0.0, 1.0) + let current_sizes = collect_body_font_sizes(doc); + current_sizes.iter().any(|&cur| { + fit.original_font_sizes.iter().any(|&orig| { + let floor = orig - fit.cum_delta - 1e-4; + cur >= floor && cur < orig - 1e-4 + }) + }) +} + +fn spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { + if fit.original_line_spacings.is_empty() { + return false; + } + let current_spacings = collect_body_line_spacings(doc); + current_spacings.iter().any(|cur| { + fit.original_line_spacings.iter().any(|orig| match (cur, orig) { + (wordcraft_doc::props::LineSpacing::Multiple(c), wordcraft_doc::props::LineSpacing::Multiple(o)) + | (wordcraft_doc::props::LineSpacing::AtLeast(c), wordcraft_doc::props::LineSpacing::AtLeast(o)) + | (wordcraft_doc::props::LineSpacing::Exactly(c), wordcraft_doc::props::LineSpacing::Exactly(o)) => { + let floor = o * fit.cum_space - 1e-4; + *c >= floor && *c < *o - 1e-4 + } + _ => false, + }) + }) } /// Records the cumulative Fit One Page reduction state into the document's internal fit tracking. -fn set_cumulative_shrink_state(doc: &mut Document, delta: f32, space: f32) { +fn set_cumulative_shrink_state( + doc: &mut Document, + delta: f32, + space: f32, + original_font_sizes: Vec, + original_line_spacings: Vec, +) { let sizes = collect_body_font_sizes(doc); let spacings = collect_body_line_spacings(doc); - doc.fit_state = Some(wordcraft_doc::FitState { cum_delta: delta, cum_space: space, reduced_font_sizes: sizes, reduced_line_spacings: spacings }); + doc.fit_state = Some(wordcraft_doc::FitState { + cum_delta: delta, + cum_space: space, + reduced_font_sizes: sizes, + reduced_line_spacings: spacings, + original_font_sizes, + original_line_spacings, + }); } /// Generates candidate font-reduction delta and line-spacing alpha pairs respecting parameter bounds. @@ -580,6 +591,26 @@ fn generate_candidate_steps(max_delta: f32, step_pt_opt: Option, adjust_spa candidates } +/// The Print options Fit One Page uses: the session's pagination-affecting visibility and numeral +/// settings, with proofing disabled because it does not change pagination. +fn fit_layout_options(s: &Session) -> LayoutOptions { + let (fn_name, fn_path) = s.resolved_filenames(); + LayoutOptions { + view: ViewMode::Print, + field_codes: s.view.field_codes, + show_hidden: s.view.marks || s.prefs.marks.hidden, + hide_deleted: !s.view.show_markup, + hide_marked: s.prefs.markup.hidden_deletions(), + deletions_in_balloons: s.revisions_in_balloons(), + proofing: false, + proof: wordcraft_layout::ProofOptions { grammar: false, spelling: Default::default() }, + numeral: s.view.numeral.resolve(crate::locale::system_locale()), + filename: Some(Arc::from(fn_name.as_str())), + filepath: Some(Arc::from(fn_path.as_str())), + ..LayoutOptions::default() + } +} + /// Executes the `layout.fitOnePage` command. /// /// Condenses the document by one page if feasible, preserving typographical @@ -597,26 +628,18 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { } // 1. Anti-compound guard: refuse if cumulative reduction already reached 1.0 pt or spacing factor <= 0.94 - let (cum_delta, cum_space) = cumulative_shrink_state(&s.doc); + let prior_fit = s.doc.fit_state.clone().filter(|fit| has_previously_reduced_text(&s.doc, fit)); + let (cum_delta, cum_space) = prior_fit.as_ref().map_or((0.0, 1.0), |fit| (fit.cum_delta, fit.cum_space)); if cum_delta >= 1.0 - 1e-4 || cum_space <= 0.94 + 1e-4 { return Err(CmdError::Failed("Unable to shrink document further without reducing text beyond readability limits.".into())); } // 2. Build equivalent Print layout options - let shown_hidden = s.view.marks || s.prefs.marks.hidden; - let hide_deleted = !s.view.show_markup; - let (fn_name, fn_path) = s.resolved_filenames(); - let print_opts = LayoutOptions { - view: ViewMode::Print, - field_codes: s.view.field_codes, - show_hidden: shown_hidden, - hide_deleted, - proofing: false, - proof: wordcraft_layout::ProofOptions { grammar: false, spelling: Default::default() }, - filename: Some(Arc::from(fn_name.as_str())), - filepath: Some(Arc::from(fn_path.as_str())), - ..LayoutOptions::default() - }; + let print_opts = fit_layout_options(s); + let shown_hidden = print_opts.show_hidden; + let hide_deleted = print_opts.hide_deleted; + let pre_font_sizes = collect_body_font_sizes(&s.doc); + let pre_line_spacings = collect_body_line_spacings(&s.doc); // 3. Initial page count evaluation let mut initial_cache = LayoutCache::new(); @@ -699,7 +722,11 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { // 8. Success commit or clean rollback match (successful_doc, applied_step) { (Some(mut new_doc), Some((d, a))) => { - set_cumulative_shrink_state(&mut new_doc, cum_delta + d, cum_space * a); + let original_font_sizes = + prior_fit.as_ref().map(|fit| fit.original_font_sizes.clone()).filter(|sizes| !sizes.is_empty()).unwrap_or(pre_font_sizes); + let original_line_spacings = + prior_fit.as_ref().map(|fit| fit.original_line_spacings.clone()).filter(|spacings| !spacings.is_empty()).unwrap_or(pre_line_spacings); + set_cumulative_shrink_state(&mut new_doc, cum_delta + d, cum_space * a, original_font_sizes, original_line_spacings); s.doc = new_doc; s.clamp_selection(); let page_word = if final_pages == 1 { "page" } else { "pages" }; @@ -839,4 +866,90 @@ mod tests { assert!(!has_explicit_breaks(&doc, false, false), "a first-row hidden override must hide the break"); assert!(has_explicit_breaks(&doc, false, true), "the hidden break counts when hidden marks are shown"); } + + #[test] + fn provenance_resets_when_body_and_heading_are_restored() { + let mut doc = Document::from_text("Alpha\nBeta\nGamma\nDelta"); + for (i, b) in doc.body.iter_mut().enumerate() { + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(b) { + let size = if i == 0 { 24.0 } else { 12.0 }; + for r in &mut p.runs { + r.props.size = Some(size); + } + } + } + let originals = collect_body_font_sizes(&doc); + let originals_spacings = collect_body_line_spacings(&doc); + wordcraft_doc::shrink::fit_document_typography(&mut doc, 1.0, 1.0); + set_cumulative_shrink_state(&mut doc, 1.0, 1.0, originals, originals_spacings); + + assert!(has_previously_reduced_text(&doc, doc.fit_state.as_ref().unwrap())); + // Restoring only the heading leaves the body reduced. + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { + for r in &mut p.runs { + r.props.size = Some(24.0); + } + } + assert!(has_previously_reduced_text(&doc, doc.fit_state.as_ref().unwrap())); + // Restoring the body too leaves no reduced text. + for b in doc.body.iter_mut().skip(1) { + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(b) { + for r in &mut p.runs { + r.props.size = Some(12.0); + } + } + } + assert!(!has_previously_reduced_text(&doc, doc.fit_state.as_ref().unwrap())); + } + + #[test] + fn provenance_resets_when_mixed_line_spacings_are_restored() { + use wordcraft_doc::props::LineSpacing; + + let mut doc = Document::from_text("Alpha\nBeta"); + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { + p.props.line_spacing = Some(LineSpacing::Multiple(1.0)); + } + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { + p.props.line_spacing = Some(LineSpacing::Multiple(2.0)); + } + let originals = collect_body_font_sizes(&doc); + let originals_spacings = collect_body_line_spacings(&doc); + wordcraft_doc::shrink::fit_document_typography(&mut doc, 0.0, 0.9); + set_cumulative_shrink_state(&mut doc, 0.0, 0.9, originals, originals_spacings); + + assert!(has_previously_reduced_text(&doc, doc.fit_state.as_ref().unwrap())); + // Restoring the 2.0 spacing but leaving the 1.0 one reduced keeps the budget. + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { + p.props.line_spacing = Some(LineSpacing::Multiple(2.0)); + } + assert!(has_previously_reduced_text(&doc, doc.fit_state.as_ref().unwrap())); + // Restoring both resets it. + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { + p.props.line_spacing = Some(LineSpacing::Multiple(1.0)); + } + assert!(!has_previously_reduced_text(&doc, doc.fit_state.as_ref().unwrap())); + } + + #[test] + fn fit_layout_options_match_session_visibility_and_numeral() { + let mut s = Session::new(Document::from_text("Alpha\nBeta")); + s.view.mode = ViewMode::Print; + s.view.show_markup = true; + s.prefs.markup.balloons = wordcraft_layout::display::BalloonMode::Revisions; + s.prefs.markup.insertions_deletions = true; + s.view.numeral = wordcraft_layout::NumeralMode::Hindi; + s.view.proofing = false; + s.prefs.mark_grammar = false; + + let opts = fit_layout_options(&s); + assert_eq!(opts.hide_deleted, !s.view.show_markup); + assert_eq!(opts.hide_marked, s.prefs.markup.hidden_deletions()); + assert_eq!(opts.deletions_in_balloons, s.revisions_in_balloons()); + assert_eq!(opts.numeral, s.view.numeral.resolve(crate::locale::system_locale())); + + let mut cache = LayoutCache::new(); + let fitted = wordcraft_layout::layout(&s.doc, &mut cache, &opts); + assert_eq!(fitted.pages.len(), s.layout().pages.len()); + } } From 15c5b68da633c7f5bdc7b2a1b3619de29c85ef7f Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Thu, 12 Nov 2026 18:23:58 -0300 Subject: [PATCH 08/12] M2.1: Use session deletion visibility in Fit One Page guards (#6433) Pass LayoutOptions::hides_deleted per story into the explicit-break scan and the font floor, so hide_marked, revisions in balloons and moved-from text are seen exactly as Print layout sees them. This keeps the guards from counting deletions the page no longer contains. --- crates/engine/src/cmd/page/fit_page.rs | 78 ++++++++++++-------------- 1 file changed, 36 insertions(+), 42 deletions(-) diff --git a/crates/engine/src/cmd/page/fit_page.rs b/crates/engine/src/cmd/page/fit_page.rs index 35148d0132..de3fbbd944 100644 --- a/crates/engine/src/cmd/page/fit_page.rs +++ b/crates/engine/src/cmd/page/fit_page.rs @@ -10,7 +10,7 @@ use serde_json::Value; use wordcraft_doc::section::SectionStart; use wordcraft_doc::shrink::fit_document_typography; use wordcraft_doc::{CharProps, Document, ParaProps, StoryRef}; -use wordcraft_layout::{LayoutCache, LayoutOptions, Page, Placed, ViewMode}; +use wordcraft_layout::{HideDeleted, LayoutCache, LayoutOptions, Page, Placed, ViewMode}; use crate::cmd::sel_result; use crate::{CmdError, CmdResult, Session, p}; @@ -45,20 +45,19 @@ const CANDIDATE_STEPS: [(f32, f32); 10] = [ /// /// Resolves character formatting using `resolve_char_in` against `table_chr` (when inside a table cell) /// and inspects both standard and complex-script text properties while ignoring superscript/subscript characters. -/// When `hide_deleted` is true, runs with deletion markup (`props.del.is_some()`) are skipped because they -/// are not rendered in Print view when revision markup is hidden. +/// When `hides` hides a deletion, runs carrying it are skipped because Print layout omits them. /// When `show_hidden` is false, runs with hidden text formatting are skipped because they are omitted from layout. fn min_para_font_size( doc: &Document, p: &wordcraft_doc::para::Paragraph, table_chr: Option<&wordcraft_doc::CharProps>, - hide_deleted: bool, + hides: HideDeleted, show_hidden: bool, ) -> f32 { let style_id = p.props.style.as_deref(); let mut min_size = if p.runs.is_empty() { let mark_resolved = doc.styles.resolve_char_in(style_id, table_chr, &p.mark); - if (mark_resolved.hidden && !show_hidden) || (hide_deleted && p.mark.del.is_some()) { f32::INFINITY } else { mark_resolved.size } + if (mark_resolved.hidden && !show_hidden) || hides.hides(doc, p.mark.del) { f32::INFINITY } else { mark_resolved.size } } else { f32::INFINITY }; @@ -67,7 +66,7 @@ fn min_para_font_size( let run_end = run_start.saturating_add(r.len).min(p.text.len()); let run_text = p.text.get(run_start..run_end).unwrap_or(""); run_start = run_end; - if hide_deleted && r.props.del.is_some() { + if hides.hides(doc, r.props.del) { continue; } @@ -92,13 +91,13 @@ fn min_para_font_size( /// Recursively calculates the minimum effective font size in points across blocks, resolving table-style conditional formatting. /// -/// When `hide_deleted` is true, deleted text runs hidden in Print view are skipped. +/// When `hides` hides a deletion, deleted text runs are skipped. /// When `show_hidden` is false, hidden text runs omitted from Print view are skipped. fn min_blocks_font_size( doc: &Document, blocks: &[Arc], table_chr: Option<&wordcraft_doc::CharProps>, - hide_deleted: bool, + hides: HideDeleted, show_hidden: bool, ) -> f32 { use wordcraft_doc::styles::PartSet; @@ -106,7 +105,7 @@ fn min_blocks_font_size( let mut min_sz = f32::INFINITY; for b in blocks { if let Some(p) = b.as_para() { - min_sz = min_sz.min(min_para_font_size(doc, p, table_chr, hide_deleted, show_hidden)); + min_sz = min_sz.min(min_para_font_size(doc, p, table_chr, hides, show_hidden)); } else if let Some(tbl) = b.as_table() { let style = tbl.props.style.as_deref().and_then(|id| doc.styles.table_style(id)); let nrows = tbl.rows.len(); @@ -125,7 +124,7 @@ fn min_blocks_font_size( cell_chr.overlay(&f.chr); } } - min_sz = min_sz.min(min_blocks_font_size(doc, &cell.blocks, Some(&cell_chr), hide_deleted, show_hidden)); + min_sz = min_sz.min(min_blocks_font_size(doc, &cell.blocks, Some(&cell_chr), hides, show_hidden)); } } } @@ -138,13 +137,13 @@ fn paragraph_has_visible_page_break( doc: &Document, p: &wordcraft_doc::para::Paragraph, table_chr: Option<&CharProps>, - hide_deleted: bool, + hides: HideDeleted, show_hidden: bool, ) -> bool { let style_id = p.props.style.as_deref(); if p.runs.is_empty() { let mark = doc.styles.resolve_char_in(style_id, table_chr, &p.mark); - let visible = !(mark.hidden && !show_hidden) && !(hide_deleted && p.mark.del.is_some()); + let visible = !(mark.hidden && !show_hidden) && !hides.hides(doc, p.mark.del); return visible && p.text.contains(wordcraft_doc::para::PAGE_BREAK); } let mut run_start = 0usize; @@ -152,7 +151,7 @@ fn paragraph_has_visible_page_break( let run_end = run_start.saturating_add(r.len).min(p.text.len()); let run_text = p.text.get(run_start..run_end).unwrap_or(""); run_start = run_end; - if hide_deleted && r.props.del.is_some() { + if hides.hides(doc, r.props.del) { continue; } let resolved = doc.styles.resolve_char_in(style_id, table_chr, &r.props); @@ -173,13 +172,13 @@ fn blocks_have_explicit_breaks( blocks: &[Arc], table_para: Option<&ParaProps>, table_chr: Option<&CharProps>, - hide_deleted: bool, + hides: HideDeleted, show_hidden: bool, ) -> bool { for b in blocks { if let Some(p) = b.as_para() { if doc.styles.resolve_para_in(&p.props, table_para).page_break_before - || paragraph_has_visible_page_break(doc, p, table_chr, hide_deleted, show_hidden) + || paragraph_has_visible_page_break(doc, p, table_chr, hides, show_hidden) { return true; } @@ -201,7 +200,7 @@ fn blocks_have_explicit_breaks( cell_chr.overlay(&st.part(part).chr); } } - if blocks_have_explicit_breaks(doc, &cell.blocks, cell_para, Some(&cell_chr), hide_deleted, show_hidden) { + if blocks_have_explicit_breaks(doc, &cell.blocks, cell_para, Some(&cell_chr), hides, show_hidden) { return true; } } @@ -213,7 +212,7 @@ fn blocks_have_explicit_breaks( /// Returns true if the document contains explicit page or section breaks that /// physically force content onto additional pages. -fn has_explicit_breaks(doc: &Document, hide_deleted: bool, show_hidden: bool) -> bool { +fn has_explicit_breaks(doc: &Document, hides: HideDeleted, show_hidden: bool) -> bool { let sections = doc.sections(); if sections.len() > 1 { for (_, sec) in §ions[1..] { @@ -222,7 +221,7 @@ fn has_explicit_breaks(doc: &Document, hide_deleted: bool, show_hidden: bool) -> } } } - blocks_have_explicit_breaks(doc, &doc.body, None, None, hide_deleted, show_hidden) + blocks_have_explicit_breaks(doc, &doc.body, None, None, hides, show_hidden) } /// True when `item` is a line-number decoration: a `DECOR_STORY` line with no block path. @@ -318,27 +317,23 @@ fn final_page_volume(doc: &Document, page: &Page) -> (usize, f32) { /// Calculates the minimum effective body font size in points across the document body and editable text boxes. /// -/// When `hide_deleted` is true, deleted text runs hidden in Print view are skipped. -/// When `show_hidden` is false, hidden text runs omitted from Print view are skipped. -fn min_body_font_size(doc: &Document, hide_deleted: bool, show_hidden: bool) -> f32 { +/// Uses the same deletion visibility as the session's Print layout for each story. +fn min_body_font_size(doc: &Document, opts: &LayoutOptions) -> f32 { let hf_boxes = wordcraft_doc::shrink::header_footer_text_boxes(doc); - let mut min_sz = min_blocks_font_size(doc, &doc.body, None, hide_deleted, show_hidden); + let mut min_sz = min_blocks_font_size(doc, &doc.body, None, opts.hides_deleted(StoryRef::Body), opts.show_hidden); for (id, part) in &doc.parts { if !matches!(part.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer | wordcraft_doc::PartKind::Comment) && !hf_boxes.contains(id) { - min_sz = min_sz.min(min_blocks_font_size(doc, &part.blocks, None, hide_deleted, show_hidden)); + min_sz = min_sz.min(min_blocks_font_size(doc, &part.blocks, None, opts.hides_deleted(StoryRef::Part(*id)), opts.show_hidden)); } } if min_sz.is_finite() { min_sz } else { wordcraft_doc::props::DEFAULT_FONT_SIZE } } /// Checks whether effective body font sizes are at or below the minimum readable font size (8.5 pt). -/// -/// When `hide_deleted` is true, deleted text runs hidden in Print view are skipped. -/// When `show_hidden` is false, hidden text runs omitted from Print view are skipped. -fn doc_has_unreadable_body_font(doc: &Document, hide_deleted: bool, show_hidden: bool) -> bool { - min_body_font_size(doc, hide_deleted, show_hidden) < MIN_COMFORTABLE_BODY_FONT_SIZE +fn doc_has_unreadable_body_font(doc: &Document, opts: &LayoutOptions) -> bool { + min_body_font_size(doc, opts) < MIN_COMFORTABLE_BODY_FONT_SIZE } /// Collects all effective font sizes in points across a slice of blocks. @@ -637,7 +632,6 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { // 2. Build equivalent Print layout options let print_opts = fit_layout_options(s); let shown_hidden = print_opts.show_hidden; - let hide_deleted = print_opts.hide_deleted; let pre_font_sizes = collect_body_font_sizes(&s.doc); let pre_line_spacings = collect_body_line_spacings(&s.doc); @@ -652,7 +646,7 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { // Explicit breaks force pages regardless of typography; refuse before any trial can appear to // succeed by moving text around them. - if has_explicit_breaks(&s.doc, hide_deleted, shown_hidden) { + if has_explicit_breaks(&s.doc, print_opts.hides_deleted(StoryRef::Body), shown_hidden) { return Err(CmdError::Failed("Unable to fit document to one page: explicit page or section breaks prevent page reduction.".into())); } @@ -696,7 +690,7 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { } // Anti-compound guard: refuse font reduction if body font is already at or below the readability floor, // while allowing spacing-only candidates (delta == 0.0) to run. - if delta > 0.0 && doc_has_unreadable_body_font(&s.doc, hide_deleted, shown_hidden) { + if delta > 0.0 && doc_has_unreadable_body_font(&s.doc, &print_opts) { continue; } @@ -704,7 +698,7 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { fit_document_typography(&mut trial_doc, delta, alpha); // Validate the trial document's effective body font sizes against 8.5 pt minimum before accepting - if delta > 0.0 && doc_has_unreadable_body_font(&trial_doc, hide_deleted, shown_hidden) { + if delta > 0.0 && doc_has_unreadable_body_font(&trial_doc, &print_opts) { continue; } @@ -738,7 +732,7 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } _ => { - if min_body_font_size(&s.doc, hide_deleted, shown_hidden) <= MIN_COMFORTABLE_BODY_FONT_SIZE { + if min_body_font_size(&s.doc, &print_opts) <= MIN_COMFORTABLE_BODY_FONT_SIZE { Err(CmdError::Failed("Unable to shrink document further without reducing text beyond readability limits.".into())) } else { Err(CmdError::Failed("Unable to fit document to one page: no permitted typography adjustment could remove a page.".into())) @@ -796,7 +790,7 @@ mod tests { if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { p.props.page_break_before = Some(true); } - assert!(has_explicit_breaks(&doc, false, false), "a direct page_break_before is an explicit break"); + assert!(has_explicit_breaks(&doc, HideDeleted::default(), false), "a direct page_break_before is an explicit break"); let mut doc = Document::new(); doc.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("body", Default::default()))]; @@ -811,7 +805,7 @@ mod tests { if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { p.props.style = Some("BreakBefore".into()); } - assert!(has_explicit_breaks(&doc, false, false), "a style-inherited page_break_before is an explicit break"); + assert!(has_explicit_breaks(&doc, HideDeleted::default(), false), "a style-inherited page_break_before is an explicit break"); let mut doc = Document::new(); let mut table = wordcraft_doc::Table::new(1, 1, 100.0); @@ -819,7 +813,7 @@ mod tests { cell_p.props.page_break_before = Some(true); table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cell_p)]; doc.body = vec![Arc::new(wordcraft_doc::Block::Table(table))]; - assert!(has_explicit_breaks(&doc, false, false), "a page_break_before inside a table cell is an explicit break"); + assert!(has_explicit_breaks(&doc, HideDeleted::default(), false), "a page_break_before inside a table cell is an explicit break"); } #[test] @@ -830,16 +824,16 @@ mod tests { if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { p.runs[0].props.hidden = Some(true); } - assert!(!has_explicit_breaks(&doc, false, false), "hidden break is omitted in Print view"); - assert!(has_explicit_breaks(&doc, false, true), "hidden break counts when hidden marks are shown"); + assert!(!has_explicit_breaks(&doc, HideDeleted::default(), false), "hidden break is omitted in Print view"); + assert!(has_explicit_breaks(&doc, HideDeleted::default(), true), "hidden break counts when hidden marks are shown"); let mut doc = Document::new(); doc.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, Default::default()))]; if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { p.runs[0].props.del = Some(0); } - assert!(!has_explicit_breaks(&doc, true, false), "deleted break is omitted while markup is hidden"); - assert!(has_explicit_breaks(&doc, false, false), "deleted break counts when markup is shown"); + assert!(!has_explicit_breaks(&doc, HideDeleted::ALL, false), "deleted break is omitted while markup is hidden"); + assert!(has_explicit_breaks(&doc, HideDeleted::default(), false), "deleted break counts when markup is shown"); } #[test] @@ -863,8 +857,8 @@ mod tests { }; Arc::make_mut(&mut doc.styles).upsert(style); - assert!(!has_explicit_breaks(&doc, false, false), "a first-row hidden override must hide the break"); - assert!(has_explicit_breaks(&doc, false, true), "the hidden break counts when hidden marks are shown"); + assert!(!has_explicit_breaks(&doc, HideDeleted::default(), false), "a first-row hidden override must hide the break"); + assert!(has_explicit_breaks(&doc, HideDeleted::default(), true), "the hidden break counts when hidden marks are shown"); } #[test] From d30a8392f932e5f752b19f473145af25ddf3ceff Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Thu, 12 Nov 2026 19:32:10 -0300 Subject: [PATCH 09/12] M2.1: Harden Fit One Page budget provenance and DOCX persistence (#6433) - Record original and reduced font sizes, line spacings and paragraph spacings as multisets, so restoring neighbouring sizes clears the exhausted budget, and paragraph spacing keeps the budget when line spacing rounds back to its original. - Resolve font and spacing budgets independently, letting the remaining component be used instead of refusing the whole command. - Persist FitState in a namespaced DOCX custom XML part and restore it on read; add a DOCX round-trip regression. --- crates/doc/src/shrink.rs | 6 + crates/docx/Cargo.toml | 2 +- crates/docx/src/fit.rs | 69 +++++ crates/docx/src/lib.rs | 1 + crates/docx/src/read.rs | 1 + crates/docx/src/read/embed.rs | 20 +- crates/docx/src/write/parts.rs | 4 + crates/engine/src/cmd/page/fit_page.rs | 332 +++++++++++++++++++++--- crates/engine/src/tests/fit_one_page.rs | 46 ++-- 9 files changed, 424 insertions(+), 57 deletions(-) create mode 100644 crates/docx/src/fit.rs diff --git a/crates/doc/src/shrink.rs b/crates/doc/src/shrink.rs index c943de737e..65dadb349a 100644 --- a/crates/doc/src/shrink.rs +++ b/crates/doc/src/shrink.rs @@ -35,12 +35,18 @@ pub struct FitState { pub reduced_font_sizes: Vec, /// Unique effective line spacing values present immediately after the reduction was applied. pub reduced_line_spacings: Vec, + /// Unique effective paragraph spacing values present immediately after the reduction was applied. + #[serde(default)] + pub reduced_paragraph_spacings: Vec, /// Unique effective body font sizes before the first Fit One Page reduction was applied. #[serde(default)] pub original_font_sizes: Vec, /// Unique effective line spacing values before the first Fit One Page reduction was applied. #[serde(default)] pub original_line_spacings: Vec, + /// Unique effective paragraph spacing values before the first Fit One Page reduction was applied. + #[serde(default)] + pub original_paragraph_spacings: Vec, } /// Scale a font size down by `delta_pt`. diff --git a/crates/docx/Cargo.toml b/crates/docx/Cargo.toml index 6f7f1b3860..66f038a185 100644 --- a/crates/docx/Cargo.toml +++ b/crates/docx/Cargo.toml @@ -15,6 +15,7 @@ quick-xml = { workspace = true } zip = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +serde_json = { workspace = true } chrono = { workspace = true } unicode-bidi = { workspace = true } cfb = { workspace = true } @@ -32,7 +33,6 @@ getrandom = { workspace = true, features = ["wasm_js"] } [dev-dependencies] wordcraft-doc = { workspace = true, features = ["test-support"] } -serde_json = { workspace = true } proptest = { workspace = true } image = { workspace = true } zip = { workspace = true } diff --git a/crates/docx/src/fit.rs b/crates/docx/src/fit.rs new file mode 100644 index 0000000000..8894800f6b --- /dev/null +++ b/crates/docx/src/fit.rs @@ -0,0 +1,69 @@ +//! The Fit One Page budget in a DOCX custom XML part. +//! +//! Word keeps custom XML stores as ordinary package parts; our own store is namespaced so it can +//! never be mistaken for a user's data. It is not a document property, so it stays out of +//! `docProps/custom.xml`. + +use wordcraft_doc::FitState; + +use crate::DocxError; +use crate::xml::{El, esc}; + +/// The part this crate writes and reads back. +pub(crate) const PART: &str = "customxml/wordcraftfit.xml"; +/// Root element of the store. +pub(crate) const ROOT: &str = "wordcraftFit"; +/// Namespace of the store. +pub(crate) const NS: &str = "urn:wordcraft:fit"; +/// Marker attribute the parser keeps (namespace declarations are stripped from `El`). +const KIND: &str = "wordcraft-fit-v1"; + +/// Whether `root` is our store (and not a user's custom XML part with a coincidental name). +pub(crate) fn is_state(root: &El) -> bool { + root.local() == ROOT && root.attr("kind") == Some(KIND) +} + +/// Serialize `fit` as the custom XML store. +pub(crate) fn write(fit: &FitState) -> Result, DocxError> { + let json = serde_json::to_string(fit).map_err(|e| DocxError::Xml(e.to_string()))?; + Ok(format!(r#"<{ROOT} xmlns="{NS}" kind="{KIND}">{}"#, esc(&json)) + .into_bytes()) +} + +/// Read the state from a parsed custom XML root, if it is ours. +pub(crate) fn read(root: &El) -> Option { + if !is_state(root) { + return None; + } + let state = root.els().find(|e| e.local() == "fitState")?; + serde_json::from_str(&state.text()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use wordcraft_doc::props::LineSpacing; + + fn state() -> FitState { + FitState { + cum_delta: 1.0, + cum_space: 0.94, + reduced_font_sizes: vec![11.0], + reduced_line_spacings: vec![LineSpacing::Multiple(0.94)], + reduced_paragraph_spacings: vec![18.8], + original_font_sizes: vec![12.0], + original_line_spacings: vec![LineSpacing::Multiple(1.0)], + original_paragraph_spacings: vec![20.0], + } + } + + /// The cumulative budget survives a real DOCX write and read, not just `serde_json`. + #[test] + fn fit_state_round_trips_through_docx() { + let mut doc = wordcraft_doc::Document::from_text("Alpha\nBeta"); + doc.fit_state = Some(state()); + let bytes = crate::write(&doc).expect("write docx"); + let back = crate::read(&bytes).expect("read docx"); + assert_eq!(back.fit_state, doc.fit_state); + } +} diff --git a/crates/docx/src/lib.rs b/crates/docx/src/lib.rs index 1667bc1c2f..9ade398f9f 100644 --- a/crates/docx/src/lib.rs +++ b/crates/docx/src/lib.rs @@ -11,6 +11,7 @@ mod bibliography; mod chart_spec; mod crypt; mod custom; +mod fit; mod flat; mod fonts; mod package; diff --git a/crates/docx/src/read.rs b/crates/docx/src/read.rs index f8570c1089..9f39daecbe 100644 --- a/crates/docx/src/read.rs +++ b/crates/docx/src/read.rs @@ -333,6 +333,7 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize, } r.read_glossary(&rels); r.keep_custom_xml(&root_rels, &rels); + r.read_fit_state(&rels); r.keep_unmodelled(&root, &root_rels, &rels); if !r.embedded.parts.is_empty() { let manifest = r.embedded.to_bytes(); diff --git a/crates/docx/src/read/embed.rs b/crates/docx/src/read/embed.rs index a2a91f4e57..b2e9197961 100644 --- a/crates/docx/src/read/embed.rs +++ b/crates/docx/src/read/embed.rs @@ -120,7 +120,11 @@ impl Reader<'_> { if ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s)) { continue; } - if !self.doc.sources.is_empty() && self.xml(&rel.target).ok().flatten().is_some_and(|r| r.local() == "Sources") { + let root = self.xml(&rel.target).ok().flatten(); + if root.as_ref().is_some_and(crate::fit::is_state) { + continue; + } + if !self.doc.sources.is_empty() && root.as_ref().is_some_and(|r| r.local() == "Sources") { continue; } // Native ink shares the custom XML relationship type, but its part belongs to the @@ -139,6 +143,20 @@ impl Reader<'_> { } } + /// Read the Fit One Page budget from our custom XML store, if the file has one. + pub(super) fn read_fit_state(&mut self, document: &Rels) { + for rel in document.list.iter().filter(|r| !r.external && rel_is(&r.kind, rt::CUSTOM_XML)).take(MAX_EMBEDDED_RELS) { + if !manifest_field_ok(&rel.target) { + continue; + } + let Some(root) = self.xml(&rel.target).ok().flatten() else { continue }; + if let Some(fit) = crate::fit::read(&root) { + self.doc.fit_state = Some(fit); + break; + } + } + } + /// Keep the parts the package and the main document relate to that WordCraft neither models /// nor writes itself (web settings, styles-with-effects, add-in task panes, the thumbnail, a /// glossary…), with every part they reach, so saving carries them unchanged. Skipped: the diff --git a/crates/docx/src/write/parts.rs b/crates/docx/src/write/parts.rs index f35a36fc91..6a52a119d6 100644 --- a/crates/docx/src/write/parts.rs +++ b/crates/docx/src/write/parts.rs @@ -179,6 +179,10 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result } } -/// Collects unique effective body font sizes across the document body and editable parts. +/// Collects every effective body font size across the document body and editable parts, with duplicates. fn collect_body_font_sizes(doc: &Document) -> Vec { let mut sizes = Vec::new(); collect_blocks_font_sizes(doc, &doc.body, None, &mut sizes); @@ -393,7 +393,6 @@ fn collect_body_font_sizes(doc: &Document) -> Vec { } } sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - sizes.dedup_by(|a, b| (*a - *b).abs() < 1e-4); sizes } @@ -420,7 +419,7 @@ fn collect_blocks_line_spacings( } } -/// Collects unique effective line spacings across body paragraphs and editable parts. +/// Collects every effective line spacing across body paragraphs and editable parts, with duplicates. fn collect_body_line_spacings(doc: &Document) -> Vec { let mut spacings = Vec::new(); collect_blocks_line_spacings(doc, &doc.body, None, &mut spacings); @@ -443,33 +442,71 @@ fn collect_body_line_spacings(doc: &Document) -> Vec std::cmp::Ordering::Less, (_, wordcraft_doc::props::LineSpacing::AtLeast(_)) => std::cmp::Ordering::Greater, }); - spacings.dedup_by(|a, b| match (a, b) { - (wordcraft_doc::props::LineSpacing::Multiple(x), wordcraft_doc::props::LineSpacing::Multiple(y)) - | (wordcraft_doc::props::LineSpacing::AtLeast(x), wordcraft_doc::props::LineSpacing::AtLeast(y)) - | (wordcraft_doc::props::LineSpacing::Exactly(x), wordcraft_doc::props::LineSpacing::Exactly(y)) => (*x - *y).abs() < 1e-4, - _ => false, - }); + spacings +} + +/// Collects all effective paragraph spacing values across a slice of blocks, preserving table context. +fn collect_blocks_paragraph_spacings( + doc: &Document, + blocks: &[Arc], + table_para: Option<&wordcraft_doc::ParaProps>, + out: &mut Vec, +) { + for b in blocks { + if let Some(p) = b.as_para() { + let resolved = doc.styles.resolve_para_in(&p.props, table_para); + out.push(resolved.space_before); + out.push(resolved.space_after); + } else if let Some(tbl) = b.as_table() { + let style = tbl.props.style.as_deref().and_then(|id| doc.styles.table_style(id)); + let cell_para = style.as_ref().map(|st| &st.para); + for row in &tbl.rows { + for cell in &row.cells { + collect_blocks_paragraph_spacings(doc, &cell.blocks, cell_para, out); + } + } + } + } +} + +/// Collects every effective paragraph spacing value across body paragraphs and editable parts. +fn collect_body_paragraph_spacings(doc: &Document) -> Vec { + let mut spacings = Vec::new(); + collect_blocks_paragraph_spacings(doc, &doc.body, None, &mut spacings); + let hf_boxes = wordcraft_doc::shrink::header_footer_text_boxes(doc); + for (id, part) in &doc.parts { + if !matches!(part.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer | wordcraft_doc::PartKind::Comment) + && !hf_boxes.contains(id) + { + collect_blocks_paragraph_spacings(doc, &part.blocks, None, &mut spacings); + } + } + spacings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); spacings } /// Checks whether previously reduced text from an earlier fit is still present in the document. /// /// Compares each current value against the original values recorded before the first fit, within -/// the cumulative reduction that fit applied. This avoids matching a body size against an unrelated -/// heading size: restoring every size to its original therefore resets the budget. +/// the cumulative reduction that fit applied. An exact match with the original multiset means every +/// size was restored, even when one original value sits inside another value's reduction interval. +#[cfg(test)] fn has_previously_reduced_text(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { - let font_reduced_present = fit.cum_delta > 0.0 && font_reduction_present(doc, fit); - let space_reduced_present = fit.cum_space < 1.0 - 1e-4 && spacing_reduction_present(doc, fit); + (fit.cum_delta > 0.0 && font_reduction_present(doc, fit)) || (fit.cum_space < 1.0 - 1e-4 && spacing_reduction_present(doc, fit)) +} - if fit.cum_delta > 0.0 && fit.cum_space < 1.0 - 1e-4 { - font_reduced_present || space_reduced_present - } else if fit.cum_delta > 0.0 { - font_reduced_present - } else if fit.cum_space < 1.0 - 1e-4 { - space_reduced_present - } else { - false - } +fn same_sizes(a: &[f32], b: &[f32]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4) +} + +fn same_spacings(a: &[wordcraft_doc::props::LineSpacing], b: &[wordcraft_doc::props::LineSpacing]) -> bool { + a.len() == b.len() + && a.iter().zip(b).all(|(x, y)| match (x, y) { + (wordcraft_doc::props::LineSpacing::Multiple(c), wordcraft_doc::props::LineSpacing::Multiple(o)) + | (wordcraft_doc::props::LineSpacing::AtLeast(c), wordcraft_doc::props::LineSpacing::AtLeast(o)) + | (wordcraft_doc::props::LineSpacing::Exactly(c), wordcraft_doc::props::LineSpacing::Exactly(o)) => (c - o).abs() < 1e-4, + _ => false, + }) } fn font_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { @@ -477,6 +514,9 @@ fn font_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) return false; } let current_sizes = collect_body_font_sizes(doc); + if same_sizes(¤t_sizes, &fit.original_font_sizes) { + return false; + } current_sizes.iter().any(|&cur| { fit.original_font_sizes.iter().any(|&orig| { let floor = orig - fit.cum_delta - 1e-4; @@ -485,11 +525,14 @@ fn font_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) }) } -fn spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { +fn line_spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { if fit.original_line_spacings.is_empty() { return false; } let current_spacings = collect_body_line_spacings(doc); + if same_spacings(¤t_spacings, &fit.original_line_spacings) { + return false; + } current_spacings.iter().any(|cur| { fit.original_line_spacings.iter().any(|orig| match (cur, orig) { (wordcraft_doc::props::LineSpacing::Multiple(c), wordcraft_doc::props::LineSpacing::Multiple(o)) @@ -503,6 +546,49 @@ fn spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitSta }) } +fn paragraph_spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { + if fit.original_paragraph_spacings.is_empty() { + return false; + } + let current_spacings = collect_body_paragraph_spacings(doc); + if same_sizes(¤t_spacings, &fit.original_paragraph_spacings) { + return false; + } + current_spacings.iter().any(|&cur| { + fit.original_paragraph_spacings.iter().any(|&orig| { + let floor = orig * fit.cum_space - 1e-4; + cur >= floor && cur < orig - 1e-4 + }) + }) +} + +fn spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { + line_spacing_reduction_present(doc, fit) || paragraph_spacing_reduction_present(doc, fit) +} + +/// The reduction budget still active in `doc`, with font and spacing resolved independently. +#[derive(Clone, Copy, Debug)] +struct ActiveBudget { + delta: f32, + space: f32, + font_active: bool, + spacing_active: bool, +} + +fn active_budget(doc: &Document) -> ActiveBudget { + let Some(fit) = doc.fit_state.as_ref() else { + return ActiveBudget { delta: 0.0, space: 1.0, font_active: false, spacing_active: false }; + }; + let font_active = fit.cum_delta > 0.0 && font_reduction_present(doc, fit); + let spacing_active = fit.cum_space < 1.0 - 1e-4 && spacing_reduction_present(doc, fit); + ActiveBudget { + delta: if font_active { fit.cum_delta } else { 0.0 }, + space: if spacing_active { fit.cum_space } else { 1.0 }, + font_active, + spacing_active, + } +} + /// Records the cumulative Fit One Page reduction state into the document's internal fit tracking. fn set_cumulative_shrink_state( doc: &mut Document, @@ -510,16 +596,20 @@ fn set_cumulative_shrink_state( space: f32, original_font_sizes: Vec, original_line_spacings: Vec, + original_paragraph_spacings: Vec, ) { let sizes = collect_body_font_sizes(doc); let spacings = collect_body_line_spacings(doc); + let paragraph_spacings = collect_body_paragraph_spacings(doc); doc.fit_state = Some(wordcraft_doc::FitState { cum_delta: delta, cum_space: space, reduced_font_sizes: sizes, reduced_line_spacings: spacings, + reduced_paragraph_spacings: paragraph_spacings, original_font_sizes, original_line_spacings, + original_paragraph_spacings, }); } @@ -622,10 +712,15 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { )); } - // 1. Anti-compound guard: refuse if cumulative reduction already reached 1.0 pt or spacing factor <= 0.94 - let prior_fit = s.doc.fit_state.clone().filter(|fit| has_previously_reduced_text(&s.doc, fit)); - let (cum_delta, cum_space) = prior_fit.as_ref().map_or((0.0, 1.0), |fit| (fit.cum_delta, fit.cum_space)); - if cum_delta >= 1.0 - 1e-4 || cum_space <= 0.94 + 1e-4 { + // 1. Anti-compound guard: each component has its own budget, so a restored font or spacing + // budget is not blocked by the other component's remaining reduction. + let prior_fit = s.doc.fit_state.clone(); + let budget = active_budget(&s.doc); + let (cum_delta, cum_space) = (budget.delta, budget.space); + let (font_active, spacing_active) = (budget.font_active, budget.spacing_active); + let font_exhausted = cum_delta >= 1.0 - 1e-4; + let spacing_exhausted = cum_space <= 0.94 + 1e-4; + if font_exhausted && spacing_exhausted { return Err(CmdError::Failed("Unable to shrink document further without reducing text beyond readability limits.".into())); } @@ -634,6 +729,7 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { let shown_hidden = print_opts.show_hidden; let pre_font_sizes = collect_body_font_sizes(&s.doc); let pre_line_spacings = collect_body_line_spacings(&s.doc); + let pre_paragraph_spacings = collect_body_paragraph_spacings(&s.doc); // 3. Initial page count evaluation let mut initial_cache = LayoutCache::new(); @@ -678,14 +774,32 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { if delta > max_delta { continue; } + // A component whose budget is exhausted is skipped, leaving the other component usable. + if font_exhausted && delta > 0.0 { + continue; + } + if spacing_exhausted && alpha < 1.0 - 1e-4 && delta <= 0.0 { + continue; + } // Anti-compound budget across runs: cap cumulative font reduction at 1.0 pt if cum_delta > 0.0 && cum_delta + delta > 1.0 + 1e-4 { continue; } - // Anti-compound budget across runs: cap cumulative line spacing compression at 0.94 floor - let cand_line_space = alpha.max(0.94); - let prior_line_space = cum_space.max(0.94); - if cum_space < 1.0 - 1e-4 && prior_line_space * cand_line_space < 0.94 - 1e-4 { + // Anti-compound budget across runs: cap cumulative line spacing compression at 0.94 floor. + // If spacing has no room left, a font candidate still runs without spacing compression. + let mut alpha = if spacing_exhausted { 1.0 } else { alpha }; + if alpha < 1.0 - 1e-4 && cum_space < 1.0 - 1e-4 { + let cand_line_space = alpha.max(0.94); + let prior_line_space = cum_space.max(0.94); + if prior_line_space * cand_line_space < 0.94 - 1e-4 { + if delta > 0.0 { + alpha = 1.0; + } else { + continue; + } + } + } + if delta <= 0.0 && alpha >= 1.0 - 1e-4 { continue; } // Anti-compound guard: refuse font reduction if body font is already at or below the readability floor, @@ -716,11 +830,29 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { // 8. Success commit or clean rollback match (successful_doc, applied_step) { (Some(mut new_doc), Some((d, a))) => { - let original_font_sizes = - prior_fit.as_ref().map(|fit| fit.original_font_sizes.clone()).filter(|sizes| !sizes.is_empty()).unwrap_or(pre_font_sizes); - let original_line_spacings = - prior_fit.as_ref().map(|fit| fit.original_line_spacings.clone()).filter(|spacings| !spacings.is_empty()).unwrap_or(pre_line_spacings); - set_cumulative_shrink_state(&mut new_doc, cum_delta + d, cum_space * a, original_font_sizes, original_line_spacings); + let prior_original_fonts = + || prior_fit.as_ref().filter(|_| font_active).map(|fit| fit.original_font_sizes.clone()).filter(|sizes| !sizes.is_empty()); + let prior_original_lines = || { + prior_fit.as_ref().filter(|_| spacing_active).map(|fit| fit.original_line_spacings.clone()).filter(|spacings| !spacings.is_empty()) + }; + let prior_original_paragraphs = || { + prior_fit + .as_ref() + .filter(|_| spacing_active) + .map(|fit| fit.original_paragraph_spacings.clone()) + .filter(|spacings| !spacings.is_empty()) + }; + let original_font_sizes = prior_original_fonts().unwrap_or(pre_font_sizes); + let original_line_spacings = prior_original_lines().unwrap_or(pre_line_spacings); + let original_paragraph_spacings = prior_original_paragraphs().unwrap_or(pre_paragraph_spacings); + set_cumulative_shrink_state( + &mut new_doc, + cum_delta + d, + cum_space * a, + original_font_sizes, + original_line_spacings, + original_paragraph_spacings, + ); s.doc = new_doc; s.clamp_selection(); let page_word = if final_pages == 1 { "page" } else { "pages" }; @@ -874,8 +1006,9 @@ mod tests { } let originals = collect_body_font_sizes(&doc); let originals_spacings = collect_body_line_spacings(&doc); + let originals_paragraphs = collect_body_paragraph_spacings(&doc); wordcraft_doc::shrink::fit_document_typography(&mut doc, 1.0, 1.0); - set_cumulative_shrink_state(&mut doc, 1.0, 1.0, originals, originals_spacings); + set_cumulative_shrink_state(&mut doc, 1.0, 1.0, originals, originals_spacings, originals_paragraphs); assert!(has_previously_reduced_text(&doc, doc.fit_state.as_ref().unwrap())); // Restoring only the heading leaves the body reduced. @@ -903,14 +1036,19 @@ mod tests { let mut doc = Document::from_text("Alpha\nBeta"); if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { p.props.line_spacing = Some(LineSpacing::Multiple(1.0)); + p.props.space_before = Some(0.0); + p.props.space_after = Some(0.0); } if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { p.props.line_spacing = Some(LineSpacing::Multiple(2.0)); + p.props.space_before = Some(0.0); + p.props.space_after = Some(0.0); } let originals = collect_body_font_sizes(&doc); let originals_spacings = collect_body_line_spacings(&doc); + let originals_paragraphs = collect_body_paragraph_spacings(&doc); wordcraft_doc::shrink::fit_document_typography(&mut doc, 0.0, 0.9); - set_cumulative_shrink_state(&mut doc, 0.0, 0.9, originals, originals_spacings); + set_cumulative_shrink_state(&mut doc, 0.0, 0.9, originals, originals_spacings, originals_paragraphs); assert!(has_previously_reduced_text(&doc, doc.fit_state.as_ref().unwrap())); // Restoring the 2.0 spacing but leaving the 1.0 one reduced keeps the budget. @@ -925,6 +1063,124 @@ mod tests { assert!(!has_previously_reduced_text(&doc, doc.fit_state.as_ref().unwrap())); } + #[test] + fn provenance_resets_when_neighbouring_sizes_are_restored() { + let mut doc = Document::from_text("Alpha\nBeta"); + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { + for r in &mut p.runs { + r.props.size = Some(12.0); + } + } + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { + for r in &mut p.runs { + r.props.size = Some(11.0); + } + } + let originals = collect_body_font_sizes(&doc); + let originals_spacings = collect_body_line_spacings(&doc); + let originals_paragraphs = collect_body_paragraph_spacings(&doc); + wordcraft_doc::shrink::fit_document_typography(&mut doc, 1.0, 1.0); + set_cumulative_shrink_state(&mut doc, 1.0, 1.0, originals, originals_spacings, originals_paragraphs); + assert!(font_reduction_present(&doc, doc.fit_state.as_ref().unwrap())); + + // Restoring 12 pt and 11 pt makes 11 pt look like another size's reduction interval; the + // exact original multiset must clear the budget. + for (i, size) in [(0, 12.0), (1, 11.0)] { + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[i]) { + for r in &mut p.runs { + r.props.size = Some(size); + } + } + } + assert!(!font_reduction_present(&doc, doc.fit_state.as_ref().unwrap())); + assert_eq!(active_budget(&doc).delta, 0.0); + } + + #[test] + fn paragraph_spacing_keeps_the_budget_when_line_spacing_rounds_back() { + use wordcraft_doc::props::LineSpacing; + + let mut doc = Document::from_text("Alpha\nBeta"); + for p in doc.body.iter_mut() { + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(p) { + p.props.line_spacing = Some(LineSpacing::Exactly(8.0)); + p.props.space_before = Some(20.0); + p.props.space_after = Some(0.0); + } + } + let originals = collect_body_font_sizes(&doc); + let originals_spacings = collect_body_line_spacings(&doc); + let originals_paragraphs = collect_body_paragraph_spacings(&doc); + wordcraft_doc::shrink::fit_document_typography(&mut doc, 0.0, 0.94); + set_cumulative_shrink_state(&mut doc, 0.0, 0.94, originals, originals_spacings, originals_paragraphs); + + // Exactly(8.0) rounds back to 8.0, but 20 pt paragraph spacing becomes 18.8 pt. + assert!(!line_spacing_reduction_present(&doc, doc.fit_state.as_ref().unwrap())); + assert!(paragraph_spacing_reduction_present(&doc, doc.fit_state.as_ref().unwrap())); + assert!(spacing_reduction_present(&doc, doc.fit_state.as_ref().unwrap())); + + for p in doc.body.iter_mut() { + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(p) { + p.props.space_before = Some(20.0); + } + } + assert!(!spacing_reduction_present(&doc, doc.fit_state.as_ref().unwrap())); + } + + #[test] + fn font_and_spacing_budgets_reset_independently() { + use wordcraft_doc::props::LineSpacing; + + let mut doc = Document::from_text("Alpha\nBeta"); + for p in doc.body.iter_mut() { + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(p) { + p.props.line_spacing = Some(LineSpacing::Multiple(1.0)); + p.props.space_before = Some(10.0); + p.props.space_after = Some(0.0); + for r in &mut p.runs { + r.props.size = Some(12.0); + } + } + } + let originals = collect_body_font_sizes(&doc); + let originals_spacings = collect_body_line_spacings(&doc); + let originals_paragraphs = collect_body_paragraph_spacings(&doc); + wordcraft_doc::shrink::fit_document_typography(&mut doc, 1.0, 0.9); + set_cumulative_shrink_state(&mut doc, 1.0, 0.9, originals, originals_spacings, originals_paragraphs); + + let both = active_budget(&doc); + assert_eq!(both.delta, 1.0); + assert!((both.space - 0.9).abs() < 1e-4); + assert!(both.font_active && both.spacing_active); + + // Restore only the spacing: its budget resets while the font budget remains. + for p in doc.body.iter_mut() { + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(p) { + p.props.line_spacing = Some(LineSpacing::Multiple(1.0)); + p.props.space_before = Some(10.0); + p.props.space_after = Some(0.0); + } + } + let spacing_restored = active_budget(&doc); + assert_eq!(spacing_restored.delta, 1.0); + assert_eq!(spacing_restored.space, 1.0); + assert!(spacing_restored.font_active); + assert!(!spacing_restored.spacing_active); + + // Restoring the fonts too clears the other budget. + for p in doc.body.iter_mut() { + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(p) { + for r in &mut p.runs { + r.props.size = Some(12.0); + } + } + } + let restored = active_budget(&doc); + assert_eq!(restored.delta, 0.0); + assert_eq!(restored.space, 1.0); + assert!(!restored.font_active && !restored.spacing_active); + } + #[test] fn fit_layout_options_match_session_visibility_and_numeral() { let mut s = Session::new(Document::from_text("Alpha\nBeta")); diff --git a/crates/engine/src/tests/fit_one_page.rs b/crates/engine/src/tests/fit_one_page.rs index 93020dae8f..1bbaaf5abe 100644 --- a/crates/engine/src/tests/fit_one_page.rs +++ b/crates/engine/src/tests/fit_one_page.rs @@ -246,30 +246,42 @@ fn fit_one_page_supports_max_delta_above_2pt() { fn fit_one_page_anti_compound_guard_prevents_repeated_invocations() { let mut lines = 28; let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { + loop { + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(0.94)); + for r in &mut p.runs { + r.props.size = Some(11.0); + } + } + } + s.touch(); + s.relayout(); + if s.layout().pages.len() >= 2 || lines >= 80 { + break; + } lines += 1; s = create_multiline_doc(lines); } assert_eq!(s.layout().pages.len(), 2); - // First invocation succeeds - let res1 = s.run("layout.fitOnePage", &json!({})); - assert!(res1.is_ok(), "first invocation must succeed: {res1:?}"); - assert_eq!(s.layout().pages.len(), 1); + // Simulate a document that already used the full 1.0 pt font and 0.94 spacing budgets. + s.doc.fit_state = Some(wordcraft_doc::FitState { + cum_delta: 1.0, + cum_space: 0.94, + original_font_sizes: vec![12.0], + original_line_spacings: vec![wordcraft_doc::props::LineSpacing::Multiple(1.0)], + ..Default::default() + }); + s.touch(); + s.relayout(); + let doc_before = s.doc.clone(); - // Add content to push it onto page 2 again - for i in 1..=5 { - s.run("text.insert", &json!({"text": format!("New spill line {i}\n")})).unwrap(); - } - assert_eq!(s.layout().pages.len(), 2); - let doc_before_second = s.doc.clone(); - - // Second invocation must be rejected by the anti-compound guard without mutating doc - let res2 = s.run("layout.fitOnePage", &json!({})); - assert!(res2.is_err(), "second invocation must be rejected by anti-compound guard"); - let err = res2.unwrap_err().to_string(); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err(), "exhausted budgets must be rejected"); + let err = res.unwrap_err().to_string(); assert!(err.contains("Unable to shrink document further without reducing text beyond readability limits."), "unexpected error message: {err}"); - assert_eq!(s.doc, doc_before_second, "document must not be mutated on rejected invocation"); + assert_eq!(s.doc, doc_before, "document must not be mutated on rejected invocation"); } #[test] From 3f7bff9204485f10c04444966acfc19f2e5d6419 Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Thu, 12 Nov 2026 19:35:12 -0300 Subject: [PATCH 10/12] M2.1: Only inspect the Fit One Page DOCX part by name (#6433) Check the fixed custom XML part name before parsing, so unrelated custom XML stores are not read twice just to look for the fit budget. --- crates/docx/src/fit.rs | 5 +++++ crates/docx/src/read/embed.rs | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/docx/src/fit.rs b/crates/docx/src/fit.rs index 8894800f6b..7d3a1e35ac 100644 --- a/crates/docx/src/fit.rs +++ b/crates/docx/src/fit.rs @@ -18,6 +18,11 @@ pub(crate) const NS: &str = "urn:wordcraft:fit"; /// Marker attribute the parser keeps (namespace declarations are stripped from `El`). const KIND: &str = "wordcraft-fit-v1"; +/// Whether `target` (a relationship target) names our part. +pub(crate) fn is_part(target: &str) -> bool { + target.trim_start_matches('/').eq_ignore_ascii_case(PART) || target.to_ascii_lowercase().ends_with(&format!("/{PART}")) +} + /// Whether `root` is our store (and not a user's custom XML part with a coincidental name). pub(crate) fn is_state(root: &El) -> bool { root.local() == ROOT && root.attr("kind") == Some(KIND) diff --git a/crates/docx/src/read/embed.rs b/crates/docx/src/read/embed.rs index b2e9197961..1e896a632b 100644 --- a/crates/docx/src/read/embed.rs +++ b/crates/docx/src/read/embed.rs @@ -120,6 +120,9 @@ impl Reader<'_> { if ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s)) { continue; } + if crate::fit::is_part(&rel.target) { + continue; + } let root = self.xml(&rel.target).ok().flatten(); if root.as_ref().is_some_and(crate::fit::is_state) { continue; @@ -146,7 +149,7 @@ impl Reader<'_> { /// Read the Fit One Page budget from our custom XML store, if the file has one. pub(super) fn read_fit_state(&mut self, document: &Rels) { for rel in document.list.iter().filter(|r| !r.external && rel_is(&r.kind, rt::CUSTOM_XML)).take(MAX_EMBEDDED_RELS) { - if !manifest_field_ok(&rel.target) { + if !manifest_field_ok(&rel.target) || !crate::fit::is_part(&rel.target) { continue; } let Some(root) = self.xml(&rel.target).ok().flatten() else { continue }; From 2bda4e5a4126e9c5240ff7e6abcb7ddeaee0c471 Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Thu, 12 Nov 2026 21:11:12 -0300 Subject: [PATCH 11/12] M2.1: Resolve Fit One Page stores by marker, reserve path, and location provenance (#6433) - Recognize a marked Fit One Page custom XML store across package and document relationships, skipping passthrough only after loading successfully. - Reserve the Fit One Page part path in EmbedWriter taken paths to prevent collisions. - Track typography provenance per formatting location rather than sorted multisets. - Validate stepPt parameter and reject non-positive or non-finite values. - Report "already fits on one page" before an exhausted cumulative budget. --- crates/doc/src/shrink.rs | 9 + crates/docx/src/fit.rs | 6 +- crates/docx/src/read.rs | 2 +- crates/docx/src/read/embed.rs | 125 ++++- crates/docx/src/write/embed.rs | 5 + crates/engine/src/cmd/page.rs | 1 + crates/engine/src/cmd/page/fit_page.rs | 583 ++++++++++++++++++------ crates/engine/src/tests/fit_one_page.rs | 28 ++ crates/ui-egui/src/ribbon/layout.rs | 1 + 9 files changed, 610 insertions(+), 150 deletions(-) diff --git a/crates/doc/src/shrink.rs b/crates/doc/src/shrink.rs index 65dadb349a..c09def5e6d 100644 --- a/crates/doc/src/shrink.rs +++ b/crates/doc/src/shrink.rs @@ -47,6 +47,15 @@ pub struct FitState { /// Unique effective paragraph spacing values before the first Fit One Page reduction was applied. #[serde(default)] pub original_paragraph_spacings: Vec, + /// Original effective body font sizes by formatting location key before the first fit. + #[serde(default)] + pub original_font_locations: std::collections::BTreeMap, + /// Original effective line spacing values by formatting location key before the first fit. + #[serde(default)] + pub original_line_spacing_locations: std::collections::BTreeMap, + /// Original effective paragraph spacing values by formatting location key before the first fit. + #[serde(default)] + pub original_paragraph_spacing_locations: std::collections::BTreeMap, } /// Scale a font size down by `delta_pt`. diff --git a/crates/docx/src/fit.rs b/crates/docx/src/fit.rs index 7d3a1e35ac..c407e9af4a 100644 --- a/crates/docx/src/fit.rs +++ b/crates/docx/src/fit.rs @@ -18,11 +18,6 @@ pub(crate) const NS: &str = "urn:wordcraft:fit"; /// Marker attribute the parser keeps (namespace declarations are stripped from `El`). const KIND: &str = "wordcraft-fit-v1"; -/// Whether `target` (a relationship target) names our part. -pub(crate) fn is_part(target: &str) -> bool { - target.trim_start_matches('/').eq_ignore_ascii_case(PART) || target.to_ascii_lowercase().ends_with(&format!("/{PART}")) -} - /// Whether `root` is our store (and not a user's custom XML part with a coincidental name). pub(crate) fn is_state(root: &El) -> bool { root.local() == ROOT && root.attr("kind") == Some(KIND) @@ -59,6 +54,7 @@ mod tests { original_font_sizes: vec![12.0], original_line_spacings: vec![LineSpacing::Multiple(1.0)], original_paragraph_spacings: vec![20.0], + ..Default::default() } } diff --git a/crates/docx/src/read.rs b/crates/docx/src/read.rs index 9f39daecbe..79e21aa970 100644 --- a/crates/docx/src/read.rs +++ b/crates/docx/src/read.rs @@ -332,8 +332,8 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize, r.read_vba_related(&v); } r.read_glossary(&rels); + r.read_fit_state(&root_rels, &rels); r.keep_custom_xml(&root_rels, &rels); - r.read_fit_state(&rels); r.keep_unmodelled(&root, &root_rels, &rels); if !r.embedded.parts.is_empty() { let manifest = r.embedded.to_bytes(); diff --git a/crates/docx/src/read/embed.rs b/crates/docx/src/read/embed.rs index 1e896a632b..c57274222b 100644 --- a/crates/docx/src/read/embed.rs +++ b/crates/docx/src/read/embed.rs @@ -120,11 +120,10 @@ impl Reader<'_> { if ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s)) { continue; } - if crate::fit::is_part(&rel.target) { - continue; - } let root = self.xml(&rel.target).ok().flatten(); - if root.as_ref().is_some_and(crate::fit::is_state) { + if self.doc.fit_state.is_some() && root.as_ref().is_some_and(crate::fit::is_state) { + // A marked Fit One Page store is consumed whatever part name it has; the + // writer recreates it under the reserved name. continue; } if !self.doc.sources.is_empty() && root.as_ref().is_some_and(|r| r.local() == "Sources") { @@ -147,15 +146,19 @@ impl Reader<'_> { } /// Read the Fit One Page budget from our custom XML store, if the file has one. - pub(super) fn read_fit_state(&mut self, document: &Rels) { - for rel in document.list.iter().filter(|r| !r.external && rel_is(&r.kind, rt::CUSTOM_XML)).take(MAX_EMBEDDED_RELS) { - if !manifest_field_ok(&rel.target) || !crate::fit::is_part(&rel.target) { - continue; - } - let Some(root) = self.xml(&rel.target).ok().flatten() else { continue }; - if let Some(fit) = crate::fit::read(&root) { - self.doc.fit_state = Some(fit); - break; + /// + /// Inspects custom XML relationships referenced by either the document or package root. + pub(super) fn read_fit_state(&mut self, package: &Rels, document: &Rels) { + for rels in [document, package] { + for rel in rels.list.iter().filter(|r| !r.external && rel_is(&r.kind, rt::CUSTOM_XML)).take(MAX_EMBEDDED_RELS) { + if !manifest_field_ok(&rel.target) { + continue; + } + let Some(root) = self.xml(&rel.target).ok().flatten() else { continue }; + if let Some(fit) = crate::fit::read(&root) { + self.doc.fit_state = Some(fit); + return; + } } } } @@ -360,6 +363,102 @@ mod tests { zw.finish().unwrap().into_inner() } + /// A package whose main part relates to one custom XML store. + fn custom_xml_package(target: &str, part: &str, bytes: &[u8]) -> Vec { + let doc = format!(r#""#); + let r = format!( + r#""# + ); + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + zw.start_file("[Content_Types].xml", opts).unwrap(); + zw.write_all(CONTENT_TYPES.as_bytes()).unwrap(); + zw.start_file("_rels/.rels", opts).unwrap(); + zw.write_all(ROOT_RELS.as_bytes()).unwrap(); + zw.start_file("word/document.xml", opts).unwrap(); + zw.write_all(doc.as_bytes()).unwrap(); + zw.start_file("word/_rels/document.xml.rels", opts).unwrap(); + zw.write_all(r.as_bytes()).unwrap(); + zw.start_file(part, opts).unwrap(); + zw.write_all(bytes).unwrap(); + zw.finish().unwrap().into_inner() + } + + /// A package whose package-level relationships refer to one custom XML store. + fn package_level_custom_xml_package(target: &str, part: &str, bytes: &[u8]) -> Vec { + let doc = format!(r#""#); + let root_rels = format!( + r#""# + ); + let r = r#""#; + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + zw.start_file("[Content_Types].xml", opts).unwrap(); + zw.write_all(CONTENT_TYPES.as_bytes()).unwrap(); + zw.start_file("_rels/.rels", opts).unwrap(); + zw.write_all(root_rels.as_bytes()).unwrap(); + zw.start_file("word/document.xml", opts).unwrap(); + zw.write_all(doc.as_bytes()).unwrap(); + zw.start_file("word/_rels/document.xml.rels", opts).unwrap(); + zw.write_all(r.as_bytes()).unwrap(); + zw.start_file(part, opts).unwrap(); + zw.write_all(bytes).unwrap(); + zw.finish().unwrap().into_inner() + } + + /// The marked store is recognized by its root, so a renamed part still restores the budget. + #[test] + fn a_marked_fit_state_loads_from_a_renamed_custom_xml_part() { + let fit = wordcraft_doc::FitState { cum_delta: 1.0, cum_space: 0.94, ..Default::default() }; + let state = crate::fit::write(&fit).unwrap(); + let bytes = custom_xml_package("../customxml/item2.xml", "customxml/item2.xml", &state); + let doc = crate::read(&bytes).unwrap(); + assert_eq!(doc.fit_state, Some(fit)); + assert!(!doc.passthrough.keys().any(|k| k.eq_ignore_ascii_case("customxml/item2.xml")), "the consumed store must not be kept in passthrough"); + } + + /// A package-level custom XML relationship restores the budget and survives saving. + #[test] + fn a_marked_fit_state_loads_from_a_package_level_custom_xml_relationship() { + let fit = wordcraft_doc::FitState { cum_delta: 1.0, cum_space: 0.94, ..Default::default() }; + let state = crate::fit::write(&fit).unwrap(); + let bytes = package_level_custom_xml_package("customxml/item2.xml", "customxml/item2.xml", &state); + let doc = crate::read(&bytes).unwrap(); + assert_eq!(doc.fit_state, Some(fit)); + assert!(!doc.passthrough.keys().any(|k| k.eq_ignore_ascii_case("customxml/item2.xml")), "the consumed store must not be kept in passthrough"); + let saved = crate::write(&doc).unwrap(); + let reopened = crate::read(&saved).unwrap(); + assert_eq!(reopened.fit_state, doc.fit_state, "the cumulative budget must survive saving"); + } + + /// A user's custom XML that happens to use our part name is not consumed. + #[test] + fn unrelated_custom_xml_at_the_reserved_fit_name_is_kept() { + let bytes = custom_xml_package("../customxml/wordcraftfit.xml", "customxml/wordcraftfit.xml", b""); + let doc = crate::read(&bytes).unwrap(); + assert!(doc.fit_state.is_none()); + assert!( + doc.passthrough.keys().any(|k| k.eq_ignore_ascii_case("customxml/wordcraftfit.xml")), + "an unrelated store at the reserved name must survive" + ); + } + + /// When fit_state is set and an unrelated custom XML store uses the reserved name, both survive saving. + #[test] + fn unrelated_custom_xml_at_reserved_name_survives_alongside_fit_state() { + let bytes = custom_xml_package("../customxml/wordcraftfit.xml", "customxml/wordcraftfit.xml", b""); + let mut doc = crate::read(&bytes).unwrap(); + let fit = wordcraft_doc::FitState { cum_delta: 1.0, cum_space: 0.94, ..Default::default() }; + doc.fit_state = Some(fit.clone()); + let saved = crate::write(&doc).expect("save must not clash or produce duplicate parts"); + let reopened = crate::read(&saved).expect("reopen must succeed"); + assert_eq!(reopened.fit_state, Some(fit)); + assert!( + reopened.passthrough.keys().any(|k| k.starts_with("customxml/")), + "the unrelated custom XML store must survive saving under an allocated name" + ); + } + /// The parts the reader carries over unchanged, as one string. fn unmodelled_roots(d: &wordcraft_doc::Document) -> String { d.passthrough.get(UNMODELLED_ROOTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default() diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index d8f40a209e..baaeae42a7 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -54,6 +54,7 @@ pub(crate) struct EmbedWriter<'d> { } impl<'d> EmbedWriter<'d> { + /// Creates a new embedded parts writer initialized with default reserved package part paths. pub fn new(doc: &'d wordcraft_doc::Document) -> EmbedWriter<'d> { let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default(); let mut taken: HashSet = [ @@ -78,6 +79,10 @@ impl<'d> EmbedWriter<'d> { if !doc.sources.is_empty() { taken.extend(["customxml/item1.xml".into(), "customxml/itemprops1.xml".into()]); } + // The Fit One Page store is written at this path on save. A kept part must not take it. + if doc.fit_state.is_some() { + taken.insert(crate::fit::PART.to_ascii_lowercase()); + } // The macro project's parts keep their names (see `write_as`). if let Some(m) = doc.passthrough.get(VBA_RELATED) { for line in String::from_utf8_lossy(m).lines() { diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 8a917e329e..29bbc37765 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -10,6 +10,7 @@ use wordcraft_doc::{Block, Pos, StoryRef}; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; +/// Returns the command specifications for the Layout tab (page setup, breaks, line numbers). pub fn specs() -> Vec { vec![ CommandSpec::new("layout.margins", "Margins", "Layout › Page Setup", margins).params( diff --git a/crates/engine/src/cmd/page/fit_page.rs b/crates/engine/src/cmd/page/fit_page.rs index 9219ce5650..9339baf006 100644 --- a/crates/engine/src/cmd/page/fit_page.rs +++ b/crates/engine/src/cmd/page/fit_page.rs @@ -4,6 +4,7 @@ //! layout evaluation, minimal typographical perturbation, and strict readability //! safeguards (line spacing floor and font size floor). +use std::collections::BTreeMap; use std::sync::Arc; use serde_json::Value; @@ -392,7 +393,6 @@ fn collect_body_font_sizes(doc: &Document) -> Vec { collect_blocks_font_sizes(doc, &part.blocks, None, &mut sizes); } } - sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); sizes } @@ -431,17 +431,6 @@ fn collect_body_line_spacings(doc: &Document) -> Vec { - x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal) - } - (wordcraft_doc::props::LineSpacing::Multiple(_), _) => std::cmp::Ordering::Less, - (_, wordcraft_doc::props::LineSpacing::Multiple(_)) => std::cmp::Ordering::Greater, - (wordcraft_doc::props::LineSpacing::AtLeast(_), _) => std::cmp::Ordering::Less, - (_, wordcraft_doc::props::LineSpacing::AtLeast(_)) => std::cmp::Ordering::Greater, - }); spacings } @@ -481,87 +470,319 @@ fn collect_body_paragraph_spacings(doc: &Document) -> Vec { collect_blocks_paragraph_spacings(doc, &part.blocks, None, &mut spacings); } } - spacings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); spacings } +/// Recursively collects effective font sizes by formatting location key across a slice of blocks. +fn collect_blocks_font_locations( + doc: &Document, + prefix: &str, + blocks: &[Arc], + table_chr: Option<&wordcraft_doc::CharProps>, + out: &mut BTreeMap, +) { + use wordcraft_doc::styles::PartSet; + + for (bi, b) in blocks.iter().enumerate() { + let block_prefix = format!("{prefix}/{bi}"); + if let Some(p) = b.as_para() { + let style_id = p.props.style.as_deref(); + if p.runs.is_empty() { + let mark_resolved = doc.styles.resolve_char_in(style_id, table_chr, &p.mark); + out.insert(format!("{block_prefix}/mark/size"), mark_resolved.size); + out.insert(format!("{block_prefix}/mark/size_cs"), mark_resolved.size_cs); + } + for (ri, r) in p.runs.iter().enumerate() { + let resolved = doc.styles.resolve_char_in(style_id, table_chr, &r.props); + if !matches!(resolved.vert_align, wordcraft_doc::props::VertAlign::Superscript | wordcraft_doc::props::VertAlign::Subscript) { + out.insert(format!("{block_prefix}/r{ri}/size"), resolved.size); + out.insert(format!("{block_prefix}/r{ri}/size_cs"), resolved.size_cs); + } + } + } else if let Some(tbl) = b.as_table() { + let style = tbl.props.style.as_deref().and_then(|id| doc.styles.table_style(id)); + let nrows = tbl.rows.len(); + let ncols = tbl.cols(); + for (ri, row) in tbl.rows.iter().enumerate() { + for (ci, cell) in row.cells.iter().enumerate() { + let mut cell_chr = wordcraft_doc::CharProps::default(); + if let Some(st) = &style { + let sizes = (st.parts.band_size.unwrap_or(0), st.parts.col_band_size.unwrap_or(0)); + let g = tbl.grid_col(ri, ci); + let span = cell.span(); + let region = PartSet::of_cell(&tbl.props.look, sizes, (ri, nrows), (g, span, ncols)); + cell_chr.overlay(&st.chr); + for part in region.iter() { + let f = st.part(part); + cell_chr.overlay(&f.chr); + } + } + let cell_prefix = format!("{block_prefix}/tbl/r{ri}/c{ci}"); + collect_blocks_font_locations(doc, &cell_prefix, &cell.blocks, Some(&cell_chr), out); + } + } + } + } +} + +/// Collects all effective font sizes by formatting location key across body paragraphs and editable parts. +fn collect_body_font_locations(doc: &Document) -> BTreeMap { + let mut out = BTreeMap::new(); + collect_blocks_font_locations(doc, "body", &doc.body, None, &mut out); + let hf_boxes = wordcraft_doc::shrink::header_footer_text_boxes(doc); + for (id, part) in &doc.parts { + if !matches!(part.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer | wordcraft_doc::PartKind::Comment) + && !hf_boxes.contains(id) + { + let prefix = format!("part:{id}"); + collect_blocks_font_locations(doc, &prefix, &part.blocks, None, &mut out); + } + } + out +} + +/// Recursively collects effective line spacing by formatting location key across a slice of blocks. +fn collect_blocks_line_spacing_locations( + doc: &Document, + prefix: &str, + blocks: &[Arc], + table_para: Option<&wordcraft_doc::ParaProps>, + out: &mut BTreeMap, +) { + for (bi, b) in blocks.iter().enumerate() { + let block_prefix = format!("{prefix}/{bi}"); + if let Some(p) = b.as_para() { + let resolved = doc.styles.resolve_para_in(&p.props, table_para); + out.insert(format!("{block_prefix}/line_spacing"), resolved.line_spacing); + } else if let Some(tbl) = b.as_table() { + let style = tbl.props.style.as_deref().and_then(|id| doc.styles.table_style(id)); + let cell_para = style.as_ref().map(|st| &st.para); + for (ri, row) in tbl.rows.iter().enumerate() { + for (ci, cell) in row.cells.iter().enumerate() { + let cell_prefix = format!("{block_prefix}/tbl/r{ri}/c{ci}"); + collect_blocks_line_spacing_locations(doc, &cell_prefix, &cell.blocks, cell_para, out); + } + } + } + } +} + +/// Collects all effective line spacing values by formatting location key across body paragraphs and editable parts. +fn collect_body_line_spacing_locations(doc: &Document) -> BTreeMap { + let mut out = BTreeMap::new(); + collect_blocks_line_spacing_locations(doc, "body", &doc.body, None, &mut out); + let hf_boxes = wordcraft_doc::shrink::header_footer_text_boxes(doc); + for (id, part) in &doc.parts { + if !matches!(part.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer | wordcraft_doc::PartKind::Comment) + && !hf_boxes.contains(id) + { + let prefix = format!("part:{id}"); + collect_blocks_line_spacing_locations(doc, &prefix, &part.blocks, None, &mut out); + } + } + out +} + +/// Recursively collects effective paragraph spacing by formatting location key across a slice of blocks. +fn collect_blocks_paragraph_spacing_locations( + doc: &Document, + prefix: &str, + blocks: &[Arc], + table_para: Option<&wordcraft_doc::ParaProps>, + out: &mut BTreeMap, +) { + for (bi, b) in blocks.iter().enumerate() { + let block_prefix = format!("{prefix}/{bi}"); + if let Some(p) = b.as_para() { + let resolved = doc.styles.resolve_para_in(&p.props, table_para); + out.insert(format!("{block_prefix}/space_before"), resolved.space_before); + out.insert(format!("{block_prefix}/space_after"), resolved.space_after); + } else if let Some(tbl) = b.as_table() { + let style = tbl.props.style.as_deref().and_then(|id| doc.styles.table_style(id)); + let cell_para = style.as_ref().map(|st| &st.para); + for (ri, row) in tbl.rows.iter().enumerate() { + for (ci, cell) in row.cells.iter().enumerate() { + let cell_prefix = format!("{block_prefix}/tbl/r{ri}/c{ci}"); + collect_blocks_paragraph_spacing_locations(doc, &cell_prefix, &cell.blocks, cell_para, out); + } + } + } + } +} + +/// Collects all effective paragraph spacing values by formatting location key across body paragraphs and editable parts. +fn collect_body_paragraph_spacing_locations(doc: &Document) -> BTreeMap { + let mut out = BTreeMap::new(); + collect_blocks_paragraph_spacing_locations(doc, "body", &doc.body, None, &mut out); + let hf_boxes = wordcraft_doc::shrink::header_footer_text_boxes(doc); + for (id, part) in &doc.parts { + if !matches!(part.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer | wordcraft_doc::PartKind::Comment) + && !hf_boxes.contains(id) + { + let prefix = format!("part:{id}"); + collect_blocks_paragraph_spacing_locations(doc, &prefix, &part.blocks, None, &mut out); + } + } + out +} + /// Checks whether previously reduced text from an earlier fit is still present in the document. /// -/// Compares each current value against the original values recorded before the first fit, within -/// the cumulative reduction that fit applied. An exact match with the original multiset means every -/// size was restored, even when one original value sits inside another value's reduction interval. +/// Compares each formatting location against its recorded original value before the first fit, +/// within the cumulative reduction that fit applied. #[cfg(test)] fn has_previously_reduced_text(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { (fit.cum_delta > 0.0 && font_reduction_present(doc, fit)) || (fit.cum_space < 1.0 - 1e-4 && spacing_reduction_present(doc, fit)) } -fn same_sizes(a: &[f32], b: &[f32]) -> bool { - a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4) -} - -fn same_spacings(a: &[wordcraft_doc::props::LineSpacing], b: &[wordcraft_doc::props::LineSpacing]) -> bool { - a.len() == b.len() - && a.iter().zip(b).all(|(x, y)| match (x, y) { - (wordcraft_doc::props::LineSpacing::Multiple(c), wordcraft_doc::props::LineSpacing::Multiple(o)) - | (wordcraft_doc::props::LineSpacing::AtLeast(c), wordcraft_doc::props::LineSpacing::AtLeast(o)) - | (wordcraft_doc::props::LineSpacing::Exactly(c), wordcraft_doc::props::LineSpacing::Exactly(o)) => (c - o).abs() < 1e-4, - _ => false, - }) -} - +/// Checks whether any font reduction from an earlier fit remains active in `doc`. +/// +/// Compares each formatting location against its recorded original value before the fit. +/// Treats locations without a recorded key as unknown and preserves the active budget when +/// counts or keys do not match; clears it only when matching locations establish that +/// reductions are fully restored. fn font_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { - if fit.original_font_sizes.is_empty() { - return false; + if fit.original_font_locations.is_empty() { + if fit.original_font_sizes.is_empty() { + return false; + } + let current_sizes = collect_body_font_sizes(doc); + return current_sizes.iter().any(|&cur| { + fit.original_font_sizes.iter().any(|&orig| { + let floor = orig - fit.cum_delta - 1e-4; + cur >= floor && cur < orig - 1e-4 + }) + }); } - let current_sizes = collect_body_font_sizes(doc); - if same_sizes(¤t_sizes, &fit.original_font_sizes) { - return false; - } - current_sizes.iter().any(|&cur| { - fit.original_font_sizes.iter().any(|&orig| { + + let current = collect_body_font_locations(doc); + let mut matching_count = 0; + for (key, &orig) in &fit.original_font_locations { + if let Some(&cur) = current.get(key) { + matching_count += 1; let floor = orig - fit.cum_delta - 1e-4; - cur >= floor && cur < orig - 1e-4 - }) - }) -} - -fn line_spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { - if fit.original_line_spacings.is_empty() { - return false; - } - let current_spacings = collect_body_line_spacings(doc); - if same_spacings(¤t_spacings, &fit.original_line_spacings) { - return false; - } - current_spacings.iter().any(|cur| { - fit.original_line_spacings.iter().any(|orig| match (cur, orig) { - (wordcraft_doc::props::LineSpacing::Multiple(c), wordcraft_doc::props::LineSpacing::Multiple(o)) - | (wordcraft_doc::props::LineSpacing::AtLeast(c), wordcraft_doc::props::LineSpacing::AtLeast(o)) - | (wordcraft_doc::props::LineSpacing::Exactly(c), wordcraft_doc::props::LineSpacing::Exactly(o)) => { - let floor = o * fit.cum_space - 1e-4; - *c >= floor && *c < *o - 1e-4 + if cur >= floor && cur < orig - 1e-4 { + return true; } - _ => false, - }) - }) + } + } + + if matching_count != fit.original_font_locations.len() || current.len() != fit.original_font_locations.len() { + return true; + } + + let all_restored = fit.original_font_locations.iter().all(|(key, &orig)| current.get(key).map(|&cur| cur >= orig - 1e-4).unwrap_or(false)); + + !all_restored } +/// Checks whether any line spacing reduction from an earlier fit remains active in `doc`. +/// +/// Compares each formatting location against its recorded original line spacing before the fit. +/// Treats locations without a recorded key as unknown and preserves the active budget when +/// counts or keys do not match; clears it only when matching locations establish that +/// reductions are fully restored. +fn line_spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { + if fit.original_line_spacing_locations.is_empty() { + if fit.original_line_spacings.is_empty() { + return false; + } + let current_spacings = collect_body_line_spacings(doc); + return current_spacings.iter().any(|cur| { + fit.original_line_spacings.iter().any(|orig| match (cur, orig) { + (wordcraft_doc::props::LineSpacing::Multiple(c), wordcraft_doc::props::LineSpacing::Multiple(o)) + | (wordcraft_doc::props::LineSpacing::AtLeast(c), wordcraft_doc::props::LineSpacing::AtLeast(o)) + | (wordcraft_doc::props::LineSpacing::Exactly(c), wordcraft_doc::props::LineSpacing::Exactly(o)) => { + let floor = o * fit.cum_space - 1e-4; + *c >= floor && *c < *o - 1e-4 + } + _ => false, + }) + }); + } + + let current = collect_body_line_spacing_locations(doc); + let mut matching_count = 0; + for (key, orig) in &fit.original_line_spacing_locations { + if let Some(cur) = current.get(key) { + matching_count += 1; + let is_reduced = match (cur, orig) { + (wordcraft_doc::props::LineSpacing::Multiple(c), wordcraft_doc::props::LineSpacing::Multiple(o)) + | (wordcraft_doc::props::LineSpacing::AtLeast(c), wordcraft_doc::props::LineSpacing::AtLeast(o)) + | (wordcraft_doc::props::LineSpacing::Exactly(c), wordcraft_doc::props::LineSpacing::Exactly(o)) => { + let floor = o * fit.cum_space - 1e-4; + *c >= floor && *c < *o - 1e-4 + } + _ => false, + }; + if is_reduced { + return true; + } + } + } + + if matching_count != fit.original_line_spacing_locations.len() || current.len() != fit.original_line_spacing_locations.len() { + return true; + } + + let all_restored = fit.original_line_spacing_locations.iter().all(|(key, orig)| { + current + .get(key) + .map(|cur| match (cur, orig) { + (wordcraft_doc::props::LineSpacing::Multiple(c), wordcraft_doc::props::LineSpacing::Multiple(o)) + | (wordcraft_doc::props::LineSpacing::AtLeast(c), wordcraft_doc::props::LineSpacing::AtLeast(o)) + | (wordcraft_doc::props::LineSpacing::Exactly(c), wordcraft_doc::props::LineSpacing::Exactly(o)) => *c >= *o - 1e-4, + _ => false, + }) + .unwrap_or(false) + }); + + !all_restored +} + +/// Checks whether any paragraph spacing reduction from an earlier fit remains active in `doc`. +/// +/// Compares each formatting location against its recorded original paragraph spacing before the fit. +/// Treats locations without a recorded key as unknown and preserves the active budget when +/// counts or keys do not match; clears it only when matching locations establish that +/// reductions are fully restored. fn paragraph_spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { - if fit.original_paragraph_spacings.is_empty() { - return false; + if fit.original_paragraph_spacing_locations.is_empty() { + if fit.original_paragraph_spacings.is_empty() { + return false; + } + let current_spacings = collect_body_paragraph_spacings(doc); + return current_spacings.iter().any(|&cur| { + fit.original_paragraph_spacings.iter().any(|&orig| { + let floor = orig * fit.cum_space - 1e-4; + cur >= floor && cur < orig - 1e-4 + }) + }); } - let current_spacings = collect_body_paragraph_spacings(doc); - if same_sizes(¤t_spacings, &fit.original_paragraph_spacings) { - return false; - } - current_spacings.iter().any(|&cur| { - fit.original_paragraph_spacings.iter().any(|&orig| { + + let current = collect_body_paragraph_spacing_locations(doc); + let mut matching_count = 0; + for (key, &orig) in &fit.original_paragraph_spacing_locations { + if let Some(&cur) = current.get(key) { + matching_count += 1; let floor = orig * fit.cum_space - 1e-4; - cur >= floor && cur < orig - 1e-4 - }) - }) + if cur >= floor && cur < orig - 1e-4 { + return true; + } + } + } + + if matching_count != fit.original_paragraph_spacing_locations.len() || current.len() != fit.original_paragraph_spacing_locations.len() { + return true; + } + + let all_restored = + fit.original_paragraph_spacing_locations.iter().all(|(key, &orig)| current.get(key).map(|&cur| cur >= orig - 1e-4).unwrap_or(false)); + + !all_restored } +/// Checks whether line or paragraph spacing reductions from an earlier fit remain active in `doc`. fn spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { line_spacing_reduction_present(doc, fit) || paragraph_spacing_reduction_present(doc, fit) } @@ -575,6 +796,7 @@ struct ActiveBudget { spacing_active: bool, } +/// Resolves the active cumulative budget for font and spacing components in `doc`. fn active_budget(doc: &Document) -> ActiveBudget { let Some(fit) = doc.fit_state.as_ref() else { return ActiveBudget { delta: 0.0, space: 1.0, font_active: false, spacing_active: false }; @@ -589,15 +811,33 @@ fn active_budget(doc: &Document) -> ActiveBudget { } } +/// The original typography baseline recorded before the first fit. +#[derive(Clone, Debug, Default)] +struct OriginalTypography { + font_sizes: Vec, + line_spacings: Vec, + paragraph_spacings: Vec, + font_locations: BTreeMap, + line_spacing_locations: BTreeMap, + paragraph_spacing_locations: BTreeMap, +} + +impl OriginalTypography { + /// Collects the typography baseline from the body and editable parts of `doc`. + fn from_doc(doc: &Document) -> Self { + Self { + font_sizes: collect_body_font_sizes(doc), + line_spacings: collect_body_line_spacings(doc), + paragraph_spacings: collect_body_paragraph_spacings(doc), + font_locations: collect_body_font_locations(doc), + line_spacing_locations: collect_body_line_spacing_locations(doc), + paragraph_spacing_locations: collect_body_paragraph_spacing_locations(doc), + } + } +} + /// Records the cumulative Fit One Page reduction state into the document's internal fit tracking. -fn set_cumulative_shrink_state( - doc: &mut Document, - delta: f32, - space: f32, - original_font_sizes: Vec, - original_line_spacings: Vec, - original_paragraph_spacings: Vec, -) { +fn set_cumulative_shrink_state(doc: &mut Document, delta: f32, space: f32, orig: OriginalTypography) { let sizes = collect_body_font_sizes(doc); let spacings = collect_body_line_spacings(doc); let paragraph_spacings = collect_body_paragraph_spacings(doc); @@ -607,9 +847,12 @@ fn set_cumulative_shrink_state( reduced_font_sizes: sizes, reduced_line_spacings: spacings, reduced_paragraph_spacings: paragraph_spacings, - original_font_sizes, - original_line_spacings, - original_paragraph_spacings, + original_font_sizes: orig.font_sizes, + original_line_spacings: orig.line_spacings, + original_paragraph_spacings: orig.paragraph_spacings, + original_font_locations: orig.font_locations, + original_line_spacing_locations: orig.line_spacing_locations, + original_paragraph_spacing_locations: orig.paragraph_spacing_locations, }); } @@ -703,7 +946,17 @@ fn fit_layout_options(s: &Session) -> LayoutOptions { /// Refuses while Track Changes and Track Formatting are on, because the style /// reductions it needs cannot be recorded as formatting revisions. pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { - // 0. Refuse while formatting changes must be tracked: fitting rescales styles, and the + // 0. Parse parameters safely + let max_delta = p::f32(v, "maxDeltaPt").unwrap_or(2.0).clamp(0.0, 10.0); + let step_pt_opt = p::f32(v, "stepPt"); + if let Some(step) = step_pt_opt + && !(step > 0.0 && step.is_finite()) + { + return Err(CmdError::Params("`stepPt` must be a positive number of points".into())); + } + let adjust_spacing = p::bool(v, "adjustSpacing").unwrap_or(true); + + // 1. Refuse while formatting changes must be tracked: fitting rescales styles, and the // formatting-recording path compares direct run/paragraph properties only, so the // inherited reductions could not be represented as revisions. if s.doc.settings.track_changes && s.prefs.markup.track_formatting { @@ -712,26 +965,21 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { )); } - // 1. Anti-compound guard: each component has its own budget, so a restored font or spacing - // budget is not blocked by the other component's remaining reduction. + // 2. Resolve the cumulative budget. Each component has its own, so a restored font or + // spacing budget is not blocked by the other component's remaining reduction. let prior_fit = s.doc.fit_state.clone(); let budget = active_budget(&s.doc); let (cum_delta, cum_space) = (budget.delta, budget.space); let (font_active, spacing_active) = (budget.font_active, budget.spacing_active); let font_exhausted = cum_delta >= 1.0 - 1e-4; let spacing_exhausted = cum_space <= 0.94 + 1e-4; - if font_exhausted && spacing_exhausted { - return Err(CmdError::Failed("Unable to shrink document further without reducing text beyond readability limits.".into())); - } - // 2. Build equivalent Print layout options + // 3. Build equivalent Print layout options let print_opts = fit_layout_options(s); let shown_hidden = print_opts.show_hidden; - let pre_font_sizes = collect_body_font_sizes(&s.doc); - let pre_line_spacings = collect_body_line_spacings(&s.doc); - let pre_paragraph_spacings = collect_body_paragraph_spacings(&s.doc); + let pre_typography = OriginalTypography::from_doc(&s.doc); - // 3. Initial page count evaluation + // 4. Initial page count evaluation let mut initial_cache = LayoutCache::new(); let initial_layout = wordcraft_layout::layout(&s.doc, &mut initial_cache, &print_opts); let p0 = initial_layout.pages.len(); @@ -739,6 +987,9 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { if p0 <= 1 { return Err(CmdError::Failed("The document already fits on one page.".into())); } + if font_exhausted && spacing_exhausted { + return Err(CmdError::Failed("Unable to shrink document further without reducing text beyond readability limits.".into())); + } // Explicit breaks force pages regardless of typography; refuse before any trial can appear to // succeed by moving text around them. @@ -746,7 +997,7 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { return Err(CmdError::Failed("Unable to fit document to one page: explicit page or section breaks prevent page reduction.".into())); } - // 4. Pre-flight check on final page content volume + // 5. Pre-flight check on final page content volume let last_page = &initial_layout.pages[p0 - 1]; let (last_page_lines, fill_ratio) = final_page_volume(&s.doc, last_page); if last_page_lines > 12 || fill_ratio > 0.30 { @@ -756,11 +1007,6 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { } let target_pages = p0.saturating_sub(1); - // 5. Parse parameters safely - let max_delta = p::f32(v, "maxDeltaPt").unwrap_or(2.0).clamp(0.0, 10.0); - let step_pt_opt = p::f32(v, "stepPt"); - let adjust_spacing = p::bool(v, "adjustSpacing").unwrap_or(true); - // 6. Generate candidate steps: either bounded fine-grained stepPt or pre-calculated steps let candidates = generate_candidate_steps(max_delta, step_pt_opt, adjust_spacing); @@ -842,17 +1088,27 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { .map(|fit| fit.original_paragraph_spacings.clone()) .filter(|spacings| !spacings.is_empty()) }; - let original_font_sizes = prior_original_fonts().unwrap_or(pre_font_sizes); - let original_line_spacings = prior_original_lines().unwrap_or(pre_line_spacings); - let original_paragraph_spacings = prior_original_paragraphs().unwrap_or(pre_paragraph_spacings); - set_cumulative_shrink_state( - &mut new_doc, - cum_delta + d, - cum_space * a, - original_font_sizes, - original_line_spacings, - original_paragraph_spacings, - ); + let prior_original_font_locs = + || prior_fit.as_ref().filter(|_| font_active).map(|fit| fit.original_font_locations.clone()).filter(|locs| !locs.is_empty()); + let prior_original_line_locs = || { + prior_fit.as_ref().filter(|_| spacing_active).map(|fit| fit.original_line_spacing_locations.clone()).filter(|locs| !locs.is_empty()) + }; + let prior_original_para_locs = || { + prior_fit + .as_ref() + .filter(|_| spacing_active) + .map(|fit| fit.original_paragraph_spacing_locations.clone()) + .filter(|locs| !locs.is_empty()) + }; + let original_typography = OriginalTypography { + font_sizes: prior_original_fonts().unwrap_or(pre_typography.font_sizes), + line_spacings: prior_original_lines().unwrap_or(pre_typography.line_spacings), + paragraph_spacings: prior_original_paragraphs().unwrap_or(pre_typography.paragraph_spacings), + font_locations: prior_original_font_locs().unwrap_or(pre_typography.font_locations), + line_spacing_locations: prior_original_line_locs().unwrap_or(pre_typography.line_spacing_locations), + paragraph_spacing_locations: prior_original_para_locs().unwrap_or(pre_typography.paragraph_spacing_locations), + }; + set_cumulative_shrink_state(&mut new_doc, cum_delta + d, cum_space * a, original_typography); s.doc = new_doc; s.clamp_selection(); let page_word = if final_pages == 1 { "page" } else { "pages" }; @@ -1004,11 +1260,9 @@ mod tests { } } } - let originals = collect_body_font_sizes(&doc); - let originals_spacings = collect_body_line_spacings(&doc); - let originals_paragraphs = collect_body_paragraph_spacings(&doc); + let orig = OriginalTypography::from_doc(&doc); wordcraft_doc::shrink::fit_document_typography(&mut doc, 1.0, 1.0); - set_cumulative_shrink_state(&mut doc, 1.0, 1.0, originals, originals_spacings, originals_paragraphs); + set_cumulative_shrink_state(&mut doc, 1.0, 1.0, orig); assert!(has_previously_reduced_text(&doc, doc.fit_state.as_ref().unwrap())); // Restoring only the heading leaves the body reduced. @@ -1044,11 +1298,9 @@ mod tests { p.props.space_before = Some(0.0); p.props.space_after = Some(0.0); } - let originals = collect_body_font_sizes(&doc); - let originals_spacings = collect_body_line_spacings(&doc); - let originals_paragraphs = collect_body_paragraph_spacings(&doc); + let orig = OriginalTypography::from_doc(&doc); wordcraft_doc::shrink::fit_document_typography(&mut doc, 0.0, 0.9); - set_cumulative_shrink_state(&mut doc, 0.0, 0.9, originals, originals_spacings, originals_paragraphs); + set_cumulative_shrink_state(&mut doc, 0.0, 0.9, orig); assert!(has_previously_reduced_text(&doc, doc.fit_state.as_ref().unwrap())); // Restoring the 2.0 spacing but leaving the 1.0 one reduced keeps the budget. @@ -1076,11 +1328,9 @@ mod tests { r.props.size = Some(11.0); } } - let originals = collect_body_font_sizes(&doc); - let originals_spacings = collect_body_line_spacings(&doc); - let originals_paragraphs = collect_body_paragraph_spacings(&doc); + let orig = OriginalTypography::from_doc(&doc); wordcraft_doc::shrink::fit_document_typography(&mut doc, 1.0, 1.0); - set_cumulative_shrink_state(&mut doc, 1.0, 1.0, originals, originals_spacings, originals_paragraphs); + set_cumulative_shrink_state(&mut doc, 1.0, 1.0, orig); assert!(font_reduction_present(&doc, doc.fit_state.as_ref().unwrap())); // Restoring 12 pt and 11 pt makes 11 pt look like another size's reduction interval; the @@ -1108,11 +1358,9 @@ mod tests { p.props.space_after = Some(0.0); } } - let originals = collect_body_font_sizes(&doc); - let originals_spacings = collect_body_line_spacings(&doc); - let originals_paragraphs = collect_body_paragraph_spacings(&doc); + let orig = OriginalTypography::from_doc(&doc); wordcraft_doc::shrink::fit_document_typography(&mut doc, 0.0, 0.94); - set_cumulative_shrink_state(&mut doc, 0.0, 0.94, originals, originals_spacings, originals_paragraphs); + set_cumulative_shrink_state(&mut doc, 0.0, 0.94, orig); // Exactly(8.0) rounds back to 8.0, but 20 pt paragraph spacing becomes 18.8 pt. assert!(!line_spacing_reduction_present(&doc, doc.fit_state.as_ref().unwrap())); @@ -1142,11 +1390,9 @@ mod tests { } } } - let originals = collect_body_font_sizes(&doc); - let originals_spacings = collect_body_line_spacings(&doc); - let originals_paragraphs = collect_body_paragraph_spacings(&doc); + let orig = OriginalTypography::from_doc(&doc); wordcraft_doc::shrink::fit_document_typography(&mut doc, 1.0, 0.9); - set_cumulative_shrink_state(&mut doc, 1.0, 0.9, originals, originals_spacings, originals_paragraphs); + set_cumulative_shrink_state(&mut doc, 1.0, 0.9, orig); let both = active_budget(&doc); assert_eq!(both.delta, 1.0); @@ -1202,4 +1448,79 @@ mod tests { let fitted = wordcraft_layout::layout(&s.doc, &mut cache, &opts); assert_eq!(fitted.pages.len(), s.layout().pages.len()); } + + #[test] + fn provenance_tracks_reductions_per_location_not_sorted_multisets() { + let mut doc = Document::from_text("Alpha\nBeta"); + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { + for r in &mut p.runs { + r.props.size = Some(12.0); + } + } + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { + for r in &mut p.runs { + r.props.size = Some(11.0); + } + } + let orig = OriginalTypography::from_doc(&doc); + wordcraft_doc::shrink::fit_document_typography(&mut doc, 1.0, 1.0); + set_cumulative_shrink_state(&mut doc, 1.0, 1.0, orig); + + // Setting the second run (reduced to 10 pt) to 12 pt yields sorted sizes [11.0, 12.0], + // matching the sorted originals [11.0, 12.0]. However, the first run (Slot 0) is still reduced + // from 12 pt to 11 pt, so the active budget must not be cleared. + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { + for r in &mut p.runs { + r.props.size = Some(12.0); + } + } + assert!(font_reduction_present(&doc, doc.fit_state.as_ref().unwrap()), "Slot 0 is still reduced"); + assert_eq!(active_budget(&doc).delta, 1.0); + + // Restoring both runs to their respective original locations clears the budget. + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { + for r in &mut p.runs { + r.props.size = Some(12.0); + } + } + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { + for r in &mut p.runs { + r.props.size = Some(11.0); + } + } + assert!(!font_reduction_present(&doc, doc.fit_state.as_ref().unwrap())); + assert_eq!(active_budget(&doc).delta, 0.0); + } + + #[test] + fn paragraph_swap_preserves_active_budget() { + let mut doc = Document::from_text("Alpha\nBeta"); + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { + for r in &mut p.runs { + r.props.size = Some(12.0); + } + } + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { + for r in &mut p.runs { + r.props.size = Some(11.0); + } + } + let orig = OriginalTypography::from_doc(&doc); + wordcraft_doc::shrink::fit_document_typography(&mut doc, 1.0, 1.0); + set_cumulative_shrink_state(&mut doc, 1.0, 1.0, orig); + + // Swap the two paragraphs: doc.body[0] is now Beta (10 pt), doc.body[1] is now Alpha (11 pt). + doc.body.swap(0, 1); + assert!(font_reduction_present(&doc, doc.fit_state.as_ref().unwrap()), "reordered paragraphs must preserve active budget"); + assert_eq!(active_budget(&doc).delta, 1.0); + } + + #[test] + fn step_pt_must_be_positive_and_finite() { + let mut s = Session::new(Document::from_text("Alpha\nBeta")); + let err_zero = s.run("layout.fitOnePage", &serde_json::json!({"stepPt": 0.0})); + assert!(matches!(err_zero, Err(CmdError::Params(_)))); + let err_neg = s.run("layout.fitOnePage", &serde_json::json!({"stepPt": -0.5})); + assert!(matches!(err_neg, Err(CmdError::Params(_)))); + } } diff --git a/crates/engine/src/tests/fit_one_page.rs b/crates/engine/src/tests/fit_one_page.rs index 1bbaaf5abe..a7458bf8a2 100644 --- a/crates/engine/src/tests/fit_one_page.rs +++ b/crates/engine/src/tests/fit_one_page.rs @@ -284,6 +284,34 @@ fn fit_one_page_anti_compound_guard_prevents_repeated_invocations() { assert_eq!(s.doc, doc_before, "document must not be mutated on rejected invocation"); } +#[test] +fn fit_one_page_already_fits_wins_over_exhausted_budget() { + let mut s = create_multiline_doc(5); + assert_eq!(s.layout().pages.len(), 1); + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(0.94)); + for r in &mut p.runs { + r.props.size = Some(11.0); + } + } + } + s.doc.fit_state = Some(wordcraft_doc::FitState { + cum_delta: 1.0, + cum_space: 0.94, + original_font_sizes: vec![12.0], + original_line_spacings: vec![wordcraft_doc::props::LineSpacing::Multiple(1.0)], + ..Default::default() + }); + s.touch(); + s.relayout(); + + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("already fits on one page"), "already-fits must take precedence over exhausted budgets: {err}"); +} + #[test] fn fit_one_page_checks_complex_script_font_floor() { let mut lines = 28; diff --git a/crates/ui-egui/src/ribbon/layout.rs b/crates/ui-egui/src/ribbon/layout.rs index 8b457c8d5b..e96089b3dc 100644 --- a/crates/ui-egui/src/ribbon/layout.rs +++ b/crates/ui-egui/src/ribbon/layout.rs @@ -48,6 +48,7 @@ pub(super) fn columns_items(ui: &mut Ui, app: &mut WordApp) { mi(ui, app, "More Columns…", "ui.dialog", json!({"name": "columns"})); } +/// Renders the Layout ribbon tab (Page Setup, Paragraph, Arrange). pub(super) fn layout(app: &mut WordApp, ui: &mut Ui) { group(ui, "Page Setup", Some("layout.pageSetup"), app, |ui, app| { // Text Direction works on what the caret is in: table cells, a text box, else the section. From 8e4a487074a04ecafef345234644cd6a8d9064a0 Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Fri, 13 Nov 2026 00:40:44 -0300 Subject: [PATCH 12/12] M2.1: Freeze comment formatting before scaling shared styles (#6433) --- crates/doc/src/shrink.rs | 43 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/shrink.rs b/crates/doc/src/shrink.rs index c09def5e6d..0e603fc051 100644 --- a/crates/doc/src/shrink.rs +++ b/crates/doc/src/shrink.rs @@ -280,7 +280,7 @@ fn freeze_blocks_formatting( /// Clean-room typographical helper for Fit One Page. Preserves relative typography /// hierarchies between headings, body text, and direct run formatting. /// Clamps font sizes to `[4.0, 1638.0]` pt and rounds to 0.5 pt (half-points). -/// Headers and footers are explicitly excluded and retain their exact original sizes. +/// Headers, footers, and comments are explicitly excluded and retain their exact original sizes. /// Footnotes are included and shrink alongside body text. pub fn fit_document_typography(doc: &mut Document, delta_pt: f32, space_factor: f32) { if !delta_pt.is_finite() || !space_factor.is_finite() { @@ -296,11 +296,12 @@ pub fn fit_document_typography(doc: &mut Document, delta_pt: f32, space_factor: // 1. Find all text boxes reachable from Header and Footer parts (including nested text boxes and chains). let hf_boxes = header_footer_text_boxes(doc); - // Freeze header and footer font sizes and spacing (and reachable text boxes) before modifying styles, - // preserving table-style context so header/footer table cells retain their conditional table formatting. + // Freeze header, footer, and comment font sizes and spacing (and reachable text boxes) before modifying styles, + // preserving table-style context so header/footer table cells retain their conditional table formatting, + // and comment stories retain their inherited formatting from shared styles. let old_styles = doc.styles.clone(); for (id, part) in doc.parts.iter_mut() { - if matches!(part.kind, PartKind::Header | PartKind::Footer) || hf_boxes.contains(id) { + if matches!(part.kind, PartKind::Header | PartKind::Footer | PartKind::Comment) || hf_boxes.contains(id) { freeze_blocks_formatting(&mut part.blocks, &old_styles, None, None, delta_pt, space_factor); } } @@ -653,6 +654,40 @@ mod tests { assert_eq!(p_after.runs[0].props.size, Some(8.0), "comments must be excluded from typography shrink pass"); } + #[test] + fn test_shrink_preserves_comment_inherited_formatting() { + let mut doc = Document::new(); + // Setup a named style "CommentText" with 12.0 pt font and 6.0 pt spacing after. + let comment_style = crate::styles::Style { + id: "CommentText".into(), + name: "Comment Text".into(), + kind: crate::styles::StyleKind::Paragraph, + chr: CharProps { size: Some(12.0), ..Default::default() }, + para: ParaProps { space_after: Some(6.0), line_spacing: Some(LineSpacing::Multiple(1.2)), ..Default::default() }, + ..Default::default() + }; + Arc::make_mut(&mut doc.styles).upsert(comment_style); + + // Comment paragraph inheriting from "CommentText" without explicit run size or paragraph spacing + let mut comment_p = Paragraph::with_text("Inherited comment text", CharProps::default()); + comment_p.props.style = Some("CommentText".to_string()); + let comment_id = doc.add_part(PartKind::Comment, vec![para_block(comment_p)]); + + // Shrink with 1.0 pt delta and 0.94 space factor + fit_document_typography(&mut doc, 1.0, 0.94); + + // Shared style "CommentText" is scaled (12.0 -> 11.0 pt) + let scaled_style = doc.styles.get("CommentText").unwrap(); + assert_eq!(scaled_style.chr.size, Some(11.0)); + + // The comment story must have frozen its original inherited formatting before styles were modified + let comment_part = doc.parts.get(&comment_id).unwrap(); + let p_after = comment_part.blocks[0].as_para().unwrap(); + assert_eq!(p_after.runs[0].props.size, Some(12.0), "comment run size must remain 12.0 pt from old styles"); + assert_eq!(p_after.props.space_after, Some(6.0), "comment paragraph space_after must remain 6.0 pt from old styles"); + assert_eq!(p_after.props.line_spacing, Some(LineSpacing::Multiple(1.2)), "comment paragraph line_spacing must remain 1.2 from old styles"); + } + #[test] fn test_header_table_text_inherits_size_from_table_style() { let mut doc = Document::new();