Files
wordcraft/crates/docbin/src/tests.rs
T

2333 lines
100 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Synthetic `.doc` fixtures, written for these tests from the [MS-DOC] specification.
//! Files produced by Word are never committed; every fixture here is built in code.
use std::io::Write;
use proptest::prelude::*;
use super::read;
mod drawing;
mod encrypted;
mod metadata;
/// Offset in the WordDocument stream where test text pieces are placed (past the FIB).
const TEXT_FC: u32 = 0x600;
/// Offset in the Table stream where the test Clx is placed.
const CLX_AT: u32 = 0x40;
/// Build an in-memory compound file holding the given root streams.
pub(crate) fn cfb_file(streams: &[(&str, &[u8])]) -> Vec<u8> {
let mut comp = cfb::CompoundFile::create(std::io::Cursor::new(Vec::new())).expect("cfb create");
for (name, data) in streams {
let mut s = comp.create_stream(format!("/{name}")).expect("cfb stream");
s.write_all(data).expect("cfb write");
}
comp.into_inner().into_inner()
}
/// Knobs of the minimal FIB these tests build (Word 97 shape, `cbRgFcLcb` = 0x5D).
pub(crate) struct FibSpec {
pub(crate) nfib: u16,
pub(crate) flags: u16,
pub(crate) ccp_text: u32,
pub(crate) ccp_ftn: u32,
pub(crate) ccp_hdd: u32,
pub(crate) ccp_edn: u32,
/// (pair index, fc, lcb) entries to poke into the RgFcLcb blob.
pub(crate) pairs: Vec<(usize, u32, u32)>,
}
impl Default for FibSpec {
fn default() -> Self {
FibSpec { nfib: 0x00C1, flags: 0x1000, ccp_text: 0, ccp_ftn: 0, ccp_hdd: 0, ccp_edn: 0, pairs: Vec::new() }
}
}
/// A minimal Word 97 FIB: FibBase + csw/rgw + cslw/rglw + 0x5D fc/lcb pairs + cswNew = 0.
pub(crate) fn fib_bytes(spec: &FibSpec) -> Vec<u8> {
let mut v = vec![0u8; 0x9A + 0x5D * 8 + 2];
let put16 = |v: &mut Vec<u8>, at: usize, x: u16| {
v[at..at + 2].copy_from_slice(&x.to_le_bytes());
};
let put32 = |v: &mut Vec<u8>, at: usize, x: u32| {
v[at..at + 4].copy_from_slice(&x.to_le_bytes());
};
put16(&mut v, 0x00, 0xA5EC);
put16(&mut v, 0x02, spec.nfib);
put16(&mut v, 0x0A, spec.flags);
put16(&mut v, 0x0C, 0x00BF);
put16(&mut v, 0x20, 0x000E);
put16(&mut v, 0x3E, 0x0016);
put32(&mut v, 0x40, 0x1000); // cbMac
put32(&mut v, 0x4C, spec.ccp_text); // rglw[3] = ccpText
put32(&mut v, 0x50, spec.ccp_ftn); // rglw[4] = ccpFtn
put32(&mut v, 0x54, spec.ccp_hdd); // rglw[5] = ccpHdd
put32(&mut v, 0x60, spec.ccp_edn); // rglw[8] = ccpEdn
put16(&mut v, 0x98, 0x005D);
for (i, fc, lcb) in &spec.pairs {
put32(&mut v, 0x9A + i * 8, *fc);
put32(&mut v, 0x9A + i * 8 + 4, *lcb);
}
v
}
/// One test piece: text stored either uncompressed (UTF-16LE) or compressed (Windows-1252).
#[derive(Clone)]
pub(crate) struct Piece {
pub(crate) compressed: bool,
pub(crate) text: String,
}
/// Build a PlcPcd (aCp[n+1] + aPcd[n]) for pieces stored at consecutive offsets from
/// `first_fc` (compressed pieces start at `fc = 2 × byte offset` per FcCompressed).
fn plcpcd(pieces: &[Piece], first_fc: u32) -> Vec<u8> {
let mut v = Vec::new();
let mut cp = 0u32;
v.extend_from_slice(&cp.to_le_bytes());
for p in pieces {
cp += p.text.chars().count() as u32;
v.extend_from_slice(&cp.to_le_bytes());
}
let mut off = 0u32;
for p in pieces {
// Bytes this piece occupies in the text region: one per character compressed, two
// uncompressed (count chars, not UTF-8 bytes).
let len = if p.compressed { p.text.chars().count() } else { p.text.chars().count() * 2 } as u32;
let fc = if p.compressed { 2 * (first_fc + off) } else { first_fc + off };
let fc_raw = if p.compressed { fc | 0x4000_0000 } else { fc };
v.extend_from_slice(&[0, 0]); // fNoParaLast & friends
v.extend_from_slice(&fc_raw.to_le_bytes());
v.extend_from_slice(&0u16.to_le_bytes()); // prm
off += len;
}
v
}
fn piece_bytes(p: &Piece) -> Vec<u8> {
if p.compressed {
// Tests only use cp1252-encodable chars in compressed pieces.
p.text.chars().map(|c| c as u8).collect()
} else {
let units: Vec<u16> = p.text.chars().map(|c| c as u16).collect();
units.iter().flat_map(|u| u.to_le_bytes()).collect()
}
}
/// A document whose main text is the given pieces (extra subdocument text may follow).
fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec<u8> {
let all: Vec<Piece> = main.iter().chain(extra.iter()).cloned().collect();
let ccp_text: u32 = main.iter().map(|p| p.text.chars().count() as u32).sum();
let mut word = vec![0u8; TEXT_FC as usize];
for p in &all {
word.extend_from_slice(&piece_bytes(p));
}
let plc = plcpcd(&all, TEXT_FC);
let mut clx = vec![0x02u8];
clx.extend_from_slice(&(plc.len() as u32).to_le_bytes());
clx.extend_from_slice(&plc);
let mut table = vec![0u8; CLX_AT as usize];
table.extend_from_slice(&clx);
let spec = FibSpec { ccp_text, ccp_hdd, pairs: vec![(33, CLX_AT, clx.len() as u32)], ..Default::default() };
word[..fib_bytes(&spec).len()].copy_from_slice(&fib_bytes(&spec));
cfb_file(&[("WordDocument", &word), ("0Table", &table)])
}
fn body_texts(doc: &wordcraft_doc::Document) -> Vec<String> {
doc.body.iter().filter_map(|b| b.as_para().map(|p| p.text.clone())).collect()
}
#[test]
fn not_a_compound_file() {
assert!(matches!(read(&[0u8; 64]), Err(super::DocbinError::Container(_))));
}
#[test]
fn cfb_without_word_stream() {
let f = cfb_file(&[("0Table", &[0u8; 16])]);
assert!(matches!(read(&f), Err(super::DocbinError::NotWord(_))));
}
#[test]
fn word_stream_too_short() {
let f = cfb_file(&[("WordDocument", &[0u8; 16])]);
assert!(matches!(read(&f), Err(super::DocbinError::NotWord(_))));
}
#[test]
fn wrong_magic_rejected() {
let mut fib = fib_bytes(&FibSpec::default());
fib[0] = 0x34;
fib[1] = 0x12;
let f = cfb_file(&[("WordDocument", &fib)]);
assert!(matches!(read(&f), Err(super::DocbinError::NotWord(m)) if m.contains("magic")));
}
#[test]
fn word_6_95_rejected() {
let mut fib = fib_bytes(&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")));
}
#[test]
fn encrypted_asks_for_a_password() {
let spec = FibSpec { flags: 0x1100, ..Default::default() };
let f = cfb_file(&[("WordDocument", &fib_bytes(&spec))]);
assert!(matches!(read(&f), Err(super::DocbinError::PasswordRequired)));
}
#[test]
fn wrong_csw_rejected() {
let mut fib = fib_bytes(&FibSpec::default());
fib[0x20] = 0x0D;
fib[0x21] = 0x00;
let f = cfb_file(&[("WordDocument", &fib)]);
assert!(matches!(read(&f), Err(super::DocbinError::NotWord(m)) if m.contains("csw")));
}
#[test]
fn missing_clx_rejected() {
let spec = FibSpec { ccp_text: 3, ..Default::default() };
let f = cfb_file(&[("WordDocument", &fib_bytes(&spec)), ("0Table", &[0u8; 16])]);
assert!(matches!(read(&f), Err(super::DocbinError::Malformed(m)) if m.contains("piece table")));
}
#[test]
fn unicode_text_extracted() {
let f = text_doc(&[Piece { compressed: false, text: "Héllo Wörld\rSecond€\r".into() }], &[], 0);
let doc = read(&f).expect("opens");
assert_eq!(body_texts(&doc), ["Héllo Wörld", "Second€"]);
}
#[test]
fn compressed_cp1252_text_extracted() {
let f = text_doc(&[Piece { compressed: true, text: "caf\u{E9} 100\u{BD}\r".into() }], &[], 0);
let doc = read(&f).expect("opens");
assert_eq!(body_texts(&doc), ["café 100½"]);
}
#[test]
fn multiple_pieces_and_encodings() {
let f = text_doc(
&[
Piece { compressed: false, text: "plain ".into() },
Piece { compressed: true, text: "mixed\u{E9} ".into() },
Piece { compressed: false, text: "end\r".into() },
],
&[],
0,
);
let doc = read(&f).expect("opens");
assert_eq!(body_texts(&doc), ["plain mixedé end"]);
}
#[test]
fn special_characters_mapped() {
let raw = "a\tb\u{B}c\u{C}d\u{E}e\u{1E}f\u{1F}g\r\u{13}field\u{14}result\u{15}\r";
let f = text_doc(&[Piece { compressed: false, text: raw.into() }], &[], 0);
let doc = read(&f).expect("opens");
// The field collapses to one anchor object; its instruction is hidden and its cached
// result kept on the object.
assert_eq!(body_texts(&doc), ["a\tb\nc\u{C}d\u{E}e\u{2011}f\u{AD}g", "\u{FFFC}"]);
let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("field para");
assert!(
matches!(p1.objects.first(), Some(wordcraft_doc::para::InlineObject::Field { instr, result, .. }) if instr == "field" && result == "result"),
"objects: {:?}",
p1.objects
);
}
#[test]
fn empty_paragraphs_kept() {
let f = text_doc(&[Piece { compressed: false, text: "a\r\rb\r".into() }], &[], 0);
let doc = read(&f).expect("opens");
assert_eq!(body_texts(&doc), ["a", "", "b"]);
}
#[test]
fn empty_document_gets_a_paragraph() {
let f = text_doc(&[Piece { compressed: false, text: "".into() }], &[], 0);
let doc = read(&f).expect("opens");
assert_eq!(doc.body.len(), 1);
}
#[test]
fn header_text_stays_out_of_body() {
let f = text_doc(
&[Piece { compressed: false, text: "body\r".into() }],
&[Piece { compressed: false, text: "header\u{D}".into() }],
8, // ccpHdd covers the extra piece
);
let doc = read(&f).expect("opens");
assert_eq!(body_texts(&doc), ["body"]);
}
#[test]
fn clx_outside_table_rejected() {
let spec = FibSpec { pairs: vec![(33, 0x1000, 0x40)], ..Default::default() };
let f = cfb_file(&[("WordDocument", &fib_bytes(&spec)), ("0Table", &[0u8; 16])]);
assert!(matches!(read(&f), Err(super::DocbinError::Malformed(_))));
}
#[test]
fn garbage_plcpcd_rejected() {
let mut table = vec![0u8; CLX_AT as usize];
table.extend_from_slice(&[0x02, 0x05, 0x00, 0x00, 0x00, 1, 2, 3, 4]); // lcb 5: not ≡ 1 mod 12
let spec = FibSpec { pairs: vec![(33, CLX_AT, 9)], ..Default::default() };
let f = cfb_file(&[("WordDocument", &fib_bytes(&spec)), ("0Table", &table)]);
assert!(matches!(read(&f), Err(super::DocbinError::Malformed(m)) if m.contains("PlcPcd")));
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn junk_bytes_never_panic(b in proptest::collection::vec(any::<u8>(), 0..4096)) {
let _ = read(&b);
}
#[test]
fn truncated_file_never_panics(n in 0..4096usize) {
let f = text_doc(
&[Piece { compressed: false, text: "Hello\rworld\r\u{E9}x\r".into() }],
&[],
0,
);
let n = n.min(f.len());
let _ = read(&f[..n]);
}
#[test]
fn flipped_bytes_never_panic(pos in 0..4096usize, bit in 0..8usize) {
let mut f = text_doc(
&[Piece { compressed: false, text: "Hello\rworld\r\u{E9}x\r".into() }],
&[],
0,
);
let at = pos.min(f.len() - 1);
if let Some(b) = f.get_mut(at) {
*b ^= 1 << (bit & 7);
}
let _ = read(&f);
}
#[test]
fn structured_truncated_never_panics(n in 0..8192usize) {
let f = structured_doc();
let n = n.min(f.len());
let _ = read(&f[..n]);
}
#[test]
fn structured_flipped_never_panics(pos in 0..8192usize, bit in 0..8usize) {
let mut f = structured_doc();
let at = pos.min(f.len() - 1);
if let Some(b) = f.get_mut(at) {
*b ^= 1 << (bit & 7);
}
let _ = read(&f);
}
#[test]
fn extras_truncated_never_panics(n in 0..8192usize) {
let f = extras_doc();
let n = n.min(f.len());
let _ = read(&f[..n]);
}
#[test]
fn extras_flipped_never_panics(pos in 0..8192usize, bit in 0..8usize) {
let mut f = extras_doc();
let at = pos.min(f.len() - 1);
if let Some(b) = f.get_mut(at) {
*b ^= 1 << (bit & 7);
}
let _ = read(&f);
}
}
// ---------------------------------------------------------------------------------------------
// D2: formatting — fixtures with a stylesheet, font table and FKP pages.
/// Twips→FC units inside a piece: 2 per character.
fn fc_of(cp: u32) -> u32 {
TEXT_FC + 2 * cp
}
/// A grpprl of Prls given as (opcode, operand) pairs.
fn grpprl(prls: &[(u16, &[u8])]) -> Vec<u8> {
let mut v = Vec::new();
for (op, operand) in prls {
v.extend_from_slice(&op.to_le_bytes());
v.extend_from_slice(operand);
}
v
}
/// One STD record (Word 97 shape, cbSTDBaseInFile = 10) with no next-paragraph style.
/// `istd_base` is zero-based; 0x0FFF means no parent.
fn std_bytes(stk: u8, istd_base: u16, name: &str, papx: &[u8], chpx: &[u8]) -> Vec<u8> {
std_bytes_next(stk, istd_base, 0x0FFF, name, papx, chpx)
}
/// One STD record with an explicit next-paragraph style ([MS-DOC] §2.9.260 StdfBase).
fn std_bytes_next(stk: u8, istd_base: u16, istd_next: u16, name: &str, papx: &[u8], chpx: &[u8]) -> Vec<u8> {
let cupx = if stk == 1 { 2 } else { 1 };
let mut base = vec![0u8; 10];
// sti 0xFFF (user-defined) | flags
base[0] = 0xFF;
base[1] = 0x0F;
// stk(4) | istdBase(12)
let w1 = (stk as u16) | (istd_base << 4);
base[2..4].copy_from_slice(&w1.to_le_bytes());
// cupx(4) | istdNext(12)
let w2 = (cupx as u16) | (istd_next << 4);
base[4..6].copy_from_slice(&w2.to_le_bytes());
// bchUpe filled below; grfstd = 0
let mut v = base;
// xstzName: cch + UTF-16 + terminator
let units: Vec<u16> = name.chars().map(|c| c as u16).collect();
v.extend_from_slice(&(units.len() as u16).to_le_bytes());
for u in &units {
v.extend_from_slice(&u.to_le_bytes());
}
v.extend_from_slice(&0u16.to_le_bytes());
if stk == 1 {
// LPUpxPapx: cbUpx + UpxPapx (istd u16 + grpprl), padded to even.
let papx_upx = [0u16.to_le_bytes().as_slice(), papx].concat();
v.extend_from_slice(&(papx_upx.len() as u16).to_le_bytes());
v.extend_from_slice(&papx_upx);
if papx_upx.len() % 2 == 1 {
v.push(0);
}
}
// LPUpxChpx: cbUpx + raw grpprl.
v.extend_from_slice(&(chpx.len() as u16).to_le_bytes());
v.extend_from_slice(chpx);
if chpx.len() % 2 == 1 {
v.push(0);
}
let cb = v.len() as u16;
let mut out = cb.to_le_bytes().to_vec();
out.extend_from_slice(&v);
// bchUpe (offset 6 in the Stdf) equals cbStd.
out[6 + 2] = cb as u8;
out[7 + 2] = (cb >> 8) as u8;
out
}
/// An STSH with the given styles.
fn stsh_bytes(styles: &[Vec<u8>]) -> Vec<u8> {
let stshi = [(styles.len() as u16).to_le_bytes(), 10u16.to_le_bytes()].concat(); // cstd, cbSTDBaseInFile
let mut v = (stshi.len() as u16).to_le_bytes().to_vec();
v.extend_from_slice(&stshi);
for s in styles {
v.extend_from_slice(s);
}
v
}
/// A non-extended SttbfFfn of font names.
fn ffn_bytes(names: &[&str]) -> Vec<u8> {
let fonts: Vec<(&str, Option<&str>)> = names.iter().map(|n| (*n, None)).collect();
ffn_bytes_alt(&fonts)
}
/// A non-extended SttbfFfn ([MS-DOC] §2.9.286): cData, cbExtra = 0, then per font a one-byte
/// cchData and an FFN (39-byte header, NUL-terminated UTF-16 name, optional alternate name
/// whose offset in characters is `ixchSzAlt`).
fn ffn_bytes_alt(fonts: &[(&str, Option<&str>)]) -> Vec<u8> {
let utf16z = |s: &str| -> Vec<u8> { s.chars().chain(['\0']).flat_map(|c| (c as u16).to_le_bytes()).collect() };
let mut v = (fonts.len() as u16).to_le_bytes().to_vec();
v.extend_from_slice(&0u16.to_le_bytes()); // cbExtra
for (name, alt) in fonts {
let mut ffn = vec![0u8; 39];
let primary = utf16z(name);
if alt.is_some() {
ffn[4] = (primary.len() / 2) as u8; // ixchSzAlt
}
ffn.extend_from_slice(&primary);
if let Some(a) = alt {
ffn.extend_from_slice(&utf16z(a));
}
v.push(ffn.len() as u8); // cchData
v.extend_from_slice(&ffn);
}
v
}
/// A ChpxFkp page mapping run boundaries (in CPs, ends exclusive) to grpprls.
fn chpx_fkp(runs: &[(u32, Vec<u8>)], end_cp: u32) -> Vec<u8> {
let mut page = vec![0u8; 512];
let n = runs.len();
for (i, r) in runs.iter().enumerate() {
page[i * 4..i * 4 + 4].copy_from_slice(&fc_of(r.0).to_le_bytes());
}
page[n * 4..n * 4 + 4].copy_from_slice(&fc_of(end_cp).to_le_bytes());
let rgb_at = (n + 1) * 4;
let mut data_at = rgb_at + n + 1;
for (i, r) in runs.iter().enumerate() {
if r.1.is_empty() {
page[rgb_at + i] = 0;
continue;
}
// Offsets are half-bytes; align the data to even offsets.
data_at += data_at & 1;
let half = (data_at / 2) as u8;
page[rgb_at + i] = half;
page[data_at] = r.1.len() as u8;
page[data_at + 1..data_at + 1 + r.1.len()].copy_from_slice(&r.1);
data_at += 1 + r.1.len();
}
page[511] = n as u8;
page
}
/// A PapxFkp page mapping paragraph-mark FCs (as "FC after the mark") to PAPX data
/// (istd + grpprl).
fn papx_fkp(entries: &[(u32, u16, Vec<u8>)]) -> Vec<u8> {
let mut page = vec![0u8; 512];
let n = entries.len();
for (i, e) in entries.iter().enumerate() {
page[i * 4..i * 4 + 4].copy_from_slice(&fc_of(e.0).to_le_bytes());
}
page[n * 4..n * 4 + 4].copy_from_slice(&fc_of(0x3FF0_0000).to_le_bytes()); // far end
let rgbx_at = (n + 1) * 4;
let mut data_at = rgbx_at + n * 13;
for (i, e) in entries.iter().enumerate() {
data_at += data_at & 1;
let half = (data_at / 2) as u8;
page[rgbx_at + i * 13] = half;
let grp = [e.1.to_le_bytes().as_slice(), e.2.as_slice()].concat();
let cb = ((grp.len() + 2) / 2) as u8;
page[data_at] = cb;
page[data_at + 1..data_at + 1 + grp.len()].copy_from_slice(&grp);
data_at += 1 + grp.len() + ((1 + grp.len()) & 1);
}
page[511] = n as u8;
page
}
/// A document with one uncompressed text piece, a stylesheet, a font table and formatting
/// pages, assembled the way Word lays out the streams.
struct RichSpec {
text: &'static str,
/// Styles in istd order, (stk, base, name, papx, chpx).
styles: Vec<(u8, u16, &'static str, Vec<u8>, Vec<u8>)>,
fonts: Vec<&'static str>,
/// Character runs (start CP, grpprl); text length ends the last.
chpx_runs: Vec<(u32, Vec<u8>)>,
/// Paragraph entries (mark CP, istd, grpprl).
papx_entries: Vec<(u32, u16, Vec<u8>)>,
}
/// Optional extras of a [`rich_doc`]: Prcs and the piece's Prm, alternate font names
/// (parallel to `RichSpec::fonts`) and next-paragraph styles (parallel to `RichSpec::styles`).
#[derive(Default)]
struct RichExtra {
prcs: Vec<Vec<u8>>,
prm: u16,
alt_fonts: Vec<Option<&'static str>>,
style_next: Vec<u16>,
}
fn rich_doc(spec: &RichSpec) -> Vec<u8> {
rich_doc_with(spec, &RichExtra::default())
}
fn rich_doc_with(spec: &RichSpec, extra: &RichExtra) -> Vec<u8> {
let text: String = spec.text.into();
let ccp = text.chars().count() as u32;
let mut plc = plcpcd(&[Piece { compressed: false, text: text.clone() }], TEXT_FC);
// The single Pcd is last: its Prm is the final two bytes.
let n = plc.len();
plc[n - 2..].copy_from_slice(&extra.prm.to_le_bytes());
let mut clx = Vec::new();
for g in &extra.prcs {
clx.push(0x01u8);
clx.extend_from_slice(&(g.len() as u16).to_le_bytes());
clx.extend_from_slice(g);
}
clx.push(0x02u8);
clx.extend_from_slice(&(plc.len() as u32).to_le_bytes());
clx.extend_from_slice(&plc);
// WordDocument: FIB | padding | text | FKP pages (512-aligned).
let chpx_page_at = (TEXT_FC as usize + text.len() * 2).div_ceil(512) * 512;
let papx_page_at = chpx_page_at + 512;
let mut word = vec![0u8; TEXT_FC as usize];
word.extend_from_slice(&piece_bytes(&Piece { compressed: false, text: text.clone() }));
word.resize(chpx_page_at, 0);
word.extend_from_slice(&chpx_fkp(&spec.chpx_runs, ccp));
word.extend_from_slice(&papx_fkp(&spec.papx_entries));
// Table: STSH | FFN | Clx | bin tables.
let styles: Vec<Vec<u8>> = spec
.styles
.iter()
.enumerate()
.map(|(i, s)| std_bytes_next(s.0, s.1, extra.style_next.get(i).copied().unwrap_or(0x0FFF), s.2, &s.3, &s.4))
.collect();
let stsh = stsh_bytes(&styles);
let fonts: Vec<(&str, Option<&str>)> = spec.fonts.iter().enumerate().map(|(i, n)| (*n, extra.alt_fonts.get(i).copied().flatten())).collect();
let ffn = ffn_bytes_alt(&fonts);
let clx_at = 0x40;
let bins_at = clx_at + clx.len();
let plcf_chpx =
[0u32.to_le_bytes().as_slice(), fc_of(0x3FF0_0000).to_le_bytes().as_slice(), ((chpx_page_at / 512) as u32).to_le_bytes().as_slice()].concat();
let plcf_papx =
[0u32.to_le_bytes().as_slice(), fc_of(0x3FF0_0000).to_le_bytes().as_slice(), ((papx_page_at / 512) as u32).to_le_bytes().as_slice()].concat();
let mut table = vec![0u8; clx_at];
table.extend_from_slice(&clx);
let chpx_bins_at = table.len();
table.extend_from_slice(&plcf_chpx);
let papx_bins_at = table.len();
table.extend_from_slice(&plcf_papx);
table.extend(std::iter::repeat_n(0, stsh.len() + ffn.len()));
let stsh_at = table.len() - stsh.len() - ffn.len();
let ffn_at = stsh_at + stsh.len();
table[stsh_at..stsh_at + stsh.len()].copy_from_slice(&stsh);
table[ffn_at..ffn_at + ffn.len()].copy_from_slice(&ffn);
let _ = bins_at;
let spec_fib = FibSpec {
ccp_text: ccp,
pairs: vec![
(33, clx_at as u32, clx.len() as u32),
(1, stsh_at as u32, stsh.len() as u32),
(15, ffn_at as u32, ffn.len() as u32),
(12, chpx_bins_at as u32, plcf_chpx.len() as u32),
(13, papx_bins_at as u32, plcf_papx.len() as u32),
],
..Default::default()
};
let fib = fib_bytes(&spec_fib);
word[..fib.len()].copy_from_slice(&fib);
cfb_file(&[("WordDocument", &word), ("0Table", &table)])
}
fn para0(doc: &wordcraft_doc::Document) -> wordcraft_doc::Paragraph {
doc.body.first().and_then(|b| b.as_para().cloned()).unwrap_or_default()
}
#[test]
fn character_formatting_applied() {
let spec = RichSpec {
text: "plain bold red\r",
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
fonts: vec!["Times New Roman", "Arial"],
chpx_runs: vec![
(0, Vec::new()),
(6, grpprl(&[(0x0835, &[0x01])])), // bold
(11, grpprl(&[(0x0835, &[0x01]), (0x2A42, &[0x06])])), // bold + red (ico 6)
],
papx_entries: vec![(0, 0, Vec::new())],
};
let doc = read(&rich_doc(&spec)).expect("opens");
let p = para0(&doc);
assert_eq!(p.text, "plain bold red");
// runs: plain(0..6) bold(6..11) bold+red(11..16)
assert!(p.runs.get(1).is_some_and(|r| r.props.bold == Some(true)), "run 1 bold: {:?}", p.runs);
assert!(p.runs.get(2).is_some_and(|r| r.props.bold == Some(true)), "run 2 bold+red: {:?}", p.runs);
assert!(p.runs.get(2).is_some_and(|r| matches!(r.props.color, Some(wordcraft_doc::props::TextColor::Rgb(_)))), "run 2 red");
assert!(p.runs.first().is_some_and(|r| r.props.bold.is_none()));
}
#[test]
fn font_and_size_from_style_and_direct() {
let spec = RichSpec {
text: "Hello\r",
styles: vec![
(1, 0x0FFF, "Normal", Vec::new(), grpprl(&[(0x4A43, &24u16.to_le_bytes())])), // 12pt
(2, 0x0FFF, "Emphasis", Vec::new(), grpprl(&[(0x4A4F, &1u16.to_le_bytes())])), // Arial
],
fonts: vec!["Times New Roman", "Arial"],
chpx_runs: vec![(0, grpprl(&[(0x4A4F, &1u16.to_le_bytes())]))],
papx_entries: vec![(0, 0, Vec::new())],
};
let doc = read(&rich_doc(&spec)).expect("opens");
assert!(doc.styles.styles.iter().any(|s| s.name == "Normal" && s.chr.size == Some(12.0)), "Normal 12pt");
let p = para0(&doc);
assert!(p.runs.first().is_some_and(|r| r.props.font.as_deref() == Some("Arial")), "run font: {:?}", p.runs);
}
#[test]
fn paragraph_props_and_heading_style() {
let spec = RichSpec {
text: "centered\rplain\r",
styles: vec![
(1, 0x0FFF, "Normal", Vec::new(), Vec::new()),
(1, 0, "heading 1", grpprl(&[(0x2461, &[0x01, 0x00])]), Vec::new()), // centered
],
fonts: vec!["Times New Roman"],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 1, Vec::new()), (9, 0, grpprl(&[(0x845E, &720u16.to_le_bytes())]))],
};
let doc = read(&rich_doc(&spec)).expect("opens");
let heading = doc.styles.styles.iter().find(|s| s.name == "Heading 1").expect("style");
assert_eq!(heading.para.align, Some(wordcraft_doc::props::Align::Center));
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(p0.props.style.as_deref(), Some("Heading 1"));
assert_eq!(heading.para.outline_level, Some(0));
let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("para");
assert_eq!(p1.props.indent_left, Some(36.0)); // 720 twips = 36pt
assert_eq!(p1.props.style.as_deref(), Some("Normal"));
}
/// #534: Chinese theses set a first-line indent of two characters (`sprmPDxaLeft1`, whose operand
/// is two bytes, plus `sprmPDxcLeft1` in hundredths of a character), and exact line spacing as a
/// negative LSPD `dyaLine`. Both were lost: the indent read as four bytes, and exact 20pt as
/// 799pt, a page per empty paragraph.
#[test]
fn first_line_indent_chars_and_exact_line_spacing() {
let lspd = |dya: i16, mult: u16| [dya.to_le_bytes(), mult.to_le_bytes()].concat();
let (exact, multiple) = (lspd(-400, 0), lspd(360, 1));
let spec = RichSpec {
text: "indented\rexact\rold\r",
styles: vec![(1, 0, "Normal", Vec::new(), Vec::new())],
fonts: vec!["Times New Roman"],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![
(0, 0, grpprl(&[(0x8460, &480i16.to_le_bytes()), (0x4457, &200i16.to_le_bytes()), (0x6412, &multiple)])),
(9, 0, grpprl(&[(0x6412, &exact)])),
(15, 0, grpprl(&[(0x8411, &(-360i16).to_le_bytes())])),
],
};
let doc = read(&rich_doc(&spec)).expect("opens");
let para = |i: usize| doc.body.get(i).and_then(|b| b.as_para()).expect("para").props.clone();
let p0 = para(0);
assert_eq!((p0.indent_first, p0.indent_first_chars), (Some(24.0), Some(2.0)));
assert_eq!(p0.line_spacing, Some(wordcraft_doc::props::LineSpacing::Multiple(1.5)));
assert_eq!(para(1).line_spacing, Some(wordcraft_doc::props::LineSpacing::Exactly(20.0)));
assert_eq!(para(2).indent_first, Some(-18.0)); // sprmPDxaLeft1_80: a hanging indent
}
/// Local corpus (gitignored `plan/word/fixtures`): real files must open with text and styles.
#[test]
fn corpus_files_open() {
let Some(dir) = std::env::var_os("WORDCRAFT_DOCBIN_FIXTURES") else { return };
let mut checked = 0;
if let Ok(entries) = std::fs::read_dir(&dir) {
for e in entries.flatten() {
let p = e.path();
if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("doc")) {
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());
let text = doc.plain_text(wordcraft_doc::StoryRef::Body);
assert!(text.chars().count() > 3, "{}: no text", p.display());
checked += 1;
}
}
}
assert!(checked > 0, "no .doc files found in {}", dir.display());
}
// ---------------------------------------------------------------------------------------------
// D3: sections, headers/footers, tables.
/// RichSpec extended for the D3 fixtures.
struct StructSpec {
text: &'static str,
/// Extra pieces after the main text: header subdocument stories.
header_stories: Vec<&'static str>,
/// (end_cp, sepx grpprl) per section; the last one reaching the text end is the final one.
sections: Vec<(u32, Vec<u8>)>,
/// Character runs as in RichSpec.
chpx_runs: Vec<(u32, Vec<u8>)>,
/// Paragraph entries as in RichSpec, with the grpprl also carrying table sprms.
papx_entries: Vec<(u32, u16, Vec<u8>)>,
/// Raw `PlfLst` bytes (LSTF array + appended LVLs) for FIB pair 73.
plf_lst: Vec<u8>,
/// Raw `PlfLfo` bytes for FIB pair 74.
plf_lfo: Vec<u8>,
/// Footnote subdocument stories, placed right after the main text.
note_stories: Vec<&'static str>,
/// Endnote subdocument stories, placed after the header stories.
endnote_stories: Vec<&'static str>,
/// Extra raw Table-stream blobs with their FIB pair index.
extra_pairs: Vec<(usize, Vec<u8>)>,
/// The Data stream (pictures), written when non-empty.
data_stream: Vec<u8>,
}
fn struct_doc(spec: &StructSpec) -> Vec<u8> {
let text: String = spec.text.into();
let ccp = text.chars().count() as u32;
let mut all: Vec<Piece> = vec![Piece { compressed: false, text: text.clone() }];
let mut story_ends = Vec::new();
let mut ftn_len = 0u32;
for s in &spec.note_stories {
let t: String = (*s).into();
ftn_len += t.chars().count() as u32;
all.push(Piece { compressed: false, text: t });
}
let mut hdd_len = 0u32;
for s in &spec.header_stories {
let t: String = (*s).into();
hdd_len += t.chars().count() as u32;
story_ends.push(hdd_len);
all.push(Piece { compressed: false, text: t });
}
let mut edn_len = 0u32;
for s in &spec.endnote_stories {
let t: String = (*s).into();
edn_len += t.chars().count() as u32;
all.push(Piece { compressed: false, text: t });
}
let plc = plcpcd(&all, TEXT_FC);
let mut clx = vec![0x02u8];
clx.extend_from_slice(&(plc.len() as u32).to_le_bytes());
clx.extend_from_slice(&plc);
let chpx_page_at = (TEXT_FC as usize + text.len() * 2).div_ceil(512) * 512;
let papx_page_at = chpx_page_at + 512;
let mut word = vec![0u8; TEXT_FC as usize];
for piece in &all {
word.extend_from_slice(&piece_bytes(piece));
}
word.resize(chpx_page_at, 0);
word.extend_from_slice(&chpx_fkp(&spec.chpx_runs, ccp));
word.extend_from_slice(&papx_fkp(&spec.papx_entries));
// PlcfSed: aCP[n+1] + aSed[n] (fn 2, fcSepx 4, fnMpr 2, fcMpr 4), and each Sepx after the
// pages. Plcfhdd: separator stories first (6 empties) then one empty + one per story.
let mut sepxes = Vec::new();
let mut plcf_sed = Vec::new();
let mut seds = Vec::new();
let first_sepx = papx_page_at + 512;
let mut off = first_sepx;
plcf_sed.extend_from_slice(&0u32.to_le_bytes());
for (end, grpprl) in &spec.sections {
plcf_sed.extend_from_slice(&end.to_le_bytes());
seds.extend_from_slice(&[0, 0]); // fn
seds.extend_from_slice(&(off as u32).to_le_bytes());
seds.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // fnMpr + fcMpr
let mut sepx = (grpprl.len() as u16).to_le_bytes().to_vec();
sepx.extend_from_slice(grpprl);
sepxes.push(sepx);
off += grpprl.len() + 2;
}
plcf_sed.extend_from_slice(&seds);
// Plcfhdd: six empty separator stories, then the section's six slots: even hdr, odd hdr,
// even ftr, odd ftr, first hdr, first ftr — story 0 goes into the odd header slot (1).
let mut plcf_hdd: Vec<u8> = Vec::new();
let mut cp = 0u32;
plcf_hdd.extend_from_slice(&cp.to_le_bytes());
for _ in 0..6 {
plcf_hdd.extend_from_slice(&cp.to_le_bytes()); // separators: empty
}
// slot 0 (even header): empty
plcf_hdd.extend_from_slice(&cp.to_le_bytes());
// slot 1 (odd header): the story, when present
let has_story = !spec.header_stories.is_empty();
if has_story {
cp += spec.header_stories.iter().map(|s| s.chars().count() as u32).sum::<u32>();
}
plcf_hdd.extend_from_slice(&cp.to_le_bytes());
// remaining slots: empty
for _ in 0..4 {
plcf_hdd.extend_from_slice(&cp.to_le_bytes());
}
let clx_at = 0x40;
let mut table = vec![0u8; clx_at];
table.extend_from_slice(&clx);
let chpx_bins_at = table.len();
let plcf_chpx =
[0u32.to_le_bytes().as_slice(), fc_of(0x3FF0_0000).to_le_bytes().as_slice(), ((chpx_page_at / 512) as u32).to_le_bytes().as_slice()].concat();
table.extend_from_slice(&plcf_chpx);
let papx_bins_at = table.len();
let plcf_papx =
[0u32.to_le_bytes().as_slice(), fc_of(0x3FF0_0000).to_le_bytes().as_slice(), ((papx_page_at / 512) as u32).to_le_bytes().as_slice()].concat();
table.extend_from_slice(&plcf_papx);
let sed_at = table.len();
table.extend_from_slice(&plcf_sed);
let hdd_plc_at = table.len();
table.extend_from_slice(&plcf_hdd);
let stsh = stsh_bytes(&[std_bytes(1, 0x0FFF, "Normal", &Vec::new(), &Vec::new())]);
let stsh_at = table.len();
table.extend_from_slice(&stsh);
let ffn = ffn_bytes(&["Times New Roman"]);
let ffn_at = table.len();
table.extend_from_slice(&ffn);
let lst_at = table.len();
table.extend_from_slice(&spec.plf_lst);
let lfo_at = table.len();
table.extend_from_slice(&spec.plf_lfo);
let mut extra_at = Vec::new();
for (i, blob) in &spec.extra_pairs {
let at = table.len() as u32;
table.extend_from_slice(blob);
extra_at.push((*i, at, blob.len() as u32));
}
let mut pairs = vec![
(33, clx_at as u32, clx.len() as u32),
(1, stsh_at as u32, stsh.len() as u32),
(15, ffn_at as u32, ffn.len() as u32),
(12, chpx_bins_at as u32, plcf_chpx.len() as u32),
(13, papx_bins_at as u32, plcf_papx.len() as u32),
(6, sed_at as u32, plcf_sed.len() as u32),
(11, hdd_plc_at as u32, plcf_hdd.len() as u32),
];
if !spec.plf_lst.is_empty() {
pairs.push((73, lst_at as u32, spec.plf_lst.len() as u32));
}
if !spec.plf_lfo.is_empty() {
pairs.push((74, lfo_at as u32, spec.plf_lfo.len() as u32));
}
pairs.extend(extra_at);
let spec_fib = FibSpec { ccp_text: ccp, ccp_ftn: ftn_len, ccp_hdd: hdd_len, ccp_edn: edn_len, pairs, ..Default::default() };
let fib = fib_bytes(&spec_fib);
word[..fib.len()].copy_from_slice(&fib);
word.extend(std::iter::repeat_n(0, first_sepx - word.len()));
for sepx in sepxes {
word.extend_from_slice(&sepx);
}
if spec.data_stream.is_empty() {
cfb_file(&[("WordDocument", &word), ("0Table", &table)])
} else {
cfb_file(&[("WordDocument", &word), ("0Table", &table), ("Data", &spec.data_stream)])
}
}
#[test]
fn section_props_parsed() {
let spec = StructSpec {
text: "a\rb\r",
header_stories: vec![],
sections: vec![(2, grpprl(&[(0xB01F, &12240u16.to_le_bytes()), (0xB020, &15840u16.to_le_bytes()), (0xB021, &1440u16.to_le_bytes())]))],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, Vec::new()), (2, 0, Vec::new())],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
// The single section ends at the text end: it becomes the document's last section.
assert_eq!(doc.last_section.page_w, 612.0);
assert_eq!(doc.last_section.page_h, 792.0);
assert_eq!(doc.last_section.margin_left, 72.0);
}
#[test]
fn two_sections_box_the_first() {
let spec = StructSpec {
text: "first\rsecond\r",
header_stories: vec![],
sections: vec![(6, grpprl(&[(0x300A, &[0x01])])), (12, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, Vec::new()), (6, 0, Vec::new())],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let sec = p0.section.as_ref().expect("section on first paragraph");
assert!(sec.title_page);
assert!(!doc.last_section.title_page);
}
#[test]
fn header_story_becomes_part() {
let spec = StructSpec {
text: "body\r",
header_stories: vec!["Header text\r\r"],
sections: vec![(5, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, Vec::new())],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
let id = doc.last_section.headers.default.expect("odd header part");
let part = doc.parts.get(&id).expect("part");
assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Header text"))));
}
/// A `sprmTDefTable` Prl for `edges` twip boundaries and TC80s of 20 bytes per column,
/// built per the spec (cb counts the bytes after it, plus one).
fn t_def_table(edges: &[i16]) -> Vec<u8> {
let itc = edges.len() as u8 - 1;
let mut body = vec![itc];
for e in edges {
body.extend_from_slice(&e.to_le_bytes());
}
for _ in 0..itc {
body.extend_from_slice(&[0u8; 20]); // TC80: default cell
}
// cb = bytes after cb + 1.
let cb = (body.len() + 1) as u16;
let mut v = (0xD608u16).to_le_bytes().to_vec();
v.extend_from_slice(&cb.to_le_bytes());
v.extend_from_slice(&body);
v
}
#[test]
fn table_assembled_from_marks() {
// A 2×2 table: "a\x07 b\x07|ttp c\x07 d\x07|ttp" then a normal paragraph.
let def_table = t_def_table(&[0, 2880, 5760]);
let text = "a\u{7}b\u{7}c\u{7}d\u{7}tail\r";
let in_tbl = grpprl(&[(0x2416, &[0x01])]);
let ttp: Vec<u8> = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]), def_table].concat();
let spec = StructSpec {
text,
header_stories: vec![],
sections: vec![(text.chars().count() as u32, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![
(0, 0, in_tbl.clone()), // "a" cell paragraph
(2, 0, ttp.clone()), // "b" cell paragraph, ends the row (TTP)
(4, 0, in_tbl), // "c" cell paragraph
(6, 0, ttp), // "d" cell paragraph, ends the row (TTP)
(8, 0, Vec::new()), // "tail" paragraph
],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");
assert_eq!(tbl.rows.len(), 2);
assert_eq!(tbl.rows[0].cells.len(), 2);
assert_eq!(tbl.grid.len(), 2);
assert_eq!(tbl.grid[0], 144.0); // 2880 twips = 144 pt
assert_eq!(tbl.total_width(), 288.0);
let cell_text = |r: usize, c: usize| tbl.rows[r].cells[c].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<String>();
assert_eq!(cell_text(0, 0), "a");
assert_eq!(cell_text(0, 1), "b");
assert_eq!(cell_text(1, 1), "d");
let last = doc.body.last().and_then(|b| b.as_para()).expect("tail para");
assert_eq!(last.text, "tail");
}
#[test]
fn table_merges_borders_shading() {
// Two rows: the first "a\x07 b\x07 c\x07" with cells 0-1 horizontally merged; the second
// "d\x07 e\x07" with table borders, a shaded first cell and a vertical-merge restart.
let def3 = t_def_table(&[0, 1920, 3840, 5760]);
let def2 = t_def_table(&[0, 2880, 5760]);
let borders = {
// sprmTTableBorders80: cb = 0x18, then 6 × Brc80 (width 4/8pt, single, ico 1, space 0).
let brc = [4u8, 1, 1, 0];
let mut op = vec![0x18u8];
for _ in 0..6 {
op.extend_from_slice(&brc);
}
let mut v = (0xD605u16).to_le_bytes().to_vec();
v.extend_from_slice(&op);
v
};
let shd = {
// sprmTSetShd: cb = 12, itc 0..1, Shd { cvFore = FFFF00 (yellow), ipat = 1 (solid,
// which shows the foreground) }.
let mut op = vec![12u8, 0, 1];
op.extend_from_slice(&[0xFF, 0xFF, 0x00, 0x00]); // cvFore = yellow
op.extend_from_slice(&[0, 0, 0, 0]); // cvBack
op.extend_from_slice(&1u16.to_le_bytes()); // ipat solid
let mut v = (0xD62Du16).to_le_bytes().to_vec();
v.extend_from_slice(&op);
v
};
let vert_merge = {
// sprmTVertMerge: cb = 2, itc 0, flag 3 (restart).
let mut v = (0xD62Bu16).to_le_bytes().to_vec();
v.extend_from_slice(&[2, 0, 3]);
v
};
let h_merge = {
// sprmTMerge: itcFirstLim 0..2.
let mut v = (0x5624u16).to_le_bytes().to_vec();
v.extend_from_slice(&[0, 2]);
v
};
let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\r";
let ttp_base = || grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]);
let mut row1: Vec<u8> = [ttp_base(), def3.clone(), h_merge].concat();
let mut row2: Vec<u8> = [ttp_base(), def2, borders, shd, vert_merge, grpprl(&[(0x9407, &(-400i16).to_le_bytes())])].concat();
let in_tbl = grpprl(&[(0x2416, &[0x01])]);
let spec = StructSpec {
text,
header_stories: vec![],
sections: vec![(text.chars().count() as u32, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![
(0, 0, in_tbl.clone()),
(2, 0, in_tbl.clone()),
(4, 0, std::mem::take(&mut row1)), // "c" cell paragraph, ends row 1 (merge 0..2)
(6, 0, in_tbl),
(8, 0, std::mem::take(&mut row2)), // "e" cell paragraph, ends row 2
(10, 0, Vec::new()),
],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");
assert_eq!(tbl.rows.len(), 2);
// Row 1: three cell marks merged to two cells, the first spanning two columns.
assert_eq!(tbl.rows[0].cells.len(), 2);
assert_eq!(tbl.rows[0].cells[0].span(), 2);
let r0 = tbl.rows[0].cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<String>();
assert_eq!(r0, "ab");
// Row 2: shading, vertical-merge restart, exact height 20pt.
assert_eq!(tbl.rows[1].cells[0].props.shading, Some(wordcraft_doc::Rgb(255, 255, 0)));
assert_eq!(tbl.rows[1].cells[0].props.vmerge, wordcraft_doc::props::VMerge::Restart);
assert_eq!(tbl.rows[1].props.height, Some(20.0));
// Table borders from the first row that carries them: single 0.5pt black.
let b = tbl.props.borders.expect("borders");
let top = b.top.expect("top border");
assert_eq!(top.style, wordcraft_doc::props::BorderStyle::Single);
assert_eq!(top.width, 0.5);
}
// ---------------------------------------------------------------------------------------------
// D3: lists (PlfLst + PlfLfo) and the combined document.
/// One LVL: number text given as chars (a `0`-valued char at placeholder position `ph` is
/// the level placeholder), with a 720-twip left indent and a 360-twip hanging first line.
fn lvl(start: u32, nfc: u8, chars: &[u16], ph: bool) -> Vec<u8> {
let mut v = vec![0u8; 28];
v[0..4].copy_from_slice(&start.to_le_bytes());
v[4] = nfc;
if ph {
v[6] = 1; // rgbxchNums[0]: the placeholder sits at one-based offset 1
}
v[15] = 1; // a space follows the number text
let papx: Vec<u8> = [grpprl(&[(0x840F, &720u16.to_le_bytes())]), grpprl(&[(0x8411, &(-360i16).to_le_bytes())])].concat();
v[25] = papx.len() as u8; // cbGrpprlPapx
v.extend_from_slice(&papx);
v.extend_from_slice(&(chars.len() as u16).to_le_bytes());
for c in chars {
v.extend_from_slice(&c.to_le_bytes());
}
v
}
/// A `PlfLst` with one multi-level list (nine decimal levels) or one simple list.
fn plf_lst(lsid: i32, simple: bool, levels: &[Vec<u8>]) -> Vec<u8> {
let mut v = 1u16.to_le_bytes().to_vec();
v.extend_from_slice(&lsid.to_le_bytes());
v.extend_from_slice(&0u32.to_le_bytes()); // tplc
for _ in 0..9 {
v.extend_from_slice(&0x0FFFu16.to_le_bytes()); // no style links
}
v.push(u8::from(simple)); // flags: fSimpleList
v.push(0); // grfhic
for l in levels {
v.extend_from_slice(l);
}
v
}
/// A `PlfLfo` with one LFO bound to `lsid`.
fn plf_lfo(lsid: i32) -> Vec<u8> {
let mut v = 1u32.to_le_bytes().to_vec(); // lfoMac
v.extend_from_slice(&lsid.to_le_bytes()); // LFO.lsid
v.extend_from_slice(&[0u8; 8]); // unused1/2
v.push(0); // clfolvl
v.extend_from_slice(&[0u8; 3]); // ibstFltAutoNum, grfhic, unused3
v.extend_from_slice(&0u32.to_le_bytes()); // LFOData.cp
v
}
#[test]
fn list_numbering_parsed() {
let levels: Vec<Vec<u8>> = (0..9)
.map(|_| lvl(1, 0x00, &[0x0000, 0x002E], true)) // "%1." style, decimal
.collect();
let text = "one\rtwo\rplain\r";
let listed = |off: bool| {
if off { grpprl(&[(0x460B, &[0x00, 0x00])]) } else { [grpprl(&[(0x260A, &[0x00])]), grpprl(&[(0x460B, &[0x01, 0x00])])].concat() }
};
let spec = StructSpec {
text,
header_stories: vec![],
sections: vec![(text.chars().count() as u32, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, listed(false)), (4, 0, listed(false)), (8, 0, listed(true))],
plf_lst: plf_lst(0x1000, false, &levels),
plf_lfo: plf_lfo(0x1000),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(doc.numbering.abstracts.len(), 1);
assert_eq!(doc.numbering.nums.len(), 1);
let l0 = doc.numbering.level(1, 0).expect("level 0");
assert_eq!(l0.format, wordcraft_doc::section::NumFormat::Decimal);
assert_eq!(l0.text, "%1.");
assert_eq!(l0.indent, 36.0);
assert_eq!(l0.hanging, 18.0);
assert_eq!(l0.suffix, wordcraft_doc::numbering::LevelSuffix::Space);
// Paragraphs 1-2 are in the list, paragraph 3 is not.
let n = |i: usize| doc.body.get(i).and_then(|b| b.as_para()).and_then(|p| p.props.numbering);
assert_eq!(n(0), Some(wordcraft_doc::props::NumRef { num: 1, level: 0 }));
assert_eq!(n(1), Some(wordcraft_doc::props::NumRef { num: 1, level: 0 }));
assert_eq!(n(2), None);
// The numbering produces labels through the shared counter.
let mut c = wordcraft_doc::numbering::Counters::default();
assert_eq!(c.next_label(&doc.numbering, 1, 0).unwrap().0, "1.");
assert_eq!(c.next_label(&doc.numbering, 1, 0).unwrap().0, "2.");
}
/// The combined D3 fixture: intro paragraph, table, list, two sections, header story.
fn structured_doc() -> Vec<u8> {
let def2 = t_def_table(&[0, 2880, 5760]);
let mut row: Vec<u8> = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]), def2].concat();
let listed = [grpprl(&[(0x260A, &[0x00])]), grpprl(&[(0x460B, &[0x01, 0x00])])].concat();
let text = "Intro\ra\u{7}b\u{7}first\rsecond\r";
let levels: Vec<Vec<u8>> = (0..9).map(|_| lvl(1, 0x00, &[0x0000, 0x002E], true)).collect();
let spec = StructSpec {
text,
header_stories: vec!["Running head\r\r"],
sections: vec![
(6, Vec::new()), // section 1: the intro paragraph; its header story is the odd header
(
text.chars().count() as u32,
grpprl(&[(0xB01F, &15840u16.to_le_bytes()), (0xB020, &12240u16.to_le_bytes())]), // landscape letter
),
],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![
(0, 0, Vec::new()), // "Intro" paragraph, ends section 1
(6, 0, grpprl(&[(0x2416, &[0x01])])), // "a" cell paragraph
(8, 0, std::mem::take(&mut row)), // "b" cell paragraph, ends the row
(10, 0, listed.clone()), // "first" paragraph, in the list
(16, 0, listed), // "second" paragraph
],
plf_lst: plf_lst(0x2000, false, &levels),
plf_lfo: plf_lfo(0x2000),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
struct_doc(&spec)
}
#[test]
fn combined_structure_document() {
let doc = read(&structured_doc()).expect("opens");
// Body shape: intro paragraph, table, two list paragraphs.
assert!(doc.body.first().and_then(|b| b.as_para()).is_some_and(|p| p.text == "Intro"), "intro para");
let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");
assert_eq!(tbl.rows.len(), 1);
assert_eq!(tbl.rows[0].cells.len(), 2);
let texts: Vec<String> = doc.plain_text(wordcraft_doc::StoryRef::Body).split('\n').map(str::to_string).collect();
assert!(texts.contains(&"first".to_string()) && texts.contains(&"second".to_string()), "list text present: {texts:?}");
// Section 1 is attached to the intro paragraph and owns the header story; the final
// section is landscape.
let sec1 = doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.section.clone()).expect("section on intro");
let h = sec1.headers.default.expect("header part");
assert!(doc.parts.get(&h).is_some_and(|p| p.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Running head")))));
assert_eq!(doc.last_section.page_w, 792.0);
assert_eq!(doc.last_section.page_h, 612.0);
assert!(doc.last_section.landscape);
// The whole document round-trips through JSON (the wcraft.json shape).
let json = serde_json::to_value(&doc).expect("serialises");
assert!(json.get("body").is_some_and(|b| b.as_array().is_some_and(|a| !a.is_empty())));
assert!(json.get("numbering").is_some_and(|n| n.get("nums").is_some_and(|x| x.as_array().is_some_and(|a| !a.is_empty()))));
assert!(json.get("lastSection").is_some_and(|s| s.get("landscape") == Some(&serde_json::json!(true))));
}
// ---------------------------------------------------------------------------------------------
// D4: notes, fields, bookmarks, images.
/// A PLC: aCP[n+1] u32s followed by `data` bytes per element.
fn plc(cps: &[u32], data: &[u8]) -> Vec<u8> {
let mut v = Vec::new();
for c in cps {
v.extend_from_slice(&c.to_le_bytes());
}
v.extend_from_slice(data);
v
}
/// A SttbfBkmk with the given names.
fn sttbf_bkmk(names: &[&str]) -> Vec<u8> {
let mut v = 0xFFFFu16.to_le_bytes().to_vec();
v.extend_from_slice(&(names.len() as u16).to_le_bytes());
v.extend_from_slice(&0u16.to_le_bytes()); // cbExtra
for n in names {
v.extend_from_slice(&(n.chars().count() as u16).to_le_bytes());
for c in n.chars() {
v.extend_from_slice(&(c as u16).to_le_bytes());
}
}
v
}
#[test]
fn footnote_reference_links_story_part() {
let text = "See\u{2} here\r";
let ccp = text.chars().count() as u32;
let spec = StructSpec {
text,
note_stories: vec!["Note body\r"],
// Pair 2 (PlcffndRef): the 0x02 sits at CP 3, auto-numbered. Pair 3 (PlcffndTxt):
// one story spanning the whole footnote subdocument.
extra_pairs: vec![(2, plc(&[3, ccp], &1u16.to_le_bytes())), (3, plc(&[0, 10], &[]))],
..structured_spec(text, ccp)
};
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let note = p0.objects.first().expect("note ref object");
let wordcraft_doc::para::InlineObject::NoteRef { kind, id, .. } = note else { panic!("not a note: {note:?}") };
assert_eq!(*kind, wordcraft_doc::para::NoteKind::Footnote);
let part = doc.parts.get(id).expect("note part");
assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Note body"))), "note text: {:?}", part.blocks);
assert_eq!(part.kind, wordcraft_doc::PartKind::Footnote);
}
#[test]
fn endnote_reference_links_story_part() {
let text = "Ref\u{2}\r";
let ccp = text.chars().count() as u32;
let spec = StructSpec {
text,
// A footnote story keeps the endnote subdocument at a non-zero offset.
note_stories: vec!["Ftn\r"],
endnote_stories: vec!["Endnote body\r"],
extra_pairs: vec![
(46, plc(&[3, ccp], &1u16.to_le_bytes())),
(47, plc(&[0, 13], &[])), // "Endnote body\r"
],
..structured_spec(text, ccp)
};
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let wordcraft_doc::para::InlineObject::NoteRef { kind, id, .. } = p0.objects.first().expect("note ref object") else { panic!("no note ref") };
assert_eq!(*kind, wordcraft_doc::para::NoteKind::Endnote);
let part = doc.parts.get(id).expect("endnote part");
assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Endnote body"))));
}
#[test]
fn fields_and_hyperlinks() {
// A HYPERLINK field (result kept as linked text) and a PAGE field (inline object).
let text = "Go \u{13}HYPERLINK http://x\u{14}site\u{15} n\u{13}PAGE\u{14}3\u{15}.\r";
let ccp = text.chars().count() as u32;
let spec = structured_spec(text, ccp);
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(p0.text, "Go site n\u{FFFC}.");
// The hyperlink result run carries the target.
let linked = p0.runs.iter().find(|r| r.props.link.is_some()).expect("linked run");
assert_eq!(linked.props.link.as_deref(), Some("http://x"));
// The PAGE field became one object with its cached result.
let wordcraft_doc::para::InlineObject::Field { instr, result, .. } = p0.objects.first().expect("field object") else {
panic!("no field: {:?}", p0.objects)
};
assert_eq!(instr, "PAGE");
assert_eq!(result, "3");
}
#[test]
fn bookmarks_become_objects() {
let text = "Hello world\r";
let ccp = text.chars().count() as u32;
let spec = StructSpec {
text,
extra_pairs: vec![(21, sttbf_bkmk(&["bm1"])), (22, plc(&[0, ccp], &[0, 0, 0, 0])), (23, plc(&[5, ccp], &[0, 0]))],
..structured_spec(text, ccp)
};
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
// The start anchor sits before the text, the end after "Hello".
assert!(
matches!(p0.objects.first(), Some(wordcraft_doc::para::InlineObject::BookmarkStart { name }) if name == "bm1"),
"objects: {:?}",
p0.objects
);
assert!(matches!(p0.objects.get(1), Some(wordcraft_doc::para::InlineObject::BookmarkEnd { name }) if name == "bm1"), "objects: {:?}", p0.objects);
assert_eq!(p0.text, "\u{FFFC}Hello\u{FFFC} world");
}
#[test]
fn inline_png_picture() {
let text = "Pic\u{1}!\r";
let ccp = text.chars().count() as u32;
// A PICF header (68 bytes) with a 1×0.5-inch goal size, then PNG-ish bytes.
let png: Vec<u8> = [b"\x89PNG\r\n\x1a\n".as_slice(), &[0u8; 16]].concat();
let mut picf = vec![0u8; 0x44];
let lcb = (0x44 + png.len()) as u32;
picf[0..4].copy_from_slice(&lcb.to_le_bytes());
picf[4..6].copy_from_slice(&0x44u16.to_le_bytes());
picf[28..30].copy_from_slice(&1440i16.to_le_bytes()); // dxaGoal
picf[30..32].copy_from_slice(&720i16.to_le_bytes()); // dyaGoal
picf[32..34].copy_from_slice(&1000u16.to_le_bytes()); // mx = 100%
picf[34..36].copy_from_slice(&1000u16.to_le_bytes()); // my = 100%
let data: Vec<u8> = [picf, png.clone()].concat();
let spec = StructSpec { text, data_stream: data, chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))], ..structured_spec(text, ccp) };
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(p0.text, "Pic\u{FFFC}!");
let wordcraft_doc::para::InlineObject::Image { media, w, h, .. } = p0.objects.first().expect("image object") else {
panic!("no image: {:?}", p0.objects)
};
assert_eq!((*w, *h), (72.0, 36.0)); // 1440 × 720 twips
let bytes = doc.media.get(media).expect("media entry");
assert_eq!(bytes.as_slice(), png.as_slice());
}
/// The D4 hostile-input fixture: notes, a field, a bookmark and a picture in one file.
fn extras_doc() -> Vec<u8> {
let png: Vec<u8> = [b"\x89PNG\r\n\x1a\n".as_slice(), &[0u8; 8]].concat();
let mut picf = vec![0u8; 0x44];
let lcb = (0x44 + png.len()) as u32;
picf[0..4].copy_from_slice(&lcb.to_le_bytes());
picf[4..6].copy_from_slice(&0x44u16.to_le_bytes());
picf[28..30].copy_from_slice(&720i16.to_le_bytes());
picf[30..32].copy_from_slice(&720i16.to_le_bytes());
picf[32..34].copy_from_slice(&1000u16.to_le_bytes());
picf[34..36].copy_from_slice(&1000u16.to_le_bytes());
let text: &'static str = "Box\u{2}\u{1}\u{13}PAGE\u{14}7\u{15}\r";
let ccp = text.chars().count() as u32;
let spec = StructSpec {
text,
note_stories: vec!["Note text\r"],
extra_pairs: vec![
(2, plc(&[3, ccp], &1u16.to_le_bytes())),
(3, plc(&[0, 10], &[])),
(21, sttbf_bkmk(&["bm"])),
(22, plc(&[0, ccp], &[0, 0, 0, 0])),
(23, plc(&[4, ccp], &[0, 0])),
],
data_stream: [picf, png].concat(),
chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))],
..structured_spec(text, ccp)
};
struct_doc(&spec)
}
/// The shared base of the D4 fixtures: one Normal style, one mark, no sections extras.
fn structured_spec(text: &'static str, ccp: u32) -> StructSpec {
StructSpec {
text,
header_stories: vec![],
sections: vec![(ccp, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
// The PAPX entry key is the CP just past the paragraph mark.
papx_entries: vec![(0, 0, Vec::new())],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
}
}
// ---------------------------------------------------------------------------------------------
// Hardening regressions: hostile CP ranges, piece lookups, picture fan-out, overflows.
/// All the paragraph text of a document: body plus every part.
fn all_text(doc: &wordcraft_doc::Document) -> String {
let mut s: String = body_texts(doc).join("\n");
for part in doc.parts.values() {
for b in &part.blocks {
if let Some(p) = b.as_para() {
s.push('\n');
s.push_str(&p.text);
}
}
}
s
}
#[test]
fn huge_story_ranges_are_clamped_to_their_subdocument() {
// Footnote, header and endnote stories whose Table-stream CPs claim [0, 4e9) (and
// ranges near u32::MAX that overflowed `base + cp`): before the clamp, each pushed
// billions of U+FFFD (or panicked on the addition).
let text = "A\u{2}\u{2}\r";
let ccp = text.chars().count() as u32;
let big = 4_000_000_000u32;
let hdd = plc(&[0, 0, 0, 0, 0, 0, 0, 0, big, 0xFFFF_FFF0, 0xFFFF_FFFF, 0xFFFF_FFFF, 0xFFFF_FFFF], &[]);
let spec = StructSpec {
text,
note_stories: vec!["Note body\r"],
header_stories: vec!["Head\r"],
endnote_stories: vec!["End\r"],
extra_pairs: vec![
(2, plc(&[1, ccp], &1u16.to_le_bytes())),
(3, plc(&[0, big], &[])),
(11, hdd),
(46, plc(&[2, ccp], &1u16.to_le_bytes())),
(47, plc(&[0xFFFF_FFF0, 0xFFFF_FFFF], &[])),
],
..structured_spec(text, ccp)
};
let doc = read(&struct_doc(&spec)).expect("opens");
let all = all_text(&doc);
assert!(!all.contains('\u{FFFD}'), "replacement characters: {} bytes of text", all.len());
assert!(all.len() < 200, "text grew to {} bytes", all.len());
let header = doc.last_section.headers.default.and_then(|id| doc.parts.get(&id)).expect("header part");
assert!(header.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text == "Head")), "header: {:?}", header.blocks);
let footnote = doc.parts.values().find(|p| p.kind == wordcraft_doc::PartKind::Footnote).expect("footnote part");
assert!(footnote.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text == "Note body")));
// The endnote story starts past the endnote subdocument: nothing to read.
assert!(!doc.parts.values().any(|p| p.kind == wordcraft_doc::PartKind::Endnote));
}
/// The text of each note a paragraph references, in order.
fn note_texts(doc: &wordcraft_doc::Document, p: &wordcraft_doc::Paragraph) -> Vec<String> {
p.objects
.iter()
.filter_map(|o| match o {
wordcraft_doc::para::InlineObject::NoteRef { id, .. } => doc.parts.get(id),
_ => None,
})
.map(|part| part.blocks.iter().filter_map(|b| b.as_para()).map(|para| para.text.as_str()).collect::<Vec<_>>().join("\n"))
.collect()
}
#[test]
fn note_stories_pointing_at_the_same_text_are_read_once() {
// 2000 footnote references whose PlcffndTxt steps back to CP 0 after every story
// ([0, 10, 0, 10, …]): every other one was read into its own part, a copy of the whole
// footnote subdocument each (1001 parts here; a large story times thousands of
// references in a hostile file).
let n = 2000u32;
let text: &'static str = Box::leak(format!("A{}\r", "\u{2}".repeat(n as usize)).into_boxed_str());
let ccp = text.chars().count() as u32;
let refs: Vec<u32> = (1..=n).chain([ccp]).collect();
let spec = StructSpec {
text,
note_stories: vec!["Note body\r"],
extra_pairs: vec![(2, plc(&refs, &1u16.to_le_bytes().repeat(n as usize))), (3, plc(&[0u32, 10].repeat(n as usize / 2 + 1), &[]))],
..structured_spec(text, ccp)
};
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(doc.parts.values().filter(|p| p.kind == wordcraft_doc::PartKind::Footnote).count(), 1);
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(note_texts(&doc, p0), vec!["Note body".to_string()]);
}
#[test]
fn note_stories_stepping_back_are_dropped_and_ordered_ones_kept() {
// Story CPs [0, 5, 10, 3, 10] for four footnotes and four endnotes: the first two
// stories are in order and read (the control); the third runs backwards and the fourth
// starts inside the first, so the third and fourth references get no note.
let text = "a\u{2}b\u{2}c\u{2}d\u{2}e\u{2}f\u{2}g\u{2}h\u{2}\r";
let ccp = text.chars().count() as u32;
let stories = plc(&[0, 5, 10, 3, 10], &[]);
let spec = StructSpec {
text,
note_stories: vec!["Aaaa\rBbbb\r"],
endnote_stories: vec!["Cccc\rDddd\r"],
extra_pairs: vec![
(2, plc(&[1, 3, 5, 7, ccp], &1u16.to_le_bytes().repeat(4))),
(3, stories.clone()),
(46, plc(&[9, 11, 13, 15, ccp], &1u16.to_le_bytes().repeat(4))),
(47, stories),
],
..structured_spec(text, ccp)
};
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(note_texts(&doc, p0), ["Aaaa", "Bbbb", "Cccc", "Dddd"].map(String::from));
assert_eq!(doc.parts.len(), 4);
}
#[test]
fn header_stories_pointing_at_the_same_text_are_read_once() {
// Two sections whose odd headers both claim the header subdocument's [0, 5) (Plcfhdd
// steps back to CP 0 inside the second section's slots): the second used to become a
// second copy of the text. Its slot is now empty, which links it to the first section's.
let text = "a\rb\r";
let ccp = text.chars().count() as u32;
// Six empty separator stories, then each section's six slots; the odd header is the
// second slot.
let hdd = plc(&[0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 5, 5, 5, 0, 5, 5, 5, 5, 5], &[]);
let spec = StructSpec {
text,
header_stories: vec!["Head\r"],
sections: vec![(2, Vec::new()), (ccp, Vec::new())],
papx_entries: vec![(0, 0, Vec::new()), (2, 0, Vec::new())],
extra_pairs: vec![(11, hdd)],
..structured_spec(text, ccp)
};
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(doc.parts.values().filter(|p| p.kind == wordcraft_doc::PartKind::Header).count(), 1);
let first = doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.section.as_ref()).expect("first section");
let header = first.headers.default.and_then(|id| doc.parts.get(&id)).expect("first section's header");
assert!(header.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text == "Head")), "header: {:?}", header.blocks);
assert_eq!(doc.last_section.headers.default, None);
}
/// A document whose piece table is given raw (cps + one Pcd per piece), with `text` bytes
/// at TEXT_FC and the FIB claiming `ccp_text` main-document characters.
fn raw_piece_doc(cps: &[u32], fcs: &[u32], text: &[u8], ccp_text: u32) -> Vec<u8> {
raw_piece_doc_with(cps, fcs, text, ccp_text, &[])
}
/// [`raw_piece_doc`] with extra raw FIB (pair, fc, lcb) entries.
fn raw_piece_doc_with(cps: &[u32], fcs: &[u32], text: &[u8], ccp_text: u32, extra: &[(usize, u32, u32)]) -> Vec<u8> {
let mut plc = Vec::new();
for c in cps {
plc.extend_from_slice(&c.to_le_bytes());
}
for fc in fcs {
plc.extend_from_slice(&[0, 0]);
plc.extend_from_slice(&fc.to_le_bytes());
plc.extend_from_slice(&0u16.to_le_bytes());
}
let mut clx = vec![0x02u8];
clx.extend_from_slice(&(plc.len() as u32).to_le_bytes());
clx.extend_from_slice(&plc);
let mut table = vec![0u8; CLX_AT as usize];
table.extend_from_slice(&clx);
let mut word = vec![0u8; TEXT_FC as usize];
word.extend_from_slice(text);
let mut pairs = vec![(33, CLX_AT, clx.len() as u32)];
pairs.extend_from_slice(extra);
let spec = FibSpec { ccp_text, pairs, ..Default::default() };
let fib = fib_bytes(&spec);
word[..fib.len()].copy_from_slice(&fib);
cfb_file(&[("WordDocument", &word), ("0Table", &table)])
}
#[test]
fn piece_claiming_far_more_text_than_stored_is_skipped() {
// One compressed piece claims CPs [0, 100M) but only "Hi\r" is stored: the walk used to
// emit a U+FFFD per missing CP (100M of them).
let fc = (2 * TEXT_FC) | 0x4000_0000;
let f = raw_piece_doc(&[0, 100_000_000], &[fc], b"Hi\r", 100_000_000);
let doc = read(&f).expect("opens");
assert_eq!(body_texts(&doc), vec!["Hi".to_string()]);
}
#[test]
fn walk_stops_at_the_last_piece() {
// The FIB claims 100M characters but the piece table ends at CP 3.
let fc = (2 * TEXT_FC) | 0x4000_0000;
let f = raw_piece_doc(&[0, 3], &[fc], b"Hi\r", 100_000_000);
let doc = read(&f).expect("opens");
assert_eq!(body_texts(&doc), vec!["Hi".to_string()]);
}
#[test]
fn inverted_and_gapped_pieces_are_skipped() {
// Piece 0 is inverted (CPs 5 → 2), piece 1 reads "ok\r", piece 2 points past the stream.
let base = (2 * TEXT_FC) | 0x4000_0000;
let f = raw_piece_doc(&[5, 2, 5, 50_000_000], &[base, base, 0x3FFF_0000], b"ok\r", 50_000_000);
let doc = read(&f).expect("opens");
let all = all_text(&doc);
assert!(!all.contains('\u{FFFD}'));
assert!(all.len() < 20, "{all:?}");
}
#[test]
fn many_pieces_walk_in_n_log_n() {
// 200k one-character pieces: the old per-character scan over every piece was
// O(chars × pieces) = 4e10 steps; with binary search this opens instantly.
let n = 200_000usize;
let mut text = vec![b'x'; n - 1];
text.push(b'\r');
let cps: Vec<u32> = (0..=n as u32).collect();
let fcs: Vec<u32> = (0..n as u32).map(|i| (2 * (TEXT_FC + i)) | 0x4000_0000).collect();
let started = std::time::Instant::now();
let doc = read(&raw_piece_doc(&cps, &fcs, &text, n as u32)).expect("opens");
let body = body_texts(&doc);
assert_eq!(body.len(), 1);
assert_eq!(body[0].len(), n - 1);
assert!(started.elapsed() < std::time::Duration::from_secs(30), "took {:?}", started.elapsed());
}
#[test]
fn surrogate_pairs_decode_across_one_cp_each() {
// `Piece` stores one u16 per char, so write the pair by hand.
let units: Vec<u16> = "a\u{1F600}b\r".encode_utf16().collect();
let bytes: Vec<u8> = units.iter().flat_map(|u| u.to_le_bytes()).collect();
let n = units.len() as u32;
let doc = read(&raw_piece_doc(&[0, n], &[TEXT_FC], &bytes, n)).expect("opens");
assert_eq!(body_texts(&doc), vec!["a\u{1F600}b".to_string()]);
}
/// A PICF header (68 bytes) for an `n`-byte PNG-looking payload.
fn picf_with_png(n: usize) -> Vec<u8> {
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
png.resize(n.max(8), 0xAB);
let mut picf = vec![0u8; 0x44];
picf[0..4].copy_from_slice(&((0x44 + png.len()) as u32).to_le_bytes());
picf[4..6].copy_from_slice(&0x44u16.to_le_bytes());
picf[28..30].copy_from_slice(&1440i16.to_le_bytes());
picf[30..32].copy_from_slice(&1440i16.to_le_bytes());
picf.extend_from_slice(&png);
picf
}
#[test]
fn anchors_sharing_one_picture_share_one_media_entry() {
// 5000 anchors on the same 16 MB picture used to copy it once per anchor (80 GB)
// before the copies were deduplicated.
let text: &'static str = Box::leak(format!("{}\r", "\u{1}".repeat(5000)).into_boxed_str());
let ccp = text.chars().count() as u32;
let spec = StructSpec {
text,
data_stream: picf_with_png(16 << 20),
chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))],
..structured_spec(text, ccp)
};
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(doc.media.len(), 1);
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(p0.objects.len(), 5000);
assert!(p0.objects.iter().all(|o| matches!(o, wordcraft_doc::para::InlineObject::Image { media, .. } if media == "image1.png")));
}
#[test]
fn picture_bytes_are_capped_per_document() {
let a = picf_with_png(600);
let b = picf_with_png(600);
let data: Vec<u8> = [a.clone(), b].concat();
let mut pics = crate::media::Pictures::default();
pics.limit = 1000;
let first = pics.get(&data, 0).expect("first fits");
// Same location again: no new bytes.
assert_eq!(pics.get(&data, 0).map(|p| p.key), Some(first.key.clone()));
// A second distinct picture would exceed the cap.
assert!(pics.get(&data, a.len() as u32).is_none());
assert_eq!(pics.media.len(), 1);
const { assert!(crate::media::MAX_MEDIA_BYTES <= 256 << 20) };
}
#[test]
fn rejected_inline_pictures_are_not_read_again() {
// A picture over the cap was located and copied again for every anchor on it.
let big = picf_with_png(1200);
let small = picf_with_png(300);
let data: Vec<u8> = [big.clone(), small.clone()].concat();
let mut pics = crate::media::Pictures::default();
pics.limit = 1000;
assert!(pics.get(&data, 0).is_none(), "over the cap");
// The failure is remembered: that place isn't read again, whatever it holds now.
assert!(pics.get(&small, 0).is_none());
// The rejected picture took nothing off the cap, and anchors sharing a picture share it.
let at = big.len() as u32;
let fits = pics.get(&data, at).expect("fits");
assert_eq!(pics.get(&data, at).map(|p| p.key), Some(fits.key));
assert_eq!(pics.media.len(), 1);
// Filling the cap exactly works; after that, a place never tried before isn't read.
let rest = picf_with_png(700);
let rest_at = data.len() as u32;
let tail: Vec<u8> = [data, rest.clone(), small].concat();
assert!(pics.get(&tail, rest_at).is_some(), "700 more bytes fill the cap");
assert!(pics.get(&tail, rest_at + rest.len() as u32).is_none(), "the cap is used up");
assert_eq!(pics.media.len(), 2);
}
#[test]
fn row_height_of_i16_min_does_not_overflow() {
// sprmTDyaRowHeight = -32768: negating the i16 overflowed (a debug-build panic).
let papx = grpprl(&[(0x9407, &i16::MIN.to_le_bytes())]);
let row = crate::table::decode(&papx);
assert_eq!(row.height(), Some(1584.0));
}
#[test]
fn list_and_lfo_offsets_near_u32_max_are_safe() {
// PlfLst / PlfLfo at fc = u32::MAX: offset sums like `at + 2 + cLst * 28` must saturate
// (they overflow on 32-bit targets such as wasm32).
let fc = (2 * TEXT_FC) | 0x4000_0000;
let far = u32::MAX - 1;
let f = raw_piece_doc_with(&[0, 2], &[fc], b"a\r", 2, &[(73, far, 2), (74, far, 4)]);
let doc = read(&f).expect("opens");
assert!(doc.numbering.nums.is_empty());
}
/// A StructSpec with one section (`sepx`) over `text`, no lists, notes or extras.
fn one_section(text: &'static str, sepx: Vec<u8>, papx_entries: Vec<(u32, u16, Vec<u8>)>) -> StructSpec {
StructSpec {
text,
header_stories: vec![],
sections: vec![(text.chars().count() as u32, sepx)],
chpx_runs: vec![(0, Vec::new())],
papx_entries,
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
}
}
#[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
// 1-7 plus a one-byte operand in bits 8-15 (isprm 0x55 is sprmCFBold); Prm1 = a
// zero-based Prc index in bits 1-15. Piece properties apply after the CHPX.
let bold = grpprl(&[(0x0835, &[0x01])]);
let prcs = vec![bold.clone(), [grpprl(&[(0x0836, &[0x01])]), bold.clone()].concat()];
let run = |prm: u16, chpx: Vec<u8>| {
let spec = RichSpec {
text: "Bold\r",
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
fonts: vec!["Times New Roman"],
chpx_runs: vec![(0, chpx)],
papx_entries: vec![(0, 0, Vec::new())],
};
let doc = read(&rich_doc_with(&spec, &RichExtra { prcs: prcs.clone(), prm, ..Default::default() })).expect("opens");
let p = para0(&doc);
assert_eq!(p.text, "Bold");
p.runs.first().map(|r| (r.props.bold, r.props.italic)).unwrap_or_default()
};
assert_eq!(run(0x01AA, Vec::new()), (Some(true), None), "Prm0 bold on");
assert_eq!(run(0x0001, Vec::new()), (Some(true), None), "Prm1 → Prc 0 (bold)");
assert_eq!(run(0x0003, Vec::new()), (Some(true), Some(true)), "Prm1 → Prc 1 (italic + bold)");
assert_eq!(run(0x00AA, bold.clone()).0, Some(false), "Prm0 bold off overrides the CHPX");
assert_eq!(run(0x0000, Vec::new()), (None, None), "no Prm: plain");
assert_eq!(run(0x0000, bold).0, Some(true), "CHPX bold alone");
}
#[test]
fn section_break_kinds_imported() {
// #459. SBkcOperand: 0 continuous, 1 new column, 2 new page, 3 even page, 4 odd page.
use wordcraft_doc::section::SectionStart;
let kinds = [SectionStart::Continuous, SectionStart::NextColumn, SectionStart::NextPage, SectionStart::EvenPage, SectionStart::OddPage];
for (bkc, want) in kinds.into_iter().enumerate() {
let spec = one_section("Text\r", grpprl(&[(0x3009, &[bkc as u8])]), vec![(0, 0, Vec::new())]);
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(doc.last_section.start, want, "sprmSBkc {bkc}");
}
}
#[test]
fn explicit_section_orientation_imported() {
// #460. SBOrientationOperand ([MS-DOC] §2.9.236): 1 portrait, 2 landscape.
let page = |w: u16, h: u16, orient: Option<u8>| {
let mut prls: Vec<(u16, &[u8])> = Vec::new();
let (w, h) = (w.to_le_bytes(), h.to_le_bytes());
let o = orient.map(|o| [o]);
if let Some(o) = &o {
prls.push((0x301D, o));
}
prls.push((0xB01F, &w));
prls.push((0xB020, &h));
let doc = read(&struct_doc(&one_section("Text\r", grpprl(&prls), vec![(0, 0, Vec::new())]))).expect("opens");
(doc.last_section.landscape, doc.last_section.page_w, doc.last_section.page_h)
};
assert_eq!(page(12240, 15840, Some(1)), (false, 612.0, 792.0), "explicit portrait");
assert_eq!(page(15840, 12240, Some(2)), (true, 792.0, 612.0), "explicit landscape");
assert_eq!(page(12240, 15840, None), (false, 612.0, 792.0), "inferred portrait");
assert_eq!(page(15840, 12240, None), (true, 792.0, 612.0), "inferred landscape");
}
#[test]
fn negative_list_reference_keeps_list_membership() {
// #461. sprmPIlfo 0xF802-0xFFFF is the negation of a 1-based LFO index whose paragraph
// keeps its own indents ([MS-DOC] §2.6.2); 0 and 0xF801 mean "not in a list".
let levels: Vec<Vec<u8>> = (0..9).map(|_| lvl(1, 0x00, &[0x0000, 0x002E], true)).collect();
let listed = |ilfo: u16| [grpprl(&[(0x260A, &[0x00]), (0x840F, &720u16.to_le_bytes())]), grpprl(&[(0x460B, &ilfo.to_le_bytes())])].concat();
let text = "pos\rneg\rzero\rnone\r";
let mut spec = one_section(text, Vec::new(), vec![(0, 0, listed(0x0001)), (4, 0, listed(0xFFFF)), (8, 0, listed(0)), (13, 0, listed(0xF801))]);
spec.plf_lst = plf_lst(0x1000, true, &levels[..1]);
spec.plf_lfo = plf_lfo(0x1000);
let doc = read(&struct_doc(&spec)).expect("opens");
let para = |i: usize| doc.body.get(i).and_then(|b| b.as_para()).map(|p| (p.props.numbering, p.props.indent_left)).unwrap_or_default();
let one = Some(wordcraft_doc::props::NumRef { num: 1, level: 0 });
assert_eq!(para(0), (one, Some(36.0)), "0x0001");
assert_eq!(para(1), (one, Some(36.0)), "0xFFFF = -1");
assert_eq!(para(2).0, None, "0");
assert_eq!(para(3).0, None, "0xF801");
}
#[test]
fn style_inherits_its_declared_base() {
// #462. StdfBase ([MS-DOC] §2.9.260): stk + istdBase share the second word, cupx +
// istdNext the third; istdBase is zero-based. The next style must not affect the base.
for next in [0u16, 1, 2, 0x0FFF] {
let spec = RichSpec {
text: "Text\r",
styles: vec![
(1, 0x0FFF, "Normal", Vec::new(), grpprl(&[(0x0835, &[0x01])])), // bold
(1, 0, "Derived", Vec::new(), Vec::new()),
(1, 1, "Grandchild", Vec::new(), grpprl(&[(0x0836, &[0x01])])), // italic
// A corrupt cycle must neither hang nor crash.
(1, 4, "Loop A", Vec::new(), Vec::new()),
(1, 3, "Loop B", Vec::new(), Vec::new()),
],
fonts: vec!["Times New Roman"],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 1, Vec::new())],
};
let extra = RichExtra { style_next: vec![0x0FFF, next, 0x0FFF, 0x0FFF, 0x0FFF], ..Default::default() };
let doc = read(&rich_doc_with(&spec, &extra)).expect("opens");
let style = |name: &str| doc.styles.styles.iter().find(|s| s.name == name).cloned().expect("style");
let derived = style("Derived");
assert_eq!(derived.based_on.as_deref(), Some("Normal"), "next {next}");
assert_eq!(derived.chr.bold, Some(true), "next {next}");
let grand = style("Grandchild");
assert_eq!(grand.based_on.as_deref(), Some("Derived"));
assert_eq!((grand.chr.bold, grand.chr.italic), (Some(true), Some(true)), "two-level chain");
assert_eq!(style("Normal").based_on, None);
assert_eq!(para0(&doc).props.style.as_deref(), Some("Derived"));
}
}
#[test]
fn alternate_font_name_keeps_later_entries() {
// #463. An STTB entry's length is its cchData ([MS-DOC] §2.2.4), which covers the FFN's
// optional alternate name (§2.9.82 xszAlt); the next entry starts after it.
for alt in [None, Some("ArialMT")] {
let spec = RichSpec {
text: "ab\r",
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
fonts: vec!["Arial", "Courier New"],
chpx_runs: vec![(0, grpprl(&[(0x4A4F, &0u16.to_le_bytes())])), (1, grpprl(&[(0x4A4F, &1u16.to_le_bytes())]))],
papx_entries: vec![(0, 0, Vec::new())],
};
let doc = read(&rich_doc_with(&spec, &RichExtra { alt_fonts: vec![alt, None], ..Default::default() })).expect("opens");
let fonts: Vec<Option<String>> = para0(&doc).runs.iter().map(|r| r.props.font.clone()).collect();
assert_eq!(fonts, [Some("Arial".to_string()), Some("Courier New".to_string())], "alt {alt:?}");
}
}
/// PChgTabsPapxOperand bytes: cb, removed stops, added stops with their TBDs.
fn tabs_papx(del: &[i16], add: &[(i16, u8)]) -> Vec<u8> {
let mut v = vec![0u8, del.len() as u8];
for d in del {
v.extend_from_slice(&d.to_le_bytes());
}
v.push(add.len() as u8);
for (x, _) in add {
v.extend_from_slice(&x.to_le_bytes());
}
v.extend(add.iter().map(|(_, tbd)| *tbd));
v[0] = (v.len() - 1) as u8;
v
}
/// Custom tab stops ([MS-DOC] sprmPChgTabsPapx, sprmPChgTabs) were skipped, so tab-aligned text
/// (centred titles, right-aligned dates, dot leaders in forms) lost its alignment. Stops added by
/// a style and by the paragraph, stops it removes, and the TBD's alignment and leader are read.
#[test]
fn tab_stops_read_from_styles_and_paragraphs() {
use wordcraft_doc::props::{TabAlign, TabLeader};
// Normal: centre at 3.25" (4680 tw), right at 6.5" (9360 tw) with a dot leader (tlc 1).
let normal = grpprl(&[(0xC60D, &tabs_papx(&[], &[(4680, 1), (9360, 2 | 1 << 3)]))]);
// The paragraph drops the centre stop and adds a left stop at 0.5" and a bar at 1".
let direct = grpprl(&[(0xC60D, &tabs_papx(&[4680], &[(720, 0), (1440, 4)]))]);
// The second paragraph uses the older sprmPChgTabs: removals carry a "close" distance.
let mut old = vec![0u8, 1];
old.extend_from_slice(&9360i16.to_le_bytes());
old.extend_from_slice(&25i16.to_le_bytes());
old.push(1);
old.extend_from_slice(&2880i16.to_le_bytes());
old.push(3 | 5 << 3); // decimal, middle dot
old[0] = (old.len() - 1) as u8;
let spec = RichSpec {
text: "\tA\tB\r\tC\r",
styles: vec![(1, 0x0FFF, "Normal", normal, Vec::new())],
fonts: vec!["Calibri"],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, direct), (5, 0, grpprl(&[(0xC615, &old)]))],
};
let doc = read(&rich_doc(&spec)).expect("opens");
let tabs = |i: usize| {
let p = doc.body.get(i).and_then(|b| b.as_para()).expect("para");
doc.styles.resolve_para(&p.props).tabs.iter().map(|t| (t.pos, t.align, t.leader)).collect::<Vec<_>>()
};
assert_eq!(
tabs(0),
[(36.0, TabAlign::Left, TabLeader::None), (72.0, TabAlign::Bar, TabLeader::None), (468.0, TabAlign::Right, TabLeader::Dot)],
"the style's stops, less the removed one, plus the paragraph's"
);
assert_eq!(tabs(1), [(144.0, TabAlign::Decimal, TabLeader::MiddleDot), (234.0, TabAlign::Center, TabLeader::None)], "sprmPChgTabs");
assert_eq!(doc.styles.styles.iter().find(|s| s.name == "Normal").and_then(|s| s.para.tabs.as_ref()).map(Vec::len), Some(2));
}
/// Hostile tab operands (counts past the end, more than 64 stops, cb 255) never panic and
/// never swallow the sprms after them.
#[test]
fn hostile_tab_operands_are_safe() {
for operand in [vec![2u8, 200, 9], vec![0], vec![255, 1, 0x10, 0x00], vec![255, 3], vec![5, 0, 70, 0, 1, 2]] {
let papx = grpprl(&[(0xC615, &operand), (0x2403, &[1])]);
let spec = RichSpec {
text: "x\r",
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
fonts: vec!["Calibri"],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, papx)],
};
let _ = read(&rich_doc(&spec));
}
let mut iter = crate::sprm::iter(&[0x15, 0xC6, 3, 0, 0, 0, 0x03, 0x24, 1]);
assert_eq!(iter.next().map(|p| (p.op, p.operand.len())), Some((0xC615, 4)), "cb is one byte: the operand is cb + 1 bytes");
assert_eq!(iter.next().map(|p| p.op), Some(0x2403), "the next sprm is still read");
}
/// A section mark (0x0C) at a section's end is the section's last paragraph mark, not a page
/// break inside a paragraph: the section's properties (here continuous) land on that paragraph.
/// Before, every section of such a file was lost and each section mark became a page break.
#[test]
fn section_marks_end_paragraphs() {
use wordcraft_doc::section::SectionStart;
let spec = StructSpec {
text: "first\u{C}second\r",
header_stories: vec![],
sections: vec![(6, grpprl(&[(0x3009, &[0x00])])), (13, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, Vec::new()), (6, 0, Vec::new())],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(body_texts(&doc), ["first", "second"]);
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(p0.section.as_ref().map(|s| s.start), Some(SectionStart::Continuous), "the section ends at its mark");
// A page break inside a section stays a page break.
let spec = StructSpec { text: "a\u{C}b\r", sections: vec![(4, Vec::new())], papx_entries: vec![(0, 0, Vec::new())], ..spec };
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(body_texts(&doc), ["a\u{C}b"]);
}
// #1380: Word 2000+ tables. Word writes each row as its cell marks plus a separate row-end
// (TTP) mark, cell shading as 10-byte `Shd`s in `sprmTDefTableShd`/`sprmTDefTableShdRaw` (the
// `Shd80` palette copy in `sprmTDefTableShd80` is only a fallback), and a horizontally
// merged row as a row with fewer, wider cells in its own `sprmTDefTable`.
/// A `sprmTDefTable` Prl with `edges` twip boundaries and no TC80s (cb counts the bytes
/// after it, plus one; Word may store fewer TC80s than cells).
fn t_def_edges(edges: &[i16]) -> Vec<u8> {
let mut body = vec![(edges.len() as u8).saturating_sub(1)];
for e in edges {
body.extend_from_slice(&e.to_le_bytes());
}
let mut v = (0xD608u16).to_le_bytes().to_vec();
v.extend_from_slice(&((body.len() + 1) as u16).to_le_bytes());
v.extend_from_slice(&body);
v
}
/// A variable-length table sprm whose operand is cb then `body`.
fn sprm_cb(op: u16, body: &[u8]) -> Vec<u8> {
let mut v = op.to_le_bytes().to_vec();
v.push(body.len() as u8);
v.extend_from_slice(body);
v
}
/// `n` copies of a 10-byte `Shd`: cvFore auto, cvBack = `back`, ipat 0 (clear), the way
/// Word writes a plain cell fill.
fn shd_clear(back: [u8; 3], n: usize) -> Vec<u8> {
let one = [0, 0, 0, 0xFF, back[0], back[1], back[2], 0, 0, 0];
one.repeat(n)
}
/// Word-shaped 5-column table: header row, a shaded body row, a one-cell legend row.
fn word_table_doc(row1: Vec<u8>, row2: Vec<u8>, row3: Vec<u8>) -> wordcraft_doc::Document {
let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\u{7}f\u{7}g\u{7}h\u{7}i\u{7}j\u{7}\u{7}legend\u{7}\u{7}tail\r";
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
let ttp = |extra: Vec<u8>| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), extra].concat();
let spec = StructSpec {
text,
header_stories: vec![],
sections: vec![(text.chars().count() as u32, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![
(0, 0, in_tbl.clone()), // a..e cell marks
(10, 0, ttp(row1)), // row 1 end mark
(11, 0, in_tbl.clone()),
(21, 0, ttp(row2)),
(22, 0, in_tbl),
(29, 0, ttp(row3)),
(30, 0, Vec::new()), // tail
],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
read(&struct_doc(&spec)).expect("opens")
}
#[test]
fn word_table_row_end_mark_and_cell_shading() {
let edges5: [i16; 6] = [0, 1440, 2880, 4320, 5760, 7200];
// Row 1: the Shd80 fallback first (icoBack 9 = navy, clear), then the exact fill, dark
// blue 1C3A70, in sprmTDefTableShd (0xD612) and sprmTDefTableShdRaw (0xD670), as Word
// 365 writes it. The exact colours must beat the palette colour.
let shd80: Vec<u8> = [0x20u8, 0x01].repeat(5);
let row1 = [
t_def_edges(&edges5),
sprm_cb(0xD609, &shd80),
sprm_cb(0xD612, &shd_clear([0x1C, 0x3A, 0x70], 5)),
sprm_cb(0xD670, &shd_clear([0x1C, 0x3A, 0x70], 5)),
]
.concat();
// Row 2: a Shd80 yellow fill (icoBack 7) on every cell, then sprmTDefTableShd with light
// blue E8F0FA for cells 0-3 and an automatic Shd for cell 4, which clears its fill.
let shd80_yellow: Vec<u8> = [0xE0u8, 0x00].repeat(5);
let shd2 = [shd_clear([0xE8, 0xF0, 0xFA], 4), vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0, 0]].concat();
let row2 = [t_def_edges(&edges5), sprm_cb(0xD609, &shd80_yellow), sprm_cb(0xD612, &shd2)].concat();
// Row 3: one cell from the first edge to the last, filled FFF2CC.
let row3 = [t_def_edges(&[0, 7200]), sprm_cb(0xD612, &shd_clear([0xFF, 0xF2, 0xCC], 1))].concat();
let doc = word_table_doc(row1, row2, row3);
let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");
assert_eq!(tbl.grid.len(), 5, "grid columns");
assert_eq!(tbl.rows.len(), 3);
let counts: Vec<usize> = tbl.rows.iter().map(|r| r.cells.len()).collect();
assert_eq!(counts, [5, 5, 1], "cells per row (the row-end mark is not a cell)");
let shd = |r: usize| tbl.rows[r].cells.iter().map(|c| c.props.shading).collect::<Vec<_>>();
assert_eq!(shd(0), vec![Some(wordcraft_doc::Rgb(0x1C, 0x3A, 0x70)); 5], "exact fill beats the Shd80 palette colour");
let light = Some(wordcraft_doc::Rgb(0xE8, 0xF0, 0xFA));
assert_eq!(shd(1), vec![light, light, light, light, None], "automatic Shd clears the Shd80 fill");
assert_eq!(shd(2), vec![Some(wordcraft_doc::Rgb(0xFF, 0xF2, 0xCC))], "legend fill");
let legend = tbl.rows[2].cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<String>();
assert_eq!(legend, "legend");
let tail = doc.body.last().and_then(|b| b.as_para()).expect("tail para");
assert_eq!(tail.text, "tail");
}
#[test]
fn word97_shd80_clear_fill_shows_its_background() {
// Word 97 writes only sprmTDefTableShd80: a plain fill is pattern clear (ipat 0) with
// the colour in icoBack. Cells: icoBack 7 (yellow), icoBack 16 (silver), auto (none),
// solid (ipat 1) with icoFore 2 (blue).
let shd80: Vec<u8> = [0xE0u16, 16 << 5, 0, (1 << 10) | 2].iter().flat_map(|v| v.to_le_bytes()).collect();
let papx = sprm_cb(0xD609, &shd80);
let row = crate::table::decode(&papx);
assert_eq!(row.shading_of(0), Some(wordcraft_doc::Rgb(255, 255, 0)));
assert_eq!(row.shading_of(1), Some(wordcraft_doc::Rgb(192, 192, 192)));
assert_eq!(row.shading_of(2), None);
assert_eq!(row.shading_of(3), Some(wordcraft_doc::Rgb(0, 0, 255)));
}
#[test]
fn shd_arrays_keep_to_their_cell_ranges() {
// sprmTDefTableShd and sprmTDefTableShdRaw cover cells 0-21; their 2nd and 3rd forms
// cover 22-43 and 44-62. An over-long first array must not reach cell 22.
let red = shd_clear([0xFF, 0, 0], 1);
let green = shd_clear([0, 0xFF, 0], 1);
let blue = shd_clear([0, 0, 0xFF], 1);
let over_long = shd_clear([0x11, 0x22, 0x33], 23);
for (first, second, third) in [(0xD612u16, 0xD616u16, 0xD60Cu16), (0xD670, 0xD671, 0xD672)] {
let papx = [sprm_cb(second, &green), sprm_cb(third, &blue), sprm_cb(first, &over_long)].concat();
let row = crate::table::decode(&papx);
assert_eq!(row.shading_of(21), Some(wordcraft_doc::Rgb(0x11, 0x22, 0x33)), "{first:04X}");
assert_eq!(row.shading_of(22), Some(wordcraft_doc::Rgb(0, 0xFF, 0)), "{second:04X} keeps cell 22");
assert_eq!(row.shading_of(44), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "{third:04X} starts at cell 44");
let papx = sprm_cb(first, &red);
assert_eq!(crate::table::decode(&papx).shading_of(0), Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
}
}
#[test]
fn hostile_table_shading_and_spans_are_safe() {
// SHD arrays longer than the row, odd lengths, empty ones; itcMac past 63; edges that
// run backwards or past the grid; merges past the row's end.
// (Kept small: the fixture's single PAPX page holds every row's grpprl.)
let long = shd_clear([1, 2, 3], 6);
let rows: Vec<(Vec<u8>, Vec<u8>, Vec<u8>)> = vec![
([sprm_cb(0xD612, &long), sprm_cb(0xD616, &long)].concat(), sprm_cb(0xD60C, &long), sprm_cb(0xD670, &long)),
(sprm_cb(0xD612, &[1, 2, 3, 4, 5, 6, 7]), sprm_cb(0xD670, &[]), sprm_cb(0xD672, &long[..13])),
(
{
let mut v = (0xD608u16).to_le_bytes().to_vec();
v.extend_from_slice(&[0xFF, 0x00, 200]);
v.extend_from_slice(&[0x10; 24]);
v
},
t_def_edges(&[7200, 0, -5, 30000]),
t_def_edges(&[-32000, 32000]),
),
(t_def_edges(&[0]), t_def_edges(&[]), [t_def_edges(&[0, 100, i16::MAX]), sprm_cb(0xD612, &long)].concat()),
(grpprl(&[(0x5624, &[0, 200])]), grpprl(&[(0x5624, &[200, 3])]), grpprl(&[(0x5624, &[1, 255])])),
];
for (r1, r2, r3) in rows {
let doc = word_table_doc(r1, r2, r3);
let text = doc.plain_text(wordcraft_doc::StoryRef::Body);
assert!(text.contains("legend") && text.contains("tail"), "{text:?}");
if let Some(t) = doc.body.iter().find_map(|b| b.as_table()) {
assert!(t.grid.len() <= 63);
for r in &t.rows {
assert!(r.cells.iter().all(|c| c.props.span <= 63));
}
}
}
// A row that is only a row-end mark (no cell marks before it), with an out-of-range
// merge and a SHD array, then a normal row.
// The second variant's sprmTDefTable is truncated to a single edge (itcMac 3), which
// must not turn the lone row-end mark into a row with no cells.
let text = "\u{7}a\u{7}\u{7}tail\r";
let ttp = |extra: Vec<u8>| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), extra].concat();
let one_edge = {
let mut v = (0xD608u16).to_le_bytes().to_vec();
v.extend_from_slice(&4u16.to_le_bytes()); // cb: itcMac + one edge, plus one
v.extend_from_slice(&[3, 0, 0]);
v
};
for lone in [[t_def_edges(&[0, 1440]), grpprl(&[(0x5624, &[0, 9])]), sprm_cb(0xD612, &long)].concat(), one_edge] {
let spec = one_section(
text,
Vec::new(),
vec![
(0, 0, ttp(lone)),
(1, 0, grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])])),
(3, 0, ttp(t_def_edges(&[0, 1440]))),
(4, 0, Vec::new()),
],
);
let doc = read(&struct_doc(&spec)).expect("opens");
let text = doc.plain_text(wordcraft_doc::StoryRef::Body);
assert!(text.contains('a') && text.contains("tail"), "{text:?}");
for t in doc.body.iter().filter_map(|b| b.as_table()) {
assert!(t.rows.iter().all(|r| !r.cells.is_empty()), "no zero-cell rows");
}
}
}
#[test]
fn nil_raw_shading_keeps_the_earlier_colour() {
// In sprmTDefTableShdRaw (and its 2nd/3rd forms) a nil Shd (ipat 0xFFFF) means "as the
// table style shades it", so it must not override sprmTDefTableShd; automatic clears.
let blue = shd_clear([0, 0, 0xFF], 1);
let red = shd_clear([0xFF, 0, 0], 1);
let nil = vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0xFF, 0xFF];
let auto = vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0, 0];
let papx = [sprm_cb(0xD612, &[blue.clone(), blue.clone(), blue.clone()].concat()), sprm_cb(0xD670, &[nil.clone(), red, auto].concat())].concat();
let row = crate::table::decode(&papx);
assert_eq!(row.shading_of(0), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "nil keeps blue");
assert_eq!(row.shading_of(1), Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
assert_eq!(row.shading_of(2), None, "automatic clears");
for (first, raw) in [(0xD616u16, 0xD671u16), (0xD60C, 0xD672)] {
let papx = [sprm_cb(first, &blue), sprm_cb(raw, &nil)].concat();
let cell = if first == 0xD616 { 22 } else { 44 };
assert_eq!(crate::table::decode(&papx).shading_of(cell), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "{raw:04X}");
}
}
#[test]
fn second_table_gets_its_own_grid() {
// #1380: a 5-column table, a paragraph, then a 3-column table. The second table's grid
// comes from its own rows, not the first table's, and its borders, alignment and indent
// are its own too (the first table's centred, bordered and indented).
let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\u{7}between\rx\u{7}y\u{7}z\u{7}\u{7}tail\r";
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
let ttp = |edges: &[i16]| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), t_def_edges(edges)].concat();
let table1_props = {
let mut borders = (0xD605u16).to_le_bytes().to_vec();
borders.push(0x18);
for _ in 0..6 {
borders.extend_from_slice(&[4, 1, 1, 0]);
}
[borders, grpprl(&[(0x5400, &1u16.to_le_bytes()), (0x9601, &720i16.to_le_bytes())])].concat()
};
let spec = StructSpec {
text,
header_stories: vec![],
sections: vec![(text.chars().count() as u32, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![
(0, 0, in_tbl.clone()),
(10, 0, [ttp(&[0, 1440, 2880, 4320, 5760, 7200]), table1_props].concat()),
(11, 0, Vec::new()), // "between"
(19, 0, in_tbl),
(25, 0, ttp(&[0, 2000, 4000, 7200])),
(26, 0, Vec::new()), // tail
],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
let tables: Vec<_> = doc.body.iter().filter_map(|b| b.as_table()).collect();
assert_eq!(tables.len(), 2);
assert_eq!(tables[0].grid.len(), 5);
assert_eq!(tables[1].grid, vec![100.0, 100.0, 160.0], "second table's own grid");
assert_eq!(tables[1].rows[0].cells.iter().map(|c| c.span()).collect::<Vec<_>>(), [1, 1, 1]);
assert!(tables[0].props.borders.is_some() && tables[0].props.align.is_some() && tables[0].props.indent.is_some());
assert_eq!(tables[1].props.borders, None, "borders reset");
assert_eq!(tables[1].props.align, None, "alignment reset");
assert_eq!(tables[1].props.indent, None, "indent reset");
}
#[test]
fn sprm_t_cell_width_twips_and_percentage() {
// [MS-DOC] §2.9.290 sprmTCellWidth (0xD635):
// cb (1 byte), itcFirst (1 byte), itcLim (1 byte), ftsWidth (1 byte), wWidth (2 bytes).
// ftsWidth = 3 (twips): 1440 twips = 72.0 pt.
let w_twips = [0u8, 2, 3, 0xA0, 0x05]; // cell 0..2, fts=3, 1440 dxa
// ftsWidth = 2 (percentage in 50ths of a percent): 2500 = 50.0%.
let w_pct = [2u8, 3, 2, 0xC4, 0x09]; // cell 2..3, fts=2, 2500 (50%)
let papx = [sprm_cb(0xD635, &w_twips), sprm_cb(0xD635, &w_pct)].concat();
let row = crate::table::decode(&papx);
assert_eq!(row.width_of(0), Some(72.0));
assert_eq!(row.width_of(1), Some(72.0));
assert_eq!(row.width_pct_of(2), Some(50.0));
// End-to-end document test: row cells receive the preferred widths.
let text = "a\u{7}b\u{7}c\u{7}\u{7}\r";
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
let ttp = [
grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]),
t_def_edges(&[0, 1440, 2880, 4320]),
sprm_cb(0xD635, &w_twips),
sprm_cb(0xD635, &w_pct),
]
.concat();
let spec = StructSpec {
text,
header_stories: vec![],
sections: vec![(text.chars().count() as u32, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, in_tbl), (6, 0, ttp), (7, 0, Vec::new())],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
let table = doc.body[0].as_table().expect("table block");
assert_eq!(table.rows[0].cells[0].props.width, Some(72.0));
assert_eq!(table.rows[0].cells[1].props.width, Some(72.0));
assert_eq!(table.rows[0].cells[2].props.width_pct, Some(50.0));
}