Charts and SmartArt: draw them instead of dropping them (#292)

A chart or SmartArt diagram in a DOCX was dropped on import: it took no
room, so the text after it moved up (five of the six chart documents in
docxide-pdf's fixtures came out a page short), and nothing was drawn.

They are now an InlineObject::Graphic: it takes its room, floats and
wraps like a picture, and draws a list of neutral items (shapes, paths,
one-line texts, pictures) that layout scales to the object's box.

- Charts (read/chart.rs) are drawn from the values the chart part
  caches: clustered, stacked and 100% bar and column charts, line, area,
  pie, doughnut and scatter charts, with title, legend, gridlines, axis
  labels and Excel number and date formats.
- SmartArt (read/diagram.rs) is drawn from the drawing Word stores
  beside the diagram: preset shapes, theme colours, text and pictures.
- Theme colour handling is shared (read/drawing_color.rs).
- Each chart or diagram part is read once, only through a relationship
  of its type, under a per-file budget of items.
- Tagged PDF tags them as figures with their alternative text, and the
  accessibility check reports charts without one.

Not yet: charts and diagrams aren't written back when saving a DOCX
(they weren't before either), so the editor doesn't let you move or
resize them. Radar, stock, 3-D and combination charts draw their first
supported chart type only; SmartArt pictures aren't clipped to their
shape.

docxide-pdf bench (365 documents, vs main + #135): page counts right
320 -> 326 (all five chart documents and one SmartArt document), Jaccard
passes 208 -> 215, SSIM passes 76 -> 79; eight documents better, none
worse.

Co-authored-by: Oliver Zieschang <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-10 14:39:41 -04:00
1 parent 4a75c57f58
commit c0bbcbac24
32 files changed
+3626 -41

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
//! Charts and SmartArt diagrams: drawings shown but not edited, kept as neutral items ready to draw.
use serde::{Deserialize, Serialize};
use crate::para::ShapeKind;
use crate::props::Rgb;
/// A drawing shown but not edited: a chart or a SmartArt diagram, as items ready to draw.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Graphic {
pub kind: GraphicKind,
/// What to draw, in points from the object's top-left corner.
#[serde(default)]
pub items: Vec<GraphicItem>,
/// The size (points) the items were built for. Drawn scaled to the object's rectangle when
/// that differs (layout shrinks oversized inline objects); 0 means the rectangle's own size.
#[serde(default)]
pub w: f32,
#[serde(default)]
pub h: f32,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GraphicKind {
#[default]
Chart,
Diagram,
}
impl GraphicKind {
/// What the graphic is, in words: the alt text of one that has none of its own.
pub fn noun(self) -> &'static str {
match self {
GraphicKind::Chart => "chart",
GraphicKind::Diagram => "diagram",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum GraphicItem {
/// A preset shape filling `rect` ([x, y, w, h]).
Shape {
rect: [f32; 4],
kind: ShapeKind,
fill: Option<Rgb>,
stroke: Option<Rgb>,
#[serde(default)]
stroke_width: f32,
},
/// A path: polygons, pie slices, line series.
Path {
segs: Vec<PathSeg>,
fill: Option<Rgb>,
stroke: Option<Rgb>,
#[serde(default)]
stroke_width: f32,
},
/// One line of text in `rect`, aligned horizontally by `align`, centred vertically.
Text {
rect: [f32; 4],
text: String,
size: f32,
color: Rgb,
#[serde(default)]
bold: bool,
#[serde(default)]
align: TextAlign,
#[serde(default)]
font: Option<String>,
},
/// A picture (`Document::media` key) stretched into `rect`.
Image { rect: [f32; 4], media: String },
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TextAlign {
Left,
#[default]
Center,
Right,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PathSeg {
Move(f32, f32),
Line(f32, f32),
Cubic(f32, f32, f32, f32, f32, f32),
Close,
}
+1
View File
@@ -14,6 +14,7 @@ pub mod bidi;
pub mod edit;
pub mod encoding;
pub mod fields;
pub mod graphic;
pub mod math;
pub mod math_edit;
pub mod math_latex;
+11 -1
View File
@@ -200,6 +200,16 @@ pub enum InlineObject {
#[serde(default)]
crop: [f32; 4],
},
/// A chart or SmartArt diagram (see [`crate::graphic`]).
Graphic {
w: f32,
h: f32,
#[serde(default)]
alt: String,
#[serde(default)]
float: Float,
graphic: std::sync::Arc<crate::graphic::Graphic>,
},
Shape {
kind: ShapeKind,
w: f32,
@@ -288,7 +298,7 @@ impl InlineObject {
}
pub fn is_floating(&self) -> bool {
match self {
InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline,
InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline,
_ => false,
}
}
+3
View File
@@ -32,6 +32,9 @@ pub mod rt {
pub const IMAGE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
pub const HYPERLINK: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
pub const VBA_PROJECT: &str = "http://schemas.microsoft.com/office/2006/relationships/vbaProject";
pub const CHART: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart";
pub const DIAGRAM_DATA: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramData";
pub const DIAGRAM_DRAWING: &str = "http://schemas.microsoft.com/office/2007/relationships/diagramDrawing";
}
/// `Document::passthrough` key for a macro project, kept as opaque bytes (never parsed or run).
File diff suppressed because it is too large. Load diff
+673
View File
@@ -0,0 +1,673 @@
//! SmartArt diagrams: the ready-made drawing (`dsp:drawing`) Word keeps beside the diagram data,
//! converted to [`GraphicItem`]s. The diagram's layout isn't run; the saved drawing is the picture.
use wordcraft_doc::graphic::{GraphicItem, PathSeg, TextAlign};
use wordcraft_doc::para::ShapeKind;
use wordcraft_doc::props::Rgb;
use super::Reader;
use super::chart::text_w;
use super::drawing_color::{color, paint};
use crate::package::{Rel, Rels, rel_is, rt};
use crate::units::{MAX_LEN_PT, int};
use crate::xml::El;
/// Deepest `dsp:grpSp` nesting followed.
const MAX_GROUP_DEPTH: usize = 8;
/// Most shapes read from one drawing.
const MAX_SHAPES: usize = 10_000;
/// Most items emitted for one diagram.
const MAX_ITEMS: usize = 50_000;
/// EMU per point.
const EMU_PT: f64 = 12_700.0;
/// Largest coordinate or extent accepted, EMU (about 78,000 pt).
const MAX_EMU: f64 = 1.0e9;
/// Line height as a multiple of the font size.
const LINE: f32 = 1.2;
/// An affine map per axis (`x' = kx·x + ox`, `y' = ky·y + oy`).
#[derive(Clone, Copy, Debug)]
struct Xf {
kx: f64,
ox: f64,
ky: f64,
oy: f64,
}
impl Xf {
const ID: Xf = Xf { kx: 1.0, ox: 0.0, ky: 1.0, oy: 0.0 };
/// `self` applied after `inner`.
fn then(self, inner: Xf) -> Xf {
Xf { kx: self.kx * inner.kx, ox: self.kx * inner.ox + self.ox, ky: self.ky * inner.ky, oy: self.ky * inner.oy + self.oy }
}
/// The box `[x, y, w, h]` mapped to corners `[x0, y0, x1, y1]`.
fn rect(self, b: [f64; 4]) -> [f64; 4] {
let (x0, x1) = (self.kx * b[0] + self.ox, self.kx * (b[0] + b[2]) + self.ox);
let (y0, y1) = (self.ky * b[1] + self.oy, self.ky * (b[1] + b[3]) + self.oy);
[x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)]
}
}
/// A shape's outline in its own box: points and whether it's closed (a fill needs closed).
type Poly = (Vec<(f64, f64)>, bool);
enum Geo {
Kind(ShapeKind),
Poly(Vec<(f64, f64)>, bool),
}
/// Points, clamped into the range layout accepts (NaN, from a degenerate transform, is 0).
fn pt(v: f64) -> f32 {
if v.is_nan() { 0.0 } else { v.clamp(-MAX_LEN_PT as f64, MAX_LEN_PT as f64) as f32 }
}
/// An EMU attribute of `e`, finite and bounded.
fn emu(e: &El, attr: &str) -> Option<f64> {
int(e.attr(attr)?).map(|v| (v as f64).clamp(-MAX_EMU, MAX_EMU))
}
/// `a:off` / `a:ext` of an element (a shape's `a:xfrm`, `dsp:txXfrm`) as `[x, y, w, h]` in EMU.
fn xfrm_box(x: &El) -> Option<[f64; 4]> {
let (off, ext) = (x.child("a:off")?, x.child("a:ext")?);
Some([emu(off, "x")?, emu(off, "y")?, emu(ext, "cx")?.max(0.0), emu(ext, "cy")?.max(0.0)])
}
/// Child-space to parent-space scale of a group, from its `a:xfrm` (`off`/`ext` against `chOff`/`chExt`).
fn group_xf(grp: &El) -> Xf {
let Some(x) = grp.child("dsp:grpSpPr").and_then(|p| p.child("a:xfrm")) else { return Xf::ID };
let v = |tag: &str, a: &str| x.child(tag).and_then(|e| emu(e, a)).unwrap_or(0.0);
let scale = |e: f64, c: f64| if c > 0.0 && e >= 0.0 { e / c } else { 1.0 };
let kx = scale(v("a:ext", "cx"), v("a:chExt", "cx"));
let ky = scale(v("a:ext", "cy"), v("a:chExt", "cy"));
Xf { kx, ox: v("a:off", "x") - v("a:chOff", "x") * kx, ky, oy: v("a:off", "y") - v("a:chOff", "y") * ky }
}
/// Shapes in `tree` (depth-first through groups), each with its transform into the drawing.
fn collect<'a>(tree: &'a El, xf: Xf, depth: usize, out: &mut Vec<(Xf, &'a El)>) {
for e in tree.els() {
match e.name.as_str() {
"dsp:sp" if out.len() < MAX_SHAPES => out.push((xf, e)),
"dsp:grpSp" if depth < MAX_GROUP_DEPTH => collect(e, xf.then(group_xf(e)), depth + 1, out),
_ => {}
}
}
}
/// The items of a SmartArt drawing, scaled so its extent fills the `w` × `h` (points) frame.
/// `image` maps a relationship id of the drawing part to a `Document::media` key.
pub(crate) fn diagram_items(drawing: &El, theme: &[Rgb], w: f32, h: f32, image: &mut dyn FnMut(&str) -> Option<String>) -> Vec<GraphicItem> {
let Some(tree) = drawing.find("dsp:spTree") else { return Vec::new() };
let mut shapes = Vec::new();
collect(tree, Xf::ID, 0, &mut shapes);
let placed: Vec<(Xf, &El, [f64; 4])> = shapes
.into_iter()
.filter_map(|(xf, sp)| {
let b = sp.child("dsp:spPr").and_then(|p| p.child("a:xfrm")).and_then(xfrm_box)?;
Some((xf, sp, b))
})
.collect();
// The drawing's extent (from its origin) against the frame. One scale for both axes, never
// above 1:1: Word draws the drawing at its own size when it fits the frame.
let (ext_x, ext_y) = placed.iter().fold((0.0f64, 0.0f64), |(mx, my), (xf, _, b)| {
let r = xf.rect(*b);
(mx.max(r[2]), my.max(r[3]))
});
let frame = |f: f32| if f.is_finite() && f > 0.0 { f as f64 * EMU_PT } else { 0.0 };
let fit_of = |frame: f64, ext: f64| if frame > 0.0 && ext > 0.0 { frame / ext } else { 1.0 };
let fit = [1.0, fit_of(frame(w), ext_x), fit_of(frame(h), ext_y)].into_iter().fold(f64::INFINITY, f64::min);
let root = Xf { kx: fit / EMU_PT, ox: 0.0, ky: fit / EMU_PT, oy: 0.0 };
let mut out = Vec::new();
for (xf, sp, b) in placed {
if out.len() >= MAX_ITEMS {
break;
}
shape(sp, root.then(xf), b, theme, fit as f32, image, &mut out);
}
out
}
/// One `dsp:sp`: its picture or outline, then its text.
/// `fit` is the drawing's scale into the frame, which text sizes follow.
fn shape(sp: &El, xf: Xf, b: [f64; 4], theme: &[Rgb], fit: f32, image: &mut dyn FnMut(&str) -> Option<String>, out: &mut Vec<GraphicItem>) {
let [x0, y0, x1, y1] = xf.rect(b);
if ![x0, y0, x1, y1].iter().all(|v| v.is_finite()) {
return;
}
let (w, h) = (x1 - x0, y1 - y0);
let sppr = sp.child("dsp:spPr");
let xfrm = sppr.and_then(|p| p.child("a:xfrm"));
let attr = |n: &str| xfrm.and_then(|x| x.attr(n));
let rot = attr("rot").and_then(int).unwrap_or(0).rem_euclid(21_600_000) as f64 / 60_000.0;
let flip_h = attr("flipH").is_some_and(on);
let flip_v = attr("flipV").is_some_and(on);
let turned = rot != 0.0 || flip_h || flip_v;
let rect = [pt(x0), pt(y0), pt(w), pt(h)];
let filled;
if let Some(blip) = sppr.and_then(|p| p.child("a:blipFill")).and_then(|f| f.find("a:blip")) {
// A picture fill stretches into the shape's box; no clipping to the outline.
if let Some(media) = blip.attr("r:embed").and_then(image) {
out.push(GraphicItem::Image { rect, media });
}
filled = false;
} else {
let fill = fill_of(sp, sppr, theme);
let (stroke, stroke_width) = line_of(sp, sppr, theme);
let prst = sppr.and_then(|p| p.child("a:prstGeom")).and_then(|g| g.attr("prst")).unwrap_or("rect");
let geom = sppr.and_then(|p| p.child("a:prstGeom"));
let geo = match kind_of(prst) {
Some(k) if !turned || prst == "ellipse" => Geo::Kind(k),
_ => match outline(prst, w, h, geom) {
Some((pts, closed)) => Geo::Poly(pts, closed),
None => Geo::Kind(kind_of(prst).unwrap_or(ShapeKind::Rectangle)),
},
};
match geo {
Geo::Kind(kind) => out.push(GraphicItem::Shape { rect, kind, fill, stroke, stroke_width }),
Geo::Poly(pts, closed) if !pts.is_empty() => {
// Flip in the box, then turn about its centre (clockwise, y down).
let (s, c) = rot.to_radians().sin_cos();
let (cx, cy) = (w / 2.0, h / 2.0);
let map = |(px, py): (f64, f64)| {
let px = if flip_h { w - px } else { px };
let py = if flip_v { h - py } else { py };
let (dx, dy) = (px - cx, py - cy);
(x0 + cx + dx * c - dy * s, y0 + cy + dx * s + dy * c)
};
let mut segs = Vec::with_capacity(pts.len() + 1);
for (i, p) in pts.into_iter().enumerate() {
let (x, y) = map(p);
segs.push(if i == 0 { PathSeg::Move(pt(x), pt(y)) } else { PathSeg::Line(pt(x), pt(y)) });
}
if closed {
segs.push(PathSeg::Close);
}
let fill = if closed { fill } else { None };
out.push(GraphicItem::Path { segs, fill, stroke, stroke_width });
}
Geo::Poly(..) => {}
}
filled = fill.is_some();
}
text(sp, xf, b, theme, filled, fit, out);
}
fn on(v: &str) -> bool {
v == "1" || v == "true"
}
/// The colour of a shape's style reference (`a:fillRef`, `a:lnRef`); index 0 means none.
fn style_colour(sp: &El, which: &str, theme: &[Rgb]) -> Option<Rgb> {
let r = sp.child("dsp:style")?.child(which)?;
if r.attr("idx") == Some("0") {
return None;
}
color(r, theme)
}
/// A shape's fill: its own `a:*Fill`, else the style's fill colour.
fn fill_of(sp: &El, sppr: Option<&El>, theme: &[Rgb]) -> Option<Rgb> {
let own = sppr.and_then(|p| p.els().find(|e| matches!(e.name.as_str(), "a:solidFill" | "a:noFill" | "a:gradFill" | "a:pattFill")));
match own {
Some(e) => paint(e, theme),
None => style_colour(sp, "a:fillRef", theme),
}
}
/// A shape's outline colour and width in points (`a:ln`, else the style's line at 0.75 pt).
fn line_of(sp: &El, sppr: Option<&El>, theme: &[Rgb]) -> (Option<Rgb>, f32) {
let ln = sppr.and_then(|p| p.child("a:ln"));
let width = ln.and_then(|l| l.attr("w")).and_then(int).map_or(0.75, |v| (v as f64 / EMU_PT).clamp(0.0, 100.0) as f32);
let own = ln.and_then(|l| l.els().find(|e| matches!(e.name.as_str(), "a:solidFill" | "a:noFill" | "a:gradFill")));
match own {
Some(e) if e.name == "a:noFill" => (None, 0.0),
Some(e) => (paint(e, theme), width),
None => (style_colour(sp, "a:lnRef", theme), width),
}
}
/// The preset's own [`ShapeKind`], for the presets that have one. `None` means a polygon.
fn kind_of(prst: &str) -> Option<ShapeKind> {
Some(match prst {
"roundRect" => ShapeKind::RoundedRectangle,
"ellipse" => ShapeKind::Ellipse,
"triangle" => ShapeKind::Triangle,
"diamond" => ShapeKind::Diamond,
"line" | "straightConnector1" => ShapeKind::Line,
"rightArrow" => ShapeKind::Arrow,
"star5" | "star4" | "star6" => ShapeKind::Star,
"heart" => ShapeKind::Heart,
"leftArrow" | "upArrow" | "downArrow" | "leftRightArrow" | "upDownArrow" | "chevron" | "homePlate" | "hexagon" | "pentagon" | "trapezoid"
| "parallelogram" | "rtTriangle" | "pie" | "blockArc" => return None,
_ => ShapeKind::Rectangle,
})
}
/// A preset's adjust value (`a:avLst` `a:gd name`, `fmla="val N"`) in its raw units, `def` when absent.
fn raw_adj(geom: Option<&El>, name: &str, def: f64) -> f64 {
geom.and_then(|g| g.child("a:avLst"))
.and_then(|l| l.children("a:gd").find(|d| d.attr("name") == Some(name)))
.and_then(|d| d.attr("fmla"))
.and_then(|f| f.strip_prefix("val "))
.and_then(|v| v.trim().parse::<f64>().ok())
.filter(|v| v.is_finite())
.map_or(def, |v| v.clamp(-1.0e9, 1.0e9))
}
/// A preset's adjust value as a fraction of the box (raw / 100000), `def` (a fraction) when absent.
fn adj(geom: Option<&El>, name: &str, def: f64) -> f64 {
raw_adj(geom, name, def * 100_000.0) / 100_000.0
}
/// A pie slice (`pie`) or block arc (`blockArc`) from `adj1` to `adj2` (60000ths of a degree,
/// clockwise from 3 o'clock); the block arc's band is `adj3` of the short side thick.
fn sector(block: bool, w: f64, h: f64, geom: Option<&El>) -> Poly {
let deg = |n: &str, def: f64| raw_adj(geom, n, def) / 60_000.0;
let (st, en) = if block { (deg("adj1", 10_800_000.0), deg("adj2", 0.0)) } else { (deg("adj1", 0.0), deg("adj2", 16_200_000.0)) };
let sweep = (en - st).rem_euclid(360.0);
let (hw, hh) = (w / 2.0, h / 2.0);
let arc = move |rx: f64, ry: f64| {
(0..=36).map(move |k| {
let t = (st + sweep * k as f64 / 36.0).to_radians();
(hw + rx * t.cos(), hh + ry * t.sin())
})
};
if block {
let band = lim(w.min(h) * adj(geom, "adj3", 0.25), w.min(h) / 2.0);
let pts = arc(hw, hh).chain(arc(hw - band, hh - band).rev()).collect();
(pts, true)
} else {
let mut pts = vec![(hw, hh)];
pts.extend(arc(hw, hh));
(pts, true)
}
}
/// `v` limited to `[0, hi]` (never panics, unlike `clamp` with `hi < 0`).
fn lim(v: f64, hi: f64) -> f64 {
v.max(0.0).min(hi.max(0.0))
}
/// The outline of a polygon preset in a `w` × `h` box (points), or `None` for presets with no
/// polygon (ellipse, heart, stars). Unknown presets are rectangles.
fn outline(prst: &str, w: f64, h: f64, geom: Option<&El>) -> Option<Poly> {
let ss = w.min(h);
let a = |n: &str, d: f64| adj(geom, n, d);
let (hw, hh) = (w / 2.0, h / 2.0);
let pts = match prst {
"line" | "straightConnector1" => return Some((vec![(0.0, 0.0), (w, h)], false)),
"pie" | "blockArc" => return Some(sector(prst == "blockArc", w, h, geom)),
"ellipse" | "heart" | "star5" | "star4" | "star6" => return None,
"roundRect" => return Some((rounded(w, h, lim(ss * a("adj", 0.16667), ss / 2.0)), true)),
"triangle" => {
let apex = lim(w * a("adj", 0.5), w);
vec![(apex, 0.0), (w, h), (0.0, h)]
}
"rtTriangle" => vec![(0.0, 0.0), (0.0, h), (w, h)],
"diamond" => vec![(hw, 0.0), (w, hh), (hw, h), (0.0, hh)],
"rightArrow" | "leftArrow" | "upArrow" | "downArrow" => {
let (len, thick) = if prst == "upArrow" || prst == "downArrow" { (h, w) } else { (w, h) };
arrow(len, thick, a("adj1", 0.5), a("adj2", 0.5), false)
.into_iter()
.map(|(u, v)| match prst {
"leftArrow" => (w - u, v),
"upArrow" => (v, h - u),
"downArrow" => (v, u),
_ => (u, v),
})
.collect()
}
"leftRightArrow" | "upDownArrow" => {
let (len, thick) = if prst == "upDownArrow" { (h, w) } else { (w, h) };
arrow(len, thick, a("adj1", 0.5), a("adj2", 0.5), true)
.into_iter()
.map(|(u, v)| if prst == "upDownArrow" { (v, u) } else { (u, v) })
.collect()
}
"chevron" => {
let x1 = lim(ss * a("adj", 0.5), w);
vec![(0.0, 0.0), (w - x1, 0.0), (w, hh), (w - x1, h), (0.0, h), (x1, hh)]
}
"homePlate" => {
let x1 = w - lim(ss * a("adj", 0.5), w);
vec![(0.0, 0.0), (x1, 0.0), (w, hh), (x1, h), (0.0, h)]
}
"hexagon" => {
let x1 = lim(ss * a("adj", 0.25), w / 2.0);
vec![(0.0, hh), (x1, 0.0), (w - x1, 0.0), (w, hh), (w - x1, h), (x1, h)]
}
"pentagon" => (0..5)
.map(|k| {
let t = (-90.0 + 72.0 * k as f64).to_radians();
(hw + hw * t.cos(), hh + hh * t.sin())
})
.collect(),
"trapezoid" => {
let x2 = lim(ss * a("adj", 0.25), w / 2.0);
vec![(0.0, h), (x2, 0.0), (w - x2, 0.0), (w, h)]
}
"parallelogram" => {
let x2 = lim(ss * a("adj", 0.25), w);
vec![(0.0, h), (x2, 0.0), (w, 0.0), (w - x2, h)]
}
_ => vec![(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)],
};
Some((pts, true))
}
/// A block arrow along `len` (tip at `len`) across `thick`. `a1` is the shaft thickness and `a2`
/// the head length, as fractions of the box; `double` puts a head on both ends.
fn arrow(len: f64, thick: f64, a1: f64, a2: f64, double: bool) -> Vec<(f64, f64)> {
let head = lim(len.min(thick) * a2, len / 2.0);
let half = lim(thick * a1 / 2.0, thick / 2.0);
let c = thick / 2.0;
if double {
vec![
(0.0, c),
(head, 0.0),
(head, c - half),
(len - head, c - half),
(len - head, 0.0),
(len, c),
(len - head, thick),
(len - head, c + half),
(head, c + half),
(head, thick),
]
} else {
vec![(0.0, c - half), (len - head, c - half), (len - head, 0.0), (len, c), (len - head, thick), (len - head, c + half), (0.0, c + half)]
}
}
/// A `w` × `h` rectangle with corners of radius `r`, each corner in six steps.
fn rounded(w: f64, h: f64, r: f64) -> Vec<(f64, f64)> {
let mut pts = Vec::with_capacity(28);
for (cx, cy, start) in [(w - r, r, -90.0), (w - r, h - r, 0.0), (r, h - r, 90.0), (r, r, 180.0)] {
for k in 0..=6 {
let t = (start + 15.0 * k as f64).to_radians();
pts.push((cx + r * t.cos(), cy + r * t.sin()));
}
}
pts
}
/// One line of a text item before it's placed.
struct Line {
text: String,
size: f32,
color: Rgb,
bold: bool,
align: TextAlign,
font: Option<String>,
}
/// The text of a `dsp:txBody`: one item per (wrapped) line, stacked and centred in the text box.
fn text(sp: &El, xf: Xf, b: [f64; 4], theme: &[Rgb], filled: bool, fit: f32, out: &mut Vec<GraphicItem>) {
let Some(tb) = sp.child("dsp:txBody") else { return };
let body = tb.child("a:bodyPr");
let scale = body
.and_then(|p| p.child("a:normAutofit"))
.and_then(|n| n.attr("fontScale"))
.and_then(int)
.map_or(1.0, |v| (v as f32 / 100_000.0).clamp(0.1, 1.0));
let inset = |n: &str, def: f64| body.and_then(|p| p.attr(n)).and_then(int).map_or(def, |v| (v as f64).clamp(0.0, MAX_EMU));
let text_box = match sp.child("dsp:txXfrm").and_then(xfrm_box) {
Some(t) => t,
None => {
let (l, t, r, bo) = (inset("lIns", 91_440.0), inset("tIns", 45_720.0), inset("rIns", 91_440.0), inset("bIns", 45_720.0));
[b[0] + l, b[1] + t, (b[2] - l - r).max(0.0), (b[3] - t - bo).max(0.0)]
}
};
let [x0, y0, x1, y1] = xf.rect(text_box);
let (x0, y0, tw, th) = (pt(x0), pt(y0), pt(x1 - x0), pt(y1 - y0));
// Each line is one item: stop at the diagram's item limit, however much text there is.
let room = MAX_ITEMS.saturating_sub(out.len());
let fallback = theme.get(if filled { 1 } else { 0 }).copied().unwrap_or(if filled { Rgb::WHITE } else { Rgb::BLACK });
let font_ref = sp.child("dsp:style").and_then(|s| s.child("a:fontRef")).and_then(|f| color(f, theme));
let mut lines: Vec<Line> = Vec::new();
for p in tb.children("a:p") {
if lines.len() >= room {
break;
}
let text: String = p.children("a:r").filter_map(|r| r.child("a:t")).map(|t| t.text()).collect();
let text: String = text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
let text = text.trim();
if text.is_empty() {
continue;
}
let run = p.children("a:r").find(|r| r.child("a:t").is_some());
let rpr = run.and_then(|r| r.child("a:rPr"));
// The file's size (18 pt when unset), reduced by the autofit and scaled with the drawing.
let base = rpr.and_then(|r| r.attr("sz")).and_then(int).map_or(18.0, |v| (v as f32 / 100.0).clamp(1.0, 400.0));
let size = base * scale * fit;
let color = rpr.and_then(|r| r.child("a:solidFill")).and_then(|f| color(f, theme)).or(font_ref).unwrap_or(fallback);
let bold = rpr.and_then(|r| r.attr("b")).is_some_and(on);
let align = match p.child("a:pPr").and_then(|q| q.attr("algn")) {
Some("l" | "just" | "dist") => TextAlign::Left,
Some("r") => TextAlign::Right,
_ => TextAlign::Center,
};
let font = rpr
.and_then(|r| r.child("a:latin"))
.and_then(|l| l.attr("typeface"))
.filter(|t| !t.is_empty() && !t.starts_with('+'))
.map(str::to_string);
for line in wrap(text, size, tw, room - lines.len()) {
lines.push(Line { text: line, size, color, bold, align, font: font.clone() });
}
}
let total: f32 = lines.iter().map(|l| l.size * LINE).sum();
let mut y = y0 + (th - total) / 2.0;
for l in lines {
let lh = l.size * LINE;
out.push(GraphicItem::Text { rect: [x0, y, tw, lh], text: l.text, size: l.size, color: l.color, bold: l.bold, align: l.align, font: l.font });
y += lh;
}
}
/// Splits `text` into at most `max` lines of whole words that fit `width` points (by the shared
/// text width estimate); a word wider than that keeps its own line.
fn wrap(text: &str, size: f32, width: f32, max: usize) -> Vec<String> {
let limit = if width > 0.0 && size > 0.0 { width } else { f32::INFINITY };
let space = text_w(" ", size);
let mut lines = Vec::new();
let (mut cur, mut cur_w) = (String::new(), 0.0f32);
for word in text.split_whitespace() {
let w = text_w(word, size);
if !cur.is_empty() && cur_w + space + w > limit {
if lines.len() + 1 >= max {
break;
}
lines.push(std::mem::take(&mut cur));
cur_w = 0.0;
}
if !cur.is_empty() {
cur.push(' ');
cur_w += space;
}
cur.push_str(word);
cur_w += w;
}
if !cur.is_empty() && lines.len() < max {
lines.push(cur);
}
lines
}
impl Reader<'_> {
/// The drawing part of a SmartArt diagram (`a:graphicData` with `dgm:relIds`): the one its
/// data part names, else the drawing numbered like the data part. `None` when there's none.
pub(super) fn diagram_part(&mut self, gd: &El, rels: &Rels) -> Option<String> {
let dm = gd.child("dgm:relIds")?.attr("r:dm")?;
let data = super::part_of(rels, dm, rt::DIAGRAM_DATA)?;
let ext_id = self.graphic_part(&data).and_then(|d| d.find("dsp:dataModelExt").and_then(|e| e.attr("relId")).map(str::to_string));
let is_drawing = |r: &&Rel| !r.external && rel_is(&r.kind, rt::DIAGRAM_DRAWING);
let drawing = ext_id.as_deref().and_then(|id| rels.by_id(id)).filter(is_drawing).or_else(|| {
// No usable relationship id: the drawing whose number matches the data part's.
let num = |t: &str| t.rsplit('/').next().unwrap_or(t).chars().filter(char::is_ascii_digit).collect::<String>();
let n = num(&data);
rels.list.iter().filter(is_drawing).find(|r| !n.is_empty() && num(&r.target) == n)
});
drawing.map(|r| r.target.clone())
}
/// The items of the SmartArt drawing part at `path`, fitted to `w` × `h` points.
pub(super) fn diagram_drawing(&mut self, path: &str, w: f32, h: f32) -> Vec<GraphicItem> {
let Some(root) = self.graphic_part(path) else { return Vec::new() };
let drels = self.pkg.rels(path);
let theme = self.doc.settings.theme_colors.clone();
let mut image = |id: &str| self.media_for(&drels, id);
diagram_items(&root, &theme, w, h, &mut image)
}
}
#[cfg(test)]
mod tests {
use super::*;
use wordcraft_doc::THEME_COLORS;
const NS: &str = r#"xmlns:dsp="http://schemas.microsoft.com/office/drawing/2008/diagram" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
fn drawing(body: &str) -> El {
crate::xml::parse(format!("<dsp:drawing {NS}><dsp:spTree>{body}</dsp:spTree></dsp:drawing>").as_bytes()).unwrap()
}
/// Items for a drawing whose only image relationship is `rId1`.
fn items(body: &str, w: f32, h: f32) -> Vec<GraphicItem> {
let root = drawing(body);
diagram_items(&root, &THEME_COLORS, w, h, &mut |id| (id == "rId1").then(|| "image1.png".to_string()))
}
/// A `dsp:sp` with the given geometry (`prst`, `xfrm` attributes) and extra spPr/txBody XML.
fn sp(xfrm: &str, prst: &str, rest: &str) -> String {
format!(
r#"<dsp:sp><dsp:spPr><a:xfrm {xfrm}><a:off x="0" y="0"/><a:ext cx="1270000" cy="1270000"/></a:xfrm><a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom>{rest}</dsp:spPr></dsp:sp>"#
)
}
#[test]
fn round_rect_takes_theme_colour_and_text() {
let body = r#"<dsp:sp><dsp:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1270000" cy="635000"/></a:xfrm><a:prstGeom prst="roundRect"><a:avLst/></a:prstGeom><a:solidFill><a:schemeClr val="accent1"/></a:solidFill><a:ln><a:noFill/></a:ln></dsp:spPr><dsp:style><a:fontRef idx="minor"><a:schemeClr val="lt1"/></a:fontRef></dsp:style><dsp:txBody><a:bodyPr/><a:p><a:pPr algn="ctr"/><a:r><a:rPr sz="2000" b="1"/><a:t>Foo</a:t></a:r></a:p></dsp:txBody></dsp:sp>"#;
let out = items(body, 100.0, 50.0);
let GraphicItem::Shape { kind, fill, stroke, rect, .. } = &out[0] else { panic!("shape first: {out:?}") };
assert_eq!(*kind, ShapeKind::RoundedRectangle);
assert_eq!(*fill, Some(Rgb(0x15, 0x60, 0x82)));
assert_eq!(*stroke, None);
assert_eq!(*rect, [0.0, 0.0, 100.0, 50.0]);
let GraphicItem::Text { text, size, color, bold, align, .. } = &out[1] else { panic!("text second: {out:?}") };
assert_eq!(text, "Foo");
assert_eq!((*size, *color, *bold, *align), (20.0, Rgb::WHITE, true, TextAlign::Center));
}
#[test]
fn chevron_is_a_path() {
let out = items(&sp("", "chevron", ""), 100.0, 100.0);
let GraphicItem::Path { segs, .. } = &out[0] else { panic!("path: {out:?}") };
assert_eq!(segs.len(), 7);
assert_eq!(segs.last(), Some(&PathSeg::Close));
}
#[test]
fn rotated_rect_is_a_rotated_path() {
let out = items(&sp(r#"rot="2700000""#, "rect", r#"<a:solidFill><a:srgbClr val="FF0000"/></a:solidFill>"#), 100.0, 100.0);
let GraphicItem::Path { segs, fill, .. } = &out[0] else { panic!("path: {out:?}") };
assert_eq!(*fill, Some(Rgb(255, 0, 0)));
let PathSeg::Move(x, y) = segs[0] else { panic!("move") };
// A 100 pt square turned 45 degrees about its centre: the top-left corner moves to the
// middle of the top edge's extension.
assert!((x - 50.0).abs() < 0.01 && (y + 20.71).abs() < 0.01, "got {x} {y}");
}
#[test]
fn lum_mod_darkens_the_theme_colour() {
let rest = r#"<a:solidFill><a:schemeClr val="accent1"><a:lumMod val="50000"/></a:schemeClr></a:solidFill>"#;
let out = items(&sp("", "rect", rest), 10.0, 10.0);
let GraphicItem::Shape { fill, .. } = &out[0] else { panic!("shape") };
let Rgb(r, g, b) = fill.expect("fill");
// accent1 (156082) at half its luminance.
assert!(r < 0x15 && g < 0x60 && b < 0x82, "got {r:02x}{g:02x}{b:02x}");
}
#[test]
fn picture_fill_is_an_image() {
let rest = r#"<a:blipFill><a:blip r:embed="rId1"/><a:stretch><a:fillRect/></a:stretch></a:blipFill>"#;
let out = items(&sp("", "ellipse", rest), 10.0, 10.0);
assert!(matches!(&out[0], GraphicItem::Image { media, .. } if media == "image1.png"), "{out:?}");
}
#[test]
fn frame_scales_the_drawing_and_groups_transform_children() {
// A 2 x 2 inch drawing in a 72 pt frame: a group at the origin holds a half-size child.
let grp = r#"<dsp:grpSp><dsp:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1270000" cy="1270000"/><a:chOff x="0" y="0"/><a:chExt cx="2540000" cy="2540000"/></a:xfrm></dsp:grpSpPr><dsp:sp><dsp:spPr><a:xfrm><a:off x="1270000" y="1270000"/><a:ext cx="1270000" cy="1270000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></dsp:spPr></dsp:sp></dsp:grpSp>"#;
let out = items(grp, 72.0, 72.0);
let GraphicItem::Shape { rect, .. } = &out[0] else { panic!("shape: {out:?}") };
// The group's child sits at half of the 1 in group extent (x = 0.5 in): 36 pt of the 72 pt frame.
assert_eq!(*rect, [36.0, 36.0, 36.0, 36.0]);
}
#[test]
fn long_text_wraps_into_several_lines() {
let body = r#"<dsp:sp><dsp:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1270000" cy="1270000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></dsp:spPr><dsp:txBody><a:bodyPr lIns="0" rIns="0"/><a:p><a:r><a:rPr sz="1000"/><a:t>one two three four five six</a:t></a:r></a:p></dsp:txBody></dsp:sp>"#;
let out = items(body, 30.0, 30.0);
let texts: Vec<&str> = out.iter().filter_map(|i| if let GraphicItem::Text { text, .. } = i { Some(text.as_str()) } else { None }).collect();
assert!(texts.len() > 1, "{texts:?}");
assert_eq!(texts.join(" "), "one two three four five six");
}
#[test]
fn hostile_numbers_and_nesting_do_not_panic() {
let huge = format!(
r#"<dsp:sp><dsp:spPr><a:xfrm rot="zzz" flipH="1"><a:off x="99999999999999999999999" y="-1e400"/><a:ext cx="-5" cy="NaN"/></a:xfrm><a:prstGeom prst="leftRightArrow"><a:avLst><a:gd name="adj1" fmla="val 1e999"/><a:gd name="adj2" fmla="val -7"/></a:avLst></a:prstGeom></dsp:spPr><dsp:txBody><a:bodyPr lIns="99999999999999999999" tIns="-9"/><a:p><a:r><a:rPr sz="99999999999999"/><a:t>x</a:t></a:r></a:p></dsp:txBody></dsp:sp>{}"#,
sp(r#"rot="2700000""#, "chevron", "")
);
let mut nested = String::new();
for _ in 0..40 {
nested = format!(
r#"<dsp:grpSp><dsp:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></dsp:grpSpPr>{nested}{}</dsp:grpSp>"#,
sp("", "rect", "")
);
}
let _ = items(&huge, f32::NAN, -3.0);
let _ = items(&huge, f32::INFINITY, 1.0e30);
let _ = items(&nested, 100.0, 100.0);
let root = drawing(&huge);
assert!(diagram_items(&root, &[], 10.0, 10.0, &mut |_| None).len() <= MAX_ITEMS);
}
#[test]
fn text_follows_autofit_and_the_drawing_fit() {
// A 100 pt drawing: `fontScale` 50% halves its 20 pt text and the 18 pt default alike.
let body = r#"<dsp:sp><dsp:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1270000" cy="1270000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></dsp:spPr><dsp:txBody><a:bodyPr><a:normAutofit fontScale="50000"/></a:bodyPr><a:p><a:r><a:rPr sz="2000"/><a:t>Big</a:t></a:r></a:p><a:p><a:r><a:rPr/><a:t>Small</a:t></a:r></a:p></dsp:txBody></dsp:sp>"#;
let sizes = |out: Vec<GraphicItem>| -> Vec<(String, f32)> {
out.into_iter().filter_map(|i| if let GraphicItem::Text { text, size, .. } = i { Some((text, size)) } else { None }).collect()
};
assert_eq!(sizes(items(body, 100.0, 100.0)), [("Big".to_string(), 10.0), ("Small".to_string(), 9.0)]);
// In a 50 pt frame the whole drawing is at half size, so its text is too.
assert_eq!(sizes(items(body, 50.0, 50.0)), [("Big".to_string(), 5.0), ("Small".to_string(), 4.5)]);
}
#[test]
fn one_huge_text_body_stops_at_the_item_limit() {
let words = "word ".repeat(MAX_ITEMS + 100);
let body = format!(
r#"<dsp:sp><dsp:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="12700" cy="12700"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></dsp:spPr><dsp:txBody><a:bodyPr lIns="0" rIns="0"/><a:p><a:r><a:t>{words}</a:t></a:r></a:p><a:p><a:r><a:t>{words}</a:t></a:r></a:p></dsp:txBody></dsp:sp>"#
);
let out = items(&body, 1.0, 1.0);
assert_eq!(out.len(), MAX_ITEMS);
}
#[test]
fn text_far_outside_nested_groups_stays_finite() {
// Eight groups each scaling 1e9: a shape at -1e9 EMU ends up beyond f32 before clamping.
let mut tree = r#"<dsp:sp><dsp:spPr><a:xfrm><a:off x="-1000000000" y="0"/><a:ext cx="1000000000" cy="0"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></dsp:spPr><dsp:txBody><a:bodyPr/><a:p><a:r><a:t>Far</a:t></a:r></a:p></dsp:txBody></dsp:sp>"#.to_string();
for _ in 0..MAX_GROUP_DEPTH {
tree = format!(
r#"<dsp:grpSp><dsp:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1000000000" cy="1"/><a:chOff x="0" y="0"/><a:chExt cx="1" cy="1"/></a:xfrm></dsp:grpSpPr>{tree}</dsp:grpSp>"#
);
}
let out = items(&tree, 100.0, 100.0);
let rects: Vec<[f32; 4]> = out.iter().filter_map(|i| if let GraphicItem::Text { rect, .. } = i { Some(*rect) } else { None }).collect();
assert!(!rects.is_empty() && rects.iter().flatten().all(|v| v.is_finite()), "{rects:?}");
}
}
+174
View File
@@ -0,0 +1,174 @@
//! DrawingML colours shared by charts and SmartArt: a colour element with its modifiers, and the
//! paint of a fill or line (solid, the first stop of a gradient, a pattern's foreground).
use wordcraft_doc::THEME_COLORS;
use wordcraft_doc::props::Rgb;
use crate::units::int;
use crate::xml::El;
/// Theme colour `i` (0 dk1, 1 lt1, 2 dk2, 3 lt2, 4..9 accent1..6, 10 hlink, 11 folHlink), falling
/// back to the default theme.
pub(crate) fn slot(theme: &[Rgb], i: usize) -> Rgb {
theme.get(i).or_else(|| THEME_COLORS.get(i)).copied().unwrap_or(Rgb::BLACK)
}
/// The colour of a fill or line element: a solid colour, the first stop of a gradient, or the
/// foreground of a pattern. `None` for no fill and picture fills.
pub(crate) fn paint(e: &El, theme: &[Rgb]) -> Option<Rgb> {
match e.name.as_str() {
"a:gradFill" => e.child("a:gsLst").and_then(|l| l.child("a:gs")).and_then(|g| color(g, theme)),
"a:pattFill" => e.child("a:fgClr").and_then(|c| color(c, theme)),
"a:noFill" | "a:blipFill" => None,
_ => color(e, theme),
}
}
/// The fill of a `…Pr` or `a:ln` element (its first solid, gradient or pattern fill), as [`paint`].
pub(crate) fn fill_colour(e: &El, theme: &[Rgb]) -> Option<Rgb> {
let f = e.els().find(|c| matches!(c.name.as_str(), "a:solidFill" | "a:gradFill" | "a:pattFill"))?;
paint(f, theme)
}
fn is_color(e: &El) -> bool {
matches!(e.name.as_str(), "a:srgbClr" | "a:schemeClr" | "a:sysClr")
}
/// A colour element (`a:srgbClr`, `a:schemeClr`, `a:sysClr`), or the one inside `e`, with its
/// `lumMod` / `lumOff` / `tint` / `shade` applied in document order.
pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
let c = if is_color(e) { e } else { e.els().find(|c| is_color(c))? };
let base = match c.name.as_str() {
"a:srgbClr" => Rgb::parse(c.attr("val")?)?,
"a:schemeClr" => *theme.get(scheme_slot(c.attr("val")?)?)?,
"a:sysClr" => Rgb::parse(c.attr("lastClr")?)?,
_ => return None,
};
let val = |m: &El| m.attr("val").and_then(int).map_or(0.0, |v| (v as f64 / 100_000.0).clamp(-10.0, 10.0));
let mut rgb = base;
for m in c.els() {
rgb = match m.name.as_str() {
"a:lumMod" => adjust_lum(rgb, |l| l * val(m)),
"a:lumOff" => adjust_lum(rgb, |l| l + val(m)),
"a:tint" => mix(rgb, 255.0, val(m)),
"a:shade" => mix(rgb, 0.0, val(m)),
_ => rgb,
};
}
Some(rgb)
}
/// Index into `Document::settings.theme_colors` (dk1, lt1, dk2, lt2, accent1..6, hlink, folHlink).
fn scheme_slot(name: &str) -> Option<usize> {
Some(match name {
"dk1" | "tx1" => 0,
"lt1" | "bg1" => 1,
"dk2" | "tx2" => 2,
"lt2" | "bg2" => 3,
"accent1" => 4,
"accent2" => 5,
"accent3" => 6,
"accent4" => 7,
"accent5" => 8,
"accent6" => 9,
"hlink" => 10,
"folHlink" => 11,
_ => return None,
})
}
/// `tint` (towards `to` = 255) and `shade` (towards 0): `v` is the share of the original colour kept.
fn mix(c: Rgb, to: f64, v: f64) -> Rgb {
let f = |x: u8| (x as f64 * v + to * (1.0 - v)).round().clamp(0.0, 255.0) as u8;
Rgb(f(c.0), f(c.1), f(c.2))
}
fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb {
let (h, s, l) = to_hsl(c);
from_hsl(h, s, f(l).clamp(0.0, 1.0))
}
fn to_hsl(c: Rgb) -> (f64, f64, f64) {
let (r, g, b) = (c.0 as f64 / 255.0, c.1 as f64 / 255.0, c.2 as f64 / 255.0);
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
let l = (max + min) / 2.0;
if max == min {
return (0.0, 0.0, l);
}
let d = max - min;
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
let h = if max == r {
(g - b) / d + if g < b { 6.0 } else { 0.0 }
} else if max == g {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
};
(h / 6.0, s, l)
}
fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
let byte = |v: f64| (v * 255.0).round().clamp(0.0, 255.0) as u8;
if s == 0.0 {
return Rgb(byte(l), byte(l), byte(l));
}
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
let p = 2.0 * l - q;
let channel = |t: f64| {
let t = t.rem_euclid(1.0);
let v = if t < 1.0 / 6.0 {
p + (q - p) * 6.0 * t
} else if t < 0.5 {
q
} else if t < 2.0 / 3.0 {
p + (q - p) * (2.0 / 3.0 - t) * 6.0
} else {
p
};
byte(v)
};
Rgb(channel(h + 1.0 / 3.0), channel(h), channel(h - 1.0 / 3.0))
}
#[cfg(test)]
mod tests {
use wordcraft_doc::THEME_COLORS;
use super::*;
const NS: &str = r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#;
/// The element `name` with `attrs` and `body`, parsed.
fn el(name: &str, attrs: &str, body: &str) -> El {
crate::xml::parse(format!(r#"<{name} {NS} {attrs}>{body}</{name}>"#).as_bytes()).unwrap()
}
#[test]
fn gradient_and_pattern_fills_take_their_first_colour() {
let theme = THEME_COLORS.to_vec();
let grad = el("a:gradFill", "", r#"<a:gsLst><a:gs pos="0"><a:srgbClr val="FF0000"/></a:gs></a:gsLst>"#);
assert_eq!(paint(&grad, &theme), Some(Rgb(255, 0, 0)));
let patt =
el("a:pattFill", r#"prst="dkDnDiag""#, r#"<a:fgClr><a:srgbClr val="00FF00"/></a:fgClr><a:bgClr><a:srgbClr val="0000FF"/></a:bgClr>"#);
assert_eq!(paint(&patt, &theme), Some(Rgb(0, 255, 0)));
let ln = el("a:ln", "", r#"<a:gradFill><a:gsLst><a:gs pos="0"><a:srgbClr val="FF0000"/></a:gs></a:gsLst></a:gradFill>"#);
assert_eq!(fill_colour(&ln, &theme), Some(Rgb(255, 0, 0)));
assert_eq!(fill_colour(&el("a:ln", "", "<a:noFill/>"), &theme), None);
}
#[test]
fn tint_and_shade_move_towards_white_and_black() {
let theme = THEME_COLORS.to_vec();
let near = |c: Option<Rgb>| c.is_some_and(|Rgb(r, g, b)| [r, g, b].iter().all(|x| x.abs_diff(128) <= 1));
assert!(near(color(&el("a:srgbClr", r#"val="000000""#, r#"<a:tint val="50000"/>"#), &theme)));
assert!(near(color(&el("a:srgbClr", r#"val="FFFFFF""#, r#"<a:shade val="50000"/>"#), &theme)));
}
#[test]
fn modifiers_apply_in_the_order_written() {
let theme = THEME_COLORS.to_vec();
let a = el("a:srgbClr", r#"val="808080""#, r#"<a:lumMod val="50000"/><a:lumOff val="50000"/>"#);
let b = el("a:srgbClr", r#"val="808080""#, r#"<a:lumOff val="50000"/><a:lumMod val="50000"/>"#);
assert_ne!(color(&a, &theme), color(&b, &theme));
}
}
+33 -1
View File
@@ -1,5 +1,8 @@
//! DOCX → [`Document`].
mod chart;
mod diagram;
mod drawing_color;
mod math;
mod props;
mod story;
@@ -7,6 +10,7 @@ mod story;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
use wordcraft_doc::graphic::{Graphic, GraphicKind};
use wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, Num};
use wordcraft_doc::para::NoteKind;
use wordcraft_doc::para::OBJ;
@@ -16,7 +20,7 @@ use wordcraft_doc::styles::{Style, StyleKind, StyleSheet, TableStyleParts};
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, PartKind, Revision, RevisionKind};
use crate::DocxError;
use crate::package::{ContentTypes, MAX_VBA_RELATED, Package, Rels, VBA_PROJECT_PART, VBA_RELATED, rt};
use crate::package::{ContentTypes, MAX_VBA_RELATED, Package, Rels, VBA_PROJECT_PART, VBA_RELATED, rel_is, rt};
use crate::units::{flag, int, on_off, tw, u32_of};
use crate::xml::El;
pub(crate) use props::PropCtx;
@@ -24,6 +28,9 @@ use story::StoryCtx;
/// Most parts (headers, notes, comments, text boxes) we create from one file.
const MAX_PARTS: usize = 50_000;
/// Most graphic work (items plus path segments, see `story::graphic_work`) built for one file's
/// charts and diagrams; later ones are left empty.
const MAX_GRAPHIC_WORK: usize = 2_000_000;
pub(crate) struct Reader<'p> {
pkg: &'p Package,
@@ -40,6 +47,18 @@ pub(crate) struct Reader<'p> {
pub comment_map: HashMap<String, u32>,
pub comments_ended: HashSet<u32>,
pub bookmarks: HashMap<String, String>,
/// Chart and diagram parts by path, parsed once.
graphic_parts: HashMap<String, Option<Arc<El>>>,
/// Charts and diagrams by (kind, part path, width bits, height bits), built once.
graphics: HashMap<(GraphicKind, String, u32, u32), Arc<Graphic>>,
/// Graphic work left before charts and diagrams are left empty (see [`MAX_GRAPHIC_WORK`]).
graphic_budget: usize,
}
/// The package path that internal relationship `id` points at, if it has type `kind` (an `rt`
/// constant): a crafted id can't make a chart of some other, possibly huge, part.
fn part_of(rels: &Rels, id: &str, kind: &str) -> Option<String> {
rels.by_id(id).filter(|r| !r.external && rel_is(&r.kind, kind)).map(|r| r.target.clone())
}
/// Read a `.docx` package.
@@ -69,6 +88,9 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
comment_map: HashMap::new(),
comments_ended: HashSet::new(),
bookmarks: HashMap::new(),
graphic_parts: HashMap::new(),
graphics: HashMap::new(),
graphic_budget: MAX_GRAPHIC_WORK,
};
r.pc.major_font = r.doc.settings.major_font.clone();
r.pc.minor_font = r.doc.settings.minor_font.clone();
@@ -302,6 +324,16 @@ impl Reader<'_> {
Some(key)
}
/// A chart or diagram part, parsed once however often it's referenced (`None` when unreadable).
fn graphic_part(&mut self, path: &str) -> Option<Arc<El>> {
if let Some(p) = self.graphic_parts.get(path) {
return p.clone();
}
let p = self.xml(path).ok().flatten().map(Arc::new);
self.graphic_parts.insert(path.to_string(), p.clone());
p
}
pub fn load_header_footer(&mut self, path: &str, footer: bool) -> Option<u32> {
if let Some(id) = self.hf_by_path.get(path) {
return Some(*id);
+52 -1
View File
@@ -2,6 +2,7 @@
use std::sync::Arc;
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind};
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{CharProps, Rgb};
use wordcraft_doc::table::{Cell, MAX_COLS, MAX_ROWS, Row, Table};
@@ -9,7 +10,7 @@ use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKi
use super::Reader;
use super::props::{sectpr, tcpr, trpr};
use crate::package::Rels;
use crate::package::{Rels, rt};
use crate::units::{int, measure};
use crate::xml::El;
@@ -649,6 +650,19 @@ impl Reader<'_> {
}
}
let gd = c.child("a:graphic").and_then(|g| g.child("a:graphicData"))?;
// Checked by URI before the blip search: a diagram's data can hold a blip further down.
let uri = gd.attr("uri").unwrap_or("");
let graphic_kind = if uri.ends_with("/chart") {
Some(GraphicKind::Chart)
} else if uri.ends_with("/diagram") {
Some(GraphicKind::Diagram)
} else {
None
};
if let Some(kind) = graphic_kind {
let graphic = self.graphic(kind, gd, rels, w, h);
return Some(InlineObject::Graphic { w, h, alt, float, graphic });
}
if let Some(blip) = gd.find("a:blip") {
let media = blip.attr("r:embed").and_then(|id| self.media_for(rels, id))?;
let mut crop = [0.0f32; 4];
@@ -669,6 +683,38 @@ impl Reader<'_> {
None
}
/// The chart or SmartArt diagram in an `a:graphicData`, as items in points from its top-left
/// corner. Charts are drawn from their cached data, SmartArt diagrams from the drawing Word
/// stores beside them. Built once per part and size; once the file's graphic budget is spent,
/// later ones are empty.
fn graphic(&mut self, kind: GraphicKind, gd: &El, rels: &Rels, w: f32, h: f32) -> Arc<Graphic> {
let source = match kind {
GraphicKind::Chart => gd.child("c:chart").and_then(|c| c.attr("r:id")).and_then(|id| super::part_of(rels, id, rt::CHART)),
GraphicKind::Diagram => self.diagram_part(gd, rels),
};
let Some(path) = source else { return Arc::new(Graphic { kind, items: Vec::new(), w, h }) };
let key = (kind, path, w.to_bits(), h.to_bits());
if let Some(g) = self.graphics.get(&key) {
return g.clone();
}
let items = if self.graphic_budget == 0 { Vec::new() } else { self.graphic_items(kind, &key.1, w, h) };
self.graphic_budget = self.graphic_budget.saturating_sub(graphic_work(&items));
let g = Arc::new(Graphic { kind, items, w, h });
self.graphics.insert(key, g.clone());
g
}
/// The items of the chart part or diagram drawing part at `path`.
fn graphic_items(&mut self, kind: GraphicKind, path: &str, w: f32, h: f32) -> Vec<GraphicItem> {
match kind {
GraphicKind::Chart => match self.graphic_part(path) {
Some(space) => super::chart::chart_items(&space, &self.doc.settings.theme_colors, w, h),
None => Vec::new(),
},
GraphicKind::Diagram => self.diagram_drawing(path, w, h),
}
}
fn read_wsp(&mut self, sc: &mut StoryCtx, wsp: &El, rels: &Rels, w: f32, h: f32, float: Float) -> InlineObject {
let sppr = wsp.child("wps:spPr");
let prst = sppr.and_then(|s| s.child("a:prstGeom")).and_then(|g| g.attr("prst")).unwrap_or("rect");
@@ -914,3 +960,8 @@ fn vml_float(shape: &El, style: &str) -> Float {
..Float::default()
}
}
/// What a graphic's items cost to keep and draw: one per item, plus one per path segment.
fn graphic_work(items: &[GraphicItem]) -> usize {
items.iter().map(|it| if let GraphicItem::Path { segs, .. } = it { segs.len() + 1 } else { 1 }).sum()
}
+2
View File
@@ -369,6 +369,8 @@ impl Writer<'_> {
w.close("w:r");
self.rev_close(w, props);
}
// Charts and diagrams aren't written back yet.
InlineObject::Graphic { .. } => {}
InlineObject::Image { media, w: iw, h: ih, alt, float, crop } => {
let Some(file) = self.media_files.get(media).cloned() else { return };
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
+3
View File
@@ -21,6 +21,7 @@ pub const NAMESPACES: &[(&str, &str)] = &[
("r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"),
("wp", "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"),
("a", "http://schemas.openxmlformats.org/drawingml/2006/main"),
("c", "http://schemas.openxmlformats.org/drawingml/2006/chart"),
("pic", "http://schemas.openxmlformats.org/drawingml/2006/picture"),
("mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"),
("w14", "http://schemas.microsoft.com/office/word/2010/wordml"),
@@ -28,6 +29,8 @@ pub const NAMESPACES: &[(&str, &str)] = &[
("wp14", "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"),
("wps", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"),
("wpg", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"),
("dgm", "http://schemas.openxmlformats.org/drawingml/2006/diagram"),
("dsp", "http://schemas.microsoft.com/office/drawing/2008/diagram"),
("m", "http://schemas.openxmlformats.org/officeDocument/2006/math"),
("v", "urn:schemas-microsoft-com:vml"),
("o", "urn:schemas-microsoft-com:office:office"),
+53
View File
@@ -407,6 +407,59 @@ fn vml_image_and_inline_drawing() {
assert_eq!(d.media.get("pic.png").unwrap().as_slice(), png);
}
#[test]
fn chart_and_diagram_drawings_are_graphic_objects() {
// A diagram's data holds a blip: the URI decides first, so no picture is read from it.
let drawing = |uri: &str, data: &str| {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="2743200" cy="1828800"/><wp:docPr id="5" name="G" descr="sales"/><a:graphic><a:graphicData uri="{uri}">{data}</a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
};
let body = format!(
"<w:p>{}{}</w:p>",
drawing("http://schemas.openxmlformats.org/drawingml/2006/chart", ""),
drawing("http://schemas.openxmlformats.org/drawingml/2006/diagram", r#"<a:blip r:embed="rIdImg"/>"#)
);
let d = read_body(&body);
let p = paras(&d);
assert_eq!(p[0].objects.len(), 2);
match (&p[0].objects[0], &p[0].objects[1]) {
(InlineObject::Graphic { w: w1, h: h1, alt, graphic: g1, .. }, InlineObject::Graphic { graphic: g2, .. }) => {
assert_eq!((*w1, *h1, alt.as_str()), (216.0, 144.0, "sales"));
assert_eq!(g1.kind, wordcraft_doc::graphic::GraphicKind::Chart);
assert_eq!(g2.kind, wordcraft_doc::graphic::GraphicKind::Diagram);
assert!(g1.items.is_empty() && g2.items.is_empty());
}
o => panic!("{o:?}"),
}
}
#[test]
fn a_chart_part_is_built_once_and_only_through_a_chart_relationship() {
let chart = r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:ser><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>1</c:v></c:pt><c:pt idx="1"><c:v>2</c:v></c:pt></c:numLit></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>"#;
let drawing = |id: &str| {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="2743200" cy="1828800"/><wp:docPr id="5" name="G"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" r:id="{id}"/></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
};
let body = format!("<w:p>{}{}{}</w:p>", drawing("rIdChart"), drawing("rIdChart"), drawing("rIdNotChart"));
let bytes = docx(
&body,
&[("rIdChart", "chart", "charts/chart1.xml"), ("rIdNotChart", "image", "charts/chart1.xml")],
&[("word/charts/chart1.xml", chart)],
);
let d = wordcraft_docx::read(&bytes).unwrap();
let graphics: Vec<_> = paras(&d)[0]
.objects
.iter()
.filter_map(|o| if let InlineObject::Graphic { graphic, .. } = o { Some(graphic.clone()) } else { None })
.collect();
assert_eq!(graphics.len(), 3);
assert!(!graphics[0].items.is_empty(), "the chart is drawn");
assert!(std::sync::Arc::ptr_eq(&graphics[0], &graphics[1]), "one build for both references");
assert!(graphics[2].items.is_empty(), "an image relationship is not a chart");
}
#[test]
fn strict_namespace_and_bad_numbers() {
let doc = r#"<w:document xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main"><w:body>
+30 -16
View File
@@ -22,10 +22,21 @@ fn has_object(s: &Session) -> Option<&'static str> {
}
fn has_floating(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Graphic { .. })) => Some(FROZEN),
Some((_, InlineObject::Image { float, .. } | InlineObject::Shape { float, .. })) if float.wrap != Wrap::Inline => None,
_ => Some("select a floating picture or shape first"),
}
}
/// Charts and diagrams are not written back on save yet, so their size, position and wrapping stay
/// as imported: they can be selected and deleted, not moved, resized or arranged.
const FROZEN: &str = "charts and diagrams can't be moved or resized yet";
/// `has_object` for the geometry commands: a chart or diagram is frozen.
fn has_movable(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Graphic { .. })) => Some(FROZEN),
_ => has_object(s),
}
}
fn has_shape(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Shape { .. })) => None,
@@ -35,7 +46,7 @@ fn has_shape(s: &Session) -> Option<&'static str> {
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("picture.size", "Size", "Picture Format › Size", size).params(r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent}"#).when(has_object),
CommandSpec::new("picture.size", "Size", "Picture Format › Size", size).params(r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent}"#).when(has_movable),
CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", |s, v| {
let c = [p::f32(v, "left"), p::f32(v, "top"), p::f32(v, "right"), p::f32(v, "bottom")].map(|x| x.unwrap_or(0.0).clamp(0.0, 0.45));
with_obj(s, |o| {
@@ -202,7 +213,7 @@ pub fn specs() -> Vec<CommandSpec> {
with_float(s, |f| f.wrap = wrap)
})
.params(r#"{"wrap": "inline|square|tight|through|topAndBottom|behindText|inFrontOfText"}"#)
.when(has_object),
.when(has_movable),
CommandSpec::new("arrange.position", "Position", "Layout › Arrange", |s, v| {
let preset = p::str(v, "preset").unwrap_or("middleCenter").to_string();
let (x, y) = (p::f32(v, "x"), p::f32(v, "y"));
@@ -223,9 +234,9 @@ pub fn specs() -> Vec<CommandSpec> {
})
})
.params(r#"{"preset"?: "topLeft|topCenter|topRight|middleLeft|middleCenter|middleRight|bottomLeft|bottomCenter|bottomRight", "x"?: pt, "y"?: pt}"#)
.when(has_object),
CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_object),
CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_object),
.when(has_movable),
CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_movable),
CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_movable),
CommandSpec::new("arrange.align", "Align", "Layout › Arrange", |s, v| {
let h = p::str(v, "value").unwrap_or("center").to_string();
let tw = super::page::sect(s).text_width();
@@ -244,11 +255,11 @@ pub fn specs() -> Vec<CommandSpec> {
})
})
.params(r#"{"value": "left|center|right"}"#)
.when(has_object),
.when(has_movable),
CommandSpec::new("arrange.selectionPane", "Selection Pane", "Layout › Arrange", objects_list).pure(),
CommandSpec::new("arrange.bounds", "Move or Resize", "Layout › Arrange", bounds)
.params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object floats it)"#)
.when(has_object),
.when(has_movable),
CommandSpec::new("arrange.nudge", "Nudge", "Layout › Arrange", |s, v| {
let d = |k| p::f32(v, k).unwrap_or(0.0).clamp(-MAX_OFFSET, MAX_OFFSET);
let (dx, dy) = (d("dx"), d("dy"));
@@ -315,7 +326,7 @@ pub fn object_selection(s: &Session) -> Option<(Pos, &InlineObject)> {
return None;
}
let o = s.doc.para_at(&a)?.object_at(a.off)?;
matches!(o, InlineObject::Image { .. } | InlineObject::Shape { .. }).then_some((a, o))
matches!(o, InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. }).then_some((a, o))
}
/// The first picture/shape in the selection, or just before a collapsed caret.
@@ -334,7 +345,7 @@ pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
if !inside {
continue;
}
if let Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. })) = p.object_at(off) {
if let Some(o @ (InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. })) = p.object_at(off) {
return Some((Pos { story, path: path.clone(), off }, o.clone()));
}
}
@@ -364,7 +375,9 @@ fn bounds(s: &mut Session, v: &Value) -> CmdResult {
let h = p::f32(v, "height").unwrap_or(h0).clamp(min, MAX_OFFSET);
if (w, h) != (w0, h0) {
with_obj(s, |o| {
if let InlineObject::Image { w: ow, h: oh, .. } | InlineObject::Shape { w: ow, h: oh, .. } = o {
if let InlineObject::Image { w: ow, h: oh, .. } | InlineObject::Graphic { w: ow, h: oh, .. } | InlineObject::Shape { w: ow, h: oh, .. } =
o
{
*ow = w;
*oh = h;
}
@@ -448,7 +461,7 @@ fn move_object(s: &mut Session, pos: Pos, page: Option<u64>, x: f32, y: f32, (w,
fn unalign(s: &mut Session) -> Result<(), CmdError> {
use wordcraft_doc::para::Anchor;
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
let (InlineObject::Image { float, .. } | InlineObject::Shape { float, .. }) = &obj else { return Ok(()) };
let (InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. }) = &obj else { return Ok(()) };
if float.h_align.is_none() && float.v_align.is_none() {
return Ok(());
}
@@ -498,7 +511,7 @@ fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Op
fn obj_size(o: &InlineObject) -> (f32, f32) {
match o {
InlineObject::Image { w, h, .. } | InlineObject::Shape { w, h, .. } => (*w, *h),
InlineObject::Image { w, h, .. } | InlineObject::Graphic { w, h, .. } | InlineObject::Shape { w, h, .. } => (*w, *h),
_ => (0.0, 0.0),
}
}
@@ -521,7 +534,7 @@ fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result
fn edit_float(s: &mut Session, pos: &Pos, f: impl Fn(&mut Float)) -> Result<InlineObject, CmdError> {
edit_obj(s, pos, |o| {
if let InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } = o {
if let InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. } = o {
f(float);
}
})
@@ -529,7 +542,7 @@ fn edit_float(s: &mut Session, pos: &Pos, f: impl Fn(&mut Float)) -> Result<Inli
fn with_float(s: &mut Session, f: impl Fn(&mut Float)) -> CmdResult {
with_obj(s, |o| match o {
InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } => {
InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. } => {
let was_inline = float.wrap == Wrap::Inline;
f(float);
// Word's distance from text for a newly wrapped object: 0.125" at the sides.
@@ -571,7 +584,7 @@ fn size(s: &mut Session, v: &Value) -> CmdResult {
}
let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0));
with_obj(s, |o| match o {
InlineObject::Image { w: ow, h: oh, .. } | InlineObject::Shape { w: ow, h: oh, .. } => {
InlineObject::Image { w: ow, h: oh, .. } | InlineObject::Graphic { w: ow, h: oh, .. } | InlineObject::Shape { w: ow, h: oh, .. } => {
*ow = w;
*oh = h;
}
@@ -742,7 +755,7 @@ fn all_objects(s: &Session) -> Vec<(Pos, InlineObject)> {
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
for off in p.object_offsets() {
if let Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. })) = p.object_at(off) {
if let Some(o @ (InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. })) = p.object_at(off) {
v.push((Pos { story: StoryRef::Body, path: path.clone(), off }, o.clone()));
}
}
@@ -759,6 +772,7 @@ fn objects_list(s: &mut Session, _: &Value) -> CmdResult {
let (kind, name) = match &o {
InlineObject::Image { alt, .. } => ("picture", if alt.is_empty() { format!("Picture {}", i + 1) } else { alt.clone() }),
InlineObject::Shape { kind, .. } => ("shape", format!("{kind:?} {}", i + 1)),
InlineObject::Graphic { graphic, .. } => ("graphic", format!("{:?} {}", graphic.kind, i + 1)),
_ => ("object", format!("Object {}", i + 1)),
};
json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o)})
+6
View File
@@ -755,6 +755,12 @@ fn accessibility(s: &mut Session, _: &Value) -> CmdResult {
{
issues.push(json!({"kind": "error", "issue": "Missing alternative text", "where": path.0, "fix": "Select the picture and run picture.altText"}));
}
// Charts and diagrams can't be given alt text yet (see objects.rs), so only report them.
if let InlineObject::Graphic { alt, .. } = o
&& alt.trim().is_empty()
{
issues.push(json!({"kind": "error", "issue": "Chart or diagram has no alternative text", "where": path.0}));
}
}
if let Some(l) = s.doc.styles.resolve_para(&p.props).outline_level {
if let Some(prev) = last_level
+37
View File
@@ -2139,3 +2139,40 @@ fn timestamps_come_from_the_clock() {
// Fixed-width ISO strings sort by time.
assert!(date >= cmd::iso_from_unix_secs(before) && date <= cmd::iso_from_unix_secs(cmd::now_unix()), "{date}");
}
#[test]
fn charts_and_diagrams_can_be_selected_and_deleted_but_not_moved() {
use std::sync::Arc;
use wordcraft_doc::para::InlineObject;
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Before "}));
let chart = InlineObject::Graphic { w: 200.0, h: 100.0, alt: String::new(), float: Default::default(), graphic: Arc::new(Default::default()) };
s.doc.insert_object(&Pos::body(0, 7), chart, &Default::default()).unwrap();
let end = 7 + wordcraft_doc::para::OBJ.len_utf8();
run(&mut s, "select.range", json!({"anchor": Pos::body(0, 7), "focus": Pos::body(0, end)}));
assert!(crate::cmd::objects::object_selection(&s).is_some(), "the chart is selected");
for (id, v) in [
("arrange.bounds", json!({"width": 50})),
("picture.size", json!({"width": 50})),
("arrange.wrap", json!({"wrap": "square"})),
("arrange.position", json!({"preset": "topLeft"})),
("arrange.align", json!({"value": "left"})),
("arrange.bringForward", json!({})),
] {
assert!(s.run(id, &v).is_err(), "{id} must not change a chart");
}
run(&mut s, "text.delete", json!({}));
assert!(s.doc.para_at(&Pos::body(0, 0)).is_some_and(|p| p.objects.is_empty()), "the chart is deleted");
}
#[test]
fn accessibility_reports_a_chart_without_alt_text() {
use std::sync::Arc;
use wordcraft_doc::para::InlineObject;
let mut s = s();
let chart = InlineObject::Graphic { w: 200.0, h: 100.0, alt: " ".into(), float: Default::default(), graphic: Arc::new(Default::default()) };
s.doc.insert_object(&Pos::body(0, 0), chart, &Default::default()).unwrap();
let v = run(&mut s, "file.accessibility", json!({}));
let issues = v["issues"].as_array().expect("issues");
assert!(issues.iter().any(|i| i["issue"] == "Chart or diagram has no alternative text"), "{issues:?}");
}
+2
View File
@@ -1237,6 +1237,8 @@ pub fn inlines_html(inlines: &[Inline]) -> String {
));
}
Inline::Anchor(a) => o.push_str(&format!("<a id=\"{}\"></a>", esc(a))),
// No picture to show: the browser shows the alt text in its place.
Inline::Figure(alt) => o.push_str(&format!("<img alt=\"{}\">", esc(alt))),
Inline::Equation { .. } => {}
}
}
+1
View File
@@ -265,6 +265,7 @@ impl Writer {
o.push_str(&format!("\\textit{{[{}]}}", escape_text(&alt)));
}
Inline::Anchor(name) => o.push_str(&format!("\\hypertarget{{{}}}{{}}", escape_url(name))),
Inline::Figure(_) => {}
Inline::Equation { linear, display } => {
self.uses.math = true;
let src = linear_to_latex(linear);
+1
View File
@@ -1087,6 +1087,7 @@ fn inlines_md(inlines: &[Inline]) -> String {
let safe: String = a.chars().filter(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | ':')).collect();
pieces.push((String::new(), Fmt::default(), Some(format!("<a id=\"{safe}\"></a>"))));
}
Inline::Figure(alt) => pieces.push((String::new(), Fmt::default(), Some(format!("![{}]()", escape(alt))))),
Inline::Equation { .. } => {}
}
}
+13 -1
View File
@@ -57,6 +57,9 @@ pub enum Inline {
Image(Img),
/// A bookmark (link target).
Anchor(String),
/// A chart or diagram known here only by its alt text: written where alt text has a place
/// (an HTML or Markdown image), left out of editable formats so it never turns into body text.
Figure(String),
/// An equation in the linear format WordCraft keeps them in (`x=(-b±√(b^2-4ac))/2a`). Formats
/// without equations write `linear` as text.
Equation {
@@ -118,7 +121,7 @@ impl Para {
for i in &self.inlines {
match i {
Inline::Text(t, _) | Inline::Equation { linear: t, .. } => s.push_str(t),
Inline::Image(_) | Inline::Anchor(_) => {}
Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) => {}
}
}
s
@@ -368,6 +371,7 @@ impl Builder<'_> {
let e2 = out.len();
let _ = out.insert_object(e2, InlineObject::BookmarkEnd { name: name.clone() }, &CharProps::default());
}
Inline::Figure(_) => {}
Inline::Equation { linear, display } => {
let linear = clean_text(linear);
if !linear.is_empty() {
@@ -688,6 +692,14 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec<Para> {
None => {}
}
}
Some(InlineObject::Graphic { alt, .. }) => {
if !buf.is_empty() {
out.push_text(&std::mem::take(&mut buf), &f);
}
if !alt.trim().is_empty() {
out.inlines.push(Inline::Figure(alt.clone()));
}
}
Some(InlineObject::Equation { linear, display, math }) => {
if !buf.is_empty() {
out.push_text(&std::mem::take(&mut buf), &f);
+1 -1
View File
@@ -247,7 +247,7 @@ impl Writer {
prev_space = false;
}
Inline::Anchor(a) => out.push_str(&format!("<text:bookmark text:name=\"{}\"/>", x(a))),
Inline::Equation { .. } => {}
Inline::Figure(_) | Inline::Equation { .. } => {}
}
}
}
+1 -1
View File
@@ -187,7 +187,7 @@ impl Writer {
let a = esc(a);
out.push_str(&format!("{{\\*\\bkmkstart {a}}}{{\\*\\bkmkend {a}}}"));
}
Inline::Equation { .. } => {}
Inline::Figure(_) | Inline::Equation { .. } => {}
}
}
}
+16
View File
@@ -538,3 +538,19 @@ fn html_img_sources_go_through_the_loader() {
// Without a loader only data: URIs load.
assert!(import("html", html.as_bytes()).unwrap().unwrap().media.is_empty());
}
#[test]
fn chart_alt_text_is_an_image_alt_never_body_text() {
let mut p = Paragraph::with_text("See below", CharProps::default());
let chart = InlineObject::Graphic { w: 100.0, h: 50.0, alt: "Sales".into(), float: Default::default(), graphic: Arc::new(Default::default()) };
p.insert_object(4, chart, &CharProps::default()).unwrap();
let mut d = Document::new();
d.body = vec![para_block(p)];
let text = |ext: &str| String::from_utf8(export(ext, &d).unwrap().unwrap()).unwrap();
assert!(text("html").contains(r#"<img alt="Sales">"#), "{}", text("html"));
assert!(text("md").contains("![Sales]()"), "{}", text("md"));
for ext in ["txt", "rtf", "tex"] {
assert!(!text(ext).contains("Sales"), "{ext}: {}", text(ext));
}
assert_eq!(para_texts(&d), ["See below"]);
}
+148 -2
View File
@@ -1,10 +1,11 @@
//! Page → draw items, shared by the raster renderer, the PDF exporter and thumbnails.
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg, TextAlign};
use wordcraft_doc::para::{InlineObject, ShapeKind};
use wordcraft_doc::props::{Border, BorderStyle, Rgb, TextColor, TextDirection, Underline};
use wordcraft_doc::section::SectionStart;
use wordcraft_doc::{Block, Document, Path, StoryRef};
use wordcraft_fonts::FaceRef;
use wordcraft_fonts::{BezPath, FaceRef};
use wordcraft_geom::Rect;
use crate::math::MItem;
@@ -66,6 +67,19 @@ pub enum Draw {
stroke: Option<Rgb>,
stroke_width: f32,
},
/// A vector path (a chart's or diagram's polygons, slices, lines): filled, then stroked.
Path {
segs: Vec<PathSeg>,
fill: Option<Rgb>,
stroke: Option<Rgb>,
stroke_width: f32,
},
/// An inline chart or diagram's draws, as one figure with its alt text (for tagged PDF).
Figure {
alt: String,
kind: GraphicKind,
draws: Vec<Draw>,
},
/// A formatting mark (¶ · → ↵) in the UI's mark colour, or in `color` (a tracked
/// paragraph mark in its reviser's colour).
Mark {
@@ -200,6 +214,7 @@ fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mu
Placed::Shape { rect, kind, fill, stroke, stroke_width } => {
out.push(Draw::Shape { rect: *rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
}
Placed::Graphic { rect, graphic, .. } => out.extend(graphic_draws(doc, graphic, *rect, alpha)),
Placed::Cell { .. } | Placed::Object { .. } => {}
Placed::Lines { story, path, para, l0, l1, x, y, turn } if turn.is_turned() => {
let mut items = Vec::new();
@@ -534,6 +549,9 @@ fn lines(
Some(InlineObject::Shape { kind, fill, stroke, stroke_width, .. }) => {
out.push(Draw::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
}
Some(InlineObject::Graphic { graphic, alt, .. }) => {
out.push(Draw::Figure { alt: alt.clone(), kind: graphic.kind, draws: graphic_draws(doc, graphic, rect, alpha) })
}
Some(InlineObject::Equation { .. }) => {
if let Some((_, ml)) = pl.maths.iter().find(|(k, _)| *k == oi) {
equation(&ml.items, cx, base, alpha, opts.placeholders, out);
@@ -694,6 +712,134 @@ fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: &
}
}
/// The draws of a chart or diagram's items inside `rect` (page coordinates). The items were built
/// for the size `g.w` × `g.h` (0: `rect`'s own size), so they are scaled to `rect`; strokes and text
/// by the mean of the two scales.
fn graphic_draws(doc: &Document, g: &Graphic, rect: Rect, alpha: f32) -> Vec<Draw> {
let (sx, sy) = (scale_of(rect.w, g.w), scale_of(rect.h, g.h));
let k = (sx + sy) / 2.0;
let inside = |r: &[f32; 4]| Rect::new(rect.x + r[0] * sx, rect.y + r[1] * sy, r[2] * sx, r[3] * sy);
let mut out = Vec::with_capacity(g.items.len());
for it in &g.items {
match it {
GraphicItem::Shape { rect: r, kind, fill, stroke, stroke_width } => {
out.push(Draw::Shape { rect: inside(r), kind: *kind, fill: *fill, stroke: *stroke, stroke_width: stroke_width * k })
}
GraphicItem::Path { segs, fill, stroke, stroke_width } => {
let segs = page_segs(segs, |x, y| (rect.x + x * sx, rect.y + y * sy));
out.push(Draw::Path { segs, fill: *fill, stroke: *stroke, stroke_width: stroke_width * k })
}
GraphicItem::Text { rect: r, .. } => out.extend(text_glyphs(doc, inside(r), it, alpha, k)),
GraphicItem::Image { rect: r, media } => out.push(Draw::Image { rect: inside(r), media: media.clone(), crop: [0.0; 4], alpha }),
}
}
out
}
/// Scale from the size `built` points a graphic's items were made for to `len` points; 1 when
/// the built size is unknown (0).
fn scale_of(len: f32, built: f32) -> f32 {
if built.is_finite() && built > 0.0 && len.is_finite() { (len / built).max(0.0) } else { 1.0 }
}
/// Path segments mapped to page coordinates by `map`, cleaned for the rasterizer and PDF: a segment
/// with a non-finite number is dropped, and so is anything before a move (or after a broken one).
fn page_segs(segs: &[PathSeg], map: impl Fn(f32, f32) -> (f32, f32)) -> Vec<PathSeg> {
let fin = |p: (f32, f32)| p.0.is_finite() && p.1.is_finite();
let mut out = Vec::with_capacity(segs.len());
let mut open = false;
for s in segs {
match *s {
PathSeg::Move(x, y) => {
let p = map(x, y);
open = fin(p);
if open {
out.push(PathSeg::Move(p.0, p.1));
}
}
PathSeg::Line(x, y) => {
let p = map(x, y);
if open && fin(p) {
out.push(PathSeg::Line(p.0, p.1));
}
}
PathSeg::Cubic(a, b, c, d, e, f) => {
let (p1, p2, p3) = (map(a, b), map(c, d), map(e, f));
if open && fin(p1) && fin(p2) && fin(p3) {
out.push(PathSeg::Cubic(p1.0, p1.1, p2.0, p2.1, p3.0, p3.1));
}
}
PathSeg::Close => {
if open {
out.push(PathSeg::Close);
}
open = false;
}
}
}
out
}
/// A path of clean segments (see [`page_segs`]) as a kurbo path, for the rasteriser and PDF.
pub fn seg_path(segs: &[PathSeg]) -> BezPath {
let mut p = BezPath::new();
for s in segs {
match *s {
PathSeg::Move(x, y) => p.move_to((x as f64, y as f64)),
PathSeg::Line(x, y) => p.line_to((x as f64, y as f64)),
PathSeg::Cubic(a, b, c, d, e, f) => p.curve_to((a as f64, b as f64), (c as f64, d as f64), (e as f64, f as f64)),
PathSeg::Close => p.close_path(),
}
}
p
}
/// One line of a `GraphicItem::Text` in `r`, shaped in its font (the document's body font when
/// unset) and aligned horizontally, centred vertically, at its size times `k`. `None` when there is
/// nothing to draw, or the item's rectangle or size isn't a finite number (a degenerate scale:
/// `r` itself is always finite, as [`Rect::new`] makes it).
fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) -> Option<Draw> {
let GraphicItem::Text { rect, text, size, color, bold, align, font } = it else { return None };
let size = *size * k;
if !rect.iter().chain([&size]).all(|v| v.is_finite()) {
return None;
}
let family = font.as_deref().filter(|f| !f.is_empty()).unwrap_or(&doc.settings.minor_font);
let size = size.clamp(1.0, 400.0);
let resolved = wordcraft_fonts::word::resolve(family, *bold, false);
let face = resolved.face.get();
let shaped = wordcraft_fonts::shape(face, text, &[], |c| c);
if shaped.is_empty() {
return None;
}
let k = size / face.upem.max(1.0) as f32;
let width: f32 = shaped.iter().map(|g| g.x_advance as f32 * k).sum();
let (ascent, descent) = wordcraft_fonts::word::line_metrics(face);
let baseline = r.y + r.h / 2.0 + (ascent - descent) as f32 * k / 2.0;
let mut pen = match align {
TextAlign::Left => r.x,
TextAlign::Center => r.x + (r.w - width) / 2.0,
TextAlign::Right => r.x + r.w - width,
};
let mut glyphs = Vec::with_capacity(shaped.len());
for g in &shaped {
glyphs.push((g.gid, pen + g.x_offset as f32 * k, baseline - g.y_offset as f32 * k));
pen += g.x_advance as f32 * k;
}
Some(Draw::Glyphs {
face: resolved.face,
size,
glyphs,
color: *color,
alpha,
synth_bold: resolved.synth_bold,
synth_italic: false,
text: text.clone(),
link: None,
ranges: Vec::new(),
})
}
fn author_index(doc: &Document, author: &str) -> u32 {
let mut seen: Vec<&str> = Vec::new();
for r in &doc.revisions {
@@ -722,7 +868,7 @@ pub fn text_color(c: &TextColor, background: Option<Rgb>) -> Rgb {
/// baseline at `cx`): inside the room kept for its effects.
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, obj_h: f32) -> Rect {
let [l, t, r, b] = match obj {
Some(InlineObject::Image { float, .. } | InlineObject::Shape { float, .. }) => float.effect_extent(),
Some(InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. }) => float.effect_extent(),
_ => [0.0; 4],
};
Rect::new(cx + l, base - obj_h + t, (adv - l - r).max(0.0), (obj_h - t - b).max(0.0))
+40 -7
View File
@@ -96,6 +96,15 @@ pub enum Placed {
stroke: Option<Rgb>,
stroke_width: f32,
},
/// A floating chart or diagram, drawn from its items inside `rect`. The object is the U+FFFC at
/// byte `off` of paragraph `path` (its alt text).
Graphic {
rect: Rect,
graphic: Arc<wordcraft_doc::graphic::Graphic>,
story: StoryRef,
path: Path,
off: usize,
},
/// A table cell's area (for hit testing and cell selection).
Cell {
rect: Rect,
@@ -128,7 +137,11 @@ impl Placed {
*x += dx;
*y += dy;
}
Placed::Fill { rect, .. } | Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Cell { rect, .. } => {
Placed::Fill { rect, .. }
| Placed::Image { rect, .. }
| Placed::Shape { rect, .. }
| Placed::Graphic { rect, .. }
| Placed::Cell { rect, .. } => {
rect.x += dx;
rect.y += dy;
}
@@ -174,7 +187,11 @@ impl Placed {
*t = turn;
}
}
Placed::Fill { rect, .. } | Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Cell { rect, .. } => {
Placed::Fill { rect, .. }
| Placed::Image { rect, .. }
| Placed::Shape { rect, .. }
| Placed::Graphic { rect, .. }
| Placed::Cell { rect, .. } => {
*rect = turn_rect(turn, x, y, *rect);
}
Placed::Object { rect, origin, .. } => {
@@ -702,7 +719,13 @@ fn fit_box(items: Vec<Placed>, height: f32) -> Vec<Placed> {
Placed::Rule { x0, y0, x1, y1, border } if y0.min(y1) < limit => {
out.push(Placed::Rule { x0, y0: y0.min(limit), x1, y1: y1.min(limit), border });
}
Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Cell { rect, .. } | Placed::Object { rect, .. } if rect.y < limit => {
Placed::Image { rect, .. }
| Placed::Shape { rect, .. }
| Placed::Graphic { rect, .. }
| Placed::Cell { rect, .. }
| Placed::Object { rect, .. }
if rect.y < limit =>
{
out.push(it);
}
_ => {}
@@ -1117,10 +1140,14 @@ struct PageFrame<'a> {
origin: (f32, f32),
}
/// A floating (not inline) picture or shape: its size and placement.
/// A floating (not inline) picture, chart or shape: its size and placement.
fn floating(o: &InlineObject) -> Option<(f32, f32, &Float)> {
match o {
InlineObject::Image { w, h, float, .. } | InlineObject::Shape { w, h, float, .. } if float.wrap != Wrap::Inline => Some((*w, *h, float)),
InlineObject::Image { w, h, float, .. } | InlineObject::Graphic { w, h, float, .. } | InlineObject::Shape { w, h, float, .. }
if float.wrap != Wrap::Inline =>
{
Some((*w, *h, float))
}
_ => None,
}
}
@@ -1194,6 +1221,7 @@ fn float_item(o: &InlineObject, rect: Rect, story: StoryRef, path: &[u32], off:
InlineObject::Shape { kind, fill, stroke, stroke_width, .. } => {
Some(Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
}
InlineObject::Graphic { graphic, .. } => Some(Placed::Graphic { rect, graphic: graphic.clone(), story, path: Path(path.to_vec()), off }),
_ => None,
}
}
@@ -1275,7 +1303,12 @@ fn place_objects(
if left_out(oi) {
continue;
}
let Some(obj @ (InlineObject::Image { w, h, float, .. } | InlineObject::Shape { w, h, float, .. })) = p.objects.get(oi) else { continue };
let Some(
obj @ (InlineObject::Image { w, h, float, .. } | InlineObject::Graphic { w, h, float, .. } | InlineObject::Shape { w, h, float, .. }),
) = p.objects.get(oi)
else {
continue;
};
let floating = float.wrap != Wrap::Inline;
let rect = if floating {
floats.get(&oi).copied().unwrap_or_else(|| float_rect(at.page, at.col, at.para_y, *w, *h, float))
@@ -1770,7 +1803,7 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti
/// Is an object floating (not laid out inline)?
pub fn is_floating(o: &InlineObject) -> bool {
match o {
InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline,
InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline,
_ => false,
}
}
+3 -1
View File
@@ -732,7 +732,9 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
let k = obj_index;
obj_index += 1;
match p.objects.get(k) {
Some(InlineObject::Image { w, h, float, .. }) | Some(InlineObject::Shape { w, h, float, .. }) => {
Some(InlineObject::Image { w, h, float, .. })
| Some(InlineObject::Graphic { w, h, float, .. })
| Some(InlineObject::Shape { w, h, float, .. }) => {
if float.wrap == wordcraft_doc::para::Wrap::Inline {
let maxw = (env.width - rp.indent_left.max(0.0) - rp.indent_right.max(0.0)).max(18.0);
let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0));
+2 -2
View File
@@ -468,7 +468,7 @@ pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> {
}
}
}
Placed::Image { rect, .. } | Placed::Shape { rect, .. } => {
Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } => {
if rect.bottom() <= cut + 0.01 {
kept_any = true;
} else {
@@ -535,7 +535,7 @@ pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> {
});
}
}
Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Object { rect, .. } => {
Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } | Placed::Object { rect, .. } => {
if rect.bottom() <= cut + 0.01 {
a.push(it.clone());
} else {
+152
View File
@@ -2376,6 +2376,158 @@ fn hidden_float_in_a_text_box_is_not_placed() {
assert_eq!((shown - hidden_n, shown_areas - hidden_areas), (1, 1), "shown {shown}/{shown_areas}, hidden {hidden_n}/{hidden_areas}");
}
#[test]
fn inline_graphic_reserves_its_height() {
let mut d = Document::from_text("Chart\nAfter");
let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: String::new(), float: Default::default(), graphic: Arc::new(Default::default()) };
d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
let l = lay(&d);
let tops: Vec<f32> = l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { y, .. } = i { Some(*y) } else { None }).collect();
assert_eq!(tops.len(), 2);
assert!(tops[1] - tops[0] >= 144.0, "the next paragraph starts below the chart: {tops:?}");
}
#[test]
fn graphic_items_draw_inside_the_object() {
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg, TextAlign};
use wordcraft_doc::props::Rgb;
let items = vec![
GraphicItem::Shape {
rect: [10.0, 5.0, 20.0, 20.0],
kind: wordcraft_doc::para::ShapeKind::Rectangle,
fill: Some(Rgb::BLACK),
stroke: None,
stroke_width: 0.0,
},
GraphicItem::Path {
segs: vec![PathSeg::Move(1.0, 2.0), PathSeg::Line(30.0, 40.0), PathSeg::Close],
fill: None,
stroke: Some(Rgb::BLACK),
stroke_width: 0.0,
},
GraphicItem::Text {
rect: [0.0, 100.0, 216.0, 20.0],
text: "Q1".into(),
size: 10.0,
color: Rgb::BLACK,
bold: false,
align: TextAlign::Center,
font: None,
},
];
let mut d = Document::from_text("Chart");
let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() };
let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
let l = lay(&d);
// The chart's draws sit inside its figure.
let draws = flat(display::page_display(&d, &l.pages[0], &Default::default()));
let shape = draws.iter().find_map(|x| if let display::Draw::Shape { rect, .. } = x { Some(*rect) } else { None }).expect("a shape");
let first = draws.iter().find_map(|x| if let display::Draw::Path { segs, .. } = x { segs.first().copied() } else { None }).expect("a path");
let PathSeg::Move(mx, my) = first else { panic!("{first:?}") };
// Path points are page coordinates, moved with the object like the shape's rectangle.
assert_eq!((mx - shape.x, my - shape.y), (1.0 - 10.0, 2.0 - 5.0));
assert!(draws.iter().any(|x| matches!(x, display::Draw::Glyphs { text, .. } if text == "Q1")));
}
#[test]
fn graphic_text_in_a_degenerate_rectangle_is_not_drawn() {
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, TextAlign};
use wordcraft_doc::props::Rgb;
let text = |rect: [f32; 4], size: f32| GraphicItem::Text {
rect,
text: "Q1".into(),
size,
color: Rgb::BLACK,
bold: false,
align: TextAlign::Center,
font: None,
};
let items = vec![text([f32::NAN, 0.0, 10.0, 10.0], 10.0), text([0.0, 0.0, f32::INFINITY, 10.0], 10.0), text([0.0, 0.0, 10.0, 10.0], f32::NAN)];
let graphic = Graphic { kind: GraphicKind::Diagram, items, ..Default::default() };
let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
let mut d = Document::from_text("Chart");
d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default()));
assert!(!draws.iter().any(|x| matches!(x, display::Draw::Glyphs { text, .. } if text == "Q1")));
}
/// The draws with figures opened up.
fn flat(draws: Vec<display::Draw>) -> Vec<display::Draw> {
draws.into_iter().flat_map(|x| if let display::Draw::Figure { draws, .. } = x { draws } else { vec![x] }).collect()
}
#[test]
fn inline_chart_is_a_figure_with_its_alt_text() {
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg};
use wordcraft_doc::props::Rgb;
let items = vec![GraphicItem::Path {
segs: vec![PathSeg::Move(1.0, 1.0), PathSeg::Line(2.0, 2.0)],
fill: None,
stroke: Some(Rgb::BLACK),
stroke_width: 1.0,
}];
let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() };
let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: "Sales by quarter".into(), float: Default::default(), graphic: Arc::new(graphic) };
let mut d = Document::from_text("Chart");
d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
let draws = display::page_display(&d, &lay(&d).pages[0], &Default::default());
let figure = draws.iter().find_map(|x| if let display::Draw::Figure { alt, draws, .. } = x { Some((alt.clone(), draws.len())) } else { None });
assert_eq!(figure, Some(("Sales by quarter".to_string(), 1)));
}
#[test]
fn graphic_paths_drop_segments_that_cannot_be_drawn() {
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg};
use wordcraft_doc::props::Rgb;
// The line before the first move, the move with a NaN, the infinite line and the line after the
// close are all dropped.
let segs = vec![
PathSeg::Line(5.0, 5.0),
PathSeg::Move(f32::NAN, 1.0),
PathSeg::Move(1.0, 2.0),
PathSeg::Line(3.0, 4.0),
PathSeg::Line(f32::INFINITY, 0.0),
PathSeg::Close,
PathSeg::Line(9.0, 9.0),
];
let items = vec![GraphicItem::Path { segs, fill: None, stroke: Some(Rgb::BLACK), stroke_width: 1.0 }];
let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() };
let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
let mut d = Document::from_text("Chart");
d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default()));
let segs: Vec<PathSeg> =
draws.iter().find_map(|x| if let display::Draw::Path { segs, .. } = x { Some(segs.clone()) } else { None }).expect("a path");
assert!(matches!(segs.as_slice(), [PathSeg::Move(x, y), PathSeg::Line(..), PathSeg::Close] if x.is_finite() && y.is_finite()), "{segs:?}");
}
#[test]
fn oversized_graphic_draws_shrunk_with_its_items() {
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind};
use wordcraft_doc::props::Rgb;
// A 1000 x 500 pt chart is wider than the text: layout shrinks it, and its items with it.
let items = vec![GraphicItem::Shape {
rect: [0.0, 0.0, 1000.0, 500.0],
kind: wordcraft_doc::para::ShapeKind::Rectangle,
fill: None,
stroke: Some(Rgb::BLACK),
stroke_width: 4.0,
}];
let graphic = Graphic { kind: GraphicKind::Chart, items, w: 1000.0, h: 500.0 };
let obj = InlineObject::Graphic { w: 1000.0, h: 500.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
let mut d = Document::from_text("Chart");
d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default()));
let (r, stroke) = draws
.iter()
.find_map(|x| if let display::Draw::Shape { rect, stroke_width, .. } = x { Some((*rect, *stroke_width)) } else { None })
.expect("a shape");
assert!(r.w < 1000.0, "{r:?}");
assert!((r.h / r.w - 0.5).abs() < 1e-3, "{r:?}");
assert!((stroke / r.w - 4.0 / 1000.0).abs() < 1e-5, "stroke {stroke} at width {}", r.w);
}
/// The paragraph at `path` on page 0: its first line's x, its y and its line count.
fn para_at(l: &DocLayout, path: &[u32]) -> Vec<(f32, f32, usize)> {
l.pages[0]
+45
View File
@@ -41,6 +41,7 @@ use krilla::paint::{Fill, FillRule, LineCap, LineJoin, Stroke, StrokeDash};
use krilla::surface::Surface;
use krilla::tagging::{Artifact, ArtifactType, ContentTag, Identifier, Node, SpanTag, Tag, TagGroup, TagKind, TagTree};
use krilla::text::{Font, GlyphId, KrillaGlyph};
use wordcraft_doc::graphic::GraphicKind;
use wordcraft_doc::para::{InlineObject, ShapeKind};
use wordcraft_doc::{Document, Path as DocPath, Rgb, StoryRef};
use wordcraft_fonts::FaceRef;
@@ -197,6 +198,11 @@ fn glyph_outlines(face: &FaceRef, size: f32, glyphs: &[(u32, f32, f32)]) -> Opti
pb.finish()
}
/// A chart's or diagram's alt text: its own, else what it is ("chart", "diagram").
fn graphic_alt(alt: &str, kind: GraphicKind) -> &str {
if alt.trim().is_empty() { kind.noun() } else { alt }
}
/// Add the segments of a kurbo path to a krilla path.
fn append_path(pb: &mut PathBuilder, path: &kurbo::BezPath) {
let f = |v: f64| v as f32;
@@ -570,6 +576,12 @@ impl Exporter<'_> {
match d {
Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)),
Draw::Image { .. } => self.tagged(s, Role::Figure, None, |me, s| me.draw(s, d)),
// An inline chart or diagram: its text is part of the figure, not the paragraph.
Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(graphic_alt(alt, *kind)), |me, s| {
for d in inner {
me.draw(s, d);
}
}),
_ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
}
}
@@ -585,6 +597,17 @@ impl Exporter<'_> {
}
});
}
Placed::Graphic { story, path, off, graphic, .. } => {
let alt = match self.doc.para(*story, path).and_then(|p| p.object_at(*off)) {
Some(InlineObject::Graphic { alt, .. }) => graphic_alt(alt, graphic.kind).to_string(),
_ => graphic.kind.noun().to_string(),
};
self.tagged(s, Role::Figure, Some(&alt), |me, s| {
for d in &draws {
me.draw(s, d);
}
});
}
_ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| {
for d in &draws {
me.draw(s, d);
@@ -643,6 +666,11 @@ impl Exporter<'_> {
fn draw(&mut self, s: &mut Surface, d: &Draw) {
match d {
Draw::Figure { draws, .. } => {
for d in draws {
self.draw(s, d);
}
}
Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges } => {
self.glyphs(s, face, *size, glyphs, *color, *alpha, *synth_bold, *synth_italic, text, ranges);
if let Some(l) = link {
@@ -659,6 +687,23 @@ impl Exporter<'_> {
}
Draw::Line { x0, y0, x1, y1, width, color, stroke, alpha } => self.line(s, (*x0, *y0, *x1, *y1), *width, *color, *stroke, *alpha),
Draw::Image { rect, media, crop, alpha } => self.picture(s, rect, media, crop, *alpha),
Draw::Path { segs, fill: f, stroke, stroke_width } => {
let mut pb = PathBuilder::new();
append_path(&mut pb, &wordcraft_layout::display::seg_path(segs));
let Some(p) = pb.finish() else { return };
s.set_fill(f.map(|c| fill(c, 1.0)));
s.set_stroke(stroke.map(|c| Stroke {
paint: rgb::Color::new(c.0, c.1, c.2).into(),
// Width 0 (or none) is a hairline.
width: if stroke_width.is_finite() && *stroke_width > 0.0 { stroke_width.clamp(0.25, 200.0) } else { 0.75 },
..Default::default()
}));
if f.is_some() || stroke.is_some() {
s.draw_path(&p);
}
s.set_fill(None);
s.set_stroke(None);
}
Draw::Shape { rect, kind, fill: f, stroke, stroke_width } => {
let Some(p) = shape_path(*kind, rect) else { return };
let can_fill = *kind != ShapeKind::Line;
+36
View File
@@ -582,6 +582,42 @@ fn turned_cell_text_exports_as_text() {
}
}
#[test]
fn inline_chart_is_a_figure_with_its_text_inside_it() {
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, TextAlign};
use wordcraft_doc::props::Rgb;
let items = vec![GraphicItem::Text {
rect: [0.0, 0.0, 216.0, 20.0],
text: "Q1".into(),
size: 10.0,
color: Rgb::BLACK,
bold: false,
align: TextAlign::Center,
font: None,
}];
let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() };
let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: "Sales by quarter".into(), float: Default::default(), graphic: Arc::new(graphic) };
let mut d = Document::from_text("Chart");
d.insert_object(&wordcraft_doc::Pos::body(0, 0), obj, &Default::default()).unwrap();
let raw = |tagged| String::from_utf8_lossy(&export(&d, &PdfOptions { tagged, compress: false, ..Default::default() }).unwrap()).into_owned();
assert!(raw(true).contains("/Figure"), "tagged chart is a figure");
assert!(!raw(false).contains("/Figure"));
}
#[test]
fn chart_without_alt_text_is_a_figure_called_chart() {
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg};
let segs = vec![PathSeg::Move(1.0, 1.0), PathSeg::Line(50.0, 50.0)];
let items = vec![GraphicItem::Path { segs, fill: None, stroke: Some(wordcraft_doc::props::Rgb::BLACK), stroke_width: 1.0 }];
let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() };
let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
let mut d = Document::from_text("Chart");
d.insert_object(&wordcraft_doc::Pos::body(0, 0), obj, &Default::default()).unwrap();
let raw = String::from_utf8_lossy(&export(&d, &PdfOptions { tagged: true, compress: false, ..Default::default() }).unwrap()).into_owned();
assert!(raw.contains("/Alt(chart)"), "no chart alt text");
assert!(!raw.contains("/Alt(picture)"));
}
/// A 25 mm square EMF with a red rectangle over its right half (device units 0.04 per 0.01 mm).
fn red_right_half_emf() -> Vec<u8> {
fn rec(typ: u32, payload: &[u8]) -> Vec<u8> {
+26 -1
View File
@@ -453,6 +453,11 @@ pub fn stem_darkening(ppem: f64) -> f64 {
fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visible: &kurbo::Rect, opts: &RenderOptions) {
match it {
Draw::Figure { draws, .. } => {
for d in draws {
draw(ctx, doc, d, view, visible, opts);
}
}
Draw::Fill { rect, color: c, alpha } => {
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
if !r.overlaps(*visible) {
@@ -524,7 +529,9 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
}
for (gid, x, y) in glyphs {
let (gx, gy) = (*x as f64, *y as f64);
if gx < visible.x0 - 100.0 || gx > visible.x1 || gy < visible.y0 || gy > visible.y1 + 200.0 {
// Written so a NaN position is skipped too.
let near = gx >= visible.x0 - 100.0 && gx <= visible.x1 && gy >= visible.y0 && gy <= visible.y1 + 200.0;
if !near {
continue;
}
let o = db.outline(face, *gid);
@@ -614,6 +621,24 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
ctx.pop_layer();
}
}
Draw::Path { segs, fill, stroke, stroke_width } => {
let path = wordcraft_layout::display::seg_path(segs);
if path.elements().is_empty() {
return;
}
ctx.set_transform(view);
if let Some(f) = fill {
ctx.set_paint(color(opts.ink(*f), 1.0));
ctx.fill_path(&path);
}
if let Some(s) = stroke {
// Width 0 (or none) is a hairline.
let w = if stroke_width.is_finite() && *stroke_width > 0.0 { stroke_width.clamp(0.25, 200.0) } else { 0.75 };
ctx.set_paint(color(opts.ink(*s), 1.0));
ctx.set_stroke(kurbo::Stroke::new(w as f64));
ctx.stroke_path(&path);
}
}
Draw::Shape { rect, kind, fill, stroke, stroke_width } => {
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
let path = shape_path(*kind, r);
+3
View File
@@ -252,6 +252,9 @@ fn page_key(app: &WordApp, page: &Page, scale_px: f32, dim_body: bool) -> u64 {
Placed::Rule { x0, y0, x1, y1, border } => format!("{x0}{y0}{x1}{y1}{border:?}").hash(&mut h),
Placed::Image { rect, media, .. } => format!("{rect:?}{media}").hash(&mut h),
Placed::Shape { rect, kind, fill, stroke, .. } => format!("{rect:?}{kind:?}{fill:?}{stroke:?}").hash(&mut h),
Placed::Graphic { rect, graphic, .. } => {
(std::sync::Arc::as_ptr(graphic) as usize, rect.x.to_bits(), rect.y.to_bits(), rect.w.to_bits(), rect.h.to_bits()).hash(&mut h)
}
Placed::Cell { .. } | Placed::Object { .. } => {}
}
}
+52 -6
View File
@@ -39,6 +39,14 @@ pub fn selected(app: &WordApp) -> Option<(Pos, &InlineObject)> {
wordcraft_engine::cmd::objects::object_selection(&app.session).filter(|(p, _)| p.story == StoryRef::Body)
}
/// Whether the object under `hit` keeps its place and size: charts and diagrams can be selected
/// and deleted, not moved or resized (they aren't written back on save yet), so they get no
/// handles, no move cursor and no drag.
fn fixed(app: &WordApp, hit: &ObjectHit) -> bool {
let pos = hit.pos();
matches!(app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)), Some(InlineObject::Graphic { .. }))
}
/// The story of the selected text box.
fn selected_text_box(app: &WordApp) -> Option<u32> {
match selected(app)? {
@@ -94,8 +102,8 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
Some((_, o)) => (wordcraft_engine::cmd::objects::min_size(o), matches!(o, InlineObject::Image { .. })),
None => return true,
};
// Objects in table cells can be selected and resized, not moved.
if grab != Grab::Move || movable(&object) {
// Objects in table cells can be selected and resized, not moved; charts neither.
if !fixed(app, &object) && (grab != Grab::Move || movable(&object)) {
app.canvas.obj_drag = Some(ObjectDrag { drag: Drag::new(grab, object.page, object.rect, at), object, min, picture });
}
}
@@ -104,7 +112,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
/// What a press at `at` would grab: a handle of the shown frame, else an object.
fn grab_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2) -> Option<(ObjectHit, Grab)> {
let handle = active(app, layout).filter(|(o, _)| o.story == StoryRef::Body).and_then(|(o, _)| {
let handle = active(app, layout).filter(|(o, _)| o.story == StoryRef::Body && !fixed(app, o)).and_then(|(o, _)| {
let f = screen(pages, scale, o.page, o.rect)?;
frame::handle_at(f, at).map(|h| (o, Grab::Resize(h)))
});
@@ -156,8 +164,8 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p
if let Some((o, editing)) = active(app, layout)
&& let Some(f) = screen(pages, scale, o.page, o.rect)
{
// Handles where it can be resized (body objects).
frame::paint(painter, t, f, editing, o.story == StoryRef::Body);
// Handles where it can be resized (body objects, not charts): else a plain frame.
frame::paint(painter, t, f, editing, o.story == StoryRef::Body && !fixed(app, &o));
}
}
@@ -169,7 +177,7 @@ pub fn cursor(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at:
if crate::canvas::editing_header_footer(app, layout) {
return None;
}
grab_at(app, layout, pages, scale, at).map(|(_, g)| g.cursor())
grab_at(app, layout, pages, scale, at).map(|(o, g)| if fixed(app, &o) { egui::CursorIcon::Default } else { g.cursor() })
}
/// Keys for a selected object: arrows nudge a floating one; typing or Enter goes into a text box.
@@ -197,3 +205,41 @@ pub fn enter_text_box(app: &mut WordApp) -> bool {
let end = app.session.doc.end_of(StoryRef::Part(id));
app.run("caret.set", json!({"pos": end})).is_ok()
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use wordcraft_doc::para::{InlineObject, ShapeKind};
use wordcraft_doc::{Document, Pos};
use super::*;
/// An app whose first paragraph holds `obj`, and where it's laid out.
fn app_with(obj: InlineObject) -> (WordApp, ObjectHit) {
let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
app.session.doc.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
let hit = app.session.layout().object(&Pos::body(0, 0), 0).expect("laid out");
(app, hit)
}
#[test]
fn charts_get_no_handles_or_drag_but_shapes_do() {
let chart =
InlineObject::Graphic { w: 200.0, h: 100.0, alt: String::new(), float: Default::default(), graphic: Arc::new(Default::default()) };
let (app, hit) = app_with(chart);
assert!(fixed(&app, &hit));
let shape = InlineObject::Shape {
kind: ShapeKind::Rectangle,
w: 50.0,
h: 50.0,
fill: None,
stroke: None,
stroke_width: 1.0,
float: Default::default(),
story: None,
};
let (app, hit) = app_with(shape);
assert!(!fixed(&app, &hit));
}
}