mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
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.
195 lines
9.8 KiB
Rust
195 lines
9.8 KiB
Rust
//! 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;
|