mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 12:20:12 -06:00
Finding 49. drawing::anchors built a fresh object for every PlcSpa entry, so one group of 1000 members named by 5000 entries built 5M children (1.4 s, ~1 GB) with the per-group child cap resetting each time. Word anchors each shape once, so a repeated spid is now skipped: every node of the shape tree is built at most once, bounded by the shared OfficeArt record budget. walk also cloned each floating object every time a story holding its anchor was walked; each copy is now charged by size (a group with all its members) to the document-wide MAX_WORK budget. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
280 lines
12 KiB
Rust
280 lines
12 KiB
Rust
//! 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::fill::Fill;
|
||
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).
|
||
///
|
||
/// The CPs of a well-formed PLC ascend, so the stories are disjoint. A story starting before
|
||
/// the end of the last one kept (a hostile table pointing many text boxes at the same text,
|
||
/// each read into its own part) is dropped: every subdocument CP becomes at most one story.
|
||
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();
|
||
let mut kept_to = 0u32;
|
||
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 && *cp >= kept_to && lid != 0 {
|
||
out.push((lid, (*cp, end)));
|
||
kept_to = 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();
|
||
let mut seen = std::collections::HashSet::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 };
|
||
// Word anchors each shape once. A repeated spid would build another copy of the shape,
|
||
// and of every member of a group, per anchor (#3710).
|
||
if !seen.insert(spid) {
|
||
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 => ShapeKind::Triangle,
|
||
6 => ShapeKind::RightTriangle,
|
||
12 | 187 => ShapeKind::Star,
|
||
13 | 69 => ShapeKind::Arrow,
|
||
66 => ShapeKind::ArrowLeft,
|
||
67 => ShapeKind::ArrowDown,
|
||
68 => ShapeKind::ArrowUp,
|
||
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(Fill::solid(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 });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|