mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 03:39:51 -06:00
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:
@@ -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)?;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,6 +44,7 @@ mod frames;
|
||||
mod import;
|
||||
mod limits;
|
||||
mod package;
|
||||
mod path;
|
||||
mod shapes;
|
||||
mod strokes;
|
||||
mod styles;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user