diff --git a/crates/doc/src/shrink.rs b/crates/doc/src/shrink.rs index c943de737e..65dadb349a 100644 --- a/crates/doc/src/shrink.rs +++ b/crates/doc/src/shrink.rs @@ -35,12 +35,18 @@ pub struct FitState { pub reduced_font_sizes: Vec, /// Unique effective line spacing values present immediately after the reduction was applied. pub reduced_line_spacings: Vec, + /// Unique effective paragraph spacing values present immediately after the reduction was applied. + #[serde(default)] + pub reduced_paragraph_spacings: Vec, /// Unique effective body font sizes before the first Fit One Page reduction was applied. #[serde(default)] pub original_font_sizes: Vec, /// Unique effective line spacing values before the first Fit One Page reduction was applied. #[serde(default)] pub original_line_spacings: Vec, + /// Unique effective paragraph spacing values before the first Fit One Page reduction was applied. + #[serde(default)] + pub original_paragraph_spacings: Vec, } /// Scale a font size down by `delta_pt`. diff --git a/crates/docx/Cargo.toml b/crates/docx/Cargo.toml index 6f7f1b3860..66f038a185 100644 --- a/crates/docx/Cargo.toml +++ b/crates/docx/Cargo.toml @@ -15,6 +15,7 @@ quick-xml = { workspace = true } zip = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +serde_json = { workspace = true } chrono = { workspace = true } unicode-bidi = { workspace = true } cfb = { workspace = true } @@ -32,7 +33,6 @@ getrandom = { workspace = true, features = ["wasm_js"] } [dev-dependencies] wordcraft-doc = { workspace = true, features = ["test-support"] } -serde_json = { workspace = true } proptest = { workspace = true } image = { workspace = true } zip = { workspace = true } diff --git a/crates/docx/src/fit.rs b/crates/docx/src/fit.rs new file mode 100644 index 0000000000..8894800f6b --- /dev/null +++ b/crates/docx/src/fit.rs @@ -0,0 +1,69 @@ +//! The Fit One Page budget in a DOCX custom XML part. +//! +//! Word keeps custom XML stores as ordinary package parts; our own store is namespaced so it can +//! never be mistaken for a user's data. It is not a document property, so it stays out of +//! `docProps/custom.xml`. + +use wordcraft_doc::FitState; + +use crate::DocxError; +use crate::xml::{El, esc}; + +/// The part this crate writes and reads back. +pub(crate) const PART: &str = "customxml/wordcraftfit.xml"; +/// Root element of the store. +pub(crate) const ROOT: &str = "wordcraftFit"; +/// Namespace of the store. +pub(crate) const NS: &str = "urn:wordcraft:fit"; +/// Marker attribute the parser keeps (namespace declarations are stripped from `El`). +const KIND: &str = "wordcraft-fit-v1"; + +/// Whether `root` is our store (and not a user's custom XML part with a coincidental name). +pub(crate) fn is_state(root: &El) -> bool { + root.local() == ROOT && root.attr("kind") == Some(KIND) +} + +/// Serialize `fit` as the custom XML store. +pub(crate) fn write(fit: &FitState) -> Result, DocxError> { + let json = serde_json::to_string(fit).map_err(|e| DocxError::Xml(e.to_string()))?; + Ok(format!(r#"<{ROOT} xmlns="{NS}" kind="{KIND}">{}"#, esc(&json)) + .into_bytes()) +} + +/// Read the state from a parsed custom XML root, if it is ours. +pub(crate) fn read(root: &El) -> Option { + if !is_state(root) { + return None; + } + let state = root.els().find(|e| e.local() == "fitState")?; + serde_json::from_str(&state.text()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use wordcraft_doc::props::LineSpacing; + + fn state() -> FitState { + FitState { + cum_delta: 1.0, + cum_space: 0.94, + reduced_font_sizes: vec![11.0], + reduced_line_spacings: vec![LineSpacing::Multiple(0.94)], + reduced_paragraph_spacings: vec![18.8], + original_font_sizes: vec![12.0], + original_line_spacings: vec![LineSpacing::Multiple(1.0)], + original_paragraph_spacings: vec![20.0], + } + } + + /// The cumulative budget survives a real DOCX write and read, not just `serde_json`. + #[test] + fn fit_state_round_trips_through_docx() { + let mut doc = wordcraft_doc::Document::from_text("Alpha\nBeta"); + doc.fit_state = Some(state()); + let bytes = crate::write(&doc).expect("write docx"); + let back = crate::read(&bytes).expect("read docx"); + assert_eq!(back.fit_state, doc.fit_state); + } +} diff --git a/crates/docx/src/lib.rs b/crates/docx/src/lib.rs index 1667bc1c2f..9ade398f9f 100644 --- a/crates/docx/src/lib.rs +++ b/crates/docx/src/lib.rs @@ -11,6 +11,7 @@ mod bibliography; mod chart_spec; mod crypt; mod custom; +mod fit; mod flat; mod fonts; mod package; diff --git a/crates/docx/src/read.rs b/crates/docx/src/read.rs index f8570c1089..9f39daecbe 100644 --- a/crates/docx/src/read.rs +++ b/crates/docx/src/read.rs @@ -333,6 +333,7 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize, } r.read_glossary(&rels); r.keep_custom_xml(&root_rels, &rels); + r.read_fit_state(&rels); r.keep_unmodelled(&root, &root_rels, &rels); if !r.embedded.parts.is_empty() { let manifest = r.embedded.to_bytes(); diff --git a/crates/docx/src/read/embed.rs b/crates/docx/src/read/embed.rs index a2a91f4e57..b2e9197961 100644 --- a/crates/docx/src/read/embed.rs +++ b/crates/docx/src/read/embed.rs @@ -120,7 +120,11 @@ impl Reader<'_> { if ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s)) { continue; } - if !self.doc.sources.is_empty() && self.xml(&rel.target).ok().flatten().is_some_and(|r| r.local() == "Sources") { + let root = self.xml(&rel.target).ok().flatten(); + if root.as_ref().is_some_and(crate::fit::is_state) { + continue; + } + if !self.doc.sources.is_empty() && root.as_ref().is_some_and(|r| r.local() == "Sources") { continue; } // Native ink shares the custom XML relationship type, but its part belongs to the @@ -139,6 +143,20 @@ impl Reader<'_> { } } + /// Read the Fit One Page budget from our custom XML store, if the file has one. + pub(super) fn read_fit_state(&mut self, document: &Rels) { + for rel in document.list.iter().filter(|r| !r.external && rel_is(&r.kind, rt::CUSTOM_XML)).take(MAX_EMBEDDED_RELS) { + if !manifest_field_ok(&rel.target) { + continue; + } + let Some(root) = self.xml(&rel.target).ok().flatten() else { continue }; + if let Some(fit) = crate::fit::read(&root) { + self.doc.fit_state = Some(fit); + break; + } + } + } + /// Keep the parts the package and the main document relate to that WordCraft neither models /// nor writes itself (web settings, styles-with-effects, add-in task panes, the thumbnail, a /// glossary…), with every part they reach, so saving carries them unchanged. Skipped: the diff --git a/crates/docx/src/write/parts.rs b/crates/docx/src/write/parts.rs index f35a36fc91..6a52a119d6 100644 --- a/crates/docx/src/write/parts.rs +++ b/crates/docx/src/write/parts.rs @@ -179,6 +179,10 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result } } -/// Collects unique effective body font sizes across the document body and editable parts. +/// Collects every effective body font size across the document body and editable parts, with duplicates. fn collect_body_font_sizes(doc: &Document) -> Vec { let mut sizes = Vec::new(); collect_blocks_font_sizes(doc, &doc.body, None, &mut sizes); @@ -393,7 +393,6 @@ fn collect_body_font_sizes(doc: &Document) -> Vec { } } sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - sizes.dedup_by(|a, b| (*a - *b).abs() < 1e-4); sizes } @@ -420,7 +419,7 @@ fn collect_blocks_line_spacings( } } -/// Collects unique effective line spacings across body paragraphs and editable parts. +/// Collects every effective line spacing across body paragraphs and editable parts, with duplicates. fn collect_body_line_spacings(doc: &Document) -> Vec { let mut spacings = Vec::new(); collect_blocks_line_spacings(doc, &doc.body, None, &mut spacings); @@ -443,33 +442,71 @@ fn collect_body_line_spacings(doc: &Document) -> Vec std::cmp::Ordering::Less, (_, wordcraft_doc::props::LineSpacing::AtLeast(_)) => std::cmp::Ordering::Greater, }); - spacings.dedup_by(|a, b| match (a, b) { - (wordcraft_doc::props::LineSpacing::Multiple(x), wordcraft_doc::props::LineSpacing::Multiple(y)) - | (wordcraft_doc::props::LineSpacing::AtLeast(x), wordcraft_doc::props::LineSpacing::AtLeast(y)) - | (wordcraft_doc::props::LineSpacing::Exactly(x), wordcraft_doc::props::LineSpacing::Exactly(y)) => (*x - *y).abs() < 1e-4, - _ => false, - }); + spacings +} + +/// Collects all effective paragraph spacing values across a slice of blocks, preserving table context. +fn collect_blocks_paragraph_spacings( + doc: &Document, + blocks: &[Arc], + table_para: Option<&wordcraft_doc::ParaProps>, + out: &mut Vec, +) { + for b in blocks { + if let Some(p) = b.as_para() { + let resolved = doc.styles.resolve_para_in(&p.props, table_para); + out.push(resolved.space_before); + out.push(resolved.space_after); + } else if let Some(tbl) = b.as_table() { + let style = tbl.props.style.as_deref().and_then(|id| doc.styles.table_style(id)); + let cell_para = style.as_ref().map(|st| &st.para); + for row in &tbl.rows { + for cell in &row.cells { + collect_blocks_paragraph_spacings(doc, &cell.blocks, cell_para, out); + } + } + } + } +} + +/// Collects every effective paragraph spacing value across body paragraphs and editable parts. +fn collect_body_paragraph_spacings(doc: &Document) -> Vec { + let mut spacings = Vec::new(); + collect_blocks_paragraph_spacings(doc, &doc.body, None, &mut spacings); + let hf_boxes = wordcraft_doc::shrink::header_footer_text_boxes(doc); + for (id, part) in &doc.parts { + if !matches!(part.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer | wordcraft_doc::PartKind::Comment) + && !hf_boxes.contains(id) + { + collect_blocks_paragraph_spacings(doc, &part.blocks, None, &mut spacings); + } + } + spacings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); spacings } /// Checks whether previously reduced text from an earlier fit is still present in the document. /// /// Compares each current value against the original values recorded before the first fit, within -/// the cumulative reduction that fit applied. This avoids matching a body size against an unrelated -/// heading size: restoring every size to its original therefore resets the budget. +/// the cumulative reduction that fit applied. An exact match with the original multiset means every +/// size was restored, even when one original value sits inside another value's reduction interval. +#[cfg(test)] fn has_previously_reduced_text(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { - let font_reduced_present = fit.cum_delta > 0.0 && font_reduction_present(doc, fit); - let space_reduced_present = fit.cum_space < 1.0 - 1e-4 && spacing_reduction_present(doc, fit); + (fit.cum_delta > 0.0 && font_reduction_present(doc, fit)) || (fit.cum_space < 1.0 - 1e-4 && spacing_reduction_present(doc, fit)) +} - if fit.cum_delta > 0.0 && fit.cum_space < 1.0 - 1e-4 { - font_reduced_present || space_reduced_present - } else if fit.cum_delta > 0.0 { - font_reduced_present - } else if fit.cum_space < 1.0 - 1e-4 { - space_reduced_present - } else { - false - } +fn same_sizes(a: &[f32], b: &[f32]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4) +} + +fn same_spacings(a: &[wordcraft_doc::props::LineSpacing], b: &[wordcraft_doc::props::LineSpacing]) -> bool { + a.len() == b.len() + && a.iter().zip(b).all(|(x, y)| match (x, y) { + (wordcraft_doc::props::LineSpacing::Multiple(c), wordcraft_doc::props::LineSpacing::Multiple(o)) + | (wordcraft_doc::props::LineSpacing::AtLeast(c), wordcraft_doc::props::LineSpacing::AtLeast(o)) + | (wordcraft_doc::props::LineSpacing::Exactly(c), wordcraft_doc::props::LineSpacing::Exactly(o)) => (c - o).abs() < 1e-4, + _ => false, + }) } fn font_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { @@ -477,6 +514,9 @@ fn font_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) return false; } let current_sizes = collect_body_font_sizes(doc); + if same_sizes(¤t_sizes, &fit.original_font_sizes) { + return false; + } current_sizes.iter().any(|&cur| { fit.original_font_sizes.iter().any(|&orig| { let floor = orig - fit.cum_delta - 1e-4; @@ -485,11 +525,14 @@ fn font_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) }) } -fn spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { +fn line_spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { if fit.original_line_spacings.is_empty() { return false; } let current_spacings = collect_body_line_spacings(doc); + if same_spacings(¤t_spacings, &fit.original_line_spacings) { + return false; + } current_spacings.iter().any(|cur| { fit.original_line_spacings.iter().any(|orig| match (cur, orig) { (wordcraft_doc::props::LineSpacing::Multiple(c), wordcraft_doc::props::LineSpacing::Multiple(o)) @@ -503,6 +546,49 @@ fn spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitSta }) } +fn paragraph_spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { + if fit.original_paragraph_spacings.is_empty() { + return false; + } + let current_spacings = collect_body_paragraph_spacings(doc); + if same_sizes(¤t_spacings, &fit.original_paragraph_spacings) { + return false; + } + current_spacings.iter().any(|&cur| { + fit.original_paragraph_spacings.iter().any(|&orig| { + let floor = orig * fit.cum_space - 1e-4; + cur >= floor && cur < orig - 1e-4 + }) + }) +} + +fn spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { + line_spacing_reduction_present(doc, fit) || paragraph_spacing_reduction_present(doc, fit) +} + +/// The reduction budget still active in `doc`, with font and spacing resolved independently. +#[derive(Clone, Copy, Debug)] +struct ActiveBudget { + delta: f32, + space: f32, + font_active: bool, + spacing_active: bool, +} + +fn active_budget(doc: &Document) -> ActiveBudget { + let Some(fit) = doc.fit_state.as_ref() else { + return ActiveBudget { delta: 0.0, space: 1.0, font_active: false, spacing_active: false }; + }; + let font_active = fit.cum_delta > 0.0 && font_reduction_present(doc, fit); + let spacing_active = fit.cum_space < 1.0 - 1e-4 && spacing_reduction_present(doc, fit); + ActiveBudget { + delta: if font_active { fit.cum_delta } else { 0.0 }, + space: if spacing_active { fit.cum_space } else { 1.0 }, + font_active, + spacing_active, + } +} + /// Records the cumulative Fit One Page reduction state into the document's internal fit tracking. fn set_cumulative_shrink_state( doc: &mut Document, @@ -510,16 +596,20 @@ fn set_cumulative_shrink_state( space: f32, original_font_sizes: Vec, original_line_spacings: Vec, + original_paragraph_spacings: Vec, ) { let sizes = collect_body_font_sizes(doc); let spacings = collect_body_line_spacings(doc); + let paragraph_spacings = collect_body_paragraph_spacings(doc); doc.fit_state = Some(wordcraft_doc::FitState { cum_delta: delta, cum_space: space, reduced_font_sizes: sizes, reduced_line_spacings: spacings, + reduced_paragraph_spacings: paragraph_spacings, original_font_sizes, original_line_spacings, + original_paragraph_spacings, }); } @@ -622,10 +712,15 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { )); } - // 1. Anti-compound guard: refuse if cumulative reduction already reached 1.0 pt or spacing factor <= 0.94 - let prior_fit = s.doc.fit_state.clone().filter(|fit| has_previously_reduced_text(&s.doc, fit)); - let (cum_delta, cum_space) = prior_fit.as_ref().map_or((0.0, 1.0), |fit| (fit.cum_delta, fit.cum_space)); - if cum_delta >= 1.0 - 1e-4 || cum_space <= 0.94 + 1e-4 { + // 1. Anti-compound guard: each component has its own budget, so a restored font or spacing + // budget is not blocked by the other component's remaining reduction. + let prior_fit = s.doc.fit_state.clone(); + let budget = active_budget(&s.doc); + let (cum_delta, cum_space) = (budget.delta, budget.space); + let (font_active, spacing_active) = (budget.font_active, budget.spacing_active); + let font_exhausted = cum_delta >= 1.0 - 1e-4; + let spacing_exhausted = cum_space <= 0.94 + 1e-4; + if font_exhausted && spacing_exhausted { return Err(CmdError::Failed("Unable to shrink document further without reducing text beyond readability limits.".into())); } @@ -634,6 +729,7 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { let shown_hidden = print_opts.show_hidden; let pre_font_sizes = collect_body_font_sizes(&s.doc); let pre_line_spacings = collect_body_line_spacings(&s.doc); + let pre_paragraph_spacings = collect_body_paragraph_spacings(&s.doc); // 3. Initial page count evaluation let mut initial_cache = LayoutCache::new(); @@ -678,14 +774,32 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { if delta > max_delta { continue; } + // A component whose budget is exhausted is skipped, leaving the other component usable. + if font_exhausted && delta > 0.0 { + continue; + } + if spacing_exhausted && alpha < 1.0 - 1e-4 && delta <= 0.0 { + continue; + } // Anti-compound budget across runs: cap cumulative font reduction at 1.0 pt if cum_delta > 0.0 && cum_delta + delta > 1.0 + 1e-4 { continue; } - // Anti-compound budget across runs: cap cumulative line spacing compression at 0.94 floor - let cand_line_space = alpha.max(0.94); - let prior_line_space = cum_space.max(0.94); - if cum_space < 1.0 - 1e-4 && prior_line_space * cand_line_space < 0.94 - 1e-4 { + // Anti-compound budget across runs: cap cumulative line spacing compression at 0.94 floor. + // If spacing has no room left, a font candidate still runs without spacing compression. + let mut alpha = if spacing_exhausted { 1.0 } else { alpha }; + if alpha < 1.0 - 1e-4 && cum_space < 1.0 - 1e-4 { + let cand_line_space = alpha.max(0.94); + let prior_line_space = cum_space.max(0.94); + if prior_line_space * cand_line_space < 0.94 - 1e-4 { + if delta > 0.0 { + alpha = 1.0; + } else { + continue; + } + } + } + if delta <= 0.0 && alpha >= 1.0 - 1e-4 { continue; } // Anti-compound guard: refuse font reduction if body font is already at or below the readability floor, @@ -716,11 +830,29 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { // 8. Success commit or clean rollback match (successful_doc, applied_step) { (Some(mut new_doc), Some((d, a))) => { - let original_font_sizes = - prior_fit.as_ref().map(|fit| fit.original_font_sizes.clone()).filter(|sizes| !sizes.is_empty()).unwrap_or(pre_font_sizes); - let original_line_spacings = - prior_fit.as_ref().map(|fit| fit.original_line_spacings.clone()).filter(|spacings| !spacings.is_empty()).unwrap_or(pre_line_spacings); - set_cumulative_shrink_state(&mut new_doc, cum_delta + d, cum_space * a, original_font_sizes, original_line_spacings); + let prior_original_fonts = + || prior_fit.as_ref().filter(|_| font_active).map(|fit| fit.original_font_sizes.clone()).filter(|sizes| !sizes.is_empty()); + let prior_original_lines = || { + prior_fit.as_ref().filter(|_| spacing_active).map(|fit| fit.original_line_spacings.clone()).filter(|spacings| !spacings.is_empty()) + }; + let prior_original_paragraphs = || { + prior_fit + .as_ref() + .filter(|_| spacing_active) + .map(|fit| fit.original_paragraph_spacings.clone()) + .filter(|spacings| !spacings.is_empty()) + }; + let original_font_sizes = prior_original_fonts().unwrap_or(pre_font_sizes); + let original_line_spacings = prior_original_lines().unwrap_or(pre_line_spacings); + let original_paragraph_spacings = prior_original_paragraphs().unwrap_or(pre_paragraph_spacings); + set_cumulative_shrink_state( + &mut new_doc, + cum_delta + d, + cum_space * a, + original_font_sizes, + original_line_spacings, + original_paragraph_spacings, + ); s.doc = new_doc; s.clamp_selection(); let page_word = if final_pages == 1 { "page" } else { "pages" }; @@ -874,8 +1006,9 @@ mod tests { } let originals = collect_body_font_sizes(&doc); let originals_spacings = collect_body_line_spacings(&doc); + let originals_paragraphs = collect_body_paragraph_spacings(&doc); wordcraft_doc::shrink::fit_document_typography(&mut doc, 1.0, 1.0); - set_cumulative_shrink_state(&mut doc, 1.0, 1.0, originals, originals_spacings); + set_cumulative_shrink_state(&mut doc, 1.0, 1.0, originals, originals_spacings, originals_paragraphs); assert!(has_previously_reduced_text(&doc, doc.fit_state.as_ref().unwrap())); // Restoring only the heading leaves the body reduced. @@ -903,14 +1036,19 @@ mod tests { let mut doc = Document::from_text("Alpha\nBeta"); if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { p.props.line_spacing = Some(LineSpacing::Multiple(1.0)); + p.props.space_before = Some(0.0); + p.props.space_after = Some(0.0); } if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { p.props.line_spacing = Some(LineSpacing::Multiple(2.0)); + p.props.space_before = Some(0.0); + p.props.space_after = Some(0.0); } let originals = collect_body_font_sizes(&doc); let originals_spacings = collect_body_line_spacings(&doc); + let originals_paragraphs = collect_body_paragraph_spacings(&doc); wordcraft_doc::shrink::fit_document_typography(&mut doc, 0.0, 0.9); - set_cumulative_shrink_state(&mut doc, 0.0, 0.9, originals, originals_spacings); + set_cumulative_shrink_state(&mut doc, 0.0, 0.9, originals, originals_spacings, originals_paragraphs); assert!(has_previously_reduced_text(&doc, doc.fit_state.as_ref().unwrap())); // Restoring the 2.0 spacing but leaving the 1.0 one reduced keeps the budget. @@ -925,6 +1063,124 @@ mod tests { assert!(!has_previously_reduced_text(&doc, doc.fit_state.as_ref().unwrap())); } + #[test] + fn provenance_resets_when_neighbouring_sizes_are_restored() { + let mut doc = Document::from_text("Alpha\nBeta"); + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { + for r in &mut p.runs { + r.props.size = Some(12.0); + } + } + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { + for r in &mut p.runs { + r.props.size = Some(11.0); + } + } + let originals = collect_body_font_sizes(&doc); + let originals_spacings = collect_body_line_spacings(&doc); + let originals_paragraphs = collect_body_paragraph_spacings(&doc); + wordcraft_doc::shrink::fit_document_typography(&mut doc, 1.0, 1.0); + set_cumulative_shrink_state(&mut doc, 1.0, 1.0, originals, originals_spacings, originals_paragraphs); + assert!(font_reduction_present(&doc, doc.fit_state.as_ref().unwrap())); + + // Restoring 12 pt and 11 pt makes 11 pt look like another size's reduction interval; the + // exact original multiset must clear the budget. + for (i, size) in [(0, 12.0), (1, 11.0)] { + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[i]) { + for r in &mut p.runs { + r.props.size = Some(size); + } + } + } + assert!(!font_reduction_present(&doc, doc.fit_state.as_ref().unwrap())); + assert_eq!(active_budget(&doc).delta, 0.0); + } + + #[test] + fn paragraph_spacing_keeps_the_budget_when_line_spacing_rounds_back() { + use wordcraft_doc::props::LineSpacing; + + let mut doc = Document::from_text("Alpha\nBeta"); + for p in doc.body.iter_mut() { + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(p) { + p.props.line_spacing = Some(LineSpacing::Exactly(8.0)); + p.props.space_before = Some(20.0); + p.props.space_after = Some(0.0); + } + } + let originals = collect_body_font_sizes(&doc); + let originals_spacings = collect_body_line_spacings(&doc); + let originals_paragraphs = collect_body_paragraph_spacings(&doc); + wordcraft_doc::shrink::fit_document_typography(&mut doc, 0.0, 0.94); + set_cumulative_shrink_state(&mut doc, 0.0, 0.94, originals, originals_spacings, originals_paragraphs); + + // Exactly(8.0) rounds back to 8.0, but 20 pt paragraph spacing becomes 18.8 pt. + assert!(!line_spacing_reduction_present(&doc, doc.fit_state.as_ref().unwrap())); + assert!(paragraph_spacing_reduction_present(&doc, doc.fit_state.as_ref().unwrap())); + assert!(spacing_reduction_present(&doc, doc.fit_state.as_ref().unwrap())); + + for p in doc.body.iter_mut() { + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(p) { + p.props.space_before = Some(20.0); + } + } + assert!(!spacing_reduction_present(&doc, doc.fit_state.as_ref().unwrap())); + } + + #[test] + fn font_and_spacing_budgets_reset_independently() { + use wordcraft_doc::props::LineSpacing; + + let mut doc = Document::from_text("Alpha\nBeta"); + for p in doc.body.iter_mut() { + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(p) { + p.props.line_spacing = Some(LineSpacing::Multiple(1.0)); + p.props.space_before = Some(10.0); + p.props.space_after = Some(0.0); + for r in &mut p.runs { + r.props.size = Some(12.0); + } + } + } + let originals = collect_body_font_sizes(&doc); + let originals_spacings = collect_body_line_spacings(&doc); + let originals_paragraphs = collect_body_paragraph_spacings(&doc); + wordcraft_doc::shrink::fit_document_typography(&mut doc, 1.0, 0.9); + set_cumulative_shrink_state(&mut doc, 1.0, 0.9, originals, originals_spacings, originals_paragraphs); + + let both = active_budget(&doc); + assert_eq!(both.delta, 1.0); + assert!((both.space - 0.9).abs() < 1e-4); + assert!(both.font_active && both.spacing_active); + + // Restore only the spacing: its budget resets while the font budget remains. + for p in doc.body.iter_mut() { + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(p) { + p.props.line_spacing = Some(LineSpacing::Multiple(1.0)); + p.props.space_before = Some(10.0); + p.props.space_after = Some(0.0); + } + } + let spacing_restored = active_budget(&doc); + assert_eq!(spacing_restored.delta, 1.0); + assert_eq!(spacing_restored.space, 1.0); + assert!(spacing_restored.font_active); + assert!(!spacing_restored.spacing_active); + + // Restoring the fonts too clears the other budget. + for p in doc.body.iter_mut() { + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(p) { + for r in &mut p.runs { + r.props.size = Some(12.0); + } + } + } + let restored = active_budget(&doc); + assert_eq!(restored.delta, 0.0); + assert_eq!(restored.space, 1.0); + assert!(!restored.font_active && !restored.spacing_active); + } + #[test] fn fit_layout_options_match_session_visibility_and_numeral() { let mut s = Session::new(Document::from_text("Alpha\nBeta")); diff --git a/crates/engine/src/tests/fit_one_page.rs b/crates/engine/src/tests/fit_one_page.rs index 93020dae8f..1bbaaf5abe 100644 --- a/crates/engine/src/tests/fit_one_page.rs +++ b/crates/engine/src/tests/fit_one_page.rs @@ -246,30 +246,42 @@ fn fit_one_page_supports_max_delta_above_2pt() { fn fit_one_page_anti_compound_guard_prevents_repeated_invocations() { let mut lines = 28; let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { + loop { + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(0.94)); + for r in &mut p.runs { + r.props.size = Some(11.0); + } + } + } + s.touch(); + s.relayout(); + if s.layout().pages.len() >= 2 || lines >= 80 { + break; + } lines += 1; s = create_multiline_doc(lines); } assert_eq!(s.layout().pages.len(), 2); - // First invocation succeeds - let res1 = s.run("layout.fitOnePage", &json!({})); - assert!(res1.is_ok(), "first invocation must succeed: {res1:?}"); - assert_eq!(s.layout().pages.len(), 1); + // Simulate a document that already used the full 1.0 pt font and 0.94 spacing budgets. + s.doc.fit_state = Some(wordcraft_doc::FitState { + cum_delta: 1.0, + cum_space: 0.94, + original_font_sizes: vec![12.0], + original_line_spacings: vec![wordcraft_doc::props::LineSpacing::Multiple(1.0)], + ..Default::default() + }); + s.touch(); + s.relayout(); + let doc_before = s.doc.clone(); - // Add content to push it onto page 2 again - for i in 1..=5 { - s.run("text.insert", &json!({"text": format!("New spill line {i}\n")})).unwrap(); - } - assert_eq!(s.layout().pages.len(), 2); - let doc_before_second = s.doc.clone(); - - // Second invocation must be rejected by the anti-compound guard without mutating doc - let res2 = s.run("layout.fitOnePage", &json!({})); - assert!(res2.is_err(), "second invocation must be rejected by anti-compound guard"); - let err = res2.unwrap_err().to_string(); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err(), "exhausted budgets must be rejected"); + let err = res.unwrap_err().to_string(); assert!(err.contains("Unable to shrink document further without reducing text beyond readability limits."), "unexpected error message: {err}"); - assert_eq!(s.doc, doc_before_second, "document must not be mutated on rejected invocation"); + assert_eq!(s.doc, doc_before, "document must not be mutated on rejected invocation"); } #[test]