mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-06 18:51:26 +00: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:
11 files changed
+600
-21
No files matched your search
@@ -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() };
|
||||
|
||||
@@ -57,7 +57,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
||||
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice |
|
||||
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
|
||||
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154; Windows and Enhanced Metafiles, `\wmetafile` and `\emfblip`, read and written, #4259), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL |
|
||||
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/it/odt_sections.rs` | Custom character styles (a common text style other than LibreOffice's link, note-mark and list-label styles kept as a character style with its name, parent and own formatting, its text in it; written back as common text styles), custom paragraph styles (a common style other than Standard, the kinds' and the note texts' kept as a document style with its name, parent and own formatting, its paragraphs in it rather than given its formatting directly; written back as common styles, named so LibreOffice keeps them), paragraph borders (each side's line and padding) and background, heading numbering (`text:outline-style`: each level's format, prefix, suffix, levels shown and start number the headings through their styles, as Word's numbered headings, and are written back), styles (the file's own Heading 1–6, Title, Quotations and Preformatted Text definitions give those styles' look on open — font, size, percent sizes included, bold, colour, spacing, alignment, keep with next — and saving writes the document's own style definitions rather than fixed ones), tab stops (left, centre, right, decimal; dot, hyphen, underline and middle-dot leaders; LibreOffice's indent-relative positions converted, and a `settings.xml` written saying positions run from the margin) and the default tab interval, character formatting (underline styles and colour, double strike, caps, small caps, hidden text kept hidden, letter spacing, scale, raised and lowered text, outline, shadow, emboss, engrave, language, Asian and complex-script fonts, complex-script size, weight and posture and both script languages (per run and as document defaults), a monospaced font by its own name, a `style:font-name` read as the family its `style:font-face` declares, and the default style's pair kerning) both ways, lists (#87; each `text:list` a list of its own unless it continues one by `text:continue-numbering` or `text:continue-list`, an item's `text:start-value` restarting from it; each level's number format — 1, i, I, a, A, none —, prefix, suffix, levels shown, start value, bullet character, indent, hanging and number alignment read from its list style and written back), floating pictures (a frame anchored to a paragraph, character or page: wrap — square on both sides, left, right or the larger side, top and bottom, behind or in front of text —, horizontal and vertical alignment or offset and what it is relative to, distances from the text, read into the picture's placement and written back in a graphic style), tables (cell borders — width, style, colour, an edge LibreOffice gives one cell shared by both — and cell padding read and written, each shared edge written once so lines don't double, a table without lines written without), pictures, page breaks (#86), paragraph spacing, indents and line spacing (proportional, fixed, at least; leading read as a proportional approximation) both ways, with the default and Standard styles' spacing as the document defaults (ODT's are none, not Word's 8 pt after and 1.15 lines), headers and footers of each master page (every, left and first page) read on open and written on save, with the header and footer distances converted between Word's page-edge measures and ODF's header bands (#863), text fields (page number and count, date, time, title, subject, author, file name, word and character counts, bookmark references as `REF`/`PAGEREF`) read as fields and written back as ODF fields; fixed dates and fields without a Word equivalent (sequences, chapters, variables) read as their text, and Word fields ODF lacks written as their result, date formats not kept, keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099); export writes each equation as a formula object of its own (MathML, with its linear text as an annotation), its frame sized from the equation's structure and centred on the line, so LibreOffice shows and edits it, page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odLine truncated
|
||||
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/it/odt_sections.rs` | Custom character styles (a common text style other than LibreOffice's link, note-mark and list-label styles kept as a character style with its name, parent and own formatting, its text in it; written back as common text styles), custom paragraph styles (a common style other than Standard, the kinds' and the note texts' kept as a document style with its name, parent and own formatting, its paragraphs in it rather than given its formatting directly; written back as common styles, named so LibreOffice keeps them), paragraph borders (each side's line and padding) and background, heading numbering (`text:outline-style`: each level's format, prefix, suffix, levels shown and start number the headings through their styles, as Word's numbered headings, and are written back), styles (the file's own Heading 1–6, Title, Quotations and Preformatted Text definitions give those styles' look on open — font, size, percent sizes included, bold, colour, spacing, alignment, keep with next — and saving writes the document's own style definitions rather than fixed ones), tab stops (left, centre, right, decimal; dot, hyphen, underline and middle-dot leaders; LibreOffice's indent-relative positions converted, and a `settings.xml` written saying positions run from the margin) and the default tab interval, character formatting (underline styles and colour, double strike, caps, small caps, hidden text kept hidden, letter spacing, scale, raised and lowered text, outline, shadow, emboss, engrave, language, Asian and complex-script fonts, complex-script size, weight and posture and both script languages (per run and as document defaults), a monospaced font by its own name, a `style:font-name` read as the family its `style:font-face` declares, and the default style's pair kerning) both ways, lists (#87; each `text:list` a list of its own unless it continues one by `text:continue-numbering` or `text:continue-list`, an item's `text:start-value` restarting from it; each level's number format — 1, i, I, a, A, none —, prefix, suffix, levels shown, start value, bullet character, indent, hanging and number alignment read from its list style and written back), floating pictures (a frame anchored to a paragraph, character or page: wrap — square on both sides, left, right or the larger side, top and bottom, behind or in front of text —, horizontal and vertical alignment or offset and what it is relative to, distances from the text, read into the picture's placement and written back in a graphic style), tables (cell borders — width, style, colour, an edge LibreOffice gives one cell shared by both — and cell padding read and written, each shared edge written once so lines don't double, a table without lines written without), pictures, page breaks (#86), paragraph spacing, indents and line spacing (proportional, fixed, at least; leading read as a proportional approximation) both ways, with the default and Standard styles' spacing as the document defaults (ODT's are none, not Word's 8 pt after and 1.15 lines), headers and footers of each master page (every, left and first page) read on open and written on save, with the header and footer distances converted between Word's page-edge measures and ODF's header bands (#863), text fields (page number and count, date, time, title, subject, author, file name, word and character counts, bookmark references as `REF`/`PAGEREF`) read as fields and written back as ODF fields; fixed dates and fields without a Word equivalent (sequences, chapters, variables) read as their text, and Word fields ODF lacks written as their result, date formats not kept, keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099); export writes each equation as a formula object of its own (MathML, with its linear text as an annotation), its frame sized from the equation's structure and centred on the line, so LibreOffice shows and edits it, page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odLine truncated
|
||||
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (`<html>`, `<!DOCTYPE html>`) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As |
|
||||
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154); the empty comments `<!-->` and `<!--->` end at once instead of swallowing the rest of the page (#5040). Header cells (`<th>`) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `<blockquote>` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `<ruby>base<rt>reading</rt></ruby>` in the base text's formatting and read back from `<ruby>`/`<rb>`/`<rt>` (`<rp>` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). |
|
||||
| `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | |
|
||||
@@ -279,6 +279,7 @@ Custom document properties retain their bookmark link targets through DOCX saves
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-01 | trivial | ODT: polygons, polylines, paths (`svg:d`: moves, lines, cubic and quadratic curves, smooth curves and elliptical arcs, absolute and relative; curves followed in 16 straight steps) and regular polygons and stars open as WordCraft freeforms in their view box, and circles (`draw:circle`, by size or centre and radius) as ellipses, instead of being skipped; WordCraft's freeforms (ink included) are written as `draw:path` in a view box of hundredths of a point. A LibreOffice document with a polygon, a polyline and a closed Bézier curve round-trips 333 px apart, and WordCraft draws it 529 px from LibreOffice |
|
||||
| 2026-11-01 | trivial | ODT: arrowheads (`draw:marker-start`/`-end`), line dashes (`draw:stroke-dash`) and gradient fills (`draw:gradient`) of shapes and frames are read into WordCraft's arrowheads, dash patterns (named ones when they match) and gradients, and written back as definitions in `styles.xml` (WordCraft's own triangle arrowhead); a line keeps the direction it is drawn in, so its arrowheads stay at their ends. A LibreOffice document with arrowheads, dashes and gradients round-trips 215 px apart, and WordCraft draws it 518 px from LibreOffice. Layout: a sloped line's arrowheads were drawn at the other diagonal's corners (the arrowhead code assumed a line from bottom left to top right, the renderers draw top left to bottom right); they now sit at the line's ends (`tests::a_lines_arrowheads_sit_at_its_ends`) |
|
||||
| 2026-11-01 | trivial | ODT: groups (`draw:g`) of shapes, text boxes and pictures open as one WordCraft group (anchored and wrapped as the group is, its members at their places in it, turned members turned, nested groups flattened) instead of being skipped, and WordCraft's groups are saved as `draw:g` (a canvas as a plain group). A frame, shape or group whose styles state no `style:wrap` now has text on both sides, as LibreOffice wraps it, instead of above and below only; `biggest` is read as the larger side. A shape whose only paragraph is LibreOffice's empty `<text:p/>` no longer gets an empty text story. A LibreOffice document with turned shapes and a group: 7,464 px apart after a round trip, now 586 |
|
||||
| 2026-11-01 | trivial | ODT: shapes and pictures turned by `draw:transform` open turned (the file's anticlockwise turn about the object's corner, then a move, becomes WordCraft's clockwise turn about the centre of the unrotated box) and are written back that way, in line too; custom shapes keep their mirroring (`draw:mirror-horizontal`/`-vertical`); a turned line is written by its turned ends. Before, a turned shape lost its turn and its position (LibreOffice writes no `svg:x`/`svg:y` for one) |
|
||||
|
||||
Reference in new issue
Block a user