mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-14 03:29:04 +00:00
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:
32 files changed
+3626
-41
No files matched your search
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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 { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ impl Writer {
|
||||
let a = esc(a);
|
||||
out.push_str(&format!("{{\\*\\bkmkstart {a}}}{{\\*\\bkmkend {a}}}"));
|
||||
}
|
||||
Inline::Equation { .. } => {}
|
||||
Inline::Figure(_) | Inline::Equation { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user