.doc import: inline pictures, floating pictures and basic shapes (#551) (#558)

* .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:
Valentin Klein 2026-10-12 09:42:18 +02:00 committed by GitHub
parent 4611afba16
commit 6801d89065
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1189 additions and 31 deletions

2
Cargo.lock generated
View File

@ -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",

View File

@ -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"

View File

@ -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) |

View File

@ -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 }

View File

@ -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 });
}
}
}
}
}

View File

@ -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).

View File

@ -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);

View File

@ -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

View File

@ -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
}

View File

@ -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);

View File

@ -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));
}
}

View File

@ -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) |

View File

@ -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 |