mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 03:39:51 -06:00
Gap 19: ODT custom shapes drawn from their own geometry
A custom shape whose type WordCraft has no kind for (hexagon, octagon, smiley, can, moon, lightning, block arc, cross, pentagon, flowchart shapes…) opened as a rectangle. It is now drawn from its own geometry (ODF 1.3 §10.6.2) as a freeform: - the enhanced path (§19.145): moves, lines, curves, ellipses (T, U), arcs (A, B, W, V) and quarter ellipses (X, Y), arcs followed in short straight steps; - its values worked out from the shape's equations (§10.6.3) and adjustment values: numbers, other equations, modifiers, the frame's keywords, + - * /, abs, sqrt, sin, cos, tan, atan, atan2, min, max and if. Path length, equation count and nesting, and points are all bounded. LibreOffice fills no part of a `draw:path` that has an open part. So a freeform with both closed and open paths is now saved as a custom shape whose path leaves the open ones unfilled (`F`). LibreOffice's preset shapes render as LibreOffice draws them. The saved file validates against the schema. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
@@ -46,6 +46,7 @@ mod flat;
|
||||
mod forms;
|
||||
mod formula_size;
|
||||
mod frames;
|
||||
mod geometry;
|
||||
mod import;
|
||||
mod limits;
|
||||
mod objects;
|
||||
|
||||
@@ -121,6 +121,9 @@ pub(super) struct Body<'a> {
|
||||
shape_flip: (bool, bool),
|
||||
/// A freeform shape's paths (a polygon's, polyline's, path's or regular polygon's).
|
||||
shape_path: Option<std::sync::Arc<wordcraft_doc::freeform::Freeform>>,
|
||||
/// A custom shape's geometry being read, when its type has no WordCraft kind: drawn from its
|
||||
/// own path and equations.
|
||||
geometry: Option<Box<super::geometry::Pending>>,
|
||||
/// The turn of a group's member being read, from its `draw:transform`.
|
||||
member_rot: Option<f32>,
|
||||
/// The depth of the `draw:g` group being read: its shapes are placed by it, not anchored.
|
||||
@@ -201,6 +204,7 @@ impl<'a> Body<'a> {
|
||||
shape_kind: None,
|
||||
shape_flip: (false, false),
|
||||
shape_path: None,
|
||||
geometry: None,
|
||||
member_rot: None,
|
||||
group_at: None,
|
||||
index: None,
|
||||
@@ -652,6 +656,12 @@ impl Body<'_> {
|
||||
return;
|
||||
}
|
||||
if self.skip > 0 {
|
||||
if self.skip == 1
|
||||
&& q == "draw:equation"
|
||||
&& let Some(g) = &mut self.geometry
|
||||
{
|
||||
g.equation(a);
|
||||
}
|
||||
self.skip += 1;
|
||||
return;
|
||||
}
|
||||
@@ -669,11 +679,12 @@ impl Body<'_> {
|
||||
if self.tracked_context(q, a) {
|
||||
return;
|
||||
}
|
||||
// A custom shape's geometry: its kind. Its formulas and handles aren't read.
|
||||
// A custom shape's geometry: its kind, or, for a type WordCraft has no kind for, its
|
||||
// path, worked out from its equations when the element ends. Its handles aren't read.
|
||||
if q == "draw:enhanced-geometry" && self.shape_kind.is_some() {
|
||||
if let Some(t) = get(a, "draw:type") {
|
||||
self.shape_kind = Some(super::shapes::kind_of(t));
|
||||
}
|
||||
let t = get(a, "draw:type").unwrap_or_default();
|
||||
self.shape_kind = Some(super::shapes::kind_of(t));
|
||||
self.geometry = super::geometry::Pending::of(t, a);
|
||||
// Mirrored (§20.112 `draw:mirror-horizontal`, §20.113 `draw:mirror-vertical`).
|
||||
let on = |k: &str| get(a, k).is_some_and(|v| v.trim() == "true");
|
||||
self.shape_flip = (on("draw:mirror-horizontal"), on("draw:mirror-vertical"));
|
||||
@@ -1089,6 +1100,13 @@ impl Body<'_> {
|
||||
}
|
||||
if self.skip > 0 {
|
||||
self.skip -= 1;
|
||||
if self.skip == 0
|
||||
&& let Some(g) = self.geometry.take()
|
||||
&& let Some(f) = g.freeform()
|
||||
{
|
||||
self.shape_kind = Some(wordcraft_doc::para::ShapeKind::Freeform);
|
||||
self.shape_path = Some(std::sync::Arc::new(f));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !self.in_text {
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
//! A custom shape's own geometry (ODF 1.3 §10.6.2 `draw:enhanced-geometry`): its path
|
||||
//! (§19.145 `draw:enhanced-path`) in its view box, the path's values worked out from its
|
||||
//! equations (§10.6.3 `draw:equation`, §19.170 `draw:formula`) and adjustment values
|
||||
//! (`draw:modifiers`). Shapes whose type WordCraft has no kind for are drawn from it, as
|
||||
//! freeforms.
|
||||
//!
|
||||
//! Arcs and ellipses are followed in short straight steps. Everything is bounded: the path's
|
||||
//! length, the equations' count and nesting, the number of points.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::xml::{Attrs, get};
|
||||
use wordcraft_doc::freeform::Freeform;
|
||||
use wordcraft_doc::para::ShapeKind;
|
||||
|
||||
/// Most equations read, most levels one may refer down through, most path tokens and points.
|
||||
const MAX_EQUATIONS: usize = 512;
|
||||
const MAX_DEPTH: usize = 64;
|
||||
const MAX_TOKENS: usize = 20_000;
|
||||
const MAX_POINTS: usize = 20_000;
|
||||
/// Straight steps an arc of a full turn is followed in.
|
||||
const ARC_STEPS: f64 = 64.0;
|
||||
|
||||
/// Longest path or formula kept, in bytes.
|
||||
const MAX_TEXT: usize = 64 * 1024;
|
||||
|
||||
/// A custom shape's geometry being read: its path, view box and adjustment values, then its
|
||||
/// equations, from the `draw:equation` children.
|
||||
pub(super) struct Pending {
|
||||
path: String,
|
||||
vb: Option<[f32; 4]>,
|
||||
modifiers: String,
|
||||
equations: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// `s` cut to at most `MAX_TEXT` bytes, at a character boundary.
|
||||
fn capped(s: &str) -> String {
|
||||
let mut end = s.len().min(MAX_TEXT);
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
s.get(..end).unwrap_or_default().to_owned()
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
/// The geometry of a `draw:enhanced-geometry` of type `t` with attributes `a`, when it is
|
||||
/// to be drawn from its own path: a type WordCraft has no kind for, with a path.
|
||||
pub(super) fn of(t: &str, a: &Attrs) -> Option<Box<Pending>> {
|
||||
let t = t.trim();
|
||||
let plain = matches!(t.strip_prefix("ooxml-").unwrap_or(t), "" | "rectangle" | "rect" | "flowchart-process" | "flowChartProcess");
|
||||
if super::shapes::kind_of(t) != ShapeKind::Rectangle || plain {
|
||||
return None;
|
||||
}
|
||||
let path = get(a, "draw:enhanced-path")?;
|
||||
Some(Box::new(Pending {
|
||||
path: capped(path),
|
||||
vb: super::path::view_box(get(a, "svg:viewBox")),
|
||||
modifiers: capped(get(a, "draw:modifiers").unwrap_or_default()),
|
||||
equations: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reads a `draw:equation` (§19.171 `draw:name`, §19.170 `draw:formula`).
|
||||
pub(super) fn equation(&mut self, a: &Attrs) {
|
||||
if self.equations.len() >= MAX_EQUATIONS {
|
||||
return;
|
||||
}
|
||||
if let (Some(n), Some(f)) = (get(a, "draw:name"), get(a, "draw:formula")) {
|
||||
self.equations.push((capped(n.trim()), capped(f)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The freeform the geometry draws.
|
||||
pub(super) fn freeform(&self) -> Option<Freeform> {
|
||||
freeform(&self.path, self.vb, &self.modifiers, &self.equations)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a geometry's values are worked out from.
|
||||
struct Env<'a> {
|
||||
vb: [f64; 4],
|
||||
modifiers: Vec<f64>,
|
||||
equations: &'a [(String, String)],
|
||||
names: HashMap<&'a str, usize>,
|
||||
memo: std::cell::RefCell<HashMap<usize, f64>>,
|
||||
}
|
||||
|
||||
impl Env<'_> {
|
||||
/// The value of a path or formula parameter: a number, `?name` (an equation), `$n` (an
|
||||
/// adjustment value) or a keyword (§19.145).
|
||||
fn param(&self, t: &str, depth: usize) -> Option<f64> {
|
||||
if let Some(n) = t.strip_prefix('?') {
|
||||
return self.equation(*self.names.get(n)?, depth);
|
||||
}
|
||||
if let Some(i) = t.strip_prefix('$') {
|
||||
return self.modifiers.get(i.parse::<usize>().ok()?).copied();
|
||||
}
|
||||
let [x, y, w, h] = self.vb;
|
||||
Some(match t {
|
||||
"left" => x,
|
||||
"top" => y,
|
||||
"right" => x + w,
|
||||
"bottom" => y + h,
|
||||
"width" | "logwidth" => w,
|
||||
"height" | "logheight" => h,
|
||||
"xstretch" | "ystretch" => 0.0,
|
||||
"hasstroke" | "hasfill" => 1.0,
|
||||
"pi" => std::f64::consts::PI,
|
||||
_ => t.parse::<f64>().ok().filter(|v| v.is_finite())?,
|
||||
})
|
||||
}
|
||||
|
||||
fn equation(&self, i: usize, depth: usize) -> Option<f64> {
|
||||
if depth > MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
if let Some(v) = self.memo.borrow().get(&i) {
|
||||
return Some(*v);
|
||||
}
|
||||
let f = &self.equations.get(i)?.1;
|
||||
let mut p = Formula { s: f.as_bytes(), at: 0, env: self, depth: depth + 1 };
|
||||
let v = p.expr()?;
|
||||
let v = if v.is_finite() { v } else { 0.0 };
|
||||
self.memo.borrow_mut().insert(i, v);
|
||||
Some(v)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `draw:formula` being read (recursive descent: sums of products of factors).
|
||||
struct Formula<'a, 'b> {
|
||||
s: &'a [u8],
|
||||
at: usize,
|
||||
env: &'b Env<'b>,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
impl Formula<'_, '_> {
|
||||
fn skip(&mut self) {
|
||||
while self.s.get(self.at).is_some_and(u8::is_ascii_whitespace) {
|
||||
self.at += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn peek(&mut self) -> Option<u8> {
|
||||
self.skip();
|
||||
self.s.get(self.at).copied()
|
||||
}
|
||||
|
||||
fn expr(&mut self) -> Option<f64> {
|
||||
if self.depth > MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let mut v = self.term()?;
|
||||
while let Some(c @ (b'+' | b'-')) = self.peek() {
|
||||
self.at += 1;
|
||||
let r = self.term()?;
|
||||
v = if c == b'+' { v + r } else { v - r };
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn term(&mut self) -> Option<f64> {
|
||||
let mut v = self.factor()?;
|
||||
while let Some(c @ (b'*' | b'/')) = self.peek() {
|
||||
self.at += 1;
|
||||
let r = self.factor()?;
|
||||
v = if c == b'*' {
|
||||
v * r
|
||||
} else if r != 0.0 {
|
||||
v / r
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn factor(&mut self) -> Option<f64> {
|
||||
self.depth += 1;
|
||||
if self.depth > MAX_DEPTH * 4 {
|
||||
return None;
|
||||
}
|
||||
let v = match self.peek()? {
|
||||
b'-' => {
|
||||
self.at += 1;
|
||||
-self.factor()?
|
||||
}
|
||||
b'+' => {
|
||||
self.at += 1;
|
||||
self.factor()?
|
||||
}
|
||||
b'(' => {
|
||||
self.at += 1;
|
||||
let v = self.expr()?;
|
||||
if self.peek() == Some(b')') {
|
||||
self.at += 1;
|
||||
}
|
||||
v
|
||||
}
|
||||
_ => {
|
||||
let start = self.at;
|
||||
while self.s.get(self.at).is_some_and(|c| c.is_ascii_alphanumeric() || matches!(c, b'?' | b'$' | b'.' | b'_')) {
|
||||
self.at += 1;
|
||||
}
|
||||
let word = std::str::from_utf8(self.s.get(start..self.at)?).ok()?;
|
||||
if word.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if self.peek() == Some(b'(') {
|
||||
self.at += 1;
|
||||
let mut args = Vec::new();
|
||||
if self.peek() != Some(b')') {
|
||||
loop {
|
||||
args.push(self.expr()?);
|
||||
match self.peek() {
|
||||
Some(b',') => self.at += 1,
|
||||
_ => break,
|
||||
}
|
||||
if args.len() > 8 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.peek() == Some(b')') {
|
||||
self.at += 1;
|
||||
}
|
||||
function(word, &args)?
|
||||
} else {
|
||||
self.env.param(word, self.depth)?
|
||||
}
|
||||
}
|
||||
};
|
||||
self.depth -= 1;
|
||||
Some(v)
|
||||
}
|
||||
}
|
||||
|
||||
/// A formula function (§19.170) of `args`.
|
||||
fn function(name: &str, a: &[f64]) -> Option<f64> {
|
||||
let x = |i: usize| a.get(i).copied();
|
||||
Some(match name {
|
||||
"abs" => x(0)?.abs(),
|
||||
"sqrt" => x(0)?.max(0.0).sqrt(),
|
||||
"sin" => x(0)?.sin(),
|
||||
"cos" => x(0)?.cos(),
|
||||
"tan" => x(0)?.tan(),
|
||||
"atan" => x(0)?.atan(),
|
||||
"atan2" => x(0)?.atan2(x(1)?),
|
||||
"min" => x(0)?.min(x(1)?),
|
||||
"max" => x(0)?.max(x(1)?),
|
||||
"if" => {
|
||||
if x(0)? > 0.0 {
|
||||
x(1)?
|
||||
} else {
|
||||
x(2)?
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A path being followed: its SVG path data, the pen's place, and how many points so far.
|
||||
#[derive(Default)]
|
||||
struct Pen {
|
||||
d: String,
|
||||
at: (f64, f64),
|
||||
points: usize,
|
||||
}
|
||||
|
||||
impl Pen {
|
||||
fn cmd(&mut self, c: char, pts: &[(f64, f64)]) {
|
||||
if self.points >= MAX_POINTS {
|
||||
return;
|
||||
}
|
||||
self.d.push(c);
|
||||
for (x, y) in pts {
|
||||
self.d.push_str(&format!(" {x:.2} {y:.2}"));
|
||||
}
|
||||
self.d.push(' ');
|
||||
self.points += pts.len().max(1);
|
||||
if let Some(p) = pts.last() {
|
||||
self.at = *p;
|
||||
}
|
||||
}
|
||||
|
||||
/// An arc of the ellipse centred at `c` with radii `r`, from angle `a0` to `a1` (radians,
|
||||
/// the y axis down): a line (or a move, `jump`) to its start, then steps along it.
|
||||
fn arc(&mut self, c: (f64, f64), r: (f64, f64), a0: f64, a1: f64, jump: bool) {
|
||||
let point = |a: f64| (c.0 + r.0 * a.cos(), c.1 + r.1 * a.sin());
|
||||
let start = point(a0);
|
||||
// A line to the start, unless the pen is there already.
|
||||
if jump || self.points == 0 || (start.0 - self.at.0).hypot(start.1 - self.at.1) > 1e-6 {
|
||||
self.cmd(if jump || self.points == 0 { 'M' } else { 'L' }, &[start]);
|
||||
}
|
||||
let sweep = a1 - a0;
|
||||
let steps = ((sweep.abs() / std::f64::consts::TAU) * ARC_STEPS).ceil().clamp(1.0, ARC_STEPS) as usize;
|
||||
for k in 1..=steps {
|
||||
self.cmd('L', &[point(a0 + sweep * k as f64 / steps as f64)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The angle of `p` on the ellipse centred at `c` with radii `r` (radians).
|
||||
fn angle_on(c: (f64, f64), r: (f64, f64), p: (f64, f64)) -> f64 {
|
||||
let (dx, dy) = ((p.0 - c.0) / r.0.max(1e-9), (p.1 - c.1) / r.1.max(1e-9));
|
||||
dy.atan2(dx)
|
||||
}
|
||||
|
||||
/// The freeform an enhanced geometry draws: its `path` with adjustment values `modifiers` and
|
||||
/// `equations` (name, formula), in view box `vb` (§19.570; the default 21600 square). `None`
|
||||
/// when the path draws nothing.
|
||||
pub(super) fn freeform(path: &str, vb: Option<[f32; 4]>, modifiers: &str, equations: &[(String, String)]) -> Option<Freeform> {
|
||||
let vb = vb.map_or([0.0, 0.0, 21600.0, 21600.0], |v| v.map(f64::from));
|
||||
if !(vb[2] > 0.0 && vb[3] > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let equations = equations.get(..equations.len().min(MAX_EQUATIONS)).unwrap_or_default();
|
||||
let names = equations.iter().enumerate().map(|(i, (n, _))| (n.as_str(), i)).collect();
|
||||
let modifiers = modifiers.split_whitespace().take(64).map(|m| m.parse::<f64>().ok().filter(|v| v.is_finite()).unwrap_or(0.0)).collect();
|
||||
let env = Env { vb, modifiers, equations, names, memo: Default::default() };
|
||||
// The path's tokens: command letters, and parameters (numbers, ?names, $n, keywords).
|
||||
let tokens: Vec<&str> = path.split(|c: char| c.is_whitespace() || c == ',').filter(|t| !t.is_empty()).take(MAX_TOKENS).collect();
|
||||
let mut pen = Pen::default();
|
||||
let mut i = 0;
|
||||
let mut cmd = 'M';
|
||||
let is_cmd = |t: &str| t.len() == 1 && t.chars().all(|c| "MLCQZNFSTUABWVXY".contains(c));
|
||||
while i < tokens.len() {
|
||||
let t = tokens.get(i).copied().unwrap_or_default();
|
||||
if is_cmd(t) {
|
||||
cmd = t.chars().next().unwrap_or('M');
|
||||
i += 1;
|
||||
match cmd {
|
||||
'Z' => pen.cmd('Z', &[]),
|
||||
'N' | 'F' | 'S' => {}
|
||||
_ => continue,
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// The command's parameters, as many as it takes each time.
|
||||
let n = match cmd {
|
||||
'M' | 'L' | 'X' | 'Y' => 2,
|
||||
'Q' => 4,
|
||||
'C' | 'T' | 'U' => 6,
|
||||
'A' | 'B' | 'W' | 'V' => 8,
|
||||
_ => {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(args) = tokens.get(i..i + n) else { break };
|
||||
i += n;
|
||||
let v: Option<Vec<f64>> = args.iter().map(|a| env.param(a, 0)).collect();
|
||||
let Some(v) = v else { continue };
|
||||
let p = |k: usize| (v.get(k).copied().unwrap_or(0.0), v.get(k + 1).copied().unwrap_or(0.0));
|
||||
match cmd {
|
||||
'M' => {
|
||||
pen.cmd('M', &[p(0)]);
|
||||
cmd = 'L';
|
||||
}
|
||||
'L' => pen.cmd('L', &[p(0)]),
|
||||
'Q' => pen.cmd('Q', &[p(0), p(2)]),
|
||||
'C' => pen.cmd('C', &[p(0), p(2), p(4)]),
|
||||
// An ellipse from angle to angle, in degrees (§19.145 T, U: U starts a new path).
|
||||
'T' | 'U' => {
|
||||
let (c, r) = (p(0), p(2));
|
||||
let (a0, a1) = (p(4).0.to_radians(), p(4).1.to_radians());
|
||||
let a1 = if a1 <= a0 { a1 + std::f64::consts::TAU } else { a1 };
|
||||
pen.arc(c, r, a0, a1, cmd == 'U');
|
||||
}
|
||||
// Arcs of the ellipse in a box, from where a ray to one point meets it to where a
|
||||
// ray to another does: anticlockwise (A, B) or clockwise (W, V); B and V start a new
|
||||
// path.
|
||||
'A' | 'B' | 'W' | 'V' => {
|
||||
let ((x1, y1), (x2, y2)) = (p(0), p(2));
|
||||
let c = ((x1 + x2) / 2.0, (y1 + y2) / 2.0);
|
||||
let r = (((x2 - x1) / 2.0).abs(), ((y2 - y1) / 2.0).abs());
|
||||
let (a0, a1) = (angle_on(c, r, p(4)), angle_on(c, r, p(6)));
|
||||
let clockwise = matches!(cmd, 'W' | 'V');
|
||||
// The y axis points down: clockwise on the page is increasing angles.
|
||||
let a1 = if clockwise {
|
||||
if a1 <= a0 { a1 + std::f64::consts::TAU } else { a1 }
|
||||
} else if a1 >= a0 {
|
||||
a1 - std::f64::consts::TAU
|
||||
} else {
|
||||
a1
|
||||
};
|
||||
pen.arc(c, r, a0, a1, matches!(cmd, 'B' | 'V'));
|
||||
}
|
||||
// A quarter ellipse to a point, setting out along the x axis (X) or the y axis (Y),
|
||||
// the next one the other way.
|
||||
'X' | 'Y' => {
|
||||
let (from, to) = (pen.at, p(0));
|
||||
let (c, r, a0, a1) = if cmd == 'X' {
|
||||
let c = (from.0, to.1);
|
||||
let r = ((to.0 - from.0).abs(), (to.1 - from.1).abs());
|
||||
(c, r, angle_on(c, r, from), angle_on(c, r, to))
|
||||
} else {
|
||||
let c = (to.0, from.1);
|
||||
let r = ((to.0 - from.0).abs(), (to.1 - from.1).abs());
|
||||
(c, r, angle_on(c, r, from), angle_on(c, r, to))
|
||||
};
|
||||
// The short way round: a quarter.
|
||||
let mut a1 = a1;
|
||||
while a1 - a0 > std::f64::consts::PI {
|
||||
a1 -= std::f64::consts::TAU;
|
||||
}
|
||||
while a0 - a1 > std::f64::consts::PI {
|
||||
a1 += std::f64::consts::TAU;
|
||||
}
|
||||
pen.arc(c, r, a0, a1, false);
|
||||
cmd = if cmd == 'X' { 'Y' } else { 'X' };
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if pen.points < 2 {
|
||||
return None;
|
||||
}
|
||||
super::path::path(&pen.d, Some(vb.map(|v| v as f32)))
|
||||
}
|
||||
@@ -355,6 +355,21 @@ impl PathReader {
|
||||
/// `freeform`'s paths as SVG path data in a view box `vw` × `vh` (whole numbers, as ODF's view
|
||||
/// box is): each path moved to, lined through, and closed when it is.
|
||||
pub(super) fn path_data(f: &Freeform, vw: u32, vh: u32) -> String {
|
||||
path_text(f, vw, vh, false)
|
||||
}
|
||||
|
||||
/// Whether `freeform` has both closed and open paths: drawn filled and unfilled.
|
||||
pub(super) fn is_mixed(f: &Freeform) -> bool {
|
||||
f.paths.iter().any(|p| p.closed) && f.paths.iter().any(|p| !p.closed)
|
||||
}
|
||||
|
||||
/// `freeform`'s paths as a custom shape's path (§19.145 `draw:enhanced-path`) in a view box
|
||||
/// `vw` × `vh`: each path ended (`N`), open ones unfilled (`F`).
|
||||
pub(super) fn enhanced_path_data(f: &Freeform, vw: u32, vh: u32) -> String {
|
||||
path_text(f, vw, vh, true)
|
||||
}
|
||||
|
||||
fn path_text(f: &Freeform, vw: u32, vh: u32, enhanced: bool) -> String {
|
||||
let sx = if f.w > 0.0 { vw as f32 / f.w } else { 1.0 };
|
||||
let sy = if f.h > 0.0 { vh as f32 / f.h } else { 1.0 };
|
||||
let n = |v: f32| {
|
||||
@@ -365,11 +380,17 @@ pub(super) fn path_data(f: &Freeform, vw: u32, vh: u32) -> String {
|
||||
for p in f.paths.iter().take(MAX_PATHS) {
|
||||
for (i, [x, y]) in p.pts.iter().take(MAX_POINTS).enumerate() {
|
||||
d.push_str(if i == 0 { "M" } else { " L" });
|
||||
if enhanced {
|
||||
d.push(' ');
|
||||
}
|
||||
d.push_str(&format!("{} {}", n(x * sx), n(y * sy)));
|
||||
}
|
||||
if p.closed {
|
||||
d.push_str(" Z");
|
||||
}
|
||||
if enhanced {
|
||||
d.push_str(if p.closed { " N" } else { " F N" });
|
||||
}
|
||||
d.push(' ');
|
||||
}
|
||||
d.trim_end().to_string()
|
||||
|
||||
@@ -6,6 +6,7 @@ mod chars;
|
||||
mod charts;
|
||||
mod comments;
|
||||
mod connectors;
|
||||
mod custom_geometry;
|
||||
mod drop_caps;
|
||||
mod encryption;
|
||||
mod equations;
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Custom shapes whose type WordCraft has no kind for (ODF 1.3 §10.6.2
|
||||
//! `draw:enhanced-geometry`): drawn from their own path (§19.145 `draw:enhanced-path`), its
|
||||
//! values worked out from the shape's equations (§10.6.3 `draw:equation`) and adjustment values
|
||||
//! (`draw:modifiers`), as freeforms.
|
||||
|
||||
use crate::odt::tests::{NS_T, package};
|
||||
use crate::odt::{export, import};
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::para::{InlineObject, ShapeKind};
|
||||
|
||||
const NS_D: &str =
|
||||
r#"xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0""#;
|
||||
|
||||
/// A 1in square custom shape with geometry attributes `geo` and equations `eqs`.
|
||||
fn shape(geo: &str, eqs: &str) -> String {
|
||||
format!(
|
||||
r#"<draw:custom-shape text:anchor-type="paragraph" svg:width="1in" svg:height="1in"><draw:enhanced-geometry {geo}>{eqs}</draw:enhanced-geometry></draw:custom-shape>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn open(shapes: &str) -> Document {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><text:p>{shapes}Text.</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
import(&package(&content, None)).unwrap()
|
||||
}
|
||||
|
||||
/// Each shape's kind and its paths in frame points (rounded), closed or not.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn drawn(d: &Document) -> Vec<(ShapeKind, Vec<(Vec<(f32, f32)>, bool)>)> {
|
||||
let r = |v: f32| v.round();
|
||||
d.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Shape { kind, w, h, freeform, .. } => {
|
||||
let paths = freeform
|
||||
.as_ref()
|
||||
.map(|f| f.placed(0.0, 0.0, *w, *h).into_iter().map(|(pts, c)| (pts.iter().map(|[x, y]| (r(*x), r(*y))).collect(), c)).collect())
|
||||
.unwrap_or_default();
|
||||
Some((*kind, paths))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hexagon_is_drawn_from_its_equations_and_adjustment_value() {
|
||||
let d = open(&shape(
|
||||
r#"svg:viewBox="0 0 21600 21600" draw:type="hexagon" draw:modifiers="5400" draw:enhanced-path="M ?f0 0 L ?f1 0 21600 10800 ?f1 21600 ?f0 21600 0 10800 Z N""#,
|
||||
r#"<draw:equation draw:name="f0" draw:formula="$0 "/><draw:equation draw:name="f1" draw:formula="right-$0"/>"#,
|
||||
));
|
||||
let pts = vec![(18.0, 0.0), (54.0, 0.0), (72.0, 36.0), (54.0, 72.0), (18.0, 72.0), (0.0, 36.0)];
|
||||
let got = drawn(&d);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].0, ShapeKind::Freeform);
|
||||
let (p, closed) = &got[0].1[0];
|
||||
assert!(*closed);
|
||||
assert_eq!(p.get(..6), Some(pts.as_slice()), "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_use_functions_operators_and_other_equations() {
|
||||
// f2 = if(f1, 10800, 0) with f1 = max(abs(-3), sqrt(16)) * (2 + 1) / 4 = 3: 10800.
|
||||
let d = open(&shape(
|
||||
r#"draw:type="mso-spt99" draw:enhanced-path="M 0 0 L ?f2 ?f3 N""#,
|
||||
r#"<draw:equation draw:name="f1" draw:formula="max(abs(-3),sqrt(16))*(2+1)/4"/><draw:equation draw:name="f2" draw:formula="if(?f1 ,width/2,0)"/><draw:equation draw:name="f3" draw:formula="-(-?f2)+0/0"/>"#,
|
||||
));
|
||||
assert_eq!(drawn(&d)[0].1, [(vec![(0.0, 0.0), (36.0, 36.0)], false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quarter_ellipses_run_along_one_axis_then_the_other() {
|
||||
// A terminator: straight top and bottom, rounded ends.
|
||||
let d = open(&shape(
|
||||
r#"draw:type="flowchart-terminator" draw:enhanced-path="M 3470 21600 X 0 10800 3470 0 L 18130 0 X 21600 10800 18130 21600 Z N""#,
|
||||
"",
|
||||
));
|
||||
let (_, paths) = &drawn(&d)[0];
|
||||
let pts = &paths[0].0;
|
||||
// Bulging out to the frame's left and right edges, never beyond.
|
||||
let (min_x, max_x) = pts.iter().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.0), b.max(p.0)));
|
||||
assert_eq!((min_x, max_x), (0.0, 72.0));
|
||||
assert!(pts.contains(&(0.0, 36.0)) && pts.contains(&(72.0, 36.0)), "{pts:?}");
|
||||
// The left end leaves the bottom point going left (along x) first: its next point is lower
|
||||
// than half way.
|
||||
let next = pts.get(1).copied().unwrap_or_default();
|
||||
assert!(next.0 < 12.0 && next.1 > 54.0, "{next:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipses_and_arcs_are_followed_in_steps() {
|
||||
// A circle (U: centre, radii, angles), and the upper half of the frame's ellipse, drawn
|
||||
// anticlockwise from its right to its left (A: box, from, to).
|
||||
let d =
|
||||
open(&shape(r#"draw:type="smiley" draw:enhanced-path="U 10800 10800 10800 10800 0 360 Z N B 0 0 21600 21600 21600 10800 0 10800 N""#, ""));
|
||||
let (_, paths) = &drawn(&d)[0];
|
||||
assert_eq!(paths.len(), 2, "{paths:?}");
|
||||
let circle = &paths[0].0;
|
||||
assert!(circle.len() > 32 && circle.iter().all(|(x, y)| ((x - 36.0).hypot(y - 36.0) - 36.0).abs() <= 1.0), "{circle:?}");
|
||||
let arc = &paths[1].0;
|
||||
assert_eq!((arc.first(), arc.last()), (Some(&(72.0, 36.0)), Some(&(0.0, 36.0))));
|
||||
// Anticlockwise on the page (y down): over the top.
|
||||
assert!(arc.iter().all(|(_, y)| *y <= 36.0), "{arc:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_shape_keeps_its_drawn_geometry_when_saved() {
|
||||
let d = open(&shape(r#"draw:type="octagon" draw:enhanced-path="M 0 0 L 21600 0 10800 21600 Z N""#, ""));
|
||||
let again = import(&export(&d).unwrap()).unwrap();
|
||||
assert_eq!(drawn(&again), drawn(&d));
|
||||
assert_eq!(drawn(&again)[0].0, ShapeKind::Freeform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_and_plain_types_keep_their_kinds() {
|
||||
let d = open(&format!(
|
||||
"{}{}",
|
||||
shape(r#"draw:type="ellipse" draw:enhanced-path="M 0 0 L 1 1 N""#, ""),
|
||||
shape(r#"draw:type="rectangle" draw:enhanced-path="M 0 0 L 21600 0 21600 21600 0 21600 Z N""#, "")
|
||||
));
|
||||
let kinds: Vec<ShapeKind> = drawn(&d).iter().map(|s| s.0).collect();
|
||||
assert_eq!(kinds, [ShapeKind::Ellipse, ShapeKind::Rectangle]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_geometry_never_panics_and_draws_what_it_can() {
|
||||
let deep = format!("{}1{}", "(".repeat(5000), ")".repeat(5000));
|
||||
let unary = "-".repeat(5000);
|
||||
let mut eqs = String::new();
|
||||
// A chain of equations each referring to the next, and one referring to itself.
|
||||
for i in 0..2000 {
|
||||
eqs.push_str(&format!(r#"<draw:equation draw:name="f{i}" draw:formula="?f{} +1"/>"#, i + 1));
|
||||
}
|
||||
eqs.push_str(r#"<draw:equation draw:name="me" draw:formula="?me"/>"#);
|
||||
for (path, eq) in [
|
||||
("M ?f0 0 L 10 10 N", eqs.as_str()),
|
||||
("M ?me ?nothing L $99 $-1 N", eqs.as_str()),
|
||||
("M 0 0 L ?d 1 N", &*format!(r#"<draw:equation draw:name="d" draw:formula="{deep}"/>"#)),
|
||||
("M 0 0 L ?u 1 N", &*format!(r#"<draw:equation draw:name="u" draw:formula="{unary}1"/>"#)),
|
||||
("M 1e308 1e308 L -1e308 1e308 C NaN inf 0 0 0 0 N", ""),
|
||||
("U 0 0 1e300 1e300 0 1e300 Z B 0 0 0 0 0 0 0 0 W 1 1 1 1 1 1 1 1 X Y X 5 5 Y", ""),
|
||||
("M 0 0 L 1", ""),
|
||||
("Q", ""),
|
||||
(&*"L 1 1 ".repeat(30_000), ""),
|
||||
(&*format!("M 0 0 {}", "U 10 10 10 10 0 360 ".repeat(5000)), ""),
|
||||
] {
|
||||
let d = open(&shape(&format!(r#"draw:type="hostile" svg:viewBox="0 0 21600 21600" draw:enhanced-path="{path}""#), eq));
|
||||
let _ = drawn(&d);
|
||||
let _ = export(&d).unwrap();
|
||||
}
|
||||
// An empty or broken view box falls back to a rectangle.
|
||||
let d = open(&shape(r#"draw:type="hostile" svg:viewBox="0 0 0 0" draw:enhanced-path="M 0 0 L 1 1 N""#, ""));
|
||||
assert_eq!(drawn(&d)[0].0, ShapeKind::Rectangle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_freeform_with_open_and_closed_paths_is_saved_as_a_custom_shape() {
|
||||
// A face and a mouth: the face filled, the mouth not. A path with an open part would be
|
||||
// drawn unfilled whole, so it is saved as a custom shape whose path marks the mouth unfilled.
|
||||
let d = open(&shape(r#"draw:type="smiley" draw:enhanced-path="M 0 0 L 21600 0 21600 21600 Z N M 5000 15000 L 16000 15000 F N""#, ""));
|
||||
let c = crate::odt::tests::content_of(&export(&d).unwrap());
|
||||
assert!(c.contains(r#"draw:type="non-primitive""#) && c.contains(" F N"), "{c}");
|
||||
assert!(!c.contains("<draw:path"), "{c}");
|
||||
let again = import(&export(&d).unwrap()).unwrap();
|
||||
assert_eq!(drawn(&again), drawn(&d));
|
||||
assert_eq!(drawn(&again)[0].1.iter().map(|p| p.1).collect::<Vec<_>>(), [true, false]);
|
||||
}
|
||||
@@ -104,12 +104,14 @@ fn regular_polygons_and_circles() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeforms_round_trip_as_paths() {
|
||||
fn freeforms_round_trip_as_paths_or_custom_shapes() {
|
||||
let d = open(
|
||||
r#"<draw:polygon text:anchor-type="paragraph" svg:width="2in" svg:height="1in" svg:viewBox="0 0 2000 1000" draw:points="0,0 2000,0 1000,1000"/><draw:path svg:width="1in" svg:height="1in" svg:viewBox="0 0 100 100" svg:d="M0 0 H50 v50 l-50 0 Z m60 0 L100 100"/>"#,
|
||||
);
|
||||
let bytes = export(&d).unwrap();
|
||||
assert_eq!(content_of(&bytes).matches("<draw:path ").count(), 2);
|
||||
// The second, with a closed and an open path, as a custom shape (see `custom_geometry`).
|
||||
let c = content_of(&bytes);
|
||||
assert_eq!((c.matches("<draw:path ").count(), c.matches("<draw:custom-shape ").count()), (1, 1));
|
||||
assert_eq!(drawn(&import(&bytes).unwrap()), drawn(&d));
|
||||
}
|
||||
|
||||
|
||||
@@ -68,8 +68,8 @@ fn shapes_open_with_their_kind_size_look_and_text() {
|
||||
// From (3in, 0) to (1in, 1in): 2in × 1in, rising from its bottom left.
|
||||
(ShapeKind::Line, (144.0, 72.0), None, Some((Rgb(0, 0, 0), 2.0)), true, true, String::new()),
|
||||
(ShapeKind::Heart, (72.0, 72.0), None, None, true, false, String::new()),
|
||||
// A geometry WordCraft has no shape for: a rectangle in its place.
|
||||
(ShapeKind::Rectangle, (72.0, 72.0), None, None, true, false, String::new()),
|
||||
// A geometry WordCraft has no shape for: drawn from its own path.
|
||||
(ShapeKind::Freeform, (72.0, 72.0), None, None, true, false, String::new()),
|
||||
]
|
||||
);
|
||||
// The shape's text isn't the paragraph's.
|
||||
|
||||
@@ -394,8 +394,12 @@ impl Writer {
|
||||
let (element, geometry) = match crate::odt::shapes::element_of(kind) {
|
||||
// A freeform without paths has nothing to draw but its frame.
|
||||
("draw:path", _) if path.is_none() => ("draw:rect", None),
|
||||
// Paths filled and unfilled: a custom shape, whose path says which is which
|
||||
// (LibreOffice fills no part of a path with an open part).
|
||||
("draw:path", _) if path.is_some_and(crate::odt::path::is_mixed) => ("draw:custom-shape", Some("non-primitive")),
|
||||
e => e,
|
||||
};
|
||||
let mut custom_path = String::new();
|
||||
let len = |v: f32, least: f32| if v.is_finite() { v.clamp(least, 1584.0) } else { 72.0 };
|
||||
let at = if element == "draw:line" || element == "draw:connector" {
|
||||
{
|
||||
@@ -421,6 +425,10 @@ impl Writer {
|
||||
let (vw, vh) = ((w * 100.0).round().max(1.0) as u32, (h * 100.0).round().max(1.0) as u32);
|
||||
at.push_str(&format!(" svg:viewBox=\"0 0 {vw} {vh}\" svg:d=\"{}\"", crate::odt::path::path_data(f, vw, vh)));
|
||||
}
|
||||
if let Some(f) = path.filter(|_| geometry == Some("non-primitive")) {
|
||||
let (vw, vh) = ((w * 100.0).round().max(1.0) as u32, (h * 100.0).round().max(1.0) as u32);
|
||||
custom_path = format!(" svg:viewBox=\"0 0 {vw} {vh}\" draw:enhanced-path=\"{}\"", crate::odt::path::enhanced_path_data(f, vw, vh));
|
||||
}
|
||||
at
|
||||
};
|
||||
// Mirrored, as a custom shape's geometry says (§20.112, §20.113).
|
||||
@@ -428,11 +436,10 @@ impl Writer {
|
||||
let (fh, fv) = float.map_or((false, false), |f| (f.flip_h, f.flip_v));
|
||||
let geometry = geometry
|
||||
.map(|t| {
|
||||
format!(
|
||||
"<draw:enhanced-geometry svg:viewBox=\"0 0 21600 21600\" draw:type=\"{t}\"{}{}/>",
|
||||
mirror(fh, "horizontal"),
|
||||
mirror(fv, "vertical")
|
||||
)
|
||||
if custom_path.is_empty() {
|
||||
custom_path = " svg:viewBox=\"0 0 21600 21600\"".into();
|
||||
}
|
||||
format!("<draw:enhanced-geometry{custom_path} draw:type=\"{t}\"{}{}/>", mirror(fh, "horizontal"), mirror(fv, "vertical"))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let conn = self.conn_attrs(kind, look);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user