diff --git a/ROADMAP.md b/ROADMAP.md index 827f22ebea..41cbe7a078 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. @@ -1444,3 +1444,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..0e603fc051 --- /dev/null +++ b/crates/doc/src/shrink.rs @@ -0,0 +1,755 @@ +//! 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, + /// 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, + /// 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`. +#[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`. +/// +/// 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. 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).ceil() / 100.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, 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() { + 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, 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 | PartKind::Comment) || 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, 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}"); + // 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); + } + + #[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_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(); + // 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, 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. + assert_eq!(scale_spacing(0.0, 0.94), 0.0); + } +} 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..c407e9af4a --- /dev/null +++ b/crates/docx/src/fit.rs @@ -0,0 +1,70 @@ +//! 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], + ..Default::default() + } + } + + /// 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 6f626ed82a..e56a3bd063 100644 --- a/crates/docx/src/read.rs +++ b/crates/docx/src/read.rs @@ -331,6 +331,7 @@ 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.keep_unmodelled(&root, &root_rels, &rels); if !r.embedded.parts.is_empty() { diff --git a/crates/docx/src/read/embed.rs b/crates/docx/src/read/embed.rs index a2a91f4e57..86ba4957a3 100644 --- a/crates/docx/src/read/embed.rs +++ b/crates/docx/src/read/embed.rs @@ -120,7 +120,13 @@ 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 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") { continue; } // Native ink shares the custom XML relationship type, but its part belongs to the @@ -139,6 +145,24 @@ impl Reader<'_> { } } + /// Read the Fit One Page budget from our custom XML store, if the file has one. + /// + /// 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; + } + } + } + } + /// 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 @@ -339,6 +363,106 @@ 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)); + let kept: Vec<_> = reopened + .passthrough + .iter() + .filter(|(k, v)| k.starts_with("customxml/") && String::from_utf8_lossy(v).contains("urn:other")) + .map(|(k, _)| k.clone()) + .collect(); + assert_eq!(kept.len(), 1, "the unrelated custom XML store must survive saving exactly once: {kept:?}"); + assert!(!kept[0].eq_ignore_ascii_case(crate::fit::PART), "it must not take the reserved fit part 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/docx/src/write/parts.rs b/crates/docx/src/write/parts.rs index f17f43ea2f..b581bb35fc 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 Vec { vec![ CommandSpec::new("layout.margins", "Margins", "Layout › Page Setup", margins).params( @@ -39,6 +40,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 +768,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..9339baf006 --- /dev/null +++ b/crates/engine/src/cmd/page/fit_page.rs @@ -0,0 +1,1526 @@ +//! 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::collections::BTreeMap; +use std::sync::Arc; + +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::{HideDeleted, LayoutCache, LayoutOptions, Page, 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 `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>, + 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) || hides.hides(doc, p.mark.del) { 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 hides.hides(doc, r.props.del) { + 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 `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>, + hides: HideDeleted, + 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, 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(); + 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), hides, show_hidden)); + } + } + } + } + min_sz +} + +/// 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>, + 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) && !hides.hides(doc, p.mark.del); + 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 hides.hides(doc, r.props.del) { + 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>, + 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, hides, show_hidden) + { + 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 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), hides, show_hidden) { + 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, hides: HideDeleted, show_hidden: bool) -> 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; + } + } + } + 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. +/// +/// 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 +} + +/// 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. +/// +/// 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, 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, 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). +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. +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 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); + 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 +} + +/// 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 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); + 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 +} + +/// 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 +} + +/// 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 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)) +} + +/// 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_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 = 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; + if cur >= floor && cur < orig - 1e-4 { + return true; + } + } + } + + 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_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 = 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; + 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) +} + +/// 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, +} + +/// 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 }; + }; + 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, + } +} + +/// 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, orig: OriginalTypography) { + 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: 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, + }); +} + +/// 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 +} + +/// 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 +/// 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. 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 { + return Err(CmdError::Disabled( + "the document tracks formatting changes: Fit One Page cannot record its style changes as formatting revisions".into(), + )); + } + + // 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; + + // 3. Build equivalent Print layout options + let print_opts = fit_layout_options(s); + let shown_hidden = print_opts.show_hidden; + let pre_typography = OriginalTypography::from_doc(&s.doc); + + // 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(); + + 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. + 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())); + } + + // 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 { + 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); + + // 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; + } + // 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. + // 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, + // while allowing spacing-only candidates (delta == 0.0) to run. + if delta > 0.0 && doc_has_unreadable_body_font(&s.doc, &print_opts) { + 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, &print_opts) { + 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))) => { + 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 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" }; + 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 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())) + } + } + } +} + +#[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); + } + + #[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, 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()))]; + 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, 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); + 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, HideDeleted::default(), 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, 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, 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] + 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, 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] + 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 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); + + 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)); + 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 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, 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. + 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 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 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); + 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 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, 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())); + 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 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, orig); + + 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")); + 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()); + } + + #[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/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 b7da22a71f..a05053b78b 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..a7458bf8a2 --- /dev/null +++ b/crates/engine/src/tests/fit_one_page.rs @@ -0,0 +1,1150 @@ +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_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")); + 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); + 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); + + // 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(); + + 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, "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; + 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 e93c934543..b9151df8f7 100644 --- a/crates/layout/src/placed.rs +++ b/crates/layout/src/placed.rs @@ -161,6 +161,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 117349384e..87a6cb6720 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]); @@ -1738,4 +1744,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 085a006013..3dcce7e532 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..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. @@ -119,6 +120,9 @@ pub(super) fn layout(app: &mut WordApp, ui: &mut Ui) { }, ); }); + 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 b1896f34c1..971ecbc52c 100644 --- a/crates/ui-egui/src/widgets/tests.rs +++ b/crates/ui-egui/src/widgets/tests.rs @@ -551,3 +551,26 @@ 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() { + use egui_kittest::kittest::Queryable; + for theme in [crate::theme::Appearance::Light, crate::theme::Appearance::Dark] { + 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| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + 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:?}"); + } +} 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 6bb881e440..a5b2332e76 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ 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. -**509 of 521 catalog features have commands (97.7%).** +**510 of 522 catalog features have commands (97.7%).** | Tab | Live | Total | |---|---|---| @@ -13,7 +13,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Insert | 47 | 53 | | Draw | 10 | 11 | | Design | 10 | 10 | -| Layout | 22 | 22 | +| Layout | 23 | 23 | | References | 25 | 25 | | Mailings | 23 | 23 | | Review | 29 | 31 |