Import Word 97-2003 .doc files (#28) (#145)

* 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=<dir>
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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* Format the merged OPEN_EXTS list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-10 08:36:38 -04:00
1 parent fe7e79ad70
commit 663904a2e2
31 files changed
+4488 -23

No files matched your search

Generated
+25
View File
@@ -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",
+2
View File
@@ -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"
+2 -2
View File
@@ -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).
+1
View File
@@ -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%+ |
+1 -1
View File
@@ -10,7 +10,7 @@ const USAGE: &str = "\
wordcraft-cli — WordCraft from the command line
USAGE:
wordcraft-cli convert <in> <out> convert between formats (docx, pdf, odt, rtf, html, md, tex, txt, json, png)
wordcraft-cli convert <in> <out> convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png)
wordcraft-cli info <file> pages, words, paragraphs, properties (JSON)
wordcraft-cli text <file> plain text
wordcraft-cli inspect <file> document structure (JSON)
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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", &["*"])
};
+16
View File
@@ -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
}
}
+1
View File
@@ -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;
+4
View File
@@ -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);
+20
View File
@@ -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 }
+154
View File
@@ -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<u64, DocbinError> {
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<u32>,
}
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<Fib, DocbinError> {
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 })
}
}
+108
View File
@@ -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<u32>,
/// Page number (× 512 = byte offset in the WordDocument stream).
pns: Vec<u32>,
}
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<Bins, DocbinError> {
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<u32> { 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<u32> { 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<u32> { 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<u16> { 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(&[]))
}
}
+380
View File
@@ -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<Rgb> {
if i == 0 {
return None;
}
ICO.get(i as usize).copied()
}
fn u16_of(p: &Prl) -> Option<u16> {
match p.operand {
[b0, b1, ..] => Some(u16::from_le_bytes([*b0, *b1])),
_ => None,
}
}
fn s16_of(p: &Prl) -> Option<i16> {
u16_of(p).map(|v| v as i16)
}
fn set(props: &mut Option<bool>, 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<i16> { u16_of(prl).map(|v| v as i16) };
let i32of = |prl: &Prl| -> Option<i32> {
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::<String>() + 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);
}
}
}
+599
View File
@@ -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<Document, DocbinError> {
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: &note_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<Run>,
props: wordcraft_doc::props::ParaProps,
mark: CharProps,
/// The current run's properties, so runs only break when formatting changes.
cur: Option<CharProps>,
/// Inline objects, each anchored at a U+FFFC pushed into `text`.
objects: Vec<InlineObject>,
}
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<ParaOut> {
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<FieldBuild> = 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<Vec<(char, CharProps)>>,
}
/// 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<String> {
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<u32> {
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<u32> {
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<F: Read + Seek>(comp: &mut cfb::CompoundFile<F>, name: &str) -> Option<Vec<u8>> {
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;
+294
View File
@@ -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<RawLvl>,
}
/// 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<u8>,
chpx: Vec<u8>,
/// The number text with each placeholder left as its raw char.
xst: Vec<u16>,
}
/// 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<ListDef> {
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<u16> = 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,
}
}
+123
View File
@@ -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<u32, (String, f32, f32)>,
/// The media entries, merged into the document at the end.
pub(crate) media: BTreeMap<String, Arc<Vec<u8>>>,
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<usize>,
ext: &'static str,
}
/// Locate the picture at `fc_pic` in the Data stream without copying it.
fn locate(data: &[u8], fc_pic: u32) -> Option<Located> {
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<usize> {
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"
}
}
+109
View File
@@ -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<Vec<u32>> {
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<Vec<String>> {
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)
}
+187
View File
@@ -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<u32>,
pieces: Vec<Pcd>,
prc_grpprls: Vec<Vec<u8>>,
}
impl PieceTable {
/// Parse the `Clx` at `table[fc .. fc+lcb]`.
pub(crate) fn parse(table: &[u8], fc: u32, lcb: u32) -> Result<PieceTable, DocbinError> {
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<u32> {
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<u16> {
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]>),
}
+177
View File
@@ -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<Section> {
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<u32> { 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<i16> {
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<u32> { 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
}
+93
View File
@@ -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<Item = Prl<'_>> {
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<u16> {
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,
})
}
+163
View File
@@ -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<u8>,
/// Character-formatting UPX grpprl (paragraph and character styles).
pub(crate) chpx: Vec<u8>,
}
/// Parse the `STSH` at `table[fc..fc+lcb]`.
pub(crate) fn parse(table: &[u8], fc: u32, lcb: u32) -> Result<Vec<RawStyle>, 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<u16> = 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<u8>> = 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<String> {
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<u16> { 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<u16> = 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
}
+414
View File
@@ -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<f32>,
height: Option<f32>,
height_rule: HeightRule,
header: bool,
cant_split: bool,
align: Option<Align>,
indent: Option<f32>,
borders: Option<Borders>,
/// 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<f32> {
self.height
}
}
pub(crate) fn decode(papx: &[u8]) -> RowInfo {
let mut r = RowInfo::default();
let mut gap_half: Option<i16> = None;
let mut dxa_left: Option<i16> = 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<ParaOut>) -> Blocks {
let mut out: Blocks = Vec::new();
let mut rows: Vec<Row> = Vec::new();
let mut cells: Vec<Cell> = Vec::new();
let mut cell: Blocks = Vec::new();
let mut grid: Vec<f32> = Vec::new();
let mut props = TableProps::default();
let mut open = false;
let mut depth_seen = 0u32;
fn flush_cell(cells: &mut Vec<Cell>, 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<Cell>, 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<Cell> = 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<Row>, cells: &mut Vec<Cell>, 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<f32> {
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<Borders> {
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<Border> {
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<Rgb> {
let cv = |b: &[u8]| -> Option<Rgb> {
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<Rgb> {
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<u16> {
match p.operand {
[b0, b1, ..] => Some(u16::from_le_bytes([*b0, *b1])),
_ => None,
}
}
fn s16(p: &Prl) -> Option<i16> {
u16_of(p).map(|v| v as i16)
}
fn i32_of(p: &Prl) -> Option<i32> {
match p.operand {
[b0, b1, b2, b3, ..] => Some(i32::from_le_bytes([*b0, *b1, *b2, *b3])),
_ => None,
}
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -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 }
+20 -2
View File
@@ -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"];
+1
View File
@@ -6,6 +6,7 @@ use wordcraft_doc::Document;
pub fn open(ext: &str, bytes: &[u8]) -> Option<Result<Document, String>> {
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),
}
}
+9
View File
@@ -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();
+2 -15
View File
@@ -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<u16> = 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)
+1 -1
View File
@@ -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) |
+1
View File
@@ -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)),