mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
craft-doc-binary: WordCraft's crates/docbin as it is at wordcraft 07f97737
The source, unchanged, so the next commit shows only what the move changes. It has no manifest yet, so it is not built.
This commit is contained in:
1 parent
1d92efa707
commit
7367e7a4fa
20 files changed
+9588
No files matched your search
@@ -0,0 +1,455 @@
|
||||
//! Password-protected Word 97-2003 files ([MS-DOC] §2.2.6, [MS-OFFCRYPTO] §2.3.5-2.3.7).
|
||||
//!
|
||||
//! Three schemes exist for `.doc`:
|
||||
//!
|
||||
//! * **XOR obfuscation** (`fObfuscated`): a 32-bit password verifier sits in `FibBase.lKey` and
|
||||
//! the streams are XORed with a 16-byte array derived from the password (method 2).
|
||||
//! * **RC4** (header version 1.1): `EncryptionHeader` at the start of the Table stream; the key
|
||||
//! per 512-byte block comes from MD5.
|
||||
//! * **RC4 CryptoAPI** (header version 2/3/4 . 2): like RC4 but with a SHA-1 key derivation, a
|
||||
//! key size of 40-128 bits and a variable-size header.
|
||||
//!
|
||||
//! In all three the WordDocument stream (past its first 68 bytes), the Table stream (past its
|
||||
//! header, for RC4) and the whole Data stream are transformed. The password is checked against
|
||||
//! the stored verifier before anything is decrypted, so a wrong password is reported and never
|
||||
//! yields a garbled document. Everything is written from the public specifications.
|
||||
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
use crate::DocbinError;
|
||||
|
||||
/// Bytes at the start of the WordDocument stream that are stored untransformed.
|
||||
pub(crate) const FIB_PLAIN: usize = 68;
|
||||
/// RC4 re-keys every this many bytes of a stream.
|
||||
const BLOCK: usize = 512;
|
||||
/// Longest password considered (the specifications' limit for the RC4 schemes).
|
||||
const MAX_PASSWORD_CHARS: usize = 255;
|
||||
/// XOR obfuscation uses at most this many single-byte password characters.
|
||||
const MAX_XOR_PASSWORD: usize = 15;
|
||||
/// `FibBase.flags`: the document is encrypted (`fEncrypted`) / obfuscated (`fObfuscated`).
|
||||
pub(crate) const F_ENCRYPTED: u16 = 0x0100;
|
||||
pub(crate) const F_OBFUSCATED: u16 = 0x8000;
|
||||
/// `EncryptionHeaderFlags`: `fCryptoAPI`, `fExternal`, `fAES`.
|
||||
const H_CRYPTO_API: u32 = 0x04;
|
||||
const H_EXTERNAL: u32 = 0x10;
|
||||
const H_AES: u32 = 0x20;
|
||||
|
||||
fn unsupported(what: &str) -> DocbinError {
|
||||
DocbinError::Encryption(what.into())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Primitives
|
||||
|
||||
/// MD5 ([RFC 1321]); the RC4 scheme is defined on it.
|
||||
pub(crate) fn md5(data: &[u8]) -> [u8; 16] {
|
||||
const S: [u32; 64] = [
|
||||
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16,
|
||||
23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
|
||||
];
|
||||
// K[i] = floor(2^32 * abs(sin(i + 1))), as the RFC defines it.
|
||||
let k: Vec<u32> = (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<u8> {
|
||||
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<u8> {
|
||||
password.encode_utf16().take(MAX_PASSWORD_CHARS).flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
|
||||
fn u16_at(d: &[u8], at: usize) -> Option<u16> {
|
||||
Some(u16::from_le_bytes(d.get(at..at.checked_add(2)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn u32_at(d: &[u8], at: usize) -> Option<u32> {
|
||||
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<Vec<u8>> {
|
||||
let mut out: Vec<Vec<u8>> = Vec::new();
|
||||
let latin: Option<Vec<u8>> = 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<u8> = 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<u8>, 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<Cipher, DocbinError> {
|
||||
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<Cipher, DocbinError> {
|
||||
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<Cipher, DocbinError> {
|
||||
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<u8> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Floating shapes and pictures: the `PlcfSpa` anchors of the main document and the headers
|
||||
//! ([MS-DOC] §2.8.27 PlcfSpa, §2.9.253 Spa) joined with the OfficeArt shapes they name, and the
|
||||
//! text box stories (`PlcftxbxTxt`, §2.8.30) that fill text boxes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use 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<u32, u32>,
|
||||
}
|
||||
|
||||
/// 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<u32, Node>,
|
||||
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<u32>| 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<u32>, 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<InlineObject> {
|
||||
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<InlineObject> {
|
||||
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<GroupChild>) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! The File Information Block ([MS-DOC] §2.5): `FibBase` plus the variable sections
|
||||
//! (`FibRgW97`, `FibRgLw97`, the `RgFcLcb` blob, `FibRgCswNew`) at the start of the
|
||||
//! WordDocument stream.
|
||||
|
||||
use crate::DocbinError;
|
||||
|
||||
/// `wIdent` of every Word 97-2003 file (Word 6/95 wrote 0xA5DC).
|
||||
const W_IDENT: u16 = 0xA5EC;
|
||||
/// Offsets within the FIB, in bytes (see the spec's Fib layout).
|
||||
const OFF_FLAGS: usize = 0x0A;
|
||||
const OFF_CSW: usize = 0x20;
|
||||
const OFF_CSLW: usize = 0x3E;
|
||||
const OFF_RGLW: usize = 0x40;
|
||||
const OFF_CB_RG_FCLCB: usize = 0x98;
|
||||
/// `csw` (count of 16-bit values in `FibRgW97`) — fixed by the spec.
|
||||
const CSW: u16 = 0x000E;
|
||||
/// `cslw` (count of 32-bit values in `FibRgLw97`) — fixed by the spec.
|
||||
const CSLW: u16 = 0x0016;
|
||||
/// `cbRgFcLcb` for each known `nFib` ([MS-DOC] Fib.cbRgFcLcb table).
|
||||
const CB_RG_FCLCB: &[(u16, u16)] = &[(0x00C1, 0x005D), (0x00D9, 0x006C), (0x0101, 0x0088), (0x010C, 0x00A4), (0x0112, 0x00B7)];
|
||||
|
||||
/// Indices of `fc`/`lcb` pairs inside `FibRgFcLcb97` (each pair is 8 bytes: fc then lcb).
|
||||
pub(crate) mod pair {
|
||||
pub(crate) const STSHF: usize = 1;
|
||||
pub(crate) const PLCF_FND_REF: usize = 2;
|
||||
pub(crate) const PLCF_FND_TXT: usize = 3;
|
||||
pub(crate) const PLCF_SED: usize = 6;
|
||||
pub(crate) const PLCF_HDD: usize = 11;
|
||||
pub(crate) const PLCF_BTE_CHPX: usize = 12;
|
||||
pub(crate) const PLCF_BTE_PAPX: usize = 13;
|
||||
pub(crate) const STTBF_FFN: usize = 15;
|
||||
pub(crate) const STTBF_BKMK: usize = 21;
|
||||
pub(crate) const PLCF_BKF: usize = 22;
|
||||
pub(crate) const PLCF_BKL: usize = 23;
|
||||
pub(crate) const CLX: usize = 33;
|
||||
/// `fcPlcSpaMom` / `fcPlcSpaHdr`: floating shape anchors of the main document / headers.
|
||||
pub(crate) const PLC_SPA_MOM: usize = 40;
|
||||
pub(crate) const PLC_SPA_HDR: usize = 41;
|
||||
pub(crate) const PLCF_END_REF: usize = 46;
|
||||
pub(crate) const PLCF_END_TXT: usize = 47;
|
||||
/// `fcDggInfo`: the OfficeArt drawing content (BLIP store, shapes).
|
||||
pub(crate) const DGG_INFO: usize = 50;
|
||||
/// `fcPlcftxbxTxt` / `fcPlcfHdrtxbxTxt`: text box stories of the main document / headers.
|
||||
pub(crate) const PLCF_TXBX_TXT: usize = 56;
|
||||
pub(crate) const PLCF_HDR_TXBX_TXT: usize = 58;
|
||||
/// `fcPlfLst`: the list definitions (LSTF array, with the LVLs appended after the blob).
|
||||
pub(crate) const PLF_LST: usize = 73;
|
||||
/// `fcPlfLfo`: the list format overrides (LFO array with LFOData appended).
|
||||
pub(crate) const PLF_LFO: usize = 74;
|
||||
}
|
||||
|
||||
/// Character-position ranges of the subdocuments, from `FibRgLw97`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(crate) struct SubDocs {
|
||||
pub(crate) text: u32,
|
||||
pub(crate) ftn: u32,
|
||||
pub(crate) hdd: u32,
|
||||
pub(crate) atn: u32,
|
||||
pub(crate) edn: u32,
|
||||
pub(crate) txbx: u32,
|
||||
pub(crate) hdr_txbx: u32,
|
||||
}
|
||||
|
||||
impl SubDocs {
|
||||
/// Total CPs across all subdocuments, checked against a cap.
|
||||
pub(crate) fn total(&self) -> Result<u64, DocbinError> {
|
||||
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<Protection> {
|
||||
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<u32>,
|
||||
}
|
||||
|
||||
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<Fib, DocbinError> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Character and paragraph formatting lookup: the bin tables (`PlcBteChpx`/`PlcBtePapx`) in
|
||||
//! the Table stream point at 512-byte FKP pages in the WordDocument stream, which map FC
|
||||
//! ranges to CHPX (grpprls) and PAPX (istd + grpprl) ([MS-DOC] §2.4.6.1/2.4.6.2).
|
||||
|
||||
use crate::DocbinError;
|
||||
|
||||
/// Largest bin table (entries) we accept.
|
||||
const MAX_BINS: usize = 100_000;
|
||||
|
||||
/// A Chpx grpprl and the FCs `[lo, hi)` for which it is the one [`Bins::chpx`] returns.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ChpxRun<'w> {
|
||||
pub(crate) grpprl: &'w [u8],
|
||||
lo: u32,
|
||||
hi: u32,
|
||||
}
|
||||
|
||||
impl ChpxRun<'_> {
|
||||
pub(crate) fn contains(&self, fc: u32) -> bool {
|
||||
self.lo <= fc && fc < self.hi
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed `PlcBte*`: FC breakpoints and the FKP page each range lives in.
|
||||
pub(crate) struct Bins {
|
||||
fcs: Vec<u32>,
|
||||
/// `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<u32>,
|
||||
}
|
||||
|
||||
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<Bins, DocbinError> {
|
||||
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<u32> { 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<u32> { 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<u32> { 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<u16> { 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(&[]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
//! 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<Rgb> {
|
||||
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<Border> {
|
||||
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<Border> {
|
||||
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<u16> {
|
||||
match p.operand {
|
||||
[b0, b1, ..] => Some(u16::from_le_bytes([*b0, *b1])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn s16_of(p: &Prl) -> Option<i16> {
|
||||
u16_of(p).map(|v| v as i16)
|
||||
}
|
||||
|
||||
fn set(props: &mut Option<bool>, 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<i16> { 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<u16>| 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<usize> {
|
||||
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<usize> {
|
||||
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::<String>() + 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,302 @@
|
||||
//! Lists: `PlfLst` (the LSTF definitions with their LVLs appended) and `PlfLfo` (the LFO
|
||||
//! instances with LFOLVL overrides) resolved into the numbering model
|
||||
//! ([MS-DOC] §2.8.1 "Determining List Formatting of a Paragraph", §2.9).
|
||||
|
||||
use 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<RawLvl>,
|
||||
}
|
||||
|
||||
/// 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<u8>,
|
||||
chpx: Vec<u8>,
|
||||
/// The number text with each placeholder left as its raw char.
|
||||
xst: Vec<u16>,
|
||||
}
|
||||
|
||||
/// 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<ListDef> {
|
||||
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<u16> = 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Inline pictures: `sprmCPicLocation` → a `PICF` in the Data stream → embedded image bytes
|
||||
//! ([MS-DOC] §2.9.190 PICF, §1.3.5 Pictures), and the shared store of every picture the
|
||||
//! document reads (inline and floating).
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::officeart;
|
||||
|
||||
/// `PICF.cbHeader` — the fixed size of the PICF structure.
|
||||
const CB_HEADER: usize = 0x44;
|
||||
/// Offsets inside PICF: `PICMID.dxaGoal/dyaGoal/mx/my` start at 28.
|
||||
const OFF_PICMID: usize = 28;
|
||||
|
||||
/// Most distinct pictures one document materialises from the Data stream.
|
||||
const MAX_PICTURES: usize = 5_000;
|
||||
/// Most picture bytes one document copies out of the Data stream, all pictures together.
|
||||
pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20;
|
||||
/// Most bytes one document decodes pictures into (metafiles inflated, DIB pixels), charged
|
||||
/// per attempt whether it succeeds or not: retained media is capped separately, by
|
||||
/// `MAX_MEDIA_BYTES` (#3711, #3712).
|
||||
const MAX_DECODE_WORK: usize = 512 << 20;
|
||||
/// The decode budget grows with the input: up to this many bytes per byte of the WordDocument
|
||||
/// and Data streams. An uncompressed DIB declares at most 32 bytes of pixels per file byte (1
|
||||
/// bit per pixel, 4 bytes charged each), so 64 passes any such picture. RLE8/RLE4 bitmaps
|
||||
/// reach about 510 times that and rely on the `MAX_DECODE_WORK` floor instead; a decompression
|
||||
/// bomb (ratios around 1e6) still hits the cap.
|
||||
const DECODE_WORK_PER_INPUT_BYTE: usize = 64;
|
||||
/// The most the decode budget grows to, whatever the input.
|
||||
const MAX_DECODE_WORK_CEILING: usize = 2 << 30;
|
||||
|
||||
/// The decode budget for a document whose pictures live in `input` stream bytes, never less
|
||||
/// than `floor`.
|
||||
pub(crate) fn decode_work(input: usize, floor: usize) -> usize {
|
||||
input.saturating_mul(DECODE_WORK_PER_INPUT_BYTE).clamp(floor, floor.max(MAX_DECODE_WORK_CEILING))
|
||||
}
|
||||
|
||||
/// The document's pictures, keyed by their location in the Data stream: anchors that share a
|
||||
/// `fc_pic` share one media entry (or one failure), and the total bytes copied are capped, so a
|
||||
/// file with thousands of anchors on one huge picture costs one copy, not thousands. Pictures of
|
||||
/// the drawing layer's BLIP store are shared the same way, by store index.
|
||||
pub(crate) struct Pictures {
|
||||
/// fc_pic → the inline picture already materialised (`None`: unreadable or over the cap,
|
||||
/// not retried).
|
||||
by_fc: HashMap<u32, Option<Inline>>,
|
||||
/// BLIP store index → media key (`None`: unreadable, not retried).
|
||||
by_bse: HashMap<u32, Option<String>>,
|
||||
/// (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<String>>,
|
||||
/// The media entries, merged into the document at the end.
|
||||
pub(crate) media: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
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<Inline> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<u8>, ext: &str, placeholder: bool) -> Option<String> {
|
||||
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<usize>,
|
||||
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<String>, 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<Found> {
|
||||
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<String>, 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<usize> {
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! 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<F: Read + Seek>(comp: &mut cfb::CompoundFile<F>) -> Option<CoreProps> {
|
||||
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<u32> {
|
||||
Some(u32::from_le_bytes(bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn parse(bytes: &[u8]) -> Option<CoreProps> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<u8> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Footnotes, endnotes and bookmarks: `PlcffndRef`/`PlcffndTxt`, `PlcfendRef`/`PlcfendTxt`,
|
||||
//! and `SttbfBkmk` + `Plcfbkf`/`Plcfbkl` ([MS-DOC] §2.9).
|
||||
|
||||
/// Cap on notes and bookmarks per document.
|
||||
const MAX: usize = 10_000;
|
||||
|
||||
/// Parse the note reference/story PLCs. `ref_pair`/`txt_pair` are the FIB pair indices
|
||||
/// (`(2, 3)` for footnotes, `(46, 47)` for endnotes); story ranges are relative to the
|
||||
/// note subdocument.
|
||||
///
|
||||
/// The story CPs of a well-formed PLC ascend, so the stories are disjoint. A hostile table
|
||||
/// that steps back would make many notes read the same text again (each one materialised
|
||||
/// as its own part), so a story starting before the end of the last one kept is dropped:
|
||||
/// every subdocument CP becomes at most one note.
|
||||
pub(crate) fn parse_notes(table: &[u8], fib: &crate::fib::Fib, ref_pair: usize, txt_pair: usize) -> Vec<(u32, (u32, u32))> {
|
||||
let refs = fib.pair(ref_pair).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 2)).unwrap_or_default();
|
||||
let txts = fib.pair(txt_pair).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 0)).unwrap_or_default();
|
||||
if txts.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut kept_to = 0u32;
|
||||
// The reference PLC's aFtnIdx order matches the story order; pair them by index.
|
||||
for (i, &cp) in refs.iter().enumerate() {
|
||||
if let (Some(&a), Some(&b)) = (txts.get(i), txts.get(i + 1))
|
||||
&& b > a
|
||||
&& a >= kept_to
|
||||
{
|
||||
out.push((cp, (a, b)));
|
||||
kept_to = b;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The CPs of a PLC (the trailing element is a limit and is kept so callers can pair
|
||||
/// range ends) plus the data-element size check. Returns the CPs only.
|
||||
fn plc_of(table: &[u8], fc: u32, lcb: u32, data_size: usize) -> Option<Vec<u32>> {
|
||||
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<Vec<String>> {
|
||||
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<u8> {
|
||||
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<u16> = 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<u8> = 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()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
//! OfficeArt (Escher) drawing records, as Word 97-2003 stores them ([MS-ODRAW], public Microsoft
|
||||
//! Open Specifications): the record header, the BLIP store and its pictures, shape containers with
|
||||
//! their property tables, and the `OfficeArtContent` of the Table stream ([MS-DOC] §2.9.172).
|
||||
//!
|
||||
//! Everything here walks hostile bytes: record lengths are clamped to their parent, nesting depth
|
||||
//! and record counts are capped, and decompressed metafiles are size-limited.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use image::ImageEncoder;
|
||||
|
||||
use 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<Rec<'a>> {
|
||||
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<u16> {
|
||||
Some(u16::from_le_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn i32_at(b: &[u8], at: usize) -> Option<i32> {
|
||||
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<u8>,
|
||||
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<Blip> {
|
||||
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<Blip> {
|
||||
// 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<Blip> {
|
||||
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<u8>,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
impl Write for PictureWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
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<u32>,
|
||||
/// Rotation, degrees clockwise.
|
||||
pub(crate) rot: f32,
|
||||
/// Crop fractions (left, top, right, bottom).
|
||||
pub(crate) crop: [f32; 4],
|
||||
pub(crate) fill_color: Option<Rgb>,
|
||||
pub(crate) filled: Option<bool>,
|
||||
pub(crate) line_color: Option<Rgb>,
|
||||
/// Line width in EMUs.
|
||||
pub(crate) line_width: Option<u32>,
|
||||
pub(crate) line: Option<bool>,
|
||||
/// 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<u32>,
|
||||
pub(crate) posrelh: Option<u32>,
|
||||
pub(crate) posv: Option<u32>,
|
||||
pub(crate) posrelv: Option<u32>,
|
||||
/// Distance from wrapped text (left, top, right, bottom), EMUs.
|
||||
pub(crate) wrap_dist: [Option<u32>; 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<u16> =
|
||||
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<Rgb> {
|
||||
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<Node>),
|
||||
}
|
||||
|
||||
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<Node> {
|
||||
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<Node> {
|
||||
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<u32, Node>,
|
||||
pub(crate) header: HashMap<u32, Node>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! The piece table ([MS-DOC] `Clx`/`Pcdt`/`PlcPcd`): where every character position lives in
|
||||
//! the WordDocument stream, and how it is encoded.
|
||||
|
||||
use 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<u32>,
|
||||
pieces: Vec<Pcd>,
|
||||
prc_grpprls: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl PieceTable {
|
||||
/// Parse the `Clx` at `table[fc .. fc+lcb]`.
|
||||
pub(crate) fn parse(table: &[u8], fc: u32, lcb: u32) -> Result<PieceTable, DocbinError> {
|
||||
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<u32> {
|
||||
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<PieceSpan<'_>> {
|
||||
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<u16> {
|
||||
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<u32> {
|
||||
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]>),
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Sections and headers/footers: `PlcfSed` → `Sed` → `Sepx` (grpprls of section sprms) and
|
||||
//! the `Plcfhdd` stories of the header subdocument ([MS-DOC] §2.8.25, §2.9.244).
|
||||
|
||||
use 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<Section> {
|
||||
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<u32> { 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<i16> {
|
||||
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<u32> { 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
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Sprm decoding ([MS-DOC] §2.6). A Prl is a 2-byte opcode followed by an operand whose
|
||||
//! size comes from the opcode's `spra` field (with two variable-length exceptions).
|
||||
|
||||
/// `sprmPChgTabs`: custom tab stops added and removed ([MS-DOC] §2.6.3, PChgTabsOperand).
|
||||
pub(crate) const P_CHG_TABS: u16 = 0xC615;
|
||||
/// `sprmPChgTabsPapx`: the same for paragraph and style formatting (PChgTabsPapxOperand).
|
||||
pub(crate) const P_CHG_TABS_PAPX: u16 = 0xC60D;
|
||||
/// `sprmTDefTable`: table cell borders and widths.
|
||||
const T_DEF_TABLE: u16 = 0xD608;
|
||||
|
||||
/// One decoded Prl: an opcode plus its operand bytes.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Prl<'a> {
|
||||
pub(crate) op: u16,
|
||||
pub(crate) operand: &'a [u8],
|
||||
}
|
||||
|
||||
impl Prl<'_> {
|
||||
/// Which kind of property this sprm modifies: 1 paragraph, 2 character, 3 picture,
|
||||
/// 4 section, 5 table ([MS-DOC] Sprm.sgc).
|
||||
pub(crate) fn sgc(&self) -> u8 {
|
||||
((self.op >> 10) & 7) as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterate the Prls of a grpprl. Malformed tails are dropped.
|
||||
pub(crate) fn iter(grpprl: &[u8]) -> impl Iterator<Item = Prl<'_>> {
|
||||
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<u16> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! The stylesheet (`STSH`) and the font table (`SttbfFfn`) from the Table stream
|
||||
//! ([MS-DOC] §2.9.271 and §2.9.247).
|
||||
|
||||
use crate::DocbinError;
|
||||
|
||||
/// Largest stylesheet we accept (styles are 0x0FFE max by the spec; stay stricter).
|
||||
const MAX_STYLES: usize = 4096;
|
||||
|
||||
/// One raw style record (`STD`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct RawStyle {
|
||||
/// 1 paragraph, 2 character, 3 table, 4 numbering.
|
||||
pub(crate) stk: u8,
|
||||
/// Zero-based index of the parent style; 0x0FFF = none.
|
||||
pub(crate) istd_base: u16,
|
||||
pub(crate) name: String,
|
||||
/// Paragraph-formatting UPX grpprl (paragraph styles).
|
||||
pub(crate) papx: Vec<u8>,
|
||||
/// Character-formatting UPX grpprl (paragraph and character styles).
|
||||
pub(crate) chpx: Vec<u8>,
|
||||
}
|
||||
|
||||
/// 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<u16>,
|
||||
pub(crate) east_asian: Option<u16>,
|
||||
pub(crate) other: Option<u16>,
|
||||
pub(crate) bidi: Option<u16>,
|
||||
}
|
||||
|
||||
/// `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<Vec<RawStyle>, 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<u16> = 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<u8>> = 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<String> {
|
||||
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<u16> { 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<u16> = 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;
|
||||
@@ -0,0 +1,519 @@
|
||||
//! Tables: the table sprms (sgc 5) carried by a row-end (TTP) mark's grpprl, and the
|
||||
//! assembly of cell-ended paragraphs into [`Table`] blocks ([MS-DOC] §2.6.3, §2.4.3).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use 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<f32>,
|
||||
height: Option<f32>,
|
||||
height_rule: HeightRule,
|
||||
header: bool,
|
||||
cant_split: bool,
|
||||
align: Option<Align>,
|
||||
indent: Option<f32>,
|
||||
borders: Option<Borders>,
|
||||
/// 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<Rgb>)>,
|
||||
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<f32> {
|
||||
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<Rgb> {
|
||||
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<f32> {
|
||||
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<f32> {
|
||||
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<T>(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<i16> = None;
|
||||
let mut dxa_left: Option<i16> = 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<Box<RowInfo>>,
|
||||
}
|
||||
|
||||
/// 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<ParaOut>) -> Blocks {
|
||||
let mut out: Blocks = Vec::new();
|
||||
let mut rows: Vec<Row> = Vec::new();
|
||||
let mut cells: Vec<Cell> = Vec::new();
|
||||
let mut cell: Blocks = Vec::new();
|
||||
let mut grid: Vec<f32> = Vec::new();
|
||||
let mut props = TableProps::default();
|
||||
let mut open = false;
|
||||
let mut depth_seen = 0u32;
|
||||
|
||||
fn flush_cell(cells: &mut Vec<Cell>, 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<Cell>, 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<Cell> = 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<Row>, cells: &mut Vec<Cell>, 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<f32> {
|
||||
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<Borders> {
|
||||
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<Rgb> {
|
||||
let cv = |b: &[u8]| -> Option<Rgb> {
|
||||
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<Rgb> {
|
||||
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<u16> {
|
||||
match p.operand {
|
||||
[b0, b1, ..] => Some(u16::from_le_bytes([*b0, *b1])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn s16(p: &Prl) -> Option<i16> {
|
||||
u16_of(p).map(|v| v as i16)
|
||||
}
|
||||
|
||||
fn i32_of(p: &Prl) -> Option<i32> {
|
||||
match p.operand {
|
||||
[b0, b1, b2, b3, ..] => Some(i32::from_le_bytes([*b0, *b1, *b2, *b3])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,653 @@
|
||||
//! Pictures and drawings: OfficeArt records ([MS-ODRAW]) hand-built for these tests from the
|
||||
//! specification, with tiny pictures generated in code. No file produced by Word is used.
|
||||
|
||||
use 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<u8> {
|
||||
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<u8>]) -> Vec<u8> {
|
||||
rec(0xF, 0, typ, &kids.concat())
|
||||
}
|
||||
|
||||
/// `OfficeArtFSP`: shape type, spid, flags.
|
||||
fn fsp(spt: u16, spid: u32, flags: u32) -> Vec<u8> {
|
||||
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<u8>)]) -> Vec<u8> {
|
||||
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<u8>]) -> Vec<u8> {
|
||||
container(0xF004, kids)
|
||||
}
|
||||
|
||||
/// A real 2×2 PNG.
|
||||
fn tiny_png() -> Vec<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
[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<u8>], shapes: &[Vec<u8>]) -> Vec<u8> {
|
||||
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<u8> {
|
||||
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<Option<u32>> =
|
||||
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<u8> {
|
||||
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<u8> = [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::<u8>(), 0..2048), spas in proptest::collection::vec(any::<u8>(), 0..512), data in proptest::collection::vec(any::<u8>(), 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<u8> {
|
||||
let mut dib = [40u32, 1, 1].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>();
|
||||
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::<Vec<_>>());
|
||||
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::<Vec<_>>())]);
|
||||
let members: Vec<Vec<u8>> = (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<u32> = (1..=n + 1).collect();
|
||||
let spas: Vec<u8> = (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<usize> =
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<Vec<u8>> = (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<Vec<u8>> = 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::<Vec<_>>();
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Password-protected files. The fixtures are encrypted here with a small encryptor written from
|
||||
//! [MS-DOC] §2.2.6 and [MS-OFFCRYPTO] §2.3.5-2.3.7; no file produced by Word is used.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
use super::{Piece, body_texts, cfb_file, text_doc};
|
||||
use crate::DocbinError;
|
||||
use crate::crypt::{F_ENCRYPTED, F_OBFUSCATED, Rc4, md5, xor_array, xor_verifier};
|
||||
use crate::{is_encrypted, read, read_with_password};
|
||||
|
||||
const TEXT: &str = "Secret \u{20ac} plans";
|
||||
/// Where the piece table's pair lives in the FIB: `fcClx` of pair 33.
|
||||
const CLX_PAIR: usize = 0x9A + 33 * 8;
|
||||
|
||||
fn plain() -> (Vec<u8>, Vec<u8>) {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
s.encode_utf16().flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
|
||||
fn sha1(parts: &[&[u8]]) -> Vec<u8> {
|
||||
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<u8>) {
|
||||
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<u8>, table: &[u8], header: &[u8], flags: u16, lkey: u32) -> (Vec<u8>, Vec<u8>) {
|
||||
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<u8> {
|
||||
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<u8>, Box<dyn Fn(u32) -> Vec<u8>>) {
|
||||
let salt = [7u8; 16];
|
||||
let h0 = md5(&utf16(pw));
|
||||
let h1 = md5(&(0..16).flat_map(|_| [&h0[..5], &salt[..]].concat()).collect::<Vec<u8>>());
|
||||
let trunc = h1[..5].to_vec();
|
||||
let key: Box<dyn Fn(u32) -> Vec<u8>> = 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<u8>, Box<dyn Fn(u32) -> Vec<u8>>) {
|
||||
let salt = [9u8; 16];
|
||||
let h0 = sha1(&[&salt, &utf16(pw)]);
|
||||
let n = (bits / 8) as usize;
|
||||
let key: Box<dyn Fn(u32) -> Vec<u8>> = 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<u8>, key: &dyn Fn(u32) -> Vec<u8>) -> Vec<u8> {
|
||||
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<u8> {
|
||||
let (h, k) = rc4_header(pw);
|
||||
rc4_file_with(h, &*k)
|
||||
}
|
||||
|
||||
fn capi_file(pw: &str, bits: u32) -> Vec<u8> {
|
||||
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());
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! OLE property sets constructed from MS-OLEPS, exercised through the public DOC reader.
|
||||
use super::{Piece, body_texts, text_doc};
|
||||
use std::io::{Cursor, Seek, SeekFrom, Write};
|
||||
|
||||
fn summary(cp: u16, values: &[(u32, u32, Vec<u8>)]) -> Vec<u8> {
|
||||
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<u8> = "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<u8>)], num_property_sets: u32) -> Vec<u8> {
|
||||
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");
|
||||
}
|
||||
Reference in new issue
Block a user