mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
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:
7 files changed
+1937
-29
No files matched your search
@@ -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}")));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: ¶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<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
@@ -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}'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())])]));
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user