Files
wordcraft/crates/docbin/src/tests.rs
T
Senura Dissanayake 796b39dde6 docbin: open Word 97-2003 files in compatibility mode 11 (#5905)
Word 97-2003 documents were laid out with the model default of mode 15. Word opens them in mode 11, which changes justification and line breaking.

Test: a_doc_opens_in_word_97_2003_compatibility_mode
2026-11-08 04:54:06 +01:00

3042 lines
135 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> {
text_doc_with(main, extra, FibSpec { ccp_hdd, ..Default::default() }, &[])
}
/// [`text_doc`] with the FIB knobs given (`ccp_text` is filled in from `main`; `pairs` must be empty) and extra
/// Table-stream data placed after the piece table: `(pair index, bytes)` for each.
fn text_doc_with(main: &[Piece], extra: &[Piece], spec: FibSpec, table_data: &[(usize, &[u8])]) -> 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 mut pairs = vec![(33, CLX_AT, clx.len() as u32)];
for (pair, bytes) in table_data {
pairs.push((*pair, table.len() as u32, bytes.len() as u32));
table.extend_from_slice(bytes);
}
debug_assert!(spec.pairs.is_empty(), "text_doc_with sets the pairs itself");
let spec = FibSpec { ccp_text, pairs, ..spec };
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"]);
}
/// A document of `n` copies of `mark` as one compressed piece.
fn marks_doc(mark: char, n: usize) -> Vec<u8> {
text_doc(&[Piece { compressed: true, text: mark.to_string().repeat(n) }], &[], 0)
}
/// Paragraph marks are one byte each in the file but about 1 KB each in memory, so they have
/// their own cap ([`super::MAX_PARAGRAPHS`]); a file past it is refused instead of growing
/// until the machine runs out. A document exactly at the cap still opens.
#[test]
fn a_flood_of_paragraph_marks_is_refused() {
let at_cap = super::read_limited(&marks_doc('\r', 1000), None, 1000).expect("opens");
assert_eq!(at_cap.body.len(), 1000);
let over = super::read_limited(&marks_doc('\r', 1001), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
}
/// The paragraph cap is one budget for the whole document, not one per story: marks in the
/// body and in a footnote add up. (A walk that got its own budget would open both over-cap files.)
#[test]
fn the_paragraph_cap_is_shared_by_the_body_and_a_footnote() {
// One footnote at CP 0 whose text is `note` marks, stored right after the body in the stream.
let doc_of = |body: usize, note: usize| {
let cps = |v: &[u32]| v.iter().flat_map(|c| c.to_le_bytes()).collect::<Vec<u8>>();
let mut refs = cps(&[0, 1]);
refs.extend_from_slice(&[0, 0]); // the one reference's data
let txt = cps(&[0, note as u32, note as u32]); // story 0..note, then the limit
let spec = FibSpec { ccp_ftn: note as u32, ..Default::default() };
text_doc_with(
&[Piece { compressed: true, text: "\r".repeat(body) }],
&[Piece { compressed: true, text: "\r".repeat(note) }],
spec,
&[(2, &refs), (3, &txt)],
)
};
let at_cap = super::read_limited(&doc_of(600, 400), None, 1000).expect("opens");
assert_eq!(at_cap.body.len(), 600);
assert_eq!(at_cap.parts.len(), 1);
let over = super::read_limited(&doc_of(600, 401), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
let over = super::read_limited(&doc_of(601, 400), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
}
/// Cell marks are paragraph marks too: a never-closed row of them is refused the same way.
#[test]
fn a_flood_of_cell_marks_is_refused() {
let over = super::read_limited(&marks_doc('\u{7}', 5000), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
}
/// The budget counts down across spends and notes the overrun (`walk` receives one shared by
/// every story of the document).
#[test]
fn the_paragraph_budget_counts_down_and_notes_the_overrun() {
let budget = super::ParaBudget { left: std::cell::Cell::new(2), exceeded: std::cell::Cell::new(false) };
assert!(budget.spend() && budget.spend());
assert!(!budget.exceeded.get());
assert!(!budget.spend());
assert!(budget.exceeded.get());
}
/// The cap is far above any real document (10,000 pages of 50 paragraphs is 500,000), and a
/// paragraph outside a table no longer carries a table context (a `ParaOut` was 1,224 bytes).
#[test]
fn the_paragraph_cap_leaves_room_and_a_paragraph_stays_small() {
const { assert!(super::MAX_PARAGRAPHS >= 1_000_000) };
assert!(std::mem::size_of::<crate::table::ParaOut>() < 1000, "{}", std::mem::size_of::<crate::table::ParaOut>());
let doc = read(&marks_doc('\r', 10)).expect("opens");
assert_eq!(doc.body.len(), 10);
}
#[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 a_doc_opens_in_word_97_2003_compatibility_mode() {
let f = text_doc(&[Piece { compressed: false, text: "body\r".into() }], &[], 0);
let doc = read(&f).expect("opens");
// Word 97-2003 files are mode 11 in Word 2013+; mode 15 would change justification and
// line breaking.
assert_eq!(doc.settings.compat_mode, 11);
}
#[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> {
stsh_bytes_with(styles, None)
}
/// An STSH with the given styles; with `standard`, a full Stshif and ftcBi giving the standard
/// fonts' indexes (ASCII, East Asian, other, complex script), as Word writes them.
fn stsh_bytes_with(styles: &[Vec<u8>], standard: Option<[u16; 4]>) -> Vec<u8> {
let mut stshi = [(styles.len() as u16).to_le_bytes(), 10u16.to_le_bytes()].concat(); // cstd, cbSTDBaseInFile
if let Some(fonts) = standard {
stshi.extend_from_slice(&[0u8; 8]); // flags, stiMaxWhenSaved, istdMaxFixedWhenSaved, nVerBuiltInNamesWhenSaved
stshi.extend(fonts.iter().flat_map(|f| f.to_le_bytes()));
}
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>,
/// The stylesheet's standard fonts (ASCII, East Asian, other, complex script).
standard_fonts: Option<[u16; 4]>,
}
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_with(&styles, extra.standard_fonts);
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);
}
/// #5066: the default font is the stylesheet's standard font (Aptos in a Word 365 file), not
/// the font table's first entry (still Times New Roman there); the East Asian and complex-script
/// standard fonts are the defaults for those scripts, and runs name theirs with sprmCRgFtc1 and
/// sprmCFtcBi.
#[test]
fn the_default_font_is_the_stylesheets_standard_font() {
let spec = |runs: Vec<(u32, Vec<u8>)>| RichSpec {
text: "Hello\r",
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), grpprl(&[(0x4A43, &24u16.to_le_bytes())]))],
fonts: vec!["Times New Roman", "Symbol", "Arial", "Aptos", "MS Mincho", "Arial Unicode"],
chpx_runs: runs,
papx_entries: vec![(0, 0, Vec::new())],
};
let with = |standard: Option<[u16; 4]>, runs| {
read(&rich_doc_with(&spec(runs), &RichExtra { standard_fonts: standard, ..Default::default() })).expect("opens")
};
let doc = with(Some([3, 4, 3, 2]), vec![(0, Vec::new())]);
let d = &doc.styles.default_chr;
assert_eq!((d.font.as_deref(), d.font_ea.as_deref(), d.font_cs.as_deref()), (Some("Aptos"), Some("MS Mincho"), Some("Arial")));
let p = para0(&doc);
let resolved = doc.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
assert_eq!(resolved.font, "Aptos", "Normal names no font, so the text takes the standard one");
// A run's own East Asian and complex-script fonts.
let doc = with(Some([3, 4, 3, 2]), vec![(0, grpprl(&[(0x4A50, &5u16.to_le_bytes()), (0x4A5E, &3u16.to_le_bytes())]))]);
let run = &para0(&doc).runs[0].props;
assert_eq!((run.font_ea.as_deref(), run.font_cs.as_deref()), (Some("Arial Unicode"), Some("Aptos")));
// Indexes past the font table, or a header too short to hold them, keep entry 0.
for standard in [Some([99, 99, 99, 99]), None] {
let doc = with(standard, vec![(0, Vec::new())]);
let d = &doc.styles.default_chr;
assert_eq!((d.font.as_deref(), d.font_ea.as_deref(), d.font_cs.as_deref()), (Some("Times New Roman"), None, None), "{standard:?}");
}
}
/// #5066: `ftcBi` is optional in the stylesheet header, and the latent style data (`cbLSD`, 4,
/// then the LSDs) follows it: a header that leaves it out doesn't make `cbLSD` the complex-script
/// font.
#[test]
fn the_complex_script_standard_font_is_read_only_when_present() {
let header = |tail: &[u16]| -> Vec<u8> {
// cstd, cbSTDBaseInFile, flags, stiMaxWhenSaved, istdMaxFixedWhenSaved,
// nVerBuiltInNamesWhenSaved; ASCII, East Asian, other.
let words = [&[0u16, 10, 0, 1, 0, 0, 3, 4, 3][..], tail].concat();
let stshi: Vec<u8> = words.iter().flat_map(|w| w.to_le_bytes()).collect();
[(stshi.len() as u16).to_le_bytes().to_vec(), stshi].concat()
};
let bidi = |tail: &[u16]| {
let b = header(tail);
crate::stsh::standard_fonts(&b, 0, b.len() as u32).bidi
};
assert_eq!(bidi(&[2]), Some(2), "a header that ends with it (Word 97)");
assert_eq!(bidi(&[4]), Some(4), "font 4, at the end of the header");
assert_eq!(bidi(&[2, 4, 0, 0]), Some(2), "followed by the latent style data");
assert_eq!(bidi(&[4, 4, 0, 0]), Some(4), "font 4, followed by the latent style data");
assert_eq!(bidi(&[4, 0, 0]), None, "left out: cbLSD is in its place");
assert_eq!(bidi(&[]), None, "a header too short for it");
let b = header(&[4, 0, 0]);
let fonts = crate::stsh::standard_fonts(&b, 0, b.len() as u32);
assert_eq!((fonts.ascii, fonts.east_asian, fonts.other), (Some(3), Some(4), Some(3)), "the other fonts are read all the same");
}
#[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", "Wingdings"]);
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 sections_ending_off_a_paragraph_mark_attach_nowhere() {
// Hostile section ends that miss every paragraph mark are skipped; the one that ends a
// paragraph still boxes it. Paragraphs are found by their end, not scanned per section.
let mut sections: Vec<(u32, Vec<u8>)> = (1..6).map(|cp| (cp, Vec::new())).collect();
sections.push((6, grpprl(&[(0x300A, &[0x01])])));
sections.push((12, Vec::new()));
let spec = StructSpec {
text: "first\rsecond\r",
header_stories: vec![],
sections,
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 paras: Vec<_> = doc.body.iter().filter_map(|b| b.as_para()).collect();
assert!(paras.first().and_then(|p| p.section.as_ref()).expect("first paragraph boxed").title_page);
assert_eq!(paras.iter().filter(|p| p.section.is_some()).count(), 1, "the missing ends attach nowhere");
}
#[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);
}
/// Table borders read the BrcType byte as paragraph borders do; a nil Brc80 is no border.
#[test]
fn table_borders_read_border_types_as_paragraphs_do() {
use wordcraft_doc::props::BorderStyle as S;
let text = "a\u{7}\u{7}tail\r";
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
let row_end = {
let mut v = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), t_def_edges(&[0, 2880])].concat();
v.extend_from_slice(&0xD605u16.to_le_bytes());
v.push(0x18);
// Dotted, triple, double wave, nil, 3-D emboss 255/8 pt wide, all zero.
for brc in [[8, 0x06, 1, 0], [8, 0x0A, 1, 0], [8, 0x15, 1, 0], [0xFF; 4], [0xFF, 0x18, 1, 0], [0, 0, 0, 0]] {
v.extend_from_slice(&brc);
}
v
};
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), (2, 0, row_end), (3, 0, grpprl(&[(0x6424, &[0xFF, 0x01, 1, 0])]))],
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");
let b = tbl.props.borders.expect("table borders");
let seen = [b.top, b.left, b.bottom, b.right, b.between, b.inside_v].map(|s| s.map(|s| (s.style, s.is_visible(), s.width)));
let want = [
(S::Dotted, true, 1.0),
(S::Triple, true, 1.0),
(S::Wave, true, 1.0),
(S::None, false, 0.0),
(S::Single, true, 12.0),
(S::None, false, 0.25),
];
assert_eq!(seen, want.map(Some));
// A paragraph border is at most 12 points too, as a table border and a DOCX `w:sz` are.
let tail = doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.text == "tail").expect("tail paragraph");
assert_eq!(tail.props.borders.and_then(|b| b.top).map(|t| t.width), Some(12.0));
}
// ---------------------------------------------------------------------------------------------
// 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 hyperlink_target_sublocation_and_quotes() {
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com" \l "heading""#), Some("http://example.com#heading".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK \l "heading""#), Some("#heading".into()));
assert_eq!(super::hyperlink_target(r#"Hyperlink "http://example.com/a path""#), Some("http://example.com/a path".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com" \o "Help text""#), Some("http://example.com".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK \l "bm" \o "tip""#), Some("#bm".into()));
}
/// A field whose name only starts with HYPERLINK is not a hyperlink.
#[test]
fn hyperlink_target_needs_the_whole_field_name() {
assert_eq!(super::hyperlink_target(r#"HYPERLINKS "x""#), None);
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com""#), Some("http://example.com".into()));
}
/// A bare `\m` or `\n` keeps the address after it; escapes, an empty address and `%22` read as in DOCX.
#[test]
fn hyperlink_target_keeps_the_address_after_a_bare_switch() {
assert_eq!(super::hyperlink_target(r#"HYPERLINK \m "http://example.com""#), Some("http://example.com".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK \n "http://example.com""#), Some("http://example.com".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK "C:\\Docs\\f.doc""#), Some(r"C:\Docs\f.doc".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK "" \l "bm""#), Some("#bm".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com/a%22b""#), Some("http://example.com/a%22b".into()));
}
#[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).cloned().expect("first fits");
// Same location again: no new bytes.
assert_eq!(pics.get(&data, 0).map(|p| p.key.clone()), 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).cloned().expect("fits");
assert_eq!(pics.get(&data, at).map(|p| p.key.clone()), 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 repeated_cell_sprms_keep_one_override_per_cell() {
// A PAPX repeating cell-range sprms (shared by every paragraph mark that names it) keeps one
// slot per cell, the last value winning, instead of a copy per repetition.
let mut sprms = Vec::new();
for w in 0..200u16 {
let [w0, w1] = (w * 20).to_le_bytes();
sprms.push(sprm_cb(0xD635, &[0, 63, 3, w0, w1]));
sprms.push(sprm_cb(0xD62C, &[0, 255, 1]));
sprms.push([0x24, 0x56, 0, 2].to_vec());
}
let row = crate::table::decode(&sprms.concat());
assert_eq!(row.width_of(0), Some(199.0), "the last width wins");
assert_eq!(row.width_of(62), Some(199.0));
assert!(row.overrides() <= 63 + 255 + 63, "{}", row.overrides());
}
#[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));
}
// ---------------------------------------------------------------------------------------------
// #1767: symbol runs and paragraph borders.
/// Word stores a symbol character (Insert › Symbol) as the placeholder U+0028 plus
/// `sprmCSymbol` (0x6A09), which carries the font table index and the character code
/// ([MS-DOC] §2.6.1 CSymbolOperand); the text stream alone would read "(".
#[test]
fn symbol_runs_read_the_character_and_font_from_sprm_c_symbol() {
let text = "(\r";
// ftc = 1 (Wingdings), xchar = F071: the box Word draws for Wingdings 0x71.
let sym = [1u16.to_le_bytes(), 0xF071u16.to_le_bytes()].concat();
let spec = StructSpec { text, chpx_runs: vec![(0, grpprl(&[(0x6A09, &sym)]))], ..structured_spec(text, text.chars().count() as u32) };
let doc = read(&struct_doc(&spec)).expect("opens");
let p = doc.body.first().and_then(|b| b.as_para()).expect("paragraph");
assert_eq!(p.text, "\u{F071}", "the symbol, not the placeholder");
assert_eq!(p.props_of_char(0).font.as_deref(), Some("Wingdings"), "its font from the font table");
}
/// Paragraph borders around a block are read from both storage forms: the Word 97 Brc80
/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour). A thick
/// border (type 0x02) stays thick in both, as tables read it too.
#[test]
fn paragraph_borders_read_from_both_forms() {
use wordcraft_doc::props::BorderStyle as S;
let b80 = [8u8, 0x01, 0x06, 0x01]; // 1 pt single line, palette red (6), 1 pt from the text
let thick80 = [8u8, 0x02, 0x06, 0x01];
let brc = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x01, 0x02, 0x00]; // cb 8, red COLORREF, 1 pt single, space 2
let thick = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x02, 0x02, 0x00];
let text = "boxed\r\r";
let spec = StructSpec {
text,
papx_entries: vec![
(0, 0, grpprl(&[(0x6424, &b80), (0x6425, &thick80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])),
(6, 0, grpprl(&[(0xC64E, &brc), (0xC64F, &thick)])),
],
..structured_spec(text, text.chars().count() as u32)
};
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("first paragraph");
let b = p0.props.borders.expect("borders");
let top = b.top.expect("top border");
assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 1.0));
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "Brc80 thick");
assert!(b.bottom.is_some() && b.right.is_some() && b.between.is_some());
let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("second paragraph");
let b = p1.props.borders.expect("borders");
let top = b.top.expect("top border");
assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 2.0));
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "BrcOperand thick");
}
/// #3642: a small compound file whose DIFAT names one FAT sector thousands of times made `cfb`
/// build a FAT of hundreds of megabytes before checking it. The precheck refuses it first.
#[test]
fn a_difat_repeating_one_fat_sector_is_refused_before_cfb_builds_the_fat() {
let f = wordcraft_doc::cfb_guard::repeating_difat_sample();
// The precheck's own error is the proof that it ran before cfb (no clock to skew).
assert!(matches!(read(&f), Err(super::DocbinError::Container(e)) if e.contains("FAT sectors in a file of")));
assert!(!super::is_encrypted(&f));
// A file cfb wrote itself passes.
assert!(wordcraft_doc::cfb_guard::check(&cfb_file(&[("WordDocument", &[0u8; 4096])])).is_ok());
}
/// A compound file `cfb` writes in `version`: `streams` at its root, then `extra` streams under
/// an `ObjectPool` storage (where Word keeps embedded objects).
fn cfb_file_in(version: cfb::Version, streams: &[(&str, &[u8])], extra: &[(&str, &[u8])]) -> Vec<u8> {
let mut comp = cfb::CompoundFile::create_with_version(version, std::io::Cursor::new(Vec::new())).expect("cfb create");
for (name, data) in streams {
comp.create_stream(format!("/{name}")).expect("cfb stream").write_all(data).expect("cfb write");
}
comp.create_storage("/ObjectPool").expect("cfb storage");
for (name, data) in extra {
comp.create_stream(format!("/ObjectPool/{name}")).expect("cfb stream").write_all(data).expect("cfb write");
}
comp.into_inner().into_inner()
}
/// #3642 review: valid compound files pass the guard and open, so it refuses only the hostile
/// ones. The repository keeps no files Word made, so these are written by the `cfb` crate in
/// the shape of Word 97–2003 files: the WordDocument and table streams of a .doc these tests
/// build, with the summary, Data and embedded-object streams Word adds, in 512-byte sectors
/// (version 3, Word's own) and 4096-byte ones (version 4); and one with a Data stream of 8 MB,
/// past what the header's 109 FAT sector slots map in 512-byte sectors (109 × 128 sectors,
/// about 7 MB), so its FAT sectors are listed in a DIFAT chain too, which the guard walks.
#[test]
fn valid_doc_shaped_compound_files_pass_the_guard_and_open() {
let doc = struct_doc(&structured_spec("Hello world\r", 12));
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(doc.as_slice())).expect("cfb open");
// The table stream is 1Table or 0Table, as the FIB's fWhichTblStm says: kept under its name.
let table_name = if comp.is_stream("/1Table") { "1Table" } else { "0Table" };
let mut stream = |name: &str| {
let mut v = Vec::new();
std::io::Read::read_to_end(&mut comp.open_stream(name).expect("stream"), &mut v).expect("read");
v
};
let (word, table) = (stream("/WordDocument"), stream(&format!("/{table_name}")));
let summary = vec![0xFEu8; 4096];
let data = |n: usize| (0..n).map(|i| (i % 251) as u8).collect::<Vec<u8>>();
let small = data(200_000);
let big = data(8 << 20);
let ole = data(70_000);
let files = [
("as built", doc.clone()),
(
"version 3",
cfb_file_in(
cfb::Version::V3,
&[("WordDocument", &word), (table_name, &table), ("Data", &small), ("\u{5}SummaryInformation", &summary)],
&[("_1234567890", &ole)],
),
),
("version 4", cfb_file_in(cfb::Version::V4, &[("WordDocument", &word), (table_name, &table), ("Data", &small)], &[("_1", &ole)])),
("with a DIFAT chain", cfb_file_in(cfb::Version::V3, &[("WordDocument", &word), (table_name, &table), ("Data", &big)], &[])),
];
let difat_sectors = |f: &[u8]| f.get(0x48..0x4C).map_or(0, |b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
assert!(difat_sectors(&files[3].1) > 0, "the 8 MB file lists FAT sectors past the header's");
for (what, f) in &files {
assert_eq!(wordcraft_doc::cfb_guard::check(f), Ok(()), "{what}");
let doc = read(f).unwrap_or_else(|e| panic!("{what}: {e}"));
assert_eq!(body_texts(&doc).first().map(String::as_str), Some("Hello world"), "{what}");
}
}
/// #3708: a long HYPERLINK target over a long result was copied (and compared) once per result
/// character. It is now copied once per run.
#[test]
fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
let n = 250_000;
let target = "x".repeat(n);
let text: &'static str = Box::leak(format!("\u{13}HYPERLINK \"http://{target}\"\u{14}{}\u{15}\r", "y".repeat(n)).into_boxed_str());
let spec = structured_spec(text, text.chars().count() as u32);
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let linked: Vec<_> = p0.runs.iter().filter(|r| r.props.link.is_some()).collect();
assert_eq!(linked.len(), 1);
assert_eq!(linked.first().map(|r| r.len), Some(n));
// One copy of the target for the one run, not one per character (~2.5 s here): counted by
// what the copies charge to the work budget, which a busy machine can't skew.
let mut result = super::FieldResult::default();
(0..n).for_each(|_| result.push('y', wordcraft_doc::props::CharProps::default()));
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) };
let work = super::Work::new(u64::MAX);
super::finish_field(&mut super::ParaBuild::default(), f, &work);
assert_eq!(u64::MAX - work.left.get(), ("http://".len() + n) as u64, "one copy of the target");
}
/// #3708: a result whose formatting changes every character pays for a target copy per run
/// out of the work budget; past it the text stays, unlinked.
#[test]
fn hyperlink_target_copies_are_charged_to_the_work_budget() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut result = super::FieldResult::default();
(0..1000).for_each(|i| result.push('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() }));
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) };
let mut pb = super::ParaBuild::default();
let budget = super::Work::new(10_000);
super::finish_field(&mut pb, f, &budget);
assert_eq!(pb.text.len(), 1000, "all text kept");
let linked: usize = pb.runs.iter().filter_map(|r| r.props.link.as_ref()).map(|l| l.len()).sum();
assert!(linked <= 10_000, "linked bytes {linked}");
assert_eq!(pb.runs.iter().filter(|r| r.props.link.is_some()).count(), 100);
assert_eq!(budget.left.get(), 0);
assert_eq!(budget.dropped(), [super::Cost::LinkTarget], "the unlinked text is noted, to be logged");
}
/// #3871 review: what the work budget leaves out is noted once per kind, in a fixed order, and
/// logged once per kind when the document has been read; nothing is noted while it lasts.
#[test]
fn the_work_budget_notes_each_kind_it_leaves_out_once() {
use super::Cost::*;
let work = super::Work::new(10);
assert!(work.charge(10, AltText));
assert!(work.dropped().is_empty(), "spent to the last byte, nothing left out");
assert!(work.charge(0, LinkTarget), "a free copy is never refused");
for _ in 0..3 {
assert!(!work.charge(1, FloatingObject));
}
assert!(!work.charge(1, AltText));
assert_eq!(work.dropped(), [AltText, FloatingObject]);
work.log();
assert_eq!(super::LAST_DROPPED.with(|d| d.borrow().clone()), [AltText, FloatingObject]);
// A document within the budget leaves nothing out.
read(&struct_doc(&structured_spec("Hello\r", 6))).expect("opens");
assert!(super::LAST_DROPPED.with(|d| d.borrow().is_empty()));
}
/// #3692: a piece's Prm grpprl (a Prc of up to 64 KB) was re-interpreted for every character.
/// It is now resolved once per formatting change.
#[test]
fn a_large_prc_is_interpreted_once_per_formatting_change() {
let prc = grpprl(&vec![(0x0835u16, &[0x01u8][..]); 21_000]);
let text: &'static str = Box::leak(format!("{}\r", "a".repeat(100_000)).into_boxed_str());
let spec = RichSpec {
text,
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
fonts: vec!["Times New Roman"],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, Vec::new())],
};
let bytes = rich_doc_with(&spec, &RichExtra { prcs: vec![prc], prm: 0x0001, ..Default::default() });
let t = std::time::Instant::now();
let doc = read(&bytes).expect("opens");
// About 60 ms on a loaded machine; interpreting the Prc per character took 88 s.
assert!(t.elapsed().as_secs() < 10, "opened in {:?}", t.elapsed());
let p = para0(&doc);
assert_eq!(p.runs.len(), 1);
assert_eq!(p.runs.first().and_then(|r| r.props.bold), Some(true));
}
/// #3692: Prm grpprls are charged to a document-wide work budget; once it is spent the Prm
/// is skipped (the text keeps its CHPX formatting).
#[test]
fn prm_grpprls_past_the_work_budget_are_skipped() {
use wordcraft_doc::props::CharProps;
let g = grpprl(&[(0x0835, &[0x01])]);
let work = super::Work::new(g.len() as u64 + 1);
let base = CharProps::default();
let mut first = CharProps::default();
super::apply_prm(&mut first, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work);
assert_eq!(first.bold, Some(true));
let mut second = CharProps::default();
super::apply_prm(&mut second, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work);
assert_eq!(second.bold, None);
assert_eq!(work.left.get(), 1);
assert_eq!(work.dropped(), [super::Cost::Formatting]);
}
/// A field's cached result keeps one `CharProps` per formatting run, not one per character
/// (365 bytes each: 4 million result characters took 1.4 GB).
#[test]
fn a_field_result_stores_one_props_per_run() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut r = super::FieldResult::default();
for _ in 0..200_000 {
r.push('x', CharProps::default());
}
assert_eq!(r.runs.len(), 1);
assert_eq!(r.runs.first().map(|(n, _)| *n), Some(200_000));
r.push('\u{e9}', bold.clone());
r.push('y', bold);
r.push('z', CharProps::default());
assert_eq!(r.runs.iter().map(|(n, _)| *n).collect::<Vec<_>>(), [200_000, 3, 1], "a two-byte char counts its bytes");
assert_eq!(r.text.len(), 200_004);
}
/// A hyperlink whose result changes formatting, with multi-byte text, keeps every run's text and
/// links all of it; a plain field keeps its result text whole.
#[test]
fn field_results_keep_their_text_and_runs() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut result = super::FieldResult::default();
for (c, p) in
[('a', CharProps::default()), ('\u{e9}', CharProps::default()), ('\u{20ac}', bold.clone()), ('b', bold), ('c', CharProps::default())]
{
result.push(c, p);
}
let f = super::FieldBuild { depth: 1, instr: "HYPERLINK \"http://x\"".into(), sep: Some(result) };
let mut pb = super::ParaBuild::default();
super::finish_field(&mut pb, f, &super::Work::new(u64::MAX));
assert_eq!(pb.text, "a\u{e9}\u{20ac}bc");
assert_eq!(pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(), [3, 4, 1]);
assert!(pb.runs.iter().all(|r| r.props.link.as_deref() == Some("http://x")));
let doc = read(&text_doc(&[Piece { compressed: false, text: "\u{13} PAGE \u{14}1\u{e9}2\u{15}\r".into() }], &[], 0)).expect("opens");
let p = doc.body.first().and_then(|b| b.as_para()).expect("paragraph");
assert!(
p.objects
.iter()
.any(|o| matches!(o, wordcraft_doc::para::InlineObject::Field { instr, result, .. } if instr == "PAGE" && result == "1\u{e9}2")),
"{:?}",
p.objects
);
}
/// A one-page CHPX bin table over `page`: FCs 0..`end` live in page 1 of the returned stream.
fn one_page_bins(page: &[u8], end: u32) -> (Vec<u8>, crate::fkp::Bins) {
let mut word = vec![0u8; 512];
word.extend_from_slice(page);
let plc = [0u32.to_le_bytes().as_slice(), end.to_le_bytes().as_slice(), 1u32.to_le_bytes().as_slice()].concat();
let bins = crate::fkp::Bins::parse(&plc, 0, 12).expect("bins");
(word, bins)
}
/// The CHPX run a lookup reports covers exactly the FCs that give the same grpprl: the walk
/// reuses it for the following characters without asking again.
#[test]
fn a_chpx_run_covers_the_fcs_with_the_same_answer() {
let runs = [(0u32, grpprl(&[(0x0835, &[1])])), (5, Vec::new()), (9, grpprl(&[(0x0836, &[1])])), (30, grpprl(&[(0x0835, &[1])]))];
let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000);
for fc in (fc_of(0) - 4)..(fc_of(70)) {
let run = bins.chpx_run(&word, fc);
assert_eq!(run.grpprl, bins.chpx(&word, fc), "fc {fc}");
for other in (fc_of(0) - 4)..(fc_of(70)) {
if run.contains(other) {
assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}");
}
}
}
assert!(bins.chpx_run(&word, fc_of(6)).contains(fc_of(8)) && !bins.chpx_run(&word, fc_of(6)).contains(fc_of(9)));
}
/// A page whose run starts do not ascend (a corrupt file) gives the same answers as before and
/// a range that never claims an FC with a different answer.
#[test]
fn a_chpx_run_is_sound_on_a_page_whose_starts_do_not_ascend() {
let runs = [(10u32, grpprl(&[(0x0835, &[1])])), (3, grpprl(&[(0x0836, &[1])])), (20, Vec::new()), (7, grpprl(&[(0x0835, &[1])]))];
let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000);
for fc in (fc_of(0) - 4)..(fc_of(70)) {
let run = bins.chpx_run(&word, fc);
assert_eq!(run.grpprl, bins.chpx(&word, fc));
for other in (fc_of(0) - 4)..(fc_of(70)) {
if run.contains(other) {
assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}");
}
}
}
}
/// A piece span answers exactly what the per-CP lookup answers, in every piece (compressed
/// and UTF-16) and past the last.
#[test]
fn a_piece_span_matches_the_per_cp_lookups() {
let doc = text_doc(
&[
Piece { compressed: true, text: "abc".into() },
Piece { compressed: false, text: "d\u{e9}fgh".into() },
Piece { compressed: true, text: "ij".into() },
],
&[],
0,
);
let mut comp = crate::open_container(&doc).expect("container");
let table = crate::stream(&mut comp, "0Table").expect("table");
let pieces = crate::piece::PieceTable::parse(&table, CLX_AT, table.len() as u32 - CLX_AT).expect("pieces");
for cp in 0..14 {
let span = pieces.span(cp);
assert_eq!(span.and_then(|s| s.fc_at(cp)), pieces.fc_of_cp(cp), "cp {cp}");
for other in 0..14 {
if span.is_some_and(|s| s.contains(other)) {
assert_eq!(pieces.span(other).and_then(|s| s.fc_at(other)), pieces.fc_of_cp(other));
}
}
}
assert!(pieces.span(10).is_none(), "past the last piece");
}
/// Characters of one formatting share one `CharProps`: it is cloned when a run starts, and not
/// even compared while the formatting stays the same.
#[test]
fn shared_props_start_one_run_per_formatting() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut pb = super::ParaBuild::default();
for _ in 0..100_000 {
pb.push_shared('a', &bold, 1);
}
pb.push_shared('b', &CharProps::default(), 2);
pb.push_shared('c', &bold, 3);
pb.push_shared('d', &bold, 4);
assert_eq!(pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(), [100_000, 1, 2], "equal props under a new generation join the run");
pb.push('e', bold.clone());
pb.push_shared('f', &bold, 4);
assert_eq!(
pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(),
[100_000, 1, 4],
"a plain push forgets the generation but still joins an equal run"
);
assert_eq!(pb.text.len(), 100_005);
}
/// Several bins, equal and descending breakpoints, a missing page and the last FC: wherever a
/// run is reported as valid it gives the answer a fresh lookup gives.
#[test]
fn chpx_runs_are_sound_across_bins() {
let page_a = chpx_fkp(&[(0, grpprl(&[(0x0835, &[1])])), (8, grpprl(&[(0x0836, &[1])]))], 20);
let page_b = chpx_fkp(&[(20, Vec::new()), (26, grpprl(&[(0x0835, &[1])]))], 40);
let mut word = vec![0u8; 512];
word.extend_from_slice(&page_a);
word.extend_from_slice(&page_b);
let (a, b, end) = (fc_of(20), fc_of(40), fc_of(60));
let plc = |fcs: &[u32], pns: &[u32]| {
[fcs.iter().flat_map(|f| f.to_le_bytes()).collect::<Vec<u8>>(), pns.iter().flat_map(|p| p.to_le_bytes()).collect()].concat()
};
let tables = [
("two bins", plc(&[0, a, end], &[1, 2])),
("an empty bin", plc(&[0, a, a, end], &[1, 2, 2])),
("a missing page", plc(&[0, a, end], &[1, 9])),
("descending breakpoints", plc(&[0, b, a, end], &[1, 2, 1])),
("fcs up to the maximum", plc(&[0, a, u32::MAX], &[1, 2])),
];
for (what, t) in &tables {
let bins = crate::fkp::Bins::parse(t, 0, t.len() as u32).expect("bins");
let probes: Vec<u32> = (0..fc_of(50)).step_by(2).chain([u32::MAX - 1, u32::MAX]).collect();
for &fc in &probes {
let run = bins.chpx_run(&word, fc);
assert_eq!(run.grpprl, bins.chpx(&word, fc), "{what}: fc {fc}");
for &other in &probes {
if run.contains(other) {
assert_eq!(bins.chpx(&word, other), run.grpprl, "{what}: fc {fc} run reused at {other}");
}
}
}
}
}
/// The same formatting changes through a whole read: runs break exactly where the CHPX changes
/// (with a field and a symbol character in between), and nowhere else.
#[test]
fn formatting_runs_break_where_the_chpx_changes() {
let spec = RichSpec {
text: "aaaabbbb\u{13}PAGE\u{14}12\u{15}ccdd(ee\r",
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
fonts: vec!["Times New Roman", "Arial"],
chpx_runs: vec![(0, grpprl(&[(0x0835, &[1])])), (4, Vec::new()), (8, grpprl(&[(0x0835, &[1])])), (20, grpprl(&[(0x0836, &[1])]))],
papx_entries: vec![(30, 0, Vec::new())],
};
let doc = read(&rich_doc(&spec)).expect("opens");
let p = para0(&doc);
let shape: Vec<(usize, Option<bool>, Option<bool>)> = p.runs.iter().map(|r| (r.len, r.props.bold, r.props.italic)).collect();
// "aaaa" bold, "bbbb" plain plus the field's anchor (3 bytes, default formatting), "ccd" bold
// (the run from the field to CP 20), "d(ee" italic.
assert_eq!(shape, [(4, Some(true), None), (7, None, None), (3, Some(true), None), (4, None, Some(true))], "{shape:?}");
}