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