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:
Claude
2026-11-01 22:37:45 +00:00
parent 2877514d9e
commit 04ff4e3a54
10 changed files with 656 additions and 15 deletions
+1
View File
@@ -46,6 +46,7 @@ mod flat;
mod forms;
mod formula_size;
mod frames;
mod geometry;
mod import;
mod limits;
mod objects;
+22 -4
View File
@@ -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 {
+420
View File
@@ -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)))
}
+21
View File
@@ -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()
+1
View File
@@ -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));
}
+2 -2
View File
@@ -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.
+12 -5
View File
@@ -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