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