diff --git a/Cargo.lock b/Cargo.lock index 653ae11b75..c1d716f57c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1019,6 +1019,32 @@ dependencies = [ "rustix 1.1.5", ] +[[package]] +name = "craft-cfb" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0" + +[[package]] +name = "craft-codepage" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0" + +[[package]] +name = "craft-doc-binary" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0" +dependencies = [ + "chrono", + "craft-cfb", + "craft-codepage", + "craft-wordmodel", + "image", + "log", + "miniz_oxide 0.9.1", + "sha1 0.11.0", + "thiserror 2.0.21", +] + [[package]] name = "craft-font-catalog" version = "0.1.0" @@ -1078,6 +1104,15 @@ dependencies = [ "writeable", ] +[[package]] +name = "craft-math" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0" +dependencies = [ + "craft-xml", + "serde", +] + [[package]] name = "craft-pdf" version = "0.1.0" @@ -1198,6 +1233,29 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "craft-wordmodel" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0" +dependencies = [ + "base64 0.23.1", + "craft-math", + "log", + "serde", + "serde_json", + "thiserror 2.0.21", + "unicode-segmentation", +] + +[[package]] +name = "craft-xml" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0" +dependencies = [ + "quick-xml 0.41.0", + "thiserror 2.0.21", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -6884,16 +6942,11 @@ name = "wordcraft-docbin" version = "0.18.1" dependencies = [ "cfb", - "chrono", - "image", - "log", - "miniz_oxide 0.9.1", - "proptest", + "craft-doc-binary", + "craft-wordmodel", + "serde", "serde_json", - "sha1 0.11.0", - "thiserror 2.0.21", "wordcraft-doc", - "wordcraft-geom", ] [[package]] diff --git a/crates/docbin/Cargo.toml b/crates/docbin/Cargo.toml index c819c33885..9d49766d0b 100644 --- a/crates/docbin/Cargo.toml +++ b/crates/docbin/Cargo.toml @@ -9,17 +9,12 @@ description = "WordCraft reader for the Word 97-2003 binary file format (.doc, [ workspace = true [dependencies] +craft-doc-binary = { git = "https://github.com/storytold/craft-libs", rev = "75c70fa6a786240eb853e6267e25fc445f510cd0", version = "=0.1.0" } +craft-wordmodel = { git = "https://github.com/storytold/craft-libs", rev = "75c70fa6a786240eb853e6267e25fc445f510cd0", version = "=0.1.0" } wordcraft-doc = { workspace = true } -wordcraft-geom = { workspace = true } -cfb = { workspace = true } -image = { workspace = true } -miniz_oxide = { workspace = true } -sha1 = { workspace = true } -thiserror = { workspace = true } -log = { workspace = true } -chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } [dev-dependencies] wordcraft-doc = { workspace = true, features = ["test-support"] } -proptest = { workspace = true } -serde_json = { workspace = true } +cfb = { workspace = true } diff --git a/crates/docbin/src/crypt.rs b/crates/docbin/src/crypt.rs deleted file mode 100644 index 9e139f6922..0000000000 --- a/crates/docbin/src/crypt.rs +++ /dev/null @@ -1,455 +0,0 @@ -//! 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/docbin/src/drawing.rs b/crates/docbin/src/drawing.rs deleted file mode 100644 index 33f45de345..0000000000 --- a/crates/docbin/src/drawing.rs +++ /dev/null @@ -1,283 +0,0 @@ -//! 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 wordcraft_doc::fill::Fill; -use wordcraft_doc::para::{Anchor, Float, FloatAlign, GroupChild, InlineObject, ShapeKind, Wrap}; -use wordcraft_doc::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 = wordcraft_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() >= wordcraft_doc::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/docbin/src/fib.rs b/crates/docbin/src/fib.rs deleted file mode 100644 index 7bb3ec1495..0000000000 --- a/crates/docbin/src/fib.rs +++ /dev/null @@ -1,185 +0,0 @@ -//! 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/docbin/src/fkp.rs b/crates/docbin/src/fkp.rs deleted file mode 100644 index 3de272da26..0000000000 --- a/crates/docbin/src/fkp.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! 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/docbin/src/fmt.rs b/crates/docbin/src/fmt.rs deleted file mode 100644 index a5d85012c8..0000000000 --- a/crates/docbin/src/fmt.rs +++ /dev/null @@ -1,572 +0,0 @@ -//! Sprms → model properties, and style-chain resolution. - -use wordcraft_doc::Rgb; -use wordcraft_doc::props::{ - Align, Border, BorderStyle, Borders, CharProps, Highlight, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop, TextColor, Underline, VertAlign, -}; -use wordcraft_doc::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/docbin/src/lib.rs b/crates/docbin/src/lib.rs index 6b3014933e..8759bedc2b 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -1,1035 +1,31 @@ -//! WordCraft reader for the Word 97-2003 binary file format (`.doc`, [MS-DOC]). +//! WordCraft adapter for reading Word 97-2003 binary documents (`.doc`, [MS-DOC]). //! -//! [`read`] turns the bytes of a `.doc` file into a [`wordcraft_doc::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 every WordCraft parser, 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 file-format implementation lives in `craft-doc-binary`. This crate adapts the shared +//! model to WordCraft's application model while keeping the public read and password APIs stable. +//! Stories are converted paragraph by paragraph, and embedded media buffers are moved without a +//! full-document serialization copy. +//! +//! [MS-DOC]: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/ -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; +mod model_bridge; -use std::io::{Read, Seek}; -use std::sync::Arc; +pub use craft_doc_binary::DocbinError; +pub use model_bridge::from_shared_model; -use wordcraft_doc::link::hyperlink_target; -use wordcraft_doc::para::InlineObject; -use wordcraft_doc::para::NoteKind; -use wordcraft_doc::para::Paragraph; -use wordcraft_doc::para::Run; -use wordcraft_doc::props::CharProps; -use wordcraft_doc::props::NumRef; -use wordcraft_doc::section::HeaderSet; -use wordcraft_doc::styles::StyleSheet; -use wordcraft_doc::{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. +/// Whether the input is a readable Word 97-2003 document protected by a password. pub fn is_encrypted(bytes: &[u8]) -> bool { - let Ok(mut comp) = open_container(bytes) else { return false }; - stream(&mut comp, "WordDocument").is_some_and(|w| fib::peek_encryption(&w).is_some()) + craft_doc_binary::is_encrypted(bytes) } -/// Open the compound file around a `.doc`, after [`wordcraft_doc::cfb_guard::check`]. -fn open_container(bytes: &[u8]) -> Result>, DocbinError> { - wordcraft_doc::cfb_guard::check(bytes).map_err(DocbinError::Container)?; - cfb::CompoundFile::open(std::io::Cursor::new(bytes)).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 a Word 97-2003 binary document into WordCraft's document model. +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 mut comp = open_container(bytes)?; - let mut word = stream(&mut 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 = stream(&mut comp, named).or_else(|| stream(&mut 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(&mut 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(&mut 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: wordcraft_doc::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, or `None` if it is absent. -fn stream(comp: &mut cfb::CompoundFile, name: &str) -> Option> { - let mut s = comp.open_stream(format!("/{name}")).ok()?; - let mut v = Vec::new(); - // Read with a cap one byte above the limit so oversized streams are still caught by the - // caller instead of exhausting memory here. - s.by_ref().take(MAX_STREAM + 1).read_to_end(&mut v).ok()?; - Some(v) +/// Read a document that may be password-protected, preserving the shared reader's diagnostics. +pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result { + let shared = craft_doc_binary::read_with_password(bytes, password)?; + from_shared_model(shared) } #[cfg(test)] diff --git a/crates/docbin/src/list.rs b/crates/docbin/src/list.rs deleted file mode 100644 index 34931f8e4b..0000000000 --- a/crates/docbin/src/list.rs +++ /dev/null @@ -1,302 +0,0 @@ -//! 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 wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, Num, Numbering}; -use wordcraft_doc::props::{Align, CharProps, ParaProps}; -use wordcraft_doc::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/docbin/src/media.rs b/crates/docbin/src/media.rs deleted file mode 100644 index da3656d513..0000000000 --- a/crates/docbin/src/media.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! 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/docbin/src/metadata.rs b/crates/docbin/src/metadata.rs deleted file mode 100644 index 4b9fcf766c..0000000000 --- a/crates/docbin/src/metadata.rs +++ /dev/null @@ -1,193 +0,0 @@ -//! 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 std::io::{Read, Seek}; - -use wordcraft_doc::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: &mut cfb::CompoundFile) -> Option { - let stream = comp.open_stream("/\u{5}SummaryInformation").ok()?; - if stream.len() > MAX_STREAM { - return None; - } - let mut bytes = Vec::new(); - stream.take(MAX_STREAM + 1).read_to_end(&mut bytes).ok()?; - if bytes.len() as u64 > MAX_STREAM { - return None; - } - parse(&bytes) -} - -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 wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(&codepage) { - Some(bytes.iter().map(|b| wordcraft_doc::encoding::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/docbin/src/model_bridge.rs b/crates/docbin/src/model_bridge.rs new file mode 100644 index 0000000000..bd08055223 --- /dev/null +++ b/crates/docbin/src/model_bridge.rs @@ -0,0 +1,230 @@ +//! Bounded conversion from the shared Word model to WordCraft's app model. +//! +//! The model crates intentionally have independent Rust types. Convert document stories a +//! paragraph at a time, recursively through tables, so a large document never needs a second +//! serialized copy of its complete body in memory. Embedded media and opaque streams are moved +//! directly because both models use the same `Arc>` representation. + +use std::sync::Arc; + +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::DocbinError; + +fn convert(value: T) -> Result +where + T: Serialize, + U: DeserializeOwned, +{ + let bytes = serde_json::to_vec(&value).map_err(|error| DocbinError::Malformed(format!("shared model conversion: {error}")))?; + drop(value); + serde_json::from_slice(&bytes).map_err(|error| DocbinError::Malformed(format!("shared model conversion: {error}"))) +} + +fn blocks(source: craft_wordmodel::Blocks) -> Result { + source + .into_iter() + .map(|block| { + let block = Arc::try_unwrap(block).unwrap_or_else(|shared| (*shared).clone()); + block_value(block).map(Arc::new) + }) + .collect() +} + +fn block_value(source: craft_wordmodel::Block) -> Result { + match source { + craft_wordmodel::Block::Para(paragraph) => paragraph_value(paragraph).map(wordcraft_doc::Block::Para), + craft_wordmodel::Block::Table(table) => table_value(table).map(wordcraft_doc::Block::Table), + } +} + +fn paragraph_value(source: craft_wordmodel::para::Paragraph) -> Result { + let craft_wordmodel::para::Paragraph { text, runs, props, mark, objects, section, .. } = source; + let mut paragraph = wordcraft_doc::para::Paragraph::new(); + paragraph.text = text; + paragraph.runs = + runs.into_iter().map(|run| Ok(wordcraft_doc::para::Run { len: run.len, props: convert(run.props)? })).collect::>()?; + paragraph.props = convert(props)?; + paragraph.mark = convert(mark)?; + paragraph.objects = objects.into_iter().map(object_value).collect::>()?; + paragraph.section = section.map(|section| convert(*section).map(Box::new)).transpose()?; + Ok(paragraph) +} + +fn object_value(source: craft_wordmodel::para::InlineObject) -> Result { + use craft_wordmodel::para::InlineObject as Source; + use wordcraft_doc::para::InlineObject as Target; + + match source { + Source::Group { w, h, float, ch_w, ch_h, children, canvas } => Ok(Target::Group { + w, + h, + float: convert(float)?, + ch_w, + ch_h, + children: children.into_iter().map(group_child_value).collect::>()?, + canvas: canvas.map(convert).transpose()?, + }), + other => convert(other), + } +} + +fn group_child_value(source: craft_wordmodel::para::GroupChild) -> Result { + Ok(wordcraft_doc::para::GroupChild { x: source.x, y: source.y, obj: object_value(source.obj)? }) +} + +fn table_value(source: craft_wordmodel::table::Table) -> Result { + Ok(wordcraft_doc::table::Table { + props: convert(source.props)?, + grid: source.grid, + rows: source.rows.into_iter().map(row_value).collect::>()?, + controls: convert(source.controls)?, + }) +} + +fn row_value(source: craft_wordmodel::table::Row) -> Result { + Ok(wordcraft_doc::table::Row { + props: convert(source.props)?, + cells: source.cells.into_iter().map(cell_value).collect::>()?, + controls: convert(source.controls)?, + }) +} + +fn cell_value(source: craft_wordmodel::table::Cell) -> Result { + Ok(wordcraft_doc::table::Cell { props: convert(source.props)?, blocks: blocks(source.blocks)?, controls: convert(source.controls)? }) +} + +fn part_value(source: craft_wordmodel::Part) -> Result { + Ok(wordcraft_doc::Part { kind: convert(source.kind)?, blocks: blocks(source.blocks)?, body: convert(source.body)? }) +} + +fn glossary_value(source: craft_wordmodel::BuildingBlock) -> Result { + Ok(wordcraft_doc::BuildingBlock { + name: source.name, + gallery: source.gallery, + category: source.category, + description: source.description, + guid: source.guid, + style: source.style, + types: source.types, + behaviors: source.behaviors, + blocks: blocks(source.blocks)?, + }) +} + +/// Converts the shared reader's model without copying the full document or its embedded media. +/// +/// The DOC reader leaves WordCraft-only transient fit state, editor fragments, and style change +/// annotations at their defaults; the persistent document fields are transferred here. Body and +/// table-cell stories are mapped one paragraph at a time, while shared media buffers are moved. +pub fn from_shared_model(source: craft_wordmodel::Document) -> Result { + let craft_wordmodel::Document { + body, + last_section, + parts, + styles, + numbering, + comments, + revisions, + settings, + core, + sources, + custom_props, + footnote_separators, + endnote_separators, + media, + passthrough, + glossary, + .. + } = source; + + let mut document = wordcraft_doc::Document::new(); + document.body = blocks(body)?; + document.last_section = convert(last_section)?; + document.parts = parts.into_iter().map(|(id, part)| Ok((id, part_value(part)?))).collect::>()?; + document.styles = Arc::new(convert((*styles).clone())?); + document.numbering = Arc::new(convert((*numbering).clone())?); + document.comments = convert(comments)?; + document.revisions = convert(revisions)?; + document.settings = convert(settings)?; + document.core = convert(core)?; + document.sources = convert(sources)?; + document.custom_props = convert(custom_props)?; + document.footnote_separators = convert(footnote_separators)?; + document.endnote_separators = convert(endnote_separators)?; + document.media = media; + document.passthrough = passthrough; + document.glossary = glossary.into_iter().map(glossary_value).collect::>()?; + Ok(document) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bridges_nested_stories_and_moves_media_without_copying() { + let mut source = craft_wordmodel::Document::new(); + let long_text = "x".repeat(4 << 20); + let mut body_para = craft_wordmodel::para::Paragraph::with_text(&long_text, Default::default()); + body_para.objects.push(craft_wordmodel::para::InlineObject::Group { + w: 32.0, + h: 24.0, + float: Default::default(), + ch_w: 64.0, + ch_h: 48.0, + children: vec![craft_wordmodel::para::GroupChild { + x: 4.0, + y: 6.0, + obj: craft_wordmodel::para::InlineObject::Field { + instr: "PAGE".into(), + result: "7".into(), + locked: true, + dirty: false, + code: false, + form: String::new(), + }, + }], + canvas: None, + }); + let cell = craft_wordmodel::table::Cell::with_text("nested cell"); + let table = craft_wordmodel::table::Table { + rows: vec![craft_wordmodel::table::Row { cells: vec![cell], ..Default::default() }], + ..Default::default() + }; + source.body = vec![Arc::new(craft_wordmodel::Block::Para(body_para)), Arc::new(craft_wordmodel::Block::Table(table))]; + source.parts.insert( + 12, + craft_wordmodel::Part { + kind: craft_wordmodel::PartKind::TextBox, + blocks: vec![craft_wordmodel::para_block(craft_wordmodel::para::Paragraph::with_text("part story", Default::default()))], + ..Default::default() + }, + ); + source.settings.compat_mode = 11; + let image = Arc::new(vec![0x5a; 1 << 20]); + source.media.insert("image1.png".into(), image.clone()); + + let document = from_shared_model(source).expect("model conversion"); + assert_eq!(document.body.len(), 2); + assert_eq!(document.body[0].as_para().map(|p| p.text.as_str()), Some(long_text.as_str())); + let group = match &document.body[0].as_para().expect("paragraph").objects[0] { + wordcraft_doc::para::InlineObject::Group { children, ch_w, .. } => { + assert_eq!(*ch_w, 64.0); + children + } + other => panic!("expected grouped object, got {other:?}"), + }; + assert_eq!((group[0].x, group[0].y), (4.0, 6.0)); + assert!( + matches!(&group[0].obj, wordcraft_doc::para::InlineObject::Field { instr, result, locked: true, .. } if instr == "PAGE" && result == "7") + ); + let table = document.body[1].as_table().expect("table block"); + assert_eq!(table.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("nested cell")); + assert_eq!(document.parts[&12].blocks[0].as_para().map(|p| p.text.as_str()), Some("part story")); + assert_eq!(document.settings.compat_mode, 11); + assert!(Arc::ptr_eq(document.media.get("image1.png").expect("moved media"), &image)); + assert!(document.fit_state.is_none()); + } +} diff --git a/crates/docbin/src/notes.rs b/crates/docbin/src/notes.rs deleted file mode 100644 index dac259099f..0000000000 --- a/crates/docbin/src/notes.rs +++ /dev/null @@ -1,193 +0,0 @@ -//! 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| wordcraft_doc::encoding::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/docbin/src/officeart.rs b/crates/docbin/src/officeart.rs deleted file mode 100644 index fd665874d9..0000000000 --- a/crates/docbin/src/officeart.rs +++ /dev/null @@ -1,552 +0,0 @@ -//! 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 wordcraft_doc::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/docbin/src/piece.rs b/crates/docbin/src/piece.rs deleted file mode 100644 index fda4e5470c..0000000000 --- a/crates/docbin/src/piece.rs +++ /dev/null @@ -1,230 +0,0 @@ -//! The piece table ([MS-DOC] `Clx`/`Pcdt`/`PlcPcd`): where every character position lives in -//! the WordDocument stream, and how it is encoded. - -use wordcraft_doc::encoding::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/docbin/src/sections.rs b/crates/docbin/src/sections.rs deleted file mode 100644 index 209fa5841a..0000000000 --- a/crates/docbin/src/sections.rs +++ /dev/null @@ -1,195 +0,0 @@ -//! 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 wordcraft_doc::section::NumFormat; -use wordcraft_doc::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/docbin/src/sprm.rs b/crates/docbin/src/sprm.rs deleted file mode 100644 index 7db632caff..0000000000 --- a/crates/docbin/src/sprm.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! 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/docbin/src/stsh.rs b/crates/docbin/src/stsh.rs deleted file mode 100644 index 6186ab1f54..0000000000 --- a/crates/docbin/src/stsh.rs +++ /dev/null @@ -1,194 +0,0 @@ -//! 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| wordcraft_doc::encoding::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/docbin/src/table.rs b/crates/docbin/src/table.rs deleted file mode 100644 index 400c8715c7..0000000000 --- a/crates/docbin/src/table.rs +++ /dev/null @@ -1,519 +0,0 @@ -//! 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 wordcraft_doc::Rgb; -use wordcraft_doc::para::Paragraph; -use wordcraft_doc::props::{Align, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge}; -use wordcraft_doc::table::{Cell, Row, Table}; -use wordcraft_doc::{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/docbin/src/tests.rs b/crates/docbin/src/tests.rs index 863801463f..9dd08a085b 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -3,13 +3,7 @@ use std::io::Write; -use proptest::prelude::*; - -use super::read; - -mod drawing; -mod encrypted; -mod metadata; +use super::{DocbinError, is_encrypted, read, read_with_password}; /// Offset in the WordDocument stream where test text pieces are placed (past the FIB). const TEXT_FC: u32 = 0x600; @@ -146,2948 +140,31 @@ fn text_doc_with(main: &[Piece], extra: &[Piece], spec: FibSpec, table_data: &[( } fn body_texts(doc: &wordcraft_doc::Document) -> Vec { - doc.body.iter().filter_map(|b| b.as_para().map(|p| p.text.clone())).collect() + doc.body.iter().filter_map(|block| block.as_para().map(|paragraph| paragraph.text.clone())).collect() +} + +fn assert_body_texts(doc: &wordcraft_doc::Document, expected: &[&str]) { + assert_eq!(body_texts(doc), expected.iter().map(|text| String::from(*text)).collect::>()); } #[test] -fn not_a_compound_file() { - assert!(matches!(read(&[0u8; 64]), Err(super::DocbinError::Container(_)))); +fn rejects_non_compound_input_with_the_shared_error_category() { + assert!(matches!(read(&[0u8; 64]), Err(DocbinError::Container(_)))); + assert!(!is_encrypted(&[0u8; 64])); } #[test] -fn cfb_without_word_stream() { - let f = cfb_file(&[("0Table", &[0u8; 16])]); - assert!(matches!(read(&f), Err(super::DocbinError::NotWord(_)))); -} - -#[test] -fn word_stream_too_short() { - let f = cfb_file(&[("WordDocument", &[0u8; 16])]); - assert!(matches!(read(&f), Err(super::DocbinError::NotWord(_)))); -} - -#[test] -fn wrong_magic_rejected() { - let mut fib = fib_bytes(&FibSpec::default()); - fib[0] = 0x34; - fib[1] = 0x12; - let f = cfb_file(&[("WordDocument", &fib)]); - assert!(matches!(read(&f), Err(super::DocbinError::NotWord(m)) if m.contains("magic"))); -} - -#[test] -fn word_6_95_rejected() { - let mut fib = fib_bytes(&FibSpec::default()); - fib[0] = 0xDC; - fib[1] = 0xA5; - let f = cfb_file(&[("WordDocument", &fib)]); - assert!(matches!(read(&f), Err(super::DocbinError::NotWord(m)) if m.contains("6.0/95"))); -} - -#[test] -fn encrypted_asks_for_a_password() { - let spec = FibSpec { flags: 0x1100, ..Default::default() }; - let f = cfb_file(&[("WordDocument", &fib_bytes(&spec))]); - assert!(matches!(read(&f), Err(super::DocbinError::PasswordRequired))); -} - -#[test] -fn wrong_csw_rejected() { - let mut fib = fib_bytes(&FibSpec::default()); - fib[0x20] = 0x0D; - fib[0x21] = 0x00; - let f = cfb_file(&[("WordDocument", &fib)]); - assert!(matches!(read(&f), Err(super::DocbinError::NotWord(m)) if m.contains("csw"))); -} - -#[test] -fn missing_clx_rejected() { - let spec = FibSpec { ccp_text: 3, ..Default::default() }; - let f = cfb_file(&[("WordDocument", &fib_bytes(&spec)), ("0Table", &[0u8; 16])]); - assert!(matches!(read(&f), Err(super::DocbinError::Malformed(m)) if m.contains("piece table"))); -} - -#[test] -fn unicode_text_extracted() { - let f = text_doc(&[Piece { compressed: false, text: "Héllo Wörld\rSecond€\r".into() }], &[], 0); - let doc = read(&f).expect("opens"); - assert_eq!(body_texts(&doc), ["Héllo Wörld", "Second€"]); -} - -#[test] -fn compressed_cp1252_text_extracted() { - let f = text_doc(&[Piece { compressed: true, text: "caf\u{E9} 100\u{BD}\r".into() }], &[], 0); - let doc = read(&f).expect("opens"); - assert_eq!(body_texts(&doc), ["café 100½"]); -} - -#[test] -fn multiple_pieces_and_encodings() { - let f = text_doc( - &[ - Piece { compressed: false, text: "plain ".into() }, - Piece { compressed: true, text: "mixed\u{E9} ".into() }, - Piece { compressed: false, text: "end\r".into() }, - ], - &[], - 0, - ); - let doc = read(&f).expect("opens"); - assert_eq!(body_texts(&doc), ["plain mixedé end"]); -} - -#[test] -fn special_characters_mapped() { - let raw = "a\tb\u{B}c\u{C}d\u{E}e\u{1E}f\u{1F}g\r\u{13}field\u{14}result\u{15}\r"; - let f = text_doc(&[Piece { compressed: false, text: raw.into() }], &[], 0); - let doc = read(&f).expect("opens"); - // The field collapses to one anchor object; its instruction is hidden and its cached - // result kept on the object. - assert_eq!(body_texts(&doc), ["a\tb\nc\u{C}d\u{E}e\u{2011}f\u{AD}g", "\u{FFFC}"]); - let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("field para"); - assert!( - matches!(p1.objects.first(), Some(wordcraft_doc::para::InlineObject::Field { instr, result, .. }) if instr == "field" && result == "result"), - "objects: {:?}", - p1.objects - ); -} - -#[test] -fn empty_paragraphs_kept() { - let f = text_doc(&[Piece { compressed: false, text: "a\r\rb\r".into() }], &[], 0); - let doc = read(&f).expect("opens"); - assert_eq!(body_texts(&doc), ["a", "", "b"]); -} - -/// A document of `n` copies of `mark` as one compressed piece. -fn marks_doc(mark: char, n: usize) -> Vec { - 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: &wordcraft_doc::Document) -> wordcraft_doc::Paragraph { - doc.body.first().and_then(|b| b.as_para().cloned()).unwrap_or_default() -} - -#[test] -fn character_formatting_applied() { - let spec = RichSpec { - text: "plain bold red\r", - styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())], - fonts: vec!["Times New Roman", "Arial"], - chpx_runs: vec![ - (0, Vec::new()), - (6, grpprl(&[(0x0835, &[0x01])])), // bold - (11, grpprl(&[(0x0835, &[0x01]), (0x2A42, &[0x06])])), // bold + red (ico 6) - ], - papx_entries: vec![(0, 0, Vec::new())], - }; - let doc = read(&rich_doc(&spec)).expect("opens"); - let p = para0(&doc); - assert_eq!(p.text, "plain bold red"); - // runs: plain(0..6) bold(6..11) bold+red(11..16) - assert!(p.runs.get(1).is_some_and(|r| r.props.bold == Some(true)), "run 1 bold: {:?}", p.runs); - assert!(p.runs.get(2).is_some_and(|r| r.props.bold == Some(true)), "run 2 bold+red: {:?}", p.runs); - assert!(p.runs.get(2).is_some_and(|r| matches!(r.props.color, Some(wordcraft_doc::props::TextColor::Rgb(_)))), "run 2 red"); - assert!(p.runs.first().is_some_and(|r| r.props.bold.is_none())); -} - -#[test] -fn font_and_size_from_style_and_direct() { - let spec = RichSpec { - text: "Hello\r", - styles: vec![ - (1, 0x0FFF, "Normal", Vec::new(), grpprl(&[(0x4A43, &24u16.to_le_bytes())])), // 12pt - (2, 0x0FFF, "Emphasis", Vec::new(), grpprl(&[(0x4A4F, &1u16.to_le_bytes())])), // Arial - ], - fonts: vec!["Times New Roman", "Arial"], - chpx_runs: vec![(0, grpprl(&[(0x4A4F, &1u16.to_le_bytes())]))], - papx_entries: vec![(0, 0, Vec::new())], - }; - let doc = read(&rich_doc(&spec)).expect("opens"); - assert!(doc.styles.styles.iter().any(|s| s.name == "Normal" && s.chr.size == Some(12.0)), "Normal 12pt"); - let p = para0(&doc); - assert!(p.runs.first().is_some_and(|r| r.props.font.as_deref() == Some("Arial")), "run font: {:?}", p.runs); -} - -/// #5066: the default font is the stylesheet's standard font (Aptos in a Word 365 file), not -/// the font table's first entry (still Times New Roman there); the East Asian and complex-script -/// standard fonts are the defaults for those scripts, and runs name theirs with sprmCRgFtc1 and -/// sprmCFtcBi. -#[test] -fn the_default_font_is_the_stylesheets_standard_font() { - let spec = |runs: Vec<(u32, Vec)>| 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(wordcraft_doc::props::Align::Center)); - let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); - assert_eq!(p0.props.style.as_deref(), Some("Heading 1")); - assert_eq!(heading.para.outline_level, Some(0)); - let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("para"); - assert_eq!(p1.props.indent_left, Some(36.0)); // 720 twips = 36pt - assert_eq!(p1.props.style.as_deref(), Some("Normal")); -} - -/// #534: Chinese theses set a first-line indent of two characters (`sprmPDxaLeft1`, whose operand -/// is two bytes, plus `sprmPDxcLeft1` in hundredths of a character), and exact line spacing as a -/// negative LSPD `dyaLine`. Both were lost: the indent read as four bytes, and exact 20pt as -/// 799pt, a page per empty paragraph. -#[test] -fn first_line_indent_chars_and_exact_line_spacing() { - let lspd = |dya: i16, mult: u16| [dya.to_le_bytes(), mult.to_le_bytes()].concat(); - let (exact, multiple) = (lspd(-400, 0), lspd(360, 1)); - let spec = RichSpec { - text: "indented\rexact\rold\r", - styles: vec![(1, 0, "Normal", Vec::new(), Vec::new())], - fonts: vec!["Times New Roman"], - chpx_runs: vec![(0, Vec::new())], - papx_entries: vec![ - (0, 0, grpprl(&[(0x8460, &480i16.to_le_bytes()), (0x4457, &200i16.to_le_bytes()), (0x6412, &multiple)])), - (9, 0, grpprl(&[(0x6412, &exact)])), - (15, 0, grpprl(&[(0x8411, &(-360i16).to_le_bytes())])), - ], - }; - let doc = read(&rich_doc(&spec)).expect("opens"); - let para = |i: usize| doc.body.get(i).and_then(|b| b.as_para()).expect("para").props.clone(); - let p0 = para(0); - assert_eq!((p0.indent_first, p0.indent_first_chars), (Some(24.0), Some(2.0))); - assert_eq!(p0.line_spacing, Some(wordcraft_doc::props::LineSpacing::Multiple(1.5))); - assert_eq!(para(1).line_spacing, Some(wordcraft_doc::props::LineSpacing::Exactly(20.0))); - assert_eq!(para(2).indent_first, Some(-18.0)); // sprmPDxaLeft1_80: a hanging indent -} - -/// Local corpus (gitignored `plan/word/fixtures`): real files must open with text and styles. -#[test] -fn corpus_files_open() { - let Some(dir) = std::env::var_os("WORDCRAFT_DOCBIN_FIXTURES") else { return }; - let mut checked = 0; - if let Ok(entries) = std::fs::read_dir(&dir) { - for e in entries.flatten() { - let p = e.path(); - if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("doc")) { - let bytes = std::fs::read(&p).expect("read"); - let doc = match super::read(&bytes) { - Ok(d) => d, - // Word 6/95 files are a documented, intentional rejection. - Err(super::DocbinError::NotWord(m)) if m.contains("6.0/95") => continue, - Err(e) => panic!("{}: {e}", p.display()), - }; - assert!(!doc.styles.styles.is_empty(), "{}: no styles", p.display()); - let text = doc.plain_text(wordcraft_doc::StoryRef::Body); - assert!(text.chars().count() > 3, "{}: no text", p.display()); - checked += 1; - } - } - } - assert!(checked > 0, "no .doc files found in {}", dir.display()); -} - -// --------------------------------------------------------------------------------------------- -// D3: sections, headers/footers, tables. - -/// RichSpec extended for the D3 fixtures. -struct StructSpec { - text: &'static str, - /// Extra pieces after the main text: header subdocument stories. - header_stories: Vec<&'static str>, - /// (end_cp, sepx grpprl) per section; the last one reaching the text end is the final one. - sections: Vec<(u32, Vec)>, - /// 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), +fn reads_compressed_and_utf16_document_text() { + let fixtures = [ + (Piece { compressed: true, text: "Hello\rworld".into() }, vec!["Hello", "world"]), + (Piece { compressed: true, text: "Résumé".into() }, vec!["Résumé"]), + (Piece { compressed: false, text: "日本語".into() }, vec!["日本語"]), ]; - 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)]) + for (piece, expected) in fixtures { + let input = text_doc(std::slice::from_ref(&piece), &[], 0); + let document = read(&input).expect("shared DOC reader"); + assert_body_texts(&document, &expected); + assert!(!is_encrypted(&input)); + assert_body_texts(&read_with_password(&input, Some("ignored")).expect("unprotected password ignored"), &expected); } } - -#[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(wordcraft_doc::Rgb(255, 255, 0))); - assert_eq!(tbl.rows[1].cells[0].props.vmerge, wordcraft_doc::props::VMerge::Restart); - assert_eq!(tbl.rows[1].props.height, Some(20.0)); - // Table borders from the first row that carries them: single 0.5pt black. - let b = tbl.props.borders.expect("borders"); - let top = b.top.expect("top border"); - assert_eq!(top.style, wordcraft_doc::props::BorderStyle::Single); - assert_eq!(top.width, 0.5); -} - -/// Table borders read the BrcType byte as paragraph borders do; a nil Brc80 is no border. -#[test] -fn table_borders_read_border_types_as_paragraphs_do() { - use wordcraft_doc::props::BorderStyle as S; - let text = "a\u{7}\u{7}tail\r"; - let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]); - let row_end = { - let mut v = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), t_def_edges(&[0, 2880])].concat(); - v.extend_from_slice(&0xD605u16.to_le_bytes()); - v.push(0x18); - // Dotted, triple, double wave, nil, 3-D emboss 255/8 pt wide, all zero. - for brc in [[8, 0x06, 1, 0], [8, 0x0A, 1, 0], [8, 0x15, 1, 0], [0xFF; 4], [0xFF, 0x18, 1, 0], [0, 0, 0, 0]] { - v.extend_from_slice(&brc); - } - v - }; - let spec = StructSpec { - text, - header_stories: vec![], - sections: vec![(text.chars().count() as u32, Vec::new())], - chpx_runs: vec![(0, Vec::new())], - papx_entries: vec![(0, 0, in_tbl), (2, 0, row_end), (3, 0, grpprl(&[(0x6424, &[0xFF, 0x01, 1, 0])]))], - plf_lst: Vec::new(), - plf_lfo: Vec::new(), - note_stories: vec![], - endnote_stories: vec![], - extra_pairs: Vec::new(), - data_stream: Vec::new(), - }; - let doc = read(&struct_doc(&spec)).expect("opens"); - let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table"); - let b = tbl.props.borders.expect("table borders"); - let seen = [b.top, b.left, b.bottom, b.right, b.between, b.inside_v].map(|s| s.map(|s| (s.style, s.is_visible(), s.width))); - let want = [ - (S::Dotted, true, 1.0), - (S::Triple, true, 1.0), - (S::Wave, true, 1.0), - (S::None, false, 0.0), - (S::Single, true, 12.0), - (S::None, false, 0.25), - ]; - assert_eq!(seen, want.map(Some)); - // A paragraph border is at most 12 points too, as a table border and a DOCX `w:sz` are. - let tail = doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.text == "tail").expect("tail paragraph"); - assert_eq!(tail.props.borders.and_then(|b| b.top).map(|t| t.width), Some(12.0)); -} - -// --------------------------------------------------------------------------------------------- -// D3: lists (PlfLst + PlfLfo) and the combined document. - -/// One LVL: number text given as chars (a `0`-valued char at placeholder position `ph` is -/// the level placeholder), with a 720-twip left indent and a 360-twip hanging first line. -fn lvl(start: u32, nfc: u8, chars: &[u16], ph: bool) -> Vec { - 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, wordcraft_doc::section::NumFormat::Decimal); - assert_eq!(l0.text, "%1."); - assert_eq!(l0.indent, 36.0); - assert_eq!(l0.hanging, 18.0); - assert_eq!(l0.suffix, wordcraft_doc::numbering::LevelSuffix::Space); - // Paragraphs 1-2 are in the list, paragraph 3 is not. - let n = |i: usize| doc.body.get(i).and_then(|b| b.as_para()).and_then(|p| p.props.numbering); - assert_eq!(n(0), Some(wordcraft_doc::props::NumRef { num: 1, level: 0 })); - assert_eq!(n(1), Some(wordcraft_doc::props::NumRef { num: 1, level: 0 })); - assert_eq!(n(2), None); - // The numbering produces labels through the shared counter. - let mut c = wordcraft_doc::numbering::Counters::default(); - assert_eq!(c.next_label(&doc.numbering, 1, 0).unwrap().0, "1."); - assert_eq!(c.next_label(&doc.numbering, 1, 0).unwrap().0, "2."); -} - -/// The combined D3 fixture: intro paragraph, table, list, two sections, header story. -fn structured_doc() -> Vec { - 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(wordcraft_doc::StoryRef::Body).split('\n').map(str::to_string).collect(); - assert!(texts.contains(&"first".to_string()) && texts.contains(&"second".to_string()), "list text present: {texts:?}"); - // Section 1 is attached to the intro paragraph and owns the header story; the final - // section is landscape. - let sec1 = doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.section.clone()).expect("section on intro"); - let h = sec1.headers.default.expect("header part"); - assert!(doc.parts.get(&h).is_some_and(|p| p.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Running head"))))); - assert_eq!(doc.last_section.page_w, 792.0); - assert_eq!(doc.last_section.page_h, 612.0); - assert!(doc.last_section.landscape); - // The whole document round-trips through JSON (the wcraft.json shape). - let json = serde_json::to_value(&doc).expect("serialises"); - assert!(json.get("body").is_some_and(|b| b.as_array().is_some_and(|a| !a.is_empty()))); - assert!(json.get("numbering").is_some_and(|n| n.get("nums").is_some_and(|x| x.as_array().is_some_and(|a| !a.is_empty())))); - assert!(json.get("lastSection").is_some_and(|s| s.get("landscape") == Some(&serde_json::json!(true)))); -} - -// --------------------------------------------------------------------------------------------- -// D4: notes, fields, bookmarks, images. - -/// A PLC: aCP[n+1] u32s followed by `data` bytes per element. -fn plc(cps: &[u32], data: &[u8]) -> Vec { - 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 wordcraft_doc::para::InlineObject::NoteRef { kind, id, .. } = note else { panic!("not a note: {note:?}") }; - assert_eq!(*kind, wordcraft_doc::para::NoteKind::Footnote); - let part = doc.parts.get(id).expect("note part"); - assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Note body"))), "note text: {:?}", part.blocks); - assert_eq!(part.kind, wordcraft_doc::PartKind::Footnote); -} - -#[test] -fn endnote_reference_links_story_part() { - let text = "Ref\u{2}\r"; - let ccp = text.chars().count() as u32; - let spec = StructSpec { - text, - // A footnote story keeps the endnote subdocument at a non-zero offset. - note_stories: vec!["Ftn\r"], - endnote_stories: vec!["Endnote body\r"], - extra_pairs: vec![ - (46, plc(&[3, ccp], &1u16.to_le_bytes())), - (47, plc(&[0, 13], &[])), // "Endnote body\r" - ], - ..structured_spec(text, ccp) - }; - let doc = read(&struct_doc(&spec)).expect("opens"); - let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); - let wordcraft_doc::para::InlineObject::NoteRef { kind, id, .. } = p0.objects.first().expect("note ref object") else { panic!("no note ref") }; - assert_eq!(*kind, wordcraft_doc::para::NoteKind::Endnote); - let part = doc.parts.get(id).expect("endnote part"); - assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Endnote body")))); -} - -#[test] -fn fields_and_hyperlinks() { - // A HYPERLINK field (result kept as linked text) and a PAGE field (inline object). - let text = "Go \u{13}HYPERLINK http://x\u{14}site\u{15} n\u{13}PAGE\u{14}3\u{15}.\r"; - let ccp = text.chars().count() as u32; - let spec = structured_spec(text, ccp); - let doc = read(&struct_doc(&spec)).expect("opens"); - let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); - assert_eq!(p0.text, "Go site n\u{FFFC}."); - // The hyperlink result run carries the target. - let linked = p0.runs.iter().find(|r| r.props.link.is_some()).expect("linked run"); - assert_eq!(linked.props.link.as_deref(), Some("http://x")); - // The PAGE field became one object with its cached result. - let wordcraft_doc::para::InlineObject::Field { instr, result, .. } = p0.objects.first().expect("field object") else { - panic!("no field: {:?}", p0.objects) - }; - assert_eq!(instr, "PAGE"); - assert_eq!(result, "3"); -} - -#[test] -fn hyperlink_target_sublocation_and_quotes() { - assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com" \l "heading""#), Some("http://example.com#heading".into())); - assert_eq!(super::hyperlink_target(r#"HYPERLINK \l "heading""#), Some("#heading".into())); - assert_eq!(super::hyperlink_target(r#"Hyperlink "http://example.com/a path""#), Some("http://example.com/a path".into())); - assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com" \o "Help text""#), Some("http://example.com".into())); - assert_eq!(super::hyperlink_target(r#"HYPERLINK \l "bm" \o "tip""#), Some("#bm".into())); -} - -/// A field whose name only starts with HYPERLINK is not a hyperlink. -#[test] -fn hyperlink_target_needs_the_whole_field_name() { - assert_eq!(super::hyperlink_target(r#"HYPERLINKS "x""#), None); - assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com""#), Some("http://example.com".into())); -} - -/// A bare `\m` or `\n` keeps the address after it; escapes, an empty address and `%22` read as in DOCX. -#[test] -fn hyperlink_target_keeps_the_address_after_a_bare_switch() { - assert_eq!(super::hyperlink_target(r#"HYPERLINK \m "http://example.com""#), Some("http://example.com".into())); - assert_eq!(super::hyperlink_target(r#"HYPERLINK \n "http://example.com""#), Some("http://example.com".into())); - assert_eq!(super::hyperlink_target(r#"HYPERLINK "C:\\Docs\\f.doc""#), Some(r"C:\Docs\f.doc".into())); - assert_eq!(super::hyperlink_target(r#"HYPERLINK "" \l "bm""#), Some("#bm".into())); - assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com/a%22b""#), Some("http://example.com/a%22b".into())); -} - -#[test] -fn bookmarks_become_objects() { - let text = "Hello world\r"; - let ccp = text.chars().count() as u32; - let spec = StructSpec { - text, - extra_pairs: vec![(21, sttbf_bkmk(&["bm1"])), (22, plc(&[0, ccp], &[0, 0, 0, 0])), (23, plc(&[5, ccp], &[0, 0]))], - ..structured_spec(text, ccp) - }; - let doc = read(&struct_doc(&spec)).expect("opens"); - let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); - // The start anchor sits before the text, the end after "Hello". - assert!( - matches!(p0.objects.first(), Some(wordcraft_doc::para::InlineObject::BookmarkStart { name }) if name == "bm1"), - "objects: {:?}", - p0.objects - ); - assert!(matches!(p0.objects.get(1), Some(wordcraft_doc::para::InlineObject::BookmarkEnd { name }) if name == "bm1"), "objects: {:?}", p0.objects); - assert_eq!(p0.text, "\u{FFFC}Hello\u{FFFC} world"); -} - -#[test] -fn inline_png_picture() { - let text = "Pic\u{1}!\r"; - let ccp = text.chars().count() as u32; - // A PICF header (68 bytes) with a 1×0.5-inch goal size, then PNG-ish bytes. - let png: Vec = [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 wordcraft_doc::para::InlineObject::Image { media, w, h, .. } = p0.objects.first().expect("image object") else { - panic!("no image: {:?}", p0.objects) - }; - assert_eq!((*w, *h), (72.0, 36.0)); // 1440 × 720 twips - let bytes = doc.media.get(media).expect("media entry"); - assert_eq!(bytes.as_slice(), png.as_slice()); -} - -/// The D4 hostile-input fixture: notes, a field, a bookmark and a picture in one file. -fn extras_doc() -> Vec { - 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: &wordcraft_doc::Document) -> String { - let mut s: String = body_texts(doc).join("\n"); - for part in doc.parts.values() { - for b in &part.blocks { - if let Some(p) = b.as_para() { - s.push('\n'); - s.push_str(&p.text); - } - } - } - s -} - -#[test] -fn huge_story_ranges_are_clamped_to_their_subdocument() { - // Footnote, header and endnote stories whose Table-stream CPs claim [0, 4e9) (and - // ranges near u32::MAX that overflowed `base + cp`): before the clamp, each pushed - // billions of U+FFFD (or panicked on the addition). - let text = "A\u{2}\u{2}\r"; - let ccp = text.chars().count() as u32; - let big = 4_000_000_000u32; - let hdd = plc(&[0, 0, 0, 0, 0, 0, 0, 0, big, 0xFFFF_FFF0, 0xFFFF_FFFF, 0xFFFF_FFFF, 0xFFFF_FFFF], &[]); - let spec = StructSpec { - text, - note_stories: vec!["Note body\r"], - header_stories: vec!["Head\r"], - endnote_stories: vec!["End\r"], - extra_pairs: vec![ - (2, plc(&[1, ccp], &1u16.to_le_bytes())), - (3, plc(&[0, big], &[])), - (11, hdd), - (46, plc(&[2, ccp], &1u16.to_le_bytes())), - (47, plc(&[0xFFFF_FFF0, 0xFFFF_FFFF], &[])), - ], - ..structured_spec(text, ccp) - }; - let doc = read(&struct_doc(&spec)).expect("opens"); - let all = all_text(&doc); - assert!(!all.contains('\u{FFFD}'), "replacement characters: {} bytes of text", all.len()); - assert!(all.len() < 200, "text grew to {} bytes", all.len()); - let header = doc.last_section.headers.default.and_then(|id| doc.parts.get(&id)).expect("header part"); - assert!(header.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text == "Head")), "header: {:?}", header.blocks); - let footnote = doc.parts.values().find(|p| p.kind == wordcraft_doc::PartKind::Footnote).expect("footnote part"); - assert!(footnote.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text == "Note body"))); - // The endnote story starts past the endnote subdocument: nothing to read. - assert!(!doc.parts.values().any(|p| p.kind == wordcraft_doc::PartKind::Endnote)); -} - -/// The text of each note a paragraph references, in order. -fn note_texts(doc: &wordcraft_doc::Document, p: &wordcraft_doc::Paragraph) -> Vec { - p.objects - .iter() - .filter_map(|o| match o { - wordcraft_doc::para::InlineObject::NoteRef { id, .. } => doc.parts.get(id), - _ => None, - }) - .map(|part| part.blocks.iter().filter_map(|b| b.as_para()).map(|para| para.text.as_str()).collect::>().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 == wordcraft_doc::PartKind::Footnote).count(), 1); - let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); - assert_eq!(note_texts(&doc, p0), vec!["Note body".to_string()]); -} - -#[test] -fn note_stories_stepping_back_are_dropped_and_ordered_ones_kept() { - // Story CPs [0, 5, 10, 3, 10] for four footnotes and four endnotes: the first two - // stories are in order and read (the control); the third runs backwards and the fourth - // starts inside the first, so the third and fourth references get no note. - let text = "a\u{2}b\u{2}c\u{2}d\u{2}e\u{2}f\u{2}g\u{2}h\u{2}\r"; - let ccp = text.chars().count() as u32; - let stories = plc(&[0, 5, 10, 3, 10], &[]); - let spec = StructSpec { - text, - note_stories: vec!["Aaaa\rBbbb\r"], - endnote_stories: vec!["Cccc\rDddd\r"], - extra_pairs: vec![ - (2, plc(&[1, 3, 5, 7, ccp], &1u16.to_le_bytes().repeat(4))), - (3, stories.clone()), - (46, plc(&[9, 11, 13, 15, ccp], &1u16.to_le_bytes().repeat(4))), - (47, stories), - ], - ..structured_spec(text, ccp) - }; - let doc = read(&struct_doc(&spec)).expect("opens"); - let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); - assert_eq!(note_texts(&doc, p0), ["Aaaa", "Bbbb", "Cccc", "Dddd"].map(String::from)); - assert_eq!(doc.parts.len(), 4); -} - -#[test] -fn header_stories_pointing_at_the_same_text_are_read_once() { - // Two sections whose odd headers both claim the header subdocument's [0, 5) (Plcfhdd - // steps back to CP 0 inside the second section's slots): the second used to become a - // second copy of the text. Its slot is now empty, which links it to the first section's. - let text = "a\rb\r"; - let ccp = text.chars().count() as u32; - // Six empty separator stories, then each section's six slots; the odd header is the - // second slot. - let hdd = plc(&[0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 5, 5, 5, 0, 5, 5, 5, 5, 5], &[]); - let spec = StructSpec { - text, - header_stories: vec!["Head\r"], - sections: vec![(2, Vec::new()), (ccp, Vec::new())], - papx_entries: vec![(0, 0, Vec::new()), (2, 0, Vec::new())], - extra_pairs: vec![(11, hdd)], - ..structured_spec(text, ccp) - }; - let doc = read(&struct_doc(&spec)).expect("opens"); - assert_eq!(doc.parts.values().filter(|p| p.kind == wordcraft_doc::PartKind::Header).count(), 1); - let first = doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.section.as_ref()).expect("first section"); - let header = first.headers.default.and_then(|id| doc.parts.get(&id)).expect("first section's header"); - assert!(header.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text == "Head")), "header: {:?}", header.blocks); - assert_eq!(doc.last_section.headers.default, None); -} - -/// A document whose piece table is given raw (cps + one Pcd per piece), with `text` bytes -/// at TEXT_FC and the FIB claiming `ccp_text` main-document characters. -fn raw_piece_doc(cps: &[u32], fcs: &[u32], text: &[u8], ccp_text: u32) -> Vec { - 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, wordcraft_doc::para::InlineObject::Image { media, .. } if media == "image1.png"))); -} - -#[test] -fn picture_bytes_are_capped_per_document() { - let a = picf_with_png(600); - let b = picf_with_png(600); - let data: Vec = [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 wordcraft_doc::section::SectionStart; - let kinds = [SectionStart::Continuous, SectionStart::NextColumn, SectionStart::NextPage, SectionStart::EvenPage, SectionStart::OddPage]; - for (bkc, want) in kinds.into_iter().enumerate() { - let spec = one_section("Text\r", grpprl(&[(0x3009, &[bkc as u8])]), vec![(0, 0, Vec::new())]); - let doc = read(&struct_doc(&spec)).expect("opens"); - assert_eq!(doc.last_section.start, want, "sprmSBkc {bkc}"); - } -} - -#[test] -fn explicit_section_orientation_imported() { - // #460. SBOrientationOperand ([MS-DOC] §2.9.236): 1 portrait, 2 landscape. - let page = |w: u16, h: u16, orient: Option| { - 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(wordcraft_doc::props::NumRef { num: 1, level: 0 }); - assert_eq!(para(0), (one, Some(36.0)), "0x0001"); - assert_eq!(para(1), (one, Some(36.0)), "0xFFFF = -1"); - assert_eq!(para(2).0, None, "0"); - assert_eq!(para(3).0, None, "0xF801"); -} - -#[test] -fn style_inherits_its_declared_base() { - // #462. StdfBase ([MS-DOC] §2.9.260): stk + istdBase share the second word, cupx + - // istdNext the third; istdBase is zero-based. The next style must not affect the base. - for next in [0u16, 1, 2, 0x0FFF] { - let spec = RichSpec { - text: "Text\r", - styles: vec![ - (1, 0x0FFF, "Normal", Vec::new(), grpprl(&[(0x0835, &[0x01])])), // bold - (1, 0, "Derived", Vec::new(), Vec::new()), - (1, 1, "Grandchild", Vec::new(), grpprl(&[(0x0836, &[0x01])])), // italic - // A corrupt cycle must neither hang nor crash. - (1, 4, "Loop A", Vec::new(), Vec::new()), - (1, 3, "Loop B", Vec::new(), Vec::new()), - ], - fonts: vec!["Times New Roman"], - chpx_runs: vec![(0, Vec::new())], - papx_entries: vec![(0, 1, Vec::new())], - }; - let extra = RichExtra { style_next: vec![0x0FFF, next, 0x0FFF, 0x0FFF, 0x0FFF], ..Default::default() }; - let doc = read(&rich_doc_with(&spec, &extra)).expect("opens"); - let style = |name: &str| doc.styles.styles.iter().find(|s| s.name == name).cloned().expect("style"); - let derived = style("Derived"); - assert_eq!(derived.based_on.as_deref(), Some("Normal"), "next {next}"); - assert_eq!(derived.chr.bold, Some(true), "next {next}"); - let grand = style("Grandchild"); - assert_eq!(grand.based_on.as_deref(), Some("Derived")); - assert_eq!((grand.chr.bold, grand.chr.italic), (Some(true), Some(true)), "two-level chain"); - assert_eq!(style("Normal").based_on, None); - assert_eq!(para0(&doc).props.style.as_deref(), Some("Derived")); - } -} - -#[test] -fn alternate_font_name_keeps_later_entries() { - // #463. An STTB entry's length is its cchData ([MS-DOC] §2.2.4), which covers the FFN's - // optional alternate name (§2.9.82 xszAlt); the next entry starts after it. - for alt in [None, Some("ArialMT")] { - let spec = RichSpec { - text: "ab\r", - styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())], - fonts: vec!["Arial", "Courier New"], - chpx_runs: vec![(0, grpprl(&[(0x4A4F, &0u16.to_le_bytes())])), (1, grpprl(&[(0x4A4F, &1u16.to_le_bytes())]))], - papx_entries: vec![(0, 0, Vec::new())], - }; - let doc = read(&rich_doc_with(&spec, &RichExtra { alt_fonts: vec![alt, None], ..Default::default() })).expect("opens"); - let fonts: Vec> = 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 wordcraft_doc::props::{TabAlign, TabLeader}; - // Normal: centre at 3.25" (4680 tw), right at 6.5" (9360 tw) with a dot leader (tlc 1). - let normal = grpprl(&[(0xC60D, &tabs_papx(&[], &[(4680, 1), (9360, 2 | 1 << 3)]))]); - // The paragraph drops the centre stop and adds a left stop at 0.5" and a bar at 1". - let direct = grpprl(&[(0xC60D, &tabs_papx(&[4680], &[(720, 0), (1440, 4)]))]); - // The second paragraph uses the older sprmPChgTabs: removals carry a "close" distance. - let mut old = vec![0u8, 1]; - old.extend_from_slice(&9360i16.to_le_bytes()); - old.extend_from_slice(&25i16.to_le_bytes()); - old.push(1); - old.extend_from_slice(&2880i16.to_le_bytes()); - old.push(3 | 5 << 3); // decimal, middle dot - old[0] = (old.len() - 1) as u8; - let spec = RichSpec { - text: "\tA\tB\r\tC\r", - styles: vec![(1, 0x0FFF, "Normal", normal, Vec::new())], - fonts: vec!["Calibri"], - chpx_runs: vec![(0, Vec::new())], - papx_entries: vec![(0, 0, direct), (5, 0, grpprl(&[(0xC615, &old)]))], - }; - let doc = read(&rich_doc(&spec)).expect("opens"); - let tabs = |i: usize| { - let p = doc.body.get(i).and_then(|b| b.as_para()).expect("para"); - doc.styles.resolve_para(&p.props).tabs.iter().map(|t| (t.pos, t.align, t.leader)).collect::>() - }; - assert_eq!( - tabs(0), - [(36.0, TabAlign::Left, TabLeader::None), (72.0, TabAlign::Bar, TabLeader::None), (468.0, TabAlign::Right, TabLeader::Dot)], - "the style's stops, less the removed one, plus the paragraph's" - ); - assert_eq!(tabs(1), [(144.0, TabAlign::Decimal, TabLeader::MiddleDot), (234.0, TabAlign::Center, TabLeader::None)], "sprmPChgTabs"); - assert_eq!(doc.styles.styles.iter().find(|s| s.name == "Normal").and_then(|s| s.para.tabs.as_ref()).map(Vec::len), Some(2)); -} - -/// Hostile tab operands (counts past the end, more than 64 stops, cb 255) never panic and -/// never swallow the sprms after them. -#[test] -fn hostile_tab_operands_are_safe() { - for operand in [vec![2u8, 200, 9], vec![0], vec![255, 1, 0x10, 0x00], vec![255, 3], vec![5, 0, 70, 0, 1, 2]] { - let papx = grpprl(&[(0xC615, &operand), (0x2403, &[1])]); - let spec = RichSpec { - text: "x\r", - styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())], - fonts: vec!["Calibri"], - chpx_runs: vec![(0, Vec::new())], - papx_entries: vec![(0, 0, papx)], - }; - let _ = read(&rich_doc(&spec)); - } - let mut iter = crate::sprm::iter(&[0x15, 0xC6, 3, 0, 0, 0, 0x03, 0x24, 1]); - assert_eq!(iter.next().map(|p| (p.op, p.operand.len())), Some((0xC615, 4)), "cb is one byte: the operand is cb + 1 bytes"); - assert_eq!(iter.next().map(|p| p.op), Some(0x2403), "the next sprm is still read"); -} - -/// A section mark (0x0C) at a section's end is the section's last paragraph mark, not a page -/// break inside a paragraph: the section's properties (here continuous) land on that paragraph. -/// Before, every section of such a file was lost and each section mark became a page break. -#[test] -fn section_marks_end_paragraphs() { - use wordcraft_doc::section::SectionStart; - let spec = StructSpec { - text: "first\u{C}second\r", - header_stories: vec![], - sections: vec![(6, grpprl(&[(0x3009, &[0x00])])), (13, Vec::new())], - chpx_runs: vec![(0, Vec::new())], - papx_entries: vec![(0, 0, Vec::new()), (6, 0, Vec::new())], - plf_lst: Vec::new(), - plf_lfo: Vec::new(), - note_stories: vec![], - endnote_stories: vec![], - extra_pairs: Vec::new(), - data_stream: Vec::new(), - }; - let doc = read(&struct_doc(&spec)).expect("opens"); - assert_eq!(body_texts(&doc), ["first", "second"]); - let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); - assert_eq!(p0.section.as_ref().map(|s| s.start), Some(SectionStart::Continuous), "the section ends at its mark"); - // A page break inside a section stays a page break. - let spec = StructSpec { text: "a\u{C}b\r", sections: vec![(4, Vec::new())], papx_entries: vec![(0, 0, Vec::new())], ..spec }; - let doc = read(&struct_doc(&spec)).expect("opens"); - assert_eq!(body_texts(&doc), ["a\u{C}b"]); -} - -// #1380: Word 2000+ tables. Word writes each row as its cell marks plus a separate row-end -// (TTP) mark, cell shading as 10-byte `Shd`s in `sprmTDefTableShd`/`sprmTDefTableShdRaw` (the -// `Shd80` palette copy in `sprmTDefTableShd80` is only a fallback), and a horizontally -// merged row as a row with fewer, wider cells in its own `sprmTDefTable`. - -/// A `sprmTDefTable` Prl with `edges` twip boundaries and no TC80s (cb counts the bytes -/// after it, plus one; Word may store fewer TC80s than cells). -fn t_def_edges(edges: &[i16]) -> Vec { - 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) -> wordcraft_doc::Document { - let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\u{7}f\u{7}g\u{7}h\u{7}i\u{7}j\u{7}\u{7}legend\u{7}\u{7}tail\r"; - let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]); - let ttp = |extra: Vec| [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(wordcraft_doc::Rgb(0x1C, 0x3A, 0x70)); 5], "exact fill beats the Shd80 palette colour"); - let light = Some(wordcraft_doc::Rgb(0xE8, 0xF0, 0xFA)); - assert_eq!(shd(1), vec![light, light, light, light, None], "automatic Shd clears the Shd80 fill"); - assert_eq!(shd(2), vec![Some(wordcraft_doc::Rgb(0xFF, 0xF2, 0xCC))], "legend fill"); - let legend = tbl.rows[2].cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::(); - 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(wordcraft_doc::Rgb(255, 255, 0))); - assert_eq!(row.shading_of(1), Some(wordcraft_doc::Rgb(192, 192, 192))); - assert_eq!(row.shading_of(2), None); - assert_eq!(row.shading_of(3), Some(wordcraft_doc::Rgb(0, 0, 255))); -} - -#[test] -fn shd_arrays_keep_to_their_cell_ranges() { - // sprmTDefTableShd and sprmTDefTableShdRaw cover cells 0-21; their 2nd and 3rd forms - // cover 22-43 and 44-62. An over-long first array must not reach cell 22. - let red = shd_clear([0xFF, 0, 0], 1); - let green = shd_clear([0, 0xFF, 0], 1); - let blue = shd_clear([0, 0, 0xFF], 1); - let over_long = shd_clear([0x11, 0x22, 0x33], 23); - for (first, second, third) in [(0xD612u16, 0xD616u16, 0xD60Cu16), (0xD670, 0xD671, 0xD672)] { - let papx = [sprm_cb(second, &green), sprm_cb(third, &blue), sprm_cb(first, &over_long)].concat(); - let row = crate::table::decode(&papx); - assert_eq!(row.shading_of(21), Some(wordcraft_doc::Rgb(0x11, 0x22, 0x33)), "{first:04X}"); - assert_eq!(row.shading_of(22), Some(wordcraft_doc::Rgb(0, 0xFF, 0)), "{second:04X} keeps cell 22"); - assert_eq!(row.shading_of(44), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "{third:04X} starts at cell 44"); - let papx = sprm_cb(first, &red); - assert_eq!(crate::table::decode(&papx).shading_of(0), Some(wordcraft_doc::Rgb(0xFF, 0, 0))); - } -} - -#[test] -fn hostile_table_shading_and_spans_are_safe() { - // SHD arrays longer than the row, odd lengths, empty ones; itcMac past 63; edges that - // run backwards or past the grid; merges past the row's end. - // (Kept small: the fixture's single PAPX page holds every row's grpprl.) - let long = shd_clear([1, 2, 3], 6); - let rows: Vec<(Vec, 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(wordcraft_doc::StoryRef::Body); - assert!(text.contains("legend") && text.contains("tail"), "{text:?}"); - if let Some(t) = doc.body.iter().find_map(|b| b.as_table()) { - assert!(t.grid.len() <= 63); - for r in &t.rows { - assert!(r.cells.iter().all(|c| c.props.span <= 63)); - } - } - } - // A row that is only a row-end mark (no cell marks before it), with an out-of-range - // merge and a SHD array, then a normal row. - // The second variant's sprmTDefTable is truncated to a single edge (itcMac 3), which - // must not turn the lone row-end mark into a row with no cells. - let text = "\u{7}a\u{7}\u{7}tail\r"; - let ttp = |extra: Vec| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), extra].concat(); - let one_edge = { - let mut v = (0xD608u16).to_le_bytes().to_vec(); - v.extend_from_slice(&4u16.to_le_bytes()); // cb: itcMac + one edge, plus one - v.extend_from_slice(&[3, 0, 0]); - v - }; - for lone in [[t_def_edges(&[0, 1440]), grpprl(&[(0x5624, &[0, 9])]), sprm_cb(0xD612, &long)].concat(), one_edge] { - let spec = one_section( - text, - Vec::new(), - vec![ - (0, 0, ttp(lone)), - (1, 0, grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])])), - (3, 0, ttp(t_def_edges(&[0, 1440]))), - (4, 0, Vec::new()), - ], - ); - let doc = read(&struct_doc(&spec)).expect("opens"); - let text = doc.plain_text(wordcraft_doc::StoryRef::Body); - assert!(text.contains('a') && text.contains("tail"), "{text:?}"); - for t in doc.body.iter().filter_map(|b| b.as_table()) { - assert!(t.rows.iter().all(|r| !r.cells.is_empty()), "no zero-cell rows"); - } - } -} - -#[test] -fn nil_raw_shading_keeps_the_earlier_colour() { - // In sprmTDefTableShdRaw (and its 2nd/3rd forms) a nil Shd (ipat 0xFFFF) means "as the - // table style shades it", so it must not override sprmTDefTableShd; automatic clears. - let blue = shd_clear([0, 0, 0xFF], 1); - let red = shd_clear([0xFF, 0, 0], 1); - let nil = vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0xFF, 0xFF]; - let auto = vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0, 0]; - let papx = [sprm_cb(0xD612, &[blue.clone(), blue.clone(), blue.clone()].concat()), sprm_cb(0xD670, &[nil.clone(), red, auto].concat())].concat(); - let row = crate::table::decode(&papx); - assert_eq!(row.shading_of(0), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "nil keeps blue"); - assert_eq!(row.shading_of(1), Some(wordcraft_doc::Rgb(0xFF, 0, 0))); - assert_eq!(row.shading_of(2), None, "automatic clears"); - for (first, raw) in [(0xD616u16, 0xD671u16), (0xD60C, 0xD672)] { - let papx = [sprm_cb(first, &blue), sprm_cb(raw, &nil)].concat(); - let cell = if first == 0xD616 { 22 } else { 44 }; - assert_eq!(crate::table::decode(&papx).shading_of(cell), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "{raw:04X}"); - } -} - -#[test] -fn second_table_gets_its_own_grid() { - // #1380: a 5-column table, a paragraph, then a 3-column table. The second table's grid - // comes from its own rows, not the first table's, and its borders, alignment and indent - // are its own too (the first table's centred, bordered and indented). - let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\u{7}between\rx\u{7}y\u{7}z\u{7}\u{7}tail\r"; - let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]); - let ttp = |edges: &[i16]| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), t_def_edges(edges)].concat(); - let table1_props = { - let mut borders = (0xD605u16).to_le_bytes().to_vec(); - borders.push(0x18); - for _ in 0..6 { - borders.extend_from_slice(&[4, 1, 1, 0]); - } - [borders, grpprl(&[(0x5400, &1u16.to_le_bytes()), (0x9601, &720i16.to_le_bytes())])].concat() - }; - let spec = StructSpec { - text, - header_stories: vec![], - sections: vec![(text.chars().count() as u32, Vec::new())], - chpx_runs: vec![(0, Vec::new())], - papx_entries: vec![ - (0, 0, in_tbl.clone()), - (10, 0, [ttp(&[0, 1440, 2880, 4320, 5760, 7200]), table1_props].concat()), - (11, 0, Vec::new()), // "between" - (19, 0, in_tbl), - (25, 0, ttp(&[0, 2000, 4000, 7200])), - (26, 0, Vec::new()), // tail - ], - plf_lst: Vec::new(), - plf_lfo: Vec::new(), - note_stories: vec![], - endnote_stories: vec![], - extra_pairs: Vec::new(), - data_stream: Vec::new(), - }; - let doc = read(&struct_doc(&spec)).expect("opens"); - let tables: Vec<_> = doc.body.iter().filter_map(|b| b.as_table()).collect(); - assert_eq!(tables.len(), 2); - assert_eq!(tables[0].grid.len(), 5); - assert_eq!(tables[1].grid, vec![100.0, 100.0, 160.0], "second table's own grid"); - assert_eq!(tables[1].rows[0].cells.iter().map(|c| c.span()).collect::>(), [1, 1, 1]); - assert!(tables[0].props.borders.is_some() && tables[0].props.align.is_some() && tables[0].props.indent.is_some()); - assert_eq!(tables[1].props.borders, None, "borders reset"); - assert_eq!(tables[1].props.align, None, "alignment reset"); - assert_eq!(tables[1].props.indent, None, "indent reset"); -} - -#[test] -fn repeated_cell_sprms_keep_one_override_per_cell() { - // A PAPX repeating cell-range sprms (shared by every paragraph mark that names it) keeps one - // slot per cell, the last value winning, instead of a copy per repetition. - let mut sprms = Vec::new(); - for w in 0..200u16 { - let [w0, w1] = (w * 20).to_le_bytes(); - sprms.push(sprm_cb(0xD635, &[0, 63, 3, w0, w1])); - sprms.push(sprm_cb(0xD62C, &[0, 255, 1])); - sprms.push([0x24, 0x56, 0, 2].to_vec()); - } - let row = crate::table::decode(&sprms.concat()); - assert_eq!(row.width_of(0), Some(199.0), "the last width wins"); - assert_eq!(row.width_of(62), Some(199.0)); - assert!(row.overrides() <= 63 + 255 + 63, "{}", row.overrides()); -} - -#[test] -fn sprm_t_cell_width_twips_and_percentage() { - // [MS-DOC] §2.9.290 sprmTCellWidth (0xD635): - // cb (1 byte), itcFirst (1 byte), itcLim (1 byte), ftsWidth (1 byte), wWidth (2 bytes). - // ftsWidth = 3 (twips): 1440 twips = 72.0 pt. - let w_twips = [0u8, 2, 3, 0xA0, 0x05]; // cell 0..2, fts=3, 1440 dxa - // ftsWidth = 2 (percentage in 50ths of a percent): 2500 = 50.0%. - let w_pct = [2u8, 3, 2, 0xC4, 0x09]; // cell 2..3, fts=2, 2500 (50%) - let papx = [sprm_cb(0xD635, &w_twips), sprm_cb(0xD635, &w_pct)].concat(); - - let row = crate::table::decode(&papx); - assert_eq!(row.width_of(0), Some(72.0)); - assert_eq!(row.width_of(1), Some(72.0)); - assert_eq!(row.width_pct_of(2), Some(50.0)); - - // End-to-end document test: row cells receive the preferred widths. - let text = "a\u{7}b\u{7}c\u{7}\u{7}\r"; - let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]); - let ttp = [ - grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), - t_def_edges(&[0, 1440, 2880, 4320]), - sprm_cb(0xD635, &w_twips), - sprm_cb(0xD635, &w_pct), - ] - .concat(); - let spec = StructSpec { - text, - header_stories: vec![], - sections: vec![(text.chars().count() as u32, Vec::new())], - chpx_runs: vec![(0, Vec::new())], - papx_entries: vec![(0, 0, in_tbl), (6, 0, ttp), (7, 0, Vec::new())], - plf_lst: Vec::new(), - plf_lfo: Vec::new(), - note_stories: vec![], - endnote_stories: vec![], - extra_pairs: Vec::new(), - data_stream: Vec::new(), - }; - let doc = read(&struct_doc(&spec)).expect("opens"); - let table = doc.body[0].as_table().expect("table block"); - assert_eq!(table.rows[0].cells[0].props.width, Some(72.0)); - assert_eq!(table.rows[0].cells[1].props.width, Some(72.0)); - assert_eq!(table.rows[0].cells[2].props.width_pct, Some(50.0)); -} - -// --------------------------------------------------------------------------------------------- -// #1767: symbol runs and paragraph borders. - -/// Word stores a symbol character (Insert › Symbol) as the placeholder U+0028 plus -/// `sprmCSymbol` (0x6A09), which carries the font table index and the character code -/// ([MS-DOC] §2.6.1 CSymbolOperand); the text stream alone would read "(". -#[test] -fn symbol_runs_read_the_character_and_font_from_sprm_c_symbol() { - let text = "(\r"; - // ftc = 1 (Wingdings), xchar = F071: the box Word draws for Wingdings 0x71. - let sym = [1u16.to_le_bytes(), 0xF071u16.to_le_bytes()].concat(); - let spec = StructSpec { text, chpx_runs: vec![(0, grpprl(&[(0x6A09, &sym)]))], ..structured_spec(text, text.chars().count() as u32) }; - let doc = read(&struct_doc(&spec)).expect("opens"); - let p = doc.body.first().and_then(|b| b.as_para()).expect("paragraph"); - assert_eq!(p.text, "\u{F071}", "the symbol, not the placeholder"); - assert_eq!(p.props_of_char(0).font.as_deref(), Some("Wingdings"), "its font from the font table"); -} - -/// Paragraph borders around a block are read from both storage forms: the Word 97 Brc80 -/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour). A thick -/// border (type 0x02) stays thick in both, as tables read it too. -#[test] -fn paragraph_borders_read_from_both_forms() { - use wordcraft_doc::props::BorderStyle as S; - let b80 = [8u8, 0x01, 0x06, 0x01]; // 1 pt single line, palette red (6), 1 pt from the text - let thick80 = [8u8, 0x02, 0x06, 0x01]; - let brc = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x01, 0x02, 0x00]; // cb 8, red COLORREF, 1 pt single, space 2 - let thick = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x02, 0x02, 0x00]; - let text = "boxed\r\r"; - let spec = StructSpec { - text, - papx_entries: vec![ - (0, 0, grpprl(&[(0x6424, &b80), (0x6425, &thick80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])), - (6, 0, grpprl(&[(0xC64E, &brc), (0xC64F, &thick)])), - ], - ..structured_spec(text, text.chars().count() as u32) - }; - let doc = read(&struct_doc(&spec)).expect("opens"); - let p0 = doc.body.first().and_then(|b| b.as_para()).expect("first paragraph"); - let b = p0.props.borders.expect("borders"); - let top = b.top.expect("top border"); - assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 1.0)); - assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0))); - assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "Brc80 thick"); - assert!(b.bottom.is_some() && b.right.is_some() && b.between.is_some()); - let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("second paragraph"); - let b = p1.props.borders.expect("borders"); - let top = b.top.expect("top border"); - assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 2.0)); - assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0))); - assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "BrcOperand thick"); -} - -/// #3642: a small compound file whose DIFAT names one FAT sector thousands of times made `cfb` -/// build a FAT of hundreds of megabytes before checking it. The precheck refuses it first. -#[test] -fn a_difat_repeating_one_fat_sector_is_refused_before_cfb_builds_the_fat() { - let f = wordcraft_doc::cfb_guard::repeating_difat_sample(); - // The precheck's own error is the proof that it ran before cfb (no clock to skew). - assert!(matches!(read(&f), Err(super::DocbinError::Container(e)) if e.contains("FAT sectors in a file of"))); - assert!(!super::is_encrypted(&f)); - // A file cfb wrote itself passes. - assert!(wordcraft_doc::cfb_guard::check(&cfb_file(&[("WordDocument", &[0u8; 4096])])).is_ok()); -} - -/// A compound file `cfb` writes in `version`: `streams` at its root, then `extra` streams under -/// an `ObjectPool` storage (where Word keeps embedded objects). -fn cfb_file_in(version: cfb::Version, streams: &[(&str, &[u8])], extra: &[(&str, &[u8])]) -> Vec { - let mut comp = cfb::CompoundFile::create_with_version(version, std::io::Cursor::new(Vec::new())).expect("cfb create"); - for (name, data) in streams { - comp.create_stream(format!("/{name}")).expect("cfb stream").write_all(data).expect("cfb write"); - } - comp.create_storage("/ObjectPool").expect("cfb storage"); - for (name, data) in extra { - comp.create_stream(format!("/ObjectPool/{name}")).expect("cfb stream").write_all(data).expect("cfb write"); - } - comp.into_inner().into_inner() -} - -/// #3642 review: valid compound files pass the guard and open, so it refuses only the hostile -/// ones. The repository keeps no files Word made, so these are written by the `cfb` crate in -/// the shape of Word 97–2003 files: the WordDocument and table streams of a .doc these tests -/// build, with the summary, Data and embedded-object streams Word adds, in 512-byte sectors -/// (version 3, Word's own) and 4096-byte ones (version 4); and one with a Data stream of 8 MB, -/// past what the header's 109 FAT sector slots map in 512-byte sectors (109 × 128 sectors, -/// about 7 MB), so its FAT sectors are listed in a DIFAT chain too, which the guard walks. -#[test] -fn valid_doc_shaped_compound_files_pass_the_guard_and_open() { - let doc = struct_doc(&structured_spec("Hello world\r", 12)); - let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(doc.as_slice())).expect("cfb open"); - // The table stream is 1Table or 0Table, as the FIB's fWhichTblStm says: kept under its name. - let table_name = if comp.is_stream("/1Table") { "1Table" } else { "0Table" }; - let mut stream = |name: &str| { - let mut v = Vec::new(); - std::io::Read::read_to_end(&mut comp.open_stream(name).expect("stream"), &mut v).expect("read"); - v - }; - let (word, table) = (stream("/WordDocument"), stream(&format!("/{table_name}"))); - let summary = vec![0xFEu8; 4096]; - let data = |n: usize| (0..n).map(|i| (i % 251) as u8).collect::>(); - let small = data(200_000); - let big = data(8 << 20); - let ole = data(70_000); - let files = [ - ("as built", doc.clone()), - ( - "version 3", - cfb_file_in( - cfb::Version::V3, - &[("WordDocument", &word), (table_name, &table), ("Data", &small), ("\u{5}SummaryInformation", &summary)], - &[("_1234567890", &ole)], - ), - ), - ("version 4", cfb_file_in(cfb::Version::V4, &[("WordDocument", &word), (table_name, &table), ("Data", &small)], &[("_1", &ole)])), - ("with a DIFAT chain", cfb_file_in(cfb::Version::V3, &[("WordDocument", &word), (table_name, &table), ("Data", &big)], &[])), - ]; - let difat_sectors = |f: &[u8]| f.get(0x48..0x4C).map_or(0, |b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); - assert!(difat_sectors(&files[3].1) > 0, "the 8 MB file lists FAT sectors past the header's"); - for (what, f) in &files { - assert_eq!(wordcraft_doc::cfb_guard::check(f), Ok(()), "{what}"); - let doc = read(f).unwrap_or_else(|e| panic!("{what}: {e}")); - assert_eq!(body_texts(&doc).first().map(String::as_str), Some("Hello world"), "{what}"); - } -} - -/// #3708: a long HYPERLINK target over a long result was copied (and compared) once per result -/// character. It is now copied once per run. -#[test] -fn a_long_hyperlink_over_a_long_result_is_one_linked_run() { - let n = 250_000; - let target = "x".repeat(n); - let text: &'static str = Box::leak(format!("\u{13}HYPERLINK \"http://{target}\"\u{14}{}\u{15}\r", "y".repeat(n)).into_boxed_str()); - let spec = structured_spec(text, text.chars().count() as u32); - let doc = read(&struct_doc(&spec)).expect("opens"); - let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); - let linked: Vec<_> = p0.runs.iter().filter(|r| r.props.link.is_some()).collect(); - assert_eq!(linked.len(), 1); - assert_eq!(linked.first().map(|r| r.len), Some(n)); - // One copy of the target for the one run, not one per character (~2.5 s here): counted by - // what the copies charge to the work budget, which a busy machine can't skew. - let mut result = super::FieldResult::default(); - (0..n).for_each(|_| result.push('y', wordcraft_doc::props::CharProps::default())); - let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) }; - let work = super::Work::new(u64::MAX); - super::finish_field(&mut super::ParaBuild::default(), f, &work); - assert_eq!(u64::MAX - work.left.get(), ("http://".len() + n) as u64, "one copy of the target"); -} - -/// #3708: a result whose formatting changes every character pays for a target copy per run -/// out of the work budget; past it the text stays, unlinked. -#[test] -fn hyperlink_target_copies_are_charged_to_the_work_budget() { - use wordcraft_doc::props::CharProps; - let bold = CharProps { bold: Some(true), ..Default::default() }; - let mut result = super::FieldResult::default(); - (0..1000).for_each(|i| result.push('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })); - let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) }; - let mut pb = super::ParaBuild::default(); - let budget = super::Work::new(10_000); - super::finish_field(&mut pb, f, &budget); - assert_eq!(pb.text.len(), 1000, "all text kept"); - let linked: usize = pb.runs.iter().filter_map(|r| r.props.link.as_ref()).map(|l| l.len()).sum(); - assert!(linked <= 10_000, "linked bytes {linked}"); - assert_eq!(pb.runs.iter().filter(|r| r.props.link.is_some()).count(), 100); - assert_eq!(budget.left.get(), 0); - assert_eq!(budget.dropped(), [super::Cost::LinkTarget], "the unlinked text is noted, to be logged"); -} - -/// #3871 review: what the work budget leaves out is noted once per kind, in a fixed order, and -/// logged once per kind when the document has been read; nothing is noted while it lasts. -#[test] -fn the_work_budget_notes_each_kind_it_leaves_out_once() { - use super::Cost::*; - let work = super::Work::new(10); - assert!(work.charge(10, AltText)); - assert!(work.dropped().is_empty(), "spent to the last byte, nothing left out"); - assert!(work.charge(0, LinkTarget), "a free copy is never refused"); - for _ in 0..3 { - assert!(!work.charge(1, FloatingObject)); - } - assert!(!work.charge(1, AltText)); - assert_eq!(work.dropped(), [AltText, FloatingObject]); - work.log(); - assert_eq!(super::LAST_DROPPED.with(|d| d.borrow().clone()), [AltText, FloatingObject]); - // A document within the budget leaves nothing out. - read(&struct_doc(&structured_spec("Hello\r", 6))).expect("opens"); - assert!(super::LAST_DROPPED.with(|d| d.borrow().is_empty())); -} - -/// #3692: a piece's Prm grpprl (a Prc of up to 64 KB) was re-interpreted for every character. -/// It is now resolved once per formatting change. -#[test] -fn a_large_prc_is_interpreted_once_per_formatting_change() { - let prc = grpprl(&vec![(0x0835u16, &[0x01u8][..]); 21_000]); - let text: &'static str = Box::leak(format!("{}\r", "a".repeat(100_000)).into_boxed_str()); - let spec = RichSpec { - text, - styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())], - fonts: vec!["Times New Roman"], - chpx_runs: vec![(0, Vec::new())], - papx_entries: vec![(0, 0, Vec::new())], - }; - let bytes = rich_doc_with(&spec, &RichExtra { prcs: vec![prc], prm: 0x0001, ..Default::default() }); - let t = std::time::Instant::now(); - let doc = read(&bytes).expect("opens"); - // About 60 ms on a loaded machine; interpreting the Prc per character took 88 s. - assert!(t.elapsed().as_secs() < 10, "opened in {:?}", t.elapsed()); - let p = para0(&doc); - assert_eq!(p.runs.len(), 1); - assert_eq!(p.runs.first().and_then(|r| r.props.bold), Some(true)); -} - -/// #3692: Prm grpprls are charged to a document-wide work budget; once it is spent the Prm -/// is skipped (the text keeps its CHPX formatting). -#[test] -fn prm_grpprls_past_the_work_budget_are_skipped() { - use wordcraft_doc::props::CharProps; - let g = grpprl(&[(0x0835, &[0x01])]); - let work = super::Work::new(g.len() as u64 + 1); - let base = CharProps::default(); - let mut first = CharProps::default(); - super::apply_prm(&mut first, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work); - assert_eq!(first.bold, Some(true)); - let mut second = CharProps::default(); - super::apply_prm(&mut second, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work); - assert_eq!(second.bold, None); - assert_eq!(work.left.get(), 1); - assert_eq!(work.dropped(), [super::Cost::Formatting]); -} - -/// A field's cached result keeps one `CharProps` per formatting run, not one per character -/// (365 bytes each: 4 million result characters took 1.4 GB). -#[test] -fn a_field_result_stores_one_props_per_run() { - use wordcraft_doc::props::CharProps; - let bold = CharProps { bold: Some(true), ..Default::default() }; - let mut r = super::FieldResult::default(); - for _ in 0..200_000 { - r.push('x', CharProps::default()); - } - assert_eq!(r.runs.len(), 1); - assert_eq!(r.runs.first().map(|(n, _)| *n), Some(200_000)); - r.push('\u{e9}', bold.clone()); - r.push('y', bold); - r.push('z', CharProps::default()); - assert_eq!(r.runs.iter().map(|(n, _)| *n).collect::>(), [200_000, 3, 1], "a two-byte char counts its bytes"); - assert_eq!(r.text.len(), 200_004); -} - -/// A hyperlink whose result changes formatting, with multi-byte text, keeps every run's text and -/// links all of it; a plain field keeps its result text whole. -#[test] -fn field_results_keep_their_text_and_runs() { - use wordcraft_doc::props::CharProps; - let bold = CharProps { bold: Some(true), ..Default::default() }; - let mut result = super::FieldResult::default(); - for (c, p) in - [('a', CharProps::default()), ('\u{e9}', CharProps::default()), ('\u{20ac}', bold.clone()), ('b', bold), ('c', CharProps::default())] - { - result.push(c, p); - } - let f = super::FieldBuild { depth: 1, instr: "HYPERLINK \"http://x\"".into(), sep: Some(result) }; - let mut pb = super::ParaBuild::default(); - super::finish_field(&mut pb, f, &super::Work::new(u64::MAX)); - assert_eq!(pb.text, "a\u{e9}\u{20ac}bc"); - assert_eq!(pb.runs.iter().map(|r| r.len).collect::>(), [3, 4, 1]); - assert!(pb.runs.iter().all(|r| r.props.link.as_deref() == Some("http://x"))); - let doc = read(&text_doc(&[Piece { compressed: false, text: "\u{13} PAGE \u{14}1\u{e9}2\u{15}\r".into() }], &[], 0)).expect("opens"); - let p = doc.body.first().and_then(|b| b.as_para()).expect("paragraph"); - assert!( - p.objects - .iter() - .any(|o| matches!(o, wordcraft_doc::para::InlineObject::Field { instr, result, .. } if instr == "PAGE" && result == "1\u{e9}2")), - "{:?}", - p.objects - ); -} - -/// A one-page CHPX bin table over `page`: FCs 0..`end` live in page 1 of the returned stream. -fn one_page_bins(page: &[u8], end: u32) -> (Vec, crate::fkp::Bins) { - let mut word = vec![0u8; 512]; - word.extend_from_slice(page); - let plc = [0u32.to_le_bytes().as_slice(), end.to_le_bytes().as_slice(), 1u32.to_le_bytes().as_slice()].concat(); - let bins = crate::fkp::Bins::parse(&plc, 0, 12).expect("bins"); - (word, bins) -} - -/// The CHPX run a lookup reports covers exactly the FCs that give the same grpprl: the walk -/// reuses it for the following characters without asking again. -#[test] -fn a_chpx_run_covers_the_fcs_with_the_same_answer() { - let runs = [(0u32, grpprl(&[(0x0835, &[1])])), (5, Vec::new()), (9, grpprl(&[(0x0836, &[1])])), (30, grpprl(&[(0x0835, &[1])]))]; - let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000); - for fc in (fc_of(0) - 4)..(fc_of(70)) { - let run = bins.chpx_run(&word, fc); - assert_eq!(run.grpprl, bins.chpx(&word, fc), "fc {fc}"); - for other in (fc_of(0) - 4)..(fc_of(70)) { - if run.contains(other) { - assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}"); - } - } - } - assert!(bins.chpx_run(&word, fc_of(6)).contains(fc_of(8)) && !bins.chpx_run(&word, fc_of(6)).contains(fc_of(9))); -} - -/// A page whose run starts do not ascend (a corrupt file) gives the same answers as before and -/// a range that never claims an FC with a different answer. -#[test] -fn a_chpx_run_is_sound_on_a_page_whose_starts_do_not_ascend() { - let runs = [(10u32, grpprl(&[(0x0835, &[1])])), (3, grpprl(&[(0x0836, &[1])])), (20, Vec::new()), (7, grpprl(&[(0x0835, &[1])]))]; - let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000); - for fc in (fc_of(0) - 4)..(fc_of(70)) { - let run = bins.chpx_run(&word, fc); - assert_eq!(run.grpprl, bins.chpx(&word, fc)); - for other in (fc_of(0) - 4)..(fc_of(70)) { - if run.contains(other) { - assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}"); - } - } - } -} - -/// A piece span answers exactly what the per-CP lookup answers, in every piece (compressed -/// and UTF-16) and past the last. -#[test] -fn a_piece_span_matches_the_per_cp_lookups() { - let doc = text_doc( - &[ - Piece { compressed: true, text: "abc".into() }, - Piece { compressed: false, text: "d\u{e9}fgh".into() }, - Piece { compressed: true, text: "ij".into() }, - ], - &[], - 0, - ); - let mut comp = crate::open_container(&doc).expect("container"); - let table = crate::stream(&mut comp, "0Table").expect("table"); - let pieces = crate::piece::PieceTable::parse(&table, CLX_AT, table.len() as u32 - CLX_AT).expect("pieces"); - for cp in 0..14 { - let span = pieces.span(cp); - assert_eq!(span.and_then(|s| s.fc_at(cp)), pieces.fc_of_cp(cp), "cp {cp}"); - for other in 0..14 { - if span.is_some_and(|s| s.contains(other)) { - assert_eq!(pieces.span(other).and_then(|s| s.fc_at(other)), pieces.fc_of_cp(other)); - } - } - } - assert!(pieces.span(10).is_none(), "past the last piece"); -} - -/// Characters of one formatting share one `CharProps`: it is cloned when a run starts, and not -/// even compared while the formatting stays the same. -#[test] -fn shared_props_start_one_run_per_formatting() { - use wordcraft_doc::props::CharProps; - let bold = CharProps { bold: Some(true), ..Default::default() }; - let mut pb = super::ParaBuild::default(); - for _ in 0..100_000 { - pb.push_shared('a', &bold, 1); - } - pb.push_shared('b', &CharProps::default(), 2); - pb.push_shared('c', &bold, 3); - pb.push_shared('d', &bold, 4); - assert_eq!(pb.runs.iter().map(|r| r.len).collect::>(), [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/docbin/src/tests/drawing.rs b/crates/docbin/src/tests/drawing.rs deleted file mode 100644 index dc18c3d372..0000000000 --- a/crates/docbin/src/tests/drawing.rs +++ /dev/null @@ -1,653 +0,0 @@ -//! 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 proptest::prelude::*; -use wordcraft_doc::fill::Fill; -use wordcraft_doc::para::{Anchor, InlineObject, ShapeKind, Wrap}; -use wordcraft_doc::props::Rgb; - -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, wordcraft_doc::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| wordcraft_doc::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/docbin/src/tests/encrypted.rs b/crates/docbin/src/tests/encrypted.rs deleted file mode 100644 index f6fcea9716..0000000000 --- a/crates/docbin/src/tests/encrypted.rs +++ /dev/null @@ -1,241 +0,0 @@ -//! 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/docbin/src/tests/metadata.rs b/crates/docbin/src/tests/metadata.rs deleted file mode 100644 index b36a963c47..0000000000 --- a/crates/docbin/src/tests/metadata.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! 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) -> wordcraft_doc::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"); -}