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.
This commit is contained in:
batistalucasv committed 2026-11-12 23:44:54 -03:00
1 parent 3f7bff9204
commit 2bda4e5a41
9 files changed
+610 -150

No files matched your search

+9
View File
@@ -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<f32>,
/// Original effective body font sizes by formatting location key before the first fit.
#[serde(default)]
pub original_font_locations: std::collections::BTreeMap<String, f32>,
/// Original effective line spacing values by formatting location key before the first fit.
#[serde(default)]
pub original_line_spacing_locations: std::collections::BTreeMap<String, LineSpacing>,
/// Original effective paragraph spacing values by formatting location key before the first fit.
#[serde(default)]
pub original_paragraph_spacing_locations: std::collections::BTreeMap<String, f32>,
}
/// Scale a font size down by `delta_pt`.
+1 -5
View File
@@ -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()
}
}
+1 -1
View File
@@ -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();
+112 -13
View File
@@ -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<u8> {
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body/></w:document>"#);
let r = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="{target}"/></Relationships>"#
);
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<u8> {
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body/></w:document>"#);
let root_rels = format!(
r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rIdFit" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="{target}"/></Relationships>"#
);
let r = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>"#;
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"<other xmlns=\"urn:other\"/>");
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"<other xmlns=\"urn:other\"/>");
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()
+5
View File
@@ -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<String> = [
@@ -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() {
+1
View File
@@ -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<CommandSpec> {
vec![
CommandSpec::new("layout.margins", "Margins", "Layout › Page Setup", margins).params(
+452 -131
View File
@@ -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<f32> {
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<wordcraft_doc::props::LineS
collect_blocks_line_spacings(doc, &part.blocks, None, &mut spacings);
}
}
spacings.sort_by(|a, b| match (a, b) {
(wordcraft_doc::props::LineSpacing::Multiple(x), wordcraft_doc::props::LineSpacing::Multiple(y))
| (wordcraft_doc::props::LineSpacing::AtLeast(x), wordcraft_doc::props::LineSpacing::AtLeast(y))
| (wordcraft_doc::props::LineSpacing::Exactly(x), wordcraft_doc::props::LineSpacing::Exactly(y)) => {
x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal)
}
(wordcraft_doc::props::LineSpacing::Multiple(_), _) => std::cmp::Ordering::Less,
(_, wordcraft_doc::props::LineSpacing::Multiple(_)) => std::cmp::Ordering::Greater,
(wordcraft_doc::props::LineSpacing::AtLeast(_), _) => std::cmp::Ordering::Less,
(_, wordcraft_doc::props::LineSpacing::AtLeast(_)) => std::cmp::Ordering::Greater,
});
spacings
}
@@ -481,87 +470,319 @@ fn collect_body_paragraph_spacings(doc: &Document) -> Vec<f32> {
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<wordcraft_doc::Block>],
table_chr: Option<&wordcraft_doc::CharProps>,
out: &mut BTreeMap<String, f32>,
) {
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<String, f32> {
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<wordcraft_doc::Block>],
table_para: Option<&wordcraft_doc::ParaProps>,
out: &mut BTreeMap<String, wordcraft_doc::props::LineSpacing>,
) {
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<String, wordcraft_doc::props::LineSpacing> {
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<wordcraft_doc::Block>],
table_para: Option<&wordcraft_doc::ParaProps>,
out: &mut BTreeMap<String, f32>,
) {
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<String, f32> {
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(&current_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(&current_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(&current_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<f32>,
line_spacings: Vec<wordcraft_doc::props::LineSpacing>,
paragraph_spacings: Vec<f32>,
font_locations: BTreeMap<String, f32>,
line_spacing_locations: BTreeMap<String, wordcraft_doc::props::LineSpacing>,
paragraph_spacing_locations: BTreeMap<String, f32>,
}
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<f32>,
original_line_spacings: Vec<wordcraft_doc::props::LineSpacing>,
original_paragraph_spacings: Vec<f32>,
) {
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(_))));
}
}
+28
View File
@@ -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;
+1
View File
@@ -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.