diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index 1e20db246..dcb956a87 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -24,6 +24,7 @@ pub mod rt { pub const NUMBERING: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering"; pub const SETTINGS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings"; pub const ATTACHED_TEMPLATE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/attachedTemplate"; + pub const FONT_TABLE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable"; pub const THEME: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme"; pub const FOOTNOTES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes"; pub const ENDNOTES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes"; diff --git a/crates/docx/src/read/embed.rs b/crates/docx/src/read/embed.rs index e684dde0f..0fadb2b52 100644 --- a/crates/docx/src/read/embed.rs +++ b/crates/docx/src/read/embed.rs @@ -6,7 +6,8 @@ //! reaches through relationships (chart, embedded workbook, colours and style; diagram data, layout, //! quick style, colours and drawing; OLE embedding and its picture…) as opaque bytes in //! `Document::passthrough`, with their content types and relationships in an -//! [`EmbeddedManifest`](crate::package::EmbeddedManifest). +//! [`EmbeddedManifest`](crate::package::EmbeddedManifest). The font table and the fonts it embeds +//! are kept the same way. use std::sync::Arc; @@ -73,7 +74,7 @@ impl Reader<'_> { /// Keep part `root` and every part it reaches through its relationships. False when `root` /// itself can't be kept. - fn keep_part_tree(&mut self, root: &str) -> bool { + pub(super) fn keep_part_tree(&mut self, root: &str) -> bool { if self.embedded.parts.contains_key(root) { return true; } diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index df8c48b84..e931e9224 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -161,6 +161,10 @@ pub(crate) fn read_opened(pkg: &Package) -> Result { r.pc.minor_font = r.doc.settings.minor_font.clone(); let part = |t: &str| rels.by_type(t).filter(|x| !x.external).map(|x| x.target.clone()); + // The font table and the fonts embedded through it ride along as opaque parts. + if let Some(p) = part(rt::FONT_TABLE) { + r.keep_part_tree(&p); + } if let Some(p) = part(rt::THEME) { r.read_theme(&p); } diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index c76012a60..8fd0c7881 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -29,6 +29,9 @@ const PART_COST: u64 = 4096; /// Where the stories' parts live: every story this writer makes is a part in `word/`. const STORY_PART: &str = "word/document.xml"; +/// The font table kept from a file (read by `Reader::keep_part_tree`). +const FONT_TABLE_PART: &str = "word/fontTable.xml"; + /// The embedded-object parts of a package being written. pub(crate) struct EmbedWriter<'d> { manifest: EmbeddedManifest, @@ -90,6 +93,14 @@ 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) { + rels.add(rt::FONT_TABLE, &relative(STORY_PART, &name), false); + } + } + /// Reserve the names of the media files this writer writes. pub fn reserve_media<'a>(&mut self, files: impl Iterator) { for f in files { diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 4562ca689..bb6fb98bb 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -368,6 +368,8 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result = BTreeMap::new(); for (key, file) in &wr.media_files { diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index e3b95d194..7cf466eba 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1514,6 +1514,33 @@ fn vml_lines_and_rectangles() { assert!(matches!(&objs[1], InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Rgb(255, 0, 0)), stroke: None, w: 30.0, h: 20.0, .. })); } +#[test] +fn embedded_fonts_survive_a_round_trip() { + let table = r#""#; + let table_rels = rels(&[("rId1", "font", "fonts/Roboto-regular.ttf"), ("rId2", "font", "fonts/Roboto-bold.ttf")]); + let bytes = docx( + "Hi", + &[("rId9", "fontTable", "fontTable.xml")], + &[ + ("word/fontTable.xml", table), + ("word/_rels/fontTable.xml.rels", &table_rels), + ("word/fonts/Roboto-regular.ttf", "regular font bytes"), + ("word/fonts/Roboto-bold.ttf", "bold font bytes"), + ], + ); + let out = wordcraft_docx::write(&wordcraft_docx::read(&bytes).unwrap()).unwrap(); + let files = unzip(&out); + assert_eq!(text(&files, "word/fontTable.xml"), table); + assert_eq!(text(&files, "word/fonts/Roboto-regular.ttf"), "regular font bytes"); + assert_eq!(text(&files, "word/fonts/Roboto-bold.ttf"), "bold font bytes"); + let table_rels = text(&files, "word/_rels/fontTable.xml.rels"); + assert!(table_rels.contains(r#"Id="rId1""#) && table_rels.contains("fonts/Roboto-regular.ttf"), "{table_rels}"); + assert!(text(&files, "word/_rels/document.xml.rels").contains("fontTable.xml")); + // A second save keeps them too. + let again = unzip(&wordcraft_docx::write(&wordcraft_docx::read(&out).unwrap()).unwrap()); + assert_eq!(text(&again, "word/fonts/Roboto-bold.ttf"), "bold font bytes"); +} + // ---- native Word ink: opaque preservation, never re-encoded as WordCraft freeforms ---- const INK_NS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk"; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 24f129721..55605bc6d 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-11 · **Change:** 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-10 · **Last updated:** 2026-10-12 · **Change:** trivial (font table and embedded fonts kept on save, #770; previously trivial: native save temporary-file ownership and failure protection; previously trivial: Print Markup: comments in PDF/print/copies, #589; previously trivial: SmartArt written from WordCraft's model, #497; 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; @@ -70,7 +70,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Native Word ink (`w14:contentPart`, InkML) | 🟡 | 🟡 | Drawing-level ink kept opaque with its InkML part, relationship and content type; run-level `mc:AlternateContent` keeps the native branch and VML picture fallback. Shows the preview or a missing-picture box. Repeated saves, copies, resize, rotation and namespace aliases tested with original fixtures. No native stroke rendering, eraser, conversion or replay; direct run-level content parts and ink nested in groups/canvases remain unsupported | | 3D models | ❌ | ❌ | Dropped on read, not preserved on save | | Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) | -| Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not read; attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles | +| Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not used for rendering, but the font table, its relationships and the embedded font parts are written back unchanged (#770); attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles | | Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom XML stores reached from the package/main document preserve opaque data, properties and relationships; binding evaluation remains absent. Custom properties round-trip; bibliography model I/O remains separate (#532 open) | | Section text direction (`w:textDirection` in `w:sectPr`, §17.6.20) | ✅ | ✅ | `tbRl`, `btLr` (and `tbRlV`/`tbLrV`, read as `tbRl`); `lrTbV` and unknown values read as horizontal (#491) | | Ruby (`w:ruby`: `w:rubyPr`, `w:rt`, `w:rubyBase`) | ✅ | ✅ | #288; base and ruby text with their run formatting, alignment, sizes, raise, language | @@ -189,6 +189,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-10 | trivial | Right-to-left tables (`w:bidiVisual`) read and written (#362) | | 2026-10-10 | trivial | Drawing canvas (`wpc:wpc`) read and written (#344); group shapes row brought up to date (#267) | | 2026-10-11 | trivial | Spreadsheets (.xlsx/.xlsm/.ods) read as mail-merge recipient lists (#334) | +| 2026-10-12 | trivial | Font table (`word/fontTable.xml`), its relationships and embedded fonts (`word/fonts/*`) written back unchanged (#770) | | 2026-10-12 | minor | Preserve opaque custom XML stores and related properties; bindings and control support remain separate | | 2026-10-11 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged | | 2026-10-11 | minor | Preserve imported TOC ranges independently of paragraph styles, with explicit evaluation limits |