Keep the font table and embedded fonts when saving a .docx (#770) (#831)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
MarekSpodBiedry
2026-10-12 09:02:02 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent e347ef203e
commit 7fbcd0643a
7 changed files with 51 additions and 4 deletions
+1
View File
@@ -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";
+3 -2
View File
@@ -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;
}
+4
View File
@@ -161,6 +161,10 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
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);
}
+11
View File
@@ -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<Item = &'a String>) {
for f in files {
+2
View File
@@ -368,6 +368,8 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
PartRels::default(),
);
wr.embeds.font_table(&mut rels);
// Media referenced by some drawing (orphan parts would make the package suspect).
let mut media_types: BTreeMap<String, &str> = BTreeMap::new();
for (key, file) in &wr.media_files {
+27
View File
@@ -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#"<w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:font w:name="Roboto"><w:embedRegular r:id="rId1"/><w:embedBold r:id="rId2"/></w:font></w:fonts>"#;
let table_rels = rels(&[("rId1", "font", "fonts/Roboto-regular.ttf"), ("rId2", "font", "fonts/Roboto-bold.ttf")]);
let bytes = docx(
"<w:p><w:r><w:t>Hi</w:t></w:r></w:p>",
&[("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";
+3 -2
View File
@@ -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 |