Files
wordcraft/crates/docbin/src/officeart.rs
T
952eb57688 Respect the image byte limit during DOC DIB conversion (#1590)
* Engine: the Word 2003 format check reads through io, so commands still never touch files themselves

is_word_2003_file (#1081) opened the file with std::fs, which the file-access guard test
(commands_do_not_touch_the_file_system_themselves) forbids outside io.rs; main failed it.
A new io::read_file_head reads the first bytes inside the read root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UI: a chrome click counts the page as focused at once, so the next keystroke isn't dropped; the ribbon-focus test passes again

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: DOCのDIB画像変換でバイト上限を守る

* Drop the duplicate read_file_head left by the merge with main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: haranishi <223786623+haranishi@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:50:54 -04:00

493 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! OfficeArt (Escher) drawing records, as Word 97-2003 stores them ([MS-ODRAW], public Microsoft
//! Open Specifications): the record header, the BLIP store and its pictures, shape containers with
//! their property tables, and the `OfficeArtContent` of the Table stream ([MS-DOC] §2.9.172).
//!
//! Everything here walks hostile bytes: record lengths are clamped to their parent, nesting depth
//! and record counts are capped, and decompressed metafiles are size-limited.
use std::collections::HashMap;
use std::io::Write;
use image::ImageEncoder;
use wordcraft_doc::props::Rgb;
/// Deepest container nesting walked (groups inside groups…).
const MAX_DEPTH: usize = 12;
/// Most records walked in one drawing tree or BLIP store.
const MAX_RECORDS: usize = 200_000;
/// Most BLIP store entries kept.
const MAX_BSE: usize = 10_000;
/// Most drawings (main document, headers) read from `OfficeArtContent`.
const MAX_DRAWINGS: usize = 8;
/// Largest decompressed metafile (bytes).
const MAX_METAFILE: usize = 64 << 20;
/// Largest DIB converted to PNG, per side (pixels).
const MAX_DIB_SIDE: u32 = 16_384;
/// Record types ([MS-ODRAW] §2.2, §2.3).
pub(crate) mod rt {
pub(crate) const DGG_CONTAINER: u16 = 0xF000;
pub(crate) const BSTORE_CONTAINER: u16 = 0xF001;
pub(crate) const DG_CONTAINER: u16 = 0xF002;
pub(crate) const SPGR_CONTAINER: u16 = 0xF003;
pub(crate) const SP_CONTAINER: u16 = 0xF004;
pub(crate) const FBSE: u16 = 0xF007;
pub(crate) const FSPGR: u16 = 0xF009;
pub(crate) const FSP: u16 = 0xF00A;
pub(crate) const FOPT: u16 = 0xF00B;
pub(crate) const CLIENT_TEXTBOX: u16 = 0xF00D;
pub(crate) const CHILD_ANCHOR: u16 = 0xF00F;
pub(crate) const SECONDARY_FOPT: u16 = 0xF121;
pub(crate) const TERTIARY_FOPT: u16 = 0xF122;
pub(crate) const BLIP_EMF: u16 = 0xF01A;
pub(crate) const BLIP_WMF: u16 = 0xF01B;
pub(crate) const BLIP_PICT: u16 = 0xF01C;
pub(crate) const BLIP_JPEG: u16 = 0xF01D;
pub(crate) const BLIP_PNG: u16 = 0xF01E;
pub(crate) const BLIP_DIB: u16 = 0xF01F;
pub(crate) const BLIP_TIFF: u16 = 0xF029;
pub(crate) const BLIP_JPEG_CMYK: u16 = 0xF02A;
}
/// One record: header fields and its body (clamped to what the parent holds).
#[derive(Clone, Copy, Debug)]
pub(crate) struct Rec<'a> {
pub(crate) inst: u16,
pub(crate) typ: u16,
pub(crate) body: &'a [u8],
}
/// The record at the start of `buf` and the bytes it spans (header included). A body longer
/// than `buf` is cut to what is there.
pub(crate) fn record(buf: &[u8]) -> Option<(Rec<'_>, usize)> {
let h = buf.get(0..8)?;
let ver_inst = u16::from_le_bytes([h[0], h[1]]);
let typ = u16::from_le_bytes([h[2], h[3]]);
let len = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as usize;
let rest = buf.get(8..)?;
let len = len.min(rest.len());
let body = rest.get(..len)?;
Some((Rec { inst: ver_inst >> 4, typ, body }, 8 + len))
}
/// The sibling records of a container body, at most `budget` of them (the budget is shared
/// across a whole walk, so a deep or wide tree cannot multiply the work).
pub(crate) fn children<'a>(body: &'a [u8], budget: &mut usize) -> Vec<Rec<'a>> {
let mut out = Vec::new();
let mut at = 0usize;
while *budget > 0 {
let Some((rec, used)) = body.get(at..).and_then(record) else { break };
*budget -= 1;
out.push(rec);
at = at.saturating_add(used);
}
out
}
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
Some(u16::from_le_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?))
}
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
Some(u32::from_le_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?))
}
fn i32_at(b: &[u8], at: usize) -> Option<i32> {
u32_at(b, at).map(|v| v as i32)
}
// ---------------------------------------------------------------------------------------------
// Pictures (BLIPs).
/// A decoded picture: bytes ready for `Document::media` and their file extension.
pub(crate) struct Blip {
pub(crate) bytes: Vec<u8>,
pub(crate) ext: &'static str,
/// The format cannot be drawn (Macintosh PICT): the picture is kept as a placeholder of
/// the right size.
pub(crate) placeholder: bool,
}
/// The picture of an `OfficeArtFBSE` body: its embedded BLIP, or the BLIP stored at `foDelay`
/// in `delay` (the WordDocument stream for the document's BLIP store, the Data stream for
/// inline pictures). `limit` caps the bytes produced.
pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize) -> Option<Blip> {
// btWin32, btMacOS, rgbUid[16], tag, size, cRef, foDelay, unused, cbName, unused×2.
let size = u32_at(fbse, 20)? as usize;
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 blip(&rec, limit);
}
// Not embedded: the record sits in the delay stream.
let end = fo_delay.checked_add(size.max(8))?.min(delay.len());
let (rec, _) = record(delay.get(fo_delay..end)?)?;
blip(&rec, limit)
}
/// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31).
pub(crate) fn blip(rec: &Rec, limit: usize) -> 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),
}
}
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 };
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 the BMP bytes are kept (and render as a grey placeholder).
fn dib_to_png(dib: &[u8], limit: usize) -> 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)?;
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 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 })
}
_ => Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }),
}
}
/// 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),
0x01BF => {
// fFilled (bit 4) is valid when fUsefFilled (bit 20) is set.
if op & (1 << 20) != 0 {
self.filled = Some(op & (1 << 4) != 0);
}
}
0x01C0 => self.line_color = color(op),
0x01CB => self.line_width = Some(op),
0x01D0 | 0x01D1 => self.arrowhead |= op != 0,
0x01FF => {
// fLine (bit 3) is valid when fUsefLine (bit 19) is set.
if op & (1 << 19) != 0 {
self.line = Some(op & (1 << 3) != 0);
}
}
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
}