From 5fdccdf60255f761ceea3e2716eadb42fad90878 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 14:57:13 -0700 Subject: [PATCH] Place: Word 97-2003 .doc files place as area type with their formatting File > Place reads a Word 97-2003 document (a compound file named .doc, or one that holds a WordDocument stream) and sets its main text as area type, as it does for .rtf and .docx files. The text comes from the piece table, in 16-bit Unicode or compressed 8-bit pieces. Formatting is resolved from the style sheet's default font, the paragraph style, the character style and the direct character and paragraph properties, and keeps the same properties the .docx reader maps. Table cells are separated by tabs and each row is a paragraph, and a field places as its result. Hidden and deleted text, pictures, headers, footers, notes, comments and text boxes are left out, and the reply's warnings name each kind of content left out or changed once. Word 6.0 and Word 95 documents, encrypted documents and documents last saved with fast save are refused, and so are structures that lie outside their streams. The compound file is read with the cfb crate (MIT), which adds one package to Cargo.lock. --- Cargo.lock | 12 + crates/engine/Cargo.toml | 2 + crates/engine/src/cmd/fileio.rs | 5 +- crates/engine/src/cmd/fileio/formats.rs | 15 +- crates/engine/src/cmd/place.rs | 12 + crates/engine/src/cmd/place/doc.rs | 1161 +++++++++++++++++++++++ crates/engine/src/tests/textimport.rs | 363 ++++++- crates/engine/tests/text_import_fuzz.rs | 162 +++- docs/file-format-parity.md | 4 +- docs/mcp.md | 12 + docs/target-app-parity.md | 1 + 11 files changed, 1734 insertions(+), 15 deletions(-) create mode 100644 crates/engine/src/cmd/place/doc.rs diff --git a/Cargo.lock b/Cargo.lock index 934be1b12..248679aac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -705,6 +705,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" @@ -6205,6 +6216,7 @@ dependencies = [ name = "vectorcraft-engine" version = "0.19.0" dependencies = [ + "cfb", "craft-docpath", "crc32fast", "flate2", diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 7b1199277..9d4ad7399 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -40,6 +40,8 @@ hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["st flate2 = { workspace = true } # The XML parts of Word documents placed as type (place/docx.rs). roxmltree = "0.21" +# The compound file that holds a Word 97-2003 document placed as type (place/doc.rs). +cfb = "0.15" kurbo = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index b873f3638..4079b4230 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -124,8 +124,9 @@ pub(crate) use export_names::{default_name, merge, unique_file_names, with_black use export_names::{distinct_names, write_encoded}; use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, writable}; pub use formats::{ - DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension, file_stem, - format, format_for_name, has_own_title, library_open_filters, open_filters, place_filters, plugin_formats, titled_for_export, writable_format, + DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension, + file_stem, format, format_for_name, has_own_title, library_open_filters, open_filters, place_filters, plugin_formats, titled_for_export, + writable_format, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index a1687e214..8df178845 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -566,8 +566,14 @@ pub const RTF_EXTS: &[&str] = &["rtf"]; /// Word documents: File → Place sets them as area type with their formatting. pub const DOCX_EXTS: &[&str] = &["docx"]; -/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`], [`RTF_EXTS`] and -/// [`DOCX_EXTS`]. +/// Word 97-2003 documents: File → Place sets them as area type with their formatting. +pub const DOC_EXTS: &[&str] = &["doc"]; + +/// The Place dialog's Word filter: Word documents and Word 97-2003 documents. +const WORD_EXTS: &[&str] = &["docx", "doc"]; + +/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`], [`RTF_EXTS`], +/// [`DOCX_EXTS`] and [`DOC_EXTS`]. pub const PLACE_EXTS: &[&str] = &[ "vectorcraft", "drawcraft", @@ -610,6 +616,7 @@ pub const PLACE_EXTS: &[&str] = &[ "txt", "rtf", "docx", + "doc", ]; /// One dialog filter per readable format. @@ -645,13 +652,13 @@ pub fn library_open_filters( } /// File → Place dialog filters: "All placeable files", then one per placeable format, then text, -/// RTF and Word. +/// RTF and Word (`.docx` and `.doc`). pub fn place_filters() -> impl Iterator { std::iter::once(("All placeable files", PLACE_EXTS)) .chain(format_filters().filter(|(_, exts)| exts.iter().all(|ext| PLACE_EXTS.contains(ext)))) .chain(std::iter::once(("Text", TEXT_EXTS))) .chain(std::iter::once(("RTF", RTF_EXTS))) - .chain(std::iter::once(("Word", DOCX_EXTS))) + .chain(std::iter::once(("Word", WORD_EXTS))) } /// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`): a built-in diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 2ab05f69e..d76de1adb 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -20,6 +20,8 @@ //! - An RTF file becomes area type with its character and paragraph formatting ([`rtf`]). //! - A Word document (`.docx`) becomes area type with the character and paragraph formatting its //! styles and direct formatting give ([`docx`]). +//! - A Word 97-2003 document (`.doc`) becomes area type the same way, its main text read from its +//! compound file, with a warning for each kind of content left out ([`doc`]). //! - A VectorCraft document placed from a file with Link (the default) becomes a placed document: //! one locked object showing the artboard `page`, linked to the file and read again when it //! changes ([`document`]); without Link, an editable copy of its art. @@ -30,6 +32,7 @@ //! `place` tool) with several files, which emits one `file.place` per click or drag. pub(crate) mod adopt; +mod doc; pub(crate) mod document; pub(crate) mod docx; mod rtf; @@ -169,6 +172,15 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result) +//! from the compound file \[MS-CFB\] describes +//! (). +//! +//! - The `WordDocument` stream starts with the file information block (FIB, §2.5.1), which locates +//! the structures read from the `0Table` or `1Table` stream (`fWhichTblStm`). Documents saved by +//! Word 97 and later (`nFib` 0x00C1 and above) are read; older ones, encrypted or obfuscated ones +//! and ones last saved with fast save (`fComplex`) are refused. +//! - Text: the main document's `ccpText` characters, from the pieces the Clx's PlcPcd lists +//! (§2.9.38, §2.8.35) in their order, each in 16-bit Unicode or in compressed 8-bit text with the +//! mapping §2.9.73 gives. A paragraph mark (U+000D) ends a paragraph, and so does a page or +//! section break (U+000C) at the end of a paragraph's properties (PapxFkp); a line break +//! (U+000B) starts a new paragraph in the same formatting. A table's cells (cell marks, U+0007) +//! are separated by tabs and each row (a table terminating mark) is a paragraph. Tabs are tabs, +//! and U+001E and U+001F are the nonbreaking and optional hyphens. Of a field (U+0013, U+0014 and +//! U+0015), its result is read. +//! - Character formatting: the font (`sprmCRgFtc0`, named by the font table SttbfFfn), the size, +//! bold, italic, underline (every style but none), strikethrough and double strikethrough, all +//! caps, small caps, superscript and subscript, raised and lowered text (`sprmCHpsPos`) and the +//! color (`sprmCIco`, `sprmCCv`). +//! - Paragraph formatting: alignment (`sprmPJc80`, then `sprmPJc`: start and end follow the +//! direction, and the kashida alignments are justified with Short, Medium or Long kashidas), the +//! left and right indents (the physical ones Word 97 writes, unless the logical ones later +//! versions write are given), first-line indent, space before and after, and direction +//! (`sprmPFBiDi`). +//! - Formatting is resolved as "Determining Formatting Properties" describes: the style sheet's +//! default font and 10 pt, then the paragraph style (STSH, §2.9.271) and the styles it is based +//! on, then the run's character style (`sprmCIstd`) and the styles it is based on, then the +//! direct formatting of the run (Chpx) and of the paragraph (Papx). A toggle operand of 0x80 or +//! 0x81 takes or reverses the value the styles give. +//! +//! Hidden text, deleted revisions, field instructions, pictures and embedded objects, note and +//! comment references and the other stories (footnotes, endnotes, headers, footers, comments and +//! text boxes) are left out. The reply's warnings name each kind of content that was left out or +//! changed, once: pictures and objects, fields, the other stories, tracked changes and tables. A +//! stream larger than [`MAX_STREAM`] bytes, a main text longer than [`text::MAX_TEXT`] characters, +//! more than [`MAX_PIECES`] pieces or [`MAX_PAGES`] property pages, and placed type of more than +//! [`MAX_PARAGRAPHS`] paragraphs or [`MAX_RUNS`] runs are refused, and so is a structure that lies +//! outside its stream. + +use std::io::{Cursor, Read}; + +use vectorcraft_doc::text::Kashida; +use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun}; + +use super::text::{self, Story}; +use super::{EngineError, Paint, Result, bad, fileio}; + +/// Word 97-2003 documents as `file.place` reports them (not a format `document.open` reads). +pub(super) const FORMAT: fileio::Format = fileio::Format { + id: "doc", + label: "Word 97-2003", + extensions: fileio::DOC_EXTS, + mime: "application/msword", + read: true, + write: false, + raster: false, + options: &[], +}; + +/// The first bytes of a compound file (\[MS-CFB\] §2.2). +const SIGNATURE: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; +/// The most bytes a stream read from the compound file may hold. +const MAX_STREAM: u64 = 64 << 20; +/// The most pieces the main text may be made of. +const MAX_PIECES: usize = 100_000; +/// The most 512-byte pages of character or of paragraph properties read. +const MAX_PAGES: usize = 16_384; +/// The most styles read from the style sheet (`Stshif.cstd` is below 0x0FFE). +const MAX_STYLES: usize = 0x0FFE; +/// The most styles one style's chain of `istdBase` takes in. +const MAX_BASED_ON: usize = 32; +/// The most fonts read from the font table (SttbfFfn's `cData` is at most 0x7FF0). +const MAX_FONTS: usize = 0x7FF0; +/// The most bytes of a font name kept. +const MAX_NAME: usize = 256; +/// The most paragraphs placed type takes from one Word document. +const MAX_PARAGRAPHS: usize = 100_000; +/// The most runs of one character formatting placed type takes from one Word document. +const MAX_RUNS: usize = 100_000; +/// The deepest nesting of fields read; deeper ones are left out with their results. +const MAX_FIELDS: usize = 64; +/// `istdBase` of a style that is based on none. +const NO_STYLE: u16 = 0x0FFF; + +/// The FibRgFcLcb97 pairs read (§2.5.6), by their index. +const STSHF: usize = 1; +const PLCF_BTE_CHPX: usize = 12; +const PLCF_BTE_PAPX: usize = 13; +const STTBF_FFN: usize = 15; +const CLX: usize = 33; +/// The number of pairs FibRgFcLcb97 holds. +const FC_LCB_97: u16 = 0x005D; + +/// Is the file `name` with `bytes` a Word 97-2003 document: a compound file named `.doc`, or one +/// that holds a `WordDocument` stream? +pub(super) fn is_doc(name: &str, bytes: &[u8]) -> bool { + bytes.starts_with(&SIGNATURE) + && (fileio::DOC_EXTS.contains(&fileio::extension(name).as_str()) + || cfb::CompoundFile::open(Cursor::new(bytes)).is_ok_and(|f| f.is_stream("/WordDocument"))) +} + +/// The main text of a Word 97-2003 document's `bytes` (a compound file) with its character and +/// paragraph formatting, and a warning for each kind of content left out or changed. +pub(super) fn import(bytes: &[u8], cmd: &str) -> Result<(Story, Vec)> { + let mut file = cfb::CompoundFile::open(Cursor::new(bytes)).map_err(|_| damaged(cmd, "its compound file structure is damaged"))?; + let wd = stream(&mut file, "WordDocument", cmd)?.ok_or_else(|| bad(cmd, "not a Word document: its compound file has no WordDocument stream"))?; + let fib = Fib::read(&wd, cmd)?; + let table_name = if fib.table_one { "1Table" } else { "0Table" }; + let table = stream(&mut file, table_name, cmd)?.ok_or_else(|| damaged(cmd, format!("it has no {table_name} stream")))?; + let clx = fib.part(&table, CLX, cmd)?.ok_or_else(|| damaged(cmd, "it has no piece table"))?; + let text = main_text(&wd, &pieces(clx, cmd)?, fib.ccp_text, cmd)?; + let chpx = match fib.part(&table, PLCF_BTE_CHPX, cmd)? { + Some(plc) => runs(&wd, plc, Fkp::Chpx, cmd)?, + None => vec![], + }; + let papx = match fib.part(&table, PLCF_BTE_PAPX, cmd)? { + Some(plc) => runs(&wd, plc, Fkp::Papx, cmd)?, + None => vec![], + }; + let sheet = match fib.part(&table, STSHF, cmd)? { + Some(stsh) => Sheet::read(stsh, cmd)?, + None => Sheet::default(), + }; + let fonts = match fib.part(&table, STTBF_FFN, cmd)? { + Some(sttb) => fonts(sttb, cmd)?, + None => vec![], + }; + let mut r = Reader::new(&sheet, &fonts); + r.read(&text, &chpx, &papx); + let warnings = r.warnings(&fib); + Ok((r.finish(cmd)?, warnings)) +} + +/// The error for a Word document whose structures can't be read. +fn damaged(cmd: &str, why: impl std::fmt::Display) -> EngineError { + bad(cmd, format!("can't read this Word document: {why}")) +} + +/// The stream `name` of the compound file; None when it has no such stream. +fn stream(file: &mut cfb::CompoundFile>, name: &str, cmd: &str) -> Result>> { + if !file.is_stream(name) { + return Ok(None); + } + let s = file.open_stream(name).map_err(|_| damaged(cmd, format!("its {name} stream is damaged")))?; + if s.len() > MAX_STREAM { + return Err(bad(cmd, format!("the Word document is too large to place (its {name} stream holds more than {} MB)", MAX_STREAM >> 20))); + } + let mut out = vec![]; + s.take(MAX_STREAM).read_to_end(&mut out).map_err(|_| damaged(cmd, format!("its {name} stream is damaged")))?; + Ok(Some(out)) +} + +/// The little-endian 16-bit integer at `at`. +fn u16_at(b: &[u8], at: usize) -> Option { + b.get(at..at.checked_add(2)?).and_then(|s| s.try_into().ok()).map(u16::from_le_bytes) +} + +/// The little-endian 32-bit integer at `at`. +fn u32_at(b: &[u8], at: usize) -> Option { + b.get(at..at.checked_add(4)?).and_then(|s| s.try_into().ok()).map(u32::from_le_bytes) +} + +/// `len` bytes of `b` from `at`. +fn bytes_at(b: &[u8], at: usize, len: usize) -> Option<&[u8]> { + b.get(at..at.checked_add(len)?) +} + +/// What the reader takes from the file information block. +struct Fib { + /// The table stream is `1Table` (else `0Table`). + table_one: bool, + /// The main document's length in characters. + ccp_text: u32, + /// The lengths of the footnotes, headers and footers, comments, endnotes, text boxes and + /// header text boxes (`ccpFtn`, `ccpHdd`, `ccpAtn`, `ccpEdn`, `ccpTxbx`, `ccpHdrTxbx`). + stories: [u32; 6], + /// FibRgFcLcb's offset and size pairs. + fc_lcb: Vec<(u32, u32)>, +} + +impl Fib { + /// The FIB at the start of the WordDocument stream `wd` (§2.5.1). + fn read(wd: &[u8], cmd: &str) -> Result { + if u16_at(wd, 0) != Some(0xA5EC) { + return Err(bad(cmd, "not a Word document: its WordDocument stream doesn't start with a file information block")); + } + let field = |at: usize| u16_at(wd, at).ok_or_else(|| damaged(cmd, "its file information block is cut short")); + if field(2)? < 0x00C1 { + return Err(bad( + cmd, + "the Word document was saved by Word 6.0 or Word 95, which VectorCraft doesn't read; save it as a Word 97-2003 document", + )); + } + let flags = field(10)?; + // fEncrypted (fObfuscated only says how). + if flags & 0x0100 != 0 { + return Err(bad(cmd, "the Word document is encrypted or protected with a password; save it without one to place it")); + } + // fComplex: the last save was an incremental (fast) save. + if flags & 0x0004 != 0 { + return Err(bad( + cmd, + "the Word document was last saved with fast save, which VectorCraft doesn't read; save it again with fast save turned off", + )); + } + // csw (14 16-bit values) and cslw (22 32-bit values) are fixed. + if field(32)? != 0x000E || field(62)? != 0x0016 { + return Err(damaged(cmd, "its file information block is damaged")); + } + let count = field(152)?; + if count < FC_LCB_97 { + return Err(damaged(cmd, "its file information block is damaged")); + } + let pair = |i: usize| Some((u32_at(wd, 154 + 8 * i)?, u32_at(wd, 158 + 8 * i)?)); + let fc_lcb = + (0..usize::from(count)).map(pair).collect::>>().ok_or_else(|| damaged(cmd, "its file information block is cut short"))?; + // FibRgLw97 starts at 64: ccpText is its fourth value. + let lw = |i: usize| u32_at(wd, 64 + 4 * i).unwrap_or_default(); + Ok(Self { table_one: flags & 0x0200 != 0, ccp_text: lw(3), stories: [lw(4), lw(5), lw(7), lw(8), lw(9), lw(10)], fc_lcb }) + } + + /// The structure FibRgFcLcb's pair `index` locates in the table stream `table`; None when it + /// is empty. + fn part<'a>(&self, table: &'a [u8], index: usize, cmd: &str) -> Result> { + let Some(&(fc, lcb)) = self.fc_lcb.get(index).filter(|(_, lcb)| *lcb > 0) else { return Ok(None) }; + bytes_at(table, fc as usize, lcb as usize).map(Some).ok_or_else(|| damaged(cmd, "a table it lists lies outside its table stream")) + } +} + +/// A piece of the text (Pcd): characters `cp..end`, stored from file position `fc`. +struct Piece { + cp: u32, + end: u32, + fc: u32, + /// Compressed 8-bit text (else 16-bit Unicode). + compressed: bool, +} + +/// The pieces the Clx `clx` lists (§2.9.38), in character order. +fn pieces(clx: &[u8], cmd: &str) -> Result> { + let broken = || damaged(cmd, "its piece table is damaged"); + let mut at = 0usize; + // Prc records (property modifiers for pieces) come first. + while clx.get(at) == Some(&0x01) { + let size = u16_at(clx, at + 1).map(|v| v as i16).filter(|v| *v >= 0).ok_or_else(broken)?; + at += 3 + size as usize; + } + if clx.get(at) != Some(&0x02) { + return Err(broken()); + } + let lcb = u32_at(clx, at + 1).ok_or_else(broken)? as usize; + let plc = bytes_at(clx, at + 5, lcb).ok_or_else(broken)?; + if lcb < 4 || !(lcb - 4).is_multiple_of(12) { + return Err(broken()); + } + let n = (lcb - 4) / 12; + if n > MAX_PIECES { + return Err(bad(cmd, format!("the Word document's text is made of more than {MAX_PIECES} pieces, too many to place"))); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let (cp, end) = (u32_at(plc, 4 * i).ok_or_else(broken)?, u32_at(plc, 4 * i + 4).ok_or_else(broken)?); + // A Pcd: 2 bytes of flags, then FcCompressed (fc in bits 0 to 29, fCompressed in bit 30). + let raw = u32_at(plc, 4 * (n + 1) + 8 * i + 2).ok_or_else(broken)?; + if end <= cp || out.last().is_some_and(|p: &Piece| p.end != cp) || (i == 0 && cp != 0) { + return Err(broken()); + } + let compressed = raw & 0x4000_0000 != 0; + let fc = raw & 0x3FFF_FFFF; + out.push(Piece { cp, end, fc: if compressed { fc / 2 } else { fc }, compressed }); + } + Ok(out) +} + +/// A character of the main text: the character, the file position of its bytes, and their number. +type Char = (char, u32, u8); + +/// The first `ccp` characters of the pieces, read from the WordDocument stream `wd`. +fn main_text(wd: &[u8], pieces: &[Piece], ccp: u32, cmd: &str) -> Result> { + if ccp as usize > text::MAX_TEXT { + return Err(bad(cmd, format!("the Word document has too much text to place as type (at most {} MB)", text::MAX_TEXT >> 20))); + } + if pieces.last().is_none_or(|p| p.end < ccp) { + return Err(damaged(cmd, "its piece table doesn't cover its text")); + } + let outside = || damaged(cmd, "a piece of its text lies outside its WordDocument stream"); + let mut out = Vec::with_capacity(ccp as usize); + for p in pieces.iter().take_while(|p| p.cp < ccp) { + let n = (p.end.min(ccp) - p.cp) as usize; + if p.compressed { + let b = bytes_at(wd, p.fc as usize, n).ok_or_else(outside)?; + out.extend(b.iter().zip(p.fc..).map(|(&c, fc)| (compressed(c), fc, 1))); + continue; + } + let b = bytes_at(wd, p.fc as usize, n.checked_mul(2).ok_or_else(outside)?).ok_or_else(outside)?; + let units: Vec<(u16, u32)> = b.as_chunks::<2>().0.iter().zip((p.fc..).step_by(2)).map(|(&u, fc)| (u16::from_le_bytes(u), fc)).collect(); + let mut i = 0; + while let Some(&(u, fc)) = units.get(i) { + let pair = units.get(i + 1).map(|&(low, _)| low).filter(|low| (0xD800..0xDC00).contains(&u) && (0xDC00..0xE000).contains(low)); + let c = match pair { + Some(low) => char::decode_utf16([u, low]).next().and_then(|c| c.ok()), + None => char::from_u32(u32::from(u)), + }; + let width = if pair.is_some() { 4 } else { 2 }; + out.push((c.unwrap_or(char::REPLACEMENT_CHARACTER), fc, width)); + i += usize::from(width / 2); + } + } + Ok(out) +} + +/// Byte `b` of compressed text (§2.9.73): the byte as a Unicode character, except the ones that +/// stand for these. +fn compressed(b: u8) -> char { + const MAPPED: [(u8, char); 24] = [ + (0x82, '\u{201A}'), + (0x83, '\u{0192}'), + (0x84, '\u{201E}'), + (0x85, '\u{2026}'), + (0x86, '\u{2020}'), + (0x87, '\u{2021}'), + (0x88, '\u{02C6}'), + (0x89, '\u{2030}'), + (0x8A, '\u{0160}'), + (0x8B, '\u{2039}'), + (0x8C, '\u{0152}'), + (0x91, '\u{2018}'), + (0x92, '\u{2019}'), + (0x93, '\u{201C}'), + (0x94, '\u{201D}'), + (0x95, '\u{2022}'), + (0x96, '\u{2013}'), + (0x97, '\u{2014}'), + (0x98, '\u{02DC}'), + (0x99, '\u{2122}'), + (0x9A, '\u{0161}'), + (0x9B, '\u{203A}'), + (0x9C, '\u{0153}'), + (0x9F, '\u{0178}'), + ]; + MAPPED.iter().find(|(k, _)| *k == b).map_or(char::from(b), |(_, c)| *c) +} + +/// Which property pages a bin table points to. +#[derive(Clone, Copy, PartialEq)] +enum Fkp { + /// ChpxFkp (§2.9.33): character properties. + Chpx, + /// PapxFkp (§2.9.174): paragraph properties. + Papx, +} + +/// The file positions `start..end` and the properties a property page gives them. +struct Run { + start: u32, + end: u32, + /// The paragraph style (Papx only). + istd: u16, + grpprl: Vec, +} + +/// The runs of the property pages the bin table `plc` (PlcBteChpx or PlcBtePapx) points to, in +/// the WordDocument stream `wd`, ordered by file position. +fn runs(wd: &[u8], plc: &[u8], kind: Fkp, cmd: &str) -> Result> { + let broken = || damaged(cmd, "its character or paragraph properties are damaged"); + if plc.len() < 4 || !(plc.len() - 4).is_multiple_of(8) { + return Err(broken()); + } + let n = (plc.len() - 4) / 8; + if n > MAX_PAGES { + return Err(bad(cmd, format!("the Word document has more than {MAX_PAGES} pages of properties, too many to place"))); + } + let mut out = vec![]; + for i in 0..n { + // PnFkpChpx and PnFkpPapx: the page number in bits 0 to 21. + let pn = u32_at(plc, 4 * (n + 1) + 4 * i).ok_or_else(broken)? & 0x003F_FFFF; + let page = bytes_at(wd, pn as usize * 512, 512).ok_or_else(broken)?; + page_runs(page, kind, &mut out).ok_or_else(broken)?; + } + out.sort_by_key(|r| r.start); + Ok(out) +} + +/// The runs of one 512-byte property page; None when it is damaged. +fn page_runs(page: &[u8], kind: Fkp, out: &mut Vec) -> Option<()> { + let count = usize::from(*page.get(511)?); + let most = if kind == Fkp::Chpx { 0x65 } else { 0x1D }; + if count == 0 || count > most { + return None; + } + for i in 0..count { + let (start, end) = (u32_at(page, 4 * i)?, u32_at(page, 4 * i + 4)?); + // ChpxFkp's rgb: one byte each; PapxFkp's rgbx: 13-byte BxPap, its first byte the offset. + let entry = 4 * (count + 1) + if kind == Fkp::Chpx { i } else { 13 * i }; + let offset = usize::from(*page.get(entry)?) * 2; + let (istd, grpprl) = match (offset, kind) { + (0, _) => (0, vec![]), + (_, Fkp::Chpx) => { + let cb = usize::from(*page.get(offset)?); + (0, bytes_at(page, offset + 1, cb).filter(|_| offset + 1 + cb <= 511)?.to_vec()) + } + (_, Fkp::Papx) => { + // PapxInFkp: 2 × cb − 1 bytes, or (cb = 0) 2 × cb′ bytes after cb′. + let (at, len) = match usize::from(*page.get(offset)?) { + 0 => (offset + 2, usize::from(*page.get(offset + 1)?) * 2), + cb => (offset + 1, 2 * cb - 1), + }; + let grp = bytes_at(page, at, len).filter(|_| at + len <= 511 && len >= 2)?; + (u16_at(grp, 0)?, grp.get(2..)?.to_vec()) + } + }; + if end <= start { + return None; + } + out.push(Run { start, end, istd, grpprl }); + } + Some(()) +} + +/// The run of `runs` (ordered by file position) that holds file position `fc`. +fn run_at(runs: &[Run], fc: u32) -> Option { + let i = runs.partition_point(|r| r.start <= fc).checked_sub(1)?; + runs.get(i).filter(|r| fc < r.end).map(|_| i) +} + +/// The size of the operand of `sprm` (§2.2.5.1) whose bytes start `rest`. +fn operand_len(sprm: u16, rest: &[u8]) -> Option { + Some(match sprm >> 13 { + 0 | 1 => 1, + 2 | 4 | 5 => 2, + 3 => 4, + 7 => 3, + // sprmTDefTable: a 2-byte size of the rest, plus 1. + _ if sprm == 0xD608 => usize::from(u16_at(rest, 0)?) + 1, + // sprmPChgTabs with cb 255: the deleted tabs with their close tolerances, then the added + // tabs with their descriptors. + _ if sprm == 0xC615 && rest.first() == Some(&255) => { + let deleted = usize::from(*rest.get(1)?); + let added = usize::from(*rest.get(2 + 4 * deleted)?); + 3 + 4 * deleted + 3 * added + } + _ => usize::from(*rest.first()?) + 1, + }) +} + +/// Calls `f` with each Prl of `grpprl`: its sprm and its operand. +fn each_prl(grpprl: &[u8], mut f: impl FnMut(u16, &[u8])) { + let mut at = 0; + while let Some(sprm) = u16_at(grpprl, at) { + let rest = grpprl.get(at + 2..).unwrap_or_default(); + let Some((n, op)) = operand_len(sprm, rest).and_then(|n| Some((n, rest.get(..n)?))) else { return }; + // A variable operand's bytes follow its size. + let op = if sprm >> 13 == 6 && !matches!(sprm, 0xD608 | 0xC615) { op.get(1..).unwrap_or_default() } else { op }; + f(sprm, op); + at += 2 + n; + } +} + +/// A style of the style sheet. +struct Style { + /// 1: paragraph style, 2: character style. + stk: u8, + base: u16, + papx: Vec, + chpx: Vec, +} + +/// The style sheet: its styles by istd and its default font. +#[derive(Default)] +struct Sheet { + styles: Vec>, + /// `Stshif.ftcAsci`. + font: Option, +} + +impl Sheet { + /// The style sheet STSH (§2.9.271). + fn read(stsh: &[u8], cmd: &str) -> Result { + let broken = || damaged(cmd, "its style sheet is damaged"); + let size = usize::from(u16_at(stsh, 0).ok_or_else(broken)?); + let stshi = bytes_at(stsh, 2, size).ok_or_else(broken)?; + let count = usize::from(u16_at(stshi, 0).ok_or_else(broken)?).min(MAX_STYLES); + // cbSTDBaseInFile: StdfBase, and StdfPost2000 when it is 18. + let base_size = usize::from(u16_at(stshi, 2).ok_or_else(broken)?).max(10); + let font = u16_at(stshi, 12); + let mut at = 2 + size; + let mut styles = Vec::with_capacity(count); + for _ in 0..count { + let Some(len) = u16_at(stsh, at) else { break }; + let std = bytes_at(stsh, at + 2, usize::from(len)).ok_or_else(broken)?; + styles.push(if std.is_empty() { None } else { Some(style(std, base_size).ok_or_else(broken)?) }); + // LPStd records start on even offsets. + at += 2 + usize::from(len) + usize::from(len) % 2; + } + Ok(Self { styles, font }) + } + + /// Style `istd` of kind `stk` and the styles it is based on, the first of the chain first. + fn chain(&self, istd: u16, stk: u8) -> Vec<&Style> { + let mut seen: Vec = vec![]; + let mut chain = vec![]; + let mut next = istd; + while next != NO_STYLE && !seen.contains(&next) && seen.len() < MAX_BASED_ON { + let Some(s) = self.styles.get(usize::from(next)).and_then(Option::as_ref).filter(|s| s.stk == stk) else { break }; + seen.push(next); + chain.push(s); + next = s.base; + } + chain.reverse(); + chain + } +} + +/// A style definition (STD, §2.9.258) whose Stdf takes `base_size` bytes; None when it is damaged. +fn style(std: &[u8], base_size: usize) -> Option