mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-14 14:32:31 +00:00
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:
1 parent
3f7bff9204
commit
2bda4e5a41
9 files changed
+610
-150
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(¤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<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(_))));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user