From 932a2e89552d59228050c32508ea380775f815b7 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 02:21:34 +0000 Subject: [PATCH 01/94] =?UTF-8?q?#3872:=20review=20=E2=80=94=20font=20tabl?= =?UTF-8?q?e=20found=20by=20its=20stored=20spelling;=20privacy=20rewrite?= =?UTF-8?q?=20escapes=20attribute=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A package storing WORD/FONTTABLE.XML (part names are case-insensitive) loaded no embedded fonts and a save dropped the table and every font: lookups used the exact constant spelling. They now find the key case-insensitively. - scrub_private re-pushed attribute values raw (quick-xml push_attribute does not escape), so a value read from single quotes became malformed XML. The value is now unescaped and escaped again. - rel_targets matches _rels/ case-insensitively. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 --- crates/docx/src/fonts.rs | 13 +++++++--- crates/docx/src/package.rs | 12 ++++++++- crates/docx/src/write/embed.rs | 12 ++++++--- crates/docx/tests/it/glossary.rs | 25 ++++++++++++++++++ crates/docx/tests/it/passthrough.rs | 40 +++++++++++++++++++++++++++++ docs/file-format-parity.md | 3 ++- 6 files changed, 95 insertions(+), 10 deletions(-) diff --git a/crates/docx/src/fonts.rs b/crates/docx/src/fonts.rs index 66e92ae0b8..5ab5512e92 100644 --- a/crates/docx/src/fonts.rs +++ b/crates/docx/src/fonts.rs @@ -8,7 +8,7 @@ use std::collections::HashSet; use wordcraft_doc::Document; -use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART}; +use crate::package::{EMBEDDED_PARTS, EmbeddedManifest}; /// Most font files taken from one document. const MAX_FONTS: usize = 64; @@ -41,11 +41,15 @@ pub fn embedded_fonts(doc: &Document) -> Vec { /// [`embedded_fonts`], copying at most `budget` bytes of font files. fn fonts_within(doc: &Document, mut budget: u64) -> Vec { let budget_total = budget; - let Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else { + let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default(); + // The table is kept under the spelling the package stores it as (#3872). + let Some(key) = crate::package::font_table_key(&doc.passthrough) else { return Vec::new(); }; - let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default(); - let Some(part) = manifest.parts.get(FONT_TABLE_PART) else { + let Some(table) = doc.passthrough.get(key).and_then(|b| crate::xml::parse(b).ok()) else { + return Vec::new(); + }; + let Some(part) = manifest.parts.get(key) else { return Vec::new(); }; let mut out = Vec::new(); @@ -118,6 +122,7 @@ mod tests { use std::sync::Arc; use super::*; + use crate::package::FONT_TABLE_PART; /// A document whose font table makes `refs` references (`(font name, part)`) to font parts /// `parts` (`(name, size)`). diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index a7511fd2bc..bdefc38fa7 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -96,6 +96,15 @@ pub const MAX_VBA_RELATED: usize = 64; pub const EMBEDDED_PARTS: &str = "wordcraft:embedded.parts"; /// The font table kept from a file (read by `Reader::keep_part_tree`), with the fonts it embeds. pub const FONT_TABLE_PART: &str = "word/fontTable.xml"; + +/// The spelling the font table is stored under in `parts` (part names are case-insensitive, so a +/// package may spell it `WORD/FONTTABLE.XML`), preferring the exact [`FONT_TABLE_PART`]. +pub fn font_table_key(parts: &BTreeMap) -> Option<&str> { + if let Some((k, _)) = parts.get_key_value(FONT_TABLE_PART) { + return Some(k.as_str()); + } + parts.keys().find(|k| k.eq_ignore_ascii_case(FONT_TABLE_PART)).map(String::as_str) +} /// Custom XML roots kept by the package (P) or main document (D): `owner\ttype\tpart`. /// Their opaque bytes and related parts use the same bounded manifest as embedded objects. pub const CUSTOM_XML_ROOTS: &str = "wordcraft:customXml.roots"; @@ -412,7 +421,8 @@ fn rel_targets(files: &BTreeMap>>) -> HashSet { let mut named = HashSet::new(); for (name, bytes) in files { // `dir/_rels/file.rels` relates `dir/file`, so its targets resolve against `dir`. - let Some(at) = name.rfind("_rels/").filter(|_| name.to_ascii_lowercase().ends_with(".rels")) else { continue }; + let lower = name.to_ascii_lowercase(); + let Some(at) = lower.rfind("_rels/").filter(|_| lower.ends_with(".rels")) else { continue }; let dir = name.get(..at).unwrap_or("").trim_end_matches('/'); let Ok(root) = xml::parse(bytes) else { continue }; for r in root.els().filter(|e| e.local() == "Relationship") { diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index 405b901e4f..24f79a5630 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -14,8 +14,7 @@ use wordcraft_doc::para::Float; use super::PartRels; use crate::package::{ - CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED, - rel_is, relative, rt, + CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED, rel_is, relative, rt, }; use crate::units::{MAX_LEN_PT, emu, int}; use crate::xml::{self, El, MAX_DEPTH}; @@ -105,7 +104,8 @@ impl<'d> EmbedWriter<'d> { /// Write the font table kept from a file, with the fonts it embeds, relating it from `rels`. pub fn font_table(&mut self, rels: &mut PartRels) { let mut written = Vec::new(); - if let Some(name) = self.copy_tree(FONT_TABLE_PART, &mut HashMap::new(), &mut written) { + let Some(root) = crate::package::font_table_key(self.passthrough).map(str::to_string) else { return }; + if let Some(name) = self.copy_tree(&root, &mut HashMap::new(), &mut written) { rels.add(rt::FONT_TABLE, &relative(STORY_PART, &name), false); } } @@ -747,7 +747,11 @@ fn scrub_private(bytes: &[u8], names: bool, dates: bool) -> Option> { b"date" if dates && wml_attr => None, b"dateUtc" if dates => None, _ => { - el.push_attribute(a); + // `push_attribute` writes the value as given, so a value read from + // single quotes (`'Say "hi"'`) must be escaped again (#3872). + let plain = a.normalized_value(quick_xml::XmlVersion::Implicit1_0).ok()?; + let escaped = quick_xml::escape::escape(plain.as_ref()); + el.push_attribute(Attribute { key: a.key, value: Cow::Borrowed(escaped.as_bytes()) }); continue; } }; diff --git a/crates/docx/tests/it/glossary.rs b/crates/docx/tests/it/glossary.rs index 92e07a6514..8e6a858872 100644 --- a/crates/docx/tests/it/glossary.rs +++ b/crates/docx/tests/it/glossary.rs @@ -509,3 +509,28 @@ fn hostile_glossaries_never_panic() { let doc = read(two); assert_eq!(doc.glossary.len(), 1); } + +#[test] +fn a_private_save_escapes_the_attribute_values_it_rewrites() { + // A value read from single quotes holds a bare `"`; the privacy rewrite wrote it back inside + // double quotes unescaped, which is not XML (review of #3872). + let quoted = r#"Added clause"#; + let bytes = package( + &[("rId7", GLOSSARY_REL, "glossary/document.xml")], + &[ + ("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)])), + ("word/glossary/document.xml", glossary_xml(quoted)), + ], + ); + let doc = wordcraft_docx::read(&bytes).unwrap(); + let name = doc.glossary.first().map(|e| e.name.clone()).expect("an entry"); + assert_eq!(name, r#"Say "hi" & "#); + let mut private = doc.clone(); + private.settings.remove_personal_info = true; + let saved = wordcraft_docx::write(&private).unwrap(); + let files = unzip(&saved); + let g = text(&files, &glossary_target(&files).expect("a glossary")); + assert!(!g.contains("Quill Ramsey"), "{g}"); + let again = wordcraft_docx::read(&saved).unwrap(); + assert_eq!(again.glossary.first().map(|e| e.name.as_str()), Some(name.as_str()), "{g}"); +} diff --git a/crates/docx/tests/it/passthrough.rs b/crates/docx/tests/it/passthrough.rs index 65d4e3495e..d5182922b2 100644 --- a/crates/docx/tests/it/passthrough.rs +++ b/crates/docx/tests/it/passthrough.rs @@ -403,3 +403,43 @@ fn a_part_reached_under_many_spellings_is_kept_and_written_once() { let written: usize = files.values().map(Vec::len).sum(); assert!(written < 4 * 64 * 1024, "{written} bytes written"); } + +#[test] +fn embedded_fonts_load_and_save_when_the_package_spells_the_font_table_in_capitals() { + // Part names are case-insensitive: a package storing `WORD/FONTTABLE.XML` and its fonts in + // capitals, related in lower case, loaded no fonts, and a save dropped the table and every + // font (review of #3872). + let table = format!( + r#""# + ); + let table_rels = rels(&[("rId1", &format!("{REL}/font"), "fonts/font1.odttf")]); + let font_bytes = vec![9u8; 2048]; + let ct = CT.replace( + ")> = vec![ + ("_rels/.rels", rels(&[("rIdMain", &format!("{REL}/officeDocument"), "word/document.xml")])), + ("[Content_Types].xml", ct.into_bytes()), + ( + "word/document.xml", + format!(r#"Body"#).into_bytes(), + ), + ("word/_rels/document.xml.rels", rels(&[("rId1", &format!("{REL}/fontTable"), "fontTable.xml")])), + ]; + entries.push(("WORD/FONTTABLE.XML", table.into_bytes())); + entries.push(("WORD/_RELS/FONTTABLE.XML.RELS", table_rels)); + entries.push(("WORD/FONTS/FONT1.ODTTF", font_bytes.clone())); + let doc = wordcraft_docx::read(&zip(&entries)).unwrap(); + let fonts = wordcraft_docx::embedded_fonts(&doc); + assert_eq!(fonts.len(), 1, "the font loads"); + assert_eq!(fonts[0].data, font_bytes); + // And through a save and a reopen. + let saved = wordcraft_docx::write(&doc).unwrap(); + let files = unzip(&saved); + assert!(files.keys().any(|k| k.to_ascii_lowercase().ends_with("fonttable.xml")), "{:?}", files.keys()); + let again = wordcraft_docx::read(&saved).unwrap(); + let fonts = wordcraft_docx::embedded_fonts(&again); + assert_eq!(fonts.len(), 1, "the font survives a save: {:?}", files.keys()); + assert_eq!(fonts[0].data, font_bytes); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7f56600bf4..6b38552f56 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -267,6 +267,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | 2026-10-17 | trivial | Encryption rows: Standard (Word 2007, AES-ECB) encrypted packages open with a password, also when named `.doc`/`.dot`; AES headers in Word 97-2003 files are outside the public spec and refused (gap 9). The duplicate combined `.docx`/`.doc` row is removed; the `.doc` row reads 🟡 ~60% (it opens XOR/RC4/RC4 CryptoAPI); hours 2–4 → 1–2 and 4–6 → 1–2 (left: checks against Word-written files); total re-summed from the rows, 175–287 h | | 2026-10-25 | trivial | Repeated DOCX style IDs: repeated save/reopen regression for whole-definition replacement. | | 2026-10-25 | trivial | Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`) | +| 2026-10-26 | trivial | DOCX: a package spelling `WORD/FONTTABLE.XML` and its fonts in another casing than the relationships still loads and saves its embedded fonts (the font table is found by its stored spelling), and a save that removes personal information escapes the attribute values of a kept glossary (`w:val='Say "hi"'` stays well-formed) (#3872 review) | | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (the `.doc` reader logs what its copy budget leaves out, once a kind per document; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats | 2026-10-23 | trivial | `.doc` reader logs what its copy budget leaves out (floating objects, link targets, alt text, Prm formatting), once a kind per document (#3692) | From cd683a9fd1bdb7b3c2af9f26a65d6ed228236623 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 02:42:26 +0000 Subject: [PATCH 02/94] =?UTF-8?q?#3872:=20review=20=E2=80=94=20privacy=20r?= =?UTF-8?q?ewrite=20keeps=20attribute=20values=20as=20written,=20escaping?= =?UTF-8?q?=20only=20quotes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unescaping and escaping again turned and into raw characters that read back as spaces. The raw value of well-formed markup holds no `<` and every `&` starts a reference, so only a `"` from a single-quoted value is replaced with ". Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 --- crates/docx/src/write/embed.rs | 24 +++++++++++++++++++----- crates/docx/tests/it/glossary.rs | 25 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index 24f79a5630..cc13df58ee 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -747,11 +747,25 @@ fn scrub_private(bytes: &[u8], names: bool, dates: bool) -> Option> { b"date" if dates && wml_attr => None, b"dateUtc" if dates => None, _ => { - // `push_attribute` writes the value as given, so a value read from - // single quotes (`'Say "hi"'`) must be escaped again (#3872). - let plain = a.normalized_value(quick_xml::XmlVersion::Implicit1_0).ok()?; - let escaped = quick_xml::escape::escape(plain.as_ref()); - el.push_attribute(Attribute { key: a.key, value: Cow::Borrowed(escaped.as_bytes()) }); + // `push_attribute` writes the value as given, inside `"`. In well-formed + // markup the raw value holds no `<` and every `&` starts a valid + // reference, so only a `"` from a single-quoted value needs escaping; + // references such as ` ` stay as written (#3872). + let raw = a.value.as_ref(); + let value = if raw.contains(&b'"') { + let mut v = Vec::with_capacity(raw.len() + 8); + for &c in raw { + if c == b'"' { + v.extend_from_slice(b"""); + } else { + v.push(c); + } + } + Cow::Owned(v) + } else { + Cow::Borrowed(raw) + }; + el.push_attribute(Attribute { key: a.key, value }); continue; } }; diff --git a/crates/docx/tests/it/glossary.rs b/crates/docx/tests/it/glossary.rs index 8e6a858872..d784375947 100644 --- a/crates/docx/tests/it/glossary.rs +++ b/crates/docx/tests/it/glossary.rs @@ -534,3 +534,28 @@ fn a_private_save_escapes_the_attribute_values_it_rewrites() { let again = wordcraft_docx::read(&saved).unwrap(); assert_eq!(again.glossary.first().map(|e| e.name.as_str()), Some(name.as_str()), "{g}"); } + +#[test] +fn a_private_save_keeps_character_references_in_attribute_values_as_written() { + // ` ` and ` ` are a newline and a tab only as references; written as the raw + // characters they read back as spaces (review of #3872). + let entry = r#"Added clause"#; + let bytes = package( + &[("rId7", GLOSSARY_REL, "glossary/document.xml")], + &[ + ("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)])), + ("word/glossary/document.xml", glossary_xml(entry)), + ], + ); + let doc = wordcraft_docx::read(&bytes).unwrap(); + let before = doc.glossary.first().map(|e| e.description.clone()).expect("an entry"); + assert_eq!(before, "line1\nline2\tend"); + let mut private = doc.clone(); + private.settings.remove_personal_info = true; + let saved = wordcraft_docx::write(&private).unwrap(); + let files = unzip(&saved); + let g = text(&files, &glossary_target(&files).expect("a glossary")); + assert!(!g.contains("Quill Ramsey") && g.contains(r#"w:val="line1 line2 end""#), "{g}"); + let again = wordcraft_docx::read(&saved).unwrap(); + assert_eq!(again.glossary.first().map(|e| e.description.as_str()), Some(before.as_str())); +} From 04ebc4ed4236cb19d70561d6bff86fa3f9cc8a37 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 15:13:56 +0100 Subject: [PATCH 03/94] #3995: Thai one-face fallback tests skip unless a Thai font with Latin is installed Noto Sans Thai and its siblings have no Latin letters or digits, so on a machine whose only Thai fonts are Noto no face covers the test run and fallback_for_run rightly returns None. The guard now asks whether the Thai substitute covers the whole run, which doesn't go through fallback_for_run. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/fonts/src/fontdb/tests.rs | 8 +++++--- crates/layout/src/tests/complex.rs | 15 +++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/crates/fonts/src/fontdb/tests.rs b/crates/fonts/src/fontdb/tests.rs index 52c5b86dcf..48e1573938 100644 --- a/crates/fonts/src/fontdb/tests.rs +++ b/crates/fonts/src/fontdb/tests.rs @@ -98,12 +98,14 @@ const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู #[test] fn a_thai_run_with_latin_falls_back_to_one_face() { let db = FontDb::global(); - if db.fallback_for('น', 0).is_none() { - eprintln!("skipped: no Thai font installed"); + let chars: Vec = THAI_RUN.chars().filter(|c| !c.is_whitespace()).collect(); + // Needs a Thai font with Latin letters and digits (Tahoma, Leelawadee UI, Thonburi, the + // TLWG fonts…): Noto Sans Thai alone has none, so no face covers the run. + if !chars.iter().all(|c| crate::word::resolve("TH Sarabun New", false, false).face.covers(*c)) { + eprintln!("skipped: no Thai font with Latin letters installed"); return; } let latin = db.face(FALLBACK_FAMILY, "Regular"); - let chars: Vec = THAI_RUN.chars().filter(|c| !c.is_whitespace()).collect(); let f = db.fallback_for_run(&chars, latin.id(), false, false).expect("one face covers the run"); assert_ne!(f.id, latin.id); for c in &chars { diff --git a/crates/layout/src/tests/complex.rs b/crates/layout/src/tests/complex.rs index 74a40ded97..7936784243 100644 --- a/crates/layout/src/tests/complex.rs +++ b/crates/layout/src/tests/complex.rs @@ -118,6 +118,13 @@ fn thai_font_installed() -> bool { wordcraft_fonts::FontDb::global().fallback_for('\u{0E19}', 0).is_some() } +/// Is a Thai font with Latin letters and digits installed (Tahoma, Leelawadee UI, Thonburi, the +/// TLWG fonts…)? Noto Sans Thai alone has none, so no face covers [`THAI_RUN`]: `false` skips. +fn thai_latin_font_installed() -> bool { + let face = wordcraft_fonts::word::resolve("TH Sarabun New", false, false).face; + THAI_RUN.chars().filter(|c| !c.is_whitespace()).all(|c| face.covers(c)) +} + /// Body paragraph 0 of `text`, its run set in `font` at 16 pt (`None` keeps the document's /// default font), and the face of every cluster: (cluster start, face). fn cluster_faces(text: &str, font: Option<&str>) -> Vec<(usize, wordcraft_fonts::FaceRef)> { @@ -132,8 +139,8 @@ fn cluster_faces(text: &str, font: Option<&str>) -> Vec<(usize, wordcraft_fonts: #[test] fn a_missing_thai_font_draws_the_whole_run_in_one_thai_face() { - if !thai_font_installed() { - eprintln!("skipped: no Thai font installed"); + if !thai_latin_font_installed() { + eprintln!("skipped: no Thai font with Latin letters installed"); return; } // Word substitutes one font (Cordia New) for a missing Thai font, so the Latin letters, the @@ -154,8 +161,8 @@ fn a_missing_thai_font_draws_the_whole_run_in_one_thai_face() { #[test] fn a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it() { - if !thai_font_installed() { - eprintln!("skipped: no Thai font installed"); + if !thai_latin_font_installed() { + eprintln!("skipped: no Thai font with Latin letters installed"); return; } // When the substitute lacks the run's Thai letters (no listed Thai font is installed, so the From 77578282951af916e6cbc2331d9a0a6bf18dbc03 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 09:43:02 +0100 Subject: [PATCH 04/94] #3907: continuous sections running off a page go onto the next; empty marks in a row stack to the page bottom A continuous section moves the column top down below the text above. at_top() then held at every new section start, so a paragraph that did not fit was placed anyway, as on an empty page. A run of one-line continuous sections ran off the bottom of page 1 (100 of them reached 2,480 pt down a 792 pt page). The overflow checks for paragraphs now use fresh(): at the top with nothing above on this page. at_top() still drives the spacing rules, unchanged. Empty paragraphs that close a continuous section right after another section's mark took no room. Measured in Word on synthetic files ("Start", N such marks, "End"): 10 put "End" about 9 cm down page 1; 50, 100 and 200 all put it on the first line of page 2, the same with 24 pt marks or no paragraph spacing. So each takes its line while it fits, then stays at the page's bottom. A mark right after text stays quiet, as measured before. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- ROADMAP.md | 2 ++ crates/layout/src/ctx.rs | 3 +++ crates/layout/src/flow.rs | 23 ++++++++++++---- crates/layout/src/page.rs | 10 +++++++ crates/layout/src/pass.rs | 7 +++-- crates/layout/src/tests.rs | 54 ++++++++++++++++++++++++++++++++++++++ docs/layout-parity.md | 5 ++-- 7 files changed, 95 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b892e00e8f..477dfb3cdc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -172,6 +172,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-24: AutoCorrect: a word typed after a no-break space or an ideographic space is corrected as after a plain space (replacements, sentence capitals, ordinals and links). AutoCorrect left such a word as typed. - 2026-10-23: Table of contents: Update Table on a TOC set in columns (here Word's own two-column TOC, written by Word in Portuguese) keeps it in its columns (#3889). The page numbers stand at the column's right edge rather than the page's, so entries no longer run over the next column and push its page numbers off the page; the TOC styles Word stores under translated ids (`Sumrio1` for `toc 1`) are read as the TOC styles, so the entries keep their font, indents and spacing; and no blank line tops the first column. Update, save and reopen keep it so. The same keeps a Portuguese Word's TOC formatted as its TOC styles say through Update Table (#3891). - 2026-10-22: Markdown inline code replaces each line ending with a space before export, including code in lists and table cells. A following heading, list marker or HTML tag stays literal code (#3738). +- 2026-10-23: Layout: paragraphs that each start a continuous section now go onto new pages. A run of them ran off the bottom of the first page, because each new section counted as the top of a page. Empty section-break paragraphs after the first in a row take a line each until the page is full and then stay at its bottom, so the text after them starts the next page, as measured in Word (#3907). - 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen. - 2026-10-24: MCP `type_text` with `paragraphs: true` is one command (#3973): it was a `text.newParagraph` and a `text.insert` per line, each snapshotting the whole document for Undo, so 10,000 lines took 2.8 s (doubling the lines cost 2.4–3.2×) and left an Undo step per line; now they take ~10 ms (linear) and one Undo takes them all back. A CRLF is one break, not two, and the breaks no longer run AutoCorrect (the per-line way capitalised every line but the last). Typed line breaks are the Enter key, so in a one-line content control, where Enter does nothing, they no longer split it into paragraphs. - 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459). @@ -678,6 +679,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-20 | minor | Restrict Editing: a file command that changes the open document is checked like any other — the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version no longer lifts the protection or drops its password | | 2026-10-17 | trivial | Progress log: disabled-control reasons translated (#2678) | | 2026-10-22 | trivial | Markdown code spans normalize line endings before export, keeping block markers literal (#3738); format readiness unchanged | +| 2026-10-23 | trivial | Progress log: continuous sections paginate, #3907 | | 2026-10-22 | trivial | Progress log: a click in the footer while editing the header moves there | | 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. | | 2026-10-17 | trivial | Progress log: Home › Underline drop-down (#2738) | diff --git a/crates/layout/src/ctx.rs b/crates/layout/src/ctx.rs index b17d682779..6505b69854 100644 --- a/crates/layout/src/ctx.rs +++ b/crates/layout/src/ctx.rs @@ -43,6 +43,9 @@ pub(crate) struct Ctx<'a> { /// Empty body paragraphs with hidden marks or continuous section breaks, in order: /// they take no room (see [`place_para`]). pub(crate) quiet_marks: Vec, + /// Empty paragraphs closing a continuous section right after another section's mark, in + /// order: each takes its line while it fits, then stays at the page's bottom (#3907). + pub(crate) stacked_marks: Vec, /// Whether even pages are mirrored (mirrored margins, on pages): see [`mirror_even_pages`]. pub(crate) mirror: bool, /// Cache-key hashes of the table-style text formatting cells were laid out with diff --git a/crates/layout/src/flow.rs b/crates/layout/src/flow.rs index f52cd5ccd0..5eeab77fc7 100644 --- a/crates/layout/src/flow.rs +++ b/crates/layout/src/flow.rs @@ -36,6 +36,19 @@ pub(crate) fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, blo } return; } + if !pb.web && ctx.stacked_marks.binary_search(&block).is_ok() { + // Measured in Word (#3907): each takes its line while it fits; past the bottom it stays + // there instead of starting a page, so the text after a long run starts the next page. + let (y, prev, bottom, content_bottom, at) = (pb.y, pb.prev.clone(), pb.bottom, pb.content_bottom, (pb.pages.len(), pb.col)); + pb.bottom = f32::MAX / 4.0; + place_para_in_flow(ctx, pb, p, block, body_top); + pb.bottom = bottom; + if (pb.pages.len(), pb.col) == at && pb.y > bottom + 0.01 { + // At the bottom: nothing more fits on the page, even a line smaller than the mark's. + (pb.y, pb.prev, pb.content_bottom) = (bottom.max(y), prev, content_bottom); + } + return; + } place_para_in_flow(ctx, pb, p, block, body_top); } @@ -164,7 +177,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: let need = height + if pl.rp.keep_next { next_first_line(ctx, block, width, 0, &mut ctx.counters.clone()) } else { 0.0 }; // (Against the whole column: balanced columns grow to hold it.) // With the 0.01 pt of slack the other fit checks take. - if pb.y + need > pb.bottom + 0.01 && !pb.at_top() && need <= pb.uncapped_bottom() - pb.top { + if pb.y + need > pb.bottom + 0.01 && !pb.fresh() && need <= pb.uncapped_bottom() - pb.top { pb.advance(block, body_top); pb.y += pl.rp.space_before.min(0.0); applied_before = pl.rp.space_before.min(0.0); @@ -324,7 +337,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: // An empty paragraph closing a section before a new page: measured in Word, it stays // on a full page instead of starting one of its own. let closing_mark = pb.next_starts_page && p.section.is_some() && p.text.is_empty(); - if over && l1 == l0 && !pb.at_top() && !split && !only_break && !closing_mark { + if over && l1 == l0 && !pb.fresh() && !split && !only_break && !closing_mark { break; } new_notes.extend(line_notes); @@ -343,11 +356,11 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: } // Widow/orphan control (2-line minimum at either end). if forced_break.is_none() && l1 < n && pl.rp.widow_control && n >= 2 { - if l1 - l0 == 1 && l0 == 0 && !pb.at_top() { + if l1 - l0 == 1 && l0 == 0 && !pb.fresh() { l1 = l0; // orphan: move the first line to the next page } else if n - l1 == 1 && (l1 - l0 >= 3 || (l0 > 0 && l1 - l0 >= 2)) { l1 -= 1; // widow: take one more line along - } else if n - l1 == 1 && l0 == 0 && l1 - l0 == 2 && !pb.at_top() { + } else if n - l1 == 1 && l0 == 0 && l1 - l0 == 2 && !pb.fresh() { // Neither split of a three-line paragraph is allowed (two and a widow, or an // orphan and two): it moves whole, as in Word. l1 = l0; @@ -358,7 +371,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: l1 -= 1; } if l1 == l0 { - if pb.at_top() { + if pb.fresh() { l1 = l0 + 1; // can't fit even one line on an empty page: overflow } else { pb.advance(block, body_top); diff --git a/crates/layout/src/page.rs b/crates/layout/src/page.rs index 6ff5984a57..80d50487c2 100644 --- a/crates/layout/src/page.rs +++ b/crates/layout/src/page.rs @@ -14,6 +14,9 @@ pub(crate) struct PageBuilder<'a> { pub(crate) cols: Vec<(f32, f32)>, pub(crate) y: f32, pub(crate) top: f32, + /// The body's top on this page: `top` moves down below the text when a continuous section + /// starts mid-page. + pub(crate) page_top: f32, pub(crate) bottom: f32, pub(crate) number: u32, pub(crate) web: bool, @@ -317,6 +320,7 @@ impl PageBuilder<'_> { self.col = 0; self.cols = s.column_boxes(); self.top = body_top; + self.page_top = body_top; self.bottom = if self.web { f32::MAX / 8.0 } else { bottom }; self.orig_bottom = self.bottom; self.y = self.top; @@ -356,6 +360,12 @@ impl PageBuilder<'_> { pub(crate) fn at_top(&self) -> bool { self.y <= self.top + 0.01 } + /// At the top with nothing above on this page, so moving on gains no room. A continuous + /// section starting mid-page is at its top but not fresh: what doesn't fit there goes to the + /// next page (#3907). + pub(crate) fn fresh(&self) -> bool { + self.at_top() && self.top <= self.page_top + 0.01 + } /// Move to the next column or page. pub(crate) fn advance(&mut self, block: usize, body_top: f32) { if self.col + 1 < self.cols.len() { diff --git a/crates/layout/src/pass.rs b/crates/layout/src/pass.rs index 68fa1ab045..b4cb7b8804 100644 --- a/crates/layout/src/pass.rs +++ b/crates/layout/src/pass.rs @@ -64,6 +64,7 @@ pub(crate) fn layout_pass( track_totals: true, pct_base: None, quiet_marks: Vec::new(), + stacked_marks: Vec::new(), mirror: false, table_keys: Vec::new(), fitted: HashMap::new(), @@ -80,12 +81,13 @@ pub(crate) fn layout_pass( let read = opts.view == ViewMode::Read; let reflow = web || read; ctx.mirror = doc.settings.mirror_margins && !reflow; - ctx.quiet_marks = sections + // Only the first of a run of such marks is quiet; the rest are stacked (Word, #3907). + (ctx.stacked_marks, ctx.quiet_marks) = sections .windows(2) .filter(|w| w[1].1.start == SectionStart::Continuous) .map(|w| w[0].0) .filter(|end| doc.body.get(*end).and_then(|b| b.as_para()).is_some_and(|p| p.text.is_empty())) - .collect(); + .partition(|end| end.checked_sub(1).and_then(|i| doc.body.get(i)).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some())); if !opts.show_hidden { ctx.quiet_marks.extend( doc.body @@ -130,6 +132,7 @@ pub(crate) fn layout_pass( cols: Vec::new(), y: 0.0, top: 0.0, + page_top: 0.0, bottom: 0.0, number: 0, web, diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index e38133a412..4a5c188f5b 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -8667,6 +8667,40 @@ fn an_empty_continuous_section_mark_takes_no_room() { assert_eq!(new_page[1].0, 1, "a next-page section starts a page: {new_page:?}"); } +/// Empty marks closing continuous sections one after another each keep their line while it +/// fits; only one right after text is quiet. Past the page's bottom they stay there, so the text +/// after the run starts the next page. Measured in Word (#3907): "Start", N such marks, "End": +/// 10 put "End" about 9 cm down page 1; 50, 100 and 200 all put it on page 2's first line. +#[test] +fn a_run_of_empty_continuous_section_marks_keeps_a_line_each() { + use wordcraft_doc::section::{SectionProps, SectionStart}; + let tops_sized = |marks: usize, size: Option| { + let mut d = Document::from_text(&format!("Start\n{}End", "\n".repeat(marks))); + for i in 1..=marks { + let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap(); + p.section = Some(Box::new(SectionProps { start: SectionStart::Continuous, ..Default::default() })); + p.mark.size = size; + } + d.last_section.start = SectionStart::Continuous; + let l = lay(&d); + let (start, end) = (l.caret(&Pos::body(0, 0)).unwrap(), l.caret(&Pos::body(marks + 1, 0)).unwrap()); + (end.page, end.top - start.top) + }; + let tops = |marks: usize| tops_sized(marks, None); + let (_, one) = tops(1); + let (_, three) = tops(3); + let line = three - one; + assert!(line > 0.0, "marks after the first take room: one {one}, three {three}"); + let (_, five) = tops(5); + assert!((five - three - line).abs() < 0.15, "a line per extra mark: three {three}, five {five}"); + let (fifty, two_hundred) = (tops(50), tops(200)); + assert_eq!(fifty.0, 1, "50 marks fill page 1, End starts page 2: {fifty:?}, line {line}"); + assert!((fifty.1 - two_hundred.1).abs() < 0.01 && two_hundred.0 == 1, "more marks stay on page 1: {fifty:?} {two_hundred:?}"); + // Large marks too: the run ends at the bottom, so a smaller line after it still starts page 2. + let big = tops_sized(100, Some(24.0)); + assert!(big.0 == 1 && (big.1 - fifty.1).abs() < 0.01, "24 pt marks: {big:?}, small: {fifty:?}"); +} + /// A floating object positioned relative to its paragraph is measured from the paragraph's start /// above its space before, measured in Word for Windows (`plan/word` R22, 10 cases: 0, 10 and 20 /// pt before, offsets 0 and −7.2 pt; the previous paragraph's space after still counts). The SARP @@ -11646,3 +11680,23 @@ fn a_row_leaves_room_for_the_border_below_it() { } } } + +/// Sections starting continuously one after another still go onto new pages: a paragraph that +/// doesn't fit below the text above it moves on rather than running off the page (#3907). A +/// hundred one-line sections lay out like a hundred plain paragraphs. +#[test] +fn continuous_sections_running_off_a_page_go_onto_the_next() { + use wordcraft_doc::section::{SectionProps, SectionStart}; + let marks = 100; + let text = format!("Start\n{}End", "x\n".repeat(marks)); + let mut d = Document::from_text(&text); + for i in 1..=marks { + d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().section = + Some(Box::new(SectionProps { start: SectionStart::Continuous, ..Default::default() })); + } + d.last_section.start = SectionStart::Continuous; + let end = lay(&d).caret(&Pos::body(marks + 1, 0)).unwrap(); + let plain = lay(&Document::from_text(&text)).caret(&Pos::body(marks + 1, 0)).unwrap(); + assert!(end.page > 0, "End past the first page: {end:?}"); + assert_eq!((end.page, (end.top * 10.0).round()), (plain.page, (plain.top * 10.0).round()), "sections {end:?}, plain {plain:?}"); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 436c39fb8b..d92e392e79 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (continuous sections one after another go onto new pages instead of running off the page; empty marks after another section's mark take a line each until the page is full, then stay at its bottom, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -52,7 +52,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Caret, clicks and selection inside a ligature (fi, ffi, lam-alef) | ✅ | The glyph's advance is shared evenly among the letters' grapheme clusters, mirrored in right-to-left text; a grapheme cluster (a letter with a combining mark, an emoji ZWJ sequence, an Indic conjunct) is never split. `layout::tests::ligature`, `engine::tests::typing::right_arrow_moves_through_a_ligature_one_letter_at_a_time` (#1974); observed in Word for Mac 16.113.4 | — | | All Caps and Small Caps: the full capital of each letter, so ß draws as SS (ff as FF); the capitals drawn for one character are one cluster, so the caret, selection, hit testing and line breaking treat ß as one character | ✅ | `layout::tests::caps_draw_sharp_s_as_two_capitals_in_one_cluster` (#1581). The PDF text layer still reads the document's lowercase letters until #1473 | — | | Columns, column breaks, separators, balancing | ✅ | Columns balance on a section's last page when the next section starts continuous (also at the document end, which Word only balances when the document ends with a continuous break); not after next-page/even/odd breaks or on a page with a column break; keep-together, keep-with-next, widow/orphan control and unsplittable rows can leave them slightly uneven (#350) | — | -| Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42). A break inserted with text selected goes before the selection and keeps it, as in Word (#852). An otherwise empty section at page start retains its line (`empty_page_section_keeps_line`); a closing mark after text stays quiet. | 1–2 | +| Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42). A break inserted with text selected goes before the selection and keeps it, as in Word (#852). An otherwise empty section at page start retains its line (`empty_page_section_keeps_line`); a closing mark after text stays quiet, but one after another section's mark takes its line while it fits and then stays at the page's bottom, so the text after a long run starts the next page; a run of continuous sections with text goes onto new pages like plain text (#3907). | 1–2 | | Labels on page-break-only list paragraphs | ✅ | `layout::tests::break_only_list_has_no_label`: no bullet or number on the break, but the next number is still 3 after item 1 and a break. Plain empty items keep their labels. Synthetic DOCX observed through Adobe PDF conversion | — | | List labels after a leading page break | ✅ | `layout::tests::bidi::list_label_after_leading_page_break_uses_its_text_lines_alignment`: the number/bullet and text retain the no-break horizontal positions for start, centre, end and justified alignment, LTR/RTL, including text with a tab. The issue's synthetic DOCX exports with the number before the heading, matching the reporter's Word/LibreOffice reference (#2964); reference applications were not rerun locally | — | | List labels: number formats (decimal, letters, Russian letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | `doc::cjk_numbers` tests, `doc::section::tests::russian_letters_follow_word`, `layout::tests::chinese_counting_list_labels_and_their_font_runs`, `layout::tests::russian_letter_list_labels_draw_cyrillic`; a label mixing faces (digit + CJK comma) is drawn run by run (#534); Russian letters skip ё, й, ъ and ь and then double (аа, бб), as observed in Word. Hebrew, Hindi, Thai and Vietnamese formats show decimal; `arabicAlpha` and `arabicAbjad` draw Arabic letters, then double (أأ, بب) (`doc::section::tests::arabic_letters_count_in_both_orders`). The label stays attached to the start of the text when the paragraph is centred or aligned to the end, as in Word (#1159). | — | @@ -284,6 +284,7 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. | 2026-10-23 | trivial | A justified line ending in a manual line break is stretched to the right margin like a wrapped line, as Word does by default; with `w:doNotExpandShiftReturn` it keeps its natural spacing (#3924). `layout::tests::justified_line_ending_in_line_break_is_stretched` | | 2026-10-16 | trivial | Exact PostScript names resolve installed static faces without changing family lookup | | 2026-10-21 | trivial | Pin the font input of four geometric layout controls; assertions and renderer font policy unchanged. | +| 2026-10-23 | trivial | Continuous sections running off a page go onto the next; empty marks after a section mark take a line until the page is full, measured in Word (#3907) | | 2026-10-20 | trivial | A click below a floating table, outside its cells, goes to the paragraph beside or after it instead of a cell of its last row, as checked in Word (#2038) | | 2026-10-19 | trivial | A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, starts the next line with its text instead of ending the line (a TOC entry's page number keeps its leader); a left tab still past the right indent there takes no room (#1840) | | 2026-10-19 | trivial | Justified lines (compatibility mode 15) shrink their spaces to fit one more word by at most a quarter of the spaces' width (was a fifth) and by at most 0.355 of that word's own width with the space before it. Measured in Word for Mac with synthetic justified paragraphs: words of 2.7 to 80 pt, spaces of 3.3 and 6.7 pt, only justified text, last lines included, never in modes 12 and 14. A real paper's justified abstract now breaks like Word on all 23 lines (3 before); on 180 real-world papers, page counts matching Word went from 139 to 142, none further | From f2fe7654e9d80fe0f81edb89b99077e9b4331daa Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 10:12:25 +0100 Subject: [PATCH 05/94] =?UTF-8?q?#3907:=20review=20=E2=80=94=20a=20kept=20?= =?UTF-8?q?paragraph=20low=20in=20a=20continuous=20section=20moves=20to=20?= =?UTF-8?q?the=20next=20page;=20a=20page=20break=20before=20a=20stacked=20?= =?UTF-8?q?mark=20keeps=20the=20new=20page's=20bottom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit (local): keep-lines compared its height with the room below the mid-page section start, so a paragraph that fits on a new page split instead. It now compares with the next column, or the next page's body when this is the last column. A stacked mark whose page break before starts a page no longer gets the previous page's bottom back. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/layout/src/flow.rs | 9 +++++++-- crates/layout/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/layout/src/flow.rs b/crates/layout/src/flow.rs index 5eeab77fc7..92362e5454 100644 --- a/crates/layout/src/flow.rs +++ b/crates/layout/src/flow.rs @@ -42,7 +42,10 @@ pub(crate) fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, blo let (y, prev, bottom, content_bottom, at) = (pb.y, pb.prev.clone(), pb.bottom, pb.content_bottom, (pb.pages.len(), pb.col)); pb.bottom = f32::MAX / 4.0; place_para_in_flow(ctx, pb, p, block, body_top); - pb.bottom = bottom; + // A break before it starts a page with its own bottom. + if pb.pages.len() == at.0 { + pb.bottom = bottom; + } if (pb.pages.len(), pb.col) == at && pb.y > bottom + 0.01 { // At the bottom: nothing more fits on the page, even a line smaller than the mark's. (pb.y, pb.prev, pb.content_bottom) = (bottom.max(y), prev, content_bottom); @@ -176,8 +179,10 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: if pl.rp.keep_lines || (pl.rp.keep_next && pb.y + height <= pb.bottom + 0.01) { let need = height + if pl.rp.keep_next { next_first_line(ctx, block, width, 0, &mut ctx.counters.clone()) } else { 0.0 }; // (Against the whole column: balanced columns grow to hold it.) + // Room in the next column, or on the next page when this is the last. + let next_top = if pb.col + 1 < pb.cols.len() { pb.top } else { pb.page_top }; // With the 0.01 pt of slack the other fit checks take. - if pb.y + need > pb.bottom + 0.01 && !pb.fresh() && need <= pb.uncapped_bottom() - pb.top { + if pb.y + need > pb.bottom + 0.01 && !pb.fresh() && need <= pb.uncapped_bottom() - next_top { pb.advance(block, body_top); pb.y += pl.rp.space_before.min(0.0); applied_before = pl.rp.space_before.min(0.0); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 4a5c188f5b..7f3b30917d 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -11681,6 +11681,34 @@ fn a_row_leaves_room_for_the_border_below_it() { } } +/// A kept-together paragraph starting a continuous section low on a page, with room there for +/// one of its lines, moves whole to the next page, where it fits (#3907 review). +#[test] +fn kept_lines_at_a_low_continuous_section_move_to_the_next_page() { + use wordcraft_doc::props::LineSpacing; + use wordcraft_doc::section::{SectionProps, SectionStart}; + let fill = 53; // 53 lines of 12 pt leave one line of a 648 pt body + let text = format!("{}{}", "x\n".repeat(fill), "word ".repeat(40)); + let mut d = Document::from_text(&text); + for i in 0..=fill { + let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap(); + p.props.line_spacing = Some(LineSpacing::Exactly(12.0)); + p.props.space_before = Some(0.0); + p.props.space_after = Some(0.0); + } + d.para_mut(StoryRef::Body, &Path::top(fill - 1)).unwrap().section = + Some(Box::new(SectionProps { start: SectionStart::Continuous, ..Default::default() })); + d.last_section.start = SectionStart::Continuous; + let kept = d.para_mut(StoryRef::Body, &Path::top(fill)).unwrap(); + kept.props.keep_lines = Some(true); + kept.props.widow_control = Some(false); + let l = lay(&d); + let last_x = l.caret(&Pos::body(fill - 1, 0)).unwrap(); + let first = l.caret(&Pos::body(fill, 0)).unwrap(); + assert_eq!(last_x.page, 0, "the filler fits on page 1: {last_x:?}"); + assert_eq!(first.page, 1, "kept paragraph moves whole: {first:?}"); +} + /// Sections starting continuously one after another still go onto new pages: a paragraph that /// doesn't fit below the text above it moves on rather than running off the page (#3907). A /// hundred one-line sections lay out like a hundred plain paragraphs. From 5225569d44945d8352f66a3cf78349b50367ff81 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Tue, 27 Oct 2026 10:04:05 +0000 Subject: [PATCH 06/94] =?UTF-8?q?#3907:=20review=20=E2=80=94=20an=20empty?= =?UTF-8?q?=20mark=20taking=20no=20room,=20or=20stacked=20past=20the=20pag?= =?UTF-8?q?e=20bottom,=20draws=20no=20list=20label;=20an=20empty=20mark=20?= =?UTF-8?q?after=20a=20text=20paragraph=20closing=20a=20section=20stays=20?= =?UTF-8?q?quiet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dpkruse's Genuine Steps Certificate: a bulleted text paragraph closes a two-column continuous section, then an empty bulleted one closes another. Word draws no bullet for the empty one; it is now quiet (takes no room) and draws no label, still counted. A stacked mark that doesn't fit keeps its line for the caret but draws nothing below the margin (CodeRabbit). Co-Authored-By: Claude Opus 5.5 --- crates/layout/src/flow.rs | 29 +++++++++++++ crates/layout/src/pass.rs | 5 ++- crates/layout/src/tests.rs | 85 ++++++++++++++++++++++++++++++++++++++ docs/layout-parity.md | 3 +- 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/crates/layout/src/flow.rs b/crates/layout/src/flow.rs index 92362e5454..e9ab7622d7 100644 --- a/crates/layout/src/flow.rs +++ b/crates/layout/src/flow.rs @@ -28,11 +28,16 @@ pub(crate) fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, blo } // Its (empty) line stays where the text ended, even at the bottom of a full column. let (y, prev, bottom, content_bottom, at) = (pb.y, pb.prev.clone(), pb.bottom, pb.content_bottom, (pb.pages.len(), pb.col)); + let drawn = pb.page().map_or(0, |page| page.items.len()); pb.bottom = f32::MAX / 4.0; place_para_in_flow(ctx, pb, p, block, body_top); pb.bottom = bottom; if (pb.pages.len(), pb.col) == at { (pb.y, pb.prev, pb.content_bottom) = (y, prev, content_bottom); + // Taking no room, it draws no list label either (Word, #3907 review); still counted. + if let Some(page) = pb.page() { + strip_mark(page, drawn, true); + } } return; } @@ -40,6 +45,7 @@ pub(crate) fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, blo // Measured in Word (#3907): each takes its line while it fits; past the bottom it stays // there instead of starting a page, so the text after a long run starts the next page. let (y, prev, bottom, content_bottom, at) = (pb.y, pb.prev.clone(), pb.bottom, pb.content_bottom, (pb.pages.len(), pb.col)); + let drawn = pb.page().map_or(0, |page| page.items.len()); pb.bottom = f32::MAX / 4.0; place_para_in_flow(ctx, pb, p, block, body_top); // A break before it starts a page with its own bottom. @@ -49,12 +55,35 @@ pub(crate) fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, blo if (pb.pages.len(), pb.col) == at && pb.y > bottom + 0.01 { // At the bottom: nothing more fits on the page, even a line smaller than the mark's. (pb.y, pb.prev, pb.content_bottom) = (bottom.max(y), prev, content_bottom); + // Nothing of it is drawn below the margin, not even its list label or shading (Word): + // its line stays only for the caret. + // ponytail: a mark anchoring objects keeps everything; strip those too if one shows up + if p.objects.is_empty() + && let Some(page) = pb.page() + { + strip_mark(page, drawn, false); + } } return; } place_para_in_flow(ctx, pb, p, block, body_top); } +/// Take the list label off the lines a mark added to `page` after its first `drawn` items; its +/// other items (shading, borders) stay only when `keep_others`. The lines stay for the caret. +fn strip_mark(page: &mut Page, drawn: usize, keep_others: bool) { + let tail = page.items.split_off(drawn.min(page.items.len())); + page.items.extend(tail.into_iter().filter_map(|item| match item { + Placed::Lines { story, path, para, l0, l1, x, y, turn } if para.label.is_some() => { + let mut bare = (*para).clone(); + bare.label = None; + Some(Placed::Lines { story, path, para: Arc::new(bare), l0, l1, x, y, turn }) + } + line @ Placed::Lines { .. } => Some(line), + other => keep_others.then_some(other), + })); +} + /// How many times one body paragraph is laid out again for the page numbers of the later pages /// it runs onto (#1352). const MAX_PAGE_RELAYS: usize = 64; diff --git a/crates/layout/src/pass.rs b/crates/layout/src/pass.rs index b4cb7b8804..4be8b8058b 100644 --- a/crates/layout/src/pass.rs +++ b/crates/layout/src/pass.rs @@ -87,7 +87,10 @@ pub(crate) fn layout_pass( .filter(|w| w[1].1.start == SectionStart::Continuous) .map(|w| w[0].0) .filter(|end| doc.body.get(*end).and_then(|b| b.as_para()).is_some_and(|p| p.text.is_empty())) - .partition(|end| end.checked_sub(1).and_then(|i| doc.body.get(i)).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some())); + .partition(|end| { + // After a text paragraph closing a section the mark is still quiet, as after any text. + end.checked_sub(1).and_then(|i| doc.body.get(i)).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some() && p.text.is_empty()) + }); if !opts.show_hidden { ctx.quiet_marks.extend( doc.body diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 7f3b30917d..a99d254b44 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -11728,3 +11728,88 @@ fn continuous_sections_running_off_a_page_go_onto_the_next() { assert!(end.page > 0, "End past the first page: {end:?}"); assert_eq!((end.page, (end.top * 10.0).round()), (plain.page, (plain.top * 10.0).round()), "sections {end:?}, plain {plain:?}"); } + +/// Bulleted empty marks past a full page's bottom draw nothing there, not even their bullets; +/// those that fit keep theirs (#3907 review: a stray bullet below the column). +#[test] +fn stacked_marks_past_the_bottom_draw_no_label() { + use wordcraft_doc::ListKind; + use wordcraft_doc::section::{SectionProps, SectionStart}; + let marks = 100; + let mut d = Document::from_text(&format!("Start\n{}End", "\n".repeat(marks))); + let id = d.numbering.add_list(ListKind::Bullet); + for i in 1..=marks { + let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap(); + p.section = Some(Box::new(SectionProps { start: SectionStart::Continuous, ..Default::default() })); + p.props.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 }); + } + d.last_section.start = SectionStart::Continuous; + let l = lay(&d); + let page = &l.pages[0]; + let bottom = page.body.y + page.body.h; + let (mut inside, mut below) = (0, 0); + for it in &page.items { + let Placed::Lines { para, y, .. } = it else { continue }; + if para.label.is_none() { + continue; + } + if *y < bottom - 0.01 { inside += 1 } else { below += 1 } + } + assert!(inside > 10, "marks that fit keep their bullets: {inside}"); + assert_eq!(below, 0, "no bullet at or below the body's bottom ({bottom})"); + assert_eq!(l.caret(&Pos::body(marks + 1, 0)).unwrap().page, 1, "End still starts page 2"); +} + +/// An empty mark after a text paragraph that itself closes a continuous section takes no room, +/// as after any text: only marks after an empty mark stack (#3907 review). +#[test] +fn an_empty_mark_after_a_text_section_mark_stays_quiet() { + use wordcraft_doc::section::{SectionProps, SectionStart}; + let gap = |text_closes: bool| { + let mut d = Document::from_text("Start\n\nEnd"); + let cont = || Some(Box::new(SectionProps { start: SectionStart::Continuous, ..Default::default() })); + if text_closes { + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = cont(); + } + d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().section = cont(); + d.last_section.start = SectionStart::Continuous; + let l = lay(&d); + l.caret(&Pos::body(2, 0)).unwrap().top - l.caret(&Pos::body(0, 0)).unwrap().top + }; + assert!((gap(true) - gap(false)).abs() < 0.01, "text closing a section: {}, plain text: {}", gap(true), gap(false)); +} + +/// dpkruse's Genuine Steps Certificate (#3907 review): a bulleted text paragraph closes a +/// two-column continuous section, then an empty bulleted paragraph closes another. Word draws no +/// bullet for the empty one wherever the text above ends, down to the foot of the page. +#[test] +fn an_empty_bulleted_mark_after_a_bulleted_section_closer_draws_no_bullet() { + use wordcraft_doc::ListKind; + use wordcraft_doc::section::{SectionProps, SectionStart}; + for filler in (40..140).step_by(3) { + let mut d = Document::from_text(&format!("{}file this form\n\nFailure to comply", "Instructions\n".repeat(filler))); + let id = d.numbering.add_list(ListKind::Bullet); + let mut two = SectionProps { start: SectionStart::Continuous, ..Default::default() }; + two.columns.count = 2; + for i in [filler, filler + 1] { + let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap(); + p.section = Some(Box::new(two.clone())); + p.props.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 }); + } + d.last_section.start = SectionStart::Continuous; + let l = lay(&d); + let bullets = l + .pages + .iter() + .flat_map(|page| &page.items) + .filter(|it| matches!(it, Placed::Lines { path, para, .. } if *path == Path::top(filler + 1) && para.label.is_some())) + .count(); + assert_eq!(bullets, 0, "{filler} lines above: the empty mark draws no bullet"); + let text = l + .pages + .iter() + .flat_map(|page| &page.items) + .any(|it| matches!(it, Placed::Lines { path, para, .. } if *path == Path::top(filler) && para.label.is_some())); + assert!(text, "{filler} lines above: the text closer keeps its bullet"); + } +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index d92e392e79..6000b9ce82 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (continuous sections one after another go onto new pages instead of running off the page; empty marks after another section's mark take a line each until the page is full, then stay at its bottom, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** trivial (continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -285,6 +285,7 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. | 2026-10-16 | trivial | Exact PostScript names resolve installed static faces without changing family lookup | | 2026-10-21 | trivial | Pin the font input of four geometric layout controls; assertions and renderer font policy unchanged. | | 2026-10-23 | trivial | Continuous sections running off a page go onto the next; empty marks after a section mark take a line until the page is full, measured in Word (#3907) | +| 2026-10-27 | trivial | Empty marks past the page bottom draw nothing, not even a list label; an empty mark after a text paragraph closing a section stays quiet and, like any mark taking no room, draws no bullet (#3907 review) | | 2026-10-20 | trivial | A click below a floating table, outside its cells, goes to the paragraph beside or after it instead of a cell of its last row, as checked in Word (#2038) | | 2026-10-19 | trivial | A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, starts the next line with its text instead of ending the line (a TOC entry's page number keeps its leader); a left tab still past the right indent there takes no room (#1840) | | 2026-10-19 | trivial | Justified lines (compatibility mode 15) shrink their spaces to fit one more word by at most a quarter of the spaces' width (was a fifth) and by at most 0.355 of that word's own width with the space before it. Measured in Word for Mac with synthetic justified paragraphs: words of 2.7 to 80 pt, spaces of 3.3 and 6.7 pt, only justified text, last lines included, never in modes 12 and 14. A real paper's justified abstract now breaks like Word on all 23 lines (3 before); on 180 real-world papers, page counts matching Word went from 139 to 142, none further | From 751eb4b20411af1557a06cc5ea1489aa91500c88 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Tue, 27 Oct 2026 10:39:26 +0000 Subject: [PATCH 07/94] =?UTF-8?q?#3907:=20CodeRabbit=20=E2=80=94=20a=20sta?= =?UTF-8?q?cked=20mark=20past=20the=20page=20bottom=20draws=20no=20line=20?= =?UTF-8?q?number=20either?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/layout/src/flow.rs | 4 +++- crates/layout/src/tests.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/layout/src/flow.rs b/crates/layout/src/flow.rs index e9ab7622d7..9e28289b68 100644 --- a/crates/layout/src/flow.rs +++ b/crates/layout/src/flow.rs @@ -70,7 +70,8 @@ pub(crate) fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, blo } /// Take the list label off the lines a mark added to `page` after its first `drawn` items; its -/// other items (shading, borders) stay only when `keep_others`. The lines stay for the caret. +/// other items (shading, borders, its line number) stay only when `keep_others`. Its own lines +/// stay for the caret. fn strip_mark(page: &mut Page, drawn: usize, keep_others: bool) { let tail = page.items.split_off(drawn.min(page.items.len())); page.items.extend(tail.into_iter().filter_map(|item| match item { @@ -79,6 +80,7 @@ fn strip_mark(page: &mut Page, drawn: usize, keep_others: bool) { bare.label = None; Some(Placed::Lines { story, path, para: Arc::new(bare), l0, l1, x, y, turn }) } + Placed::Lines { story, .. } if story == DECOR_STORY && !keep_others => None, line @ Placed::Lines { .. } => Some(line), other => keep_others.then_some(other), })); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index a99d254b44..23cd710dd7 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -11756,6 +11756,18 @@ fn stacked_marks_past_the_bottom_draw_no_label() { if *y < bottom - 0.01 { inside += 1 } else { below += 1 } } assert!(inside > 10, "marks that fit keep their bullets: {inside}"); + // Nor their line numbers (CodeRabbit). + for i in 1..=marks { + if let Some(s) = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().section.as_mut() { + s.line_numbers = Some(Default::default()); + } + } + d.last_section.line_numbers = Some(Default::default()); + let numbered = lay(&d); + let page = &numbered.pages[0]; + let low = page.items.iter().filter(|it| matches!(it, Placed::Lines { story, y, .. } if *story == DECOR_STORY && *y >= bottom - 0.01)).count(); + assert_eq!(low, 0, "no line number at or below the body's bottom ({bottom})"); + assert!(page.items.iter().any(|it| matches!(it, Placed::Lines { story, .. } if *story == DECOR_STORY)), "the marks that fit are numbered"); assert_eq!(below, 0, "no bullet at or below the body's bottom ({bottom})"); assert_eq!(l.caret(&Pos::body(marks + 1, 0)).unwrap().page, 1, "End still starts page 2"); } From 1b485e1f9075c59ee7a784a33047db1642a6e6a7 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Tue, 27 Oct 2026 11:24:50 +0000 Subject: [PATCH 08/94] =?UTF-8?q?#3907:=20CodeRabbit=20=E2=80=94=20a=20kep?= =?UTF-8?q?t=20paragraph=20is=20measured=20against=20the=20next=20page's?= =?UTF-8?q?=20body=20(a=20different=20first-page=20header)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/layout/src/flow.rs | 3 +-- crates/layout/src/page.rs | 29 ++++++++++++++++++---- crates/layout/src/tests.rs | 49 ++++++++++++++++++++++++++++++++++++++ docs/layout-parity.md | 5 ++-- 4 files changed, 77 insertions(+), 9 deletions(-) diff --git a/crates/layout/src/flow.rs b/crates/layout/src/flow.rs index 9e28289b68..60f1f096c2 100644 --- a/crates/layout/src/flow.rs +++ b/crates/layout/src/flow.rs @@ -211,9 +211,8 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: let need = height + if pl.rp.keep_next { next_first_line(ctx, block, width, 0, &mut ctx.counters.clone()) } else { 0.0 }; // (Against the whole column: balanced columns grow to hold it.) // Room in the next column, or on the next page when this is the last. - let next_top = if pb.col + 1 < pb.cols.len() { pb.top } else { pb.page_top }; // With the 0.01 pt of slack the other fit checks take. - if pb.y + need > pb.bottom + 0.01 && !pb.fresh() && need <= pb.uncapped_bottom() - next_top { + if pb.y + need > pb.bottom + 0.01 && !pb.fresh() && need <= pb.next_room(body_top) { pb.advance(block, body_top); pb.y += pl.rp.space_before.min(0.0); applied_before = pl.rp.space_before.min(0.0); diff --git a/crates/layout/src/page.rs b/crates/layout/src/page.rs index 80d50487c2..048feed8bf 100644 --- a/crates/layout/src/page.rs +++ b/crates/layout/src/page.rs @@ -106,9 +106,13 @@ pub(crate) fn page_border_rules(s: &SectionProps) -> Vec { impl PageBuilder<'_> { /// Space footnotes take at the bottom of the page. pub(crate) fn notes_h(&self) -> f32 { - match self.notes.first() { + self.stack_h(&self.notes) + } + /// The height of a page's note area holding `notes`, with its separator. + fn stack_h(&self, notes: &[NoteBox]) -> f32 { + match notes.first() { None => 0.0, - Some(first) => self.seps.sep_h(first.cont) + self.notes.iter().map(|n| n.h).sum::(), + Some(first) => self.seps.sep_h(first.cont) + notes.iter().map(|n| n.h).sum::(), } } /// The lowest bottom of the body text and tables on the current page. @@ -292,9 +296,7 @@ impl PageBuilder<'_> { let s = self.sect; self.number = self.restart_at.take().unwrap_or_else(|| self.number.saturating_add(1)); let (w, h) = if self.web { (s.page_w, f32::MAX / 4.0) } else { (s.page_w, s.page_h) }; - // A footer taller than the bottom margin pushes the body up (Word: no gap), leaving it at - // least a little room. - let bottom = (s.page_h - s.margin_bottom).min(self.foot_top.max(body_top + 36.0)); + let bottom = self.body_bottom(body_top); let body = Rect::new(s.margin_left + s.gutter, body_top, s.text_width(), (bottom - body_top).max(36.0)); let mut decor = Vec::new(); if !self.web { @@ -343,6 +345,23 @@ impl PageBuilder<'_> { self.bottom = self.bottom.min(self.top + h); } } + /// The bottom of the body on a page started with its body at `body_top`: a footer taller than + /// the bottom margin pushes it up (Word: no gap), leaving it at least a little room. + fn body_bottom(&self, body_top: f32) -> f32 { + (self.sect.page_h - self.sect.margin_bottom).min(self.foot_top.max(body_top + 36.0)) + } + /// The room for text in the next column, or on the page [`Self::advance`] starts after the + /// last: there, below its body top (`body_top`) and above its footer and the notes carried + /// onto it (#3907 CodeRabbit: a different first page's header or footer doesn't shrink it). + pub(crate) fn next_room(&self, body_top: f32) -> f32 { + if self.col + 1 < self.cols.len() { + self.uncapped_bottom() - self.top + } else if self.web { + f32::MAX / 8.0 - body_top + } else { + self.body_bottom(body_top) - self.stack_h(&self.carry) - body_top + } + } /// The bottom of the columns without balancing: above the footnotes. pub(crate) fn uncapped_bottom(&self) -> f32 { self.orig_bottom - self.notes_h() diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 23cd710dd7..6c1c422ba9 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -11709,6 +11709,55 @@ fn kept_lines_at_a_low_continuous_section_move_to_the_next_page() { assert_eq!(first.page, 1, "kept paragraph moves whole: {first:?}"); } +/// A kept-together paragraph in a continuous section is measured against the page it would move +/// to, not the one it starts on: below a tall different first-page header it doesn't fit page 1's +/// body but does page 2's, so it moves there whole (#3907 CodeRabbit). +#[test] +fn kept_lines_below_a_tall_first_page_header_move_whole_to_page_two() { + use wordcraft_doc::props::LineSpacing; + use wordcraft_doc::section::SectionStart; + let exact = |p: &mut wordcraft_doc::Paragraph, h: f32| { + p.props.line_spacing = Some(LineSpacing::Exactly(h)); + p.props.space_before = Some(0.0); + p.props.space_after = Some(0.0); + p.props.widow_control = Some(false); + }; + let kept_text = "word ".repeat(300); + // How tall the kept paragraph is (its line count depends on the fonts; 12 pt lines). + let mut probe = Document::from_text(&kept_text); + exact(probe.para_mut(StoryRef::Body, &Path::top(0)).unwrap(), 12.0); + let pl = lay(&probe); + let end = pl.caret(&Pos::body(0, kept_text.len())).unwrap(); + assert_eq!(end.page, 0, "{end:?}"); + let h = end.top + 12.0 - pl.caret(&Pos::body(0, 0)).unwrap().top; + let mut d = Document::from_text(&format!("x\n{kept_text}")); + let s = d.last_section.clone(); + let (bottom, full) = (s.page_h - s.margin_bottom, s.page_h - s.margin_bottom - s.margin_top); + assert!(h > 120.0 && h + 12.0 < full, "kept paragraph {h} fits a plain page of {full}"); + // The first section's first-page header leaves page 1, below the 12 pt filler, room for all + // but 36 pt of the kept paragraph. + let header_h = bottom - 12.0 - (h - 36.0) - s.header; + let mut head = wordcraft_doc::Paragraph::with_text("Title", Default::default()); + exact(&mut head, header_h); + let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(head)]); + let mut first_sect = s.clone(); + first_sect.title_page = true; + first_sect.headers.first = Some(id); + let filler = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + exact(filler, 12.0); + filler.section = Some(Box::new(first_sect)); + d.last_section.start = SectionStart::Continuous; + let kept = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap(); + exact(kept, 12.0); + kept.props.keep_lines = Some(true); + let l = lay(&d); + let filler = l.caret(&Pos::body(0, 0)).unwrap(); + assert_eq!(filler.page, 0, "{filler:?}"); + assert!(filler.top > s.margin_top + 100.0, "the tall header pushes page 1's body down: {filler:?}"); + let (first, last) = (l.caret(&Pos::body(1, 0)).unwrap(), l.caret(&Pos::body(1, kept_text.len())).unwrap()); + assert_eq!((first.page, last.page), (1, 1), "kept paragraph whole on page 2: {first:?} .. {last:?}"); +} + /// Sections starting continuously one after another still go onto new pages: a paragraph that /// doesn't fit below the text above it moves on rather than running off the page (#3907). A /// hundred one-line sections lay out like a hundred plain paragraphs. diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 6000b9ce82..db93a1c0d6 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** trivial (continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** trivial (continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -52,7 +52,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Caret, clicks and selection inside a ligature (fi, ffi, lam-alef) | ✅ | The glyph's advance is shared evenly among the letters' grapheme clusters, mirrored in right-to-left text; a grapheme cluster (a letter with a combining mark, an emoji ZWJ sequence, an Indic conjunct) is never split. `layout::tests::ligature`, `engine::tests::typing::right_arrow_moves_through_a_ligature_one_letter_at_a_time` (#1974); observed in Word for Mac 16.113.4 | — | | All Caps and Small Caps: the full capital of each letter, so ß draws as SS (ff as FF); the capitals drawn for one character are one cluster, so the caret, selection, hit testing and line breaking treat ß as one character | ✅ | `layout::tests::caps_draw_sharp_s_as_two_capitals_in_one_cluster` (#1581). The PDF text layer still reads the document's lowercase letters until #1473 | — | | Columns, column breaks, separators, balancing | ✅ | Columns balance on a section's last page when the next section starts continuous (also at the document end, which Word only balances when the document ends with a continuous break); not after next-page/even/odd breaks or on a page with a column break; keep-together, keep-with-next, widow/orphan control and unsplittable rows can leave them slightly uneven (#350) | — | -| Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42). A break inserted with text selected goes before the selection and keeps it, as in Word (#852). An otherwise empty section at page start retains its line (`empty_page_section_keeps_line`); a closing mark after text stays quiet, but one after another section's mark takes its line while it fits and then stays at the page's bottom, so the text after a long run starts the next page; a run of continuous sections with text goes onto new pages like plain text (#3907). | 1–2 | +| Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42). A break inserted with text selected goes before the selection and keeps it, as in Word (#852). An otherwise empty section at page start retains its line (`empty_page_section_keeps_line`); a closing mark after text stays quiet, but one after another section's mark takes its line while it fits and then stays at the page's bottom, so the text after a long run starts the next page; a run of continuous sections with text goes onto new pages like plain text, and a kept-together paragraph low in a continuous section is measured against the next page's body (its header, footer and carried notes), not the current one's (#3907). | 1–2 | | Labels on page-break-only list paragraphs | ✅ | `layout::tests::break_only_list_has_no_label`: no bullet or number on the break, but the next number is still 3 after item 1 and a break. Plain empty items keep their labels. Synthetic DOCX observed through Adobe PDF conversion | — | | List labels after a leading page break | ✅ | `layout::tests::bidi::list_label_after_leading_page_break_uses_its_text_lines_alignment`: the number/bullet and text retain the no-break horizontal positions for start, centre, end and justified alignment, LTR/RTL, including text with a tab. The issue's synthetic DOCX exports with the number before the heading, matching the reporter's Word/LibreOffice reference (#2964); reference applications were not rerun locally | — | | List labels: number formats (decimal, letters, Russian letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | `doc::cjk_numbers` tests, `doc::section::tests::russian_letters_follow_word`, `layout::tests::chinese_counting_list_labels_and_their_font_runs`, `layout::tests::russian_letter_list_labels_draw_cyrillic`; a label mixing faces (digit + CJK comma) is drawn run by run (#534); Russian letters skip ё, й, ъ and ь and then double (аа, бб), as observed in Word. Hebrew, Hindi, Thai and Vietnamese formats show decimal; `arabicAlpha` and `arabicAbjad` draw Arabic letters, then double (أأ, بب) (`doc::section::tests::arabic_letters_count_in_both_orders`). The label stays attached to the start of the text when the paragraph is centred or aligned to the end, as in Word (#1159). | — | @@ -286,6 +286,7 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. | 2026-10-21 | trivial | Pin the font input of four geometric layout controls; assertions and renderer font policy unchanged. | | 2026-10-23 | trivial | Continuous sections running off a page go onto the next; empty marks after a section mark take a line until the page is full, measured in Word (#3907) | | 2026-10-27 | trivial | Empty marks past the page bottom draw nothing, not even a list label; an empty mark after a text paragraph closing a section stays quiet and, like any mark taking no room, draws no bullet (#3907 review) | +| 2026-10-27 | trivial | A kept-together paragraph in a continuous section is measured against the next page's body, so a tall different first-page header no longer splits one that fits on page 2 (#3907 CodeRabbit) | | 2026-10-20 | trivial | A click below a floating table, outside its cells, goes to the paragraph beside or after it instead of a cell of its last row, as checked in Word (#2038) | | 2026-10-19 | trivial | A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, starts the next line with its text instead of ending the line (a TOC entry's page number keeps its leader); a left tab still past the right indent there takes no room (#1840) | | 2026-10-19 | trivial | Justified lines (compatibility mode 15) shrink their spaces to fit one more word by at most a quarter of the spaces' width (was a fifth) and by at most 0.355 of that word's own width with the space before it. Measured in Word for Mac with synthetic justified paragraphs: words of 2.7 to 80 pt, spaces of 3.3 and 6.7 pt, only justified text, last lines included, never in modes 12 and 14. A real paper's justified abstract now breaks like Word on all 23 lines (3 before); on 180 real-world papers, page counts matching Word went from 139 to 142, none further | From 89a059fb2dfa5bdb41a9ae3aaa14489e5a601b4c Mon Sep 17 00:00:00 2001 From: Jeremy Date: Mon, 26 Oct 2026 22:18:44 +0000 Subject: [PATCH 09/94] feat(installer): default to a per-user ArtCraft folder --- .github/workflows/packaging-lint.yml | 19 ++ packaging/windows/INSTALLATION.md | 35 +++ packaging/windows/check-install-policy.ps1 | 195 ++++++++++++++ packaging/windows/install-policy.json | 291 +++++++++++++++++++++ packaging/windows/install-policy.wxi | 54 ++++ packaging/windows/package.ps1 | 4 +- packaging/windows/test-install-policy.ps1 | 54 ++++ packaging/windows/wordcraft.wxs | 60 +---- 8 files changed, 664 insertions(+), 48 deletions(-) create mode 100644 packaging/windows/INSTALLATION.md create mode 100644 packaging/windows/check-install-policy.ps1 create mode 100644 packaging/windows/install-policy.json create mode 100644 packaging/windows/install-policy.wxi create mode 100644 packaging/windows/test-install-policy.ps1 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index f54b0538b9..7e847137f1 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -21,3 +21,22 @@ jobs: lint: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + & (Join-Path $tools 'wix.exe') extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX extension installation failed.' } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/packaging/windows/INSTALLATION.md b/packaging/windows/INSTALLATION.md new file mode 100644 index 0000000000..982a5b4202 --- /dev/null +++ b/packaging/windows/INSTALLATION.md @@ -0,0 +1,35 @@ +# Windows installation policy checks + +New installations default to the current account under `Programs\ArtCraft\`. +The first setup page offers installation for everyone. Scope is selected before duplicate +checks and directory costing. An update uses the existing context's remembered exact folder; +`INSTALLFOLDER` supplied explicitly is never replaced. It is a secure MSI property so the +chosen folder reaches the elevated installer. Silent machine updates pass `ALLUSERS=1`. +A cross-context copy is refused; changing context means uninstalling the old copy first. + +App registration follows the selected context. Existing optional shortcuts and file-handler +choices stay app-owned. No new cleanup operation or model/settings migration is introduced. +Packaged resources remain package-owned; separately downloaded shared models are not caches +and are outside this installer's ownership. + +## Repeatable metadata tests + +On Windows with WiX 5.0.2 and its matching UI/Util extensions: + +```powershell +./packaging/windows/test-install-policy.ps1 -OutputDirectory ./target/install-policy-tests +``` + +The script builds x64, x86 and arm64 packages with deliberately non-executable payloads. It +uses the actual package source and compiled database. A safe Windows Installer session checks +24 scope/registration/maintenance cases, remembered and explicit custom folders, sequencing, +upgrade identity and the original registration inventory recorded in `install-policy.json`. +The release packaging script runs the compiled checker before signing. Packaging CI runs the +small fixture builds without compiling the Rust application. + +Never install or distribute the `NOT-INSTALLABLE.msi` fixtures as an app. Passing these checks +does not prove real installation, elevation, legacy-version upgrades, registry writes, optional +shortcut/menu behaviour, cancellation, rollback or uninstall. Those still require the real +payload in an authorised clean Windows test environment. Cross-architecture metadata is not +execution evidence on those architectures. Existing open installer/association PRs must be +reconciled before publication. Nothing here changes macOS/Linux native packaging conventions. diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 new file mode 100644 index 0000000000..dd28956435 --- /dev/null +++ b/packaging/windows/check-install-policy.ps1 @@ -0,0 +1,195 @@ +<# +.SYNOPSIS + Check the compiled installation policy without installing any product. +.DESCRIPTION + Uses Windows Installer's safe session (OpenPackage option 1). Only property-setting custom + actions are exercised. No AppSearch, install, repair, removal or file-writing action runs. +#> +param([Parameter(Mandatory = $true)] [string] $MsiPath) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +function Read-Rows($Database, [string] $Sql, [int] $Columns) { + $view = $Database.OpenView($Sql) + try { + [void]$view.Execute() + while ($null -ne ($record = $view.Fetch())) { + try { + $values = @(for ($i = 1; $i -le $Columns; $i++) { + $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @($i)) + }) + ,$values + } finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) } + } + } finally { + [void]$view.Close() + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) + } +} +function Set-SessionProperty($Session, [string] $Name, [string] $Value) { + [void]$Session.GetType().InvokeMember('Property', 'SetProperty', $null, $Session, @($Name, $Value)) +} +function Get-SessionProperty($Session, [string] $Name) { + $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) +} +$installer = $null; $database = $null; $session = $null +try { + $installer = New-Object -ComObject WindowsInstaller.Installer + $file = (Resolve-Path -LiteralPath $MsiPath).Path + $database = $installer.OpenDatabase($file, 0) + $session = $installer.OpenPackage($file, 1) + $properties = @{} + foreach ($row in (Read-Rows $database 'SELECT `Property`, `Value` FROM `Property`' 2)) { $properties[$row[0]] = $row[1] } + $secureProperties = @($properties['SecureCustomProperties'] -split ';') + if ('INSTALLFOLDER' -notin $secureProperties) { + throw 'The selected installation folder must survive elevation (secure INSTALLFOLDER).' + } + if ($policy.mode -eq 'perUser') { + if ($properties.ContainsKey('ALLUSERS')) { throw 'Launcher must remain per-user; machine self-update is not part of this change.' } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'Launcher upgrade identity changed.' } + $dirs = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { $dirs[$row[0]] = @($row[1],$row[2]) } + if ($dirs['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or $dirs['CraftSuiteFolder'][1] -ne 'ArtCraft' -or + $dirs['CraftSuiteFolder'][0] -ne 'LocalProgramsFolder' -or $dirs['LocalProgramsFolder'][0] -ne 'LocalAppDataFolder') { + throw 'Launcher new-install folder must be under the per-user ArtCraft suite root.' + } + foreach ($explicit in @('', 'F:\Chosen exact launcher')) { + Set-SessionProperty $session 'PREVIOUSINSTALLFOLDER' 'E:\Existing exact launcher' + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + $row = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftRestoreLauncherFolder''' 1)[0] + if ($session.EvaluateCondition($row[0]) -eq 1) { + if ($session.DoAction('CraftRestoreLauncherFolder') -ne 1) { throw 'Launcher restore action failed.' } + } + $expected = if ($explicit) { $explicit } else { 'E:\Existing exact launcher' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Launcher custom or existing path was lost.' } + } + $rows = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($rows | Where-Object { $_[0] -ne '1' }).Count) { throw 'Launcher registration must stay under the current account.' } + foreach ($entry in $policy.registry) { + if (@($rows | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }).Count -ne 1) { + throw 'Launcher registration or shortcut preference changed unexpectedly.' + } + } + Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.' + return + } + if ($properties['ALLUSERS'] -ne '2' -or $properties['MSIINSTALLPERUSER'] -ne '1') { + throw 'Policy regression: new packages must default to the per-user dual-scope context.' + } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'The existing upgrade identity must not change.' } + $directories = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { + $directories[$row[0]] = @($row[1], $row[2]) + } + if ($directories['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or + $directories['CraftSuiteFolder'][1] -ne 'ArtCraft') { throw 'Expected the app under the ArtCraft suite folder.' } + $programs = $directories['CraftSuiteFolder'][0] + if ($programs -eq 'ProgramFiles6432Folder') { $programs = $directories[$programs][0] } + if ($programs -notin 'ProgramFilesFolder', 'ProgramFiles64Folder') { + throw 'Use Windows Installer native folder redirection, not a hard-coded profile path.' + } + $guards = @(Read-Rows $database 'SELECT `Condition`, `Description` FROM `LaunchCondition`' 2 | + Where-Object { $_[0] -match 'CRAFT_(USER|MACHINE)_DIR' }) + if ($guards.Count -ne 2) { throw 'Expected both duplicate-installation guards.' } + $cases = 0 + foreach ($scope in @('', '2', '1')) { + foreach ($user in @('', 'D:\Existing user app')) { + foreach ($machine in @('', 'E:\Existing machine app')) { + foreach ($installed in @('', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'Installed' $installed + Set-SessionProperty $session 'CRAFT_USER_DIR' $user + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $machine + $allowed = $true + foreach ($guard in $guards) { + $value = $session.EvaluateCondition($guard[0]) + if ($value -notin 0, 1) { throw 'Invalid duplicate guard.' } + if ($value -eq 0) { $allowed = $false } + } + $opposite = if ($scope -eq '1') { $user } else { $machine } + $expected = [bool]$installed -or -not [bool]$opposite + if ($allowed -ne $expected) { throw "Scope regression: ALLUSERS='$scope', user=$([bool]$user), machine=$([bool]$machine), maintenance=$([bool]$installed)." } + $cases++ + } + } + } + } + # Execute the actual authored type-51 actions in a safe session, not copied resolver logic. + foreach ($scope in @('', '2', '1')) { + foreach ($explicit in @('', 'F:\Chosen exact app')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } + if (-not $explicit) { + if ($session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } + } + $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } + } + } + $ui = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } + if (-not ($ui['AppSearch'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['LaunchConditions'] -and + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { + throw 'Scope must be chosen after discovery and before duplicate checks, folder restoration and costing.' + } + $execute = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } + foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { + if (-not ($execute['AppSearch'] -lt $execute[$action] -and $execute[$action] -lt $execute['CostInitialize'])) { + throw 'Silent updates must restore the folder before directory costing.' + } + } + # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. + $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) + foreach ($choice in @('user','machine','user')) { + Set-SessionProperty $session 'CRAFT_SCOPE' $choice + foreach ($event in $events) { + if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } + $property = $Matches[1] + if ($session.EvaluateCondition($event[2]) -eq 1) { + Set-SessionProperty $session $property $(if ($event[1] -eq '{}') { '' } else { $event[1] }) + } + } + $allUsers = Get-SessionProperty $session 'ALLUSERS' + $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' + if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or + ($choice -eq 'user' -and ($allUsers -ne '2' -or $perUser -ne '1'))) { + throw 'Scope selector does not reset the requested installation context.' + } + } + $actions = @(Read-Rows $database 'SELECT `Action`, `Source`, `Target`, `Type` FROM `CustomAction`' 4) + $savedOptions = @($actions | Where-Object { $_[0] -match '^CraftLoadOption[0-9]+M$' }) + foreach ($option in $savedOptions) { + if ([int]$option[3] -ne 51) { throw 'Saved-option test may run only property-setting actions.' } + $number = [regex]::Match($option[0], '[0-9]+').Value + foreach ($scope in @('2','1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session "CRAFT_OPTION_${number}_M" 'saved-machine-choice' + Set-SessionProperty $session "CRAFT_OPTION_${number}_U" 'saved-user-choice' + Set-SessionProperty $session $option[1] '' + $action = "CraftLoadOption$number" + $(if ($scope -eq '1') { 'M' } else { 'U' }) + if ($session.DoAction($action) -ne 1) { throw 'Saved-option property action failed.' } + $expected = if ($scope -eq '1') { 'saved-machine-choice' } else { 'saved-user-choice' } + if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } + } + } + $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } + if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { + throw 'HKCR keys must be relative to Classes.' + } + foreach ($entry in $policy.registry) { + $found = @($registry | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }) + if ($found.Count -ne 1) { throw "Lost or changed registration: $($entry[1]) / $($entry[2])" } + } + $installDir = @($registry | Where-Object { $_[0] -eq '-1' -and $_[1] -eq "Software\$($policy.name)" -and $_[2] -eq 'InstallDir' -and $_[3] -eq '[INSTALLFOLDER]' }) + if ($installDir.Count -ne 1) { throw 'A context-sensitive installation-folder record is required.' } + Write-Output "PASS $($policy.name): $cases scope cases, folder preservation, action sequencing and registry contract. No installation performed." +} finally { + foreach ($com in @($session, $database, $installer)) { + if ($null -ne $com) { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) } + } +} diff --git a/packaging/windows/install-policy.json b/packaging/windows/install-policy.json new file mode 100644 index 0000000000..168670be9c --- /dev/null +++ b/packaging/windows/install-policy.json @@ -0,0 +1,291 @@ +{ + "id": "wordcraft", + "name": "WordCraft", + "upgradeCode": "075497F9-E861-45D0-9C7F-A74F8C34E6BA", + "sources": [ + "wordcraft.wxs" + ], + "icon": "assets/app-icon/wordcraft.ico", + "localization": [], + "extensions": [ + "UI", + "Util" + ], + "payloads": [ + "wordcraft-cli.exe", + "wordcraft.exe" + ], + "registry": [ + [ + "-1", + "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\wordcraft.exe", + "", + "[#WordcraftExe]" + ], + [ + "-1", + "Software\\RegisteredApplications", + "WordCraft", + "Software\\WordCraft\\Capabilities" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities", + "ApplicationName", + "WordCraft" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities", + "ApplicationDescription", + "Write and design documents; open and save Word files." + ], + [ + "-1", + "Software\\WordCraft\\Capabilities", + "ApplicationIcon", + "\"[#WordcraftExe]\",0" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe", + "FriendlyAppName", + "WordCraft" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\WordCraft.Document\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.docx\\OpenWithProgids", + "WordCraft.Document", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".docx", + "" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities\\FileAssociations", + ".docx", + "WordCraft.Document" + ], + [ + "-1", + "Software\\Classes\\WordCraft.DocumentMacroEnabled\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.docm\\OpenWithProgids", + "WordCraft.DocumentMacroEnabled", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".docm", + "" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities\\FileAssociations", + ".docm", + "WordCraft.DocumentMacroEnabled" + ], + [ + "-1", + "Software\\Classes\\WordCraft.Template\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.dotx\\OpenWithProgids", + "WordCraft.Template", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".dotx", + "" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities\\FileAssociations", + ".dotx", + "WordCraft.Template" + ], + [ + "-1", + "Software\\Classes\\WordCraft.TemplateMacroEnabled\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.dotm\\OpenWithProgids", + "WordCraft.TemplateMacroEnabled", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".dotm", + "" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities\\FileAssociations", + ".dotm", + "WordCraft.TemplateMacroEnabled" + ], + [ + "-1", + "Software\\Classes\\WordCraft.LegacyDocument\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.doc\\OpenWithProgids", + "WordCraft.LegacyDocument", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".doc", + "" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities\\FileAssociations", + ".doc", + "WordCraft.LegacyDocument" + ], + [ + "-1", + "Software\\Classes\\WordCraft.LegacyTemplate\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.dot\\OpenWithProgids", + "WordCraft.LegacyTemplate", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".dot", + "" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities\\FileAssociations", + ".dot", + "WordCraft.LegacyTemplate" + ], + [ + "-1", + "Software\\Classes\\WordCraft.RichText\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.rtf\\OpenWithProgids", + "WordCraft.RichText", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".rtf", + "" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities\\FileAssociations", + ".rtf", + "WordCraft.RichText" + ], + [ + "-1", + "Software\\Classes\\WordCraft.OpenDocumentText\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.odt\\OpenWithProgids", + "WordCraft.OpenDocumentText", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".odt", + "" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities\\FileAssociations", + ".odt", + "WordCraft.OpenDocumentText" + ], + [ + "-1", + "Software\\Classes\\WordCraft.Markdown\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.md\\OpenWithProgids", + "WordCraft.Markdown", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".md", + "" + ], + [ + "-1", + "Software\\Classes\\WordCraft.PlainText\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.txt\\OpenWithProgids", + "WordCraft.PlainText", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".txt", + "" + ] + ], + "mode": "dualScope" +} diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi new file mode 100644 index 0000000000..0a23e558bf --- /dev/null +++ b/packaging/windows/install-policy.wxi @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index e89a25e432..3289a7ba6a 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -156,9 +156,11 @@ foreach ($build in @(@($Msi, 'perMachine'), @($UserMsi, 'perUserOrMachine'))) { -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext ` -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\wordcraft.ico')" ` -d "LicenseRtf=$LicenseRtf" ` - -d "AppNoticesDir=$AppNotices" -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" -d "Scope=$($build[1])" ` + -d "AppNoticesDir=$AppNotices" -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" -d "Scope=perUserOrMachine" ` -o $out } + # Check the compiled scope, path and registration contract before signing. + & (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($out, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $out diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 new file mode 100644 index 0000000000..21199ca7f4 --- /dev/null +++ b/packaging/windows/test-install-policy.ps1 @@ -0,0 +1,54 @@ +<# +.SYNOPSIS + Build non-executable installer metadata fixtures; never install them. +#> +param( + [Parameter(Mandatory = $true)] [string] $OutputDirectory, + [ValidateSet('x64','x86','arm64')] [string[]] $Architectures = @('x64','x86','arm64') +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +[void](New-Item -ItemType Directory -Force -Path $OutputDirectory) +$output = (Resolve-Path -LiteralPath $OutputDirectory).Path +Add-Type -AssemblyName System.Drawing +foreach ($arch in $Architectures) { + $stage = Join-Path $output $arch + [void](New-Item -ItemType Directory -Force -Path $stage) + foreach ($name in $policy.payloads) { + $destination = Join-Path $stage $name + [void](New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($destination))) + Set-Content -LiteralPath $destination -Encoding Ascii -Value 'NON-EXECUTABLE MSI METADATA TEST FIXTURE' + } + foreach ($folder in @('docs','models','notices')) { + $path = Join-Path $stage $folder + [void](New-Item -ItemType Directory -Force -Path $path) + Set-Content -LiteralPath (Join-Path $path "$folder-fixture.txt") -Value 'Metadata test fixture, not a user file or model.' + } + foreach ($model in @('metadata-one.rten','metadata-two.rten')) { + Set-Content -LiteralPath (Join-Path (Join-Path $stage 'models') $model) -Encoding Ascii -Value 'NON-EXECUTABLE MODEL METADATA FIXTURE' + } + foreach ($spec in @(@('banner.bmp',493,58),@('dialog.bmp',493,312))) { + $bitmap = [Drawing.Bitmap]::new($spec[1],$spec[2],[Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { $bitmap.Save((Join-Path $stage $spec[0]),[Drawing.Imaging.ImageFormat]::Bmp) } + finally { $bitmap.Dispose() } + } + Set-Content -LiteralPath (Join-Path $stage 'licence.rtf') -Encoding Ascii -Value '{\rtf1 Metadata test fixture.}' + $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) + $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" + $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) + & wix build @sources -arch $arch -wx @extensions ` + -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` + -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` + -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` + -d "AppNoticesDir=$(Join-Path $stage 'notices')" -d 'HasCraftFontLicences=1' -d 'HasCli=1' ` + -d "BannerBmp=$(Join-Path $stage 'banner.bmp')" -d "DialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "UiBannerBmp=$(Join-Path $stage 'banner.bmp')" -d "UiDialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "LicenseRtf=$(Join-Path $stage 'licence.rtf')" -o $msi @($policy.localization | ForEach-Object { '-loc'; Join-Path $PSScriptRoot $_ }) + if ($LASTEXITCODE -ne 0) { throw "WiX validation failed for $arch (exit $LASTEXITCODE)." } + $shell = (Get-Process -Id $PID).Path + & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $msi + if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $arch (exit $LASTEXITCODE)." } + Write-Output "PASS $arch metadata-only package. This is not an executable installer." +} diff --git a/packaging/windows/wordcraft.wxs b/packaging/windows/wordcraft.wxs index 5be96705b1..bd28f31e19 100644 --- a/packaging/windows/wordcraft.wxs +++ b/packaging/windows/wordcraft.wxs @@ -1,47 +1,14 @@ - - - - - - + + + - + @@ -59,16 +26,16 @@ - + - + - + + + @@ -76,7 +43,7 @@ - + @@ -89,16 +56,15 @@ - + - + + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 3289a7ba6a..9a0941f344 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -160,7 +160,9 @@ foreach ($build in @(@($Msi, 'perMachine'), @($UserMsi, 'perUserOrMachine'))) { -o $out } # Check the compiled scope, path and registration contract before signing. - & (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi + $shell = (Get-Process -Id $PID).Path + & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $out + if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $out." } # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($out, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $out diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 index 21199ca7f4..a574122c08 100644 --- a/packaging/windows/test-install-policy.ps1 +++ b/packaging/windows/test-install-policy.ps1 @@ -12,6 +12,7 @@ $root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json [void](New-Item -ItemType Directory -Force -Path $OutputDirectory) $output = (Resolve-Path -LiteralPath $OutputDirectory).Path +& (Join-Path $PSScriptRoot 'test-packaging-loop.ps1') Add-Type -AssemblyName System.Drawing foreach ($arch in $Architectures) { $stage = Join-Path $output $arch diff --git a/packaging/windows/test-packaging-loop.ps1 b/packaging/windows/test-packaging-loop.ps1 new file mode 100644 index 0000000000..94b73c6c5a --- /dev/null +++ b/packaging/windows/test-packaging-loop.ps1 @@ -0,0 +1,21 @@ +<# Check the real packaging loop without compiling, signing or installing. #> +$ErrorActionPreference = 'Stop' +$tokens = $null; $errors = $null +$ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'package.ps1'), [ref]$tokens, [ref]$errors) +if ($errors.Count) { throw 'The packaging script does not parse.' } +$loop = @($ast.FindAll({ param($node) + $node -is [Management.Automation.Language.ForEachStatementAst] -and $node.Variable.VariablePath.UserPath -eq 'build' +}, $true)) +if ($loop.Count -ne 1) { throw 'Expected one artifact packaging loop.' } +$checks = @($loop[0].FindAll({ param($node) + $node -is [Management.Automation.Language.CommandAst] -and $node.InvocationOperator.ToString() -eq 'Ampersand' -and + @($node.CommandElements | Where-Object { $_.Extent.Text -match 'check-install-policy.ps1' }).Count -gt 0 +}, $true)) +if ($checks.Count -ne 1) { throw 'Every packaging iteration needs its own policy check.' } +$elements = $checks[0].CommandElements +$index = 0 +while ($index -lt $elements.Count -and $elements[$index].Extent.Text -ne '-MsiPath') { $index++ } +if ($index + 1 -ge $elements.Count -or $elements[$index + 1].Extent.Text -ne '$out') { + throw 'The policy check must inspect the current artifact ($out), not the first MSI.' +} +Write-Output 'PASS each WordCraft artifact is checked before signing.' From 1b0a59d4d225c89fb286728e5685bd4e5e56a741 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 11:46:37 +0000 Subject: [PATCH 11/94] fix(installer): harden install-policy checks and packaging-lint workflow --- .github/workflows/packaging-lint.yml | 9 +++++++-- packaging/windows/check-install-policy.ps1 | 13 ++++++++++--- packaging/windows/test-install-policy.ps1 | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 7e847137f1..ea61d84303 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -28,6 +28,8 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install the pinned packaging toolchain shell: pwsh run: | @@ -35,8 +37,11 @@ jobs: dotnet tool install --tool-path $tools wix --version 5.0.2 if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } $tools >> $env:GITHUB_PATH - & (Join-Path $tools 'wix.exe') extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 - if ($LASTEXITCODE -ne 0) { throw 'WiX extension installation failed.' } + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } - name: Build and check non-executable installer fixtures shell: pwsh run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index 00b683d708..451295a2e4 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -122,9 +122,11 @@ try { Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } - if (-not $explicit) { - if ($session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } - } + # Evaluate the compiled condition, so the test exercises it instead of calling the action directly. + $restore = @(Read-Rows $database ('SELECT `Condition` FROM `InstallExecuteSequence` WHERE `Action` = ''{0}''' -f $action) 1)[0] + $selected = $session.EvaluateCondition($restore[0]) -eq 1 + if ($selected -eq [bool]$explicit) { throw "$action must run only when no folder was supplied (condition: $($restore[0]))." } + if ($selected -and $session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } } @@ -145,6 +147,11 @@ try { throw 'Silent updates must restore the folder before directory costing.' } } + foreach ($resolver in @('CraftUFromLegacyExe','CraftMFromLegacyExe')) { + if (-not ($execute['AppSearch'] -lt $execute[$resolver] -and $execute[$resolver] -lt $execute['LaunchConditions'])) { + throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.' + } + } # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) # Model the full-UI upgrade entry with an existing machine-wide copy. Run diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 index a574122c08..23e51c8078 100644 --- a/packaging/windows/test-install-policy.ps1 +++ b/packaging/windows/test-install-policy.ps1 @@ -39,7 +39,7 @@ foreach ($arch in $Architectures) { $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) - & wix build @sources -arch $arch -wx @extensions ` + & wix build @sources -arch $arch @extensions ` -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` From 3031870ce3b80c9e6f402c66055032cad896812a Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 12:09:04 +0000 Subject: [PATCH 12/94] docs(installer): describe the per-user default install location and scope --- README.md | 9 +++++---- packaging/windows/package.ps1 | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b0445a01eb..130fdd60dd 100644 --- a/README.md +++ b/README.md @@ -227,10 +227,11 @@ closer pagination fidelity, a stability sweep and native printing. Details and e | arm64 (Snapdragon and other ARM PCs) | `wordcraft--windows-arm64.msi` | `wordcraft--windows-user-arm64.msi` | `wordcraft--windows-arm64-portable.zip` | | x86 (32-bit) | `wordcraft--windows-x86.msi` | `wordcraft--windows-user-x86.msi` | `wordcraft--windows-x86-portable.zip` | -The all-users installer puts WordCraft in Program Files and needs administrator rights. The -just-me installer needs none: it installs into `%LOCALAPPDATA%\Programs\WordCraft` for your -account only. Choose one: installing both gives two WordCraft entries in Settings › Apps and two -in the Start menu. Installers and executables are code-signed. +Both installers are the same dual-scope package. By default it installs for your account only, +into `%LOCALAPPDATA%\Programs\ArtCraft\WordCraft`, and needs no administrator rights; choosing +"Everyone on this computer" on the first page installs into `C:\Program Files\ArtCraft\WordCraft` +and asks for an administrator. Install only one copy: a copy for another scope is refused, so +uninstall first to change who WordCraft is installed for. Installers and executables are code-signed. **If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default. Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 9a0941f344..3030cfb6e0 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -143,10 +143,10 @@ $rtf = '{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\fswiss\fcharset0 Tahoma;}}\vi ($paragraphs -join "\par`r`n") + "\par`r`n}`r`n" [System.IO.File]::WriteAllText($LicenseRtf, $rtf, [System.Text.Encoding]::ASCII) -# Two MSIs from one source: per-machine (Program Files, needs admin) and per-user (#39: no admin, -# into %LOCALAPPDATA%\Programs; see wordcraft.wxs). The per-user name puts "user" before the arch -# so the per-machine MSI sorts first: the shared windows-arm64 workflow installs the first *.msi -# it finds and checks Program Files. +# Two MSI file names from one dual-scope source (see wordcraft.wxs and install-policy.wxi): per user by +# default into %LOCALAPPDATA%\Programs\ArtCraft\WordCraft (#39), per machine into Program Files\ArtCraft +# on request. The user name puts "user" before the arch so the plain name sorts first: the shared +# windows-arm64 workflow installs the first *.msi it finds and checks the per-user folder. $Msi = Join-Path $Dist "wordcraft-$Version-windows-$Arch.msi" $UserMsi = Join-Path $Dist "wordcraft-$Version-windows-user-$Arch.msi" foreach ($build in @(@($Msi, 'perMachine'), @($UserMsi, 'perUserOrMachine'))) { From 72b8818ea681a163cf9764ba3b30f7a528e80e95 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 12:17:18 +0000 Subject: [PATCH 13/94] packaging(windows): ship one dual-scope MSI The package is dual-scope, so the separate -windows-user MSI was the same package under a second name. Build and policy-check the single plain-named MSI before signing, which is the name every sibling app, the launcher and the shared windows-arm64 workflow use. Replace the loop test with one that checks the MSI is policy-checked once, before it is signed. --- README.md | 12 +++--- docs/releasing.md | 2 +- packaging/windows/package.ps1 | 46 ++++++++++----------- packaging/windows/test-install-policy.ps1 | 2 +- packaging/windows/test-packaging-loop.ps1 | 21 ---------- packaging/windows/test-packaging-script.ps1 | 29 +++++++++++++ 6 files changed, 58 insertions(+), 54 deletions(-) delete mode 100644 packaging/windows/test-packaging-loop.ps1 create mode 100644 packaging/windows/test-packaging-script.ps1 diff --git a/README.md b/README.md index 130fdd60dd..08c7382b23 100644 --- a/README.md +++ b/README.md @@ -221,13 +221,13 @@ closer pagination fidelity, a stability sweep and native printing. Details and e ### Windows -| Build | Installer (all users) | Installer (just me) | Portable | -|---|---|---|---| -| x64 (64-bit Intel/AMD) | `wordcraft--windows-x64.msi` | `wordcraft--windows-user-x64.msi` | `wordcraft--windows-x64-portable.zip` | -| arm64 (Snapdragon and other ARM PCs) | `wordcraft--windows-arm64.msi` | `wordcraft--windows-user-arm64.msi` | `wordcraft--windows-arm64-portable.zip` | -| x86 (32-bit) | `wordcraft--windows-x86.msi` | `wordcraft--windows-user-x86.msi` | `wordcraft--windows-x86-portable.zip` | +| Build | Installer | Portable | +|---|---|---| +| x64 (64-bit Intel/AMD) | `wordcraft--windows-x64.msi` | `wordcraft--windows-x64-portable.zip` | +| arm64 (Snapdragon and other ARM PCs) | `wordcraft--windows-arm64.msi` | `wordcraft--windows-arm64-portable.zip` | +| x86 (32-bit) | `wordcraft--windows-x86.msi` | `wordcraft--windows-x86-portable.zip` | -Both installers are the same dual-scope package. By default it installs for your account only, +The installer is one dual-scope package. By default it installs for your account only, into `%LOCALAPPDATA%\Programs\ArtCraft\WordCraft`, and needs no administrator rights; choosing "Everyone on this computer" on the first page installs into `C:\Program Files\ArtCraft\WordCraft` and asks for an administrator. Install only one copy: a copy for another scope is refused, so diff --git a/docs/releasing.md b/docs/releasing.md index fc2503550a..17d41e28cf 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -11,7 +11,7 @@ this file has the WordCraft specifics. | Platform | Artifacts | Signing | |---|---|---| | macOS | universal `WordCraft.app` in a `.dmg`, universal CLI zip | Developer ID + notarization (`APPLE_*` secrets) | -| Windows | x64, x86 and arm64 `.msi` (per-machine, and a per-user `-windows-user-.msi` that needs no admin rights) + portable `.zip` | Azure Trusted Signing (`AZURE_*` secrets) | +| Windows | x64, x86 and arm64 `.msi` (one dual-scope package: per-user by default, needs no admin rights; "Everyone on this computer" installs per machine) + portable `.zip` | Azure Trusted Signing (`AZURE_*` secrets) | | Linux | x86_64 and aarch64 `.AppImage` (+ `.zsync` for AppImageUpdate), `.flatpak` bundle, `.deb`, `.rpm`, `.tar.gz`; Flathub manifest | checksums | | Linux riscv64 | `.tar.gz` (cross-compiled on `ubuntu-24.04`, glibc >= 2.39; CLI smoke-tested under QEMU) | checksums | | FreeBSD | x86_64 `.tar.gz` | checksums | diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 3030cfb6e0..3126a18797 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -4,8 +4,9 @@ .DESCRIPTION Produces, in $env:DIST (default: dist/release): - wordcraft--windows-.msi per-machine installer (WiX v5) - wordcraft--windows-user-.msi per-user installer, no admin rights (#39) + wordcraft--windows-.msi installer (WiX v5), one dual-scope package: for the + current user by default, no admin rights (#39); + "Everyone on this computer" installs per machine wordcraft--windows--portable.zip wordcraft.exe + wordcraft-cli.exe The binaries link the C runtime statically (+crt-static), so neither the MSI nor the portable @@ -143,30 +144,25 @@ $rtf = '{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\fswiss\fcharset0 Tahoma;}}\vi ($paragraphs -join "\par`r`n") + "\par`r`n}`r`n" [System.IO.File]::WriteAllText($LicenseRtf, $rtf, [System.Text.Encoding]::ASCII) -# Two MSI file names from one dual-scope source (see wordcraft.wxs and install-policy.wxi): per user by -# default into %LOCALAPPDATA%\Programs\ArtCraft\WordCraft (#39), per machine into Program Files\ArtCraft -# on request. The user name puts "user" before the arch so the plain name sorts first: the shared -# windows-arm64 workflow installs the first *.msi it finds and checks the per-user folder. +# One MSI from one dual-scope source (see wordcraft.wxs and install-policy.wxi): per user by default into +# %LOCALAPPDATA%\Programs\ArtCraft\WordCraft (#39), per machine into Program Files\ArtCraft on request. +# The plain name is the one every sibling app, the launcher and the shared windows-arm64 workflow expect. $Msi = Join-Path $Dist "wordcraft-$Version-windows-$Arch.msi" -$UserMsi = Join-Path $Dist "wordcraft-$Version-windows-user-$Arch.msi" -foreach ($build in @(@($Msi, 'perMachine'), @($UserMsi, 'perUserOrMachine'))) { - $out = $build[0] - Invoke-Native "wix build ($($build[1]))" { - wix build (Join-Path $PSScriptRoot 'wordcraft.wxs') -arch $Arch ` - -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext ` - -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\wordcraft.ico')" ` - -d "LicenseRtf=$LicenseRtf" ` - -d "AppNoticesDir=$AppNotices" -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" -d "Scope=perUserOrMachine" ` - -o $out - } - # Check the compiled scope, path and registration contract before signing. - $shell = (Get-Process -Id $PID).Path - & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $out - if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $out." } - # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. - Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($out, '.wixpdb')) - & (Join-Path $PSScriptRoot 'sign.ps1') $out +Invoke-Native "wix build" { + wix build (Join-Path $PSScriptRoot 'wordcraft.wxs') -arch $Arch ` + -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext ` + -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\wordcraft.ico')" ` + -d "LicenseRtf=$LicenseRtf" ` + -d "AppNoticesDir=$AppNotices" -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" -d "Scope=perUserOrMachine" ` + -o $Msi } +# Check the compiled scope, path and registration contract before signing. +$shell = (Get-Process -Id $PID).Path +& $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi +if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $Msi." } +# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. +Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) +& (Join-Path $PSScriptRoot 'sign.ps1') $Msi # ---- portable zip ------------------------------------------------------------------------------ $Portable = Join-Path $TargetDir "windows-package\wordcraft-$Version-windows-$Arch-portable" @@ -195,4 +191,4 @@ if ($Arch -ne 'arm64' -or $HostArch -eq 'arm64') { } else { Write-Output "skipping wordcraft-cli --version: an $Arch build doesn't run on this $HostArch machine" } -Get-Item $Msi, $UserMsi, $Zip | Format-Table Name, Length +Get-Item $Msi, $Zip | Format-Table Name, Length diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 index 23e51c8078..8049bc72d9 100644 --- a/packaging/windows/test-install-policy.ps1 +++ b/packaging/windows/test-install-policy.ps1 @@ -12,7 +12,7 @@ $root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json [void](New-Item -ItemType Directory -Force -Path $OutputDirectory) $output = (Resolve-Path -LiteralPath $OutputDirectory).Path -& (Join-Path $PSScriptRoot 'test-packaging-loop.ps1') +& (Join-Path $PSScriptRoot 'test-packaging-script.ps1') Add-Type -AssemblyName System.Drawing foreach ($arch in $Architectures) { $stage = Join-Path $output $arch diff --git a/packaging/windows/test-packaging-loop.ps1 b/packaging/windows/test-packaging-loop.ps1 deleted file mode 100644 index 94b73c6c5a..0000000000 --- a/packaging/windows/test-packaging-loop.ps1 +++ /dev/null @@ -1,21 +0,0 @@ -<# Check the real packaging loop without compiling, signing or installing. #> -$ErrorActionPreference = 'Stop' -$tokens = $null; $errors = $null -$ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'package.ps1'), [ref]$tokens, [ref]$errors) -if ($errors.Count) { throw 'The packaging script does not parse.' } -$loop = @($ast.FindAll({ param($node) - $node -is [Management.Automation.Language.ForEachStatementAst] -and $node.Variable.VariablePath.UserPath -eq 'build' -}, $true)) -if ($loop.Count -ne 1) { throw 'Expected one artifact packaging loop.' } -$checks = @($loop[0].FindAll({ param($node) - $node -is [Management.Automation.Language.CommandAst] -and $node.InvocationOperator.ToString() -eq 'Ampersand' -and - @($node.CommandElements | Where-Object { $_.Extent.Text -match 'check-install-policy.ps1' }).Count -gt 0 -}, $true)) -if ($checks.Count -ne 1) { throw 'Every packaging iteration needs its own policy check.' } -$elements = $checks[0].CommandElements -$index = 0 -while ($index -lt $elements.Count -and $elements[$index].Extent.Text -ne '-MsiPath') { $index++ } -if ($index + 1 -ge $elements.Count -or $elements[$index + 1].Extent.Text -ne '$out') { - throw 'The policy check must inspect the current artifact ($out), not the first MSI.' -} -Write-Output 'PASS each WordCraft artifact is checked before signing.' diff --git a/packaging/windows/test-packaging-script.ps1 b/packaging/windows/test-packaging-script.ps1 new file mode 100644 index 0000000000..662c043092 --- /dev/null +++ b/packaging/windows/test-packaging-script.ps1 @@ -0,0 +1,29 @@ +<# Check the real packaging script without compiling, signing or installing: one MSI, policy-checked before it is signed. #> +$ErrorActionPreference = 'Stop' +$tokens = $null; $errors = $null +$script = Join-Path $PSScriptRoot 'package.ps1' +$ast = [Management.Automation.Language.Parser]::ParseFile($script, [ref]$tokens, [ref]$errors) +if ($errors.Count) { throw 'The packaging script does not parse.' } +if ($ast.Extent.Text -match 'windows-user|UserMsi') { + throw 'WordCraft ships one dual-scope MSI; there is no separate per-user installer to package.' +} +$commands = @($ast.FindAll({ param($node) $node -is [Management.Automation.Language.CommandAst] }, $true)) +$checks = @($commands | Where-Object { + $_.InvocationOperator.ToString() -eq 'Ampersand' -and + @($_.CommandElements | Where-Object { $_.Extent.Text -match 'check-install-policy.ps1' }).Count -gt 0 +}) +if ($checks.Count -ne 1) { throw 'Expected exactly one policy check in the packaging script.' } +$elements = $checks[0].CommandElements +$index = 0 +while ($index -lt $elements.Count -and $elements[$index].Extent.Text -ne '-MsiPath') { $index++ } +if ($index + 1 -ge $elements.Count -or $elements[$index + 1].Extent.Text -ne '$Msi') { + throw 'The policy check must inspect the MSI being packaged ($Msi).' +} +$signs = @($commands | Where-Object { + $_.InvocationOperator.ToString() -eq 'Ampersand' -and + @($_.CommandElements | Where-Object { $_.Extent.Text -match 'sign.ps1' }).Count -gt 0 -and + $_.CommandElements[-1].Extent.Text -eq '$Msi' +}) +if ($signs.Count -ne 1) { throw 'Expected the MSI to be signed exactly once.' } +if ($checks[0].Extent.StartOffset -ge $signs[0].Extent.StartOffset) { throw 'The MSI must pass the policy check before it is signed.' } +Write-Output 'PASS the WordCraft MSI is policy-checked before it is signed.' From d603983a6153692d2897e1f561a03bce75558a0e Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 12:25:21 +0000 Subject: [PATCH 14/94] docs(installer): note that the 32-bit all-users folder is Program Files (x86); tidy wording --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 08c7382b23..fbe286f7be 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ closer pagination fidelity, a stability sweep and native printing. Details and e The installer is one dual-scope package. By default it installs for your account only, into `%LOCALAPPDATA%\Programs\ArtCraft\WordCraft`, and needs no administrator rights; choosing -"Everyone on this computer" on the first page installs into `C:\Program Files\ArtCraft\WordCraft` +"Everyone on this computer" on the first page installs into `C:\Program Files\ArtCraft\WordCraft` (`Program Files (x86)` for the 32-bit build) and asks for an administrator. Install only one copy: a copy for another scope is refused, so uninstall first to change who WordCraft is installed for. Installers and executables are code-signed. From 8cdc172171f0873f3989904026f724a37e8e4f7b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:39:09 +0100 Subject: [PATCH 15/94] Lay out the paragraph after a kept heading once A paragraph with keep with next measures the paragraph after it before it is placed (next_first_line). That measurement called layout_para directly, so every paragraph following a heading was laid out twice in each layout pass: once to measure it and once, through the cache, when it was placed. The measurement now goes through the layout cache under the key para_labelled uses (Ctx::para_ahead) when its inputs are the same: no proofing, no typed word, not inside a text box, no inline text boxes that resize, the body's tracked-deletion view. Otherwise it is laid out on its own as before. It counts nothing (list labels, equation numbers and the page-total flag are restored), so pagination is unchanged. Adds crates/engine/examples/export_bench.rs, which saves a long generated document (the sample document 200 times, 259 pages) through io::save_bytes and prints wall times: cargo run --release -p wordcraft-engine --example export_bench -- pdf 200 9 PDF export on that document, interleaved with main (Ryzen, 16 threads): median 286 ms -> 256 ms (-10.6 %). The PDF bytes are identical. Co-Authored-By: Claude Opus 5.5 --- crates/engine/examples/export_bench.rs | 64 ++++++++++++++++++++++++++ crates/layout/src/ctx.rs | 30 ++++++++++++ crates/layout/src/flow.rs | 43 +++++++++-------- crates/layout/src/tests.rs | 24 ++++++++++ 4 files changed, 142 insertions(+), 19 deletions(-) create mode 100644 crates/engine/examples/export_bench.rs diff --git a/crates/engine/examples/export_bench.rs b/crates/engine/examples/export_bench.rs new file mode 100644 index 0000000000..318576a4be --- /dev/null +++ b/crates/engine/examples/export_bench.rs @@ -0,0 +1,64 @@ +//! Export timing: saves a long generated document (the built-in sample document repeated) through +//! the real save path, the way `file.save` / `file.export` write it, and prints wall times. +//! +//! `cargo run --release -p wordcraft-engine --example export_bench -- [ext] [copies] [runs] [out]` +//! +//! Defaults: `pdf`, 40 copies (about 100 pages), 7 runs. With `out` the last run's bytes are +//! written there, to compare the output of two builds. + +use std::sync::Arc; +use std::time::Instant; + +use wordcraft_engine::doc::props::CharProps; +use wordcraft_engine::doc::{Block, Document}; +use wordcraft_engine::{io, sample}; + +/// The sample document's body `copies` times, each paragraph ending in its copy number so no two +/// copies share text (as the chapters of a real long document don't). +fn long_document(copies: usize) -> Document { + let mut d = sample::sample_document(); + // A fixed creation date: the PDF's dates (and the ID derived from them) stay the same from run to run. + d.core.created = "2026-01-01T00:00:00Z".into(); + let one: Vec = d.body.iter().map(|b| (**b).clone()).collect(); + d.body.clear(); + for i in 0..copies { + for b in &one { + let mut b = b.clone(); + if let Block::Para(p) = &mut b { + let at = p.len(); + let _ = p.insert_text(at, &format!(" {i}"), &CharProps::default()); + } + d.body.push(Arc::new(b)); + } + } + d +} + +fn main() -> Result<(), String> { + let args: Vec = std::env::args().skip(1).collect(); + let ext = args.first().map_or("pdf", String::as_str); + let copies = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(40); + let runs = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(7usize).max(1); + let doc = long_document(copies); + let name = format!("bench.{ext}"); + let mut ms = Vec::with_capacity(runs); + let mut bytes = Vec::new(); + for _ in 0..runs { + let t = Instant::now(); + bytes = io::save_bytes(&name, &doc)?; + ms.push(t.elapsed().as_secs_f64() * 1000.0); + } + let mut sorted = ms.clone(); + sorted.sort_by(f64::total_cmp); + let median = sorted.get(sorted.len() / 2).copied().unwrap_or_default(); + let min = sorted.first().copied().unwrap_or_default(); + println!( + "{ext}: {copies} copies, {} bytes, runs {:?} ms, median {median:.1} ms, min {min:.1} ms", + bytes.len(), + ms.iter().map(|m| m.round()).collect::>() + ); + if let Some(out) = args.get(3) { + std::fs::write(out, &bytes).map_err(|e| e.to_string())?; + } + Ok(()) +} diff --git a/crates/layout/src/ctx.rs b/crates/layout/src/ctx.rs index b17d682779..62b99b5d9c 100644 --- a/crates/layout/src/ctx.rs +++ b/crates/layout/src/ctx.rs @@ -226,6 +226,36 @@ impl Ctx<'_> { pl } + /// `p` laid out on its own, as the paragraph after a kept one is measured before it is placed + /// (no wrap areas, proofing, typed word or resized text boxes), through the cache when + /// [`Ctx::para_labelled`] would lay it out with those same inputs: it is then not laid out a + /// second time when it is placed. Counts nothing (list labels, equation numbers, page totals). + /// `None` when the inputs differ. + pub(crate) fn para_ahead(&mut self, p: &Paragraph, width: f32, label: Option<(String, Level)>) -> Option> { + let resized = p + .objects + .iter() + .any(|o| matches!(o, InlineObject::Shape { story: Some(_), float, .. } if float.wrap == Wrap::Inline && float.rot == 0.0)); + if self.opts.proofing || self.boxes.inside() || resized || self.hide_deleted != self.opts.hides_deleted(StoryRef::Body) { + return None; + } + let kept = (self.eq_count, self.uses_totals); + let (key, eq_number) = self.para_key(p, width, None, &[], label.as_ref(), &[]); + (self.eq_count, self.uses_totals) = kept; + if key.typing.is_some() { + return None; + } + self.cache.used.insert(key.clone()); + if let Some(pl) = self.cache.paras.get(&key) { + self.cache.hits += 1; + return Some(pl.clone()); + } + self.cache.misses += 1; + let pl = Arc::new(para::layout_para(p, &self.para_env(width, label, None, &[], eq_number, None, &[]))); + self.cache.paras.insert(key, pl.clone()); + Some(pl) + } + /// What `measure` reads from `p` laid out as [`Ctx::para_labelled`] would, remembering only /// the result: a table's columns measure every cell once at an unbounded width, and keeping /// those layouts would double what a long table holds (and walk them all again on each pass). diff --git a/crates/layout/src/flow.rs b/crates/layout/src/flow.rs index f52cd5ccd0..e7919fd540 100644 --- a/crates/layout/src/flow.rs +++ b/crates/layout/src/flow.rs @@ -628,26 +628,31 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32, depth: usize, counte let rp = ctx.doc.styles.resolve_para(&p.props); let label = p.props.numbering.or(rp.numbering).filter(|n| n.num != 0).and_then(|n| counters.next_label(&ctx.doc.numbering, n.num, n.level)); - let env = para::ParaEnv { - doc: ctx.doc, - width, - label, - fields: &ctx.fields, - show_hidden: ctx.opts.show_hidden, - field_codes: ctx.opts.field_codes, - hide_deleted: ctx.opts.hides_deleted(StoryRef::Body), - table: None, - proofing: None, - typing_word: None, - numeral: ctx.opts.numeral, - exclusions: &[], - eq_number: ctx.eq_count, - read_line_spacing: if ctx.opts.view == ViewMode::Read { ctx.opts.read_line_spacing } else { None }, - grid: ctx.grid, - fit_objects: false, - fitted: &[], + let pl = match ctx.para_ahead(p, width, label.clone()) { + Some(pl) => pl, + None => { + let env = para::ParaEnv { + doc: ctx.doc, + width, + label, + fields: &ctx.fields, + show_hidden: ctx.opts.show_hidden, + field_codes: ctx.opts.field_codes, + hide_deleted: ctx.opts.hides_deleted(StoryRef::Body), + table: None, + proofing: None, + typing_word: None, + numeral: ctx.opts.numeral, + exclusions: &[], + eq_number: ctx.eq_count, + read_line_spacing: if ctx.opts.view == ViewMode::Read { ctx.opts.read_line_spacing } else { None }, + grid: ctx.grid, + fit_objects: false, + fitted: &[], + }; + Arc::new(para::layout_para(p, &env)) + } }; - let pl = para::layout_para(p, &env); // Its border room, unless it joins the paragraph before (or after) in one box. let own = Edges::own(&pl, 0, pl.lines.len()); let top = if joined(ctx.doc, &ctx.doc.body, block + 1, Some(block), None) { 0.0 } else { own.top }; diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index e38133a412..c993a9d270 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -2463,6 +2463,30 @@ fn cache_reuses_unchanged_paragraphs() { assert_eq!(c.misses, misses + 1); } +/// The paragraph after a heading kept with it is measured before the heading is placed. That +/// layout goes into the cache under the key it is placed with, so it is laid out once, and the +/// pages are those of the uncached measurement (proofing on measures without the cache). +#[test] +fn kept_headings_measure_the_next_paragraph_through_the_cache() { + let mut d = Document::new(); + let kept = ParaProps { keep_next: Some(true), ..Default::default() }; + for i in 0..40 { + let mut heading = wordcraft_doc::Paragraph::with_text(&format!("Heading {i}"), Default::default()); + heading.props = kept.clone(); + d.body.push(wordcraft_doc::para_block(heading)); + let body = format!("Body text {i} under its heading, long enough to wrap onto a second line in the column. ").repeat(3); + d.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&body, Default::default()))); + } + let mut c = LayoutCache::new(); + let l = layout(&d, &mut c, &LayoutOptions::default()); + assert!(l.pages.len() > 2, "{}", l.pages.len()); + assert_eq!(c.len(), d.body.len(), "one layout per paragraph"); + assert_eq!(c.misses, d.body.len() as u64, "each paragraph laid out once"); + let proofed = layout(&d, &mut LayoutCache::new(), &LayoutOptions { proofing: true, ..Default::default() }); + let starts = |l: &DocLayout| (0..d.body.len()).map(|i| l.caret(&Pos::body(i, 0)).map(|c| (c.page, c.top.to_bits()))).collect::>(); + assert_eq!(starts(&l), starts(&proofed)); +} + /// Autofit measures every cell at an unbounded width; the cache keeps what it measured, not /// those layouts, so a long table holds one layout per cell paragraph. Laying the document out /// again after an edit elsewhere measures nothing again. From ffa127c615ffe8477187f36f0c2bdbc7de7f6e80 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:39:09 +0100 Subject: [PATCH 16/94] Skip per-character table lookups for plain text in layout and PDF export Shortcuts that give the same answers as the lookups they skip: - is_rtl: nothing before U+0590 is right to left, so Latin, Greek and Cyrillic text skips the bidi class search. - strong_rtl and is_mark: ASCII letters are the only strong ASCII characters, and no ASCII character is a mark. - cluster_starts: in ASCII text every character starts a grapheme except LF after CR, and no ASCII character joins a cluster. - display lines: a glyph run's vectors and text are sized up front, and the symbol-font check is made once per run instead of once per cluster. - PDF glyph runs: the baseline (the y most glyphs share) is only worked out for italic runs, the only ones that use it, and a run on one baseline returns it without counting. Tests check each shortcut against the full lookup (every code point for the bidi classes, every ASCII pair for the clusters). PDF export of the export_bench document, interleaved with main: median 293 ms -> 241 ms (-18 % with the previous commit). The PDF bytes are identical. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/cluster.rs | 21 +++++++++++++++++++++ crates/fonts/src/lib.rs | 13 ++++++++++++- crates/layout/src/display/lines.rs | 16 ++++++++++++---- crates/layout/src/para.rs | 16 ++++++++++++++++ crates/layout/src/para/bidi.rs | 4 ++++ crates/layout/src/para/chars.rs | 3 ++- crates/pdf/src/exporter/text.rs | 4 ++-- crates/pdf/src/glyphs.rs | 4 ++++ crates/pdf/src/lib.rs | 2 +- crates/pdf/src/tests.rs | 12 ++++++++++++ 10 files changed, 86 insertions(+), 9 deletions(-) diff --git a/crates/doc/src/cluster.rs b/crates/doc/src/cluster.rs index b41a8fcc41..186b79f9d6 100644 --- a/crates/doc/src/cluster.rs +++ b/crates/doc/src/cluster.rs @@ -64,6 +64,11 @@ pub fn is_cluster_boundary(text: &str, off: usize) -> bool { /// Byte offsets where clusters start (graphemes, joined per [`joins_cluster`]), in order; the /// first is 0 for non-empty text. pub fn cluster_starts(text: &str) -> Vec { + // ASCII: every character starts a grapheme except LF after CR, and none joins (UAX #29). + if text.is_ascii() { + let b = text.as_bytes(); + return (0..b.len()).filter(|&i| !(i > 0 && b.get(i - 1) == Some(&b'\r') && b.get(i) == Some(&b'\n'))).collect(); + } let mut out: Vec = Vec::new(); let mut prev: Option = None; for (i, g) in text.grapheme_indices(true) { @@ -188,6 +193,22 @@ mod tests { assert_eq!(split(kinzi, &cluster_starts(kinzi)), vec!["အ", "င်္ဂ", "လိ", "ပ်"]); } + /// The ASCII shortcut gives the grapheme rules' answer. + #[test] + fn ascii_cluster_starts_match_the_graphemes() { + let graphemes = |t: &str| -> Vec { t.grapheme_indices(true).map(|(i, _)| i).collect() }; + let all: String = (0u8..128).map(char::from).collect(); + for t in ["", "a", "Hello, world.", "\r\n", "a\r\nb\n\rc\r\r\n\n", "\t \u{7f}x\u{0}", all.as_str()] { + assert_eq!(cluster_starts(t), graphemes(t), "{t:?}"); + } + for a in 0u8..128 { + for b in 0u8..128 { + let t = format!("{}{}", char::from(a), char::from(b)); + assert_eq!(cluster_starts(&t), graphemes(&t), "{t:?}"); + } + } + } + #[test] fn khmer_subscripts_and_indic_conjuncts_are_one_cluster() { // ខ្មែរ: ខ + coeng + ម + ែ, then រ. diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index d8e6acf7d4..59d6295aa5 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -133,7 +133,9 @@ pub fn feature_value(tag: &[u8; 4], value: u32) -> Feature { /// A strong right-to-left character (Hebrew, Arabic, …)? pub fn is_rtl(c: char) -> bool { use unicode_bidi::BidiClass::{AL, R}; - matches!(unicode_bidi::bidi_class(c), R | AL) + // Nothing before the Hebrew block is right to left: Latin, Greek and Cyrillic text skips the + // table search. + c >= '\u{590}' && matches!(unicode_bidi::bidi_class(c), R | AL) } /// Maximal runs of one direction (neutrals join the run they're in): (byte range, right-to-left). @@ -254,6 +256,15 @@ pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 { mod tests { use super::*; + /// The shortcut before the Hebrew block gives the bidi class's answer for every character. + #[test] + fn is_rtl_matches_the_bidi_class() { + use unicode_bidi::BidiClass::{AL, R}; + for c in (0..=0x10ffff).filter_map(char::from_u32) { + assert_eq!(is_rtl(c), matches!(unicode_bidi::bidi_class(c), R | AL), "{:04x}", c as u32); + } + } + #[test] fn last_resort_face_parses() { let f = fontdb::last_resort_face(); diff --git a/crates/layout/src/display/lines.rs b/crates/layout/src/display/lines.rs index b648999852..0d253f9205 100644 --- a/crates/layout/src/display/lines.rs +++ b/crates/layout/src/display/lines.rs @@ -156,9 +156,17 @@ pub(super) fn lines( k += 1; continue; }; - let mut glyphs = Vec::new(); - let mut text = String::new(); - let mut ranges: Vec> = Vec::new(); + // Sized for the run's text clusters up front: no growing per glyph. + let run = pl.clusters.get(k..line.c1.min(pl.clusters.len())).unwrap_or_default(); + let (n_glyphs, n_bytes) = run + .iter() + .take_while(|c| c.style == si) + .filter(|c| matches!(c.kind, ClKind::Text | ClKind::Space)) + .fold((0usize, 0usize), |(g, b), c| (g + c.g1.saturating_sub(c.g0) as usize, b + c.end.saturating_sub(c.start))); + let mut glyphs = Vec::with_capacity(n_glyphs); + let mut text = String::with_capacity(n_bytes); + let mut ranges: Vec> = Vec::with_capacity(n_glyphs); + let symbol_font = wordcraft_doc::symbol_fonts::is_symbol_font(&st.rc.font); let run_start = k; while k < line.c1 { let Some(c) = pl.clusters.get(k) else { break }; @@ -184,7 +192,7 @@ pub(super) fn lines( } else if let Some(p) = para { let t = p.text.get(c.start..c.end).unwrap_or(""); // Symbol-font characters (Wingdings…) read as the Unicode symbols drawn. - if wordcraft_doc::symbol_fonts::is_symbol_font(&st.rc.font) { + if symbol_font { text.push_str(&wordcraft_doc::symbol_fonts::map_text(&st.rc.font, t)); } else if st.rc.caps || st.rc.small_caps { // All Caps and Small Caps read as the capitals drawn (#1473), mapped diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 24db103142..25dd940a6f 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -423,6 +423,22 @@ mod label_spacing_tests { mod tests { use super::*; + /// The ASCII shortcuts give the bidi class's answer for every character. + #[test] + fn strong_and_mark_shortcuts_match_the_bidi_class() { + use unicode_bidi::BidiClass::{AL, BN, L, NSM, R}; + for c in (0..=0x10ffff).filter_map(char::from_u32) { + let class = unicode_bidi::bidi_class(c); + let strong = match class { + R | AL => Some(true), + L => Some(false), + _ => None, + }; + assert_eq!(bidi::strong_rtl(c), strong, "{:04x}", c as u32); + assert_eq!(chars::is_mark(c), matches!(class, NSM | BN) && !c.is_control(), "{:04x}", c as u32); + } + } + #[test] fn symbols_ignore_roman_hint() { let mut d = Document::new(); diff --git a/crates/layout/src/para/bidi.rs b/crates/layout/src/para/bidi.rs index d62af4e7f0..62bee16d34 100644 --- a/crates/layout/src/para/bidi.rs +++ b/crates/layout/src/para/bidi.rs @@ -15,6 +15,10 @@ pub(super) enum Dir<'l> { /// The direction of `c` if it is a strong letter (UAX #9 P2): `Some(true)` right to left. pub(super) fn strong_rtl(c: char) -> Option { use unicode_bidi::BidiClass::{AL, L, R}; + // In ASCII only the letters are strong (L). + if c.is_ascii() { + return c.is_ascii_alphabetic().then_some(false); + } match unicode_bidi::bidi_class(c) { R | AL => Some(true), L => Some(false), diff --git a/crates/layout/src/para/chars.rs b/crates/layout/src/para/chars.rs index 5302f44cca..ba039850a7 100644 --- a/crates/layout/src/para/chars.rs +++ b/crates/layout/src/para/chars.rs @@ -58,7 +58,8 @@ pub(super) fn caps_expands(c: char) -> bool { /// character before them: same font, same shaping run, so Arabic joining isn't broken. pub(super) fn is_mark(c: char) -> bool { use unicode_bidi::BidiClass::{BN, NSM}; - matches!(unicode_bidi::bidi_class(c), NSM | BN) && !c.is_control() + // No ASCII character is a mark (its BN characters are controls). + !c.is_ascii() && matches!(unicode_bidi::bidi_class(c), NSM | BN) && !c.is_control() } /// A digit Word's Numeral modes substitute: ASCII or Arabic-Indic (U+0660–U+0669). diff --git a/crates/pdf/src/exporter/text.rs b/crates/pdf/src/exporter/text.rs index d92d4891ca..fff476667f 100644 --- a/crates/pdf/src/exporter/text.rs +++ b/crates/pdf/src/exporter/text.rs @@ -110,8 +110,6 @@ impl Exporter<'_> { let placed: Vec<(u32, f32, f32, std::ops::Range)> = order.iter().filter_map(|&i| Some((glyphs.get(i).copied()?, ranges.get(i).cloned()?))).map(|((g, x, y), r)| (g, x, y, r)).collect(); let Some(&(_, x0, y0, _)) = placed.first() else { return }; - // The run's baseline: the y most of its glyphs share (marks sit higher or lower). - let base = baseline(glyphs).unwrap_or(y0); let kg: Vec = placed .iter() .enumerate() @@ -125,6 +123,8 @@ impl Exporter<'_> { .collect(); let pushed = italic; if italic { + // The run's baseline: the y most of its glyphs share (marks sit higher or lower). + let base = baseline(glyphs).unwrap_or(y0); s.push_transform(&Transform::from_row(1.0, 0.0, -0.21, 1.0, 0.21 * base, 0.0)); } s.set_fill(Some(fill(color, alpha))); diff --git a/crates/pdf/src/glyphs.rs b/crates/pdf/src/glyphs.rs index ea62641565..bad9a1b21a 100644 --- a/crates/pdf/src/glyphs.rs +++ b/crates/pdf/src/glyphs.rs @@ -116,6 +116,10 @@ pub(crate) fn visual_order( /// The y most of a run's glyphs share: its baseline, which raised or lowered marks leave. pub(crate) fn baseline(glyphs: &[(u32, f32, f32)]) -> Option { + let &(_, _, first) = glyphs.first()?; + if glyphs.iter().all(|g| g.2.to_bits() == first.to_bits()) { + return Some(first); + } let mut counts: HashMap = HashMap::new(); for (_, _, y) in glyphs { *counts.entry(y.to_bits()).or_insert(0) += 1; diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 0363fc1392..78a27d35e8 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -65,7 +65,7 @@ pub use export::{export, export_layout}; #[cfg(test)] use exporter::Exporter; #[cfg(test)] -use glyphs::{cluster_ends, left_to_right, split_clusters, visual_order}; +use glyphs::{baseline, cluster_ends, left_to_right, split_clusters, visual_order}; #[cfg(test)] use metadata::{civil, parse_iso}; pub use options::{PdfError, PdfOptions}; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index f6548d9c79..c8f1bec54c 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -2450,3 +2450,15 @@ fn cluster_ends_match_the_next_larger_cluster_in_linear_time() { assert!(ends[..=n].iter().all(|e| *e == 2 * n + 2)); assert_eq!(ends.last(), Some(&(2 * n + 4))); } + +/// A run's baseline is the y most of its glyphs share, ties to the lower bits; a run on one +/// baseline (the common case) gives its y without counting. +#[test] +fn baseline_is_the_most_shared_y() { + assert_eq!(baseline(&[]), None); + assert_eq!(baseline(&[(1, 0.0, 7.5)]), Some(7.5)); + assert_eq!(baseline(&[(1, 0.0, 7.5), (2, 3.0, 7.5), (3, 6.0, 7.5)]), Some(7.5)); + assert_eq!(baseline(&[(1, 0.0, 7.5), (2, 3.0, 4.0), (3, 6.0, 7.5)]), Some(7.5)); + assert_eq!(baseline(&[(1, 0.0, 9.0), (2, 3.0, 4.0)]), Some(4.0)); + assert_eq!(baseline(&[(1, 0.0, -0.0), (2, 3.0, 0.0)]), Some(0.0)); +} From ffa35d62dbac6c9f4dc0bf24a7b78325e11f2c1d Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 15:32:42 +0000 Subject: [PATCH 17/94] fix(installer): resolve the wizard scope and folder correctly Real msiexec runs (per-user and all-users, silent and wizard) found two defects that the metadata fixtures cannot see: - "Only for me" published ALLUSERS=2, so FindRelatedProducts treated the run as per-machine and skipped the existing per-user copy. An interactive upgrade left two registered products and uninstall left files behind. The page now leaves ALLUSERS unset for a per-user choice. - Windows Installer resolves Program Files once, for the context it started in, so choosing "Everyone" in the wizard installed into the user profile with a machine-wide registration. The wizard now sets the new-install folder itself after the choice (Program Files, Program Files (x86) for 32-bit, or the per-user Programs folder). Silent installs are unchanged. check-install-policy.ps1 now fails if either regresses. --- packaging/windows/check-install-policy.ps1 | 24 +++++++++++++++++++++- packaging/windows/install-policy.wxi | 15 +++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index 451295a2e4..a4a14bc648 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -140,6 +140,28 @@ try { $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' } + # Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice. + # The wizard therefore sets the new-install folder itself, and only in the wizard. + $defaults = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | Where-Object { $_[0] -like 'CraftDefault*' }) + if ($defaults.Count -lt 2) { throw 'The wizard must set the new-install folder after the scope page; Program Files is resolved before the choice.' } + foreach ($row in $defaults) { + if (-not ($ui['CraftScopeDlg'] -lt [int]$row[1] -and [int]$row[1] -lt $ui['CostInitialize'])) { throw "$($row[0]) must run after the scope page and before costing." } + } + if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { + throw 'Silent installs must not use the wizard-only default folder actions.' + } + foreach ($scope in @('1', '')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' '' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + foreach ($row in @($defaults | Sort-Object { [int]$_[1] })) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $folder = Get-SessionProperty $session 'INSTALLFOLDER' + $wanted = if ($scope -eq '1') { '^[A-Za-z]:\\Program Files( \(x86\))?\\ArtCraft\\[^\\]+\\$' } else { '^' + [regex]::Escape($env:LOCALAPPDATA) + '\\Programs\\ArtCraft\\[^\\]+\\$' } + if ($folder -notmatch $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder'." } + } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { @@ -177,7 +199,7 @@ try { $allUsers = Get-SessionProperty $session 'ALLUSERS' $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or - ($choice -eq 'user' -and ($allUsers -ne '2' -or $perUser -ne '1'))) { + ($choice -eq 'user' -and ($allUsers -or $perUser -ne '1'))) { throw 'Scope selector does not reset the requested installation context.' } } diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi index ffb78406f8..c5bc406c09 100644 --- a/packaging/windows/install-policy.wxi +++ b/packaging/windows/install-policy.wxi @@ -1,6 +1,9 @@ + @@ -25,6 +28,16 @@ + + + + + + + + + @@ -42,7 +55,7 @@ - + From de3bfaf049796abf00ab36e39e14952049170733 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:39:10 +0100 Subject: [PATCH 18/94] Keep harfrust shape plans per face Each shaping call let harfrust compile a new shape plan (the face's lookups for the run's script, direction, language and features), which took about as long as shaping a short run. Each thread now keeps up to 64 plans (the oldest dropped to make room), found by face and harfrust's ShapePlanKey::matches, and passes the plan to the shaper. A kept plan is only used for the face it was made for: the face id (from one process-wide counter, so no two faces share it) together with the font bytes' address, length and face index. The plan is the one the shaper would have made for the buffer, so the glyphs are the same; tests shape with kept and fresh plans and compare, and check the per-thread bound. PDF export of the export_bench document (259 pages), interleaved with main (A/A within 1 %): median 421 ms -> 319 ms (-24.2 %), measured on a loaded machine; on a quieter run earlier 294 ms -> 224 ms (-23.8 %). PNG export of page 1 (which lays out the whole document): about -34 %. The PDF and PNG bytes are identical to main's. Co-Authored-By: Claude Opus 5.5 --- crates/fonts/src/lib.rs | 124 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 59d6295aa5..b7655439b1 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -19,7 +19,7 @@ pub use fontdb::{ base_style, bundled, decode_font_file, is_font_file_name, name_language_matches, sniff, system_font_dirs, }; pub use harfrust::Feature; -use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer}; +use harfrust::{Direction, ShapeOptions, ShapePlan, ShapePlanKey, Shaper, Tag, UnicodeBuffer}; pub use kurbo::BezPath; pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks}; use skrifa::MetadataProvider; @@ -221,7 +221,8 @@ fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(ch } buf.guess_segment_properties(); buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }); - let gb = shaper.shape(buf, ShapeOptions::new().features(features)); + let plan = shape_plan(face, &shaper, &buf, features); + let gb = shaper.shape(buf, ShapeOptions::new().plan(plan.as_deref()).features(features)); for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) { out.push(ShapedGlyph { gid: info.glyph_id, @@ -247,6 +248,69 @@ fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(ch out } +/// How many shape plans each thread keeps: the oldest is dropped to make room, so a thread never +/// holds more than this many, whatever fonts, scripts and feature sets it shapes. +const MAX_SHAPE_PLANS: usize = 64; + +/// The face a kept shape plan was made for. Face ids come from one process-wide counter +/// (`NEXT_ID`, taken once when a face is built), so no two faces share one; the font bytes' +/// address and length and the face index are kept too, so a plan is only ever reused for the +/// font data it was made from, even if the counter were to wrap after 2^32 faces. +#[derive(Clone, Copy, PartialEq, Eq)] +struct PlanFace { + id: u32, + data: usize, + len: usize, + index: u32, +} + +impl PlanFace { + fn of(face: &FontFace) -> PlanFace { + let data = face.data(); + PlanFace { id: face.id(), data: data.as_ptr() as usize, len: data.len(), index: face.index() } + } +} + +thread_local! { + /// Shape plans made so far on this thread, by face: making one (the face's lookups for a + /// script, direction and feature set) can take longer than shaping the run. + static SHAPE_PLANS: std::cell::RefCell)>> = const { std::cell::RefCell::new(Vec::new()) }; +} + +/// The plan the shaper would make for `buf` in `face` with `features`, made once per thread (the +/// oldest dropped past [`MAX_SHAPE_PLANS`]). A kept plan is used only for the same face and when +/// harfrust's own [`ShapePlanKey::matches`] accepts it (script, direction, language, the face's +/// variation instance and the features). `None` leaves making it to the shaper. +fn shape_plan(face: &FontFace, shaper: &Shaper, buf: &UnicodeBuffer, features: &[Feature]) -> Option> { + // The buffer's script is unset when it reads as unknown: the guess never sets that. + let script = Some(buf.script()).filter(|s| *s != harfrust::script::UNKNOWN); + let language = buf.language(); + let direction = buf.direction(); + if direction == Direction::Invalid { + return None; + } + let key = ShapePlanKey::new(script, direction).language(language.as_ref()).instance(face.instance.as_ref()).features(features); + let of = PlanFace::of(face); + SHAPE_PLANS.with(|cell| { + let mut plans = cell.try_borrow_mut().ok()?; + if let Some((_, plan)) = plans.iter().find(|(f, plan)| *f == of && key.matches(plan)) { + return Some(plan.clone()); + } + let plan = std::rc::Rc::new(ShapePlan::new(shaper, direction, script, language.as_ref(), features)); + if plans.len() >= MAX_SHAPE_PLANS { + plans.remove(0); + } + plans.push((of, plan.clone())); + Some(plan) + }) +} + +/// How many shape plans this thread keeps. +#[cfg(test)] +fn kept_shape_plans() -> usize { + SHAPE_PLANS.with(|cell| cell.try_borrow().map_or(0, |p| p.len())) +} + /// Glyph id of the first of `chars` the face has (0 = .notdef). pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 { chars.iter().map(|c| face.glyph_for(*c)).find(|g| *g != 0).unwrap_or(0) @@ -293,6 +357,62 @@ mod tests { assert!(g.iter().all(|g| g.x_advance > 0 && g.gid != 0)); } + /// Shaping with a plan kept from an earlier run gives what the shaper's own plan gives. + #[test] + fn kept_shape_plans_shape_as_fresh_ones() { + let fresh = |face: &FontFace, text: &str, features: &[Feature], rtl: bool| -> Vec<(u32, u32, i32, i32, i32)> { + let Some(hb) = face.hb() else { return Vec::new() }; + let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); + let mut buf = UnicodeBuffer::new(); + buf.push_str(text); + buf.guess_segment_properties(); + buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }); + let gb = shaper.shape(buf, ShapeOptions::new().features(features)); + gb.glyph_infos().iter().zip(gb.glyph_positions()).map(|(i, p)| (i.glyph_id, i.cluster, p.x_advance, p.x_offset, p.y_offset)).collect() + }; + let kept = |face: &FontFace, text: &str, features: &[Feature], rtl: bool| -> Vec<(u32, u32, i32, i32, i32)> { + shape_dir(face, text, features, &|c| c, rtl).iter().map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset)).collect() + }; + let db = FontDb::global(); + let faces = [db.face(DEFAULT_FAMILY, "Regular"), db.face(DEFAULT_FAMILY, "Italic"), db.face("Source Sans 3", "Regular")]; + let features = + [vec![], vec![feature("-liga").unwrap()], vec![feature_value(b"kern", 0)], vec![feature("smcp").unwrap(), feature("-kern").unwrap()]]; + for _ in 0..2 { + for face in &faces { + for f in &features { + for (text, rtl) in [("office affine", false), ("12, 34.", false), ("AVATAR Wolf", false), ("abc", true), ("", false)] { + assert_eq!(kept(face, text, f, rtl), fresh(face, text, f, rtl), "{} {text:?} {f:?} {rtl}", face.family); + } + } + } + } + } + + /// However many feature sets and directions a thread shapes with, it keeps at most + /// [`MAX_SHAPE_PLANS`] plans, and shaping still matches a fresh plan once old ones are dropped. + #[test] + fn kept_shape_plans_are_bounded() { + let face = FontDb::global().face(DEFAULT_FAMILY, "Regular"); + let fresh = |features: &[Feature], rtl: bool| -> Vec<(u32, i32)> { + let Some(hb) = face.hb() else { return Vec::new() }; + let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); + let mut buf = UnicodeBuffer::new(); + buf.push_str("office"); + buf.guess_segment_properties(); + buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }); + let gb = shaper.shape(buf, ShapeOptions::new().features(features)); + gb.glyph_infos().iter().zip(gb.glyph_positions()).map(|(i, p)| (i.glyph_id, p.x_advance)).collect() + }; + for n in 0..3 * MAX_SHAPE_PLANS as u32 { + let features = [feature_value(b"kern", n % 2), feature_value(b"liga", n)]; + let rtl = n % 3 == 0; + let kept: Vec<(u32, i32)> = shape_dir(&face, "office", &features, &|c| c, rtl).iter().map(|g| (g.gid, g.x_advance)).collect(); + assert_eq!(kept, fresh(&features, rtl), "{n}"); + assert!(kept_shape_plans() <= MAX_SHAPE_PLANS, "{}", kept_shape_plans()); + } + assert_eq!(kept_shape_plans(), MAX_SHAPE_PLANS); + } + #[test] fn ligatures_can_be_disabled() { let face = FontDb::global().face(DEFAULT_FAMILY, "Regular"); From 54e1b1e7f345cd562e53b966e27fe43ea5d1d5f0 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 19:13:02 +0000 Subject: [PATCH 19/94] fix(installer): check the exact architecture-specific machine folder The pre-signing check matched any folder named Program Files, so it rejected a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit root for an x86 package and accepted a different app name. Build the expected folder from the package architecture's resolved root and the policy name. --- packaging/windows/check-install-policy.ps1 | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index a4a14bc648..cc9416e9c9 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -32,6 +32,12 @@ function Set-SessionProperty($Session, [string] $Name, [string] $Value) { function Get-SessionProperty($Session, [string] $Name) { $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) } +function Get-PackageTemplate($Database) { + # Summary information property 7 is the platform: "Intel;1033", "x64;1033" or "Arm64;1033". + $summary = $Database.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $Database, @(0)) + try { [string]$summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) } + finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) } +} $installer = $null; $database = $null; $session = $null try { $installer = New-Object -ComObject WindowsInstaller.Installer @@ -150,6 +156,12 @@ try { if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { throw 'Silent installs must not use the wizard-only default folder actions.' } + # The machine default comes from the environment of the package architecture, not from a folder name. + # x86 packages use Program Files (x86) on 64-bit Windows; x64 and Arm64 packages use the 64-bit root. + $machineRoot = if ((Get-PackageTemplate $database) -match '^Intel') { + if (Get-SessionProperty $session 'VersionNT64') { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles } + } elseif ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles } + $machineFolder = (Join-Path $machineRoot "ArtCraft\$($policy.name)") + '\' foreach ($scope in @('1', '')) { Set-SessionProperty $session 'ALLUSERS' $scope Set-SessionProperty $session 'INSTALLFOLDER' '' @@ -159,8 +171,8 @@ try { if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } } $folder = Get-SessionProperty $session 'INSTALLFOLDER' - $wanted = if ($scope -eq '1') { '^[A-Za-z]:\\Program Files( \(x86\))?\\ArtCraft\\[^\\]+\\$' } else { '^' + [regex]::Escape($env:LOCALAPPDATA) + '\\Programs\\ArtCraft\\[^\\]+\\$' } - if ($folder -notmatch $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder'." } + $wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' } + if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." } } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } From 0bd3420553269a4ad2a0a81ff59721c0a16d5cbc Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 27 Oct 2026 20:51:47 +0000 Subject: [PATCH 20/94] ci: use storytold/craft-common-actions at the merged installer-policy commit --- .github/workflows/windows-arm64.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index c09f5bd2bb..85a3f19518 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -27,7 +27,7 @@ permissions: jobs: windows-arm64: # Immutable supporting workflow revision until the new install contract lands upstream. - uses: Jeremy8776/craft-common-actions/.github/workflows/windows-arm64.yml@f4666d5cbf35f9bdeda24f1f88c7fb5c6adf5276 + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f with: app-name: wordcraft app-dir: WordCraft From 1d3e6d9ca8ccfe03c904f4ad89b85806a0683e7a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 01:06:47 +0000 Subject: [PATCH 21/94] Refactor doc for strict Clippy checks --- crates/doc/src/block.rs | 2 +- crates/doc/src/compare.rs | 2 +- crates/doc/src/document.rs | 4 +- crates/doc/src/math_edit.rs | 119 +++---- crates/doc/src/math_latex.rs | 447 ++++++++++++++------------- crates/doc/src/para/float.rs | 2 +- crates/doc/src/para/inline_kinds.rs | 2 +- crates/doc/src/para/inline_object.rs | 2 +- crates/doc/src/para/run.rs | 2 +- crates/doc/src/parts.rs | 2 +- crates/doc/src/position.rs | 2 +- crates/doc/src/props/char_props.rs | 40 ++- crates/doc/src/props/color.rs | 2 +- crates/doc/src/props/para_props.rs | 23 +- crates/doc/src/props/table_props.rs | 2 +- crates/doc/src/review.rs | 2 +- crates/doc/src/settings.rs | 2 +- crates/doc/src/text_walk.rs | 2 +- 18 files changed, 362 insertions(+), 297 deletions(-) diff --git a/crates/doc/src/block.rs b/crates/doc/src/block.rs index 251aa6e849..540876ec1d 100644 --- a/crates/doc/src/block.rs +++ b/crates/doc/src/block.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Deserialize, Paragraph, Serialize, Table}; /// A block in a story. (Blocks are always behind an `Arc`, so the size difference is fine.) #[allow(clippy::large_enum_variant)] diff --git a/crates/doc/src/compare.rs b/crates/doc/src/compare.rs index 41ca3ca3d5..dc41383adb 100644 --- a/crates/doc/src/compare.rs +++ b/crates/doc/src/compare.rs @@ -1,6 +1,6 @@ use super::block::{Block, Blocks}; use super::parts::Part; -use super::*; +use super::{Arc, BTreeMap, BuildingBlock, Cell, Paragraph, Row, Table}; pub fn para_block(p: Paragraph) -> Arc { Arc::new(Block::Para(p)) diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 318bfcc330..7d116858c2 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -5,7 +5,9 @@ use super::parts::{MAX_LINKED_BOXES, NoteSeparators, Part, PartKind, PermRange, use super::position::{Path, Pos, StoryRef}; use super::settings::{CoreProps, CustomProp, Settings}; use super::text_walk::{TextMode, notes_and_boxes, story_word_count_of, text_of, walk}; -use super::*; +use std::sync::Arc; + +use super::{BTreeMap, CharProps, Document, InlineObject, Numbering, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section}; /// [`Document::media`] by content, so adding media is linear in the bytes added rather than a /// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back; diff --git a/crates/doc/src/math_edit.rs b/crates/doc/src/math_edit.rs index bc64d43cc4..9140a73cd1 100644 --- a/crates/doc/src/math_edit.rs +++ b/crates/doc/src/math_edit.rs @@ -1061,63 +1061,8 @@ pub fn apply_structure(root: &mut Arg, pos: &MathPos, level: usize, action: &str *kind = ScriptKind::SubSup; here(if action == "script.addSup" { 2 } else { 1 }) } - (MNode::Matrix { rows, .. }, a) if a.starts_with("matrix.") => { - let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(1).max(1); - // Row and column of the caret's cell. - let mut k = 0; - let mut rc = (0, 0); - for (r, row) in rows.iter().enumerate() { - if c < k + row.len() { - rc = (r, c - k); - break; - } - k += row.len(); - } - let (r, col) = rc; - let (mut tr, mut tc) = (r, col); - match a { - "matrix.rowAbove" | "matrix.rowBelow" if rows.len() < 1000 => { - let at = if a == "matrix.rowAbove" { r } else { r + 1 }; - rows.insert(at.min(rows.len()), vec![Vec::new(); ncols]); - tr = at; - } - "matrix.colBefore" | "matrix.colAfter" if ncols < 64 => { - let at = if a == "matrix.colBefore" { col } else { col + 1 }; - for row in rows.iter_mut() { - row.insert(at.min(row.len()), Vec::new()); - } - tc = at; - } - "matrix.deleteRow" if rows.len() > 1 => { - rows.remove(r.min(rows.len() - 1)); - tr = r.min(rows.len() - 1); - } - "matrix.deleteCol" if ncols > 1 => { - for row in rows.iter_mut() { - if col < row.len() { - row.remove(col); - } - } - tc = col.min(ncols - 2); - } - _ => {} - } - let flat: usize = rows.iter().take(tr).map(|x| x.len()).sum::() + tc; - here(flat) - } - (MNode::EqArr { rows }, a) if a.starts_with("eqarr.") => match a { - "eqarr.rowAbove" | "eqarr.rowBelow" if rows.len() < 1000 => { - let at = if a == "eqarr.rowAbove" { c } else { c + 1 }.min(rows.len()); - rows.insert(at, Vec::new()); - here(at) - } - "eqarr.deleteRow" if rows.len() > 1 => { - let k = c.min(rows.len() - 1); - rows.remove(k); - here(k.min(rows.len() - 1)) - } - _ => inner, - }, + (MNode::Matrix { rows, .. }, a) if a.starts_with("matrix.") => apply_matrix_action(rows, c, a, &here), + (MNode::EqArr { rows }, a) if a.starts_with("eqarr.") => apply_equation_array_action(rows, c, a, &here, inner), (MNode::GroupChr { top, chr, .. }, "group.flip") => { *top = !*top; *chr = match *chr { @@ -1141,6 +1086,66 @@ pub fn apply_structure(root: &mut Arg, pos: &MathPos, level: usize, action: &str Some(clamp(root, &out)) } +fn apply_matrix_action(rows: &mut Vec>, caret: usize, action: &str, here: &impl Fn(usize) -> MathPos) -> MathPos { + let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1); + let (mut row_index, mut column) = (0, 0); + let mut offset = 0; + for (index, row) in rows.iter().enumerate() { + if caret < offset + row.len() { + row_index = index; + column = caret - offset; + break; + } + offset += row.len(); + } + let (mut target_row, mut target_column) = (row_index, column); + match action { + "matrix.rowAbove" | "matrix.rowBelow" if rows.len() < 1000 => { + let at = if action == "matrix.rowAbove" { row_index } else { row_index + 1 }; + rows.insert(at.min(rows.len()), vec![Vec::new(); columns]); + target_row = at; + } + "matrix.colBefore" | "matrix.colAfter" if columns < 64 => { + let at = if action == "matrix.colBefore" { column } else { column + 1 }; + for row in rows.iter_mut() { + row.insert(at.min(row.len()), Vec::new()); + } + target_column = at; + } + "matrix.deleteRow" if rows.len() > 1 => { + rows.remove(row_index.min(rows.len() - 1)); + target_row = row_index.min(rows.len() - 1); + } + "matrix.deleteCol" if columns > 1 => { + for row in rows.iter_mut() { + if column < row.len() { + row.remove(column); + } + } + target_column = column.min(columns - 2); + } + _ => {} + } + let flat = rows.iter().take(target_row).map(Vec::len).sum::() + target_column; + here(flat) +} + +fn apply_equation_array_action(rows: &mut Vec, caret: usize, action: &str, here: &impl Fn(usize) -> MathPos, inner: MathPos) -> MathPos { + match action { + "eqarr.rowAbove" | "eqarr.rowBelow" if rows.len() < 1000 => { + let at = (if action == "eqarr.rowAbove" { caret } else { caret + 1 }).min(rows.len()); + rows.insert(at, Vec::new()); + here(at) + } + "eqarr.deleteRow" if rows.len() > 1 => { + let index = caret.min(rows.len() - 1); + rows.remove(index); + here(index.min(rows.len() - 1)) + } + _ => inner, + } +} + /// Set the limit placement of n-ary operators (`None`: Word's default by operator). pub fn set_limits(nodes: &mut Arg, loc: Option) { for n in nodes.iter_mut() { diff --git a/crates/doc/src/math_latex.rs b/crates/doc/src/math_latex.rs index 526b7966e1..92979534ce 100644 --- a/crates/doc/src/math_latex.rs +++ b/crates/doc/src/math_latex.rs @@ -863,233 +863,252 @@ fn group_chr_tex(chr: char, top: bool) -> &'static str { } } -fn node(n: &MNode, s: &mut String, depth: usize) { - match n { - MNode::Run(r) => { - let (open, close) = if r.nor { - ("\\text{", "}") - } else { - match (r.sty, r.scr) { - (_, MScr::DoubleStruck) => ("\\mathbb{", "}"), - (_, MScr::Script) => ("\\mathcal{", "}"), - (_, MScr::Fraktur) => ("\\mathfrak{", "}"), - (_, MScr::SansSerif) => ("\\mathsf{", "}"), - (_, MScr::Monospace) => ("\\mathtt{", "}"), - (Some(MSty::Bold), _) => ("\\mathbf{", "}"), - (Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"), - (Some(MSty::Plain), _) if r.text.chars().any(|c| c.is_alphabetic()) => { - if FUNCTION_NAMES.contains(&r.text.as_str()) { - s.push('\\'); - s.push_str(&r.text); - s.push(' '); - return; - } - ("\\mathrm{", "}") - } - _ => ("", ""), - } - }; - s.push_str(open); - if r.nor { - text_chars(&r.text, s); - } else { - chars(&r.text, s); - } - s.push_str(close); - } - MNode::Frac { kind, num, den } => match kind { - FracKind::NoBar => { - s.push_str("\\genfrac{}{}{0pt}{}"); - braced(num, s, depth); - braced(den, s, depth); - } - FracKind::Linear | FracKind::Skewed => { - braced(num, s, depth); - s.push('/'); - braced(den, s, depth); - } - FracKind::Bar => { - s.push_str("\\frac"); - braced(num, s, depth); - braced(den, s, depth); - } - }, - MNode::Script { kind, base, sub, sup } => { - if *kind == ScriptKind::Pre { - s.push_str("{}_"); - braced(sub, s, depth); - s.push('^'); - braced(sup, s, depth); - braced(base, s, depth); - return; - } - braced(base, s, depth); - if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) { - s.push('_'); - braced(sub, s, depth); - } - if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) { - s.push('^'); - braced(sup, s, depth); - } - } - MNode::Rad { deg, deg_hide, e } => { - s.push_str("\\sqrt"); - if !deg_hide && !deg.is_empty() { - s.push('['); - arg(deg, s, depth); - s.push(']'); - } - braced(e, s, depth); - } - MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } => { - match name_of(*chr) { - Some(n) => { - s.push('\\'); - s.push_str(n); - } - None => s.push(*chr), - } - match lim_loc { - Some(crate::math::LimLoc::UndOvr) => s.push_str("\\limits"), - Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => s.push_str("\\nolimits"), - _ => {} - } - if !sub_hide && !sub.is_empty() { - s.push('_'); - braced(sub, s, depth); - } - if !sup_hide && !sup.is_empty() { - s.push('^'); - braced(sup, s, depth); - } - s.push(' '); - braced(e, s, depth); - } - MNode::Delim { beg, end, sep, elems, .. } => { - s.push_str("\\left"); - s.push_str(&delim_tex(*beg)); - let mid = match sep { - Some(c) => format!("\\middle{}", delim_tex(Some(*c))), - None => "\\middle|".to_string(), - }; - for (i, e) in elems.iter().enumerate() { - if i > 0 { - s.push_str(&mid); - } - arg(e, s, depth); - } - s.push_str("\\right"); - s.push_str(&delim_tex(*end)); - } +fn node(node: &MNode, output: &mut String, depth: usize) { + match node { + MNode::Run(run) => run_node(run, output), + MNode::Frac { kind, num, den } => fraction_node(*kind, num, den, output, depth), + MNode::Script { kind, base, sub, sup } => script_node(*kind, base, sub, sup, output, depth), + MNode::Rad { deg, deg_hide, e } => radical_node(deg, *deg_hide, e, output, depth), + MNode::Nary { .. } => nary_node(node, output, depth), + MNode::Delim { beg, end, sep, elems, .. } => delimiter_node(*beg, *end, *sep, elems, output, depth), MNode::Func { name, e } => { - arg(name, s, depth); - braced(e, s, depth); - } - MNode::Lim { upper, e, lim } => { - // `\lim_{…}` keeps its name; other bases stack. - let simple = matches!(e.as_slice(), [MNode::Run(r)] if r.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&r.text.as_str())); - if simple && !upper { - arg(e, s, depth); - s.push('_'); - braced(lim, s, depth); - } else if let [MNode::GroupChr { chr, top, e: inner }] = e.as_slice() { - s.push_str(group_chr_tex(*chr, *top)); - braced(inner, s, depth); - s.push(if *upper { '^' } else { '_' }); - braced(lim, s, depth); - } else { - s.push_str(if *upper { "\\overset" } else { "\\underset" }); - braced(lim, s, depth); - braced(e, s, depth); - } - } - MNode::Acc { chr, e } => { - let cmd = match chr { - '\u{302}' => "hat", - '\u{303}' => "tilde", - '\u{307}' => "dot", - '\u{308}' => "ddot", - '\u{20DB}' => "dddot", - '\u{30C}' => "check", - '\u{306}' => "breve", - '\u{301}' => "acute", - '\u{300}' => "grave", - '\u{305}' | '\u{AF}' => "bar", - '\u{20D6}' => "overleftarrow", - '\u{20E1}' => "overleftrightarrow", - _ => "vec", - }; - s.push('\\'); - s.push_str(cmd); - braced(e, s, depth); + arg(name, output, depth); + braced(e, output, depth); } + MNode::Lim { upper, e, lim } => limit_node(*upper, e, lim, output, depth), + MNode::Acc { chr, e } => accent_node(*chr, e, output, depth), MNode::Bar { top, e } => { - s.push_str(if *top { "\\overline" } else { "\\underline" }); - braced(e, s, depth); + output.push_str(if *top { "\\overline" } else { "\\underline" }); + braced(e, output, depth); } MNode::BorderBox { e, .. } => { - s.push_str("\\boxed"); - braced(e, s, depth); + output.push_str("\\boxed"); + braced(e, output, depth); } - MNode::Boxed { e } => braced(e, s, depth), + MNode::Boxed { e } => braced(e, output, depth), MNode::GroupChr { chr, top, e } => { - s.push_str(group_chr_tex(*chr, *top)); - braced(e, s, depth); - } - MNode::EqArr { rows } => { - if rows.len() == 1 - && let Some(r) = rows.first() - && crate::math::has_number_mark(r) - { - arg(r, s, depth); - return; - } - s.push_str("\\begin{aligned}"); - for (i, r) in rows.iter().enumerate() { - if i > 0 { - s.push_str("\\\\"); - } - for n in r { - match n { - MNode::Run(run) if !run.lit && run.text.contains('&') => { - for (k, part) in run.text.split('&').enumerate() { - if k > 0 { - s.push('&'); - } - let mut p = run.clone(); - p.text = part.to_string(); - node(&MNode::Run(p), s, depth + 1); - } - } - other => node(other, s, depth + 1), - } - } - } - s.push_str("\\end{aligned}"); - } - MNode::Matrix { rows, .. } => { - s.push_str("\\begin{matrix}"); - for (i, r) in rows.iter().enumerate() { - if i > 0 { - s.push_str("\\\\"); - } - for (j, c) in r.iter().enumerate() { - if j > 0 { - s.push('&'); - } - arg(c, s, depth); - } - } - s.push_str("\\end{matrix}"); + output.push_str(group_chr_tex(*chr, *top)); + braced(e, output, depth); } + MNode::EqArr { rows } => equation_array_node(rows, output, depth), + MNode::Matrix { rows, .. } => matrix_node(rows, output, depth), MNode::Phant { e, .. } => { - s.push_str("\\phantom"); - braced(e, s, depth); + output.push_str("\\phantom"); + braced(e, output, depth); } } } +fn run_node(run: &MRun, output: &mut String) { + let (open, close) = if run.nor { + ("\\text{", "}") + } else { + match (run.sty, run.scr) { + (_, MScr::DoubleStruck) => ("\\mathbb{", "}"), + (_, MScr::Script) => ("\\mathcal{", "}"), + (_, MScr::Fraktur) => ("\\mathfrak{", "}"), + (_, MScr::SansSerif) => ("\\mathsf{", "}"), + (_, MScr::Monospace) => ("\\mathtt{", "}"), + (Some(MSty::Bold), _) => ("\\mathbf{", "}"), + (Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"), + (Some(MSty::Plain), _) if run.text.chars().any(char::is_alphabetic) => { + if FUNCTION_NAMES.contains(&run.text.as_str()) { + output.push('\\'); + output.push_str(&run.text); + output.push(' '); + return; + } + ("\\mathrm{", "}") + } + _ => ("", ""), + } + }; + output.push_str(open); + if run.nor { + text_chars(&run.text, output) + } else { + chars(&run.text, output) + } + output.push_str(close); +} + +fn fraction_node(kind: FracKind, numerator: &[MNode], denominator: &[MNode], output: &mut String, depth: usize) { + match kind { + FracKind::NoBar => { + output.push_str("\\genfrac{}{}{0pt}{}"); + braced(numerator, output, depth); + braced(denominator, output, depth); + } + FracKind::Linear | FracKind::Skewed => { + braced(numerator, output, depth); + output.push('/'); + braced(denominator, output, depth); + } + FracKind::Bar => { + output.push_str("\\frac"); + braced(numerator, output, depth); + braced(denominator, output, depth); + } + } +} + +fn script_node(kind: ScriptKind, base: &[MNode], sub: &[MNode], sup: &[MNode], output: &mut String, depth: usize) { + if kind == ScriptKind::Pre { + output.push_str("{}_"); + braced(sub, output, depth); + output.push('^'); + braced(sup, output, depth); + braced(base, output, depth); + return; + } + braced(base, output, depth); + if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) { + output.push('_'); + braced(sub, output, depth); + } + if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) { + output.push('^'); + braced(sup, output, depth); + } +} + +fn radical_node(degree: &[MNode], degree_hidden: bool, expression: &[MNode], output: &mut String, depth: usize) { + output.push_str("\\sqrt"); + if !degree_hidden && !degree.is_empty() { + output.push('['); + arg(degree, output, depth); + output.push(']'); + } + braced(expression, output, depth); +} + +fn nary_node(node: &MNode, output: &mut String, depth: usize) { + let MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } = node else { return }; + match name_of(*chr) { + Some(name) => { + output.push('\\'); + output.push_str(name); + } + None => output.push(*chr), + } + match lim_loc { + Some(crate::math::LimLoc::UndOvr) => output.push_str("\\limits"), + Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => output.push_str("\\nolimits"), + _ => {} + } + if !sub_hide && !sub.is_empty() { + output.push('_'); + braced(sub, output, depth); + } + if !sup_hide && !sup.is_empty() { + output.push('^'); + braced(sup, output, depth); + } + output.push(' '); + braced(e, output, depth); +} + +fn delimiter_node(begin: Option, end: Option, separator: Option, elements: &[Arg], output: &mut String, depth: usize) { + output.push_str("\\left"); + output.push_str(&delim_tex(begin)); + let middle = separator.map(|c| format!("\\middle{}", delim_tex(Some(c)))).unwrap_or_else(|| "\\middle|".to_string()); + for (index, element) in elements.iter().enumerate() { + if index > 0 { + output.push_str(&middle); + } + arg(element, output, depth); + } + output.push_str("\\right"); + output.push_str(&delim_tex(end)); +} + +fn limit_node(upper: bool, expression: &[MNode], limit: &[MNode], output: &mut String, depth: usize) { + let simple = matches!(expression, [MNode::Run(run)] if run.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&run.text.as_str())); + if simple && !upper { + arg(expression, output, depth); + output.push('_'); + braced(limit, output, depth); + } else if let [MNode::GroupChr { chr, top, e: inner }] = expression { + output.push_str(group_chr_tex(*chr, *top)); + braced(inner, output, depth); + output.push(if upper { '^' } else { '_' }); + braced(limit, output, depth); + } else { + output.push_str(if upper { "\\overset" } else { "\\underset" }); + braced(limit, output, depth); + braced(expression, output, depth); + } +} + +fn accent_node(character: char, expression: &[MNode], output: &mut String, depth: usize) { + let command = match character { + '\u{302}' => "hat", + '\u{303}' => "tilde", + '\u{307}' => "dot", + '\u{308}' => "ddot", + '\u{20DB}' => "dddot", + '\u{30C}' => "check", + '\u{306}' => "breve", + '\u{301}' => "acute", + '\u{300}' => "grave", + '\u{305}' | '\u{AF}' => "bar", + '\u{20D6}' => "overleftarrow", + '\u{20E1}' => "overleftrightarrow", + _ => "vec", + }; + output.push('\\'); + output.push_str(command); + braced(expression, output, depth); +} + +fn equation_array_node(rows: &[Arg], output: &mut String, depth: usize) { + if rows.len() == 1 + && let Some(row) = rows.first() + && crate::math::has_number_mark(row) + { + arg(row, output, depth); + return; + } + output.push_str("\\begin{aligned}"); + for (index, row) in rows.iter().enumerate() { + if index > 0 { + output.push_str("\\\\"); + } + for child_node in row { + match child_node { + MNode::Run(run) if !run.lit && run.text.contains('&') => { + for (index, part) in run.text.split('&').enumerate() { + if index > 0 { + output.push('&'); + } + let mut piece = run.clone(); + piece.text = part.to_string(); + run_node(&piece, output); + } + } + other => node(other, output, depth + 1), + } + } + } + output.push_str("\\end{aligned}"); +} + +fn matrix_node(rows: &[Vec], output: &mut String, depth: usize) { + output.push_str("\\begin{matrix}"); + for (row_index, row) in rows.iter().enumerate() { + if row_index > 0 { + output.push_str("\\\\"); + } + for (column, cell) in row.iter().enumerate() { + if column > 0 { + output.push('&'); + } + arg(cell, output, depth); + } + } + output.push_str("\\end{matrix}"); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/doc/src/para/float.rs b/crates/doc/src/para/float.rs index 120bb24cfc..86f81c94d1 100644 --- a/crates/doc/src/para/float.rs +++ b/crates/doc/src/para/float.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// How a floating object is placed. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] diff --git a/crates/doc/src/para/inline_kinds.rs b/crates/doc/src/para/inline_kinds.rs index 45c9412bc4..686089f7f7 100644 --- a/crates/doc/src/para/inline_kinds.rs +++ b/crates/doc/src/para/inline_kinds.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] diff --git a/crates/doc/src/para/inline_object.rs b/crates/doc/src/para/inline_object.rs index 2b65a2ec9b..d10bbb9385 100644 --- a/crates/doc/src/para/inline_object.rs +++ b/crates/doc/src/para/inline_object.rs @@ -1,6 +1,6 @@ use super::float::{Float, Wrap, cap_alt}; use super::inline_kinds::{NoteKind, PTabAlign, PTabBase, RubyAlign, ShapeKind}; -use super::*; +use super::{CharProps, Deserialize, Serialize}; /// An inline object anchored at a U+FFFC in the paragraph text. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/para/run.rs b/crates/doc/src/para/run.rs index 3c619c8751..10ff4f09e9 100644 --- a/crates/doc/src/para/run.rs +++ b/crates/doc/src/para/run.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{AtomicU64, CharProps, Deserialize, Ordering, Serialize}; /// Object replacement character: marks an inline object in paragraph text. pub const OBJ: char = '\u{FFFC}'; diff --git a/crates/doc/src/parts.rs b/crates/doc/src/parts.rs index 535073bd9e..830aa04307 100644 --- a/crates/doc/src/parts.rs +++ b/crates/doc/src/parts.rs @@ -1,6 +1,6 @@ use super::block::Blocks; use super::position::Pos; -use super::*; +use super::{Deserialize, Serialize, props}; /// A free id, `min` or more, for something new (a part, a list, a comment): one past `largest`, the /// largest id in use, so ids keep growing; once that would pass `u32::MAX` (a file can use any id), diff --git a/crates/doc/src/position.rs b/crates/doc/src/position.rs index 479aab4ae7..800197720a 100644 --- a/crates/doc/src/position.rs +++ b/crates/doc/src/position.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// Which story a position is in. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)] diff --git a/crates/doc/src/props/char_props.rs b/crates/doc/src/props/char_props.rs index 04133bad14..b8ef433c52 100644 --- a/crates/doc/src/props/char_props.rs +++ b/crates/doc/src/props/char_props.rs @@ -1,6 +1,6 @@ use super::color::{Rgb, TextColor}; use super::para_props::Border; -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -447,24 +447,50 @@ impl CharProps { /// The fields of `self` that differ from `base` (equal ones unset): what `self` adds when /// overlaid on `base`. pub fn minus(mut self, base: &CharProps) -> CharProps { - drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, - shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl, - cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, ligatures); + self.minus_text_style(base); + self.minus_script_style(base); + self.minus_document_marks(base); self } + fn minus_text_style(&mut self, base: &CharProps) { + drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, + shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl); + } + + fn minus_script_style(&mut self, base: &CharProps) { + drop_same_fields!(self, base; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout); + } + + fn minus_document_marks(&mut self, base: &CharProps) { + drop_same_fields!(self, base; link, ins, del, ligatures); + } + /// Apply every `Some` field of `patch` on top of `self`. /// /// Plain and complex-script properties inherit independently. Commands that intend to /// format both scripts must set both values explicitly. pub fn overlay(&mut self, patch: &CharProps) { - overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, - shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl, - cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, fmt_change, ligatures); + self.overlay_text_style(patch); + self.overlay_script_style(patch); + self.overlay_document_marks(patch); if let Some(p) = &patch.text_effects { self.text_effects.get_or_insert_with(Default::default).overlay(p); } } + + fn overlay_text_style(&mut self, patch: &CharProps) { + overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, + shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl); + } + + fn overlay_script_style(&mut self, patch: &CharProps) { + overlay_fields!(self, patch; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout); + } + + fn overlay_document_marks(&mut self, patch: &CharProps) { + overlay_fields!(self, patch; link, ins, del, fmt_change, ligatures); + } pub fn overlaid(mut self, patch: &CharProps) -> CharProps { self.overlay(patch); self diff --git a/crates/doc/src/props/color.rs b/crates/doc/src/props/color.rs index 41631279d3..baf2d948d4 100644 --- a/crates/doc/src/props/color.rs +++ b/crates/doc/src/props/color.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// An sRGB colour. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)] diff --git a/crates/doc/src/props/para_props.rs b/crates/doc/src/props/para_props.rs index cab54173ca..29a8b86850 100644 --- a/crates/doc/src/props/para_props.rs +++ b/crates/doc/src/props/para_props.rs @@ -1,6 +1,6 @@ use super::char_props::{NumChange, PropChange}; use super::color::Rgb; -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -367,10 +367,23 @@ pub struct ParaProps { impl ParaProps { pub fn overlay(&mut self, patch: &ParaProps) { - overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars, space_before, space_after, space_before_auto, space_after_auto, line_spacing, - contextual_spacing, mirror_indents, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders, - suppress_hyphens, suppress_line_numbers, bidi, drop_cap, drop_cap_margin, drop_cap_distance, drop_cap_font, frame, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, - snap_to_grid); + self.overlay_layout(patch); + self.overlay_pagination(patch); + self.overlay_script_layout(patch); + } + + fn overlay_layout(&mut self, patch: &ParaProps) { + overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars, + space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders); + } + + fn overlay_pagination(&mut self, patch: &ParaProps) { + overlay_fields!(self, patch; keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, suppress_hyphens, suppress_line_numbers, + drop_cap, drop_cap_margin, drop_cap_distance, drop_cap_font, frame, snap_to_grid); + } + + fn overlay_script_layout(&mut self, patch: &ParaProps) { + overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn); } pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps { self.overlay(patch); diff --git a/crates/doc/src/props/table_props.rs b/crates/doc/src/props/table_props.rs index 8ae58b531a..8461ffc029 100644 --- a/crates/doc/src/props/table_props.rs +++ b/crates/doc/src/props/table_props.rs @@ -1,7 +1,7 @@ use super::char_props::PropChange; use super::color::Rgb; use super::para_props::{Align, Borders}; -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] diff --git a/crates/doc/src/review.rs b/crates/doc/src/review.rs index 47d9f72328..5a0b96820a 100644 --- a/crates/doc/src/review.rs +++ b/crates/doc/src/review.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Blocks, Deserialize, Document, Serialize}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] #[serde(default, rename_all = "camelCase")] diff --git a/crates/doc/src/settings.rs b/crates/doc/src/settings.rs index d5a7390383..aec1bd70f8 100644 --- a/crates/doc/src/settings.rs +++ b/crates/doc/src/settings.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Rgb, Serialize, math, section, styles}; /// A bibliography source. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] diff --git a/crates/doc/src/text_walk.rs b/crates/doc/src/text_walk.rs index 799f79893d..d7a681a5f8 100644 --- a/crates/doc/src/text_walk.rs +++ b/crates/doc/src/text_walk.rs @@ -1,7 +1,7 @@ use super::block::{Block, Blocks}; use super::parts::PartKind; use super::position::Path; -use super::*; +use super::{InlineObject, Numbering, numbering, para, section, styles}; /// Words in `s` (tokens separated by whitespace that contain at least one alphanumeric char). pub fn count_words(s: &str) -> usize { From 75d30d69b4f10a351e0860f85829a9f4b5c94edf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 01:06:47 +0000 Subject: [PATCH 22/94] Refactor docx for strict Clippy checks --- crates/docx/src/read/chart/canvas.rs | 2 +- crates/docx/src/read/chart/model.rs | 2 +- crates/docx/src/read/chart/parse.rs | 2 +- crates/docx/src/read/chart/plot.rs | 2 +- crates/docx/src/read/chart/polar.rs | 2 +- crates/docx/src/read/meta.rs | 5 +- crates/docx/src/read/parts.rs | 5 +- crates/docx/src/read/story/blocks.rs | 6 +- crates/docx/src/read/story/drawing.rs | 6 +- crates/docx/src/read/story/fields.rs | 2 +- crates/docx/src/read/story/runs.rs | 15 +- crates/docx/src/read/story/vml.rs | 24 +- crates/docx/src/read/styles.rs | 5 +- crates/docx/src/read/toc.rs | 2 +- crates/docx/src/smart_art.rs | 100 +++-- crates/docx/src/write/bibliography.rs | 2 +- crates/docx/src/write/math.rs | 120 +++--- crates/docx/src/write/parts.rs | 330 ++++++++-------- crates/docx/src/write/props.rs | 72 ++-- crates/docx/src/write/settings.rs | 36 +- crates/docx/src/write/story/blocks.rs | 2 +- crates/docx/src/write/story/drawing.rs | 34 +- crates/docx/src/write/story/field.rs | 13 +- crates/docx/src/write/story/object.rs | 456 ++++++++++++----------- crates/docx/src/write/story/para.rs | 4 +- crates/docx/src/write/story/section.rs | 2 +- crates/docx/src/write/story/toc.rs | 2 +- crates/docx/src/write/styles.rs | 2 +- crates/docx/tests/it/content_controls.rs | 35 +- crates/docx/tests/it/fixtures.rs | 14 +- crates/docx/tests/it/roundtrip.rs | 114 ++++-- 31 files changed, 810 insertions(+), 608 deletions(-) diff --git a/crates/docx/src/read/chart/canvas.rs b/crates/docx/src/read/chart/canvas.rs index f83ab3db41..a8280e4ba7 100644 --- a/crates/docx/src/read/chart/canvas.rs +++ b/crates/docx/src/read/chart/canvas.rs @@ -1,5 +1,5 @@ use super::model::Pos; -use super::*; +use super::{CHAR_W, GAP, GraphicItem, LEGEND_SHARE, LINE, MAX_LEGEND_ROWS, PAD, PathSeg, Rgb, ShapeKind, TEXT, TextAlign}; #[derive(Clone, Copy, Debug, PartialEq)] pub(super) struct Rect { diff --git a/crates/docx/src/read/chart/model.rs b/crates/docx/src/read/chart/model.rs index 807e9e6eff..a3ee99637a 100644 --- a/crates/docx/src/read/chart/model.rs +++ b/crates/docx/src/read/chart/model.rs @@ -1,4 +1,4 @@ -use super::*; +use super::Rgb; /// A data label to draw once the chart is: the value at (`x`, `y`), centred there (`inside`, in /// white on the fill) or just beside it (above, or right of a horizontal bar's end). diff --git a/crates/docx/src/read/chart/parse.rs b/crates/docx/src/read/chart/parse.rs index 5b6551268b..737effde32 100644 --- a/crates/docx/src/read/chart/parse.rs +++ b/crates/docx/src/read/chart/parse.rs @@ -1,7 +1,7 @@ use super::format::{date_text, excel_stamp, format_value, is_date_code}; use super::model::{Axis, Grouping, Kind, Pos, Series}; use super::plot::label; -use super::*; +use super::{Chart, El, FONT, LINE_W, MAX_CELLS, MAX_POINTS, MAX_RADAR_SERIES, MAX_SERIES, MAX_SPOKES, Rgb, fill_colour, int, measure, slot}; impl Chart { pub(super) fn parse(space: &El, theme: &[Rgb]) -> Option { diff --git a/crates/docx/src/read/chart/plot.rs b/crates/docx/src/read/chart/plot.rs index 5857f51e73..652ee79a8b 100644 --- a/crates/docx/src/read/chart/plot.rs +++ b/crates/docx/src/read/chart/plot.rs @@ -3,7 +3,7 @@ use super::format::format_value; use super::model::{DataLabel, Grouping, Kind, Series}; use super::parse::category_text; use super::polar::{draw_data_labels, label_of}; -use super::*; +use super::{AXIS, Chart, El, GAP, GRID, GraphicItem, LINE, MAX_LABEL, MAX_TICKS, PathSeg, Rgb, ShapeKind, TextAlign, fill_colour, measure}; pub(super) fn label(s: &str) -> String { s.chars().take(MAX_LABEL).collect() diff --git a/crates/docx/src/read/chart/polar.rs b/crates/docx/src/read/chart/polar.rs index f8f4f0b382..57ad10472b 100644 --- a/crates/docx/src/read/chart/polar.rs +++ b/crates/docx/src/read/chart/polar.rs @@ -2,7 +2,7 @@ use super::canvas::{Rect, clip_text, fill, stroke, text, text_w, widest}; use super::format::format_value; use super::model::{DataLabel, Kind, Series}; use super::plot::{Axes, has_values}; -use super::*; +use super::{Chart, FRAC_PI_2, GAP, GRID, GraphicItem, LINE, PathSeg, Rgb, ShapeKind, TAU, TEXT, TextAlign}; pub(super) fn pie(out: &mut Vec, c: &Chart, area: Rect) { let Some(s) = c.series.first() else { return }; diff --git a/crates/docx/src/read/meta.rs b/crates/docx/src/read/meta.rs index 5ec53de6f3..9739f66dce 100644 --- a/crates/docx/src/read/meta.rs +++ b/crates/docx/src/read/meta.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, BTreeMap, Blocks, Comment, DocxError, El, HashMap, HashSet, MAX_PARTS, MAX_SOURCES, MAX_TEMPLATE_PATH, NoteKind, NumFormat, Paragraph, + PartKind, Reader, Rels, Seen, StoryCtx, Zoom, ZoomFit, int, math, on_off, para_block, props, tw, u32_of, +}; impl Reader<'_> { pub(super) fn read_settings(&mut self, path: &str) -> Result<(), DocxError> { diff --git a/crates/docx/src/read/parts.rs b/crates/docx/src/read/parts.rs index cc3bfbcd24..f5ce13834e 100644 --- a/crates/docx/src/read/parts.rs +++ b/crates/docx/src/read/parts.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, PartKind, Reader, Rels, + Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style, +}; impl Reader<'_> { /// Keep the parts the VBA project at `project` relates to, with their relationship types and diff --git a/crates/docx/src/read/story/blocks.rs b/crates/docx/src/read/story/blocks.rs index 3789690e5d..b1b26ca835 100644 --- a/crates/docx/src/read/story/blocks.rs +++ b/crates/docx/src/read/story/blocks.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Arc, Block, Blocks, Cell, CharProps, El, InlineObject, MAX_COLS, MAX_ROWS, MAX_TABLE_DEPTH, Node, NumChange, PB, Paragraph, Phase, PropChange, + Reader, Rels, RevisionKind, Row, Run, RunCtx, StoryCtx, Table, children_of_choice, clean_text, collect, last_para_mut, para_block, + placeholder_of, sectpr, tcpr, trpr, +}; impl Reader<'_> { /// Read a story's blocks from a container element (`w:body`, `w:hdr`, `w:tc`, `w:txbxContent`…). diff --git a/crates/docx/src/read/story/drawing.rs b/crates/docx/src/read/story/drawing.rs index ab8df77486..f8cd077307 100644 --- a/crates/docx/src/read/story/drawing.rs +++ b/crates/docx/src/read/story/drawing.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + AltText, Anchor, Arc, Blocks, CHARTEX_URI, ConnEnd, ContentTypes, El, Embedded, Float, FloatAlign, Glow, Graphic, GraphicItem, GraphicKind, + INK_REL, INK_URI, InlineObject, MAX_STORY_DEPTH, Node, PartKind, Reader, Rels, Rgb, Shadow, ShapeEffects, ShapeExtra, ShapeKind, StoryCtx, + TextWarp, Wrap, WrapText, cap_alt, children_of_choice, int, measure, on_off_attr, own_fill, preset_kind, rt, shape_paint, u32_of, +}; impl Reader<'_> { // ---- drawings ---- diff --git a/crates/docx/src/read/story/fields.rs b/crates/docx/src/read/story/fields.rs index f661300b67..1847d3e8ad 100644 --- a/crates/docx/src/read/story/fields.rs +++ b/crates/docx/src/read/story/fields.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CharProps, FieldState, InlineObject, Item, MAX_FIELD_DEPTH, PB, Phase, Reader, StoryCtx}; /// Enclosed characters (`eq \o\ac(○,字)`), with their style from the code's font sizes: Word /// enlarges the symbol (bigger than the text, which keeps the field's size) or shrinks the text diff --git a/crates/docx/src/read/story/runs.rs b/crates/docx/src/read/story/runs.rs index 222a46f563..9dec4c5b48 100644 --- a/crates/docx/src/read/story/runs.rs +++ b/crates/docx/src/read/story/runs.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CharProps, El, InlineObject, Item, MAX_FORM, MAX_INSTR, NoteKind, PB, PTabAlign, PTabBase, Phase, Reader, Rels, RevisionKind, RunCtx, StoryCtx, + TextColor, children_of_choice, clean_text, int, measure, on_off_attr, placeholder_of, sink, +}; impl Reader<'_> { pub(super) fn read_inline_children(&mut self, sc: &mut StoryCtx, pb: &mut PB, e: &El, rels: &Rels, ctx: &RunCtx, depth: usize) { @@ -105,7 +108,7 @@ impl Reader<'_> { // Some generators write a break directly under the paragraph instead of inside a run. "w:br" | "w:cr" => { let props = self.run_props_none(ctx); - self.read_run_child(sc, pb, k, &props, rels, &mut None, depth + 1); + self.read_run_child(sc, pb, k, (&props, rels), &mut None, depth + 1); } _ => {} } @@ -131,21 +134,19 @@ impl Reader<'_> { }; let mut note: Option<(NoteKind, u32, Option)> = None; for k in r.els() { - self.read_run_child(sc, pb, k, &props, rels, &mut note, depth + 1); + self.read_run_child(sc, pb, k, (&props, rels), &mut note, depth + 1); } if let Some((kind, id, custom)) = note.take() { self.emit_obj(sc, pb, InlineObject::NoteRef { kind, id, custom: custom.unwrap_or_default() }, &props); } } - #[allow(clippy::too_many_arguments)] fn read_run_child( &mut self, sc: &mut StoryCtx, pb: &mut PB, k: &El, - props: &CharProps, - rels: &Rels, + (props, rels): (&CharProps, &Rels), note: &mut Option<(NoteKind, u32, Option)>, depth: usize, ) { @@ -301,7 +302,7 @@ impl Reader<'_> { self.emit_obj(sc, pb, o, props); } else if let Some(c) = children_of_choice(k) { for x in c.els() { - self.read_run_child(sc, pb, x, props, rels, note, depth + 1); + self.read_run_child(sc, pb, x, (props, rels), note, depth + 1); } } } diff --git a/crates/docx/src/read/story/vml.rs b/crates/docx/src/read/story/vml.rs index e3fd56b024..75faa2c91a 100644 --- a/crates/docx/src/read/story/vml.rs +++ b/crates/docx/src/read/story/vml.rs @@ -1,4 +1,14 @@ -use super::*; +use super::{ + Anchor, Arc, CharProps, El, Float, FloatAlign, InlineObject, MAX_STORY_DEPTH, MAX_VML_GROUP_DEPTH, Paragraph, PartKind, Reader, Rels, Rgb, + ShapeExtra, ShapeKind, StoryCtx, TextColor, TextWarp, Wrap, int, measure, para_block, vml_color, vml_fill, vml_fraction, vml_preset_kind, +}; + +#[derive(Clone, Copy)] +struct VmlGroupSpace { + origin: (f32, f32), + scale: (f32, f32), + depth: usize, +} impl Reader<'_> { /// Every legacy VML drawing in one `w:pict`, in document order: a `w:pict` may hold any @@ -43,7 +53,7 @@ impl Reader<'_> { let (ch_w, ch_h) = vml_coordpair(grp, "coordsize").filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((w.max(1.0), h.max(1.0))); let (cx, cy) = vml_coordpair(grp, "coordorigin").unwrap_or((0.0, 0.0)); let mut children = Vec::new(); - self.vml_group_members(sc, pict, grp, rels, (-cx, -cy, 1.0, 1.0), 0, &mut children); + self.vml_group_members(sc, pict, grp, rels, VmlGroupSpace { origin: (-cx, -cy), scale: (1.0, 1.0), depth: 0 }, &mut children); if children.is_empty() { return None; } @@ -59,11 +69,11 @@ impl Reader<'_> { pict: &El, g: &El, rels: &Rels, - t: (f32, f32, f32, f32), - depth: usize, + space: VmlGroupSpace, out: &mut Vec, ) { - let (x0, y0, sx, sy) = t; + let (x0, y0) = space.origin; + let (sx, sy) = space.scale; let max = crate::units::MAX_LEN_PT; let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max); for e in g.els() { @@ -77,7 +87,7 @@ impl Reader<'_> { let num = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).map_or(0.0, wordcraft_geom::finite); let ((ox, oy), (ew, eh)) = ((num("left"), num("top")), (num("width"), num("height"))); if e.local() == "group" { - if depth >= MAX_VML_GROUP_DEPTH { + if space.depth >= MAX_VML_GROUP_DEPTH { continue; } // Its members' space maps onto its own box. @@ -85,7 +95,7 @@ impl Reader<'_> { let (cox, coy) = vml_coordpair(e, "coordorigin").unwrap_or((0.0, 0.0)); let (kx, ky) = (ew / cw, eh / ch); let nt = (x0 + (ox - cox * kx) * sx, y0 + (oy - coy * ky) * sy, sx * kx, sy * ky); - self.vml_group_members(sc, pict, e, rels, nt, depth + 1, out); + self.vml_group_members(sc, pict, e, rels, VmlGroupSpace { origin: (nt.0, nt.1), scale: (nt.2, nt.3), depth: space.depth + 1 }, out); continue; } let Some(mut obj) = self.read_vml_shape(sc, pict, e, rels, Some((sx, sy))) else { continue }; diff --git a/crates/docx/src/read/styles.rs b/crates/docx/src/read/styles.rs index 7a97f02f9c..55d01e8a49 100644 --- a/crates/docx/src/read/styles.rs +++ b/crates/docx/src/read/styles.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts, + VAlign, flag, int, props, tw, u32_of, +}; /// Built-in style names Word stores in lower case, and how the UI shows them. pub(crate) const LOWER_NAMES: &[(&str, &str)] = &[ diff --git a/crates/docx/src/read/toc.rs b/crates/docx/src/read/toc.rs index d7307b73c3..1c246732a2 100644 --- a/crates/docx/src/read/toc.rs +++ b/crates/docx/src/read/toc.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Block, Blocks, ControlEvent, ControlKind, Document, HashSet, InlineObject, OBJ, StyleKind, control_events, pair_events}; /// Word draws a table of contents' entry links without the Hyperlink character style (no blue, /// no underline) though the entries carry it (measured in Word for Windows: a Hyperlink-styled run diff --git a/crates/docx/src/smart_art.rs b/crates/docx/src/smart_art.rs index 22eda0567c..9378192a87 100644 --- a/crates/docx/src/smart_art.rs +++ b/crates/docx/src/smart_art.rs @@ -805,16 +805,17 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String { /// A layout node: `alg` with its parameters, a shape (`None`: no geometry), what text it shows /// (`presOf` attributes), then `body`. -fn layout_node( - w: &mut W, - name: &str, - style: Option<&str>, - alg: &str, - params: &[(&str, &str)], - shape: Option<&str>, - pres_of: &[(&str, &str)], - body: impl FnOnce(&mut W), -) { +struct LayoutNode<'a> { + name: &'a str, + style: Option<&'a str>, + alg: &'a str, + params: &'a [(&'a str, &'a str)], + shape: Option<&'a str>, + pres_of: &'a [(&'a str, &'a str)], +} + +fn layout_node(w: &mut W, node: LayoutNode<'_>, body: impl FnOnce(&mut W)) { + let LayoutNode { name, style, alg, params, shape, pres_of } = node; let mut attrs = vec![("name", name)]; if let Some(s) = style { attrs.push(("styleLbl", s)); @@ -870,45 +871,74 @@ pub fn layout_xml(l: SmartArtLayout) -> String { SmartArtLayout::Cycle | SmartArtLayout::Radial | SmartArtLayout::Venn => "ellipse", SmartArtLayout::Pyramid => "trapezoid", }; - layout_node(&mut w, "diagram", None, alg, params, None, &[], |w| match l { + layout_node(&mut w, LayoutNode { name: "diagram", style: None, alg, params, shape: None, pres_of: &[] }, |w| match l { SmartArtLayout::Hierarchy => { w.open("dgm:forEach", &[("name", "branches"), ("axis", "ch"), ("ptType", "node")]); - layout_node(w, "branch", None, "hierRoot", &[], None, &[], |w| { - layout_node(w, "item", Some("node1"), "tx", &[], Some(shape), &[("axis", "self")], |_| {}); - layout_node(w, "below", None, "hierChild", &[("chAlign", "t")], None, &[], |w| { - w.open("dgm:forEach", &[("name", "joins"), ("axis", "ch"), ("ptType", "parTrans")]); - layout_node( - w, - "join", - Some("connector"), - "conn", - &[("dim", "1D"), ("connRout", "bend"), ("begPts", "bCtr"), ("endPts", "tCtr")], - Some("conn"), - &[("axis", "self")], - |_| {}, - ); - w.close("dgm:forEach"); - w.empty("dgm:forEach", &[("name", "deeper"), ("ref", "branches")]); - }); + layout_node(w, LayoutNode { name: "branch", style: None, alg: "hierRoot", params: &[], shape: None, pres_of: &[] }, |w| { + layout_node( + w, + LayoutNode { name: "item", style: Some("node1"), alg: "tx", params: &[], shape: Some(shape), pres_of: &[("axis", "self")] }, + |_| {}, + ); + layout_node( + w, + LayoutNode { name: "below", style: None, alg: "hierChild", params: &[("chAlign", "t")], shape: None, pres_of: &[] }, + |w| { + w.open("dgm:forEach", &[("name", "joins"), ("axis", "ch"), ("ptType", "parTrans")]); + layout_node( + w, + LayoutNode { + name: "join", + style: Some("connector"), + alg: "conn", + params: &[("dim", "1D"), ("connRout", "bend"), ("begPts", "bCtr"), ("endPts", "tCtr")], + shape: Some("conn"), + pres_of: &[("axis", "self")], + }, + |_| {}, + ); + w.close("dgm:forEach"); + w.empty("dgm:forEach", &[("name", "deeper"), ("ref", "branches")]); + }, + ); }); w.close("dgm:forEach"); } SmartArtLayout::Radial => { w.open("dgm:forEach", &[("name", "middle"), ("axis", "ch"), ("ptType", "node"), ("cnt", "1")]); - layout_node(w, "centre", Some("node1"), "tx", &[], Some(shape), &[("axis", "self")], |w| { - w.open("dgm:forEach", &[("name", "around"), ("axis", "ch"), ("ptType", "node")]); - layout_node(w, "spoke", Some("node1"), "tx", &[], Some(shape), ITEM_TEXT, |_| {}); - w.close("dgm:forEach"); - }); + layout_node( + w, + LayoutNode { name: "centre", style: Some("node1"), alg: "tx", params: &[], shape: Some(shape), pres_of: &[("axis", "self")] }, + |w| { + w.open("dgm:forEach", &[("name", "around"), ("axis", "ch"), ("ptType", "node")]); + layout_node( + w, + LayoutNode { name: "spoke", style: Some("node1"), alg: "tx", params: &[], shape: Some(shape), pres_of: ITEM_TEXT }, + |_| {}, + ); + w.close("dgm:forEach"); + }, + ); w.close("dgm:forEach"); } _ => { w.open("dgm:forEach", &[("name", "items"), ("axis", "ch"), ("ptType", "node")]); let style = if l == SmartArtLayout::Venn { "venn" } else { "node1" }; - layout_node(w, "item", Some(style), "tx", &[], Some(shape), ITEM_TEXT, |_| {}); + layout_node(w, LayoutNode { name: "item", style: Some(style), alg: "tx", params: &[], shape: Some(shape), pres_of: ITEM_TEXT }, |_| {}); if matches!(l, SmartArtLayout::Process | SmartArtLayout::Cycle) { w.open("dgm:forEach", &[("name", "arrows"), ("axis", "followSib"), ("ptType", "sibTrans"), ("cnt", "1")]); - layout_node(w, "arrow", Some("connector"), "conn", &[("dim", "2D")], Some("rightArrow"), &[("axis", "self")], |_| {}); + layout_node( + w, + LayoutNode { + name: "arrow", + style: Some("connector"), + alg: "conn", + params: &[("dim", "2D")], + shape: Some("rightArrow"), + pres_of: &[("axis", "self")], + }, + |_| {}, + ); w.close("dgm:forEach"); } w.close("dgm:forEach"); diff --git a/crates/docx/src/write/bibliography.rs b/crates/docx/src/write/bibliography.rs index 2f0f8b9a36..ee343c8662 100644 --- a/crates/docx/src/write/bibliography.rs +++ b/crates/docx/src/write/bibliography.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, W}; fn to_source_type(kind: &str) -> &str { match kind.to_ascii_lowercase().as_str() { diff --git a/crates/docx/src/write/math.rs b/crates/docx/src/write/math.rs index a1ae2a8c5b..5cacc0b51f 100644 --- a/crates/docx/src/write/math.rs +++ b/crates/docx/src/write/math.rs @@ -179,6 +179,67 @@ fn char_prop(w: &mut W, name: &str, c: Option) { val(w, name, &s); } +fn write_matrix(w: &mut W, n: &MNode, depth: usize) { + let MNode::Matrix { rows, col_jc } = n else { return }; + let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1); + w.open("m:m", &[]); + w.open("m:mPr", &[]); + w.open("m:mcs", &[]); + for c in 0..cols { + let jc = match col_jc.get(c).copied().unwrap_or_default() { + ColJc::Center => "center", + ColJc::Left => "left", + ColJc::Right => "right", + }; + w.open("m:mc", &[]); + w.open("m:mcPr", &[]); + val(w, "m:count", "1"); + val(w, "m:mcJc", jc); + w.close("m:mcPr"); + w.close("m:mc"); + } + w.close("m:mcs"); + ctrl(w); + w.close("m:mPr"); + for row in rows { + w.open("m:mr", &[]); + for c in 0..cols { + arg(w, "m:e", row.get(c).map(|v| v.as_slice()).unwrap_or(&[]), depth); + } + w.close("m:mr"); + } + w.close("m:m"); +} + +fn write_nary(w: &mut W, n: &MNode, depth: usize) { + let MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } = n else { return }; + w.open("m:nary", &[]); + w.open("m:naryPr", &[]); + if *chr != '∫' { + char_prop(w, "m:chr", Some(*chr)); + } + match lim_loc { + Some(LimLoc::UndOvr) => val(w, "m:limLoc", "undOvr"), + Some(LimLoc::SubSup) => val(w, "m:limLoc", "subSup"), + None => {} + } + if *grow { + val(w, "m:grow", "1"); + } + if *sub_hide { + val(w, "m:subHide", "1"); + } + if *sup_hide { + val(w, "m:supHide", "1"); + } + ctrl(w); + w.close("m:naryPr"); + arg(w, "m:sub", sub, depth); + arg(w, "m:sup", sup, depth); + arg(w, "m:e", e, depth); + w.close("m:nary"); +} + fn node(w: &mut W, n: &MNode, depth: usize) { match n { MNode::Run(r) => run(w, r), @@ -235,33 +296,7 @@ fn node(w: &mut W, n: &MNode, depth: usize) { arg(w, "m:e", e, depth); w.close("m:rad"); } - MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } => { - w.open("m:nary", &[]); - w.open("m:naryPr", &[]); - if *chr != '∫' { - char_prop(w, "m:chr", Some(*chr)); - } - match lim_loc { - Some(LimLoc::UndOvr) => val(w, "m:limLoc", "undOvr"), - Some(LimLoc::SubSup) => val(w, "m:limLoc", "subSup"), - None => {} - } - if *grow { - val(w, "m:grow", "1"); - } - if *sub_hide { - val(w, "m:subHide", "1"); - } - if *sup_hide { - val(w, "m:supHide", "1"); - } - ctrl(w); - w.close("m:naryPr"); - arg(w, "m:sub", sub, depth); - arg(w, "m:sup", sup, depth); - arg(w, "m:e", e, depth); - w.close("m:nary"); - } + MNode::Nary { .. } => write_nary(w, n, depth), MNode::Delim { beg, end, sep, grow, shp_match, elems } => { w.open("m:d", &[]); w.open("m:dPr", &[]); @@ -375,36 +410,7 @@ fn node(w: &mut W, n: &MNode, depth: usize) { } w.close("m:eqArr"); } - MNode::Matrix { rows, col_jc } => { - let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1); - w.open("m:m", &[]); - w.open("m:mPr", &[]); - w.open("m:mcs", &[]); - for c in 0..cols { - let jc = match col_jc.get(c).copied().unwrap_or_default() { - ColJc::Center => "center", - ColJc::Left => "left", - ColJc::Right => "right", - }; - w.open("m:mc", &[]); - w.open("m:mcPr", &[]); - val(w, "m:count", "1"); - val(w, "m:mcJc", jc); - w.close("m:mcPr"); - w.close("m:mc"); - } - w.close("m:mcs"); - ctrl(w); - w.close("m:mPr"); - for r in rows { - w.open("m:mr", &[]); - for c in 0..cols { - arg(w, "m:e", r.get(c).map(|v| v.as_slice()).unwrap_or(&[]), depth); - } - w.close("m:mr"); - } - w.close("m:m"); - } + MNode::Matrix { .. } => write_matrix(w, n, depth), MNode::Phant { show, zero_wid, zero_asc, zero_desc, e } => { w.open("m:phant", &[]); w.open("m:phantPr", &[]); diff --git a/crates/docx/src/write/parts.rs b/crates/docx/src/write/parts.rs index 05adf217cc..7cea957d46 100644 --- a/crates/docx/src/write/parts.rs +++ b/crates/docx/src/write/parts.rs @@ -3,7 +3,10 @@ use super::settings::{ ANONYMOUS_INITIALS, annotation_author, app_xml, core_xml, note_id_base, notes_part, settings_xml, theme_xml, write_comment_ids, }; use super::styles::{numbering_xml, styles_xml}; -use super::*; +use super::{ + BTreeMap, Blocks, CT_WML, Document, DocxError, Flavor, HashMap, MAX_VBA_RELATED, PartRels, VBA_PROJECT_PART, VBA_RELATED, W, Writer, embed, + glossary, rt, settings_merge, watermark, xml, +}; /// The parts of the package [`write_as`] zips, in order, `[Content_Types].xml` first: (part name /// without the leading `/`, bytes). @@ -60,6 +63,8 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result = Vec::new(); let mut rels = PartRels::default(); let mut root = PartRels::default(); + let ns = xml::body_ns(); + let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); // Comments get paragraph ids before any story is written. let mut comment_paras: Vec<(u32, String)> = Vec::new(); @@ -73,41 +78,7 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); - w.open("w:document", &ns_refs); - let pic = doc.settings.page_picture.as_ref().filter(|p| wr.media_files.contains_key(&p.media) && doc.media.contains_key(&p.media)); - if pic.is_some() || doc.settings.page_color.is_some() { - let color = doc.settings.page_color.map(|c| c.hex()); - let attrs: Vec<(&str, &str)> = color.iter().map(|c| ("w:color", c.as_str())).collect(); - w.open("w:background", &attrs); - if let Some(p) = pic - && let Some(file) = wr.media_files.get(&p.media) - { - let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false); - wr.used_media.insert(p.media.clone()); - w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]); - let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)]; - if !p.fill_type.is_empty() { - fa.push(("type", &p.fill_type)); - } - if !p.title.is_empty() { - fa.push(("o:title", &p.title)); - } - w.empty("v:fill", &fa); - w.close("v:background"); - } - w.close("w:background"); - } - w.open("w:body", &[]); - wr.blocks(&mut w, &doc.body, &mut rels, true, 0); - let last = doc.last_section.clone(); - wr.sectpr(&mut w, &last, &mut rels); - w.close("w:body"); - w.close("w:document"); - let body = w.into_bytes(); + let body = write_document_body(doc, &mut wr, &mut rels, &ns_refs); // Headers and footers (discovered while writing sections). let mut done = 0; @@ -150,126 +121,9 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result { - w.open(tag, &[("w:type", kind), ("w:id", id)]); - if blocks.is_empty() { - w.empty("w:p", &[]); - } else { - wr.blocks(&mut w, blocks, &mut prels, false, 0); - } - w.close(tag); - } - None if kind != "continuationNotice" => { - w.open(tag, &[("w:type", kind), ("w:id", id)]); - w.open("w:p", &[]); - w.open("w:pPr", &[]); - w.empty("w:spacing", &[("w:after", "0"), ("w:line", "240"), ("w:lineRule", "auto")]); - w.close("w:pPr"); - w.open("w:r", &[]); - w.empty(if kind == "separator" { "w:separator" } else { "w:continuationSeparator" }, &[]); - w.close("w:r"); - w.close("w:p"); - w.close(tag); - } - None => {} - } - } - let base = note_id_base(doc, foot); - let mut k = 0; - while let Some(pid) = (if foot { &wr.footnotes } else { &wr.endnotes }).get(k).copied() { - let nid = (k + base).to_string(); - k += 1; - w.open(tag, &[("w:id", &nid)]); - let blocks: Blocks = doc.parts.get(&pid).map(|p| p.blocks.clone()).unwrap_or_default(); - wr.note_blocks(&mut w, &blocks, &mut prels, foot, pid); - w.close(tag); - } - w.close(root); - let kind = if foot { rt::FOOTNOTES } else { rt::ENDNOTES }; - rels.add(kind, file, false); - push_part( - &mut entries, - &mut overrides, - &format!("word/{file}"), - w.into_bytes(), - &format!("{CT_WML}{}+xml", if foot { "footnotes" } else { "endnotes" }), - prels, - ); - } + write_notes(doc, &ns_refs, &mut wr, &mut entries, &mut overrides, &mut rels); - // Comments. - if !doc.comments.is_empty() { - let mut prels = PartRels::default(); - let mut w = W::new(); - w.open("w:comments", &ns_refs); - for (cid, c) in &doc.comments { - let id = cid.to_string(); - let author = annotation_author(doc, &c.author); - let mut a = vec![("w:id", id.as_str()), ("w:author", author)]; - if !c.date.is_empty() && !doc.settings.remove_date_time { - a.push(("w:date", c.date.as_str())); - } - if doc.settings.remove_personal_info { - a.push(("w:initials", ANONYMOUS_INITIALS)); - } else if !c.initials.is_empty() { - a.push(("w:initials", c.initials.as_str())); - } - w.open("w:comment", &a); - let blocks: Blocks = doc.parts.get(&c.part).map(|p| p.blocks.clone()).unwrap_or_default(); - wr.blocks(&mut w, &blocks, &mut prels, false, 0); - w.close("w:comment"); - } - w.close("w:comments"); - rels.add(rt::COMMENTS, "comments.xml", false); - push_part(&mut entries, &mut overrides, "word/comments.xml", w.into_bytes(), &format!("{CT_WML}comments+xml"), prels); - - let mut w = W::new(); - w.open( - "w15:commentsEx", - &[ - ("xmlns:w15", "http://schemas.microsoft.com/office/word/2012/wordml"), - ("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"), - ], - ); - let pid_of = |cid: u32| comment_paras.iter().find(|(c, _)| *c == cid).map(|(_, p)| p.clone()); - for (cid, c) in &doc.comments { - let Some(pid) = pid_of(*cid) else { continue }; - let done = if c.resolved { "1" } else { "0" }; - match c.parent.and_then(pid_of) { - Some(parent) => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:paraIdParent", &parent), ("w15:done", done)]), - None => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:done", done)]), - } - } - w.close("w15:commentsEx"); - rels.add(rt::COMMENTS_EX, "commentsExtended.xml", false); - push_part( - &mut entries, - &mut overrides, - "word/commentsExtended.xml", - w.into_bytes(), - &format!("{CT_WML}commentsExtended+xml"), - PartRels::default(), - ); - write_comment_ids(doc, &comment_paras, &mut entries, &mut overrides, &mut rels); - } + write_comments(doc, &comment_paras, &ns_refs, &mut wr, &mut entries, &mut overrides, &mut rels); rels.add(rt::STYLES, "styles.xml", false); push_part(&mut entries, &mut overrides, "word/styles.xml", styles_xml(doc), &format!("{CT_WML}styles+xml"), PartRels::default()); @@ -425,6 +279,172 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result, rels: &mut PartRels, ns_refs: &[(&str, &str)]) -> Vec { + let mut w = W::new(); + w.open("w:document", ns_refs); + let pic = doc.settings.page_picture.as_ref().filter(|p| wr.media_files.contains_key(&p.media) && doc.media.contains_key(&p.media)); + if pic.is_some() || doc.settings.page_color.is_some() { + let color = doc.settings.page_color.map(|c| c.hex()); + let attrs: Vec<(&str, &str)> = color.iter().map(|c| ("w:color", c.as_str())).collect(); + w.open("w:background", &attrs); + if let Some(p) = pic + && let Some(file) = wr.media_files.get(&p.media) + { + let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false); + wr.used_media.insert(p.media.clone()); + w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]); + let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)]; + if !p.fill_type.is_empty() { + fa.push(("type", &p.fill_type)); + } + if !p.title.is_empty() { + fa.push(("o:title", &p.title)); + } + w.empty("v:fill", &fa); + w.close("v:background"); + } + w.close("w:background"); + } + w.open("w:body", &[]); + wr.blocks(&mut w, &doc.body, rels, true, 0); + let last = doc.last_section.clone(); + wr.sectpr(&mut w, &last, rels); + w.close("w:body"); + w.close("w:document"); + w.into_bytes() +} + +fn write_notes( + doc: &Document, + ns_refs: &[(&str, &str)], + wr: &mut Writer<'_>, + entries: &mut Vec<(String, Vec)>, + overrides: &mut Vec<(String, String)>, + rels: &mut PartRels, +) { + for foot in [true, false] { + let seps = if foot { &doc.footnote_separators } else { &doc.endnote_separators }; + if !notes_part(doc, wr, foot) { + continue; + } + let (root, tag, file) = if foot { ("w:footnotes", "w:footnote", "footnotes.xml") } else { ("w:endnotes", "w:endnote", "endnotes.xml") }; + let mut prels = PartRels::default(); + let mut w = W::new(); + w.open(root, ns_refs); + // The separators and continuation notice: the document's own, else the defaults (no + // notice). Word numbers them -1, 0 and 1. + for (id, kind, own) in [ + ("-1", "separator", &seps.separator), + ("0", "continuationSeparator", &seps.continuation_separator), + ("1", "continuationNotice", &seps.continuation_notice), + ] { + match own { + Some(blocks) => { + w.open(tag, &[("w:type", kind), ("w:id", id)]); + if blocks.is_empty() { + w.empty("w:p", &[]); + } else { + wr.blocks(&mut w, blocks, &mut prels, false, 0); + } + w.close(tag); + } + None if kind != "continuationNotice" => { + w.open(tag, &[("w:type", kind), ("w:id", id)]); + w.open("w:p", &[]); + w.open("w:pPr", &[]); + w.empty("w:spacing", &[("w:after", "0"), ("w:line", "240"), ("w:lineRule", "auto")]); + w.close("w:pPr"); + w.open("w:r", &[]); + w.empty(if kind == "separator" { "w:separator" } else { "w:continuationSeparator" }, &[]); + w.close("w:r"); + w.close("w:p"); + w.close(tag); + } + None => {} + } + } + let base = note_id_base(doc, foot); + let mut k = 0; + while let Some(pid) = (if foot { &wr.footnotes } else { &wr.endnotes }).get(k).copied() { + let nid = (k + base).to_string(); + k += 1; + w.open(tag, &[("w:id", &nid)]); + let blocks: Blocks = doc.parts.get(&pid).map(|p| p.blocks.clone()).unwrap_or_default(); + wr.note_blocks(&mut w, &blocks, &mut prels, foot, pid); + w.close(tag); + } + w.close(root); + let kind = if foot { rt::FOOTNOTES } else { rt::ENDNOTES }; + rels.add(kind, file, false); + push_part( + entries, + overrides, + &format!("word/{file}"), + w.into_bytes(), + &format!("{CT_WML}{}+xml", if foot { "footnotes" } else { "endnotes" }), + prels, + ); + } +} + +fn write_comments( + doc: &Document, + comment_paras: &[(u32, String)], + ns_refs: &[(&str, &str)], + wr: &mut Writer<'_>, + entries: &mut Vec<(String, Vec)>, + overrides: &mut Vec<(String, String)>, + rels: &mut PartRels, +) { + if !doc.comments.is_empty() { + let mut prels = PartRels::default(); + let mut w = W::new(); + w.open("w:comments", ns_refs); + for (cid, c) in &doc.comments { + let id = cid.to_string(); + let author = annotation_author(doc, &c.author); + let mut a = vec![("w:id", id.as_str()), ("w:author", author)]; + if !c.date.is_empty() && !doc.settings.remove_date_time { + a.push(("w:date", c.date.as_str())); + } + if doc.settings.remove_personal_info { + a.push(("w:initials", ANONYMOUS_INITIALS)); + } else if !c.initials.is_empty() { + a.push(("w:initials", c.initials.as_str())); + } + w.open("w:comment", &a); + let blocks: Blocks = doc.parts.get(&c.part).map(|p| p.blocks.clone()).unwrap_or_default(); + wr.blocks(&mut w, &blocks, &mut prels, false, 0); + w.close("w:comment"); + } + w.close("w:comments"); + rels.add(rt::COMMENTS, "comments.xml", false); + push_part(entries, overrides, "word/comments.xml", w.into_bytes(), &format!("{CT_WML}comments+xml"), prels); + + let mut w = W::new(); + w.open( + "w15:commentsEx", + &[ + ("xmlns:w15", "http://schemas.microsoft.com/office/word/2012/wordml"), + ("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"), + ], + ); + let pid_of = |cid: u32| comment_paras.iter().find(|(c, _)| *c == cid).map(|(_, p)| p.clone()); + for (cid, c) in &doc.comments { + let Some(pid) = pid_of(*cid) else { continue }; + let done = if c.resolved { "1" } else { "0" }; + match c.parent.and_then(pid_of) { + Some(parent) => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:paraIdParent", &parent), ("w15:done", done)]), + None => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:done", done)]), + } + } + w.close("w15:commentsEx"); + rels.add(rt::COMMENTS_EX, "commentsExtended.xml", false); + push_part(entries, overrides, "word/commentsExtended.xml", w.into_bytes(), &format!("{CT_WML}commentsExtended+xml"), PartRels::default()); + write_comment_ids(doc, comment_paras, entries, overrides, rels); + } +} + pub(super) fn push_part( entries: &mut Vec<(String, Vec)>, overrides: &mut Vec<(String, String)>, diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index aba9fdf464..b67ee6b076 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -253,6 +253,40 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha toggle(w, "w:keepNext", p.keep_next); toggle(w, "w:keepLines", p.keep_lines); toggle(w, "w:pageBreakBefore", p.page_break_before); + write_frame(w, p, framed); + toggle(w, "w:widowControl", p.widow_control); + if let Some(nr) = p.numbering { + w.open("w:numPr", &[]); + w.val("w:ilvl", &n(nr.level.min(8) as i64)); + w.val("w:numId", &n(nr.num as i64)); + if let Some(a) = num_change { + let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect(); + w.empty("w:numberingChange", &refs); + } + w.close("w:numPr"); + } + toggle(w, "w:suppressLineNumbers", p.suppress_line_numbers); + if let Some(b) = &p.borders { + borders(w, "w:pBdr", b, Some("w:between"), &[]); + } + if let Some(s) = p.shading { + w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &s.hex())]); + } + write_tabs(w, p); + write_text_flow(w, p); + write_spacing(w, p); + write_indentation(w, p); + toggle(w, "w:contextualSpacing", p.contextual_spacing); + toggle(w, "w:mirrorIndents", p.mirror_indents); + if let Some(a) = p.align { + w.val("w:jc", align_val(a)); + } + if let Some(l) = p.outline_level { + w.val("w:outlineLvl", &n(l.min(9) as i64)); + } +} + +fn write_frame(w: &mut W, p: &ParaProps, framed: bool) { if framed && let Some(lines) = p.drop_cap.filter(|l| *l > 0) { // ECMA-376 §17.3.1.11: the drop cap's frame — dropped or in the margin, the lines it // spans and its distance from the text (`w:hSpace`, twips). @@ -295,24 +329,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha } w.empty("w:framePr", &a); } - toggle(w, "w:widowControl", p.widow_control); - if let Some(nr) = p.numbering { - w.open("w:numPr", &[]); - w.val("w:ilvl", &n(nr.level.min(8) as i64)); - w.val("w:numId", &n(nr.num as i64)); - if let Some(a) = num_change { - let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect(); - w.empty("w:numberingChange", &refs); - } - w.close("w:numPr"); - } - toggle(w, "w:suppressLineNumbers", p.suppress_line_numbers); - if let Some(b) = &p.borders { - borders(w, "w:pBdr", b, Some("w:between"), &[]); - } - if let Some(s) = p.shading { - w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &s.hex())]); - } +} + +fn write_tabs(w: &mut W, p: &ParaProps) { if let Some(tabs) = &p.tabs && !tabs.is_empty() { @@ -334,6 +353,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha } w.close("w:tabs"); } +} + +fn write_text_flow(w: &mut W, p: &ParaProps) { toggle(w, "w:suppressAutoHyphens", p.suppress_hyphens); toggle(w, "w:kinsoku", p.kinsoku); toggle(w, "w:wordWrap", p.word_wrap); @@ -343,6 +365,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha toggle(w, "w:autoSpaceDN", p.auto_space_dn); toggle(w, "w:bidi", p.bidi); toggle(w, "w:snapToGrid", p.snap_to_grid); +} + +fn write_spacing(w: &mut W, p: &ParaProps) { if p.space_before.is_some() || p.space_after.is_some() || p.line_spacing.is_some() @@ -375,6 +400,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha } w.empty("w:spacing", &a); } +} + +fn write_indentation(w: &mut W, p: &ParaProps) { let chars = [p.indent_left_chars, p.indent_right_chars, p.indent_first_chars]; if p.indent_left.is_some() || p.indent_right.is_some() || p.indent_first.is_some() || chars.iter().any(Option::is_some) { let l = p.indent_left.map(twips); @@ -404,14 +432,6 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha } w.empty("w:ind", &a); } - toggle(w, "w:contextualSpacing", p.contextual_spacing); - toggle(w, "w:mirrorIndents", p.mirror_indents); - if let Some(a) = p.align { - w.val("w:jc", align_val(a)); - } - if let Some(l) = p.outline_level { - w.val("w:outlineLvl", &n(l.min(9) as i64)); - } } pub fn margins(w: &mut W, tag: &str, m: &[f32; 4]) { diff --git a/crates/docx/src/write/settings.rs b/crates/docx/src/write/settings.rs index 4359055ba5..df4e77fa64 100644 --- a/crates/docx/src/write/settings.rs +++ b/crates/docx/src/write/settings.rs @@ -1,5 +1,5 @@ use super::parts::push_part; -use super::*; +use super::{CT_WML, Document, PartRels, W, Writer, math, rt, story, xml}; /// The file id of the first footnote or endnote: after the separators (-1, 0) and the /// continuation notice (1), when the document has one. @@ -102,22 +102,41 @@ pub(super) fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, temp } } w.val("w:characterSpacingControl", "doNotCompress"); + write_note_settings(&mut w, s, s_notes, footnotes, endnotes); + write_compatibility_settings(&mut w, doc); + if let Some(m) = &s.math { + math::math_pr(&mut w, m); + } + w.close("w:settings"); + w.into_bytes() +} + +fn write_note_settings( + w: &mut W, + settings: &wordcraft_doc::Settings, + separators: (&wordcraft_doc::NoteSeparators, &wordcraft_doc::NoteSeparators), + footnotes: bool, + endnotes: bool, +) { for (tag, fmt, pr, used, el) in [ - ("w:footnotePr", s.footnote_format, &s.footnote_pr, footnotes, "w:footnote"), - ("w:endnotePr", s.endnote_format, &s.endnote_pr, endnotes, "w:endnote"), + ("w:footnotePr", settings.footnote_format, &settings.footnote_pr, footnotes, "w:footnote"), + ("w:endnotePr", settings.endnote_format, &settings.endnote_pr, endnotes, "w:endnote"), ] { w.open(tag, &[]); - story::note_pr_inner(&mut w, &wordcraft_doc::section::NoteProps { num_fmt: Some(fmt), ..*pr }); + story::note_pr_inner(w, &wordcraft_doc::section::NoteProps { num_fmt: Some(fmt), ..*pr }); if used { w.empty(el, &[("w:id", "-1")]); w.empty(el, &[("w:id", "0")]); - let seps = if el == "w:footnote" { &s_notes.0 } else { &s_notes.1 }; + let seps = if el == "w:footnote" { separators.0 } else { separators.1 }; if seps.continuation_notice.is_some() { w.empty(el, &[("w:id", "1")]); } } w.close(tag); } +} + +fn write_compatibility_settings(w: &mut W, doc: &Document) { w.open("w:compat", &[]); // CT_Compat: the older switches come before the `w:compatSetting`s. if doc.settings.do_not_expand_shift_return { @@ -126,17 +145,12 @@ pub(super) fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, temp if doc.settings.sum_paragraph_spacing { w.empty("w:doNotUseHTMLParagraphAutoSpacing", &[]); } - if s.allow_space_of_same_style_in_table { + if doc.settings.allow_space_of_same_style_in_table { w.empty("w:allowSpaceOfSameStyleInTable", &[]); } let mode = doc.settings.compat_mode.clamp(11, 15).to_string(); w.empty("w:compatSetting", &[("w:name", "compatibilityMode"), ("w:uri", "http://schemas.microsoft.com/office/word"), ("w:val", &mode)]); w.close("w:compat"); - if let Some(m) = &s.math { - math::math_pr(&mut w, m); - } - w.close("w:settings"); - w.into_bytes() } /// Is `d` a usable `w16cid:durableId`: 1–8 hex digits, below 0x7FFFFFFF, not 0? diff --git a/crates/docx/src/write/story/blocks.rs b/crates/docx/src/write/story/blocks.rs index f85995a831..bdb2fb5427 100644 --- a/crates/docx/src/write/story/blocks.rs +++ b/crates/docx/src/write/story/blocks.rs @@ -1,5 +1,5 @@ use super::toc::written_toc_span; -use super::*; +use super::{Block, Blocks, CharProps, MAX_DEPTH, PartRels, Table, W, Writer, tblpr, tcpr, trpr, twips}; impl Writer<'_> { fn write_trailing_para(&mut self, w: &mut W, depth: usize) { diff --git a/crates/docx/src/write/story/drawing.rs b/crates/docx/src/write/story/drawing.rs index 0fa653bb05..1c819fa66a 100644 --- a/crates/docx/src/write/story/drawing.rs +++ b/crates/docx/src/write/story/drawing.rs @@ -1,5 +1,16 @@ use super::object::ink_name; -use super::*; +use super::{ + AltText, Anchor, Fill, Float, GradientFlip, GradientKind, InlineObject, PartRels, PathShape, Rgb, ShapeEffects, ShapeKind, W, Wrap, Writer, emu, + n, rt, +}; + +pub(super) struct DrawingInfo<'a> { + pub float: &'a Float, + pub size: (f32, f32), + pub docpr: &'a str, + pub name: &'a str, + pub alt: &'a str, +} impl Writer<'_> { /// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0). @@ -198,28 +209,19 @@ impl Writer<'_> { w.close("wps:wsp"); } - #[allow(clippy::too_many_arguments)] /// A `w:drawing` holding `graphic` (an `a:graphic`) in a frame of the object's size and place. - #[allow(clippy::too_many_arguments)] - pub(super) fn graphic_frame( - &mut self, - w: &mut W, - float: &Float, - (gw, gh): (f32, f32), - docpr: &str, - name: &str, - alt: &str, - graphic: &crate::xml::El, - ) { + pub(super) fn graphic_frame(&mut self, w: &mut W, info: DrawingInfo<'_>, graphic: &crate::xml::El) { + let inline = info.float.wrap == Wrap::Inline; w.open("w:drawing", &[]); - self.drawing_open(w, float, gw, gh, docpr, name, alt); + self.drawing_open(w, info); w.empty("wp:cNvGraphicFramePr", &[]); w.raw(&graphic.to_xml()); - w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); + w.close(if inline { "wp:inline" } else { "wp:anchor" }); w.close("w:drawing"); } - pub(super) fn drawing_open(&mut self, w: &mut W, float: &Float, iw: f32, ih: f32, docpr: &str, name: &str, alt: &str) { + pub(super) fn drawing_open(&mut self, w: &mut W, info: DrawingInfo<'_>) { + let DrawingInfo { float, size: (iw, ih), docpr, name, alt } = info; let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0))); if float.wrap == Wrap::Inline { w.open("wp:inline", &[("distT", "0"), ("distB", "0"), ("distL", "0"), ("distR", "0")]); diff --git a/crates/docx/src/write/story/field.rs b/crates/docx/src/write/story/field.rs index 7aecffc74e..b1eec8116e 100644 --- a/crates/docx/src/write/story/field.rs +++ b/crates/docx/src/write/story/field.rs @@ -1,8 +1,17 @@ use super::toc::is_toc; -use super::*; +use super::{CharProps, W, Writer, rpr}; + +pub(super) struct FieldData<'a> { + pub instr: &'a str, + pub result: &'a str, + pub locked: bool, + pub dirty: bool, + pub form: &'a str, +} impl Writer<'_> { - pub(super) fn field(&mut self, w: &mut W, instr: &str, result: &str, locked: bool, dirty: bool, form: &str, props: &CharProps) { + pub(super) fn field(&mut self, w: &mut W, field: FieldData<'_>, props: &CharProps) { + let FieldData { instr, result, locked, dirty, form } = field; if self.toc_hold_end && is_toc(instr) { // A TOC heading's field opens in the first entry and closes after the last (see `toc_span`). self.toc_hold_end = false; diff --git a/crates/docx/src/write/story/object.rs b/crates/docx/src/write/story/object.rs index 0fc089f07d..340ca8fce3 100644 --- a/crates/docx/src/write/story/object.rs +++ b/crates/docx/src/write/story/object.rs @@ -1,7 +1,7 @@ -use super::drawing::{shape_name, spin_attrs, write_fill, xfrm_spin_only}; -use super::field::field_run; +use super::drawing::{DrawingInfo, shape_name, spin_attrs, write_fill, xfrm_spin_only}; +use super::field::{FieldData, field_run}; use super::section::comment_reference_style; -use super::*; +use super::{CharProps, Fill, InlineObject, NoteKind, PartRels, TextColor, W, Wrap, Writer, emu, n, rpr}; impl Writer<'_> { pub(super) fn object(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) { @@ -56,7 +56,9 @@ impl Writer<'_> { None => crate::write::math::write_equation(w, linear, *display, math, color), } } - InlineObject::Field { instr, result, locked, dirty, form, .. } => self.field(w, instr, result, *locked, *dirty, form, props), + InlineObject::Field { instr, result, locked, dirty, form, .. } => { + self.field(w, FieldData { instr, result, locked: *locked, dirty: *dirty, form }, props) + } InlineObject::FieldStart { instr, locked, dirty } => { self.rev_open(w, props); self.field_start(w, instr, *locked, *dirty, "", props); @@ -111,223 +113,10 @@ impl Writer<'_> { } // Editable charts and SmartArt keep main's model writer; opaque graphics and native // ink keep their original source and related parts. - InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } => { - let ink = graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink; - let root = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) { - (Some(spec), _, _) => crate::xml::parse(self.chart_graphic(spec, rels).as_bytes()).ok(), - (None, Some(spec), _) => crate::xml::parse(self.smart_art_graphic(spec, *gw, *gh, rels).as_bytes()).ok(), - // Ink in a group member's form (no drawing of its own) can't stand alone: left - // out before its parts are queued, so no orphan part or relationship is written. - (None, None, Some(src)) if ink && !standalone_ink(src) => { - log::warn!("docx: group-member ink outside its group can't be written; it is left out"); - None - } - (None, None, Some(src)) => self.embedded_tree(src, rels, ink.then_some((*gw, *gh, float, alt.as_str()))), - (None, None, None) => None, - }; - let Some(mut root) = root else { return }; - let direct_ink = ink && root.name == "w:contentPart"; - if ink && !direct_ink { - Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0); - } - // Native ink, or an Office 2016 chart (`cx:chart`) with its fallback picture. - let alternate = (root.name == "mc:AlternateContent").then_some(&root); - let fallback = alternate.and_then(|e| e.child("mc:Fallback")); - let choice = alternate.and_then(|e| e.child("mc:Choice")); - let native = choice.and_then(|e| e.find("a:graphic")); - if alternate.is_some() && native.is_none() { - return; - } - // A chart's picture gets the chart's current frame too; ink's VML preview is resized in place. - let picture = if ink { None } else { fallback.and_then(|e| e.find("a:graphic")) }; - self.rev_open(w, props); - w.open("w:r", &[]); - self.rpr(w, props); - if direct_ink { - w.raw(&root.to_xml()); - } else { - if fallback.is_some() { - let requires = if ink { "wpi" } else { choice.and_then(|c| c.attr("Requires")).unwrap_or_default() }; - w.open("mc:AlternateContent", &[]); - w.open("mc:Choice", &[("Requires", requires)]); - } - let docpr = self.next_docpr(); - let name = match graphic.kind { - wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"), - wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"), - wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"), - }; - let z = self.z; - self.graphic_frame(w, float, (*gw, *gh), &docpr, &name, alt, native.unwrap_or(&root)); - if let Some(fallback) = fallback { - w.close("mc:Choice"); - match picture { - Some(picture) => { - // Both branches are one object: same id and stacking order. - self.z = z; - w.open("mc:Fallback", &[]); - self.graphic_frame(w, float, (*gw, *gh), &docpr, &name, alt, picture); - w.close("mc:Fallback"); - } - None => w.raw(&fallback.to_xml()), - } - w.close("mc:AlternateContent"); - } - } - w.close("w:r"); - self.rev_close(w, props); - } - InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } => { - // An OLE object read from a file: the object itself, at its current size. - if let Some(src) = ole.as_deref() - && let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str()))) - { - self.rev_open(w, props); - w.open("w:r", &[]); - self.rpr(w, props); - w.raw(&obj); - w.close("w:r"); - self.rev_close(w, props); - return; - } - let Some(file) = self.media_files.get(media).cloned() else { return }; - self.rev_open(w, props); - w.open("w:r", &[]); - self.rpr(w, props); - w.open("w:drawing", &[]); - let docpr = self.next_docpr(); - let name = format!("Picture {docpr}"); - self.drawing_open(w, float, *iw, *ih, &docpr, &name, alt); - w.empty("wp:cNvGraphicFramePr", &[]); - w.open("a:graphic", &[]); - w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]); - self.pic(w, o, &file, (0.0, 0.0), rels); - w.close("a:graphicData"); - w.close("a:graphic"); - w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); - w.close("w:drawing"); - w.close("w:r"); - self.rev_close(w, props); - } - InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, extra, .. } => { - self.rev_open(w, props); - w.open("w:r", &[]); - self.rpr(w, props); - w.open("w:drawing", &[]); - let docpr = self.shape_docpr(extra.id); - // Ink is told apart by its name (and its pen), which reading looks for. - let name = shape_name(*kind, freeform.as_deref(), &docpr); - // The effect extent leaves room for the shadow and glow. - let mut float = float.clone(); - for (e, fx) in float.effect.iter_mut().zip(effects.extent()) { - *e = wordcraft_geom::finite(*e).max(fx); - } - // Tight and Through wrapping need a polygon: the shape's outline unless it has one. - if matches!(float.wrap, Wrap::Tight | Wrap::Through) && float.wrap_polygon.is_none() { - float.wrap_polygon = wordcraft_doc::wrap::shape_outline(*kind, *sw, *sh, extra.corner) - .and_then(|pts| wordcraft_doc::wrap::WrapPolygon::from_unit(false, &pts)) - .map(std::sync::Arc::new); - } - let float = &float; - self.drawing_open(w, float, *sw, *sh, &docpr, &name, o.alt_text()); - w.empty("wp:cNvGraphicFramePr", &[]); - w.open("a:graphic", &[]); - w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]); - self.wsp(w, o, None, (0.0, 0.0), rels, depth); - w.close("a:graphicData"); - w.close("a:graphic"); - w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); - w.close("w:drawing"); - w.close("w:r"); - self.rev_close(w, props); - } - InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children, canvas } => { - self.rev_open(w, props); - w.open("w:r", &[]); - self.rpr(w, props); - w.open("w:drawing", &[]); - let docpr = self.next_docpr(); - let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" }); - self.drawing_open(w, float, *gw, *gh, &docpr, &name, o.alt_text()); - w.empty("wp:cNvGraphicFramePr", &[]); - w.open("a:graphic", &[]); - let outer = match canvas { - Some(c) => { - // A Drawing Canvas: its background (`wpc:bg`) and outline (`wpc:whole`), then - // its members, at offsets in the canvas's own (unscaled) space. - w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas")]); - w.open("wpc:wpc", &[]); - w.open("wpc:bg", &[]); - write_fill(w, c.fill.as_ref()); - w.close("wpc:bg"); - w.open("wpc:whole", &[]); - match c.stroke { - Some(_) => w.open("a:ln", &[("w", &emu(c.stroke_width.clamp(0.0, 100.0)))]), - None => w.open("a:ln", &[]), - } - write_fill(w, c.stroke.map(Fill::solid).as_ref()); - w.close("a:ln"); - w.close("wpc:whole"); - "wpc:wpc" - } - None => { - w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup")]); - // ECMA-376 §20.1.7.6 (`a:xfrm` of a group): the members' space `a:chOff`/`a:chExt` - // maps onto the group's `a:off`/`a:ext`. - w.open("wpg:wgp", &[]); - w.empty("wpg:cNvGrpSpPr", &[]); - w.open("wpg:grpSpPr", &[]); - w.open("a:xfrm", &spin_attrs(float.spin()).iter().map(|(k, v)| (*k, v.as_str())).collect::>()); - w.empty("a:off", &[("x", "0"), ("y", "0")]); - w.empty("a:ext", &[("cx", &emu(gw.max(0.0))), ("cy", &emu(gh.max(0.0)))]); - w.empty("a:chOff", &[("x", "0"), ("y", "0")]); - w.empty("a:chExt", &[("cx", &emu(ch_w.max(0.0))), ("cy", &emu(ch_h.max(0.0)))]); - w.close("a:xfrm"); - w.close("wpg:grpSpPr"); - "wpg:wgp" - } - }; - for c in children.iter().take(wordcraft_doc::para::MAX_GROUP_CHILDREN) { - let at = (c.x, c.y); - match &c.obj { - InlineObject::Image { media, .. } => { - if let Some(file) = self.media_files.get(media).cloned() { - self.pic(w, &c.obj, &file, at, rels); - } - } - InlineObject::Shape { extra, .. } => { - let id = self.shape_docpr(extra.id); - self.wsp(w, &c.obj, Some(&id), at, rels, depth); - } - // Native ink kept from the file, with its fallback, at the member's place. - InlineObject::Graphic { w: iw, h: ih, float, graphic, .. } if graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink => { - if let Some(src) = graphic.source.as_deref() - && let Some(mut root) = self.embedded_tree(src, rels, None) - { - Self::place_member(&mut root, [c.x, c.y, *iw, *ih], float, 0); - w.raw(&root.to_xml()); - } - } - _ => {} - } - } - if canvas.is_some() && float.spin() != wordcraft_geom::Spin::default() { - // The canvas schema has no transform of its own: keep its turn in our - // extension (Word draws it unturned, as it can't turn a canvas). - w.open("wpc:extLst", &[]); - w.open("a:ext", &[("uri", crate::CANVAS_SPIN_EXT)]); - xfrm_spin_only(w, float.spin()); - w.close("a:ext"); - w.close("wpc:extLst"); - } - w.close(outer); - w.close("a:graphicData"); - w.close("a:graphic"); - w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); - w.close("w:drawing"); - w.close("w:r"); - self.rev_close(w, props); - } + InlineObject::Graphic { .. } => self.write_graphic(w, o, props, rels, depth), + InlineObject::Image { .. } => self.write_image(w, o, props, rels, depth), + InlineObject::Shape { .. } => self.write_shape(w, o, props, rels, depth), + InlineObject::Group { .. } => self.write_group(w, o, props, rels, depth), InlineObject::Ruby { base, ruby, align, size, raise, base_size, lang, props: ruby_props } => { // ECMA-376 §17.3.3.25: a run holding w:ruby (properties, ruby text, base text). let t = if self.is_del(props) { "w:delText" } else { "w:t" }; @@ -385,6 +174,231 @@ impl Writer<'_> { } } } + + fn write_graphic(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, _depth: usize) { + let InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } = o else { return }; + let ink = graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink; + let root = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) { + (Some(spec), _, _) => crate::xml::parse(self.chart_graphic(spec, rels).as_bytes()).ok(), + (None, Some(spec), _) => crate::xml::parse(self.smart_art_graphic(spec, *gw, *gh, rels).as_bytes()).ok(), + // Ink in a group member's form (no drawing of its own) can't stand alone: left + // out before its parts are queued, so no orphan part or relationship is written. + (None, None, Some(src)) if ink && !standalone_ink(src) => { + log::warn!("docx: group-member ink outside its group can't be written; it is left out"); + None + } + (None, None, Some(src)) => self.embedded_tree(src, rels, ink.then_some((*gw, *gh, float, alt.as_str()))), + (None, None, None) => None, + }; + let Some(mut root) = root else { return }; + let direct_ink = ink && root.name == "w:contentPart"; + if ink && !direct_ink { + Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0); + } + // Native ink, or an Office 2016 chart (`cx:chart`) with its fallback picture. + let alternate = (root.name == "mc:AlternateContent").then_some(&root); + let fallback = alternate.and_then(|e| e.child("mc:Fallback")); + let choice = alternate.and_then(|e| e.child("mc:Choice")); + let native = choice.and_then(|e| e.find("a:graphic")); + if alternate.is_some() && native.is_none() { + return; + } + // A chart's picture gets the chart's current frame too; ink's VML preview is resized in place. + let picture = if ink { None } else { fallback.and_then(|e| e.find("a:graphic")) }; + self.rev_open(w, props); + w.open("w:r", &[]); + self.rpr(w, props); + if direct_ink { + w.raw(&root.to_xml()); + } else { + if fallback.is_some() { + let requires = if ink { "wpi" } else { choice.and_then(|c| c.attr("Requires")).unwrap_or_default() }; + w.open("mc:AlternateContent", &[]); + w.open("mc:Choice", &[("Requires", requires)]); + } + let docpr = self.next_docpr(); + let name = match graphic.kind { + wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"), + wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"), + wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"), + }; + let z = self.z; + self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, native.unwrap_or(&root)); + if let Some(fallback) = fallback { + w.close("mc:Choice"); + match picture { + Some(picture) => { + // Both branches are one object: same id and stacking order. + self.z = z; + w.open("mc:Fallback", &[]); + self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, picture); + w.close("mc:Fallback"); + } + None => w.raw(&fallback.to_xml()), + } + w.close("mc:AlternateContent"); + } + } + w.close("w:r"); + self.rev_close(w, props); + } + + fn write_image(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, _depth: usize) { + let InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } = o else { return }; + // An OLE object read from a file: the object itself, at its current size. + if let Some(src) = ole.as_deref() + && let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str()))) + { + self.rev_open(w, props); + w.open("w:r", &[]); + self.rpr(w, props); + w.raw(&obj); + w.close("w:r"); + self.rev_close(w, props); + return; + } + let Some(file) = self.media_files.get(media).cloned() else { return }; + self.rev_open(w, props); + w.open("w:r", &[]); + self.rpr(w, props); + w.open("w:drawing", &[]); + let docpr = self.next_docpr(); + let name = format!("Picture {docpr}"); + self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt }); + w.empty("wp:cNvGraphicFramePr", &[]); + w.open("a:graphic", &[]); + w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]); + self.pic(w, o, &file, (0.0, 0.0), rels); + w.close("a:graphicData"); + w.close("a:graphic"); + w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); + w.close("w:drawing"); + w.close("w:r"); + self.rev_close(w, props); + } + + fn write_shape(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) { + let InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, extra, .. } = o else { return }; + self.rev_open(w, props); + w.open("w:r", &[]); + self.rpr(w, props); + w.open("w:drawing", &[]); + let docpr = self.shape_docpr(extra.id); + // Ink is told apart by its name (and its pen), which reading looks for. + let name = shape_name(*kind, freeform.as_deref(), &docpr); + // The effect extent leaves room for the shadow and glow. + let mut float = float.clone(); + for (e, fx) in float.effect.iter_mut().zip(effects.extent()) { + *e = wordcraft_geom::finite(*e).max(fx); + } + // Tight and Through wrapping need a polygon: the shape's outline unless it has one. + if matches!(float.wrap, Wrap::Tight | Wrap::Through) && float.wrap_polygon.is_none() { + float.wrap_polygon = wordcraft_doc::wrap::shape_outline(*kind, *sw, *sh, extra.corner) + .and_then(|pts| wordcraft_doc::wrap::WrapPolygon::from_unit(false, &pts)) + .map(std::sync::Arc::new); + } + let float = &float; + self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text() }); + w.empty("wp:cNvGraphicFramePr", &[]); + w.open("a:graphic", &[]); + w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]); + self.wsp(w, o, None, (0.0, 0.0), rels, depth); + w.close("a:graphicData"); + w.close("a:graphic"); + w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); + w.close("w:drawing"); + w.close("w:r"); + self.rev_close(w, props); + } + + fn write_group(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) { + let InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children, canvas } = o else { return }; + self.rev_open(w, props); + w.open("w:r", &[]); + self.rpr(w, props); + w.open("w:drawing", &[]); + let docpr = self.next_docpr(); + let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" }); + self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text() }); + w.empty("wp:cNvGraphicFramePr", &[]); + w.open("a:graphic", &[]); + let outer = match canvas { + Some(c) => { + // A Drawing Canvas: its background (`wpc:bg`) and outline (`wpc:whole`), then + // its members, at offsets in the canvas's own (unscaled) space. + w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas")]); + w.open("wpc:wpc", &[]); + w.open("wpc:bg", &[]); + write_fill(w, c.fill.as_ref()); + w.close("wpc:bg"); + w.open("wpc:whole", &[]); + match c.stroke { + Some(_) => w.open("a:ln", &[("w", &emu(c.stroke_width.clamp(0.0, 100.0)))]), + None => w.open("a:ln", &[]), + } + write_fill(w, c.stroke.map(Fill::solid).as_ref()); + w.close("a:ln"); + w.close("wpc:whole"); + "wpc:wpc" + } + None => { + w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup")]); + // ECMA-376 §20.1.7.6 (`a:xfrm` of a group): the members' space `a:chOff`/`a:chExt` + // maps onto the group's `a:off`/`a:ext`. + w.open("wpg:wgp", &[]); + w.empty("wpg:cNvGrpSpPr", &[]); + w.open("wpg:grpSpPr", &[]); + w.open("a:xfrm", &spin_attrs(float.spin()).iter().map(|(k, v)| (*k, v.as_str())).collect::>()); + w.empty("a:off", &[("x", "0"), ("y", "0")]); + w.empty("a:ext", &[("cx", &emu(gw.max(0.0))), ("cy", &emu(gh.max(0.0)))]); + w.empty("a:chOff", &[("x", "0"), ("y", "0")]); + w.empty("a:chExt", &[("cx", &emu(ch_w.max(0.0))), ("cy", &emu(ch_h.max(0.0)))]); + w.close("a:xfrm"); + w.close("wpg:grpSpPr"); + "wpg:wgp" + } + }; + for c in children.iter().take(wordcraft_doc::para::MAX_GROUP_CHILDREN) { + let at = (c.x, c.y); + match &c.obj { + InlineObject::Image { media, .. } => { + if let Some(file) = self.media_files.get(media).cloned() { + self.pic(w, &c.obj, &file, at, rels); + } + } + InlineObject::Shape { extra, .. } => { + let id = self.shape_docpr(extra.id); + self.wsp(w, &c.obj, Some(&id), at, rels, depth); + } + // Native ink kept from the file, with its fallback, at the member's place. + InlineObject::Graphic { w: iw, h: ih, float, graphic, .. } if graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink => { + if let Some(src) = graphic.source.as_deref() + && let Some(mut root) = self.embedded_tree(src, rels, None) + { + Self::place_member(&mut root, [c.x, c.y, *iw, *ih], float, 0); + w.raw(&root.to_xml()); + } + } + _ => {} + } + } + if canvas.is_some() && float.spin() != wordcraft_geom::Spin::default() { + // The canvas schema has no transform of its own: keep its turn in our + // extension (Word draws it unturned, as it can't turn a canvas). + w.open("wpc:extLst", &[]); + w.open("a:ext", &[("uri", crate::CANVAS_SPIN_EXT)]); + xfrm_spin_only(w, float.spin()); + w.close("a:ext"); + w.close("wpc:extLst"); + } + w.close(outer); + w.close("a:graphicData"); + w.close("a:graphic"); + w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); + w.close("w:drawing"); + w.close("w:r"); + self.rev_close(w, props); + } } pub(super) fn ink_name(tool: wordcraft_doc::freeform::InkTool) -> &'static str { diff --git a/crates/docx/src/write/story/para.rs b/crates/docx/src/write/story/para.rs index f8c999df70..712a031993 100644 --- a/crates/docx/src/write/story/para.rs +++ b/crates/docx/src/write/story/para.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + ChangeAttrs, CharProps, InlineObject, Paragraph, PartRels, Piece, PropChange, RevisionKind, W, Writer, open_change, ppr_inner, rpr, rpr_inner, rt, +}; impl Writer<'_> { pub(super) fn para(&mut self, w: &mut W, p: &Paragraph, rels: &mut PartRels, top: bool, depth: usize) { diff --git a/crates/docx/src/write/story/section.rs b/crates/docx/src/write/story/section.rs index fdf91ccf6e..e9712ddcc5 100644 --- a/crates/docx/src/write/story/section.rs +++ b/crates/docx/src/write/story/section.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LineNumberRestart, NoteProps, PartRels, SectionProps, SectionStart, W, Writer, borders, n, open_change, twips}; impl Writer<'_> { /// The section's headers, plus the header holding only the watermark in each slot the diff --git a/crates/docx/src/write/story/toc.rs b/crates/docx/src/write/story/toc.rs index 7ecd76b430..47c1041b75 100644 --- a/crates/docx/src/write/story/toc.rs +++ b/crates/docx/src/write/story/toc.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Blocks, InlineObject, W, Writer}; impl Writer<'_> { pub(super) fn update_toc_before_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: bool, w: &mut W) { diff --git a/crates/docx/src/write/styles.rs b/crates/docx/src/write/styles.rs index 31a3246061..e750ba32e0 100644 --- a/crates/docx/src/write/styles.rs +++ b/crates/docx/src/write/styles.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, Level, LevelSuffix, ParaProps, Style, StyleKind, TablePart, VAlign, W, props, xml}; /// Built-in names Word keeps in lower case in styles.xml. fn file_style_name(name: &str) -> String { diff --git a/crates/docx/tests/it/content_controls.rs b/crates/docx/tests/it/content_controls.rs index 7aab1415ed..63b79c4c4f 100644 --- a/crates/docx/tests/it/content_controls.rs +++ b/crates/docx/tests/it/content_controls.rs @@ -109,7 +109,13 @@ fn by_id(d: &Document, id: i64) -> ContentControl { #[test] fn every_kind_reads_with_its_properties() { let d = read(&form()).unwrap(); - let intro = controls(&d).into_iter().find(|c| c.title == "Intro").unwrap(); + assert_intro_control(&d); + assert_control_kinds(&d); + assert_table_controls(&d); +} + +fn assert_intro_control(d: &Document) { + let intro = controls(d).into_iter().find(|c| c.title == "Intro").unwrap(); assert!(intro.block && intro.showing_placeholder); assert_eq!((intro.tag.as_str(), intro.id, intro.lock), ("intro", Some(-1201), ControlLock::SdtLocked)); assert_eq!(intro.placeholder, "DefaultPlaceholder_1"); @@ -119,13 +125,15 @@ fn every_kind_reads_with_its_properties() { let ranges = d.control_ranges(StoryRef::Body); let r = ranges.iter().find(|r| r.control.title == "Intro").unwrap(); assert_eq!(d.control_text(r), "Write the introduction.\nSecond paragraph"); +} - assert_eq!(by_id(&d, 7).kind, ControlKind::Text { multi_line: true }); - assert!(!by_id(&d, 7).block); +fn assert_control_kinds(d: &Document) { + assert_eq!(by_id(d, 7).kind, ControlKind::Text { multi_line: true }); + assert!(!by_id(d, 7).block); assert!( - matches!(by_id(&d, 8).kind, ControlKind::CheckBox { checked: true, checked_char: '\u{2612}', ref checked_font, .. } if checked_font == "Symbola") + matches!(by_id(d, 8).kind, ControlKind::CheckBox { checked: true, checked_char: '\u{2612}', ref checked_font, .. } if checked_font == "Symbola") ); - let dd = by_id(&d, 9); + let dd = by_id(d, 9); assert_eq!(dd.lock, ControlLock::ContentLocked); assert_eq!( dd.kind, @@ -135,14 +143,17 @@ fn every_kind_reads_with_its_properties() { } ); assert!( - matches!(by_id(&d, 10).kind, ControlKind::Date { ref full_date, ref format, .. } if full_date == "2026-10-11T00:00:00Z" && format == "d.M.yyyy") + matches!(by_id(d, 10).kind, ControlKind::Date { ref full_date, ref format, .. } if full_date == "2026-10-11T00:00:00Z" && format == "d.M.yyyy") ); - assert_eq!(by_id(&d, 10).extra.len(), 1, "data binding kept"); - assert_eq!(by_id(&d, 12).kind, ControlKind::RichText); - assert!(matches!(by_id(&d, 13).kind, ControlKind::Gallery { list: false, ref gallery, unique: true, .. } if gallery == "Cover Pages")); - assert!(matches!(by_id(&d, 20).kind, ControlKind::RepeatingSection { ref title, .. } if title == "Items")); - assert_eq!(by_id(&d, 21).kind, ControlKind::RepeatingSectionItem); - assert_eq!(by_id(&d, 23).kind, ControlKind::Picture); + assert_eq!(by_id(d, 10).extra.len(), 1, "data binding kept"); + assert_eq!(by_id(d, 12).kind, ControlKind::RichText); + assert!(matches!(by_id(d, 13).kind, ControlKind::Gallery { list: false, ref gallery, unique: true, .. } if gallery == "Cover Pages")); + assert!(matches!(by_id(d, 20).kind, ControlKind::RepeatingSection { ref title, .. } if title == "Items")); + assert_eq!(by_id(d, 21).kind, ControlKind::RepeatingSectionItem); + assert_eq!(by_id(d, 23).kind, ControlKind::Picture); +} + +fn assert_table_controls(d: &Document) { // Row-level controls sit on the rows, the cell-level one on its cell, the table's on the table. let tables: Vec<_> = d.body.iter().filter_map(|b| b.as_table()).collect(); assert_eq!(tables.len(), 2); diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index a7c382660f..5d9e128fba 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -495,6 +495,12 @@ fn styles_numbering_settings_notes_comments_from_parts() { ("docProps/core.xml", core.as_bytes()), ]); let d = wordcraft_docx::read(&bytes).unwrap(); + assert_imported_styles(&d); + assert_imported_numbering_and_settings(&d); + assert_imported_body_and_metadata(&d); +} + +fn assert_imported_styles(d: &Document) { // Styles: localized default id renamed to Normal; display names mapped. let h = d.styles.get("1").unwrap(); assert_eq!(h.name, "Heading 1"); @@ -511,6 +517,9 @@ fn styles_numbering_settings_notes_comments_from_parts() { assert_eq!(g.header_chr.bold, Some(true)); assert_eq!(d.settings.theme_colors[4], wordcraft_doc::Rgb(0x44, 0x72, 0xC4)); assert_eq!(d.settings.major_font, "Major Face"); +} + +fn assert_imported_numbering_and_settings(d: &Document) { // Numbering. let lv = d.numbering.level(2, 0).unwrap(); assert_eq!((lv.start, lv.text.as_str(), lv.indent, lv.hanging, lv.align), (3, "%1)", 36.0, 18.0, Align::Right)); @@ -525,8 +534,11 @@ fn styles_numbering_settings_notes_comments_from_parts() { // A hostile start is clamped, an unknown position left out. let fp = d.settings.footnote_pr; assert_eq!((fp.pos, fp.num_start, fp.num_restart), (None, Some(1), Some(wordcraft_doc::section::NoteRestart::EachPage))); +} + +fn assert_imported_body_and_metadata(d: &Document) { // Body. - let p = paras(&d); + let p = paras(d); assert_eq!(p[0].props.style.as_deref(), Some("1")); assert_eq!(p[0].props.numbering, Some(wordcraft_doc::props::NumRef { num: 2, level: 1 })); assert_eq!(p[0].props_of_char(4).font.as_deref(), Some("Major Face")); diff --git a/crates/docx/tests/it/roundtrip.rs b/crates/docx/tests/it/roundtrip.rs index 8ba6cc0d61..55a532834d 100644 --- a/crates/docx/tests/it/roundtrip.rs +++ b/crates/docx/tests/it/roundtrip.rs @@ -2262,9 +2262,13 @@ fn bibliography_sources_round_trip() { }); let bytes = wordcraft_docx::write(&d).expect("write"); - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); - // Verify package-root customXml parts exist + assert_bibliography_parts(&bytes); + assert_bibliography_sources_round_trip(&bytes); +} + +fn assert_bibliography_parts(bytes: &[u8]) { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); assert!(zip.by_name("customXml/item1.xml").is_ok()); assert!(zip.by_name("customXml/itemProps1.xml").is_ok()); assert!(zip.by_name("customXml/_rels/item1.xml.rels").is_ok()); @@ -2281,6 +2285,12 @@ fn bibliography_sources_round_trip() { let mut item_xml = String::new(); std::io::Read::read_to_string(&mut zip.by_name("customXml/item1.xml").unwrap(), &mut item_xml).unwrap(); + assert_bibliography_types(&item_xml); + assert_bibliography_people(&item_xml); + assert_bibliography_details(&item_xml); +} + +fn assert_bibliography_types(item_xml: &str) { assert!(item_xml.contains("SelectedStyle=\"\\APA.XSL\""), "{item_xml}"); assert!(item_xml.contains("StyleName=\"APA\""), "{item_xml}"); assert!(item_xml.contains("Book"), "{item_xml}"); @@ -2288,12 +2298,18 @@ fn bibliography_sources_round_trip() { assert!(item_xml.contains("InternetSite"), "{item_xml}"); assert!(item_xml.contains("BookSection"), "{item_xml}"); assert!(item_xml.contains("Misc"), "{item_xml}"); +} + +fn assert_bibliography_people(item_xml: &str) { assert!(item_xml.contains(""), "{item_xml}"); assert!(item_xml.contains("RiveraAlex"), "{item_xml}"); assert!(item_xml.contains("SmithJohnDavid"), "{item_xml}"); assert!(item_xml.contains("Solo"), "{item_xml}"); assert!(item_xml.contains(""), "{item_xml}"); assert!(item_xml.contains("JohnsonRobert"), "{item_xml}"); +} + +fn assert_bibliography_details(item_xml: &str) { assert!(item_xml.contains("2nd"), "{item_xml}"); assert!(item_xml.contains("10"), "{item_xml}"); assert!(item_xml.contains("15"), "{item_xml}"); @@ -2303,50 +2319,68 @@ fn bibliography_sources_round_trip() { assert!(item_xml.contains("05"), "{item_xml}"); assert!(item_xml.contains("15"), "{item_xml}"); assert!(item_xml.contains("2024-05-15"), "{item_xml}"); +} - let back = wordcraft_docx::read(&bytes).expect("read"); +fn assert_bibliography_sources_round_trip(bytes: &[u8]) { + let back = wordcraft_docx::read(bytes).expect("read"); assert_eq!(back.sources.len(), 5); - assert_eq!(back.sources[0].tag, "Rivera2021"); - assert_eq!(back.sources[0].kind, "book"); - assert_eq!(back.sources[0].author, "Rivera, Alex; Chen, Mei"); - assert_eq!(back.sources[0].title, "Shared Spaces"); - assert_eq!(back.sources[0].year, "2021"); - assert_eq!(back.sources[0].publisher, "Harbor Books"); - assert_eq!(back.sources[0].city, "Portland"); - assert_eq!(back.sources[0].editor, "Johnson, Robert"); - assert_eq!(back.sources[0].edition, "2nd"); - assert_eq!(back.sources[0].month, "10"); - assert_eq!(back.sources[0].day, "15"); + assert_bibliography_source_0(&back.sources[0]); + assert_bibliography_source_1(&back.sources[1]); + assert_bibliography_source_2(&back.sources[2]); + assert_bibliography_source_3(&back.sources[3]); + assert_bibliography_source_4(&back.sources[4]); +} - assert_eq!(back.sources[1].tag, "Smith2023"); - assert_eq!(back.sources[1].kind, "article"); - assert_eq!(back.sources[1].author, "Smith, John David"); - assert_eq!(back.sources[1].title, "Modern Typography"); - assert_eq!(back.sources[1].journal, "Design Quarterly"); - assert_eq!(back.sources[1].volume, "12"); - assert_eq!(back.sources[1].pages, "45-60"); - assert_eq!(back.sources[1].url, "https://example.com/typo"); - assert_eq!(back.sources[1].issue, "4"); - assert_eq!(back.sources[1].doi, "10.1000/182"); +fn assert_bibliography_source_0(source: &Source) { + assert_eq!(source.tag, "Rivera2021"); + assert_eq!(source.kind, "book"); + assert_eq!(source.author, "Rivera, Alex; Chen, Mei"); + assert_eq!(source.title, "Shared Spaces"); + assert_eq!(source.year, "2021"); + assert_eq!(source.publisher, "Harbor Books"); + assert_eq!(source.city, "Portland"); + assert_eq!(source.editor, "Johnson, Robert"); + assert_eq!(source.edition, "2nd"); + assert_eq!(source.month, "10"); + assert_eq!(source.day, "15"); +} - assert_eq!(back.sources[2].tag, "WebSite2024"); - assert_eq!(back.sources[2].kind, "website"); - assert_eq!(back.sources[2].author, "Solo"); - assert_eq!(back.sources[2].title, "Open Fonts"); - assert_eq!(back.sources[2].url, "https://example.org"); - assert_eq!(back.sources[2].accessed, "2024-05-15"); +fn assert_bibliography_source_1(source: &Source) { + assert_eq!(source.tag, "Smith2023"); + assert_eq!(source.kind, "article"); + assert_eq!(source.author, "Smith, John David"); + assert_eq!(source.title, "Modern Typography"); + assert_eq!(source.journal, "Design Quarterly"); + assert_eq!(source.volume, "12"); + assert_eq!(source.pages, "45-60"); + assert_eq!(source.url, "https://example.com/typo"); + assert_eq!(source.issue, "4"); + assert_eq!(source.doi, "10.1000/182"); +} - assert_eq!(back.sources[3].tag, "Chapter2025"); - assert_eq!(back.sources[3].kind, "bookSection"); - assert_eq!(back.sources[3].author, "Lee, Harper"); - assert_eq!(back.sources[3].title, "Chapter One"); - assert_eq!(back.sources[3].journal, "Anthology of Prose"); - assert_eq!(back.sources[3].pages, "1-20"); +fn assert_bibliography_source_2(source: &Source) { + assert_eq!(source.tag, "WebSite2024"); + assert_eq!(source.kind, "website"); + assert_eq!(source.author, "Solo"); + assert_eq!(source.title, "Open Fonts"); + assert_eq!(source.url, "https://example.org"); + assert_eq!(source.accessed, "2024-05-15"); +} - assert_eq!(back.sources[4].tag, "Misc2026"); - assert_eq!(back.sources[4].kind, "other"); - assert_eq!(back.sources[4].title, "Archive Manuscript"); - assert_eq!(back.sources[4].city, "Oxford"); +fn assert_bibliography_source_3(source: &Source) { + assert_eq!(source.tag, "Chapter2025"); + assert_eq!(source.kind, "bookSection"); + assert_eq!(source.author, "Lee, Harper"); + assert_eq!(source.title, "Chapter One"); + assert_eq!(source.journal, "Anthology of Prose"); + assert_eq!(source.pages, "1-20"); +} + +fn assert_bibliography_source_4(source: &Source) { + assert_eq!(source.tag, "Misc2026"); + assert_eq!(source.kind, "other"); + assert_eq!(source.title, "Archive Manuscript"); + assert_eq!(source.city, "Oxford"); } #[test] From a2435388e542cb0a450e493a33b3e70168533574 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 01:06:48 +0000 Subject: [PATCH 23/94] Refactor engine for strict Clippy checks --- crates/engine/src/cmd/citations/cross_refs.rs | 2 +- crates/engine/src/cmd/citations/generated.rs | 5 +- crates/engine/src/cmd/citations/render.rs | 2 +- crates/engine/src/cmd/citations/sources.rs | 2 +- crates/engine/src/cmd/column.rs | 3 +- crates/engine/src/cmd/fmt_revisions.rs | 31 +- crates/engine/src/cmd/insert/header_footer.rs | 5 +- crates/engine/src/cmd/insert/items.rs | 5 +- crates/engine/src/cmd/insert/links.rs | 2 +- crates/engine/src/cmd/insert/objects.rs | 5 +- crates/engine/src/cmd/mailings/filter.rs | 2 +- crates/engine/src/cmd/mailings/labels.rs | 5 +- crates/engine/src/cmd/mailings/merge.rs | 5 +- crates/engine/src/cmd/mailings/recipients.rs | 2 +- crates/engine/src/cmd/objects/arrange.rs | 2 +- crates/engine/src/cmd/objects/group.rs | 2 +- crates/engine/src/cmd/objects/picture.rs | 2 +- crates/engine/src/cmd/objects/select.rs | 2 +- crates/engine/src/cmd/objects/shape.rs | 2 +- crates/engine/src/cmd/objects/specs.rs | 2 +- crates/engine/src/cmd/para/format.rs | 2 +- crates/engine/src/cmd/para/lists.rs | 2 +- crates/engine/src/cmd/para/outline.rs | 2 +- crates/engine/src/cmd/para/styles.rs | 2 +- crates/engine/src/cmd/para/tabs_borders.rs | 2 +- crates/engine/src/cmd/references/fields.rs | 5 +- crates/engine/src/cmd/references/notes.rs | 5 +- .../engine/src/cmd/references/properties.rs | 2 +- crates/engine/src/cmd/references/toc.rs | 5 +- crates/engine/src/cmd/review/changes.rs | 13 +- crates/engine/src/cmd/review/comments.rs | 5 +- crates/engine/src/cmd/review/proofing.rs | 2 +- crates/engine/src/cmd/table/cells.rs | 2 +- crates/engine/src/cmd/table/clear.rs | 2 +- crates/engine/src/cmd/table/convert.rs | 5 +- crates/engine/src/cmd/table/layout.rs | 5 +- crates/engine/src/cmd/table/select.rs | 2 +- crates/engine/src/cmd/table/structure.rs | 2 +- crates/engine/src/cmd/text/autoformat.rs | 5 +- crates/engine/src/cmd/text/delete.rs | 2 +- crates/engine/src/cmd/text/enter_tab.rs | 2 +- crates/engine/src/cmd/text/unicode.rs | 2 +- crates/engine/src/cmd/tools/actions.rs | 2 +- crates/engine/src/cmd/tools/autocorrect.rs | 2 +- crates/engine/src/cmd/tools/compare.rs | 4 +- crates/engine/src/cmd/tools/inspect.rs | 2 +- crates/engine/src/cmd/tools/specs.rs | 5 +- crates/engine/src/cmd/tools/typing.rs | 2 +- crates/engine/src/session/history.rs | 2 +- crates/engine/src/session/ime.rs | 2 +- crates/engine/src/session/layout.rs | 2 +- crates/engine/src/session/lifecycle.rs | 2 +- crates/engine/src/session/query.rs | 2 +- crates/engine/src/session/run.rs | 302 +++++++++++------- crates/engine/src/session/snapshot.rs | 2 +- crates/engine/src/tests.rs | 15 +- .../engine/tests/it/three_column_headers.rs | 120 ++++--- 57 files changed, 378 insertions(+), 252 deletions(-) diff --git a/crates/engine/src/cmd/citations/cross_refs.rs b/crates/engine/src/cmd/citations/cross_refs.rs index f465045779..64967f0bd7 100644 --- a/crates/engine/src/cmd/citations/cross_refs.rs +++ b/crates/engine/src/cmd/citations/cross_refs.rs @@ -1,5 +1,5 @@ use super::generated::page_of; -use super::*; +use super::{CharProps, CmdError, CmdResult, InlineObject, Paragraph, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p}; pub(super) fn cross_ref(s: &mut Session, v: &Value) -> CmdResult { let to = p::str(v, "to").unwrap_or("heading"); diff --git a/crates/engine/src/cmd/citations/generated.rs b/crates/engine/src/cmd/citations/generated.rs index 2f9e580d62..f0946e10b2 100644 --- a/crates/engine/src/cmd/citations/generated.rs +++ b/crates/engine/src/cmd/citations/generated.rs @@ -2,7 +2,10 @@ use super::cross_refs::caption_label; use super::render::entry; use super::sources::bib_order; use super::styles::style; -use super::*; +use super::{ + Block, CharProps, CmdError, CmdResult, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader, TabStop, + Value, delete_selection, heading_letter, p, sel_result, sort_key, +}; /// The TOC field for `references.tableOfFigures` (ECMA-376 §17.16.5.68): `\c "