diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index df0ed55..bb145b6 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -85,6 +85,16 @@ fn is_range_instr(instr: &str) -> bool { instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN")) } +/// The last paragraph of a block list: its last block, or inside a final table the last +/// paragraph of its last cell (nested tables to [`MAX_TABLE_DEPTH`]). +fn last_para_mut(out: &mut Blocks, depth: usize) -> Option<&mut Paragraph> { + match Arc::make_mut(out.last_mut()?) { + Block::Para(p) => Some(p), + Block::Table(t) if depth < MAX_TABLE_DEPTH => last_para_mut(&mut t.rows.last_mut()?.cells.last_mut()?.blocks, depth + 1), + Block::Table(_) => None, + } +} + /// The field that buffers content now: the innermost one that isn't a range field, unless it /// has spilled (then content goes straight into the paragraph). fn sink(sc: &mut StoryCtx) -> Option<&mut FieldState> { @@ -250,12 +260,13 @@ impl Reader<'_> { out.extend(inner); } - /// Markers left over at the end of a story go to its last paragraph. + /// Markers left over at the end of a story go to its last paragraph (after a final table: + /// the last paragraph of its last cell, so a bookmark around the table keeps its end). pub fn flush_pending(&mut self, sc: &mut StoryCtx, out: &mut Blocks) { if sc.pending.is_empty() { return; } - if let Some(Block::Para(p)) = out.last_mut().map(Arc::make_mut) { + if let Some(p) = last_para_mut(out, 0) { for o in sc.pending.drain(..) { let props = p.runs.last().map(|r| r.props.clone()).unwrap_or_default(); p.text.push(wordcraft_doc::para::OBJ); @@ -768,6 +779,20 @@ impl Reader<'_> { self.emit_obj(sc, pb, InlineObject::FieldEnd, &f.props); return; } + // A cached result holding a picture, chart or shape (`INCLUDEPICTURE`'s embedded + // picture) can't be a text result: keep the field as a range around it so the picture + // stays in the document and is saved again. The linked source is never read. + if !nested_in_instr && f.buf.iter().any(|(it, _)| matches!(it, Item::Obj(o) if o.frame().is_some())) { + self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked }, &f.props); + for (it, p) in f.buf { + match it { + Item::Text(t) => self.emit_text(sc, pb, &t, &p), + Item::Obj(o) => self.emit_obj(sc, pb, o, &p), + } + } + self.emit_obj(sc, pb, InlineObject::FieldEnd, &f.props); + return; + } let mut result = String::new(); for (it, _) in &f.buf { match it { diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 0735301..8d454eb 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1174,3 +1174,66 @@ fn note_separators_and_continuation_notice_round_trip() { assert!(settings.contains(r#""#), "{settings}"); check(&wordcraft_docx::read(&out).unwrap()); } + +/// #464: a bookmark whose end follows the document's final table keeps its end (it goes into +/// the table's last cell) and is saved as a matched pair. +#[test] +fn bookmark_around_final_table_keeps_its_end() { + let body = r#"Cell"#; + let d = read_body(body); + let marks = |d: &Document| { + let Block::Table(t) = &*d.body[0] else { panic!("{:?}", d.body[0]) }; + t.rows[0].cells[0].blocks[0].as_para().unwrap().objects.clone() + }; + let want = vec![InlineObject::BookmarkStart { name: "TableRange".into() }, InlineObject::BookmarkEnd { name: "TableRange".into() }]; + assert_eq!(marks(&d), want); + let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!(marks(&again), want); + assert_eq!(again.bookmarks().len(), 1); +} + +/// #465: a field whose cached result is an embedded picture (`INCLUDEPICTURE`, simple, complex or +/// locked) keeps the picture through reading and saving; the linked file is never read. +#[test] +fn includepicture_keeps_its_embedded_picture() { + let png: &[u8] = b"\x89PNG\r\n\x1a\nstand-in"; + let pic = r#""#; + let cases = [ + (format!(r#"{pic}"#), false), + (format!(r#"{pic}"#), true), + ( + format!( + r#" INCLUDEPICTURE "picture.png" {pic}"# + ), + false, + ), + ]; + for (field, locked) in cases { + let bytes = docx(&format!("{field}"), &[("rIdPic", "image", "media/picture.png")], &[]); + let bytes = { + // `docx` takes text parts; add the picture bytes as a binary part. + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut entries: Vec<(String, Vec)> = Vec::new(); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let mut v = Vec::new(); + std::io::Read::read_to_end(&mut f, &mut v).unwrap(); + entries.push((f.name().to_string(), v)); + } + entries.push(("word/media/picture.png".into(), png.to_vec())); + zip(&entries.iter().map(|(n, v)| (n.as_str(), v.as_slice())).collect::>()) + }; + let check = |d: &Document| { + let p = paras(d); + let objs = &p[0].objects; + assert_eq!(objs.len(), 3, "{objs:?}"); + assert_eq!(objs[0], InlineObject::FieldStart { instr: "INCLUDEPICTURE \"picture.png\"".into(), locked }); + let InlineObject::Image { media, .. } = &objs[1] else { panic!("{objs:?}") }; + assert_eq!(d.media.get(media).unwrap().as_slice(), png); + assert_eq!(objs[2], InlineObject::FieldEnd); + }; + let d = wordcraft_docx::read(&bytes).unwrap(); + check(&d); + check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap()); + } +} diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 344d6ee..3642a38 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -382,6 +382,55 @@ fn has_page_fields(p: &Paragraph) -> bool { }) } +/// Does `p` show a page total, which depends on how many pages the document has? +fn has_total_fields(p: &Paragraph) -> bool { + p.objects.iter().any(|o| match o { + InlineObject::Field { instr, .. } => matches!(fields::field_name(instr).as_str(), "NUMPAGES" | "SECTIONPAGES"), + _ => false, + }) +} + +/// Page totals: of the document, and of each section (by index). +#[derive(Clone, Debug, PartialEq)] +struct Totals { + pages: u32, + sections: Vec, +} + +impl Totals { + /// What a first pass assumes: one page. + fn first_guess(sections: usize) -> Totals { + Totals { pages: 1, sections: vec![1; sections] } + } + /// The totals of laid-out `pages` (a section without a page of its own counts one, as in + /// headers and footers). + fn of(pages: &[Page], sections: usize) -> Totals { + let mut per = vec![0u32; sections]; + for p in pages { + if let Some(n) = per.get_mut(p.section) { + *n = n.saturating_add(1); + } + } + for n in &mut per { + *n = (*n).max(1); + } + Totals { pages: u32::try_from(pages.len()).unwrap_or(u32::MAX).max(1), sections: per } + } + fn section(&self, si: usize) -> u32 { + self.sections.get(si).copied().unwrap_or(1) + } +} + +/// The most extra layout passes for page totals in the body: a total that changes the page +/// count (its digits pushing text to another page) is tried again, this many times at most. +const MAX_TOTAL_PASSES: usize = 3; + +/// A layout pass: finished, or to be done again with the page totals it found. +enum Pass { + Done(DocLayout), + Again(Totals), +} + /// Walk state while paginating. struct Ctx<'a> { doc: &'a Document, @@ -399,6 +448,11 @@ struct Ctx<'a> { chains: textbox::Chains, /// The current section's document grid (body text only: none for headers and footers). grid: para::Grid, + /// Whether body text (or its notes) laid out so far shows a page total (`NUMPAGES`, + /// `SECTIONPAGES`), which is only known once the whole body is paginated. + uses_totals: bool, + /// Whether to note `uses_totals` now (off while laying out headers and footers). + track_totals: bool, } impl Ctx<'_> { @@ -469,6 +523,9 @@ impl Ctx<'_> { label: Option<(String, Level)>, ) -> Arc { let page = if has_page_fields(p) { Some(self.fields.page_key()) } else { None }; + if self.track_totals && !self.uses_totals && has_total_fields(p) { + self.uses_totals = true; + } // Automatic equation numbers count through the document. let eq_here: u32 = p .objects @@ -1385,28 +1442,50 @@ const MAX_NOTE_PASSES: usize = 3; /// Lay out the whole document. pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> DocLayout { let t0 = now_ms(); - let mut pages = HashMap::new(); - let mut l = layout_pass(doc, cache, opts, &pages); + // Page totals shown in the body (`NUMPAGES`, `SECTIONPAGES`) are known only after + // paginating: lay out again with the totals found until they hold (bounded). Footnotes + // numbered again on each page likewise settle over a few more passes. let per_page = opts.view == ViewMode::Print && doc.sections().iter().any(|(_, s)| doc.note_options(s, false).num_restart == wordcraft_doc::section::NoteRestart::EachPage); - if per_page { - // Number each page's footnotes from where the pass before placed them, until they stay. - for _ in 1..MAX_NOTE_PASSES { - let placed = footnote_pages(doc, &l.pages); - if placed == pages { - break; + let mut note_pages = HashMap::new(); + let mut note_passes = 1; + let mut totals = Totals::first_guess(doc.sections().len()); + let mut passes = 0; + let mut l = loop { + match layout_pass(doc, cache, opts, ¬e_pages, &totals, passes < MAX_TOTAL_PASSES) { + Pass::Again(t) => { + totals = t; + passes += 1; + } + Pass::Done(l) => { + if per_page && note_passes < MAX_NOTE_PASSES { + // Number each page's footnotes from where the pass before placed them, until they stay. + let placed = footnote_pages(doc, &l.pages); + if placed != note_pages { + note_pages = placed; + note_passes += 1; + continue; + } + } + break l; } - pages = placed; - l = layout_pass(doc, cache, opts, &pages); } - } + }; l.ms = now_ms() - t0; l } -/// One layout pass, numbering footnotes restarted on each page by `note_pages` ([`note_info`]). -fn layout_pass(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, note_pages: &HashMap) -> DocLayout { - let t0 = now_ms(); +/// One layout pass, numbering footnotes restarted on each page by `note_pages` ([`note_info`]), +/// with `totals` assumed for the page totals in the body. With `may_repeat`, a pass whose body +/// shows totals that turn out different asks to be done again. +fn layout_pass( + doc: &Document, + cache: &mut LayoutCache, + opts: &LayoutOptions, + note_pages: &HashMap, + totals: &Totals, + may_repeat: bool, +) -> Pass { let env = env_hash(doc, opts); if env != cache.env { cache.paras.clear(); @@ -1426,8 +1505,8 @@ fn layout_pass(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, no counters: Counters::default(), fields: FieldCtx { page: 1, - pages: 1, - section_pages: 1, + pages: totals.pages, + section_pages: totals.section(0), section: 1, notes: Arc::new(notes), title: doc.core.title.as_str().into(), @@ -1440,6 +1519,8 @@ fn layout_pass(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, no boxes: wordcraft_doc::BoxBudget::default(), chains: Default::default(), grid: para::Grid::default(), + uses_totals: false, + track_totals: true, }; let sections = doc.sections(); let web = opts.view != ViewMode::Print; @@ -1516,6 +1597,8 @@ fn layout_pass(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, no if !web && !doc.footnote_separators.is_empty() { pb.seps = NoteSeps::new(&mut ctx, &doc.footnote_separators, sect.text_width()); } + ctx.fields.section = u32::try_from(si).unwrap_or(u32::MAX).saturating_add(1); + ctx.fields.section_pages = totals.section(si); // Headers and footers left out are linked to the previous section's, and take its room. let (heads, feet) = (linked(§ions, si, |s| s.headers), linked(§ions, si, |s| s.footers)); let body_top = if web { sect.margin_top } else { body_top_for(&mut ctx, sect, heads.default) }; @@ -1619,7 +1702,14 @@ fn layout_pass(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, no p.h = bottom + 36.0; } ctx.grid = para::Grid::default(); + if !web && ctx.uses_totals && may_repeat { + let found = Totals::of(&pages, sections.len()); + if found != *totals { + return Pass::Again(found); + } + } if !web { + ctx.track_totals = false; headers_footers(&mut ctx, &mut pages, §ions); if doc.settings.mirror_margins { mirror_even_pages(&mut pages, §ions); @@ -1639,7 +1729,7 @@ fn layout_pass(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, no } } } - DocLayout { pages, index, ms: now_ms() - t0, text_boxes: ctx.boxes.total().saturating_sub(boxes_discarded) } + Pass::Done(DocLayout { pages, index, ms: 0.0, text_boxes: ctx.boxes.total().saturating_sub(boxes_discarded) }) } /// Pagination state saved before a block, to lay the blocks from there out again (balancing @@ -1879,7 +1969,9 @@ fn body_top_for(ctx: &mut Ctx, sect: &SectionProps, header: Option) -> f32 let blocks = part.blocks.clone(); // Placed as `headers_footers` draws it, so page-relative floats wrap the same way. let frame = PageFrame { sect, origin: (sect.margin_left + sect.gutter, sect.header) }; + let track = std::mem::replace(&mut ctx.track_totals, false); let h = layout_box(ctx, StoryRef::Part(id), &blocks, &[], sect.text_width(), None, 0, Some(frame)).height; + ctx.track_totals = track; // Word starts the body right below a header that reaches past the top margin, no gap. sect.margin_top.max(sect.header + h) } @@ -2511,6 +2603,7 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl match split { Some((a, b)) => { place(pb, &a); + table_notes(ctx, pb, &a.items); rest = Some(b); } // Like Word, a row that does not fit on an empty page, or under the header rows @@ -2529,10 +2622,39 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl } fresh = true; } - place(pb, rest.as_ref().unwrap_or(row)); + let last = rest.as_ref().unwrap_or(row); + place(pb, last); + table_notes(ctx, pb, &last.items); } } +/// Footnotes referenced in the cell lines of a placed table row (or part of one) go to the +/// bottom of the page, like those of body paragraphs; notes that don't fit there continue on +/// the next page (see [`PageBuilder::flush_notes`]). +fn table_notes(ctx: &mut Ctx, pb: &mut PageBuilder, items: &[Placed]) { + if pb.web { + return; + } + let mut ids: Vec = Vec::new(); + for it in items { + let Placed::Lines { story: StoryRef::Body, para, l0, l1, .. } = it else { continue }; + let (Some(a), Some(b)) = (para.lines.get(*l0), para.lines.get(l1.saturating_sub(1))) else { continue }; + for (ci, id) in ¶.notes { + if *ci >= a.c0 && *ci < b.c1 && !ids.contains(id) && !pb.notes.iter().any(|n| n.id == *id) { + ids.push(*id); + } + } + } + let before = pb.notes_h(); + for id in ids { + let Some(part) = ctx.doc.parts.get(&id).filter(|p| p.kind == wordcraft_doc::PartKind::Footnote) else { continue }; + let blocks = part.blocks.clone(); + let BoxLayout { items, height: h, .. } = layout_box(ctx, StoryRef::Part(id), &blocks, &[], pb.sect.text_width(), None, 0, None); + pb.notes.push(NoteBox { id, items, h, cont: false }); + } + pb.bottom -= pb.notes_h() - before; +} + /// Place floating table `tl` (`w:tblpPr`): at its own position, which takes no room in the text /// flow; the text after it wraps around it. fn place_floating_table(pb: &mut PageBuilder, tl: &table::TableLayout, f: &TableFloat, legacy: f32, block: usize, body_top: f32) { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index ff3aca0..a4b61b3 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -400,7 +400,7 @@ fn right_tab_aligns_text_end() { fn lists_get_labels_and_indent() { let mut d = Document::from_text("one\ntwo\nthree"); let id = d.numbering.add_list(wordcraft_doc::ListKind::Numbered); - d.format_paragraphs(&Pos::body(0, 0), &Pos::body(2, 0), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap(); + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(2, 5), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap(); let l = lay(&d); let labels: Vec = l.pages[0] .items @@ -3542,3 +3542,57 @@ fn endnotes_at_section_end_and_footnotes_below_text() { let text = lay(&d).caret(&Pos::body(1, 0)).unwrap(); assert!(below > text.top + text.height && below < bottom - 100.0, "{below} {bottom} {text:?}"); } + +/// #451: page totals in the body show the document's page count (the stale cached result is +/// ignored), on every page, like the same fields in the footer. +#[test] +fn body_page_totals_count_every_page() { + let mut d = Document::from_text("Body-1\nBody-2\nBody-3"); + let field = |instr: &str| InlineObject::Field { instr: instr.into(), result: "9".into(), locked: false, code: false }; + for i in 0..3 { + let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap(); + p.props.page_break_before = Some(i > 0); + let n = p.len(); + p.insert_object(n, field("NUMPAGES"), &Default::default()).unwrap(); + let n = p.len(); + p.insert_object(n, field("SECTIONPAGES"), &Default::default()).unwrap(); + } + let mut fp = wordcraft_doc::Paragraph::new(); + fp.insert_object(0, field("NUMPAGES"), &Default::default()).unwrap(); + let id = d.add_part(wordcraft_doc::PartKind::Footer, vec![wordcraft_doc::para_block(fp)]); + d.last_section.footers.default = Some(id); + let l = lay(&d); + assert_eq!(l.pages.len(), 3); + let shown = |items: &[Placed]| -> Vec { + items + .iter() + .flat_map(|it| if let Placed::Lines { para, .. } = it { para.shown.iter().map(|s| s.1.clone()).collect() } else { vec![] }) + .collect() + }; + for (i, p) in l.pages.iter().enumerate() { + assert_eq!(shown(&p.items), ["3", "3"], "body of page {}", i + 1); + assert_eq!(shown(&p.footer), ["3"], "footer of page {}", i + 1); + } + // One page: one pass, total 1. + let mut d1 = Document::from_text("Only"); + let p = d1.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.insert_object(4, field("NUMPAGES"), &Default::default()).unwrap(); + assert_eq!(shown(&lay(&d1).pages[0].items), ["1"]); +} + +/// #450: a footnote referenced in a table cell is placed at the bottom of its page, like one in +/// a body paragraph. +#[test] +fn table_cell_footnotes_are_placed() { + let mut d = Document::from_text("After the table."); + d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(wordcraft_doc::Table::new(1, 1, 300.0))).unwrap(); + let cell = Pos { story: StoryRef::Body, path: Path(vec![0, 0, 0, 0]), off: 0 }; + d.para_mut(StoryRef::Body, &cell.path).unwrap().insert_text(0, "Table reference", &Default::default()).unwrap(); + let id = footnote(&mut d, &Pos { off: "Table reference".len(), ..cell }, "Table note payload."); + let l = lay(&d); + assert_eq!(l.pages.len(), 1); + let c = l.caret(&d.start_of(StoryRef::Part(id))).expect("the cell's footnote is placed"); + assert_eq!(c.page, 0); + let body = l.caret(&Pos::body(1, 0)).unwrap(); + assert!(c.top > body.top + 100.0, "at the page bottom: {c:?} {body:?}"); +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 0a9510d..dc35288 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -1165,9 +1165,28 @@ impl Exporter<'_> { } } - /// Where a bookmark is: (output page, x, y). + /// Where a bookmark is: (output page, x, y) — the page and line its start lands on, which + /// in a paragraph split across pages need not be the paragraph's first page. fn bookmark(&self, name: &str) -> Option<(usize, f32, f32)> { let (_, pos) = self.doc.bookmarks().into_iter().find(|(n, _)| n == name)?; + if let Some(c) = self.lay.caret(&pos) + && let Some(out) = self.out_index.get(&c.page) + { + // The left edge of the paragraph's piece on that page, at the top of the line. + let x = self + .lay + .index + .get(&(pos.story, pos.path.clone())) + .into_iter() + .flatten() + .filter(|(pi, _)| *pi == c.page) + .find_map(|(pi, ii)| match self.lay.pages.get(*pi).and_then(|p| p.items.get(*ii)) { + Some(Placed::Lines { x, .. }) => Some(*x), + _ => None, + }) + .unwrap_or(c.x); + return Some((*out, x, c.top)); + } self.locate(pos.story, &pos.path) } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index e75544c..7d50568 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -764,3 +764,37 @@ fn cosmetic_metafile_pens_are_thin_but_visible() { let raw = String::from_utf8_lossy(&bytes); assert!(raw.contains("0.25 w"), "the hairline is a quarter point wide"); } + +/// #455: a link to a bookmark after a page break inside its paragraph goes to the page the +/// bookmark is on, not the paragraph's first page. +#[test] +fn bookmark_destination_is_the_page_of_its_start() { + let mut d = Document::new(); + let mut p = Paragraph::with_text("Paragraph start\u{c}Bookmark target", CharProps::default()); + let at = "Paragraph start\u{c}".len(); + p.insert_object(at, InlineObject::BookmarkStart { name: "target".into() }, &CharProps::default()).unwrap(); + p.insert_object(0, InlineObject::BookmarkStart { name: "first".into() }, &CharProps::default()).unwrap(); + d.body = vec![para_block(p)]; + let lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + assert_eq!(lay.pages.len(), 2); + let pages: Vec = (0..lay.pages.len()).collect(); + let outlined = HashSet::new(); + let ex = Exporter { + doc: &d, + lay: &lay, + opts: &PdfOptions::default(), + out_index: pages.iter().enumerate().map(|(o, p)| (*p, o)).collect(), + fonts: HashMap::new(), + outlined: &outlined, + cmaps: HashMap::new(), + images: HashMap::new(), + vectors: HashMap::new(), + tags: Vec::new(), + tag_index: HashMap::new(), + links: Vec::new(), + }; + let (page, x, y) = ex.bookmark("target").unwrap(); + assert_eq!(page, 1, "the bookmark is on the second page"); + assert!((x - 72.0).abs() < 1.0 && y < 150.0, "{x} {y}"); + assert_eq!(ex.bookmark("first").map(|b| b.0), Some(0)); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ebdf537..2279dcb 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-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Restrict Editing: Word's legacy protection hash verified, unsupported forms refused, #412; previously trivial: Restrict Editing protection, locked styles and exception ranges read and written, #412; 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-11 · **Change:** trivial (bookmark around a final table and `INCLUDEPICTURE` pictures kept, #464 #465; formatting revisions landed, #41; 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; @@ -54,9 +54,9 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Tracked insertions and deletions (`w:ins`/`w:del`), paragraph-mark revisions | ✅ | ✅ | #125, #244 | | Formatting revisions (`w:rPrChange`, `w:pPrChange`, `w:sectPrChange`, `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:numberingChange`) | ✅ | ✅ | #41: kept on open and save, schema order (change element last); changes inside `styles.xml` are dropped. RTF/ODT ignore them | | Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. RTF/ODT/PDF export the result (moved-away text hidden like deletions) | -| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges | -| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297) | +| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465) | | Content controls (`w:sdt`) | ✅ | ✅ | Block, inline, row and cell level, nested; title, tag, id, lock, placeholder, temporary, rich/plain text, `w14:checkbox`, combo box, drop-down list, date, picture, building block gallery, `w15` repeating sections; other `w:sdtPr` children (data binding, appearance) kept verbatim (#381). A TOC's control is rebuilt by the TOC | +| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges | | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | | DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through | | WordArt text effects (`w14:textFill`, `w14:textOutline`, `w14:shadow`, `w14:glow`, `w14:reflection` in `w:rPr`) | ✅ | ✅ | Solid, gradient (linear) and no fill, outline colour/width, shadow, glow, reflection (#416); `w14:scene3d`/`w14:props3d` dropped | @@ -90,6 +90,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | A bookmark (or comment range) ending after the document's last table keeps its end, in the table's last cell (#464); a field whose cached result is a picture (`INCLUDEPICTURE`) is kept as a range field around the picture, so the picture survives save; the linked file is never read (#465) | | 2026-10-11 | trivial | Restrict Editing: Word's legacy password hash (`w:cryptAlgorithmSid`, `w:hash`, `w:salt`, `w:cryptSpinCount`) verified per ECMA-376 Part 4 §14.8.1; unsupported hash forms refused (#412) | | 2026-10-11 | trivial | Restrict Editing: `w:documentProtection` (edit, formatting, password hash), `w:locked` styles and `w:permStart`/`w:permEnd` exceptions read and written (#412) | | 2026-10-11 | trivial | WordArt text effects (`w14:` run properties), text warps (`a:prstTxWarp`) and connectors with glued ends read and written (#416) | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index eec4141..569244d 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-11 · **Change:** trivial (hyphenation zone, capitals and consecutive-hyphen limit from the document, #407; 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-11 · **Change:** 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, @@ -55,7 +55,7 @@ font can't match Word's metrics. | Bidirectional paragraphs, mixed-direction lines; RTL tables and sections | 🟡 | #207; RTL tables (#362): grid columns from the right edge, start alignment and indent from the right margin, cell margins and left/right borders mirrored, merged and turned cells and nested tables included; RTL sections (#362): newspaper columns right to left with the line between mirrored. Right-hand page gutter (`w:rtlGutter`) missing | 1 | | Equations: display layout, numbering, wrapping long display equations | ✅ | #191; long display equations break at top-level operators (`m:brkBin`, `m:brkBinSub`), manual breaks and `m:alnAt`, continuation lines indented by `m:wrapIndent` or set right (`m:wrapRight`) (#326) | — | | Hidden text excluded from breaking and hyphenation | ✅ | #173 | — | -| Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52 | — | +| Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52; totals in the body need a second pass once the page count is known (#451) | — | | Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | mode 15 behaviour; the ~60 legacy compat options are mostly ignored | 10–20 | | Page-by-page comparison harness against Word | ❌ | Run Word locally on our own synthetic documents, export PDF, compare line breaks; Word output stays under `plan/` (never committed) | 8–12 | @@ -72,6 +72,7 @@ font can't match Word's metrics. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | NUMPAGES/SECTIONPAGES in the body show the real totals (the body is laid out again once the page count is known, at most three extra passes, #451); footnotes referenced in table cells go to the page bottom (#450); PDF links to a bookmark go to the page its start is on, not its paragraph's first page (#455) | | 2026-10-11 | trivial | Mirrored margins on even pages, tall and linked footers/headers push the body, long endnotes continue, caret in continued notes (#452, #453, #454, #456, #457) | | 2026-10-11 | trivial | Hyphenation zone, words in capitals and consecutive-hyphen limit follow the document settings; Manual Hyphenation (#407) | | 2026-10-11 | trivial | Document grid: line and character pitch (#391) |