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.
194 lines
7.4 KiB
Rust
194 lines
7.4 KiB
Rust
//! 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()]);
|
|
}
|
|
}
|