From 2bda4e5a4126e9c5240ff7e6abcb7ddeaee0c471 Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Thu, 12 Nov 2026 21:11:12 -0300 Subject: [PATCH] M2.1: Resolve Fit One Page stores by marker, reserve path, and location provenance (#6433) - Recognize a marked Fit One Page custom XML store across package and document relationships, skipping passthrough only after loading successfully. - Reserve the Fit One Page part path in EmbedWriter taken paths to prevent collisions. - Track typography provenance per formatting location rather than sorted multisets. - Validate stepPt parameter and reject non-positive or non-finite values. - Report "already fits on one page" before an exhausted cumulative budget. --- crates/doc/src/shrink.rs | 9 + crates/docx/src/fit.rs | 6 +- crates/docx/src/read.rs | 2 +- crates/docx/src/read/embed.rs | 125 ++++- crates/docx/src/write/embed.rs | 5 + crates/engine/src/cmd/page.rs | 1 + crates/engine/src/cmd/page/fit_page.rs | 583 ++++++++++++++++++------ crates/engine/src/tests/fit_one_page.rs | 28 ++ crates/ui-egui/src/ribbon/layout.rs | 1 + 9 files changed, 610 insertions(+), 150 deletions(-) diff --git a/crates/doc/src/shrink.rs b/crates/doc/src/shrink.rs index 65dadb349a..c09def5e6d 100644 --- a/crates/doc/src/shrink.rs +++ b/crates/doc/src/shrink.rs @@ -47,6 +47,15 @@ pub struct FitState { /// Unique effective paragraph spacing values before the first Fit One Page reduction was applied. #[serde(default)] pub original_paragraph_spacings: Vec, + /// Original effective body font sizes by formatting location key before the first fit. + #[serde(default)] + pub original_font_locations: std::collections::BTreeMap, + /// Original effective line spacing values by formatting location key before the first fit. + #[serde(default)] + pub original_line_spacing_locations: std::collections::BTreeMap, + /// Original effective paragraph spacing values by formatting location key before the first fit. + #[serde(default)] + pub original_paragraph_spacing_locations: std::collections::BTreeMap, } /// Scale a font size down by `delta_pt`. diff --git a/crates/docx/src/fit.rs b/crates/docx/src/fit.rs index 7d3a1e35ac..c407e9af4a 100644 --- a/crates/docx/src/fit.rs +++ b/crates/docx/src/fit.rs @@ -18,11 +18,6 @@ pub(crate) const NS: &str = "urn:wordcraft:fit"; /// Marker attribute the parser keeps (namespace declarations are stripped from `El`). const KIND: &str = "wordcraft-fit-v1"; -/// Whether `target` (a relationship target) names our part. -pub(crate) fn is_part(target: &str) -> bool { - target.trim_start_matches('/').eq_ignore_ascii_case(PART) || target.to_ascii_lowercase().ends_with(&format!("/{PART}")) -} - /// Whether `root` is our store (and not a user's custom XML part with a coincidental name). pub(crate) fn is_state(root: &El) -> bool { root.local() == ROOT && root.attr("kind") == Some(KIND) @@ -59,6 +54,7 @@ mod tests { original_font_sizes: vec![12.0], original_line_spacings: vec![LineSpacing::Multiple(1.0)], original_paragraph_spacings: vec![20.0], + ..Default::default() } } diff --git a/crates/docx/src/read.rs b/crates/docx/src/read.rs index 9f39daecbe..79e21aa970 100644 --- a/crates/docx/src/read.rs +++ b/crates/docx/src/read.rs @@ -332,8 +332,8 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize, r.read_vba_related(&v); } r.read_glossary(&rels); + r.read_fit_state(&root_rels, &rels); r.keep_custom_xml(&root_rels, &rels); - r.read_fit_state(&rels); r.keep_unmodelled(&root, &root_rels, &rels); if !r.embedded.parts.is_empty() { let manifest = r.embedded.to_bytes(); diff --git a/crates/docx/src/read/embed.rs b/crates/docx/src/read/embed.rs index 1e896a632b..c57274222b 100644 --- a/crates/docx/src/read/embed.rs +++ b/crates/docx/src/read/embed.rs @@ -120,11 +120,10 @@ impl Reader<'_> { if ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s)) { continue; } - if crate::fit::is_part(&rel.target) { - continue; - } let root = self.xml(&rel.target).ok().flatten(); - if root.as_ref().is_some_and(crate::fit::is_state) { + if self.doc.fit_state.is_some() && root.as_ref().is_some_and(crate::fit::is_state) { + // A marked Fit One Page store is consumed whatever part name it has; the + // writer recreates it under the reserved name. continue; } if !self.doc.sources.is_empty() && root.as_ref().is_some_and(|r| r.local() == "Sources") { @@ -147,15 +146,19 @@ impl Reader<'_> { } /// Read the Fit One Page budget from our custom XML store, if the file has one. - pub(super) fn read_fit_state(&mut self, document: &Rels) { - for rel in document.list.iter().filter(|r| !r.external && rel_is(&r.kind, rt::CUSTOM_XML)).take(MAX_EMBEDDED_RELS) { - if !manifest_field_ok(&rel.target) || !crate::fit::is_part(&rel.target) { - continue; - } - let Some(root) = self.xml(&rel.target).ok().flatten() else { continue }; - if let Some(fit) = crate::fit::read(&root) { - self.doc.fit_state = Some(fit); - break; + /// + /// Inspects custom XML relationships referenced by either the document or package root. + pub(super) fn read_fit_state(&mut self, package: &Rels, document: &Rels) { + for rels in [document, package] { + for rel in rels.list.iter().filter(|r| !r.external && rel_is(&r.kind, rt::CUSTOM_XML)).take(MAX_EMBEDDED_RELS) { + if !manifest_field_ok(&rel.target) { + continue; + } + let Some(root) = self.xml(&rel.target).ok().flatten() else { continue }; + if let Some(fit) = crate::fit::read(&root) { + self.doc.fit_state = Some(fit); + return; + } } } } @@ -360,6 +363,102 @@ mod tests { zw.finish().unwrap().into_inner() } + /// A package whose main part relates to one custom XML store. + fn custom_xml_package(target: &str, part: &str, bytes: &[u8]) -> Vec { + let doc = format!(r#""#); + let r = format!( + r#""# + ); + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + zw.start_file("[Content_Types].xml", opts).unwrap(); + zw.write_all(CONTENT_TYPES.as_bytes()).unwrap(); + zw.start_file("_rels/.rels", opts).unwrap(); + zw.write_all(ROOT_RELS.as_bytes()).unwrap(); + zw.start_file("word/document.xml", opts).unwrap(); + zw.write_all(doc.as_bytes()).unwrap(); + zw.start_file("word/_rels/document.xml.rels", opts).unwrap(); + zw.write_all(r.as_bytes()).unwrap(); + zw.start_file(part, opts).unwrap(); + zw.write_all(bytes).unwrap(); + zw.finish().unwrap().into_inner() + } + + /// A package whose package-level relationships refer to one custom XML store. + fn package_level_custom_xml_package(target: &str, part: &str, bytes: &[u8]) -> Vec { + let doc = format!(r#""#); + let root_rels = format!( + r#""# + ); + let r = r#""#; + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + zw.start_file("[Content_Types].xml", opts).unwrap(); + zw.write_all(CONTENT_TYPES.as_bytes()).unwrap(); + zw.start_file("_rels/.rels", opts).unwrap(); + zw.write_all(root_rels.as_bytes()).unwrap(); + zw.start_file("word/document.xml", opts).unwrap(); + zw.write_all(doc.as_bytes()).unwrap(); + zw.start_file("word/_rels/document.xml.rels", opts).unwrap(); + zw.write_all(r.as_bytes()).unwrap(); + zw.start_file(part, opts).unwrap(); + zw.write_all(bytes).unwrap(); + zw.finish().unwrap().into_inner() + } + + /// The marked store is recognized by its root, so a renamed part still restores the budget. + #[test] + fn a_marked_fit_state_loads_from_a_renamed_custom_xml_part() { + let fit = wordcraft_doc::FitState { cum_delta: 1.0, cum_space: 0.94, ..Default::default() }; + let state = crate::fit::write(&fit).unwrap(); + let bytes = custom_xml_package("../customxml/item2.xml", "customxml/item2.xml", &state); + let doc = crate::read(&bytes).unwrap(); + assert_eq!(doc.fit_state, Some(fit)); + assert!(!doc.passthrough.keys().any(|k| k.eq_ignore_ascii_case("customxml/item2.xml")), "the consumed store must not be kept in passthrough"); + } + + /// A package-level custom XML relationship restores the budget and survives saving. + #[test] + fn a_marked_fit_state_loads_from_a_package_level_custom_xml_relationship() { + let fit = wordcraft_doc::FitState { cum_delta: 1.0, cum_space: 0.94, ..Default::default() }; + let state = crate::fit::write(&fit).unwrap(); + let bytes = package_level_custom_xml_package("customxml/item2.xml", "customxml/item2.xml", &state); + let doc = crate::read(&bytes).unwrap(); + assert_eq!(doc.fit_state, Some(fit)); + assert!(!doc.passthrough.keys().any(|k| k.eq_ignore_ascii_case("customxml/item2.xml")), "the consumed store must not be kept in passthrough"); + let saved = crate::write(&doc).unwrap(); + let reopened = crate::read(&saved).unwrap(); + assert_eq!(reopened.fit_state, doc.fit_state, "the cumulative budget must survive saving"); + } + + /// A user's custom XML that happens to use our part name is not consumed. + #[test] + fn unrelated_custom_xml_at_the_reserved_fit_name_is_kept() { + let bytes = custom_xml_package("../customxml/wordcraftfit.xml", "customxml/wordcraftfit.xml", b""); + let doc = crate::read(&bytes).unwrap(); + assert!(doc.fit_state.is_none()); + assert!( + doc.passthrough.keys().any(|k| k.eq_ignore_ascii_case("customxml/wordcraftfit.xml")), + "an unrelated store at the reserved name must survive" + ); + } + + /// When fit_state is set and an unrelated custom XML store uses the reserved name, both survive saving. + #[test] + fn unrelated_custom_xml_at_reserved_name_survives_alongside_fit_state() { + let bytes = custom_xml_package("../customxml/wordcraftfit.xml", "customxml/wordcraftfit.xml", b""); + let mut doc = crate::read(&bytes).unwrap(); + let fit = wordcraft_doc::FitState { cum_delta: 1.0, cum_space: 0.94, ..Default::default() }; + doc.fit_state = Some(fit.clone()); + let saved = crate::write(&doc).expect("save must not clash or produce duplicate parts"); + let reopened = crate::read(&saved).expect("reopen must succeed"); + assert_eq!(reopened.fit_state, Some(fit)); + assert!( + reopened.passthrough.keys().any(|k| k.starts_with("customxml/")), + "the unrelated custom XML store must survive saving under an allocated name" + ); + } + /// The parts the reader carries over unchanged, as one string. fn unmodelled_roots(d: &wordcraft_doc::Document) -> String { d.passthrough.get(UNMODELLED_ROOTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default() diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index d8f40a209e..baaeae42a7 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -54,6 +54,7 @@ pub(crate) struct EmbedWriter<'d> { } impl<'d> EmbedWriter<'d> { + /// Creates a new embedded parts writer initialized with default reserved package part paths. pub fn new(doc: &'d wordcraft_doc::Document) -> EmbedWriter<'d> { let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default(); let mut taken: HashSet = [ @@ -78,6 +79,10 @@ impl<'d> EmbedWriter<'d> { if !doc.sources.is_empty() { taken.extend(["customxml/item1.xml".into(), "customxml/itemprops1.xml".into()]); } + // The Fit One Page store is written at this path on save. A kept part must not take it. + if doc.fit_state.is_some() { + taken.insert(crate::fit::PART.to_ascii_lowercase()); + } // The macro project's parts keep their names (see `write_as`). if let Some(m) = doc.passthrough.get(VBA_RELATED) { for line in String::from_utf8_lossy(m).lines() { diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 8a917e329e..29bbc37765 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -10,6 +10,7 @@ use wordcraft_doc::{Block, Pos, StoryRef}; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; +/// Returns the command specifications for the Layout tab (page setup, breaks, line numbers). pub fn specs() -> Vec { vec![ CommandSpec::new("layout.margins", "Margins", "Layout › Page Setup", margins).params( diff --git a/crates/engine/src/cmd/page/fit_page.rs b/crates/engine/src/cmd/page/fit_page.rs index 9219ce5650..9339baf006 100644 --- a/crates/engine/src/cmd/page/fit_page.rs +++ b/crates/engine/src/cmd/page/fit_page.rs @@ -4,6 +4,7 @@ //! layout evaluation, minimal typographical perturbation, and strict readability //! safeguards (line spacing floor and font size floor). +use std::collections::BTreeMap; use std::sync::Arc; use serde_json::Value; @@ -392,7 +393,6 @@ fn collect_body_font_sizes(doc: &Document) -> Vec { collect_blocks_font_sizes(doc, &part.blocks, None, &mut sizes); } } - sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); sizes } @@ -431,17 +431,6 @@ fn collect_body_line_spacings(doc: &Document) -> Vec { - x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal) - } - (wordcraft_doc::props::LineSpacing::Multiple(_), _) => std::cmp::Ordering::Less, - (_, wordcraft_doc::props::LineSpacing::Multiple(_)) => std::cmp::Ordering::Greater, - (wordcraft_doc::props::LineSpacing::AtLeast(_), _) => std::cmp::Ordering::Less, - (_, wordcraft_doc::props::LineSpacing::AtLeast(_)) => std::cmp::Ordering::Greater, - }); spacings } @@ -481,87 +470,319 @@ fn collect_body_paragraph_spacings(doc: &Document) -> Vec { collect_blocks_paragraph_spacings(doc, &part.blocks, None, &mut spacings); } } - spacings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); spacings } +/// Recursively collects effective font sizes by formatting location key across a slice of blocks. +fn collect_blocks_font_locations( + doc: &Document, + prefix: &str, + blocks: &[Arc], + table_chr: Option<&wordcraft_doc::CharProps>, + out: &mut BTreeMap, +) { + use wordcraft_doc::styles::PartSet; + + for (bi, b) in blocks.iter().enumerate() { + let block_prefix = format!("{prefix}/{bi}"); + if let Some(p) = b.as_para() { + let style_id = p.props.style.as_deref(); + if p.runs.is_empty() { + let mark_resolved = doc.styles.resolve_char_in(style_id, table_chr, &p.mark); + out.insert(format!("{block_prefix}/mark/size"), mark_resolved.size); + out.insert(format!("{block_prefix}/mark/size_cs"), mark_resolved.size_cs); + } + for (ri, r) in p.runs.iter().enumerate() { + let resolved = doc.styles.resolve_char_in(style_id, table_chr, &r.props); + if !matches!(resolved.vert_align, wordcraft_doc::props::VertAlign::Superscript | wordcraft_doc::props::VertAlign::Subscript) { + out.insert(format!("{block_prefix}/r{ri}/size"), resolved.size); + out.insert(format!("{block_prefix}/r{ri}/size_cs"), resolved.size_cs); + } + } + } else if let Some(tbl) = b.as_table() { + let style = tbl.props.style.as_deref().and_then(|id| doc.styles.table_style(id)); + let nrows = tbl.rows.len(); + let ncols = tbl.cols(); + for (ri, row) in tbl.rows.iter().enumerate() { + for (ci, cell) in row.cells.iter().enumerate() { + let mut cell_chr = wordcraft_doc::CharProps::default(); + if let Some(st) = &style { + let sizes = (st.parts.band_size.unwrap_or(0), st.parts.col_band_size.unwrap_or(0)); + let g = tbl.grid_col(ri, ci); + let span = cell.span(); + let region = PartSet::of_cell(&tbl.props.look, sizes, (ri, nrows), (g, span, ncols)); + cell_chr.overlay(&st.chr); + for part in region.iter() { + let f = st.part(part); + cell_chr.overlay(&f.chr); + } + } + let cell_prefix = format!("{block_prefix}/tbl/r{ri}/c{ci}"); + collect_blocks_font_locations(doc, &cell_prefix, &cell.blocks, Some(&cell_chr), out); + } + } + } + } +} + +/// Collects all effective font sizes by formatting location key across body paragraphs and editable parts. +fn collect_body_font_locations(doc: &Document) -> BTreeMap { + let mut out = BTreeMap::new(); + collect_blocks_font_locations(doc, "body", &doc.body, None, &mut out); + let hf_boxes = wordcraft_doc::shrink::header_footer_text_boxes(doc); + for (id, part) in &doc.parts { + if !matches!(part.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer | wordcraft_doc::PartKind::Comment) + && !hf_boxes.contains(id) + { + let prefix = format!("part:{id}"); + collect_blocks_font_locations(doc, &prefix, &part.blocks, None, &mut out); + } + } + out +} + +/// Recursively collects effective line spacing by formatting location key across a slice of blocks. +fn collect_blocks_line_spacing_locations( + doc: &Document, + prefix: &str, + blocks: &[Arc], + table_para: Option<&wordcraft_doc::ParaProps>, + out: &mut BTreeMap, +) { + for (bi, b) in blocks.iter().enumerate() { + let block_prefix = format!("{prefix}/{bi}"); + if let Some(p) = b.as_para() { + let resolved = doc.styles.resolve_para_in(&p.props, table_para); + out.insert(format!("{block_prefix}/line_spacing"), resolved.line_spacing); + } else if let Some(tbl) = b.as_table() { + let style = tbl.props.style.as_deref().and_then(|id| doc.styles.table_style(id)); + let cell_para = style.as_ref().map(|st| &st.para); + for (ri, row) in tbl.rows.iter().enumerate() { + for (ci, cell) in row.cells.iter().enumerate() { + let cell_prefix = format!("{block_prefix}/tbl/r{ri}/c{ci}"); + collect_blocks_line_spacing_locations(doc, &cell_prefix, &cell.blocks, cell_para, out); + } + } + } + } +} + +/// Collects all effective line spacing values by formatting location key across body paragraphs and editable parts. +fn collect_body_line_spacing_locations(doc: &Document) -> BTreeMap { + let mut out = BTreeMap::new(); + collect_blocks_line_spacing_locations(doc, "body", &doc.body, None, &mut out); + let hf_boxes = wordcraft_doc::shrink::header_footer_text_boxes(doc); + for (id, part) in &doc.parts { + if !matches!(part.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer | wordcraft_doc::PartKind::Comment) + && !hf_boxes.contains(id) + { + let prefix = format!("part:{id}"); + collect_blocks_line_spacing_locations(doc, &prefix, &part.blocks, None, &mut out); + } + } + out +} + +/// Recursively collects effective paragraph spacing by formatting location key across a slice of blocks. +fn collect_blocks_paragraph_spacing_locations( + doc: &Document, + prefix: &str, + blocks: &[Arc], + table_para: Option<&wordcraft_doc::ParaProps>, + out: &mut BTreeMap, +) { + for (bi, b) in blocks.iter().enumerate() { + let block_prefix = format!("{prefix}/{bi}"); + if let Some(p) = b.as_para() { + let resolved = doc.styles.resolve_para_in(&p.props, table_para); + out.insert(format!("{block_prefix}/space_before"), resolved.space_before); + out.insert(format!("{block_prefix}/space_after"), resolved.space_after); + } else if let Some(tbl) = b.as_table() { + let style = tbl.props.style.as_deref().and_then(|id| doc.styles.table_style(id)); + let cell_para = style.as_ref().map(|st| &st.para); + for (ri, row) in tbl.rows.iter().enumerate() { + for (ci, cell) in row.cells.iter().enumerate() { + let cell_prefix = format!("{block_prefix}/tbl/r{ri}/c{ci}"); + collect_blocks_paragraph_spacing_locations(doc, &cell_prefix, &cell.blocks, cell_para, out); + } + } + } + } +} + +/// Collects all effective paragraph spacing values by formatting location key across body paragraphs and editable parts. +fn collect_body_paragraph_spacing_locations(doc: &Document) -> BTreeMap { + let mut out = BTreeMap::new(); + collect_blocks_paragraph_spacing_locations(doc, "body", &doc.body, None, &mut out); + let hf_boxes = wordcraft_doc::shrink::header_footer_text_boxes(doc); + for (id, part) in &doc.parts { + if !matches!(part.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer | wordcraft_doc::PartKind::Comment) + && !hf_boxes.contains(id) + { + let prefix = format!("part:{id}"); + collect_blocks_paragraph_spacing_locations(doc, &prefix, &part.blocks, None, &mut out); + } + } + out +} + /// Checks whether previously reduced text from an earlier fit is still present in the document. /// -/// Compares each current value against the original values recorded before the first fit, within -/// the cumulative reduction that fit applied. An exact match with the original multiset means every -/// size was restored, even when one original value sits inside another value's reduction interval. +/// Compares each formatting location against its recorded original value before the first fit, +/// within the cumulative reduction that fit applied. #[cfg(test)] fn has_previously_reduced_text(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { (fit.cum_delta > 0.0 && font_reduction_present(doc, fit)) || (fit.cum_space < 1.0 - 1e-4 && spacing_reduction_present(doc, fit)) } -fn same_sizes(a: &[f32], b: &[f32]) -> bool { - a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4) -} - -fn same_spacings(a: &[wordcraft_doc::props::LineSpacing], b: &[wordcraft_doc::props::LineSpacing]) -> bool { - a.len() == b.len() - && a.iter().zip(b).all(|(x, y)| match (x, y) { - (wordcraft_doc::props::LineSpacing::Multiple(c), wordcraft_doc::props::LineSpacing::Multiple(o)) - | (wordcraft_doc::props::LineSpacing::AtLeast(c), wordcraft_doc::props::LineSpacing::AtLeast(o)) - | (wordcraft_doc::props::LineSpacing::Exactly(c), wordcraft_doc::props::LineSpacing::Exactly(o)) => (c - o).abs() < 1e-4, - _ => false, - }) -} - +/// Checks whether any font reduction from an earlier fit remains active in `doc`. +/// +/// Compares each formatting location against its recorded original value before the fit. +/// Treats locations without a recorded key as unknown and preserves the active budget when +/// counts or keys do not match; clears it only when matching locations establish that +/// reductions are fully restored. fn font_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { - if fit.original_font_sizes.is_empty() { - return false; + if fit.original_font_locations.is_empty() { + if fit.original_font_sizes.is_empty() { + return false; + } + let current_sizes = collect_body_font_sizes(doc); + return current_sizes.iter().any(|&cur| { + fit.original_font_sizes.iter().any(|&orig| { + let floor = orig - fit.cum_delta - 1e-4; + cur >= floor && cur < orig - 1e-4 + }) + }); } - let current_sizes = collect_body_font_sizes(doc); - if same_sizes(¤t_sizes, &fit.original_font_sizes) { - return false; - } - current_sizes.iter().any(|&cur| { - fit.original_font_sizes.iter().any(|&orig| { + + let current = collect_body_font_locations(doc); + let mut matching_count = 0; + for (key, &orig) in &fit.original_font_locations { + if let Some(&cur) = current.get(key) { + matching_count += 1; let floor = orig - fit.cum_delta - 1e-4; - cur >= floor && cur < orig - 1e-4 - }) - }) -} - -fn line_spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { - if fit.original_line_spacings.is_empty() { - return false; - } - let current_spacings = collect_body_line_spacings(doc); - if same_spacings(¤t_spacings, &fit.original_line_spacings) { - return false; - } - current_spacings.iter().any(|cur| { - fit.original_line_spacings.iter().any(|orig| match (cur, orig) { - (wordcraft_doc::props::LineSpacing::Multiple(c), wordcraft_doc::props::LineSpacing::Multiple(o)) - | (wordcraft_doc::props::LineSpacing::AtLeast(c), wordcraft_doc::props::LineSpacing::AtLeast(o)) - | (wordcraft_doc::props::LineSpacing::Exactly(c), wordcraft_doc::props::LineSpacing::Exactly(o)) => { - let floor = o * fit.cum_space - 1e-4; - *c >= floor && *c < *o - 1e-4 + if cur >= floor && cur < orig - 1e-4 { + return true; } - _ => false, - }) - }) + } + } + + if matching_count != fit.original_font_locations.len() || current.len() != fit.original_font_locations.len() { + return true; + } + + let all_restored = fit.original_font_locations.iter().all(|(key, &orig)| current.get(key).map(|&cur| cur >= orig - 1e-4).unwrap_or(false)); + + !all_restored } +/// Checks whether any line spacing reduction from an earlier fit remains active in `doc`. +/// +/// Compares each formatting location against its recorded original line spacing before the fit. +/// Treats locations without a recorded key as unknown and preserves the active budget when +/// counts or keys do not match; clears it only when matching locations establish that +/// reductions are fully restored. +fn line_spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { + if fit.original_line_spacing_locations.is_empty() { + if fit.original_line_spacings.is_empty() { + return false; + } + let current_spacings = collect_body_line_spacings(doc); + return current_spacings.iter().any(|cur| { + fit.original_line_spacings.iter().any(|orig| match (cur, orig) { + (wordcraft_doc::props::LineSpacing::Multiple(c), wordcraft_doc::props::LineSpacing::Multiple(o)) + | (wordcraft_doc::props::LineSpacing::AtLeast(c), wordcraft_doc::props::LineSpacing::AtLeast(o)) + | (wordcraft_doc::props::LineSpacing::Exactly(c), wordcraft_doc::props::LineSpacing::Exactly(o)) => { + let floor = o * fit.cum_space - 1e-4; + *c >= floor && *c < *o - 1e-4 + } + _ => false, + }) + }); + } + + let current = collect_body_line_spacing_locations(doc); + let mut matching_count = 0; + for (key, orig) in &fit.original_line_spacing_locations { + if let Some(cur) = current.get(key) { + matching_count += 1; + let is_reduced = match (cur, orig) { + (wordcraft_doc::props::LineSpacing::Multiple(c), wordcraft_doc::props::LineSpacing::Multiple(o)) + | (wordcraft_doc::props::LineSpacing::AtLeast(c), wordcraft_doc::props::LineSpacing::AtLeast(o)) + | (wordcraft_doc::props::LineSpacing::Exactly(c), wordcraft_doc::props::LineSpacing::Exactly(o)) => { + let floor = o * fit.cum_space - 1e-4; + *c >= floor && *c < *o - 1e-4 + } + _ => false, + }; + if is_reduced { + return true; + } + } + } + + if matching_count != fit.original_line_spacing_locations.len() || current.len() != fit.original_line_spacing_locations.len() { + return true; + } + + let all_restored = fit.original_line_spacing_locations.iter().all(|(key, orig)| { + current + .get(key) + .map(|cur| match (cur, orig) { + (wordcraft_doc::props::LineSpacing::Multiple(c), wordcraft_doc::props::LineSpacing::Multiple(o)) + | (wordcraft_doc::props::LineSpacing::AtLeast(c), wordcraft_doc::props::LineSpacing::AtLeast(o)) + | (wordcraft_doc::props::LineSpacing::Exactly(c), wordcraft_doc::props::LineSpacing::Exactly(o)) => *c >= *o - 1e-4, + _ => false, + }) + .unwrap_or(false) + }); + + !all_restored +} + +/// Checks whether any paragraph spacing reduction from an earlier fit remains active in `doc`. +/// +/// Compares each formatting location against its recorded original paragraph spacing before the fit. +/// Treats locations without a recorded key as unknown and preserves the active budget when +/// counts or keys do not match; clears it only when matching locations establish that +/// reductions are fully restored. fn paragraph_spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { - if fit.original_paragraph_spacings.is_empty() { - return false; + if fit.original_paragraph_spacing_locations.is_empty() { + if fit.original_paragraph_spacings.is_empty() { + return false; + } + let current_spacings = collect_body_paragraph_spacings(doc); + return current_spacings.iter().any(|&cur| { + fit.original_paragraph_spacings.iter().any(|&orig| { + let floor = orig * fit.cum_space - 1e-4; + cur >= floor && cur < orig - 1e-4 + }) + }); } - let current_spacings = collect_body_paragraph_spacings(doc); - if same_sizes(¤t_spacings, &fit.original_paragraph_spacings) { - return false; - } - current_spacings.iter().any(|&cur| { - fit.original_paragraph_spacings.iter().any(|&orig| { + + let current = collect_body_paragraph_spacing_locations(doc); + let mut matching_count = 0; + for (key, &orig) in &fit.original_paragraph_spacing_locations { + if let Some(&cur) = current.get(key) { + matching_count += 1; let floor = orig * fit.cum_space - 1e-4; - cur >= floor && cur < orig - 1e-4 - }) - }) + if cur >= floor && cur < orig - 1e-4 { + return true; + } + } + } + + if matching_count != fit.original_paragraph_spacing_locations.len() || current.len() != fit.original_paragraph_spacing_locations.len() { + return true; + } + + let all_restored = + fit.original_paragraph_spacing_locations.iter().all(|(key, &orig)| current.get(key).map(|&cur| cur >= orig - 1e-4).unwrap_or(false)); + + !all_restored } +/// Checks whether line or paragraph spacing reductions from an earlier fit remain active in `doc`. fn spacing_reduction_present(doc: &Document, fit: &wordcraft_doc::shrink::FitState) -> bool { line_spacing_reduction_present(doc, fit) || paragraph_spacing_reduction_present(doc, fit) } @@ -575,6 +796,7 @@ struct ActiveBudget { spacing_active: bool, } +/// Resolves the active cumulative budget for font and spacing components in `doc`. fn active_budget(doc: &Document) -> ActiveBudget { let Some(fit) = doc.fit_state.as_ref() else { return ActiveBudget { delta: 0.0, space: 1.0, font_active: false, spacing_active: false }; @@ -589,15 +811,33 @@ fn active_budget(doc: &Document) -> ActiveBudget { } } +/// The original typography baseline recorded before the first fit. +#[derive(Clone, Debug, Default)] +struct OriginalTypography { + font_sizes: Vec, + line_spacings: Vec, + paragraph_spacings: Vec, + font_locations: BTreeMap, + line_spacing_locations: BTreeMap, + paragraph_spacing_locations: BTreeMap, +} + +impl OriginalTypography { + /// Collects the typography baseline from the body and editable parts of `doc`. + fn from_doc(doc: &Document) -> Self { + Self { + font_sizes: collect_body_font_sizes(doc), + line_spacings: collect_body_line_spacings(doc), + paragraph_spacings: collect_body_paragraph_spacings(doc), + font_locations: collect_body_font_locations(doc), + line_spacing_locations: collect_body_line_spacing_locations(doc), + paragraph_spacing_locations: collect_body_paragraph_spacing_locations(doc), + } + } +} + /// Records the cumulative Fit One Page reduction state into the document's internal fit tracking. -fn set_cumulative_shrink_state( - doc: &mut Document, - delta: f32, - space: f32, - original_font_sizes: Vec, - original_line_spacings: Vec, - original_paragraph_spacings: Vec, -) { +fn set_cumulative_shrink_state(doc: &mut Document, delta: f32, space: f32, orig: OriginalTypography) { let sizes = collect_body_font_sizes(doc); let spacings = collect_body_line_spacings(doc); let paragraph_spacings = collect_body_paragraph_spacings(doc); @@ -607,9 +847,12 @@ fn set_cumulative_shrink_state( reduced_font_sizes: sizes, reduced_line_spacings: spacings, reduced_paragraph_spacings: paragraph_spacings, - original_font_sizes, - original_line_spacings, - original_paragraph_spacings, + original_font_sizes: orig.font_sizes, + original_line_spacings: orig.line_spacings, + original_paragraph_spacings: orig.paragraph_spacings, + original_font_locations: orig.font_locations, + original_line_spacing_locations: orig.line_spacing_locations, + original_paragraph_spacing_locations: orig.paragraph_spacing_locations, }); } @@ -703,7 +946,17 @@ fn fit_layout_options(s: &Session) -> LayoutOptions { /// Refuses while Track Changes and Track Formatting are on, because the style /// reductions it needs cannot be recorded as formatting revisions. pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { - // 0. Refuse while formatting changes must be tracked: fitting rescales styles, and the + // 0. Parse parameters safely + let max_delta = p::f32(v, "maxDeltaPt").unwrap_or(2.0).clamp(0.0, 10.0); + let step_pt_opt = p::f32(v, "stepPt"); + if let Some(step) = step_pt_opt + && !(step > 0.0 && step.is_finite()) + { + return Err(CmdError::Params("`stepPt` must be a positive number of points".into())); + } + let adjust_spacing = p::bool(v, "adjustSpacing").unwrap_or(true); + + // 1. Refuse while formatting changes must be tracked: fitting rescales styles, and the // formatting-recording path compares direct run/paragraph properties only, so the // inherited reductions could not be represented as revisions. if s.doc.settings.track_changes && s.prefs.markup.track_formatting { @@ -712,26 +965,21 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { )); } - // 1. Anti-compound guard: each component has its own budget, so a restored font or spacing - // budget is not blocked by the other component's remaining reduction. + // 2. Resolve the cumulative budget. Each component has its own, so a restored font or + // spacing budget is not blocked by the other component's remaining reduction. let prior_fit = s.doc.fit_state.clone(); let budget = active_budget(&s.doc); let (cum_delta, cum_space) = (budget.delta, budget.space); let (font_active, spacing_active) = (budget.font_active, budget.spacing_active); let font_exhausted = cum_delta >= 1.0 - 1e-4; let spacing_exhausted = cum_space <= 0.94 + 1e-4; - if font_exhausted && spacing_exhausted { - return Err(CmdError::Failed("Unable to shrink document further without reducing text beyond readability limits.".into())); - } - // 2. Build equivalent Print layout options + // 3. Build equivalent Print layout options let print_opts = fit_layout_options(s); let shown_hidden = print_opts.show_hidden; - let pre_font_sizes = collect_body_font_sizes(&s.doc); - let pre_line_spacings = collect_body_line_spacings(&s.doc); - let pre_paragraph_spacings = collect_body_paragraph_spacings(&s.doc); + let pre_typography = OriginalTypography::from_doc(&s.doc); - // 3. Initial page count evaluation + // 4. Initial page count evaluation let mut initial_cache = LayoutCache::new(); let initial_layout = wordcraft_layout::layout(&s.doc, &mut initial_cache, &print_opts); let p0 = initial_layout.pages.len(); @@ -739,6 +987,9 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { if p0 <= 1 { return Err(CmdError::Failed("The document already fits on one page.".into())); } + if font_exhausted && spacing_exhausted { + return Err(CmdError::Failed("Unable to shrink document further without reducing text beyond readability limits.".into())); + } // Explicit breaks force pages regardless of typography; refuse before any trial can appear to // succeed by moving text around them. @@ -746,7 +997,7 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { return Err(CmdError::Failed("Unable to fit document to one page: explicit page or section breaks prevent page reduction.".into())); } - // 4. Pre-flight check on final page content volume + // 5. Pre-flight check on final page content volume let last_page = &initial_layout.pages[p0 - 1]; let (last_page_lines, fill_ratio) = final_page_volume(&s.doc, last_page); if last_page_lines > 12 || fill_ratio > 0.30 { @@ -756,11 +1007,6 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { } let target_pages = p0.saturating_sub(1); - // 5. Parse parameters safely - let max_delta = p::f32(v, "maxDeltaPt").unwrap_or(2.0).clamp(0.0, 10.0); - let step_pt_opt = p::f32(v, "stepPt"); - let adjust_spacing = p::bool(v, "adjustSpacing").unwrap_or(true); - // 6. Generate candidate steps: either bounded fine-grained stepPt or pre-calculated steps let candidates = generate_candidate_steps(max_delta, step_pt_opt, adjust_spacing); @@ -842,17 +1088,27 @@ pub fn fit_one_page(s: &mut Session, v: &Value) -> CmdResult { .map(|fit| fit.original_paragraph_spacings.clone()) .filter(|spacings| !spacings.is_empty()) }; - let original_font_sizes = prior_original_fonts().unwrap_or(pre_font_sizes); - let original_line_spacings = prior_original_lines().unwrap_or(pre_line_spacings); - let original_paragraph_spacings = prior_original_paragraphs().unwrap_or(pre_paragraph_spacings); - set_cumulative_shrink_state( - &mut new_doc, - cum_delta + d, - cum_space * a, - original_font_sizes, - original_line_spacings, - original_paragraph_spacings, - ); + let prior_original_font_locs = + || prior_fit.as_ref().filter(|_| font_active).map(|fit| fit.original_font_locations.clone()).filter(|locs| !locs.is_empty()); + let prior_original_line_locs = || { + prior_fit.as_ref().filter(|_| spacing_active).map(|fit| fit.original_line_spacing_locations.clone()).filter(|locs| !locs.is_empty()) + }; + let prior_original_para_locs = || { + prior_fit + .as_ref() + .filter(|_| spacing_active) + .map(|fit| fit.original_paragraph_spacing_locations.clone()) + .filter(|locs| !locs.is_empty()) + }; + let original_typography = OriginalTypography { + font_sizes: prior_original_fonts().unwrap_or(pre_typography.font_sizes), + line_spacings: prior_original_lines().unwrap_or(pre_typography.line_spacings), + paragraph_spacings: prior_original_paragraphs().unwrap_or(pre_typography.paragraph_spacings), + font_locations: prior_original_font_locs().unwrap_or(pre_typography.font_locations), + line_spacing_locations: prior_original_line_locs().unwrap_or(pre_typography.line_spacing_locations), + paragraph_spacing_locations: prior_original_para_locs().unwrap_or(pre_typography.paragraph_spacing_locations), + }; + set_cumulative_shrink_state(&mut new_doc, cum_delta + d, cum_space * a, original_typography); s.doc = new_doc; s.clamp_selection(); let page_word = if final_pages == 1 { "page" } else { "pages" }; @@ -1004,11 +1260,9 @@ mod tests { } } } - let originals = collect_body_font_sizes(&doc); - let originals_spacings = collect_body_line_spacings(&doc); - let originals_paragraphs = collect_body_paragraph_spacings(&doc); + let orig = OriginalTypography::from_doc(&doc); wordcraft_doc::shrink::fit_document_typography(&mut doc, 1.0, 1.0); - set_cumulative_shrink_state(&mut doc, 1.0, 1.0, originals, originals_spacings, originals_paragraphs); + set_cumulative_shrink_state(&mut doc, 1.0, 1.0, orig); assert!(has_previously_reduced_text(&doc, doc.fit_state.as_ref().unwrap())); // Restoring only the heading leaves the body reduced. @@ -1044,11 +1298,9 @@ mod tests { p.props.space_before = Some(0.0); p.props.space_after = Some(0.0); } - let originals = collect_body_font_sizes(&doc); - let originals_spacings = collect_body_line_spacings(&doc); - let originals_paragraphs = collect_body_paragraph_spacings(&doc); + let orig = OriginalTypography::from_doc(&doc); wordcraft_doc::shrink::fit_document_typography(&mut doc, 0.0, 0.9); - set_cumulative_shrink_state(&mut doc, 0.0, 0.9, originals, originals_spacings, originals_paragraphs); + set_cumulative_shrink_state(&mut doc, 0.0, 0.9, orig); assert!(has_previously_reduced_text(&doc, doc.fit_state.as_ref().unwrap())); // Restoring the 2.0 spacing but leaving the 1.0 one reduced keeps the budget. @@ -1076,11 +1328,9 @@ mod tests { r.props.size = Some(11.0); } } - let originals = collect_body_font_sizes(&doc); - let originals_spacings = collect_body_line_spacings(&doc); - let originals_paragraphs = collect_body_paragraph_spacings(&doc); + let orig = OriginalTypography::from_doc(&doc); wordcraft_doc::shrink::fit_document_typography(&mut doc, 1.0, 1.0); - set_cumulative_shrink_state(&mut doc, 1.0, 1.0, originals, originals_spacings, originals_paragraphs); + set_cumulative_shrink_state(&mut doc, 1.0, 1.0, orig); assert!(font_reduction_present(&doc, doc.fit_state.as_ref().unwrap())); // Restoring 12 pt and 11 pt makes 11 pt look like another size's reduction interval; the @@ -1108,11 +1358,9 @@ mod tests { p.props.space_after = Some(0.0); } } - let originals = collect_body_font_sizes(&doc); - let originals_spacings = collect_body_line_spacings(&doc); - let originals_paragraphs = collect_body_paragraph_spacings(&doc); + let orig = OriginalTypography::from_doc(&doc); wordcraft_doc::shrink::fit_document_typography(&mut doc, 0.0, 0.94); - set_cumulative_shrink_state(&mut doc, 0.0, 0.94, originals, originals_spacings, originals_paragraphs); + set_cumulative_shrink_state(&mut doc, 0.0, 0.94, orig); // Exactly(8.0) rounds back to 8.0, but 20 pt paragraph spacing becomes 18.8 pt. assert!(!line_spacing_reduction_present(&doc, doc.fit_state.as_ref().unwrap())); @@ -1142,11 +1390,9 @@ mod tests { } } } - let originals = collect_body_font_sizes(&doc); - let originals_spacings = collect_body_line_spacings(&doc); - let originals_paragraphs = collect_body_paragraph_spacings(&doc); + let orig = OriginalTypography::from_doc(&doc); wordcraft_doc::shrink::fit_document_typography(&mut doc, 1.0, 0.9); - set_cumulative_shrink_state(&mut doc, 1.0, 0.9, originals, originals_spacings, originals_paragraphs); + set_cumulative_shrink_state(&mut doc, 1.0, 0.9, orig); let both = active_budget(&doc); assert_eq!(both.delta, 1.0); @@ -1202,4 +1448,79 @@ mod tests { let fitted = wordcraft_layout::layout(&s.doc, &mut cache, &opts); assert_eq!(fitted.pages.len(), s.layout().pages.len()); } + + #[test] + fn provenance_tracks_reductions_per_location_not_sorted_multisets() { + let mut doc = Document::from_text("Alpha\nBeta"); + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { + for r in &mut p.runs { + r.props.size = Some(12.0); + } + } + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { + for r in &mut p.runs { + r.props.size = Some(11.0); + } + } + let orig = OriginalTypography::from_doc(&doc); + wordcraft_doc::shrink::fit_document_typography(&mut doc, 1.0, 1.0); + set_cumulative_shrink_state(&mut doc, 1.0, 1.0, orig); + + // Setting the second run (reduced to 10 pt) to 12 pt yields sorted sizes [11.0, 12.0], + // matching the sorted originals [11.0, 12.0]. However, the first run (Slot 0) is still reduced + // from 12 pt to 11 pt, so the active budget must not be cleared. + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { + for r in &mut p.runs { + r.props.size = Some(12.0); + } + } + assert!(font_reduction_present(&doc, doc.fit_state.as_ref().unwrap()), "Slot 0 is still reduced"); + assert_eq!(active_budget(&doc).delta, 1.0); + + // Restoring both runs to their respective original locations clears the budget. + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { + for r in &mut p.runs { + r.props.size = Some(12.0); + } + } + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { + for r in &mut p.runs { + r.props.size = Some(11.0); + } + } + assert!(!font_reduction_present(&doc, doc.fit_state.as_ref().unwrap())); + assert_eq!(active_budget(&doc).delta, 0.0); + } + + #[test] + fn paragraph_swap_preserves_active_budget() { + let mut doc = Document::from_text("Alpha\nBeta"); + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[0]) { + for r in &mut p.runs { + r.props.size = Some(12.0); + } + } + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { + for r in &mut p.runs { + r.props.size = Some(11.0); + } + } + let orig = OriginalTypography::from_doc(&doc); + wordcraft_doc::shrink::fit_document_typography(&mut doc, 1.0, 1.0); + set_cumulative_shrink_state(&mut doc, 1.0, 1.0, orig); + + // Swap the two paragraphs: doc.body[0] is now Beta (10 pt), doc.body[1] is now Alpha (11 pt). + doc.body.swap(0, 1); + assert!(font_reduction_present(&doc, doc.fit_state.as_ref().unwrap()), "reordered paragraphs must preserve active budget"); + assert_eq!(active_budget(&doc).delta, 1.0); + } + + #[test] + fn step_pt_must_be_positive_and_finite() { + let mut s = Session::new(Document::from_text("Alpha\nBeta")); + let err_zero = s.run("layout.fitOnePage", &serde_json::json!({"stepPt": 0.0})); + assert!(matches!(err_zero, Err(CmdError::Params(_)))); + let err_neg = s.run("layout.fitOnePage", &serde_json::json!({"stepPt": -0.5})); + assert!(matches!(err_neg, Err(CmdError::Params(_)))); + } } diff --git a/crates/engine/src/tests/fit_one_page.rs b/crates/engine/src/tests/fit_one_page.rs index 1bbaaf5abe..a7458bf8a2 100644 --- a/crates/engine/src/tests/fit_one_page.rs +++ b/crates/engine/src/tests/fit_one_page.rs @@ -284,6 +284,34 @@ fn fit_one_page_anti_compound_guard_prevents_repeated_invocations() { assert_eq!(s.doc, doc_before, "document must not be mutated on rejected invocation"); } +#[test] +fn fit_one_page_already_fits_wins_over_exhausted_budget() { + let mut s = create_multiline_doc(5); + assert_eq!(s.layout().pages.len(), 1); + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(0.94)); + for r in &mut p.runs { + r.props.size = Some(11.0); + } + } + } + s.doc.fit_state = Some(wordcraft_doc::FitState { + cum_delta: 1.0, + cum_space: 0.94, + original_font_sizes: vec![12.0], + original_line_spacings: vec![wordcraft_doc::props::LineSpacing::Multiple(1.0)], + ..Default::default() + }); + s.touch(); + s.relayout(); + + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("already fits on one page"), "already-fits must take precedence over exhausted budgets: {err}"); +} + #[test] fn fit_one_page_checks_complex_script_font_floor() { let mut lines = 28; diff --git a/crates/ui-egui/src/ribbon/layout.rs b/crates/ui-egui/src/ribbon/layout.rs index 8b457c8d5b..e96089b3dc 100644 --- a/crates/ui-egui/src/ribbon/layout.rs +++ b/crates/ui-egui/src/ribbon/layout.rs @@ -48,6 +48,7 @@ pub(super) fn columns_items(ui: &mut Ui, app: &mut WordApp) { mi(ui, app, "More Columns…", "ui.dialog", json!({"name": "columns"})); } +/// Renders the Layout ribbon tab (Page Setup, Paragraph, Arrange). pub(super) fn layout(app: &mut WordApp, ui: &mut Ui) { group(ui, "Page Setup", Some("layout.pageSetup"), app, |ui, app| { // Text Direction works on what the caret is in: table cells, a text box, else the section.