mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-14 03:29:04 +00:00
* 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:
31 files changed
+4488
-23
No files matched your search
Generated
+25
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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%+ |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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", &["*"])
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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(&[]))
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: ¬e_links, marks: &marks, ..ctx };
|
||||
|
||||
// Sections: each section's properties attach to the paragraph that ends it (its story of
|
||||
// header/footer parts included); the section reaching the end of the text is the final one.
|
||||
let mut paras = walk(&ctx, 0, fib.ccp.text, &mut pics);
|
||||
let last = sects.last().map(|s| s.props.clone());
|
||||
for (si, sec) in sects.iter().enumerate() {
|
||||
let mut props = sec.props.clone();
|
||||
let id_of = |k: usize| hf_ids.iter().find(|(s, kk, _)| *s == si && *kk == k).map(|(_, _, id)| *id);
|
||||
props.headers = HeaderSet { even: id_of(0), default: id_of(1), first: id_of(4) };
|
||||
props.footers = HeaderSet { even: id_of(2), default: id_of(3), first: id_of(5) };
|
||||
let is_final = si + 1 == sects.len() || sec.end_cp >= fib.ccp.text;
|
||||
if is_final {
|
||||
continue; // handled below through `last`
|
||||
}
|
||||
for p in paras.iter_mut() {
|
||||
if p.end_cp == sec.end_cp {
|
||||
p.para.section = Some(Box::new(props));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(mut l) = last {
|
||||
let si = sects.len().saturating_sub(1);
|
||||
let id_of = |k: usize| hf_ids.iter().find(|(s, kk, _)| *s == si && *kk == k).map(|(_, _, id)| *id);
|
||||
l.headers = HeaderSet { even: id_of(0), default: id_of(1), first: id_of(4) };
|
||||
l.footers = HeaderSet { even: id_of(2), default: id_of(3), first: id_of(5) };
|
||||
doc.last_section = l;
|
||||
}
|
||||
|
||||
doc.media.extend(pics.media);
|
||||
doc.body = table::assemble(paras);
|
||||
doc.ensure_nonempty();
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len`
|
||||
/// CPs, as absolute CPs clamped to that subdocument; `None` when empty.
|
||||
fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> {
|
||||
let (a, b) = (a.min(len), b.min(len));
|
||||
if b <= a {
|
||||
return None;
|
||||
}
|
||||
Some((base.checked_add(a)?, base.checked_add(b)?))
|
||||
}
|
||||
|
||||
/// Everything the walker needs besides its CP range.
|
||||
struct WalkCtx<'a> {
|
||||
word: &'a [u8],
|
||||
data: &'a [u8],
|
||||
pieces: &'a PieceTable,
|
||||
chpx_bins: &'a Bins,
|
||||
papx_bins: &'a Bins,
|
||||
fonts: &'a [String],
|
||||
raw_styles: &'a [stsh::RawStyle],
|
||||
sheet: &'a StyleSheet,
|
||||
/// (reference CP in the main document, kind, part id) for notes.
|
||||
notes: &'a [(u32, NoteKind, u32)],
|
||||
/// Bookmark boundaries (main-document CPs), sorted for a sequential walk.
|
||||
marks: &'a [(u32, notes::Mark)],
|
||||
}
|
||||
|
||||
/// State while assembling one paragraph from per-CP formatting.
|
||||
#[derive(Default)]
|
||||
struct ParaBuild {
|
||||
text: String,
|
||||
runs: Vec<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;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]>),
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"];
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in new issue
Block a user