* .doc import: inline pictures, floating pictures and basic shapes (#551) Word 97-2003 files kept their text but dropped every picture and drawing. Pictures and drawings are now read from the OfficeArt records ([MS-DOC], [MS-ODRAW]): - Inline pictures: the PICF's OfficeArtInlineSpContainer (MM_SHAPE / MM_SHAPEFILE) and its FBSE/BLIP, with crop and alt text from the shape properties; the older raw-image path stays as a fallback. - Floating pictures and shapes: PlcSpaMom/PlcSpaHdr anchors joined with the shapes of the OfficeArtContent drawings (fcDggInfo), placed by the SPA bounds, bx/by or posrelh/posrelv/posh/posv, wrap type and fBelowText; rotation and flips; pictures from the BLIP store. - Shapes: rectangle, rounded rectangle, ellipse, diamond, triangles, star, block arrows, heart, lines/connectors, with fill/line colour and width; groups flattened into one group space; text boxes linked to their PlcftxbxTxt story parts by spid. - BLIPs: PNG, JPEG, TIFF as-is, DIB converted to PNG, EMF/WMF inflated (the metafile renderer draws them), PICT kept as a sized placeholder. - Hostile input: record lengths clamped to their parent, nesting depth and record counts capped, decompression size-limited, picture bytes capped per document as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Review fixes for #558: miniz_oxide as a workspace dependency Restores the change @echelon pushed in 04b59287, which a rebase dropped: miniz_oxide is declared once in the root [workspace.dependencies] and docbin uses it with workspace = true. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
4611afba16
commit
6801d89065
|
|
@ -5056,7 +5056,9 @@ name = "wordcraft-docbin"
|
|||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"image",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg",
|
|||
krilla = { version = "0.8", features = ["pdf"] }
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
|
||||
miniz_oxide = "0.8"
|
||||
cfb = "0.15"
|
||||
# Password-protected (agile-encrypted) Word documents: pure-Rust RustCrypto primitives.
|
||||
aes = "0.9"
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged |
|
||||
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|
||||
|---|---|
|
||||
| 2026-10-11 | `.doc` import keeps pictures and drawings (#551): inline and floating pictures (PNG, JPEG, DIB, EMF, WMF), shapes, lines, groups and text boxes with position and wrap, from [MS-ODRAW] |
|
||||
| 2026-10-11 | Tables resize with the mouse (#217): drag a column border (the neighbour makes up the difference; Shift moves one column), a row's bottom border, or the handle past the bottom-right corner to scale the table; a dashed guide while dragging, one undo step per drag; `table.resizeDrag` for agents |
|
||||
| 2026-10-11 | Animation effects setting: File › Options › Accessibility › Animation effects (Use system setting, On, Off). With the system's animations off (Windows' Animation effects, macOS' Reduce motion, the Linux/BSD desktop's setting, a browser's reduced motion) the page no longer smooth-scrolls: wheel notches and Ctrl+wheel zoom land at once, in the document and in every list and pane, and menus and buttons don't fade; `ui.animations` for agents |
|
||||
| 2026-10-11 | Linux/BSD: Open, Save As, Export and Insert Picture work without a file chooser portal or zenity (labwc, sway…): WordCraft shows its own file browser and says how to get the system dialog; File › Open › Browse in WordCraft… is always there (#576) |
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ workspace = true
|
|||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-geom = { workspace = true }
|
||||
cfb = { workspace = true }
|
||||
image = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,262 @@
|
|||
//! Floating shapes and pictures: the `PlcfSpa` anchors of the main document and the headers
|
||||
//! ([MS-DOC] §2.8.27 PlcfSpa, §2.9.253 Spa) joined with the OfficeArt shapes they name, and the
|
||||
//! text box stories (`PlcftxbxTxt`, §2.8.30) that fill text boxes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, GroupChild, InlineObject, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
use crate::media::Pictures;
|
||||
use crate::officeart::{Node, Sp};
|
||||
|
||||
/// Most anchors (and text box stories) read per PLC.
|
||||
const MAX_ANCHORS: usize = 10_000;
|
||||
/// `Spa` and `FTXBXS` record sizes.
|
||||
const CB_SPA: usize = 26;
|
||||
const CB_FTXBXS: usize = 22;
|
||||
/// Largest frame or offset kept (points; 22 inches, Word's own limit for sizes).
|
||||
const MAX_PT: f32 = 1584.0;
|
||||
/// Deepest group nesting flattened.
|
||||
const MAX_GROUP_DEPTH: usize = 8;
|
||||
|
||||
/// The (CP, data) entries of a PLC with `cb`-byte data elements, the trailing CP dropped.
|
||||
fn plc(table: &[u8], (fc, lcb): (u32, u32), cb: usize) -> Vec<(u32, &[u8])> {
|
||||
let Some(blob) = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize)) else { return Vec::new() };
|
||||
let n = blob.len().saturating_sub(4) / (4 + cb);
|
||||
let n = n.min(MAX_ANCHORS);
|
||||
let data_at = (n + 1) * 4;
|
||||
(0..n)
|
||||
.filter_map(|i| {
|
||||
let cp = u32::from_le_bytes(blob.get(i * 4..i * 4 + 4)?.try_into().ok()?);
|
||||
let at = data_at + i * cb;
|
||||
Some((cp, blob.get(at..at + cb)?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The text box stories of a `PlcftxbxTxt` (or `PlcfHdrtxbxTxt`): (shape spid, story CP range
|
||||
/// relative to the text box subdocument).
|
||||
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();
|
||||
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 && lid != 0 {
|
||||
out.push((lid, (*cp, 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();
|
||||
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 };
|
||||
let tw = |v: i64| (v as f32 / 20.0).clamp(-2.0 * MAX_PT, 2.0 * MAX_PT);
|
||||
let (mut x, mut y) = (tw(xl as i64), tw(yt as i64));
|
||||
let (mut w, mut h) = (tw(xr as i64 - xl as i64).clamp(0.0, MAX_PT), tw(yb as i64 - yt as i64).clamp(0.0, MAX_PT));
|
||||
let sp = node.sp();
|
||||
let rot = wordcraft_geom::normalize_degrees(if sp.props.rot.is_finite() { sp.props.rot } else { 0.0 });
|
||||
// A shape turned by 45°–135° (or 225°–315°) is anchored by its turned bounds: the
|
||||
// unrotated frame has the other aspect, about the same centre.
|
||||
if (45.0..135.0).contains(&rot) || (225.0..315.0).contains(&rot) {
|
||||
x += (w - h) / 2.0;
|
||||
y += (h - w) / 2.0;
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
let mut float = spa_float(flags, &sp.props);
|
||||
float.x = x;
|
||||
float.y = y;
|
||||
float.rot = rot;
|
||||
float.flip_h = sp.flip_h();
|
||||
float.flip_v = sp.flip_v();
|
||||
let obj = match node {
|
||||
Node::Shape(sp) => leaf(sp, w, h, float, b, pics),
|
||||
Node::Group(sp, members) => group(sp, members, w, h, float, b, pics),
|
||||
};
|
||||
match obj {
|
||||
Some(obj) => out.push((cp_base.saturating_add(cp), obj)),
|
||||
None => pics.dropped(cp_base.saturating_add(cp)),
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|(cp, _)| *cp);
|
||||
out
|
||||
}
|
||||
|
||||
/// Placement from a `Spa`'s flags (fHdr, bx, by, wr, wrk, fRcaSimple, fBelowText, fAnchorLock)
|
||||
/// and the shape's position properties, which take precedence when present.
|
||||
fn spa_float(flags: u16, p: &crate::officeart::Props) -> Float {
|
||||
let bx = (flags >> 1) & 3;
|
||||
let by = (flags >> 3) & 3;
|
||||
let wr = (flags >> 5) & 0xF;
|
||||
let below = flags & (1 << 14) != 0;
|
||||
let wrap = match wr {
|
||||
1 => Wrap::TopAndBottom,
|
||||
3 => {
|
||||
if below {
|
||||
Wrap::BehindText
|
||||
} else {
|
||||
Wrap::InFrontOfText
|
||||
}
|
||||
}
|
||||
4 => Wrap::Tight,
|
||||
5 => Wrap::Through,
|
||||
_ => Wrap::Square,
|
||||
};
|
||||
let h_rel = match p.posrelh.unwrap_or(bx as u32) {
|
||||
0 => Anchor::Margin,
|
||||
1 => Anchor::Page,
|
||||
3 => Anchor::Character,
|
||||
_ => Anchor::Column,
|
||||
};
|
||||
let v_rel = match p.posrelv.unwrap_or(by as u32) {
|
||||
0 => Anchor::Margin,
|
||||
1 => Anchor::Page,
|
||||
3 => Anchor::Line,
|
||||
_ => Anchor::Paragraph,
|
||||
};
|
||||
let align = |v: Option<u32>| match v {
|
||||
Some(1) => Some(FloatAlign::Start),
|
||||
Some(2) => Some(FloatAlign::Center),
|
||||
Some(3) => Some(FloatAlign::End),
|
||||
Some(4) => Some(FloatAlign::Inside),
|
||||
Some(5) => Some(FloatAlign::Outside),
|
||||
_ => None,
|
||||
};
|
||||
// Distances from text, EMUs; Word's defaults are 0.125" at the sides, none above and below.
|
||||
let emu = |v: Option<u32>, def: f32| v.map_or(def, |e| (e as f32 / 12_700.0).clamp(0.0, MAX_PT));
|
||||
let [dl, dt, dr, db] = p.wrap_dist;
|
||||
Float {
|
||||
wrap,
|
||||
h_rel,
|
||||
v_rel,
|
||||
h_align: align(p.posh),
|
||||
v_align: align(p.posv),
|
||||
dist: emu(dl, 9.0).max(emu(dr, 9.0)),
|
||||
dist_top: emu(dt, 0.0),
|
||||
dist_bottom: emu(db, 0.0),
|
||||
..Float::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// One shape as a picture or a drawn shape, `w` × `h` points.
|
||||
fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -> Option<InlineObject> {
|
||||
let p = &sp.props;
|
||||
if let Some(pib) = p.pib {
|
||||
let media = pics.bse(pib, b.bstore, b.delay)?;
|
||||
return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, 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 | 6 => ShapeKind::Triangle,
|
||||
12 | 187 => ShapeKind::Star,
|
||||
13 | 66 | 67 | 68 | 69 => ShapeKind::Arrow,
|
||||
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;
|
||||
let fill = if line || p.filled == Some(false) { None } else { Some(p.fill_color.unwrap_or(Rgb::WHITE)) };
|
||||
let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) };
|
||||
// Line widths are EMUs; Word's default is 0.75 pt.
|
||||
let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0));
|
||||
Some(InlineObject::Shape {
|
||||
kind,
|
||||
w,
|
||||
h,
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width,
|
||||
float,
|
||||
story,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A group: its members flattened into the group's own coordinate space (nested groups mapped
|
||||
/// through their child anchors).
|
||||
fn group(sp: &Sp, members: &[Node], w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -> Option<InlineObject> {
|
||||
let [l, t, r, btm] = sp.group_space.unwrap_or([0, 0, 0, 0]);
|
||||
let (cw, ch) = ((r as f64 - l as f64) as f32, (btm as f64 - t as f64) as f32);
|
||||
let (ch_w, ch_h) = if cw > 0.0 && ch > 0.0 { (cw, ch) } else { (w.max(1.0), h.max(1.0)) };
|
||||
let mut children = Vec::new();
|
||||
// Map: outer = x0 + inner × s; the top group's space starts at its FSPGR origin.
|
||||
members_into(members, (-(l as f32), -(t as f32), 1.0, 1.0), 0, b, pics, &mut children);
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(InlineObject::Group { w, h, float, ch_w, ch_h, children, canvas: None })
|
||||
}
|
||||
|
||||
fn members_into(nodes: &[Node], t: (f32, f32, f32, f32), depth: usize, b: &Build, pics: &mut Pictures, out: &mut Vec<GroupChild>) {
|
||||
let (x0, y0, sx, sy) = t;
|
||||
let fit = |v: f32| if v.is_finite() { v.clamp(-1e7, 1e7) } else { 0.0 };
|
||||
for n in nodes {
|
||||
if out.len() >= wordcraft_doc::para::MAX_GROUP_CHILDREN {
|
||||
return;
|
||||
}
|
||||
let sp = n.sp();
|
||||
let Some([l, t, r, btm]) = sp.child else { continue };
|
||||
let (ox, oy) = (l as f32, t as f32);
|
||||
let (ew, eh) = ((r as f64 - l as f64) as f32, (btm as f64 - t as f64) as f32);
|
||||
match n {
|
||||
Node::Group(_, members) => {
|
||||
if depth >= MAX_GROUP_DEPTH {
|
||||
continue;
|
||||
}
|
||||
let [gl, gt, gr, gb] = sp.group_space.unwrap_or([l, t, r, btm]);
|
||||
let (gw, gh) = ((gr as f64 - gl as f64) as f32, (gb as f64 - gt as f64) as f32);
|
||||
let (kx, ky) = if gw > 0.0 && gh > 0.0 { (ew / gw, eh / gh) } else { (1.0, 1.0) };
|
||||
let nt = (fit(x0 + (ox - gl as f32 * kx) * sx), fit(y0 + (oy - gt as f32 * ky) * sy), fit(sx * kx), fit(sy * ky));
|
||||
members_into(members, nt, depth + 1, b, pics, out);
|
||||
}
|
||||
Node::Shape(sp) => {
|
||||
let float = Float { rot: sp.props.rot, flip_h: sp.flip_h(), flip_v: sp.flip_v(), ..Float::default() };
|
||||
let (w, h) = (fit(ew * sx).max(0.0), fit(eh * sy).max(0.0));
|
||||
if let Some(obj) = leaf(sp, w, h, float, b, pics) {
|
||||
out.push(GroupChild { x: fit(x0 + ox * sx), y: fit(y0 + oy * sy), obj });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -33,8 +33,16 @@ pub(crate) mod pair {
|
|||
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).
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@
|
|||
//! capped), so hostile input yields an error or a best-effort document, never a panic.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod drawing;
|
||||
mod fib;
|
||||
mod fkp;
|
||||
mod fmt;
|
||||
mod list;
|
||||
mod media;
|
||||
mod notes;
|
||||
mod officeart;
|
||||
mod piece;
|
||||
mod sections;
|
||||
mod sprm;
|
||||
|
|
@ -139,8 +141,35 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
|||
sheet: &sheet,
|
||||
notes: &[],
|
||||
marks: &[],
|
||||
drawn: &[],
|
||||
};
|
||||
|
||||
// Drawings: text box stories become parts first, then the floating shapes and pictures
|
||||
// anchored in the main document and the headers, found by their anchor CP while walking.
|
||||
let txbx_base = edn_base.saturating_add(fib.ccp.edn);
|
||||
let hdr_txbx_base = txbx_base.saturating_add(fib.ccp.txbx);
|
||||
let art = fib
|
||||
.pair(fib::pair::DGG_INFO)
|
||||
.and_then(|(f, l)| table.get(f as usize..(f as usize).saturating_add(l as usize)))
|
||||
.map(officeart::content)
|
||||
.unwrap_or_default();
|
||||
let mut tb_stories = std::collections::HashMap::new();
|
||||
for (pair, base, len) in [(fib::pair::PLCF_TXBX_TXT, txbx_base, fib.ccp.txbx), (fib::pair::PLCF_HDR_TXBX_TXT, hdr_txbx_base, fib.ccp.hdr_txbx)] {
|
||||
for (spid, ab) in drawing::textbox_stories(&table, fib.pair(pair)) {
|
||||
let Some((a, b)) = story(base, len, ab) else { continue };
|
||||
if doc.parts.len() >= MAX_PARTS || tb_stories.contains_key(&spid) {
|
||||
continue;
|
||||
}
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
tb_stories.insert(spid, doc.add_part(PartKind::TextBox, blocks));
|
||||
}
|
||||
}
|
||||
let build = drawing::Build { bstore: &art.bstore, delay: &word, stories: &tb_stories };
|
||||
let mut drawn = drawing::anchors(&table, fib.pair(fib::pair::PLC_SPA_MOM), 0, &art.main, &build, &mut pics);
|
||||
drawn.extend(drawing::anchors(&table, fib.pair(fib::pair::PLC_SPA_HDR), hdd_base, &art.header, &build, &mut pics));
|
||||
drawn.sort_by_key(|(cp, _)| *cp);
|
||||
let ctx = WalkCtx { drawn: &drawn, ..ctx };
|
||||
|
||||
// Header and footer parts: skip the six separator stories, then six stories per section
|
||||
// in the order even header, odd (default) header, even footer, odd (default) footer,
|
||||
// first header, first footer. Empty stories inherit from the previous section.
|
||||
|
|
@ -246,6 +275,8 @@ struct WalkCtx<'a> {
|
|||
notes: &'a [(u32, NoteKind, u32)],
|
||||
/// Bookmark boundaries (main-document CPs), sorted for a sequential walk.
|
||||
marks: &'a [(u32, notes::Mark)],
|
||||
/// Floating shapes and pictures by anchor CP (absolute), sorted.
|
||||
drawn: &'a [(u32, InlineObject)],
|
||||
}
|
||||
|
||||
/// State while assembling one paragraph from per-CP formatting.
|
||||
|
|
@ -405,21 +436,29 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
|||
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
|
||||
let fc_pic = pic_location(ctx.chpx_bins.chpx(ctx.word, fc));
|
||||
match fc_pic.and_then(|at| pics.get(ctx.data, at)) {
|
||||
Some((key, w, h)) => {
|
||||
Some(p) => {
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(InlineObject::Image {
|
||||
media: key,
|
||||
w,
|
||||
h,
|
||||
alt: String::new(),
|
||||
media: p.key,
|
||||
w: p.w,
|
||||
h: p.h,
|
||||
alt: p.alt,
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
crop: p.crop,
|
||||
ole: None,
|
||||
});
|
||||
}
|
||||
None => pics.dropped(cp),
|
||||
}
|
||||
}
|
||||
'\u{8}' => {
|
||||
// Drawn object anchor: the floating shape or picture anchored at this CP.
|
||||
let at = ctx.drawn.partition_point(|(c, _)| *c < cp);
|
||||
for (_, obj) in ctx.drawn.get(at..).unwrap_or_default().iter().take_while(|(c, _)| *c == cp) {
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(obj.clone());
|
||||
}
|
||||
}
|
||||
'\u{2}' => {
|
||||
// Footnote or endnote reference; custom symbols share the same character.
|
||||
let at = ctx.notes.partition_point(|(c, _, _)| *c < cp);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
//! Inline pictures: `sprmCPicLocation` → a `PICF` in the Data stream → embedded image bytes
|
||||
//! ([MS-DOC] §2.9.190 PICF, §1.3.5 Pictures).
|
||||
//! ([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.
|
||||
|
|
@ -17,10 +20,13 @@ pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20;
|
|||
|
||||
/// The document's pictures, keyed by their location in the Data stream: anchors that share a
|
||||
/// `fc_pic` share one media entry, and the total bytes copied are capped, so a file with
|
||||
/// thousands of anchors on one huge picture costs one copy, not thousands.
|
||||
/// 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 → (media key, width, height) of pictures already materialised.
|
||||
by_fc: HashMap<u32, (String, f32, f32)>,
|
||||
/// fc_pic → the inline picture already materialised.
|
||||
by_fc: HashMap<u32, Inline>,
|
||||
/// BLIP store index → media key (`None`: unreadable, not retried).
|
||||
by_bse: HashMap<u32, Option<String>>,
|
||||
/// The media entries, merged into the document at the end.
|
||||
pub(crate) media: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
total: usize,
|
||||
|
|
@ -28,33 +34,89 @@ pub(crate) struct Pictures {
|
|||
pub(crate) limit: usize,
|
||||
/// 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(), media: BTreeMap::new(), total: 0, limit: MAX_MEDIA_BYTES, dropped: 0 }
|
||||
Pictures {
|
||||
by_fc: HashMap::new(),
|
||||
by_bse: HashMap::new(),
|
||||
media: BTreeMap::new(),
|
||||
total: 0,
|
||||
limit: MAX_MEDIA_BYTES,
|
||||
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 {
|
||||
/// The media key and display size (points) of the picture at `fc_pic`, copying its
|
||||
/// bytes the first time. `None` when the header is malformed, the payload is a format
|
||||
/// we do not embed (metafiles), or a picture count/byte cap is reached.
|
||||
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<(String, f32, f32)> {
|
||||
/// 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 let Some(hit) = self.by_fc.get(&fc_pic) {
|
||||
return Some(hit.clone());
|
||||
}
|
||||
if self.by_fc.len() >= MAX_PICTURES {
|
||||
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
|
||||
return None;
|
||||
}
|
||||
let pic = locate(data, fc_pic)?;
|
||||
let bytes = data.get(pic.range)?;
|
||||
let pic = match locate(data, fc_pic)? {
|
||||
Found::Range(pic) => {
|
||||
let bytes = data.get(pic.range.clone())?.to_vec();
|
||||
let key = self.insert(bytes, pic.ext, false)?;
|
||||
Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() }
|
||||
}
|
||||
Found::Shape { w, h, blip, props } => {
|
||||
let key = self.insert(blip.bytes, blip.ext, blip.placeholder)?;
|
||||
Inline { key, w, h, crop: props.crop, alt: props.alt }
|
||||
}
|
||||
};
|
||||
self.by_fc.insert(fc_pic, pic.clone());
|
||||
Some(pic)
|
||||
}
|
||||
|
||||
/// 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 room = self.limit.saturating_sub(self.total);
|
||||
let key = officeart::fbse_blip(fbse, delay, room).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder));
|
||||
self.by_bse.insert(pib, 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{}.{}", self.by_fc.len() + 1, pic.ext);
|
||||
self.media.insert(key.clone(), Arc::new(bytes.to_vec()));
|
||||
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;
|
||||
self.by_fc.insert(fc_pic, (key.clone(), pic.w, pic.h));
|
||||
Some((key, pic.w, pic.h))
|
||||
Some(key)
|
||||
}
|
||||
|
||||
/// Note a picture anchor that could not be materialised.
|
||||
|
|
@ -74,27 +136,64 @@ struct Located {
|
|||
ext: &'static str,
|
||||
}
|
||||
|
||||
/// Locate the picture at `fc_pic` in the Data stream without copying it.
|
||||
fn locate(data: &[u8], fc_pic: u32) -> Option<Located> {
|
||||
/// 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, blip: officeart::Blip, 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).
|
||||
fn locate(data: &[u8], fc_pic: u32) -> 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 (blip, props) = inline_shape(pic.get(at..end)?, data)?;
|
||||
// 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, blip, 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 end = lcb.min(pic.len());
|
||||
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(Located { w, h, range: start..start.checked_add(image.len())?, ext: ext_of(image) })
|
||||
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).
|
||||
fn inline_shape(buf: &[u8], data: &[u8]) -> Option<(officeart::Blip, 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 blip = officeart::fbse_blip(fbse, data, MAX_MEDIA_BYTES)?;
|
||||
Some((blip, sp.props))
|
||||
}
|
||||
|
||||
/// Offset of the first embedded raster image inside the payload, skipping the small
|
||||
|
|
|
|||
|
|
@ -0,0 +1,463 @@
|
|||
//! 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 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),
|
||||
}
|
||||
}
|
||||
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]) -> 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())?;
|
||||
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);
|
||||
reader.limits(limits);
|
||||
let mut png = Vec::new();
|
||||
match reader.decode() {
|
||||
Ok(img) if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_ok() => {
|
||||
Some(Blip { bytes: png, ext: "png", placeholder: false })
|
||||
}
|
||||
_ => Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 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
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ use proptest::prelude::*;
|
|||
|
||||
use super::read;
|
||||
|
||||
mod drawing;
|
||||
|
||||
/// Offset in the WordDocument stream where test text pieces are placed (past the FIB).
|
||||
const TEXT_FC: u32 = 0x600;
|
||||
/// Offset in the Table stream where the test Clx is placed.
|
||||
|
|
@ -1628,7 +1630,7 @@ fn picture_bytes_are_capped_per_document() {
|
|||
pics.limit = 1000;
|
||||
let first = pics.get(&data, 0).expect("first fits");
|
||||
// Same location again: no new bytes.
|
||||
assert_eq!(pics.get(&data, 0).map(|p| p.0), Some(first.0.clone()));
|
||||
assert_eq!(pics.get(&data, 0).map(|p| p.key), Some(first.key.clone()));
|
||||
// A second distinct picture would exceed the cap.
|
||||
assert!(pics.get(&data, a.len() as u32).is_none());
|
||||
assert_eq!(pics.media.len(), 1);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,276 @@
|
|||
//! Pictures and drawings: OfficeArt records ([MS-ODRAW]) hand-built for these tests from the
|
||||
//! specification, with tiny pictures generated in code. No file produced by Word is used.
|
||||
|
||||
use proptest::prelude::*;
|
||||
use wordcraft_doc::para::{Anchor, InlineObject, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
use super::{StructSpec, grpprl, plc, read, struct_doc, structured_spec};
|
||||
use crate::officeart;
|
||||
|
||||
/// One OfficeArt record: header (recVer, recInstance, recType, recLen) + body.
|
||||
fn rec(ver: u16, inst: u16, typ: u16, body: &[u8]) -> Vec<u8> {
|
||||
let vi = (inst << 4) | (ver & 0xF);
|
||||
[vi.to_le_bytes().as_slice(), &typ.to_le_bytes(), &(body.len() as u32).to_le_bytes(), body].concat()
|
||||
}
|
||||
|
||||
fn container(typ: u16, kids: &[Vec<u8>]) -> Vec<u8> {
|
||||
rec(0xF, 0, typ, &kids.concat())
|
||||
}
|
||||
|
||||
/// `OfficeArtFSP`: shape type, spid, flags.
|
||||
fn fsp(spt: u16, spid: u32, flags: u32) -> Vec<u8> {
|
||||
rec(2, spt, 0xF00A, &[spid.to_le_bytes(), flags.to_le_bytes()].concat())
|
||||
}
|
||||
|
||||
/// `OfficeArtFOPT` with simple properties and complex ones (data appended in order).
|
||||
fn fopt(simple: &[(u16, u32)], complex: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut body = Vec::new();
|
||||
for (pid, op) in simple {
|
||||
body.extend_from_slice(&pid.to_le_bytes());
|
||||
body.extend_from_slice(&op.to_le_bytes());
|
||||
}
|
||||
for (pid, data) in complex {
|
||||
body.extend_from_slice(&(pid | 0x8000).to_le_bytes());
|
||||
body.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
}
|
||||
for (_, data) in complex {
|
||||
body.extend_from_slice(data);
|
||||
}
|
||||
rec(3, (simple.len() + complex.len()) as u16, 0xF00B, &body)
|
||||
}
|
||||
|
||||
/// A shape container with the given records.
|
||||
fn sp(kids: &[Vec<u8>]) -> Vec<u8> {
|
||||
container(0xF004, kids)
|
||||
}
|
||||
|
||||
/// A real 2×2 PNG.
|
||||
fn tiny_png() -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([10, 20, 30, 255]));
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// `OfficeArtBlipPNG` (one UID, tag) and the `OfficeArtFBSE` embedding it.
|
||||
fn fbse_png(png: &[u8]) -> Vec<u8> {
|
||||
let blip = rec(0, 0x6E0, 0xF01E, &[[0u8; 16].as_slice(), &[0xFF], png].concat());
|
||||
let mut body = vec![6u8, 6];
|
||||
body.extend_from_slice(&[0u8; 16]); // rgbUid
|
||||
body.extend_from_slice(&0u16.to_le_bytes()); // tag
|
||||
body.extend_from_slice(&(blip.len() as u32).to_le_bytes()); // size
|
||||
body.extend_from_slice(&1u32.to_le_bytes()); // cRef
|
||||
body.extend_from_slice(&0u32.to_le_bytes()); // foDelay
|
||||
body.extend_from_slice(&[0, 0, 0, 0]); // unused1, cbName, unused2, unused3
|
||||
body.extend_from_slice(&blip);
|
||||
rec(2, 6, 0xF007, &body)
|
||||
}
|
||||
|
||||
/// UTF-16LE, NUL-terminated (a complex string property).
|
||||
fn wz(s: &str) -> Vec<u8> {
|
||||
s.encode_utf16().chain([0]).flat_map(|u| u.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, *stroke, *stroke_width), (Some(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, float.wrap), (ShapeKind::TextBox, None, Wrap::TopAndBottom));
|
||||
let part = story.and_then(|id| doc.parts.get(&id)).expect("text box story part");
|
||||
assert_eq!(part.kind, wordcraft_doc::PartKind::TextBox);
|
||||
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
|
||||
}
|
||||
|
||||
/// 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).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).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).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).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));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (imported TOC result boundaries preserved; native save temporary-file ownership and failure protection; previously trivial: Print Markup: comments in PDF/print/copies, #589; previously trivial: SmartArt written from WordCraft's model, #497; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (.doc pictures, shapes and text boxes read, #551; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
|
||||
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
|
||||
share of a typical real-world file's content and formatting that survives (read: shows correctly;
|
||||
|
|
@ -22,7 +22,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
|||
| `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Details below |
|
||||
| `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | one fixture | Writing Strict is rare |
|
||||
| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run |
|
||||
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~55% | 25–40 | `crates/docbin/src/tests.rs` (56) | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks, pictures. Metafile (WMF/EMF) pictures, Word 6/95 files and encrypted files are refused or dropped. No writer |
|
||||
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` (56) | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Freeform shapes, z-order and Word 6/95 or encrypted files are dropped or refused. No writer |
|
||||
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. No PDF/A option, no "best for printing/online" choice |
|
||||
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
|
||||
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. Thin: comments, notes, revisions, sections, RTL |
|
||||
|
|
@ -156,6 +156,7 @@ raise readiness estimates. No Word-derived files are fixtures.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | `.doc`: inline and floating pictures (BLIP store, PNG/JPEG/DIB/TIFF/EMF/WMF), basic shapes, groups and text boxes read from OfficeArt drawings (#551); fidelity ~55% → ~60% |
|
||||
| 2026-10-11 | trivial | Word XML Document (Flat OPC) read and written, Word 2003 XML read (#548) |
|
||||
| 2026-10-11 | trivial | #534: DOCX indents in characters read and written; `.doc` first-line indents (`sprmPDxaLeft1`, its two-byte operand, and `sprmPDxaLeft1_80`), indents in characters (`sprmPDxc*`) and exact/at-least line spacing (LSPD) read correctly |
|
||||
| 2026-10-11 | trivial | List and page number formats: all ST_NumberFormat values round-trip; Chinese, Japanese and Korean counting, legal and digit formats, stems/branches, kana, hangul, enclosed and full-width numbers drawn; unknown values are decimal; .doc number format codes mapped (#534) |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (imported TOC boundaries preserved with refresh coverage; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (.doc pictures and drawings read, #551; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
The work list. One entry per shortfall, ranked by how much it stops a Word user from switching
|
||||
(**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent
|
||||
|
|
@ -26,7 +26,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr
|
|||
| 11 | **Interface languages**: 8 of the 12 key languages missing (hi, ar, fr, id, de, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the six translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) |
|
||||
| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); missing: Ink to Math, arrows in Ink to Shape, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 9/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) |
|
||||
| 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), no combo/3D/radar/stock charts, axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 14 | **`.doc` can't be written; metafile pictures and Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 14 | **`.doc` can't be written; Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 15 | **References depth**: sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Sources live only in the session (#169 open) | Students and researchers | 6–10 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19). RTL tables and sections landed (#362) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) |
|
||||
| 17 | **East Asian typography**: Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | Catalog misses `para.asianTypography` | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) |
|
||||
|
|
@ -69,6 +69,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | `.doc` pictures, shapes and text boxes read (#551); gap #14 narrowed to the writer and Word 6/95 files |
|
||||
| 2026-10-11 | trivial | Table column and row borders and the resize handle drag on the page (#217); UI/UX gap narrowed to the ruler's column markers |
|
||||
| 2026-10-11 | trivial | UI/UX gaps list: the system's animation setting is now read on macOS and the Linux/BSD desktops; trying it on real machines remains |
|
||||
| 2026-10-11 | trivial | Animation effects setting landed (File › Options › Accessibility); UI/UX gaps list: the system's animation setting isn't read on macOS or Linux/BSD yet |
|
||||
|
|
|
|||
Loading…
Reference in New Issue