Gap 19: ODT polygons, polylines, paths and circles read and written

Polygons and polylines (`draw:points`), paths (`svg:d`) and regular
polygons and stars (`draw:corners`, `draw:concave`, `draw:sharpness`)
open as WordCraft freeforms in their view box (none: the box around
their points) instead of being skipped; circles (`draw:circle`, by size
or by centre and radius) open as ellipses.

The path data reader follows SVG path syntax: moves, lines, horizontal
and vertical lines, cubic and quadratic curves and their smooth forms,
and elliptical arcs (converted from endpoint to centre form), absolute
and relative, with repeated commands; curves are followed in 16 straight
steps, a subpath closed by `Z` is a closed path, and input is bounded
(numbers, points, paths, coordinates).

WordCraft's freeforms (ink included) are written as `draw:path` in a
view box of hundredths of a point (whole numbers, as ODF's view box is).

A LibreOffice document with a polygon, a polyline and a closed Bézier
curve (relative `c` and smooth `s` commands) round-trips 333 px apart,
and WordCraft draws it 529 px from LibreOffice; the written content
validates against the ODF 1.3 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 07:38:35 +00:00
parent 88a5fff9d5
commit 422b59f3bc
11 changed files with 600 additions and 21 deletions
+6 -2
View File
@@ -298,7 +298,10 @@ fn drawing_inline(doc: &Document, o: &InlineObject, opts: FlowOpts, depth: usize
use wordcraft_doc::para::ShapeKind;
let (sx, sy) = scale;
match o {
InlineObject::Shape { kind, w, h, float, story, fill, stroke, stroke_width, extra, .. } if *kind != ShapeKind::Freeform => {
// A freeform needs its paths.
InlineObject::Shape { kind, w, h, float, story, fill, stroke, stroke_width, extra, freeform, .. }
if *kind != ShapeKind::Freeform || freeform.is_some() =>
{
// A connector is a straight line.
let kind = match kind {
ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => ShapeKind::Line,
@@ -315,7 +318,8 @@ fn drawing_inline(doc: &Document, o: &InlineObject, opts: FlowOpts, depth: usize
inset: part.and_then(|p| p.body.inset.is_some().then(|| p.body.insets_pt())),
auto_fit: part.is_some_and(|p| p.body.auto_fit),
};
Some(Inline::Shape { kind, w: w * sx, h: h * sy, float: kept_float(float), blocks, look })
let path = freeform.clone().filter(|_| kind == ShapeKind::Freeform);
Some(Inline::Shape { kind, w: w * sx, h: h * sy, float: kept_float(float), blocks, look, path })
}
InlineObject::Image { media, w, h, alt, float, .. } => {
let data = doc.media.get(media)?;
+5 -2
View File
@@ -315,7 +315,7 @@ impl<'a> Builder<'a> {
});
Some(InlineObject::Image { media: key, w, h, alt: img.alt.clone(), float: float.unwrap_or_default(), crop: [0.0; 4], ole: None })
}
Inline::Shape { kind, w, h, float, blocks, look } => {
Inline::Shape { kind, w, h, float, blocks, look, path } => {
// A text box always has its story; a shape only when it holds text.
let story = if *kind == wordcraft_doc::para::ShapeKind::TextBox || !blocks.is_empty() {
let story = self.blocks(blocks, 1);
@@ -344,7 +344,10 @@ impl<'a> Builder<'a> {
stroke_width,
float: float.as_deref().cloned().unwrap_or_default(),
story,
freeform: None,
freeform: path
.clone()
.filter(|_| *kind == wordcraft_doc::para::ShapeKind::Freeform)
.map(|f| std::sync::Arc::new((*f).clone().sanitized())),
effects: Default::default(),
extra: wordcraft_doc::connector::ShapeExtra {
dash: look.dash.clone(),
+2
View File
@@ -211,6 +211,8 @@ pub enum Inline {
float: Option<Box<wordcraft_doc::para::Float>>,
blocks: Vec<FBlock>,
look: BoxLook,
/// A freeform's paths ([`wordcraft_doc::para::ShapeKind::Freeform`]).
path: Option<std::sync::Arc<wordcraft_doc::freeform::Freeform>>,
},
/// Drawing shapes, text boxes and pictures grouped into one object: its size in points, its
/// placement when it floats, and its members, each at its offset in the group. Only flows
+1
View File
@@ -44,6 +44,7 @@ mod frames;
mod import;
mod limits;
mod package;
mod path;
mod shapes;
mod strokes;
mod styles;
+9 -4
View File
@@ -301,6 +301,8 @@ pub(super) struct Body<'a> {
shape_kind: Option<wordcraft_doc::para::ShapeKind>,
/// A custom shape's mirroring (left to right, top to bottom), from its geometry.
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>>,
/// 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.
@@ -424,6 +426,7 @@ impl<'a> Body<'a> {
frame_look: model::BoxLook::default(),
shape_kind: None,
shape_flip: (false, false),
shape_path: None,
member_rot: None,
group_at: None,
group: None,
@@ -863,7 +866,7 @@ impl Body<'_> {
self.skip = 1;
}
// Drawing shapes (ODF 1.3 §10.3) anchored in the text, with their text.
"rect" | "ellipse" | "line" | "custom-shape"
"rect" | "ellipse" | "circle" | "line" | "custom-shape" | "polygon" | "polyline" | "path" | "regular-polygon"
if q.starts_with("draw:") && self.frame.is_none() && self.note.is_none() && self.del.is_none() && !self.in_tc =>
{
self.start_shape(q, a);
@@ -880,8 +883,8 @@ impl Body<'_> {
});
}
}
// Other shapes, and shapes in groups, are left out, with their text. Groups are
// walked, so the pictures in them are still read.
// Connectors, captions and measures, and shapes where none is read (in a note, a
// comment, a shape's text or deleted text), are left out with their text.
"rect" | "line" | "polyline" | "polygon" | "regular-polygon" | "path" | "circle" | "ellipse" | "connector" | "caption" | "measure"
| "custom-shape"
if q.starts_with("draw:") =>
@@ -1467,7 +1470,8 @@ impl Body<'_> {
// each) holds no text.
let empty = blocks.iter().all(|b| matches!(b, FBlock::Para(p) if p.inlines.is_empty()));
let blocks = if kind != wordcraft_doc::para::ShapeKind::TextBox && empty { Vec::new() } else { blocks };
Some(Inline::Shape { kind, w: w.unwrap_or(72.0), h: h.unwrap_or(1.0), float: float.map(Box::new), blocks, look })
let path = self.shape_path.take().filter(|_| kind == wordcraft_doc::para::ShapeKind::Freeform);
Some(Inline::Shape { kind, w: w.unwrap_or(72.0), h: h.unwrap_or(1.0), float: float.map(Box::new), blocks, look, path })
}
(None, Some((linear, display))) => Some(Inline::Equation { linear, display }),
(None, None) => img.and_then(|data| make_img(data, w, h, alt.trim())).map(|mut img| {
@@ -1556,6 +1560,7 @@ impl Body<'_> {
float.rot = rot;
}
self.frame_look = g.shape_look(kind, &self.styles.defs);
self.shape_path = super::shapes::freeform(local(q), a, w, h).map(std::sync::Arc::new);
self.shape_kind = Some(kind);
let frame = (Some((Some(w), Some(h), None, String::new(), None)), Some(float), std::mem::take(&mut self.frame_look));
self.start_note(a);
+376
View File
@@ -0,0 +1,376 @@
//! Geometry of polygons, polylines, paths and regular polygons (ODF 1.3 §10.3.4–10.3.7, §19.1xx
//! `draw:points`, `svg:d`, `svg:viewBox`): their points, in their view box's coordinates, into
//! a WordCraft freeform (straight segments: curves and arcs are followed by short steps).
use wordcraft_doc::freeform::{FreePath, Freeform, MAX_PATHS, MAX_POINTS};
/// Steps a curve or arc is followed by.
const CURVE_STEPS: usize = 16;
/// Most numbers read from one `svg:d` or `draw:points` (hostile files).
const MAX_NUMBERS: usize = 200_000;
/// Largest coordinate kept.
const MAX_COORD: f64 = 1.0e6;
/// A view box (`svg:viewBox`, §19.570: min x, min y, width, height).
pub(super) fn view_box(v: Option<&str>) -> Option<[f32; 4]> {
let n: Vec<f32> = numbers(v?).take(4).collect();
let [x, y, w, h] = n.try_into().ok()?;
Some([x, y, w, h])
}
/// The numbers in `s`, separated by spaces or commas (an SVG number list), at most
/// [`MAX_NUMBERS`]; what doesn't read as a number ends it.
fn numbers(s: &str) -> impl Iterator<Item = f32> + '_ {
s.split(|c: char| c.is_whitespace() || c == ',')
.filter(|t| !t.is_empty())
.take(MAX_NUMBERS)
.map_while(|t| t.parse::<f32>().ok())
.map(|v| if v.is_finite() { v.clamp(-MAX_COORD as f32, MAX_COORD as f32) } else { 0.0 })
}
/// A freeform from `paths` (view box coordinates) in view box `vb` (none, or an empty one: the
/// box around the points): points from its corner.
fn freeform(paths: Vec<FreePath>, vb: Option<[f32; 4]>) -> Option<Freeform> {
let [x0, y0, w, h] = match vb {
Some(vb @ [_, _, w, h]) if w > 0.0 && h > 0.0 => vb,
_ => {
let all = || paths.iter().flat_map(|p| p.pts.iter());
let (x0, y0) = all().fold((f32::MAX, f32::MAX), |(x, y), p| (x.min(p[0]), y.min(p[1])));
let (x1, y1) = all().fold((f32::MIN, f32::MIN), |(x, y), p| (x.max(p[0]), y.max(p[1])));
if x0 > x1 { [0.0, 0.0, 0.0, 0.0] } else { [x0, y0, x1 - x0, y1 - y0] }
}
};
let paths: Vec<FreePath> = paths
.into_iter()
.take(MAX_PATHS)
.map(|p| FreePath { pts: p.pts.into_iter().take(MAX_POINTS).map(|[x, y]| [x - x0, y - y0]).collect(), closed: p.closed })
.filter(|p| !p.pts.is_empty())
.collect();
(!paths.is_empty()).then(|| Freeform { w: w.max(0.0), h: h.max(0.0), paths, ..Default::default() }.sanitized())
}
/// A polygon's or polyline's points (`draw:points`: "x,y x,y …"), closed or not.
pub(super) fn points(v: &str, closed: bool, vb: Option<[f32; 4]>) -> Option<Freeform> {
let n: Vec<f32> = numbers(v).collect();
let pts = n.chunks_exact(2).map(|c| [c[0], c[1]]).take(MAX_POINTS).collect();
freeform(vec![FreePath { pts, closed }], vb)
}
/// A regular polygon (§10.3.6) of `corners` corners in a `w` × `h` box, its first corner at the
/// top middle; concave (a star), its inner corners `sharpness` (0–1) of the way in.
pub(super) fn regular(corners: u32, concave: bool, sharpness: f32, w: f32, h: f32) -> Option<Freeform> {
let n = corners.clamp(3, 1000) as usize;
let (rx, ry) = (f64::from(w) / 2.0, f64::from(h) / 2.0);
let inner = 1.0 - f64::from(if sharpness.is_finite() { sharpness.clamp(0.0, 1.0) } else { 0.0 });
let step = std::f64::consts::TAU / n as f64;
let mut pts = Vec::with_capacity(n * 2);
for i in 0..n {
let a = -std::f64::consts::FRAC_PI_2 + step * i as f64;
pts.push([(rx + rx * a.cos()) as f32, (ry + ry * a.sin()) as f32]);
if concave {
let b = a + step / 2.0;
pts.push([(rx + rx * inner * b.cos()) as f32, (ry + ry * inner * b.sin()) as f32]);
}
}
freeform(vec![FreePath { pts, closed: true }], Some([0.0, 0.0, w, h]))
}
/// A path's SVG path data (`svg:d`, §19.523: moves, lines, cubic and quadratic curves and
/// elliptical arcs, absolute and relative), each subpath a freeform path.
pub(super) fn path(d: &str, vb: Option<[f32; 4]>) -> Option<Freeform> {
let mut p = PathReader::default();
let mut tokens = Tokens { s: d, at: 0, read: 0, peeked: None };
let mut cmd = None;
while let Some(t) = tokens.next() {
let c = match t {
Token::Cmd(c) => c,
// More numbers repeat the command (a move's are lines).
Token::Num(_) => match cmd {
Some('M') => 'L',
Some('m') => 'l',
Some(c) => c,
None => break,
},
};
if let Token::Num(_) = t {
tokens.peeked = Some(t);
}
cmd = Some(c);
if !p.command(c, &mut tokens) {
break;
}
}
p.end(false);
freeform(p.paths, vb)
}
#[derive(Clone, Copy)]
enum Token {
Cmd(char),
Num(f64),
}
/// The commands and numbers of path data.
struct Tokens<'a> {
s: &'a str,
at: usize,
read: usize,
/// A number read as the start of a repeated command, to be read again as its first.
peeked: Option<Token>,
}
impl Tokens<'_> {
fn next(&mut self) -> Option<Token> {
if let Some(t) = self.peeked.take() {
return Some(t);
}
self.read += 1;
if self.read > MAX_NUMBERS {
return None;
}
let rest = self.s.get(self.at..)?;
let skip = rest.len() - rest.trim_start_matches(|c: char| c.is_whitespace() || c == ',').len();
self.at += skip;
let rest = self.s.get(self.at..)?;
let c = rest.chars().next()?;
if c.is_ascii_alphabetic() && c != 'e' && c != 'E' {
self.at += c.len_utf8();
return Some(Token::Cmd(c));
}
// A number: sign, digits, a point, an exponent; a second point or sign starts the next.
let b = rest.as_bytes();
let mut end = 0;
let (mut dot, mut exp) = (false, false);
while let Some(&ch) = b.get(end) {
let ok = match ch {
b'0'..=b'9' => true,
b'+' | b'-' => end == 0 || matches!(b.get(end - 1), Some(b'e' | b'E')),
b'.' if !dot && !exp => {
dot = true;
true
}
b'e' | b'E' if !exp && end > 0 => {
exp = true;
true
}
_ => false,
};
if !ok {
break;
}
end += 1;
}
if end == 0 {
// Not a number: stop.
self.at = self.s.len();
return None;
}
let v = rest.get(..end).and_then(|n| n.parse::<f64>().ok()).filter(|v| v.is_finite()).unwrap_or(0.0);
self.at += end;
Some(Token::Num(v.clamp(-MAX_COORD, MAX_COORD)))
}
fn num(&mut self) -> Option<f64> {
match self.next() {
Some(Token::Num(v)) => Some(v),
Some(Token::Cmd(_)) => {
// A command where a number belongs: the data is malformed; stop.
self.at = self.s.len();
None
}
None => None,
}
}
}
#[derive(Default)]
struct PathReader {
paths: Vec<FreePath>,
cur: Vec<[f32; 2]>,
at: (f64, f64),
start: (f64, f64),
/// The last curve's second control point, for a smooth curve's reflection.
last_ctrl: Option<(f64, f64, bool)>,
}
impl PathReader {
fn push(&mut self, p: (f64, f64)) {
if self.cur.len() < MAX_POINTS {
let c = |v: f64| v.clamp(-MAX_COORD, MAX_COORD) as f32;
self.cur.push([c(p.0), c(p.1)]);
}
self.at = p;
}
/// A drawing command without a move before it starts its subpath where the pen is.
fn begin(&mut self) {
if self.cur.is_empty() {
self.push(self.at);
}
}
fn end(&mut self, closed: bool) {
let pts = std::mem::take(&mut self.cur);
if !pts.is_empty() && self.paths.len() < MAX_PATHS {
self.paths.push(FreePath { pts, closed });
}
}
/// Command `c` with its numbers from `t`; false when the data runs out or is malformed.
fn command(&mut self, c: char, t: &mut Tokens<'_>) -> bool {
let rel = c.is_ascii_lowercase();
let (ox, oy) = if rel { self.at } else { (0.0, 0.0) };
let pt = |t: &mut Tokens<'_>| Some((t.num()? + ox, t.num()? + oy));
let mut quad = false;
match c.to_ascii_uppercase() {
'M' => {
let Some(p) = pt(t) else { return false };
self.end(false);
self.start = p;
self.push(p);
}
'L' => {
let Some(p) = pt(t) else { return false };
self.begin();
self.push(p);
}
'H' => {
let Some(x) = t.num() else { return false };
let x = if rel { x + self.at.0 } else { x };
self.begin();
self.push((x, self.at.1));
}
'V' => {
let Some(y) = t.num() else { return false };
let y = if rel { y + self.at.1 } else { y };
self.begin();
self.push((self.at.0, y));
}
'C' | 'S' => {
let c1 = if c.eq_ignore_ascii_case(&'S') { self.reflected(false) } else { pt(t) };
let (Some(c1), Some(c2), Some(e)) = (c1, pt(t), pt(t)) else { return false };
self.cubic(c1, c2, e);
self.last_ctrl = Some((c2.0, c2.1, false));
return true;
}
'Q' | 'T' => {
let q = if c.eq_ignore_ascii_case(&'T') { self.reflected(true) } else { pt(t) };
let (Some(q), Some(e)) = (q, pt(t)) else { return false };
// A quadratic curve is the cubic with controls two thirds of the way to its own.
let s = self.at;
let c1 = (s.0 + 2.0 / 3.0 * (q.0 - s.0), s.1 + 2.0 / 3.0 * (q.1 - s.1));
let c2 = (e.0 + 2.0 / 3.0 * (q.0 - e.0), e.1 + 2.0 / 3.0 * (q.1 - e.1));
self.cubic(c1, c2, e);
self.last_ctrl = Some((q.0, q.1, true));
quad = true;
}
'A' => {
let (Some(rx), Some(ry), Some(rot), Some(large), Some(sweep), Some(e)) = (t.num(), t.num(), t.num(), t.num(), t.num(), pt(t)) else {
return false;
};
self.arc((rx, ry), rot, large != 0.0, sweep != 0.0, e);
}
'Z' => {
self.end(true);
// A new subpath after it starts where this one did, unless it moves.
self.at = self.start;
}
_ => return false,
}
if !quad {
self.last_ctrl = None;
}
true
}
/// The first control point of a smooth curve: the last one's reflected (`quad`: of a
/// quadratic curve), or the current point.
fn reflected(&self, quad: bool) -> Option<(f64, f64)> {
Some(match self.last_ctrl {
Some((x, y, q)) if q == quad => (2.0 * self.at.0 - x, 2.0 * self.at.1 - y),
_ => self.at,
})
}
fn cubic(&mut self, c1: (f64, f64), c2: (f64, f64), e: (f64, f64)) {
let s = self.at;
self.begin();
for i in 1..CURVE_STEPS {
let t = i as f64 / CURVE_STEPS as f64;
let u = 1.0 - t;
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
self.push((a * s.0 + b * c1.0 + c * c2.0 + d * e.0, a * s.1 + b * c1.1 + c * c2.1 + d * e.1));
}
self.push(e);
}
/// An elliptical arc to `e` (SVG's endpoint form, turned into its centre form).
fn arc(&mut self, r: (f64, f64), rot_deg: f64, large: bool, sweep: bool, e: (f64, f64)) {
let s = self.at;
self.begin();
let (mut rx, mut ry) = (r.0.abs(), r.1.abs());
if rx < 1e-9 || ry < 1e-9 || (s.0 - e.0).abs() + (s.1 - e.1).abs() < 1e-9 {
self.push(e);
return;
}
let (sin, cos) = (rot_deg % 360.0).to_radians().sin_cos();
// The start relative to the chord's middle, in the ellipse's own axes.
let (dx, dy) = ((s.0 - e.0) / 2.0, (s.1 - e.1) / 2.0);
let (x1, y1) = (cos * dx + sin * dy, -sin * dx + cos * dy);
// Radii too small to reach are scaled up just enough.
let k = (x1 * x1) / (rx * rx) + (y1 * y1) / (ry * ry);
if k > 1.0 {
(rx, ry) = (rx * k.sqrt(), ry * k.sqrt());
}
let num = (rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1).max(0.0);
let den = rx * rx * y1 * y1 + ry * ry * x1 * x1;
let mut f = if den > 0.0 { (num / den).sqrt() } else { 0.0 };
if large == sweep {
f = -f;
}
let (cx1, cy1) = (f * rx * y1 / ry, -f * ry * x1 / rx);
let (cx, cy) = (cos * cx1 - sin * cy1 + (s.0 + e.0) / 2.0, sin * cx1 + cos * cy1 + (s.1 + e.1) / 2.0);
let angle = |ux: f64, uy: f64| uy.atan2(ux);
let a0 = angle((x1 - cx1) / rx, (y1 - cy1) / ry);
let a1 = angle((-x1 - cx1) / rx, (-y1 - cy1) / ry);
let mut da = a1 - a0;
if sweep && da < 0.0 {
da += std::f64::consts::TAU;
} else if !sweep && da > 0.0 {
da -= std::f64::consts::TAU;
}
if !da.is_finite() || !cx.is_finite() || !cy.is_finite() {
self.push(e);
return;
}
for i in 1..CURVE_STEPS {
let a = a0 + da * i as f64 / CURVE_STEPS as f64;
let (px, py) = (rx * a.cos(), ry * a.sin());
self.push((cos * px - sin * py + cx, sin * px + cos * py + cy));
}
self.push(e);
}
}
/// `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 {
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| {
let v = if v.is_finite() { v.clamp(-MAX_COORD as f32, MAX_COORD as f32) } else { 0.0 };
format!("{}", v.round() as i64)
};
let mut d = String::new();
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" });
d.push_str(&format!("{} {}", n(x * sx), n(y * sy)));
}
if p.closed {
d.push_str(" Z");
}
d.push(' ');
}
d.trim_end().to_string()
}
+42 -3
View File
@@ -11,14 +11,26 @@ const MAX_LEN: f32 = 1584.0;
/// Whether `q` is a drawing shape element read as a shape.
pub(super) fn is_shape(q: &str) -> bool {
matches!(q, "draw:rect" | "draw:ellipse" | "draw:line" | "draw:custom-shape")
matches!(
q,
"draw:rect"
| "draw:ellipse"
| "draw:circle"
| "draw:line"
| "draw:custom-shape"
| "draw:polygon"
| "draw:polyline"
| "draw:path"
| "draw:regular-polygon"
)
}
/// The kind of shape element `name` (local name); a custom shape's geometry says more.
pub(super) fn element_kind(name: &str) -> ShapeKind {
match name {
"ellipse" => ShapeKind::Ellipse,
"ellipse" | "circle" => ShapeKind::Ellipse,
"line" => ShapeKind::Line,
"polygon" | "polyline" | "path" | "regular-polygon" => ShapeKind::Freeform,
_ => ShapeKind::Rectangle,
}
}
@@ -63,7 +75,8 @@ pub(super) fn element_of(kind: ShapeKind) -> (&'static str, Option<&'static str>
ShapeKind::ArrowDown => custom("down-arrow"),
ShapeKind::Star => custom("star5"),
ShapeKind::Heart => custom("heart"),
ShapeKind::Rectangle | ShapeKind::TextBox | ShapeKind::Freeform => ("draw:rect", None),
ShapeKind::Freeform => ("draw:path", None),
ShapeKind::Rectangle | ShapeKind::TextBox => ("draw:rect", None),
}
}
@@ -78,6 +91,32 @@ pub(super) fn geometry(kind: ShapeKind, a: &Attrs) -> (f32, f32, Option<f32>, Op
let size = |d: f32| d.abs().min(MAX_LEN);
return (size(x2 - x1), size(y2 - y1), Some(x1.min(x2)), Some(y1.min(y2)), (x2 < x1, y2 < y1));
}
// A circle may be given by its centre and radius (§19.546 `svg:cx`, §19.554 `svg:r`).
if get(a, "svg:width").is_none()
&& let Some(r) = len("svg:r").map(f32::abs)
{
let (cx, cy) = (len("svg:cx").unwrap_or(r), len("svg:cy").unwrap_or(r));
let d = (r * 2.0).clamp(1.0, MAX_LEN);
return (d, d, Some(cx - r), Some(cy - r), (false, false));
}
let size = |k: &str| len(k).map_or(72.0, |v| v.clamp(1.0, MAX_LEN));
(size("svg:width"), size("svg:height"), len("svg:x"), len("svg:y"), (false, false))
}
/// The paths of a polygon, polyline, path or regular polygon `name` (local) with attributes
/// `a`, `w` × `h` points: in its view box (§19.570 `svg:viewBox`; none: around its points).
pub(super) fn freeform(name: &str, a: &Attrs, w: f32, h: f32) -> Option<wordcraft_doc::freeform::Freeform> {
use super::path;
let vb = path::view_box(get(a, "svg:viewBox"));
match name {
"polygon" | "polyline" => path::points(get(a, "draw:points")?, name == "polygon", vb),
"path" => path::path(get(a, "svg:d")?, vb),
"regular-polygon" => {
let corners = get(a, "draw:corners").and_then(|c| c.trim().parse::<u32>().ok()).unwrap_or(3);
let concave = get(a, "draw:concave").is_some_and(|c| c.trim() == "true");
let sharpness = get(a, "draw:sharpness").and_then(|v| v.trim().strip_suffix('%')?.trim().parse::<f32>().ok()).map_or(0.0, |p| p / 100.0);
path::regular(corners, concave, sharpness, w, h)
}
_ => None,
}
}
+1
View File
@@ -14,6 +14,7 @@ mod named_styles;
mod notes;
mod outline;
mod para_borders;
mod polygons;
mod rotation;
mod rtl;
mod settings;
@@ -0,0 +1,136 @@
//! Polygons, polylines, paths and regular polygons (ODF 1.3 §10.3.4–10.3.7, §10.3.9:
//! `draw:polygon`, `draw:polyline`, `draw:path` with its SVG path data, `draw:regular-polygon`)
//! and circles (§10.3.8): opened as WordCraft freeforms (curves followed by short straight
//! segments) and ellipses, and freeforms written back as paths.
use crate::odt::tests::{NS_T, content_of, 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""#;
/// Each shape: (kind, frame size, its paths as points in frame points rounded, closed).
#[allow(clippy::type_complexity)]
fn drawn(d: &Document) -> Vec<(ShapeKind, (f32, f32), Vec<(Vec<(f32, f32)>, bool)>)> {
let r = |v: f32| (v * 10.0).round() / 10.0;
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, closed)| (pts.iter().map(|[x, y]| (r(*x), r(*y))).collect(), closed))
.collect()
})
.unwrap_or_default();
Some((*kind, (r(*w), r(*h)), paths))
}
_ => None,
})
.collect()
}
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()
}
#[test]
fn polygons_and_polylines_open_as_freeforms() {
let d = open(
r#"<draw:polygon text:anchor-type="paragraph" svg:width="2in" svg:height="1in" svg:viewBox="100 100 2000 1000" draw:points="100,100 2100,100 1100,1100"/><draw:polyline text:anchor-type="as-char" svg:width="1in" svg:height="1in" svg:viewBox="0 0 10 10" draw:points="0,10 5,0 10,10"/>"#,
);
assert_eq!(
drawn(&d),
[
(ShapeKind::Freeform, (144.0, 72.0), vec![(vec![(0.0, 0.0), (144.0, 0.0), (72.0, 72.0)], true)]),
(ShapeKind::Freeform, (72.0, 72.0), vec![(vec![(0.0, 72.0), (36.0, 0.0), (72.0, 72.0)], false)]),
]
);
}
#[test]
fn a_path_follows_its_svg_data() {
// Two subpaths: a closed square drawn with absolute, relative, horizontal and vertical
// moves, and an open cubic curve.
let d = open(
r#"<draw:path text:anchor-type="paragraph" svg:width="1in" svg:height="1in" svg:viewBox="0 0 100 100" svg:d="M0 0 H50 v50 l-50 0 Z m60 0 C60 100 100 100 100 0"/>"#,
);
let [(kind, size, paths)] = drawn(&d).try_into().unwrap();
assert_eq!((kind, size), (ShapeKind::Freeform, (72.0, 72.0)));
assert_eq!(paths.len(), 2);
assert_eq!(paths[0], (vec![(0.0, 0.0), (36.0, 0.0), (36.0, 36.0), (0.0, 36.0)], true));
// The curve, in short straight steps, from its start to its end through its middle.
let (curve, closed) = &paths[1];
assert!(!closed);
assert!(curve.len() > 8, "{curve:?}");
assert_eq!((curve.first(), curve.last()), (Some(&(43.2, 0.0)), Some(&(72.0, 0.0))));
assert!(curve.iter().any(|(x, y)| (x - 57.6).abs() < 1.0 && (y - 54.0).abs() < 1.0), "the curve's middle: {curve:?}");
}
#[test]
fn arcs_and_quadratic_curves_reach_their_ends() {
let d =
open(r#"<draw:path svg:width="1in" svg:height="1in" svg:viewBox="0 0 100 100" svg:d="M0 50 A50 50 0 0 1 100 50 Q100 100 50 100 T0 50 z"/>"#);
let [(_, _, paths)] = drawn(&d).try_into().unwrap();
let (pts, closed) = &paths[0];
assert!(*closed);
// The half circle's top passes (50, 0); the quadratic pieces end at (50, 100) and (0, 50).
assert!(pts.iter().any(|(x, y)| (x - 36.0).abs() < 1.0 && y.abs() < 1.0), "{pts:?}");
assert!(pts.contains(&(72.0, 36.0)) && pts.contains(&(36.0, 72.0)) && pts.last() == Some(&(0.0, 36.0)), "{pts:?}");
}
#[test]
fn regular_polygons_and_circles() {
let d = open(
r#"<draw:regular-polygon svg:width="1in" svg:height="1in" draw:corners="5"/><draw:regular-polygon svg:width="1in" svg:height="1in" draw:corners="5" draw:concave="true" draw:sharpness="50%"/><draw:circle svg:width="1in" svg:height="0.5in"/>"#,
);
let shapes = drawn(&d);
assert_eq!(shapes[0].2.len(), 1);
assert_eq!((shapes[0].2[0].0.len(), shapes[0].2[0].1), (5, true));
// The first corner at the top middle.
assert_eq!(shapes[0].2[0].0[0], (36.0, 0.0));
assert_eq!(shapes[1].2[0].0.len(), 10);
assert_eq!((shapes[2].0, shapes[2].1), (ShapeKind::Ellipse, (72.0, 36.0)));
}
#[test]
fn freeforms_round_trip_as_paths() {
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);
assert_eq!(drawn(&import(&bytes).unwrap()), drawn(&d));
}
#[test]
fn hostile_paths_do_not_panic() {
let long = "L1 1 ".repeat(20_000);
for el in [
format!(r#"<draw:path svg:viewBox="0 0 0 0" svg:d="M0 0 {long}"/>"#),
r#"<draw:path svg:viewBox="a b c" svg:d="M1e40 NaN C 1 2 3 Z Z Z m l h v q t s a 1 1 1 1 1 1"/>"#.to_string(),
r#"<draw:path svg:viewBox="0 0 100 100" svg:d="M0 0 A0 0 0 1 1 100 100 A1e30 1e30 1e30 0 0 -1e30 5"/>"#.to_string(),
r#"<draw:polygon svg:viewBox="0 0 -5 1e40" draw:points="1,2,3 ,, 4"/>"#.to_string(),
r#"<draw:regular-polygon svg:width="1in" svg:height="1in" draw:corners="4000000000" draw:concave="true" draw:sharpness="-1e30%"/>"#
.to_string(),
r#"<draw:regular-polygon draw:corners="0"/><draw:regular-polygon draw:corners="1"/>"#.to_string(),
] {
let d = open(&el);
for (_, _, paths) in drawn(&d) {
for (pts, _) in paths {
assert!(pts.len() <= wordcraft_doc::freeform::MAX_POINTS && pts.iter().all(|(x, y)| x.is_finite() && y.is_finite()));
}
}
let _ = export(&d).unwrap();
}
}
+20 -9
View File
@@ -942,8 +942,8 @@ impl Writer {
self.comment(*id, comment, out);
prev_space = false;
}
Inline::Shape { kind, w, h, float, blocks, look } => {
self.drawing_shape(*kind, (*w, *h), Place::Text(float.as_deref()), look, blocks, out);
Inline::Shape { kind, w, h, float, blocks, look, path } => {
self.drawing_shape(*kind, (*w, *h), Place::Text(float.as_deref()), (look, path.as_deref()), blocks, out);
prev_space = false;
}
Inline::CommentEnd(id) => out.push_str(&format!("<office:annotation-end office:name=\"{}\"/>", annotation_name(self.prefix, *id))),
@@ -1013,14 +1013,14 @@ impl Writer {
kind: wordcraft_doc::para::ShapeKind,
size: (f32, f32),
place: Place<'_>,
look: &model::BoxLook,
(look, path): (&model::BoxLook, Option<&wordcraft_doc::freeform::Freeform>),
blocks: &[FBlock],
out: &mut String,
) {
if kind == wordcraft_doc::para::ShapeKind::TextBox {
self.text_box(size, place, look, blocks, out);
} else {
self.shape(kind, size, place, look, blocks, out);
self.shape(kind, size, place, (look, path), blocks, out);
}
}
@@ -1072,8 +1072,8 @@ impl Writer {
for m in members {
let (x, y) = (at.0 + m.x, at.1 + m.y);
match &m.inline {
Inline::Shape { kind, w, h, float, blocks, look } => {
self.drawing_shape(*kind, (*w, *h), Place::Member(x, y, float.as_deref()), look, blocks, out)
Inline::Shape { kind, w, h, float, blocks, look, path } => {
self.drawing_shape(*kind, (*w, *h), Place::Member(x, y, float.as_deref()), (look, path.as_deref()), blocks, out)
}
Inline::Image(img) => self.image(img, Place::Member(x, y, img.float.as_deref()), out),
Inline::Group { members, .. } if depth < model::MAX_GROUP_DEPTH => {
@@ -1125,7 +1125,7 @@ impl Writer {
kind: wordcraft_doc::para::ShapeKind,
size: (f32, f32),
place: Place<'_>,
look: &model::BoxLook,
(look, path): (&model::BoxLook, Option<&wordcraft_doc::freeform::Freeform>),
blocks: &[FBlock],
out: &mut String,
) {
@@ -1137,7 +1137,11 @@ impl Writer {
let style = self.graphic_style(format!("{props}{look_attrs}"));
let body = self.story(blocks);
self.boxes += 1;
let (element, geometry) = super::shapes::element_of(kind);
let (element, geometry) = match super::shapes::element_of(kind) {
// A freeform without paths has nothing to draw but its frame.
("draw:path", _) if path.is_none() => ("draw:rect", None),
e => e,
};
let len = |v: f32, least: f32| if v.is_finite() { v.clamp(least, 1584.0) } else { 72.0 };
let at = if element == "draw:line" {
{
@@ -1156,7 +1160,14 @@ impl Writer {
format!(" svg:x1=\"{}\" svg:y1=\"{}\" svg:x2=\"{}\" svg:y2=\"{}\"", pt(x1), pt(y1), pt(x2), pt(y2))
}
} else {
format!(" svg:width=\"{}\" svg:height=\"{}\"{xy}", pt(len(size.0, 1.0)), pt(len(size.1, 1.0)))
let (w, h) = (len(size.0, 1.0), len(size.1, 1.0));
let mut at = format!(" svg:width=\"{}\" svg:height=\"{}\"{xy}", pt(w), pt(h));
if let Some(f) = path.filter(|_| element == "draw:path") {
// Its paths in a view box of hundredths of a point (whole numbers, §19.570).
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=\"{}\"", super::path::path_data(f, vw, vh)));
}
at
};
// Mirrored, as a custom shape's geometry says (§20.112, §20.113).
let mirror = |on: bool, k: &str| if on { format!(" draw:mirror-{k}=\"true\"") } else { String::new() };
File diff suppressed because one or more lines are too long