diff --git a/Cargo.lock b/Cargo.lock index 936a2f595..1508b3ee0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -618,6 +618,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f13298d9178622b7916ec5d01d470425c04273c8ed07e87b1c38b34c4f4612b" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -1057,6 +1068,24 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-doc-binary" +version = "0.1.0" +dependencies = [ + "cfb", + "chrono", + "craft-cfb", + "craft-codepage", + "craft-wordmodel", + "image", + "log", + "miniz_oxide 0.9.1", + "proptest", + "serde_json", + "sha1 0.11.0", + "thiserror 2.0.21", +] + [[package]] name = "craft-docpath" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a51b5204a..303922646 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ craft-crw = { path = "crates/crw" } craft-csv = { path = "crates/csv" } craft-dav = { path = "crates/dav" } craft-dialogs = { path = "crates/dialogs" } +craft-doc-binary = { path = "crates/doc-binary" } craft-docx = { path = "crates/docx" } craft-dxf = { path = "crates/dxf" } craft-drawingml = { path = "crates/drawingml" } diff --git a/README.md b/README.md index f50a8994b..fba3a4cff 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = ")`, `is_encrypted(bytes)`. +- Text in pieces (8-bit and UTF-16), paragraph and character formatting, styles, lists, + tables, sections, headers and footers, notes, comments, fields, bookmarks, pictures and + OfficeArt shapes, document properties. +- XOR obfuscation, RC4 and RC4 CryptoAPI password protection. + +There is no writer. + +## Limits + +Streams (1 GiB each, refused by `craft-cfb` before anything is allocated), paragraphs, pieces, +property counts, table nesting and drawing records are capped; the constants are in the source +next to what they bound, and each has a test. Hostile input gives an error or a best-effort +document, never a panic. diff --git a/crates/doc-binary/src/crypt.rs b/crates/doc-binary/src/crypt.rs new file mode 100644 index 000000000..9e139f692 --- /dev/null +++ b/crates/doc-binary/src/crypt.rs @@ -0,0 +1,455 @@ +//! Password-protected Word 97-2003 files ([MS-DOC] §2.2.6, [MS-OFFCRYPTO] §2.3.5-2.3.7). +//! +//! Three schemes exist for `.doc`: +//! +//! * **XOR obfuscation** (`fObfuscated`): a 32-bit password verifier sits in `FibBase.lKey` and +//! the streams are XORed with a 16-byte array derived from the password (method 2). +//! * **RC4** (header version 1.1): `EncryptionHeader` at the start of the Table stream; the key +//! per 512-byte block comes from MD5. +//! * **RC4 CryptoAPI** (header version 2/3/4 . 2): like RC4 but with a SHA-1 key derivation, a +//! key size of 40-128 bits and a variable-size header. +//! +//! In all three the WordDocument stream (past its first 68 bytes), the Table stream (past its +//! header, for RC4) and the whole Data stream are transformed. The password is checked against +//! the stored verifier before anything is decrypted, so a wrong password is reported and never +//! yields a garbled document. Everything is written from the public specifications. + +use sha1::{Digest, Sha1}; + +use crate::DocbinError; + +/// Bytes at the start of the WordDocument stream that are stored untransformed. +pub(crate) const FIB_PLAIN: usize = 68; +/// RC4 re-keys every this many bytes of a stream. +const BLOCK: usize = 512; +/// Longest password considered (the specifications' limit for the RC4 schemes). +const MAX_PASSWORD_CHARS: usize = 255; +/// XOR obfuscation uses at most this many single-byte password characters. +const MAX_XOR_PASSWORD: usize = 15; +/// `FibBase.flags`: the document is encrypted (`fEncrypted`) / obfuscated (`fObfuscated`). +pub(crate) const F_ENCRYPTED: u16 = 0x0100; +pub(crate) const F_OBFUSCATED: u16 = 0x8000; +/// `EncryptionHeaderFlags`: `fCryptoAPI`, `fExternal`, `fAES`. +const H_CRYPTO_API: u32 = 0x04; +const H_EXTERNAL: u32 = 0x10; +const H_AES: u32 = 0x20; + +fn unsupported(what: &str) -> DocbinError { + DocbinError::Encryption(what.into()) +} + +// --------------------------------------------------------------------------------------------- +// Primitives + +/// MD5 ([RFC 1321]); the RC4 scheme is defined on it. +pub(crate) fn md5(data: &[u8]) -> [u8; 16] { + const S: [u32; 64] = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, + 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, + ]; + // K[i] = floor(2^32 * abs(sin(i + 1))), as the RFC defines it. + let k: Vec = (0..64u32).map(|i| (f64::from(i + 1).sin().abs() * 4_294_967_296.0) as u32).collect(); + let (mut a0, mut b0, mut c0, mut d0) = (0x6745_2301u32, 0xefcd_ab89u32, 0x98ba_dcfeu32, 0x1032_5476u32); + let mut msg = Vec::with_capacity(data.len() + 72); + msg.extend_from_slice(data); + msg.push(0x80); + while msg.len() % 64 != 56 { + msg.push(0); + } + msg.extend_from_slice(&(data.len() as u64).wrapping_mul(8).to_le_bytes()); + for chunk in msg.as_chunks::<64>().0 { + let mut m = [0u32; 16]; + let words = chunk.as_chunks::<4>().0; + for (w, b) in m.iter_mut().zip(words) { + *w = u32::from_le_bytes(*b); + } + let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0); + for i in 0..64usize { + let (f, g) = match i / 16 { + 0 => ((b & c) | (!b & d), i), + 1 => ((d & b) | (!d & c), (5 * i + 1) % 16), + 2 => (b ^ c ^ d, (3 * i + 5) % 16), + _ => (c ^ (b | !d), (7 * i) % 16), + }; + let f = f.wrapping_add(a).wrapping_add(k[i]).wrapping_add(m[g]); + a = d; + d = c; + c = b; + b = b.wrapping_add(f.rotate_left(S[i])); + } + a0 = a0.wrapping_add(a); + b0 = b0.wrapping_add(b); + c0 = c0.wrapping_add(c); + d0 = d0.wrapping_add(d); + } + let mut out = [0u8; 16]; + let (cells, _) = out.as_chunks_mut::<4>(); + for (o, v) in cells.iter_mut().zip([a0, b0, c0, d0]) { + *o = v.to_le_bytes(); + } + out +} + +/// SHA-1, as [MS-OFFCRYPTO] §2.3.5 specifies for the RC4 CryptoAPI key derivation. +/// The digest is part of the file format, not a choice made here: a stronger hash would +/// make password-protected `.doc` files written by Word unreadable. +fn sha1(parts: &[&[u8]]) -> Vec { + let mut h = Sha1::new(); // NOSONAR: required by the MS-OFFCRYPTO RC4 CryptoAPI format + for p in parts { + h.update(p); + } + h.finalize().to_vec() +} + +/// The RC4 stream cipher. +pub(crate) struct Rc4 { + s: [u8; 256], + i: u8, + j: u8, +} + +impl Rc4 { + pub(crate) fn new(key: &[u8]) -> Rc4 { + let mut s = [0u8; 256]; + for (n, v) in s.iter_mut().enumerate() { + *v = n as u8; + } + let kl = key.len().max(1); + let mut j = 0u8; + for i in 0..256usize { + let k = key.get(i % kl).copied().unwrap_or(0); + j = j.wrapping_add(s[i]).wrapping_add(k); + s.swap(i, usize::from(j)); + } + Rc4 { s, i: 0, j: 0 } + } + + fn next(&mut self) -> u8 { + self.i = self.i.wrapping_add(1); + self.j = self.j.wrapping_add(self.s[usize::from(self.i)]); + self.s.swap(usize::from(self.i), usize::from(self.j)); + self.s[usize::from(self.s[usize::from(self.i)].wrapping_add(self.s[usize::from(self.j)]))] + } + + pub(crate) fn apply(&mut self, data: &mut [u8]) { + for b in data { + *b ^= self.next(); + } + } +} + +fn utf16le(password: &str) -> Vec { + password.encode_utf16().take(MAX_PASSWORD_CHARS).flat_map(u16::to_le_bytes).collect() +} + +fn u16_at(d: &[u8], at: usize) -> Option { + Some(u16::from_le_bytes(d.get(at..at.checked_add(2)?)?.try_into().ok()?)) +} + +fn u32_at(d: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?)) +} + +// --------------------------------------------------------------------------------------------- +// XOR obfuscation (method 1 key/verifier pieces, method 2 array and transform) + +const PAD_ARRAY: [u8; 15] = [0xBB, 0xFF, 0xFF, 0xBA, 0xFF, 0xFF, 0xB9, 0x80, 0x00, 0xBE, 0x0F, 0x00, 0xBF, 0x0F, 0x00]; +const INITIAL_CODE: [u16; 15] = + [0xE1F0, 0x1D0F, 0xCC9C, 0x84C0, 0x110C, 0x0E10, 0xF1CE, 0x313E, 0x1872, 0xE139, 0xD40F, 0x84F9, 0x280C, 0xA96A, 0x4EC3]; +const XOR_MATRIX: [u16; 105] = [ + 0xAEFC, 0x4DD9, 0x9BB2, 0x2745, 0x4E8A, 0x9D14, 0x2A09, 0x7B61, 0xF6C2, 0xFDA5, 0xEB6B, 0xC6F7, 0x9DCF, 0x2BBF, 0x4563, 0x8AC6, 0x05AD, 0x0B5A, + 0x16B4, 0x2D68, 0x5AD0, 0x0375, 0x06EA, 0x0DD4, 0x1BA8, 0x3750, 0x6EA0, 0xDD40, 0xD849, 0xA0B3, 0x5147, 0xA28E, 0x553D, 0xAA7A, 0x44D5, 0x6F45, + 0xDE8A, 0xAD35, 0x4A4B, 0x9496, 0x390D, 0x721A, 0xEB23, 0xC667, 0x9CEF, 0x29FF, 0x53FE, 0xA7FC, 0x5FD9, 0x47D3, 0x8FA6, 0x0F6D, 0x1EDA, 0x3DB4, + 0x7B68, 0xF6D0, 0xB861, 0x60E3, 0xC1C6, 0x93AD, 0x377B, 0x6EF6, 0xDDEC, 0x45A0, 0x8B40, 0x06A1, 0x0D42, 0x1A84, 0x3508, 0x6A10, 0xAA51, 0x4483, + 0x8906, 0x022D, 0x045A, 0x08B4, 0x1168, 0x76B4, 0xED68, 0xCAF1, 0x85C3, 0x1BA7, 0x374E, 0x6E9C, 0x3730, 0x6E60, 0xDCC0, 0xA9A1, 0x4363, 0x86C6, + 0x1DAD, 0x3331, 0x6662, 0xCCC4, 0x89A9, 0x0373, 0x06E6, 0x0DCC, 0x1021, 0x2042, 0x4084, 0x8108, 0x1231, 0x2462, 0x48C4, +]; + +/// `CreateXorKey_Method1`. `pw` is 1 to 15 bytes. +fn xor_key(pw: &[u8]) -> u16 { + let mut key = INITIAL_CODE.get(pw.len().wrapping_sub(1)).copied().unwrap_or(0); + let mut current = 0x68i32; + for &ch in pw.iter().rev() { + let mut ch = ch; + for _ in 0..7 { + if ch & 0x40 != 0 + && let Some(m) = usize::try_from(current).ok().and_then(|c| XOR_MATRIX.get(c)) + { + key ^= m; + } + ch <<= 1; + current -= 1; + } + } + key +} + +/// `CreatePasswordVerifier_Method1`. +fn verifier_method1(pw: &[u8]) -> u16 { + let mut v = 0u16; + for &b in pw.iter().rev().chain(std::iter::once(&(pw.len() as u8))) { + let i1 = u16::from(v & 0x4000 != 0); + let i2 = v.wrapping_mul(2) & 0x7FFF; + v = (i1 | i2) ^ u16::from(b); + } + v ^ 0xCE4B +} + +/// `CreatePasswordVerifier_Method2`: the 32-bit value stored in `FibBase.lKey`. +pub(crate) fn xor_verifier(pw: &[u8]) -> u32 { + (u32::from(xor_key(pw)) << 16) | u32::from(verifier_method1(pw)) +} + +/// `CreateXorArray_Method2`. +pub(crate) fn xor_array(pw: &[u8]) -> [u8; 16] { + let key = (xor_verifier(pw) >> 16) as u16; + let (high, low) = ((key >> 8) as u8, key as u8); + let mut arr = [0u8; 16]; + for (i, slot) in arr.iter_mut().enumerate() { + *slot = pw.get(i).copied().unwrap_or_else(|| PAD_ARRAY.get(i.saturating_sub(pw.len())).copied().unwrap_or(0)); + } + let (pairs, _) = arr.as_chunks_mut::<2>(); + for pair in pairs { + pair[0] = (pair[0] ^ low).rotate_right(1); + pair[1] = (pair[1] ^ high).rotate_right(1); + } + arr +} + +/// The single-byte forms of a password the XOR scheme tries: the system-codepage form (Latin-1 +/// here) and the "low byte, or high byte when the low one is zero" form. Each is cut to 15. +fn xor_candidates(password: &str) -> Vec> { + let mut out: Vec> = Vec::new(); + let latin: Option> = password.chars().take(MAX_XOR_PASSWORD).map(|c| u8::try_from(u32::from(c)).ok()).collect(); + if let Some(l) = latin { + out.push(l); + } + let folded: Vec = password.encode_utf16().take(MAX_XOR_PASSWORD).map(|u| if u & 0xFF == 0 { (u >> 8) as u8 } else { u as u8 }).collect(); + if !out.contains(&folded) { + out.push(folded); + } + out.retain(|p| !p.is_empty()); + out +} + +// --------------------------------------------------------------------------------------------- +// The cipher + +/// How the streams of one file are transformed, once the password has been verified. +pub(crate) enum Cipher { + Xor { array: [u8; 16] }, + Rc4 { base: Vec, crypto_api: bool, key_len: usize, table_skip: usize }, +} + +impl Cipher { + /// Check `password` against the file's verifier and set up the cipher. `flags` and `lkey` come + /// from the FIB base, `table` is the (still encrypted) Table stream. + pub(crate) fn open(flags: u16, lkey: u32, table: &[u8], password: &str) -> Result { + if flags & F_OBFUSCATED != 0 { + for pw in xor_candidates(password) { + if xor_verifier(&pw) == lkey { + return Ok(Cipher::Xor { array: xor_array(&pw) }); + } + } + return Err(DocbinError::WrongPassword); + } + let short = || DocbinError::Malformed("the encryption header is truncated".into()); + let major = u16_at(table, 0).ok_or_else(short)?; + let minor = u16_at(table, 2).ok_or_else(short)?; + match (major, minor) { + (1, 1) => Self::open_rc4(table, password), + (2..=4, 2) => Self::open_crypto_api(table, password), + _ => Err(unsupported(&format!("unknown encryption version {major}.{minor}"))), + } + } + + fn open_rc4(table: &[u8], password: &str) -> Result { + let short = || DocbinError::Malformed("the encryption header is truncated".into()); + let salt = table.get(4..20).ok_or_else(short)?; + let enc_verifier = table.get(20..36).ok_or_else(short)?; + let enc_hash = table.get(36..52).ok_or_else(short)?; + let h0 = md5(&utf16le(password)); + let mut buf = Vec::with_capacity(336); + for _ in 0..16 { + buf.extend_from_slice(&h0[..5]); + buf.extend_from_slice(salt); + } + let base = md5(&buf)[..5].to_vec(); + let cipher = Cipher::Rc4 { base, crypto_api: false, key_len: 16, table_skip: 52 }; + let mut rc4 = Rc4::new(&cipher.block_key(0)); + let mut verifier = [0u8; 16]; + verifier.copy_from_slice(enc_verifier); + let mut hash = [0u8; 16]; + hash.copy_from_slice(enc_hash); + rc4.apply(&mut verifier); + rc4.apply(&mut hash); + if md5(&verifier) != hash { + return Err(DocbinError::WrongPassword); + } + Ok(cipher) + } + + fn open_crypto_api(table: &[u8], password: &str) -> Result { + let short = || DocbinError::Malformed("the encryption header is truncated".into()); + let hflags = u32_at(table, 4).ok_or_else(short)?; + if hflags & H_EXTERNAL != 0 { + return Err(unsupported("the document uses an external encryption provider")); + } + // [MS-OFFCRYPTO] defines AES only for the encrypted packages of Word 2007 and later + // (which open through the .docx reader whatever their extension); a Word 97-2003 file + // may only use XOR, RC4 or RC4 CryptoAPI, so there is no public scheme to follow here. + if hflags & H_AES != 0 { + return Err(unsupported( + "this Word 97-2003 file claims AES encryption, which is only defined for the newer encrypted-package format; it can't be opened", + )); + } + if hflags & H_CRYPTO_API == 0 { + return Err(unsupported("unknown encryption header")); + } + let hsize = usize::try_from(u32_at(table, 8).ok_or_else(short)?).unwrap_or(usize::MAX); + // EncryptionHeader: flags, sizeExtra, algId, algIdHash, keySize, providerType, 2 reserved + // words and the CSP name; it starts at byte 12. + if hsize < 32 || hsize > table.len() { + return Err(DocbinError::Malformed("the encryption header has a bad size".into())); + } + let alg_id = u32_at(table, 20).ok_or_else(short)?; + let alg_hash = u32_at(table, 24).ok_or_else(short)?; + if !matches!(alg_id, 0 | 0x6801) { + return Err(unsupported(&format!("encryption algorithm 0x{alg_id:X}"))); + } + if !matches!(alg_hash, 0 | 0x8004) { + return Err(unsupported(&format!("hash algorithm 0x{alg_hash:X}"))); + } + let mut bits = u32_at(table, 28).ok_or_else(short)?; + if bits == 0 { + bits = 40; + } + if !(40..=128).contains(&bits) || bits % 8 != 0 { + return Err(unsupported(&format!("a {bits}-bit key"))); + } + let key_len = (bits / 8) as usize; + // EncryptionVerifier: saltSize, salt[16], encryptedVerifier[16], verifierHashSize, + // encryptedVerifierHash[20]. + let v = 12 + hsize; + if u32_at(table, v) != Some(16) { + return Err(DocbinError::Malformed("the encryption salt has a bad size".into())); + } + let salt = table.get(v + 4..v + 20).ok_or_else(short)?; + let enc_verifier = table.get(v + 20..v + 36).ok_or_else(short)?; + if u32_at(table, v + 36) != Some(20) { + return Err(unsupported("a verifier hash that is not SHA-1")); + } + let enc_hash = table.get(v + 40..v + 60).ok_or_else(short)?; + let mut pw = utf16le(password); + let base = sha1(&[salt, &pw]); + pw.fill(0); + let cipher = Cipher::Rc4 { base, crypto_api: true, key_len, table_skip: v + 60 }; + let mut rc4 = Rc4::new(&cipher.block_key(0)); + let mut verifier = enc_verifier.to_vec(); + let mut hash = enc_hash.to_vec(); + rc4.apply(&mut verifier); + rc4.apply(&mut hash); + if sha1(&[&verifier]) != hash { + return Err(DocbinError::WrongPassword); + } + Ok(cipher) + } + + /// The RC4 key of stream block `block`. + fn block_key(&self, block: u32) -> Vec { + match self { + Cipher::Xor { .. } => Vec::new(), + Cipher::Rc4 { base, crypto_api: false, .. } => md5(&[base.as_slice(), &block.to_le_bytes()].concat()).to_vec(), + Cipher::Rc4 { base, crypto_api: true, key_len, .. } => { + let mut k = sha1(&[base, &block.to_le_bytes()]); + k.truncate(*key_len); + // A 40-bit key is zero-padded to 128 bits. + if *key_len == 5 { + k.resize(16, 0); + } + k + } + } + } + + /// Decrypt `data` in place; its first `skip` bytes are stored untransformed. + fn decrypt(&self, data: &mut [u8], skip: usize) { + match self { + Cipher::Xor { array } => { + for (i, b) in data.iter_mut().enumerate().skip(skip) { + let a = array[i % 16]; + // Zero bytes, and bytes equal to the array element, were left as they were. + if *b != 0 && *b != a { + *b ^= a; + } + } + } + Cipher::Rc4 { .. } => { + for (n, chunk) in data.chunks_mut(BLOCK).enumerate() { + let Ok(block) = u32::try_from(n) else { return }; + let mut rc4 = Rc4::new(&self.block_key(block)); + let start = n * BLOCK; + // The keystream runs over the untransformed bytes too. + let plain = skip.saturating_sub(start).min(chunk.len()); + let head = chunk.get(..plain).map(<[u8]>::to_vec).unwrap_or_default(); + rc4.apply(chunk); + if let Some(dst) = chunk.get_mut(..plain) { + dst.copy_from_slice(&head); + } + } + } + } + } + + pub(crate) fn decrypt_word(&self, data: &mut [u8]) { + self.decrypt(data, FIB_PLAIN); + } + + pub(crate) fn decrypt_table(&self, data: &mut [u8]) { + let skip = match self { + Cipher::Xor { .. } => 0, + Cipher::Rc4 { table_skip, .. } => *table_skip, + }; + self.decrypt(data, skip); + } + + pub(crate) fn decrypt_data(&self, data: &mut [u8]) { + self.decrypt(data, 0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() + } + + #[test] + fn md5_matches_the_rfc_1321_vectors() { + assert_eq!(hex(&md5(b"")), "d41d8cd98f00b204e9800998ecf8427e"); + assert_eq!(hex(&md5(b"a")), "0cc175b9c0f1b6a831c399e269772661"); + assert_eq!(hex(&md5(b"abc")), "900150983cd24fb0d6963f7d28e17f72"); + assert_eq!(hex(&md5(b"message digest")), "f96b697d7cb7938d525a2f31aaf161d0"); + assert_eq!( + hex(&md5(b"12345678901234567890123456789012345678901234567890123456789012345678901234567890")), + "57edf4a22be3c955ac49da2e2107b67a" + ); + } + + #[test] + fn rc4_matches_a_published_vector() { + let mut d = b"Plaintext".to_vec(); + Rc4::new(b"Key").apply(&mut d); + assert_eq!(hex(&d), "bbf316e8d940af0ad3"); + } + + #[test] + fn xor_candidates_cut_to_fifteen_and_skip_empty() { + assert!(xor_candidates("").is_empty()); + assert_eq!(xor_candidates("abcdefghijklmnopqrst")[0].len(), 15); + // Not representable as single bytes: only the folded form remains. + assert_eq!(xor_candidates("\u{4e2d}").len(), 1); + } +} diff --git a/crates/doc-binary/src/drawing.rs b/crates/doc-binary/src/drawing.rs new file mode 100644 index 000000000..9a5483b53 --- /dev/null +++ b/crates/doc-binary/src/drawing.rs @@ -0,0 +1,283 @@ +//! Floating shapes and pictures: the `PlcfSpa` anchors of the main document and the headers +//! ([MS-DOC] §2.8.27 PlcfSpa, §2.9.253 Spa) joined with the OfficeArt shapes they name, and the +//! text box stories (`PlcftxbxTxt`, §2.8.30) that fill text boxes. + +use std::collections::HashMap; + +use craft_wordmodel::fill::Fill; +use craft_wordmodel::para::{Anchor, Float, FloatAlign, GroupChild, InlineObject, ShapeKind, Wrap}; +use craft_wordmodel::props::Rgb; + +use crate::media::Pictures; +use crate::officeart::{Node, Sp}; + +/// Most anchors (and text box stories) read per PLC. +const MAX_ANCHORS: usize = 10_000; +/// `Spa` and `FTXBXS` record sizes. +const CB_SPA: usize = 26; +const CB_FTXBXS: usize = 22; +/// Largest frame or offset kept (points; 22 inches, Word's own limit for sizes). +const MAX_PT: f32 = 1584.0; +/// Deepest group nesting flattened. +const MAX_GROUP_DEPTH: usize = 8; + +/// The (CP, data) entries of a PLC with `cb`-byte data elements, the trailing CP dropped. +fn plc(table: &[u8], (fc, lcb): (u32, u32), cb: usize) -> Vec<(u32, &[u8])> { + let Some(blob) = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize)) else { return Vec::new() }; + let n = blob.len().saturating_sub(4) / (4 + cb); + let n = n.min(MAX_ANCHORS); + let data_at = (n + 1) * 4; + (0..n) + .filter_map(|i| { + let cp = u32::from_le_bytes(blob.get(i * 4..i * 4 + 4)?.try_into().ok()?); + let at = data_at + i * cb; + Some((cp, blob.get(at..at + cb)?)) + }) + .collect() +} + +/// The text box stories of a `PlcftxbxTxt` (or `PlcfHdrtxbxTxt`): (shape spid, story CP range +/// relative to the text box subdocument). +/// +/// The CPs of a well-formed PLC ascend, so the stories are disjoint. A story starting before +/// the end of the last one kept (a hostile table pointing many text boxes at the same text, +/// each read into its own part) is dropped: every subdocument CP becomes at most one story. +pub(crate) fn textbox_stories(table: &[u8], pair: Option<(u32, u32)>) -> Vec<(u32, (u32, u32))> { + let Some(pair) = pair else { return Vec::new() }; + let Some(blob) = table.get(pair.0 as usize..(pair.0 as usize).saturating_add(pair.1 as usize)) else { return Vec::new() }; + let mut out = Vec::new(); + let mut kept_to = 0u32; + for (i, (cp, ftxbxs)) in plc(table, pair, CB_FTXBXS).iter().enumerate() { + // The story runs to the next entry's CP (the PLC's last CP for the last entry). + let Some(end) = blob.get((i + 1) * 4..(i + 1) * 4 + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes) else { continue }; + let Some(lid) = ftxbxs.get(14..18).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes) else { continue }; + if end > *cp && *cp >= kept_to && lid != 0 { + out.push((lid, (*cp, end))); + kept_to = end; + } + } + out +} + +/// What building the floating objects needs. +pub(crate) struct Build<'a> { + /// The BLIP store and the stream holding BLIPs not embedded in it (WordDocument). + pub(crate) bstore: &'a [&'a [u8]], + pub(crate) delay: &'a [u8], + /// Text box part ids by shape spid. + pub(crate) stories: &'a HashMap, +} + +/// The floating objects anchored by `PlcfSpa` (at `pair`), as (absolute anchor CP, object), +/// sorted by CP. `cp_base` turns the PLC's CPs into absolute ones (the header subdocument's +/// start for `PlcSpaHdr`). +pub(crate) fn anchors( + table: &[u8], + pair: Option<(u32, u32)>, + cp_base: u32, + shapes: &HashMap, + b: &Build, + pics: &mut Pictures, +) -> Vec<(u32, InlineObject)> { + let Some(pair) = pair else { return Vec::new() }; + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for (cp, spa) in plc(table, pair, CB_SPA) { + let i32_at = |o: usize| spa.get(o..o + 4).and_then(|b| b.try_into().ok()).map(i32::from_le_bytes).unwrap_or(0); + let spid = i32_at(0) as u32; + let [xl, yt, xr, yb] = [4, 8, 12, 16].map(i32_at); + let flags = spa.get(20..22).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0); + let Some(node) = shapes.get(&spid) else { continue }; + // Word anchors each shape once. A repeated spid would build another copy of the shape, + // and of every member of a group, per anchor (#3710). + if !seen.insert(spid) { + continue; + } + let tw = |v: i64| (v as f32 / 20.0).clamp(-2.0 * MAX_PT, 2.0 * MAX_PT); + let (mut x, mut y) = (tw(xl as i64), tw(yt as i64)); + let (mut w, mut h) = (tw(xr as i64 - xl as i64).clamp(0.0, MAX_PT), tw(yb as i64 - yt as i64).clamp(0.0, MAX_PT)); + let sp = node.sp(); + let rot = craft_wordmodel::geom::normalize_degrees(if sp.props.rot.is_finite() { sp.props.rot } else { 0.0 }); + // A shape turned by 45°–135° (or 225°–315°) is anchored by its turned bounds: the + // unrotated frame has the other aspect, about the same centre. + if (45.0..135.0).contains(&rot) || (225.0..315.0).contains(&rot) { + x += (w - h) / 2.0; + y += (h - w) / 2.0; + std::mem::swap(&mut w, &mut h); + } + let mut float = spa_float(flags, &sp.props); + float.x = x; + float.y = y; + float.rot = rot; + float.flip_h = sp.flip_h(); + float.flip_v = sp.flip_v(); + let obj = match node { + Node::Shape(sp) => leaf(sp, w, h, float, b, pics), + Node::Group(sp, members) => group(sp, members, w, h, float, b, pics), + }; + match obj { + Some(obj) => out.push((cp_base.saturating_add(cp), obj)), + None => pics.dropped(cp_base.saturating_add(cp)), + } + } + out.sort_by_key(|(cp, _)| *cp); + out +} + +/// Placement from a `Spa`'s flags (fHdr, bx, by, wr, wrk, fRcaSimple, fBelowText, fAnchorLock) +/// and the shape's position properties, which take precedence when present. +fn spa_float(flags: u16, p: &crate::officeart::Props) -> Float { + let bx = (flags >> 1) & 3; + let by = (flags >> 3) & 3; + let wr = (flags >> 5) & 0xF; + let below = flags & (1 << 14) != 0; + let wrap = match wr { + 1 => Wrap::TopAndBottom, + 3 => { + if below { + Wrap::BehindText + } else { + Wrap::InFrontOfText + } + } + 4 => Wrap::Tight, + 5 => Wrap::Through, + _ => Wrap::Square, + }; + let h_rel = match p.posrelh.unwrap_or(bx as u32) { + 0 => Anchor::Margin, + 1 => Anchor::Page, + 3 => Anchor::Character, + _ => Anchor::Column, + }; + let v_rel = match p.posrelv.unwrap_or(by as u32) { + 0 => Anchor::Margin, + 1 => Anchor::Page, + 3 => Anchor::Line, + _ => Anchor::Paragraph, + }; + let align = |v: Option| match v { + Some(1) => Some(FloatAlign::Start), + Some(2) => Some(FloatAlign::Center), + Some(3) => Some(FloatAlign::End), + Some(4) => Some(FloatAlign::Inside), + Some(5) => Some(FloatAlign::Outside), + _ => None, + }; + // Distances from text, EMUs; Word's defaults are 0.125" at the sides, none above and below. + let emu = |v: Option, def: f32| v.map_or(def, |e| (e as f32 / 12_700.0).clamp(0.0, MAX_PT)); + let [dl, dt, dr, db] = p.wrap_dist; + Float { + wrap, + h_rel, + v_rel, + h_align: align(p.posh), + v_align: align(p.posv), + dist: emu(dl, 9.0).max(emu(dr, 9.0)), + dist_top: emu(dt, 0.0), + dist_bottom: emu(db, 0.0), + ..Float::default() + } +} + +/// One shape as a picture or a drawn shape, `w` × `h` points. +fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -> Option { + let p = &sp.props; + if let Some(pib) = p.pib { + let media = pics.bse(pib, b.bstore, b.delay)?; + return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, fx: Default::default(), ole: None }); + } + let story = b.stories.get(&sp.spid).copied().filter(|_| sp.textbox || sp.spt == 202); + let mut kind = match sp.spt { + 1 => ShapeKind::Rectangle, + 2 => ShapeKind::RoundedRectangle, + 3 => ShapeKind::Ellipse, + 4 => ShapeKind::Diamond, + 5 => ShapeKind::Triangle, + 6 => ShapeKind::RightTriangle, + 12 | 187 => ShapeKind::Star, + 13 | 69 => ShapeKind::Arrow, + 66 => ShapeKind::ArrowLeft, + 67 => ShapeKind::ArrowDown, + 68 => ShapeKind::ArrowUp, + 20 | 32 => ShapeKind::Line, + 74 => ShapeKind::Heart, + 202 => ShapeKind::TextBox, + // A freeform's path is not read: better left out than drawn as a box. + 0 => return None, + _ => ShapeKind::Rectangle, + }; + if story.is_some() && kind == ShapeKind::Rectangle { + kind = ShapeKind::TextBox; + } + let line = kind == ShapeKind::Line; + // Brackets and braces (85 to 88, bracket and brace pairs 185, 186) are unfilled unless + // `fFilled` says otherwise, as Word's VML shapetypes for them say (`filled="f"`). They are + // drawn as their box here, which a default fill would make an opaque block over the text. + let unfilled = p.filled == Some(false) || (matches!(sp.spt, 85..=88 | 185 | 186) && p.filled != Some(true)); + let fill = if line || unfilled { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) }; + let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) }; + // Line widths are EMUs; Word's default is 0.75 pt. + let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0)); + Some(InlineObject::Shape { + kind, + w, + h, + fill, + stroke, + stroke_width, + float, + story, + freeform: None, + effects: Default::default(), + extra: Default::default(), + }) +} + +/// A group: its members flattened into the group's own coordinate space (nested groups mapped +/// through their child anchors). +fn group(sp: &Sp, members: &[Node], w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -> Option { + let [l, t, r, btm] = sp.group_space.unwrap_or([0, 0, 0, 0]); + let (cw, ch) = ((r as f64 - l as f64) as f32, (btm as f64 - t as f64) as f32); + let (ch_w, ch_h) = if cw > 0.0 && ch > 0.0 { (cw, ch) } else { (w.max(1.0), h.max(1.0)) }; + let mut children = Vec::new(); + // Map: outer = x0 + inner × s; the top group's space starts at its FSPGR origin. + members_into(members, (-(l as f32), -(t as f32), 1.0, 1.0), 0, b, pics, &mut children); + if children.is_empty() { + return None; + } + Some(InlineObject::Group { w, h, float, ch_w, ch_h, children, canvas: None }) +} + +fn members_into(nodes: &[Node], t: (f32, f32, f32, f32), depth: usize, b: &Build, pics: &mut Pictures, out: &mut Vec) { + let (x0, y0, sx, sy) = t; + let fit = |v: f32| if v.is_finite() { v.clamp(-1e7, 1e7) } else { 0.0 }; + for n in nodes { + if out.len() >= craft_wordmodel::para::MAX_GROUP_CHILDREN { + return; + } + let sp = n.sp(); + let Some([l, t, r, btm]) = sp.child else { continue }; + let (ox, oy) = (l as f32, t as f32); + let (ew, eh) = ((r as f64 - l as f64) as f32, (btm as f64 - t as f64) as f32); + match n { + Node::Group(_, members) => { + if depth >= MAX_GROUP_DEPTH { + continue; + } + let [gl, gt, gr, gb] = sp.group_space.unwrap_or([l, t, r, btm]); + let (gw, gh) = ((gr as f64 - gl as f64) as f32, (gb as f64 - gt as f64) as f32); + let (kx, ky) = if gw > 0.0 && gh > 0.0 { (ew / gw, eh / gh) } else { (1.0, 1.0) }; + let nt = (fit(x0 + (ox - gl as f32 * kx) * sx), fit(y0 + (oy - gt as f32 * ky) * sy), fit(sx * kx), fit(sy * ky)); + members_into(members, nt, depth + 1, b, pics, out); + } + Node::Shape(sp) => { + let float = Float { rot: sp.props.rot, flip_h: sp.flip_h(), flip_v: sp.flip_v(), ..Float::default() }; + let (w, h) = (fit(ew * sx).max(0.0), fit(eh * sy).max(0.0)); + if let Some(obj) = leaf(sp, w, h, float, b, pics) { + out.push(GroupChild { x: fit(x0 + ox * sx), y: fit(y0 + oy * sy), obj }); + } + } + } + } +} diff --git a/crates/doc-binary/src/fib.rs b/crates/doc-binary/src/fib.rs new file mode 100644 index 000000000..7bb3ec149 --- /dev/null +++ b/crates/doc-binary/src/fib.rs @@ -0,0 +1,185 @@ +//! The File Information Block ([MS-DOC] §2.5): `FibBase` plus the variable sections +//! (`FibRgW97`, `FibRgLw97`, the `RgFcLcb` blob, `FibRgCswNew`) at the start of the +//! WordDocument stream. + +use crate::DocbinError; + +/// `wIdent` of every Word 97-2003 file (Word 6/95 wrote 0xA5DC). +const W_IDENT: u16 = 0xA5EC; +/// Offsets within the FIB, in bytes (see the spec's Fib layout). +const OFF_FLAGS: usize = 0x0A; +const OFF_CSW: usize = 0x20; +const OFF_CSLW: usize = 0x3E; +const OFF_RGLW: usize = 0x40; +const OFF_CB_RG_FCLCB: usize = 0x98; +/// `csw` (count of 16-bit values in `FibRgW97`) — fixed by the spec. +const CSW: u16 = 0x000E; +/// `cslw` (count of 32-bit values in `FibRgLw97`) — fixed by the spec. +const CSLW: u16 = 0x0016; +/// `cbRgFcLcb` for each known `nFib` ([MS-DOC] Fib.cbRgFcLcb table). +const CB_RG_FCLCB: &[(u16, u16)] = &[(0x00C1, 0x005D), (0x00D9, 0x006C), (0x0101, 0x0088), (0x010C, 0x00A4), (0x0112, 0x00B7)]; + +/// Indices of `fc`/`lcb` pairs inside `FibRgFcLcb97` (each pair is 8 bytes: fc then lcb). +pub(crate) mod pair { + pub(crate) const STSHF: usize = 1; + pub(crate) const PLCF_FND_REF: usize = 2; + pub(crate) const PLCF_FND_TXT: usize = 3; + pub(crate) const PLCF_SED: usize = 6; + pub(crate) const PLCF_HDD: usize = 11; + pub(crate) const PLCF_BTE_CHPX: usize = 12; + pub(crate) const PLCF_BTE_PAPX: usize = 13; + pub(crate) const STTBF_FFN: usize = 15; + pub(crate) const STTBF_BKMK: usize = 21; + pub(crate) const PLCF_BKF: usize = 22; + pub(crate) const PLCF_BKL: usize = 23; + pub(crate) const CLX: usize = 33; + /// `fcPlcSpaMom` / `fcPlcSpaHdr`: floating shape anchors of the main document / headers. + pub(crate) const PLC_SPA_MOM: usize = 40; + pub(crate) const PLC_SPA_HDR: usize = 41; + pub(crate) const PLCF_END_REF: usize = 46; + pub(crate) const PLCF_END_TXT: usize = 47; + /// `fcDggInfo`: the OfficeArt drawing content (BLIP store, shapes). + pub(crate) const DGG_INFO: usize = 50; + /// `fcPlcftxbxTxt` / `fcPlcfHdrtxbxTxt`: text box stories of the main document / headers. + pub(crate) const PLCF_TXBX_TXT: usize = 56; + pub(crate) const PLCF_HDR_TXBX_TXT: usize = 58; + /// `fcPlfLst`: the list definitions (LSTF array, with the LVLs appended after the blob). + pub(crate) const PLF_LST: usize = 73; + /// `fcPlfLfo`: the list format overrides (LFO array with LFOData appended). + pub(crate) const PLF_LFO: usize = 74; +} + +/// Character-position ranges of the subdocuments, from `FibRgLw97`. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct SubDocs { + pub(crate) text: u32, + pub(crate) ftn: u32, + pub(crate) hdd: u32, + pub(crate) atn: u32, + pub(crate) edn: u32, + pub(crate) txbx: u32, + pub(crate) hdr_txbx: u32, +} + +impl SubDocs { + /// Total CPs across all subdocuments, checked against a cap. + pub(crate) fn total(&self) -> Result { + let t = u64::from(self.text) + + u64::from(self.ftn) + + u64::from(self.hdd) + + u64::from(self.atn) + + u64::from(self.edn) + + u64::from(self.txbx) + + u64::from(self.hdr_txbx); + // Word's own documents stay far below this; a larger total means a corrupt FIB + // (the ccp fields are signed and "MUST be 0, 1, or greater"). + if t > 100_000_000 { + return Err(DocbinError::Limit(format!("document claims {t} characters"))); + } + Ok(t) + } +} + +/// What the unencrypted head of a password-protected file says (see [`peek_encryption`]). +#[derive(Clone, Copy, Debug)] +pub(crate) struct Protection { + pub(crate) flags: u16, + /// `FibBase.lKey`: the XOR verifier, or the size of the RC4 header in the Table stream. + pub(crate) lkey: u32, + pub(crate) which_tbl_stm: bool, +} + +/// Is the WordDocument stream that of an encrypted Word 97-2003 file? Only the first 68 bytes are +/// stored in the clear; this reads nothing else. `None` for anything else, including streams that +/// are too short or have a foreign magic (the full parse reports those). +pub(crate) fn peek_encryption(word: &[u8]) -> Option { + if word.len() < crate::crypt::FIB_PLAIN || word.get(0..2)? != W_IDENT.to_le_bytes() { + return None; + } + let flags = u16::from_le_bytes([*word.get(OFF_FLAGS)?, *word.get(OFF_FLAGS + 1)?]); + if flags & crate::crypt::F_ENCRYPTED == 0 { + return None; + } + let lkey = u32::from_le_bytes([*word.get(0x0E)?, *word.get(0x0F)?, *word.get(0x10)?, *word.get(0x11)?]); + Some(Protection { flags, lkey, which_tbl_stm: flags & 0x0200 != 0 }) +} + +/// The parsed FIB. +#[derive(Clone, Debug)] +pub(crate) struct Fib { + /// Selects 1Table (true) or 0Table (false). + pub(crate) which_tbl_stm: bool, + pub(crate) ccp: SubDocs, + /// The `RgFcLcb` pairs flattened as fc₀, lcb₀, fc₁, lcb₁, … + pub(crate) pairs: Vec, +} + +impl Fib { + /// The (fc, lcb) pair at `FibRgFcLcb97` index `i` (later blobs follow contiguously). + pub(crate) fn pair(&self, i: usize) -> Option<(u32, u32)> { + let fc = *self.pairs.get(i * 2)?; + let lcb = *self.pairs.get(i * 2 + 1)?; + Some((fc, lcb)) + } + + /// Parse the FIB from the head of the WordDocument stream. + pub(crate) fn parse(word: &[u8]) -> Result { + let u16le = |at: usize| -> u16 { u16::from_le_bytes([word.get(at).copied().unwrap_or(0), word.get(at + 1).copied().unwrap_or(0)]) }; + let u32le = |at: usize| -> u32 { + u32::from_le_bytes([ + word.get(at).copied().unwrap_or(0), + word.get(at + 1).copied().unwrap_or(0), + word.get(at + 2).copied().unwrap_or(0), + word.get(at + 3).copied().unwrap_or(0), + ]) + }; + if word.len() < OFF_RGLW + 88 { + return Err(DocbinError::NotWord(format!("WordDocument stream is {} bytes", word.len()))); + } + let w_ident = u16le(0); + if w_ident == 0xA5DC { + return Err(DocbinError::NotWord("Word 6.0/95 format, only Word 97-2003 is supported".into())); + } + if w_ident != W_IDENT { + return Err(DocbinError::NotWord(format!("magic 0x{w_ident:04X}, expected 0x{W_IDENT:04X}"))); + } + let flags = u16le(OFF_FLAGS); + let nfib = u16le(2); + if u16le(OFF_CSW) != CSW { + return Err(DocbinError::NotWord(format!("csw is 0x{:04X}", u16le(OFF_CSW)))); + } + if u16le(OFF_CSLW) != CSLW { + return Err(DocbinError::NotWord(format!("cslw is 0x{:04X}", u16le(OFF_CSLW)))); + } + // rglw u32 indices per FibRgLw97: cbMac, r1, r2, ccpText, ccpFtn, ccpHdd, r3, ccpAtn, + // ccpEdn, ccpTxbx, ccpHdrTxbx, … + let lw = |i: usize| -> u32 { u32le(OFF_RGLW + i * 4) }; + // The ccp fields are signed in the spec; negative values are corrupt input, clamped. + let ccp = |i: usize| -> u32 { (lw(i) as i32).max(0) as u32 }; + let sub = SubDocs { text: ccp(3), ftn: ccp(4), hdd: ccp(5), atn: ccp(7), edn: ccp(8), txbx: ccp(9), hdr_txbx: ccp(10) }; + sub.total()?; + + let cb_rg_fclcb = u16le(OFF_CB_RG_FCLCB) as usize; + if cb_rg_fclcb == 0 { + return Err(DocbinError::NotWord("empty RgFcLcb".into())); + } + if let Some(&(_, expected)) = CB_RG_FCLCB.iter().find(|(n, _)| *n == nfib) + && cb_rg_fclcb < expected as usize + { + // Word itself always writes the full blob for its version; anything smaller is + // corrupt, but a *larger* blob (newer writer) is fine — we just read what's there. + return Err(DocbinError::NotWord(format!("cbRgFcLcb is 0x{cb_rg_fclcb:04X} for nFib 0x{nfib:04X}"))); + } + let blob_off = OFF_CB_RG_FCLCB + 2; + let blob_len = cb_rg_fclcb.saturating_mul(8); + if word.len() < blob_off + blob_len { + return Err(DocbinError::NotWord("truncated RgFcLcb".into())); + } + let mut pairs = Vec::with_capacity(cb_rg_fclcb * 2); + for i in 0..cb_rg_fclcb * 2 { + pairs.push(u32le(blob_off + i * 4)); + } + + Ok(Fib { which_tbl_stm: flags & 0x0200 != 0, ccp: sub, pairs }) + } +} diff --git a/crates/doc-binary/src/fkp.rs b/crates/doc-binary/src/fkp.rs new file mode 100644 index 000000000..3de272da2 --- /dev/null +++ b/crates/doc-binary/src/fkp.rs @@ -0,0 +1,147 @@ +//! Character and paragraph formatting lookup: the bin tables (`PlcBteChpx`/`PlcBtePapx`) in +//! the Table stream point at 512-byte FKP pages in the WordDocument stream, which map FC +//! ranges to CHPX (grpprls) and PAPX (istd + grpprl) ([MS-DOC] §2.4.6.1/2.4.6.2). + +use crate::DocbinError; + +/// Largest bin table (entries) we accept. +const MAX_BINS: usize = 100_000; + +/// A Chpx grpprl and the FCs `[lo, hi)` for which it is the one [`Bins::chpx`] returns. +#[derive(Clone, Copy)] +pub(crate) struct ChpxRun<'w> { + pub(crate) grpprl: &'w [u8], + lo: u32, + hi: u32, +} + +impl ChpxRun<'_> { + pub(crate) fn contains(&self, fc: u32) -> bool { + self.lo <= fc && fc < self.hi + } +} + +/// A parsed `PlcBte*`: FC breakpoints and the FKP page each range lives in. +pub(crate) struct Bins { + fcs: Vec, + /// `fcs` ascends, so the bin of an FC holds every FC up to the next breakpoint (a corrupt + /// table that does not gets no cached ranges, [`ChpxRun`]). + sorted: bool, + /// Page number (× 512 = byte offset in the WordDocument stream). + pns: Vec, +} + +impl Bins { + /// Parse the Plc at `table[fc..fc+lcb]` (aFc[n+1] u32s + aPn[n] u32s). + pub(crate) fn parse(table: &[u8], fc: u32, lcb: u32) -> Result { + let fc = fc as usize; + let lcb = lcb as usize; + if lcb < 4 || !(lcb - 4).is_multiple_of(8) { + return Ok(Bins { fcs: Vec::new(), sorted: true, pns: Vec::new() }); + } + let end = fc.checked_add(lcb).ok_or_else(|| DocbinError::Malformed("bin table overflow".into()))?; + let plc = table.get(fc..end).ok_or_else(|| DocbinError::Malformed("bin table outside the Table stream".into()))?; + let n = (lcb - 4) / 8; + if n > MAX_BINS { + return Err(DocbinError::Limit(format!("bin table has {n} entries"))); + } + let at = |i: usize| -> Option { plc.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) }; + let mut fcs = Vec::with_capacity(n + 1); + for i in 0..=n { + fcs.push(at(i).ok_or_else(|| DocbinError::Malformed("truncated bin aFc".into()))?); + } + let mut pns = Vec::with_capacity(n); + for i in 0..n { + // PnBte* keeps the page number in its low bits; the rest must be zero. + pns.push(at(n + 1 + i).ok_or_else(|| DocbinError::Malformed("truncated bin aPn".into()))? & 0x003F_FFFF); + } + let sorted = fcs.windows(2).all(|w| w.first() <= w.get(1)); + Ok(Bins { fcs, sorted, pns }) + } + + /// The page containing `fc`, as a slice of the WordDocument stream, and the FC range + /// `[lo, hi)` of its bin (`hi` is `u32::MAX` for the last; an empty range when the table + /// is not ascending, so nothing is cached from it). + fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<(&'w [u8], u32, u32)> { + let i = self.fcs.partition_point(|&f| f <= fc).checked_sub(1)?; + let pn = *self.pns.get(i)?; + let at = pn.checked_mul(512)? as usize; + let page = word.get(at..at.checked_add(512)?)?; + if !self.sorted { + return Some((page, 1, 0)); + } + let lo = self.fcs.get(i).copied().unwrap_or(0); + let hi = self.fcs.get(i + 1).copied().unwrap_or(u32::MAX); + Some((page, lo, hi)) + } + + /// The grpprl of the Chpx covering `fc` ([MS-DOC] ChpxFkp: rgfc u32s, rgb offsets ×2, + /// crun in the last byte). + pub(crate) fn chpx<'w>(&self, word: &'w [u8], fc: u32) -> &'w [u8] { + self.chpx_run(word, fc).grpprl + } + + /// [`Bins::chpx`] with the FC range over which the same grpprl is the answer, so a walk + /// over consecutive characters looks it up once per run, not once per character. + pub(crate) fn chpx_run<'w>(&self, word: &'w [u8], fc: u32) -> ChpxRun<'w> { + let none = ChpxRun { grpprl: &[], lo: 1, hi: 0 }; + let Some((page, page_lo, page_hi)) = self.page(word, fc) else { return none }; + let Some(&crun) = page.get(511) else { return none }; + let crun = crun.min(0x65) as usize; + let rgfc = |i: usize| -> Option { page.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) }; + // rgb array sits right after rgfc (crun+1 u32s); each byte is a half-offset into the + // region between rgb's end and crun. + let rgb_at = (crun + 1) * 4; + // The last run starting at or before `fc`. It stays the answer for every FC from its own + // start up to the smallest start after it (also in a page whose starts do not ascend). + let j = (0..crun).rev().find(|&j| rgfc(j).is_some_and(|f| f <= fc)); + let first_after = j.map_or(0, |j| j + 1); + let hi = (first_after..crun).filter_map(rgfc).min().map_or(page_hi, |m| m.min(page_hi)); + let lo = j.and_then(rgfc).map_or(page_lo, |f| f.max(page_lo)); + let run = |grpprl: &'w [u8]| ChpxRun { grpprl, lo, hi }; + let Some(j) = j else { return run(&[]) }; + let Some(&off) = page.get(rgb_at + j) else { return run(&[]) }; + if off == 0 { + return run(&[]); + } + let at = off as usize * 2; + let Some(&cb) = page.get(at) else { return run(&[]) }; + run(page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[])) + } + + /// The (istd, grpprl) of the Papx of the paragraph whose mark is just before `fc` + /// ([MS-DOC] PapxFkp: rgfc u32s — each the FC *following* a paragraph mark —, rgbx BX + /// entries (1 offset byte + 12 PHE bytes), crun in the last byte; the PAPX itself is a + /// PapxInFkp with the cb/cb' size quirk). Query with the FC of the character after the mark. + pub(crate) fn papx<'w>(&self, word: &'w [u8], fc: u32) -> (u16, &'w [u8]) { + let Some((page, _, _)) = self.page(word, fc) else { return (0, &[]) }; + let Some(&crun) = page.get(511) else { return (0, &[]) }; + // BX is 13 bytes; cap crun at what fits before byte 511. + let max_run = (511usize.saturating_sub((crun as usize + 1) * 4)) / 13; + let crun = (crun as usize).min(max_run); + let rgfc = |i: usize| -> Option { page.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) }; + let rgbx_at = (crun + 1) * 4; + let j = (0..crun).rev().find(|&j| rgfc(j).is_some_and(|f| f <= fc)); + let Some(j) = j else { return (0, &[]) }; + let Some(&off) = page.get(rgbx_at + j * 13) else { return (0, &[]) }; + if off == 0 { + return (0, &[]); + } + let at = off as usize * 2; + let u16at = |o: usize| -> Option { page.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]])) }; + let Some(cb) = page.get(at).copied() else { return (0, &[]) }; + // PapxInFkp: if cb is nonzero the GrpPrlAndIstd (istd u16 + grpprl) is 2×cb-1 bytes; + // if cb is zero, the next byte cb' (1 byte) gives 2×cb' bytes of it. + let (data_at, data_len) = if cb != 0 { + (at + 1, 2 * cb as usize - 1) + } else { + let cb2 = page.get(at + 1).copied().unwrap_or(0) as usize; + (at + 2, 2 * cb2) + }; + if data_len < 2 { + return (0, &[]); + } + let istd = u16at(data_at).unwrap_or(0); + (istd, page.get(data_at + 2..data_at + data_len).unwrap_or(&[])) + } +} diff --git a/crates/doc-binary/src/fmt.rs b/crates/doc-binary/src/fmt.rs new file mode 100644 index 000000000..1b8be2333 --- /dev/null +++ b/crates/doc-binary/src/fmt.rs @@ -0,0 +1,572 @@ +//! Sprms → model properties, and style-chain resolution. + +use craft_wordmodel::Rgb; +use craft_wordmodel::props::{ + Align, Border, BorderStyle, Borders, CharProps, Highlight, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop, TextColor, Underline, VertAlign, +}; +use craft_wordmodel::styles::{Style, StyleKind, StyleSheet}; + +use crate::sprm::{self, Prl, Toggle}; + +// Character sprms ([MS-DOC] §2.6.1). +const C_BOLD: u16 = 0x0835; +const C_ITALIC: u16 = 0x0836; +const C_STRIKE: u16 = 0x0837; +const C_SMALL_CAPS: u16 = 0x083A; +const C_CAPS: u16 = 0x083B; +const C_HIDDEN: u16 = 0x083C; // sprmCFVanish +const C_KUL: u16 = 0x2A3E; // underline style (Kul) +const C_ICO: u16 = 0x2A42; // palette colour +const C_CR: u16 = 0x6870; // explicit COLORREF +const C_HPS: u16 = 0x4A43; // half-points +const C_HPS_POS: u16 = 0x4845; // super/subscript by half-points +const C_FTC0: u16 = 0x4A4F; // font index (ASCII) +const C_FTC1: u16 = 0x4A50; // East Asian font index (sprmCRgFtc1) +const C_FTC_BI: u16 = 0x4A5E; // complex-script font index (sprmCFtcBi) +const C_HIGHLIGHT: u16 = 0x2A0C; +const C_ISTD: u16 = 0x4A30; // character style + +// Paragraph sprms ([MS-DOC] §2.6.2). +const P_JC: u16 = 0x2461; +const P_JC80: u16 = 0x2403; +const P_DXA_LEFT: u16 = 0x845E; +const P_DXA_LEFT80: u16 = 0x840F; +const P_DXA_RIGHT: u16 = 0x845D; +const P_DXA_RIGHT80: u16 = 0x840E; +const P_DXA_FIRST: u16 = 0x8460; // sprmPDxaLeft1 +const P_DXA_FIRST80: u16 = 0x8411; // sprmPDxaLeft1_80 +// Indents in hundredths of a character (sprmPDxcRight, sprmPDxcLeft, sprmPDxcLeft1). +const P_DXC_RIGHT: u16 = 0x4455; +const P_DXC_LEFT: u16 = 0x4456; +const P_DXC_FIRST: u16 = 0x4457; +const P_DYA_BEFORE: u16 = 0xA413; +const P_DYA_AFTER: u16 = 0xA414; +const P_DYA_LINE: u16 = 0x6412; +const P_KEEP: u16 = 0x2405; // keep lines together +const P_KEEP_FOLLOW: u16 = 0x2406; // keep with next +const P_PAGE_BREAK_BEFORE: u16 = 0x2407; +const P_OUTLVL: u16 = 0x2640; +// Paragraph borders ([MS-DOC] §2.6.2). The 80 forms carry a Brc80 (palette colour), the +// newer ones a BrcOperand: cb(1) + Brc(8, with a full COLORREF). +const P_BRC_TOP80: u16 = 0x6424; +const P_BRC_LEFT80: u16 = 0x6425; +const P_BRC_BOTTOM80: u16 = 0x6426; +const P_BRC_RIGHT80: u16 = 0x6427; +const P_BRC_BETWEEN80: u16 = 0x6428; +const P_BRC_TOP: u16 = 0xC64E; +const P_BRC_LEFT: u16 = 0xC64F; +const P_BRC_BOTTOM: u16 = 0xC650; +const P_BRC_RIGHT: u16 = 0xC651; +const P_BRC_BETWEEN: u16 = 0xC652; + +/// Word's 16-colour `Ico` palette ([MS-DOC] §2.9.135), 0 = automatic. +const ICO: [Rgb; 17] = [ + Rgb(0, 0, 0), + Rgb(0, 0, 0), + Rgb(0, 0, 255), + Rgb(0, 255, 255), + Rgb(0, 255, 0), + Rgb(255, 0, 255), + Rgb(255, 0, 0), + Rgb(255, 255, 0), + Rgb(255, 255, 255), + Rgb(0, 0, 128), + Rgb(0, 128, 128), + Rgb(0, 128, 0), + Rgb(128, 0, 128), + Rgb(128, 0, 0), + Rgb(128, 128, 0), + Rgb(128, 128, 128), + Rgb(192, 192, 192), +]; + +/// Highlight palette ([MS-DOC] §2.9.135 Highlight, order of Word's highlighter row). +const HL: [Highlight; 17] = [ + Highlight::None, + Highlight::Yellow, + Highlight::BrightGreen, + Highlight::Turquoise, + Highlight::Pink, + Highlight::Blue, + Highlight::Red, + Highlight::DarkBlue, + Highlight::Teal, + Highlight::Green, + Highlight::Violet, + Highlight::DarkRed, + Highlight::DarkYellow, + Highlight::Gray50, + Highlight::Gray25, + Highlight::None, + Highlight::None, +]; + +/// The `Ico` palette colour (0 = automatic → `None`). +pub(crate) fn ico(i: u8) -> Option { + if i == 0 { + return None; + } + ICO.get(i as usize).copied() +} + +/// The model border style a `brcType` stands for ([MS-DOC] §2.9.22 BrcType; 0x02, thick, is in +/// the BRC of Microsoft's Word 97-2007 specification). Types the model doesn't carry (the gap +/// variants, 3-D, inset/outset) draw as single lines, as the DOCX reader maps them. +fn brc_style(t: u8) -> BorderStyle { + match t { + 0x00 => BorderStyle::None, + 0x02 => BorderStyle::Thick, + 0x03 => BorderStyle::Double, + 0x06 => BorderStyle::Dotted, + 0x07 | 0x16 => BorderStyle::Dashed, + 0x08 | 0x09 => BorderStyle::DotDash, + 0x0A => BorderStyle::Triple, + 0x14 | 0x15 => BorderStyle::Wave, + _ => BorderStyle::Single, + } +} + +/// The border width in points: `dptLineWidth` in 1/8-point steps from 2 to 96, as DOCX `w:sz` +/// reads (1-point steps for page borders, which never reach a paragraph property). +fn brc_width(t: u8, dpt: u8) -> f32 { + if t >= 0x40 { f32::from(dpt) } else { (f32::from(dpt.max(2)) / 8.0).min(12.0) } +} + +/// One border from a Brc80 operand ([MS-DOC] §2.9.18). All bits set means "no border"; a +/// truncated operand is ignored. +pub(crate) fn brc80(op: &[u8]) -> Option { + let [dpt, ty, ico_index, tail, ..] = op else { return None }; + if [*dpt, *ty, *ico_index, *tail] == [0xFF; 4] { + return Some(Border::default()); + } + Some(Border { style: brc_style(*ty), width: brc_width(*ty, *dpt), color: ico(*ico_index), space: f32::from(tail & 0x1F) }) +} + +/// One border from a `BrcOperand` ([MS-DOC] §2.9.21): cb(1, MUST be 8) + Brc(8). +fn brc(op: &[u8]) -> Option { + if op.first() != Some(&8) { + return None; + } + let b = op.get(1..9)?; + if b.iter().all(|x| *x == 0xFF) { + return Some(Border::default()); + } + let color = match b.get(..4) { + Some([0, 0, 0, 0]) => None, // automatic + Some(px) => Some(Rgb(px[0], px[1], px[2])), + None => None, + }; + Some(Border { style: brc_style(b[5]), width: brc_width(b[5], b[4]), color, space: f32::from(b[6] & 0x1F) }) +} + +/// Put a decoded border on the side its sprm names (`between` = the border between this +/// paragraph and the next). +fn set_border(p: &mut ParaProps, op: u16, b: Border) { + let borders = p.borders.get_or_insert_with(Borders::default); + let slot = match op { + P_BRC_TOP80 | P_BRC_TOP => &mut borders.top, + P_BRC_LEFT80 | P_BRC_LEFT => &mut borders.left, + P_BRC_BOTTOM80 | P_BRC_BOTTOM => &mut borders.bottom, + P_BRC_RIGHT80 | P_BRC_RIGHT => &mut borders.right, + _ => &mut borders.between, + }; + *slot = Some(b); +} + +fn u16_of(p: &Prl) -> Option { + match p.operand { + [b0, b1, ..] => Some(u16::from_le_bytes([*b0, *b1])), + _ => None, + } +} + +fn s16_of(p: &Prl) -> Option { + u16_of(p).map(|v| v as i16) +} + +fn set(props: &mut Option, t: Toggle, base: bool) { + *props = Some(match t { + Toggle::On => true, + Toggle::Off => false, + Toggle::Invert => !base, + Toggle::Keep => return, + }); +} + +/// Apply a character-property sprm. `base` carries the style-inherited values so toggle +/// operands can invert them; `fonts` maps sprmCRgFtc0 indices to names. +pub(crate) fn apply_char(p: &mut CharProps, prl: &Prl, base: &CharProps, fonts: &[String]) { + let op = prl.op; + match op { + C_BOLD => set(&mut p.bold, sprm::toggle(prl), base.bold.unwrap_or(false)), + C_ITALIC => set(&mut p.italic, sprm::toggle(prl), base.italic.unwrap_or(false)), + C_STRIKE => set(&mut p.strike, sprm::toggle(prl), base.strike.unwrap_or(false)), + C_SMALL_CAPS => set(&mut p.small_caps, sprm::toggle(prl), base.small_caps.unwrap_or(false)), + C_CAPS => set(&mut p.caps, sprm::toggle(prl), base.caps.unwrap_or(false)), + C_HIDDEN => set(&mut p.hidden, sprm::toggle(prl), base.hidden.unwrap_or(false)), + C_KUL => { + p.underline = match prl.operand.first().copied().unwrap_or(0) { + 0x00 => Some(Underline::None), + 0x01 => Some(Underline::Single), + 0x02 => Some(Underline::Words), + 0x03 => Some(Underline::Double), + 0x04 => Some(Underline::Dotted), + 0x06 => Some(Underline::Thick), + 0x07 => Some(Underline::Dash), + 0x09 => Some(Underline::DotDash), + 0x0A => Some(Underline::DotDotDash), + 0x0B => Some(Underline::Wave), + 0x14 => Some(Underline::Dotted), + 0x17 => Some(Underline::Thick), + 0x18 => Some(Underline::Dash), + 0x19 => Some(Underline::DotDash), + 0x1A => Some(Underline::DotDotDash), + 0x1B => Some(Underline::DoubleWave), + _ => Some(Underline::Single), + }; + } + C_ICO => { + if let Some(rgb) = ICO.get(prl.operand.first().copied().unwrap_or(0) as usize) { + p.color = Some(if rgb == &Rgb::BLACK && prl.operand.first() == Some(&0) { TextColor::Auto } else { TextColor::Rgb(*rgb) }); + } + } + C_CR => { + // COLORREF 0x00BBGGRR, stored little-endian: operand bytes are R, G, B. + if let [r, g, b, ..] = prl.operand + && let (r, g, b) = (*r, *g, *b) + { + p.color = Some(TextColor::Rgb(Rgb(r, g, b))); + } + } + C_HPS => { + if let Some(hps) = u16_of(prl) { + p.size = Some((hps as f32 / 2.0).clamp(1.0, 1638.0)); + } + } + C_HPS_POS => { + if let Some(pos) = s16_of(prl) { + p.vert_align = Some(match pos { + pos if pos > 0 => VertAlign::Superscript, + pos if pos < 0 => VertAlign::Subscript, + _ => VertAlign::Baseline, + }); + } + } + C_FTC0 => { + if let Some(name) = u16_of(prl).and_then(|i| fonts.get(i as usize)) { + p.font = Some(name.clone()); + } + } + C_FTC1 => { + if let Some(name) = u16_of(prl).and_then(|i| fonts.get(i as usize)) { + p.font_ea = Some(name.clone()); + } + } + C_FTC_BI => { + if let Some(name) = u16_of(prl).and_then(|i| fonts.get(i as usize)) { + p.font_cs = Some(name.clone()); + } + } + C_HIGHLIGHT => { + if let Some(h) = HL.get(prl.operand.first().copied().unwrap_or(0) as usize) { + p.highlight = Some(*h); + } + } + C_ISTD => {} // resolved separately through the style table + _ => {} + } +} + +/// Custom tab stops from `sprmPChgTabsPapx` / `sprmPChgTabs` ([MS-DOC] PChgTabsPapxOperand, +/// PChgTabsOperand): the stops removed become `Clear` stops (they hide inherited ones) and the +/// stops added carry their alignment and leader (TBD: jc in bits 0–2, tlc in bits 3–5). +fn change_tabs(p: &mut ParaProps, prl: &Prl) { + let op = prl.operand; + let i16_at = |i: usize| op.get(i..i + 2).map(|b| f32::from(i16::from_le_bytes([b[0], b[1]])) / 20.0); + // Both operands start with cb; sprmPChgTabs's removals also carry a "close" distance each. + let close = prl.op == sprm::P_CHG_TABS; + let mut at = 1; + let Some(&del) = op.get(at) else { return }; + let del = usize::from(del.min(64)); + at += 1; + let mut removed = Vec::with_capacity(del); + for k in 0..del { + if let Some(x) = i16_at(at + 2 * k) { + removed.push(x); + } + } + at += 2 * del * if close { 2 } else { 1 }; + let Some(&add) = op.get(at) else { return }; + let add = usize::from(add.min(64)); + at += 1; + let mut tabs = p.tabs.take().unwrap_or_default(); + // Removed within 25 twips (1.25 pt), as the format says. + tabs.retain(|t| !removed.iter().any(|x| (t.pos - x).abs() <= 1.25)); + for x in removed { + tabs.push(TabStop { pos: x, align: TabAlign::Clear, leader: TabLeader::None }); + } + for k in 0..add { + let (Some(pos), Some(&tbd)) = (i16_at(at + 2 * k), op.get(at + 2 * add + k)) else { break }; + let align = match tbd & 7 { + 1 => TabAlign::Center, + 2 => TabAlign::Right, + 3 => TabAlign::Decimal, + 4 => TabAlign::Bar, + _ => TabAlign::Left, + }; + let leader = match (tbd >> 3) & 7 { + 1 => TabLeader::Dot, + 2 => TabLeader::Hyphen, + 3 | 4 => TabLeader::Underscore, + 5 => TabLeader::MiddleDot, + _ => TabLeader::None, + }; + tabs.retain(|t| (t.pos - pos).abs() > 0.05); + tabs.push(TabStop { pos, align, leader }); + } + tabs.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + p.tabs = Some(tabs); +} + +/// Apply a paragraph-property sprm. +pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) { + let tw = |v: i32| (v as f32 / 20.0).clamp(-1000.0, 1000.0); + let s16 = |prl: &Prl| -> Option { u16_of(prl).map(|v| v as i16) }; + match prl.op { + sprm::P_CHG_TABS | sprm::P_CHG_TABS_PAPX => change_tabs(p, prl), + P_JC | P_JC80 => { + p.align = match prl.operand.first().copied().unwrap_or(0) { + 1 => Some(Align::Center), + 2 => Some(Align::Right), + 3 => Some(Align::Justify), + 4 => Some(Align::Distribute), + _ => Some(Align::Left), + }; + } + P_DXA_LEFT | P_DXA_LEFT80 => { + if let Some(v) = s16(prl) { + p.indent_left = Some(tw(v as i32)); + } + } + P_DXA_RIGHT | P_DXA_RIGHT80 => { + if let Some(v) = s16(prl) { + p.indent_right = Some(tw(v as i32)); + } + } + // A two-byte operand, like the other indents (spra 4). + P_DXA_FIRST | P_DXA_FIRST80 => { + if let Some(v) = s16(prl) { + p.indent_first = Some(tw(v as i32)); + } + } + P_DXC_RIGHT | P_DXC_LEFT | P_DXC_FIRST => { + if let Some(v) = s16(prl) { + let chars = Some(f32::from(v) / 100.0); + match prl.op { + P_DXC_RIGHT => p.indent_right_chars = chars, + P_DXC_LEFT => p.indent_left_chars = chars, + _ => p.indent_first_chars = chars, + } + } + } + P_DYA_BEFORE => { + if let Some(v) = s16(prl) { + p.space_before = Some((v as f32 / 20.0).clamp(0.0, 3168.0)); + } + } + P_DYA_AFTER => { + if let Some(v) = s16(prl) { + p.space_after = Some((v as f32 / 20.0).clamp(0.0, 3168.0)); + } + } + // LSPD ([MS-DOC] §2.9.150): a signed dyaLine, then fMultLinespace. In lines (240ths) when + // fMultLinespace is 1; else twips, at least when positive and exactly when negative. + P_DYA_LINE => { + if let Some(v) = s16(prl) { + let mult = prl.operand.get(2..4).is_some_and(|b| b == [1, 0]); + let v = f32::from(v); + p.line_spacing = Some(if mult { + LineSpacing::Multiple((v.abs() / 240.0).clamp(0.06, 132.0)) + } else if v < 0.0 { + LineSpacing::Exactly((-v / 20.0).clamp(0.7, 1584.0)) + } else { + LineSpacing::AtLeast((v / 20.0).clamp(0.0, 1584.0)) + }); + } + } + P_KEEP => { + if matches!(sprm::toggle(prl), Toggle::On) { + p.keep_lines = Some(true); + } + } + P_KEEP_FOLLOW => { + if matches!(sprm::toggle(prl), Toggle::On) { + p.keep_next = Some(true); + } + } + P_PAGE_BREAK_BEFORE => { + if matches!(sprm::toggle(prl), Toggle::On) { + p.page_break_before = Some(true); + } + } + P_OUTLVL => { + if let Some(&l) = prl.operand.first() { + p.outline_level = Some(l.min(8)); + } + } + P_BRC_TOP80 | P_BRC_LEFT80 | P_BRC_BOTTOM80 | P_BRC_RIGHT80 | P_BRC_BETWEEN80 => { + if let Some(b) = brc80(prl.operand) { + set_border(p, prl.op, b); + } + } + P_BRC_TOP | P_BRC_LEFT | P_BRC_BOTTOM | P_BRC_RIGHT | P_BRC_BETWEEN => { + if let Some(b) = brc(prl.operand) { + set_border(p, prl.op, b); + } + } + _ => {} + } +} + +/// Build the model stylesheet: resolve each raw style's chain (base → this) and the ids we +/// reference from paragraphs. +pub(crate) fn stylesheet(raw: &[crate::stsh::RawStyle], fonts: &[String], standard: crate::stsh::StandardFonts) -> StyleSheet { + let mut sheet = StyleSheet::empty(); + // Word's base character properties: the stylesheet's standard fonts (Aptos in a Word 365 + // file, whose font table still starts with Times New Roman, #5066), else font index 0 of the + // table; 10 pt ([MS-DOC] sprmCHps). + let font = |i: Option| i.and_then(|i| fonts.get(usize::from(i))).cloned(); + sheet.default_chr.font = font(standard.ascii).or_else(|| fonts.first().cloned()); + sheet.default_chr.font_ea = font(standard.east_asian); + sheet.default_chr.font_cs = font(standard.bidi); + if sheet.default_chr.size.is_none() { + sheet.default_chr.size = Some(10.0); + } + for (istd, st) in raw.iter().enumerate() { + if st.name.is_empty() { + continue; + } + let id = style_id(&st.name); + let kind = match st.stk { + 2 => StyleKind::Character, + 3 => StyleKind::Table, + 4 => StyleKind::Numbering, + _ => StyleKind::Paragraph, + }; + let base = base_of(raw, istd).and_then(|i| raw.get(i)).map(|b| style_id(&b.name)); + // Inherit: properties come from the base chain, root first, then our own grpprls. + let mut chr = CharProps::default(); + let mut para = ParaProps::default(); + for s in style_chain(raw, istd).into_iter().filter_map(|i| raw.get(i)) { + apply_chain(&mut chr, &s.chpx, fonts); + apply_chain_para(&mut para, &s.papx); + } + if para.outline_level.is_none() && kind == StyleKind::Paragraph { + let h = heading_level(&st.name); + if h > 0 { + para.outline_level = Some(h - 1); + } + } + sheet.styles.push(Style { id, name: display_name(&st.name), kind, based_on: base, chr, para, ..Default::default() }); + } + sheet +} + +/// Most `istdBase` links followed; a longer chain (or a cycle) in a corrupt file stops there. +const MAX_STYLE_DEPTH: usize = 16; + +/// The parent of style `istd`: its zero-based `istdBase` ([MS-DOC] §2.9.260), when that names +/// another existing, named style (0x0FFF means none). +fn base_of(raw: &[crate::stsh::RawStyle], istd: usize) -> Option { + let b = raw.get(istd)?.istd_base; + let b = usize::from(b); + (b != 0x0FFF && b != istd && raw.get(b).is_some_and(|s| !s.name.is_empty())).then_some(b) +} + +/// Style `istd` and its ancestors, root first and `istd` last. Cycles and chains deeper than +/// [`MAX_STYLE_DEPTH`] are cut. +fn style_chain(raw: &[crate::stsh::RawStyle], istd: usize) -> Vec { + let mut chain = vec![istd]; + let mut cur = istd; + while chain.len() < MAX_STYLE_DEPTH { + let Some(b) = base_of(raw, cur) else { break }; + if chain.contains(&b) { + break; + } + chain.push(b); + cur = b; + } + chain.reverse(); + chain +} + +/// The style-id form of a stored (lower-case) built-in name, matching the docx reader's ids. +pub(crate) fn style_id(name: &str) -> String { + let lower = name.trim().to_ascii_lowercase(); + match lower.as_str() { + "normal" => "Normal".into(), + "title" => "Title".into(), + "subtitle" => "Subtitle".into(), + "quote" | "body text" | "body text 2" | "body text 3" | "body text indent" | "body text indent 2" | "body text indent 3" => { + let mut c = lower.chars(); + match c.next() { + Some(f) => f.to_uppercase().collect::() + c.as_str(), + None => String::new(), + } + } + "default paragraph font" => "Default Paragraph Font".into(), + _ => { + let mut out = String::with_capacity(name.len()); + let mut up = true; + for ch in name.trim().chars() { + if ch == ' ' { + up = true; + out.push(ch); + } else if up { + out.extend(ch.to_uppercase()); + up = false; + } else { + out.push(ch); + } + } + out + } + } +} + +/// "heading 3" → 3; 0 when not a heading. +fn heading_level(name: &str) -> u8 { + let lower = name.trim().to_ascii_lowercase(); + lower.strip_prefix("heading ").and_then(|d| d.parse().ok()).filter(|&l: &u8| (1..=9).contains(&l)).unwrap_or(0) +} + +fn display_name(name: &str) -> String { + let lower = name.trim().to_ascii_lowercase(); + let h = heading_level(&lower); + if h > 0 { + return format!("Heading {h}"); + } + match lower.as_str() { + "body text" => "Body Text".into(), + _ => style_id(name), + } +} + +/// Apply a grpprl of character sprms with a toggle base. +fn apply_chain(chr: &mut CharProps, grpprl: &[u8], fonts: &[String]) { + let base = chr.clone(); + for prl in sprm::iter(grpprl) { + if prl.sgc() == 2 { + apply_char(chr, &prl, &base, fonts); + } + } +} + +/// Apply a grpprl of paragraph sprms. +fn apply_chain_para(para: &mut ParaProps, grpprl: &[u8]) { + for prl in sprm::iter(grpprl) { + if prl.sgc() == 1 { + apply_para(para, &prl); + } + } +} diff --git a/crates/doc-binary/src/lib.rs b/crates/doc-binary/src/lib.rs new file mode 100644 index 000000000..386e753f5 --- /dev/null +++ b/crates/doc-binary/src/lib.rs @@ -0,0 +1,1051 @@ +//! Reader for the Word 97-2003 binary file format (`.doc`, [MS-DOC]), WordCraft's. +//! +//! [`read`] turns the bytes of a `.doc` file into a [`craft_wordmodel::Document`]. There is no +//! writer: saving always goes through the other formats. The reader is written from the public +//! specification ([MS-DOC], Word (.doc) Binary File Format, Microsoft Open Specifications) and, +//! like the other Craft parsers, is lenient by design (unknown structures are skipped, bad numbers +//! fall back to defaults) and bounded (stream sizes, piece counts and property counts are +//! capped), so hostile input yields an error or a best-effort document, never a panic. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +// The code came from an app whose lints allow this. +#![allow(clippy::type_complexity)] + +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +struct ReadmeExamples; + +mod crypt; +mod drawing; +mod fib; +mod fkp; +mod fmt; +mod list; +mod media; +mod metadata; +mod notes; +mod officeart; +mod piece; +mod sections; +mod sprm; +mod stsh; +mod table; + +use std::sync::Arc; + +use craft_wordmodel::link::hyperlink_target; +use craft_wordmodel::para::InlineObject; +use craft_wordmodel::para::NoteKind; +use craft_wordmodel::para::Paragraph; +use craft_wordmodel::para::Run; +use craft_wordmodel::props::CharProps; +use craft_wordmodel::props::NumRef; +use craft_wordmodel::section::HeaderSet; +use craft_wordmodel::styles::StyleSheet; +use craft_wordmodel::{Document, PartKind}; + +use fkp::Bins; +use piece::{PieceTable, PrmRef}; +use sprm::Prl; +use table::ParaOut; + +/// Largest stream we materialise (bytes); the engine already caps whole files at 2 GB. +const MAX_STREAM: u64 = 1 << 30; +/// Most header/footer parts we create (matches the docx reader's cap). +const MAX_PARTS: usize = 50_000; +/// Hard ceiling on characters materialised across every story walk of one document (body, +/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the +/// WordDocument stream (see [`read`]); this caps even a maximal stream. +const MAX_TOTAL_CHARS: u64 = 64 << 20; +/// Most paragraph and cell marks across every story walk of one document. The character +/// budget does not see them: a mark is one byte of the file but about 1 KB of document, so a +/// file of nothing but marks would grow memory about a thousand times faster than its size. +/// The cap bounds a flood to about 2 GB while reading (about 4 GB at the very cap, when the +/// blocks are assembled). Past it the read fails with [`DocbinError::Limit`]. Real documents stay far below it: +/// 50 paragraphs on each of 10,000 pages is 500,000, and a 10,000-page one-column table (46 +/// rows, each a cell mark plus a row mark) is about 920,000. +const MAX_PARAGRAPHS: usize = 2_000_000; +/// Bytes of repeated work per document beyond the text itself: piece Prm grpprls (Prcs, up +/// to 64 KB each) interpreted (#3692), hyperlink targets (#3708), picture alt text (#3709) +/// and floating objects (#3710) copied into every run or anchor. Past it Prms are skipped, +/// copied strings left empty and floating objects dropped, each kind logged once a document +/// ([`Work::log`]). +const MAX_WORK: u64 = 64 << 20; + +/// What the [`MAX_WORK`] budget pays for, and leaves out once it is spent. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Cost { + /// A piece's Prm grpprl: past the budget the piece keeps its CHPX formatting only. + Formatting, + /// A hyperlink target copied into a run of its result: past it the text is kept, unlinked. + LinkTarget, + /// A picture's alt text copied into an anchor: past it the picture has none. + AltText, + /// A floating object copied into its anchor: past it the object is left out. + FloatingObject, +} + +impl Cost { + const ALL: [Cost; 4] = [Cost::Formatting, Cost::LinkTarget, Cost::AltText, Cost::FloatingObject]; + + /// What is left out, for the log. + fn left_out(self) -> &'static str { + match self { + Cost::Formatting => "some text keeps only part of its formatting", + Cost::LinkTarget => "some hyperlinks' text is kept without its link", + Cost::AltText => "some pictures are left without their alt text", + Cost::FloatingObject => "some floating shapes and pictures are left out", + } + } +} + +/// One document's [`MAX_WORK`] budget, and what it has left out (each kind logged once when +/// the document has been read, [`Work::log`]). +struct Work { + left: std::cell::Cell, + dropped: std::cell::Cell<[bool; 4]>, +} + +impl Work { + fn new(budget: u64) -> Self { + Work { left: std::cell::Cell::new(budget), dropped: std::cell::Cell::new([false; 4]) } + } + + /// Spends `cost` bytes on `what`: false (and `what` noted as left out) when they aren't left. + fn charge(&self, cost: u64, what: Cost) -> bool { + match self.left.get().checked_sub(cost) { + Some(left) => { + self.left.set(left); + true + } + None => { + let mut dropped = self.dropped.get(); + if let Some(d) = Cost::ALL.iter().position(|c| *c == what).and_then(|i| dropped.get_mut(i)) { + *d = true; + } + self.dropped.set(dropped); + false + } + } + } + + /// The kinds of work left out so far, in [`Cost::ALL`] order. + fn dropped(&self) -> Vec { + Cost::ALL.iter().zip(self.dropped.get()).filter(|(_, d)| *d).map(|(c, _)| *c).collect() + } + + /// Logs one warning for each kind left out. + fn log(&self) { + let dropped = self.dropped(); + for c in &dropped { + log::warn!(".doc: the document repeats more than WordCraft copies ({MAX_WORK} bytes): {}", c.left_out()); + } + #[cfg(test)] + LAST_DROPPED.with(|d| *d.borrow_mut() = dropped); + } +} + +#[cfg(test)] +thread_local! { + /// What the last document read on this thread left out for the work budget ([`Work::log`]). + static LAST_DROPPED: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} +/// `sprmCPicLocation`: the fc of a picture in the Data stream. +const C_PIC_LOCATION: u16 = 0x6A03; +/// `sprmCSymbol`: the record font and character of a symbol character ([MS-DOC] §2.6.1); the +/// text stream holds the placeholder U+0028 at its position. +const C_SYMBOL: u16 = 0x6A09; +/// `sprmPIlvl` / `sprmPIlfo`: the paragraph's list level and list. +const P_ILVL: u16 = 0x260A; +const P_ILFO: u16 = 0x460B; + +/// Errors from reading a Word 97-2003 binary file. +#[derive(Debug, thiserror::Error, Clone, PartialEq)] +pub enum DocbinError { + /// The bytes are not a readable compound file (OLE2/CFB container). + #[error("not a Word document (compound file): {0}")] + Container(String), + /// The compound file is readable but is not a Word 97-2003 document. + #[error("not a Word 97-2003 document: {0}")] + NotWord(String), + /// The document is password-protected and no password was given. + #[error("this document is protected with a password")] + PasswordRequired, + /// The password does not open the document. + #[error("the password is incorrect")] + WrongPassword, + /// The document is encrypted in a way WordCraft can't read (AES, an external provider, an + /// unknown header). + #[error("encrypted document: {0}")] + Encryption(String), + /// The input exceeds a safety limit (stream size, piece count…). + #[error("limit exceeded: {0}")] + Limit(String), + /// A structure is malformed beyond recovery. + #[error("malformed document: {0}")] + Malformed(String), +} + +/// Is this `.doc` password-protected (XOR obfuscation, RC4 or RC4 CryptoAPI)? False for +/// anything that is not a readable Word 97-2003 file. +pub fn is_encrypted(bytes: &[u8]) -> bool { + let Ok(comp) = open_container(bytes) else { return false }; + stream(&comp, "WordDocument").ok().flatten().is_some_and(|w| fib::peek_encryption(&w).is_some()) +} + +/// Open the compound file around a `.doc` with `craft-cfb`, which bounds the directory and the +/// streams it reads. +fn open_container(bytes: &[u8]) -> Result, DocbinError> { + craft_cfb::CompoundFile::open_with(bytes, craft_cfb::Limits { max_stream_size: MAX_STREAM, ..craft_cfb::Limits::default() }) + .map_err(|e| DocbinError::Container(e.to_string())) +} + +/// Read a `.doc` (Word 97-2003 binary) file into a [`Document`]. A password-protected file +/// gives [`DocbinError::PasswordRequired`]; see [`read_with_password`]. +pub fn read(bytes: &[u8]) -> Result { + read_with_password(bytes, None) +} + +/// [`read`] for a file that may be password-protected: it is checked against the file's stored +/// verifier ([`DocbinError::WrongPassword`] when it does not match) and then decrypted. The +/// password is ignored for an unprotected file. +pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result { + read_limited(bytes, password, MAX_PARAGRAPHS) +} + +/// [`read_with_password`] with the paragraph cap as a parameter, so tests need not build a +/// file of [`MAX_PARAGRAPHS`] marks. +fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) -> Result { + let comp = open_container(bytes)?; + let mut word = stream(&comp, "WordDocument")?.ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?; + if word.len() as u64 > MAX_STREAM { + return Err(DocbinError::Limit(format!("WordDocument stream is {} bytes", word.len()))); + } + let enc = fib::peek_encryption(&word); + // The FIB names the Table stream (0Table or 1Table); fall back to whichever exists, + // since files written by other writers sometimes carry the wrong bit. + let which = if let Some(e) = enc { e.which_tbl_stm } else { fib::Fib::parse(&word)?.which_tbl_stm }; + let named = if which { "1Table" } else { "0Table" }; + let other = if which { "0Table" } else { "1Table" }; + let mut table = match stream(&comp, named)? { + Some(t) => t, + None => stream(&comp, other)?.unwrap_or_default(), + }; + if table.len() as u64 > MAX_STREAM { + return Err(DocbinError::Limit(format!("Table stream is {} bytes", table.len()))); + } + let mut data = stream(&comp, "Data")?.unwrap_or_default(); + if data.len() as u64 > MAX_STREAM { + return Err(DocbinError::Limit(format!("Data stream is {} bytes", data.len()))); + } + if let Some(e) = enc { + // Verify the password first; only then is anything decrypted. + let password = password.ok_or(DocbinError::PasswordRequired)?; + let cipher = crypt::Cipher::open(e.flags, e.lkey, &table, password)?; + cipher.decrypt_word(&mut word); + cipher.decrypt_table(&mut table); + cipher.decrypt_data(&mut data); + } + let fib = fib::Fib::parse(&word)?; + + // The piece table locates every character in the WordDocument stream. + let (fc_clx, lcb_clx) = + fib.pair(fib::pair::CLX).filter(|(_, lcb)| *lcb > 0).ok_or_else(|| DocbinError::Malformed("the FIB has no piece table (Clx)".into()))?; + let pieces = piece::PieceTable::parse(&table, fc_clx, lcb_clx)?; + + // Styles, fonts and the formatting bin tables; each is optional in broken files. + let fonts = fib.pair(fib::pair::STTBF_FFN).map(|(f, l)| stsh::fonts(&table, f, l)).unwrap_or_default(); + let raw_styles = match fib.pair(fib::pair::STSHF) { + Some((f, l)) => stsh::parse(&table, f, l)?, + None => Vec::new(), + }; + let standard_fonts = fib.pair(fib::pair::STSHF).map(|(f, l)| stsh::standard_fonts(&table, f, l)).unwrap_or_default(); + let chpx_bins = match fib.pair(fib::pair::PLCF_BTE_CHPX) { + Some((f, l)) => Bins::parse(&table, f, l)?, + None => Bins::parse(&[], 0, 0)?, + }; + let papx_bins = match fib.pair(fib::pair::PLCF_BTE_PAPX) { + Some((f, l)) => Bins::parse(&table, f, l)?, + None => Bins::parse(&[], 0, 0)?, + }; + + let mut doc = Document::new(); + // Word opens Word 97-2003 files in compatibility mode 11 (Word 2003); the model's default + // 15 would apply Word 2013+ justification and line-breaking rules to them. + doc.settings.compat_mode = 11; + if let Some(core) = metadata::read(&comp) { + doc.core = core; + } + doc.body.clear(); + doc.styles = Arc::new(fmt::stylesheet(&raw_styles, &fonts, standard_fonts)); + doc.numbering = Arc::new(list::parse(&table, &fib, &fonts)); + let sheet: StyleSheet = StyleSheet::clone(&doc.styles); + let sects = sections::parse(&word, &table, &fib); + let stories = sections::header_stories(&table, &fib); + // Subdocument layout in the CP space: main document, footnotes, headers, comments, + // endnotes, text boxes, header text boxes. + // Story ranges from the Table stream are hostile: each is clamped to its subdocument's + // length here and to the last piece's CP inside `walk`. + let ftn_base = fib.ccp.text; + let hdd_base = fib.ccp.text.saturating_add(fib.ccp.ftn); + let edn_base = hdd_base.saturating_add(fib.ccp.hdd).saturating_add(fib.ccp.atn); + let mut pics = media::Pictures::for_input(word.len().saturating_add(data.len())); + // Every materialised character comes from at least one byte of the WordDocument stream, so a + // legitimate document's total text (body plus every header, footnote and text box, each read + // once) cannot exceed its stream. A hostile piece or story table that aliases the same bytes + // many times can, so the walk is held to twice the stream's size — generous for real files, + // tight against aliasing — and never past the absolute ceiling. + let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS)); + let work = Work::new(MAX_WORK); + let paragraphs = ParaBudget { left: std::cell::Cell::new(max_paragraphs), exceeded: std::cell::Cell::new(false) }; + + let ctx = WalkCtx { + word: &word, + data: &data, + pieces: &pieces, + chpx_bins: &chpx_bins, + papx_bins: &papx_bins, + fonts: &fonts, + raw_styles: &raw_styles, + sheet: &sheet, + notes: &[], + marks: &[], + drawn: &[], + sect_ends: &[], + budget: &budget, + paragraphs: ¶graphs, + work: &work, + }; + + // Drawings: text box stories become parts first, then the floating shapes and pictures + // anchored in the main document and the headers, found by their anchor CP while walking. + let txbx_base = edn_base.saturating_add(fib.ccp.edn); + let hdr_txbx_base = txbx_base.saturating_add(fib.ccp.txbx); + let art = fib + .pair(fib::pair::DGG_INFO) + .and_then(|(f, l)| table.get(f as usize..(f as usize).saturating_add(l as usize))) + .map(officeart::content) + .unwrap_or_default(); + let mut tb_stories = std::collections::HashMap::new(); + for (pair, base, len) in [(fib::pair::PLCF_TXBX_TXT, txbx_base, fib.ccp.txbx), (fib::pair::PLCF_HDR_TXBX_TXT, hdr_txbx_base, fib.ccp.hdr_txbx)] { + for (spid, ab) in drawing::textbox_stories(&table, fib.pair(pair)) { + let Some((a, b)) = story(base, len, ab) else { continue }; + if doc.parts.len() >= MAX_PARTS || tb_stories.contains_key(&spid) { + continue; + } + let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?; + tb_stories.insert(spid, doc.add_part(PartKind::TextBox, blocks)); + } + } + let build = drawing::Build { bstore: &art.bstore, delay: &word, stories: &tb_stories }; + let mut drawn = drawing::anchors(&table, fib.pair(fib::pair::PLC_SPA_MOM), 0, &art.main, &build, &mut pics); + drawn.extend(drawing::anchors(&table, fib.pair(fib::pair::PLC_SPA_HDR), hdd_base, &art.header, &build, &mut pics)); + drawn.sort_by_key(|(cp, _)| *cp); + let ctx = WalkCtx { drawn: &drawn, ..ctx }; + + // Header and footer parts: skip the six separator stories, then six stories per section + // in the order even header, odd (default) header, even footer, odd (default) footer, + // first header, first footer. Empty stories inherit from the previous section. + let mut hf_ids: Vec<(usize, usize, u32)> = Vec::new(); + for si in 0..sects.len() { + for k in 0..6usize { + let Some(&ab) = stories.get(6 + si * 6 + k) else { break }; + let Some((a, b)) = story(hdd_base, fib.ccp.hdd, ab) else { continue }; + if doc.parts.len() >= MAX_PARTS { + continue; + } + let kind = match k { + 0 | 1 | 4 => PartKind::Header, + _ => PartKind::Footer, + }; + let blocks = header_story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?; + if !blocks.is_empty() { + let id = doc.add_part(kind, blocks); + hf_ids.push((si, k, id)); + } + } + } + + // Footnote and endnote stories: each text range becomes a part; the reference CPs then + // link to the part ids during the main walk. + let mut note_links: Vec<(u32, NoteKind, u32)> = Vec::new(); + for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_FND_REF, fib::pair::PLCF_FND_TXT) { + let Some((a, b)) = story(ftn_base, fib.ccp.ftn, ab) else { continue }; + let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?; + if !blocks.is_empty() && doc.parts.len() < MAX_PARTS { + let id = doc.add_part(PartKind::Footnote, blocks); + note_links.push((cp, NoteKind::Footnote, id)); + } + } + for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_END_REF, fib::pair::PLCF_END_TXT) { + let Some((a, b)) = story(edn_base, fib.ccp.edn, ab) else { continue }; + let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?; + if !blocks.is_empty() && doc.parts.len() < MAX_PARTS { + let id = doc.add_part(PartKind::Endnote, blocks); + note_links.push((cp, NoteKind::Endnote, id)); + } + } + // Sorted so the main walk finds each reference by binary search. + note_links.sort_by_key(|(cp, _, _)| *cp); + let marks = notes::parse_bookmarks(&table, &fib); + let mut sect_ends: Vec = sects.iter().map(|s| s.end_cp).collect(); + sect_ends.sort_unstable(); + let ctx = WalkCtx { notes: ¬e_links, marks: &marks, sect_ends: §_ends, ..ctx }; + + // Sections: each section's properties attach to the paragraph that ends it (its story of + // header/footer parts included); the section reaching the end of the text is the final one. + let mut paras = walk(&ctx, 0, fib.ccp.text, &mut pics); + // The first paragraph ending at each CP, found once rather than by a scan per section. + let mut ending: std::collections::HashMap = std::collections::HashMap::new(); + for (i, p) in paras.iter().enumerate() { + ending.entry(p.end_cp).or_insert(i); + } + let last = sects.last().map(|s| s.props.clone()); + for (si, sec) in sects.iter().enumerate() { + let mut props = sec.props.clone(); + let id_of = |k: usize| hf_ids.iter().find(|(s, kk, _)| *s == si && *kk == k).map(|(_, _, id)| *id); + props.headers = HeaderSet { even: id_of(0), default: id_of(1), first: id_of(4) }; + props.footers = HeaderSet { even: id_of(2), default: id_of(3), first: id_of(5) }; + let is_final = si + 1 == sects.len() || sec.end_cp >= fib.ccp.text; + if is_final { + continue; // handled below through `last` + } + if let Some(p) = ending.get(&sec.end_cp).and_then(|&i| paras.get_mut(i)) { + p.para.section = Some(Box::new(props)); + } + } + if let Some(mut l) = last { + let si = sects.len().saturating_sub(1); + let id_of = |k: usize| hf_ids.iter().find(|(s, kk, _)| *s == si && *kk == k).map(|(_, _, id)| *id); + l.headers = HeaderSet { even: id_of(0), default: id_of(1), first: id_of(4) }; + l.footers = HeaderSet { even: id_of(2), default: id_of(3), first: id_of(5) }; + doc.last_section = l; + } + + if paragraphs.exceeded.get() { + return Err(paragraph_limit(max_paragraphs)); + } + doc.media.extend(pics.media); + doc.body = table::assemble(paras); + doc.ensure_nonempty(); + work.log(); + Ok(doc) +} + +fn paragraph_limit(max_paragraphs: usize) -> DocbinError { + DocbinError::Limit(format!("the document has more than {max_paragraphs} paragraphs and table cells")) +} + +/// Walks one story into blocks. A walk that spent the shared paragraph budget is refused before +/// its partial list is assembled, so a flood does not build blocks it will throw away. +fn story_blocks( + ctx: &WalkCtx<'_>, + a: u32, + b: u32, + pics: &mut media::Pictures, + max_paragraphs: usize, +) -> Result { + let paras = walk(ctx, a, b, pics); + if ctx.paragraphs.exceeded.get() { + return Err(paragraph_limit(max_paragraphs)); + } + Ok(table::assemble(paras)) +} + +/// A header or footer story's blocks without its guard paragraph mark. [MS-DOC] 2.3.3: a +/// non-empty header story ends with a paragraph mark that only separates it from the next story +/// and is not part of its contents, so a one-line header is stored as `text\r\r`. Laid out, the +/// guard would add an empty paragraph to every header and footer and push the body down when +/// the header reaches past the top margin (#5922). +/// +/// Only an empty last paragraph after an ordinary one is dropped: a story that is nothing but a +/// mark stays a part (an empty header, not one linked to the previous section), and a mark right +/// after a table row stays the paragraph that closes the table. +fn header_story_blocks( + ctx: &WalkCtx<'_>, + a: u32, + b: u32, + pics: &mut media::Pictures, + max_paragraphs: usize, +) -> Result { + let mut paras = walk(ctx, a, b, pics); + if ctx.paragraphs.exceeded.get() { + return Err(paragraph_limit(max_paragraphs)); + } + let outside_table = |p: &table::ParaOut| p.row.as_ref().is_none_or(|r| !r.in_table && !r.ttp && !r.cell_mark); + if let [.., prev, last] = paras.as_slice() { + let guard = outside_table(last) && last.para.text.is_empty() && last.para.objects.is_empty() && last.para.section.is_none(); + if guard && outside_table(prev) { + paras.pop(); + } + } + Ok(table::assemble(paras)) +} + +/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len` +/// CPs, as absolute CPs clamped to that subdocument; `None` when empty. +fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> { + let (a, b) = (a.min(len), b.min(len)); + if b <= a { + return None; + } + Some((base.checked_add(a)?, base.checked_add(b)?)) +} + +/// Everything the walker needs besides its CP range. +struct WalkCtx<'a> { + word: &'a [u8], + data: &'a [u8], + pieces: &'a PieceTable, + chpx_bins: &'a Bins, + papx_bins: &'a Bins, + fonts: &'a [String], + raw_styles: &'a [stsh::RawStyle], + sheet: &'a StyleSheet, + /// (reference CP in the main document, kind, part id) for notes. + notes: &'a [(u32, NoteKind, u32)], + /// Bookmark boundaries (main-document CPs), sorted for a sequential walk. + marks: &'a [(u32, notes::Mark)], + /// Floating shapes and pictures by anchor CP (absolute), sorted. + drawn: &'a [(u32, InlineObject)], + /// Where sections end (main-document CPs, sorted): the section mark (0x0C) just before + /// each ends the section's last paragraph, it isn't a page break inside it. + sect_ends: &'a [u32], + /// Characters still allowed across every story walk of this document. Story ranges (headers, + /// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of + /// them could otherwise materialise the same text thousands of times; this bounds the total. + budget: &'a std::cell::Cell, + /// Paragraph and cell marks still allowed across every story walk ([`MAX_PARAGRAPHS`]). + paragraphs: &'a ParaBudget, + /// Repeated-work bytes left ([`MAX_WORK`]), and what they left out. + work: &'a Work, +} + +/// The paragraph and cell marks still allowed in this document, and whether a walk ran into +/// the end of them (the read then fails). +struct ParaBudget { + left: std::cell::Cell, + exceeded: std::cell::Cell, +} + +impl ParaBudget { + /// Spends one mark; false (and the overrun noted) when none are left. + fn spend(&self) -> bool { + match self.left.get().checked_sub(1) { + Some(left) => { + self.left.set(left); + true + } + None => { + self.exceeded.set(true); + false + } + } + } +} + +/// State while assembling one paragraph from per-CP formatting. +#[derive(Default)] +struct ParaBuild { + text: String, + runs: Vec, + props: craft_wordmodel::props::ParaProps, + mark: CharProps, + /// Which resolved formatting ([`FmtState::generation`]) the last character came from, while + /// the last run is still the one it extended: the same generation means equal properties. + run_gen: Option, + /// Inline objects, each anchored at a U+FFFC pushed into `text`. + objects: Vec, +} + +impl ParaBuild { + fn push(&mut self, c: char, props: CharProps) { + self.run_gen = None; + let len = c.len_utf8(); + match self.runs.last_mut() { + Some(r) if r.props == props => r.len += len, + _ => self.runs.push(Run { len, props }), + } + self.text.push(c); + } + + /// [`ParaBuild::push`] for properties that are resolved once and shared by many characters: + /// they are cloned only when a run starts, and not even compared while `generation` stays + /// the same. + fn push_shared(&mut self, c: char, props: &CharProps, generation: u64) { + let len = c.len_utf8(); + match self.runs.last_mut() { + Some(r) if self.run_gen == Some(generation) || r.props == *props => r.len += len, + _ => self.runs.push(Run { len, props: props.clone() }), + } + self.run_gen = Some(generation); + self.text.push(c); + } + + /// Append `c` to the current run, whose properties the caller knows it matches. + fn extend(&mut self, c: char) { + if let Some(r) = self.runs.last_mut() { + r.len += c.len_utf8(); + } + self.text.push(c); + } +} + +/// Walk a CP range, splitting paragraphs at 0x0D/0x07 marks, applying the direct character +/// formatting of each CP and the paragraph formatting of each mark. Works for the main +/// document and for any subdocument range (headers, footers, notes…). Field characters, +/// note references, bookmarks and inline pictures become inline objects; pictures go into +/// `pics`, which shares one media entry per Data-stream location. +/// +/// The range is clamped to the last piece's CP, and unreadable stretches (gaps, truncated +/// pieces) are skipped a whole piece at a time, so the work is bounded by the text that +/// really exists, not by the CP numbers a file claims. +fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -> Vec { + let cp_end = cp_end.min(ctx.pieces.cp_end()); + let mut out = Vec::new(); + let mut pb = ParaBuild::default(); + let mut cp = cp_start; + let mut marks = ctx.marks.iter().peekable(); + // Field assembly state: instruction characters are swallowed, the result is kept until + // the end character, then emitted as one object (hyperlinks keep their result text). + let mut field: Option = None; + let mut fmt = FmtState::default(); + while cp < cp_end { + // Bookmark boundaries at this CP (main-document walks only; subdocument walks get + // an empty table and `cp` never matches the main-document CPs). + // `<=` so a boundary inside a skipped stretch is still emitted (late) rather than + // blocking every later one. + while marks.peek().is_some_and(|&(mcp, _)| *mcp <= cp) { + if let Some((_, m)) = marks.next() { + let obj = match m { + notes::Mark::Start(name) => InlineObject::BookmarkStart { name: name.clone() }, + notes::Mark::End(name) => InlineObject::BookmarkEnd { name: name.clone() }, + }; + pb.push('\u{FFFC}', CharProps::default()); + pb.objects.push(obj); + } + } + let (c, units) = match ctx.pieces.char_at(ctx.word, cp) { + Ok(found) => found, + Err(next) => { + cp = next; + continue; + } + }; + // Every real character counts against the document-wide budget; once it is spent, stop + // (a hostile file with thousands of story ranges over the same text can't multiply on). + if ctx.budget.get() == 0 { + break; + } + ctx.budget.set(ctx.budget.get() - 1); + // A section mark (0x0C) at a section's end is that section's last paragraph mark. + let section_mark = c == '\u{C}' && ctx.sect_ends.binary_search(&cp.saturating_add(1)).is_ok(); + match c { + '\r' | '\u{7}' | '\u{C}' if c != '\u{C}' || section_mark => { + if !ctx.paragraphs.spend() { + break; + } + // Paragraph mark: its PAPX covers the FC range of the paragraph it ends, so + // querying at the mark's own FC finds that paragraph's properties. + let fc_mark = ctx.pieces.fc_of_cp(cp).unwrap_or(0); + let (istd, papx) = ctx.papx_bins.papx(ctx.word, fc_mark); + if let Some(st) = ctx.raw_styles.get(istd as usize).filter(|s| !s.name.is_empty()) { + pb.props.style = Some(fmt::style_id(&st.name)); + } + let mut row = table::decode(papx); + row.cell_mark = c == '\u{7}'; + // Only table marks keep their table context; a plain paragraph would carry + // a `RowInfo` (about 300 bytes) it never uses. + let row = row.is_table().then(|| Box::new(row)); + let mut ilvl = 0u8; + let mut ilfo = 0i32; + for prl in sprm::iter(papx) { + if prl.op == P_ILVL { + ilvl = prl.operand.first().copied().unwrap_or(0).min(8); + continue; + } + if prl.op == P_ILFO { + ilfo = match prl.operand { + [b0, b1, ..] => i16::from_le_bytes([*b0, *b1]) as i32, + _ => 0, + }; + continue; + } + if prl.sgc() == 1 { + fmt::apply_para(&mut pb.props, &prl); + } + } + if let Some(num) = num_of(ilfo) { + pb.props.numbering = Some(NumRef { num, level: ilvl }); + } + let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0); + pb.mark = char_props(ctx.chpx_bins.chpx(ctx.word, fc), ctx.fonts, &CharProps::default()); + let done = std::mem::take(&mut pb); + out.push(ParaOut { + para: Paragraph { + text: done.text, + runs: done.runs, + props: done.props, + mark: done.mark, + objects: done.objects, + ..Default::default() + }, + end_cp: cp.saturating_add(1), + row, + }); + } + // Field begin/separator/end ([MS-DOC] §2.9.84 Plcfld). + '\u{13}' => { + match field.as_mut() { + Some(f) => { + f.depth = f.depth.saturating_add(1); + } + None => field = Some(FieldBuild { depth: 1, instr: String::new(), sep: None }), + } + cp = cp.saturating_add(1); + continue; + } + '\u{14}' => { + if let Some(f) = field.as_mut() + && f.depth == 1 + && f.sep.is_none() + { + f.sep = Some(FieldResult::default()); + } + cp = cp.saturating_add(1); + continue; + } + '\u{15}' => { + if let Some(mut f) = field.take() { + if f.depth > 1 { + f.depth -= 1; + field = Some(f); + } else { + finish_field(&mut pb, f, ctx.work); + } + } + cp = cp.saturating_add(1); + continue; + } + '\u{1}' => { + // Inline picture: the CHPX of the anchor carries sprmCPicLocation. + let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0); + let fc_pic = pic_location(ctx.chpx_bins.chpx(ctx.word, fc)); + match fc_pic.and_then(|at| pics.get(ctx.data, at)) { + Some(p) => { + // Every anchor of a picture gets its own copy of the alt text; the + // copies are charged to the work budget (#3709). + let alt = if ctx.work.charge(p.alt.len() as u64, Cost::AltText) { p.alt.clone() } else { String::new() }; + pb.push('\u{FFFC}', CharProps::default()); + pb.objects.push(InlineObject::Image { + media: p.key.clone(), + w: p.w, + h: p.h, + alt, + float: Default::default(), + crop: p.crop, + fx: Default::default(), + ole: None, + }); + } + None => pics.dropped(cp), + } + } + '\u{8}' => { + // Drawn object anchor: the floating shape or picture anchored at this CP. + let at = ctx.drawn.partition_point(|(c, _)| *c < cp); + // Each copy (a story can be walked more than once) is charged to the work budget + // by what a clone copies: a picture's alt text, a group with all its members (#3710). + for (_, obj) in ctx.drawn.get(at..).unwrap_or_default().iter().take_while(|(c, _)| *c == cp) { + if !ctx.work.charge(object_cost(obj), Cost::FloatingObject) { + continue; + } + pb.push('\u{FFFC}', CharProps::default()); + pb.objects.push(obj.clone()); + } + } + '\u{2}' => { + // Footnote or endnote reference; custom symbols share the same character. + let at = ctx.notes.partition_point(|(c, _, _)| *c < cp); + if let Some((_, kind, id)) = ctx.notes.get(at).filter(|(c, _, _)| *c == cp) { + pb.push('\u{FFFC}', CharProps::default()); + pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() }); + } + } + '\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut fmt), + '\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut fmt), + '\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut fmt), + '\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut fmt), + '\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut fmt), + '\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut fmt), + // Object anchors, comment references and other special characters are dropped + // (or handled by later passes). + c if (c as u32) < 0x20 => {} + c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut fmt), + } + cp = cp.saturating_add(units); + } + if !pb.text.is_empty() { + let done = pb; + out.push(ParaOut { + para: Paragraph { text: done.text, runs: done.runs, props: done.props, mark: done.mark, objects: done.objects, ..Default::default() }, + end_cp: cp_end, + row: None, + }); + } + out +} + +/// Field assembly: `instr` collects everything before the separator (nested fields +/// flattened), `sep` holds the cached result once the separator was seen. +struct FieldBuild { + depth: u32, + instr: String, + sep: Option, +} + +/// A field's cached result: its text and, as runs, the formatting of that text. A run is added +/// only when the formatting changes, so a result of millions of equally formatted characters +/// costs one `CharProps`, not one per character (365 bytes each). +#[derive(Default)] +struct FieldResult { + text: String, + /// (byte length in `text`, formatting) in order; adjacent runs differ. + runs: Vec<(usize, CharProps)>, +} + +impl FieldResult { + fn push(&mut self, c: char, props: CharProps) { + let len = c.len_utf8(); + match self.runs.last_mut() { + Some((n, last)) if *last == props => *n += len, + _ => self.runs.push((len, props)), + } + self.text.push(c); + } +} + +/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all +/// its members (#3710). +fn object_cost(obj: &InlineObject) -> u64 { + let shallow = std::mem::size_of::() as u64; + match obj { + InlineObject::Image { alt, .. } => shallow.saturating_add(alt.len() as u64), + InlineObject::Group { children, .. } => children.iter().fold(shallow, |n, c| n.saturating_add(object_cost(&c.obj))), + _ => shallow, + } +} + +/// Emit a finished field: HYPERLINK keeps its result text as a linked run; every other +/// field becomes one inline object with its cached result. +/// +/// The target is copied once per run of equally formatted result text, not per character, +/// and each copy is charged to `work` ([`MAX_WORK`]); once that is spent the rest of the +/// result is kept as plain text (#3708). +fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) { + let instr = f.instr.trim().to_string(); + let result = f.sep.unwrap_or_default(); + if let Some(target) = hyperlink_target(&instr) { + let cost = target.len() as u64; + let mut chars = result.text.chars(); + for (len, props) in result.runs { + let mut linked = props; + if work.charge(cost, Cost::LinkTarget) { + linked.link = Some(target.clone()); + } + let mut left = len; + let Some(first) = chars.next() else { break }; + pb.push(first, linked); + left = left.saturating_sub(first.len_utf8()); + while left > 0 { + let Some(c) = chars.next() else { break }; + pb.extend(c); + left = left.saturating_sub(c.len_utf8()); + } + } + return; + } + pb.push('\u{FFFC}', CharProps::default()); + pb.objects.push(InlineObject::Field { instr, result: result.text, locked: false, dirty: false, code: false, form: String::new() }); +} + +/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream. +fn pic_location(chpx: &[u8]) -> Option { + for prl in sprm::iter(chpx) { + if prl.op == C_PIC_LOCATION { + return match prl.operand { + [b0, b1, b2, b3, ..] => Some(u32::from_le_bytes([*b0, *b1, *b2, *b3])), + _ => None, + }; + } + } + None +} + +/// `sprmCSymbol` from a CHPX grpprl: the font table index and the character code of a symbol +/// character ([MS-DOC] §2.6.1, CSymbolOperand). +fn symbol_operand(chpx: &[u8]) -> Option<(u16, u16)> { + for prl in sprm::iter(chpx) { + if prl.op == C_SYMBOL + && let [f0, f1, c0, c1, ..] = prl.operand + { + return Some((u16::from_le_bytes([*f0, *f1]), u16::from_le_bytes([*c0, *c1]))); + } + } + None +} + +/// `sprmPIlfo` operand (a signed 16-bit value, [MS-DOC] §2.6.2) → the num id, if the +/// paragraph is in a list. 0x0001-0x07FE is a 1-based LFO index; 0xF802-0xFFFF (-2046..=-1) +/// is the negation of one and keeps the paragraph's own indents, which we do by leaving the +/// level's indents out of the paragraph (they never enter `ParaProps` anyway). 0 and 0xF801 +/// mean "not in a list". +fn num_of(ilfo: i32) -> Option { + match ilfo { + 0x0001..=0x07FE | -0x07FE..=-1 => Some(ilfo.unsigned_abs()), + _ => None, + } +} + +/// Resolve the character properties for the character at `cp`: the CHPX grpprl of its FC, +/// plus the piece's Prm, over the paragraph style's base character props. Inside a field, +/// the character is recorded into the instruction or the cached result instead of (or as +/// well as) the visible text. +fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, st: &mut FmtState<'a>) { + // The piece and the CHPX run of the previous character usually cover this one too. + let span = match st.span.filter(|s| s.contains(cp)) { + Some(s) => Some(s), + None => { + st.span = ctx.pieces.span(cp); + st.span + } + }; + let fc = span.and_then(|s| s.fc_at(cp)).unwrap_or(0); + let run = match st.chpx.filter(|r| r.contains(fc)) { + Some(r) => r, + None => { + let r = ctx.chpx_bins.chpx_run(ctx.word, fc); + st.chpx = Some(r); + r + } + }; + let grpprl = run.grpprl; + let prm = span.map_or(0, |s| s.prm()); + let style = pb.props.style.as_deref(); + let hit = st.memo.as_ref().is_some_and(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style); + if !hit { + let default; + let base = match style.and_then(|id| ctx.sheet.get(id)) { + Some(sty) => &sty.chr, + None => { + default = CharProps::default(); + &default + } + }; + let mut props = char_props(grpprl, ctx.fonts, base); + apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work); + st.memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props }); + st.generation += 1; + } + let Some(m) = st.memo.as_ref() else { return }; + // A symbol character (Insert › Symbol) is stored as the placeholder U+0028 with + // sprmCSymbol carrying the font and the character code ([MS-DOC] §2.6.1). + let symbol = if c == '(' { symbol_operand(grpprl) } else { None }; + if field.is_none() && symbol.is_none() { + pb.push_shared(c, &m.props, st.generation); + return; + } + let mut props = m.props.clone(); + let mut c = c; + if let Some((ftc, xchar)) = symbol { + if let Some(font) = ctx.fonts.get(usize::from(ftc)) { + props.font = Some(font.clone()); + } + if let Some(sym) = char::from_u32(u32::from(xchar)) { + c = sym; + } + } + match field { + Some(f) => match f.sep.as_mut() { + // Inside a field, characters are recorded for the field object instead of shown; + // `finish_field` emits the visible text (hyperlink runs or one object anchor). + Some(result) => result.push(c, props), + None => f.instr.push(c), + }, + None => pb.push(c, props), + } +} + +/// What the walk carries from one character to the next while it resolves formatting: the piece +/// and the CHPX run the last character was in (so most characters need no search), and the last +/// formatting resolved. +#[derive(Default)] +struct FmtState<'a> { + span: Option>, + chpx: Option>, + memo: Option>, + /// Counts the memo's replacements, so equal values mean the same properties. + generation: u64, +} + +/// The last character formatting resolved, reused while the CHPX, the piece's Prm and the +/// paragraph style stay the same, so a Prm grpprl (up to 64 KB) is interpreted once per +/// formatting change, not once per character (#3692). +struct FmtMemo<'a> { + /// The CHPX grpprl, compared by address: it is a slice of the WordDocument stream. + chpx: &'a [u8], + prm: u16, + style: Option, + props: CharProps, +} + +/// Apply a piece's Prm (Prm0 short sprm or Prm1 grpprl) to character properties. A Prm1 +/// grpprl is charged to `work`; once that is spent the grpprl is skipped. +fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &CharProps, work: &Work) { + match prm { + PrmRef::None => {} + PrmRef::Sprm0 { isprm, val } => { + if let Some(op) = sprm::from_prm0(isprm) { + let operand = [val]; + let prl = Prl { op, operand: &operand }; + if prl.sgc() == 2 { + fmt::apply_char(props, &prl, base, fonts); + } + } + } + PrmRef::Grpprl(Some(g)) => { + if !work.charge(g.len() as u64, Cost::Formatting) { + return; + } + for prl in sprm::iter(g) { + if prl.sgc() == 2 { + fmt::apply_char(props, &prl, base, fonts); + } + } + } + PrmRef::Grpprl(None) => {} + } +} + +/// Character props from a CHPX grpprl applied over `base`. +fn char_props(grpprl: &[u8], fonts: &[String], base: &CharProps) -> CharProps { + let mut p = base.clone(); + for prl in sprm::iter(grpprl) { + if prl.sgc() == 2 { + fmt::apply_char(&mut p, &prl, base, fonts); + } + } + p +} + +/// Read a root-level stream fully; `None` if it is absent or cannot be read. A stream larger than +/// [`MAX_STREAM`] is [`DocbinError::Limit`], and nothing is allocated for it. +fn stream(comp: &craft_cfb::CompoundFile<'_>, name: &str) -> Result>, DocbinError> { + match comp.read_path_capped(name, MAX_STREAM) { + Ok(v) => Ok(Some(v)), + Err(craft_cfb::Error::LimitExceeded(e)) => Err(DocbinError::Limit(format!("{name} stream: {e}"))), + Err(_) => Ok(None), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/doc-binary/src/list.rs b/crates/doc-binary/src/list.rs new file mode 100644 index 000000000..873792812 --- /dev/null +++ b/crates/doc-binary/src/list.rs @@ -0,0 +1,302 @@ +//! Lists: `PlfLst` (the LSTF definitions with their LVLs appended) and `PlfLfo` (the LFO +//! instances with LFOLVL overrides) resolved into the numbering model +//! ([MS-DOC] §2.8.1 "Determining List Formatting of a Paragraph", §2.9). + +use craft_wordmodel::numbering::{AbstractNum, Level, LevelSuffix, Num, Numbering}; +use craft_wordmodel::props::{Align, CharProps, ParaProps}; +use craft_wordmodel::section::NumFormat; + +use crate::fib::{Fib, pair}; +use crate::fmt; +use crate::sprm; + +/// Lists (and therefore LFOs) we are willing to read. +const MAX_LISTS: usize = 2_000; +/// Override levels per LFO ([MS-DOC] LFOLVL count is at most 9 by construction). +const MAX_LFO_LVL: usize = 9; + +/// One list definition: an LSTF plus its levels (one if `fSimpleList`, else nine). +struct ListDef { + lsid: i32, + levels: Vec, +} + +/// One LVL, as stored ([MS-DOC] §2.9.182): LVLF fields, the two grpprls and the number text. +struct RawLvl { + start: u32, + nfc: u8, + jc: u8, + flags: u8, + ixch_follow: u8, + rgbxch_nums: [u8; 9], + papx: Vec, + chpx: Vec, + /// The number text with each placeholder left as its raw char. + xst: Vec, +} + +/// A bullet level's character, with the 0xF000 flag bits stripped and the common +/// Symbol/Wingdings glyphs mapped to their Unicode equivalents so every font renders them. +fn bullet_char(c: u16) -> char { + match c & 0x0FFF { + 0xB7 => '\u{2022}', // Symbol bullet + 0xA7 => '\u{25AA}', // Wingdings small square + 0x6F => '\u{25CB}', // Symbol open circle + 0x77 | 0x78 => '\u{25CF}', // Wingdings filled circles + other => char::from_u32(other as u32).unwrap_or('\u{2022}'), + } +} + +/// MSONFC → model number format ([MS-OSHARED] §2.2.1.3; 0x17 = bullets, 0xFF = none). Codes +/// 0x00 to 0x27 run in the order of ST_NumberFormat (decimal, upperRoman … 0x16 decimalZero, 0x17 +/// bullet … 0x25 chineseCounting, 0x26 chineseLegalSimplified, 0x27 chineseCountingThousand); +/// later codes are drawn as decimal. +fn num_format(nfc: u8) -> NumFormat { + match nfc { + 0xFF => NumFormat::None, + 0x00..=0x27 => NumFormat::ALL.get(nfc as usize).copied().unwrap_or(NumFormat::Decimal), + _ => NumFormat::Decimal, + } +} + +#[cfg(test)] +#[test] +fn number_format_codes() { + assert_eq!(num_format(0x04), NumFormat::LowerLetter); + assert_eq!(num_format(0x16), NumFormat::DecimalZero); + assert_eq!(num_format(0x17), NumFormat::Bullet); + assert_eq!(num_format(0x25), NumFormat::ChineseCounting); + assert_eq!(num_format(0x27), NumFormat::ChineseCountingThousand); + assert_eq!(num_format(0x60), NumFormat::Decimal); +} + +/// Read the lists of a document; empty when the FIB carries none (or they are unreadable). +pub(crate) fn parse(table: &[u8], fib: &Fib, fonts: &[String]) -> Numbering { + let defs = match fib.pair(pair::PLF_LST) { + Some((fc, _)) => parse_plf_lst(table, fc), + None => Vec::new(), + }; + if defs.is_empty() { + return Numbering::default(); + } + let mut numbering = Numbering::default(); + for (i, def) in defs.iter().enumerate() { + numbering.abstracts.push(AbstractNum { + id: i as u32, + name: None, + levels: def.levels.iter().enumerate().map(|(j, l)| level_of(l, j, fonts)).collect(), + ..Default::default() + }); + } + if let Some((fc, lcb)) = fib.pair(pair::PLF_LFO) + && lcb > 0 + { + parse_plf_lfo(table, fc, &defs, &mut numbering, fonts); + } + numbering +} + +/// `PlfLst`: cLst u16, then cLst × LSTF (28 bytes each); the LVLs follow the whole array, +/// one per simple list and nine per multi-level list, in LSTF order. `lcbPlfLst` covers only +/// the LSTFs, so the LVLs are read past it, bounded by the stream. +fn parse_plf_lst(table: &[u8], fc: u32) -> Vec { + let at = fc as usize; + let Some(c_lst) = table.get(at..at.saturating_add(2)).map(|b| u16::from_le_bytes([b[0], b[1]])) else { return Vec::new() }; + let c_lst = c_lst as usize; + if c_lst == 0 || c_lst > MAX_LISTS { + return Vec::new(); + } + let mut defs = Vec::with_capacity(c_lst); + // LSTF: lsid i32, tplc, rgistdPara[9], flags byte (bit 0 = fSimpleList), grfhic. + let mut counts = Vec::with_capacity(c_lst); + for i in 0..c_lst { + let f = at.saturating_add(2).saturating_add(i.saturating_mul(28)); + let lsid = table.get(f..f.saturating_add(4)).map(|b| i32::from_le_bytes([b[0], b[1], b[2], b[3]])).unwrap_or(-1); + let simple = table.get(f.saturating_add(26)).copied().unwrap_or(0) & 1 != 0; + defs.push(ListDef { lsid, levels: Vec::new() }); + counts.push(if simple { 1 } else { 9 }); + } + let mut off = at.saturating_add(2).saturating_add(c_lst.saturating_mul(28)); + for (def, n) in defs.iter_mut().zip(counts) { + for _ in 0..n { + match parse_lvl(table, off) { + Some((lvl, len)) => { + def.levels.push(lvl); + off = off.saturating_add(len); + } + None => return defs, + } + } + } + defs +} + +/// One LVL ([MS-DOC] §2.9.182): LVLF (28 bytes) + grpprlPapx + grpprlChpx + Xst. +/// Returns the level and its total length in bytes. +fn parse_lvl(table: &[u8], at: usize) -> Option<(RawLvl, usize)> { + let lvlf = table.get(at..at.saturating_add(28))?; + let start = u32::from_le_bytes([lvlf[0], lvlf[1], lvlf[2], lvlf[3]]); + let nfc = lvlf[4]; + // Byte 5 packs jc (bits 0-1), fLegal (bit 2), fNoRestart (bit 3), … + let jc_flags = lvlf[5]; + let mut rgbxch_nums = [0u8; 9]; + rgbxch_nums.copy_from_slice(&lvlf[6..15]); + let ixch_follow = lvlf[15]; + let cb_chpx = lvlf[24] as usize; + let cb_papx = lvlf[25] as usize; + let mut at = at.saturating_add(28); + // Field order in an LVL: lvlf, grpprlPapx, grpprlChpx, then the Xst. + let papx = table.get(at..at.saturating_add(cb_papx)).unwrap_or(&[]).to_vec(); + at = at.saturating_add(cb_papx); + let chpx = table.get(at..at.saturating_add(cb_chpx)).unwrap_or(&[]).to_vec(); + at = at.saturating_add(cb_chpx); + let cch = table.get(at..at.saturating_add(2)).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0); + let cch = cch.min(64) as usize; + let chars = table.get(at.saturating_add(2)..at.saturating_add(2).saturating_add(cch * 2))?; + let xst: Vec = chars.as_chunks::<2>().0.iter().map(|b| u16::from_le_bytes([b[0], b[1]])).collect(); + let len = 28 + cb_chpx + cb_papx + 2 + cch * 2; + Some((RawLvl { start: start.min(0x7FFF), nfc, jc: jc_flags & 3, flags: jc_flags, ixch_follow, rgbxch_nums, papx, chpx, xst }, len)) +} + +/// `PlfLfo`: lfoMac u32, then lfoMac × LFO (16 bytes), then lfoMac × LFOData in parallel. +/// Each LFOData is a CP (skipped) followed by clfolvl × LFOLVL (8 bytes + optional LVL). +fn parse_plf_lfo(table: &[u8], fc: u32, defs: &[ListDef], numbering: &mut Numbering, fonts: &[String]) { + let at = fc as usize; + let Some(lfo_mac) = table.get(at..at.saturating_add(4)).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) else { return }; + let lfo_mac = lfo_mac.min(MAX_LISTS as u32) as usize; + let mut lsids = Vec::with_capacity(lfo_mac); + let mut clfolvls = Vec::with_capacity(lfo_mac); + for i in 0..lfo_mac { + let f = at.saturating_add(4).saturating_add(i.saturating_mul(16)); + let lsid = table.get(f..f.saturating_add(4)).map(|b| i32::from_le_bytes([b[0], b[1], b[2], b[3]])).unwrap_or(-1); + let clfolvl = table.get(f.saturating_add(12)).copied().unwrap_or(0); + lsids.push(lsid); + clfolvls.push(clfolvl); + } + let mut off = at.saturating_add(4).saturating_add(lfo_mac.saturating_mul(16)); + for (i, (&lsid, &clfolvl)) in lsids.iter().zip(&clfolvls).enumerate() { + let Some(def) = defs.iter().position(|d| d.lsid == lsid) else { + // Keep the LFOData walk in step even for unknown lists. + off = skip_lfodatas(table, off, clfolvl); + continue; + }; + // LFOData: cp u32, then clfolvl × LFOLVL. + let mut start_overrides = Vec::new(); + let mut overrides = Vec::new(); + off = off.saturating_add(4); + for _ in 0..(clfolvl as usize).min(MAX_LFO_LVL) { + let Some(hdr) = table.get(off..off.saturating_add(8)) else { break }; + let i_start = u32::from_le_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]); + let flags = hdr[4]; + let lvl = (flags & 0x0F) as usize; + let f_start = flags & 0x10 != 0; + let f_fmt = flags & 0x20 != 0; + off = off.saturating_add(8); + let mut fmt_lvl = None; + if f_fmt { + match parse_lvl(table, off) { + Some((l, len)) => { + fmt_lvl = Some(l); + off = off.saturating_add(len); + } + None => break, + } + } + if f_start { + start_overrides.push((lvl as u8, i_start.min(0x7FFF))); + } + if let Some(l) = fmt_lvl { + overrides.push((lvl, l)); + } + } + // Skip any LFOLVLs we did not consume (clfolvl above the cap). + if clfolvl as usize > MAX_LFO_LVL { + off = skip_lfodatas(table, off, clfolvl - MAX_LFO_LVL as u8); + } + let abstract_id = if overrides.is_empty() { + def as u32 + } else { + // A formatting override replaces whole levels: give the LFO its own abstract + // built from the base levels with the overrides applied. + let mut levels = numbering.abstracts.get(def).map(|a| a.levels.clone()).unwrap_or_default(); + for (lvl, raw) in overrides { + if let Some(slot) = levels.get_mut(lvl) { + *slot = level_of(&raw, lvl, fonts); + } + } + let id = numbering.abstracts.len() as u32; + numbering.abstracts.push(AbstractNum { id, name: None, levels, ..Default::default() }); + id + }; + numbering.nums.push(Num { id: (i + 1) as u32, abstract_id, start_overrides, ..Default::default() }); + } +} + +/// Advance past `n` LFOLVLs whose bodies we do not need; bounded and best-effort. +fn skip_lfodatas(table: &[u8], mut off: usize, n: u8) -> usize { + for _ in 0..n { + let Some(hdr) = table.get(off..off.saturating_add(8)) else { return off }; + let f_fmt = hdr[4] & 0x20 != 0; + off = off.saturating_add(8); + if f_fmt && let Some((_, len)) = parse_lvl(table, off) { + off = off.saturating_add(len); + } + } + off +} + +/// Turn a stored LVL into a model level. +fn level_of(l: &RawLvl, lvl_index: usize, fonts: &[String]) -> Level { + // Number text: characters listed in rgbxchNums are level placeholders (their value is + // the zero-based level); bullet chars carry 0xF000 flag bits to strip. + let mut text = String::new(); + for (i, &c) in l.xst.iter().enumerate() { + if l.rgbxch_nums.contains(&(i as u8 + 1)) && c <= 8 { + text.push('%'); + text.push((b'1' + c as u8) as char); + } else { + text.push(bullet_char(c)); + } + } + if text.is_empty() { + text.push('\u{2022}'); + } + // Indents from the level's grpprlPapx. + let mut props = ParaProps::default(); + for prl in sprm::iter(&l.papx) { + if prl.sgc() == 1 { + fmt::apply_para(&mut props, &prl); + } + } + let indent = props.indent_left.unwrap_or(18.0 * (lvl_index as f32 + 1.0)).clamp(0.0, 1000.0); + let hanging = props.indent_first.map(|f| (-f).clamp(0.0, 1000.0)).unwrap_or(18.0); + let mut chr = CharProps::default(); + let base = chr.clone(); + for prl in sprm::iter(&l.chpx) { + if prl.sgc() == 2 { + fmt::apply_char(&mut chr, &prl, &base, fonts); + } + } + Level { + start: l.start.max(1), + format: num_format(l.nfc), + text, + align: match l.jc { + 1 => Align::Center, + 2 => Align::Right, + _ => Align::Left, + }, + indent, + hanging, + suffix: match l.ixch_follow { + 1 => LevelSuffix::Space, + 2 => LevelSuffix::Nothing, + _ => LevelSuffix::Tab, + }, + chr, + restart: l.flags & 0x08 == 0, + legal: l.flags & 0x04 != 0, + style: None, + ..Level::default() + } +} diff --git a/crates/doc-binary/src/media.rs b/crates/doc-binary/src/media.rs new file mode 100644 index 000000000..da3656d51 --- /dev/null +++ b/crates/doc-binary/src/media.rs @@ -0,0 +1,277 @@ +//! Inline pictures: `sprmCPicLocation` → a `PICF` in the Data stream → embedded image bytes +//! ([MS-DOC] §2.9.190 PICF, §1.3.5 Pictures), and the shared store of every picture the +//! document reads (inline and floating). + +use std::collections::{BTreeMap, HashMap}; +use std::ops::Range; +use std::sync::Arc; + +use crate::officeart; + +/// `PICF.cbHeader` — the fixed size of the PICF structure. +const CB_HEADER: usize = 0x44; +/// Offsets inside PICF: `PICMID.dxaGoal/dyaGoal/mx/my` start at 28. +const OFF_PICMID: usize = 28; + +/// Most distinct pictures one document materialises from the Data stream. +const MAX_PICTURES: usize = 5_000; +/// Most picture bytes one document copies out of the Data stream, all pictures together. +pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20; +/// Most bytes one document decodes pictures into (metafiles inflated, DIB pixels), charged +/// per attempt whether it succeeds or not: retained media is capped separately, by +/// `MAX_MEDIA_BYTES` (#3711, #3712). +const MAX_DECODE_WORK: usize = 512 << 20; +/// The decode budget grows with the input: up to this many bytes per byte of the WordDocument +/// and Data streams. An uncompressed DIB declares at most 32 bytes of pixels per file byte (1 +/// bit per pixel, 4 bytes charged each), so 64 passes any such picture. RLE8/RLE4 bitmaps +/// reach about 510 times that and rely on the `MAX_DECODE_WORK` floor instead; a decompression +/// bomb (ratios around 1e6) still hits the cap. +const DECODE_WORK_PER_INPUT_BYTE: usize = 64; +/// The most the decode budget grows to, whatever the input. +const MAX_DECODE_WORK_CEILING: usize = 2 << 30; + +/// The decode budget for a document whose pictures live in `input` stream bytes, never less +/// than `floor`. +pub(crate) fn decode_work(input: usize, floor: usize) -> usize { + input.saturating_mul(DECODE_WORK_PER_INPUT_BYTE).clamp(floor, floor.max(MAX_DECODE_WORK_CEILING)) +} + +/// The document's pictures, keyed by their location in the Data stream: anchors that share a +/// `fc_pic` share one media entry (or one failure), and the total bytes copied are capped, so a +/// file with thousands of anchors on one huge picture costs one copy, not thousands. Pictures of +/// the drawing layer's BLIP store are shared the same way, by store index. +pub(crate) struct Pictures { + /// fc_pic → the inline picture already materialised (`None`: unreadable or over the cap, + /// not retried). + by_fc: HashMap>, + /// BLIP store index → media key (`None`: unreadable, not retried). + by_bse: HashMap>, + /// (stream, foDelay, record length) of a BLIP record outside the store → media key: store + /// entries and inline pictures that alias one record share one decode (`None`: failed, not + /// retried). The stream is told apart by its address. + by_delay: HashMap<(usize, usize, usize), Option>, + /// The media entries, merged into the document at the end. + pub(crate) media: BTreeMap>>, + total: usize, + /// The byte cap (`MAX_MEDIA_BYTES`; smaller in tests). + pub(crate) limit: usize, + /// Decode work left (`MAX_DECODE_WORK`; smaller in tests). + pub(crate) work: officeart::Decode, + /// Anchors whose picture was dropped (only the first few are logged). + dropped: usize, + /// Pictures kept as placeholders (formats we can't draw). + placeholders: usize, +} + +impl Default for Pictures { + fn default() -> Self { + Pictures { + by_fc: HashMap::new(), + by_bse: HashMap::new(), + by_delay: HashMap::new(), + media: BTreeMap::new(), + total: 0, + limit: MAX_MEDIA_BYTES, + work: officeart::Decode::new(MAX_DECODE_WORK), + dropped: 0, + placeholders: 0, + } + } +} + +/// An inline picture: its media key, display size (points), crop fractions and alt text. +#[derive(Clone, Debug)] +pub(crate) struct Inline { + pub(crate) key: String, + pub(crate) w: f32, + pub(crate) h: f32, + pub(crate) crop: [f32; 4], + pub(crate) alt: String, +} + +impl Pictures { + /// An empty store whose decode budget scales with the `input` stream bytes (WordDocument + /// plus Data) the pictures can come from. + pub(crate) fn for_input(input: usize) -> Self { + Pictures { work: officeart::Decode::new(decode_work(input, MAX_DECODE_WORK)), ..Pictures::default() } + } + + /// The inline picture at `fc_pic` in the Data stream, copying its bytes the first time. + /// `None` when the header is malformed, the picture can't be read, or a picture + /// count/byte cap is reached. + pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<&Inline> { + if !self.by_fc.contains_key(&fc_pic) { + if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES { + return None; + } + let pic = self.read(data, fc_pic); + self.by_fc.insert(fc_pic, pic); + } + self.by_fc.get(&fc_pic)?.as_ref() + } + + /// Read the inline picture at `fc_pic`: nothing is decoded or copied beyond what is left of + /// the byte cap, and nothing at all once it is used up. + fn read(&mut self, data: &[u8], fc_pic: u32) -> Option { + let room = self.limit.saturating_sub(self.total); + if room == 0 { + return None; + } + match locate(data, fc_pic, self)? { + Found::Range(pic) => { + let bytes = data.get(pic.range.clone()).filter(|b| b.len() <= room)?.to_vec(); + let key = self.insert(bytes, pic.ext, false)?; + Some(Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() }) + } + Found::Shape { w, h, key, props } => Some(Inline { key: key?, w, h, crop: props.crop, alt: props.alt }), + } + } + + /// The media key of BLIP store entry `pib` (1-based), decoding it the first time; `delay` + /// is the stream that holds BLIPs not embedded in the store (the WordDocument stream). + pub(crate) fn bse(&mut self, pib: u32, bstore: &[&[u8]], delay: &[u8]) -> Option { + if let Some(hit) = self.by_bse.get(&pib) { + return hit.clone(); + } + if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES { + return None; + } + let fbse = bstore.get((pib as usize).checked_sub(1)?)?; + let key = self.decode_bse(fbse, delay); + self.by_bse.insert(pib, key.clone()); + key + } + + /// Decode a store entry's BLIP, once per delay-stream record however many entries name it. + fn decode_bse(&mut self, fbse: &[u8], delay: &[u8]) -> Option { + let (rec, at) = officeart::fbse_record(fbse, delay)?; + let at = at.map(|(fo, used)| (delay.as_ptr() as usize, fo, used)); + if let Some(hit) = at.and_then(|at| self.by_delay.get(&at)) { + return hit.clone(); + } + let room = self.limit.saturating_sub(self.total); + let key = officeart::blip(&rec, room, &mut self.work).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder)); + if let Some(at) = at { + self.by_delay.insert(at, key.clone()); + } + key + } + + /// Add picture bytes as a new media entry, within the byte cap. + fn insert(&mut self, bytes: Vec, ext: &str, placeholder: bool) -> Option { + let total = self.total.checked_add(bytes.len()).filter(|&t| t <= self.limit)?; + let key = format!("image{}.{ext}", self.media.len() + 1); + if placeholder { + if self.placeholders < 10 { + log::warn!("docbin: picture `{key}` is in a format WordCraft can't draw ({ext}); it shows as a placeholder"); + } + self.placeholders = self.placeholders.saturating_add(1); + } + self.media.insert(key.clone(), Arc::new(bytes)); + self.total = total; + Some(key) + } + + /// Note a picture anchor that could not be materialised. + pub(crate) fn dropped(&mut self, cp: u32) { + if self.dropped < 10 { + log::warn!("docbin: picture at CP {cp} could not be read (or a picture cap was reached) and was dropped"); + } + self.dropped = self.dropped.saturating_add(1); + } +} + +/// Where a picture's image bytes sit in the Data stream, and its display size in points. +struct Located { + w: f32, + h: f32, + range: Range, + ext: &'static str, +} + +/// What a `PICFAndOfficeArtData` holds: raw image bytes (older writers), or an +/// `OfficeArtInlineSpContainer` (Word 97 and later) whose BLIP is decoded. +enum Found { + Range(Located), + Shape { w: f32, h: f32, key: Option, props: officeart::Props }, +} + +/// `PICF.mfpf.mm` values of pictures stored as a shape (`MM_SHAPE`) or a shape with a file +/// name (`MM_SHAPEFILE`). +const MM_SHAPE: u16 = 0x0064; +const MM_SHAPEFILE: u16 = 0x0066; + +/// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData); +/// the BLIP is decoded once per record and charged to the decode budget. +fn locate(data: &[u8], fc_pic: u32, pics: &mut Pictures) -> Option { + let base = fc_pic as usize; + let pic = data.get(base..)?; + let lcb = u32::from_le_bytes(pic.get(0..4)?.try_into().ok()?) as usize; + let cb_header = u16::from_le_bytes(pic.get(4..6)?.try_into().ok()?) as usize; + let cb_header = if (0x20..=0x100).contains(&cb_header) { cb_header } else { CB_HEADER }; + let mm = u16::from_le_bytes(pic.get(6..8)?.try_into().ok()?); + let m = pic.get(OFF_PICMID..OFF_PICMID + 8)?; + let dxa_goal = i16::from_le_bytes([m[0], m[1]]) as f32; + let dya_goal = i16::from_le_bytes([m[2], m[3]]) as f32; + let mx = u16::from_le_bytes([m[4], m[5]]) as f32; + let my = u16::from_le_bytes([m[6], m[7]]) as f32; + let scale = |r: f32| if r > 0.0 { (r / 1000.0).clamp(0.01, 100.0) } else { 1.0 }; + let end = lcb.min(pic.len()); + if mm == MM_SHAPE || mm == MM_SHAPEFILE { + let mut at = cb_header; + if mm == MM_SHAPEFILE { + at = at.checked_add(1 + *pic.get(at)? as usize)?; + } + let (key, props) = inline_shape(pic.get(at..end)?, data, pics)?; + // The goal size is before cropping; the crop fractions take their share off it. + let [cl, ct, cr, cb] = props.crop; + let w = (dxa_goal / 20.0 * (1.0 - cl - cr).max(0.01) * scale(mx)).clamp(1.0, 1584.0); + let h = (dya_goal / 20.0 * (1.0 - ct - cb).max(0.01) * scale(my)).clamp(1.0, 1584.0); + return Some(Found::Shape { w, h, key, props }); + } + let w = (dxa_goal / 20.0 * scale(mx)).clamp(1.0, 1584.0); + let h = (dya_goal / 20.0 * scale(my)).clamp(1.0, 1584.0); + let body = pic.get(cb_header..end)?; + let at = sniff(body)?; + let image = body.get(at..)?; + let start = base.checked_add(cb_header)?.checked_add(at)?; + Some(Found::Range(Located { w, h, range: start..start.checked_add(image.len())?, ext: ext_of(image) })) +} + +/// An `OfficeArtInlineSpContainer`: the picture shape, then the `OfficeArtFBSE` records its +/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). The BLIP is decoded +/// once per record, however many pictures (and FBSE sizes) name it. +fn inline_shape(buf: &[u8], data: &[u8], pics: &mut Pictures) -> Option<(Option, officeart::Props)> { + let mut budget = 10_000usize; + let recs = officeart::children(buf, &mut budget); + let sp = recs.iter().find(|r| r.typ == officeart::rt::SP_CONTAINER).map(|r| officeart::shape(r.body, &mut budget))?; + let fbses: Vec<&[u8]> = recs.iter().filter(|r| r.typ == officeart::rt::FBSE).map(|r| r.body).collect(); + let fbse = sp.props.pib.and_then(|p| fbses.get((p as usize).checked_sub(1)?)).or(fbses.first())?; + let key = pics.decode_bse(fbse, data); + Some((key, sp.props)) +} + +/// Offset of the first embedded raster image inside the payload, skipping the small +/// metafile header some writers put in front of PNG/JPEG data. `None` for metafiles. +fn sniff(body: &[u8]) -> Option { + for at in [0usize, 8, 12, 16, 20, 22, 24, 32] { + if let Some(b) = body.get(at..) + && (b.starts_with(b"\x89PNG") || b.starts_with(b"\xFF\xD8\xFF") || b.starts_with(b"GIF8") || b.starts_with(b"BM")) + { + return Some(at); + } + } + None +} + +fn ext_of(b: &[u8]) -> &'static str { + if b.starts_with(b"\x89PNG") { + "png" + } else if b.starts_with(b"\xFF\xD8\xFF") { + "jpeg" + } else if b.starts_with(b"GIF8") { + "gif" + } else { + "bmp" + } +} diff --git a/crates/doc-binary/src/metadata.rs b/crates/doc-binary/src/metadata.rs new file mode 100644 index 000000000..e45520394 --- /dev/null +++ b/crates/doc-binary/src/metadata.rs @@ -0,0 +1,182 @@ +//! Optional document text properties from [MS-OLEPS] §2.21 and §2.25.1. +//! Broken metadata must not prevent opening the document or allocate from unchecked sizes. + +use craft_wordmodel::CoreProps; + +const MAX_STREAM: u64 = 2 << 20; +const MAX_STRING: usize = 64 << 10; +const SUMMARY_FMTID: &[u8] = &[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0x00, 0x2b, 0x27, 0xb3, 0xd9]; + +pub(crate) fn read(comp: &craft_cfb::CompoundFile<'_>) -> Option { + parse(&comp.read_path_capped("\u{5}SummaryInformation", MAX_STREAM).ok()?) +} + +fn u32_at(bytes: &[u8], offset: usize) -> Option { + Some(u32::from_le_bytes(bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?)) +} + +fn parse(bytes: &[u8]) -> Option { + if bytes.get(..2)? != [0xfe, 0xff] || bytes.get(28..44)? != SUMMARY_FMTID || u32_at(bytes, 24)? < 1 { + return None; + } + if u16::from_le_bytes(bytes.get(2..4)?.try_into().ok()?) > 1 { + return None; + } + let offset = u32_at(bytes, 44)? as usize; + if offset < 48 || !offset.is_multiple_of(4) { + return None; + } + let size = u32_at(bytes, offset)? as usize; + let set = bytes.get(offset..offset.checked_add(size)?)?; + let count = u32_at(set, 4)? as usize; + if count > 4096 { + return None; + } + let table_end = 8 + count * 8; + let table = set.get(8..table_end)?; + // Offsets are relative to the property set. Bound each value by the next property so a + // forged string length cannot read another property's bytes as part of the title. + let property = |id: u32| -> Option<&[u8]> { + let entry = table.as_chunks::<8>().0.iter().find(|entry| u32_at(entry.as_slice(), 0) == Some(id))?.as_slice(); + let start = u32_at(entry, 4)? as usize; + if start < table_end || !start.is_multiple_of(4) { + return None; + } + let end = table + .as_chunks::<8>() + .0 + .iter() + .filter_map(|e| u32_at(e, 4)) + .map(|n| n as usize) + .filter(|n| *n > start) + .min() + .unwrap_or(set.len()) + .min(set.len()); + set.get(start..end) + }; + let codepage = property(1) + .and_then(|cp| { + let vt = u32_at(cp, 0)?; + if vt == 2 || vt == 3 { Some(u16::from_le_bytes(cp.get(4..6)?.try_into().ok()?)) } else { None } + }) + .unwrap_or(1252); + let get = |id| property(id).and_then(|value| text(value, codepage)).unwrap_or_default(); + let get_filetime = |id| property(id).and_then(filetime_iso).unwrap_or_default(); + Some(CoreProps { + title: get(2), + subject: get(3), + creator: get(4), + keywords: get(5), + description: get(6), + last_modified_by: get(8), + revision: get(9).parse().unwrap_or_default(), + created: get_filetime(12), + modified: get_filetime(13), + ..Default::default() + }) +} + +/// A `VT_FILETIME` property value as an ISO-8601 UTC string (`YYYY-MM-DDTHH:MM:SSZ`), or `None` +/// if it is not a FILETIME, is zero, or predates the 1601 FILETIME epoch. +fn filetime_iso(value: &[u8]) -> Option { + let kind = u32_at(value, 0)?; + if kind != 64 { + return None; + } + let ft_low = u32_at(value, 4)? as u64; + let ft_high = u32_at(value, 8)? as u64; + let ft = (ft_high << 32) | ft_low; + if ft == 0 { + return None; + } + const EPOCH_DIFFERENCE: u64 = 116_444_736_000_000_000; + let unix_100ns = ft.checked_sub(EPOCH_DIFFERENCE)?; + let unix_secs = i64::try_from(unix_100ns / 10_000_000).ok()?; + let (days, sod) = (unix_secs.div_euclid(86_400), unix_secs.rem_euclid(86_400)); + let (h, mi, s) = (sod / 3600, sod / 60 % 60, sod % 60); + let (y, m, d) = civil(days); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999. + Some(format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")) +} + +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// A FILETIME can land past `NaiveDate`'s ±262143-year range, where the old hand-rolled converter +/// still produced a date; dropping to `None` there would change the output. The Gregorian calendar +/// repeats every 400 years (146_097 days), so chrono converts the day within one cycle and whole +/// cycles go back into the year (an `i128` so the extremes can't overflow), keeping chrono as the +/// only calendar logic and reproducing the old result exactly. 1970-01-01 is `num_days_from_ce` 719_163. +fn civil(days: i64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } +} + +fn text(value: &[u8], codepage: u16) -> Option { + let kind = u32_at(value, 0)?; + let count = u32_at(value, 4)? as usize; + let size = match kind { + 30 => count, // CodePageString length is in bytes, even for code page 1200. + 31 => count.checked_mul(2)?, // UnicodeString length is in UTF-16 units. + _ => return None, + }; + if size > MAX_STRING { + return None; + } + let bytes = value.get(8..8usize.checked_add(size)?)?; + if kind == 31 || codepage == 1200 { + if !bytes.len().is_multiple_of(2) { + return None; + } + let units = bytes.as_chunks::<2>().0.iter().map(|b| u16::from_le_bytes([b[0], b[1]])).take_while(|u| *u != 0); + Some(char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()) + } else { + let bytes = bytes.split(|b| *b == 0).next().unwrap_or_default(); + if codepage == 65001 { + Some(String::from_utf8_lossy(bytes).into_owned()) + } else if craft_codepage::SINGLE_BYTE_CODE_PAGES.contains(&codepage) { + Some(bytes.iter().map(|b| craft_codepage::windows_byte(codepage, *b)).collect()) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::filetime_iso; + + /// A `FILETIME` property (VT_FILETIME = 64) as `YYYY-MM-DDTHH:MM:SSZ`. The values are pinned + /// to what the old hand-rolled `civil` produced. + fn value(ft: u64) -> Vec { + let mut v = 64u32.to_le_bytes().to_vec(); + v.extend_from_slice(&((ft & 0xffff_ffff) as u32).to_le_bytes()); + v.extend_from_slice(&((ft >> 32) as u32).to_le_bytes()); + v + } + + #[test] + fn filetime_to_iso() { + // 2020-01-01T00:00:00Z and the same day at 13:45:09. + assert_eq!(filetime_iso(&value(132_223_104_000_000_000)).as_deref(), Some("2020-01-01T00:00:00Z")); + assert_eq!(filetime_iso(&value(132_223_599_090_000_000)).as_deref(), Some("2020-01-01T13:45:09Z")); + // Year 10000 keeps the plain zero-padded year (no leading `+` that chrono's `%Y` would add). + assert_eq!(filetime_iso(&value(2_650_467_744_000_000_000)).as_deref(), Some("10000-01-01T00:00:00Z")); + // The largest FILETIME (u64::MAX) lands in year 60056: past year 9999 (so the plain year + // matters) and the old `civil` converted it without ever dropping to the epoch. chrono's + // own range (year 262143) isn't reachable here — a u64 FILETIME caps at unix seconds + // ~1.83e12 — but the 400-year cycle keeps the result exact regardless. + assert_eq!(filetime_iso(&value(u64::MAX)).as_deref(), Some("60056-05-28T05:36:10Z")); + // Zero, a non-FILETIME kind and times before the 1601 epoch are rejected, not a panic. + assert_eq!(filetime_iso(&value(0)), None); + assert_eq!(filetime_iso(&[2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), None); + assert_eq!(filetime_iso(&value(1)), None); + } +} diff --git a/crates/doc-binary/src/notes.rs b/crates/doc-binary/src/notes.rs new file mode 100644 index 000000000..51e3c4b84 --- /dev/null +++ b/crates/doc-binary/src/notes.rs @@ -0,0 +1,193 @@ +//! Footnotes, endnotes and bookmarks: `PlcffndRef`/`PlcffndTxt`, `PlcfendRef`/`PlcfendTxt`, +//! and `SttbfBkmk` + `Plcfbkf`/`Plcfbkl` ([MS-DOC] §2.9). + +/// Cap on notes and bookmarks per document. +const MAX: usize = 10_000; + +/// Parse the note reference/story PLCs. `ref_pair`/`txt_pair` are the FIB pair indices +/// (`(2, 3)` for footnotes, `(46, 47)` for endnotes); story ranges are relative to the +/// note subdocument. +/// +/// The story CPs of a well-formed PLC ascend, so the stories are disjoint. A hostile table +/// that steps back would make many notes read the same text again (each one materialised +/// as its own part), so a story starting before the end of the last one kept is dropped: +/// every subdocument CP becomes at most one note. +pub(crate) fn parse_notes(table: &[u8], fib: &crate::fib::Fib, ref_pair: usize, txt_pair: usize) -> Vec<(u32, (u32, u32))> { + let refs = fib.pair(ref_pair).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 2)).unwrap_or_default(); + let txts = fib.pair(txt_pair).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 0)).unwrap_or_default(); + if txts.len() < 2 { + return Vec::new(); + } + let mut out = Vec::new(); + let mut kept_to = 0u32; + // The reference PLC's aFtnIdx order matches the story order; pair them by index. + for (i, &cp) in refs.iter().enumerate() { + if let (Some(&a), Some(&b)) = (txts.get(i), txts.get(i + 1)) + && b > a + && a >= kept_to + { + out.push((cp, (a, b))); + kept_to = b; + } + } + out +} + +/// The CPs of a PLC (the trailing element is a limit and is kept so callers can pair +/// range ends) plus the data-element size check. Returns the CPs only. +fn plc_of(table: &[u8], fc: u32, lcb: u32, data_size: usize) -> Option> { + if lcb == 0 { + return None; + } + let plc = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize))?; + if plc.len() < 4 { + return None; + } + // aCP has one more element than aData: n = (len - data_size) / (4 + data_size). + let n = (plc.len() - data_size) / (4 + data_size); + if n == 0 || n > MAX { + return None; + } + let mut cps = Vec::with_capacity(n.min(MAX)); + for i in 0..=n.min(MAX) { + match plc.get(i * 4..i * 4 + 4) { + Some(b) => cps.push(u32::from_le_bytes([b[0], b[1], b[2], b[3]])), + None => break, + } + } + Some(cps) +} + +/// A bookmark boundary to insert while walking. +pub(crate) enum Mark { + Start(String), + End(String), +} + +/// Parse `SttbfBkmk` + `Plcfbkf` + `Plcfbkl` into (cp, event) pairs sorted for a sequential +/// walk. Hidden bookmarks (leading `_`) are kept: the engine can decide. +pub(crate) fn parse_bookmarks(table: &[u8], fib: &crate::fib::Fib) -> Vec<(u32, Mark)> { + let names = fib.pair(crate::fib::pair::STTBF_BKMK).and_then(|(fc, lcb)| sttb_strings(table, fc, lcb)).unwrap_or_default(); + let starts = fib.pair(crate::fib::pair::PLCF_BKF).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 4)).unwrap_or_default(); + let ends = fib.pair(crate::fib::pair::PLCF_BKL).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 2)).unwrap_or_default(); + let n = starts.len().saturating_sub(1).min(ends.len().saturating_sub(1)).min(names.len()).min(MAX); + let mut out = Vec::with_capacity(n * 2); + for i in 0..n { + let (Some(&a), Some(&b)) = (starts.get(i), ends.get(i)) else { break }; + if b < a { + continue; + } + if let Some(name) = names.get(i) { + if name.is_empty() { + continue; + } + out.push((a, Mark::Start(name.clone()))); + out.push((b, Mark::End(name.clone()))); + } + } + out.sort_by_key(|(cp, m)| (*cp, matches!(m, Mark::End(_)))); + out +} + +/// Strings of an STTB: extended (fExtend = 0xFFFF, 2-byte length and UTF-16 chars) or not +/// (1-byte length and Windows-1252 chars). `cbExtra` bytes after each string are skipped. +/// Names are kept to 40 characters, but the reader always advances by the stored length. +fn sttb_strings(table: &[u8], fc: u32, lcb: u32) -> Option> { + let b = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize))?; + let extended = b.get(0..2)? == [0xFF, 0xFF]; + let head = if extended { 2 } else { 0 }; + let h = b.get(head..head + 4)?; + let c_data = u16::from_le_bytes([h[0], h[1]]) as usize; + let cb_extra = u16::from_le_bytes([h[2], h[3]]) as usize; + if c_data > MAX { + return None; + } + let mut at = head + 4; + let mut out = Vec::with_capacity(c_data); + for _ in 0..c_data { + let (cch, unit) = if extended { + match b.get(at..at + 2) { + Some(x) => (u16::from_le_bytes([x[0], x[1]]) as usize, 2), + None => break, + } + } else { + match b.get(at) { + Some(&x) => (x as usize, 1), + None => break, + } + }; + at += unit; + let Some(chars) = b.get(at..at + cch * unit) else { break }; + at += cch * unit; + // The string's extra data must be there too, or the table is cut short. + if b.get(at..at + cb_extra).is_none() { + break; + } + at += cb_extra; + let s: String = if extended { + chars.as_chunks::<2>().0.iter().take(40).map(|c| char::from_u32(u16::from_le_bytes(*c) as u32).unwrap_or('\u{FFFD}')).collect() + } else { + chars.iter().take(40).map(|&c| craft_codepage::cp1252(c)).collect() + }; + out.push(s); + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::sttb_strings; + + fn ext_sttb(names: &[&str], extra: &[u8]) -> Vec { + let mut b = vec![0xFF, 0xFF]; + b.extend_from_slice(&(names.len() as u16).to_le_bytes()); + b.extend_from_slice(&(extra.len() as u16).to_le_bytes()); + for n in names { + let units: Vec = n.encode_utf16().collect(); + b.extend_from_slice(&(units.len() as u16).to_le_bytes()); + for u in units { + b.extend_from_slice(&u.to_le_bytes()); + } + b.extend_from_slice(extra); + } + b + } + + #[test] + fn long_bookmark_name_does_not_desync_table() { + let long = "A".repeat(50); + let b = ext_sttb(&[&long, "bm2"], &[]); + let got = sttb_strings(&b, 0, b.len() as u32).unwrap(); + assert_eq!(got, vec!["A".repeat(40), "bm2".to_string()]); + } + + #[test] + fn sttb_extra_data_is_skipped() { + let b = ext_sttb(&["first", "bm2"], &[1, 2, 3, 4]); + let got = sttb_strings(&b, 0, b.len() as u32).unwrap(); + assert_eq!(got, vec!["first".to_string(), "bm2".to_string()]); + } + + #[test] + fn a_name_without_its_extra_data_is_dropped() { + let mut b = ext_sttb(&["first", "bm2"], &[1, 2, 3, 4]); + b.truncate(b.len() - 2); + let got = sttb_strings(&b, 0, b.len() as u32).unwrap(); + assert_eq!(got, vec!["first".to_string()]); + } + + #[test] + fn non_extended_sttb_reads_byte_strings() { + let mut b = Vec::new(); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + for n in ["one", "caf\u{e9}"] { + let bytes: Vec = n.chars().map(|c| c as u8).collect(); + b.push(bytes.len() as u8); + b.extend_from_slice(&bytes); + b.extend_from_slice(&[9, 9]); + } + let got = sttb_strings(&b, 0, b.len() as u32).unwrap(); + assert_eq!(got, vec!["one".to_string(), "caf\u{e9}".to_string()]); + } +} diff --git a/crates/doc-binary/src/officeart.rs b/crates/doc-binary/src/officeart.rs new file mode 100644 index 000000000..b5cf18c1c --- /dev/null +++ b/crates/doc-binary/src/officeart.rs @@ -0,0 +1,552 @@ +//! OfficeArt (Escher) drawing records, as Word 97-2003 stores them ([MS-ODRAW], public Microsoft +//! Open Specifications): the record header, the BLIP store and its pictures, shape containers with +//! their property tables, and the `OfficeArtContent` of the Table stream ([MS-DOC] §2.9.172). +//! +//! Everything here walks hostile bytes: record lengths are clamped to their parent, nesting depth +//! and record counts are capped, and decompressed metafiles are size-limited. + +use std::collections::HashMap; +use std::io::Write; + +use image::ImageEncoder; + +use craft_wordmodel::props::Rgb; + +/// Deepest container nesting walked (groups inside groups…). +const MAX_DEPTH: usize = 12; +/// Most records walked in one drawing tree or BLIP store. +const MAX_RECORDS: usize = 200_000; +/// Most BLIP store entries kept. +const MAX_BSE: usize = 10_000; +/// Most drawings (main document, headers) read from `OfficeArtContent`. +const MAX_DRAWINGS: usize = 8; +/// Largest decompressed metafile (bytes). +const MAX_METAFILE: usize = 64 << 20; +/// Largest DIB converted to PNG, per side (pixels). +const MAX_DIB_SIDE: u32 = 16_384; + +/// Record types ([MS-ODRAW] §2.2, §2.3). +pub(crate) mod rt { + pub(crate) const DGG_CONTAINER: u16 = 0xF000; + pub(crate) const BSTORE_CONTAINER: u16 = 0xF001; + pub(crate) const DG_CONTAINER: u16 = 0xF002; + pub(crate) const SPGR_CONTAINER: u16 = 0xF003; + pub(crate) const SP_CONTAINER: u16 = 0xF004; + pub(crate) const FBSE: u16 = 0xF007; + pub(crate) const FSPGR: u16 = 0xF009; + pub(crate) const FSP: u16 = 0xF00A; + pub(crate) const FOPT: u16 = 0xF00B; + pub(crate) const CLIENT_TEXTBOX: u16 = 0xF00D; + pub(crate) const CHILD_ANCHOR: u16 = 0xF00F; + pub(crate) const SECONDARY_FOPT: u16 = 0xF121; + pub(crate) const TERTIARY_FOPT: u16 = 0xF122; + pub(crate) const BLIP_EMF: u16 = 0xF01A; + pub(crate) const BLIP_WMF: u16 = 0xF01B; + pub(crate) const BLIP_PICT: u16 = 0xF01C; + pub(crate) const BLIP_JPEG: u16 = 0xF01D; + pub(crate) const BLIP_PNG: u16 = 0xF01E; + pub(crate) const BLIP_DIB: u16 = 0xF01F; + pub(crate) const BLIP_TIFF: u16 = 0xF029; + pub(crate) const BLIP_JPEG_CMYK: u16 = 0xF02A; +} + +/// One record: header fields and its body (clamped to what the parent holds). +#[derive(Clone, Copy, Debug)] +pub(crate) struct Rec<'a> { + pub(crate) inst: u16, + pub(crate) typ: u16, + pub(crate) body: &'a [u8], +} + +/// The record at the start of `buf` and the bytes it spans (header included). A body longer +/// than `buf` is cut to what is there. +pub(crate) fn record(buf: &[u8]) -> Option<(Rec<'_>, usize)> { + let h = buf.get(0..8)?; + let ver_inst = u16::from_le_bytes([h[0], h[1]]); + let typ = u16::from_le_bytes([h[2], h[3]]); + let len = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as usize; + let rest = buf.get(8..)?; + let len = len.min(rest.len()); + let body = rest.get(..len)?; + Some((Rec { inst: ver_inst >> 4, typ, body }, 8 + len)) +} + +/// The sibling records of a container body, at most `budget` of them (the budget is shared +/// across a whole walk, so a deep or wide tree cannot multiply the work). +pub(crate) fn children<'a>(body: &'a [u8], budget: &mut usize) -> Vec> { + let mut out = Vec::new(); + let mut at = 0usize; + while *budget > 0 { + let Some((rec, used)) = body.get(at..).and_then(record) else { break }; + *budget -= 1; + out.push(rec); + at = at.saturating_add(used); + } + out +} + +fn u16_at(b: &[u8], at: usize) -> Option { + Some(u16::from_le_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?)) +} + +fn u32_at(b: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?)) +} + +fn i32_at(b: &[u8], at: usize) -> Option { + u32_at(b, at).map(|v| v as i32) +} + +// --------------------------------------------------------------------------------------------- +// Pictures (BLIPs). + +/// A decoded picture: bytes ready for `Document::media` and their file extension. +pub(crate) struct Blip { + pub(crate) bytes: Vec, + pub(crate) ext: &'static str, + /// The format cannot be drawn (Macintosh PICT): the picture is kept as a placeholder of + /// the right size. + pub(crate) placeholder: bool, +} + +/// The picture of an `OfficeArtFBSE` body: its embedded BLIP, or the BLIP stored at `foDelay` +/// in `delay` (the WordDocument stream for the document's BLIP store, the Data stream for +/// inline pictures). `limit` caps the bytes produced. +#[cfg(test)] +pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize, work: &mut Decode) -> Option { + let (rec, _) = fbse_record(fbse, delay)?; + blip(&rec, limit, work) +} + +/// The document's decode budget: bytes of inflating and pixel decoding left, and how many +/// attempts it has refused (only the first few are logged). +pub(crate) struct Decode { + pub(crate) left: usize, + refused: usize, +} + +impl Decode { + pub(crate) fn new(left: usize) -> Self { + Decode { left, refused: 0 } + } + + /// Take `cost` off the budget; `false` (and a log line) when it doesn't fit. + fn charge(&mut self, cost: usize, what: &str) -> bool { + match self.left.checked_sub(cost) { + Some(left) if cost > 0 => { + self.left = left; + true + } + _ => { + if self.refused < 10 { + log::warn!( + "docbin: a {what} needing {cost} bytes of decode work is over the decode budget ({} left) and was not decoded", + self.left + ); + } + self.refused = self.refused.saturating_add(1); + false + } + } + } +} + +/// The BLIP record of an `OfficeArtFBSE`: embedded after it, or in the delay stream, where +/// its (`foDelay`, record length) location is returned too (entries can share one record, +/// whatever sizes their FBSEs claim: the record's own header is what counts). +pub(crate) fn fbse_record<'a>(fbse: &'a [u8], delay: &'a [u8]) -> Option<(Rec<'a>, Option<(usize, usize)>)> { + // btWin32, btMacOS, rgbUid[16], tag, size, cRef, foDelay, unused, cbName, unused×2. + let fo_delay = u32_at(fbse, 28)? as usize; + let cb_name = *fbse.get(33)? as usize; + let at = 36usize.checked_add(cb_name)?; + if let Some((rec, _)) = fbse.get(at..).and_then(record) { + return Some((rec, None)); + } + // Not embedded: the record sits in the delay stream. + let (rec, used) = record(delay.get(fo_delay..)?)?; + Some((rec, Some((fo_delay, used)))) +} + +/// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31). `work` is the +/// document's decode budget: inflating a metafile or decoding a DIB is charged to it before +/// it runs, whether it succeeds or not, and nothing is decoded once it is spent. +pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut Decode) -> Option { + // Each type has two instance values; the odd one carries a second 16-byte UID. + let uids = if rec.inst & 1 == 1 { 32 } else { 16 }; + match rec.typ { + rt::BLIP_PNG | rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK | rt::BLIP_DIB | rt::BLIP_TIFF => { + let data = rec.body.get(uids + 1..)?; // + tag byte + if data.is_empty() || data.len() > limit { + return None; + } + match rec.typ { + rt::BLIP_PNG => Some(Blip { bytes: data.to_vec(), ext: "png", placeholder: false }), + rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK => Some(Blip { bytes: data.to_vec(), ext: "jpeg", placeholder: false }), + rt::BLIP_TIFF => Some(Blip { bytes: data.to_vec(), ext: "tiff", placeholder: false }), + _ => dib_to_png(data, limit, work), + } + } + rt::BLIP_EMF | rt::BLIP_WMF | rt::BLIP_PICT => { + // OfficeArtMetafileHeader: cbSize, rcBounds, ptSize, cbSave, compression, filter. + let hdr = rec.body.get(uids..uids + 34)?; + let cb_size = u32_at(hdr, 0)? as usize; + let cb_save = u32_at(hdr, 28)? as usize; + let compression = *hdr.get(32)?; + let raw = rec.body.get(uids + 34..)?; + let raw = raw.get(..cb_save.min(raw.len()))?; + let cap = limit.min(MAX_METAFILE); + let bytes = if compression == 0 { + let want = if cb_size > 0 { cb_size.min(cap) } else { cap }; + // The compressed stream is read whole whatever it inflates to: a record that + // claims to inflate to nothing still costs its input (#3871). + if !work.charge(want.max(raw.len()), "metafile") { + return None; + } + miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, want).ok()? + } else { + if raw.len() > cap { + return None; + } + raw.to_vec() + }; + if bytes.is_empty() { + return None; + } + match rec.typ { + rt::BLIP_EMF => Some(Blip { bytes, ext: "emf", placeholder: false }), + rt::BLIP_WMF => Some(Blip { bytes, ext: "wmf", placeholder: false }), + _ => Some(Blip { bytes, ext: "pict", placeholder: true }), + } + } + _ => None, + } +} + +/// A device-independent bitmap (BITMAPINFOHEADER + palette + pixels) as a PNG: the DIB gets +/// the 14-byte BMP file header it lacks, is decoded with size limits and re-encoded. When the +/// bitmap can't be decoded, or its declared pixels (4 bytes each) don't fit in `work`, the +/// BMP bytes are kept (and render as a grey placeholder). +fn dib_to_png(dib: &[u8], limit: usize, work: &mut Decode) -> Option { + let header = u32_at(dib, 0)? as usize; + if !(12..=124).contains(&header) { + return None; + } + let (bits, used, compression) = + if header == 12 { (u16_at(dib, 10)? as usize, 0, 0) } else { (u16_at(dib, 14)? as usize, u32_at(dib, 32)? as usize, u32_at(dib, 16)?) }; + let entry = if header == 12 { 3 } else { 4 }; + let colors = if used > 0 { + used.min(256) + } else if bits <= 8 { + 1usize << bits + } else { + 0 + }; + // BI_BITFIELDS masks follow a 40-byte header. + let masks = if header == 40 && (compression == 3 || compression == 6) { if compression == 3 { 12 } else { 16 } } else { 0 }; + let off_bits = 14usize.checked_add(header)?.checked_add(colors * entry)?.checked_add(masks)?; + let file_size = 14usize.checked_add(dib.len()).filter(|&size| size <= limit)?; + // The declared size is what decoding allocates, however small the (RLE) data is. + let (w, h) = if header == 12 { + (u64::from(u16_at(dib, 4)?), u64::from(u16_at(dib, 6)?)) + } else { + (u64::from(u32_at(dib, 4)?), u64::from((u32_at(dib, 8)? as i32).unsigned_abs())) + }; + let cost = w.saturating_mul(h).saturating_mul(4).try_into().unwrap_or(usize::MAX); + // Copying the DIB into a BMP is work too, however few pixels it declares: it is charged + // before the copy is made (#3871). A bitmap whose pixels don't fit still keeps its BMP + // bytes as a placeholder, if the copy fits. + let decode = work.charge(cost.max(dib.len()), "bitmap"); + if !decode && !work.charge(dib.len(), "bitmap") { + return None; + } + let mut bmp = Vec::with_capacity(file_size); + bmp.extend_from_slice(b"BM"); + bmp.extend_from_slice(&u32::try_from(file_size).ok()?.to_le_bytes()); + bmp.extend_from_slice(&[0, 0, 0, 0]); + bmp.extend_from_slice(&u32::try_from(off_bits).ok()?.to_le_bytes()); + bmp.extend_from_slice(dib); + let placeholder = |bmp| Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }); + if !decode { + return placeholder(bmp); + } + let mut reader = image::ImageReader::new(std::io::Cursor::new(&bmp)); + reader.set_format(image::ImageFormat::Bmp); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_DIB_SIDE); + limits.max_image_height = Some(MAX_DIB_SIDE); + limits.max_alloc = Some((256 << 20).min(limit as u64)); + reader.limits(limits); + let mut png = PictureWriter { bytes: Vec::new(), limit }; + match reader.decode() { + Ok(img) + if image::codecs::png::PngEncoder::new(&mut png).write_image(img.as_bytes(), img.width(), img.height(), img.color().into()).is_ok() => + { + Some(Blip { bytes: png.bytes, ext: "png", placeholder: false }) + } + _ => placeholder(bmp), + } +} + +/// Cap encoded bytes while the PNG encoder writes, before the buffer can grow. +struct PictureWriter { + bytes: Vec, + limit: usize, +} + +impl Write for PictureWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let Some(end) = self.bytes.len().checked_add(buf.len()).filter(|&end| end <= self.limit) else { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "picture exceeds remaining media budget")); + }; + if end > self.bytes.capacity() { + let new_capacity = self.bytes.capacity().saturating_mul(2).max(end).min(self.limit); + self.bytes.try_reserve_exact(new_capacity - self.bytes.len()).map_err(std::io::Error::other)?; + } + self.bytes.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +// --------------------------------------------------------------------------------------------- +// Shapes. + +/// The shape properties we use, from `OfficeArtFOPT` and its secondary/tertiary tables +/// ([MS-ODRAW] §2.3). +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Props { + /// `pib`: 1-based index of the picture in the BLIP store. + pub(crate) pib: Option, + /// Rotation, degrees clockwise. + pub(crate) rot: f32, + /// Crop fractions (left, top, right, bottom). + pub(crate) crop: [f32; 4], + pub(crate) fill_color: Option, + pub(crate) filled: Option, + pub(crate) line_color: Option, + /// Line width in EMUs. + pub(crate) line_width: Option, + pub(crate) line: Option, + /// An arrowhead at either end of a line. + pub(crate) arrowhead: bool, + /// `posh`/`posrelh`/`posv`/`posrelv`: alignment and reference frame (Word 2000 and later). + pub(crate) posh: Option, + pub(crate) posrelh: Option, + pub(crate) posv: Option, + pub(crate) posrelv: Option, + /// Distance from wrapped text (left, top, right, bottom), EMUs. + pub(crate) wrap_dist: [Option; 4], + /// `wzDescription`: the alternative text. + pub(crate) alt: String, +} + +impl Props { + /// Apply one property table (`OfficeArtFOPT` or a secondary/tertiary one): `count` 6-byte + /// entries, then the complex data of the entries flagged complex, in order. + pub(crate) fn apply(&mut self, body: &[u8], count: u16) { + let n = (count as usize).min(body.len() / 6); + let mut complex_at = n * 6; + for i in 0..n { + let Some(opid) = u16_at(body, i * 6) else { break }; + let Some(op) = u32_at(body, i * 6 + 2) else { break }; + let pid = opid & 0x3FFF; + if opid & 0x8000 != 0 { + let start = complex_at; + complex_at = complex_at.saturating_add(op as usize); + if pid == 0x0381 { + let end = complex_at.min(body.len()); + if let Some(data) = body.get(start..end) { + let units: Vec = + data.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).take_while(|u| *u != 0).take(4096).collect(); + self.alt = String::from_utf16_lossy(&units); + } + } + continue; + } + let fixed = |v: u32| (v as i32) as f32 / 65_536.0; + match pid { + 0x0004 => self.rot = fixed(op), + 0x0100..=0x0103 => { + // cropFromTop, cropFromBottom, cropFromLeft, cropFromRight. + let slot = [1, 3, 0, 2][(pid - 0x0100) as usize]; + let v = fixed(op); + self.crop[slot] = if v.is_finite() { v.clamp(0.0, 0.99) } else { 0.0 }; + } + 0x0104 => self.pib = Some(op), + 0x0181 => self.fill_color = color(op), + // fFilled (bit 4) is valid when fUsefFilled (bit 20) is set. + 0x01BF if op & (1 << 20) != 0 => self.filled = Some(op & (1 << 4) != 0), + 0x01BF => {} + 0x01C0 => self.line_color = color(op), + 0x01CB => self.line_width = Some(op), + 0x01D0 | 0x01D1 => self.arrowhead |= op != 0, + // fLine (bit 3) is valid when fUsefLine (bit 19) is set. + 0x01FF if op & (1 << 19) != 0 => self.line = Some(op & (1 << 3) != 0), + 0x01FF => {} + 0x0384..=0x0387 => self.wrap_dist[(pid - 0x0384) as usize] = Some(op), + 0x038F => self.posh = Some(op), + 0x0390 => self.posrelh = Some(op), + 0x0391 => self.posv = Some(op), + 0x0392 => self.posrelv = Some(op), + _ => {} + } + } + } +} + +/// An `OfficeArtCOLORREF` holding a literal RGB value (palette, scheme and system colour +/// references are not resolved). +fn color(v: u32) -> Option { + let [r, g, b, flags] = v.to_le_bytes(); + // fPaletteIndex, fSchemeIndex, fSysIndex. + if flags & 0x19 != 0 { + return None; + } + Some(Rgb(r, g, b)) +} + +/// One shape (`OfficeArtSpContainer`). +#[derive(Clone, Debug, Default)] +pub(crate) struct Sp { + pub(crate) spid: u32, + /// Shape type (`MSOSPT`), from the FSP's instance. + pub(crate) spt: u16, + /// FSP flags: fGroup, fChild, fPatriarch, fDeleted, fOleShape, fHaveMaster, fFlipH, fFlipV… + pub(crate) flags: u32, + pub(crate) props: Props, + /// `OfficeArtChildAnchor` (left, top, right, bottom) in the parent group's space. + pub(crate) child: Option<[i32; 4]>, + /// `OfficeArtFSPGR`: a group's own coordinate space. + pub(crate) group_space: Option<[i32; 4]>, + /// Has an `OfficeArtClientTextbox` (its text is in the text box story). + pub(crate) textbox: bool, +} + +impl Sp { + pub(crate) fn flip_h(&self) -> bool { + self.flags & (1 << 6) != 0 + } + pub(crate) fn flip_v(&self) -> bool { + self.flags & (1 << 7) != 0 + } + fn deleted(&self) -> bool { + self.flags & (1 << 3) != 0 + } +} + +/// A shape or a group of shapes. +#[derive(Clone, Debug)] +pub(crate) enum Node { + Shape(Sp), + /// A group: its own shape record (FSPGR, anchor) and members. + Group(Sp, Vec), +} + +impl Node { + pub(crate) fn sp(&self) -> &Sp { + match self { + Node::Shape(sp) | Node::Group(sp, _) => sp, + } + } +} + +/// Parse one `OfficeArtSpContainer`. +pub(crate) fn shape(body: &[u8], budget: &mut usize) -> Sp { + let mut sp = Sp::default(); + for rec in children(body, budget) { + match rec.typ { + rt::FSP => { + sp.spt = rec.inst; + sp.spid = u32_at(rec.body, 0).unwrap_or(0); + sp.flags = u32_at(rec.body, 4).unwrap_or(0); + } + rt::FOPT | rt::SECONDARY_FOPT | rt::TERTIARY_FOPT => sp.props.apply(rec.body, rec.inst), + rt::FSPGR | rt::CHILD_ANCHOR => { + let r = [0, 4, 8, 12].map(|o| i32_at(rec.body, o)); + if let [Some(l), Some(t), Some(r), Some(b)] = r { + if rec.typ == rt::FSPGR { + sp.group_space = Some([l, t, r, b]); + } else { + sp.child = Some([l, t, r, b]); + } + } + } + rt::CLIENT_TEXTBOX => sp.textbox = true, + _ => {} + } + } + sp +} + +/// Parse an `OfficeArtSpgrContainer`: the first shape is the group itself, the rest its members. +fn group(body: &[u8], depth: usize, budget: &mut usize) -> Option { + let recs = children(body, budget); + let mut it = recs.iter(); + let head = it.find(|r| r.typ == rt::SP_CONTAINER).map(|r| shape(r.body, budget))?; + let mut members = Vec::new(); + for rec in it { + if let Some(n) = node(rec, depth + 1, budget) { + members.push(n); + } + } + Some(Node::Group(head, members)) +} + +/// A shape or group record as a node (`None` for other records, deleted shapes or past the +/// depth cap). +fn node(rec: &Rec, depth: usize, budget: &mut usize) -> Option { + match rec.typ { + rt::SP_CONTAINER => Some(shape(rec.body, budget)).filter(|s| !s.deleted()).map(Node::Shape), + rt::SPGR_CONTAINER if depth < MAX_DEPTH => group(rec.body, depth, budget), + _ => None, + } +} + +/// The document's drawings: the BLIP store and the top-level shapes of each drawing by spid. +#[derive(Default)] +pub(crate) struct Content<'a> { + /// `OfficeArtFBSE` bodies, in BLIP store order (`pib` 1 = the first). + pub(crate) bstore: Vec<&'a [u8]>, + /// The main document's shapes (`dgglbl` 0) and the headers' (`dgglbl` 1), by spid. + pub(crate) main: HashMap, + pub(crate) header: HashMap, +} + +/// Parse `OfficeArtContent` (the `fcDggInfo` blob of the Table stream): an +/// `OfficeArtDggContainer` followed by (`dgglbl`, `OfficeArtDgContainer`) drawings. +pub(crate) fn content(blob: &[u8]) -> Content<'_> { + let mut out = Content::default(); + let mut budget = MAX_RECORDS; + let Some((dgg, mut at)) = record(blob) else { return out }; + if dgg.typ == rt::DGG_CONTAINER { + for rec in children(dgg.body, &mut budget) { + if rec.typ == rt::BSTORE_CONTAINER { + out.bstore = children(rec.body, &mut budget).into_iter().filter(|r| r.typ == rt::FBSE).map(|r| r.body).take(MAX_BSE).collect(); + } + } + } + for _ in 0..MAX_DRAWINGS { + let Some(&label) = blob.get(at) else { break }; + let Some((dg, used)) = blob.get(at + 1..).and_then(record) else { break }; + at = at.saturating_add(1 + used); + if dg.typ != rt::DG_CONTAINER { + continue; + } + let into = if label == 0 { &mut out.main } else { &mut out.header }; + for rec in children(dg.body, &mut budget) { + if rec.typ != rt::SPGR_CONTAINER { + continue; + } + // The top group: its first shape is the patriarch; the members are the drawing's shapes. + if let Some(Node::Group(_, members)) = group(rec.body, 0, &mut budget) { + for n in members { + into.entry(n.sp().spid).or_insert(n); + } + } + } + } + out +} diff --git a/crates/doc-binary/src/piece.rs b/crates/doc-binary/src/piece.rs new file mode 100644 index 000000000..5f36a8198 --- /dev/null +++ b/crates/doc-binary/src/piece.rs @@ -0,0 +1,230 @@ +//! The piece table ([MS-DOC] `Clx`/`Pcdt`/`PlcPcd`): where every character position lives in +//! the WordDocument stream, and how it is encoded. + +use craft_codepage::cp1252; + +use crate::DocbinError; + +/// Most pieces any real file has; more means a corrupt `PlcPcd`. +const MAX_PIECES: usize = 1_000_000; + +/// One piece: text at FcCompressed `fc` (30-bit offset + compression bit). +#[derive(Clone, Copy, Debug)] +struct Pcd { + fc: u32, + compressed: bool, + /// `Prm`: either a single sprm (Prm0) or an index into the Clx's Prc grpprls (Prm1). + prm: u16, +} + +/// The parsed piece table plus the grpprls of the Prcs that Prm1 references. +pub(crate) struct PieceTable { + /// n+1 character-position starts. + cps: Vec, + pieces: Vec, + prc_grpprls: Vec>, +} + +impl PieceTable { + /// Parse the `Clx` at `table[fc .. fc+lcb]`. + pub(crate) fn parse(table: &[u8], fc: u32, lcb: u32) -> Result { + let fc = fc as usize; + let lcb = lcb as usize; + if lcb == 0 { + return Err(DocbinError::Malformed("empty Clx".into())); + } + let end = fc.checked_add(lcb).ok_or_else(|| DocbinError::Malformed("Clx offset overflow".into()))?; + let clx = table.get(fc..end).ok_or_else(|| DocbinError::Malformed(format!("Clx at {fc}+{lcb} is outside the Table stream")))?; + // A Clx is an array of Prc (clxt 0x01, each holding a grpprl that Prm1 values can + // reference) followed by the Pcdt (clxt 0x02) that holds the piece table. + let mut prc_grpprls = Vec::new(); + let mut i = 0usize; + let plc = loop { + let Some(&clxt) = clx.get(i) else { + return Err(DocbinError::Malformed("Clx ends before the Pcdt".into())); + }; + match clxt { + 0x01 => { + let cb = clx + .get(i + 1..i + 3) + .map(|b| u16::from_le_bytes([b[0], b[1]]) as usize) + .ok_or_else(|| DocbinError::Malformed("truncated Prc".into()))?; + let stop = i.checked_add(3).and_then(|v| v.checked_add(cb)).ok_or_else(|| DocbinError::Malformed("Prc size overflow".into()))?; + if let Some(g) = clx.get(i + 3..stop) + && prc_grpprls.len() < MAX_PIECES + { + prc_grpprls.push(g.to_vec()); + } + i = stop; + } + 0x02 => { + let lcb_plc = clx + .get(i + 1..i + 5) + .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize) + .ok_or_else(|| DocbinError::Malformed("truncated Pcdt".into()))?; + let start = i.checked_add(5).ok_or_else(|| DocbinError::Malformed("Pcdt offset overflow".into()))?; + let stop = start.checked_add(lcb_plc).ok_or_else(|| DocbinError::Malformed("Pcdt size overflow".into()))?; + break clx.get(start..stop).ok_or_else(|| DocbinError::Malformed("PlcPcd is outside the Clx".into()))?; + } + other => return Err(DocbinError::Malformed(format!("unexpected Clx item 0x{other:02X}"))), + } + }; + // PlcPcd: aCp[n+1] u32s followed by aPcd[n] Pcds (8 bytes each). + if plc.len() < 4 || (plc.len() - 4) % 12 != 0 { + return Err(DocbinError::Malformed(format!("PlcPcd is {} bytes", plc.len()))); + } + let n = (plc.len() - 4) / 12; + if n > MAX_PIECES { + return Err(DocbinError::Limit(format!("piece table has {n} pieces"))); + } + let mut cps = Vec::with_capacity(n + 1); + for j in 0..=n { + let at = j * 4; + let Some(b) = plc.get(at..at + 4) else { + return Err(DocbinError::Malformed("truncated aCp".into())); + }; + // Keep the CPs non-decreasing so binary search over them is sound: an inverted + // piece in a corrupt file becomes an empty one. + let c = u32::from_le_bytes([b[0], b[1], b[2], b[3]]); + cps.push(c.max(cps.last().copied().unwrap_or(0))); + } + let mut pieces = Vec::with_capacity(n); + for j in 0..n { + let at = (n + 1) * 4 + j * 8 + 2; + let Some(fc_raw) = plc.get(at..at + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) else { + return Err(DocbinError::Malformed("truncated aPcd".into())); + }; + let prm = plc.get(at + 4..at + 6).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0); + pieces.push(Pcd { fc: fc_raw & 0x3FFF_FFFF, compressed: fc_raw & 0x4000_0000 != 0, prm }); + } + Ok(PieceTable { cps, pieces, prc_grpprls }) + } + + /// The Prls this piece's `Prm` adds. Bit 0 (`fComplex`) discriminates: a Prm0 + /// ([MS-DOC] §2.9.215) holds a 7-bit `isprm` in bits 1-7 and its one-byte operand in + /// bits 8-15; a Prm1 (§2.9.216) holds a zero-based index into the Clx's Prcs in bits + /// 1-15. + pub(crate) fn piece_prm(&self, cp: u32) -> PrmRef<'_> { + let Some(p) = self.piece_of(cp) else { return PrmRef::None }; + if p.prm & 1 == 0 { + let isprm = ((p.prm >> 1) & 0x7F) as u8; + if isprm == 0 { + return PrmRef::None; + } + PrmRef::Sprm0 { isprm, val: (p.prm >> 8) as u8 } + } else { + PrmRef::Grpprl(self.prc_grpprls.get((p.prm >> 1) as usize).map(Vec::as_slice)) + } + } + + fn piece_of(&self, cp: u32) -> Option<&Pcd> { + let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?; + if i >= self.pieces.len() { + return None; + } + self.pieces.get(i) + } + + /// The FC of a character position in the space the FKP bin tables use: true byte + /// offsets into the WordDocument stream. A compressed piece stores its base `fc` + /// doubled (`byte = fc/2`), so the doubling unwinds here; an uncompressed piece's `fc` + /// already is its byte offset and each character covers two bytes. + pub(crate) fn fc_of_cp(&self, cp: u32) -> Option { + let p = self.piece_of(cp)?; + let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?; + let cp0 = *self.cps.get(i)?; + let step = cp.checked_sub(cp0)?.checked_mul(2)?; + if p.compressed { p.fc.checked_add(step)?.checked_div(2) } else { p.fc.checked_add(step) } + } + + /// The piece holding `cp`, with the CP range it covers: for every CP in that range the + /// answers of [`PieceTable::fc_of_cp`] and the piece's Prm come from this span alone, + /// so a sequential walk searches the piece table once per piece. + pub(crate) fn span(&self, cp: u32) -> Option> { + let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?; + let pcd = self.pieces.get(i)?; + let cp_lo = *self.cps.get(i)?; + let cp_hi = *self.cps.get(i + 1)?; + Some(PieceSpan { cp_lo, cp_hi, pcd }) + } + + /// The CP just past the last piece: no text exists at or beyond it. + pub(crate) fn cp_end(&self) -> u32 { + self.cps.last().copied().unwrap_or(0) + } + + /// The character stored at `cp` (control characters included) and how many CPs it + /// covers (2 for a surrogate pair), found by binary search over the pieces. + /// `Err(next)` when there is no readable character at `cp` (a gap between pieces, an + /// inverted piece, or piece bytes past the end of the stream): `next` is the CP to + /// resume at, always greater than `cp` (`u32::MAX` past the last piece), so callers + /// skip an unreadable range in one step instead of one CP at a time. + pub(crate) fn char_at(&self, word: &[u8], cp: u32) -> Result<(char, u32), u32> { + let i = self.cps.partition_point(|&c| c <= cp); + // The next piece boundary above `cp`, or the end of the CP space. + let next = self.cps.get(i).copied().filter(|&n| n > cp).unwrap_or(u32::MAX); + let Some(i) = i.checked_sub(1) else { return Err(next) }; + let (Some(p), Some(&cp0)) = (self.pieces.get(i), self.cps.get(i)) else { return Err(u32::MAX) }; + let Some(off) = cp.checked_sub(cp0) else { return Err(next) }; + let off = off as usize; + if p.compressed { + // 8-bit, Windows-1252, one byte per character, stored at fc/2. + let at = ((p.fc / 2) as usize).checked_add(off); + return match at.and_then(|a| word.get(a)) { + Some(&b) => Ok((cp1252(b), 1)), + None => Err(next), + }; + } + // UTF-16LE, one 16-bit unit per character, stored at fc. + let unit = |k: usize| -> Option { + let at = (p.fc as usize).checked_add(k.checked_mul(2)?)?; + word.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])) + }; + let Some(u) = unit(off) else { return Err(next) }; + if (0xD800..0xDC00).contains(&u) + && cp.saturating_add(1) < next + && let Some(lo) = unit(off.saturating_add(1)).filter(|lo| (0xDC00..0xE000).contains(lo)) + && let Some(Ok(c)) = char::decode_utf16([u, lo]).next() + { + return Ok((c, 2)); + } + Ok((char::from_u32(u as u32).unwrap_or('\u{FFFD}'), 1)) + } +} + +/// One piece and the CPs `[cp_lo, cp_hi)` it covers ([`PieceTable::span`]). +#[derive(Clone, Copy)] +pub(crate) struct PieceSpan<'a> { + cp_lo: u32, + cp_hi: u32, + pcd: &'a Pcd, +} + +impl PieceSpan<'_> { + pub(crate) fn contains(&self, cp: u32) -> bool { + self.cp_lo <= cp && cp < self.cp_hi + } + + /// [`PieceTable::fc_of_cp`] for a CP inside the span. + pub(crate) fn fc_at(&self, cp: u32) -> Option { + let step = cp.checked_sub(self.cp_lo)?.checked_mul(2)?; + if self.pcd.compressed { self.pcd.fc.checked_add(step)?.checked_div(2) } else { self.pcd.fc.checked_add(step) } + } + + /// The raw Prm of the piece: two CPs with the same value get the same [`PrmRef`]. + pub(crate) fn prm(&self) -> u16 { + self.pcd.prm + } +} + +/// What a piece's `Prm` refers to. +pub(crate) enum PrmRef<'a> { + None, + /// Prm0: a short sprm index into the isprm table, with a 1-byte operand. + Sprm0 { + isprm: u8, + val: u8, + }, + /// Prm1: a grpprl from a Prc (absent if out of range). + Grpprl(Option<&'a [u8]>), +} diff --git a/crates/doc-binary/src/sections.rs b/crates/doc-binary/src/sections.rs new file mode 100644 index 000000000..31ebfd251 --- /dev/null +++ b/crates/doc-binary/src/sections.rs @@ -0,0 +1,195 @@ +//! Sections and headers/footers: `PlcfSed` → `Sed` → `Sepx` (grpprls of section sprms) and +//! the `Plcfhdd` stories of the header subdocument ([MS-DOC] §2.8.25, §2.9.244). + +use craft_wordmodel::section::NumFormat; +use craft_wordmodel::section::{Columns, SectionProps, SectionStart}; + +use crate::fib::{Fib, pair}; +use crate::sprm::{self, Prl}; + +/// Most sections we read. +const MAX_SECTIONS: usize = 1024; + +// Section sprms ([MS-DOC] §2.6.4). +const S_BKC: u16 = 0x3009; +const S_TITLE_PAGE: u16 = 0x300A; // sprmSFTitlePage +const S_CCOLUMNS: u16 = 0x500B; +const S_PGN_START: u16 = 0x300C; // sprmSPgnStart +const S_ORIENTATION: u16 = 0x301D; // sprmSBOrientation +const S_HDR_TOP: u16 = 0xB017; +const S_HDR_BOTTOM: u16 = 0xB018; +const S_XA_PAGE: u16 = 0xB01F; +const S_YA_PAGE: u16 = 0xB020; +const S_DXA_LEFT: u16 = 0xB021; +const S_DXA_RIGHT: u16 = 0xB022; +const S_DYA_TOP: u16 = 0x9023; +const S_DYA_BOTTOM: u16 = 0x9024; +const S_DXA_GUTTER: u16 = 0xB025; + +/// One section: covers paragraphs up to (excluding) `end_cp`, with its properties. +pub(crate) struct Section { + pub(crate) end_cp: u32, + pub(crate) props: SectionProps, +} + +/// Parse `PlcfSed` and each section's `Sepx` grpprl. +pub(crate) fn parse(word: &[u8], table: &[u8], fib: &Fib) -> Vec
{ + let Some((fc, lcb)) = fib.pair(pair::PLCF_SED) else { return Vec::new() }; + let Some(plc) = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize)) else { return Vec::new() }; + // PlcSed: aCp[n+1] u32s + aSed[n] (Sed = fn 2, fcSepx 4, fnMpr 2, fcMpr 4). + if plc.len() < 4 || (plc.len() - 4) % 16 != 0 { + return Vec::new(); + } + let n = (plc.len() - 4) / 16; + let cp_at = |i: usize| -> Option { plc.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) }; + let mut out = Vec::with_capacity(n.min(MAX_SECTIONS)); + for i in 0..n.min(MAX_SECTIONS) { + let Some(end_cp) = cp_at(i + 1) else { break }; + let sed_at = (n + 1) * 4 + i * 12; + let fc_sepx = plc.get(sed_at + 2..sed_at + 6).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])).unwrap_or(0xFFFF_FFFF); + let mut props = SectionProps::default(); + // Sepx: cb u16, then a grpprl of section sprms. + let mut explicit_orient = false; + if let Some(grpprl) = + word.get(fc_sepx as usize..).and_then(|sepx| sepx.get(..2).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize)).and_then(|cb| { + let at = (fc_sepx as usize).checked_add(2)?; + word.get(at..at.checked_add(cb)?) + }) + { + for prl in sprm::iter(grpprl) { + // Only a defined operand counts as explicit; anything else leaves the + // orientation to the page shape. + if prl.op == S_ORIENTATION && matches!(prl.operand.first(), Some(1 | 2)) { + explicit_orient = true; + } + apply_section(&mut props, &prl); + } + } + // Without an explicit orientation sprm, infer it from the page shape. + if !explicit_orient { + if props.page_w < props.page_h { + props.landscape = false; + } else if props.page_w > props.page_h { + props.landscape = true; + } + } + out.push(Section { end_cp, props }); + } + out +} + +/// Apply a section sprm to the model's section properties. All the measurement sprms here +/// carry 16-bit twip operands ([MS-DOC] §2.6.4). +fn apply_section(s: &mut SectionProps, prl: &Prl) { + let tw = |v: i32| (v as f32 / 20.0).clamp(0.0, 1584.0); + let s16 = |prl: &Prl| -> Option { + match prl.operand { + [b0, b1, ..] => Some(i16::from_le_bytes([*b0, *b1])), + _ => None, + } + }; + match prl.op { + S_BKC => { + s.start = match prl.operand.first().copied().unwrap_or(2) { + // SBkcOperand ([MS-DOC] sprmSBkc): bkcContinuous, bkcNewColumn, bkcNewPage, + // bkcEvenPage, bkcOddPage; anything else falls back to the default (new page). + 0 => SectionStart::Continuous, + 1 => SectionStart::NextColumn, + 3 => SectionStart::EvenPage, + 4 => SectionStart::OddPage, + _ => SectionStart::NextPage, + }; + } + S_TITLE_PAGE => s.title_page = prl.operand.first().is_some_and(|&v| v != 0), + S_CCOLUMNS => { + if let Some(c) = s16(prl) { + s.columns = Columns { count: (c as u32).saturating_add(1).min(44), ..Default::default() }; + } + } + S_PGN_START => { + if let Some(v) = s16(prl) { + s.page_num_start = Some(v.max(0) as u32); + s.page_num_format = NumFormat::Decimal; + } + } + // SBOrientationOperand ([MS-DOC] §2.9.236): 1 portrait, 2 landscape. + S_ORIENTATION => match prl.operand.first() { + Some(1) => s.landscape = false, + Some(2) => s.landscape = true, + _ => {} + }, + S_HDR_TOP => { + if let Some(v) = s16(prl) { + s.header = tw(v as i32); + } + } + S_HDR_BOTTOM => { + if let Some(v) = s16(prl) { + s.footer = tw(v as i32); + } + } + S_XA_PAGE => { + if let Some(v) = s16(prl) { + s.page_w = tw(v as i32).max(7.2); + } + } + S_YA_PAGE => { + if let Some(v) = s16(prl) { + s.page_h = tw(v as i32).max(7.2); + } + } + S_DXA_LEFT => { + if let Some(v) = s16(prl) { + s.margin_left = tw(v as i32); + } + } + S_DXA_RIGHT => { + if let Some(v) = s16(prl) { + s.margin_right = tw(v as i32); + } + } + S_DYA_TOP => { + if let Some(v) = s16(prl) { + s.margin_top = tw(v as i32); + } + } + S_DYA_BOTTOM => { + if let Some(v) = s16(prl) { + s.margin_bottom = tw(v as i32); + } + } + S_DXA_GUTTER => { + if let Some(v) = s16(prl) { + s.gutter = tw(v as i32); + } + } + _ => {} + } +} + +/// The header stories' CP ranges from `Plcfhdd` (a PLC of bare CPs): story i spans +/// `[cps[i], cps[i+1])`. Stories 0-5 are footnote/endnote separators; every section then owns +/// six stories in the order even header, odd (default) header, even footer, odd (default) +/// footer, first header, first footer. The CPs are relative to the header subdocument. +/// +/// The CPs of a well-formed PLC ascend, so the stories are disjoint. A story starting before +/// the end of an earlier one (a hostile table pointing many sections at the same text, each +/// read into its own part) is left empty, like a slot linked to the previous section's: +/// every subdocument CP becomes at most one story. +pub(crate) fn header_stories(table: &[u8], fib: &Fib) -> Vec<(u32, u32)> { + let Some((fc, lcb)) = fib.pair(pair::PLCF_HDD) else { return Vec::new() }; + let Some(plc) = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize)) else { return Vec::new() }; + // Only the six separator stories and six per section (at most 1024) are ever used. + let n = (lcb as usize / 4).min(6 + 6 * MAX_SECTIONS + 1); + let cp_at = |i: usize| -> Option { plc.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) }; + let mut stories = Vec::with_capacity(n); + let mut kept_to = 0u32; + for i in 0..n.saturating_sub(1) { + if let (Some(a), Some(b)) = (cp_at(i), cp_at(i + 1)) { + let b = if a < kept_to { a } else { b.max(a) }; + kept_to = kept_to.max(b); + stories.push((a, b)); + } + } + stories +} diff --git a/crates/doc-binary/src/sprm.rs b/crates/doc-binary/src/sprm.rs new file mode 100644 index 000000000..7db632caf --- /dev/null +++ b/crates/doc-binary/src/sprm.rs @@ -0,0 +1,104 @@ +//! Sprm decoding ([MS-DOC] §2.6). A Prl is a 2-byte opcode followed by an operand whose +//! size comes from the opcode's `spra` field (with two variable-length exceptions). + +/// `sprmPChgTabs`: custom tab stops added and removed ([MS-DOC] §2.6.3, PChgTabsOperand). +pub(crate) const P_CHG_TABS: u16 = 0xC615; +/// `sprmPChgTabsPapx`: the same for paragraph and style formatting (PChgTabsPapxOperand). +pub(crate) const P_CHG_TABS_PAPX: u16 = 0xC60D; +/// `sprmTDefTable`: table cell borders and widths. +const T_DEF_TABLE: u16 = 0xD608; + +/// One decoded Prl: an opcode plus its operand bytes. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Prl<'a> { + pub(crate) op: u16, + pub(crate) operand: &'a [u8], +} + +impl Prl<'_> { + /// Which kind of property this sprm modifies: 1 paragraph, 2 character, 3 picture, + /// 4 section, 5 table ([MS-DOC] Sprm.sgc). + pub(crate) fn sgc(&self) -> u8 { + ((self.op >> 10) & 7) as u8 + } +} + +/// Iterate the Prls of a grpprl. Malformed tails are dropped. +pub(crate) fn iter(grpprl: &[u8]) -> impl Iterator> { + let mut rest = grpprl; + std::iter::from_fn(move || { + if rest.len() < 2 { + return None; + } + let op = u16::from_le_bytes([rest[0], rest[1]]); + let after = &rest[2..]; + // Operand size per spra, with the exceptions whose size byte lies. + let len = match (op >> 13, op) { + // PChgTabsOperand.cb is one byte, the size after it; 255 means "work it out" from the + // two counts: cb, cTabs, 4 bytes per removed stop, cTabs, 3 bytes per added stop. + (_, P_CHG_TABS) => match after.first().copied() { + Some(255) => after.get(1).and_then(|&del| { + let add = after.get(2 + 4 * del as usize).copied()?; + Some(1 + 1 + 4 * del as usize + 1 + 3 * add as usize) + }), + Some(cb) => Some(1 + cb as usize), + None => None, + }, + // TDefTableOperand.cb counts the bytes after cb, incremented by 1, so the + // operand is one byte longer than cb. + (_, T_DEF_TABLE) => after.get(..2).map(|b| 1 + u16::from_le_bytes([b[0], b[1]]) as usize), + (0, _) | (1, _) => Some(1), + (2, _) | (4, _) | (5, _) => Some(2), + (3, _) => Some(4), + (7, _) => Some(3), + (6, _) => after.first().map(|&n| 1 + n as usize), + _ => None, + }?; + let operand = after.get(..len)?; + rest = &after[len.min(after.len())..]; + Some(Prl { op, operand }) + }) +} + +/// A ToggleOperand value: 0 = off, 1 = on, 0x80 = leave unchanged, 0x81 = invert. +pub(crate) enum Toggle { + Off, + On, + Keep, + Invert, +} + +pub(crate) fn toggle(p: &Prl) -> Toggle { + match p.operand.first().copied().unwrap_or(0) { + 0x81 => Toggle::Invert, + 0x80 => Toggle::Keep, + 0x00 => Toggle::Off, + _ => Toggle::On, + } +} + +/// The isprm indices of Prm0 that map to sprms we understand ([MS-DOC] §2.9.215 table). +pub(crate) fn from_prm0(isprm: u8) -> Option { + Some(match isprm { + 0x05 => 0x2461, // sprmPJc + 0x07 => 0x2405, // sprmPFKeep + 0x08 => 0x2406, // sprmPFKeepFollow + 0x09 => 0x2407, // sprmPFPageBreakBefore + 0x33 => 0x2435, // sprmPWidowControl (low byte of the operand is the value) + 0x78 => 0x2640, // sprmPOutLvl + 0x4D => 0x2A0C, // sprmCHighlight + 0x55 => 0x0835, // sprmCFBold + 0x56 => 0x0836, // sprmCFItalic + 0x57 => 0x0837, // sprmCFStrike + 0x58 => 0x0838, // sprmCFOutline + 0x59 => 0x0839, // sprmCFShadow + 0x5A => 0x083A, // sprmCFSmallCaps + 0x5B => 0x083B, // sprmCFCaps + 0x5C => 0x083C, // sprmCFVanish + 0x5E => 0x2A3E, // sprmCKul + 0x62 => 0x2A42, // sprmCIco + 0x74 => 0x0854, // sprmCFImprint + 0x75 => 0x0855, // sprmCFSpec + _ => return None, + }) +} diff --git a/crates/doc-binary/src/stsh.rs b/crates/doc-binary/src/stsh.rs new file mode 100644 index 000000000..3bd0328f3 --- /dev/null +++ b/crates/doc-binary/src/stsh.rs @@ -0,0 +1,194 @@ +//! The stylesheet (`STSH`) and the font table (`SttbfFfn`) from the Table stream +//! ([MS-DOC] §2.9.271 and §2.9.247). + +use crate::DocbinError; + +/// Largest stylesheet we accept (styles are 0x0FFE max by the spec; stay stricter). +const MAX_STYLES: usize = 4096; + +/// One raw style record (`STD`). +#[derive(Clone, Debug, Default)] +pub(crate) struct RawStyle { + /// 1 paragraph, 2 character, 3 table, 4 numbering. + pub(crate) stk: u8, + /// Zero-based index of the parent style; 0x0FFF = none. + pub(crate) istd_base: u16, + pub(crate) name: String, + /// Paragraph-formatting UPX grpprl (paragraph styles). + pub(crate) papx: Vec, + /// Character-formatting UPX grpprl (paragraph and character styles). + pub(crate) chpx: Vec, +} + +/// The stylesheet's standard fonts, the ones text takes when neither its style nor its own +/// formatting names a font: font-table indexes of the ASCII, East Asian and other +/// (non-East-Asian, non-ASCII) fonts (`Stshif.rgftcStandardChpStsh`, [MS-DOC] §2.9.273) and the +/// complex-script font (`Stshi.ftcBi`, §2.9.272). `None` where the header is too short for one. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct StandardFonts { + pub(crate) ascii: Option, + pub(crate) east_asian: Option, + pub(crate) other: Option, + pub(crate) bidi: Option, +} + +/// `StshiLsd.cbLSD`: the size of a latent style's `LSD` ([MS-DOC] `StshiLsd`). +const LSD_SIZE: u16 = 4; + +/// The [`StandardFonts`] in the header (`Stshi`) of the `STSH` at `table[fc..fc+lcb]`: Word 365 +/// writes Aptos there while the font table's first entry is still Times New Roman (#5066). +pub(crate) fn standard_fonts(table: &[u8], fc: u32, lcb: u32) -> StandardFonts { + let Some(stsh) = (fc as usize).checked_add(lcb as usize).and_then(|end| table.get(fc as usize..end)) else { return StandardFonts::default() }; + let cb_stshi = stsh.get(0..2).map_or(0, |b| u16::from_le_bytes([b[0], b[1]]) as usize); + let stshi = stsh.get(2..).map_or(&[][..], |s| s.get(..cb_stshi.min(s.len())).unwrap_or(&[])); + // Stshif: cstd, cbSTDBaseInFile, flags, stiMaxWhenSaved, istdMaxFixedWhenSaved, + // nVerBuiltInNamesWhenSaved (2 bytes each), then rgftcStandardChpStsh (3 × 2); ftcBi follows. + let at = |o: usize| stshi.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]])); + // ftcBi is optional, and the latent style data (`StshiLsd`, whose `cbLSD` MUST be 4) follows + // it: a 4 right after it says it is there, a 4 in its place with more data after says it was + // left out. A header that ends right after the field (as Word 97's) has it, a 4 included. + let bidi = match (at(18), at(20)) { + (b, Some(LSD_SIZE) | None) => b, + (Some(LSD_SIZE), Some(_)) => None, + (b, _) => b, + }; + StandardFonts { ascii: at(12), east_asian: at(14), other: at(16), bidi } +} + +/// Parse the `STSH` at `table[fc..fc+lcb]`. +pub(crate) fn parse(table: &[u8], fc: u32, lcb: u32) -> Result, DocbinError> { + let fc = fc as usize; + let lcb = lcb as usize; + let end = fc.checked_add(lcb).ok_or_else(|| DocbinError::Malformed("STSH overflow".into()))?; + let stsh = table.get(fc..end).ok_or_else(|| DocbinError::Malformed("STSH outside the Table stream".into()))?; + if stsh.len() < 4 { + return Ok(Vec::new()); + } + // LPStshi: cbStshi u16 + Stshi; the style count and the size of each Stdf are what we need. + let cb_stshi = u16::from_le_bytes([stsh[0], stsh[1]]) as usize; + let stshi = stsh.get(2..2 + cb_stshi).unwrap_or(&[]); + if stshi.len() < 4 { + return Ok(Vec::new()); + } + let cstd = u16::from_le_bytes([stshi[0], stshi[1]]) as usize; + let cb_std_base = u16::from_le_bytes([stshi[2], stshi[3]]) as usize; + // 10 (Word 97) or 18 (with StdfPost2000); anything else is corrupt but tolerated at 10. + let cb_std_base = if cb_std_base == 0x12 { 18 } else { 10 }; + let cstd = cstd.min(MAX_STYLES); + let mut rest = stsh.get(2 + cb_stshi..).unwrap_or(&[]); + let mut out = Vec::with_capacity(cstd); + for _ in 0..cstd { + if rest.len() < 2 || out.len() >= MAX_STYLES { + break; + } + let cb_std = u16::from_le_bytes([rest[0], rest[1]]) as usize; + let (std, next) = rest.split_at(rest.len().min(2 + cb_std)); + rest = next; + if cb_std == 0 { + out.push(RawStyle::default()); // empty style: keeps istd numbering aligned + continue; + } + let Some(stdf) = std.get(2..2 + cb_std_base.min(cb_std.saturating_sub(2))) else { + out.push(RawStyle::default()); + continue; + }; + // StdfBase ([MS-DOC] §2.9.260, 10 bytes): sti(12)+flags(4); stk(4)+istdBase(12); + // cupx(4)+istdNext(12); bchUpe(16) (= cbStd); grfstd(16). istdBase is a zero-based + // index into the stylesheet, 0x0FFF meaning "no parent". + let stk = (stdf.get(2).copied().unwrap_or(1) & 0x0F).max(1); + let w1 = stdf.get(2..4).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0xFFF1); + let istd_base = w1 >> 4; + let w2 = stdf.get(4..6).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0); + let cupx = (w2 & 0x0F) as usize; + let _istd_next = w2 >> 4; // next-paragraph style; unused for now + let mut at = 2 + cb_std_base; + // xstzName: Xst (cch u16 + UTF-16LE) + terminator u16. + let mut name = String::new(); + if let Some(cch) = std.get(at..at + 2).map(|b| u16::from_le_bytes([b[0], b[1]])).map(|c| (c as usize).min(256)) { + if let Some(units) = std.get(at + 2..at + 2 + cch * 2) { + let chars: Vec = units.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(); + name = String::from_utf16_lossy(&chars); + } + at += 2 + cch * 2 + 2; + } + // grLPUpxSw: cupx entries, each cbUpx u16 + data (+ pad to even). + let mut upxs: Vec> = Vec::new(); + for _ in 0..cupx.min(3) { + let Some(cb) = std.get(at..at + 2).map(|b| u16::from_le_bytes([b[0], b[1]])).map(|v| v as usize) else { break }; + let data = std.get(at + 2..at + 2 + cb).unwrap_or(&[]).to_vec(); + upxs.push(data); + at += 2 + cb + (cb & 1); // padded to even; cb in the header excludes the pad + } + // Paragraph styles: UPX0 is a PAPX (istd u16 + grpprl), UPX1 a raw grpprl of + // character sprms; character styles: UPX0 is the character grpprl. + let mut papx = Vec::new(); + let mut chpx = Vec::new(); + if stk == 1 { + if let Some(u) = upxs.first() { + papx = u.get(2..).unwrap_or(&[]).to_vec(); // skip the istd of GrpPrlAndIstd + } + if let Some(u) = upxs.get(1) { + chpx = u.clone(); + } + } else if stk == 2 + && let Some(u) = upxs.first() + { + chpx = u.clone(); + } + out.push(RawStyle { stk, istd_base, name, papx, chpx }); + } + Ok(out) +} + +/// Font names from the `SttbfFfn` at `table[fc..fc+lcb]` ([MS-DOC] §2.9.286): an STTB +/// (§2.2.4) whose entries are FFN records (§2.9.82). Each entry is a length (`cchData`: a +/// one-byte byte count in the non-extended table the spec requires, a two-byte count of +/// 16-bit units in an extended one) followed by the FFN, and that length alone decides where +/// the next entry starts: an FFN ends with its primary name and an optional alternate name +/// (`xszAlt`), so scanning for the primary name's terminator would land inside the latter. +pub(crate) fn fonts(table: &[u8], fc: u32, lcb: u32) -> Vec { + let end = match (fc as usize).checked_add(lcb as usize) { + Some(e) if e <= table.len() => e, + _ => return Vec::new(), + }; + let Some(sttb) = table.get(fc as usize..end) else { return Vec::new() }; + let u16at = |o: usize| -> Option { sttb.get(o..o.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])) }; + let Some(first) = u16at(0) else { return Vec::new() }; + // FFN: ffid(1) wWeight(2) chs(1) ixchSzAlt(1) panose(10) fs(24) = 39 bytes, then the + // primary name, NUL-terminated, as UTF-16LE in the Word 97 files we accept (8-bit names + // from older writers fall back to Windows-1252). + let name_of = |ffn: &[u8]| -> String { + let tail = ffn.get(FFN_HEADER.min(ffn.len())..).unwrap_or(&[]); + let units: Vec = tail.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).take_while(|&u| u != 0).take(64).collect(); + let s = String::from_utf16_lossy(&units); + if s.chars().any(|c| c as u32 > 0x7F || c.is_ascii_graphic() || c == ' ') || s.is_empty() { + s + } else { + // An 8-bit name reads as garbage through UTF-16; fall back to Windows-1252. + tail.iter().take_while(|&&b| b != 0).take(64).map(|&b| craft_codepage::cp1252(b)).collect() + } + }; + // STTB header: [fExtend 0xFFFF] cData(2) cbExtra(2); each entry is followed by cbExtra + // bytes (0 for font tables, but skip them if present). + let (extended, cdata, cb_extra, mut at) = + if first == 0xFFFF { (true, u16at(2).unwrap_or(0), u16at(4).unwrap_or(0), 6usize) } else { (false, first, u16at(2).unwrap_or(0), 4usize) }; + let mut out = Vec::new(); + for _ in 0..cdata.min(1024) { + let (len_bytes, cb) = if extended { + let Some(cb) = u16at(at) else { break }; + (2usize, usize::from(cb).saturating_mul(2)) + } else { + let Some(&cb) = sttb.get(at) else { break }; + (1usize, usize::from(cb)) + }; + let start = at.saturating_add(len_bytes); + let stop = start.saturating_add(cb); + // A record cut short by the end of the table still yields what it holds. + out.push(name_of(sttb.get(start..stop.min(sttb.len())).unwrap_or(&[]))); + at = stop.saturating_add(usize::from(cb_extra)); + } + out +} + +/// Bytes of an FFN before its primary name ([MS-DOC] §2.9.82). +const FFN_HEADER: usize = 39; diff --git a/crates/doc-binary/src/table.rs b/crates/doc-binary/src/table.rs new file mode 100644 index 000000000..8cb2be78d --- /dev/null +++ b/crates/doc-binary/src/table.rs @@ -0,0 +1,519 @@ +//! Tables: the table sprms (sgc 5) carried by a row-end (TTP) mark's grpprl, and the +//! assembly of cell-ended paragraphs into [`Table`] blocks ([MS-DOC] §2.6.3, §2.4.3). + +use std::sync::Arc; + +use craft_wordmodel::Rgb; +use craft_wordmodel::para::Paragraph; +use craft_wordmodel::props::{Align, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge}; +use craft_wordmodel::table::{Cell, Row, Table}; +use craft_wordmodel::{Block, Blocks, para_block}; + +use crate::fmt; +use crate::sprm::{self, Prl}; + +// Paragraph sprms for table membership. +const P_IN_TABLE: u16 = 0x2416; +const P_TTP: u16 = 0x2417; +/// `sprmPItap`: table depth (we only model depth 1; deeper rows fall back to paragraphs). +const P_ITAP: u16 = 0x6649; + +// Table sprms ([MS-DOC] §2.6.3). +const T_JC: u16 = 0x5400; +const T_JC_90: u16 = 0x548A; +const T_DXA_LEFT: u16 = 0x9601; +const T_DXA_GAP_HALF: u16 = 0x9602; +const T_TABLE_HEADER: u16 = 0x3404; +const T_TABLE_BORDERS_80: u16 = 0xD605; +const T_ROW_HEIGHT: u16 = 0x9407; +const T_DEF_TABLE: u16 = 0xD608; +const T_DEF_TABLE_SHD_80: u16 = 0xD609; +/// `sprmTDefTableShd` / `Shd2nd` / `Shd3rd`: 10-byte `Shd`s for cells 0-21, 22-43, 44-62. +const T_DEF_TABLE_SHD: u16 = 0xD612; +const T_DEF_TABLE_SHD_2ND: u16 = 0xD616; +const T_DEF_TABLE_SHD_3RD: u16 = 0xD60C; +/// `sprmTDefTableShdRaw` / `Raw2nd` / `Raw3rd`: the same `Shd` arrays over the same cell +/// ranges, which Word 2007+ writes next to the above. +const T_DEF_TABLE_SHD_RAW: u16 = 0xD670; +const T_DEF_TABLE_SHD_RAW_2ND: u16 = 0xD671; +const T_DEF_TABLE_SHD_RAW_3RD: u16 = 0xD672; +const T_CANT_SPLIT_90: u16 = 0x3403; +const T_MERGE: u16 = 0x5624; +const T_VERT_MERGE: u16 = 0xD62B; +const T_VERT_ALIGN: u16 = 0xD62C; +const T_SET_SHD: u16 = 0xD62D; +const T_CANT_SPLIT: u16 = 0x3466; +const T_CELL_WIDTH: u16 = 0xD635; + +/// Most grid columns we build from a `sprmTDefTable` (the model's own cap is 63; one more +/// edge is fine but merged rows can sum spans slightly over, so clamp when assembling). +const MAX_COLS: usize = 63; + +/// What a paragraph mark says about its table context, from its PAPX. +#[derive(Default)] +pub(crate) struct RowInfo { + pub(crate) in_table: bool, + pub(crate) ttp: bool, + /// The mark itself was 0x07 (a cell end), set by the walker. + pub(crate) cell_mark: bool, + /// Table depth from `sprmPItap` (0 or 1 supported; deeper tables degrade to paragraphs). + pub(crate) depth: u32, + /// Cell-edge positions (points) from `sprmTDefTable` — [MS-DOC] defines them per row, + /// so each row keeps its own; ascending in well-formed rows, the first edge usually 0 + /// (the row origin). Rows without a `sprmTDefTable` keep this empty. + pub(crate) edges: Vec, + height: Option, + height_rule: HeightRule, + header: bool, + cant_split: bool, + align: Option, + indent: Option, + borders: Option, + /// Horizontal merges: (itcFirst, itcLim) cell-index ranges. + merges: Vec<(u8, u8)>, + /// Per-cell overrides keyed by (pre-merge) cell index. + vmerge: Vec<(u8, VMerge)>, + /// Later entries win: the `Shd` arrays override the `Shd80` fallback before them, and an + /// automatic `Shd` (`None`) clears it. + shading: Vec<(u8, Option)>, + widths: Vec<(u8, f32)>, + width_pcts: Vec<(u8, f32)>, + valign: Vec<(u8, VAlign)>, +} + +/// Read the table context out of a paragraph mark's PAPX grpprl. +impl RowInfo { + /// Does the mark belong to a table: inside one, ending a row, or ending a cell? + pub(crate) fn is_table(&self) -> bool { + self.in_table || self.ttp || self.cell_mark + } + + /// The row height from `sprmTDyaRowHeight`, for tests. + #[cfg(test)] + pub(crate) fn height(&self) -> Option { + self.height + } + + /// The fill cell `i` ends up with (the last shading entry for it wins), for tests. + #[cfg(test)] + pub(crate) fn shading_of(&self, i: u8) -> Option { + self.shading.iter().rfind(|(k, _)| *k == i).and_then(|(_, s)| *s) + } + + /// The width cell `i` ends up with, for tests. + #[cfg(test)] + pub(crate) fn width_of(&self, i: u8) -> Option { + self.widths.iter().rfind(|(k, _)| *k == i).map(|(_, w)| *w) + } + + /// How many per-cell overrides the row keeps, for tests. + #[cfg(test)] + pub(crate) fn overrides(&self) -> usize { + self.vmerge.len() + self.shading.len() + self.widths.len() + self.width_pcts.len() + self.valign.len() + self.merges.len() + } + + /// The width percentage cell `i` ends up with, for tests. + #[cfg(test)] + pub(crate) fn width_pct_of(&self, i: u8) -> Option { + self.width_pcts.iter().rfind(|(k, _)| *k == i).map(|(_, w)| *w) + } +} + +/// Set cell `c`'s override to `v`, replacing an earlier one: one slot per cell, however often +/// a PAPX repeats the sprm, so a shared PAPX can't pile up copies on every paragraph mark. +fn set(slots: &mut Vec<(u8, T)>, c: u8, v: T) { + match slots.iter_mut().find(|(k, _)| *k == c) { + Some(slot) => slot.1 = v, + None => slots.push((c, v)), + } +} + +pub(crate) fn decode(papx: &[u8]) -> RowInfo { + let mut r = RowInfo::default(); + let mut gap_half: Option = None; + let mut dxa_left: Option = None; + for prl in sprm::iter(papx) { + // The membership sprms are paragraph sprms (sgc 1); the rest here are table sprms + // (sgc 5), so match opcodes directly. + match prl.op { + P_IN_TABLE => r.in_table = prl.operand.first().copied().unwrap_or(0) != 0, + P_TTP => r.ttp = prl.operand.first().copied().unwrap_or(0) != 0, + P_ITAP => r.depth = i32_of(&prl).unwrap_or(0).max(0) as u32, + T_DEF_TABLE => r.edges = def_table_edges(prl.operand), + T_TABLE_HEADER => r.header = prl.operand.first().copied().unwrap_or(0) != 0, + T_CANT_SPLIT | T_CANT_SPLIT_90 => r.cant_split = prl.operand.first().copied().unwrap_or(0) != 0, + T_ROW_HEIGHT => { + if let Some(v) = s16(&prl) { + if v < 0 { + r.height_rule = HeightRule::Exact; + r.height = Some((-(v as f32) / 20.0).clamp(0.0, 1584.0)); + } else { + r.height_rule = HeightRule::AtLeast; + r.height = Some((v as f32 / 20.0).clamp(0.0, 1584.0)); + } + } + } + T_JC | T_JC_90 => { + r.align = match u16_of(&prl).unwrap_or(0) { + 1 => Some(Align::Center), + 2 => Some(Align::Right), + _ => Some(Align::Left), + }; + } + T_DXA_LEFT => dxa_left = s16(&prl), + T_DXA_GAP_HALF => gap_half = s16(&prl), + T_TABLE_BORDERS_80 => r.borders = table_borders(prl.operand), + T_MERGE => { + // Rows apply only the first MAX_COLS merges. + if let [a, b, ..] = prl.operand + && b > a + && r.merges.len() < MAX_COLS + { + r.merges.push((*a, *b)); + } + } + T_VERT_MERGE => { + // cb, itc, flag: 1 = continues the merge from above, 3 = restarts it. + if let [_, itc, flag, ..] = prl.operand { + let m = match flag { + 3 => VMerge::Restart, + 1 => VMerge::Continue, + _ => VMerge::None, + }; + // The first override for a cell wins. + if m != VMerge::None && !r.vmerge.iter().any(|(k, _)| k == itc) { + r.vmerge.push((*itc, m)); + } + } + } + T_SET_SHD => { + if let Some(rgb) = prl.operand.get(3..).and_then(shd_rgb) + && let [a, b, ..] = prl.operand.get(1..3).unwrap_or(&[]) + { + for c in *a..(*b).min(MAX_COLS as u8) { + set(&mut r.shading, c, Some(rgb)); + } + } + } + T_DEF_TABLE_SHD_80 => { + let cells = prl.operand.get(1..).unwrap_or(&[]); + for (i, b) in cells.as_chunks::<2>().0.iter().take(MAX_COLS).enumerate() { + if let Some(rgb) = shd80_rgb(b) { + set(&mut r.shading, i as u8, Some(rgb)); + } + } + } + T_DEF_TABLE_SHD | T_DEF_TABLE_SHD_RAW | T_DEF_TABLE_SHD_2ND | T_DEF_TABLE_SHD_RAW_2ND | T_DEF_TABLE_SHD_3RD | T_DEF_TABLE_SHD_RAW_3RD => { + // Each array covers its own cell range (0-21, 22-43, 44-62); extra entries + // are ignored so an over-long array can't override the next range. + let (first, lim) = match prl.op { + T_DEF_TABLE_SHD_2ND | T_DEF_TABLE_SHD_RAW_2ND => (22, 44), + T_DEF_TABLE_SHD_3RD | T_DEF_TABLE_SHD_RAW_3RD => (44, MAX_COLS), + _ => (0, 22), + }; + let cells = prl.operand.get(1..).unwrap_or(&[]); + let raw = matches!(prl.op, T_DEF_TABLE_SHD_RAW | T_DEF_TABLE_SHD_RAW_2ND | T_DEF_TABLE_SHD_RAW_3RD); + for (i, b) in cells.as_chunks::<10>().0.iter().take(lim - first).enumerate() { + // In the Raw arrays a nil Shd (ipat 0xFFFF) means "as the table style + // shades it", so it leaves any earlier fill alone. + if raw && b.get(8..10) == Some(&[0xFF, 0xFF]) { + continue; + } + set(&mut r.shading, (first + i) as u8, shd_rgb(b)); + } + } + T_CELL_WIDTH => { + // Operand: cb (1 byte), itcFirst (1 byte), itcLim (1 byte), ftsWidth (1 byte), wWidth (2 bytes). + // [MS-DOC] §2.9.290 sprmTCellWidth (0xD635). + // ftsWidth: 3 = twips (dxa), 2 = percentage (50ths of a percent). + if let [_, a, b, fts, w0, w1, ..] = prl.operand { + if *fts == 3 { + let w = (i16::from_le_bytes([*w0, *w1]) as f32 / 20.0).clamp(0.0, 1584.0); + for c in *a..(*b).min(MAX_COLS as u8) { + set(&mut r.widths, c, w); + } + } else if *fts == 2 { + let pct = (u16::from_le_bytes([*w0, *w1]) as f32 / 50.0).clamp(0.0, 100.0); + for c in *a..(*b).min(MAX_COLS as u8) { + set(&mut r.width_pcts, c, pct); + } + } + } + } + T_VERT_ALIGN => { + if let [_, a, b, va, ..] = prl.operand { + let v = match va { + 1 => VAlign::Center, + 2 => VAlign::Bottom, + _ => VAlign::Top, + }; + for c in *a..(*b).min(MAX_COLS as u8) { + set(&mut r.valign, c, v); + } + } + } + _ => {} + } + } + // The table indent is the row origin minus half the cell gap. + if let Some(l) = dxa_left { + let gap = gap_half.unwrap_or(0) as f32 / 20.0; + r.indent = Some(((l as f32 / 20.0) - gap).clamp(-1584.0, 1584.0)); + } + r +} + +/// A finished paragraph plus the bookkeeping the block assembly needs. +pub(crate) struct ParaOut { + pub(crate) para: Paragraph, + /// CP just past the terminating mark. + pub(crate) end_cp: u32, + /// Table context from the mark's PAPX; `None` for a paragraph outside a table. + pub(crate) row: Option>, +} + +/// Group consecutive in-table paragraphs into [`Table`] blocks; the rest pass through. +/// Rows that never close (or tables nested deeper than we model) degrade to paragraphs so +/// no text is lost. +pub(crate) fn assemble(paras: Vec) -> Blocks { + let mut out: Blocks = Vec::new(); + let mut rows: Vec = Vec::new(); + let mut cells: Vec = Vec::new(); + let mut cell: Blocks = Vec::new(); + let mut grid: Vec = Vec::new(); + let mut props = TableProps::default(); + let mut open = false; + let mut depth_seen = 0u32; + + fn flush_cell(cells: &mut Vec, cell: &mut Blocks) { + let mut blocks = std::mem::take(cell); + if blocks.is_empty() { + blocks.push(para_block(Paragraph::new())); + } + cells.push(Cell { props: CellProps { span: 1, ..Default::default() }, blocks, controls: Default::default() }); + } + fn build_row(mut cells: Vec, info: &RowInfo) -> Row { + // Per-cell overrides first (sprm cell indices count pre-merge cells), then merges. + for (i, c) in cells.iter_mut().enumerate() { + let i = i as u8; + if let Some((_, m)) = info.vmerge.iter().find(|(k, _)| *k == i) { + c.props.vmerge = *m; + } + if let Some((_, w)) = info.widths.iter().rfind(|(k, _)| *k == i) { + c.props.width = Some(*w); + } + if let Some((_, pct)) = info.width_pcts.iter().rfind(|(k, _)| *k == i) { + c.props.width_pct = Some(*pct); + } + if let Some((_, s)) = info.shading.iter().rfind(|(k, _)| *k == i) { + c.props.shading = *s; + } + if let Some((_, v)) = info.valign.iter().rfind(|(k, _)| *k == i) { + c.props.valign = Some(*v); + } + } + for &(a, b) in info.merges.iter().take(MAX_COLS) { + let a = a as usize; + let b = (b as usize).min(cells.len()); + if a < b && b <= cells.len() && b - a > 1 { + let removed: Vec = cells.drain(a + 1..b).collect(); + if let Some(first) = cells.get_mut(a) { + first.props.span = ((b - a) as u32).min(63); + for c in removed { + first.blocks.extend(c.blocks.into_iter().filter(|x| x.as_para().is_none_or(|p| !p.is_empty()))); + } + if first.blocks.len() > 1 && first.blocks.first().and_then(|x| x.as_para()).is_some_and(Paragraph::is_empty) { + first.blocks.remove(0); + } + } + } + } + Row { + props: RowProps { + height: info.height, + height_rule: info.height_rule, + header: info.header, + cant_split: info.cant_split, + ..Default::default() + }, + cells, + controls: Default::default(), + } + } + fn flush_table(out: &mut Blocks, rows: &mut Vec, cells: &mut Vec, cell: &mut Blocks, grid: &[f32], props: &TableProps, nested: bool) { + if !cells.is_empty() || !cell.is_empty() { + flush_cell(cells, cell); + // A stray trailing row (no TTP of its own): size its cells from the grid. + let mut stray = build_row(std::mem::take(cells), &RowInfo::default()); + for (i, c) in stray.cells.iter_mut().enumerate() { + if c.props.width.is_none() && c.props.span == 1 { + c.props.width = grid.get(i).copied(); + } + } + rows.push(stray); + } + if rows.is_empty() { + return; + } + if nested { + // Unclosed or nested deeper than we model: keep the text, drop the table shape. + for r in rows.iter() { + for c in &r.cells { + out.extend(c.blocks.iter().cloned()); + } + } + } else { + out.push(Arc::new(Block::Table(Table { + props: props.clone(), + grid: grid.to_vec(), + rows: std::mem::take(rows), + controls: Default::default(), + }))); + } + rows.clear(); + } + + for p in paras { + let Some(info) = p.row else { + if open { + flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1); + grid.clear(); + // Table props are per-table (first row carrying the sprm wins within the + // table): reset the accumulator so one table's borders, align, indent and + // cell margins never leak into the next (the repro's first table wrote + // Word's default 108-twip gap, and every later table — including the + // change history with its own 40-twip gap — inherited 5.4 pt margins). + props = TableProps::default(); + open = false; + depth_seen = 0; + } + out.push(para_block(p.para)); + continue; + }; + if !open { + open = true; + } + depth_seen = depth_seen.max(info.depth); + if grid.is_empty() { + // Column widths from the table's first `sprmTDefTable`. + grid = info.edges.windows(2).take(MAX_COLS).map(|w| (w[1] - w[0]).max(0.0)).collect(); + } + if info.borders.is_some() && props.borders.is_none() { + props.borders = info.borders; + } + if props.align.is_none() { + props.align = info.align; + } + if props.indent.is_none() { + props.indent = info.indent; + } + if info.cell_mark { + // A row-end (TTP) mark terminates the row. Word writes rows as N content + // cells each ended by their own cell mark, followed by an empty row-end mark + // that carries the row's TDefTable (the 3GPP change history: 8 content marks + // + an empty TTP whose TDefTable defines itcMac = 8). When that mark ends no + // content of its own and the TDefTable already has a column per flushed cell, + // it is a pure row terminator, not the end of a phantom (N+1)th cell — such a + // cell has no column (width None) and shifts every real cell's span one grid + // column over. A TTP mark with content, or one short of the TDefTable's + // columns, still ends a real cell (rows without a TDefTable keep today's + // shape: the count is unknowable, so the mark always flushes). + let row_end_only = info.ttp && p.para.text.is_empty() && cell.is_empty() && !info.edges.is_empty() && cells.len() + 1 >= info.edges.len(); + // ...but a row-end mark with no cells before it still makes a (one-cell) row. + let row_end_only = row_end_only && !cells.is_empty(); + if !p.para.text.is_empty() { + cell.push(para_block(p.para)); + } + if !row_end_only { + flush_cell(&mut cells, &mut cell); + } + if info.ttp { + let row = build_row(std::mem::take(&mut cells), &info); + rows.push(row); + } + } else { + cell.push(para_block(p.para)); + } + } + if open { + flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1); + } + out +} + +/// Cell-edge positions (points) from a `sprmTDefTable` operand: cb u16, itcMac u8, then +/// itcMac+1 twip boundaries followed by the TC80s (skipped). Edges are returned as stored: +/// per-row, in operand order; sorting and capping happen when the table grid is assembled. +fn def_table_edges(operand: &[u8]) -> Vec { + let itc = operand.get(2).copied().unwrap_or(0) as usize; + if itc == 0 { + return Vec::new(); + } + // itcMac is a u8, so this allocation stays tiny; the loop stops at the operand's end. + let mut edges = Vec::with_capacity((itc + 1).min(256)); + for i in 0..=itc { + match operand.get(3 + i * 2..3 + i * 2 + 2) { + Some(b) => edges.push(i16::from_le_bytes([b[0], b[1]]) as f32 / 20.0), + None => break, + } + } + edges +} + +/// Table borders from `sprmTTableBorders80`: cb (0x18) then six Brc80s in the order top, +/// left, bottom, right, inside-horizontal, inside-vertical, read as paragraph borders are. +fn table_borders(operand: &[u8]) -> Option { + let b = |i: usize| operand.get(1 + i * 4..1 + i * 4 + 4).and_then(fmt::brc80); + Some(Borders { top: b(0), left: b(1), bottom: b(2), right: b(3), between: b(4), inside_v: b(5) }) +} + +/// Background colour from a 10-byte `Shd`: cvFore, cvBack (COLORREFs), ipat. Solid patterns +/// show the foreground; clear (0) and the other patterns show the background (Word writes a +/// plain cell fill as clear with cvBack); nil (0xFFFF) and automatic colours are no fill. +fn shd_rgb(b: &[u8]) -> Option { + let cv = |b: &[u8]| -> Option { + match b { + [_, _, _, 0xFF] => None, // fAuto + [r, g, bl, _] => Some(Rgb(*r, *g, *bl)), + _ => None, + } + }; + let ipat = b.get(8..10).map(|x| u16::from_le_bytes([x[0], x[1]]))?; + match ipat { + 0xFFFF => None, + 1 => cv(b.get(0..4)?), + _ => cv(b.get(4..8)?), + } +} + +/// Background colour from a 2-byte `Shd80` (icoFore, icoBack, ipat bit-fields). As in +/// [`shd_rgb`], solid shows the foreground and clear (0) the background, which is how Word +/// 97 writes a plain fill; ico 0 is automatic (no fill). +fn shd80_rgb(b: &[u8]) -> Option { + let v = u16::from_le_bytes([*b.first()?, *b.get(1)?]); + let fore = v & 0x1F; + let back = (v >> 5) & 0x1F; + let ipat = v >> 10; + if fore == 0x1F && back == 0x1F { + return None; + } + fmt::ico(if ipat == 1 { fore as u8 } else { back as u8 }) +} + +fn u16_of(p: &Prl) -> Option { + match p.operand { + [b0, b1, ..] => Some(u16::from_le_bytes([*b0, *b1])), + _ => None, + } +} + +fn s16(p: &Prl) -> Option { + u16_of(p).map(|v| v as i16) +} + +fn i32_of(p: &Prl) -> Option { + match p.operand { + [b0, b1, b2, b3, ..] => Some(i32::from_le_bytes([*b0, *b1, *b2, *b3])), + _ => None, + } +} diff --git a/crates/doc-binary/src/tests.rs b/crates/doc-binary/src/tests.rs new file mode 100644 index 000000000..80cef3257 --- /dev/null +++ b/crates/doc-binary/src/tests.rs @@ -0,0 +1,3084 @@ +//! 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 { + 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 { + let mut v = vec![0u8; 0x9A + 0x5D * 8 + 2]; + let put16 = |v: &mut Vec, at: usize, x: u16| { + v[at..at + 2].copy_from_slice(&x.to_le_bytes()); + }; + let put32 = |v: &mut Vec, 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 { + 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 { + 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 = 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 { + text_doc_with(main, extra, FibSpec { ccp_hdd, ..Default::default() }, &[]) +} + +/// [`text_doc`] with the FIB knobs given (`ccp_text` is filled in from `main`; `pairs` must be empty) and extra +/// Table-stream data placed after the piece table: `(pair index, bytes)` for each. +fn text_doc_with(main: &[Piece], extra: &[Piece], spec: FibSpec, table_data: &[(usize, &[u8])]) -> Vec { + let all: Vec = main.iter().chain(extra.iter()).cloned().collect(); + let ccp_text: u32 = main.iter().map(|p| p.text.chars().count() as u32).sum(); + let mut word = vec![0u8; TEXT_FC as usize]; + for p in &all { + word.extend_from_slice(&piece_bytes(p)); + } + let plc = plcpcd(&all, TEXT_FC); + let mut clx = vec![0x02u8]; + clx.extend_from_slice(&(plc.len() as u32).to_le_bytes()); + clx.extend_from_slice(&plc); + let mut table = vec![0u8; CLX_AT as usize]; + table.extend_from_slice(&clx); + let mut pairs = vec![(33, CLX_AT, clx.len() as u32)]; + for (pair, bytes) in table_data { + pairs.push((*pair, table.len() as u32, bytes.len() as u32)); + table.extend_from_slice(bytes); + } + debug_assert!(spec.pairs.is_empty(), "text_doc_with sets the pairs itself"); + let spec = FibSpec { ccp_text, pairs, ..spec }; + word[..fib_bytes(&spec).len()].copy_from_slice(&fib_bytes(&spec)); + cfb_file(&[("WordDocument", &word), ("0Table", &table)]) +} + +fn body_texts(doc: &craft_wordmodel::Document) -> Vec { + 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(craft_wordmodel::para::InlineObject::Field { instr, result, .. }) if instr == "field" && result == "result"), + "objects: {:?}", + p1.objects + ); +} + +#[test] +fn empty_paragraphs_kept() { + let f = text_doc(&[Piece { compressed: false, text: "a\r\rb\r".into() }], &[], 0); + let doc = read(&f).expect("opens"); + assert_eq!(body_texts(&doc), ["a", "", "b"]); +} + +/// A document of `n` copies of `mark` as one compressed piece. +fn marks_doc(mark: char, n: usize) -> Vec { + text_doc(&[Piece { compressed: true, text: mark.to_string().repeat(n) }], &[], 0) +} + +/// Paragraph marks are one byte each in the file but about 1 KB each in memory, so they have +/// their own cap ([`super::MAX_PARAGRAPHS`]); a file past it is refused instead of growing +/// until the machine runs out. A document exactly at the cap still opens. +#[test] +fn a_flood_of_paragraph_marks_is_refused() { + let at_cap = super::read_limited(&marks_doc('\r', 1000), None, 1000).expect("opens"); + assert_eq!(at_cap.body.len(), 1000); + let over = super::read_limited(&marks_doc('\r', 1001), None, 1000); + assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}"); +} + +/// The paragraph cap is one budget for the whole document, not one per story: marks in the +/// body and in a footnote add up. (A walk that got its own budget would open both over-cap files.) +#[test] +fn the_paragraph_cap_is_shared_by_the_body_and_a_footnote() { + // One footnote at CP 0 whose text is `note` marks, stored right after the body in the stream. + let doc_of = |body: usize, note: usize| { + let cps = |v: &[u32]| v.iter().flat_map(|c| c.to_le_bytes()).collect::>(); + let mut refs = cps(&[0, 1]); + refs.extend_from_slice(&[0, 0]); // the one reference's data + let txt = cps(&[0, note as u32, note as u32]); // story 0..note, then the limit + let spec = FibSpec { ccp_ftn: note as u32, ..Default::default() }; + text_doc_with( + &[Piece { compressed: true, text: "\r".repeat(body) }], + &[Piece { compressed: true, text: "\r".repeat(note) }], + spec, + &[(2, &refs), (3, &txt)], + ) + }; + let at_cap = super::read_limited(&doc_of(600, 400), None, 1000).expect("opens"); + assert_eq!(at_cap.body.len(), 600); + assert_eq!(at_cap.parts.len(), 1); + let over = super::read_limited(&doc_of(600, 401), None, 1000); + assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}"); + let over = super::read_limited(&doc_of(601, 400), None, 1000); + assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}"); +} + +/// Cell marks are paragraph marks too: a never-closed row of them is refused the same way. +#[test] +fn a_flood_of_cell_marks_is_refused() { + let over = super::read_limited(&marks_doc('\u{7}', 5000), None, 1000); + assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}"); +} + +/// The budget counts down across spends and notes the overrun (`walk` receives one shared by +/// every story of the document). +#[test] +fn the_paragraph_budget_counts_down_and_notes_the_overrun() { + let budget = super::ParaBudget { left: std::cell::Cell::new(2), exceeded: std::cell::Cell::new(false) }; + assert!(budget.spend() && budget.spend()); + assert!(!budget.exceeded.get()); + assert!(!budget.spend()); + assert!(budget.exceeded.get()); +} + +/// The cap is far above any real document (10,000 pages of 50 paragraphs is 500,000), and a +/// paragraph outside a table no longer carries a table context (a `ParaOut` was 1,224 bytes). +#[test] +fn the_paragraph_cap_leaves_room_and_a_paragraph_stays_small() { + const { assert!(super::MAX_PARAGRAPHS >= 1_000_000) }; + assert!(std::mem::size_of::() < 1000, "{}", std::mem::size_of::()); + let doc = read(&marks_doc('\r', 10)).expect("opens"); + assert_eq!(doc.body.len(), 10); +} + +#[test] +fn empty_document_gets_a_paragraph() { + let f = text_doc(&[Piece { compressed: false, text: "".into() }], &[], 0); + let doc = read(&f).expect("opens"); + assert_eq!(doc.body.len(), 1); +} + +#[test] +fn a_doc_opens_in_word_97_2003_compatibility_mode() { + let f = text_doc(&[Piece { compressed: false, text: "body\r".into() }], &[], 0); + let doc = read(&f).expect("opens"); + // Word 97-2003 files are mode 11 in Word 2013+; mode 15 would change justification and + // line breaking. + assert_eq!(doc.settings.compat_mode, 11); +} + +#[test] +fn header_text_stays_out_of_body() { + let f = text_doc( + &[Piece { compressed: false, text: "body\r".into() }], + &[Piece { compressed: false, text: "header\u{D}".into() }], + 8, // ccpHdd covers the extra piece + ); + let doc = read(&f).expect("opens"); + assert_eq!(body_texts(&doc), ["body"]); +} + +#[test] +fn clx_outside_table_rejected() { + let spec = FibSpec { pairs: vec![(33, 0x1000, 0x40)], ..Default::default() }; + let f = cfb_file(&[("WordDocument", &fib_bytes(&spec)), ("0Table", &[0u8; 16])]); + assert!(matches!(read(&f), Err(super::DocbinError::Malformed(_)))); +} + +#[test] +fn garbage_plcpcd_rejected() { + let mut table = vec![0u8; CLX_AT as usize]; + table.extend_from_slice(&[0x02, 0x05, 0x00, 0x00, 0x00, 1, 2, 3, 4]); // lcb 5: not ≡ 1 mod 12 + let spec = FibSpec { pairs: vec![(33, CLX_AT, 9)], ..Default::default() }; + let f = cfb_file(&[("WordDocument", &fib_bytes(&spec)), ("0Table", &table)]); + assert!(matches!(read(&f), Err(super::DocbinError::Malformed(m)) if m.contains("PlcPcd"))); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn junk_bytes_never_panic(b in proptest::collection::vec(any::(), 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 { + 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 { + 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 { + 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 = 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]) -> Vec { + stsh_bytes_with(styles, None) +} + +/// An STSH with the given styles; with `standard`, a full Stshif and ftcBi giving the standard +/// fonts' indexes (ASCII, East Asian, other, complex script), as Word writes them. +fn stsh_bytes_with(styles: &[Vec], standard: Option<[u16; 4]>) -> Vec { + let mut stshi = [(styles.len() as u16).to_le_bytes(), 10u16.to_le_bytes()].concat(); // cstd, cbSTDBaseInFile + if let Some(fonts) = standard { + stshi.extend_from_slice(&[0u8; 8]); // flags, stiMaxWhenSaved, istdMaxFixedWhenSaved, nVerBuiltInNamesWhenSaved + stshi.extend(fonts.iter().flat_map(|f| f.to_le_bytes())); + } + let mut v = (stshi.len() as u16).to_le_bytes().to_vec(); + v.extend_from_slice(&stshi); + for s in styles { + v.extend_from_slice(s); + } + v +} + +/// A non-extended SttbfFfn of font names. +fn ffn_bytes(names: &[&str]) -> Vec { + 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 { + let utf16z = |s: &str| -> Vec { 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)], end_cp: u32) -> Vec { + 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)]) -> Vec { + 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, Vec)>, + fonts: Vec<&'static str>, + /// Character runs (start CP, grpprl); text length ends the last. + chpx_runs: Vec<(u32, Vec)>, + /// Paragraph entries (mark CP, istd, grpprl). + papx_entries: Vec<(u32, u16, Vec)>, +} + +/// 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>, + prm: u16, + alt_fonts: Vec>, + style_next: Vec, + /// The stylesheet's standard fonts (ASCII, East Asian, other, complex script). + standard_fonts: Option<[u16; 4]>, +} + +fn rich_doc(spec: &RichSpec) -> Vec { + rich_doc_with(spec, &RichExtra::default()) +} + +fn rich_doc_with(spec: &RichSpec, extra: &RichExtra) -> Vec { + 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> = spec + .styles + .iter() + .enumerate() + .map(|(i, s)| std_bytes_next(s.0, s.1, extra.style_next.get(i).copied().unwrap_or(0x0FFF), s.2, &s.3, &s.4)) + .collect(); + let stsh = stsh_bytes_with(&styles, extra.standard_fonts); + let fonts: Vec<(&str, Option<&str>)> = spec.fonts.iter().enumerate().map(|(i, n)| (*n, extra.alt_fonts.get(i).copied().flatten())).collect(); + let ffn = ffn_bytes_alt(&fonts); + let clx_at = 0x40; + let bins_at = clx_at + clx.len(); + let plcf_chpx = + [0u32.to_le_bytes().as_slice(), fc_of(0x3FF0_0000).to_le_bytes().as_slice(), ((chpx_page_at / 512) as u32).to_le_bytes().as_slice()].concat(); + let plcf_papx = + [0u32.to_le_bytes().as_slice(), fc_of(0x3FF0_0000).to_le_bytes().as_slice(), ((papx_page_at / 512) as u32).to_le_bytes().as_slice()].concat(); + let mut table = vec![0u8; clx_at]; + table.extend_from_slice(&clx); + let chpx_bins_at = table.len(); + table.extend_from_slice(&plcf_chpx); + let papx_bins_at = table.len(); + table.extend_from_slice(&plcf_papx); + table.extend(std::iter::repeat_n(0, stsh.len() + ffn.len())); + let stsh_at = table.len() - stsh.len() - ffn.len(); + let ffn_at = stsh_at + stsh.len(); + table[stsh_at..stsh_at + stsh.len()].copy_from_slice(&stsh); + table[ffn_at..ffn_at + ffn.len()].copy_from_slice(&ffn); + let _ = bins_at; + + let spec_fib = FibSpec { + ccp_text: ccp, + pairs: vec![ + (33, clx_at as u32, clx.len() as u32), + (1, stsh_at as u32, stsh.len() as u32), + (15, ffn_at as u32, ffn.len() as u32), + (12, chpx_bins_at as u32, plcf_chpx.len() as u32), + (13, papx_bins_at as u32, plcf_papx.len() as u32), + ], + ..Default::default() + }; + let fib = fib_bytes(&spec_fib); + word[..fib.len()].copy_from_slice(&fib); + cfb_file(&[("WordDocument", &word), ("0Table", &table)]) +} + +fn para0(doc: &craft_wordmodel::Document) -> craft_wordmodel::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(craft_wordmodel::props::TextColor::Rgb(_)))), "run 2 red"); + assert!(p.runs.first().is_some_and(|r| r.props.bold.is_none())); +} + +#[test] +fn font_and_size_from_style_and_direct() { + let spec = RichSpec { + text: "Hello\r", + styles: vec![ + (1, 0x0FFF, "Normal", Vec::new(), grpprl(&[(0x4A43, &24u16.to_le_bytes())])), // 12pt + (2, 0x0FFF, "Emphasis", Vec::new(), grpprl(&[(0x4A4F, &1u16.to_le_bytes())])), // Arial + ], + fonts: vec!["Times New Roman", "Arial"], + chpx_runs: vec![(0, grpprl(&[(0x4A4F, &1u16.to_le_bytes())]))], + papx_entries: vec![(0, 0, Vec::new())], + }; + let doc = read(&rich_doc(&spec)).expect("opens"); + assert!(doc.styles.styles.iter().any(|s| s.name == "Normal" && s.chr.size == Some(12.0)), "Normal 12pt"); + let p = para0(&doc); + assert!(p.runs.first().is_some_and(|r| r.props.font.as_deref() == Some("Arial")), "run font: {:?}", p.runs); +} + +/// #5066: the default font is the stylesheet's standard font (Aptos in a Word 365 file), not +/// the font table's first entry (still Times New Roman there); the East Asian and complex-script +/// standard fonts are the defaults for those scripts, and runs name theirs with sprmCRgFtc1 and +/// sprmCFtcBi. +#[test] +fn the_default_font_is_the_stylesheets_standard_font() { + let spec = |runs: Vec<(u32, Vec)>| RichSpec { + text: "Hello\r", + styles: vec![(1, 0x0FFF, "Normal", Vec::new(), grpprl(&[(0x4A43, &24u16.to_le_bytes())]))], + fonts: vec!["Times New Roman", "Symbol", "Arial", "Aptos", "MS Mincho", "Arial Unicode"], + chpx_runs: runs, + papx_entries: vec![(0, 0, Vec::new())], + }; + let with = |standard: Option<[u16; 4]>, runs| { + read(&rich_doc_with(&spec(runs), &RichExtra { standard_fonts: standard, ..Default::default() })).expect("opens") + }; + let doc = with(Some([3, 4, 3, 2]), vec![(0, Vec::new())]); + let d = &doc.styles.default_chr; + assert_eq!((d.font.as_deref(), d.font_ea.as_deref(), d.font_cs.as_deref()), (Some("Aptos"), Some("MS Mincho"), Some("Arial"))); + let p = para0(&doc); + let resolved = doc.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props); + assert_eq!(resolved.font, "Aptos", "Normal names no font, so the text takes the standard one"); + // A run's own East Asian and complex-script fonts. + let doc = with(Some([3, 4, 3, 2]), vec![(0, grpprl(&[(0x4A50, &5u16.to_le_bytes()), (0x4A5E, &3u16.to_le_bytes())]))]); + let run = ¶0(&doc).runs[0].props; + assert_eq!((run.font_ea.as_deref(), run.font_cs.as_deref()), (Some("Arial Unicode"), Some("Aptos"))); + // Indexes past the font table, or a header too short to hold them, keep entry 0. + for standard in [Some([99, 99, 99, 99]), None] { + let doc = with(standard, vec![(0, Vec::new())]); + let d = &doc.styles.default_chr; + assert_eq!((d.font.as_deref(), d.font_ea.as_deref(), d.font_cs.as_deref()), (Some("Times New Roman"), None, None), "{standard:?}"); + } +} + +/// #5066: `ftcBi` is optional in the stylesheet header, and the latent style data (`cbLSD`, 4, +/// then the LSDs) follows it: a header that leaves it out doesn't make `cbLSD` the complex-script +/// font. +#[test] +fn the_complex_script_standard_font_is_read_only_when_present() { + let header = |tail: &[u16]| -> Vec { + // cstd, cbSTDBaseInFile, flags, stiMaxWhenSaved, istdMaxFixedWhenSaved, + // nVerBuiltInNamesWhenSaved; ASCII, East Asian, other. + let words = [&[0u16, 10, 0, 1, 0, 0, 3, 4, 3][..], tail].concat(); + let stshi: Vec = words.iter().flat_map(|w| w.to_le_bytes()).collect(); + [(stshi.len() as u16).to_le_bytes().to_vec(), stshi].concat() + }; + let bidi = |tail: &[u16]| { + let b = header(tail); + crate::stsh::standard_fonts(&b, 0, b.len() as u32).bidi + }; + assert_eq!(bidi(&[2]), Some(2), "a header that ends with it (Word 97)"); + assert_eq!(bidi(&[4]), Some(4), "font 4, at the end of the header"); + assert_eq!(bidi(&[2, 4, 0, 0]), Some(2), "followed by the latent style data"); + assert_eq!(bidi(&[4, 4, 0, 0]), Some(4), "font 4, followed by the latent style data"); + assert_eq!(bidi(&[4, 0, 0]), None, "left out: cbLSD is in its place"); + assert_eq!(bidi(&[]), None, "a header too short for it"); + let b = header(&[4, 0, 0]); + let fonts = crate::stsh::standard_fonts(&b, 0, b.len() as u32); + assert_eq!((fonts.ascii, fonts.east_asian, fonts.other), (Some(3), Some(4), Some(3)), "the other fonts are read all the same"); +} + +#[test] +fn paragraph_props_and_heading_style() { + let spec = RichSpec { + text: "centered\rplain\r", + styles: vec![ + (1, 0x0FFF, "Normal", Vec::new(), Vec::new()), + (1, 0, "heading 1", grpprl(&[(0x2461, &[0x01, 0x00])]), Vec::new()), // centered + ], + fonts: vec!["Times New Roman"], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 1, Vec::new()), (9, 0, grpprl(&[(0x845E, &720u16.to_le_bytes())]))], + }; + let doc = read(&rich_doc(&spec)).expect("opens"); + let heading = doc.styles.styles.iter().find(|s| s.name == "Heading 1").expect("style"); + assert_eq!(heading.para.align, Some(craft_wordmodel::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(craft_wordmodel::props::LineSpacing::Multiple(1.5))); + assert_eq!(para(1).line_spacing, Some(craft_wordmodel::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(craft_wordmodel::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)>, + /// Character runs as in RichSpec. + chpx_runs: Vec<(u32, Vec)>, + /// Paragraph entries as in RichSpec, with the grpprl also carrying table sprms. + papx_entries: Vec<(u32, u16, Vec)>, + /// Raw `PlfLst` bytes (LSTF array + appended LVLs) for FIB pair 73. + plf_lst: Vec, + /// Raw `PlfLfo` bytes for FIB pair 74. + plf_lfo: Vec, + /// 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)>, + /// The Data stream (pictures), written when non-empty. + data_stream: Vec, +} + +fn struct_doc(spec: &StructSpec) -> Vec { + let text: String = spec.text.into(); + let ccp = text.chars().count() as u32; + let mut all: Vec = 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 = 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::(); + } + plcf_hdd.extend_from_slice(&cp.to_le_bytes()); + // remaining slots: empty + for _ in 0..4 { + plcf_hdd.extend_from_slice(&cp.to_le_bytes()); + } + + let clx_at = 0x40; + let mut table = vec![0u8; clx_at]; + table.extend_from_slice(&clx); + let chpx_bins_at = table.len(); + let plcf_chpx = + [0u32.to_le_bytes().as_slice(), fc_of(0x3FF0_0000).to_le_bytes().as_slice(), ((chpx_page_at / 512) as u32).to_le_bytes().as_slice()].concat(); + table.extend_from_slice(&plcf_chpx); + let papx_bins_at = table.len(); + let plcf_papx = + [0u32.to_le_bytes().as_slice(), fc_of(0x3FF0_0000).to_le_bytes().as_slice(), ((papx_page_at / 512) as u32).to_le_bytes().as_slice()].concat(); + table.extend_from_slice(&plcf_papx); + let sed_at = table.len(); + table.extend_from_slice(&plcf_sed); + let hdd_plc_at = table.len(); + table.extend_from_slice(&plcf_hdd); + let stsh = stsh_bytes(&[std_bytes(1, 0x0FFF, "Normal", &Vec::new(), &Vec::new())]); + let stsh_at = table.len(); + table.extend_from_slice(&stsh); + let ffn = ffn_bytes(&["Times New Roman", "Wingdings"]); + let ffn_at = table.len(); + table.extend_from_slice(&ffn); + let lst_at = table.len(); + table.extend_from_slice(&spec.plf_lst); + let lfo_at = table.len(); + table.extend_from_slice(&spec.plf_lfo); + + let mut extra_at = Vec::new(); + for (i, blob) in &spec.extra_pairs { + let at = table.len() as u32; + table.extend_from_slice(blob); + extra_at.push((*i, at, blob.len() as u32)); + } + + let mut pairs = vec![ + (33, clx_at as u32, clx.len() as u32), + (1, stsh_at as u32, stsh.len() as u32), + (15, ffn_at as u32, ffn.len() as u32), + (12, chpx_bins_at as u32, plcf_chpx.len() as u32), + (13, papx_bins_at as u32, plcf_papx.len() as u32), + (6, sed_at as u32, plcf_sed.len() as u32), + (11, hdd_plc_at as u32, plcf_hdd.len() as u32), + ]; + if !spec.plf_lst.is_empty() { + pairs.push((73, lst_at as u32, spec.plf_lst.len() as u32)); + } + if !spec.plf_lfo.is_empty() { + pairs.push((74, lfo_at as u32, spec.plf_lfo.len() as u32)); + } + pairs.extend(extra_at); + let spec_fib = FibSpec { ccp_text: ccp, ccp_ftn: ftn_len, ccp_hdd: hdd_len, ccp_edn: edn_len, pairs, ..Default::default() }; + let fib = fib_bytes(&spec_fib); + word[..fib.len()].copy_from_slice(&fib); + word.extend(std::iter::repeat_n(0, first_sepx - word.len())); + for sepx in sepxes { + word.extend_from_slice(&sepx); + } + if spec.data_stream.is_empty() { + cfb_file(&[("WordDocument", &word), ("0Table", &table)]) + } else { + cfb_file(&[("WordDocument", &word), ("0Table", &table), ("Data", &spec.data_stream)]) + } +} + +#[test] +fn section_props_parsed() { + let spec = StructSpec { + text: "a\rb\r", + header_stories: vec![], + sections: vec![(2, grpprl(&[(0xB01F, &12240u16.to_le_bytes()), (0xB020, &15840u16.to_le_bytes()), (0xB021, &1440u16.to_le_bytes())]))], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 0, Vec::new()), (2, 0, Vec::new())], + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + // The single section ends at the text end: it becomes the document's last section. + assert_eq!(doc.last_section.page_w, 612.0); + assert_eq!(doc.last_section.page_h, 792.0); + assert_eq!(doc.last_section.margin_left, 72.0); +} + +#[test] +fn two_sections_box_the_first() { + let spec = StructSpec { + text: "first\rsecond\r", + header_stories: vec![], + sections: vec![(6, grpprl(&[(0x300A, &[0x01])])), (12, Vec::new())], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 0, Vec::new()), (6, 0, Vec::new())], + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + let sec = p0.section.as_ref().expect("section on first paragraph"); + assert!(sec.title_page); + assert!(!doc.last_section.title_page); +} + +#[test] +fn sections_ending_off_a_paragraph_mark_attach_nowhere() { + // Hostile section ends that miss every paragraph mark are skipped; the one that ends a + // paragraph still boxes it. Paragraphs are found by their end, not scanned per section. + let mut sections: Vec<(u32, Vec)> = (1..6).map(|cp| (cp, Vec::new())).collect(); + sections.push((6, grpprl(&[(0x300A, &[0x01])]))); + sections.push((12, Vec::new())); + let spec = StructSpec { + text: "first\rsecond\r", + header_stories: vec![], + sections, + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 0, Vec::new()), (6, 0, Vec::new())], + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let paras: Vec<_> = doc.body.iter().filter_map(|b| b.as_para()).collect(); + assert!(paras.first().and_then(|p| p.section.as_ref()).expect("first paragraph boxed").title_page); + assert_eq!(paras.iter().filter(|p| p.section.is_some()).count(), 1, "the missing ends attach nowhere"); +} + +#[test] +fn header_story_becomes_part() { + let spec = StructSpec { + text: "body\r", + header_stories: vec!["Header text\r\r"], + sections: vec![(5, Vec::new())], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 0, Vec::new())], + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let id = doc.last_section.headers.default.expect("odd header part"); + let part = doc.parts.get(&id).expect("part"); + assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Header text")))); +} + +/// The odd header's blocks for a one-section document whose header story is `story`, with +/// `papx_entries` over the whole stream (the header story starts at CP 5, after `body\r`): +/// paragraph texts, a table as ``; `None` when the slot has no part. +fn odd_header_blocks(story: &'static str, papx_entries: Vec<(u32, u16, Vec)>) -> Option> { + let spec = StructSpec { + text: "body\r", + header_stories: vec![story], + sections: vec![(5, Vec::new())], + 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(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let id = doc.last_section.headers.default?; + let part = doc.parts.get(&id).expect("part"); + Some(part.blocks.iter().map(|b| b.as_para().map_or_else(|| "
".to_string(), |p| p.text.clone())).collect()) +} + +/// #5922: [MS-DOC] 2.3.3 ends every non-empty header story with a guard paragraph mark that +/// is not part of its contents. Read as a paragraph, it put an empty line under every header +/// and pushed the first body line down when the header reached past the top margin. +#[test] +fn header_story_guard_mark_is_not_a_paragraph() { + let plain = || vec![(0, 0, Vec::new())]; + // One line typed into the header: content "Header\r" + guard. + assert_eq!(odd_header_blocks("Header\r\r", plain()), Some(vec!["Header".to_string()])); + // Enter pressed after it: the empty paragraph is content and stays, only the guard goes. + assert_eq!(odd_header_blocks("Header\r\r\r", plain()), Some(vec!["Header".to_string(), String::new()])); + // An empty header (one empty content paragraph + guard) is still a header of its own. + assert_eq!(odd_header_blocks("\r\r", plain()), Some(vec![String::new()])); + // A story that is only a mark (no guard) keeps its paragraph and its part. + assert_eq!(odd_header_blocks("\r", plain()), Some(vec![String::new()])); + // A story without a guard keeps its last paragraph. + assert_eq!(odd_header_blocks("Header\r", plain()), Some(vec!["Header".to_string()])); +} + +/// #5922: a mark right after a table row in a header story is kept: it is the paragraph that +/// closes the table, not a guard to drop. +#[test] +fn header_story_mark_after_a_table_row_is_kept() { + let in_tbl = grpprl(&[(0x2416, &[0x01])]); + let ttp: Vec = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]), t_def_table(&[0, 2880])].concat(); + // Stream: "body\r" (CPs 0-4), then the header story "a\x07" (5-6), row end "\x07" (7), "\r" (8). + let papx = vec![(0, 0, Vec::new()), (5, 0, in_tbl), (7, 0, ttp), (8, 0, Vec::new())]; + assert_eq!(odd_header_blocks("a\u{7}\u{7}\r", papx), Some(vec!["
".to_string(), String::new()])); +} + +/// 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 { + 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 = [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::(); + 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 = [ttp_base(), def3.clone(), h_merge].concat(); + let mut row2: Vec = [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::(); + assert_eq!(r0, "ab"); + // Row 2: shading, vertical-merge restart, exact height 20pt. + assert_eq!(tbl.rows[1].cells[0].props.shading, Some(craft_wordmodel::Rgb(255, 255, 0))); + assert_eq!(tbl.rows[1].cells[0].props.vmerge, craft_wordmodel::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, craft_wordmodel::props::BorderStyle::Single); + assert_eq!(top.width, 0.5); +} + +/// Table borders read the BrcType byte as paragraph borders do; a nil Brc80 is no border. +#[test] +fn table_borders_read_border_types_as_paragraphs_do() { + use craft_wordmodel::props::BorderStyle as S; + let text = "a\u{7}\u{7}tail\r"; + let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]); + let row_end = { + let mut v = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), t_def_edges(&[0, 2880])].concat(); + v.extend_from_slice(&0xD605u16.to_le_bytes()); + v.push(0x18); + // Dotted, triple, double wave, nil, 3-D emboss 255/8 pt wide, all zero. + for brc in [[8, 0x06, 1, 0], [8, 0x0A, 1, 0], [8, 0x15, 1, 0], [0xFF; 4], [0xFF, 0x18, 1, 0], [0, 0, 0, 0]] { + v.extend_from_slice(&brc); + } + v + }; + let spec = StructSpec { + text, + header_stories: vec![], + sections: vec![(text.chars().count() as u32, Vec::new())], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 0, in_tbl), (2, 0, row_end), (3, 0, grpprl(&[(0x6424, &[0xFF, 0x01, 1, 0])]))], + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table"); + let b = tbl.props.borders.expect("table borders"); + let seen = [b.top, b.left, b.bottom, b.right, b.between, b.inside_v].map(|s| s.map(|s| (s.style, s.is_visible(), s.width))); + let want = [ + (S::Dotted, true, 1.0), + (S::Triple, true, 1.0), + (S::Wave, true, 1.0), + (S::None, false, 0.0), + (S::Single, true, 12.0), + (S::None, false, 0.25), + ]; + assert_eq!(seen, want.map(Some)); + // A paragraph border is at most 12 points too, as a table border and a DOCX `w:sz` are. + let tail = doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.text == "tail").expect("tail paragraph"); + assert_eq!(tail.props.borders.and_then(|b| b.top).map(|t| t.width), Some(12.0)); +} + +// --------------------------------------------------------------------------------------------- +// D3: lists (PlfLst + PlfLfo) and the combined document. + +/// One LVL: number text given as chars (a `0`-valued char at placeholder position `ph` is +/// the level placeholder), with a 720-twip left indent and a 360-twip hanging first line. +fn lvl(start: u32, nfc: u8, chars: &[u16], ph: bool) -> Vec { + 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 = [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]) -> Vec { + 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 { + 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> = (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, craft_wordmodel::section::NumFormat::Decimal); + assert_eq!(l0.text, "%1."); + assert_eq!(l0.indent, 36.0); + assert_eq!(l0.hanging, 18.0); + assert_eq!(l0.suffix, craft_wordmodel::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(craft_wordmodel::props::NumRef { num: 1, level: 0 })); + assert_eq!(n(1), Some(craft_wordmodel::props::NumRef { num: 1, level: 0 })); + assert_eq!(n(2), None); + // The numbering produces labels through the shared counter. + let mut c = craft_wordmodel::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 { + let def2 = t_def_table(&[0, 2880, 5760]); + let mut row: Vec = [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> = (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 = doc.plain_text(craft_wordmodel::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 { + 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 { + 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 craft_wordmodel::para::InlineObject::NoteRef { kind, id, .. } = note else { panic!("not a note: {note:?}") }; + assert_eq!(*kind, craft_wordmodel::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, craft_wordmodel::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 craft_wordmodel::para::InlineObject::NoteRef { kind, id, .. } = p0.objects.first().expect("note ref object") else { panic!("no note ref") }; + assert_eq!(*kind, craft_wordmodel::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 craft_wordmodel::para::InlineObject::Field { instr, result, .. } = p0.objects.first().expect("field object") else { + panic!("no field: {:?}", p0.objects) + }; + assert_eq!(instr, "PAGE"); + assert_eq!(result, "3"); +} + +#[test] +fn hyperlink_target_sublocation_and_quotes() { + assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com" \l "heading""#), Some("http://example.com#heading".into())); + assert_eq!(super::hyperlink_target(r#"HYPERLINK \l "heading""#), Some("#heading".into())); + assert_eq!(super::hyperlink_target(r#"Hyperlink "http://example.com/a path""#), Some("http://example.com/a path".into())); + assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com" \o "Help text""#), Some("http://example.com".into())); + assert_eq!(super::hyperlink_target(r#"HYPERLINK \l "bm" \o "tip""#), Some("#bm".into())); +} + +/// A field whose name only starts with HYPERLINK is not a hyperlink. +#[test] +fn hyperlink_target_needs_the_whole_field_name() { + assert_eq!(super::hyperlink_target(r#"HYPERLINKS "x""#), None); + assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com""#), Some("http://example.com".into())); +} + +/// A bare `\m` or `\n` keeps the address after it; escapes, an empty address and `%22` read as in DOCX. +#[test] +fn hyperlink_target_keeps_the_address_after_a_bare_switch() { + assert_eq!(super::hyperlink_target(r#"HYPERLINK \m "http://example.com""#), Some("http://example.com".into())); + assert_eq!(super::hyperlink_target(r#"HYPERLINK \n "http://example.com""#), Some("http://example.com".into())); + assert_eq!(super::hyperlink_target(r#"HYPERLINK "C:\\Docs\\f.doc""#), Some(r"C:\Docs\f.doc".into())); + assert_eq!(super::hyperlink_target(r#"HYPERLINK "" \l "bm""#), Some("#bm".into())); + assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com/a%22b""#), Some("http://example.com/a%22b".into())); +} + +#[test] +fn bookmarks_become_objects() { + let text = "Hello world\r"; + let ccp = text.chars().count() as u32; + let spec = StructSpec { + text, + extra_pairs: vec![(21, sttbf_bkmk(&["bm1"])), (22, plc(&[0, ccp], &[0, 0, 0, 0])), (23, plc(&[5, ccp], &[0, 0]))], + ..structured_spec(text, ccp) + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + // The start anchor sits before the text, the end after "Hello". + assert!( + matches!(p0.objects.first(), Some(craft_wordmodel::para::InlineObject::BookmarkStart { name }) if name == "bm1"), + "objects: {:?}", + p0.objects + ); + assert!( + matches!(p0.objects.get(1), Some(craft_wordmodel::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 = [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 = [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 craft_wordmodel::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 { + let png: Vec = [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: &craft_wordmodel::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 == craft_wordmodel::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 == craft_wordmodel::PartKind::Endnote)); +} + +/// The text of each note a paragraph references, in order. +fn note_texts(doc: &craft_wordmodel::Document, p: &craft_wordmodel::Paragraph) -> Vec { + p.objects + .iter() + .filter_map(|o| match o { + craft_wordmodel::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::>().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 = (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 == craft_wordmodel::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 == craft_wordmodel::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 { + 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 { + 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 = (0..=n as u32).collect(); + let fcs: Vec = (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 = "a\u{1F600}b\r".encode_utf16().collect(); + let bytes: Vec = 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 { + 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, craft_wordmodel::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 = [a.clone(), b].concat(); + let mut pics = crate::media::Pictures::default(); + pics.limit = 1000; + let first = pics.get(&data, 0).cloned().expect("first fits"); + // Same location again: no new bytes. + assert_eq!(pics.get(&data, 0).map(|p| p.key.clone()), Some(first.key.clone())); + // A second distinct picture would exceed the cap. + assert!(pics.get(&data, a.len() as u32).is_none()); + assert_eq!(pics.media.len(), 1); + const { assert!(crate::media::MAX_MEDIA_BYTES <= 256 << 20) }; +} + +#[test] +fn rejected_inline_pictures_are_not_read_again() { + // A picture over the cap was located and copied again for every anchor on it. + let big = picf_with_png(1200); + let small = picf_with_png(300); + let data: Vec = [big.clone(), small.clone()].concat(); + let mut pics = crate::media::Pictures::default(); + pics.limit = 1000; + assert!(pics.get(&data, 0).is_none(), "over the cap"); + // The failure is remembered: that place isn't read again, whatever it holds now. + assert!(pics.get(&small, 0).is_none()); + // The rejected picture took nothing off the cap, and anchors sharing a picture share it. + let at = big.len() as u32; + let fits = pics.get(&data, at).cloned().expect("fits"); + assert_eq!(pics.get(&data, at).map(|p| p.key.clone()), Some(fits.key)); + assert_eq!(pics.media.len(), 1); + // Filling the cap exactly works; after that, a place never tried before isn't read. + let rest = picf_with_png(700); + let rest_at = data.len() as u32; + let tail: Vec = [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, papx_entries: Vec<(u32, u16, Vec)>) -> 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| { + 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 craft_wordmodel::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| { + 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> = (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(craft_wordmodel::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> = 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 { + 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 craft_wordmodel::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::>() + }; + 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 craft_wordmodel::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 { + 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 { + 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 { + 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, row2: Vec, row3: Vec) -> craft_wordmodel::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| [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 = [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 = [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 = 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::>(); + assert_eq!(shd(0), vec![Some(craft_wordmodel::Rgb(0x1C, 0x3A, 0x70)); 5], "exact fill beats the Shd80 palette colour"); + let light = Some(craft_wordmodel::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(craft_wordmodel::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::(); + 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 = [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(craft_wordmodel::Rgb(255, 255, 0))); + assert_eq!(row.shading_of(1), Some(craft_wordmodel::Rgb(192, 192, 192))); + assert_eq!(row.shading_of(2), None); + assert_eq!(row.shading_of(3), Some(craft_wordmodel::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(craft_wordmodel::Rgb(0x11, 0x22, 0x33)), "{first:04X}"); + assert_eq!(row.shading_of(22), Some(craft_wordmodel::Rgb(0, 0xFF, 0)), "{second:04X} keeps cell 22"); + assert_eq!(row.shading_of(44), Some(craft_wordmodel::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(craft_wordmodel::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, Vec, Vec)> = 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(craft_wordmodel::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| [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(craft_wordmodel::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(craft_wordmodel::Rgb(0, 0, 0xFF)), "nil keeps blue"); + assert_eq!(row.shading_of(1), Some(craft_wordmodel::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(craft_wordmodel::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::>(), [1, 1, 1]); + assert!(tables[0].props.borders.is_some() && tables[0].props.align.is_some() && tables[0].props.indent.is_some()); + assert_eq!(tables[1].props.borders, None, "borders reset"); + assert_eq!(tables[1].props.align, None, "alignment reset"); + assert_eq!(tables[1].props.indent, None, "indent reset"); +} + +#[test] +fn repeated_cell_sprms_keep_one_override_per_cell() { + // A PAPX repeating cell-range sprms (shared by every paragraph mark that names it) keeps one + // slot per cell, the last value winning, instead of a copy per repetition. + let mut sprms = Vec::new(); + for w in 0..200u16 { + let [w0, w1] = (w * 20).to_le_bytes(); + sprms.push(sprm_cb(0xD635, &[0, 63, 3, w0, w1])); + sprms.push(sprm_cb(0xD62C, &[0, 255, 1])); + sprms.push([0x24, 0x56, 0, 2].to_vec()); + } + let row = crate::table::decode(&sprms.concat()); + assert_eq!(row.width_of(0), Some(199.0), "the last width wins"); + assert_eq!(row.width_of(62), Some(199.0)); + assert!(row.overrides() <= 63 + 255 + 63, "{}", row.overrides()); +} + +#[test] +fn sprm_t_cell_width_twips_and_percentage() { + // [MS-DOC] §2.9.290 sprmTCellWidth (0xD635): + // cb (1 byte), itcFirst (1 byte), itcLim (1 byte), ftsWidth (1 byte), wWidth (2 bytes). + // ftsWidth = 3 (twips): 1440 twips = 72.0 pt. + let w_twips = [0u8, 2, 3, 0xA0, 0x05]; // cell 0..2, fts=3, 1440 dxa + // ftsWidth = 2 (percentage in 50ths of a percent): 2500 = 50.0%. + let w_pct = [2u8, 3, 2, 0xC4, 0x09]; // cell 2..3, fts=2, 2500 (50%) + let papx = [sprm_cb(0xD635, &w_twips), sprm_cb(0xD635, &w_pct)].concat(); + + let row = crate::table::decode(&papx); + assert_eq!(row.width_of(0), Some(72.0)); + assert_eq!(row.width_of(1), Some(72.0)); + assert_eq!(row.width_pct_of(2), Some(50.0)); + + // End-to-end document test: row cells receive the preferred widths. + let text = "a\u{7}b\u{7}c\u{7}\u{7}\r"; + let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]); + let ttp = [ + grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), + t_def_edges(&[0, 1440, 2880, 4320]), + sprm_cb(0xD635, &w_twips), + sprm_cb(0xD635, &w_pct), + ] + .concat(); + let spec = StructSpec { + text, + header_stories: vec![], + sections: vec![(text.chars().count() as u32, Vec::new())], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 0, in_tbl), (6, 0, ttp), (7, 0, Vec::new())], + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let table = doc.body[0].as_table().expect("table block"); + assert_eq!(table.rows[0].cells[0].props.width, Some(72.0)); + assert_eq!(table.rows[0].cells[1].props.width, Some(72.0)); + assert_eq!(table.rows[0].cells[2].props.width_pct, Some(50.0)); +} + +// --------------------------------------------------------------------------------------------- +// #1767: symbol runs and paragraph borders. + +/// Word stores a symbol character (Insert › Symbol) as the placeholder U+0028 plus +/// `sprmCSymbol` (0x6A09), which carries the font table index and the character code +/// ([MS-DOC] §2.6.1 CSymbolOperand); the text stream alone would read "(". +#[test] +fn symbol_runs_read_the_character_and_font_from_sprm_c_symbol() { + let text = "(\r"; + // ftc = 1 (Wingdings), xchar = F071: the box Word draws for Wingdings 0x71. + let sym = [1u16.to_le_bytes(), 0xF071u16.to_le_bytes()].concat(); + let spec = StructSpec { text, chpx_runs: vec![(0, grpprl(&[(0x6A09, &sym)]))], ..structured_spec(text, text.chars().count() as u32) }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let p = doc.body.first().and_then(|b| b.as_para()).expect("paragraph"); + assert_eq!(p.text, "\u{F071}", "the symbol, not the placeholder"); + assert_eq!(p.props_of_char(0).font.as_deref(), Some("Wingdings"), "its font from the font table"); +} + +/// Paragraph borders around a block are read from both storage forms: the Word 97 Brc80 +/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour). A thick +/// border (type 0x02) stays thick in both, as tables read it too. +#[test] +fn paragraph_borders_read_from_both_forms() { + use craft_wordmodel::props::BorderStyle as S; + let b80 = [8u8, 0x01, 0x06, 0x01]; // 1 pt single line, palette red (6), 1 pt from the text + let thick80 = [8u8, 0x02, 0x06, 0x01]; + let brc = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x01, 0x02, 0x00]; // cb 8, red COLORREF, 1 pt single, space 2 + let thick = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x02, 0x02, 0x00]; + let text = "boxed\r\r"; + let spec = StructSpec { + text, + papx_entries: vec![ + (0, 0, grpprl(&[(0x6424, &b80), (0x6425, &thick80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])), + (6, 0, grpprl(&[(0xC64E, &brc), (0xC64F, &thick)])), + ], + ..structured_spec(text, text.chars().count() as u32) + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("first paragraph"); + let b = p0.props.borders.expect("borders"); + let top = b.top.expect("top border"); + assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 1.0)); + assert_eq!(top.color, Some(craft_wordmodel::Rgb(0xFF, 0, 0))); + assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "Brc80 thick"); + assert!(b.bottom.is_some() && b.right.is_some() && b.between.is_some()); + let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("second paragraph"); + let b = p1.props.borders.expect("borders"); + let top = b.top.expect("top border"); + assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 2.0)); + assert_eq!(top.color, Some(craft_wordmodel::Rgb(0xFF, 0, 0))); + assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "BrcOperand thick"); +} + +/// A compound file `cfb` writes in `version`: `streams` at its root, then `extra` streams under +/// an `ObjectPool` storage (where Word keeps embedded objects). +fn cfb_file_in(version: cfb::Version, streams: &[(&str, &[u8])], extra: &[(&str, &[u8])]) -> Vec { + let mut comp = cfb::CompoundFile::create_with_version(version, std::io::Cursor::new(Vec::new())).expect("cfb create"); + for (name, data) in streams { + comp.create_stream(format!("/{name}")).expect("cfb stream").write_all(data).expect("cfb write"); + } + comp.create_storage("/ObjectPool").expect("cfb storage"); + for (name, data) in extra { + comp.create_stream(format!("/ObjectPool/{name}")).expect("cfb stream").write_all(data).expect("cfb write"); + } + comp.into_inner().into_inner() +} + +/// #3642 review: valid compound files open, so craft-cfb's checks refuse only the hostile +/// ones. The repository keeps no files Word made, so these are written by the `cfb` crate in +/// the shape of Word 97–2003 files: the WordDocument and table streams of a .doc these tests +/// build, with the summary, Data and embedded-object streams Word adds, in 512-byte sectors +/// (version 3, Word's own) and 4096-byte ones (version 4); and one with a Data stream of 8 MB, +/// past what the header's 109 FAT sector slots map in 512-byte sectors (109 × 128 sectors, +/// about 7 MB), so its FAT sectors are listed in a DIFAT chain too. +#[test] +fn valid_doc_shaped_compound_files_open() { + let doc = struct_doc(&structured_spec("Hello world\r", 12)); + let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(doc.as_slice())).expect("cfb open"); + // The table stream is 1Table or 0Table, as the FIB's fWhichTblStm says: kept under its name. + let table_name = if comp.is_stream("/1Table") { "1Table" } else { "0Table" }; + let mut stream = |name: &str| { + let mut v = Vec::new(); + std::io::Read::read_to_end(&mut comp.open_stream(name).expect("stream"), &mut v).expect("read"); + v + }; + let (word, table) = (stream("/WordDocument"), stream(&format!("/{table_name}"))); + let summary = vec![0xFEu8; 4096]; + let data = |n: usize| (0..n).map(|i| (i % 251) as u8).collect::>(); + let small = data(200_000); + let big = data(8 << 20); + let ole = data(70_000); + let files = [ + ("as built", doc.clone()), + ( + "version 3", + cfb_file_in( + cfb::Version::V3, + &[("WordDocument", &word), (table_name, &table), ("Data", &small), ("\u{5}SummaryInformation", &summary)], + &[("_1234567890", &ole)], + ), + ), + ("version 4", cfb_file_in(cfb::Version::V4, &[("WordDocument", &word), (table_name, &table), ("Data", &small)], &[("_1", &ole)])), + ("with a DIFAT chain", cfb_file_in(cfb::Version::V3, &[("WordDocument", &word), (table_name, &table), ("Data", &big)], &[])), + ]; + let difat_sectors = |f: &[u8]| f.get(0x48..0x4C).map_or(0, |b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + assert!(difat_sectors(&files[3].1) > 0, "the 8 MB file lists FAT sectors past the header's"); + for (what, f) in &files { + let doc = read(f).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(body_texts(&doc).first().map(String::as_str), Some("Hello world"), "{what}"); + } +} + +/// #3708: a long HYPERLINK target over a long result was copied (and compared) once per result +/// character. It is now copied once per run. +#[test] +fn a_long_hyperlink_over_a_long_result_is_one_linked_run() { + let n = 250_000; + let target = "x".repeat(n); + let text: &'static str = Box::leak(format!("\u{13}HYPERLINK \"http://{target}\"\u{14}{}\u{15}\r", "y".repeat(n)).into_boxed_str()); + let spec = structured_spec(text, text.chars().count() as u32); + let doc = read(&struct_doc(&spec)).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + let linked: Vec<_> = p0.runs.iter().filter(|r| r.props.link.is_some()).collect(); + assert_eq!(linked.len(), 1); + assert_eq!(linked.first().map(|r| r.len), Some(n)); + // One copy of the target for the one run, not one per character (~2.5 s here): counted by + // what the copies charge to the work budget, which a busy machine can't skew. + let mut result = super::FieldResult::default(); + (0..n).for_each(|_| result.push('y', craft_wordmodel::props::CharProps::default())); + let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) }; + let work = super::Work::new(u64::MAX); + super::finish_field(&mut super::ParaBuild::default(), f, &work); + assert_eq!(u64::MAX - work.left.get(), ("http://".len() + n) as u64, "one copy of the target"); +} + +/// #3708: a result whose formatting changes every character pays for a target copy per run +/// out of the work budget; past it the text stays, unlinked. +#[test] +fn hyperlink_target_copies_are_charged_to_the_work_budget() { + use craft_wordmodel::props::CharProps; + let bold = CharProps { bold: Some(true), ..Default::default() }; + let mut result = super::FieldResult::default(); + (0..1000).for_each(|i| result.push('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })); + let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) }; + let mut pb = super::ParaBuild::default(); + let budget = super::Work::new(10_000); + super::finish_field(&mut pb, f, &budget); + assert_eq!(pb.text.len(), 1000, "all text kept"); + let linked: usize = pb.runs.iter().filter_map(|r| r.props.link.as_ref()).map(|l| l.len()).sum(); + assert!(linked <= 10_000, "linked bytes {linked}"); + assert_eq!(pb.runs.iter().filter(|r| r.props.link.is_some()).count(), 100); + assert_eq!(budget.left.get(), 0); + assert_eq!(budget.dropped(), [super::Cost::LinkTarget], "the unlinked text is noted, to be logged"); +} + +/// #3871 review: what the work budget leaves out is noted once per kind, in a fixed order, and +/// logged once per kind when the document has been read; nothing is noted while it lasts. +#[test] +fn the_work_budget_notes_each_kind_it_leaves_out_once() { + use super::Cost::*; + let work = super::Work::new(10); + assert!(work.charge(10, AltText)); + assert!(work.dropped().is_empty(), "spent to the last byte, nothing left out"); + assert!(work.charge(0, LinkTarget), "a free copy is never refused"); + for _ in 0..3 { + assert!(!work.charge(1, FloatingObject)); + } + assert!(!work.charge(1, AltText)); + assert_eq!(work.dropped(), [AltText, FloatingObject]); + work.log(); + assert_eq!(super::LAST_DROPPED.with(|d| d.borrow().clone()), [AltText, FloatingObject]); + // A document within the budget leaves nothing out. + read(&struct_doc(&structured_spec("Hello\r", 6))).expect("opens"); + assert!(super::LAST_DROPPED.with(|d| d.borrow().is_empty())); +} + +/// #3692: a piece's Prm grpprl (a Prc of up to 64 KB) was re-interpreted for every character. +/// It is now resolved once per formatting change. +#[test] +fn a_large_prc_is_interpreted_once_per_formatting_change() { + let prc = grpprl(&vec![(0x0835u16, &[0x01u8][..]); 21_000]); + let text: &'static str = Box::leak(format!("{}\r", "a".repeat(100_000)).into_boxed_str()); + let spec = RichSpec { + text, + styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())], + fonts: vec!["Times New Roman"], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 0, Vec::new())], + }; + let bytes = rich_doc_with(&spec, &RichExtra { prcs: vec![prc], prm: 0x0001, ..Default::default() }); + let t = std::time::Instant::now(); + let doc = read(&bytes).expect("opens"); + // About 60 ms on a loaded machine; interpreting the Prc per character took 88 s. + assert!(t.elapsed().as_secs() < 10, "opened in {:?}", t.elapsed()); + let p = para0(&doc); + assert_eq!(p.runs.len(), 1); + assert_eq!(p.runs.first().and_then(|r| r.props.bold), Some(true)); +} + +/// #3692: Prm grpprls are charged to a document-wide work budget; once it is spent the Prm +/// is skipped (the text keeps its CHPX formatting). +#[test] +fn prm_grpprls_past_the_work_budget_are_skipped() { + use craft_wordmodel::props::CharProps; + let g = grpprl(&[(0x0835, &[0x01])]); + let work = super::Work::new(g.len() as u64 + 1); + let base = CharProps::default(); + let mut first = CharProps::default(); + super::apply_prm(&mut first, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work); + assert_eq!(first.bold, Some(true)); + let mut second = CharProps::default(); + super::apply_prm(&mut second, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work); + assert_eq!(second.bold, None); + assert_eq!(work.left.get(), 1); + assert_eq!(work.dropped(), [super::Cost::Formatting]); +} + +/// A field's cached result keeps one `CharProps` per formatting run, not one per character +/// (365 bytes each: 4 million result characters took 1.4 GB). +#[test] +fn a_field_result_stores_one_props_per_run() { + use craft_wordmodel::props::CharProps; + let bold = CharProps { bold: Some(true), ..Default::default() }; + let mut r = super::FieldResult::default(); + for _ in 0..200_000 { + r.push('x', CharProps::default()); + } + assert_eq!(r.runs.len(), 1); + assert_eq!(r.runs.first().map(|(n, _)| *n), Some(200_000)); + r.push('\u{e9}', bold.clone()); + r.push('y', bold); + r.push('z', CharProps::default()); + assert_eq!(r.runs.iter().map(|(n, _)| *n).collect::>(), [200_000, 3, 1], "a two-byte char counts its bytes"); + assert_eq!(r.text.len(), 200_004); +} + +/// A hyperlink whose result changes formatting, with multi-byte text, keeps every run's text and +/// links all of it; a plain field keeps its result text whole. +#[test] +fn field_results_keep_their_text_and_runs() { + use craft_wordmodel::props::CharProps; + let bold = CharProps { bold: Some(true), ..Default::default() }; + let mut result = super::FieldResult::default(); + for (c, p) in + [('a', CharProps::default()), ('\u{e9}', CharProps::default()), ('\u{20ac}', bold.clone()), ('b', bold), ('c', CharProps::default())] + { + result.push(c, p); + } + let f = super::FieldBuild { depth: 1, instr: "HYPERLINK \"http://x\"".into(), sep: Some(result) }; + let mut pb = super::ParaBuild::default(); + super::finish_field(&mut pb, f, &super::Work::new(u64::MAX)); + assert_eq!(pb.text, "a\u{e9}\u{20ac}bc"); + assert_eq!(pb.runs.iter().map(|r| r.len).collect::>(), [3, 4, 1]); + assert!(pb.runs.iter().all(|r| r.props.link.as_deref() == Some("http://x"))); + let doc = read(&text_doc(&[Piece { compressed: false, text: "\u{13} PAGE \u{14}1\u{e9}2\u{15}\r".into() }], &[], 0)).expect("opens"); + let p = doc.body.first().and_then(|b| b.as_para()).expect("paragraph"); + assert!( + p.objects + .iter() + .any(|o| matches!(o, craft_wordmodel::para::InlineObject::Field { instr, result, .. } if instr == "PAGE" && result == "1\u{e9}2")), + "{:?}", + p.objects + ); +} + +/// A one-page CHPX bin table over `page`: FCs 0..`end` live in page 1 of the returned stream. +fn one_page_bins(page: &[u8], end: u32) -> (Vec, crate::fkp::Bins) { + let mut word = vec![0u8; 512]; + word.extend_from_slice(page); + let plc = [0u32.to_le_bytes().as_slice(), end.to_le_bytes().as_slice(), 1u32.to_le_bytes().as_slice()].concat(); + let bins = crate::fkp::Bins::parse(&plc, 0, 12).expect("bins"); + (word, bins) +} + +/// The CHPX run a lookup reports covers exactly the FCs that give the same grpprl: the walk +/// reuses it for the following characters without asking again. +#[test] +fn a_chpx_run_covers_the_fcs_with_the_same_answer() { + let runs = [(0u32, grpprl(&[(0x0835, &[1])])), (5, Vec::new()), (9, grpprl(&[(0x0836, &[1])])), (30, grpprl(&[(0x0835, &[1])]))]; + let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000); + for fc in (fc_of(0) - 4)..(fc_of(70)) { + let run = bins.chpx_run(&word, fc); + assert_eq!(run.grpprl, bins.chpx(&word, fc), "fc {fc}"); + for other in (fc_of(0) - 4)..(fc_of(70)) { + if run.contains(other) { + assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}"); + } + } + } + assert!(bins.chpx_run(&word, fc_of(6)).contains(fc_of(8)) && !bins.chpx_run(&word, fc_of(6)).contains(fc_of(9))); +} + +/// A page whose run starts do not ascend (a corrupt file) gives the same answers as before and +/// a range that never claims an FC with a different answer. +#[test] +fn a_chpx_run_is_sound_on_a_page_whose_starts_do_not_ascend() { + let runs = [(10u32, grpprl(&[(0x0835, &[1])])), (3, grpprl(&[(0x0836, &[1])])), (20, Vec::new()), (7, grpprl(&[(0x0835, &[1])]))]; + let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000); + for fc in (fc_of(0) - 4)..(fc_of(70)) { + let run = bins.chpx_run(&word, fc); + assert_eq!(run.grpprl, bins.chpx(&word, fc)); + for other in (fc_of(0) - 4)..(fc_of(70)) { + if run.contains(other) { + assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}"); + } + } + } +} + +/// A piece span answers exactly what the per-CP lookup answers, in every piece (compressed +/// and UTF-16) and past the last. +#[test] +fn a_piece_span_matches_the_per_cp_lookups() { + let doc = text_doc( + &[ + Piece { compressed: true, text: "abc".into() }, + Piece { compressed: false, text: "d\u{e9}fgh".into() }, + Piece { compressed: true, text: "ij".into() }, + ], + &[], + 0, + ); + let comp = crate::open_container(&doc).expect("container"); + let table = crate::stream(&comp, "0Table").expect("readable").expect("table"); + let pieces = crate::piece::PieceTable::parse(&table, CLX_AT, table.len() as u32 - CLX_AT).expect("pieces"); + for cp in 0..14 { + let span = pieces.span(cp); + assert_eq!(span.and_then(|s| s.fc_at(cp)), pieces.fc_of_cp(cp), "cp {cp}"); + for other in 0..14 { + if span.is_some_and(|s| s.contains(other)) { + assert_eq!(pieces.span(other).and_then(|s| s.fc_at(other)), pieces.fc_of_cp(other)); + } + } + } + assert!(pieces.span(10).is_none(), "past the last piece"); +} + +/// Characters of one formatting share one `CharProps`: it is cloned when a run starts, and not +/// even compared while the formatting stays the same. +#[test] +fn shared_props_start_one_run_per_formatting() { + use craft_wordmodel::props::CharProps; + let bold = CharProps { bold: Some(true), ..Default::default() }; + let mut pb = super::ParaBuild::default(); + for _ in 0..100_000 { + pb.push_shared('a', &bold, 1); + } + pb.push_shared('b', &CharProps::default(), 2); + pb.push_shared('c', &bold, 3); + pb.push_shared('d', &bold, 4); + assert_eq!(pb.runs.iter().map(|r| r.len).collect::>(), [100_000, 1, 2], "equal props under a new generation join the run"); + pb.push('e', bold.clone()); + pb.push_shared('f', &bold, 4); + assert_eq!( + pb.runs.iter().map(|r| r.len).collect::>(), + [100_000, 1, 4], + "a plain push forgets the generation but still joins an equal run" + ); + assert_eq!(pb.text.len(), 100_005); +} + +/// Several bins, equal and descending breakpoints, a missing page and the last FC: wherever a +/// run is reported as valid it gives the answer a fresh lookup gives. +#[test] +fn chpx_runs_are_sound_across_bins() { + let page_a = chpx_fkp(&[(0, grpprl(&[(0x0835, &[1])])), (8, grpprl(&[(0x0836, &[1])]))], 20); + let page_b = chpx_fkp(&[(20, Vec::new()), (26, grpprl(&[(0x0835, &[1])]))], 40); + let mut word = vec![0u8; 512]; + word.extend_from_slice(&page_a); + word.extend_from_slice(&page_b); + let (a, b, end) = (fc_of(20), fc_of(40), fc_of(60)); + let plc = |fcs: &[u32], pns: &[u32]| { + [fcs.iter().flat_map(|f| f.to_le_bytes()).collect::>(), pns.iter().flat_map(|p| p.to_le_bytes()).collect()].concat() + }; + let tables = [ + ("two bins", plc(&[0, a, end], &[1, 2])), + ("an empty bin", plc(&[0, a, a, end], &[1, 2, 2])), + ("a missing page", plc(&[0, a, end], &[1, 9])), + ("descending breakpoints", plc(&[0, b, a, end], &[1, 2, 1])), + ("fcs up to the maximum", plc(&[0, a, u32::MAX], &[1, 2])), + ]; + for (what, t) in &tables { + let bins = crate::fkp::Bins::parse(t, 0, t.len() as u32).expect("bins"); + let probes: Vec = (0..fc_of(50)).step_by(2).chain([u32::MAX - 1, u32::MAX]).collect(); + for &fc in &probes { + let run = bins.chpx_run(&word, fc); + assert_eq!(run.grpprl, bins.chpx(&word, fc), "{what}: fc {fc}"); + for &other in &probes { + if run.contains(other) { + assert_eq!(bins.chpx(&word, other), run.grpprl, "{what}: fc {fc} run reused at {other}"); + } + } + } + } +} + +/// The same formatting changes through a whole read: runs break exactly where the CHPX changes +/// (with a field and a symbol character in between), and nowhere else. +#[test] +fn formatting_runs_break_where_the_chpx_changes() { + let spec = RichSpec { + text: "aaaabbbb\u{13}PAGE\u{14}12\u{15}ccdd(ee\r", + styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())], + fonts: vec!["Times New Roman", "Arial"], + chpx_runs: vec![(0, grpprl(&[(0x0835, &[1])])), (4, Vec::new()), (8, grpprl(&[(0x0835, &[1])])), (20, grpprl(&[(0x0836, &[1])]))], + papx_entries: vec![(30, 0, Vec::new())], + }; + let doc = read(&rich_doc(&spec)).expect("opens"); + let p = para0(&doc); + let shape: Vec<(usize, Option, Option)> = p.runs.iter().map(|r| (r.len, r.props.bold, r.props.italic)).collect(); + // "aaaa" bold, "bbbb" plain plus the field's anchor (3 bytes, default formatting), "ccd" bold + // (the run from the field to CP 20), "d(ee" italic. + assert_eq!(shape, [(4, Some(true), None), (7, None, None), (3, Some(true), None), (4, None, Some(true))], "{shape:?}"); +} diff --git a/crates/doc-binary/src/tests/drawing.rs b/crates/doc-binary/src/tests/drawing.rs new file mode 100644 index 000000000..b91d2df4c --- /dev/null +++ b/crates/doc-binary/src/tests/drawing.rs @@ -0,0 +1,653 @@ +//! Pictures and drawings: OfficeArt records ([MS-ODRAW]) hand-built for these tests from the +//! specification, with tiny pictures generated in code. No file produced by Word is used. + +use craft_wordmodel::fill::Fill; +use craft_wordmodel::para::{Anchor, InlineObject, ShapeKind, Wrap}; +use craft_wordmodel::props::Rgb; +use proptest::prelude::*; + +use super::{StructSpec, grpprl, plc, read, struct_doc, structured_spec}; +use crate::officeart; + +/// One OfficeArt record: header (recVer, recInstance, recType, recLen) + body. +fn rec(ver: u16, inst: u16, typ: u16, body: &[u8]) -> Vec { + let vi = (inst << 4) | (ver & 0xF); + [vi.to_le_bytes().as_slice(), &typ.to_le_bytes(), &(body.len() as u32).to_le_bytes(), body].concat() +} + +fn container(typ: u16, kids: &[Vec]) -> Vec { + rec(0xF, 0, typ, &kids.concat()) +} + +/// `OfficeArtFSP`: shape type, spid, flags. +fn fsp(spt: u16, spid: u32, flags: u32) -> Vec { + rec(2, spt, 0xF00A, &[spid.to_le_bytes(), flags.to_le_bytes()].concat()) +} + +/// `OfficeArtFOPT` with simple properties and complex ones (data appended in order). +fn fopt(simple: &[(u16, u32)], complex: &[(u16, Vec)]) -> Vec { + let mut body = Vec::new(); + for (pid, op) in simple { + body.extend_from_slice(&pid.to_le_bytes()); + body.extend_from_slice(&op.to_le_bytes()); + } + for (pid, data) in complex { + body.extend_from_slice(&(pid | 0x8000).to_le_bytes()); + body.extend_from_slice(&(data.len() as u32).to_le_bytes()); + } + for (_, data) in complex { + body.extend_from_slice(data); + } + rec(3, (simple.len() + complex.len()) as u16, 0xF00B, &body) +} + +/// A shape container with the given records. +fn sp(kids: &[Vec]) -> Vec { + container(0xF004, kids) +} + +/// A real 2×2 PNG. +fn tiny_png() -> Vec { + let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([10, 20, 30, 255])); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// `OfficeArtBlipPNG` (one UID, tag) and the `OfficeArtFBSE` embedding it. +fn fbse_png(png: &[u8]) -> Vec { + let blip = rec(0, 0x6E0, 0xF01E, &[[0u8; 16].as_slice(), &[0xFF], png].concat()); + let mut body = vec![6u8, 6]; + body.extend_from_slice(&[0u8; 16]); // rgbUid + body.extend_from_slice(&0u16.to_le_bytes()); // tag + body.extend_from_slice(&(blip.len() as u32).to_le_bytes()); // size + body.extend_from_slice(&1u32.to_le_bytes()); // cRef + body.extend_from_slice(&0u32.to_le_bytes()); // foDelay + body.extend_from_slice(&[0, 0, 0, 0]); // unused1, cbName, unused2, unused3 + body.extend_from_slice(&blip); + rec(2, 6, 0xF007, &body) +} + +/// UTF-16LE, NUL-terminated (a complex string property). +fn wz(s: &str) -> Vec { + s.encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect() +} + +/// A `Spa` (26 bytes): spid, bounds in twips, flags, cTxbx. +fn spa(spid: u32, [l, t, r, b]: [i32; 4], flags: u16) -> Vec { + [spid.to_le_bytes().as_slice(), &l.to_le_bytes(), &t.to_le_bytes(), &r.to_le_bytes(), &b.to_le_bytes(), &flags.to_le_bytes(), &0u32.to_le_bytes()] + .concat() +} + +/// `Spa` flags from bx, by, wr and fBelowText. +fn spa_flags(bx: u16, by: u16, wr: u16, below: bool) -> u16 { + (bx << 1) | (by << 3) | (wr << 5) | (u16::from(below) << 14) +} + +/// `OfficeArtContent`: a DggContainer holding a BLIP store, then one main-document drawing. +fn art_content(fbses: &[Vec], shapes: &[Vec]) -> Vec { + let bstore = rec(0xF, fbses.len() as u16, 0xF001, &fbses.concat()); + let dgg = container(0xF000, &[bstore]); + let patriarch = sp(&[fsp(0, 1024, 0b101)]); + let spgr = container(0xF003, &[[patriarch].as_slice(), shapes].concat()); + let dg = container(0xF002, &[rec(0, 1, 0xF008, &[0u8; 8]), spgr]); + [dgg, vec![0u8], dg].concat() +} + +/// A PICF for an inline OfficeArt picture: `MM_SHAPE`, 1 × 0.5 inch goal size at 100 %. +fn picf_shape(rest: &[u8]) -> Vec { + let mut picf = vec![0u8; 0x44]; + picf[0..4].copy_from_slice(&((0x44 + rest.len()) as u32).to_le_bytes()); + picf[4..6].copy_from_slice(&0x44u16.to_le_bytes()); + picf[6..8].copy_from_slice(&0x0064u16.to_le_bytes()); // mfpf.mm = MM_SHAPE + picf[28..30].copy_from_slice(&1440i16.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()); + [picf.as_slice(), rest].concat() +} + +#[test] +fn inline_officeart_picture_with_crop_and_alt_text() { + let png = tiny_png(); + // A picture frame whose pib names the first FBSE that follows it, a quarter cropped + // off the left, with alt text. + let shape = sp(&[fsp(75, 1025, 0xA00), fopt(&[(0x0102, 0x4000), (0x0104, 1)], &[(0x0381, wz("Logo"))])]); + let data = picf_shape(&[shape, fbse_png(&png)].concat()); + let text = "Pic\u{1}!\r"; + let ccp = text.chars().count() as u32; + 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 Some(InlineObject::Image { media, w, h, alt, crop, float, .. }) = p0.objects.first() else { panic!("no image: {:?}", p0.objects) }; + assert_eq!((*w, *h), (54.0, 36.0)); // 72 pt less a quarter, × 36 pt + assert_eq!(*crop, [0.25, 0.0, 0.0, 0.0]); + assert_eq!(alt, "Logo"); + assert_eq!(float.wrap, Wrap::Inline); + assert_eq!(doc.media.get(media).map(|b| b.as_slice()), Some(png.as_slice())); +} + +#[test] +fn floating_picture_from_the_blip_store_keeps_position_and_wrap() { + let png = tiny_png(); + let pic = sp(&[fsp(75, 1025, 0xA00), fopt(&[(0x0104, 1)], &[])]); + let art = art_content(&[fbse_png(&png)], &[pic]); + // Two inches by one, 1" from the page's left edge and 0.5" below the top margin; square wrap. + let spas = plc(&[1, 3], &spa(1025, [1440, 720, 4320, 2160], spa_flags(1, 0, 2, false))); + let text = "A\u{8}B\r"; + let ccp = text.chars().count() as u32; + let spec = StructSpec { text, extra_pairs: vec![(40, spas), (50, art)], ..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, "A\u{FFFC}B"); + let Some(InlineObject::Image { media, w, h, float, .. }) = p0.objects.first() else { panic!("no image: {:?}", p0.objects) }; + assert_eq!((*w, *h), (144.0, 72.0)); + assert_eq!((float.wrap, float.h_rel, float.v_rel, float.x, float.y), (Wrap::Square, Anchor::Page, Anchor::Margin, 72.0, 36.0)); + assert_eq!(doc.media.get(media).map(|b| b.as_slice()), Some(png.as_slice())); +} + +#[test] +fn shapes_keep_kind_colours_wrap_and_text_box_text() { + // A red ellipse with a 2 pt blue outline behind the text, and an unfilled text box. + let ellipse = sp(&[fsp(3, 1026, 0xA00), fopt(&[(0x0181, 0x0000_00FF), (0x01C0, 0x00FF_0000), (0x01CB, 25_400)], &[])]); + let tb = sp(&[fsp(202, 1027, 0xA00), fopt(&[(0x01BF, 1 << 20)], &[]), rec(0, 0, 0xF00D, &0x0001_0000u32.to_le_bytes())]); + let art = art_content(&[], &[ellipse, tb]); + let spas = + plc(&[1, 2, 4], &[spa(1026, [0, 0, 1440, 1440], spa_flags(2, 2, 3, true)), spa(1027, [0, 0, 2880, 720], spa_flags(0, 0, 1, false))].concat()); + // The text box story: an FTXBXS whose lid is the shape's spid (cTxbx, cReusable, fReusable, + // reserved, lid, txidUndo). + let ftxbxs = + [1i32.to_le_bytes().as_slice(), &0i32.to_le_bytes(), &0i16.to_le_bytes(), &0i32.to_le_bytes(), &1027u32.to_le_bytes(), &0i32.to_le_bytes()] + .concat(); + let txbx = plc(&[0, 7], &ftxbxs); + let text = "A\u{8}\u{8}B\r"; + let ccp = text.chars().count() as u32; + // The text box story is stored as the subdocument right after the main text; the endnote + // slot of the fixture writes it there and the FIB is then patched to call it the text box + // subdocument (ccpEdn = 0, ccpTxbx = 7). + let spec = + StructSpec { text, endnote_stories: vec!["Inside\r"], extra_pairs: vec![(40, spas), (50, art), (56, txbx)], ..structured_spec(text, ccp) }; + let file = patch_word(&struct_doc(&spec), &[(0x60, 0), (0x64, 7)]); + let doc = read(&file).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + assert_eq!(p0.objects.len(), 2, "{:?}", p0.objects); + let Some(InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, .. }) = p0.objects.first() else { panic!("{:?}", p0.objects) }; + assert_eq!((*kind, *w, *h), (ShapeKind::Ellipse, 72.0, 72.0)); + assert_eq!((fill.clone(), *stroke, *stroke_width), (Some(Fill::solid(Rgb(255, 0, 0))), Some(Rgb(0, 0, 255)), 2.0)); + assert_eq!((float.wrap, float.h_rel, float.v_rel), (Wrap::BehindText, Anchor::Column, Anchor::Paragraph)); + let Some(InlineObject::Shape { kind, fill, float, story, .. }) = p0.objects.get(1) else { panic!("{:?}", p0.objects) }; + assert_eq!((*kind, fill.clone(), float.wrap), (ShapeKind::TextBox, None, Wrap::TopAndBottom)); + let part = story.and_then(|id| doc.parts.get(&id)).expect("text box story part"); + assert_eq!(part.kind, craft_wordmodel::PartKind::TextBox); + assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside")); +} + +#[test] +fn braces_and_brackets_are_unfilled_unless_filled_is_set() { + // A right brace (88) in front of the text with no fill properties, and a left bracket (85) + // whose fFilled is set: only the bracket is filled. A plain rectangle (1) keeps its default + // white fill. + let brace = sp(&[fsp(88, 1026, 0xA00)]); + let bracket = sp(&[fsp(85, 1027, 0xA00), fopt(&[(0x01BF, (1 << 20) | (1 << 4))], &[])]); + let rect = sp(&[fsp(1, 1028, 0xA00)]); + let art = art_content(&[], &[brace, bracket, rect]); + let flags = spa_flags(0, 0, 3, false); + let spas = + plc(&[1, 2, 3, 5], &[spa(1026, [0, 0, 240, 1140], flags), spa(1027, [0, 0, 240, 1140], flags), spa(1028, [0, 0, 240, 1140], flags)].concat()); + let text = "A\u{8}\u{8}\u{8}B\r"; + let ccp = text.chars().count() as u32; + let spec = StructSpec { text, extra_pairs: vec![(40, spas), (50, art)], ..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 fills: Vec<_> = p0 + .objects + .iter() + .map(|o| match o { + InlineObject::Shape { fill, stroke, w, h, .. } => (fill.clone(), *stroke, *w, *h), + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!( + fills, + [ + (None, Some(Rgb::BLACK), 12.0, 57.0), + (Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0), + (Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0), + ] + ); +} + +#[test] +fn text_boxes_pointing_at_the_same_story_text_read_it_once() { + // Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0 + // in between): each was read into its own part, so a hostile table could copy one story + // thousands of times. The second story is dropped and its box stays empty. + let tb = |spid: u32| sp(&[fsp(202, spid, 0xA00), fopt(&[(0x01BF, 1 << 20)], &[]), rec(0, 0, 0xF00D, &0x0001_0000u32.to_le_bytes())]); + let art = art_content(&[], &[tb(1027), tb(1028)]); + let flags = spa_flags(0, 0, 1, false); + let spas = plc(&[1, 2, 4], &[spa(1027, [0, 0, 2880, 720], flags), spa(1028, [0, 0, 2880, 720], flags)].concat()); + // FTXBXS: cTxbx, cReusable, fReusable, reserved, lid, txidUndo. + let ftxbxs = |lid: u32| { + [1i32.to_le_bytes().as_slice(), &0i32.to_le_bytes(), &0i16.to_le_bytes(), &0i32.to_le_bytes(), &lid.to_le_bytes(), &0i32.to_le_bytes()] + .concat() + }; + let txbx = plc(&[0, 7, 0, 7], &[ftxbxs(1027), ftxbxs(1), ftxbxs(1028)].concat()); + let text = "A\u{8}\u{8}B\r"; + let ccp = text.chars().count() as u32; + // As above: the endnote slot holds the text box subdocument, then the FIB is patched. + let spec = + StructSpec { text, endnote_stories: vec!["Inside\r"], extra_pairs: vec![(40, spas), (50, art), (56, txbx)], ..structured_spec(text, ccp) }; + let doc = read(&patch_word(&struct_doc(&spec), &[(0x60, 0), (0x64, 7)])).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + let stories: Vec> = + p0.objects.iter().filter_map(|o| if let InlineObject::Shape { story, .. } = o { Some(*story) } else { None }).collect(); + let [Some(first), None] = stories[..] else { panic!("stories: {stories:?}") }; + let part = doc.parts.get(&first).expect("text box story part"); + assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside")); + assert_eq!(doc.parts.len(), 1); +} + +/// Overwrite little-endian u32s in the WordDocument stream of a compound file. +fn patch_word(file: &[u8], at: &[(usize, u32)]) -> Vec { + use std::io::Read; + let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(file.to_vec())).unwrap(); + let mut streams = Vec::new(); + for name in ["WordDocument", "0Table", "Data"] { + if let Ok(mut s) = comp.open_stream(format!("/{name}")) { + let mut v = Vec::new(); + s.read_to_end(&mut v).unwrap(); + if name == "WordDocument" { + for (o, x) in at { + v[*o..*o + 4].copy_from_slice(&x.to_le_bytes()); + } + } + streams.push((name, v)); + } + } + let refs: Vec<(&str, &[u8])> = streams.iter().map(|(n, v)| (*n, v.as_slice())).collect(); + super::cfb_file(&refs) +} + +#[test] +fn dib_becomes_png_and_compressed_metafiles_inflate() { + // A 2×1 24-bit DIB (BITMAPINFOHEADER, rows padded to 4 bytes). + let mut dib = Vec::new(); + for v in [40u32, 2, 1] { + dib.extend_from_slice(&v.to_le_bytes()); + } + dib.extend_from_slice(&1u16.to_le_bytes()); + dib.extend_from_slice(&24u16.to_le_bytes()); + dib.extend_from_slice(&[0u8; 24]); + dib.extend_from_slice(&[0, 0, 255, 0, 255, 0, 0, 0]); + let r = rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat()); + let (dib_rec, _) = officeart::record(&r).unwrap(); + let blip = officeart::blip(&dib_rec, 1 << 20, &mut officeart::Decode::new(usize::MAX)).expect("DIB decodes"); + assert_eq!(blip.ext, "png"); + let img = image::load_from_memory(&blip.bytes).unwrap().to_rgb8(); + assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (2, 1, [255, 0, 0])); + + // A deflated WMF stays a WMF (the metafile renderer draws it). + let wmf: Vec = [1u16, 9, 0x300].iter().flat_map(|v| v.to_le_bytes()).chain([0u8; 12]).collect(); + let packed = miniz_oxide::deflate::compress_to_vec_zlib(&wmf, 6); + let mut hdr = Vec::new(); + hdr.extend_from_slice(&(wmf.len() as u32).to_le_bytes()); + hdr.extend_from_slice(&[0u8; 24]); // rcBounds, ptSize + hdr.extend_from_slice(&(packed.len() as u32).to_le_bytes()); + hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter + let r = rec(0, 0x216, 0xF01B, &[[0u8; 16].as_slice(), &hdr, &packed].concat()); + let (wmf_rec, _) = officeart::record(&r).unwrap(); + let blip = officeart::blip(&wmf_rec, 1 << 20, &mut officeart::Decode::new(usize::MAX)).expect("WMF inflates"); + assert_eq!((blip.ext, blip.bytes), ("wmf", wmf)); +} + +#[test] +fn deeply_nested_groups_and_lying_lengths_are_bounded() { + // 2000 nested group containers: the walk stops at its depth cap (no stack overflow). + let mut g = sp(&[fsp(1, 2000, 0)]); + for _ in 0..2_000 { + g = container(0xF003, &[sp(&[fsp(0, 2001, 1)]), g]); + } + let _ = officeart::content(&art_content(&[], &[g])); + // A metafile claiming 4 GB uncompressed with garbage data, and an FBSE pointing far out. + let mut hdr = vec![0xFFu8; 4]; + hdr.extend_from_slice(&[0u8; 24]); + hdr.extend_from_slice(&u32::MAX.to_le_bytes()); + hdr.extend_from_slice(&[0, 0]); + let r = rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, &[0x78, 0x9C, 1, 2, 3]].concat()); + let (emf, _) = officeart::record(&r).unwrap(); + assert!(officeart::blip(&emf, 1 << 20, &mut officeart::Decode::new(usize::MAX)).is_none()); + let mut fbse = vec![0u8; 36]; + fbse[28..32].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20, &mut officeart::Decode::new(usize::MAX)).is_none()); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + #[test] + fn junk_drawings_never_panic(art in proptest::collection::vec(any::(), 0..2048), spas in proptest::collection::vec(any::(), 0..512), data in proptest::collection::vec(any::(), 0..1024)) { + let text = "A\u{8}\u{1}\u{8}B\r"; + let ccp = text.chars().count() as u32; + let picf = picf_shape(&data); + let spec = StructSpec { + text, + data_stream: picf, + chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))], + extra_pairs: vec![(40, spas), (50, art.clone()), (56, art)], + ..structured_spec(text, ccp) + }; + let _ = read(&struct_doc(&spec)); + } +} + +// A 1×1 24-bit DIB, including its padded four-byte row. +fn one_pixel_dib() -> Vec { + let mut dib = [40u32, 1, 1].iter().flat_map(|v| v.to_le_bytes()).collect::>(); + dib.extend_from_slice(&1u16.to_le_bytes()); + dib.extend_from_slice(&24u16.to_le_bytes()); + dib.extend_from_slice(&[0u8; 24]); + dib.extend_from_slice(&[30, 60, 90, 0]); + dib +} + +#[test] +fn dib_conversion_caps_header_and_encoded_bytes() { + let dib = one_pixel_dib(); + let record = rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat()); + let (record, _) = officeart::record(&record).unwrap(); + let bmp_size = 14 + dib.len(); + assert!(officeart::blip(&record, bmp_size - 1, &mut officeart::Decode::new(usize::MAX)).is_none(), "the BMP header also needs room"); + let fallback = officeart::blip(&record, bmp_size, &mut officeart::Decode::new(usize::MAX)).expect("the BMP fallback fits exactly"); + assert_eq!((fallback.ext, fallback.placeholder, fallback.bytes.len()), ("bmp", true, bmp_size)); + let img = image::load_from_memory(&fallback.bytes).unwrap().to_rgb8(); + assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (1, 1, [90, 60, 30])); + let normal = officeart::blip(&record, 512, &mut officeart::Decode::new(usize::MAX)).expect("normal conversion fits"); + assert_eq!((normal.ext, normal.placeholder), ("png", false)); + assert!(normal.bytes.len() > bmp_size && normal.bytes.len() <= 512); + assert_eq!(image::load_from_memory(&normal.bytes).unwrap().width(), 1); +} + +/// #3709: every anchor of one inline picture got its own copy of the alt text (up to 4096 +/// UTF-16 units). Copies are charged to the document's work budget; past it alt is empty. +#[test] +fn alt_text_copies_across_many_anchors_are_bounded() { + let alt: String = "€".repeat(4096); // 12 KB as UTF-8 + let shape = sp(&[fsp(75, 1025, 0xA00), fopt(&[(0x0104, 1)], &[(0x0381, wz(&alt))])]); + let data = picf_shape(&[shape, fbse_png(&tiny_png())].concat()); + let text: &'static str = Box::leak(format!("{}\r", "\u{1}".repeat(8000)).into_boxed_str()); + let ccp = text.chars().count() as u32; + 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"); + let alts: Vec<&str> = p0.objects.iter().filter_map(|o| if let InlineObject::Image { alt, .. } = o { Some(alt.as_str()) } else { None }).collect(); + assert_eq!(alts.len(), 8000, "every anchor keeps its picture"); + assert_eq!(alts.first().copied(), Some(alt.as_str())); + let total: usize = alts.iter().map(|a| a.len()).sum(); + assert!(total <= 64 << 20, "alt bytes {total}"); + assert_eq!(alts.last().copied(), Some("")); + // Leaving it out is logged, once (#3871 review). + assert_eq!(crate::LAST_DROPPED.with(|d| d.borrow().clone()), [crate::Cost::AltText]); +} + +/// #3710: every PlcSpa entry naming a group built another copy of all its members. A shape is +/// anchored once; repeated spids are skipped. +#[test] +fn a_group_named_by_many_anchors_is_built_once() { + let rect = |l: i32| rec(0, 0, 0xF00F, &[l, 0, l + 10, 10].iter().flat_map(|v| v.to_le_bytes()).collect::>()); + let head = sp(&[fsp(0, 3000, 0x201), rec(1, 0, 0xF009, &[0i32, 0, 10_000, 10].iter().flat_map(|v| v.to_le_bytes()).collect::>())]); + let members: Vec> = (0..1000).map(|i| sp(&[fsp(1, 4000 + i as u32, 0xA02), rect(i * 10)])).collect(); + let group = container(0xF003, &[vec![head], members].concat()); + let art = art_content(&[], &[group]); + let n = 5000u32; + let cps: Vec = (1..=n + 1).collect(); + let spas: Vec = (0..n).flat_map(|_| spa(3000, [0, 0, 1440, 1440], spa_flags(0, 0, 3, false))).collect(); + let text = "A\u{8}\u{8}\u{8}B\r"; + let ccp = text.chars().count() as u32; + let spec = StructSpec { text, extra_pairs: vec![(40, plc(&cps, &spas)), (50, art)], ..structured_spec(text, ccp) }; + let t = std::time::Instant::now(); + let doc = read(&struct_doc(&spec)).expect("opens"); + // About 10 ms on a loaded machine, so 10 s is a backstop; the old code (a copy per entry, + // 6-8 s here) is caught by the group count below, one group per anchor ([1000; 3]). + assert!(t.elapsed().as_secs() < 10, "opened in {:?}", t.elapsed()); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + let groups: Vec = + p0.objects.iter().filter_map(|o| if let InlineObject::Group { children, .. } = o { Some(children.len()) } else { None }).collect(); + assert_eq!(groups, [1000]); +} + +/// #3710: a floating picture's copy is charged its alt text too, and a group every member's. +#[test] +fn a_floating_copy_is_charged_its_alt_text() { + let image = |alt: String| InlineObject::Image { + media: String::new(), + w: 1.0, + h: 1.0, + alt, + float: Default::default(), + crop: Default::default(), + fx: Default::default(), + ole: None, + }; + let bare = crate::object_cost(&image(String::new())); + assert_eq!(crate::object_cost(&image("€".repeat(4096))), bare + 3 * 4096); + let member = |obj| craft_wordmodel::para::GroupChild { x: 0.0, y: 0.0, obj }; + let group = InlineObject::Group { + w: 1.0, + h: 1.0, + float: Default::default(), + ch_w: 1.0, + ch_h: 1.0, + children: vec![member(image("x".repeat(100))), member(image(String::new()))], + canvas: None, + }; + assert_eq!(crate::object_cost(&group), 3 * bare + 100); +} + +/// A deflated WMF BLIP record declaring `size` uncompressed bytes. +fn packed_wmf(size: u32, packed: &[u8]) -> Vec { + let mut hdr = size.to_le_bytes().to_vec(); + hdr.extend_from_slice(&[0u8; 24]); // rcBounds, ptSize + hdr.extend_from_slice(&(packed.len() as u32).to_le_bytes()); + hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter + rec(0, 0x216, officeart::rt::BLIP_WMF, &[[0u8; 16].as_slice(), &hdr, packed].concat()) +} + +/// An `OfficeArtFBSE` body whose BLIP (of `size` bytes) sits at `fo_delay` in the delay stream. +fn delayed_fbse(size: usize, fo_delay: usize) -> Vec { + let mut fbse = vec![0u8; 36]; + fbse[20..24].copy_from_slice(&(size as u32).to_le_bytes()); + fbse[28..32].copy_from_slice(&(fo_delay as u32).to_le_bytes()); + fbse +} + +/// #3711: store entries aliasing one delay-stream metafile each inflated (and copied) it again. +/// Now the record is decoded once and every entry shares its media key. +#[test] +fn store_entries_aliasing_one_delayed_metafile_decode_it_once() { + let wmf = vec![0u8; 1 << 20]; + let delay = packed_wmf(wmf.len() as u32, &miniz_oxide::deflate::compress_to_vec_zlib(&wmf, 6)); + let fbse = delayed_fbse(delay.len(), 0); + let store: Vec<&[u8]> = vec![&fbse; 5_000]; + let mut pics = crate::media::Pictures::default(); + pics.work = officeart::Decode::new(8 << 20); + let keys: Vec<_> = (1..=store.len() as u32).map(|pib| pics.bse(pib, &store, &delay)).collect(); + assert!(keys.iter().all(|k| k.is_some() && *k == keys[0]), "every entry shares one picture"); + assert_eq!(pics.work.left, (8 << 20) - wmf.len(), "inflated once"); +} + +/// #3711: distinct metafiles that fail to inflate are charged their declared size anyway, so +/// a document of them stops decoding once the work budget is spent. +#[test] +fn failing_metafile_inflations_spend_the_decode_budget() { + let records: Vec> = (0..10u8).map(|i| packed_wmf(1 << 20, &[0x78, 0x9C, i, 1, 2, 3])).collect(); + let delay = records.concat(); + let mut at = 0; + let fbses: Vec> = records + .iter() + .map(|r| { + at += r.len(); + delayed_fbse(r.len(), at - r.len()) + }) + .collect(); + let store: Vec<&[u8]> = fbses.iter().map(Vec::as_slice).collect(); + let mut pics = crate::media::Pictures::default(); + pics.work = officeart::Decode::new(3 << 20); + for pib in 1..=store.len() as u32 { + assert!(pics.bse(pib, &store, &delay).is_none()); + } + assert_eq!(pics.work.left, 0, "three attempts spent the budget; the rest weren't tried"); +} + +/// #3712: a DIB's declared pixels are charged before decoding (an RLE bitmap is tiny on disk but +/// decodes to w × h × 4 bytes); past the budget it stays a BMP placeholder, undecoded. +#[test] +fn dib_decoding_is_charged_its_declared_pixels() { + let blip_of = |dib: &[u8]| rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], dib].concat()); + let one = blip_of(&one_pixel_dib()); + let (one, _) = officeart::record(&one).unwrap(); + let mut work = officeart::Decode::new(3); + assert!(officeart::blip(&one, 512, &mut work).is_none(), "not even the copy fits: nothing is copied"); + assert_eq!(work.left, 3, "nothing decoded, nothing charged"); + let dib_len = one.body.len() - 17; + let mut work = officeart::Decode::new(dib_len); + assert_eq!(officeart::blip(&one, 512, &mut work).map(|b| (b.ext, b.placeholder)), Some(("png", false))); + assert_eq!(work.left, 0, "the copy is charged, not just 4 bytes a pixel"); + + // 8-bit RLE declaring 16384 × 16384 (1 GiB decoded) in a few bytes. + let mut big = [40u32, 16_384, 16_384].iter().flat_map(|v| v.to_le_bytes()).collect::>(); + big.extend_from_slice(&1u16.to_le_bytes()); + big.extend_from_slice(&8u16.to_le_bytes()); + big.extend_from_slice(&1u32.to_le_bytes()); // BI_RLE8 + big.extend_from_slice(&[0u8; 12]); + big.extend_from_slice(&1u32.to_le_bytes()); // one palette colour + big.extend_from_slice(&[0u8; 4]); // clrImportant + big.extend_from_slice(&[0u8; 4]); // the palette colour + big.extend_from_slice(&[0, 0, 0, 1]); // end of line, end of bitmap + let big = blip_of(&big); + let (big, _) = officeart::record(&big).unwrap(); + let mut work = officeart::Decode::new(512 << 20); + assert_eq!(officeart::blip(&big, 1 << 20, &mut work).map(|b| (b.ext, b.placeholder)), Some(("bmp", true))); + assert_eq!(work.left, (512 << 20) - (big.body.len() - 17), "only the copy of the DIB was charged, not its declared pixels"); +} + +/// An EMF `OfficeArtBlip` record (compressed) with `raw` as its stream, claiming `cb_size`. +fn emf_blip(cb_size: u32, raw: &[u8]) -> Vec { + let mut hdr = vec![0u8; 34]; + hdr[0..4].copy_from_slice(&cb_size.to_le_bytes()); + hdr[28..32].copy_from_slice(&(raw.len() as u32).to_le_bytes()); + hdr[32] = 0; // compressed + hdr[33] = 0xFE; + rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, raw].concat()) +} + +/// #3871 review: the zlib input is charged to the decode budget, not just what it inflates to. +#[test] +fn compressed_metafile_input_is_charged_to_the_decode_budget() { + let raw = vec![0x5Au8; 1 << 20]; // not even zlib: costs its input all the same + let r = emf_blip(1, &raw); + let (emf, _) = officeart::record(&r).unwrap(); + let mut work = officeart::Decode::new(10 << 20); + assert!(officeart::blip(&emf, 64 << 20, &mut work).is_none()); + assert_eq!(work.left, (10 << 20) - raw.len(), "1 MB of input charged, however little it claims to inflate to"); + // Ten more of them exhaust the budget; none is inflated past it. + let mut work = officeart::Decode::new(3 << 20); + for _ in 0..10 { + let _ = officeart::blip(&emf, 64 << 20, &mut work); + } + assert_eq!(work.left, 0, "three attempts fit, the rest are refused"); +} + +/// An inline `PICFAndOfficeArtData` (shape, FBSE) whose BLIP sits at `fo_delay` in Data; the +/// FBSE claims `size` bytes. +fn inline_pic(fo_delay: u32, size: u32) -> Vec { + let mut fbse = vec![0u8; 36]; + fbse[20..24].copy_from_slice(&size.to_le_bytes()); + fbse[28..32].copy_from_slice(&fo_delay.to_le_bytes()); + let body = [container(0xF004, &[]), rec(2, 6, 0xF007, &fbse)].concat(); + let mut picf = vec![0u8; 0x44]; + picf[0..4].copy_from_slice(&((0x44 + body.len()) as u32).to_le_bytes()); + picf[4..6].copy_from_slice(&0x44u16.to_le_bytes()); + picf[6..8].copy_from_slice(&0x64u16.to_le_bytes()); // MM_SHAPE + picf[28..30].copy_from_slice(&1440i16.to_le_bytes()); + picf[30..32].copy_from_slice(&1440i16.to_le_bytes()); + [picf, body].concat() +} + +/// #3871 review: inline pictures that alias one BLIP record share one decode, whatever sizes +/// their FBSEs claim and wherever their PICFs sit. +#[test] +fn inline_pictures_aliasing_one_blip_record_are_decoded_once() { + let blip = emf_blip(1, &vec![0x5Au8; 64 << 10]); + let a = inline_pic(0, 70_000); + let b = inline_pic(0, 80_000); + let c = inline_pic(0, 90_000); + let blip_at = (a.len() + b.len() + c.len()) as u32; + let (a, b, c) = (inline_pic(blip_at, 70_000), inline_pic(blip_at, 80_000), inline_pic(blip_at, 90_000)); + let data = [a.clone(), b.clone(), c, blip].concat(); + let mut pics = crate::media::Pictures::default(); + let start = pics.work.left; + let _ = pics.get(&data, 0); + let _ = pics.get(&data, a.len() as u32); + let _ = pics.get(&data, (a.len() + b.len()) as u32); + assert_eq!(start - pics.work.left, 64 << 10, "one record, one charge"); +} + +/// A 1-bit-per-pixel DIB of `side`×`side` pixels with `bytes` of pixel data. +fn dib_1bpp(side: u32, bytes: usize) -> Vec { + let mut d = vec![0u8; 40]; + d[0..4].copy_from_slice(&40u32.to_le_bytes()); + d[4..8].copy_from_slice(&side.to_le_bytes()); + d[8..12].copy_from_slice(&side.to_le_bytes()); + d[12..14].copy_from_slice(&1u16.to_le_bytes()); + d[14..16].copy_from_slice(&1u16.to_le_bytes()); + d.extend_from_slice(&[0, 0, 0, 0, 255, 255, 255, 0]); // palette + d.extend(std::iter::repeat_n(0u8, bytes)); + d +} + +fn dib_record(dib: &[u8]) -> Vec { + rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], dib].concat()) +} + +/// #3871 review: the decode budget scales with the input, so a document of many legitimate +/// large scans decodes them all; a high-ratio set is still capped. +#[test] +fn the_decode_budget_scales_with_the_input_but_still_caps_bombs() { + let floor = 1 << 20; + // 20 legitimate 1024² 1-bit scans: 4 MB charged each, 128 KB of file each. + let real = dib_record(&dib_1bpp(1024, 1024 * 1024 / 8)); + let (rec_real, _) = officeart::record(&real).unwrap(); + let input = 20 * real.len(); + let mut work = officeart::Decode::new(crate::media::decode_work(input, floor)); + for i in 0..20 { + let b = officeart::blip(&rec_real, 64 << 20, &mut work).unwrap(); + assert!(!b.placeholder, "scan {i} decodes"); + } + // Declared 20000² (1.6 GB each) in a few dozen bytes each: a placeholder, never decoded. + let bomb = dib_record(&dib_1bpp(20_000, 16)); + let (rec_bomb, _) = officeart::record(&bomb).unwrap(); + let mut work = officeart::Decode::new(crate::media::decode_work(20 * bomb.len(), floor)); + for _ in 0..20 { + assert!(officeart::blip(&rec_bomb, 64 << 20, &mut work).unwrap().placeholder); + } + assert!(work.left <= crate::media::decode_work(20 * bomb.len(), floor), "nothing was refunded or invented"); +} + +/// #3871 review: the copy of a DIB into a BMP is charged before it is made, however few pixels +/// the header declares; with the budget spent nothing is copied at all. +#[test] +fn a_dib_with_a_large_tail_is_charged_for_its_copy() { + let big = dib_record(&dib_1bpp(1, 1 << 20)); // 1 pixel declared, 1 MB of trailing bytes + let (big, _) = officeart::record(&big).unwrap(); + let dib_len = big.body.len() - 17; + let mut work = officeart::Decode::new(10 << 20); + assert!(!officeart::blip(&big, 64 << 20, &mut work).unwrap().placeholder); + assert_eq!(10 << 20, work.left + dib_len, "charged for the bytes, not just 4 per pixel"); + // Room for the copy: decoded. Not even that: nothing is copied. + let mut work = officeart::Decode::new(dib_len + 1); + assert!(officeart::blip(&big, 64 << 20, &mut work).is_some()); + let mut work = officeart::Decode::new(dib_len - 1); + assert!(officeart::blip(&big, 64 << 20, &mut work).is_none()); + assert_eq!(work.left, dib_len - 1); +} diff --git a/crates/doc-binary/src/tests/encrypted.rs b/crates/doc-binary/src/tests/encrypted.rs new file mode 100644 index 000000000..f6fcea971 --- /dev/null +++ b/crates/doc-binary/src/tests/encrypted.rs @@ -0,0 +1,241 @@ +//! Password-protected files. The fixtures are encrypted here with a small encryptor written from +//! [MS-DOC] §2.2.6 and [MS-OFFCRYPTO] §2.3.5-2.3.7; no file produced by Word is used. + +use std::io::Read; + +use sha1::{Digest, Sha1}; + +use super::{Piece, body_texts, cfb_file, text_doc}; +use crate::DocbinError; +use crate::crypt::{F_ENCRYPTED, F_OBFUSCATED, Rc4, md5, xor_array, xor_verifier}; +use crate::{is_encrypted, read, read_with_password}; + +const TEXT: &str = "Secret \u{20ac} plans"; +/// Where the piece table's pair lives in the FIB: `fcClx` of pair 33. +const CLX_PAIR: usize = 0x9A + 33 * 8; + +fn plain() -> (Vec, Vec) { + let f = text_doc(&[Piece { compressed: false, text: format!("{TEXT}\r") }], &[], 0); + let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(f)).unwrap(); + let mut get = |n: &str| { + let mut v = Vec::new(); + comp.open_stream(format!("/{n}")).unwrap().read_to_end(&mut v).unwrap(); + v + }; + (get("WordDocument"), get("0Table")) +} + +fn rewrap(word: &[u8], table: &[u8]) -> Vec { + cfb_file(&[("WordDocument", word), ("0Table", table)]) +} + +fn put16(v: &mut [u8], at: usize, x: u16) { + v[at..at + 2].copy_from_slice(&x.to_le_bytes()); +} + +fn put32(v: &mut [u8], at: usize, x: u32) { + v[at..at + 4].copy_from_slice(&x.to_le_bytes()); +} + +fn utf16(s: &str) -> Vec { + s.encode_utf16().flat_map(u16::to_le_bytes).collect() +} + +fn sha1(parts: &[&[u8]]) -> Vec { + let mut h = Sha1::new(); + for p in parts { + h.update(p); + } + h.finalize().to_vec() +} + +/// Encrypt (= RC4 is symmetric) `data` in 512-byte blocks; the first `skip` bytes stay as they are +/// but the keystream still runs over them. +fn rc4_stream(data: &mut [u8], skip: usize, key: &dyn Fn(u32) -> Vec) { + for (n, chunk) in data.chunks_mut(512).enumerate() { + let orig = chunk.to_vec(); + Rc4::new(&key(n as u32)).apply(chunk); + let keep = skip.saturating_sub(n * 512).min(chunk.len()); + chunk[..keep].copy_from_slice(&orig[..keep]); + } +} + +/// Put `header` in front of the table and set the FIB's encryption fields. +fn assemble(mut word: Vec, table: &[u8], header: &[u8], flags: u16, lkey: u32) -> (Vec, Vec) { + let fl = u16::from_le_bytes([word[0x0A], word[0x0B]]) | flags; + put16(&mut word, 0x0A, fl); + put32(&mut word, 0x0E, lkey); + let fc = u32::from_le_bytes(word[CLX_PAIR..CLX_PAIR + 4].try_into().unwrap()); + put32(&mut word, CLX_PAIR, fc + header.len() as u32); + ([word].concat(), [header, table].concat()) +} + +fn xor_file(pw: &[u8]) -> Vec { + let (word, table) = plain(); + let (mut word, mut table) = assemble(word, &table, &[], F_ENCRYPTED | F_OBFUSCATED, xor_verifier(pw)); + let arr = xor_array(pw); + for (data, skip) in [(&mut word, 68), (&mut table, 0)] { + for (i, b) in data.iter_mut().enumerate().skip(skip) { + if *b != 0 && *b ^ arr[i % 16] != 0 { + *b ^= arr[i % 16]; + } + } + } + rewrap(&word, &table) +} + +fn rc4_header(pw: &str) -> (Vec, Box Vec>) { + let salt = [7u8; 16]; + let h0 = md5(&utf16(pw)); + let h1 = md5(&(0..16).flat_map(|_| [&h0[..5], &salt[..]].concat()).collect::>()); + let trunc = h1[..5].to_vec(); + let key: Box Vec> = Box::new(move |b| md5(&[trunc.as_slice(), &b.to_le_bytes()].concat()).to_vec()); + let verifier = *b"0123456789abcdef"; + let mut enc = [verifier.to_vec(), md5(&verifier).to_vec()].concat(); + Rc4::new(&key(0)).apply(&mut enc); + ([&[1, 0, 1, 0][..], &salt, &enc].concat(), key) +} + +fn capi_header(pw: &str, bits: u32, hflags: u32) -> (Vec, Box Vec>) { + let salt = [9u8; 16]; + let h0 = sha1(&[&salt, &utf16(pw)]); + let n = (bits / 8) as usize; + let key: Box Vec> = Box::new(move |b| { + let mut k = sha1(&[&h0, &b.to_le_bytes()])[..n].to_vec(); + if n == 5 { + k.resize(16, 0); + } + k + }); + let verifier = *b"fedcba9876543210"; + let mut enc = [verifier.to_vec(), sha1(&[&verifier])].concat(); + Rc4::new(&key(0)).apply(&mut enc); + let mut body = Vec::new(); + for x in [hflags, 0, 0x6801, 0x8004, bits, 1, 0, 0] { + body.extend_from_slice(&x.to_le_bytes()); + } + body.extend_from_slice(&utf16("Test RC4 provider\0")); + let mut h = vec![3, 0, 2, 0]; + h.extend_from_slice(&hflags.to_le_bytes()); + h.extend_from_slice(&(body.len() as u32).to_le_bytes()); + h.extend_from_slice(&body); + h.extend_from_slice(&16u32.to_le_bytes()); + h.extend_from_slice(&salt); + h.extend_from_slice(&enc[..16]); + h.extend_from_slice(&20u32.to_le_bytes()); + h.extend_from_slice(&enc[16..]); + (h, key) +} + +fn rc4_file_with(header: Vec, key: &dyn Fn(u32) -> Vec) -> Vec { + let (word, table) = plain(); + let (mut word, mut table) = assemble(word, &table, &header, F_ENCRYPTED, header.len() as u32); + rc4_stream(&mut word, 68, key); + rc4_stream(&mut table, header.len(), key); + rewrap(&word, &table) +} + +fn rc4_file(pw: &str) -> Vec { + let (h, k) = rc4_header(pw); + rc4_file_with(h, &*k) +} + +fn capi_file(pw: &str, bits: u32) -> Vec { + let (h, k) = capi_header(pw, bits, 0x04); + rc4_file_with(h, &*k) +} + +fn check(f: &[u8], pw: &str, wrong: &str) { + assert!(is_encrypted(f)); + assert_eq!(read(f).unwrap_err(), DocbinError::PasswordRequired); + assert_eq!(read_with_password(f, Some(wrong)).unwrap_err(), DocbinError::WrongPassword); + assert_eq!(read_with_password(f, Some("")).unwrap_err(), DocbinError::WrongPassword); + let doc = read_with_password(f, Some(pw)).unwrap(); + assert_eq!(body_texts(&doc), [TEXT]); +} + +#[test] +fn xor_obfuscation_round_trip() { + check(&xor_file(b"hunter2"), "hunter2", "hunter3"); + // Longer than 15 characters: only the first 15 count, as in the format. + let long = xor_file(b"abcdefghijklmno"); + assert_eq!(body_texts(&read_with_password(&long, Some("abcdefghijklmnopqrst")).unwrap()), [TEXT]); + // A non-ASCII (Latin-1) password. + check(&xor_file(&[b'p', 0xE4, b's', b's']), "p\u{e4}ss", "pass"); +} + +#[test] +fn rc4_round_trip() { + check(&rc4_file("correct horse"), "correct horse", "Correct horse"); + check(&rc4_file("p\u{e4}ssw\u{f6}rd \u{1f511}"), "p\u{e4}ssw\u{f6}rd \u{1f511}", "password"); +} + +#[test] +fn rc4_crypto_api_round_trip() { + check(&capi_file("battery staple", 128), "battery staple", "battery stapl"); + // 40-bit keys are zero-padded to 128 bits. + check(&capi_file("battery staple", 40), "battery staple", "x"); +} + +#[test] +fn unencrypted_files_ignore_the_password() { + let f = text_doc(&[Piece { compressed: false, text: "Plain\r".into() }], &[], 0); + assert!(!is_encrypted(&f)); + assert_eq!(body_texts(&read_with_password(&f, Some("whatever")).unwrap()), ["Plain"]); +} + +#[test] +fn a_tampered_verifier_is_a_wrong_password_never_a_garbled_document() { + let (h, k) = rc4_header("pw"); + let mut bad = h.clone(); + bad[40] ^= 1; // inside the encrypted verifier hash + assert_eq!(read_with_password(&rc4_file_with(bad, &*k), Some("pw")).unwrap_err(), DocbinError::WrongPassword); +} + +#[test] +fn unsupported_variants_say_so() { + // AES and external providers, an unknown header version. + for hflags in [0x04 | 0x20, 0x10] { + let (h, k) = capi_header("pw", 128, hflags); + let e = read_with_password(&rc4_file_with(h, &*k), Some("pw")).unwrap_err(); + assert!(matches!(e, DocbinError::Encryption(_)), "{e:?}"); + if hflags & 0x20 != 0 { + assert!(e.to_string().contains("only defined for the newer encrypted-package format"), "{e}"); + } + } + let (mut h, k) = rc4_header("pw"); + h[0] = 9; + assert!(matches!(read_with_password(&rc4_file_with(h, &*k), Some("pw")), Err(DocbinError::Encryption(_)))); +} + +#[test] +fn damaged_headers_are_errors_never_panics() { + for f in [rc4_header("pw"), capi_header("pw", 128, 0x04)] { + let (h, k) = f; + let full = rc4_file_with(h.clone(), &*k); + let (word, table) = { + let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(&full)).unwrap(); + let mut get = |n: &str| { + let mut v = Vec::new(); + comp.open_stream(format!("/{n}")).unwrap().read_to_end(&mut v).unwrap(); + v + }; + (get("WordDocument"), get("0Table")) + }; + // Truncated everywhere in and around the header: an error, not a panic. + for n in 0..h.len() + 8 { + let r = read_with_password(&rewrap(&word, &table[..n.min(table.len())]), Some("pw")); + assert!(r.is_err(), "length {n}"); + } + // Every header byte garbled. + for at in 0..h.len() { + let mut t = table.clone(); + t[at] ^= 0xFF; + let _ = read_with_password(&rewrap(&word, &t), Some("pw")); + } + } + // A header size claiming more than the stream holds. + let (mut h, k) = capi_header("pw", 128, 0x04); + h[8..12].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(read_with_password(&rc4_file_with(h, &*k), Some("pw")).is_err()); +} diff --git a/crates/doc-binary/src/tests/metadata.rs b/crates/doc-binary/src/tests/metadata.rs new file mode 100644 index 000000000..14cd3b2dd --- /dev/null +++ b/crates/doc-binary/src/tests/metadata.rs @@ -0,0 +1,164 @@ +//! OLE property sets constructed from MS-OLEPS, exercised through the public DOC reader. +use super::{Piece, body_texts, text_doc}; +use std::io::{Cursor, Seek, SeekFrom, Write}; + +fn summary(cp: u16, values: &[(u32, u32, Vec)]) -> Vec { + let mut props = vec![(1, [2u32.to_le_bytes().as_slice(), &cp.to_le_bytes(), &[0, 0]].concat())]; + for (id, kind, bytes) in values { + let size = if *kind == 31 { bytes.len() / 2 } else { bytes.len() } as u32; + let mut value = [kind.to_le_bytes().as_slice(), &size.to_le_bytes(), bytes].concat(); + while !value.len().is_multiple_of(4) { + value.push(0); + } + props.push((*id, value)); + } + let mut section = vec![0; 8 + 8 * props.len()]; + section[4..8].copy_from_slice(&(props.len() as u32).to_le_bytes()); + for (i, (id, value)) in props.iter().enumerate() { + section[8 + i * 8..12 + i * 8].copy_from_slice(&id.to_le_bytes()); + let offset = section.len() as u32; + section[12 + i * 8..16 + i * 8].copy_from_slice(&offset.to_le_bytes()); + section.extend_from_slice(value); + } + let size = section.len() as u32; + section[..4].copy_from_slice(&size.to_le_bytes()); + let mut header = vec![0; 48]; + header[..2].copy_from_slice(&[0xfe, 0xff]); + header[24..28].copy_from_slice(&1u32.to_le_bytes()); + header[28..44].copy_from_slice(&[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0, 0x2b, 0x27, 0xb3, 0xd9]); + header[44..48].copy_from_slice(&48u32.to_le_bytes()); + header.extend(section); + header +} + +fn open_with_metadata(bytes: &[u8], template: bool) -> craft_wordmodel::Document { + let file = text_doc(&[Piece { compressed: false, text: "Body stays readable\r".into() }], &[], 0); + let mut cfb = cfb::CompoundFile::open(Cursor::new(file)).unwrap(); + cfb.create_stream("/\u{5}SummaryInformation").unwrap().write_all(bytes).unwrap(); + if template { + let mut stream = cfb.open_stream("/WordDocument").unwrap(); + stream.seek(SeekFrom::Start(0x0a)).unwrap(); + stream.write_all(&0x1001u16.to_le_bytes()).unwrap(); + } + let doc = crate::read(&cfb.into_inner().into_inner()).unwrap(); + assert_eq!(body_texts(&doc), ["Body stays readable"]); + doc +} + +#[test] +fn doc_and_dot_import_summary_text_properties() { + let data = summary( + 1252, + &[ + (2, 30, b"R\xe9sum\xe9\0".to_vec()), + (3, 30, b"Subject\0".to_vec()), + (4, 30, b"Writer\0".to_vec()), + (5, 30, b"one;two\0".to_vec()), + (6, 30, b"Description\0".to_vec()), + (8, 30, b"Editor\0".to_vec()), + (9, 30, b"12\0".to_vec()), + ], + ); + for template in [false, true] { + let c = open_with_metadata(&data, template).core; + assert_eq!(c.title, "Résumé"); + assert_eq!(c.subject, "Subject"); + assert_eq!(c.creator, "Writer"); + assert_eq!(c.keywords, "one;two"); + assert_eq!(c.description, "Description"); + assert_eq!(c.last_modified_by, "Editor"); + assert_eq!(c.revision, 12); + } +} + +#[test] +fn summary_handles_codepages_and_utf16_length_units() { + let wide: Vec = "Unicode ä 文 😀\0".encode_utf16().flat_map(u16::to_le_bytes).collect(); + for kind in [30, 31] { + assert_eq!(open_with_metadata(&summary(1200, &[(2, kind, wide.clone())]), false).core.title, "Unicode ä 文 😀"); + } + assert_eq!(open_with_metadata(&summary(65001, &[(2, 30, "UTF-8 ä\0".as_bytes().to_vec())]), false).core.title, "UTF-8 ä"); + assert_eq!(open_with_metadata(&summary(1251, &[(2, 30, vec![0xcf, 0xf0, 0xe8, 0xe2, 0xe5, 0xf2, 0])]), false).core.title, "Привет"); +} + +#[test] +fn malformed_or_oversized_summary_does_not_break_doc_open() { + let valid = summary(1252, &[(2, 30, b"Title\0".to_vec())]); + for n in [0, 2, 24, 44, 47, 55, valid.len() - 5] { + assert!(open_with_metadata(&valid[..n], false).core.title.is_empty()); + } + for (at, value) in [(44, u32::MAX), (48, u32::MAX), (52, u32::MAX), (68, 4), (84, u32::MAX)] { + let mut bad = valid.clone(); + bad[at..at + 4].copy_from_slice(&value.to_le_bytes()); + assert!(open_with_metadata(&bad, false).core.title.is_empty(), "offset {at}"); + } + let huge = summary(1252, &[(2, 30, vec![b'x'; (64 << 10) + 1])]); + assert!(open_with_metadata(&huge, false).core.title.is_empty()); + assert!(open_with_metadata(&vec![0; (2 << 20) + 1], false).core.title.is_empty()); +} + +fn custom_summary(props: &[(u32, Vec)], num_property_sets: u32) -> Vec { + let mut section = vec![0; 8 + 8 * props.len()]; + section[4..8].copy_from_slice(&(props.len() as u32).to_le_bytes()); + for (i, (id, value)) in props.iter().enumerate() { + section[8 + i * 8..12 + i * 8].copy_from_slice(&id.to_le_bytes()); + let offset = section.len() as u32; + section[12 + i * 8..16 + i * 8].copy_from_slice(&offset.to_le_bytes()); + section.extend_from_slice(value); + } + let size = section.len() as u32; + section[..4].copy_from_slice(&size.to_le_bytes()); + let mut header = vec![0; 28 + 20 * num_property_sets as usize]; + header[..2].copy_from_slice(&[0xfe, 0xff]); + header[24..28].copy_from_slice(&num_property_sets.to_le_bytes()); + // First property set FMTID and offset + header[28..44].copy_from_slice(&[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0, 0x2b, 0x27, 0xb3, 0xd9]); + let first_offset = header.len() as u32; + header[44..48].copy_from_slice(&first_offset.to_le_bytes()); + if num_property_sets > 1 { + // Dummy second property set header + header[48..64].copy_from_slice(&[1; 16]); + let second_offset = (first_offset + size) as u32; + header[64..68].copy_from_slice(&second_offset.to_le_bytes()); + } + header.extend(section); + header +} + +#[test] +fn summary_defaults_codepage_and_accepts_vti4_and_num_property_sets() { + let title_prop = [30u32.to_le_bytes().as_slice(), &7u32.to_le_bytes(), b"Default\0"].concat(); + // 1. Missing PID_CODEPAGE (property 1) should default to 1252 per MS-OLEPS. + let no_cp = custom_summary(&[(2, title_prop.clone())], 1); + assert_eq!(open_with_metadata(&no_cp, false).core.title, "Default"); + + // 2. PID_CODEPAGE as VT_I4 (type 3). + let cp_vti4 = [3u32.to_le_bytes().as_slice(), &1252u32.to_le_bytes()].concat(); + let with_vti4 = custom_summary(&[(1, cp_vti4), (2, title_prop.clone())], 1); + assert_eq!(open_with_metadata(&with_vti4, false).core.title, "Default"); + + // 3. Stream with NumPropertySets = 2. + let multi_sets = custom_summary(&[(2, title_prop)], 2); + assert_eq!(open_with_metadata(&multi_sets, false).core.title, "Default"); +} + +#[test] +fn summary_parses_filetime_created_and_modified_dates() { + // 2024-03-15T12:30:45Z = 133549794450000000 in 100ns since 1601-01-01 + let ft_val: u64 = 133_549_794_450_000_000; + let ft_low = (ft_val & 0xFFFFFFFF) as u32; + let ft_high = (ft_val >> 32) as u32; + let filetime_bytes = [64u32.to_le_bytes().as_slice(), &ft_low.to_le_bytes(), &ft_high.to_le_bytes()].concat(); + + let stream = custom_summary( + &[ + (12, filetime_bytes.clone()), // PIDSI_CREATE_DTM + (13, filetime_bytes), // PIDSI_LASTSAVE_DTM + ], + 1, + ); + + let core = open_with_metadata(&stream, false).core; + assert_eq!(core.created, "2024-03-15T12:30:45Z"); + assert_eq!(core.modified, "2024-03-15T12:30:45Z"); +}