diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index cfb9265c1..c7487c33d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -48,6 +48,7 @@ use wordcraft_doc::{Document, Path as DocPath, Rgb, StoryRef}; use wordcraft_fonts::FaceRef; use wordcraft_geom::Rect; use wordcraft_layout::display::{DisplayOptions, Draw, Stroke as LineStyle, page_display}; +use wordcraft_layout::para::{ClKind, LineEnd}; use wordcraft_layout::wordart::ArtPaint; use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, Page, Placed, layout}; use wordcraft_metafile::{Picture, PlacedItem}; @@ -1485,14 +1486,31 @@ impl Exporter<'_> { for (out, pi) in pages.iter().enumerate() { let Some(page) = self.lay.pages.get(*pi) else { continue }; for it in &page.items { - let Placed::Lines { story: StoryRef::Body, path, para, l0: 0, x, y, .. } = it else { continue }; + let Placed::Lines { story: StoryRef::Body, path, para, l0, l1, x, y, .. } = it else { continue }; let Some(p) = self.doc.para(StoryRef::Body, path) else { continue }; let Some(level) = self.doc.styles.resolve_para(&p.props).outline_level else { continue }; + // A leading page break can be placed on the previous page without the heading. + let first = para + .lines + .iter() + .take_while(|line| { + line.end == LineEnd::PageBreak + && para + .clusters + .get(line.c0..line.c1) + .is_some_and(|cs| cs.iter().all(|c| matches!(c.kind, ClKind::PageBreak | ClKind::Marker))) + }) + .count(); + if !(*l0..*l1).contains(&first) { + continue; + } + let Some(line) = para.lines.get(first) else { continue }; + let Some(piece) = para.lines.get(*l0) else { continue }; let title: String = self.title_text(p, para).split_whitespace().collect::>().join(" ").chars().take(200).collect(); if title.is_empty() { continue; } - let (x, y) = Fit::of(page).point(*x, *y); + let (x, y) = Fit::of(page).point(*x, *y + line.top - piece.top); flat.push((level.min(8), title, out, x, y)); } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 55724aceb..1dab81746 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1085,6 +1085,71 @@ fn ref_fields_export_cached_links() { } } +#[test] +fn outline_after_leading_breaks() { + use hayro_syntax::object::{Array, Dict}; + + for (lead, marker) in [ + ("\u{c}", None), + ("\u{c}\u{c}", None), + ("\u{c}", Some(InlineObject::BookmarkStart { name: "leading".into() })), + ("\u{c}\u{c}", Some(InlineObject::CommentStart { id: 0 })), + ] { + let mut d = Document::from_text("Earlier page"); + let mut heading = Paragraph::with_text(&format!("{lead}Navigation target"), CharProps::default()).styled("Heading1"); + heading.insert_object(lead.len(), InlineObject::BookmarkStart { name: "target".into() }, &CharProps::default()).unwrap(); + if let Some(marker) = marker { + heading.insert_object(0, marker, &CharProps::default()).unwrap(); + } + d.body.push(para_block(heading)); + let bytes = export(&d, &PdfOptions::default()).unwrap(); + let text = extract_text(&bytes); + let target = text.iter().position(|t| squash(t).contains("Navigation target")).unwrap(); + assert!(target > 0, "the heading follows a page break"); + for pages in [None, Some(vec![target]), Some(vec![target, 0]), Some(vec![0])] { + let expected = pages.as_ref().map_or(Some(target), |p| p.iter().position(|i| *i == target)); + let bytes = export(&d, &PdfOptions { pages, ..Default::default() }).unwrap(); + let pdf = hayro_syntax::Pdf::new(bytes).unwrap(); + let catalog = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); + let outlines = catalog.get::>(b"Outlines"); + let Some(expected) = expected else { + assert!(outlines.is_none(), "a page containing only the break has no heading outline"); + continue; + }; + let first = outlines.unwrap().get::>(b"First").unwrap(); + let dest = first.get::>(b"Dest").unwrap(); + assert_eq!(dest.iter::>().next().unwrap(), *pdf.pages()[expected].raw(), "outline must reach the visible heading"); + assert!(first.get::>(b"Next").is_none(), "one outline entry per heading"); + } + } +} + +#[test] +fn outline_keeps_visible_objects() { + use hayro_syntax::object::{Array, Dict}; + + let mut base = Document::from_text("Earlier text"); + let media = base.add_media(png(), "png"); + for object in [ + InlineObject::Field { instr: "QUOTE Visible".into(), result: "Visible".into(), locked: true, dirty: false, code: false, form: String::new() }, + InlineObject::Image { media, w: 16.0, h: 8.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None }, + ] { + let mut d = base.clone(); + let mut heading = Paragraph::with_text("\u{c}After break", CharProps::default()).styled("Heading1"); + heading.insert_object(0, object, &CharProps::default()).unwrap(); + heading.insert_object(0, InlineObject::BookmarkStart { name: "leading".into() }, &CharProps::default()).unwrap(); + d.body.push(para_block(heading)); + let bytes = export(&d, &PdfOptions::default()).unwrap(); + assert!(squash(&extract_text(&bytes)[1]).contains("After break")); + let pdf = hayro_syntax::Pdf::new(bytes).unwrap(); + let catalog = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); + let outlines = catalog.get::>(b"Outlines").unwrap(); + let first = outlines.get::>(b"First").unwrap(); + let dest = first.get::>(b"Dest").unwrap(); + assert_eq!(dest.iter::>().next().unwrap(), *pdf.pages()[0].raw(), "visible objects before the break anchor the heading"); + } +} + /// Print Markup (#589): the comments and their replies print beside each page, which is scaled /// down to keep its paper size; without it they stay out of the PDF. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index caa9f1613..60d77a1b5 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-18 · **Change:** 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-18 · **Change:** 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; @@ -39,7 +39,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | one fixture | Writing Strict is rare | | `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run | | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). | -| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | +| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). Double-byte East Asian code pages (Japanese `\fcharset128`, Chinese, Korean) not decoded yet. Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; paragraph-mark and formatting revisions not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, section export, RTL | | `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL | @@ -230,6 +230,7 @@ Imported text fields use the first nonempty cached result run's formatting on th | 2026-10-16 | trivial | Legacy `w:outline`, `w:shadow`, `w:emboss` and `w:imprint` run effects drawn on screen and in PDFs (#2534) | | 2026-10-15 | trivial | `w:doNotTrackMoves` read and written (Track moves off: a tracked cut and paste is a deletion and an insertion) | | 2026-10-16 | trivial | Positional tabs (`w:ptab`) keep their alignment, base and leader through DOCX saves instead of becoming plain tabs (#2367) | +| 2026-10-16 | trivial | PDF outline destinations point to heading text after leading page breaks, including selected-page exports | | 2026-10-13 | trivial | A cell's explicit top vertical alignment (`w:vAlign w:val="top"`) is kept on read and write and beats a table style that centres or bottom-aligns (#1288) | | 2026-10-14 | trivial | Row table border exceptions survive DOCX saves | | 2026-10-12 | trivial | Fields keep `w:dirty` through DOCX round trips — on buffered fields and range-field begin markers alike — and computing a result (Update Field, Update Table) clears it like Word's F9, so a generator's placeholder TOC still refreshes when Word opens the file until WordCraft replaces the placeholder (#877) |