Add craft-doc-binary: WordCraft's .doc reader on craft-wordmodel and craft-cfb

Add craft-doc-binary: WordCraft's .doc reader on craft-wordmodel and craft-cfb

* 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.

* craft-doc-binary: on craft-wordmodel, craft-cfb and craft-codepage

- The compound file is read with craft-cfb (bounded directory, streams refused
  above 1 GiB before anything is allocated), in place of the cfb crate and
  WordCraft's cfb_guard precheck; an oversized stream is DocbinError::Limit.
- 8-bit text uses craft-codepage in place of wordcraft_doc::encoding.
- wordcraft-doc is craft-wordmodel; wordcraft-geom's helpers are its geom.

The tests keep building their files with the cfb crate (a dev-dependency), so
craft-cfb's reader is checked against another implementation's writer. The
repeating-DIFAT regression is craft-cfb's own; the guard asserts in the
valid-files test go with the guard.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 22:43:11 -05:00
1 parent d1f10a9612
commit d516b44691
25 files changed
+9671

No files matched your search

Generated
+29
View File
@@ -618,6 +618,17 @@ dependencies = [
"shlex",
]
[[package]]
name = "cfb"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f13298d9178622b7916ec5d01d470425c04273c8ed07e87b1c38b34c4f4612b"
dependencies = [
"fnv",
"uuid",
"web-time",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -1057,6 +1068,24 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-doc-binary"
version = "0.1.0"
dependencies = [
"cfb",
"chrono",
"craft-cfb",
"craft-codepage",
"craft-wordmodel",
"image",
"log",
"miniz_oxide 0.9.1",
"proptest",
"serde_json",
"sha1 0.11.0",
"thiserror 2.0.21",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
+1
View File
@@ -37,6 +37,7 @@ craft-crw = { path = "crates/crw" }
craft-csv = { path = "crates/csv" }
craft-dav = { path = "crates/dav" }
craft-dialogs = { path = "crates/dialogs" }
craft-doc-binary = { path = "crates/doc-binary" }
craft-docx = { path = "crates/docx" }
craft-dxf = { path = "crates/dxf" }
craft-drawingml = { path = "crates/drawingml" }
+2
View File
@@ -66,6 +66,8 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
- [`craft-doc`](crates/doc/README.md): reads PhotoCraft `.pcraft`, VectorCraft `.vectorcraft`
and EffectCraft `.ecproj` documents without the apps: the kind and version, the previews and
PDF they carry, and their pages and layers. Rendering and editing stay in each app.
- [`craft-doc-binary`](crates/doc-binary/README.md): the Word 97-2003 binary format (`.doc`, [MS-DOC]) read into
`craft-wordmodel`, with XOR, RC4 and RC4 CryptoAPI password protection. WordCraft's reader, on `craft-cfb`.
- [`craft-docpath`](crates/docpath/src/lib.rs): decides whether a path or `file:` URL stored in a
document may be opened. Paths stored in documents are followed only when they are local or on the
document's own network share; other network paths, device paths and non-file URLs are refused.
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "craft-doc-binary"
description = "Reader for the Word 97-2003 binary file format (.doc, [MS-DOC]) into the craft-wordmodel document model, with XOR, RC4 and RC4 CryptoAPI password protection."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
craft-cfb = { workspace = true }
craft-codepage = { workspace = true }
craft-wordmodel = { workspace = true }
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
image = { version = "0.25", default-features = false, features = ["png", "bmp"] }
log = "0.4"
miniz_oxide = "0.9"
sha1 = "0.11"
thiserror = { workspace = true }
[dev-dependencies]
# Test files are written with an independent compound-file writer, so craft-cfb's reader is
# checked against another implementation's output.
cfb = "0.15"
craft-wordmodel = { workspace = true, features = ["test-support"] }
proptest = { workspace = true }
serde_json = "1"
+26
View File
@@ -0,0 +1,26 @@
# craft-doc-binary
Reader for the Word 97-2003 binary file format (`.doc`, [MS-DOC]) into the `craft-wordmodel`
document model. It is WordCraft's reader, on `craft-cfb` for the compound file and
`craft-codepage` for 8-bit text.
```rust
// Not a compound file: an error, never a panic.
assert!(craft_doc_binary::read(b"not a .doc").is_err());
assert!(!craft_doc_binary::is_encrypted(b"not a .doc"));
```
- `read(bytes)`, `read_with_password(bytes, Option<&str>)`, `is_encrypted(bytes)`.
- Text in pieces (8-bit and UTF-16), paragraph and character formatting, styles, lists,
tables, sections, headers and footers, notes, comments, fields, bookmarks, pictures and
OfficeArt shapes, document properties.
- XOR obfuscation, RC4 and RC4 CryptoAPI password protection.
There is no writer.
## Limits
Streams (1 GiB each, refused by `craft-cfb` before anything is allocated), paragraphs, pieces,
property counts, table nesting and drawing records are capped; the constants are in the source
next to what they bound, and each has a test. Hostile input gives an error or a best-effort
document, never a panic.
+455
View File
@@ -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);
}
}
+283
View File
@@ -0,0 +1,283 @@
//! Floating shapes and pictures: the `PlcfSpa` anchors of the main document and the headers
//! ([MS-DOC] §2.8.27 PlcfSpa, §2.9.253 Spa) joined with the OfficeArt shapes they name, and the
//! text box stories (`PlcftxbxTxt`, §2.8.30) that fill text boxes.
use std::collections::HashMap;
use craft_wordmodel::fill::Fill;
use craft_wordmodel::para::{Anchor, Float, FloatAlign, GroupChild, InlineObject, ShapeKind, Wrap};
use craft_wordmodel::props::Rgb;
use crate::media::Pictures;
use crate::officeart::{Node, Sp};
/// Most anchors (and text box stories) read per PLC.
const MAX_ANCHORS: usize = 10_000;
/// `Spa` and `FTXBXS` record sizes.
const CB_SPA: usize = 26;
const CB_FTXBXS: usize = 22;
/// Largest frame or offset kept (points; 22 inches, Word's own limit for sizes).
const MAX_PT: f32 = 1584.0;
/// Deepest group nesting flattened.
const MAX_GROUP_DEPTH: usize = 8;
/// The (CP, data) entries of a PLC with `cb`-byte data elements, the trailing CP dropped.
fn plc(table: &[u8], (fc, lcb): (u32, u32), cb: usize) -> Vec<(u32, &[u8])> {
let Some(blob) = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize)) else { return Vec::new() };
let n = blob.len().saturating_sub(4) / (4 + cb);
let n = n.min(MAX_ANCHORS);
let data_at = (n + 1) * 4;
(0..n)
.filter_map(|i| {
let cp = u32::from_le_bytes(blob.get(i * 4..i * 4 + 4)?.try_into().ok()?);
let at = data_at + i * cb;
Some((cp, blob.get(at..at + cb)?))
})
.collect()
}
/// The text box stories of a `PlcftxbxTxt` (or `PlcfHdrtxbxTxt`): (shape spid, story CP range
/// relative to the text box subdocument).
///
/// The CPs of a well-formed PLC ascend, so the stories are disjoint. A story starting before
/// the end of the last one kept (a hostile table pointing many text boxes at the same text,
/// each read into its own part) is dropped: every subdocument CP becomes at most one story.
pub(crate) fn textbox_stories(table: &[u8], pair: Option<(u32, u32)>) -> Vec<(u32, (u32, u32))> {
let Some(pair) = pair else { return Vec::new() };
let Some(blob) = table.get(pair.0 as usize..(pair.0 as usize).saturating_add(pair.1 as usize)) else { return Vec::new() };
let mut out = Vec::new();
let mut kept_to = 0u32;
for (i, (cp, ftxbxs)) in plc(table, pair, CB_FTXBXS).iter().enumerate() {
// The story runs to the next entry's CP (the PLC's last CP for the last entry).
let Some(end) = blob.get((i + 1) * 4..(i + 1) * 4 + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes) else { continue };
let Some(lid) = ftxbxs.get(14..18).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes) else { continue };
if end > *cp && *cp >= kept_to && lid != 0 {
out.push((lid, (*cp, end)));
kept_to = end;
}
}
out
}
/// What building the floating objects needs.
pub(crate) struct Build<'a> {
/// The BLIP store and the stream holding BLIPs not embedded in it (WordDocument).
pub(crate) bstore: &'a [&'a [u8]],
pub(crate) delay: &'a [u8],
/// Text box part ids by shape spid.
pub(crate) stories: &'a HashMap<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 = craft_wordmodel::geom::normalize_degrees(if sp.props.rot.is_finite() { sp.props.rot } else { 0.0 });
// A shape turned by 45°–135° (or 225°–315°) is anchored by its turned bounds: the
// unrotated frame has the other aspect, about the same centre.
if (45.0..135.0).contains(&rot) || (225.0..315.0).contains(&rot) {
x += (w - h) / 2.0;
y += (h - w) / 2.0;
std::mem::swap(&mut w, &mut h);
}
let mut float = spa_float(flags, &sp.props);
float.x = x;
float.y = y;
float.rot = rot;
float.flip_h = sp.flip_h();
float.flip_v = sp.flip_v();
let obj = match node {
Node::Shape(sp) => leaf(sp, w, h, float, b, pics),
Node::Group(sp, members) => group(sp, members, w, h, float, b, pics),
};
match obj {
Some(obj) => out.push((cp_base.saturating_add(cp), obj)),
None => pics.dropped(cp_base.saturating_add(cp)),
}
}
out.sort_by_key(|(cp, _)| *cp);
out
}
/// Placement from a `Spa`'s flags (fHdr, bx, by, wr, wrk, fRcaSimple, fBelowText, fAnchorLock)
/// and the shape's position properties, which take precedence when present.
fn spa_float(flags: u16, p: &crate::officeart::Props) -> Float {
let bx = (flags >> 1) & 3;
let by = (flags >> 3) & 3;
let wr = (flags >> 5) & 0xF;
let below = flags & (1 << 14) != 0;
let wrap = match wr {
1 => Wrap::TopAndBottom,
3 => {
if below {
Wrap::BehindText
} else {
Wrap::InFrontOfText
}
}
4 => Wrap::Tight,
5 => Wrap::Through,
_ => Wrap::Square,
};
let h_rel = match p.posrelh.unwrap_or(bx as u32) {
0 => Anchor::Margin,
1 => Anchor::Page,
3 => Anchor::Character,
_ => Anchor::Column,
};
let v_rel = match p.posrelv.unwrap_or(by as u32) {
0 => Anchor::Margin,
1 => Anchor::Page,
3 => Anchor::Line,
_ => Anchor::Paragraph,
};
let align = |v: Option<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() >= craft_wordmodel::para::MAX_GROUP_CHILDREN {
return;
}
let sp = n.sp();
let Some([l, t, r, btm]) = sp.child else { continue };
let (ox, oy) = (l as f32, t as f32);
let (ew, eh) = ((r as f64 - l as f64) as f32, (btm as f64 - t as f64) as f32);
match n {
Node::Group(_, members) => {
if depth >= MAX_GROUP_DEPTH {
continue;
}
let [gl, gt, gr, gb] = sp.group_space.unwrap_or([l, t, r, btm]);
let (gw, gh) = ((gr as f64 - gl as f64) as f32, (gb as f64 - gt as f64) as f32);
let (kx, ky) = if gw > 0.0 && gh > 0.0 { (ew / gw, eh / gh) } else { (1.0, 1.0) };
let nt = (fit(x0 + (ox - gl as f32 * kx) * sx), fit(y0 + (oy - gt as f32 * ky) * sy), fit(sx * kx), fit(sy * ky));
members_into(members, nt, depth + 1, b, pics, out);
}
Node::Shape(sp) => {
let float = Float { rot: sp.props.rot, flip_h: sp.flip_h(), flip_v: sp.flip_v(), ..Float::default() };
let (w, h) = (fit(ew * sx).max(0.0), fit(eh * sy).max(0.0));
if let Some(obj) = leaf(sp, w, h, float, b, pics) {
out.push(GroupChild { x: fit(x0 + ox * sx), y: fit(y0 + oy * sy), obj });
}
}
}
}
}
+185
View File
@@ -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 })
}
}
+147
View File
@@ -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(&[]))
}
}
+572
View File
@@ -0,0 +1,572 @@
//! Sprms → model properties, and style-chain resolution.
use craft_wordmodel::Rgb;
use craft_wordmodel::props::{
Align, Border, BorderStyle, Borders, CharProps, Highlight, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop, TextColor, Underline, VertAlign,
};
use craft_wordmodel::styles::{Style, StyleKind, StyleSheet};
use crate::sprm::{self, Prl, Toggle};
// Character sprms ([MS-DOC] §2.6.1).
const C_BOLD: u16 = 0x0835;
const C_ITALIC: u16 = 0x0836;
const C_STRIKE: u16 = 0x0837;
const C_SMALL_CAPS: u16 = 0x083A;
const C_CAPS: u16 = 0x083B;
const C_HIDDEN: u16 = 0x083C; // sprmCFVanish
const C_KUL: u16 = 0x2A3E; // underline style (Kul)
const C_ICO: u16 = 0x2A42; // palette colour
const C_CR: u16 = 0x6870; // explicit COLORREF
const C_HPS: u16 = 0x4A43; // half-points
const C_HPS_POS: u16 = 0x4845; // super/subscript by half-points
const C_FTC0: u16 = 0x4A4F; // font index (ASCII)
const C_FTC1: u16 = 0x4A50; // East Asian font index (sprmCRgFtc1)
const C_FTC_BI: u16 = 0x4A5E; // complex-script font index (sprmCFtcBi)
const C_HIGHLIGHT: u16 = 0x2A0C;
const C_ISTD: u16 = 0x4A30; // character style
// Paragraph sprms ([MS-DOC] §2.6.2).
const P_JC: u16 = 0x2461;
const P_JC80: u16 = 0x2403;
const P_DXA_LEFT: u16 = 0x845E;
const P_DXA_LEFT80: u16 = 0x840F;
const P_DXA_RIGHT: u16 = 0x845D;
const P_DXA_RIGHT80: u16 = 0x840E;
const P_DXA_FIRST: u16 = 0x8460; // sprmPDxaLeft1
const P_DXA_FIRST80: u16 = 0x8411; // sprmPDxaLeft1_80
// Indents in hundredths of a character (sprmPDxcRight, sprmPDxcLeft, sprmPDxcLeft1).
const P_DXC_RIGHT: u16 = 0x4455;
const P_DXC_LEFT: u16 = 0x4456;
const P_DXC_FIRST: u16 = 0x4457;
const P_DYA_BEFORE: u16 = 0xA413;
const P_DYA_AFTER: u16 = 0xA414;
const P_DYA_LINE: u16 = 0x6412;
const P_KEEP: u16 = 0x2405; // keep lines together
const P_KEEP_FOLLOW: u16 = 0x2406; // keep with next
const P_PAGE_BREAK_BEFORE: u16 = 0x2407;
const P_OUTLVL: u16 = 0x2640;
// Paragraph borders ([MS-DOC] §2.6.2). The 80 forms carry a Brc80 (palette colour), the
// newer ones a BrcOperand: cb(1) + Brc(8, with a full COLORREF).
const P_BRC_TOP80: u16 = 0x6424;
const P_BRC_LEFT80: u16 = 0x6425;
const P_BRC_BOTTOM80: u16 = 0x6426;
const P_BRC_RIGHT80: u16 = 0x6427;
const P_BRC_BETWEEN80: u16 = 0x6428;
const P_BRC_TOP: u16 = 0xC64E;
const P_BRC_LEFT: u16 = 0xC64F;
const P_BRC_BOTTOM: u16 = 0xC650;
const P_BRC_RIGHT: u16 = 0xC651;
const P_BRC_BETWEEN: u16 = 0xC652;
/// Word's 16-colour `Ico` palette ([MS-DOC] §2.9.135), 0 = automatic.
const ICO: [Rgb; 17] = [
Rgb(0, 0, 0),
Rgb(0, 0, 0),
Rgb(0, 0, 255),
Rgb(0, 255, 255),
Rgb(0, 255, 0),
Rgb(255, 0, 255),
Rgb(255, 0, 0),
Rgb(255, 255, 0),
Rgb(255, 255, 255),
Rgb(0, 0, 128),
Rgb(0, 128, 128),
Rgb(0, 128, 0),
Rgb(128, 0, 128),
Rgb(128, 0, 0),
Rgb(128, 128, 0),
Rgb(128, 128, 128),
Rgb(192, 192, 192),
];
/// Highlight palette ([MS-DOC] §2.9.135 Highlight, order of Word's highlighter row).
const HL: [Highlight; 17] = [
Highlight::None,
Highlight::Yellow,
Highlight::BrightGreen,
Highlight::Turquoise,
Highlight::Pink,
Highlight::Blue,
Highlight::Red,
Highlight::DarkBlue,
Highlight::Teal,
Highlight::Green,
Highlight::Violet,
Highlight::DarkRed,
Highlight::DarkYellow,
Highlight::Gray50,
Highlight::Gray25,
Highlight::None,
Highlight::None,
];
/// The `Ico` palette colour (0 = automatic → `None`).
pub(crate) fn ico(i: u8) -> Option<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
+302
View File
@@ -0,0 +1,302 @@
//! Lists: `PlfLst` (the LSTF definitions with their LVLs appended) and `PlfLfo` (the LFO
//! instances with LFOLVL overrides) resolved into the numbering model
//! ([MS-DOC] §2.8.1 "Determining List Formatting of a Paragraph", §2.9).
use craft_wordmodel::numbering::{AbstractNum, Level, LevelSuffix, Num, Numbering};
use craft_wordmodel::props::{Align, CharProps, ParaProps};
use craft_wordmodel::section::NumFormat;
use crate::fib::{Fib, pair};
use crate::fmt;
use crate::sprm;
/// Lists (and therefore LFOs) we are willing to read.
const MAX_LISTS: usize = 2_000;
/// Override levels per LFO ([MS-DOC] LFOLVL count is at most 9 by construction).
const MAX_LFO_LVL: usize = 9;
/// One list definition: an LSTF plus its levels (one if `fSimpleList`, else nine).
struct ListDef {
lsid: i32,
levels: Vec<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()
}
}
+277
View File
@@ -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"
}
}
+182
View File
@@ -0,0 +1,182 @@
//! Optional document text properties from [MS-OLEPS] §2.21 and §2.25.1.
//! Broken metadata must not prevent opening the document or allocate from unchecked sizes.
use craft_wordmodel::CoreProps;
const MAX_STREAM: u64 = 2 << 20;
const MAX_STRING: usize = 64 << 10;
const SUMMARY_FMTID: &[u8] = &[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0x00, 0x2b, 0x27, 0xb3, 0xd9];
pub(crate) fn read(comp: &craft_cfb::CompoundFile<'_>) -> Option<CoreProps> {
parse(&comp.read_path_capped("\u{5}SummaryInformation", MAX_STREAM).ok()?)
}
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 craft_codepage::SINGLE_BYTE_CODE_PAGES.contains(&codepage) {
Some(bytes.iter().map(|b| craft_codepage::windows_byte(codepage, *b)).collect())
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::filetime_iso;
/// A `FILETIME` property (VT_FILETIME = 64) as `YYYY-MM-DDTHH:MM:SSZ`. The values are pinned
/// to what the old hand-rolled `civil` produced.
fn value(ft: u64) -> Vec<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);
}
}
+193
View File
@@ -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| craft_codepage::cp1252(c)).collect()
};
out.push(s);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::sttb_strings;
fn ext_sttb(names: &[&str], extra: &[u8]) -> Vec<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()]);
}
}
+552
View File
@@ -0,0 +1,552 @@
//! OfficeArt (Escher) drawing records, as Word 97-2003 stores them ([MS-ODRAW], public Microsoft
//! Open Specifications): the record header, the BLIP store and its pictures, shape containers with
//! their property tables, and the `OfficeArtContent` of the Table stream ([MS-DOC] §2.9.172).
//!
//! Everything here walks hostile bytes: record lengths are clamped to their parent, nesting depth
//! and record counts are capped, and decompressed metafiles are size-limited.
use std::collections::HashMap;
use std::io::Write;
use image::ImageEncoder;
use craft_wordmodel::props::Rgb;
/// Deepest container nesting walked (groups inside groups…).
const MAX_DEPTH: usize = 12;
/// Most records walked in one drawing tree or BLIP store.
const MAX_RECORDS: usize = 200_000;
/// Most BLIP store entries kept.
const MAX_BSE: usize = 10_000;
/// Most drawings (main document, headers) read from `OfficeArtContent`.
const MAX_DRAWINGS: usize = 8;
/// Largest decompressed metafile (bytes).
const MAX_METAFILE: usize = 64 << 20;
/// Largest DIB converted to PNG, per side (pixels).
const MAX_DIB_SIDE: u32 = 16_384;
/// Record types ([MS-ODRAW] §2.2, §2.3).
pub(crate) mod rt {
pub(crate) const DGG_CONTAINER: u16 = 0xF000;
pub(crate) const BSTORE_CONTAINER: u16 = 0xF001;
pub(crate) const DG_CONTAINER: u16 = 0xF002;
pub(crate) const SPGR_CONTAINER: u16 = 0xF003;
pub(crate) const SP_CONTAINER: u16 = 0xF004;
pub(crate) const FBSE: u16 = 0xF007;
pub(crate) const FSPGR: u16 = 0xF009;
pub(crate) const FSP: u16 = 0xF00A;
pub(crate) const FOPT: u16 = 0xF00B;
pub(crate) const CLIENT_TEXTBOX: u16 = 0xF00D;
pub(crate) const CHILD_ANCHOR: u16 = 0xF00F;
pub(crate) const SECONDARY_FOPT: u16 = 0xF121;
pub(crate) const TERTIARY_FOPT: u16 = 0xF122;
pub(crate) const BLIP_EMF: u16 = 0xF01A;
pub(crate) const BLIP_WMF: u16 = 0xF01B;
pub(crate) const BLIP_PICT: u16 = 0xF01C;
pub(crate) const BLIP_JPEG: u16 = 0xF01D;
pub(crate) const BLIP_PNG: u16 = 0xF01E;
pub(crate) const BLIP_DIB: u16 = 0xF01F;
pub(crate) const BLIP_TIFF: u16 = 0xF029;
pub(crate) const BLIP_JPEG_CMYK: u16 = 0xF02A;
}
/// One record: header fields and its body (clamped to what the parent holds).
#[derive(Clone, Copy, Debug)]
pub(crate) struct Rec<'a> {
pub(crate) inst: u16,
pub(crate) typ: u16,
pub(crate) body: &'a [u8],
}
/// The record at the start of `buf` and the bytes it spans (header included). A body longer
/// than `buf` is cut to what is there.
pub(crate) fn record(buf: &[u8]) -> Option<(Rec<'_>, usize)> {
let h = buf.get(0..8)?;
let ver_inst = u16::from_le_bytes([h[0], h[1]]);
let typ = u16::from_le_bytes([h[2], h[3]]);
let len = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as usize;
let rest = buf.get(8..)?;
let len = len.min(rest.len());
let body = rest.get(..len)?;
Some((Rec { inst: ver_inst >> 4, typ, body }, 8 + len))
}
/// The sibling records of a container body, at most `budget` of them (the budget is shared
/// across a whole walk, so a deep or wide tree cannot multiply the work).
pub(crate) fn children<'a>(body: &'a [u8], budget: &mut usize) -> Vec<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
}
+230
View File
@@ -0,0 +1,230 @@
//! The piece table ([MS-DOC] `Clx`/`Pcdt`/`PlcPcd`): where every character position lives in
//! the WordDocument stream, and how it is encoded.
use craft_codepage::cp1252;
use crate::DocbinError;
/// Most pieces any real file has; more means a corrupt `PlcPcd`.
const MAX_PIECES: usize = 1_000_000;
/// One piece: text at FcCompressed `fc` (30-bit offset + compression bit).
#[derive(Clone, Copy, Debug)]
struct Pcd {
fc: u32,
compressed: bool,
/// `Prm`: either a single sprm (Prm0) or an index into the Clx's Prc grpprls (Prm1).
prm: u16,
}
/// The parsed piece table plus the grpprls of the Prcs that Prm1 references.
pub(crate) struct PieceTable {
/// n+1 character-position starts.
cps: Vec<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]>),
}
+195
View File
@@ -0,0 +1,195 @@
//! Sections and headers/footers: `PlcfSed` → `Sed` → `Sepx` (grpprls of section sprms) and
//! the `Plcfhdd` stories of the header subdocument ([MS-DOC] §2.8.25, §2.9.244).
use craft_wordmodel::section::NumFormat;
use craft_wordmodel::section::{Columns, SectionProps, SectionStart};
use crate::fib::{Fib, pair};
use crate::sprm::{self, Prl};
/// Most sections we read.
const MAX_SECTIONS: usize = 1024;
// Section sprms ([MS-DOC] §2.6.4).
const S_BKC: u16 = 0x3009;
const S_TITLE_PAGE: u16 = 0x300A; // sprmSFTitlePage
const S_CCOLUMNS: u16 = 0x500B;
const S_PGN_START: u16 = 0x300C; // sprmSPgnStart
const S_ORIENTATION: u16 = 0x301D; // sprmSBOrientation
const S_HDR_TOP: u16 = 0xB017;
const S_HDR_BOTTOM: u16 = 0xB018;
const S_XA_PAGE: u16 = 0xB01F;
const S_YA_PAGE: u16 = 0xB020;
const S_DXA_LEFT: u16 = 0xB021;
const S_DXA_RIGHT: u16 = 0xB022;
const S_DYA_TOP: u16 = 0x9023;
const S_DYA_BOTTOM: u16 = 0x9024;
const S_DXA_GUTTER: u16 = 0xB025;
/// One section: covers paragraphs up to (excluding) `end_cp`, with its properties.
pub(crate) struct Section {
pub(crate) end_cp: u32,
pub(crate) props: SectionProps,
}
/// Parse `PlcfSed` and each section's `Sepx` grpprl.
pub(crate) fn parse(word: &[u8], table: &[u8], fib: &Fib) -> Vec<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
}
+104
View File
@@ -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,
})
}
+194
View File
@@ -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| craft_codepage::cp1252(b)).collect()
}
};
// STTB header: [fExtend 0xFFFF] cData(2) cbExtra(2); each entry is followed by cbExtra
// bytes (0 for font tables, but skip them if present).
let (extended, cdata, cb_extra, mut at) =
if first == 0xFFFF { (true, u16at(2).unwrap_or(0), u16at(4).unwrap_or(0), 6usize) } else { (false, first, u16at(2).unwrap_or(0), 4usize) };
let mut out = Vec::new();
for _ in 0..cdata.min(1024) {
let (len_bytes, cb) = if extended {
let Some(cb) = u16at(at) else { break };
(2usize, usize::from(cb).saturating_mul(2))
} else {
let Some(&cb) = sttb.get(at) else { break };
(1usize, usize::from(cb))
};
let start = at.saturating_add(len_bytes);
let stop = start.saturating_add(cb);
// A record cut short by the end of the table still yields what it holds.
out.push(name_of(sttb.get(start..stop.min(sttb.len())).unwrap_or(&[])));
at = stop.saturating_add(usize::from(cb_extra));
}
out
}
/// Bytes of an FFN before its primary name ([MS-DOC] §2.9.82).
const FFN_HEADER: usize = 39;
+519
View File
@@ -0,0 +1,519 @@
//! Tables: the table sprms (sgc 5) carried by a row-end (TTP) mark's grpprl, and the
//! assembly of cell-ended paragraphs into [`Table`] blocks ([MS-DOC] §2.6.3, §2.4.3).
use std::sync::Arc;
use craft_wordmodel::Rgb;
use craft_wordmodel::para::Paragraph;
use craft_wordmodel::props::{Align, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge};
use craft_wordmodel::table::{Cell, Row, Table};
use craft_wordmodel::{Block, Blocks, para_block};
use crate::fmt;
use crate::sprm::{self, Prl};
// Paragraph sprms for table membership.
const P_IN_TABLE: u16 = 0x2416;
const P_TTP: u16 = 0x2417;
/// `sprmPItap`: table depth (we only model depth 1; deeper rows fall back to paragraphs).
const P_ITAP: u16 = 0x6649;
// Table sprms ([MS-DOC] §2.6.3).
const T_JC: u16 = 0x5400;
const T_JC_90: u16 = 0x548A;
const T_DXA_LEFT: u16 = 0x9601;
const T_DXA_GAP_HALF: u16 = 0x9602;
const T_TABLE_HEADER: u16 = 0x3404;
const T_TABLE_BORDERS_80: u16 = 0xD605;
const T_ROW_HEIGHT: u16 = 0x9407;
const T_DEF_TABLE: u16 = 0xD608;
const T_DEF_TABLE_SHD_80: u16 = 0xD609;
/// `sprmTDefTableShd` / `Shd2nd` / `Shd3rd`: 10-byte `Shd`s for cells 0-21, 22-43, 44-62.
const T_DEF_TABLE_SHD: u16 = 0xD612;
const T_DEF_TABLE_SHD_2ND: u16 = 0xD616;
const T_DEF_TABLE_SHD_3RD: u16 = 0xD60C;
/// `sprmTDefTableShdRaw` / `Raw2nd` / `Raw3rd`: the same `Shd` arrays over the same cell
/// ranges, which Word 2007+ writes next to the above.
const T_DEF_TABLE_SHD_RAW: u16 = 0xD670;
const T_DEF_TABLE_SHD_RAW_2ND: u16 = 0xD671;
const T_DEF_TABLE_SHD_RAW_3RD: u16 = 0xD672;
const T_CANT_SPLIT_90: u16 = 0x3403;
const T_MERGE: u16 = 0x5624;
const T_VERT_MERGE: u16 = 0xD62B;
const T_VERT_ALIGN: u16 = 0xD62C;
const T_SET_SHD: u16 = 0xD62D;
const T_CANT_SPLIT: u16 = 0x3466;
const T_CELL_WIDTH: u16 = 0xD635;
/// Most grid columns we build from a `sprmTDefTable` (the model's own cap is 63; one more
/// edge is fine but merged rows can sum spans slightly over, so clamp when assembling).
const MAX_COLS: usize = 63;
/// What a paragraph mark says about its table context, from its PAPX.
#[derive(Default)]
pub(crate) struct RowInfo {
pub(crate) in_table: bool,
pub(crate) ttp: bool,
/// The mark itself was 0x07 (a cell end), set by the walker.
pub(crate) cell_mark: bool,
/// Table depth from `sprmPItap` (0 or 1 supported; deeper tables degrade to paragraphs).
pub(crate) depth: u32,
/// Cell-edge positions (points) from `sprmTDefTable` — [MS-DOC] defines them per row,
/// so each row keeps its own; ascending in well-formed rows, the first edge usually 0
/// (the row origin). Rows without a `sprmTDefTable` keep this empty.
pub(crate) edges: Vec<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
+653
View File
@@ -0,0 +1,653 @@
//! Pictures and drawings: OfficeArt records ([MS-ODRAW]) hand-built for these tests from the
//! specification, with tiny pictures generated in code. No file produced by Word is used.
use craft_wordmodel::fill::Fill;
use craft_wordmodel::para::{Anchor, InlineObject, ShapeKind, Wrap};
use craft_wordmodel::props::Rgb;
use proptest::prelude::*;
use super::{StructSpec, grpprl, plc, read, struct_doc, structured_spec};
use crate::officeart;
/// One OfficeArt record: header (recVer, recInstance, recType, recLen) + body.
fn rec(ver: u16, inst: u16, typ: u16, body: &[u8]) -> Vec<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, craft_wordmodel::PartKind::TextBox);
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
}
#[test]
fn braces_and_brackets_are_unfilled_unless_filled_is_set() {
// A right brace (88) in front of the text with no fill properties, and a left bracket (85)
// whose fFilled is set: only the bracket is filled. A plain rectangle (1) keeps its default
// white fill.
let brace = sp(&[fsp(88, 1026, 0xA00)]);
let bracket = sp(&[fsp(85, 1027, 0xA00), fopt(&[(0x01BF, (1 << 20) | (1 << 4))], &[])]);
let rect = sp(&[fsp(1, 1028, 0xA00)]);
let art = art_content(&[], &[brace, bracket, rect]);
let flags = spa_flags(0, 0, 3, false);
let spas =
plc(&[1, 2, 3, 5], &[spa(1026, [0, 0, 240, 1140], flags), spa(1027, [0, 0, 240, 1140], flags), spa(1028, [0, 0, 240, 1140], flags)].concat());
let text = "A\u{8}\u{8}\u{8}B\r";
let ccp = text.chars().count() as u32;
let spec = StructSpec { text, extra_pairs: vec![(40, spas), (50, art)], ..structured_spec(text, ccp) };
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let fills: Vec<_> = p0
.objects
.iter()
.map(|o| match o {
InlineObject::Shape { fill, stroke, w, h, .. } => (fill.clone(), *stroke, *w, *h),
other => panic!("{other:?}"),
})
.collect();
assert_eq!(
fills,
[
(None, Some(Rgb::BLACK), 12.0, 57.0),
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
]
);
}
#[test]
fn text_boxes_pointing_at_the_same_story_text_read_it_once() {
// Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0
// in between): each was read into its own part, so a hostile table could copy one story
// thousands of times. The second story is dropped and its box stays empty.
let tb = |spid: u32| sp(&[fsp(202, spid, 0xA00), fopt(&[(0x01BF, 1 << 20)], &[]), rec(0, 0, 0xF00D, &0x0001_0000u32.to_le_bytes())]);
let art = art_content(&[], &[tb(1027), tb(1028)]);
let flags = spa_flags(0, 0, 1, false);
let spas = plc(&[1, 2, 4], &[spa(1027, [0, 0, 2880, 720], flags), spa(1028, [0, 0, 2880, 720], flags)].concat());
// FTXBXS: cTxbx, cReusable, fReusable, reserved, lid, txidUndo.
let ftxbxs = |lid: u32| {
[1i32.to_le_bytes().as_slice(), &0i32.to_le_bytes(), &0i16.to_le_bytes(), &0i32.to_le_bytes(), &lid.to_le_bytes(), &0i32.to_le_bytes()]
.concat()
};
let txbx = plc(&[0, 7, 0, 7], &[ftxbxs(1027), ftxbxs(1), ftxbxs(1028)].concat());
let text = "A\u{8}\u{8}B\r";
let ccp = text.chars().count() as u32;
// As above: the endnote slot holds the text box subdocument, then the FIB is patched.
let spec =
StructSpec { text, endnote_stories: vec!["Inside\r"], extra_pairs: vec![(40, spas), (50, art), (56, txbx)], ..structured_spec(text, ccp) };
let doc = read(&patch_word(&struct_doc(&spec), &[(0x60, 0), (0x64, 7)])).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let stories: Vec<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| craft_wordmodel::para::GroupChild { x: 0.0, y: 0.0, obj };
let group = InlineObject::Group {
w: 1.0,
h: 1.0,
float: Default::default(),
ch_w: 1.0,
ch_h: 1.0,
children: vec![member(image("x".repeat(100))), member(image(String::new()))],
canvas: None,
};
assert_eq!(crate::object_cost(&group), 3 * bare + 100);
}
/// A deflated WMF BLIP record declaring `size` uncompressed bytes.
fn packed_wmf(size: u32, packed: &[u8]) -> Vec<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);
}
+241
View File
@@ -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());
}
+164
View File
@@ -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) -> craft_wordmodel::Document {
let file = text_doc(&[Piece { compressed: false, text: "Body stays readable\r".into() }], &[], 0);
let mut cfb = cfb::CompoundFile::open(Cursor::new(file)).unwrap();
cfb.create_stream("/\u{5}SummaryInformation").unwrap().write_all(bytes).unwrap();
if template {
let mut stream = cfb.open_stream("/WordDocument").unwrap();
stream.seek(SeekFrom::Start(0x0a)).unwrap();
stream.write_all(&0x1001u16.to_le_bytes()).unwrap();
}
let doc = crate::read(&cfb.into_inner().into_inner()).unwrap();
assert_eq!(body_texts(&doc), ["Body stays readable"]);
doc
}
#[test]
fn doc_and_dot_import_summary_text_properties() {
let data = summary(
1252,
&[
(2, 30, b"R\xe9sum\xe9\0".to_vec()),
(3, 30, b"Subject\0".to_vec()),
(4, 30, b"Writer\0".to_vec()),
(5, 30, b"one;two\0".to_vec()),
(6, 30, b"Description\0".to_vec()),
(8, 30, b"Editor\0".to_vec()),
(9, 30, b"12\0".to_vec()),
],
);
for template in [false, true] {
let c = open_with_metadata(&data, template).core;
assert_eq!(c.title, "Résumé");
assert_eq!(c.subject, "Subject");
assert_eq!(c.creator, "Writer");
assert_eq!(c.keywords, "one;two");
assert_eq!(c.description, "Description");
assert_eq!(c.last_modified_by, "Editor");
assert_eq!(c.revision, 12);
}
}
#[test]
fn summary_handles_codepages_and_utf16_length_units() {
let wide: Vec<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");
}