diff --git a/crates/doc-binary/src/fib.rs b/crates/doc-binary/src/fib.rs index 7bb3ec149..4224f6962 100644 --- a/crates/doc-binary/src/fib.rs +++ b/crates/doc-binary/src/fib.rs @@ -138,7 +138,9 @@ impl Fib { } 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())); + // `read` sends Word 6.0/95 streams to `word6` before this FIB is parsed. This keeps any + // other caller from reading one with the Word 97 layout, which it doesn't have. + return Err(DocbinError::NotWord("a Word 6.0/95 file doesn't have the Word 97 FIB layout".into())); } if w_ident != W_IDENT { return Err(DocbinError::NotWord(format!("magic 0x{w_ident:04X}, expected 0x{W_IDENT:04X}"))); diff --git a/crates/doc-binary/src/fkp.rs b/crates/doc-binary/src/fkp.rs index 3de272da2..3a795d4a4 100644 --- a/crates/doc-binary/src/fkp.rs +++ b/crates/doc-binary/src/fkp.rs @@ -29,6 +29,19 @@ pub(crate) struct Bins { sorted: bool, /// Page number (× 512 = byte offset in the WordDocument stream). pns: Vec, + /// Runs read ahead of time instead of pages (Word 6.0/95, whose FKPs and sprms are another + /// layout, translated by [`crate::word6`]): sorted by `lo`, not overlapping. + translated: Option>, +} + +/// One run of a [`Bins::translated`] table: the FCs `[lo, hi)` it covers, the paragraph style +/// (for PAPX runs) and a Word 97 grpprl. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct TranslatedRun { + pub(crate) lo: u32, + pub(crate) hi: u32, + pub(crate) istd: u16, + pub(crate) grpprl: Vec, } impl Bins { @@ -37,7 +50,7 @@ impl Bins { 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(), sorted: true, pns: Vec::new() }); + return Ok(Bins { fcs: Vec::new(), sorted: true, pns: Vec::new(), translated: None }); } 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()))?; @@ -56,7 +69,31 @@ impl Bins { pns.push(at(n + 1 + i).ok_or_else(|| DocbinError::Malformed("truncated bin aPn".into()))? & 0x003F_FFFF); } let sorted = fcs.windows(2).all(|w| w.first() <= w.get(1)); - Ok(Bins { fcs, sorted, pns }) + Ok(Bins { fcs, sorted, pns, translated: None }) + } + + /// A table of runs read ahead of time ([`TranslatedRun`]). Runs are sorted by start; one that + /// overlaps an earlier one (a corrupt file) or covers nothing is dropped. + pub(crate) fn translated(mut runs: Vec) -> Bins { + runs.sort_by_key(|r| r.lo); + let mut kept: Vec = Vec::with_capacity(runs.len()); + for r in runs { + if r.lo < r.hi && kept.last().is_none_or(|last| r.lo >= last.hi) { + kept.push(r); + } + } + Bins { fcs: Vec::new(), sorted: true, pns: Vec::new(), translated: Some(kept) } + } + + /// For a translated table: the run covering `fc`, or the gap `[lo, hi)` between runs that + /// holds it. + fn translated_at(runs: &[TranslatedRun], fc: u32) -> Result<&TranslatedRun, (u32, u32)> { + let i = runs.partition_point(|r| r.lo <= fc); + let prev = i.checked_sub(1).and_then(|i| runs.get(i)); + match prev { + Some(r) if fc < r.hi => Ok(r), + _ => Err((prev.map_or(0, |r| r.hi), runs.get(i).map_or(u32::MAX, |r| r.lo))), + } } /// The page containing `fc`, as a slice of the WordDocument stream, and the FC range @@ -77,13 +114,19 @@ impl Bins { /// 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] { + pub(crate) fn chpx<'w>(&'w self, word: &'w [u8], fc: u32) -> &'w [u8] { self.chpx_run(word, fc).grpprl } /// [`Bins::chpx`] with the FC range over which the same grpprl is the answer, so a walk /// over consecutive characters looks it up once per run, not once per character. - pub(crate) fn chpx_run<'w>(&self, word: &'w [u8], fc: u32) -> ChpxRun<'w> { + pub(crate) fn chpx_run<'w>(&'w self, word: &'w [u8], fc: u32) -> ChpxRun<'w> { + if let Some(runs) = &self.translated { + return match Self::translated_at(runs, fc) { + Ok(r) => ChpxRun { grpprl: &r.grpprl, lo: r.lo, hi: r.hi }, + Err((lo, hi)) => ChpxRun { grpprl: &[], lo, hi }, + }; + } let none = ChpxRun { grpprl: &[], lo: 1, hi: 0 }; let Some((page, page_lo, page_hi)) = self.page(word, fc) else { return none }; let Some(&crun) = page.get(511) else { return none }; @@ -113,7 +156,13 @@ impl Bins { /// ([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]) { + pub(crate) fn papx<'w>(&'w self, word: &'w [u8], fc: u32) -> (u16, &'w [u8]) { + if let Some(runs) = &self.translated { + return match Self::translated_at(runs, fc) { + Ok(r) => (r.istd, &r.grpprl), + Err(_) => (0, &[]), + }; + } 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. diff --git a/crates/doc-binary/src/lib.rs b/crates/doc-binary/src/lib.rs index 386e753f5..b2d7f3729 100644 --- a/crates/doc-binary/src/lib.rs +++ b/crates/doc-binary/src/lib.rs @@ -6,6 +6,9 @@ //! like the other Craft parsers, 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. +//! +//! Word 6.0/95 files, which that specification does not cover, are read by `word6` from their +//! observed layout: their text, formatting and tables so far. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] // The code came from an app whose lints allow this. #![allow(clippy::type_complexity)] @@ -29,6 +32,7 @@ mod sections; mod sprm; mod stsh; mod table; +mod word6; use std::sync::Arc; @@ -220,6 +224,9 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) -> if word.len() as u64 > MAX_STREAM { return Err(DocbinError::Limit(format!("WordDocument stream is {} bytes", word.len()))); } + if word6::is_word6(&word) { + return read_word6(&comp, &word, max_paragraphs); + } let enc = fib::peek_encryption(&word); // 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. @@ -431,6 +438,55 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) -> Ok(doc) } +/// Read a Word 6.0/95 file ([`word6`]): its main text, with its character and paragraph +/// formatting and table rows translated to Word 97 sprms, through the same walk as a Word 97 file. +/// Its paragraph styles are read too; character styles and the other stories are not yet, and a +/// section mark reads as a page break. +fn read_word6(comp: &craft_cfb::CompoundFile<'_>, word: &[u8], max_paragraphs: usize) -> Result { + let fib = word6::Fib6::parse(word)?; + let content = word6::content(word, &fib)?; + let mut doc = Document::new(); + // Word opens these files in compatibility mode 11 too. + doc.settings.compat_mode = 11; + if let Some(core) = metadata::read(comp) { + doc.core = core; + } + doc.body.clear(); + // The paragraph styles, built as for Word 97 files from their translated formatting. + doc.styles = Arc::new(fmt::stylesheet(&content.styles, &content.fonts, stsh::StandardFonts::default())); + let sheet: StyleSheet = StyleSheet::clone(&doc.styles); + // As for Word 97 files: the text read can't exceed twice the stream, nor the absolute ceiling. + let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS)); + let work = Work::new(MAX_WORK); + let paragraphs = ParaBudget { left: std::cell::Cell::new(max_paragraphs), exceeded: std::cell::Cell::new(false) }; + let mut pics = media::Pictures::for_input(word.len()); + let ctx = WalkCtx { + word, + data: &[], + pieces: &content.pieces, + chpx_bins: &content.chpx, + papx_bins: &content.papx, + fonts: &content.fonts, + raw_styles: &content.styles, + sheet: &sheet, + notes: &[], + marks: &[], + drawn: &[], + sect_ends: &[], + budget: &budget, + paragraphs: ¶graphs, + work: &work, + }; + let paras = walk(&ctx, 0, fib.ccp_text, &mut pics); + if paragraphs.exceeded.get() { + return Err(paragraph_limit(max_paragraphs)); + } + doc.body = table::assemble(paras); + doc.ensure_nonempty(); + work.log(); + Ok(doc) +} + fn paragraph_limit(max_paragraphs: usize) -> DocbinError { DocbinError::Limit(format!("the document has more than {max_paragraphs} paragraphs and table cells")) } @@ -648,6 +704,20 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic // 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); + // The Prm of the piece holding the mark adds its paragraph sprms after the PAPX's + // ([MS-DOC] §2.4.6.1, step 5), as Word 6/95's fast saves record a paragraph change. + let extra = prm_paragraph_sprms(ctx.pieces.piece_prm(cp), ctx.work); + let combined; + let papx: &[u8] = if extra.is_empty() { + papx + } else { + // Re-framed from its Prls, so that a pad byte at its end can't swallow the + // first appended opcode. + let mut v: Vec = sprm::iter(papx).flat_map(|p| p.op.to_le_bytes().into_iter().chain(p.operand.iter().copied())).collect(); + v.extend_from_slice(&extra); + combined = v; + &combined + }; 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)); } @@ -991,13 +1061,42 @@ struct FmtState<'a> { /// paragraph style stay the same, so a Prm grpprl (up to 64 KB) is interpreted once per /// formatting change, not once per character (#3692). struct FmtMemo<'a> { - /// The CHPX grpprl, compared by address: it is a slice of the WordDocument stream. + /// The CHPX grpprl, compared by address: a slice of the WordDocument stream, or of a Word 6.0/95 + /// file's translated runs, both of which stay put for the walk. chpx: &'a [u8], prm: u16, style: Option, props: CharProps, } +/// The paragraph and table sprms (sgc 1 and 5) of a piece's Prm, as Prls to append to a +/// paragraph's PAPX: a Prm0's single sprm, or those of the Prm1's grpprl. A Prm1 grpprl is +/// charged to `work`, as for [`apply_prm`]. +fn prm_paragraph_sprms(prm: PrmRef<'_>, work: &Work) -> Vec { + let mut out = Vec::new(); + match prm { + PrmRef::Sprm0 { isprm, val } => { + if let Some(op) = sprm::from_prm0(isprm) + && matches!(Prl { op, operand: &[] }.sgc(), 1 | 5) + { + out.extend_from_slice(&op.to_le_bytes()); + out.push(val); + } + } + PrmRef::Grpprl(Some(g)) => { + if !work.charge(g.len() as u64, Cost::Formatting) { + return out; + } + for prl in sprm::iter(g).filter(|p| matches!(p.sgc(), 1 | 5)) { + out.extend_from_slice(&prl.op.to_le_bytes()); + out.extend_from_slice(prl.operand); + } + } + PrmRef::None | PrmRef::Grpprl(None) => {} + } + out +} + /// Apply a piece's Prm (Prm0 short sprm or Prm1 grpprl) to character properties. A Prm1 /// grpprl is charged to `work`; once that is spent the grpprl is skipped. fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &CharProps, work: &Work) { diff --git a/crates/doc-binary/src/piece.rs b/crates/doc-binary/src/piece.rs index 5f36a8198..701a4bc67 100644 --- a/crates/doc-binary/src/piece.rs +++ b/crates/doc-binary/src/piece.rs @@ -1,20 +1,71 @@ //! The piece table ([MS-DOC] `Clx`/`Pcdt`/`PlcPcd`): where every character position lives in //! the WordDocument stream, and how it is encoded. +//! +//! Word 6.0/95 files ([`crate::word6`]) keep the same `Clx` shape, but every piece's text is at a +//! plain byte offset: 8-bit text in the code page of its piece's or run's font (or the document's), +//! or UTF-16 in a file with the FIB's fExtChar flag. -use craft_codepage::cp1252; +use craft_codepage::{code_page_byte, cp1252, is_byte_code_page}; 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). +/// How a piece's characters are stored. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Text { + /// UTF-16LE at `fc`. + Utf16, + /// Windows-1252, one byte per character at `fc / 2` (Word 97 FcCompressed). + Compressed, + /// One byte per character at `fc` itself, in the code page [`PieceTable::code_page_at`] gives + /// that byte (Word 6.0/95). + Bytes, +} + +/// One piece: its text at `fc` (for Word 97, the 30-bit offset of FcCompressed). #[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). + text: Text, + /// `Prm`: either a single sprm (Prm0) or an index into the Clx's Prc grpprls (Prm1). In a Word + /// 6.0/95 table the Prcs hold translated grpprls ([`PieceTable::parse_word6`]), and a Prm0's + /// isprm is already the opcode [MS-DOC]'s isprm table maps. prm: u16, + /// For a Word 6.0/95 [`Text::Bytes`] piece whose Prm names a font: that font's code page, + /// which outranks the runs' ([`PieceTable::code_page_at`]). + code_page: Option, +} + +impl Pcd { + /// The byte offset of the character `step / 2` characters into the piece. `step` is twice + /// the CP offset, so one formula serves all three encodings. + fn fc_at(&self, step: u32) -> Option { + match self.text { + Text::Utf16 => self.fc.checked_add(step), + Text::Compressed => self.fc.checked_add(step)?.checked_div(2), + Text::Bytes => self.fc.checked_add(step / 2), + } + } +} + +/// The code page of the 8-bit text in the byte range `[lo, hi)` of the WordDocument stream. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct CodePageRun { + pub(crate) lo: u32, + pub(crate) hi: u32, + pub(crate) code_page: u16, +} + +/// How a Word 6.0/95 file's text is stored and decoded ([`crate::word6`]). +pub(crate) struct Word6Text { + /// UTF-16 rather than 8-bit (the FIB's fExtChar flag). + pub(crate) wide: bool, + /// The code page of 8-bit text that no piece or run gives one for. + pub(crate) default_code_page: u16, + /// The code page of each byte range, from its run's font (sorted by `lo`, not overlapping). + pub(crate) code_pages: Vec, } /// The parsed piece table plus the grpprls of the Prcs that Prm1 references. @@ -23,11 +74,58 @@ pub(crate) struct PieceTable { cps: Vec, pieces: Vec, prc_grpprls: Vec>, + /// For [`Text::Bytes`] pieces: the code page of each byte range, sorted by `lo`, not + /// overlapping. Bytes outside every range are in `default_code_page`. + code_pages: Vec, + default_code_page: u16, } impl PieceTable { /// Parse the `Clx` at `table[fc .. fc+lcb]`. pub(crate) fn parse(table: &[u8], fc: u32, lcb: u32) -> Result { + Self::parse_clx(table, fc, lcb, None) + } + + /// Parse a Word 6.0/95 `Clx` at `word[fc .. fc+lcb]`: the Word 97 shape, but every piece's text + /// is at its plain byte offset. `piece_code_page` gives the code page a piece's raw Prm + /// imposes (with the Clx's raw Prc grpprls to look Prm1 indices up in), if any; then each Prc + /// grpprl is replaced by `translate_prc`'s Word 97 sprms, which the walk applies to the piece. + pub(crate) fn parse_word6( + word: &[u8], + fc: u32, + lcb: u32, + text: Word6Text, + piece_code_page: impl Fn(u16, &[Vec]) -> Option, + translate_prc: impl Fn(&[u8]) -> Vec, + ) -> Result { + let mut t = Self::parse_clx(word, fc, lcb, Some(text.wide))?; + if !text.wide { + for p in &mut t.pieces { + p.code_page = piece_code_page(p.prm, &t.prc_grpprls); + } + } + t.prc_grpprls = t.prc_grpprls.iter().map(|g| translate_prc(g)).collect(); + t.code_pages = text.code_pages; + t.default_code_page = text.default_code_page; + Ok(t) + } + + /// One Word 6.0/95 piece: `ccp` characters from byte `fc` on. This covers a file that was not + /// fast-saved, which has no `Clx`. + pub(crate) fn contiguous_word6(fc: u32, ccp: u32, text: Word6Text) -> PieceTable { + let kind = if text.wide { Text::Utf16 } else { Text::Bytes }; + PieceTable { + cps: vec![0, ccp], + pieces: vec![Pcd { fc, text: kind, prm: 0, code_page: None }], + prc_grpprls: Vec::new(), + code_pages: text.code_pages, + default_code_page: text.default_code_page, + } + } + + /// `word6`: `None` for Word 97 and later, `Some(wide)` for a Word 6.0/95 file (whose raw Prms + /// and Prcs are kept for [`PieceTable::parse_word6`]). + fn parse_clx(table: &[u8], fc: u32, lcb: u32, word6: Option) -> Result { let fc = fc as usize; let lcb = lcb as usize; if lcb == 0 { @@ -95,9 +193,26 @@ impl PieceTable { 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 }); + pieces.push(match word6 { + // A plain byte offset, with no compression bit: 8-bit text, or UTF-16 in a file with + // fExtChar. The raw Prm is kept for `parse_word6`. + Some(wide) => Pcd { fc: fc_raw, text: if wide { Text::Utf16 } else { Text::Bytes }, prm, code_page: None }, + None => { + let text = if fc_raw & 0x4000_0000 != 0 { Text::Compressed } else { Text::Utf16 }; + Pcd { fc: fc_raw & 0x3FFF_FFFF, text, prm, code_page: None } + } + }); + } + Ok(PieceTable { cps, pieces, prc_grpprls, code_pages: Vec::new(), default_code_page: 1252 }) + } + + /// The code page of the 8-bit text byte at `fc` (in [`Text::Bytes`] pieces). + fn code_page_at(&self, fc: u32) -> u16 { + let i = self.code_pages.partition_point(|r| r.lo <= fc); + match i.checked_sub(1).and_then(|i| self.code_pages.get(i)) { + Some(r) if fc < r.hi => r.code_page, + _ => self.default_code_page, } - Ok(PieceTable { cps, pieces, prc_grpprls }) } /// The Prls this piece's `Prm` adds. Bit 0 (`fComplex`) discriminates: a Prm0 @@ -133,8 +248,7 @@ impl PieceTable { 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) } + p.fc_at(cp.checked_sub(cp0)?.checked_mul(2)?) } /// The piece holding `cp`, with the CP range it covers: for every CP in that range the @@ -167,13 +281,24 @@ impl PieceTable { 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), - }; + match p.text { + Text::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), + }; + } + Text::Bytes => { + // 8-bit, one byte per character at fc, in its run's code page. + let at = (p.fc as usize).checked_add(off); + return match at.and_then(|a| Some((u32::try_from(a).ok()?, *word.get(a)?))) { + Some((a, b)) => Ok((byte_char(p.code_page.unwrap_or_else(|| self.code_page_at(a)), b), 1)), + None => Err(next), + }; + } + Text::Utf16 => {} } // UTF-16LE, one 16-bit unit per character, stored at fc. let unit = |k: usize| -> Option { @@ -207,8 +332,7 @@ impl PieceSpan<'_> { /// [`PieceTable::fc_of_cp`] for a CP inside the span. pub(crate) fn fc_at(&self, cp: u32) -> Option { - let step = cp.checked_sub(self.cp_lo)?.checked_mul(2)?; - if self.pcd.compressed { self.pcd.fc.checked_add(step)?.checked_div(2) } else { self.pcd.fc.checked_add(step) } + self.pcd.fc_at(cp.checked_sub(self.cp_lo)?.checked_mul(2)?) } /// The raw Prm of the piece: two CPs with the same value get the same [`PrmRef`]. @@ -228,3 +352,23 @@ pub(crate) enum PrmRef<'a> { /// Prm1: a grpprl from a Prc (absent if out of range). Grpprl(Option<&'a [u8]>), } + +/// Windows' code page number for symbol fonts (CP_SYMBOL). +pub(crate) const SYMBOL_CODE_PAGE: u16 = 42; + +/// One byte of 8-bit text in `code_page`. The East Asian code pages store a character as a lead +/// and a trail byte, which one CP per byte can't represent, so their bytes above ASCII read as +/// U+FFFD rather than as unrelated Windows-1252 letters. +fn byte_char(code_page: u16, b: u8) -> char { + if code_page == SYMBOL_CODE_PAGE { + // Each character from the space up to its private-use code point, as Word reads a document + // marked as Symbol; control characters (paragraph marks, fields) stay themselves. + if b < 0x20 { char::from(b) } else { char::from_u32(0xF000 + u32::from(b)).unwrap_or('\u{FFFD}') } + } else if is_byte_code_page(code_page) { + code_page_byte(code_page, b) + } else if b.is_ascii() { + char::from(b) + } else { + '\u{FFFD}' + } +} diff --git a/crates/doc-binary/src/tests.rs b/crates/doc-binary/src/tests.rs index 80cef3257..ec5650695 100644 --- a/crates/doc-binary/src/tests.rs +++ b/crates/doc-binary/src/tests.rs @@ -10,6 +10,7 @@ use super::read; mod drawing; mod encrypted; mod metadata; +mod word6; /// Offset in the WordDocument stream where test text pieces are placed (past the FIB). const TEXT_FC: u32 = 0x600; @@ -176,12 +177,15 @@ fn wrong_magic_rejected() { } #[test] -fn word_6_95_rejected() { - let mut fib = fib_bytes(&FibSpec::default()); +fn word_6_95_magic_goes_to_the_word6_reader() { + // A Word 6.0/95 signature is no longer refused: the stream is read as that version (here an + // empty document, since a Word 97 FIB holds no Word 6 text length). The flags are cleared: + // the Word 97 default sets fExtChar, which the Word 6 reader refuses. + let mut fib = fib_bytes(&FibSpec { flags: 0, ..FibSpec::default() }); fib[0] = 0xDC; fib[1] = 0xA5; let f = cfb_file(&[("WordDocument", &fib)]); - assert!(matches!(read(&f), Err(super::DocbinError::NotWord(m)) if m.contains("6.0/95"))); + assert!(read(&f).is_ok()); } #[test] @@ -882,8 +886,6 @@ fn corpus_files_open() { let bytes = std::fs::read(&p).expect("read"); let doc = match super::read(&bytes) { Ok(d) => d, - // Word 6/95 files are a documented, intentional rejection. - Err(super::DocbinError::NotWord(m)) if m.contains("6.0/95") => continue, Err(e) => panic!("{}: {e}", p.display()), }; assert!(!doc.styles.styles.is_empty(), "{}: no styles", p.display()); @@ -2106,6 +2108,29 @@ fn one_section(text: &'static str, sepx: Vec, papx_entries: Vec<(u32, u16, V } } +#[test] +fn piece_prm_paragraph_formatting_applies_to_the_marks_paragraph() { + // [MS-DOC] §2.4.6.1 step 5: the Prm of the piece holding the paragraph mark adds its paragraph + // sprms after the PAPX's. Prm1 -> a Prc with sprmPDxaLeft80 (1440 twips) and sprmPJc80 + // (centred); Prm0 isprm 0x05 (sprmPJc) with operand 2 (right). + let prcs = vec![grpprl(&[(0x840F, &1440i16.to_le_bytes()), (0x2403, &[0x01])])]; + let para = |prm: u16| { + let spec = RichSpec { + text: "Text\r", + styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())], + fonts: vec!["Times New Roman"], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(4, 0, grpprl(&[(0x840F, &360i16.to_le_bytes())]))], + }; + let doc = read(&rich_doc_with(&spec, &RichExtra { prcs: prcs.clone(), prm, ..Default::default() })).expect("opens"); + let p = para0(&doc); + (p.props.indent_left, p.props.align) + }; + assert_eq!(para(0x0000), (Some(18.0), None), "no Prm: the PAPX's indent"); + assert_eq!(para(0x0001), (Some(72.0), Some(craft_wordmodel::props::Align::Center)), "Prm1: the Prc's sprms override the PAPX's"); + assert_eq!(para(0x020A), (Some(18.0), Some(craft_wordmodel::props::Align::Right)), "Prm0: sprmPJc right"); +} + #[test] fn piece_prm_character_formatting_applied() { // #458. Bit 0 of a Prm is fComplex ([MS-DOC] §2.9.215/§2.9.216): Prm0 = isprm in bits diff --git a/crates/doc-binary/src/tests/word6.rs b/crates/doc-binary/src/tests/word6.rs new file mode 100644 index 000000000..6bd9fb8f3 --- /dev/null +++ b/crates/doc-binary/src/tests/word6.rs @@ -0,0 +1,743 @@ +//! Word 6.0/95 fixtures, built in code. No public specification describes this version; these +//! build the layout observed in real files and described in `crate::word6`. + +use proptest::prelude::*; + +use super::{body_texts, cfb_file}; +use crate::{DocbinError, read}; + +/// Where the text starts: right after the FIB, as in every observed file. +const TEXT_AT: u32 = 0x300; + +/// One stylesheet entry as Word 6/95 stores it. +#[derive(Clone, Copy, Default)] +struct Std<'a> { + /// The built-in style id, or 0x0FFE for a user-defined style. + sti: u16, + /// 1 paragraph, 2 character. + sgc: u16, + /// The base style's index, or 0x0FFF. + base: u16, + name: &'a str, + /// Paragraph sprms (paragraph styles only). + papx: &'a [u8], + /// Character sprms. + chpx: &'a [u8], +} + +/// The stylesheet bytes for `styles`: the 14-byte header (count, fixed-part size 8, …), then per +/// style a size and the entry, or 0 for an empty slot. +fn stylesheet(styles: &[Option]) -> Vec { + let mut v = vec![14u8, 0]; + for w in [styles.len() as u16, 8, 1, 0x4B, 0x0F, 0, 0] { + v.extend_from_slice(&w.to_le_bytes()); + } + for (istd, st) in styles.iter().enumerate() { + let Some(st) = st else { + v.extend_from_slice(&[0, 0]); + continue; + }; + let cupx: u16 = if st.sgc == 1 { 2 } else { 1 }; + let mut e = Vec::new(); + for w in [st.sti, st.sgc | st.base << 4, cupx, 0] { + e.extend_from_slice(&w.to_le_bytes()); + } + e.push(st.name.len() as u8); + e.extend_from_slice(st.name.as_bytes()); + e.push(0); + let upx = |e: &mut Vec, bytes: &[u8]| { + if e.len() % 2 == 1 { + e.push(0); + } + e.extend_from_slice(&(bytes.len() as u16).to_le_bytes()); + e.extend_from_slice(bytes); + }; + if st.sgc == 1 { + let mut papx = (istd as u16).to_le_bytes().to_vec(); + papx.extend_from_slice(st.papx); + upx(&mut e, &papx); + } + upx(&mut e, st.chpx); + v.extend_from_slice(&(e.len() as u16).to_le_bytes()); + v.extend_from_slice(&e); + } + v +} + +/// What a test Word 6.0/95 file holds. Offsets in `runs` and `pieces` are into `text`. +#[derive(Clone, Default)] +struct Doc6<'a> { + nfib: u16, + flags: u16, + /// The stored 8-bit text. + text: &'a [u8], + /// (charset, name) per font index. + fonts: &'a [(u8, &'a str)], + /// The font table's entries as raw bytes instead of `fonts` (the size word is added). + raw_fonts: Option<&'a [u8]>, + /// Contiguous character runs (lo, hi, grpprl), all in one FKP page. + runs: &'a [(u32, u32, &'a [u8])], + /// Contiguous paragraph runs (lo, hi, grpprl), style 0, all in one FKP page. Each covers the + /// paragraph whose mark lies in it. + paras: &'a [(u32, u32, &'a [u8])], + /// For a fast-saved file: (length, offset) per piece, in CP order. + pieces: Option<&'a [(u32, u32)]>, + /// Each piece's Prm (0 when absent or shorter than `pieces`). + prms: &'a [u16], + /// The Clx's Prc grpprls, which a Prm1 indexes. + prcs: &'a [&'a [u8]], + /// The FIB's character set word at 0x14. + chse: u16, + /// The style of each paragraph run (0, Normal, when absent or shorter than `paras`). + para_styles: &'a [u16], + /// The stylesheet: one entry per style index, `None` for an empty slot. + styles: &'a [Option>], + /// The main text's length in characters, when it isn't the stored bytes' (16-bit text). + ccp: Option, +} + +fn put16(v: &mut [u8], at: usize, x: u16) { + v[at..at + 2].copy_from_slice(&x.to_le_bytes()); +} + +fn put32(v: &mut [u8], at: usize, x: u32) { + v[at..at + 4].copy_from_slice(&x.to_le_bytes()); +} + +/// Append one FKP page for `runs` and its bin table, and return the bin table's (fc, lcb). A +/// character page has a 1-byte entry per run and CHPXs (byte count + grpprl); a paragraph page +/// has 7-byte entries and PAPXs (word count + style 0 + grpprl padded to whole words). +fn fkp(w: &mut Vec, runs: &[(u32, u32, &[u8])], papx: bool, istds: &[u16]) -> (u32, u32) { + let (Some(first), Some(last)) = (runs.first(), runs.last()) else { return (0, 0) }; + w.resize(w.len().div_ceil(512) * 512, 0); + let pn = (w.len() / 512) as u16; + let crun = runs.len(); + let entry = if papx { 7 } else { 1 }; + let mut page = [0u8; 512]; + for (k, (lo, _, _)) in runs.iter().enumerate() { + put32(&mut page, 4 * k, TEXT_AT + lo); + } + put32(&mut page, 4 * crun, TEXT_AT + last.1); + // Records from the end of the page down, each at an even offset. + let mut top = 511usize; + for (k, (_, _, g)) in runs.iter().enumerate() { + let body: Vec = if papx { + let mut b = istds.get(k).copied().unwrap_or(0).to_le_bytes().to_vec(); + b.extend_from_slice(g); + if b.len() % 2 == 1 { + b.push(0); + } + b + } else if g.is_empty() { + continue; + } else { + g.to_vec() + }; + let at = (top - 1 - body.len()) & !1; + page[at] = if papx { (body.len() / 2) as u8 } else { body.len() as u8 }; + page[at + 1..at + 1 + body.len()].copy_from_slice(&body); + page[4 * (crun + 1) + entry * k] = (at / 2) as u8; + top = at; + } + page[511] = crun as u8; + w.extend_from_slice(&page); + let mut bte = Vec::new(); + bte.extend_from_slice(&(TEXT_AT + first.0).to_le_bytes()); + bte.extend_from_slice(&(TEXT_AT + last.1).to_le_bytes()); + bte.extend_from_slice(&pn.to_le_bytes()); + let at = w.len() as u32; + w.extend_from_slice(&bte); + (at, bte.len() as u32) +} + +/// The WordDocument stream: FIB, text, then the FKP pages, font table and piece table. +fn word_stream(d: &Doc6) -> Vec { + let mut w = vec![0u8; TEXT_AT as usize]; + w.extend_from_slice(d.text); + let ccp = match (d.ccp, d.pieces) { + (Some(ccp), _) => ccp, + (None, pieces) => match pieces { + Some(p) => p.iter().map(|(len, _)| len).sum(), + None => d.text.len() as u32, + }, + }; + let mut pairs: Vec<(usize, u32, u32)> = Vec::new(); + for (pair, runs, papx) in [(12, d.runs, false), (13, d.paras, true)] { + let (fc, lcb) = fkp(&mut w, runs, papx, d.para_styles); + if lcb > 0 { + pairs.push((pair, fc, lcb)); + } + } + if !d.styles.is_empty() { + let st = stylesheet(d.styles); + pairs.push((1, w.len() as u32, st.len() as u32)); + w.extend_from_slice(&st); + } + if !d.fonts.is_empty() || d.raw_fonts.is_some() { + let mut ffn = vec![0u8, 0]; + ffn.extend_from_slice(d.raw_fonts.unwrap_or_default()); + for (charset, name) in d.fonts { + let mut e = vec![0u8, 0x16, 0x90, 0x01, *charset, 0x00]; + e.extend_from_slice(name.as_bytes()); + e.push(0); + e[0] = (e.len() - 1) as u8; + ffn.extend_from_slice(&e); + } + let size = ffn.len() as u16; + put16(&mut ffn, 0, size); + pairs.push((15, w.len() as u32, ffn.len() as u32)); + w.extend_from_slice(&ffn); + } + if let Some(pieces) = d.pieces { + let mut plc = Vec::new(); + let mut cp = 0u32; + plc.extend_from_slice(&cp.to_le_bytes()); + for (len, _) in pieces { + cp += len; + plc.extend_from_slice(&cp.to_le_bytes()); + } + for (i, (_, off)) in pieces.iter().enumerate() { + plc.extend_from_slice(&[0, 0]); + plc.extend_from_slice(&(TEXT_AT + off).to_le_bytes()); + plc.extend_from_slice(&d.prms.get(i).copied().unwrap_or(0).to_le_bytes()); + } + let mut clx = Vec::new(); + for g in d.prcs { + clx.push(0x01); + clx.extend_from_slice(&(g.len() as u16).to_le_bytes()); + clx.extend_from_slice(g); + } + clx.push(0x02); + clx.extend_from_slice(&(plc.len() as u32).to_le_bytes()); + clx.extend_from_slice(&plc); + pairs.push((33, w.len() as u32, clx.len() as u32)); + w.extend_from_slice(&clx); + } + put16(&mut w, 0x00, 0xA5DC); + put16(&mut w, 0x02, if d.nfib == 0 { 0x0065 } else { d.nfib }); + put16(&mut w, 0x06, 0x0409); + put16(&mut w, 0x0A, d.flags | if d.pieces.is_some() { 0x0004 } else { 0 }); + put16(&mut w, 0x0C, 0x0065); + put16(&mut w, 0x14, d.chse); + put32(&mut w, 0x18, TEXT_AT); + put32(&mut w, 0x1C, TEXT_AT + d.text.len() as u32); + let len = w.len() as u32; + put32(&mut w, 0x20, len); + put32(&mut w, 0x34, ccp); + for (i, fc, lcb) in pairs { + put32(&mut w, 0x58 + i * 8, fc); + put32(&mut w, 0x58 + i * 8 + 4, lcb); + } + w +} + +/// A Word 6.0/95 file holding `d`: the WordDocument stream only, as the real ones have no Table +/// stream. +fn word6_file(d: &Doc6) -> Vec { + cfb_file(&[("WordDocument", &word_stream(d))]) +} + +fn texts(d: &Doc6) -> Vec { + body_texts(&read(&word6_file(d)).expect("a Word 6.0/95 file opens")) +} + +/// Fonts 0 (Western) and 1 (Cyrillic, charset 204), as the Russian files have. +const FONTS: &[(u8, &str)] = &[(0, "Times New Roman"), (204, "Times New Roman Cyr")]; + +#[test] +fn word_6_and_95_files_open_as_paragraphs() { + for nfib in [0x0065, 0x0068] { + let d = Doc6 { nfib, text: b"Hello\rWorld\r", ..Default::default() }; + assert_eq!(texts(&d), ["Hello", "World"], "nFib 0x{nfib:04X}"); + } +} + +#[test] +fn fast_saved_text_follows_the_piece_table() { + // Stored out of order; the pieces' fcs are plain byte offsets (Word 97 would halve them). + let d = Doc6 { text: b"World\rHello\r", pieces: Some(&[(6, 6), (6, 0)]), ..Default::default() }; + assert_eq!(texts(&d), ["Hello", "World"]); +} + +#[test] +fn fast_saved_file_without_a_piece_table_is_malformed() { + let d = Doc6 { flags: 0x0004, text: b"Hello\r", ..Default::default() }; + assert!(matches!(read(&word6_file(&d)), Err(DocbinError::Malformed(_)))); +} + +#[test] +fn a_runs_font_charset_chooses_its_code_page() { + // "Da " in font 0, then 0xC4 0xE0 ("Да" in Windows-1251) in the Cyrillic font 1. + let text = b"Da \xC4\xE0\r"; + let runs: &[(u32, u32, &[u8])] = &[(0, 3, &[]), (3, 5, &[0x5D, 0x01, 0x00]), (5, 6, &[])]; + let d = Doc6 { text, fonts: FONTS, runs, ..Default::default() }; + assert_eq!(texts(&d), ["Da Да"]); +} + +#[test] +fn the_font_sprm_is_found_after_other_sprms() { + // As in real files: bold-like toggles, a size, a picture anchor's length-prefixed sprm. + let text = b"\xC4\xE0\r"; + for g in [&[0x55, 0x81, 0x63, 0x18, 0x00, 0x5D, 0x01, 0x00][..], &[0x75, 0x01, 0x44, 0x04, 0, 0, 0, 0, 0x5D, 0x01, 0x00][..]] { + let runs: &[(u32, u32, &[u8])] = &[(0, 2, g), (2, 3, &[])]; + let d = Doc6 { text, fonts: FONTS, runs, ..Default::default() }; + assert_eq!(texts(&d), ["Да"], "grpprl {g:02X?}"); + } +} + +#[test] +fn the_last_font_sprm_of_a_run_chooses_its_code_page() { + // Font 0, then font 1 (Cyrillic): the later sprm is the one the formatting applies. + let runs: &[(u32, u32, &[u8])] = &[(0, 2, &[0x5D, 0x00, 0x00, 0x5D, 0x01, 0x00]), (2, 3, &[])]; + let d = Doc6 { text: b"\xC4\xE0\r", fonts: FONTS, runs, ..Default::default() }; + assert_eq!(texts(&d), ["Да"]); +} + +#[test] +fn an_unknown_sprm_leaves_font_0() { + // 0x99 has no known operand length, so the rest can't be framed: font 0 (Windows-1252). + let text = b"\xC4\xE0\r"; + let runs: &[(u32, u32, &[u8])] = &[(0, 2, &[0x99, 0x5D, 0x01, 0x00]), (2, 3, &[])]; + let d = Doc6 { text, fonts: FONTS, runs, ..Default::default() }; + assert_eq!(texts(&d), ["Äà"]); +} + +#[test] +fn east_asian_font_bytes_read_as_replacement_characters() { + // A multibyte code page can't be read one byte per CP: ASCII stays, the rest is U+FFFD. + let text = b"ab\x82\xA0\r"; + let runs: &[(u32, u32, &[u8])] = &[(0, 4, &[0x5D, 0x01, 0x00]), (4, 5, &[])]; + let d = Doc6 { text, fonts: &[(0, "Arial"), (128, "MS Mincho")], runs, ..Default::default() }; + assert_eq!(texts(&d), ["ab\u{FFFD}\u{FFFD}"]); +} + +#[test] +fn the_toggle_sprms_before_the_font_are_framed() { + // 0x57 (strike, a one-byte toggle in [MS-DOC]'s Prm0 isprm table) before the font sprm: the + // font must still be found, or the Cyrillic text reads as Windows-1252. + let text = b"\xC4\xE0\r"; + for op in [0x57, 0x58, 0x59, 0x5C] { + let g = [op, 0x01, 0x5D, 0x01, 0x00]; + let runs: &[(u32, u32, &[u8])] = &[(0, 2, &g), (2, 3, &[])]; + let d = Doc6 { text, fonts: FONTS, runs, ..Default::default() }; + assert_eq!(texts(&d), ["Да"], "opcode 0x{op:02X}"); + } +} + +#[test] +fn a_font_entry_too_short_for_its_charset_ends_the_font_table() { + // Entry 0 says it is 3 bytes long, which can't hold the charset at offset 4. The bytes after + // it (the next entry, whose flags byte is 0xCC) must not be read as font 0's charset. + let raw: &[u8] = &[0x02, 0x16, 0x90, 0x05, 0xCC, 0x90, 0x01, 0xCC, 0x00, b'X', 0]; + let d = Doc6 { text: b"\xC4\xE0\r", raw_fonts: Some(raw), runs: &[(0, 3, &[])], ..Default::default() }; + assert_eq!(texts(&d), ["Äà"], "font 0 has no charset: Windows-1252"); +} + +#[test] +fn a_bin_table_naming_one_page_many_times_reads_it_once() { + // A hostile bin table: 1,000,000 entries, all naming one page of 50 Cyrillic runs. The text still + // reads right; that the page is read once is pinned in `word6::tests`. + let text: Vec = std::iter::repeat_n(0xC4u8, 50).chain(*b"\r").collect(); + let g: &[u8] = &[0x5D, 0x01, 0x00]; + let runs: Vec<(u32, u32, &[u8])> = (0..50).map(|i| (i, i + 1, g)).chain([(50, 51, &[][..])]).collect(); + let mut w = word_stream(&Doc6 { text: &text, fonts: FONTS, runs: &runs, ..Default::default() }); + let u32_at = |w: &[u8], at: usize| u32::from_le_bytes([w[at], w[at + 1], w[at + 2], w[at + 3]]); + let bte_fc = u32_at(&w, 0x58 + 12 * 8) as usize; + let pn = u16::from_le_bytes([w[bte_fc + 8], w[bte_fc + 9]]); + let n = 1_000_000usize; + let mut bte = Vec::with_capacity(4 * (n + 1) + 2 * n); + bte.extend_from_slice(&TEXT_AT.to_le_bytes()); + for _ in 0..n { + bte.extend_from_slice(&(TEXT_AT + 51).to_le_bytes()); + } + for _ in 0..n { + bte.extend_from_slice(&pn.to_le_bytes()); + } + let at = w.len() as u32; + w.extend_from_slice(&bte); + put32(&mut w, 0x58 + 12 * 8, at); + put32(&mut w, 0x58 + 12 * 8 + 4, bte.len() as u32); + let doc = read(&cfb_file(&[("WordDocument", &w)])).expect("opens"); + assert_eq!(body_texts(&doc), ["Д".repeat(50)]); +} + +/// `s` as UTF-16LE bytes. +fn utf16(s: &str) -> Vec { + s.encode_utf16().flat_map(u16::to_le_bytes).collect() +} + +#[test] +fn a_file_with_extended_characters_holds_utf16_text() { + // fExtChar (0x1000): Word reads the text as 16-bit units. + let text = utf16("Да ok\r"); + let d = Doc6 { flags: 0x1000, text: &text, ccp: Some(6), ..Default::default() }; + assert_eq!(texts(&d), ["Да ok"]); +} + +#[test] +fn utf16_text_stops_at_the_end_of_its_bytes() { + // Word reads no more 16-bit characters than the text's byte range (0x18..0x1C) holds, even + // when the character count says more. + let text = utf16("Hi\r"); + let d = Doc6 { flags: 0x1000, text: &text, ccp: Some(50), ..Default::default() }; + assert_eq!(texts(&d), ["Hi"]); +} + +#[test] +fn text_stops_at_the_end_of_its_bytes() { + // The same for 8-bit text: Word read 19 characters of a file whose count said 40. The font + // table after the text must not be read as body text. + let d = Doc6 { text: b"Hi\r", ccp: Some(50), fonts: FONTS, ..Default::default() }; + assert_eq!(texts(&d), ["Hi"]); +} + +#[test] +fn fast_saved_utf16_pieces_are_at_plain_byte_offsets() { + let text = utf16("World\rHello\r"); + let d = Doc6 { flags: 0x1000, text: &text, pieces: Some(&[(6, 12), (6, 0)]), ..Default::default() }; + assert_eq!(texts(&d), ["Hello", "World"]); +} + +#[test] +fn a_macintosh_document_reads_as_mac_roman() { + // FIB 0x14 = 0x0100: byte 0x8E is "é" (Mac Roman), not "Ž" (Windows-1252), even in a run whose + // font is Cyrillic. + let runs: &[(u32, u32, &[u8])] = &[(0, 2, &[0x5D, 0x01, 0x00]), (2, 3, &[])]; + let d = Doc6 { chse: 0x0100, text: b"H\x8E\r", fonts: FONTS, runs, ..Default::default() }; + assert_eq!(texts(&d), ["Hé"]); +} + +#[test] +fn a_symbol_document_maps_characters_to_private_use() { + // FIB 0x14 = 0x0002: every character from the space up is U+F000 + byte; marks stay marks. + let d = Doc6 { chse: 0x0002, text: b"Hi\x8E\rA\r", ..Default::default() }; + assert_eq!(texts(&d), ["\u{F048}\u{F069}\u{F08E}", "\u{F041}"]); +} + +#[test] +fn another_character_set_leaves_only_ascii_readable() { + let d = Doc6 { chse: 0x0001, text: b"H\x8Ei\r", ..Default::default() }; + assert_eq!(texts(&d), ["H\u{FFFD}i"]); +} + +#[test] +fn a_prm_on_the_marks_piece_formats_its_paragraph() { + // As Word 95 fast-saves a 1" left indent on the first paragraph: the piece holding its mark + // has Prm1 -> Prc `11 a0 05` (left indent 1440), and its PAPX says nothing. Word shows 72 pt; + // the second paragraph keeps none. + let text = b"First\rSecond\r"; + let pieces: &[(u32, u32)] = &[(5, 0), (1, 5), (7, 6)]; + let d = Doc6 { text, pieces: Some(pieces), prms: &[0, 0x0001], prcs: &[&[0x11, 0xA0, 0x05]], ..Default::default() }; + let doc = read(&word6_file(&d)).expect("opens"); + let paras: Vec<_> = doc.body.iter().filter_map(|b| b.as_para()).collect(); + assert_eq!(paras.iter().map(|p| p.text.as_str()).collect::>(), ["First", "Second"]); + assert_eq!((paras[0].props.indent_left, paras[1].props.indent_left), (Some(72.0), None)); +} + +#[test] +fn a_pieces_prm_formats_its_text() { + // As Word 95 fast-saves formatting: piece 0 has Prm1 -> a Prc setting font 1 and bold, which + // its character run doesn't. Piece 0 shows in font 1, bold (and decodes in its code page); + // piece 1 keeps the run's plain formatting. + let text = b"\xC4\xE0\xC4\xE0\r"; + let d = + Doc6 { text, fonts: FONTS, pieces: Some(&[(2, 0), (3, 2)]), prms: &[0x0001], prcs: &[&[0x5D, 0x01, 0x00, 0x55, 0x01]], ..Default::default() }; + let doc = read(&word6_file(&d)).expect("opens"); + let p = first_para(&doc); + assert_eq!(p.text, "ДаÄà"); + let first = &p.runs.first().expect("a run").props; + assert_eq!((first.font.as_deref(), first.bold), (Some("Times New Roman Cyr"), Some(true))); + let last = &p.runs.last().expect("a run").props; + assert_eq!((last.font.as_deref(), last.bold), (None, None)); +} + +#[test] +fn a_font_in_a_pieces_prm_chooses_its_code_page() { + // As Word 95 fast-saves a font change: piece 0 ("\xC4\xE0") has Prm1 -> Prc 0 = sprm 0x5D font 1 + // (Cyrillic), while its character run names no font. Piece 1 keeps the run's font 0. + let text = b"\xC4\xE0\xC4\xE0\r"; + let d = Doc6 { text, fonts: FONTS, pieces: Some(&[(2, 0), (3, 2)]), prms: &[0x0001], prcs: &[&[0x5D, 0x01, 0x00]], ..Default::default() }; + assert_eq!(texts(&d), ["ДаÄà"]); +} + +#[test] +fn a_prm_index_past_the_prcs_is_ignored() { + let d = Doc6 { text: b"\xC4\xE0\r", fonts: FONTS, pieces: Some(&[(3, 0)]), prms: &[0x0011], prcs: &[&[0x5D, 0x01, 0x00]], ..Default::default() }; + assert_eq!(texts(&d), ["Äà"]); +} + +#[test] +fn password_protected_files_are_refused() { + let d = Doc6 { flags: 0x0100, text: b"secret\r", ..Default::default() }; + assert!(matches!(read(&word6_file(&d)), Err(DocbinError::Encryption(_)))); +} + +/// The first paragraph of `doc`'s body. +fn first_para(doc: &craft_wordmodel::Document) -> &craft_wordmodel::para::Paragraph { + doc.body.iter().find_map(|b| b.as_para()).expect("a paragraph") +} + +#[test] +fn character_formatting_is_read() { + // Bold on, italic as the opposite of the (unread, so plain) style, 14 pt, red, single + // underline, font 2: the Word 6 opcodes as observed. + let g: &[u8] = &[0x55, 0x01, 0x56, 0x81, 0x63, 0x1C, 0x00, 0x62, 0x06, 0x5E, 0x01, 0x5D, 0x02, 0x00]; + let runs: &[(u32, u32, &[u8])] = &[(0, 2, g), (2, 3, &[])]; + let fonts = &[(0, "Times New Roman"), (2, "Symbol"), (0, "Arial")]; + let doc = read(&word6_file(&Doc6 { text: b"Hi\r", fonts, runs, ..Default::default() })).expect("opens"); + let p = first_para(&doc); + let c = &p.runs.first().expect("a run").props; + assert_eq!((c.bold, c.italic, c.size), (Some(true), Some(true), Some(14.0))); + assert_eq!(c.color, Some(craft_wordmodel::props::TextColor::Rgb(craft_wordmodel::props::Rgb(0xFF, 0, 0)))); + assert_eq!(c.underline, Some(craft_wordmodel::props::Underline::Single)); + assert_eq!(c.font.as_deref(), Some("Arial")); +} + +#[test] +fn unseen_operand_values_are_left_unread() { + // Underline 3 and "multiple" line spacing were never seen in a real file, so they aren't + // guessed at. + let runs: &[(u32, u32, &[u8])] = &[(0, 3, &[0x5E, 0x03])]; + let paras: &[(u32, u32, &[u8])] = &[(0, 3, &[0x14, 0xF0, 0x00, 0x01, 0x00])]; + let doc = read(&word6_file(&Doc6 { text: b"Hi\r", runs, paras, ..Default::default() })).expect("opens"); + let p = first_para(&doc); + assert_eq!(p.runs.first().and_then(|r| r.props.underline), None); + assert_eq!(p.props.line_spacing, None); +} + +#[test] +fn an_unseen_alignment_value_is_left_unread() { + // Only 0-3 were seen; 4 would otherwise become "distribute" and 0xFF "left". + for jc in [0x04u8, 0xFF] { + let g = [0x05, jc]; + let paras: &[(u32, u32, &[u8])] = &[(0, 3, &g)]; + let doc = read(&word6_file(&Doc6 { text: b"Hi\r", paras, ..Default::default() })).expect("opens"); + assert_eq!(first_para(&doc).props.align, None, "jc {jc:#04x}"); + } +} + +#[test] +fn paragraph_formatting_is_read() { + // Justified, 0.5" left and 0.25" right indents, a 0.5" hanging first line, at least 27 pt. + let g: &[u8] = &[0x05, 0x03, 0x11, 0xD0, 0x02, 0x10, 0x68, 0x01, 0x13, 0x30, 0xFD, 0x14, 0x1C, 0x02, 0x00, 0x00]; + let paras: &[(u32, u32, &[u8])] = &[(0, 3, g), (3, 6, &[0x05, 0x01])]; + let doc = read(&word6_file(&Doc6 { text: b"Hi\rHo\r", paras, ..Default::default() })).expect("opens"); + let ps: Vec<_> = doc.body.iter().filter_map(|b| b.as_para()).collect(); + let p = &ps[0].props; + assert_eq!(p.align, Some(craft_wordmodel::props::Align::Justify)); + assert_eq!((p.indent_left, p.indent_right, p.indent_first), (Some(36.0), Some(18.0), Some(-36.0))); + assert_eq!(p.line_spacing, Some(craft_wordmodel::props::LineSpacing::AtLeast(27.0))); + assert_eq!(ps[1].props.align, Some(craft_wordmodel::props::Align::Center)); +} + +/// A Word 6 table definition sprm (0xBE): the cell edges, then a 10-byte descriptor per cell +/// whose first word holds the merge flags. +fn def_table(edges: &[i16], rgfs: &[u16]) -> Vec { + let mut rest = vec![rgfs.len() as u8]; + for e in edges { + rest.extend_from_slice(&e.to_le_bytes()); + } + for r in rgfs { + rest.extend_from_slice(&r.to_le_bytes()); + rest.extend_from_slice(&[0; 8]); + } + let mut v = vec![0xBE]; + v.extend_from_slice(&((rest.len() + 1) as u16).to_le_bytes()); + v.extend_from_slice(&rest); + v +} + +/// The rows of the first table in `doc`'s body, each as its cells' texts. +fn table_rows(doc: &craft_wordmodel::Document) -> Vec> { + let t = doc.body.iter().find_map(|b| match b.as_ref() { + craft_wordmodel::Block::Table(t) => Some(t), + _ => None, + }); + let t = t.expect("a table"); + t.rows + .iter() + .map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::>().join("|")).collect()) + .collect() +} + +#[test] +fn table_rows_and_merged_cells_are_read() { + // Row 1: "A", "B". Row 2: "C" merged across both cells. In-table marks carry 0x18, the row + // ends 0x18, 0x19 and the table definition. + let text = b"A\x07B\x07\x07C\x07\x07\x07after\r"; + let mut row1 = vec![0x18, 1, 0x19, 1]; + row1.extend(def_table(&[0, 1440, 2880], &[0, 0])); + let mut row2 = vec![0x18, 1, 0x19, 1]; + row2.extend(def_table(&[0, 1440, 2880], &[0x0001, 0x0002])); + let cell: &[u8] = &[0x18, 1]; + let paras: &[(u32, u32, &[u8])] = &[(0, 2, cell), (2, 4, cell), (4, 5, &row1), (5, 7, cell), (7, 8, cell), (8, 9, &row2), (9, 15, &[])]; + let doc = read(&word6_file(&Doc6 { text, paras, ..Default::default() })).expect("opens"); + assert_eq!(table_rows(&doc), [vec!["A", "B"], vec!["C"]]); + assert_eq!(body_texts(&doc), ["after"], "the paragraph after the table stays outside it"); +} + +#[test] +fn several_merges_in_one_row_each_keep_their_cells() { + // Five cells merged as [A B] [C] [D E]: the second merge must not be shifted by the first. + let text = b"A\x07B\x07C\x07D\x07E\x07\x07"; + let mut row = vec![0x18, 1, 0x19, 1]; + row.extend(def_table(&[0, 100, 200, 300, 400, 500], &[0x0001, 0x0002, 0x0000, 0x0001, 0x0002])); + let cell: &[u8] = &[0x18, 1]; + let paras: &[(u32, u32, &[u8])] = &[(0, 2, cell), (2, 4, cell), (4, 6, cell), (6, 8, cell), (8, 10, cell), (10, 11, &row)]; + let doc = read(&word6_file(&Doc6 { text, paras, ..Default::default() })).expect("opens"); + assert_eq!(table_rows(&doc), [vec!["A|B", "C", "D|E"]]); +} + +#[test] +fn a_field_shows_its_cached_result() { + let d = Doc6 { text: b"Date \x13DATE \x1412/12/97\x15\r", ..Default::default() }; + let text = read(&word6_file(&d)).expect("opens").plain_text(craft_wordmodel::StoryRef::Body); + assert!(text.contains("Date 12/12/97"), "{text:?}"); + assert!(!text.contains("DATE"), "{text:?}"); +} + +/// A fixture's paragraph styles: Normal, an empty slot, then "Mine" (user-defined, centred, bold, on +/// Normal) and "Heading" stored under a local name with sti 1. +const STYLES: &[Option] = &[ + Some(Std { sti: 0, sgc: 1, base: 0x0FFF, name: "Standard", papx: &[], chpx: &[0x61, 0x09, 0x04] }), + None, + Some(Std { sti: 0x0FFE, sgc: 1, base: 0, name: "Mine", papx: &[0x05, 0x01], chpx: &[0x55, 0x01] }), + Some(Std { sti: 1, sgc: 1, base: 0, name: "Überschrift 1", papx: &[0x08, 0x01], chpx: &[0x55, 0x81, 0x63, 0x20, 0x00] }), +]; + +/// Each paragraph's style name, through the document's style sheet. +fn style_names(doc: &craft_wordmodel::Document) -> Vec { + doc.body + .iter() + .filter_map(|b| b.as_para()) + .map(|p| p.props.style.as_deref().and_then(|id| doc.styles.get(id)).map_or(String::new(), |s| s.name.clone())) + .collect() +} + +#[test] +fn paragraphs_take_their_styles() { + let paras: &[(u32, u32, &[u8])] = &[(0, 2, &[]), (2, 4, &[]), (4, 6, &[])]; + let d = Doc6 { text: b"A\rB\rC\r", paras, para_styles: &[0, 2, 3], styles: STYLES, ..Default::default() }; + let doc = read(&word6_file(&d)).expect("opens"); + // A built-in style goes by its built-in name, whatever the file stores (as in Word). + assert_eq!(style_names(&doc), ["Normal", "Mine", "Heading 1"]); +} + +#[test] +fn styles_carry_their_formatting_and_base() { + let d = Doc6 { text: b"A\r", styles: STYLES, ..Default::default() }; + let doc = read(&word6_file(&d)).expect("opens"); + let mine = doc.styles.styles.iter().find(|s| s.name == "Mine").expect("Mine"); + assert_eq!(mine.para.align, Some(craft_wordmodel::props::Align::Center)); + assert_eq!(mine.chr.bold, Some(true)); + assert_eq!(mine.based_on.as_deref().and_then(|id| doc.styles.get(id)).map(|s| s.name.as_str()), Some("Normal")); + let heading = doc.styles.styles.iter().find(|s| s.name == "Heading 1").expect("Heading 1"); + assert_eq!((heading.para.keep_next, heading.chr.size), (Some(true), Some(16.0))); +} + +#[test] +fn bulleted_styles_frame_past_their_numbering() { + // 0x0D (1 byte) and 0x0C (a count byte and its bytes) before the indents, as in the corpus. + let mut papx = vec![0x05, 0x03, 0x0D, 0x0B, 0x11, 0x1C, 0x01, 0x13, 0xE4, 0xFE, 0x0C, 4, 0xFF, 0x01, 0x00, 0x08]; + papx.extend_from_slice(&[0x0E, 0x01, 0x2C, 0x00, 0x1D, 0x60, 0x1A, 0xF8, 0xFF, 0x25, 0x02, 0x10, 0x68, 0x01]); + let styles = &[Some(Std { sgc: 1, base: 0x0FFF, name: "Normal", papx: &papx, ..Default::default() })]; + let doc = read(&word6_file(&Doc6 { text: b"A\r", styles, ..Default::default() })).expect("opens"); + let normal = doc.styles.get("Normal").expect("Normal"); + assert_eq!((normal.para.indent_left, normal.para.indent_first, normal.para.indent_right), (Some(14.2), Some(-14.2), Some(18.0))); +} + +#[test] +fn a_runs_language_or_its_styles_chooses_the_code_page_without_a_font() { + // No font named anywhere: the style's language decides (Russian -> Windows-1251), as in Word. + let styles = &[Some(Std { sgc: 1, base: 0x0FFF, name: "Normal", chpx: &[0x61, 0x19, 0x04], ..Default::default() })]; + let d = Doc6 { text: b"\xC4\xE0\r", fonts: FONTS, styles, ..Default::default() }; + assert_eq!(texts(&d), ["Да"]); + // The run's own language outranks the style's. + let runs: &[(u32, u32, &[u8])] = &[(0, 3, &[0x61, 0x09, 0x04])]; + let d = Doc6 { text: b"\xC4\xE0\r", fonts: FONTS, styles, runs, ..Default::default() }; + assert_eq!(texts(&d), ["Äà"]); +} + +#[test] +fn font_0_is_not_a_fallback() { + // Font 0 is Cyrillic, but nothing names it and the style's language is English: Word reads + // Windows-1252. + let fonts = &[(204, "Times New Roman Cyr"), (0, "Arial")]; + let styles = &[Some(Std { sgc: 1, base: 0x0FFF, name: "Normal", chpx: &[0x61, 0x09, 0x04], ..Default::default() })]; + // A character run that names no font (where the reader used to take font 0). + let runs: &[(u32, u32, &[u8])] = &[(0, 3, &[])]; + let d = Doc6 { text: b"\xC4\xE0\r", fonts, styles, runs, ..Default::default() }; + assert_eq!(texts(&d), ["Äà"]); +} + +#[test] +fn a_font_named_in_the_style_chooses_the_code_page() { + // The base style names the Cyrillic font 1; the run and its own style name none. + let styles = &[ + Some(Std { sgc: 1, base: 0x0FFF, name: "Normal", chpx: &[0x5D, 0x01, 0x00], ..Default::default() }), + Some(Std { sti: 0x0FFE, sgc: 1, base: 0, name: "Child", ..Default::default() }), + ]; + let paras: &[(u32, u32, &[u8])] = &[(0, 3, &[])]; + let d = Doc6 { text: b"\xC4\xE0\r", fonts: FONTS, styles, paras, para_styles: &[1], ..Default::default() }; + assert_eq!(texts(&d), ["Да"]); +} + +#[test] +fn an_ansi_font_leaves_the_code_page_to_the_language() { + // Arial (ANSI) named on the run, the style's language Russian: Word reads Cyrillic. + let fonts = &[(0, "Times New Roman"), (0, "Arial")]; + let styles = &[Some(Std { sgc: 1, base: 0x0FFF, name: "Normal", chpx: &[0x61, 0x19, 0x04], ..Default::default() })]; + let runs: &[(u32, u32, &[u8])] = &[(0, 2, &[0x5D, 0x01, 0x00]), (2, 3, &[])]; + let d = Doc6 { text: b"\xC4\xE0\r", fonts, styles, runs, ..Default::default() }; + assert_eq!(texts(&d), ["Да"]); +} + +/// A fixture with every structure read: runs, fonts and a piece table. +fn full_stream() -> Vec { + let runs: &[(u32, u32, &[u8])] = &[(0, 6, &[0x55, 0x81]), (6, 9, &[0x5D, 0x01, 0x00]), (9, 12, &[])]; + let styles = &[ + Some(Std { sgc: 1, base: 0x0FFF, name: "Normal", chpx: &[0x61, 0x09, 0x04], ..Default::default() }), + None, + Some(Std { sti: 0x0FFE, sgc: 1, base: 0, name: "Mine", papx: &[0x05, 0x01], chpx: &[0x55, 0x01] }), + ]; + let paras: &[(u32, u32, &[u8])] = &[(0, 6, &[]), (6, 12, &[])]; + word_stream(&Doc6 { + text: b"Hello\r\xC4\xE0\rx\r\r", + fonts: FONTS, + runs, + paras, + para_styles: &[2, 0], + styles, + pieces: Some(&[(6, 0), (6, 6)]), + ..Default::default() + }) +} + +#[test] +fn the_full_fixture_reads() { + let f = cfb_file(&[("WordDocument", &full_stream())]); + let doc = read(&f).expect("opens"); + assert_eq!(body_texts(&doc), ["Hello", "Да", "x", ""]); + assert_eq!(style_names(&doc)[0], "Mine"); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn corrupt_word6_streams_never_panic(edits in proptest::collection::vec((0..0x700usize, any::()), 1..16)) { + let mut w = full_stream(); + for (at, b) in edits { + if let Some(x) = w.get_mut(at) { + *x = b; + } + } + let _ = read(&cfb_file(&[("WordDocument", &w)])); + } + + #[test] + fn truncated_word6_streams_never_panic(n in 0..0x700usize) { + let w = full_stream(); + let _ = read(&cfb_file(&[("WordDocument", &w[..n.min(w.len())])])); + } +} diff --git a/crates/doc-binary/src/word6.rs b/crates/doc-binary/src/word6.rs new file mode 100644 index 000000000..0139e3ce5 --- /dev/null +++ b/crates/doc-binary/src/word6.rs @@ -0,0 +1,826 @@ +//! Word 6.0 and Word 95 `.doc` files (`wIdent` 0xA5DC, `nFib` 0x0065 and 0x0068). +//! +//! No public specification describes this version: [MS-DOC] covers Word 97 and later. Everything +//! here is observed, from the layout of real files checked against what Word shows when it opens +//! them (its text through COM, its formatting in the `.docx` Word saves from them), plus the Word +//! 97 structures in [MS-DOC] that these files visibly share. Nothing else is assumed. +//! +//! What is read: the main text, its paragraphs and fields, the code page of each run, the +//! character and paragraph formatting listed below, table rows, and the paragraph styles (with +//! their formatting). Character styles, sections, headers and footers, notes, pictures, borders, +//! shading and tabs are not read yet. +//! +//! The layout as observed: +//! +//! - A compound file with a `WordDocument` stream and **no Table stream**: the structures that +//! Word 97 keeps in `0Table`/`1Table` live in the WordDocument stream itself. +//! - The FIB is one fixed block. The flags at 0x0A have the Word 97 meaning for bit 2 (fast-saved, +//! set exactly in the files that carry a piece table) and, as far as can be told, bit 8 +//! (encrypted). The text starts at the u32 at 0x18, and the main text's length in characters is +//! the u32 at 0x34. +//! - From 0x58, (u32 fc, u32 lcb) pairs, in the order of Word 97's `FibRgFcLcb97`. Index 12 is the +//! character bin table, 13 the paragraph bin table, 15 the font table and 33 the piece table. +//! - Text is one byte per character, in the code page of the font of its run. The FIB's language +//! does not choose it: files marked English hold Russian and Czech text that Word shows as such. +//! - Both bin tables hold 2-byte page numbers (Word 97: 4 bytes). The FKP pages have the Word 97 +//! layout, except that a paragraph page's entries are 7 bytes (an offset byte and 6 more; Word +//! 97: 13), and a PAPX is a count of 2-byte words followed by exactly that many words: the +//! 2-byte style index, then the grpprl (padded with one 0 byte to whole words). +//! - Sprms have **1-byte opcodes**. Those with a 1-byte operand match the opcodes [MS-DOC]'s +//! `Prm0` table lists for the same properties (bold 0x55, colour 0x62, in-table 0x18…); the +//! others were identified against Word. See [`chp`] and [`pap`]. +//! - A font table entry is a size byte (the entry's length − 1), a flags byte, a 2-byte weight, the +//! charset byte, one more byte, then the name in 8-bit characters. +//! - The stylesheet (pair 1) has the Word 97 shape with smaller parts: a 14-byte header whose +//! first two words are the style count and the size of a style's fixed part (8, Word 97: 10); +//! then per style a u16 size (0: an empty slot) and that many bytes: the fixed part (sti, then +//! kind and base style, then UPX count and next style, then a size), the name (a length byte, +//! 8-bit characters, a 0), and the UPXs, each a u16 size and its bytes, padded to even. A +//! paragraph style's first UPX is its style index and paragraph sprms, the second its character +//! sprms; a character style has one, its character sprms. + +use craft_codepage::charset_code_page; + +use crate::DocbinError; +use crate::fkp::{Bins, TranslatedRun}; +use crate::piece::{CodePageRun, PieceTable, SYMBOL_CODE_PAGE, Word6Text}; +use crate::stsh::RawStyle; + +/// `wIdent` of a Word 6.0/95 file. +const W_IDENT: u16 = 0xA5DC; +const OFF_FLAGS: usize = 0x0A; +/// Fast-saved: the text is in pieces listed by a piece table. +const F_COMPLEX: u16 = 0x0004; +/// Password-protected (Word 97's bit; no unprotected file sets it). +const F_ENCRYPTED: u16 = 0x0100; +/// Word 97's `fExtChar` bit. Word reads a Word 6.0/95 file that sets it as UTF-16 text (each +/// piece's fc a plain byte offset), and so does this reader. +const F_EXT_CHAR: u16 = 0x1000; +/// The document's character set (u16): 0 Windows, 0x0100 Macintosh (all 8-bit text is Mac Roman, +/// whatever the fonts say), 0x0002 Symbol (every byte a private-use character). Word reads the +/// bytes above ASCII of any other value as U+FFFD. +const OFF_CHSE: usize = 0x14; +const OFF_FC_MAC: usize = 0x1C; +/// Bin table entries read, as for Word 97 (`fkp::MAX_BINS`). +const MAX_BINS: usize = 100_000; +/// FKP runs read (character or paragraph): far more than any real file's, and few enough to stay small. +const MAX_RUNS: usize = 1_000_000; +const OFF_FC_MIN: usize = 0x18; +const OFF_CCP_TEXT: usize = 0x34; +const OFF_PAIRS: usize = 0x58; +/// Pairs read: up to and including the piece table's. +const PAIRS: usize = 34; +const PAIR_STSH: usize = 1; +const PAIR_BTE_CHPX: usize = 12; +const PAIR_BTE_PAPX: usize = 13; +const PAIR_FONTS: usize = 15; +const PAIR_CLX: usize = 33; +/// More characters than Word's own documents get near: the FIB is corrupt (as for Word 97). +const MAX_CCP: u32 = 100_000_000; +/// The font table's entries: more than any real file has. +const MAX_FONTS: usize = 0x1_0000; +/// FKP page size in bytes. +const PAGE: usize = 512; +/// Size of a paragraph FKP entry (offset byte + 6). +const PAPX_BX: usize = 7; +/// Most cells a row's table definition is read for (the model's own cap is 63). +const MAX_CELLS: usize = 63; +/// Styles read: style indexes stop below 0x0FFF ("no style"). +const MAX_STYLES: usize = 0x0FFE; +/// Base-style links followed when a style inherits a font or language. +const MAX_STYLE_DEPTH: usize = 16; + +/// Character sprms read, by Word 6.0/95 opcode. Each one was matched against Word's own reading of +/// the corpus files (the `.docx` it saves from them). +mod chp { + /// Bold, a toggle (0 off, 1 on, 0x80 as the style, 0x81 the opposite of the style). + pub(super) const BOLD: u8 = 0x55; + /// Italic, a toggle. + pub(super) const ITALIC: u8 = 0x56; + /// Small capitals, a toggle. + pub(super) const SMALL_CAPS: u8 = 0x5A; + /// All capitals, a toggle. + pub(super) const CAPS: u8 = 0x5B; + /// The font: a 2-byte index into the font table. + pub(super) const FONT: u8 = 0x5D; + /// Underline: 0 none, 1 single (the only values seen). + pub(super) const UNDERLINE: u8 = 0x5E; + /// Colour: an index into Word's 16-colour palette, as Word 97's `Ico`. + pub(super) const COLOR: u8 = 0x62; + /// Size in half-points (2 bytes). + pub(super) const SIZE: u8 = 0x63; + /// The language (2 bytes, a Windows LCID): with no non-ANSI font, it gives the code page. + pub(super) const LID: u8 = 0x61; +} + +/// Paragraph and table sprms read, by Word 6.0/95 opcode, matched like [`chp`]'s. +mod pap { + /// Alignment: 0 left, 1 centred, 2 right, 3 justified. + pub(super) const JC: u8 = 0x05; + /// Keep with the next paragraph (1 byte; on the heading styles H1-H6, which Word shows with + /// keepNext; [MS-DOC]'s isprm table has sprmPFKeepFollow at 0x08). + pub(super) const KEEP_NEXT: u8 = 0x08; + /// Right indent in twips (signed, 2 bytes). + pub(super) const RIGHT: u8 = 0x10; + /// Left indent in twips. + pub(super) const LEFT: u8 = 0x11; + /// First-line indent in twips, relative to the left one (negative: hanging). + pub(super) const FIRST: u8 = 0x13; + /// Line spacing: the 4-byte Word 97 `LSPD` (dyaLine, fMultLinespace). + pub(super) const LINE: u8 = 0x14; + /// The paragraph is in a table. + pub(super) const IN_TABLE: u8 = 0x18; + /// The mark ends a table row. + pub(super) const TTP: u8 = 0x19; + /// Row alignment (2 bytes): 1 centred. + pub(super) const ROW_JC: u8 = 0xB6; + /// Row height in twips (signed, 2 bytes): positive at least, negative exactly. + pub(super) const ROW_HEIGHT: u8 = 0xBD; + /// The row's cells: u16 count, cell count, (count + 1) i16 cell edges, then a 10-byte + /// descriptor per cell whose first word's bit 0 starts a horizontal merge and bit 1 continues + /// it. + pub(super) const DEF_TABLE: u8 = 0xBE; +} + +/// Word 97 opcodes the translations produce ([MS-DOC] §2.6). +mod w97 { + pub(super) const C_BOLD: u16 = 0x0835; + pub(super) const C_ITALIC: u16 = 0x0836; + pub(super) const C_SMALL_CAPS: u16 = 0x083A; + pub(super) const C_CAPS: u16 = 0x083B; + pub(super) const C_KUL: u16 = 0x2A3E; + pub(super) const C_ICO: u16 = 0x2A42; + pub(super) const C_HPS: u16 = 0x4A43; + pub(super) const C_FTC0: u16 = 0x4A4F; + pub(super) const P_JC80: u16 = 0x2403; + pub(super) const P_KEEP_FOLLOW: u16 = 0x2406; + pub(super) const P_DXA_RIGHT80: u16 = 0x840E; + pub(super) const P_DXA_LEFT80: u16 = 0x840F; + pub(super) const P_DXA_LEFT1_80: u16 = 0x8411; + pub(super) const P_DYA_LINE: u16 = 0x6412; + pub(super) const P_IN_TABLE: u16 = 0x2416; + pub(super) const P_TTP: u16 = 0x2417; + pub(super) const T_JC: u16 = 0x5400; + pub(super) const T_ROW_HEIGHT: u16 = 0x9407; + pub(super) const T_DEF_TABLE: u16 = 0xD608; + pub(super) const T_MERGE: u16 = 0x5624; +} + +/// Is this WordDocument stream a Word 6.0/95 one? +pub(crate) fn is_word6(word: &[u8]) -> bool { + word.get(0..2) == Some(&W_IDENT.to_le_bytes()[..]) +} + +/// The fields of a Word 6.0/95 FIB that are read. +#[derive(Clone, Debug)] +pub(crate) struct Fib6 { + complex: bool, + /// Text is UTF-16 (fExtChar). + wide: bool, + chse: u16, + fc_min: u32, + fc_mac: u32, + /// Characters in the main text. + pub(crate) ccp_text: u32, + pairs: [(u32, u32); PAIRS], +} + +impl Fib6 { + pub(crate) fn parse(word: &[u8]) -> Result { + let u16_at = |at: usize| word.get(at..at + 2).map(|b| u16::from_le_bytes([b[0], b[1]])); + let u32_at = |at: usize| word.get(at..at + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + if word.len() < OFF_PAIRS + PAIRS * 8 { + return Err(DocbinError::NotWord(format!("Word 6.0/95 WordDocument stream is {} bytes", word.len()))); + } + let flags = u16_at(OFF_FLAGS).unwrap_or(0); + if flags & F_ENCRYPTED != 0 { + return Err(DocbinError::Encryption("password-protected Word 6.0/95 files can't be opened".into())); + } + let ccp_text = u32_at(OFF_CCP_TEXT).unwrap_or(0); + if ccp_text > MAX_CCP { + return Err(DocbinError::Limit(format!("document claims {ccp_text} characters"))); + } + let mut pairs = [(0, 0); PAIRS]; + for (i, p) in pairs.iter_mut().enumerate() { + let at = OFF_PAIRS + i * 8; + *p = (u32_at(at).unwrap_or(0), u32_at(at + 4).unwrap_or(0)); + } + Ok(Fib6 { + complex: flags & F_COMPLEX != 0, + wide: flags & F_EXT_CHAR != 0, + chse: u16_at(OFF_CHSE).unwrap_or(0), + fc_min: u32_at(OFF_FC_MIN).unwrap_or(0), + fc_mac: u32_at(OFF_FC_MAC).unwrap_or(0), + ccp_text, + pairs, + }) + } + + /// The (fc, lcb) pair at index `i`, if it points at anything. + fn pair(&self, i: usize) -> Option<(u32, u32)> { + self.pairs.get(i).copied().filter(|&(_, lcb)| lcb > 0) + } +} + +/// Everything the walk needs from a Word 6.0/95 file besides its text stream. +pub(crate) struct Content { + pub(crate) pieces: PieceTable, + /// Character formatting, translated to Word 97 grpprls. + pub(crate) chpx: Bins, + /// Paragraph formatting and table rows, translated to Word 97 grpprls. + pub(crate) papx: Bins, + /// Font names by index (the walk resolves translated font sprms against them). + pub(crate) fonts: Vec, + /// The styles by index, their formatting translated to Word 97 grpprls. + pub(crate) styles: Vec, +} + +/// Read the text, formatting and fonts of a Word 6.0/95 file. +/// +/// 8-bit text decodes, as Word does, in the code page of: the document's character set when it +/// isn't Windows ([`OFF_CHSE`]); else the font a piece's Prm names (as Word 95 writes when a fast +/// save changes a font) when its charset has a code page; else, the same for the font of the +/// character run, or the one its paragraph style (or a base of it) names; else the language of +/// the run, or of its paragraph style; else Windows-1252. A font is only one named somewhere: +/// font 0 is not a fallback, and a font with the ANSI charset leaves the choice to the language. +pub(crate) fn content(word: &[u8], fib: &Fib6) -> Result { + let fonts = fib.pair(PAIR_FONTS).map(|(fc, lcb)| font_table(word, fc, lcb)).unwrap_or_default(); + let chp_runs = fib.pair(PAIR_BTE_CHPX).map(|(fc, lcb)| fkp_runs(word, fc, lcb, false)).unwrap_or_default(); + let pap_runs = fib.pair(PAIR_BTE_PAPX).map(|(fc, lcb)| fkp_runs(word, fc, lcb, true)).unwrap_or_default(); + let (styles, facts) = fib.pair(PAIR_STSH).map(|(fc, lcb)| stylesheet(word, fc, lcb)).unwrap_or_default(); + let windows = fib.chse == 0; + let default_code_page = match fib.chse { + // Text that no run covers is formatted by the Normal style. + 0 => text_code_page(&fonts, &facts, 0, None, None), + 0x0100 => MAC_ROMAN, + 0x0002 => SYMBOL_CODE_PAGE, + // Not a code page: bytes above ASCII read as U+FFFD. + _ => 0, + }; + let code_pages = if windows { code_page_runs(&chp_runs, &pap_runs, &facts, &fonts, default_code_page) } else { Vec::new() }; + let text = Word6Text { wide: fib.wide, default_code_page, code_pages }; + let pieces = if fib.complex { + let (fc, lcb) = fib.pair(PAIR_CLX).ok_or_else(|| DocbinError::Malformed("the fast-saved file has no piece table".into()))?; + // A Prm1's Prc may name the piece's font (sprm 0x5D); a Prm0 can't, its operand being one + // byte. + let piece_code_page = |prm: u16, prcs: &[Vec]| -> Option { + if !windows || prm & 1 == 0 { + return None; + } + last_font(prcs.get(usize::from(prm >> 1))?).and_then(|f| font_code_page(&fonts, f)) + }; + // The Prcs then carry Word 97 sprms, as the CHPXs and PAPXs do: the walk applies their + // character sprms to the piece's text and their paragraph sprms to the paragraph whose mark + // the piece holds. + PieceTable::parse_word6(word, fc, lcb, text, piece_code_page, translate_prc)? + } else { + // Word reads no more characters than the text's byte range (fcMin..fcMac) holds, one byte + // each or two for UTF-16, whatever the character count says. + let unit = if fib.wide { 2 } else { 1 }; + let ccp = fib.ccp_text.min(fib.fc_mac.saturating_sub(fib.fc_min) / unit); + PieceTable::contiguous_word6(fib.fc_min, ccp, text) + }; + let chpx = Bins::translated(chp_runs.iter().map(|r| TranslatedRun { lo: r.lo, hi: r.hi, istd: 0, grpprl: translate_chp(r.grpprl) }).collect()); + let papx = + Bins::translated(pap_runs.iter().map(|r| TranslatedRun { lo: r.lo, hi: r.hi, istd: r.istd, grpprl: translate_pap(r.grpprl) }).collect()); + Ok(Content { pieces, chpx, papx, fonts: fonts.into_iter().map(|f| f.name).collect(), styles }) +} + +/// Mac OS Roman, the 8-bit text of a Macintosh Word document. +const MAC_ROMAN: u16 = craft_codepage::MAC_ROMAN_CODE_PAGE; + +/// The code page of font `font`'s charset, if it has one (not for ANSI, default or symbol +/// charsets, whose text Word decodes by its language). +fn font_code_page(fonts: &[Font], font: u16) -> Option { + fonts.get(usize::from(font)).and_then(|f| charset_code_page(i32::from(f.charset))) +} + +/// Windows' ANSI code page for the language `lid` (an LCID), which Word uses for 8-bit text in a +/// font without a charset of its own. Russian (1251) and English (1252) were checked against Word; +/// the rest is the standard Windows assignment. +fn lid_code_page(lid: u16) -> u16 { + match lid & 0x03FF { + // Serbian in Cyrillic (0x0C1A, 0x1C1A); Croatian and Serbian in Latin otherwise. + 0x1A => match lid { + 0x0C1A | 0x1C1A => 1251, + _ => 1250, + }, + 0x02 | 0x19 | 0x22 | 0x23 | 0x2F | 0x3F | 0x40 | 0x44 | 0x50 => 1251, + 0x05 | 0x0E | 0x15 | 0x18 | 0x1B | 0x1C | 0x24 => 1250, + 0x08 => 1253, + 0x1F => 1254, + 0x0D => 1255, + 0x01 | 0x20 | 0x29 => 1256, + 0x25..=0x27 => 1257, + 0x2A => 1258, + 0x1E => 874, + 0x11 => 932, + 0x12 => 949, + 0x04 => match lid { + 0x0804 | 0x1004 => 936, + _ => 950, + }, + _ => 1252, + } +} + +/// A style's own font and language sprms and its base, for inheritance ([`StyleFacts::resolve`]). +#[derive(Clone, Copy, Debug, Default)] +struct StyleFacts { + base: u16, + font: Option, + lid: Option, +} + +impl StyleFacts { + /// Style `istd`'s font and language, each from the style itself or the nearest base that has + /// one. + fn resolve(facts: &[StyleFacts], istd: u16) -> (Option, Option) { + let (mut font, mut lid) = (None, None); + let mut at = istd; + for _ in 0..MAX_STYLE_DEPTH { + let Some(f) = facts.get(usize::from(at)) else { break }; + font = font.or(f.font); + lid = lid.or(f.lid); + if font.is_some() && lid.is_some() { + break; + } + at = f.base; + } + (font, lid) + } +} + +/// The styles of the stylesheet at `word[fc..fc+lcb]`, by index (an empty slot is a default +/// style, which keeps the indexes aligned), with their formatting translated, and each style's +/// own font and language. Reading stops at the first entry that runs past the table. +fn stylesheet(word: &[u8], fc: u32, lcb: u32) -> (Vec, Vec) { + let Some(st) = (fc as usize).checked_add(lcb as usize).and_then(|end| word.get(fc as usize..end)) else { return Default::default() }; + let u16_at = |b: &[u8], at: usize| b.get(at..at + 2).map(|x| u16::from_le_bytes([x[0], x[1]])); + let Some(cb_header) = u16_at(st, 0).map(usize::from) else { return Default::default() }; + let (Some(cstd), Some(cb_base)) = (u16_at(st, 2), u16_at(st, 4)) else { return Default::default() }; + let cb_base = usize::from(cb_base); + let (mut styles, mut facts) = (Vec::new(), Vec::new()); + let mut at = 2 + cb_header; + for _ in 0..usize::from(cstd).min(MAX_STYLES) { + let Some(cb) = u16_at(st, at).map(usize::from) else { break }; + let Some(std) = st.get(at + 2..at + 2 + cb) else { break }; + at += 2 + cb; + let (style, fact) = style(std, cb_base).unwrap_or_else(|| (RawStyle::default(), StyleFacts { base: 0x0FFF, ..Default::default() })); + styles.push(style); + facts.push(fact); + } + (styles, facts) +} + +/// The names of the built-in styles by sti: the "Index within built-in styles" of the reserved +/// style names in [MS-OE376] (Part 4 §2.7.3.9), which [MS-DOC]'s StdfBase says sti is. 0x0FFE +/// (user-defined) and anything past the table keep the name stored in the file. +const BUILT_IN_NAMES: [&str; 107] = [ + "Normal", + "heading 1", + "heading 2", + "heading 3", + "heading 4", + "heading 5", + "heading 6", + "heading 7", + "heading 8", + "heading 9", + "index 1", + "index 2", + "index 3", + "index 4", + "index 5", + "index 6", + "index 7", + "index 8", + "index 9", + "toc 1", + "toc 2", + "toc 3", + "toc 4", + "toc 5", + "toc 6", + "toc 7", + "toc 8", + "toc 9", + "Normal Indent", + "footnote text", + "annotation text", + "header", + "footer", + "index heading", + "caption", + "table of figures", + "envelope address", + "envelope return", + "footnote reference", + "annotation reference", + "line number", + "page number", + "endnote reference", + "endnote text", + "table of authorities", + "macro", + "toa heading", + "List", + "List Bullet", + "List Number", + "List 2", + "List 3", + "List 4", + "List 5", + "List Bullet 2", + "List Bullet 3", + "List Bullet 4", + "List Bullet 5", + "List Number 2", + "List Number 3", + "List Number 4", + "List Number 5", + "Title", + "Closing", + "Signature", + "Default Paragraph Font", + "Body Text", + "Body Text Indent", + "List Continue", + "List Continue 2", + "List Continue 3", + "List Continue 4", + "List Continue 5", + "Message Header", + "Subtitle", + "Salutation", + "Date", + "Body Text First Indent", + "Body Text First Indent 2", + "Note Heading", + "Body Text 2", + "Body Text 3", + "Body Text Indent 2", + "Body Text Indent 3", + "Block Text", + "Hyperlink", + "FollowedHyperlink", + "Strong", + "Emphasis", + "Document Map", + "Plain Text", + "E-mail Signature", + "HTML Top of Form", + "HTML Bottom of Form", + "Normal (Web)", + "HTML Acronym", + "HTML Address", + "HTML Cite", + "HTML Code", + "HTML Definition", + "HTML Keyboard", + "HTML Preformatted", + "HTML Sample", + "HTML Typewriter", + "HTML Variable", + "Normal Table", + "annotation subject", +]; + +/// One style entry (`None` for an empty slot or one too short for its fixed part and name). +fn style(std: &[u8], cb_base: usize) -> Option<(RawStyle, StyleFacts)> { + let u16_at = |at: usize| std.get(at..at + 2).map(|x| u16::from_le_bytes([x[0], x[1]])); + if cb_base < 8 { + return None; + } + let (sti, kind_base, upx_next) = (u16_at(0)? & 0x0FFF, u16_at(2)?, u16_at(4)?); + let (stk, istd_base, cupx) = ((kind_base & 0x000F) as u8, kind_base >> 4, usize::from(upx_next & 0x000F)); + let len = usize::from(*std.get(cb_base)?); + // A built-in style goes by its built-in name, as in Word, whatever name the file stores + // ("Default" or "Обычный" for Normal). Other names are 8-bit, as Word shows them + // (Windows-1252: names it couldn't store read back with '?'). + let stored = std.get(cb_base + 1..cb_base + 1 + len)?; + let name: String = match BUILT_IN_NAMES.get(usize::from(sti)) { + Some(built_in) => (*built_in).to_owned(), + None => stored.iter().map(|&b| craft_codepage::cp1252(b)).collect(), + }; + let mut upxs: Vec<&[u8]> = Vec::new(); + let mut j = cb_base + 1 + len + 1; + for _ in 0..cupx.min(3) { + j += j % 2; + let Some(n) = u16_at(j).map(usize::from) else { break }; + let Some(upx) = std.get(j + 2..j + 2 + n) else { break }; + upxs.push(upx); + j += 2 + n; + } + // A paragraph style: style index + paragraph sprms, then character sprms. A character style: + // character sprms. + let (papx, chpx) = match stk { + 1 => (upxs.first().and_then(|u| u.get(2..)).unwrap_or_default(), upxs.get(1).copied().unwrap_or_default()), + _ => (&[][..], upxs.first().copied().unwrap_or_default()), + }; + let fact = StyleFacts { base: istd_base, font: last_font(chpx), lid: last_lid(chpx) }; + Some((RawStyle { stk, istd_base, name, papx: translate_pap(papx), chpx: translate_chp(chpx) }, fact)) +} + +/// One font table entry. +struct Font { + name: String, + charset: u8, +} + +/// The fonts of the font table at `word[fc..fc+lcb]`, by index. A truncated entry ends the table. +fn font_table(word: &[u8], fc: u32, lcb: u32) -> Vec { + let Some(table) = (fc as usize).checked_add(lcb as usize).and_then(|end| word.get(fc as usize..end)) else { return Vec::new() }; + // The table starts with its own size, which bounds the entries along with `lcb`. + let size = table.get(0..2).map_or(0, |b| usize::from(u16::from_le_bytes([b[0], b[1]]))); + let table = table.get(..size.min(table.len())).unwrap_or_default(); + let mut fonts = Vec::new(); + let mut at = 2usize; + while let Some(&cb_m1) = table.get(at) { + let next = at + usize::from(cb_m1) + 1; + // The entry must lie inside the table and hold its charset (one too short is corrupt). + let Some(entry) = table.get(at..next) else { break }; + let (Some(&charset), Some(name)) = (entry.get(4), entry.get(6..)) else { break }; + if fonts.len() >= MAX_FONTS { + break; + } + // An 8-bit name; font names are ASCII in every file seen, other bytes read as Latin-1. + let name = name.split(|&b| b == 0).next().unwrap_or_default().iter().map(|&b| char::from(b)).collect(); + fonts.push(Font { name, charset }); + at = next; + } + fonts +} + +/// One FKP run as stored: its FC range, the paragraph style (PAPX runs) and its Word 6 grpprl. +struct Run<'w> { + lo: u32, + hi: u32, + istd: u16, + grpprl: &'w [u8], +} + +/// The page numbers of a bin table with `n` entries ((n+1) u32 FCs, then n u16 page numbers), +/// each once, however many entries name it: a page's runs carry their own FCs, so reading it +/// again would only repeat them. The entries read are capped as for Word 97. +fn distinct_pages(plc: &[u8], n: usize) -> Vec { + let mut pns: Vec = (0..n.min(MAX_BINS)) + .filter_map(|i| plc.get(4 * (n + 1) + 2 * i..4 * (n + 1) + 2 * i + 2).map(|b| usize::from(u16::from_le_bytes([b[0], b[1]])))) + .collect(); + pns.sort_unstable(); + pns.dedup(); + pns +} + +/// Every run of the FKP pages a bin table at `word[fc..fc+lcb]` lists, each page read once and +/// at most [`MAX_RUNS`] runs in all. Unreadable pages are skipped. +fn fkp_runs(word: &[u8], fc: u32, lcb: u32, papx: bool) -> Vec> { + let Some(plc) = (fc as usize).checked_add(lcb as usize).and_then(|end| word.get(fc as usize..end)) else { return Vec::new() }; + let Some(n) = plc.len().checked_sub(4).filter(|r| r % 6 == 0).map(|r| r / 6) else { return Vec::new() }; + let entry = if papx { PAPX_BX } else { 1 }; + let mut runs = Vec::new(); + for pn in distinct_pages(plc, n) { + if runs.len() >= MAX_RUNS { + break; + } + let Some(page) = pn.checked_mul(PAGE).and_then(|at| word.get(at..at.checked_add(PAGE)?)) else { continue }; + let crun = usize::from(page.get(PAGE - 1).copied().unwrap_or(0)); + // The FCs and the entries must fit before the count byte. + if 4 * (crun + 1) + entry * crun >= PAGE { + continue; + } + let fc_at = |k: usize| page.get(4 * k..4 * k + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + for k in 0..crun { + let (Some(lo), Some(hi)) = (fc_at(k), fc_at(k + 1)) else { break }; + let off = usize::from(page.get(4 * (crun + 1) + entry * k).copied().unwrap_or(0)) * 2; + let (istd, grpprl) = match (off, papx) { + (0, _) => (0, &[][..]), + // A CHPX: a byte count, then the grpprl. + (_, false) => (0, page.get(off).and_then(|&cb| page.get(off + 1..off + 1 + usize::from(cb))).unwrap_or_default()), + // A PAPX: a count of 2-byte words, then the style index and the grpprl. + (_, true) => { + let body = page.get(off).and_then(|&cw| page.get(off + 1..off + 1 + 2 * usize::from(cw))).unwrap_or_default(); + let istd = body.get(0..2).map_or(0, |b| u16::from_le_bytes([b[0], b[1]])); + (istd, body.get(2..).unwrap_or_default()) + } + }; + runs.push(Run { lo, hi, istd, grpprl }); + } + } + runs +} + +/// The sprms of a Word 6.0/95 grpprl as (opcode, operand). Reading stops at an opcode whose +/// operand length is not known, since the rest can't be framed, and at a lone trailing 0 (the +/// padding of a PAPX to whole words). +fn sprms(grpprl: &[u8]) -> impl Iterator { + let mut rest = grpprl; + std::iter::from_fn(move || { + let (&op, after) = rest.split_first()?; + let len = match op { + // 0x55-0x5C are the character toggles of [MS-DOC]'s Prm0 isprm table (bold, italic, + // strike, outline, shadow, small caps, caps, hidden), all one byte; 0x55, 0x56, 0x5A + // and 0x5B are also seen in real files. + // 0x0D, 0x0E, 0x1D, 0x25 and 0x2C are one-byte paragraph sprms of [MS-DOC]'s isprm table + // too (numbering level, no line numbers, frame anchoring, wrapping, no hyphenation). + 0x05 | 0x08 | 0x0D | 0x0E | 0x18 | 0x19 | 0x1D | 0x25 | 0x2C | 0x33 | 0x55..=0x5C | 0x5E | 0x62 | 0x68 | 0x75 | 0xB7 | 0xB9 => 1, + // 0x1A/0x1B: a frame's position; 0x6B: kerning in half-points (H1 has 0x0024; Word + // shows kern=36). + 0x10 | 0x11 | 0x13 | 0x15 | 0x16 | 0x1A | 0x1B | 0x26 | 0x27 | 0x28 | 0x29 | 0x2F | 0x50 | 0x5D | 0x60 | 0x61 | 0x63 | 0x6B | 0xB6 + | 0xB8 | 0xBD | 0xC0 => 2, + 0x14 => 4, + // A count byte, then that many bytes (0x0C: a bulleted style's numbering, 52 bytes). + 0x0C | 0x0F | 0x44 | 0xBF => 1 + usize::from(*after.first()?), + // A 2-byte count, then one byte fewer than it says (as Word 97's sprmTDefTable). + 0xBE => 2 + usize::from(u16::from_le_bytes([*after.first()?, *after.get(1)?])).saturating_sub(1), + _ => return None, + }; + let operand = after.get(..len)?; + rest = after.get(len..).unwrap_or_default(); + Some((op, operand)) + }) +} + +/// Append a Word 97 Prl: opcode, then operand. +fn prl(out: &mut Vec, op: u16, operand: &[u8]) { + out.extend_from_slice(&op.to_le_bytes()); + out.extend_from_slice(operand); +} + +/// A Word 6.0/95 CHPX grpprl as the Word 97 sprms it stands for. Sprms not read are left out. +fn translate_chp(grpprl: &[u8]) -> Vec { + let mut out = Vec::new(); + for (op, v) in sprms(grpprl) { + match op { + chp::BOLD => prl(&mut out, w97::C_BOLD, v), + chp::ITALIC => prl(&mut out, w97::C_ITALIC, v), + chp::SMALL_CAPS => prl(&mut out, w97::C_SMALL_CAPS, v), + chp::CAPS => prl(&mut out, w97::C_CAPS, v), + // Only none and single were seen; any other value is left unread. + chp::UNDERLINE if matches!(v, [0] | [1]) => prl(&mut out, w97::C_KUL, v), + chp::COLOR => prl(&mut out, w97::C_ICO, v), + chp::SIZE => prl(&mut out, w97::C_HPS, v), + chp::FONT => prl(&mut out, w97::C_FTC0, v), + _ => {} + } + } + out +} + +/// A Word 6.0/95 Prc grpprl as the Word 97 sprms it stands for: character and paragraph sprms +/// alike (Word 95 records a paragraph change before a fast save as a Prc on the mark's piece). +fn translate_prc(grpprl: &[u8]) -> Vec { + [translate_chp(grpprl), translate_pap(grpprl)].concat() +} + +/// A Word 6.0/95 PAPX grpprl as the Word 97 sprms it stands for, table rows included. Sprms not +/// read are left out. +fn translate_pap(grpprl: &[u8]) -> Vec { + let mut out = Vec::new(); + for (op, v) in sprms(grpprl) { + match op { + // Only 0-3 were seen: another value is left unread. + pap::JC if matches!(v, [0..=3]) => prl(&mut out, w97::P_JC80, v), + pap::KEEP_NEXT => prl(&mut out, w97::P_KEEP_FOLLOW, v), + pap::RIGHT => prl(&mut out, w97::P_DXA_RIGHT80, v), + pap::LEFT => prl(&mut out, w97::P_DXA_LEFT80, v), + pap::FIRST => prl(&mut out, w97::P_DXA_LEFT1_80, v), + // Only "at least" spacing was seen (a positive height, not a multiple): the other + // forms are left unread. + pap::LINE => { + if let [l0, l1, 0, 0] = v + && i16::from_le_bytes([*l0, *l1]) > 0 + { + prl(&mut out, w97::P_DYA_LINE, v); + } + } + pap::IN_TABLE => prl(&mut out, w97::P_IN_TABLE, v), + pap::TTP => prl(&mut out, w97::P_TTP, v), + pap::ROW_JC => prl(&mut out, w97::T_JC, v), + pap::ROW_HEIGHT => prl(&mut out, w97::T_ROW_HEIGHT, v), + pap::DEF_TABLE => def_table(&mut out, v), + _ => {} + } + } + out +} + +/// A Word 6.0/95 table definition as a Word 97 `sprmTDefTable` holding the cell edges (the only +/// part the table reader uses) and one `sprmTMerge` per horizontal merge. +fn def_table(out: &mut Vec, v: &[u8]) { + let Some(&itc) = v.get(2) else { return }; + let itc = usize::from(itc).min(MAX_CELLS); + let Some(edges) = v.get(3..3 + 2 * (itc + 1)) else { return }; + // Word 97 operand: cb (u16: the bytes after it, plus one), the cell count, the edges. + let cb = (1 + edges.len() + 1) as u16; + out.extend_from_slice(&w97::T_DEF_TABLE.to_le_bytes()); + out.extend_from_slice(&cb.to_le_bytes()); + out.push(itc as u8); + out.extend_from_slice(edges); + let cells = v.get(3 + edges.len()..).unwrap_or_default(); + let mut merges: Vec<(usize, usize)> = Vec::new(); + let mut start: Option = None; + for i in 0..=itc { + let rgf = cells.get(10 * i..10 * i + 2).map_or(0, |b| u16::from_le_bytes([b[0], b[1]])); + let continues = i < itc && rgf & 0x0002 != 0 && rgf & 0x0001 == 0; + if continues { + continue; + } + // A merge runs from its first cell through every cell that continues it. + if let Some(a) = start.take() + && i > a + 1 + { + merges.push((a, i)); + } + if i < itc && rgf & 0x0001 != 0 { + start = Some(i); + } + } + // Right to left: the table reader folds each merge's cells into its first one as it goes, so + // a merge further right must be applied before one to its left moves its cells. + for (a, b) in merges.into_iter().rev() { + prl(out, w97::T_MERGE, &[a as u8, b as u8]); + } +} + +/// The code page of text in paragraph style `istd` whose run names `run_font` and `run_lid`: the +/// run's font, else the style's (nearest in its base chain), when that font's charset has a code +/// page; else the run's language, else the style's; else Windows-1252. +fn text_code_page(fonts: &[Font], facts: &[StyleFacts], istd: u16, run_font: Option, run_lid: Option) -> u16 { + let (style_font, style_lid) = StyleFacts::resolve(facts, istd); + run_font.or(style_font).and_then(|f| font_code_page(fonts, f)).or_else(|| run_lid.or(style_lid).map(lid_code_page)).unwrap_or(1252) +} + +/// The language a grpprl names (sprm 0x61), if any: the last one. +fn last_lid(grpprl: &[u8]) -> Option { + sprms(grpprl).filter(|(op, _)| *op == chp::LID).filter_map(|(_, v)| Some(u16::from_le_bytes([*v.first()?, *v.get(1)?]))).last() +} + +/// The byte ranges whose code page is not the document default `default`, cut at every character and paragraph run +/// boundary, each stretch in [`text_code_page`]'s. Sorted by start and not overlapping. +fn code_page_runs(chp: &[Run], pap: &[Run], facts: &[StyleFacts], fonts: &[Font], default: u16) -> Vec { + let sorted = |runs: &[Run]| -> Vec<(u32, u32, usize)> { + let mut v: Vec<(u32, u32, usize)> = runs.iter().enumerate().filter(|(_, r)| r.lo < r.hi).map(|(i, r)| (r.lo, r.hi, i)).collect(); + v.sort_unstable(); + v + }; + let (chp_sorted, pap_sorted) = (sorted(chp), sorted(pap)); + let covering = |v: &[(u32, u32, usize)], fc: u32| -> Option { + let k = v.partition_point(|&(lo, _, _)| lo <= fc).checked_sub(1)?; + v.get(k).filter(|&&(_, hi, _)| fc < hi).map(|&(_, _, i)| i) + }; + let mut cuts: Vec = chp_sorted.iter().chain(pap_sorted.iter()).flat_map(|&(lo, hi, _)| [lo, hi]).collect(); + cuts.sort_unstable(); + cuts.dedup(); + let mut out: Vec = Vec::new(); + for w in cuts.windows(2) { + let (&lo, &hi) = (w.first().unwrap_or(&0), w.get(1).unwrap_or(&0)); + let run = covering(&chp_sorted, lo).and_then(|i| chp.get(i)); + let para = covering(&pap_sorted, lo).and_then(|i| pap.get(i)); + if run.is_none() && para.is_none() { + continue; + } + let run_font = run.and_then(|r| last_font(r.grpprl)); + let run_lid = run.and_then(|r| last_lid(r.grpprl)); + let code_page = text_code_page(fonts, facts, para.map_or(0, |p| p.istd), run_font, run_lid); + if code_page == default { + continue; + } + match out.last_mut() { + Some(last) if last.hi == lo && last.code_page == code_page => last.hi = hi, + _ => out.push(CodePageRun { lo, hi, code_page }), + } + } + out +} + +/// The font a CHPX or Prc grpprl names (sprm 0x5D), if any: the last one, as a later sprm +/// overrides an earlier one when the formatting is applied (a font before an opcode of unknown +/// length stands). +fn last_font(grpprl: &[u8]) -> Option { + sprms(grpprl).filter(|(op, _)| *op == chp::FONT).filter_map(|(_, v)| Some(u16::from_le_bytes([*v.first()?, *v.get(1)?]))).last() +} + +#[cfg(test)] +mod tests { + #[test] + fn a_page_named_by_many_bin_entries_is_read_once() { + // A hostile bin table: a million entries all naming page 7, and one naming page 3. + let n = 1_000_001usize; + let mut plc = vec![0u8; 4 * (n + 1)]; + for i in 0..n { + plc.extend_from_slice(&(if i == 5 { 3u16 } else { 7u16 }).to_le_bytes()); + } + assert_eq!(super::distinct_pages(&plc, n), [3, 7]); + } + + #[test] + fn bin_entries_past_the_cap_are_not_read() { + let n = super::MAX_BINS + 1; + let mut plc = vec![0u8; 4 * (n + 1)]; + for i in 0..n { + plc.extend_from_slice(&(if i == n - 1 { 9u16 } else { 7u16 }).to_le_bytes()); + } + assert_eq!(super::distinct_pages(&plc, n), [7]); + } +}