Merge pull request #1351 from storytold/fix/reland-word6-doc-reader

craft-doc-binary: re-land Word 6.0/95 reader on main
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-14 13:28:47 -05:00
commit 74509ff055
7 files changed
+1937 -29

No files matched your search

+3 -1
View File
@@ -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}")));
+54 -5
View File
@@ -29,6 +29,19 @@ pub(crate) struct Bins {
sorted: bool,
/// Page number (× 512 = byte offset in the WordDocument stream).
pns: Vec<u32>,
/// 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<Vec<TranslatedRun>>,
}
/// 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<u8>,
}
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<TranslatedRun>) -> Bins {
runs.sort_by_key(|r| r.lo);
let mut kept: Vec<TranslatedRun> = 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.
+100 -1
View File
@@ -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<Document, DocbinError> {
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: &paragraphs,
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<u8> = 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<String>,
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<u8> {
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) {
+161 -17
View File
@@ -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<u16>,
}
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<u32> {
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<CodePageRun>,
}
/// The parsed piece table plus the grpprls of the Prcs that Prm1 references.
@@ -23,11 +74,58 @@ pub(crate) struct PieceTable {
cps: Vec<u32>,
pieces: Vec<Pcd>,
prc_grpprls: Vec<Vec<u8>>,
/// 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<CodePageRun>,
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<PieceTable, DocbinError> {
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<u8>]) -> Option<u16>,
translate_prc: impl Fn(&[u8]) -> Vec<u8>,
) -> Result<PieceTable, DocbinError> {
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<bool>) -> Result<PieceTable, DocbinError> {
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<u16> {
@@ -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<u32> {
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}'
}
}
+30 -5
View File
@@ -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<u8>, 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
+747
View File
@@ -0,0 +1,747 @@
//! 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<Std>]) -> Vec<u8> {
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<u8>, 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<Std<'a>>],
/// The main text's length in characters, when it isn't the stored bytes' (16-bit text).
ccp: Option<u32>,
}
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<u8>, 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<u8> = 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<u8> {
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<u8> {
cfb_file(&[("WordDocument", &word_stream(d))])
}
fn texts(d: &Doc6) -> Vec<String> {
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 Да"]);
}
/// Text in an East Asian code page (a Japanese font, charset 128: Shift-JIS) is refused, not
/// read a byte at a time into replacement characters (review of wordcraft#6345).
#[test]
fn east_asian_text_is_refused_not_lost() {
let fonts: &[(u8, &str)] = &[(0, "Times New Roman"), (128, "MS Mincho")];
let runs: &[(u32, u32, &[u8])] = &[(0, 2, &[0x5D, 0x01, 0x00]), (2, 3, &[])];
let d = Doc6 { text: b"\x93\xfa\r", fonts, runs, ..Default::default() };
assert!(matches!(read(&word6_file(&d)), Err(DocbinError::NotWord(e)) if e.contains("East Asian")));
// The same file in a Western font still opens.
let d = Doc6 { text: b"\x93\xfa\r", fonts, runs: &[], ..Default::default() };
assert_eq!(texts(&d).len(), 1);
}
#[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 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<u8> = 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<u8> {
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::<Vec<_>>(), ["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<u8> {
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<Vec<String>> {
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::<Vec<_>>().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<Std>] = &[
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<String> {
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<u8> {
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::<u8>()), 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())])]));
}
}
+842
View File
@@ -0,0 +1,842 @@
//! 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 u16 header size (14),
//! then a header of that many bytes 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<Fib6, DocbinError> {
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<String>,
/// The styles by index, their formatting translated to Word 97 grpprls.
pub(crate) styles: Vec<RawStyle>,
}
/// 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<Content, DocbinError> {
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() };
// shortcut: East Asian (lead and trail byte) text is refused, as all Word 6.0/95 files were
// before; read it a character at a time when the piece walk can decode byte pairs.
let east_asian = || DocbinError::NotWord("Word 6.0/95 text in an East Asian code page is not read yet".into());
if craft_codepage::is_multibyte_code_page(default_code_page) || code_pages.iter().any(|r| craft_codepage::is_multibyte_code_page(r.code_page)) {
return Err(east_asian());
}
let multibyte_piece = std::cell::Cell::new(false);
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<u8>]| -> Option<u16> {
if !windows || prm & 1 == 0 {
return None;
}
let cp = last_font(prcs.get(usize::from(prm >> 1))?).and_then(|f| font_code_page(&fonts, f));
if cp.is_some_and(craft_codepage::is_multibyte_code_page) {
multibyte_piece.set(true);
}
cp
};
// 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.
let table = PieceTable::parse_word6(word, fc, lcb, text, piece_code_page, translate_prc)?;
if multibyte_piece.get() {
return Err(east_asian());
}
table
} 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<u16> {
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<u16>,
lid: Option<u16>,
}
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<u16>, Option<u16>) {
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<RawStyle>, Vec<StyleFacts>) {
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<Font> {
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<usize> {
let mut pns: Vec<usize> = (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<Run<'_>> {
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<Item = (u8, &[u8])> {
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<u8>, 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<u8> {
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<u8> {
[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<u8> {
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<u8>, 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<usize> = 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<u16>, run_lid: Option<u16>) -> 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<u16> {
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<CodePageRun> {
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<usize> {
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<u32> = chp_sorted.iter().chain(pap_sorted.iter()).flat_map(|&(lo, hi, _)| [lo, hi]).collect();
cuts.sort_unstable();
cuts.dedup();
let mut out: Vec<CodePageRun> = 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<u16> {
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]);
}
}