From 663904a2e2000bf6dd39a8b3eb411f31f997b401 Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Sat, 10 Oct 2026 09:36:38 -0300 Subject: [PATCH] Import Word 97-2003 .doc files (#28) (#145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * D0+D1: open Word 97-2003 .doc — container, FIB, piece table text (#28) New wordcraft-docbin crate (L2) reads the OLE2 container via the cfb crate (MIT) and parses the FIB and piece table from the [MS-DOC] specification: text pieces decode as UTF-16LE or Windows-1252 (table moved to wordcraft-doc::encoding), subdocument CP ranges, and Word's special characters map to the model's (tabs, breaks, hyphens). .doc/.dot registered in OPEN_EXTS and the I/O dispatch. Encrypted and Word 6/95 files give clear errors; every length is checked and proptest truncation/flips never panic. * D2: .doc formatting — stylesheet, fonts, CHPX/PAPX FKPs, sprms (#28) Sprm decoder with operand-size rules (incl. sprmTDefTable/sprmPChgTabs exceptions and the Prm0 isprm table), bin tables + ChpxFkp/PapxFkp page lookup, STSH styles (StdfBase layout validated against real Word files: bchUpe == cbStd, sti 0 = Normal) and the SttbfFfn font table in both extended and self-delimiting flavours. The CP walk now splits runs on formatting changes and resolves paragraph styles: bold/italic/strike/ underline/caps/hidden, colours (Ico palette + COLORREF), half-point sizes, super/subscript, fonts, highlights; alignment, indents, spacing, keep options, outline levels. Unknown sprms are skipped leniently; an env-gated corpus test opens real files (styles + text asserted). * D3: .doc structure — sections, headers/footers, tables, lists (#28) - Sections: PlcfSed/Sepx with 16-bit measurement sprms (sprmSXaPage & co), explicit sprmSBOrientation with a width/height fallback; even/odd/first header and footer stories from Plcfhdd become document parts. - Tables: sprmTDefTable grids (operand length fixed to cb+1 per the spec), horizontal merges (sprmTMerge), vertical merges (sprmTVertMerge), cell shading (sprmTSetShd / sprmTDefTableShd80), cell widths, valign, row height/header/cant-split, and table borders (sprmTTableBorders80 Brc80s). - Lists: PlfLst (LSTF + appended LVLs) and PlfLfo (LFO + LFOLVL overrides) resolved into the numbering model; sprmPIlvl/sprmPIlfo mark paragraphs. - Combined fixture (intro + table + list + 2 sections + header) round-trips through Document JSON; hostile proptests now mutate the structured fixture. * D4: .doc notes, fields, bookmarks and inline pictures (#28) - Footnotes/endnotes: PlcffndRef/PlcfEndRef reference CPs paired with the PlcffndTxt/PlcfEndTxt story ranges; each story walks into a Footnote or Endnote part and the 0x02 anchor becomes a NoteRef object. - Fields: 0x13/0x14/0x15 state machine in the walker — instruction text is hidden, the cached result kept; HYPERLINK fields keep their result as linked runs, everything else becomes one Field object. - Bookmarks: SttbfBkmk names + Plcfbkf/Plcfbkl CPs become BookmarkStart/ BookmarkEnd objects at their anchors. - Pictures: sprmCPicLocation points into the Data stream; the PICF header gives the display size (dxaGoal/dyaGoal scaled by mx/my) and the payload is format-sniffed (PNG/JPEG/GIF/BMP; metafiles are dropped with a warn). - Hostile proptests now truncate/flip a fixture with all of the above. * D5: .doc import hardening, caps and roadmap (#28) - Cap inline pictures at 5 000 per document. - Engine test: .doc/.dot bytes dispatch through the Word 97-2003 reader (garbage must error, never panic or open empty). - ROADMAP: Word 97-2003 import row (60% — text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks, raster pictures). Real-corpus validation stays opt-in: WORDCRAFT_DOCBIN_FIXTURES= cargo test -p wordcraft-docbin corpus_files_open. * Corpus fixes: compressed-piece FC space, PAPX query point, none-level labels (#28) Validated against two real files (kept locally under plan/word/fixtures, gitignored): a 1 MB Lorem-ipsum sample and a 2003 styles/lists/table sample. - FKP bin tables and pages address the WordDocument stream in true byte offsets; a compressed piece stores its base fc doubled (byte = fc/2), so fc_of_cp now unwinds the doubling. Before, every PAPX/CHPX lookup in compressed (cp1252) files landed on the wrong entry — tables collapsed to one row, lists and heading styles were lost. - The paragraph-properties query now uses the paragraph mark's own FC (inside the paragraph) instead of the FC after it, which picked up the next paragraph's FKP entry. - Fixture papx entries re-keyed to paragraph-start CPs, the real rgfc convention. - Counters::next_label returns nothing for NumFormat::None levels (Word's numFmt none shows no label; also fixes a latent docx-reader case). - Symbol/Wingdings bullet chars map to Unicode glyphs (• ▪ ○ ●). With this, both corpus files render headings, styles, nested and numbered lists, the tables and the PAGE field correctly; metafile-wrapped images and OLE objects stay dropped by policy (logged). * docbin: bound the text walk by real text; dedupe and cap pictures (#28) Hostile CP numbers no longer drive the work: - header/footnote/endnote story ranges from the Table stream are clamped to their subdocument (ccpHdd/ccpFtn/ccpEdn) with checked base additions, and every walk stops at the last piece's CP; a story spanning [0, 4e9) used to push billions of U+FFFD; - characters are found by binary search over the pieces (PieceTable::char_at), and an unreadable stretch (gap, truncated or out-of-stream piece) is skipped in one step rather than one U+FFFD per CP; the old per-character scan was O(chars x pieces); - the piece CPs are made non-decreasing at parse, so inverted pieces are empty and binary search stays sound; surrogate pairs now decode; - note references are looked up by binary search; the style's CharProps is borrowed, not cloned, per character; - pictures are interned by fc_pic (5000 anchors on one picture share one media entry and one copy) and the bytes copied per document are capped at 256 MB. Each fix has a crafted-bytes regression test; against the previous code the story, piece-claim, last-piece and many-pieces tests hang (killed at 60 s) and the shared picture test copies the picture once per anchor (80 GB). Co-Authored-By: Claude Opus 5.5 * docbin: checked offset arithmetic in lists, sections and table rows (#28) - list.rs: PlfLst/PlfLfo/LVL offsets (at + 2 + cLst*28, at + 4 + lfoMac*16, off += len, ...) saturate, which matters on 32-bit targets (wasm32) where fc + n can overflow usize; the LFO override path no longer indexes abstracts directly. - sections.rs: the Sepx range (fc_sepx + 2 + cb) is checked; Plcfhdd is read only as far as the stories a capped section count can use (it used to reserve lcb/4 entries). - table.rs: sprmTDyaRowHeight = i16::MIN negated in i16 (overflow panic in debug builds); now -(v as f32). Regression tests: the row height test failed before (overflow panic with debug assertions, a 0 pt height without); the list test pins the saturating offsets (it only overflowed on 32-bit targets). Co-Authored-By: Claude Opus 5.5 * Format the merged OPEN_EXTS list Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 25 + Cargo.toml | 2 + README.md | 4 +- ROADMAP.md | 1 + apps/wordcraft-cli/src/main.rs | 2 +- apps/wordcraft-web/src/web.rs | 2 +- apps/wordcraft/src/main.rs | 2 +- crates/doc/src/encoding.rs | 16 + crates/doc/src/lib.rs | 1 + crates/doc/src/numbering.rs | 4 + crates/docbin/Cargo.toml | 20 + crates/docbin/src/fib.rs | 154 ++++ crates/docbin/src/fkp.rs | 108 +++ crates/docbin/src/fmt.rs | 380 ++++++++ crates/docbin/src/lib.rs | 599 ++++++++++++ crates/docbin/src/list.rs | 294 ++++++ crates/docbin/src/media.rs | 123 +++ crates/docbin/src/notes.rs | 109 +++ crates/docbin/src/piece.rs | 187 ++++ crates/docbin/src/sections.rs | 177 ++++ crates/docbin/src/sprm.rs | 93 ++ crates/docbin/src/stsh.rs | 163 ++++ crates/docbin/src/table.rs | 414 +++++++++ crates/docbin/src/tests.rs | 1578 ++++++++++++++++++++++++++++++++ crates/engine/Cargo.toml | 1 + crates/engine/src/io.rs | 22 +- crates/engine/src/io_ext.rs | 1 + crates/engine/src/tests.rs | 9 + crates/formats/src/txt.rs | 17 +- docs/mcp.md | 2 +- xtask/src/layers.rs | 1 + 31 files changed, 4488 insertions(+), 23 deletions(-) create mode 100644 crates/doc/src/encoding.rs create mode 100644 crates/docbin/Cargo.toml create mode 100644 crates/docbin/src/fib.rs create mode 100644 crates/docbin/src/fkp.rs create mode 100644 crates/docbin/src/fmt.rs create mode 100644 crates/docbin/src/lib.rs create mode 100644 crates/docbin/src/list.rs create mode 100644 crates/docbin/src/media.rs create mode 100644 crates/docbin/src/notes.rs create mode 100644 crates/docbin/src/piece.rs create mode 100644 crates/docbin/src/sections.rs create mode 100644 crates/docbin/src/sprm.rs create mode 100644 crates/docbin/src/stsh.rs create mode 100644 crates/docbin/src/table.rs create mode 100644 crates/docbin/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index e241efb..6ad7443 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -640,6 +640,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f13298d9178622b7916ec5d01d470425c04273c8ed07e87b1c38b34c4f4612b" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -4884,6 +4895,19 @@ dependencies = [ "wordcraft-geom", ] +[[package]] +name = "wordcraft-docbin" +version = "0.4.0" +dependencies = [ + "cfb", + "log", + "proptest", + "serde_json", + "thiserror 2.0.21", + "wordcraft-doc", + "wordcraft-geom", +] + [[package]] name = "wordcraft-docx" version = "0.4.0" @@ -4910,6 +4934,7 @@ dependencies = [ "serde_json", "thiserror 2.0.21", "wordcraft-doc", + "wordcraft-docbin", "wordcraft-docx", "wordcraft-fonts", "wordcraft-formats", diff --git a/Cargo.toml b/Cargo.toml index 1b75fcc..008fdeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ wordcraft-doc = { path = "crates/doc" } wordcraft-fonts = { path = "crates/fonts" } wordcraft-layout = { path = "crates/layout" } wordcraft-docx = { path = "crates/docx" } +wordcraft-docbin = { path = "crates/docbin" } wordcraft-formats = { path = "crates/formats" } wordcraft-render = { path = "crates/render" } wordcraft-pdf = { path = "crates/pdf" } @@ -49,6 +50,7 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg", krilla = { version = "0.8", features = ["pdf"] } quick-xml = "0.38" zip = { version = "3", default-features = false, features = ["deflate"] } +cfb = "0.15" # ui egui = "0.36" diff --git a/README.md b/README.md index fb589c9..60b293c 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes - **Familiar.** Word's ribbon tabs, groups, shortcuts and behaviour: Enter continues a list, Tab demotes it, Ctrl/⌘+B bolds the word under the caret, the Styles gallery previews styles live, F4 repeats, F7 checks spelling, F8 extends the selection. -- **Your files.** Opens and saves .docx (OOXML), and also .odt, .rtf, .html, .md, .tex (LaTeX), .txt; exports PDF +- **Your files.** Opens and saves .docx (OOXML), and also .odt, .rtf, .html, .md, .tex (LaTeX), .txt; opens Word 97-2003 .doc; exports PDF with real, selectable text, links and bookmarks. - **Fast.** Paragraph layout is cached, so typing in a 188-page document re-lays it out in about 1.4 ms; pages render on demand. @@ -118,7 +118,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes | **Review** | Spelling and grammar with suggestions, thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector | | **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels | | **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español or Українська (follows the system language by default) | -| **Files** | .docx read/write (opens in Word), PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images | +| **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images | The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md) and the generated [feature parity report](docs/parity.md). diff --git a/ROADMAP.md b/ROADMAP.md index ecd813e..e9630f1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -62,6 +62,7 @@ the icon art). | Draw tab (ink), SmartArt, charts, 3D models, equations editor | Not started / linear equations only | 5% | | File formats: DOCX read/write | Good (Word opens our files); charts/SmartArt/OLE dropped; untested on a real-world corpus | 75% | | File formats: PDF, ODT, RTF, HTML, Markdown, LaTeX, TXT | Working | 70% | +| File formats: Word 97-2003 `.doc`/`.dot` import | Text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks, pictures (spec-based reader; metafile images and Word 6/95 rejected) | 60% | | View modes (print, web, draft, read, focus, zoom, navigation pane) | Working | 70% | | Backstage (new from templates, open, info, export, options) | Working; printing goes through PDF | 55% | | Agent control (CLI, MCP, control channel, macros) | Beyond Word | 100%+ | diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index a68d41c..02346b1 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -10,7 +10,7 @@ const USAGE: &str = "\ wordcraft-cli — WordCraft from the command line USAGE: - wordcraft-cli convert convert between formats (docx, pdf, odt, rtf, html, md, tex, txt, json, png) + wordcraft-cli convert convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png) wordcraft-cli info pages, words, paragraphs, properties (JSON) wordcraft-cli text plain text wordcraft-cli inspect document structure (JSON) diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index b0ad3eb..33c77a8 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -8,7 +8,7 @@ use wasm_bindgen::JsCast as _; use wordcraft_engine::Session; use wordcraft_ui_egui::{Inbox, Services, WordApp}; -const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"]; +const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"]; const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"]; const CANVAS_ID: &str = "wordcraft_canvas"; const LOADING_ID: &str = "wordcraft_loading"; diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 400edea..0b9471a 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -111,7 +111,7 @@ fn services() -> Services { let d = if purpose == "picture" { d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp"]) } else { - d.add_filter("Documents", &["docx", "docm", "dotx", "dotm", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"]) + d.add_filter("Documents", &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"]) .add_filter("Word document", &["docx"]) .add_filter("All files", &["*"]) }; diff --git a/crates/doc/src/encoding.rs b/crates/doc/src/encoding.rs new file mode 100644 index 0000000..29dc569 --- /dev/null +++ b/crates/doc/src/encoding.rs @@ -0,0 +1,16 @@ +//! Windows-1252 (the byte encoding legacy Word text pieces and RTF bytes use). + +/// Windows-1252 code points for 0x80..=0x9F. +const CP1252: [u16; 32] = [ + 0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F, 0x0090, 0x2018, + 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178, +]; + +/// One Windows-1252 byte as a char. +pub fn cp1252(b: u8) -> char { + if (0x80..0xA0).contains(&b) { + CP1252.get((b - 0x80) as usize).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}') + } else { + b as char + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index cf82d05..211e12e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -11,6 +11,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod edit; +pub mod encoding; pub mod fields; pub mod numbering; pub mod para; diff --git a/crates/doc/src/numbering.rs b/crates/doc/src/numbering.rs index 7fe0648..447f0d3 100644 --- a/crates/doc/src/numbering.rs +++ b/crates/doc/src/numbering.rs @@ -248,6 +248,10 @@ impl Counters { // This list's own definition of a level wins over its abstract list's. let level_def = |k: usize| numbering.level(num, k as u8); let def = level_def(lv)?.clone(); + // A "none" level shows no label at all (Word's numFmt none) — not even its text. + if def.format == NumFormat::None { + return None; + } let st = self.state.entry(abs.id).or_insert([0; 9]); if let std::collections::hash_map::Entry::Vacant(e) = self.seen.entry(num) { e.insert(true); diff --git a/crates/docbin/Cargo.toml b/crates/docbin/Cargo.toml new file mode 100644 index 0000000..253a0b2 --- /dev/null +++ b/crates/docbin/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "wordcraft-docbin" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "WordCraft reader for the Word 97-2003 binary file format (.doc, [MS-DOC])" + +[lints] +workspace = true + +[dependencies] +wordcraft-doc = { workspace = true } +wordcraft-geom = { workspace = true } +cfb = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/docbin/src/fib.rs b/crates/docbin/src/fib.rs new file mode 100644 index 0000000..959240f --- /dev/null +++ b/crates/docbin/src/fib.rs @@ -0,0 +1,154 @@ +//! The File Information Block ([MS-DOC] §2.5): `FibBase` plus the variable sections +//! (`FibRgW97`, `FibRgLw97`, the `RgFcLcb` blob, `FibRgCswNew`) at the start of the +//! WordDocument stream. + +use crate::DocbinError; + +/// `wIdent` of every Word 97-2003 file (Word 6/95 wrote 0xA5DC). +const W_IDENT: u16 = 0xA5EC; +/// Offsets within the FIB, in bytes (see the spec's Fib layout). +const OFF_FLAGS: usize = 0x0A; +const OFF_CSW: usize = 0x20; +const OFF_CSLW: usize = 0x3E; +const OFF_RGLW: usize = 0x40; +const OFF_CB_RG_FCLCB: usize = 0x98; +/// `csw` (count of 16-bit values in `FibRgW97`) — fixed by the spec. +const CSW: u16 = 0x000E; +/// `cslw` (count of 32-bit values in `FibRgLw97`) — fixed by the spec. +const CSLW: u16 = 0x0016; +/// `cbRgFcLcb` for each known `nFib` ([MS-DOC] Fib.cbRgFcLcb table). +const CB_RG_FCLCB: &[(u16, u16)] = &[(0x00C1, 0x005D), (0x00D9, 0x006C), (0x0101, 0x0088), (0x010C, 0x00A4), (0x0112, 0x00B7)]; + +/// Indices of `fc`/`lcb` pairs inside `FibRgFcLcb97` (each pair is 8 bytes: fc then lcb). +pub(crate) mod pair { + pub(crate) const STSHF: usize = 1; + pub(crate) const PLCF_FND_REF: usize = 2; + pub(crate) const PLCF_FND_TXT: usize = 3; + pub(crate) const PLCF_SED: usize = 6; + pub(crate) const PLCF_HDD: usize = 11; + pub(crate) const PLCF_BTE_CHPX: usize = 12; + pub(crate) const PLCF_BTE_PAPX: usize = 13; + pub(crate) const STTBF_FFN: usize = 15; + pub(crate) const STTBF_BKMK: usize = 21; + pub(crate) const PLCF_BKF: usize = 22; + pub(crate) const PLCF_BKL: usize = 23; + pub(crate) const CLX: usize = 33; + pub(crate) const PLCF_END_REF: usize = 46; + pub(crate) const PLCF_END_TXT: usize = 47; + /// `fcPlfLst`: the list definitions (LSTF array, with the LVLs appended after the blob). + pub(crate) const PLF_LST: usize = 73; + /// `fcPlfLfo`: the list format overrides (LFO array with LFOData appended). + pub(crate) const PLF_LFO: usize = 74; +} + +/// Character-position ranges of the subdocuments, from `FibRgLw97`. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct SubDocs { + pub(crate) text: u32, + pub(crate) ftn: u32, + pub(crate) hdd: u32, + pub(crate) atn: u32, + pub(crate) edn: u32, + pub(crate) txbx: u32, + pub(crate) hdr_txbx: u32, +} + +impl SubDocs { + /// Total CPs across all subdocuments, checked against a cap. + pub(crate) fn total(&self) -> Result { + let t = u64::from(self.text) + + u64::from(self.ftn) + + u64::from(self.hdd) + + u64::from(self.atn) + + u64::from(self.edn) + + u64::from(self.txbx) + + u64::from(self.hdr_txbx); + // Word's own documents stay far below this; a larger total means a corrupt FIB + // (the ccp fields are signed and "MUST be 0, 1, or greater"). + if t > 100_000_000 { + return Err(DocbinError::Limit(format!("document claims {t} characters"))); + } + Ok(t) + } +} + +/// The parsed FIB. +#[derive(Clone, Debug)] +pub(crate) struct Fib { + pub(crate) encrypted: bool, + /// Selects 1Table (true) or 0Table (false). + pub(crate) which_tbl_stm: bool, + pub(crate) ccp: SubDocs, + /// The `RgFcLcb` pairs flattened as fc₀, lcb₀, fc₁, lcb₁, … + pub(crate) pairs: Vec, +} + +impl Fib { + /// The (fc, lcb) pair at `FibRgFcLcb97` index `i` (later blobs follow contiguously). + pub(crate) fn pair(&self, i: usize) -> Option<(u32, u32)> { + let fc = *self.pairs.get(i * 2)?; + let lcb = *self.pairs.get(i * 2 + 1)?; + Some((fc, lcb)) + } + + /// Parse the FIB from the head of the WordDocument stream. + pub(crate) fn parse(word: &[u8]) -> Result { + let u16le = |at: usize| -> u16 { u16::from_le_bytes([word.get(at).copied().unwrap_or(0), word.get(at + 1).copied().unwrap_or(0)]) }; + let u32le = |at: usize| -> u32 { + u32::from_le_bytes([ + word.get(at).copied().unwrap_or(0), + word.get(at + 1).copied().unwrap_or(0), + word.get(at + 2).copied().unwrap_or(0), + word.get(at + 3).copied().unwrap_or(0), + ]) + }; + if word.len() < OFF_RGLW + 88 { + return Err(DocbinError::NotWord(format!("WordDocument stream is {} bytes", word.len()))); + } + let w_ident = u16le(0); + if w_ident == 0xA5DC { + return Err(DocbinError::NotWord("Word 6.0/95 format, only Word 97-2003 is supported".into())); + } + if w_ident != W_IDENT { + return Err(DocbinError::NotWord(format!("magic 0x{w_ident:04X}, expected 0x{W_IDENT:04X}"))); + } + let flags = u16le(OFF_FLAGS); + let nfib = u16le(2); + if u16le(OFF_CSW) != CSW { + return Err(DocbinError::NotWord(format!("csw is 0x{:04X}", u16le(OFF_CSW)))); + } + if u16le(OFF_CSLW) != CSLW { + return Err(DocbinError::NotWord(format!("cslw is 0x{:04X}", u16le(OFF_CSLW)))); + } + // rglw u32 indices per FibRgLw97: cbMac, r1, r2, ccpText, ccpFtn, ccpHdd, r3, ccpAtn, + // ccpEdn, ccpTxbx, ccpHdrTxbx, … + let lw = |i: usize| -> u32 { u32le(OFF_RGLW + i * 4) }; + // The ccp fields are signed in the spec; negative values are corrupt input, clamped. + let ccp = |i: usize| -> u32 { (lw(i) as i32).max(0) as u32 }; + let sub = SubDocs { text: ccp(3), ftn: ccp(4), hdd: ccp(5), atn: ccp(7), edn: ccp(8), txbx: ccp(9), hdr_txbx: ccp(10) }; + sub.total()?; + + let cb_rg_fclcb = u16le(OFF_CB_RG_FCLCB) as usize; + if cb_rg_fclcb == 0 { + return Err(DocbinError::NotWord("empty RgFcLcb".into())); + } + if let Some(&(_, expected)) = CB_RG_FCLCB.iter().find(|(n, _)| *n == nfib) + && cb_rg_fclcb < expected as usize + { + // Word itself always writes the full blob for its version; anything smaller is + // corrupt, but a *larger* blob (newer writer) is fine — we just read what's there. + return Err(DocbinError::NotWord(format!("cbRgFcLcb is 0x{cb_rg_fclcb:04X} for nFib 0x{nfib:04X}"))); + } + let blob_off = OFF_CB_RG_FCLCB + 2; + let blob_len = cb_rg_fclcb.saturating_mul(8); + if word.len() < blob_off + blob_len { + return Err(DocbinError::NotWord("truncated RgFcLcb".into())); + } + let mut pairs = Vec::with_capacity(cb_rg_fclcb * 2); + for i in 0..cb_rg_fclcb * 2 { + pairs.push(u32le(blob_off + i * 4)); + } + + Ok(Fib { encrypted: flags & 0x0100 != 0, which_tbl_stm: flags & 0x0200 != 0, ccp: sub, pairs }) + } +} diff --git a/crates/docbin/src/fkp.rs b/crates/docbin/src/fkp.rs new file mode 100644 index 0000000..8ed395a --- /dev/null +++ b/crates/docbin/src/fkp.rs @@ -0,0 +1,108 @@ +//! Character and paragraph formatting lookup: the bin tables (`PlcBteChpx`/`PlcBtePapx`) in +//! the Table stream point at 512-byte FKP pages in the WordDocument stream, which map FC +//! ranges to CHPX (grpprls) and PAPX (istd + grpprl) ([MS-DOC] §2.4.6.1/2.4.6.2). + +use crate::DocbinError; + +/// Largest bin table (entries) we accept. +const MAX_BINS: usize = 100_000; + +/// A parsed `PlcBte*`: FC breakpoints and the FKP page each range lives in. +pub(crate) struct Bins { + fcs: Vec, + /// Page number (× 512 = byte offset in the WordDocument stream). + pns: Vec, +} + +impl Bins { + /// Parse the Plc at `table[fc..fc+lcb]` (aFc[n+1] u32s + aPn[n] u32s). + pub(crate) fn parse(table: &[u8], fc: u32, lcb: u32) -> Result { + let fc = fc as usize; + let lcb = lcb as usize; + if lcb < 4 || !(lcb - 4).is_multiple_of(8) { + return Ok(Bins { fcs: Vec::new(), pns: Vec::new() }); + } + let end = fc.checked_add(lcb).ok_or_else(|| DocbinError::Malformed("bin table overflow".into()))?; + let plc = table.get(fc..end).ok_or_else(|| DocbinError::Malformed("bin table outside the Table stream".into()))?; + let n = (lcb - 4) / 8; + if n > MAX_BINS { + return Err(DocbinError::Limit(format!("bin table has {n} entries"))); + } + let at = |i: usize| -> Option { plc.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) }; + let mut fcs = Vec::with_capacity(n + 1); + for i in 0..=n { + fcs.push(at(i).ok_or_else(|| DocbinError::Malformed("truncated bin aFc".into()))?); + } + let mut pns = Vec::with_capacity(n); + for i in 0..n { + // PnBte* keeps the page number in its low bits; the rest must be zero. + pns.push(at(n + 1 + i).ok_or_else(|| DocbinError::Malformed("truncated bin aPn".into()))? & 0x003F_FFFF); + } + Ok(Bins { fcs, pns }) + } + + /// The page containing `fc`, as a slice of the WordDocument stream. + fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<&'w [u8]> { + let i = self.fcs.partition_point(|&f| f <= fc).checked_sub(1)?; + let pn = *self.pns.get(i)?; + let at = pn.checked_mul(512)? as usize; + word.get(at..at.checked_add(512)?) + } + + /// The grpprl of the Chpx covering `fc` ([MS-DOC] ChpxFkp: rgfc u32s, rgb offsets ×2, + /// crun in the last byte). + pub(crate) fn chpx<'w>(&self, word: &'w [u8], fc: u32) -> &'w [u8] { + let Some(page) = self.page(word, fc) else { return &[] }; + let Some(&crun) = page.get(511) else { return &[] }; + let crun = crun.min(0x65) as usize; + let rgfc = |i: usize| -> Option { page.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) }; + // rgb array sits right after rgfc (crun+1 u32s); each byte is a half-offset into the + // region between rgb's end and crun. + let rgb_at = (crun + 1) * 4; + let j = (0..crun).rev().find(|&j| rgfc(j).is_some_and(|f| f <= fc)); + let Some(j) = j else { return &[] }; + let Some(&off) = page.get(rgb_at + j) else { return &[] }; + if off == 0 { + return &[]; + } + let at = off as usize * 2; + let Some(&cb) = page.get(at) else { return &[] }; + page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[]) + } + + /// The (istd, grpprl) of the Papx of the paragraph whose mark is just before `fc` + /// ([MS-DOC] PapxFkp: rgfc u32s — each the FC *following* a paragraph mark —, rgbx BX + /// entries (1 offset byte + 12 PHE bytes), crun in the last byte; the PAPX itself is a + /// PapxInFkp with the cb/cb' size quirk). Query with the FC of the character after the mark. + pub(crate) fn papx<'w>(&self, word: &'w [u8], fc: u32) -> (u16, &'w [u8]) { + let Some(page) = self.page(word, fc) else { return (0, &[]) }; + let Some(&crun) = page.get(511) else { return (0, &[]) }; + // BX is 13 bytes; cap crun at what fits before byte 511. + let max_run = (511usize.saturating_sub((crun as usize + 1) * 4)) / 13; + let crun = (crun as usize).min(max_run); + let rgfc = |i: usize| -> Option { page.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) }; + let rgbx_at = (crun + 1) * 4; + let j = (0..crun).rev().find(|&j| rgfc(j).is_some_and(|f| f <= fc)); + let Some(j) = j else { return (0, &[]) }; + let Some(&off) = page.get(rgbx_at + j * 13) else { return (0, &[]) }; + if off == 0 { + return (0, &[]); + } + let at = off as usize * 2; + let u16at = |o: usize| -> Option { page.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]])) }; + let Some(cb) = page.get(at).copied() else { return (0, &[]) }; + // PapxInFkp: if cb is nonzero the GrpPrlAndIstd (istd u16 + grpprl) is 2×cb-1 bytes; + // if cb is zero, the next byte cb' (1 byte) gives 2×cb' bytes of it. + let (data_at, data_len) = if cb != 0 { + (at + 1, 2 * cb as usize - 1) + } else { + let cb2 = page.get(at + 1).copied().unwrap_or(0) as usize; + (at + 2, 2 * cb2) + }; + if data_len < 2 { + return (0, &[]); + } + let istd = u16at(data_at).unwrap_or(0); + (istd, page.get(data_at + 2..data_at + data_len).unwrap_or(&[])) + } +} diff --git a/crates/docbin/src/fmt.rs b/crates/docbin/src/fmt.rs new file mode 100644 index 0000000..6f13b3e --- /dev/null +++ b/crates/docbin/src/fmt.rs @@ -0,0 +1,380 @@ +//! Sprms → model properties, and style-chain resolution. + +use wordcraft_doc::Rgb; +use wordcraft_doc::props::{Align, CharProps, Highlight, LineSpacing, ParaProps, TextColor, Underline, VertAlign}; +use wordcraft_doc::styles::{Style, StyleKind, StyleSheet}; + +use crate::sprm::{self, Prl, Toggle}; + +// Character sprms ([MS-DOC] §2.6.1). +const C_BOLD: u16 = 0x0835; +const C_ITALIC: u16 = 0x0836; +const C_STRIKE: u16 = 0x0837; +const C_SMALL_CAPS: u16 = 0x083A; +const C_CAPS: u16 = 0x083B; +const C_HIDDEN: u16 = 0x083C; // sprmCFVanish +const C_KUL: u16 = 0x2A3E; // underline style (Kul) +const C_ICO: u16 = 0x2A42; // palette colour +const C_CR: u16 = 0x6870; // explicit COLORREF +const C_HPS: u16 = 0x4A43; // half-points +const C_HPS_POS: u16 = 0x4845; // super/subscript by half-points +const C_FTC0: u16 = 0x4A4F; // font index +const C_HIGHLIGHT: u16 = 0x2A0C; +const C_ISTD: u16 = 0x4A30; // character style + +// Paragraph sprms ([MS-DOC] §2.6.2). +const P_JC: u16 = 0x2461; +const P_JC80: u16 = 0x2403; +const P_DXA_LEFT: u16 = 0x845E; +const P_DXA_LEFT80: u16 = 0x840F; +const P_DXA_RIGHT: u16 = 0x845D; +const P_DXA_RIGHT80: u16 = 0x840E; +const P_DXA_FIRST: u16 = 0x8460; // sprmPDxaLeft1 +const P_DYA_BEFORE: u16 = 0xA413; +const P_DYA_AFTER: u16 = 0xA414; +const P_DYA_LINE: u16 = 0x6412; +const P_KEEP: u16 = 0x2405; // keep lines together +const P_KEEP_FOLLOW: u16 = 0x2406; // keep with next +const P_PAGE_BREAK_BEFORE: u16 = 0x2407; +const P_OUTLVL: u16 = 0x2640; + +/// Word's 16-colour `Ico` palette ([MS-DOC] §2.9.135), 0 = automatic. +const ICO: [Rgb; 17] = [ + Rgb(0, 0, 0), + Rgb(0, 0, 0), + Rgb(0, 0, 255), + Rgb(0, 255, 255), + Rgb(0, 255, 0), + Rgb(255, 0, 255), + Rgb(255, 0, 0), + Rgb(255, 255, 0), + Rgb(255, 255, 255), + Rgb(0, 0, 128), + Rgb(0, 128, 128), + Rgb(0, 128, 0), + Rgb(128, 0, 128), + Rgb(128, 0, 0), + Rgb(128, 128, 0), + Rgb(128, 128, 128), + Rgb(192, 192, 192), +]; + +/// Highlight palette ([MS-DOC] §2.9.135 Highlight, order of Word's highlighter row). +const HL: [Highlight; 17] = [ + Highlight::None, + Highlight::Yellow, + Highlight::BrightGreen, + Highlight::Turquoise, + Highlight::Pink, + Highlight::Blue, + Highlight::Red, + Highlight::DarkBlue, + Highlight::Teal, + Highlight::Green, + Highlight::Violet, + Highlight::DarkRed, + Highlight::DarkYellow, + Highlight::Gray50, + Highlight::Gray25, + Highlight::None, + Highlight::None, +]; + +/// The `Ico` palette colour (0 = automatic → `None`). +pub(crate) fn ico(i: u8) -> Option { + if i == 0 { + return None; + } + ICO.get(i as usize).copied() +} + +fn u16_of(p: &Prl) -> Option { + match p.operand { + [b0, b1, ..] => Some(u16::from_le_bytes([*b0, *b1])), + _ => None, + } +} + +fn s16_of(p: &Prl) -> Option { + u16_of(p).map(|v| v as i16) +} + +fn set(props: &mut Option, t: Toggle, base: bool) { + *props = Some(match t { + Toggle::On => true, + Toggle::Off => false, + Toggle::Invert => !base, + Toggle::Keep => return, + }); +} + +/// Apply a character-property sprm. `base` carries the style-inherited values so toggle +/// operands can invert them; `fonts` maps sprmCRgFtc0 indices to names. +pub(crate) fn apply_char(p: &mut CharProps, prl: &Prl, base: &CharProps, fonts: &[String]) { + let op = prl.op; + match op { + C_BOLD => set(&mut p.bold, sprm::toggle(prl), base.bold.unwrap_or(false)), + C_ITALIC => set(&mut p.italic, sprm::toggle(prl), base.italic.unwrap_or(false)), + C_STRIKE => set(&mut p.strike, sprm::toggle(prl), base.strike.unwrap_or(false)), + C_SMALL_CAPS => set(&mut p.small_caps, sprm::toggle(prl), base.small_caps.unwrap_or(false)), + C_CAPS => set(&mut p.caps, sprm::toggle(prl), base.caps.unwrap_or(false)), + C_HIDDEN => set(&mut p.hidden, sprm::toggle(prl), base.hidden.unwrap_or(false)), + C_KUL => { + p.underline = match prl.operand.first().copied().unwrap_or(0) { + 0x00 => Some(Underline::None), + 0x01 => Some(Underline::Single), + 0x02 => Some(Underline::Words), + 0x03 => Some(Underline::Double), + 0x04 => Some(Underline::Dotted), + 0x06 => Some(Underline::Thick), + 0x07 => Some(Underline::Dash), + 0x09 => Some(Underline::DotDash), + 0x0A => Some(Underline::DotDotDash), + 0x0B => Some(Underline::Wave), + 0x14 => Some(Underline::Dotted), + 0x17 => Some(Underline::Thick), + 0x18 => Some(Underline::Dash), + 0x19 => Some(Underline::DotDash), + 0x1A => Some(Underline::DotDotDash), + 0x1B => Some(Underline::DoubleWave), + _ => Some(Underline::Single), + }; + } + C_ICO => { + if let Some(rgb) = ICO.get(prl.operand.first().copied().unwrap_or(0) as usize) { + p.color = Some(if rgb == &Rgb::BLACK && prl.operand.first() == Some(&0) { TextColor::Auto } else { TextColor::Rgb(*rgb) }); + } + } + C_CR => { + // COLORREF 0x00BBGGRR, stored little-endian: operand bytes are R, G, B. + if let [r, g, b, ..] = prl.operand + && let (r, g, b) = (*r, *g, *b) + { + p.color = Some(TextColor::Rgb(Rgb(r, g, b))); + } + } + C_HPS => { + if let Some(hps) = u16_of(prl) { + p.size = Some((hps as f32 / 2.0).clamp(1.0, 1638.0)); + } + } + C_HPS_POS => { + if let Some(pos) = s16_of(prl) { + p.vert_align = Some(match pos { + pos if pos > 0 => VertAlign::Superscript, + pos if pos < 0 => VertAlign::Subscript, + _ => VertAlign::Baseline, + }); + } + } + C_FTC0 => { + if let Some(name) = u16_of(prl).and_then(|i| fonts.get(i as usize)) { + p.font = Some(name.clone()); + } + } + C_HIGHLIGHT => { + if let Some(h) = HL.get(prl.operand.first().copied().unwrap_or(0) as usize) { + p.highlight = Some(*h); + } + } + C_ISTD => {} // resolved separately through the style table + _ => {} + } +} + +/// Apply a paragraph-property sprm. +pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) { + let tw = |v: i32| (v as f32 / 20.0).clamp(-1000.0, 1000.0); + let s16 = |prl: &Prl| -> Option { u16_of(prl).map(|v| v as i16) }; + let i32of = |prl: &Prl| -> Option { + match prl.operand { + [b0, b1, b2, b3, ..] => Some(i32::from_le_bytes([*b0, *b1, *b2, *b3])), + _ => None, + } + }; + match prl.op { + P_JC | P_JC80 => { + p.align = match prl.operand.first().copied().unwrap_or(0) { + 1 => Some(Align::Center), + 2 => Some(Align::Right), + 3 => Some(Align::Justify), + 4 => Some(Align::Distribute), + _ => Some(Align::Left), + }; + } + P_DXA_LEFT | P_DXA_LEFT80 => { + if let Some(v) = s16(prl) { + p.indent_left = Some(tw(v as i32)); + } + } + P_DXA_RIGHT | P_DXA_RIGHT80 => { + if let Some(v) = s16(prl) { + p.indent_right = Some(tw(v as i32)); + } + } + P_DXA_FIRST => { + if let Some(v) = i32of(prl) { + p.indent_first = Some(tw(v)); + } + } + P_DYA_BEFORE => { + if let Some(v) = s16(prl) { + p.space_before = Some((v as f32 / 20.0).clamp(0.0, 3168.0)); + } + } + P_DYA_AFTER => { + if let Some(v) = s16(prl) { + p.space_after = Some((v as f32 / 20.0).clamp(0.0, 3168.0)); + } + } + P_DYA_LINE => { + if let Some(v) = u16_of(prl) { + let mult = (v & 0x3FFF) as f32; + p.line_spacing = Some(match v & 0xC000 { + 0x0000 => LineSpacing::Multiple((mult / 240.0).clamp(0.1, 20.0)), + 0x8000 => LineSpacing::AtLeast((mult / 20.0).clamp(0.0, 1584.0)), + _ => LineSpacing::Exactly((mult / 20.0).clamp(0.0, 1584.0)), + }); + } + } + P_KEEP => { + if matches!(sprm::toggle(prl), Toggle::On) { + p.keep_lines = Some(true); + } + } + P_KEEP_FOLLOW => { + if matches!(sprm::toggle(prl), Toggle::On) { + p.keep_next = Some(true); + } + } + P_PAGE_BREAK_BEFORE => { + if matches!(sprm::toggle(prl), Toggle::On) { + p.page_break_before = Some(true); + } + } + P_OUTLVL => { + if let Some(&l) = prl.operand.first() { + p.outline_level = Some(l.min(8)); + } + } + _ => {} + } +} + +/// Build the model stylesheet: resolve each raw style's chain (base → this) and the ids we +/// reference from paragraphs. +pub(crate) fn stylesheet(raw: &[crate::stsh::RawStyle], fonts: &[String]) -> StyleSheet { + let mut sheet = StyleSheet::empty(); + // Word's base character properties: font index 0 of the table, 10 pt ([MS-DOC] sprmCHps). + sheet.default_chr.font = fonts.first().cloned(); + if sheet.default_chr.size.is_none() { + sheet.default_chr.size = Some(10.0); + } + for (istd, st) in raw.iter().enumerate() { + if st.name.is_empty() { + continue; + } + let id = style_id(&st.name); + let kind = match st.stk { + 2 => StyleKind::Character, + 3 => StyleKind::Table, + 4 => StyleKind::Numbering, + _ => StyleKind::Paragraph, + }; + let base = st + .istd_base + .checked_sub(1) + .filter(|&i| (i as usize) < istd && raw.get(i as usize).is_some_and(|b| !b.name.is_empty())) + .map(|i| style_id(&raw[i as usize].name)); + // Inherit: character props come from the base chain, then our own chpx. + let mut chr = CharProps::default(); + if let Some(b) = st.istd_base.checked_sub(1).filter(|&i| (i as usize) < istd).and_then(|i| raw.get(i as usize)) { + apply_chain(&mut chr, &b.chpx, fonts); + } + apply_chain(&mut chr, &st.chpx, fonts); + let mut para = ParaProps::default(); + if let Some(b) = st.istd_base.checked_sub(1).filter(|&i| (i as usize) < istd).and_then(|i| raw.get(i as usize)) { + apply_chain_para(&mut para, &b.papx); + } + apply_chain_para(&mut para, &st.papx); + if para.outline_level.is_none() && kind == StyleKind::Paragraph { + let h = heading_level(&st.name); + if h > 0 { + para.outline_level = Some(h - 1); + } + } + sheet.styles.push(Style { id, name: display_name(&st.name), kind, based_on: base, chr, para, ..Default::default() }); + } + sheet +} + +/// The style-id form of a stored (lower-case) built-in name, matching the docx reader's ids. +pub(crate) fn style_id(name: &str) -> String { + let lower = name.trim().to_ascii_lowercase(); + match lower.as_str() { + "normal" => "Normal".into(), + "title" => "Title".into(), + "subtitle" => "Subtitle".into(), + "quote" | "body text" | "body text 2" | "body text 3" | "body text indent" | "body text indent 2" | "body text indent 3" => { + let mut c = lower.chars(); + match c.next() { + Some(f) => f.to_uppercase().collect::() + c.as_str(), + None => String::new(), + } + } + "default paragraph font" => "Default Paragraph Font".into(), + _ => { + let mut out = String::with_capacity(name.len()); + let mut up = true; + for ch in name.trim().chars() { + if ch == ' ' { + up = true; + out.push(ch); + } else if up { + out.extend(ch.to_uppercase()); + up = false; + } else { + out.push(ch); + } + } + out + } + } +} + +/// "heading 3" → 3; 0 when not a heading. +fn heading_level(name: &str) -> u8 { + let lower = name.trim().to_ascii_lowercase(); + lower.strip_prefix("heading ").and_then(|d| d.parse().ok()).filter(|&l: &u8| (1..=9).contains(&l)).unwrap_or(0) +} + +fn display_name(name: &str) -> String { + let lower = name.trim().to_ascii_lowercase(); + let h = heading_level(&lower); + if h > 0 { + return format!("Heading {h}"); + } + match lower.as_str() { + "body text" => "Body Text".into(), + _ => style_id(name), + } +} + +/// Apply a grpprl of character sprms with a toggle base. +fn apply_chain(chr: &mut CharProps, grpprl: &[u8], fonts: &[String]) { + let base = chr.clone(); + for prl in sprm::iter(grpprl) { + if prl.sgc() == 2 { + apply_char(chr, &prl, &base, fonts); + } + } +} + +/// Apply a grpprl of paragraph sprms. +fn apply_chain_para(para: &mut ParaProps, grpprl: &[u8]) { + for prl in sprm::iter(grpprl) { + if prl.sgc() == 1 { + apply_para(para, &prl); + } + } +} diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs new file mode 100644 index 0000000..b2c506e --- /dev/null +++ b/crates/docbin/src/lib.rs @@ -0,0 +1,599 @@ +//! WordCraft reader for the Word 97-2003 binary file format (`.doc`, [MS-DOC]). +//! +//! [`read`] turns the bytes of a `.doc` file into a [`wordcraft_doc::Document`]. There is no +//! writer: saving always goes through the other formats. The reader is written from the public +//! specification ([MS-DOC], Word (.doc) Binary File Format, Microsoft Open Specifications) and, +//! like every WordCraft parser, is lenient by design (unknown structures are skipped, bad numbers +//! fall back to defaults) and bounded (stream sizes, piece counts and property counts are +//! capped), so hostile input yields an error or a best-effort document, never a panic. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod fib; +mod fkp; +mod fmt; +mod list; +mod media; +mod notes; +mod piece; +mod sections; +mod sprm; +mod stsh; +mod table; + +use std::io::{Read, Seek}; + +use wordcraft_doc::para::InlineObject; +use wordcraft_doc::para::NoteKind; +use wordcraft_doc::para::Paragraph; +use wordcraft_doc::para::Run; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::props::NumRef; +use wordcraft_doc::section::HeaderSet; +use wordcraft_doc::styles::StyleSheet; +use wordcraft_doc::{Document, PartKind}; + +use fkp::Bins; +use piece::{PieceTable, PrmRef}; +use sprm::Prl; +use table::ParaOut; + +/// Largest stream we materialise (bytes); the engine already caps whole files at 2 GB. +const MAX_STREAM: u64 = 1 << 30; +/// Most header/footer parts we create (matches the docx reader's cap). +const MAX_PARTS: usize = 50_000; +/// `sprmCPicLocation`: the fc of a picture in the Data stream. +const C_PIC_LOCATION: u16 = 0x6A03; +/// `sprmPIlvl` / `sprmPIlfo`: the paragraph's list level and list. +const P_ILVL: u16 = 0x260A; +const P_ILFO: u16 = 0x460B; + +/// Errors from reading a Word 97-2003 binary file. +#[derive(Debug, thiserror::Error, Clone, PartialEq)] +pub enum DocbinError { + /// The bytes are not a readable compound file (OLE2/CFB container). + #[error("not a Word document (compound file): {0}")] + Container(String), + /// The compound file is readable but is not a Word 97-2003 document. + #[error("not a Word 97-2003 document: {0}")] + NotWord(String), + /// The document is password-protected (XOR obfuscation or RC4 encryption). + #[error("encrypted or password-protected .doc files are not supported")] + Encrypted, + /// The input exceeds a safety limit (stream size, piece count…). + #[error("limit exceeded: {0}")] + Limit(String), + /// A structure is malformed beyond recovery. + #[error("malformed document: {0}")] + Malformed(String), +} + +/// Read a `.doc` (Word 97-2003 binary) file into a [`Document`]. +pub fn read(bytes: &[u8]) -> Result { + let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))?; + let word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?; + if word.len() as u64 > MAX_STREAM { + return Err(DocbinError::Limit(format!("WordDocument stream is {} bytes", word.len()))); + } + let fib = fib::Fib::parse(&word)?; + if fib.encrypted { + return Err(DocbinError::Encrypted); + } + // The FIB names the Table stream (0Table or 1Table); fall back to whichever exists, + // since files written by other writers sometimes carry the wrong bit. + let named = if fib.which_tbl_stm { "1Table" } else { "0Table" }; + let other = if fib.which_tbl_stm { "0Table" } else { "1Table" }; + let table = stream(&mut comp, named).or_else(|| stream(&mut comp, other)).unwrap_or_default(); + if table.len() as u64 > MAX_STREAM { + return Err(DocbinError::Limit(format!("Table stream is {} bytes", table.len()))); + } + + // The piece table locates every character in the WordDocument stream. + let (fc_clx, lcb_clx) = + fib.pair(fib::pair::CLX).filter(|(_, lcb)| *lcb > 0).ok_or_else(|| DocbinError::Malformed("the FIB has no piece table (Clx)".into()))?; + let pieces = piece::PieceTable::parse(&table, fc_clx, lcb_clx)?; + + // Styles, fonts and the formatting bin tables; each is optional in broken files. + let fonts = fib.pair(fib::pair::STTBF_FFN).map(|(f, l)| stsh::fonts(&table, f, l)).unwrap_or_default(); + let raw_styles = match fib.pair(fib::pair::STSHF) { + Some((f, l)) => stsh::parse(&table, f, l)?, + None => Vec::new(), + }; + let chpx_bins = match fib.pair(fib::pair::PLCF_BTE_CHPX) { + Some((f, l)) => Bins::parse(&table, f, l)?, + None => Bins::parse(&[], 0, 0)?, + }; + let papx_bins = match fib.pair(fib::pair::PLCF_BTE_PAPX) { + Some((f, l)) => Bins::parse(&table, f, l)?, + None => Bins::parse(&[], 0, 0)?, + }; + + let data = stream(&mut comp, "Data").unwrap_or_default(); + if data.len() as u64 > MAX_STREAM { + return Err(DocbinError::Limit(format!("Data stream is {} bytes", data.len()))); + } + + let mut doc = Document::new(); + doc.body.clear(); + doc.styles = fmt::stylesheet(&raw_styles, &fonts); + doc.numbering = list::parse(&table, &fib, &fonts); + let sheet: StyleSheet = doc.styles.clone(); + let sects = sections::parse(&word, &table, &fib); + let stories = sections::header_stories(&table, &fib); + // Subdocument layout in the CP space: main document, footnotes, headers, comments, + // endnotes, text boxes, header text boxes. + // Story ranges from the Table stream are hostile: each is clamped to its subdocument's + // length here and to the last piece's CP inside `walk`. + let ftn_base = fib.ccp.text; + let hdd_base = fib.ccp.text.saturating_add(fib.ccp.ftn); + let edn_base = hdd_base.saturating_add(fib.ccp.hdd).saturating_add(fib.ccp.atn); + let mut pics = media::Pictures::default(); + + let ctx = WalkCtx { + word: &word, + data: &data, + pieces: &pieces, + chpx_bins: &chpx_bins, + papx_bins: &papx_bins, + fonts: &fonts, + raw_styles: &raw_styles, + sheet: &sheet, + notes: &[], + marks: &[], + }; + + // Header and footer parts: skip the six separator stories, then six stories per section + // in the order even header, odd (default) header, even footer, odd (default) footer, + // first header, first footer. Empty stories inherit from the previous section. + let mut hf_ids: Vec<(usize, usize, u32)> = Vec::new(); + for si in 0..sects.len() { + for k in 0..6usize { + let Some(&ab) = stories.get(6 + si * 6 + k) else { break }; + let Some((a, b)) = story(hdd_base, fib.ccp.hdd, ab) else { continue }; + if doc.parts.len() >= MAX_PARTS { + continue; + } + let kind = match k { + 0 | 1 | 4 => PartKind::Header, + _ => PartKind::Footer, + }; + let blocks = table::assemble(walk(&ctx, a, b, &mut pics)); + if !blocks.is_empty() { + let id = doc.add_part(kind, blocks); + hf_ids.push((si, k, id)); + } + } + } + + // Footnote and endnote stories: each text range becomes a part; the reference CPs then + // link to the part ids during the main walk. + let mut note_links: Vec<(u32, NoteKind, u32)> = Vec::new(); + for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_FND_REF, fib::pair::PLCF_FND_TXT) { + let Some((a, b)) = story(ftn_base, fib.ccp.ftn, ab) else { continue }; + let blocks = table::assemble(walk(&ctx, a, b, &mut pics)); + if !blocks.is_empty() && doc.parts.len() < MAX_PARTS { + let id = doc.add_part(PartKind::Footnote, blocks); + note_links.push((cp, NoteKind::Footnote, id)); + } + } + for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_END_REF, fib::pair::PLCF_END_TXT) { + let Some((a, b)) = story(edn_base, fib.ccp.edn, ab) else { continue }; + let blocks = table::assemble(walk(&ctx, a, b, &mut pics)); + if !blocks.is_empty() && doc.parts.len() < MAX_PARTS { + let id = doc.add_part(PartKind::Endnote, blocks); + note_links.push((cp, NoteKind::Endnote, id)); + } + } + // Sorted so the main walk finds each reference by binary search. + note_links.sort_by_key(|(cp, _, _)| *cp); + let marks = notes::parse_bookmarks(&table, &fib); + let ctx = WalkCtx { notes: ¬e_links, marks: &marks, ..ctx }; + + // Sections: each section's properties attach to the paragraph that ends it (its story of + // header/footer parts included); the section reaching the end of the text is the final one. + let mut paras = walk(&ctx, 0, fib.ccp.text, &mut pics); + let last = sects.last().map(|s| s.props.clone()); + for (si, sec) in sects.iter().enumerate() { + let mut props = sec.props.clone(); + let id_of = |k: usize| hf_ids.iter().find(|(s, kk, _)| *s == si && *kk == k).map(|(_, _, id)| *id); + props.headers = HeaderSet { even: id_of(0), default: id_of(1), first: id_of(4) }; + props.footers = HeaderSet { even: id_of(2), default: id_of(3), first: id_of(5) }; + let is_final = si + 1 == sects.len() || sec.end_cp >= fib.ccp.text; + if is_final { + continue; // handled below through `last` + } + for p in paras.iter_mut() { + if p.end_cp == sec.end_cp { + p.para.section = Some(Box::new(props)); + break; + } + } + } + if let Some(mut l) = last { + let si = sects.len().saturating_sub(1); + let id_of = |k: usize| hf_ids.iter().find(|(s, kk, _)| *s == si && *kk == k).map(|(_, _, id)| *id); + l.headers = HeaderSet { even: id_of(0), default: id_of(1), first: id_of(4) }; + l.footers = HeaderSet { even: id_of(2), default: id_of(3), first: id_of(5) }; + doc.last_section = l; + } + + doc.media.extend(pics.media); + doc.body = table::assemble(paras); + doc.ensure_nonempty(); + Ok(doc) +} + +/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len` +/// CPs, as absolute CPs clamped to that subdocument; `None` when empty. +fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> { + let (a, b) = (a.min(len), b.min(len)); + if b <= a { + return None; + } + Some((base.checked_add(a)?, base.checked_add(b)?)) +} + +/// Everything the walker needs besides its CP range. +struct WalkCtx<'a> { + word: &'a [u8], + data: &'a [u8], + pieces: &'a PieceTable, + chpx_bins: &'a Bins, + papx_bins: &'a Bins, + fonts: &'a [String], + raw_styles: &'a [stsh::RawStyle], + sheet: &'a StyleSheet, + /// (reference CP in the main document, kind, part id) for notes. + notes: &'a [(u32, NoteKind, u32)], + /// Bookmark boundaries (main-document CPs), sorted for a sequential walk. + marks: &'a [(u32, notes::Mark)], +} + +/// State while assembling one paragraph from per-CP formatting. +#[derive(Default)] +struct ParaBuild { + text: String, + runs: Vec, + props: wordcraft_doc::props::ParaProps, + mark: CharProps, + /// The current run's properties, so runs only break when formatting changes. + cur: Option, + /// Inline objects, each anchored at a U+FFFC pushed into `text`. + objects: Vec, +} + +impl ParaBuild { + fn push(&mut self, c: char, props: CharProps) { + let len = c.len_utf8(); + match &self.cur { + Some(p) if *p == props => { + if let Some(r) = self.runs.last_mut() { + r.len += len; + } + } + _ => { + self.runs.push(Run { len, props: props.clone() }); + self.cur = Some(props); + } + } + self.text.push(c); + } +} + +/// Walk a CP range, splitting paragraphs at 0x0D/0x07 marks, applying the direct character +/// formatting of each CP and the paragraph formatting of each mark. Works for the main +/// document and for any subdocument range (headers, footers, notes…). Field characters, +/// note references, bookmarks and inline pictures become inline objects; pictures go into +/// `pics`, which shares one media entry per Data-stream location. +/// +/// The range is clamped to the last piece's CP, and unreadable stretches (gaps, truncated +/// pieces) are skipped a whole piece at a time, so the work is bounded by the text that +/// really exists, not by the CP numbers a file claims. +fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -> Vec { + let cp_end = cp_end.min(ctx.pieces.cp_end()); + let mut out = Vec::new(); + let mut pb = ParaBuild::default(); + let mut cp = cp_start; + let mut marks = ctx.marks.iter().peekable(); + // Field assembly state: instruction characters are swallowed, the result is kept until + // the end character, then emitted as one object (hyperlinks keep their result text). + let mut field: Option = None; + while cp < cp_end { + // Bookmark boundaries at this CP (main-document walks only; subdocument walks get + // an empty table and `cp` never matches the main-document CPs). + // `<=` so a boundary inside a skipped stretch is still emitted (late) rather than + // blocking every later one. + while marks.peek().is_some_and(|&(mcp, _)| *mcp <= cp) { + if let Some((_, m)) = marks.next() { + let obj = match m { + notes::Mark::Start(name) => InlineObject::BookmarkStart { name: name.clone() }, + notes::Mark::End(name) => InlineObject::BookmarkEnd { name: name.clone() }, + }; + pb.push('\u{FFFC}', CharProps::default()); + pb.objects.push(obj); + } + } + let (c, units) = match ctx.pieces.char_at(ctx.word, cp) { + Ok(found) => found, + Err(next) => { + cp = next; + continue; + } + }; + match c { + '\r' | '\u{7}' => { + // Paragraph mark: its PAPX covers the FC range of the paragraph it ends, so + // querying at the mark's own FC finds that paragraph's properties. + let fc_mark = ctx.pieces.fc_of_cp(cp).unwrap_or(0); + let (istd, papx) = ctx.papx_bins.papx(ctx.word, fc_mark); + if let Some(st) = ctx.raw_styles.get(istd as usize).filter(|s| !s.name.is_empty()) { + pb.props.style = Some(fmt::style_id(&st.name)); + } + let mut row = table::decode(papx); + row.cell_mark = c == '\u{7}'; + let mut ilvl = 0u8; + let mut ilfo = 0i32; + for prl in sprm::iter(papx) { + if prl.op == P_ILVL { + ilvl = prl.operand.first().copied().unwrap_or(0).min(8); + continue; + } + if prl.op == P_ILFO { + ilfo = match prl.operand { + [b0, b1, ..] => i16::from_le_bytes([*b0, *b1]) as i32, + _ => 0, + }; + continue; + } + if prl.sgc() == 1 { + fmt::apply_para(&mut pb.props, &prl); + } + } + if let Some(num) = num_of(ilfo) { + pb.props.numbering = Some(NumRef { num, level: ilvl }); + } + let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0); + pb.mark = char_props(ctx.chpx_bins.chpx(ctx.word, fc), ctx.fonts, &CharProps::default()); + let done = std::mem::take(&mut pb); + out.push(ParaOut { + para: Paragraph { + text: done.text, + runs: done.runs, + props: done.props, + mark: done.mark, + objects: done.objects, + ..Default::default() + }, + end_cp: cp.saturating_add(1), + row, + }); + } + // Field begin/separator/end ([MS-DOC] §2.9.84 Plcfld). + '\u{13}' => { + match field.as_mut() { + Some(f) => { + f.depth = f.depth.saturating_add(1); + } + None => field = Some(FieldBuild { depth: 1, instr: String::new(), sep: None }), + } + cp = cp.saturating_add(1); + continue; + } + '\u{14}' => { + if let Some(f) = field.as_mut() + && f.depth == 1 + && f.sep.is_none() + { + f.sep = Some(Vec::new()); + } + cp = cp.saturating_add(1); + continue; + } + '\u{15}' => { + if let Some(mut f) = field.take() { + if f.depth > 1 { + f.depth -= 1; + field = Some(f); + } else { + finish_field(&mut pb, f); + } + } + cp = cp.saturating_add(1); + continue; + } + '\u{1}' => { + // Inline picture: the CHPX of the anchor carries sprmCPicLocation. + let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0); + let fc_pic = pic_location(ctx.chpx_bins.chpx(ctx.word, fc)); + match fc_pic.and_then(|at| pics.get(ctx.data, at)) { + Some((key, w, h)) => { + pb.push('\u{FFFC}', CharProps::default()); + pb.objects.push(InlineObject::Image { media: key, w, h, alt: String::new(), float: Default::default(), crop: [0.0; 4] }); + } + None => pics.dropped(cp), + } + } + '\u{2}' => { + // Footnote or endnote reference; custom symbols share the same character. + let at = ctx.notes.partition_point(|(c, _, _)| *c < cp); + if let Some((_, kind, id)) = ctx.notes.get(at).filter(|(c, _, _)| *c == cp) { + pb.push('\u{FFFC}', CharProps::default()); + pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() }); + } + } + '\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut()), + '\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut()), + '\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut()), + '\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut()), + '\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut()), + '\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut()), + // Object anchors, comment references and other special characters are dropped + // (or handled by later passes). + c if (c as u32) < 0x20 => {} + c => push_formatted(&mut pb, cp, c, ctx, field.as_mut()), + } + cp = cp.saturating_add(units); + } + if !pb.text.is_empty() { + let done = pb; + out.push(ParaOut { + para: Paragraph { text: done.text, runs: done.runs, props: done.props, mark: done.mark, objects: done.objects, ..Default::default() }, + end_cp: cp_end, + row: table::RowInfo::default(), + }); + } + out +} + +/// Field assembly: `instr` collects everything before the separator (nested fields +/// flattened), `sep` holds the cached result once the separator was seen. +struct FieldBuild { + depth: u32, + instr: String, + sep: Option>, +} + +/// Emit a finished field: HYPERLINK keeps its result text as a linked run; every other +/// field becomes one inline object with its cached result. +fn finish_field(pb: &mut ParaBuild, f: FieldBuild) { + let instr = f.instr.trim().to_string(); + let result = f.sep.unwrap_or_default(); + let result_text: String = result.iter().map(|(c, _)| *c).collect(); + if let Some(target) = hyperlink_target(&instr) { + for (c, mut props) in result { + props.link = Some(target.clone()); + pb.push(c, props); + } + return; + } + pb.push('\u{FFFC}', CharProps::default()); + pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false }); +} + +/// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target +/// (`#bookmark` for internal links); `None` when not a hyperlink. +fn hyperlink_target(instr: &str) -> Option { + let rest = instr.strip_prefix("HYPERLINK").or_else(|| instr.strip_prefix("hyperlink"))?; + let rest = rest.strip_prefix(' ').unwrap_or(rest); + if rest.is_empty() { + return None; + } + let mut quoted = None; + let mut local = None; + let mut tokens = rest.split_whitespace().peekable(); + while let Some(tok) = tokens.next() { + match tok { + "\\l" => { + local = Some(tokens.next().unwrap_or_default().trim_matches('"').to_string()); + } + "\\o" | "\\m" | "\\n" | "\\t" => { + let _ = tokens.next(); + } + _ if quoted.is_none() => quoted = Some(tok.trim_matches('"').to_string()), + _ => {} + } + } + Some(match local { + Some(name) if !name.is_empty() => format!("#{name}"), + _ => quoted.unwrap_or_default(), + }) +} + +/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream. +fn pic_location(chpx: &[u8]) -> Option { + for prl in sprm::iter(chpx) { + if prl.op == C_PIC_LOCATION { + return match prl.operand { + [b0, b1, b2, b3, ..] => Some(u32::from_le_bytes([*b0, *b1, *b2, *b3])), + _ => None, + }; + } + } + None +} + +/// `sprmPIlfo` operand → the num id, if the paragraph is in a list. Values 0xF802-0xFFFF are +/// the negation of a 1-based index and keep the paragraph's own indents, which we do by +/// leaving the level's indents out of the paragraph (they never enter `ParaProps` anyway). +fn num_of(ilfo: i32) -> Option { + match ilfo { + 0x0001..=0x07FE => Some(ilfo as u32), + 0xF802..=0xFFFF => Some((-(ilfo as i16)) as i32 as u32), + _ => None, + } +} + +/// Resolve the character properties for the character at `cp`: the CHPX grpprl of its FC, +/// plus the piece's Prm, over the paragraph style's base character props. Inside a field, +/// the character is recorded into the instruction or the cached result instead of (or as +/// well as) the visible text. +fn push_formatted(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx, field: Option<&mut FieldBuild>) { + let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0); + let grpprl = ctx.chpx_bins.chpx(ctx.word, fc); + let default; + let base = match pb.props.style.as_deref().and_then(|id| ctx.sheet.get(id)) { + Some(st) => &st.chr, + None => { + default = CharProps::default(); + &default + } + }; + let mut props = char_props(grpprl, ctx.fonts, base); + apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base); + match field { + Some(f) => match f.sep.as_mut() { + // Inside a field, characters are recorded for the field object instead of shown; + // `finish_field` emits the visible text (hyperlink runs or one object anchor). + Some(result) => result.push((c, props)), + None => f.instr.push(c), + }, + None => pb.push(c, props), + } +} + +/// Apply a piece's Prm (Prm0 short sprm or Prm1 grpprl) to character properties. +fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &CharProps) { + match prm { + PrmRef::None => {} + PrmRef::Sprm0 { isprm, val } => { + if let Some(op) = sprm::from_prm0(isprm) { + let operand = [val]; + let prl = Prl { op, operand: &operand }; + if prl.sgc() == 2 { + fmt::apply_char(props, &prl, base, fonts); + } + } + } + PrmRef::Grpprl(Some(g)) => { + for prl in sprm::iter(g) { + if prl.sgc() == 2 { + fmt::apply_char(props, &prl, base, fonts); + } + } + } + PrmRef::Grpprl(None) => {} + } +} + +/// Character props from a CHPX grpprl applied over `base`. +fn char_props(grpprl: &[u8], fonts: &[String], base: &CharProps) -> CharProps { + let mut p = base.clone(); + for prl in sprm::iter(grpprl) { + if prl.sgc() == 2 { + fmt::apply_char(&mut p, &prl, base, fonts); + } + } + p +} + +/// Read a root-level stream fully, or `None` if it is absent. +fn stream(comp: &mut cfb::CompoundFile, name: &str) -> Option> { + let mut s = comp.open_stream(format!("/{name}")).ok()?; + let mut v = Vec::new(); + // Read with a cap one byte above the limit so oversized streams are still caught by the + // caller instead of exhausting memory here. + s.by_ref().take(MAX_STREAM + 1).read_to_end(&mut v).ok()?; + Some(v) +} + +#[cfg(test)] +mod tests; diff --git a/crates/docbin/src/list.rs b/crates/docbin/src/list.rs new file mode 100644 index 0000000..e7a6196 --- /dev/null +++ b/crates/docbin/src/list.rs @@ -0,0 +1,294 @@ +//! Lists: `PlfLst` (the LSTF definitions with their LVLs appended) and `PlfLfo` (the LFO +//! instances with LFOLVL overrides) resolved into the numbering model +//! ([MS-DOC] §2.8.1 "Determining List Formatting of a Paragraph", §2.9). + +use wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, Num, Numbering}; +use wordcraft_doc::props::{Align, CharProps, ParaProps}; +use wordcraft_doc::section::NumFormat; + +use crate::fib::{Fib, pair}; +use crate::fmt; +use crate::sprm; + +/// Lists (and therefore LFOs) we are willing to read. +const MAX_LISTS: usize = 2_000; +/// Override levels per LFO ([MS-DOC] LFOLVL count is at most 9 by construction). +const MAX_LFO_LVL: usize = 9; + +/// One list definition: an LSTF plus its levels (one if `fSimpleList`, else nine). +struct ListDef { + lsid: i32, + levels: Vec, +} + +/// One LVL, as stored ([MS-DOC] §2.9.182): LVLF fields, the two grpprls and the number text. +struct RawLvl { + start: u32, + nfc: u8, + jc: u8, + flags: u8, + ixch_follow: u8, + rgbxch_nums: [u8; 9], + papx: Vec, + chpx: Vec, + /// The number text with each placeholder left as its raw char. + xst: Vec, +} + +/// A bullet level's character, with the 0xF000 flag bits stripped and the common +/// Symbol/Wingdings glyphs mapped to their Unicode equivalents so every font renders them. +fn bullet_char(c: u16) -> char { + match c & 0x0FFF { + 0xB7 => '\u{2022}', // Symbol bullet + 0xA7 => '\u{25AA}', // Wingdings small square + 0x6F => '\u{25CB}', // Symbol open circle + 0x77 | 0x78 => '\u{25CF}', // Wingdings filled circles + other => char::from_u32(other as u32).unwrap_or('\u{2022}'), + } +} + +/// MSONFC → model number format ([MS-OSHARED] §2.2.1.3; 0x17 = bullets, 0xFF = none). +fn num_format(nfc: u8) -> NumFormat { + match nfc { + 0x01 => NumFormat::UpperRoman, + 0x02 => NumFormat::LowerRoman, + 0x03 => NumFormat::UpperLetter, + 0x04 => NumFormat::LowerLetter, + 0x05 => NumFormat::Ordinal, + 0x06 => NumFormat::CardinalText, + 0x07 => NumFormat::OrdinalText, + 0x0A | 0x0B => NumFormat::DecimalZero, + 0x17 => NumFormat::Bullet, + 0xFF => NumFormat::None, + _ => NumFormat::Decimal, + } +} + +/// Read the lists of a document; empty when the FIB carries none (or they are unreadable). +pub(crate) fn parse(table: &[u8], fib: &Fib, fonts: &[String]) -> Numbering { + let defs = match fib.pair(pair::PLF_LST) { + Some((fc, _)) => parse_plf_lst(table, fc), + None => Vec::new(), + }; + if defs.is_empty() { + return Numbering::default(); + } + let mut numbering = Numbering::default(); + for (i, def) in defs.iter().enumerate() { + numbering.abstracts.push(AbstractNum { + id: i as u32, + name: None, + levels: def.levels.iter().enumerate().map(|(j, l)| level_of(l, j, fonts)).collect(), + }); + } + if let Some((fc, lcb)) = fib.pair(pair::PLF_LFO) + && lcb > 0 + { + parse_plf_lfo(table, fc, &defs, &mut numbering, fonts); + } + numbering +} + +/// `PlfLst`: cLst u16, then cLst × LSTF (28 bytes each); the LVLs follow the whole array, +/// one per simple list and nine per multi-level list, in LSTF order. `lcbPlfLst` covers only +/// the LSTFs, so the LVLs are read past it, bounded by the stream. +fn parse_plf_lst(table: &[u8], fc: u32) -> Vec { + let at = fc as usize; + let Some(c_lst) = table.get(at..at.saturating_add(2)).map(|b| u16::from_le_bytes([b[0], b[1]])) else { return Vec::new() }; + let c_lst = c_lst as usize; + if c_lst == 0 || c_lst > MAX_LISTS { + return Vec::new(); + } + let mut defs = Vec::with_capacity(c_lst); + // LSTF: lsid i32, tplc, rgistdPara[9], flags byte (bit 0 = fSimpleList), grfhic. + let mut counts = Vec::with_capacity(c_lst); + for i in 0..c_lst { + let f = at.saturating_add(2).saturating_add(i.saturating_mul(28)); + let lsid = table.get(f..f.saturating_add(4)).map(|b| i32::from_le_bytes([b[0], b[1], b[2], b[3]])).unwrap_or(-1); + let simple = table.get(f.saturating_add(26)).copied().unwrap_or(0) & 1 != 0; + defs.push(ListDef { lsid, levels: Vec::new() }); + counts.push(if simple { 1 } else { 9 }); + } + let mut off = at.saturating_add(2).saturating_add(c_lst.saturating_mul(28)); + for (def, n) in defs.iter_mut().zip(counts) { + for _ in 0..n { + match parse_lvl(table, off) { + Some((lvl, len)) => { + def.levels.push(lvl); + off = off.saturating_add(len); + } + None => return defs, + } + } + } + defs +} + +/// One LVL ([MS-DOC] §2.9.182): LVLF (28 bytes) + grpprlPapx + grpprlChpx + Xst. +/// Returns the level and its total length in bytes. +fn parse_lvl(table: &[u8], at: usize) -> Option<(RawLvl, usize)> { + let lvlf = table.get(at..at.saturating_add(28))?; + let start = u32::from_le_bytes([lvlf[0], lvlf[1], lvlf[2], lvlf[3]]); + let nfc = lvlf[4]; + // Byte 5 packs jc (bits 0-1), fLegal (bit 2), fNoRestart (bit 3), … + let jc_flags = lvlf[5]; + let mut rgbxch_nums = [0u8; 9]; + rgbxch_nums.copy_from_slice(&lvlf[6..15]); + let ixch_follow = lvlf[15]; + let cb_chpx = lvlf[24] as usize; + let cb_papx = lvlf[25] as usize; + let mut at = at.saturating_add(28); + // Field order in an LVL: lvlf, grpprlPapx, grpprlChpx, then the Xst. + let papx = table.get(at..at.saturating_add(cb_papx)).unwrap_or(&[]).to_vec(); + at = at.saturating_add(cb_papx); + let chpx = table.get(at..at.saturating_add(cb_chpx)).unwrap_or(&[]).to_vec(); + at = at.saturating_add(cb_chpx); + let cch = table.get(at..at.saturating_add(2)).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0); + let cch = cch.min(64) as usize; + let chars = table.get(at.saturating_add(2)..at.saturating_add(2).saturating_add(cch * 2))?; + let xst: Vec = chars.as_chunks::<2>().0.iter().map(|b| u16::from_le_bytes([b[0], b[1]])).collect(); + let len = 28 + cb_chpx + cb_papx + 2 + cch * 2; + Some((RawLvl { start: start.min(0x7FFF), nfc, jc: jc_flags & 3, flags: jc_flags, ixch_follow, rgbxch_nums, papx, chpx, xst }, len)) +} + +/// `PlfLfo`: lfoMac u32, then lfoMac × LFO (16 bytes), then lfoMac × LFOData in parallel. +/// Each LFOData is a CP (skipped) followed by clfolvl × LFOLVL (8 bytes + optional LVL). +fn parse_plf_lfo(table: &[u8], fc: u32, defs: &[ListDef], numbering: &mut Numbering, fonts: &[String]) { + let at = fc as usize; + let Some(lfo_mac) = table.get(at..at.saturating_add(4)).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) else { return }; + let lfo_mac = lfo_mac.min(MAX_LISTS as u32) as usize; + let mut lsids = Vec::with_capacity(lfo_mac); + let mut clfolvls = Vec::with_capacity(lfo_mac); + for i in 0..lfo_mac { + let f = at.saturating_add(4).saturating_add(i.saturating_mul(16)); + let lsid = table.get(f..f.saturating_add(4)).map(|b| i32::from_le_bytes([b[0], b[1], b[2], b[3]])).unwrap_or(-1); + let clfolvl = table.get(f.saturating_add(12)).copied().unwrap_or(0); + lsids.push(lsid); + clfolvls.push(clfolvl); + } + let mut off = at.saturating_add(4).saturating_add(lfo_mac.saturating_mul(16)); + for (i, (&lsid, &clfolvl)) in lsids.iter().zip(&clfolvls).enumerate() { + let Some(def) = defs.iter().position(|d| d.lsid == lsid) else { + // Keep the LFOData walk in step even for unknown lists. + off = skip_lfodatas(table, off, clfolvl); + continue; + }; + // LFOData: cp u32, then clfolvl × LFOLVL. + let mut start_overrides = Vec::new(); + let mut overrides = Vec::new(); + off = off.saturating_add(4); + for _ in 0..(clfolvl as usize).min(MAX_LFO_LVL) { + let Some(hdr) = table.get(off..off.saturating_add(8)) else { break }; + let i_start = u32::from_le_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]); + let flags = hdr[4]; + let lvl = (flags & 0x0F) as usize; + let f_start = flags & 0x10 != 0; + let f_fmt = flags & 0x20 != 0; + off = off.saturating_add(8); + let mut fmt_lvl = None; + if f_fmt { + match parse_lvl(table, off) { + Some((l, len)) => { + fmt_lvl = Some(l); + off = off.saturating_add(len); + } + None => break, + } + } + if f_start { + start_overrides.push((lvl as u8, i_start.min(0x7FFF))); + } + if let Some(l) = fmt_lvl { + overrides.push((lvl, l)); + } + } + // Skip any LFOLVLs we did not consume (clfolvl above the cap). + if clfolvl as usize > MAX_LFO_LVL { + off = skip_lfodatas(table, off, clfolvl - MAX_LFO_LVL as u8); + } + let abstract_id = if overrides.is_empty() { + def as u32 + } else { + // A formatting override replaces whole levels: give the LFO its own abstract + // built from the base levels with the overrides applied. + let mut levels = numbering.abstracts.get(def).map(|a| a.levels.clone()).unwrap_or_default(); + for (lvl, raw) in overrides { + if let Some(slot) = levels.get_mut(lvl) { + *slot = level_of(&raw, lvl, fonts); + } + } + let id = numbering.abstracts.len() as u32; + numbering.abstracts.push(AbstractNum { id, name: None, levels }); + id + }; + numbering.nums.push(Num { id: (i + 1) as u32, abstract_id, start_overrides, ..Default::default() }); + } +} + +/// Advance past `n` LFOLVLs whose bodies we do not need; bounded and best-effort. +fn skip_lfodatas(table: &[u8], mut off: usize, n: u8) -> usize { + for _ in 0..n { + let Some(hdr) = table.get(off..off.saturating_add(8)) else { return off }; + let f_fmt = hdr[4] & 0x20 != 0; + off = off.saturating_add(8); + if f_fmt && let Some((_, len)) = parse_lvl(table, off) { + off = off.saturating_add(len); + } + } + off +} + +/// Turn a stored LVL into a model level. +fn level_of(l: &RawLvl, lvl_index: usize, fonts: &[String]) -> Level { + // Number text: characters listed in rgbxchNums are level placeholders (their value is + // the zero-based level); bullet chars carry 0xF000 flag bits to strip. + let mut text = String::new(); + for (i, &c) in l.xst.iter().enumerate() { + if l.rgbxch_nums.contains(&(i as u8 + 1)) && c <= 8 { + text.push('%'); + text.push((b'1' + c as u8) as char); + } else { + text.push(bullet_char(c)); + } + } + if text.is_empty() { + text.push('\u{2022}'); + } + // Indents from the level's grpprlPapx. + let mut props = ParaProps::default(); + for prl in sprm::iter(&l.papx) { + if prl.sgc() == 1 { + fmt::apply_para(&mut props, &prl); + } + } + let indent = props.indent_left.unwrap_or(18.0 * (lvl_index as f32 + 1.0)).clamp(0.0, 1000.0); + let hanging = props.indent_first.map(|f| (-f).clamp(0.0, 1000.0)).unwrap_or(18.0); + let mut chr = CharProps::default(); + let base = chr.clone(); + for prl in sprm::iter(&l.chpx) { + if prl.sgc() == 2 { + fmt::apply_char(&mut chr, &prl, &base, fonts); + } + } + Level { + start: l.start.max(1), + format: num_format(l.nfc), + text, + align: match l.jc { + 1 => Align::Center, + 2 => Align::Right, + _ => Align::Left, + }, + indent, + hanging, + suffix: match l.ixch_follow { + 1 => LevelSuffix::Space, + 2 => LevelSuffix::Nothing, + _ => LevelSuffix::Tab, + }, + chr, + restart: l.flags & 0x08 == 0, + legal: l.flags & 0x04 != 0, + style: None, + } +} diff --git a/crates/docbin/src/media.rs b/crates/docbin/src/media.rs new file mode 100644 index 0000000..82ca60f --- /dev/null +++ b/crates/docbin/src/media.rs @@ -0,0 +1,123 @@ +//! Inline pictures: `sprmCPicLocation` → a `PICF` in the Data stream → embedded image bytes +//! ([MS-DOC] §2.9.190 PICF, §1.3.5 Pictures). + +use std::collections::{BTreeMap, HashMap}; +use std::ops::Range; +use std::sync::Arc; + +/// `PICF.cbHeader` — the fixed size of the PICF structure. +const CB_HEADER: usize = 0x44; +/// Offsets inside PICF: `PICMID.dxaGoal/dyaGoal/mx/my` start at 28. +const OFF_PICMID: usize = 28; + +/// Most distinct pictures one document materialises from the Data stream. +const MAX_PICTURES: usize = 5_000; +/// Most picture bytes one document copies out of the Data stream, all pictures together. +pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20; + +/// The document's pictures, keyed by their location in the Data stream: anchors that share a +/// `fc_pic` share one media entry, and the total bytes copied are capped, so a file with +/// thousands of anchors on one huge picture costs one copy, not thousands. +pub(crate) struct Pictures { + /// fc_pic → (media key, width, height) of pictures already materialised. + by_fc: HashMap, + /// The media entries, merged into the document at the end. + pub(crate) media: BTreeMap>>, + total: usize, + /// The byte cap (`MAX_MEDIA_BYTES`; smaller in tests). + pub(crate) limit: usize, + /// Anchors whose picture was dropped (only the first few are logged). + dropped: usize, +} + +impl Default for Pictures { + fn default() -> Self { + Pictures { by_fc: HashMap::new(), media: BTreeMap::new(), total: 0, limit: MAX_MEDIA_BYTES, dropped: 0 } + } +} + +impl Pictures { + /// The media key and display size (points) of the picture at `fc_pic`, copying its + /// bytes the first time. `None` when the header is malformed, the payload is a format + /// we do not embed (metafiles), or a picture count/byte cap is reached. + pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<(String, f32, f32)> { + if let Some(hit) = self.by_fc.get(&fc_pic) { + return Some(hit.clone()); + } + if self.by_fc.len() >= MAX_PICTURES { + return None; + } + let pic = locate(data, fc_pic)?; + let bytes = data.get(pic.range)?; + let total = self.total.checked_add(bytes.len()).filter(|&t| t <= self.limit)?; + let key = format!("image{}.{}", self.by_fc.len() + 1, pic.ext); + self.media.insert(key.clone(), Arc::new(bytes.to_vec())); + self.total = total; + self.by_fc.insert(fc_pic, (key.clone(), pic.w, pic.h)); + Some((key, pic.w, pic.h)) + } + + /// Note a picture anchor that could not be materialised. + pub(crate) fn dropped(&mut self, cp: u32) { + if self.dropped < 10 { + log::warn!("docbin: picture at CP {cp} could not be read (or a picture cap was reached) and was dropped"); + } + self.dropped = self.dropped.saturating_add(1); + } +} + +/// Where a picture's image bytes sit in the Data stream, and its display size in points. +struct Located { + w: f32, + h: f32, + range: Range, + ext: &'static str, +} + +/// Locate the picture at `fc_pic` in the Data stream without copying it. +fn locate(data: &[u8], fc_pic: u32) -> Option { + let base = fc_pic as usize; + let pic = data.get(base..)?; + let lcb = u32::from_le_bytes(pic.get(0..4)?.try_into().ok()?) as usize; + let cb_header = u16::from_le_bytes(pic.get(4..6)?.try_into().ok()?) as usize; + let cb_header = if (0x20..=0x100).contains(&cb_header) { cb_header } else { CB_HEADER }; + let m = pic.get(OFF_PICMID..OFF_PICMID + 8)?; + let dxa_goal = i16::from_le_bytes([m[0], m[1]]) as f32; + let dya_goal = i16::from_le_bytes([m[2], m[3]]) as f32; + let mx = u16::from_le_bytes([m[4], m[5]]) as f32; + let my = u16::from_le_bytes([m[6], m[7]]) as f32; + let scale = |r: f32| if r > 0.0 { (r / 1000.0).clamp(0.01, 100.0) } else { 1.0 }; + let w = (dxa_goal / 20.0 * scale(mx)).clamp(1.0, 1584.0); + let h = (dya_goal / 20.0 * scale(my)).clamp(1.0, 1584.0); + let end = lcb.min(pic.len()); + let body = pic.get(cb_header..end)?; + let at = sniff(body)?; + let image = body.get(at..)?; + let start = base.checked_add(cb_header)?.checked_add(at)?; + Some(Located { w, h, range: start..start.checked_add(image.len())?, ext: ext_of(image) }) +} + +/// Offset of the first embedded raster image inside the payload, skipping the small +/// metafile header some writers put in front of PNG/JPEG data. `None` for metafiles. +fn sniff(body: &[u8]) -> Option { + for at in [0usize, 8, 12, 16, 20, 22, 24, 32] { + if let Some(b) = body.get(at..) + && (b.starts_with(b"\x89PNG") || b.starts_with(b"\xFF\xD8\xFF") || b.starts_with(b"GIF8") || b.starts_with(b"BM")) + { + return Some(at); + } + } + None +} + +fn ext_of(b: &[u8]) -> &'static str { + if b.starts_with(b"\x89PNG") { + "png" + } else if b.starts_with(b"\xFF\xD8\xFF") { + "jpeg" + } else if b.starts_with(b"GIF8") { + "gif" + } else { + "bmp" + } +} diff --git a/crates/docbin/src/notes.rs b/crates/docbin/src/notes.rs new file mode 100644 index 0000000..e49c86e --- /dev/null +++ b/crates/docbin/src/notes.rs @@ -0,0 +1,109 @@ +//! Footnotes, endnotes and bookmarks: `PlcffndRef`/`PlcffndTxt`, `PlcfendRef`/`PlcfendTxt`, +//! and `SttbfBkmk` + `Plcfbkf`/`Plcfbkl` ([MS-DOC] §2.9). + +/// Cap on notes and bookmarks per document. +const MAX: usize = 10_000; + +/// Parse the note reference/story PLCs. `ref_pair`/`txt_pair` are the FIB pair indices +/// (`(2, 3)` for footnotes, `(46, 47)` for endnotes); story ranges are relative to the +/// note subdocument. +pub(crate) fn parse_notes(table: &[u8], fib: &crate::fib::Fib, ref_pair: usize, txt_pair: usize) -> Vec<(u32, (u32, u32))> { + let refs = fib.pair(ref_pair).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 2)).unwrap_or_default(); + let txts = fib.pair(txt_pair).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 0)).unwrap_or_default(); + if txts.len() < 2 { + return Vec::new(); + } + let mut out = Vec::new(); + // The reference PLC's aFtnIdx order matches the story order; pair them by index. + for (i, &cp) in refs.iter().enumerate() { + if let (Some(a), Some(b)) = (txts.get(i), txts.get(i + 1)) + && b > a + { + out.push((cp, (*a, *b))); + } + } + out +} + +/// The CPs of a PLC (the trailing element is a limit and is kept so callers can pair +/// range ends) plus the data-element size check. Returns the CPs only. +fn plc_of(table: &[u8], fc: u32, lcb: u32, data_size: usize) -> Option> { + if lcb == 0 { + return None; + } + let plc = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize))?; + if plc.len() < 4 { + return None; + } + // aCP has one more element than aData: n = (len - data_size) / (4 + data_size). + let n = (plc.len() - data_size) / (4 + data_size); + if n == 0 || n > MAX { + return None; + } + let mut cps = Vec::with_capacity(n.min(MAX)); + for i in 0..=n.min(MAX) { + match plc.get(i * 4..i * 4 + 4) { + Some(b) => cps.push(u32::from_le_bytes([b[0], b[1], b[2], b[3]])), + None => break, + } + } + Some(cps) +} + +/// A bookmark boundary to insert while walking. +pub(crate) enum Mark { + Start(String), + End(String), +} + +/// Parse `SttbfBkmk` + `Plcfbkf` + `Plcfbkl` into (cp, event) pairs sorted for a sequential +/// walk. Hidden bookmarks (leading `_`) are kept: the engine can decide. +pub(crate) fn parse_bookmarks(table: &[u8], fib: &crate::fib::Fib) -> Vec<(u32, Mark)> { + let names = fib.pair(crate::fib::pair::STTBF_BKMK).and_then(|(fc, lcb)| sttb_strings(table, fc, lcb)).unwrap_or_default(); + let starts = fib.pair(crate::fib::pair::PLCF_BKF).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 4)).unwrap_or_default(); + let ends = fib.pair(crate::fib::pair::PLCF_BKL).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 2)).unwrap_or_default(); + let n = starts.len().saturating_sub(1).min(ends.len().saturating_sub(1)).min(names.len()).min(MAX); + let mut out = Vec::with_capacity(n * 2); + for i in 0..n { + let (Some(&a), Some(&b)) = (starts.get(i), ends.get(i)) else { break }; + if b < a { + continue; + } + if let Some(name) = names.get(i) { + if name.is_empty() { + continue; + } + out.push((a, Mark::Start(name.clone()))); + out.push((b, Mark::End(name.clone()))); + } + } + out.sort_by_key(|(cp, m)| (*cp, matches!(m, Mark::End(_)))); + out +} + +/// Strings of an extended STTB (fExtend = 0xFFFF, 2-byte chars, no extra data). +fn sttb_strings(table: &[u8], fc: u32, lcb: u32) -> Option> { + let b = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize))?; + if b.len() < 6 { + return None; + } + let c_data = u16::from_le_bytes([b[2], b[3]]) as usize; + if c_data > MAX { + return None; + } + let mut at = 6usize; + let mut out = Vec::with_capacity(c_data); + for _ in 0..c_data { + let cch = match b.get(at..at + 2) { + Some(x) => u16::from_le_bytes([x[0], x[1]]) as usize, + None => break, + }; + at += 2; + let cch = cch.min(40); + let chars = b.get(at..at + cch * 2)?; + at += cch * 2; + let s: String = chars.as_chunks::<2>().0.iter().map(|c| char::from_u32(u16::from_le_bytes(*c) as u32).unwrap_or('\u{FFFD}')).collect(); + out.push(s); + } + Some(out) +} diff --git a/crates/docbin/src/piece.rs b/crates/docbin/src/piece.rs new file mode 100644 index 0000000..e0b8622 --- /dev/null +++ b/crates/docbin/src/piece.rs @@ -0,0 +1,187 @@ +//! The piece table ([MS-DOC] `Clx`/`Pcdt`/`PlcPcd`): where every character position lives in +//! the WordDocument stream, and how it is encoded. + +use wordcraft_doc::encoding::cp1252; + +use crate::DocbinError; + +/// Most pieces any real file has; more means a corrupt `PlcPcd`. +const MAX_PIECES: usize = 1_000_000; + +/// One piece: text at FcCompressed `fc` (30-bit offset + compression bit). +#[derive(Clone, Copy, Debug)] +struct Pcd { + fc: u32, + compressed: bool, + /// `Prm`: either a single sprm (Prm0) or an index into the Clx's Prc grpprls (Prm1). + prm: u16, +} + +/// The parsed piece table plus the grpprls of the Prcs that Prm1 references. +pub(crate) struct PieceTable { + /// n+1 character-position starts. + cps: Vec, + pieces: Vec, + prc_grpprls: Vec>, +} + +impl PieceTable { + /// Parse the `Clx` at `table[fc .. fc+lcb]`. + pub(crate) fn parse(table: &[u8], fc: u32, lcb: u32) -> Result { + let fc = fc as usize; + let lcb = lcb as usize; + if lcb == 0 { + return Err(DocbinError::Malformed("empty Clx".into())); + } + let end = fc.checked_add(lcb).ok_or_else(|| DocbinError::Malformed("Clx offset overflow".into()))?; + let clx = table.get(fc..end).ok_or_else(|| DocbinError::Malformed(format!("Clx at {fc}+{lcb} is outside the Table stream")))?; + // A Clx is an array of Prc (clxt 0x01, each holding a grpprl that Prm1 values can + // reference) followed by the Pcdt (clxt 0x02) that holds the piece table. + let mut prc_grpprls = Vec::new(); + let mut i = 0usize; + let plc = loop { + let Some(&clxt) = clx.get(i) else { + return Err(DocbinError::Malformed("Clx ends before the Pcdt".into())); + }; + match clxt { + 0x01 => { + let cb = clx + .get(i + 1..i + 3) + .map(|b| u16::from_le_bytes([b[0], b[1]]) as usize) + .ok_or_else(|| DocbinError::Malformed("truncated Prc".into()))?; + let stop = i.checked_add(3).and_then(|v| v.checked_add(cb)).ok_or_else(|| DocbinError::Malformed("Prc size overflow".into()))?; + if let Some(g) = clx.get(i + 3..stop) + && prc_grpprls.len() < MAX_PIECES + { + prc_grpprls.push(g.to_vec()); + } + i = stop; + } + 0x02 => { + let lcb_plc = clx + .get(i + 1..i + 5) + .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize) + .ok_or_else(|| DocbinError::Malformed("truncated Pcdt".into()))?; + let start = i.checked_add(5).ok_or_else(|| DocbinError::Malformed("Pcdt offset overflow".into()))?; + let stop = start.checked_add(lcb_plc).ok_or_else(|| DocbinError::Malformed("Pcdt size overflow".into()))?; + break clx.get(start..stop).ok_or_else(|| DocbinError::Malformed("PlcPcd is outside the Clx".into()))?; + } + other => return Err(DocbinError::Malformed(format!("unexpected Clx item 0x{other:02X}"))), + } + }; + // PlcPcd: aCp[n+1] u32s followed by aPcd[n] Pcds (8 bytes each). + if plc.len() < 4 || (plc.len() - 4) % 12 != 0 { + return Err(DocbinError::Malformed(format!("PlcPcd is {} bytes", plc.len()))); + } + let n = (plc.len() - 4) / 12; + if n > MAX_PIECES { + return Err(DocbinError::Limit(format!("piece table has {n} pieces"))); + } + let mut cps = Vec::with_capacity(n + 1); + for j in 0..=n { + let at = j * 4; + let Some(b) = plc.get(at..at + 4) else { + return Err(DocbinError::Malformed("truncated aCp".into())); + }; + // Keep the CPs non-decreasing so binary search over them is sound: an inverted + // piece in a corrupt file becomes an empty one. + let c = u32::from_le_bytes([b[0], b[1], b[2], b[3]]); + cps.push(c.max(cps.last().copied().unwrap_or(0))); + } + let mut pieces = Vec::with_capacity(n); + for j in 0..n { + let at = (n + 1) * 4 + j * 8 + 2; + let Some(fc_raw) = plc.get(at..at + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) else { + return Err(DocbinError::Malformed("truncated aPcd".into())); + }; + let prm = plc.get(at + 4..at + 6).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0); + pieces.push(Pcd { fc: fc_raw & 0x3FFF_FFFF, compressed: fc_raw & 0x4000_0000 != 0, prm }); + } + Ok(PieceTable { cps, pieces, prc_grpprls }) + } + + /// The Prls this piece's `Prm` adds: Prm0 holds one short sprm (isprm, val), Prm1 + /// references a grpprl from one of the Clx's Prcs ([MS-DOC] Prm). + pub(crate) fn piece_prm(&self, cp: u32) -> PrmRef<'_> { + match self.piece_of(cp) { + Some(p) if p.prm & 0x8000 == 0 && (p.prm & 0x7F) != 0 => PrmRef::Sprm0 { isprm: (p.prm & 0x7F) as u8, val: (p.prm >> 8) as u8 }, + Some(p) if p.prm & 0x8000 != 0 => PrmRef::Grpprl(self.prc_grpprls.get((p.prm & 0x7FFF) as usize).map(|v| v.as_slice())), + _ => PrmRef::None, + } + } + + fn piece_of(&self, cp: u32) -> Option<&Pcd> { + let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?; + if i >= self.pieces.len() { + return None; + } + self.pieces.get(i) + } + + /// The FC of a character position in the space the FKP bin tables use: true byte + /// offsets into the WordDocument stream. A compressed piece stores its base `fc` + /// doubled (`byte = fc/2`), so the doubling unwinds here; an uncompressed piece's `fc` + /// already is its byte offset and each character covers two bytes. + pub(crate) fn fc_of_cp(&self, cp: u32) -> Option { + let p = self.piece_of(cp)?; + let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?; + let cp0 = *self.cps.get(i)?; + let step = cp.checked_sub(cp0)?.checked_mul(2)?; + if p.compressed { p.fc.checked_add(step)?.checked_div(2) } else { p.fc.checked_add(step) } + } + + /// The CP just past the last piece: no text exists at or beyond it. + pub(crate) fn cp_end(&self) -> u32 { + self.cps.last().copied().unwrap_or(0) + } + + /// The character stored at `cp` (control characters included) and how many CPs it + /// covers (2 for a surrogate pair), found by binary search over the pieces. + /// `Err(next)` when there is no readable character at `cp` (a gap between pieces, an + /// inverted piece, or piece bytes past the end of the stream): `next` is the CP to + /// resume at, always greater than `cp` (`u32::MAX` past the last piece), so callers + /// skip an unreadable range in one step instead of one CP at a time. + pub(crate) fn char_at(&self, word: &[u8], cp: u32) -> Result<(char, u32), u32> { + let i = self.cps.partition_point(|&c| c <= cp); + // The next piece boundary above `cp`, or the end of the CP space. + let next = self.cps.get(i).copied().filter(|&n| n > cp).unwrap_or(u32::MAX); + let Some(i) = i.checked_sub(1) else { return Err(next) }; + let (Some(p), Some(&cp0)) = (self.pieces.get(i), self.cps.get(i)) else { return Err(u32::MAX) }; + let Some(off) = cp.checked_sub(cp0) else { return Err(next) }; + let off = off as usize; + if p.compressed { + // 8-bit, Windows-1252, one byte per character, stored at fc/2. + let at = ((p.fc / 2) as usize).checked_add(off); + return match at.and_then(|a| word.get(a)) { + Some(&b) => Ok((cp1252(b), 1)), + None => Err(next), + }; + } + // UTF-16LE, one 16-bit unit per character, stored at fc. + let unit = |k: usize| -> Option { + let at = (p.fc as usize).checked_add(k.checked_mul(2)?)?; + word.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])) + }; + let Some(u) = unit(off) else { return Err(next) }; + if (0xD800..0xDC00).contains(&u) + && cp.saturating_add(1) < next + && let Some(lo) = unit(off.saturating_add(1)).filter(|lo| (0xDC00..0xE000).contains(lo)) + && let Some(Ok(c)) = char::decode_utf16([u, lo]).next() + { + return Ok((c, 2)); + } + Ok((char::from_u32(u as u32).unwrap_or('\u{FFFD}'), 1)) + } +} + +/// What a piece's `Prm` refers to. +pub(crate) enum PrmRef<'a> { + None, + /// Prm0: a short sprm index into the isprm table, with a 1-byte operand. + Sprm0 { + isprm: u8, + val: u8, + }, + /// Prm1: a grpprl from a Prc (absent if out of range). + Grpprl(Option<&'a [u8]>), +} diff --git a/crates/docbin/src/sections.rs b/crates/docbin/src/sections.rs new file mode 100644 index 0000000..6303029 --- /dev/null +++ b/crates/docbin/src/sections.rs @@ -0,0 +1,177 @@ +//! Sections and headers/footers: `PlcfSed` → `Sed` → `Sepx` (grpprls of section sprms) and +//! the `Plcfhdd` stories of the header subdocument ([MS-DOC] §2.8.25, §2.9.244). + +use wordcraft_doc::section::NumFormat; +use wordcraft_doc::section::{Columns, SectionProps, SectionStart}; + +use crate::fib::{Fib, pair}; +use crate::sprm::{self, Prl}; + +/// Most sections we read. +const MAX_SECTIONS: usize = 1024; + +// Section sprms ([MS-DOC] §2.6.4). +const S_BKC: u16 = 0x3009; +const S_TITLE_PAGE: u16 = 0x300A; // sprmSFTitlePage +const S_CCOLUMNS: u16 = 0x500B; +const S_PGN_START: u16 = 0x300C; // sprmSPgnStart +const S_ORIENTATION: u16 = 0x301D; // sprmSBOrientation +const S_HDR_TOP: u16 = 0xB017; +const S_HDR_BOTTOM: u16 = 0xB018; +const S_XA_PAGE: u16 = 0xB01F; +const S_YA_PAGE: u16 = 0xB020; +const S_DXA_LEFT: u16 = 0xB021; +const S_DXA_RIGHT: u16 = 0xB022; +const S_DYA_TOP: u16 = 0x9023; +const S_DYA_BOTTOM: u16 = 0x9024; +const S_DXA_GUTTER: u16 = 0xB025; + +/// One section: covers paragraphs up to (excluding) `end_cp`, with its properties. +pub(crate) struct Section { + pub(crate) end_cp: u32, + pub(crate) props: SectionProps, +} + +/// Parse `PlcfSed` and each section's `Sepx` grpprl. +pub(crate) fn parse(word: &[u8], table: &[u8], fib: &Fib) -> Vec
{ + let Some((fc, lcb)) = fib.pair(pair::PLCF_SED) else { return Vec::new() }; + let Some(plc) = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize)) else { return Vec::new() }; + // PlcSed: aCp[n+1] u32s + aSed[n] (Sed = fn 2, fcSepx 4, fnMpr 2, fcMpr 4). + if plc.len() < 4 || (plc.len() - 4) % 16 != 0 { + return Vec::new(); + } + let n = (plc.len() - 4) / 16; + let cp_at = |i: usize| -> Option { plc.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) }; + let mut out = Vec::with_capacity(n.min(MAX_SECTIONS)); + for i in 0..n.min(MAX_SECTIONS) { + let Some(end_cp) = cp_at(i + 1) else { break }; + let sed_at = (n + 1) * 4 + i * 12; + let fc_sepx = plc.get(sed_at + 2..sed_at + 6).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])).unwrap_or(0xFFFF_FFFF); + let mut props = SectionProps::default(); + // Sepx: cb u16, then a grpprl of section sprms. + let mut explicit_orient = false; + if let Some(grpprl) = + word.get(fc_sepx as usize..).and_then(|sepx| sepx.get(..2).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize)).and_then(|cb| { + let at = (fc_sepx as usize).checked_add(2)?; + word.get(at..at.checked_add(cb)?) + }) + { + for prl in sprm::iter(grpprl) { + if prl.op == S_ORIENTATION { + explicit_orient = true; + } + apply_section(&mut props, &prl); + } + } + // Without an explicit orientation sprm, infer it from the page shape. + if !explicit_orient { + if props.page_w < props.page_h { + props.landscape = false; + } else if props.page_w > props.page_h { + props.landscape = true; + } + } + out.push(Section { end_cp, props }); + } + out +} + +/// Apply a section sprm to the model's section properties. All the measurement sprms here +/// carry 16-bit twip operands ([MS-DOC] §2.6.4). +fn apply_section(s: &mut SectionProps, prl: &Prl) { + let tw = |v: i32| (v as f32 / 20.0).clamp(0.0, 1584.0); + let s16 = |prl: &Prl| -> Option { + match prl.operand { + [b0, b1, ..] => Some(i16::from_le_bytes([*b0, *b1])), + _ => None, + } + }; + match prl.op { + S_BKC => { + s.start = match prl.operand.first().copied().unwrap_or(2) { + 0 => SectionStart::Continuous, + 1 => SectionStart::EvenPage, + 2 => SectionStart::OddPage, + _ => SectionStart::NextPage, + }; + } + S_TITLE_PAGE => s.title_page = prl.operand.first().is_some_and(|&v| v != 0), + S_CCOLUMNS => { + if let Some(c) = s16(prl) { + s.columns = Columns { count: (c as u32).saturating_add(1).min(44), ..Default::default() }; + } + } + S_PGN_START => { + if let Some(v) = s16(prl) { + s.page_num_start = Some(v.max(0) as u32); + s.page_num_format = NumFormat::Decimal; + } + } + S_ORIENTATION => s.landscape = prl.operand.first() == Some(&1), + S_HDR_TOP => { + if let Some(v) = s16(prl) { + s.header = tw(v as i32); + } + } + S_HDR_BOTTOM => { + if let Some(v) = s16(prl) { + s.footer = tw(v as i32); + } + } + S_XA_PAGE => { + if let Some(v) = s16(prl) { + s.page_w = tw(v as i32).max(7.2); + } + } + S_YA_PAGE => { + if let Some(v) = s16(prl) { + s.page_h = tw(v as i32).max(7.2); + } + } + S_DXA_LEFT => { + if let Some(v) = s16(prl) { + s.margin_left = tw(v as i32); + } + } + S_DXA_RIGHT => { + if let Some(v) = s16(prl) { + s.margin_right = tw(v as i32); + } + } + S_DYA_TOP => { + if let Some(v) = s16(prl) { + s.margin_top = tw(v as i32); + } + } + S_DYA_BOTTOM => { + if let Some(v) = s16(prl) { + s.margin_bottom = tw(v as i32); + } + } + S_DXA_GUTTER => { + if let Some(v) = s16(prl) { + s.gutter = tw(v as i32); + } + } + _ => {} + } +} + +/// The header stories' CP ranges from `Plcfhdd` (a PLC of bare CPs): story i spans +/// `[cps[i], cps[i+1])`. Stories 0-5 are footnote/endnote separators; every section then owns +/// six stories in the order even header, odd (default) header, even footer, odd (default) +/// footer, first header, first footer. The CPs are relative to the header subdocument. +pub(crate) fn header_stories(table: &[u8], fib: &Fib) -> Vec<(u32, u32)> { + let Some((fc, lcb)) = fib.pair(pair::PLCF_HDD) else { return Vec::new() }; + let Some(plc) = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize)) else { return Vec::new() }; + // Only the six separator stories and six per section (at most 1024) are ever used. + let n = (lcb as usize / 4).min(6 + 6 * MAX_SECTIONS + 1); + let cp_at = |i: usize| -> Option { plc.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) }; + let mut stories = Vec::with_capacity(n); + for i in 0..n.saturating_sub(1) { + if let (Some(a), Some(b)) = (cp_at(i), cp_at(i + 1)) { + stories.push((a, b.max(a))); + } + } + stories +} diff --git a/crates/docbin/src/sprm.rs b/crates/docbin/src/sprm.rs new file mode 100644 index 0000000..aec6218 --- /dev/null +++ b/crates/docbin/src/sprm.rs @@ -0,0 +1,93 @@ +//! Sprm decoding ([MS-DOC] §2.6). A Prl is a 2-byte opcode followed by an operand whose +//! size comes from the opcode's `spra` field (with two variable-length exceptions). + +/// `sprmPChgTabs`: custom tab stops ([MS-DOC] §2.6.29 — handled in a later pass). +const P_CHG_TABS: u16 = 0xC615; +/// `sprmTDefTable`: table cell borders and widths. +const T_DEF_TABLE: u16 = 0xD608; + +/// One decoded Prl: an opcode plus its operand bytes. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Prl<'a> { + pub(crate) op: u16, + pub(crate) operand: &'a [u8], +} + +impl Prl<'_> { + /// Which kind of property this sprm modifies: 1 paragraph, 2 character, 3 picture, + /// 4 section, 5 table ([MS-DOC] Sprm.sgc). + pub(crate) fn sgc(&self) -> u8 { + ((self.op >> 10) & 7) as u8 + } +} + +/// Iterate the Prls of a grpprl. Malformed tails are dropped. +pub(crate) fn iter(grpprl: &[u8]) -> impl Iterator> { + let mut rest = grpprl; + std::iter::from_fn(move || { + if rest.len() < 2 { + return None; + } + let op = u16::from_le_bytes([rest[0], rest[1]]); + let after = &rest[2..]; + // Operand size per spra, with the exceptions whose size byte lies. + let len = match (op >> 13, op) { + (_, P_CHG_TABS) => after.get(..2).map(|b| 2 + u16::from_le_bytes([b[0], b[1]]) as usize), + // TDefTableOperand.cb counts the bytes after cb, incremented by 1, so the + // operand is one byte longer than cb. + (_, T_DEF_TABLE) => after.get(..2).map(|b| 1 + u16::from_le_bytes([b[0], b[1]]) as usize), + (0, _) | (1, _) => Some(1), + (2, _) | (4, _) | (5, _) => Some(2), + (3, _) => Some(4), + (7, _) => Some(3), + (6, _) => after.first().map(|&n| 1 + n as usize), + _ => None, + }?; + let operand = after.get(..len)?; + rest = &after[len.min(after.len())..]; + Some(Prl { op, operand }) + }) +} + +/// A ToggleOperand value: 0 = off, 1 = on, 0x80 = leave unchanged, 0x81 = invert. +pub(crate) enum Toggle { + Off, + On, + Keep, + Invert, +} + +pub(crate) fn toggle(p: &Prl) -> Toggle { + match p.operand.first().copied().unwrap_or(0) { + 0x81 => Toggle::Invert, + 0x80 => Toggle::Keep, + 0x00 => Toggle::Off, + _ => Toggle::On, + } +} + +/// The isprm indices of Prm0 that map to sprms we understand ([MS-DOC] §2.9.215 table). +pub(crate) fn from_prm0(isprm: u8) -> Option { + Some(match isprm { + 0x05 => 0x2461, // sprmPJc + 0x07 => 0x2405, // sprmPFKeep + 0x08 => 0x2406, // sprmPFKeepFollow + 0x09 => 0x2407, // sprmPFPageBreakBefore + 0x33 => 0x2435, // sprmPWidowControl (low byte of the operand is the value) + 0x78 => 0x2640, // sprmPOutLvl + 0x4D => 0x2A0C, // sprmCHighlight + 0x55 => 0x0835, // sprmCFBold + 0x56 => 0x0836, // sprmCFItalic + 0x57 => 0x0837, // sprmCFStrike + 0x58 => 0x0838, // sprmCFOutline + 0x59 => 0x0839, // sprmCFShadow + 0x5A => 0x083A, // sprmCFSmallCaps + 0x5B => 0x083B, // sprmCFCaps + 0x5C => 0x083C, // sprmCFVanish + 0x5E => 0x2A3E, // sprmCKul + 0x62 => 0x2A42, // sprmCIco + 0x74 => 0x0854, // sprmCFImprint + 0x75 => 0x0855, // sprmCFSpec + _ => return None, + }) +} diff --git a/crates/docbin/src/stsh.rs b/crates/docbin/src/stsh.rs new file mode 100644 index 0000000..ec8bf7a --- /dev/null +++ b/crates/docbin/src/stsh.rs @@ -0,0 +1,163 @@ +//! The stylesheet (`STSH`) and the font table (`SttbfFfn`) from the Table stream +//! ([MS-DOC] §2.9.271 and §2.9.247). + +use crate::DocbinError; + +/// Largest stylesheet we accept (styles are 0x0FFE max by the spec; stay stricter). +const MAX_STYLES: usize = 4096; + +/// One raw style record (`STD`). +#[derive(Clone, Debug, Default)] +pub(crate) struct RawStyle { + /// 1 paragraph, 2 character, 3 table, 4 numbering. + pub(crate) stk: u8, + /// 0x0FFF = none. + pub(crate) istd_base: u16, + pub(crate) name: String, + /// Paragraph-formatting UPX grpprl (paragraph styles). + pub(crate) papx: Vec, + /// Character-formatting UPX grpprl (paragraph and character styles). + pub(crate) chpx: Vec, +} + +/// Parse the `STSH` at `table[fc..fc+lcb]`. +pub(crate) fn parse(table: &[u8], fc: u32, lcb: u32) -> Result, DocbinError> { + let fc = fc as usize; + let lcb = lcb as usize; + let end = fc.checked_add(lcb).ok_or_else(|| DocbinError::Malformed("STSH overflow".into()))?; + let stsh = table.get(fc..end).ok_or_else(|| DocbinError::Malformed("STSH outside the Table stream".into()))?; + if stsh.len() < 4 { + return Ok(Vec::new()); + } + // LPStshi: cbStshi u16 + Stshi; the style count and the size of each Stdf are what we need. + let cb_stshi = u16::from_le_bytes([stsh[0], stsh[1]]) as usize; + let stshi = stsh.get(2..2 + cb_stshi).unwrap_or(&[]); + if stshi.len() < 4 { + return Ok(Vec::new()); + } + let cstd = u16::from_le_bytes([stshi[0], stshi[1]]) as usize; + let cb_std_base = u16::from_le_bytes([stshi[2], stshi[3]]) as usize; + // 10 (Word 97) or 18 (with StdfPost2000); anything else is corrupt but tolerated at 10. + let cb_std_base = if cb_std_base == 0x12 { 18 } else { 10 }; + let cstd = cstd.min(MAX_STYLES); + let mut rest = stsh.get(2 + cb_stshi..).unwrap_or(&[]); + let mut out = Vec::with_capacity(cstd); + for _ in 0..cstd { + if rest.len() < 2 || out.len() >= MAX_STYLES { + break; + } + let cb_std = u16::from_le_bytes([rest[0], rest[1]]) as usize; + let (std, next) = rest.split_at(rest.len().min(2 + cb_std)); + rest = next; + if cb_std == 0 { + out.push(RawStyle::default()); // empty style: keeps istd numbering aligned + continue; + } + let Some(stdf) = std.get(2..2 + cb_std_base.min(cb_std.saturating_sub(2))) else { + out.push(RawStyle::default()); + continue; + }; + // StdfBase (10 bytes): sti(12)+flags(4); stk(4)+istdNext(12); cupx(4)+istdBase(12); + // bchUpe(16) (= cbStd); grfstd(16). Validated against Word's own files: bchUpe equals + // cbStd and style 0 is sti 0 ("Normal"). + let stk = (stdf.get(2).copied().unwrap_or(1) & 0x0F).max(1); + let w1 = stdf.get(2..4).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0xFFF1); + let _istd_next = w1 >> 4; // next-paragraph style; unused for now + let w2 = stdf.get(4..6).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0); + let cupx = (w2 & 0x0F) as usize; + let istd_base = w2 >> 4; + let mut at = 2 + cb_std_base; + // xstzName: Xst (cch u16 + UTF-16LE) + terminator u16. + let mut name = String::new(); + if let Some(cch) = std.get(at..at + 2).map(|b| u16::from_le_bytes([b[0], b[1]])).map(|c| (c as usize).min(256)) { + if let Some(units) = std.get(at + 2..at + 2 + cch * 2) { + let chars: Vec = units.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(); + name = String::from_utf16_lossy(&chars); + } + at += 2 + cch * 2 + 2; + } + // grLPUpxSw: cupx entries, each cbUpx u16 + data (+ pad to even). + let mut upxs: Vec> = Vec::new(); + for _ in 0..cupx.min(3) { + let Some(cb) = std.get(at..at + 2).map(|b| u16::from_le_bytes([b[0], b[1]])).map(|v| v as usize) else { break }; + let data = std.get(at + 2..at + 2 + cb).unwrap_or(&[]).to_vec(); + upxs.push(data); + at += 2 + cb + (cb & 1); // padded to even; cb in the header excludes the pad + } + // Paragraph styles: UPX0 is a PAPX (istd u16 + grpprl), UPX1 a raw grpprl of + // character sprms; character styles: UPX0 is the character grpprl. + let mut papx = Vec::new(); + let mut chpx = Vec::new(); + if stk == 1 { + if let Some(u) = upxs.first() { + papx = u.get(2..).unwrap_or(&[]).to_vec(); // skip the istd of GrpPrlAndIstd + } + if let Some(u) = upxs.get(1) { + chpx = u.clone(); + } + } else if stk == 2 + && let Some(u) = upxs.first() + { + chpx = u.clone(); + } + out.push(RawStyle { stk, istd_base, name, papx, chpx }); + } + Ok(out) +} + +/// Font names from the `SttbfFfn` at `table[fc..fc+lcb]`: an STTB of FFN records. Extended +/// tables carry a 2-byte length per record; non-extended ones rely on each FFN's NUL- +/// terminated name ([MS-DOC] §2.9.243/§2.9.247). +pub(crate) fn fonts(table: &[u8], fc: u32, lcb: u32) -> Vec { + let end = match (fc as usize).checked_add(lcb as usize) { + Some(e) if e <= table.len() => e, + _ => return Vec::new(), + }; + let sttb = &table[fc as usize..end]; + let u16at = |o: usize| -> Option { sttb.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]])) }; + let Some(first) = u16at(0) else { return Vec::new() }; + // FFN header: ffid(1) wWeight(2) ixchSzAlt(2) chs(1) panose(10) fs(24) = 40 bytes, then + // the name, NUL-terminated, as UTF-16LE in every extended table and in the Word 97 files + // we accept from non-extended ones. + let name_of = |ffn: &[u8]| -> String { + let tail = ffn.get(40.min(ffn.len())..).unwrap_or(&[]); + let units: Vec = tail.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).take_while(|&u| u != 0).take(64).collect(); + let s = String::from_utf16_lossy(&units); + if s.chars().any(|c| c as u32 > 0x7F || c.is_ascii_graphic() || c == ' ') || s.is_empty() { + s + } else { + // An 8-bit name reads as garbage through UTF-16; fall back to Windows-1252. + tail.iter().take_while(|&&b| b != 0).take(64).map(|&b| wordcraft_doc::encoding::cp1252(b)).collect() + } + }; + let mut out = Vec::new(); + if first == 0xFFFF { + // Extended: fExtend, cbExtra, cData, then cbData + FFN + extra per record. + let cdata = u16at(4).unwrap_or(0).min(1024) as usize; + let mut at = 6usize; + for _ in 0..cdata { + let Some(cb) = u16at(at).map(|v| v as usize) else { break }; + if let Some(ffn) = sttb.get(at + 2..at + 2 + cb) { + out.push(name_of(ffn)); + } + at += 2 + cb; + } + } else { + // Non-extended: cData, cbExtra (0 for Ffn tables), then self-delimiting FFNs. + let cdata = first.min(1024) as usize; + let cb_extra = u16at(2).unwrap_or(0) as usize; + let mut at = 4usize; + for _ in 0..cdata { + let rest = sttb.get(at..).unwrap_or(&[]); + if rest.is_empty() { + break; + } + // The name ends at the UTF-16 NUL; keep a cap for hostile inputs. + let name_len = rest[40.min(rest.len())..].as_chunks::<2>().0.iter().position(|c| c == &[0, 0]).unwrap_or(64).saturating_mul(2); + let rec_len = 40 + name_len + 2; + out.push(name_of(rest)); + at += rec_len + cb_extra; + } + } + out +} diff --git a/crates/docbin/src/table.rs b/crates/docbin/src/table.rs new file mode 100644 index 0000000..1452140 --- /dev/null +++ b/crates/docbin/src/table.rs @@ -0,0 +1,414 @@ +//! Tables: the table sprms (sgc 5) carried by a row-end (TTP) mark's grpprl, and the +//! assembly of cell-ended paragraphs into [`Table`] blocks ([MS-DOC] §2.6.3, §2.4.3). + +use std::sync::Arc; + +use wordcraft_doc::Rgb; +use wordcraft_doc::para::Paragraph; +use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge}; +use wordcraft_doc::table::{Cell, Row, Table}; +use wordcraft_doc::{Block, Blocks, para_block}; + +use crate::fmt; +use crate::sprm::{self, Prl}; + +// Paragraph sprms for table membership. +const P_IN_TABLE: u16 = 0x2416; +const P_TTP: u16 = 0x2417; +/// `sprmPItap`: table depth (we only model depth 1; deeper rows fall back to paragraphs). +const P_ITAP: u16 = 0x6649; + +// Table sprms ([MS-DOC] §2.6.3). +const T_JC: u16 = 0x5400; +const T_JC_90: u16 = 0x548A; +const T_DXA_LEFT: u16 = 0x9601; +const T_DXA_GAP_HALF: u16 = 0x9602; +const T_TABLE_HEADER: u16 = 0x3404; +const T_TABLE_BORDERS_80: u16 = 0xD605; +const T_ROW_HEIGHT: u16 = 0x9407; +const T_DEF_TABLE: u16 = 0xD608; +const T_DEF_TABLE_SHD_80: u16 = 0xD609; +const T_CANT_SPLIT_90: u16 = 0x3403; +const T_MERGE: u16 = 0x5624; +const T_VERT_MERGE: u16 = 0xD62B; +const T_VERT_ALIGN: u16 = 0xD62C; +const T_SET_SHD: u16 = 0xD62D; +const T_CANT_SPLIT: u16 = 0x3466; +const T_CELL_WIDTH: u16 = 0xD635; + +/// Most grid columns we build from a `sprmTDefTable` (the model's own cap is 63; one more +/// edge is fine but merged rows can sum spans slightly over, so clamp when assembling). +const MAX_COLS: usize = 63; + +/// What a paragraph mark says about its table context, from its PAPX. +#[derive(Default)] +pub(crate) struct RowInfo { + pub(crate) in_table: bool, + pub(crate) ttp: bool, + /// The mark itself was 0x07 (a cell end), set by the walker. + pub(crate) cell_mark: bool, + /// Table depth from `sprmPItap` (0 or 1 supported; deeper tables degrade to paragraphs). + pub(crate) depth: u32, + /// Column widths (points) from `sprmTDefTable`. + pub(crate) grid: Vec, + height: Option, + height_rule: HeightRule, + header: bool, + cant_split: bool, + align: Option, + indent: Option, + borders: Option, + /// Horizontal merges: (itcFirst, itcLim) cell-index ranges. + merges: Vec<(u8, u8)>, + /// Per-cell overrides keyed by (pre-merge) cell index. + vmerge: Vec<(u8, VMerge)>, + shading: Vec<(u8, Rgb)>, + widths: Vec<(u8, f32)>, + valign: Vec<(u8, VAlign)>, +} + +/// Read the table context out of a paragraph mark's PAPX grpprl. +impl RowInfo { + /// The row height from `sprmTDyaRowHeight`, for tests. + #[cfg(test)] + pub(crate) fn height(&self) -> Option { + self.height + } +} + +pub(crate) fn decode(papx: &[u8]) -> RowInfo { + let mut r = RowInfo::default(); + let mut gap_half: Option = None; + let mut dxa_left: Option = None; + for prl in sprm::iter(papx) { + // The membership sprms are paragraph sprms (sgc 1); the rest here are table sprms + // (sgc 5), so match opcodes directly. + match prl.op { + P_IN_TABLE => r.in_table = prl.operand.first().copied().unwrap_or(0) != 0, + P_TTP => r.ttp = prl.operand.first().copied().unwrap_or(0) != 0, + P_ITAP => r.depth = i32_of(&prl).unwrap_or(0).max(0) as u32, + T_DEF_TABLE => r.grid = def_table_grid(prl.operand), + T_TABLE_HEADER => r.header = prl.operand.first().copied().unwrap_or(0) != 0, + T_CANT_SPLIT | T_CANT_SPLIT_90 => r.cant_split = prl.operand.first().copied().unwrap_or(0) != 0, + T_ROW_HEIGHT => { + if let Some(v) = s16(&prl) { + if v < 0 { + r.height_rule = HeightRule::Exact; + r.height = Some((-(v as f32) / 20.0).clamp(0.0, 1584.0)); + } else { + r.height_rule = HeightRule::AtLeast; + r.height = Some((v as f32 / 20.0).clamp(0.0, 1584.0)); + } + } + } + T_JC | T_JC_90 => { + r.align = match u16_of(&prl).unwrap_or(0) { + 1 => Some(Align::Center), + 2 => Some(Align::Right), + _ => Some(Align::Left), + }; + } + T_DXA_LEFT => dxa_left = s16(&prl), + T_DXA_GAP_HALF => gap_half = s16(&prl), + T_TABLE_BORDERS_80 => r.borders = table_borders(prl.operand), + T_MERGE => { + if let [a, b, ..] = prl.operand + && b > a + { + r.merges.push((*a, *b)); + } + } + T_VERT_MERGE => { + // cb, itc, flag: 1 = continues the merge from above, 3 = restarts it. + if let [_, itc, flag, ..] = prl.operand { + let m = match flag { + 3 => VMerge::Restart, + 1 => VMerge::Continue, + _ => VMerge::None, + }; + if m != VMerge::None { + r.vmerge.push((*itc, m)); + } + } + } + T_SET_SHD => { + if let Some(rgb) = prl.operand.get(3..).and_then(shd_rgb) + && let [a, b, ..] = prl.operand.get(1..3).unwrap_or(&[]) + { + for c in *a..*b { + r.shading.push((c, rgb)); + } + } + } + T_DEF_TABLE_SHD_80 => { + let cells = prl.operand.get(1..).unwrap_or(&[]); + for (i, b) in cells.as_chunks::<2>().0.iter().take(MAX_COLS).enumerate() { + if let Some(rgb) = shd80_rgb(b) { + r.shading.push((i as u8, rgb)); + } + } + } + T_CELL_WIDTH => { + // cb, itcFirst, itcLim, ftsWidth (3 = twips), wWidth. + if let [a, b, fts, w0, w1, ..] = prl.operand + && *fts == 3 + { + let w = (i16::from_le_bytes([*w0, *w1]) as f32 / 20.0).clamp(0.0, 1584.0); + for c in *a..(*b).min(MAX_COLS as u8) { + r.widths.push((c, w)); + } + } + } + T_VERT_ALIGN => { + if let [_, a, b, va, ..] = prl.operand { + let v = match va { + 1 => VAlign::Center, + 2 => VAlign::Bottom, + _ => VAlign::Top, + }; + for c in *a..*b { + r.valign.push((c, v)); + } + } + } + _ => {} + } + } + // The table indent is the row origin minus half the cell gap. + if let Some(l) = dxa_left { + let gap = gap_half.unwrap_or(0) as f32 / 20.0; + r.indent = Some(((l as f32 / 20.0) - gap).clamp(-1584.0, 1584.0)); + } + r +} + +/// A finished paragraph plus the bookkeeping the block assembly needs. +pub(crate) struct ParaOut { + pub(crate) para: Paragraph, + /// CP just past the terminating mark. + pub(crate) end_cp: u32, + /// Table context from the mark's PAPX (empty default = not in a table). + pub(crate) row: RowInfo, +} + +/// Group consecutive in-table paragraphs into [`Table`] blocks; the rest pass through. +/// Rows that never close (or tables nested deeper than we model) degrade to paragraphs so +/// no text is lost. +pub(crate) fn assemble(paras: Vec) -> Blocks { + let mut out: Blocks = Vec::new(); + let mut rows: Vec = Vec::new(); + let mut cells: Vec = Vec::new(); + let mut cell: Blocks = Vec::new(); + let mut grid: Vec = Vec::new(); + let mut props = TableProps::default(); + let mut open = false; + let mut depth_seen = 0u32; + + fn flush_cell(cells: &mut Vec, cell: &mut Blocks) { + let mut blocks = std::mem::take(cell); + if blocks.is_empty() { + blocks.push(para_block(Paragraph::new())); + } + cells.push(Cell { props: CellProps { span: 1, ..Default::default() }, blocks }); + } + fn build_row(mut cells: Vec, info: &RowInfo) -> Row { + // Per-cell overrides first (sprm cell indices count pre-merge cells), then merges. + for (i, c) in cells.iter_mut().enumerate() { + let i = i as u8; + if let Some((_, m)) = info.vmerge.iter().find(|(k, _)| *k == i) { + c.props.vmerge = *m; + } + if let Some((_, w)) = info.widths.iter().find(|(k, _)| *k == i) { + c.props.width = Some(*w); + } + if let Some((_, s)) = info.shading.iter().find(|(k, _)| *k == i) { + c.props.shading = Some(*s); + } + if let Some((_, v)) = info.valign.iter().find(|(k, _)| *k == i) { + c.props.valign = *v; + } + } + for &(a, b) in info.merges.iter().take(MAX_COLS) { + let a = a as usize; + let b = (b as usize).min(cells.len()); + if a < b && b <= cells.len() && b - a > 1 { + let removed: Vec = cells.drain(a + 1..b).collect(); + if let Some(first) = cells.get_mut(a) { + first.props.span = ((b - a) as u32).min(63); + for c in removed { + first.blocks.extend(c.blocks.into_iter().filter(|x| x.as_para().is_none_or(|p| !p.is_empty()))); + } + if first.blocks.len() > 1 && first.blocks.first().and_then(|x| x.as_para()).is_some_and(Paragraph::is_empty) { + first.blocks.remove(0); + } + } + } + } + Row { props: RowProps { height: info.height, height_rule: info.height_rule, header: info.header, cant_split: info.cant_split }, cells } + } + fn flush_table(out: &mut Blocks, rows: &mut Vec, cells: &mut Vec, cell: &mut Blocks, grid: &[f32], props: &TableProps, nested: bool) { + if !cells.is_empty() || !cell.is_empty() { + flush_cell(cells, cell); + // A stray trailing row (no TTP of its own): size its cells from the grid. + let mut stray = build_row(std::mem::take(cells), &RowInfo::default()); + for (i, c) in stray.cells.iter_mut().enumerate() { + if c.props.width.is_none() && c.props.span == 1 { + c.props.width = grid.get(i).copied(); + } + } + rows.push(stray); + } + if rows.is_empty() { + return; + } + if nested { + // Unclosed or nested deeper than we model: keep the text, drop the table shape. + for r in rows.iter() { + for c in &r.cells { + out.extend(c.blocks.iter().cloned()); + } + } + } else { + out.push(Arc::new(Block::Table(Table { props: props.clone(), grid: grid.to_vec(), rows: std::mem::take(rows) }))); + } + rows.clear(); + } + + for p in paras { + let info = p.row; + let is_table = info.in_table || info.ttp || info.cell_mark; + if !is_table { + if open { + flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1); + open = false; + depth_seen = 0; + } + out.push(para_block(p.para)); + continue; + } + if !open { + open = true; + } + depth_seen = depth_seen.max(info.depth); + if !info.grid.is_empty() && grid.is_empty() { + grid = info.grid.clone(); + } + if info.borders.is_some() && props.borders.is_none() { + props.borders = info.borders; + } + if props.align.is_none() { + props.align = info.align; + } + if props.indent.is_none() { + props.indent = info.indent; + } + if info.cell_mark { + if !p.para.text.is_empty() { + cell.push(para_block(p.para)); + } + flush_cell(&mut cells, &mut cell); + if info.ttp { + let row = build_row(std::mem::take(&mut cells), &info); + rows.push(row); + } + } else { + cell.push(para_block(p.para)); + } + } + if open { + flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1); + } + out +} + +/// Column widths (points) from a `sprmTDefTable` operand: cb u16, itcMac u8, then itcMac+1 +/// twip boundaries followed by the TC80s (skipped). +fn def_table_grid(operand: &[u8]) -> Vec { + let itc = operand.get(2).copied().unwrap_or(0) as usize; + if itc == 0 || itc > MAX_COLS { + return Vec::new(); + } + let mut edges = Vec::with_capacity(itc + 1); + for i in 0..=itc { + match operand.get(3 + i * 2..3 + i * 2 + 2) { + Some(b) => edges.push(i16::from_le_bytes([b[0], b[1]]) as f32 / 20.0), + None => break, + } + } + edges.windows(2).map(|w| (w[1] - w[0]).max(0.0)).collect() +} + +/// Table borders from `sprmTTableBorders80`: cb (0x18) then six Brc80s in the order top, +/// left, bottom, right, inside-horizontal, inside-vertical. +fn table_borders(operand: &[u8]) -> Option { + let b = |i: usize| operand.get(1 + i * 4..1 + i * 4 + 4).and_then(brc80); + Some(Borders { top: b(0), left: b(1), bottom: b(2), right: b(3), between: b(4), inside_v: b(5) }) +} + +/// One Brc80: width in 1/8 pt, type, palette colour, space/flags byte. +fn brc80(b: &[u8]) -> Option { + let [w, ty, ico, flags] = b else { return None }; + let style = match ty { + 0x00 => BorderStyle::None, + 0x02 | 0x15 => BorderStyle::Thick, + 0x03 => BorderStyle::Double, + 0x04 | 0x05 => BorderStyle::Dotted, + 0x06 | 0x07 => BorderStyle::Dashed, + 0x08 | 0x09 => BorderStyle::DotDash, + 0x0A | 0x18 => BorderStyle::Wave, + 0x11 => BorderStyle::Triple, + _ => BorderStyle::Single, + }; + if *ty == 0 && *w == 0 { + return None; + } + Some(Border { style, width: (*w as f32 / 8.0).clamp(0.25, 12.0), color: fmt::ico(*ico), space: (flags & 0x1F) as f32 }) +} + +/// Background colour from a 10-byte `Shd`: cvFore, cvBack (COLORREFs), ipat. Solid patterns +/// show the foreground; other patterns are approximated by their background. +fn shd_rgb(b: &[u8]) -> Option { + let cv = |b: &[u8]| -> Option { + match b { + [_, _, _, 0xFF] => None, // fAuto + [r, g, bl, _] => Some(Rgb(*r, *g, *bl)), + _ => None, + } + }; + let ipat = b.get(8..10).map(|x| u16::from_le_bytes([x[0], x[1]]))?; + match ipat { + 0 | 0xFFFF => None, + 1 => cv(b.get(0..4)?), + _ => cv(b.get(4..8)?), + } +} + +/// Background colour from a 2-byte `Shd80` (icoFore, icoBack, ipat bit-fields). +fn shd80_rgb(b: &[u8]) -> Option { + let v = u16::from_le_bytes([*b.first()?, *b.get(1)?]); + let fore = v & 0x1F; + let back = (v >> 5) & 0x1F; + let ipat = v >> 10; + if ipat == 0 || (fore == 0x1F && back == 0x1F) { + return None; + } + fmt::ico(if ipat == 1 { fore as u8 } else { back as u8 }) +} + +fn u16_of(p: &Prl) -> Option { + match p.operand { + [b0, b1, ..] => Some(u16::from_le_bytes([*b0, *b1])), + _ => None, + } +} + +fn s16(p: &Prl) -> Option { + u16_of(p).map(|v| v as i16) +} + +fn i32_of(p: &Prl) -> Option { + match p.operand { + [b0, b1, b2, b3, ..] => Some(i32::from_le_bytes([*b0, *b1, *b2, *b3])), + _ => None, + } +} diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs new file mode 100644 index 0000000..f6fa8a5 --- /dev/null +++ b/crates/docbin/src/tests.rs @@ -0,0 +1,1578 @@ +//! Synthetic `.doc` fixtures, written for these tests from the [MS-DOC] specification. +//! Files produced by Word are never committed; every fixture here is built in code. + +use std::io::Write; + +use proptest::prelude::*; + +use super::read; + +/// Offset in the WordDocument stream where test text pieces are placed (past the FIB). +const TEXT_FC: u32 = 0x600; +/// Offset in the Table stream where the test Clx is placed. +const CLX_AT: u32 = 0x40; + +/// Build an in-memory compound file holding the given root streams. +pub(crate) fn cfb_file(streams: &[(&str, &[u8])]) -> Vec { + let mut comp = cfb::CompoundFile::create(std::io::Cursor::new(Vec::new())).expect("cfb create"); + for (name, data) in streams { + let mut s = comp.create_stream(format!("/{name}")).expect("cfb stream"); + s.write_all(data).expect("cfb write"); + } + comp.into_inner().into_inner() +} + +/// Knobs of the minimal FIB these tests build (Word 97 shape, `cbRgFcLcb` = 0x5D). +pub(crate) struct FibSpec { + pub(crate) nfib: u16, + pub(crate) flags: u16, + pub(crate) ccp_text: u32, + pub(crate) ccp_ftn: u32, + pub(crate) ccp_hdd: u32, + pub(crate) ccp_edn: u32, + /// (pair index, fc, lcb) entries to poke into the RgFcLcb blob. + pub(crate) pairs: Vec<(usize, u32, u32)>, +} + +impl Default for FibSpec { + fn default() -> Self { + FibSpec { nfib: 0x00C1, flags: 0x1000, ccp_text: 0, ccp_ftn: 0, ccp_hdd: 0, ccp_edn: 0, pairs: Vec::new() } + } +} + +/// A minimal Word 97 FIB: FibBase + csw/rgw + cslw/rglw + 0x5D fc/lcb pairs + cswNew = 0. +pub(crate) fn fib_bytes(spec: &FibSpec) -> Vec { + let mut v = vec![0u8; 0x9A + 0x5D * 8 + 2]; + let put16 = |v: &mut Vec, at: usize, x: u16| { + v[at..at + 2].copy_from_slice(&x.to_le_bytes()); + }; + let put32 = |v: &mut Vec, at: usize, x: u32| { + v[at..at + 4].copy_from_slice(&x.to_le_bytes()); + }; + put16(&mut v, 0x00, 0xA5EC); + put16(&mut v, 0x02, spec.nfib); + put16(&mut v, 0x0A, spec.flags); + put16(&mut v, 0x0C, 0x00BF); + put16(&mut v, 0x20, 0x000E); + put16(&mut v, 0x3E, 0x0016); + put32(&mut v, 0x40, 0x1000); // cbMac + put32(&mut v, 0x4C, spec.ccp_text); // rglw[3] = ccpText + put32(&mut v, 0x50, spec.ccp_ftn); // rglw[4] = ccpFtn + put32(&mut v, 0x54, spec.ccp_hdd); // rglw[5] = ccpHdd + put32(&mut v, 0x60, spec.ccp_edn); // rglw[8] = ccpEdn + put16(&mut v, 0x98, 0x005D); + for (i, fc, lcb) in &spec.pairs { + put32(&mut v, 0x9A + i * 8, *fc); + put32(&mut v, 0x9A + i * 8 + 4, *lcb); + } + v +} + +/// One test piece: text stored either uncompressed (UTF-16LE) or compressed (Windows-1252). +#[derive(Clone)] +pub(crate) struct Piece { + pub(crate) compressed: bool, + pub(crate) text: String, +} + +/// Build a PlcPcd (aCp[n+1] + aPcd[n]) for pieces stored at consecutive offsets from +/// `first_fc` (compressed pieces start at `fc = 2 × byte offset` per FcCompressed). +fn plcpcd(pieces: &[Piece], first_fc: u32) -> Vec { + let mut v = Vec::new(); + let mut cp = 0u32; + v.extend_from_slice(&cp.to_le_bytes()); + for p in pieces { + cp += p.text.chars().count() as u32; + v.extend_from_slice(&cp.to_le_bytes()); + } + let mut off = 0u32; + for p in pieces { + // Bytes this piece occupies in the text region: one per character compressed, two + // uncompressed (count chars, not UTF-8 bytes). + let len = if p.compressed { p.text.chars().count() } else { p.text.chars().count() * 2 } as u32; + let fc = if p.compressed { 2 * (first_fc + off) } else { first_fc + off }; + let fc_raw = if p.compressed { fc | 0x4000_0000 } else { fc }; + v.extend_from_slice(&[0, 0]); // fNoParaLast & friends + v.extend_from_slice(&fc_raw.to_le_bytes()); + v.extend_from_slice(&0u16.to_le_bytes()); // prm + off += len; + } + v +} + +fn piece_bytes(p: &Piece) -> Vec { + if p.compressed { + // Tests only use cp1252-encodable chars in compressed pieces. + p.text.chars().map(|c| c as u8).collect() + } else { + let units: Vec = p.text.chars().map(|c| c as u16).collect(); + units.iter().flat_map(|u| u.to_le_bytes()).collect() + } +} + +/// A document whose main text is the given pieces (extra subdocument text may follow). +fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec { + let all: Vec = main.iter().chain(extra.iter()).cloned().collect(); + let ccp_text: u32 = main.iter().map(|p| p.text.chars().count() as u32).sum(); + let mut word = vec![0u8; TEXT_FC as usize]; + for p in &all { + word.extend_from_slice(&piece_bytes(p)); + } + let plc = plcpcd(&all, TEXT_FC); + let mut clx = vec![0x02u8]; + clx.extend_from_slice(&(plc.len() as u32).to_le_bytes()); + clx.extend_from_slice(&plc); + let mut table = vec![0u8; CLX_AT as usize]; + table.extend_from_slice(&clx); + let spec = FibSpec { ccp_text, ccp_hdd, pairs: vec![(33, CLX_AT, clx.len() as u32)], ..Default::default() }; + word[..fib_bytes(&spec).len()].copy_from_slice(&fib_bytes(&spec)); + cfb_file(&[("WordDocument", &word), ("0Table", &table)]) +} + +fn body_texts(doc: &wordcraft_doc::Document) -> Vec { + doc.body.iter().filter_map(|b| b.as_para().map(|p| p.text.clone())).collect() +} + +#[test] +fn not_a_compound_file() { + assert!(matches!(read(&[0u8; 64]), Err(super::DocbinError::Container(_)))); +} + +#[test] +fn cfb_without_word_stream() { + let f = cfb_file(&[("0Table", &[0u8; 16])]); + assert!(matches!(read(&f), Err(super::DocbinError::NotWord(_)))); +} + +#[test] +fn word_stream_too_short() { + let f = cfb_file(&[("WordDocument", &[0u8; 16])]); + assert!(matches!(read(&f), Err(super::DocbinError::NotWord(_)))); +} + +#[test] +fn wrong_magic_rejected() { + let mut fib = fib_bytes(&FibSpec::default()); + fib[0] = 0x34; + fib[1] = 0x12; + let f = cfb_file(&[("WordDocument", &fib)]); + assert!(matches!(read(&f), Err(super::DocbinError::NotWord(m)) if m.contains("magic"))); +} + +#[test] +fn word_6_95_rejected() { + let mut fib = fib_bytes(&FibSpec::default()); + fib[0] = 0xDC; + fib[1] = 0xA5; + let f = cfb_file(&[("WordDocument", &fib)]); + assert!(matches!(read(&f), Err(super::DocbinError::NotWord(m)) if m.contains("6.0/95"))); +} + +#[test] +fn encrypted_rejected() { + let spec = FibSpec { flags: 0x1100, ..Default::default() }; + let f = cfb_file(&[("WordDocument", &fib_bytes(&spec))]); + assert!(matches!(read(&f), Err(super::DocbinError::Encrypted))); +} + +#[test] +fn wrong_csw_rejected() { + let mut fib = fib_bytes(&FibSpec::default()); + fib[0x20] = 0x0D; + fib[0x21] = 0x00; + let f = cfb_file(&[("WordDocument", &fib)]); + assert!(matches!(read(&f), Err(super::DocbinError::NotWord(m)) if m.contains("csw"))); +} + +#[test] +fn missing_clx_rejected() { + let spec = FibSpec { ccp_text: 3, ..Default::default() }; + let f = cfb_file(&[("WordDocument", &fib_bytes(&spec)), ("0Table", &[0u8; 16])]); + assert!(matches!(read(&f), Err(super::DocbinError::Malformed(m)) if m.contains("piece table"))); +} + +#[test] +fn unicode_text_extracted() { + let f = text_doc(&[Piece { compressed: false, text: "Héllo Wörld\rSecond€\r".into() }], &[], 0); + let doc = read(&f).expect("opens"); + assert_eq!(body_texts(&doc), ["Héllo Wörld", "Second€"]); +} + +#[test] +fn compressed_cp1252_text_extracted() { + let f = text_doc(&[Piece { compressed: true, text: "caf\u{E9} 100\u{BD}\r".into() }], &[], 0); + let doc = read(&f).expect("opens"); + assert_eq!(body_texts(&doc), ["café 100½"]); +} + +#[test] +fn multiple_pieces_and_encodings() { + let f = text_doc( + &[ + Piece { compressed: false, text: "plain ".into() }, + Piece { compressed: true, text: "mixed\u{E9} ".into() }, + Piece { compressed: false, text: "end\r".into() }, + ], + &[], + 0, + ); + let doc = read(&f).expect("opens"); + assert_eq!(body_texts(&doc), ["plain mixedé end"]); +} + +#[test] +fn special_characters_mapped() { + let raw = "a\tb\u{B}c\u{C}d\u{E}e\u{1E}f\u{1F}g\r\u{13}field\u{14}result\u{15}\r"; + let f = text_doc(&[Piece { compressed: false, text: raw.into() }], &[], 0); + let doc = read(&f).expect("opens"); + // The field collapses to one anchor object; its instruction is hidden and its cached + // result kept on the object. + assert_eq!(body_texts(&doc), ["a\tb\nc\u{C}d\u{E}e\u{2011}f\u{AD}g", "\u{FFFC}"]); + let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("field para"); + assert!( + matches!(p1.objects.first(), Some(wordcraft_doc::para::InlineObject::Field { instr, result, .. }) if instr == "field" && result == "result"), + "objects: {:?}", + p1.objects + ); +} + +#[test] +fn empty_paragraphs_kept() { + let f = text_doc(&[Piece { compressed: false, text: "a\r\rb\r".into() }], &[], 0); + let doc = read(&f).expect("opens"); + assert_eq!(body_texts(&doc), ["a", "", "b"]); +} + +#[test] +fn empty_document_gets_a_paragraph() { + let f = text_doc(&[Piece { compressed: false, text: "".into() }], &[], 0); + let doc = read(&f).expect("opens"); + assert_eq!(doc.body.len(), 1); +} + +#[test] +fn header_text_stays_out_of_body() { + let f = text_doc( + &[Piece { compressed: false, text: "body\r".into() }], + &[Piece { compressed: false, text: "header\u{D}".into() }], + 8, // ccpHdd covers the extra piece + ); + let doc = read(&f).expect("opens"); + assert_eq!(body_texts(&doc), ["body"]); +} + +#[test] +fn clx_outside_table_rejected() { + let spec = FibSpec { pairs: vec![(33, 0x1000, 0x40)], ..Default::default() }; + let f = cfb_file(&[("WordDocument", &fib_bytes(&spec)), ("0Table", &[0u8; 16])]); + assert!(matches!(read(&f), Err(super::DocbinError::Malformed(_)))); +} + +#[test] +fn garbage_plcpcd_rejected() { + let mut table = vec![0u8; CLX_AT as usize]; + table.extend_from_slice(&[0x02, 0x05, 0x00, 0x00, 0x00, 1, 2, 3, 4]); // lcb 5: not ≡ 1 mod 12 + let spec = FibSpec { pairs: vec![(33, CLX_AT, 9)], ..Default::default() }; + let f = cfb_file(&[("WordDocument", &fib_bytes(&spec)), ("0Table", &table)]); + assert!(matches!(read(&f), Err(super::DocbinError::Malformed(m)) if m.contains("PlcPcd"))); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn junk_bytes_never_panic(b in proptest::collection::vec(any::(), 0..4096)) { + let _ = read(&b); + } + + #[test] + fn truncated_file_never_panics(n in 0..4096usize) { + let f = text_doc( + &[Piece { compressed: false, text: "Hello\rworld\r\u{E9}x\r".into() }], + &[], + 0, + ); + let n = n.min(f.len()); + let _ = read(&f[..n]); + } + + #[test] + fn flipped_bytes_never_panic(pos in 0..4096usize, bit in 0..8usize) { + let mut f = text_doc( + &[Piece { compressed: false, text: "Hello\rworld\r\u{E9}x\r".into() }], + &[], + 0, + ); + let at = pos.min(f.len() - 1); + if let Some(b) = f.get_mut(at) { + *b ^= 1 << (bit & 7); + } + let _ = read(&f); + } + + #[test] + fn structured_truncated_never_panics(n in 0..8192usize) { + let f = structured_doc(); + let n = n.min(f.len()); + let _ = read(&f[..n]); + } + + #[test] + fn structured_flipped_never_panics(pos in 0..8192usize, bit in 0..8usize) { + let mut f = structured_doc(); + let at = pos.min(f.len() - 1); + if let Some(b) = f.get_mut(at) { + *b ^= 1 << (bit & 7); + } + let _ = read(&f); + } + + #[test] + fn extras_truncated_never_panics(n in 0..8192usize) { + let f = extras_doc(); + let n = n.min(f.len()); + let _ = read(&f[..n]); + } + + #[test] + fn extras_flipped_never_panics(pos in 0..8192usize, bit in 0..8usize) { + let mut f = extras_doc(); + let at = pos.min(f.len() - 1); + if let Some(b) = f.get_mut(at) { + *b ^= 1 << (bit & 7); + } + let _ = read(&f); + } +} + +// --------------------------------------------------------------------------------------------- +// D2: formatting — fixtures with a stylesheet, font table and FKP pages. + +/// Twips→FC units inside a piece: 2 per character. +fn fc_of(cp: u32) -> u32 { + TEXT_FC + 2 * cp +} + +/// A grpprl of Prls given as (opcode, operand) pairs. +fn grpprl(prls: &[(u16, &[u8])]) -> Vec { + let mut v = Vec::new(); + for (op, operand) in prls { + v.extend_from_slice(&op.to_le_bytes()); + v.extend_from_slice(operand); + } + v +} + +/// One STD record (Word 97 shape, cbSTDBaseInFile = 10). +fn std_bytes(stk: u8, istd_base: u16, name: &str, papx: &[u8], chpx: &[u8]) -> Vec { + let cupx = if stk == 1 { 2 } else { 1 }; + let mut base = vec![0u8; 10]; + // sti 0xFFF (user-defined) | flags + base[0] = 0xFF; + base[1] = 0x0F; + // stk(4) | istdNext(12) = none (0xFFF) + base[2] = stk | 0xF0; + base[3] = 0xFF; + // cupx(4) | istdBase(12) + let w2 = (cupx as u16) | (istd_base << 4); + base[4..6].copy_from_slice(&w2.to_le_bytes()); + // bchUpe filled below; grfstd = 0 + let mut v = base; + // xstzName: cch + UTF-16 + terminator + let units: Vec = name.chars().map(|c| c as u16).collect(); + v.extend_from_slice(&(units.len() as u16).to_le_bytes()); + for u in &units { + v.extend_from_slice(&u.to_le_bytes()); + } + v.extend_from_slice(&0u16.to_le_bytes()); + if stk == 1 { + // LPUpxPapx: cbUpx + UpxPapx (istd u16 + grpprl), padded to even. + let papx_upx = [0u16.to_le_bytes().as_slice(), papx].concat(); + v.extend_from_slice(&(papx_upx.len() as u16).to_le_bytes()); + v.extend_from_slice(&papx_upx); + if papx_upx.len() % 2 == 1 { + v.push(0); + } + } + // LPUpxChpx: cbUpx + raw grpprl. + v.extend_from_slice(&(chpx.len() as u16).to_le_bytes()); + v.extend_from_slice(chpx); + if chpx.len() % 2 == 1 { + v.push(0); + } + let cb = v.len() as u16; + let mut out = cb.to_le_bytes().to_vec(); + out.extend_from_slice(&v); + // bchUpe (offset 6 in the Stdf) equals cbStd. + out[6 + 2] = cb as u8; + out[7 + 2] = (cb >> 8) as u8; + out +} + +/// An STSH with the given styles. +fn stsh_bytes(styles: &[Vec]) -> Vec { + let stshi = [(styles.len() as u16).to_le_bytes(), 10u16.to_le_bytes()].concat(); // cstd, cbSTDBaseInFile + let mut v = (stshi.len() as u16).to_le_bytes().to_vec(); + v.extend_from_slice(&stshi); + for s in styles { + v.extend_from_slice(s); + } + v +} + +/// A non-extended SttbfFfn of font names. +fn ffn_bytes(names: &[&str]) -> Vec { + let mut v = (names.len() as u16).to_le_bytes().to_vec(); + v.extend_from_slice(&0u16.to_le_bytes()); // cbExtra + for n in names { + v.extend_from_slice(&[0u8; 40]); // FFN header + for c in n.chars() { + v.extend_from_slice(&(c as u16).to_le_bytes()); + } + v.extend_from_slice(&0u16.to_le_bytes()); // NUL + } + v +} + +/// A ChpxFkp page mapping run boundaries (in CPs, ends exclusive) to grpprls. +fn chpx_fkp(runs: &[(u32, Vec)], end_cp: u32) -> Vec { + let mut page = vec![0u8; 512]; + let n = runs.len(); + for (i, r) in runs.iter().enumerate() { + page[i * 4..i * 4 + 4].copy_from_slice(&fc_of(r.0).to_le_bytes()); + } + page[n * 4..n * 4 + 4].copy_from_slice(&fc_of(end_cp).to_le_bytes()); + let rgb_at = (n + 1) * 4; + let mut data_at = rgb_at + n + 1; + for (i, r) in runs.iter().enumerate() { + if r.1.is_empty() { + page[rgb_at + i] = 0; + continue; + } + // Offsets are half-bytes; align the data to even offsets. + data_at += data_at & 1; + let half = (data_at / 2) as u8; + page[rgb_at + i] = half; + page[data_at] = r.1.len() as u8; + page[data_at + 1..data_at + 1 + r.1.len()].copy_from_slice(&r.1); + data_at += 1 + r.1.len(); + } + page[511] = n as u8; + page +} + +/// A PapxFkp page mapping paragraph-mark FCs (as "FC after the mark") to PAPX data +/// (istd + grpprl). +fn papx_fkp(entries: &[(u32, u16, Vec)]) -> Vec { + let mut page = vec![0u8; 512]; + let n = entries.len(); + for (i, e) in entries.iter().enumerate() { + page[i * 4..i * 4 + 4].copy_from_slice(&fc_of(e.0).to_le_bytes()); + } + page[n * 4..n * 4 + 4].copy_from_slice(&fc_of(0x3FF0_0000).to_le_bytes()); // far end + let rgbx_at = (n + 1) * 4; + let mut data_at = rgbx_at + n * 13; + for (i, e) in entries.iter().enumerate() { + data_at += data_at & 1; + let half = (data_at / 2) as u8; + page[rgbx_at + i * 13] = half; + let grp = [e.1.to_le_bytes().as_slice(), e.2.as_slice()].concat(); + let cb = ((grp.len() + 2) / 2) as u8; + page[data_at] = cb; + page[data_at + 1..data_at + 1 + grp.len()].copy_from_slice(&grp); + data_at += 1 + grp.len() + ((1 + grp.len()) & 1); + } + page[511] = n as u8; + page +} + +/// A document with one uncompressed text piece, a stylesheet, a font table and formatting +/// pages, assembled the way Word lays out the streams. +struct RichSpec { + text: &'static str, + /// Styles in istd order, (stk, base, name, papx, chpx). + styles: Vec<(u8, u16, &'static str, Vec, Vec)>, + fonts: Vec<&'static str>, + /// Character runs (start CP, grpprl); text length ends the last. + chpx_runs: Vec<(u32, Vec)>, + /// Paragraph entries (mark CP, istd, grpprl). + papx_entries: Vec<(u32, u16, Vec)>, +} + +fn rich_doc(spec: &RichSpec) -> Vec { + let text: String = spec.text.into(); + let ccp = text.chars().count() as u32; + let _ = &text; + let plc = plcpcd(&[Piece { compressed: false, text: text.clone() }], TEXT_FC); + let mut clx = vec![0x02u8]; + clx.extend_from_slice(&(plc.len() as u32).to_le_bytes()); + clx.extend_from_slice(&plc); + + // WordDocument: FIB | padding | text | FKP pages (512-aligned). + let chpx_page_at = (TEXT_FC as usize + text.len() * 2).div_ceil(512) * 512; + let papx_page_at = chpx_page_at + 512; + let mut word = vec![0u8; TEXT_FC as usize]; + word.extend_from_slice(&piece_bytes(&Piece { compressed: false, text: text.clone() })); + word.resize(chpx_page_at, 0); + word.extend_from_slice(&chpx_fkp(&spec.chpx_runs, ccp)); + word.extend_from_slice(&papx_fkp(&spec.papx_entries)); + + // Table: STSH | FFN | Clx | bin tables. + let styles: Vec> = spec.styles.iter().map(|s| std_bytes(s.0, s.1, s.2, &s.3, &s.4)).collect(); + let stsh = stsh_bytes(&styles); + let ffn = ffn_bytes(&spec.fonts); + let clx_at = 0x40; + let bins_at = clx_at + clx.len(); + let plcf_chpx = + [0u32.to_le_bytes().as_slice(), fc_of(0x3FF0_0000).to_le_bytes().as_slice(), ((chpx_page_at / 512) as u32).to_le_bytes().as_slice()].concat(); + let plcf_papx = + [0u32.to_le_bytes().as_slice(), fc_of(0x3FF0_0000).to_le_bytes().as_slice(), ((papx_page_at / 512) as u32).to_le_bytes().as_slice()].concat(); + let mut table = vec![0u8; clx_at]; + table.extend_from_slice(&clx); + let chpx_bins_at = table.len(); + table.extend_from_slice(&plcf_chpx); + let papx_bins_at = table.len(); + table.extend_from_slice(&plcf_papx); + table.extend(std::iter::repeat_n(0, stsh.len() + ffn.len())); + let stsh_at = table.len() - stsh.len() - ffn.len(); + let ffn_at = stsh_at + stsh.len(); + table[stsh_at..stsh_at + stsh.len()].copy_from_slice(&stsh); + table[ffn_at..ffn_at + ffn.len()].copy_from_slice(&ffn); + let _ = bins_at; + + let spec_fib = FibSpec { + ccp_text: ccp, + pairs: vec![ + (33, clx_at as u32, clx.len() as u32), + (1, stsh_at as u32, stsh.len() as u32), + (15, ffn_at as u32, ffn.len() as u32), + (12, chpx_bins_at as u32, plcf_chpx.len() as u32), + (13, papx_bins_at as u32, plcf_papx.len() as u32), + ], + ..Default::default() + }; + let fib = fib_bytes(&spec_fib); + word[..fib.len()].copy_from_slice(&fib); + cfb_file(&[("WordDocument", &word), ("0Table", &table)]) +} + +fn para0(doc: &wordcraft_doc::Document) -> wordcraft_doc::Paragraph { + doc.body.first().and_then(|b| b.as_para().cloned()).unwrap_or_default() +} + +#[test] +fn character_formatting_applied() { + let spec = RichSpec { + text: "plain bold red\r", + styles: vec![(1, 0, "Normal", Vec::new(), Vec::new())], + fonts: vec!["Times New Roman", "Arial"], + chpx_runs: vec![ + (0, Vec::new()), + (6, grpprl(&[(0x0835, &[0x01])])), // bold + (11, grpprl(&[(0x0835, &[0x01]), (0x2A42, &[0x06])])), // bold + red (ico 6) + ], + papx_entries: vec![(0, 0, Vec::new())], + }; + let doc = read(&rich_doc(&spec)).expect("opens"); + let p = para0(&doc); + assert_eq!(p.text, "plain bold red"); + // runs: plain(0..6) bold(6..11) bold+red(11..16) + assert!(p.runs.get(1).is_some_and(|r| r.props.bold == Some(true)), "run 1 bold: {:?}", p.runs); + assert!(p.runs.get(2).is_some_and(|r| r.props.bold == Some(true)), "run 2 bold+red: {:?}", p.runs); + assert!(p.runs.get(2).is_some_and(|r| matches!(r.props.color, Some(wordcraft_doc::props::TextColor::Rgb(_)))), "run 2 red"); + assert!(p.runs.first().is_some_and(|r| r.props.bold.is_none())); +} + +#[test] +fn font_and_size_from_style_and_direct() { + let spec = RichSpec { + text: "Hello\r", + styles: vec![ + (1, 0, "Normal", Vec::new(), grpprl(&[(0x4A43, &24u16.to_le_bytes())])), // 12pt + (2, 0, "Emphasis", Vec::new(), grpprl(&[(0x4A4F, &1u16.to_le_bytes())])), // Arial + ], + fonts: vec!["Times New Roman", "Arial"], + chpx_runs: vec![(0, grpprl(&[(0x4A4F, &1u16.to_le_bytes())]))], + papx_entries: vec![(0, 0, Vec::new())], + }; + let doc = read(&rich_doc(&spec)).expect("opens"); + assert!(doc.styles.styles.iter().any(|s| s.name == "Normal" && s.chr.size == Some(12.0)), "Normal 12pt"); + let p = para0(&doc); + assert!(p.runs.first().is_some_and(|r| r.props.font.as_deref() == Some("Arial")), "run font: {:?}", p.runs); +} + +#[test] +fn paragraph_props_and_heading_style() { + let spec = RichSpec { + text: "centered\rplain\r", + styles: vec![ + (1, 0, "Normal", Vec::new(), Vec::new()), + (1, 0, "heading 1", grpprl(&[(0x2461, &[0x01, 0x00])]), Vec::new()), // centered + ], + fonts: vec!["Times New Roman"], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 1, Vec::new()), (9, 0, grpprl(&[(0x845E, &720u16.to_le_bytes())]))], + }; + let doc = read(&rich_doc(&spec)).expect("opens"); + let heading = doc.styles.styles.iter().find(|s| s.name == "Heading 1").expect("style"); + assert_eq!(heading.para.align, Some(wordcraft_doc::props::Align::Center)); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + assert_eq!(p0.props.style.as_deref(), Some("Heading 1")); + assert_eq!(heading.para.outline_level, Some(0)); + let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("para"); + assert_eq!(p1.props.indent_left, Some(36.0)); // 720 twips = 36pt + assert_eq!(p1.props.style.as_deref(), Some("Normal")); +} + +/// Local corpus (gitignored `plan/word/fixtures`): real files must open with text and styles. +#[test] +fn corpus_files_open() { + let Some(dir) = std::env::var_os("WORDCRAFT_DOCBIN_FIXTURES") else { return }; + let mut checked = 0; + if let Ok(entries) = std::fs::read_dir(&dir) { + for e in entries.flatten() { + let p = e.path(); + if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("doc")) { + let bytes = std::fs::read(&p).expect("read"); + let doc = match super::read(&bytes) { + Ok(d) => d, + // Word 6/95 files are a documented, intentional rejection. + Err(super::DocbinError::NotWord(m)) if m.contains("6.0/95") => continue, + Err(e) => panic!("{}: {e}", p.display()), + }; + assert!(!doc.styles.styles.is_empty(), "{}: no styles", p.display()); + let text = doc.plain_text(wordcraft_doc::StoryRef::Body); + assert!(text.chars().count() > 3, "{}: no text", p.display()); + checked += 1; + } + } + } + assert!(checked > 0, "no .doc files found in {}", dir.display()); +} + +// --------------------------------------------------------------------------------------------- +// D3: sections, headers/footers, tables. + +/// RichSpec extended for the D3 fixtures. +struct StructSpec { + text: &'static str, + /// Extra pieces after the main text: header subdocument stories. + header_stories: Vec<&'static str>, + /// (end_cp, sepx grpprl) per section; the last one reaching the text end is the final one. + sections: Vec<(u32, Vec)>, + /// Character runs as in RichSpec. + chpx_runs: Vec<(u32, Vec)>, + /// Paragraph entries as in RichSpec, with the grpprl also carrying table sprms. + papx_entries: Vec<(u32, u16, Vec)>, + /// Raw `PlfLst` bytes (LSTF array + appended LVLs) for FIB pair 73. + plf_lst: Vec, + /// Raw `PlfLfo` bytes for FIB pair 74. + plf_lfo: Vec, + /// Footnote subdocument stories, placed right after the main text. + note_stories: Vec<&'static str>, + /// Endnote subdocument stories, placed after the header stories. + endnote_stories: Vec<&'static str>, + /// Extra raw Table-stream blobs with their FIB pair index. + extra_pairs: Vec<(usize, Vec)>, + /// The Data stream (pictures), written when non-empty. + data_stream: Vec, +} + +fn struct_doc(spec: &StructSpec) -> Vec { + let text: String = spec.text.into(); + let ccp = text.chars().count() as u32; + let mut all: Vec = vec![Piece { compressed: false, text: text.clone() }]; + let mut story_ends = Vec::new(); + let mut ftn_len = 0u32; + for s in &spec.note_stories { + let t: String = (*s).into(); + ftn_len += t.chars().count() as u32; + all.push(Piece { compressed: false, text: t }); + } + let mut hdd_len = 0u32; + for s in &spec.header_stories { + let t: String = (*s).into(); + hdd_len += t.chars().count() as u32; + story_ends.push(hdd_len); + all.push(Piece { compressed: false, text: t }); + } + let mut edn_len = 0u32; + for s in &spec.endnote_stories { + let t: String = (*s).into(); + edn_len += t.chars().count() as u32; + all.push(Piece { compressed: false, text: t }); + } + let plc = plcpcd(&all, TEXT_FC); + let mut clx = vec![0x02u8]; + clx.extend_from_slice(&(plc.len() as u32).to_le_bytes()); + clx.extend_from_slice(&plc); + + let chpx_page_at = (TEXT_FC as usize + text.len() * 2).div_ceil(512) * 512; + let papx_page_at = chpx_page_at + 512; + let mut word = vec![0u8; TEXT_FC as usize]; + for piece in &all { + word.extend_from_slice(&piece_bytes(piece)); + } + word.resize(chpx_page_at, 0); + word.extend_from_slice(&chpx_fkp(&spec.chpx_runs, ccp)); + word.extend_from_slice(&papx_fkp(&spec.papx_entries)); + + // PlcfSed: aCP[n+1] + aSed[n] (fn 2, fcSepx 4, fnMpr 2, fcMpr 4), and each Sepx after the + // pages. Plcfhdd: separator stories first (6 empties) then one empty + one per story. + let mut sepxes = Vec::new(); + let mut plcf_sed = Vec::new(); + let mut seds = Vec::new(); + let first_sepx = papx_page_at + 512; + let mut off = first_sepx; + plcf_sed.extend_from_slice(&0u32.to_le_bytes()); + for (end, grpprl) in &spec.sections { + plcf_sed.extend_from_slice(&end.to_le_bytes()); + seds.extend_from_slice(&[0, 0]); // fn + seds.extend_from_slice(&(off as u32).to_le_bytes()); + seds.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // fnMpr + fcMpr + let mut sepx = (grpprl.len() as u16).to_le_bytes().to_vec(); + sepx.extend_from_slice(grpprl); + sepxes.push(sepx); + off += grpprl.len() + 2; + } + plcf_sed.extend_from_slice(&seds); + // Plcfhdd: six empty separator stories, then the section's six slots: even hdr, odd hdr, + // even ftr, odd ftr, first hdr, first ftr — story 0 goes into the odd header slot (1). + let mut plcf_hdd: Vec = Vec::new(); + let mut cp = 0u32; + plcf_hdd.extend_from_slice(&cp.to_le_bytes()); + for _ in 0..6 { + plcf_hdd.extend_from_slice(&cp.to_le_bytes()); // separators: empty + } + // slot 0 (even header): empty + plcf_hdd.extend_from_slice(&cp.to_le_bytes()); + // slot 1 (odd header): the story, when present + let has_story = !spec.header_stories.is_empty(); + if has_story { + cp += spec.header_stories.iter().map(|s| s.chars().count() as u32).sum::(); + } + plcf_hdd.extend_from_slice(&cp.to_le_bytes()); + // remaining slots: empty + for _ in 0..4 { + plcf_hdd.extend_from_slice(&cp.to_le_bytes()); + } + + let clx_at = 0x40; + let mut table = vec![0u8; clx_at]; + table.extend_from_slice(&clx); + let chpx_bins_at = table.len(); + let plcf_chpx = + [0u32.to_le_bytes().as_slice(), fc_of(0x3FF0_0000).to_le_bytes().as_slice(), ((chpx_page_at / 512) as u32).to_le_bytes().as_slice()].concat(); + table.extend_from_slice(&plcf_chpx); + let papx_bins_at = table.len(); + let plcf_papx = + [0u32.to_le_bytes().as_slice(), fc_of(0x3FF0_0000).to_le_bytes().as_slice(), ((papx_page_at / 512) as u32).to_le_bytes().as_slice()].concat(); + table.extend_from_slice(&plcf_papx); + let sed_at = table.len(); + table.extend_from_slice(&plcf_sed); + let hdd_plc_at = table.len(); + table.extend_from_slice(&plcf_hdd); + let stsh = stsh_bytes(&[std_bytes(1, 0, "Normal", &Vec::new(), &Vec::new())]); + let stsh_at = table.len(); + table.extend_from_slice(&stsh); + let ffn = ffn_bytes(&["Times New Roman"]); + let ffn_at = table.len(); + table.extend_from_slice(&ffn); + let lst_at = table.len(); + table.extend_from_slice(&spec.plf_lst); + let lfo_at = table.len(); + table.extend_from_slice(&spec.plf_lfo); + + let mut extra_at = Vec::new(); + for (i, blob) in &spec.extra_pairs { + let at = table.len() as u32; + table.extend_from_slice(blob); + extra_at.push((*i, at, blob.len() as u32)); + } + + let mut pairs = vec![ + (33, clx_at as u32, clx.len() as u32), + (1, stsh_at as u32, stsh.len() as u32), + (15, ffn_at as u32, ffn.len() as u32), + (12, chpx_bins_at as u32, plcf_chpx.len() as u32), + (13, papx_bins_at as u32, plcf_papx.len() as u32), + (6, sed_at as u32, plcf_sed.len() as u32), + (11, hdd_plc_at as u32, plcf_hdd.len() as u32), + ]; + if !spec.plf_lst.is_empty() { + pairs.push((73, lst_at as u32, spec.plf_lst.len() as u32)); + } + if !spec.plf_lfo.is_empty() { + pairs.push((74, lfo_at as u32, spec.plf_lfo.len() as u32)); + } + pairs.extend(extra_at); + let spec_fib = FibSpec { ccp_text: ccp, ccp_ftn: ftn_len, ccp_hdd: hdd_len, ccp_edn: edn_len, pairs, ..Default::default() }; + let fib = fib_bytes(&spec_fib); + word[..fib.len()].copy_from_slice(&fib); + word.extend(std::iter::repeat_n(0, first_sepx - word.len())); + for sepx in sepxes { + word.extend_from_slice(&sepx); + } + if spec.data_stream.is_empty() { + cfb_file(&[("WordDocument", &word), ("0Table", &table)]) + } else { + cfb_file(&[("WordDocument", &word), ("0Table", &table), ("Data", &spec.data_stream)]) + } +} + +#[test] +fn section_props_parsed() { + let spec = StructSpec { + text: "a\rb\r", + header_stories: vec![], + sections: vec![(2, grpprl(&[(0xB01F, &12240u16.to_le_bytes()), (0xB020, &15840u16.to_le_bytes()), (0xB021, &1440u16.to_le_bytes())]))], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 0, Vec::new()), (2, 0, Vec::new())], + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + // The single section ends at the text end: it becomes the document's last section. + assert_eq!(doc.last_section.page_w, 612.0); + assert_eq!(doc.last_section.page_h, 792.0); + assert_eq!(doc.last_section.margin_left, 72.0); +} + +#[test] +fn two_sections_box_the_first() { + let spec = StructSpec { + text: "first\rsecond\r", + header_stories: vec![], + sections: vec![(6, grpprl(&[(0x300A, &[0x01])])), (12, Vec::new())], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 0, Vec::new()), (6, 0, Vec::new())], + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + let sec = p0.section.as_ref().expect("section on first paragraph"); + assert!(sec.title_page); + assert!(!doc.last_section.title_page); +} + +#[test] +fn header_story_becomes_part() { + let spec = StructSpec { + text: "body\r", + header_stories: vec!["Header text\r\r"], + sections: vec![(5, Vec::new())], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 0, Vec::new())], + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let id = doc.last_section.headers.default.expect("odd header part"); + let part = doc.parts.get(&id).expect("part"); + assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Header text")))); +} + +/// A `sprmTDefTable` Prl for `edges` twip boundaries and TC80s of 20 bytes per column, +/// built per the spec (cb counts the bytes after it, plus one). +fn t_def_table(edges: &[i16]) -> Vec { + let itc = edges.len() as u8 - 1; + let mut body = vec![itc]; + for e in edges { + body.extend_from_slice(&e.to_le_bytes()); + } + for _ in 0..itc { + body.extend_from_slice(&[0u8; 20]); // TC80: default cell + } + // cb = bytes after cb + 1. + let cb = (body.len() + 1) as u16; + let mut v = (0xD608u16).to_le_bytes().to_vec(); + v.extend_from_slice(&cb.to_le_bytes()); + v.extend_from_slice(&body); + v +} + +#[test] +fn table_assembled_from_marks() { + // A 2×2 table: "a\x07 b\x07|ttp c\x07 d\x07|ttp" then a normal paragraph. + let def_table = t_def_table(&[0, 2880, 5760]); + let text = "a\u{7}b\u{7}c\u{7}d\u{7}tail\r"; + let in_tbl = grpprl(&[(0x2416, &[0x01])]); + let ttp: Vec = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]), def_table].concat(); + let spec = StructSpec { + text, + header_stories: vec![], + sections: vec![(text.chars().count() as u32, Vec::new())], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![ + (0, 0, in_tbl.clone()), // "a" cell paragraph + (2, 0, ttp.clone()), // "b" cell paragraph, ends the row (TTP) + (4, 0, in_tbl), // "c" cell paragraph + (6, 0, ttp), // "d" cell paragraph, ends the row (TTP) + (8, 0, Vec::new()), // "tail" paragraph + ], + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table"); + assert_eq!(tbl.rows.len(), 2); + assert_eq!(tbl.rows[0].cells.len(), 2); + assert_eq!(tbl.grid.len(), 2); + assert_eq!(tbl.grid[0], 144.0); // 2880 twips = 144 pt + assert_eq!(tbl.total_width(), 288.0); + let cell_text = |r: usize, c: usize| tbl.rows[r].cells[c].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::(); + assert_eq!(cell_text(0, 0), "a"); + assert_eq!(cell_text(0, 1), "b"); + assert_eq!(cell_text(1, 1), "d"); + let last = doc.body.last().and_then(|b| b.as_para()).expect("tail para"); + assert_eq!(last.text, "tail"); +} + +#[test] +fn table_merges_borders_shading() { + // Two rows: the first "a\x07 b\x07 c\x07" with cells 0-1 horizontally merged; the second + // "d\x07 e\x07" with table borders, a shaded first cell and a vertical-merge restart. + let def3 = t_def_table(&[0, 1920, 3840, 5760]); + let def2 = t_def_table(&[0, 2880, 5760]); + let borders = { + // sprmTTableBorders80: cb = 0x18, then 6 × Brc80 (width 4/8pt, single, ico 1, space 0). + let brc = [4u8, 1, 1, 0]; + let mut op = vec![0x18u8]; + for _ in 0..6 { + op.extend_from_slice(&brc); + } + let mut v = (0xD605u16).to_le_bytes().to_vec(); + v.extend_from_slice(&op); + v + }; + let shd = { + // sprmTSetShd: cb = 12, itc 0..1, Shd { cvFore = FFFF00 (yellow), ipat = 1 (solid, + // which shows the foreground) }. + let mut op = vec![12u8, 0, 1]; + op.extend_from_slice(&[0xFF, 0xFF, 0x00, 0x00]); // cvFore = yellow + op.extend_from_slice(&[0, 0, 0, 0]); // cvBack + op.extend_from_slice(&1u16.to_le_bytes()); // ipat solid + let mut v = (0xD62Du16).to_le_bytes().to_vec(); + v.extend_from_slice(&op); + v + }; + let vert_merge = { + // sprmTVertMerge: cb = 2, itc 0, flag 3 (restart). + let mut v = (0xD62Bu16).to_le_bytes().to_vec(); + v.extend_from_slice(&[2, 0, 3]); + v + }; + let h_merge = { + // sprmTMerge: itcFirstLim 0..2. + let mut v = (0x5624u16).to_le_bytes().to_vec(); + v.extend_from_slice(&[0, 2]); + v + }; + let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\r"; + let ttp_base = || grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]); + let mut row1: Vec = [ttp_base(), def3.clone(), h_merge].concat(); + let mut row2: Vec = [ttp_base(), def2, borders, shd, vert_merge, grpprl(&[(0x9407, &(-400i16).to_le_bytes())])].concat(); + let in_tbl = grpprl(&[(0x2416, &[0x01])]); + let spec = StructSpec { + text, + header_stories: vec![], + sections: vec![(text.chars().count() as u32, Vec::new())], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![ + (0, 0, in_tbl.clone()), + (2, 0, in_tbl.clone()), + (4, 0, std::mem::take(&mut row1)), // "c" cell paragraph, ends row 1 (merge 0..2) + (6, 0, in_tbl), + (8, 0, std::mem::take(&mut row2)), // "e" cell paragraph, ends row 2 + (10, 0, Vec::new()), + ], + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table"); + assert_eq!(tbl.rows.len(), 2); + // Row 1: three cell marks merged to two cells, the first spanning two columns. + assert_eq!(tbl.rows[0].cells.len(), 2); + assert_eq!(tbl.rows[0].cells[0].span(), 2); + let r0 = tbl.rows[0].cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::(); + assert_eq!(r0, "ab"); + // Row 2: shading, vertical-merge restart, exact height 20pt. + assert_eq!(tbl.rows[1].cells[0].props.shading, Some(wordcraft_doc::Rgb(255, 255, 0))); + assert_eq!(tbl.rows[1].cells[0].props.vmerge, wordcraft_doc::props::VMerge::Restart); + assert_eq!(tbl.rows[1].props.height, Some(20.0)); + // Table borders from the first row that carries them: single 0.5pt black. + let b = tbl.props.borders.expect("borders"); + let top = b.top.expect("top border"); + assert_eq!(top.style, wordcraft_doc::props::BorderStyle::Single); + assert_eq!(top.width, 0.5); +} + +// --------------------------------------------------------------------------------------------- +// D3: lists (PlfLst + PlfLfo) and the combined document. + +/// One LVL: number text given as chars (a `0`-valued char at placeholder position `ph` is +/// the level placeholder), with a 720-twip left indent and a 360-twip hanging first line. +fn lvl(start: u32, nfc: u8, chars: &[u16], ph: bool) -> Vec { + let mut v = vec![0u8; 28]; + v[0..4].copy_from_slice(&start.to_le_bytes()); + v[4] = nfc; + if ph { + v[6] = 1; // rgbxchNums[0]: the placeholder sits at one-based offset 1 + } + v[15] = 1; // a space follows the number text + let papx: Vec = [grpprl(&[(0x840F, &720u16.to_le_bytes())]), grpprl(&[(0x8411, &(-360i16).to_le_bytes())])].concat(); + v[25] = papx.len() as u8; // cbGrpprlPapx + v.extend_from_slice(&papx); + v.extend_from_slice(&(chars.len() as u16).to_le_bytes()); + for c in chars { + v.extend_from_slice(&c.to_le_bytes()); + } + v +} + +/// A `PlfLst` with one multi-level list (nine decimal levels) or one simple list. +fn plf_lst(lsid: i32, simple: bool, levels: &[Vec]) -> Vec { + let mut v = 1u16.to_le_bytes().to_vec(); + v.extend_from_slice(&lsid.to_le_bytes()); + v.extend_from_slice(&0u32.to_le_bytes()); // tplc + for _ in 0..9 { + v.extend_from_slice(&0x0FFFu16.to_le_bytes()); // no style links + } + v.push(u8::from(simple)); // flags: fSimpleList + v.push(0); // grfhic + for l in levels { + v.extend_from_slice(l); + } + v +} + +/// A `PlfLfo` with one LFO bound to `lsid`. +fn plf_lfo(lsid: i32) -> Vec { + let mut v = 1u32.to_le_bytes().to_vec(); // lfoMac + v.extend_from_slice(&lsid.to_le_bytes()); // LFO.lsid + v.extend_from_slice(&[0u8; 8]); // unused1/2 + v.push(0); // clfolvl + v.extend_from_slice(&[0u8; 3]); // ibstFltAutoNum, grfhic, unused3 + v.extend_from_slice(&0u32.to_le_bytes()); // LFOData.cp + v +} + +#[test] +fn list_numbering_parsed() { + let levels: Vec> = (0..9) + .map(|_| lvl(1, 0x00, &[0x0000, 0x002E], true)) // "%1." style, decimal + .collect(); + let text = "one\rtwo\rplain\r"; + let listed = |off: bool| { + if off { grpprl(&[(0x460B, &[0x00, 0x00])]) } else { [grpprl(&[(0x260A, &[0x00])]), grpprl(&[(0x460B, &[0x01, 0x00])])].concat() } + }; + let spec = StructSpec { + text, + header_stories: vec![], + sections: vec![(text.chars().count() as u32, Vec::new())], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 0, listed(false)), (4, 0, listed(false)), (8, 0, listed(true))], + plf_lst: plf_lst(0x1000, false, &levels), + plf_lfo: plf_lfo(0x1000), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + assert_eq!(doc.numbering.abstracts.len(), 1); + assert_eq!(doc.numbering.nums.len(), 1); + let l0 = doc.numbering.level(1, 0).expect("level 0"); + assert_eq!(l0.format, wordcraft_doc::section::NumFormat::Decimal); + assert_eq!(l0.text, "%1."); + assert_eq!(l0.indent, 36.0); + assert_eq!(l0.hanging, 18.0); + assert_eq!(l0.suffix, wordcraft_doc::numbering::LevelSuffix::Space); + // Paragraphs 1-2 are in the list, paragraph 3 is not. + let n = |i: usize| doc.body.get(i).and_then(|b| b.as_para()).and_then(|p| p.props.numbering); + assert_eq!(n(0), Some(wordcraft_doc::props::NumRef { num: 1, level: 0 })); + assert_eq!(n(1), Some(wordcraft_doc::props::NumRef { num: 1, level: 0 })); + assert_eq!(n(2), None); + // The numbering produces labels through the shared counter. + let mut c = wordcraft_doc::numbering::Counters::default(); + assert_eq!(c.next_label(&doc.numbering, 1, 0).unwrap().0, "1."); + assert_eq!(c.next_label(&doc.numbering, 1, 0).unwrap().0, "2."); +} + +/// The combined D3 fixture: intro paragraph, table, list, two sections, header story. +fn structured_doc() -> Vec { + let def2 = t_def_table(&[0, 2880, 5760]); + let mut row: Vec = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]), def2].concat(); + let listed = [grpprl(&[(0x260A, &[0x00])]), grpprl(&[(0x460B, &[0x01, 0x00])])].concat(); + let text = "Intro\ra\u{7}b\u{7}first\rsecond\r"; + let levels: Vec> = (0..9).map(|_| lvl(1, 0x00, &[0x0000, 0x002E], true)).collect(); + let spec = StructSpec { + text, + header_stories: vec!["Running head\r\r"], + sections: vec![ + (6, Vec::new()), // section 1: the intro paragraph; its header story is the odd header + ( + text.chars().count() as u32, + grpprl(&[(0xB01F, &15840u16.to_le_bytes()), (0xB020, &12240u16.to_le_bytes())]), // landscape letter + ), + ], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![ + (0, 0, Vec::new()), // "Intro" paragraph, ends section 1 + (6, 0, grpprl(&[(0x2416, &[0x01])])), // "a" cell paragraph + (8, 0, std::mem::take(&mut row)), // "b" cell paragraph, ends the row + (10, 0, listed.clone()), // "first" paragraph, in the list + (16, 0, listed), // "second" paragraph + ], + plf_lst: plf_lst(0x2000, false, &levels), + plf_lfo: plf_lfo(0x2000), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + struct_doc(&spec) +} + +#[test] +fn combined_structure_document() { + let doc = read(&structured_doc()).expect("opens"); + // Body shape: intro paragraph, table, two list paragraphs. + assert!(doc.body.first().and_then(|b| b.as_para()).is_some_and(|p| p.text == "Intro"), "intro para"); + let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table"); + assert_eq!(tbl.rows.len(), 1); + assert_eq!(tbl.rows[0].cells.len(), 2); + let texts: Vec = doc.plain_text(wordcraft_doc::StoryRef::Body).split('\n').map(str::to_string).collect(); + assert!(texts.contains(&"first".to_string()) && texts.contains(&"second".to_string()), "list text present: {texts:?}"); + // Section 1 is attached to the intro paragraph and owns the header story; the final + // section is landscape. + let sec1 = doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.section.clone()).expect("section on intro"); + let h = sec1.headers.default.expect("header part"); + assert!(doc.parts.get(&h).is_some_and(|p| p.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Running head"))))); + assert_eq!(doc.last_section.page_w, 792.0); + assert_eq!(doc.last_section.page_h, 612.0); + assert!(doc.last_section.landscape); + // The whole document round-trips through JSON (the wcraft.json shape). + let json = serde_json::to_value(&doc).expect("serialises"); + assert!(json.get("body").is_some_and(|b| b.as_array().is_some_and(|a| !a.is_empty()))); + assert!(json.get("numbering").is_some_and(|n| n.get("nums").is_some_and(|x| x.as_array().is_some_and(|a| !a.is_empty())))); + assert!(json.get("lastSection").is_some_and(|s| s.get("landscape") == Some(&serde_json::json!(true)))); +} + +// --------------------------------------------------------------------------------------------- +// D4: notes, fields, bookmarks, images. + +/// A PLC: aCP[n+1] u32s followed by `data` bytes per element. +fn plc(cps: &[u32], data: &[u8]) -> Vec { + let mut v = Vec::new(); + for c in cps { + v.extend_from_slice(&c.to_le_bytes()); + } + v.extend_from_slice(data); + v +} + +/// A SttbfBkmk with the given names. +fn sttbf_bkmk(names: &[&str]) -> Vec { + let mut v = 0xFFFFu16.to_le_bytes().to_vec(); + v.extend_from_slice(&(names.len() as u16).to_le_bytes()); + v.extend_from_slice(&0u16.to_le_bytes()); // cbExtra + for n in names { + v.extend_from_slice(&(n.chars().count() as u16).to_le_bytes()); + for c in n.chars() { + v.extend_from_slice(&(c as u16).to_le_bytes()); + } + } + v +} + +#[test] +fn footnote_reference_links_story_part() { + let text = "See\u{2} here\r"; + let ccp = text.chars().count() as u32; + let spec = StructSpec { + text, + note_stories: vec!["Note body\r"], + // Pair 2 (PlcffndRef): the 0x02 sits at CP 3, auto-numbered. Pair 3 (PlcffndTxt): + // one story spanning the whole footnote subdocument. + extra_pairs: vec![(2, plc(&[3, ccp], &1u16.to_le_bytes())), (3, plc(&[0, 10], &[]))], + ..structured_spec(text, ccp) + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + let note = p0.objects.first().expect("note ref object"); + let wordcraft_doc::para::InlineObject::NoteRef { kind, id, .. } = note else { panic!("not a note: {note:?}") }; + assert_eq!(*kind, wordcraft_doc::para::NoteKind::Footnote); + let part = doc.parts.get(id).expect("note part"); + assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Note body"))), "note text: {:?}", part.blocks); + assert_eq!(part.kind, wordcraft_doc::PartKind::Footnote); +} + +#[test] +fn endnote_reference_links_story_part() { + let text = "Ref\u{2}\r"; + let ccp = text.chars().count() as u32; + let spec = StructSpec { + text, + // A footnote story keeps the endnote subdocument at a non-zero offset. + note_stories: vec!["Ftn\r"], + endnote_stories: vec!["Endnote body\r"], + extra_pairs: vec![ + (46, plc(&[3, ccp], &1u16.to_le_bytes())), + (47, plc(&[0, 13], &[])), // "Endnote body\r" + ], + ..structured_spec(text, ccp) + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + let wordcraft_doc::para::InlineObject::NoteRef { kind, id, .. } = p0.objects.first().expect("note ref object") else { panic!("no note ref") }; + assert_eq!(*kind, wordcraft_doc::para::NoteKind::Endnote); + let part = doc.parts.get(id).expect("endnote part"); + assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Endnote body")))); +} + +#[test] +fn fields_and_hyperlinks() { + // A HYPERLINK field (result kept as linked text) and a PAGE field (inline object). + let text = "Go \u{13}HYPERLINK http://x\u{14}site\u{15} n\u{13}PAGE\u{14}3\u{15}.\r"; + let ccp = text.chars().count() as u32; + let spec = structured_spec(text, ccp); + let doc = read(&struct_doc(&spec)).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + assert_eq!(p0.text, "Go site n\u{FFFC}."); + // The hyperlink result run carries the target. + let linked = p0.runs.iter().find(|r| r.props.link.is_some()).expect("linked run"); + assert_eq!(linked.props.link.as_deref(), Some("http://x")); + // The PAGE field became one object with its cached result. + let wordcraft_doc::para::InlineObject::Field { instr, result, .. } = p0.objects.first().expect("field object") else { + panic!("no field: {:?}", p0.objects) + }; + assert_eq!(instr, "PAGE"); + assert_eq!(result, "3"); +} + +#[test] +fn bookmarks_become_objects() { + let text = "Hello world\r"; + let ccp = text.chars().count() as u32; + let spec = StructSpec { + text, + extra_pairs: vec![(21, sttbf_bkmk(&["bm1"])), (22, plc(&[0, ccp], &[0, 0, 0, 0])), (23, plc(&[5, ccp], &[0, 0]))], + ..structured_spec(text, ccp) + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + // The start anchor sits before the text, the end after "Hello". + assert!( + matches!(p0.objects.first(), Some(wordcraft_doc::para::InlineObject::BookmarkStart { name }) if name == "bm1"), + "objects: {:?}", + p0.objects + ); + assert!(matches!(p0.objects.get(1), Some(wordcraft_doc::para::InlineObject::BookmarkEnd { name }) if name == "bm1"), "objects: {:?}", p0.objects); + assert_eq!(p0.text, "\u{FFFC}Hello\u{FFFC} world"); +} + +#[test] +fn inline_png_picture() { + let text = "Pic\u{1}!\r"; + let ccp = text.chars().count() as u32; + // A PICF header (68 bytes) with a 1×0.5-inch goal size, then PNG-ish bytes. + let png: Vec = [b"\x89PNG\r\n\x1a\n".as_slice(), &[0u8; 16]].concat(); + let mut picf = vec![0u8; 0x44]; + let lcb = (0x44 + png.len()) as u32; + picf[0..4].copy_from_slice(&lcb.to_le_bytes()); + picf[4..6].copy_from_slice(&0x44u16.to_le_bytes()); + picf[28..30].copy_from_slice(&1440i16.to_le_bytes()); // dxaGoal + picf[30..32].copy_from_slice(&720i16.to_le_bytes()); // dyaGoal + picf[32..34].copy_from_slice(&1000u16.to_le_bytes()); // mx = 100% + picf[34..36].copy_from_slice(&1000u16.to_le_bytes()); // my = 100% + let data: Vec = [picf, png.clone()].concat(); + let spec = StructSpec { text, data_stream: data, chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))], ..structured_spec(text, ccp) }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + assert_eq!(p0.text, "Pic\u{FFFC}!"); + let wordcraft_doc::para::InlineObject::Image { media, w, h, .. } = p0.objects.first().expect("image object") else { + panic!("no image: {:?}", p0.objects) + }; + assert_eq!((*w, *h), (72.0, 36.0)); // 1440 × 720 twips + let bytes = doc.media.get(media).expect("media entry"); + assert_eq!(bytes.as_slice(), png.as_slice()); +} + +/// The D4 hostile-input fixture: notes, a field, a bookmark and a picture in one file. +fn extras_doc() -> Vec { + let png: Vec = [b"\x89PNG\r\n\x1a\n".as_slice(), &[0u8; 8]].concat(); + let mut picf = vec![0u8; 0x44]; + let lcb = (0x44 + png.len()) as u32; + picf[0..4].copy_from_slice(&lcb.to_le_bytes()); + picf[4..6].copy_from_slice(&0x44u16.to_le_bytes()); + picf[28..30].copy_from_slice(&720i16.to_le_bytes()); + picf[30..32].copy_from_slice(&720i16.to_le_bytes()); + picf[32..34].copy_from_slice(&1000u16.to_le_bytes()); + picf[34..36].copy_from_slice(&1000u16.to_le_bytes()); + let text: &'static str = "Box\u{2}\u{1}\u{13}PAGE\u{14}7\u{15}\r"; + let ccp = text.chars().count() as u32; + let spec = StructSpec { + text, + note_stories: vec!["Note text\r"], + extra_pairs: vec![ + (2, plc(&[3, ccp], &1u16.to_le_bytes())), + (3, plc(&[0, 10], &[])), + (21, sttbf_bkmk(&["bm"])), + (22, plc(&[0, ccp], &[0, 0, 0, 0])), + (23, plc(&[4, ccp], &[0, 0])), + ], + data_stream: [picf, png].concat(), + chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))], + ..structured_spec(text, ccp) + }; + struct_doc(&spec) +} + +/// The shared base of the D4 fixtures: one Normal style, one mark, no sections extras. +fn structured_spec(text: &'static str, ccp: u32) -> StructSpec { + StructSpec { + text, + header_stories: vec![], + sections: vec![(ccp, Vec::new())], + chpx_runs: vec![(0, Vec::new())], + // The PAPX entry key is the CP just past the paragraph mark. + papx_entries: vec![(0, 0, Vec::new())], + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + } +} + +// --------------------------------------------------------------------------------------------- +// Hardening regressions: hostile CP ranges, piece lookups, picture fan-out, overflows. + +/// All the paragraph text of a document: body plus every part. +fn all_text(doc: &wordcraft_doc::Document) -> String { + let mut s: String = body_texts(doc).join("\n"); + for part in doc.parts.values() { + for b in &part.blocks { + if let Some(p) = b.as_para() { + s.push('\n'); + s.push_str(&p.text); + } + } + } + s +} + +#[test] +fn huge_story_ranges_are_clamped_to_their_subdocument() { + // Footnote, header and endnote stories whose Table-stream CPs claim [0, 4e9) (and + // ranges near u32::MAX that overflowed `base + cp`): before the clamp, each pushed + // billions of U+FFFD (or panicked on the addition). + let text = "A\u{2}\u{2}\r"; + let ccp = text.chars().count() as u32; + let big = 4_000_000_000u32; + let hdd = plc(&[0, 0, 0, 0, 0, 0, 0, 0, big, 0xFFFF_FFF0, 0xFFFF_FFFF, 0xFFFF_FFFF, 0xFFFF_FFFF], &[]); + let spec = StructSpec { + text, + note_stories: vec!["Note body\r"], + header_stories: vec!["Head\r"], + endnote_stories: vec!["End\r"], + extra_pairs: vec![ + (2, plc(&[1, ccp], &1u16.to_le_bytes())), + (3, plc(&[0, big], &[])), + (11, hdd), + (46, plc(&[2, ccp], &1u16.to_le_bytes())), + (47, plc(&[0xFFFF_FFF0, 0xFFFF_FFFF], &[])), + ], + ..structured_spec(text, ccp) + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let all = all_text(&doc); + assert!(!all.contains('\u{FFFD}'), "replacement characters: {} bytes of text", all.len()); + assert!(all.len() < 200, "text grew to {} bytes", all.len()); + let header = doc.last_section.headers.default.and_then(|id| doc.parts.get(&id)).expect("header part"); + assert!(header.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text == "Head")), "header: {:?}", header.blocks); + let footnote = doc.parts.values().find(|p| p.kind == wordcraft_doc::PartKind::Footnote).expect("footnote part"); + assert!(footnote.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text == "Note body"))); + // The endnote story starts past the endnote subdocument: nothing to read. + assert!(!doc.parts.values().any(|p| p.kind == wordcraft_doc::PartKind::Endnote)); +} + +/// A document whose piece table is given raw (cps + one Pcd per piece), with `text` bytes +/// at TEXT_FC and the FIB claiming `ccp_text` main-document characters. +fn raw_piece_doc(cps: &[u32], fcs: &[u32], text: &[u8], ccp_text: u32) -> Vec { + raw_piece_doc_with(cps, fcs, text, ccp_text, &[]) +} + +/// [`raw_piece_doc`] with extra raw FIB (pair, fc, lcb) entries. +fn raw_piece_doc_with(cps: &[u32], fcs: &[u32], text: &[u8], ccp_text: u32, extra: &[(usize, u32, u32)]) -> Vec { + let mut plc = Vec::new(); + for c in cps { + plc.extend_from_slice(&c.to_le_bytes()); + } + for fc in fcs { + plc.extend_from_slice(&[0, 0]); + plc.extend_from_slice(&fc.to_le_bytes()); + plc.extend_from_slice(&0u16.to_le_bytes()); + } + let mut clx = vec![0x02u8]; + clx.extend_from_slice(&(plc.len() as u32).to_le_bytes()); + clx.extend_from_slice(&plc); + let mut table = vec![0u8; CLX_AT as usize]; + table.extend_from_slice(&clx); + let mut word = vec![0u8; TEXT_FC as usize]; + word.extend_from_slice(text); + let mut pairs = vec![(33, CLX_AT, clx.len() as u32)]; + pairs.extend_from_slice(extra); + let spec = FibSpec { ccp_text, pairs, ..Default::default() }; + let fib = fib_bytes(&spec); + word[..fib.len()].copy_from_slice(&fib); + cfb_file(&[("WordDocument", &word), ("0Table", &table)]) +} + +#[test] +fn piece_claiming_far_more_text_than_stored_is_skipped() { + // One compressed piece claims CPs [0, 100M) but only "Hi\r" is stored: the walk used to + // emit a U+FFFD per missing CP (100M of them). + let fc = (2 * TEXT_FC) | 0x4000_0000; + let f = raw_piece_doc(&[0, 100_000_000], &[fc], b"Hi\r", 100_000_000); + let doc = read(&f).expect("opens"); + assert_eq!(body_texts(&doc), vec!["Hi".to_string()]); +} + +#[test] +fn walk_stops_at_the_last_piece() { + // The FIB claims 100M characters but the piece table ends at CP 3. + let fc = (2 * TEXT_FC) | 0x4000_0000; + let f = raw_piece_doc(&[0, 3], &[fc], b"Hi\r", 100_000_000); + let doc = read(&f).expect("opens"); + assert_eq!(body_texts(&doc), vec!["Hi".to_string()]); +} + +#[test] +fn inverted_and_gapped_pieces_are_skipped() { + // Piece 0 is inverted (CPs 5 → 2), piece 1 reads "ok\r", piece 2 points past the stream. + let base = (2 * TEXT_FC) | 0x4000_0000; + let f = raw_piece_doc(&[5, 2, 5, 50_000_000], &[base, base, 0x3FFF_0000], b"ok\r", 50_000_000); + let doc = read(&f).expect("opens"); + let all = all_text(&doc); + assert!(!all.contains('\u{FFFD}')); + assert!(all.len() < 20, "{all:?}"); +} + +#[test] +fn many_pieces_walk_in_n_log_n() { + // 200k one-character pieces: the old per-character scan over every piece was + // O(chars × pieces) = 4e10 steps; with binary search this opens instantly. + let n = 200_000usize; + let mut text = vec![b'x'; n - 1]; + text.push(b'\r'); + let cps: Vec = (0..=n as u32).collect(); + let fcs: Vec = (0..n as u32).map(|i| (2 * (TEXT_FC + i)) | 0x4000_0000).collect(); + let started = std::time::Instant::now(); + let doc = read(&raw_piece_doc(&cps, &fcs, &text, n as u32)).expect("opens"); + let body = body_texts(&doc); + assert_eq!(body.len(), 1); + assert_eq!(body[0].len(), n - 1); + assert!(started.elapsed() < std::time::Duration::from_secs(30), "took {:?}", started.elapsed()); +} + +#[test] +fn surrogate_pairs_decode_across_one_cp_each() { + // `Piece` stores one u16 per char, so write the pair by hand. + let units: Vec = "a\u{1F600}b\r".encode_utf16().collect(); + let bytes: Vec = units.iter().flat_map(|u| u.to_le_bytes()).collect(); + let n = units.len() as u32; + let doc = read(&raw_piece_doc(&[0, n], &[TEXT_FC], &bytes, n)).expect("opens"); + assert_eq!(body_texts(&doc), vec!["a\u{1F600}b".to_string()]); +} + +/// A PICF header (68 bytes) for an `n`-byte PNG-looking payload. +fn picf_with_png(n: usize) -> Vec { + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.resize(n.max(8), 0xAB); + let mut picf = vec![0u8; 0x44]; + picf[0..4].copy_from_slice(&((0x44 + png.len()) as u32).to_le_bytes()); + picf[4..6].copy_from_slice(&0x44u16.to_le_bytes()); + picf[28..30].copy_from_slice(&1440i16.to_le_bytes()); + picf[30..32].copy_from_slice(&1440i16.to_le_bytes()); + picf.extend_from_slice(&png); + picf +} + +#[test] +fn anchors_sharing_one_picture_share_one_media_entry() { + // 5000 anchors on the same 16 MB picture used to copy it once per anchor (80 GB) + // before the copies were deduplicated. + let text: &'static str = Box::leak(format!("{}\r", "\u{1}".repeat(5000)).into_boxed_str()); + let ccp = text.chars().count() as u32; + let spec = StructSpec { + text, + data_stream: picf_with_png(16 << 20), + chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))], + ..structured_spec(text, ccp) + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + assert_eq!(doc.media.len(), 1); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + assert_eq!(p0.objects.len(), 5000); + assert!(p0.objects.iter().all(|o| matches!(o, wordcraft_doc::para::InlineObject::Image { media, .. } if media == "image1.png"))); +} + +#[test] +fn picture_bytes_are_capped_per_document() { + let a = picf_with_png(600); + let b = picf_with_png(600); + let data: Vec = [a.clone(), b].concat(); + let mut pics = crate::media::Pictures::default(); + pics.limit = 1000; + let first = pics.get(&data, 0).expect("first fits"); + // Same location again: no new bytes. + assert_eq!(pics.get(&data, 0).map(|p| p.0), Some(first.0.clone())); + // A second distinct picture would exceed the cap. + assert!(pics.get(&data, a.len() as u32).is_none()); + assert_eq!(pics.media.len(), 1); + const { assert!(crate::media::MAX_MEDIA_BYTES <= 256 << 20) }; +} + +#[test] +fn row_height_of_i16_min_does_not_overflow() { + // sprmTDyaRowHeight = -32768: negating the i16 overflowed (a debug-build panic). + let papx = grpprl(&[(0x9407, &i16::MIN.to_le_bytes())]); + let row = crate::table::decode(&papx); + assert_eq!(row.height(), Some(1584.0)); +} + +#[test] +fn list_and_lfo_offsets_near_u32_max_are_safe() { + // PlfLst / PlfLfo at fc = u32::MAX: offset sums like `at + 2 + cLst * 28` must saturate + // (they overflow on 32-bit targets such as wasm32). + let fc = (2 * TEXT_FC) | 0x4000_0000; + let far = u32::MAX - 1; + let f = raw_piece_doc_with(&[0, 2], &[fc], b"a\r", 2, &[(73, far, 2), (74, far, 4)]); + let doc = read(&f).expect("opens"); + assert!(doc.numbering.nums.is_empty()); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index c5a53d8..f734417 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -16,6 +16,7 @@ wordcraft-layout = { workspace = true } wordcraft-render = { workspace = true } wordcraft-proof = { workspace = true } wordcraft-docx = { workspace = true } +wordcraft-docbin = { workspace = true } wordcraft-pdf = { workspace = true } wordcraft-formats = { workspace = true } serde = { workspace = true } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 0d4d518..1e93453 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -3,8 +3,26 @@ use wordcraft_doc::Document; /// Formats WordCraft opens. -pub const OPEN_EXTS: &[&str] = - &["docx", "docm", "dotx", "dotm", "txt", "md", "markdown", "html", "htm", "rtf", "odt", "tex", "latex", "ltx", "wcraft.json", "json"]; +pub const OPEN_EXTS: &[&str] = &[ + "docx", + "docm", + "dotx", + "dotm", + "doc", + "dot", + "txt", + "md", + "markdown", + "html", + "htm", + "rtf", + "odt", + "tex", + "latex", + "ltx", + "wcraft.json", + "json", +]; /// Formats WordCraft saves (Save As). pub const SAVE_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "tex", "png", "json"]; diff --git a/crates/engine/src/io_ext.rs b/crates/engine/src/io_ext.rs index 7e4a70e..6c41656 100644 --- a/crates/engine/src/io_ext.rs +++ b/crates/engine/src/io_ext.rs @@ -6,6 +6,7 @@ use wordcraft_doc::Document; pub fn open(ext: &str, bytes: &[u8]) -> Option> { match ext { "docx" | "docm" | "dotx" | "dotm" => Some(wordcraft_docx::read(bytes).map_err(|e| e.to_string())), + "doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())), other => wordcraft_formats::import(other, bytes), } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index edd1e9d..a9ffd8f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -16,6 +16,15 @@ fn text(s: &Session) -> String { s.doc.plain_text(StoryRef::Body) } +#[test] +fn doc_extension_dispatches_to_docbin() { + // Garbage bytes with a .doc name must produce an error through the Word 97-2003 + // reader — never a panic and never a silently empty document. + for name in ["x.doc", "x.dot"] { + assert!(crate::io::open_bytes(name, &[0u8; 64]).is_err(), "{name} should fail"); + } +} + #[test] fn every_command_has_unique_id() { let reg = cmd::registry(); diff --git a/crates/formats/src/txt.rs b/crates/formats/src/txt.rs index a137d0e..c7dd7dd 100644 --- a/crates/formats/src/txt.rs +++ b/crates/formats/src/txt.rs @@ -4,23 +4,10 @@ use wordcraft_doc::Document; +pub use wordcraft_doc::encoding::cp1252; + use crate::model::{FBlock, ListCounter, from_doc}; -/// Windows-1252 code points for 0x80..=0x9F. -const CP1252: [u16; 32] = [ - 0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F, 0x0090, 0x2018, - 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178, -]; - -/// One Windows-1252 byte as a char. -pub fn cp1252(b: u8) -> char { - if (0x80..0xA0).contains(&b) { - CP1252.get((b - 0x80) as usize).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}') - } else { - b as char - } -} - fn utf16(b: &[u8], le: bool) -> String { let units: Vec = b.as_chunks::<2>().0.iter().map(|c| if le { u16::from_le_bytes(*c) } else { u16::from_be_bytes(*c) }).collect(); String::from_utf16_lossy(&units) diff --git a/docs/mcp.md b/docs/mcp.md index e61bcd6..b9a51cb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -23,7 +23,7 @@ loopback addresses; see [Keys](control-protocol.md#keys). | `execute` | run one command: `{command: "insert.table", params: {rows: 3, cols: 4}}` | | `batch` | run several commands in order | | `new_document` | blank, sample, letter, resume, report | -| `open_document` / `save_document` | docx, odt, rtf, html, md, tex, txt, json; save also pdf, png | +| `open_document` / `save_document` | docx, doc (open only), odt, rtf, html, md, tex, txt, json; save also pdf, png | | `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs) | | `select_text` | select the n-th occurrence of some text | | `get_text` / `inspect_document` | read the document back (verify without screenshots) | diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 17c86b2..1f7dd23 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -40,6 +40,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("proof", Class::Layer(1)), ("layout", Class::Layer(2)), ("docx", Class::Layer(2)), + ("docbin", Class::Layer(2)), ("formats", Class::Layer(2)), ("render", Class::Layer(3)), ("pdf", Class::Layer(3)),