mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
Fix PDF outline targets after leading page breaks (#2484)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Brandon Thomas
parent
455e1f419f
commit
fc08b0e4f6
+20
-2
@@ -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::<Vec<_>>().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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Dict<'_>>(pdf.xref().root_id()).unwrap();
|
||||
let outlines = catalog.get::<Dict<'_>>(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::<Dict<'_>>(b"First").unwrap();
|
||||
let dest = first.get::<Array<'_>>(b"Dest").unwrap();
|
||||
assert_eq!(dest.iter::<Dict<'_>>().next().unwrap(), *pdf.pages()[expected].raw(), "outline must reach the visible heading");
|
||||
assert!(first.get::<Dict<'_>>(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::<Dict<'_>>(pdf.xref().root_id()).unwrap();
|
||||
let outlines = catalog.get::<Dict<'_>>(b"Outlines").unwrap();
|
||||
let first = outlines.get::<Dict<'_>>(b"First").unwrap();
|
||||
let dest = first.get::<Array<'_>>(b"Dest").unwrap();
|
||||
assert_eq!(dest.iter::<Dict<'_>>().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]
|
||||
|
||||
@@ -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 `<blockquote>` 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 `<blockquote>` 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) |
|
||||
|
||||
Reference in New Issue
Block a user