mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-06 18:51:26 +00:00
Gap 19: ODT custom shapes drawn from their own geometry
A custom shape whose type WordCraft has no kind for (hexagon, octagon, smiley, can, moon, lightning, block arc, cross, pentagon, flowchart shapes…) opened as a rectangle. It is now drawn from its own geometry (ODF 1.3 §10.6.2) as a freeform: - the enhanced path (§19.145): moves, lines, curves, ellipses (T, U), arcs (A, B, W, V) and quarter ellipses (X, Y), arcs followed in short straight steps; - its values worked out from the shape's equations (§10.6.3) and adjustment values: numbers, other equations, modifiers, the frame's keywords, + - * /, abs, sqrt, sin, cos, tan, atan, atan2, min, max and if. Path length, equation count and nesting, and points are all bounded. LibreOffice fills no part of a `draw:path` that has an open part. So a freeform with both closed and open paths is now saved as a custom shape whose path leaves the open ones unfilled (`F`). LibreOffice's preset shapes render as LibreOffice draws them. The saved file validates against the schema. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
10 files changed
+656
-15
No files matched your search
@@ -46,6 +46,7 @@ mod flat;
|
||||
mod forms;
|
||||
mod formula_size;
|
||||
mod frames;
|
||||
mod geometry;
|
||||
mod import;
|
||||
mod limits;
|
||||
mod objects;
|
||||
|
||||
@@ -121,6 +121,9 @@ pub(super) struct Body<'a> {
|
||||
shape_flip: (bool, bool),
|
||||
/// A freeform shape's paths (a polygon's, polyline's, path's or regular polygon's).
|
||||
shape_path: Option<std::sync::Arc<wordcraft_doc::freeform::Freeform>>,
|
||||
/// A custom shape's geometry being read, when its type has no WordCraft kind: drawn from its
|
||||
/// own path and equations.
|
||||
geometry: Option<Box<super::geometry::Pending>>,
|
||||
/// The turn of a group's member being read, from its `draw:transform`.
|
||||
member_rot: Option<f32>,
|
||||
/// The depth of the `draw:g` group being read: its shapes are placed by it, not anchored.
|
||||
@@ -201,6 +204,7 @@ impl<'a> Body<'a> {
|
||||
shape_kind: None,
|
||||
shape_flip: (false, false),
|
||||
shape_path: None,
|
||||
geometry: None,
|
||||
member_rot: None,
|
||||
group_at: None,
|
||||
index: None,
|
||||
@@ -652,6 +656,12 @@ impl Body<'_> {
|
||||
return;
|
||||
}
|
||||
if self.skip > 0 {
|
||||
if self.skip == 1
|
||||
&& q == "draw:equation"
|
||||
&& let Some(g) = &mut self.geometry
|
||||
{
|
||||
g.equation(a);
|
||||
}
|
||||
self.skip += 1;
|
||||
return;
|
||||
}
|
||||
@@ -669,11 +679,12 @@ impl Body<'_> {
|
||||
if self.tracked_context(q, a) {
|
||||
return;
|
||||
}
|
||||
// A custom shape's geometry: its kind. Its formulas and handles aren't read.
|
||||
// A custom shape's geometry: its kind, or, for a type WordCraft has no kind for, its
|
||||
// path, worked out from its equations when the element ends. Its handles aren't read.
|
||||
if q == "draw:enhanced-geometry" && self.shape_kind.is_some() {
|
||||
if let Some(t) = get(a, "draw:type") {
|
||||
self.shape_kind = Some(super::shapes::kind_of(t));
|
||||
}
|
||||
let t = get(a, "draw:type").unwrap_or_default();
|
||||
self.shape_kind = Some(super::shapes::kind_of(t));
|
||||
self.geometry = super::geometry::Pending::of(t, a);
|
||||
// Mirrored (§20.112 `draw:mirror-horizontal`, §20.113 `draw:mirror-vertical`).
|
||||
let on = |k: &str| get(a, k).is_some_and(|v| v.trim() == "true");
|
||||
self.shape_flip = (on("draw:mirror-horizontal"), on("draw:mirror-vertical"));
|
||||
@@ -1089,6 +1100,13 @@ impl Body<'_> {
|
||||
}
|
||||
if self.skip > 0 {
|
||||
self.skip -= 1;
|
||||
if self.skip == 0
|
||||
&& let Some(g) = self.geometry.take()
|
||||
&& let Some(f) = g.freeform()
|
||||
{
|
||||
self.shape_kind = Some(wordcraft_doc::para::ShapeKind::Freeform);
|
||||
self.shape_path = Some(std::sync::Arc::new(f));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !self.in_text {
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
//! A custom shape's own geometry (ODF 1.3 §10.6.2 `draw:enhanced-geometry`): its path
|
||||
//! (§19.145 `draw:enhanced-path`) in its view box, the path's values worked out from its
|
||||
//! equations (§10.6.3 `draw:equation`, §19.170 `draw:formula`) and adjustment values
|
||||
//! (`draw:modifiers`). Shapes whose type WordCraft has no kind for are drawn from it, as
|
||||
//! freeforms.
|
||||
//!
|
||||
//! Arcs and ellipses are followed in short straight steps. Everything is bounded: the path's
|
||||
//! length, the equations' count and nesting, the number of points.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::xml::{Attrs, get};
|
||||
use wordcraft_doc::freeform::Freeform;
|
||||
use wordcraft_doc::para::ShapeKind;
|
||||
|
||||
/// Most equations read, most levels one may refer down through, most path tokens and points.
|
||||
const MAX_EQUATIONS: usize = 512;
|
||||
const MAX_DEPTH: usize = 64;
|
||||
const MAX_TOKENS: usize = 20_000;
|
||||
const MAX_POINTS: usize = 20_000;
|
||||
/// Straight steps an arc of a full turn is followed in.
|
||||
const ARC_STEPS: f64 = 64.0;
|
||||
|
||||
/// Longest path or formula kept, in bytes.
|
||||
const MAX_TEXT: usize = 64 * 1024;
|
||||
|
||||
/// A custom shape's geometry being read: its path, view box and adjustment values, then its
|
||||
/// equations, from the `draw:equation` children.
|
||||
pub(super) struct Pending {
|
||||
path: String,
|
||||
vb: Option<[f32; 4]>,
|
||||
modifiers: String,
|
||||
equations: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// `s` cut to at most `MAX_TEXT` bytes, at a character boundary.
|
||||
fn capped(s: &str) -> String {
|
||||
let mut end = s.len().min(MAX_TEXT);
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
s.get(..end).unwrap_or_default().to_owned()
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
/// The geometry of a `draw:enhanced-geometry` of type `t` with attributes `a`, when it is
|
||||
/// to be drawn from its own path: a type WordCraft has no kind for, with a path.
|
||||
pub(super) fn of(t: &str, a: &Attrs) -> Option<Box<Pending>> {
|
||||
let t = t.trim();
|
||||
let plain = matches!(t.strip_prefix("ooxml-").unwrap_or(t), "" | "rectangle" | "rect" | "flowchart-process" | "flowChartProcess");
|
||||
if super::shapes::kind_of(t) != ShapeKind::Rectangle || plain {
|
||||
return None;
|
||||
}
|
||||
let path = get(a, "draw:enhanced-path")?;
|
||||
Some(Box::new(Pending {
|
||||
path: capped(path),
|
||||
vb: super::path::view_box(get(a, "svg:viewBox")),
|
||||
modifiers: capped(get(a, "draw:modifiers").unwrap_or_default()),
|
||||
equations: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reads a `draw:equation` (§19.171 `draw:name`, §19.170 `draw:formula`).
|
||||
pub(super) fn equation(&mut self, a: &Attrs) {
|
||||
if self.equations.len() >= MAX_EQUATIONS {
|
||||
return;
|
||||
}
|
||||
if let (Some(n), Some(f)) = (get(a, "draw:name"), get(a, "draw:formula")) {
|
||||
self.equations.push((capped(n.trim()), capped(f)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The freeform the geometry draws.
|
||||
pub(super) fn freeform(&self) -> Option<Freeform> {
|
||||
freeform(&self.path, self.vb, &self.modifiers, &self.equations)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a geometry's values are worked out from.
|
||||
struct Env<'a> {
|
||||
vb: [f64; 4],
|
||||
modifiers: Vec<f64>,
|
||||
equations: &'a [(String, String)],
|
||||
names: HashMap<&'a str, usize>,
|
||||
memo: std::cell::RefCell<HashMap<usize, f64>>,
|
||||
}
|
||||
|
||||
impl Env<'_> {
|
||||
/// The value of a path or formula parameter: a number, `?name` (an equation), `$n` (an
|
||||
/// adjustment value) or a keyword (§19.145).
|
||||
fn param(&self, t: &str, depth: usize) -> Option<f64> {
|
||||
if let Some(n) = t.strip_prefix('?') {
|
||||
return self.equation(*self.names.get(n)?, depth);
|
||||
}
|
||||
if let Some(i) = t.strip_prefix('$') {
|
||||
return self.modifiers.get(i.parse::<usize>().ok()?).copied();
|
||||
}
|
||||
let [x, y, w, h] = self.vb;
|
||||
Some(match t {
|
||||
"left" => x,
|
||||
"top" => y,
|
||||
"right" => x + w,
|
||||
"bottom" => y + h,
|
||||
"width" | "logwidth" => w,
|
||||
"height" | "logheight" => h,
|
||||
"xstretch" | "ystretch" => 0.0,
|
||||
"hasstroke" | "hasfill" => 1.0,
|
||||
"pi" => std::f64::consts::PI,
|
||||
_ => t.parse::<f64>().ok().filter(|v| v.is_finite())?,
|
||||
})
|
||||
}
|
||||
|
||||
fn equation(&self, i: usize, depth: usize) -> Option<f64> {
|
||||
if depth > MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
if let Some(v) = self.memo.borrow().get(&i) {
|
||||
return Some(*v);
|
||||
}
|
||||
let f = &self.equations.get(i)?.1;
|
||||
let mut p = Formula { s: f.as_bytes(), at: 0, env: self, depth: depth + 1 };
|
||||
let v = p.expr()?;
|
||||
let v = if v.is_finite() { v } else { 0.0 };
|
||||
self.memo.borrow_mut().insert(i, v);
|
||||
Some(v)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `draw:formula` being read (recursive descent: sums of products of factors).
|
||||
struct Formula<'a, 'b> {
|
||||
s: &'a [u8],
|
||||
at: usize,
|
||||
env: &'b Env<'b>,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
impl Formula<'_, '_> {
|
||||
fn skip(&mut self) {
|
||||
while self.s.get(self.at).is_some_and(u8::is_ascii_whitespace) {
|
||||
self.at += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn peek(&mut self) -> Option<u8> {
|
||||
self.skip();
|
||||
self.s.get(self.at).copied()
|
||||
}
|
||||
|
||||
fn expr(&mut self) -> Option<f64> {
|
||||
if self.depth > MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let mut v = self.term()?;
|
||||
while let Some(c @ (b'+' | b'-')) = self.peek() {
|
||||
self.at += 1;
|
||||
let r = self.term()?;
|
||||
v = if c == b'+' { v + r } else { v - r };
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn term(&mut self) -> Option<f64> {
|
||||
let mut v = self.factor()?;
|
||||
while let Some(c @ (b'*' | b'/')) = self.peek() {
|
||||
self.at += 1;
|
||||
let r = self.factor()?;
|
||||
v = if c == b'*' {
|
||||
v * r
|
||||
} else if r != 0.0 {
|
||||
v / r
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn factor(&mut self) -> Option<f64> {
|
||||
self.depth += 1;
|
||||
if self.depth > MAX_DEPTH * 4 {
|
||||
return None;
|
||||
}
|
||||
let v = match self.peek()? {
|
||||
b'-' => {
|
||||
self.at += 1;
|
||||
-self.factor()?
|
||||
}
|
||||
b'+' => {
|
||||
self.at += 1;
|
||||
self.factor()?
|
||||
}
|
||||
b'(' => {
|
||||
self.at += 1;
|
||||
let v = self.expr()?;
|
||||
if self.peek() == Some(b')') {
|
||||
self.at += 1;
|
||||
}
|
||||
v
|
||||
}
|
||||
_ => {
|
||||
let start = self.at;
|
||||
while self.s.get(self.at).is_some_and(|c| c.is_ascii_alphanumeric() || matches!(c, b'?' | b'$' | b'.' | b'_')) {
|
||||
self.at += 1;
|
||||
}
|
||||
let word = std::str::from_utf8(self.s.get(start..self.at)?).ok()?;
|
||||
if word.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if self.peek() == Some(b'(') {
|
||||
self.at += 1;
|
||||
let mut args = Vec::new();
|
||||
if self.peek() != Some(b')') {
|
||||
loop {
|
||||
args.push(self.expr()?);
|
||||
match self.peek() {
|
||||
Some(b',') => self.at += 1,
|
||||
_ => break,
|
||||
}
|
||||
if args.len() > 8 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.peek() == Some(b')') {
|
||||
self.at += 1;
|
||||
}
|
||||
function(word, &args)?
|
||||
} else {
|
||||
self.env.param(word, self.depth)?
|
||||
}
|
||||
}
|
||||
};
|
||||
self.depth -= 1;
|
||||
Some(v)
|
||||
}
|
||||
}
|
||||
|
||||
/// A formula function (§19.170) of `args`.
|
||||
fn function(name: &str, a: &[f64]) -> Option<f64> {
|
||||
let x = |i: usize| a.get(i).copied();
|
||||
Some(match name {
|
||||
"abs" => x(0)?.abs(),
|
||||
"sqrt" => x(0)?.max(0.0).sqrt(),
|
||||
"sin" => x(0)?.sin(),
|
||||
"cos" => x(0)?.cos(),
|
||||
"tan" => x(0)?.tan(),
|
||||
"atan" => x(0)?.atan(),
|
||||
"atan2" => x(0)?.atan2(x(1)?),
|
||||
"min" => x(0)?.min(x(1)?),
|
||||
"max" => x(0)?.max(x(1)?),
|
||||
"if" => {
|
||||
if x(0)? > 0.0 {
|
||||
x(1)?
|
||||
} else {
|
||||
x(2)?
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A path being followed: its SVG path data, the pen's place, and how many points so far.
|
||||
#[derive(Default)]
|
||||
struct Pen {
|
||||
d: String,
|
||||
at: (f64, f64),
|
||||
points: usize,
|
||||
}
|
||||
|
||||
impl Pen {
|
||||
fn cmd(&mut self, c: char, pts: &[(f64, f64)]) {
|
||||
if self.points >= MAX_POINTS {
|
||||
return;
|
||||
}
|
||||
self.d.push(c);
|
||||
for (x, y) in pts {
|
||||
self.d.push_str(&format!(" {x:.2} {y:.2}"));
|
||||
}
|
||||
self.d.push(' ');
|
||||
self.points += pts.len().max(1);
|
||||
if let Some(p) = pts.last() {
|
||||
self.at = *p;
|
||||
}
|
||||
}
|
||||
|
||||
/// An arc of the ellipse centred at `c` with radii `r`, from angle `a0` to `a1` (radians,
|
||||
/// the y axis down): a line (or a move, `jump`) to its start, then steps along it.
|
||||
fn arc(&mut self, c: (f64, f64), r: (f64, f64), a0: f64, a1: f64, jump: bool) {
|
||||
let point = |a: f64| (c.0 + r.0 * a.cos(), c.1 + r.1 * a.sin());
|
||||
let start = point(a0);
|
||||
// A line to the start, unless the pen is there already.
|
||||
if jump || self.points == 0 || (start.0 - self.at.0).hypot(start.1 - self.at.1) > 1e-6 {
|
||||
self.cmd(if jump || self.points == 0 { 'M' } else { 'L' }, &[start]);
|
||||
}
|
||||
let sweep = a1 - a0;
|
||||
let steps = ((sweep.abs() / std::f64::consts::TAU) * ARC_STEPS).ceil().clamp(1.0, ARC_STEPS) as usize;
|
||||
for k in 1..=steps {
|
||||
self.cmd('L', &[point(a0 + sweep * k as f64 / steps as f64)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The angle of `p` on the ellipse centred at `c` with radii `r` (radians).
|
||||
fn angle_on(c: (f64, f64), r: (f64, f64), p: (f64, f64)) -> f64 {
|
||||
let (dx, dy) = ((p.0 - c.0) / r.0.max(1e-9), (p.1 - c.1) / r.1.max(1e-9));
|
||||
dy.atan2(dx)
|
||||
}
|
||||
|
||||
/// The freeform an enhanced geometry draws: its `path` with adjustment values `modifiers` and
|
||||
/// `equations` (name, formula), in view box `vb` (§19.570; the default 21600 square). `None`
|
||||
/// when the path draws nothing.
|
||||
pub(super) fn freeform(path: &str, vb: Option<[f32; 4]>, modifiers: &str, equations: &[(String, String)]) -> Option<Freeform> {
|
||||
let vb = vb.map_or([0.0, 0.0, 21600.0, 21600.0], |v| v.map(f64::from));
|
||||
if !(vb[2] > 0.0 && vb[3] > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let equations = equations.get(..equations.len().min(MAX_EQUATIONS)).unwrap_or_default();
|
||||
let names = equations.iter().enumerate().map(|(i, (n, _))| (n.as_str(), i)).collect();
|
||||
let modifiers = modifiers.split_whitespace().take(64).map(|m| m.parse::<f64>().ok().filter(|v| v.is_finite()).unwrap_or(0.0)).collect();
|
||||
let env = Env { vb, modifiers, equations, names, memo: Default::default() };
|
||||
// The path's tokens: command letters, and parameters (numbers, ?names, $n, keywords).
|
||||
let tokens: Vec<&str> = path.split(|c: char| c.is_whitespace() || c == ',').filter(|t| !t.is_empty()).take(MAX_TOKENS).collect();
|
||||
let mut pen = Pen::default();
|
||||
let mut i = 0;
|
||||
let mut cmd = 'M';
|
||||
let is_cmd = |t: &str| t.len() == 1 && t.chars().all(|c| "MLCQZNFSTUABWVXY".contains(c));
|
||||
while i < tokens.len() {
|
||||
let t = tokens.get(i).copied().unwrap_or_default();
|
||||
if is_cmd(t) {
|
||||
cmd = t.chars().next().unwrap_or('M');
|
||||
i += 1;
|
||||
match cmd {
|
||||
'Z' => pen.cmd('Z', &[]),
|
||||
'N' | 'F' | 'S' => {}
|
||||
_ => continue,
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// The command's parameters, as many as it takes each time.
|
||||
let n = match cmd {
|
||||
'M' | 'L' | 'X' | 'Y' => 2,
|
||||
'Q' => 4,
|
||||
'C' | 'T' | 'U' => 6,
|
||||
'A' | 'B' | 'W' | 'V' => 8,
|
||||
_ => {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(args) = tokens.get(i..i + n) else { break };
|
||||
i += n;
|
||||
let v: Option<Vec<f64>> = args.iter().map(|a| env.param(a, 0)).collect();
|
||||
let Some(v) = v else { continue };
|
||||
let p = |k: usize| (v.get(k).copied().unwrap_or(0.0), v.get(k + 1).copied().unwrap_or(0.0));
|
||||
match cmd {
|
||||
'M' => {
|
||||
pen.cmd('M', &[p(0)]);
|
||||
cmd = 'L';
|
||||
}
|
||||
'L' => pen.cmd('L', &[p(0)]),
|
||||
'Q' => pen.cmd('Q', &[p(0), p(2)]),
|
||||
'C' => pen.cmd('C', &[p(0), p(2), p(4)]),
|
||||
// An ellipse from angle to angle, in degrees (§19.145 T, U: U starts a new path).
|
||||
'T' | 'U' => {
|
||||
let (c, r) = (p(0), p(2));
|
||||
let (a0, a1) = (p(4).0.to_radians(), p(4).1.to_radians());
|
||||
let a1 = if a1 <= a0 { a1 + std::f64::consts::TAU } else { a1 };
|
||||
pen.arc(c, r, a0, a1, cmd == 'U');
|
||||
}
|
||||
// Arcs of the ellipse in a box, from where a ray to one point meets it to where a
|
||||
// ray to another does: anticlockwise (A, B) or clockwise (W, V); B and V start a new
|
||||
// path.
|
||||
'A' | 'B' | 'W' | 'V' => {
|
||||
let ((x1, y1), (x2, y2)) = (p(0), p(2));
|
||||
let c = ((x1 + x2) / 2.0, (y1 + y2) / 2.0);
|
||||
let r = (((x2 - x1) / 2.0).abs(), ((y2 - y1) / 2.0).abs());
|
||||
let (a0, a1) = (angle_on(c, r, p(4)), angle_on(c, r, p(6)));
|
||||
let clockwise = matches!(cmd, 'W' | 'V');
|
||||
// The y axis points down: clockwise on the page is increasing angles.
|
||||
let a1 = if clockwise {
|
||||
if a1 <= a0 { a1 + std::f64::consts::TAU } else { a1 }
|
||||
} else if a1 >= a0 {
|
||||
a1 - std::f64::consts::TAU
|
||||
} else {
|
||||
a1
|
||||
};
|
||||
pen.arc(c, r, a0, a1, matches!(cmd, 'B' | 'V'));
|
||||
}
|
||||
// A quarter ellipse to a point, setting out along the x axis (X) or the y axis (Y),
|
||||
// the next one the other way.
|
||||
'X' | 'Y' => {
|
||||
let (from, to) = (pen.at, p(0));
|
||||
let (c, r, a0, a1) = if cmd == 'X' {
|
||||
let c = (from.0, to.1);
|
||||
let r = ((to.0 - from.0).abs(), (to.1 - from.1).abs());
|
||||
(c, r, angle_on(c, r, from), angle_on(c, r, to))
|
||||
} else {
|
||||
let c = (to.0, from.1);
|
||||
let r = ((to.0 - from.0).abs(), (to.1 - from.1).abs());
|
||||
(c, r, angle_on(c, r, from), angle_on(c, r, to))
|
||||
};
|
||||
// The short way round: a quarter.
|
||||
let mut a1 = a1;
|
||||
while a1 - a0 > std::f64::consts::PI {
|
||||
a1 -= std::f64::consts::TAU;
|
||||
}
|
||||
while a0 - a1 > std::f64::consts::PI {
|
||||
a1 += std::f64::consts::TAU;
|
||||
}
|
||||
pen.arc(c, r, a0, a1, false);
|
||||
cmd = if cmd == 'X' { 'Y' } else { 'X' };
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if pen.points < 2 {
|
||||
return None;
|
||||
}
|
||||
super::path::path(&pen.d, Some(vb.map(|v| v as f32)))
|
||||
}
|
||||
@@ -355,6 +355,21 @@ impl PathReader {
|
||||
/// `freeform`'s paths as SVG path data in a view box `vw` × `vh` (whole numbers, as ODF's view
|
||||
/// box is): each path moved to, lined through, and closed when it is.
|
||||
pub(super) fn path_data(f: &Freeform, vw: u32, vh: u32) -> String {
|
||||
path_text(f, vw, vh, false)
|
||||
}
|
||||
|
||||
/// Whether `freeform` has both closed and open paths: drawn filled and unfilled.
|
||||
pub(super) fn is_mixed(f: &Freeform) -> bool {
|
||||
f.paths.iter().any(|p| p.closed) && f.paths.iter().any(|p| !p.closed)
|
||||
}
|
||||
|
||||
/// `freeform`'s paths as a custom shape's path (§19.145 `draw:enhanced-path`) in a view box
|
||||
/// `vw` × `vh`: each path ended (`N`), open ones unfilled (`F`).
|
||||
pub(super) fn enhanced_path_data(f: &Freeform, vw: u32, vh: u32) -> String {
|
||||
path_text(f, vw, vh, true)
|
||||
}
|
||||
|
||||
fn path_text(f: &Freeform, vw: u32, vh: u32, enhanced: bool) -> String {
|
||||
let sx = if f.w > 0.0 { vw as f32 / f.w } else { 1.0 };
|
||||
let sy = if f.h > 0.0 { vh as f32 / f.h } else { 1.0 };
|
||||
let n = |v: f32| {
|
||||
@@ -365,11 +380,17 @@ pub(super) fn path_data(f: &Freeform, vw: u32, vh: u32) -> String {
|
||||
for p in f.paths.iter().take(MAX_PATHS) {
|
||||
for (i, [x, y]) in p.pts.iter().take(MAX_POINTS).enumerate() {
|
||||
d.push_str(if i == 0 { "M" } else { " L" });
|
||||
if enhanced {
|
||||
d.push(' ');
|
||||
}
|
||||
d.push_str(&format!("{} {}", n(x * sx), n(y * sy)));
|
||||
}
|
||||
if p.closed {
|
||||
d.push_str(" Z");
|
||||
}
|
||||
if enhanced {
|
||||
d.push_str(if p.closed { " N" } else { " F N" });
|
||||
}
|
||||
d.push(' ');
|
||||
}
|
||||
d.trim_end().to_string()
|
||||
|
||||
@@ -6,6 +6,7 @@ mod chars;
|
||||
mod charts;
|
||||
mod comments;
|
||||
mod connectors;
|
||||
mod custom_geometry;
|
||||
mod drop_caps;
|
||||
mod encryption;
|
||||
mod equations;
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Custom shapes whose type WordCraft has no kind for (ODF 1.3 §10.6.2
|
||||
//! `draw:enhanced-geometry`): drawn from their own path (§19.145 `draw:enhanced-path`), its
|
||||
//! values worked out from the shape's equations (§10.6.3 `draw:equation`) and adjustment values
|
||||
//! (`draw:modifiers`), as freeforms.
|
||||
|
||||
use crate::odt::tests::{NS_T, package};
|
||||
use crate::odt::{export, import};
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::para::{InlineObject, ShapeKind};
|
||||
|
||||
const NS_D: &str =
|
||||
r#"xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0""#;
|
||||
|
||||
/// A 1in square custom shape with geometry attributes `geo` and equations `eqs`.
|
||||
fn shape(geo: &str, eqs: &str) -> String {
|
||||
format!(
|
||||
r#"<draw:custom-shape text:anchor-type="paragraph" svg:width="1in" svg:height="1in"><draw:enhanced-geometry {geo}>{eqs}</draw:enhanced-geometry></draw:custom-shape>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn open(shapes: &str) -> Document {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><text:p>{shapes}Text.</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
import(&package(&content, None)).unwrap()
|
||||
}
|
||||
|
||||
/// Each shape's kind and its paths in frame points (rounded), closed or not.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn drawn(d: &Document) -> Vec<(ShapeKind, Vec<(Vec<(f32, f32)>, bool)>)> {
|
||||
let r = |v: f32| v.round();
|
||||
d.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Shape { kind, w, h, freeform, .. } => {
|
||||
let paths = freeform
|
||||
.as_ref()
|
||||
.map(|f| f.placed(0.0, 0.0, *w, *h).into_iter().map(|(pts, c)| (pts.iter().map(|[x, y]| (r(*x), r(*y))).collect(), c)).collect())
|
||||
.unwrap_or_default();
|
||||
Some((*kind, paths))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hexagon_is_drawn_from_its_equations_and_adjustment_value() {
|
||||
let d = open(&shape(
|
||||
r#"svg:viewBox="0 0 21600 21600" draw:type="hexagon" draw:modifiers="5400" draw:enhanced-path="M ?f0 0 L ?f1 0 21600 10800 ?f1 21600 ?f0 21600 0 10800 Z N""#,
|
||||
r#"<draw:equation draw:name="f0" draw:formula="$0 "/><draw:equation draw:name="f1" draw:formula="right-$0"/>"#,
|
||||
));
|
||||
let pts = vec![(18.0, 0.0), (54.0, 0.0), (72.0, 36.0), (54.0, 72.0), (18.0, 72.0), (0.0, 36.0)];
|
||||
let got = drawn(&d);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].0, ShapeKind::Freeform);
|
||||
let (p, closed) = &got[0].1[0];
|
||||
assert!(*closed);
|
||||
assert_eq!(p.get(..6), Some(pts.as_slice()), "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_use_functions_operators_and_other_equations() {
|
||||
// f2 = if(f1, 10800, 0) with f1 = max(abs(-3), sqrt(16)) * (2 + 1) / 4 = 3: 10800.
|
||||
let d = open(&shape(
|
||||
r#"draw:type="mso-spt99" draw:enhanced-path="M 0 0 L ?f2 ?f3 N""#,
|
||||
r#"<draw:equation draw:name="f1" draw:formula="max(abs(-3),sqrt(16))*(2+1)/4"/><draw:equation draw:name="f2" draw:formula="if(?f1 ,width/2,0)"/><draw:equation draw:name="f3" draw:formula="-(-?f2)+0/0"/>"#,
|
||||
));
|
||||
assert_eq!(drawn(&d)[0].1, [(vec![(0.0, 0.0), (36.0, 36.0)], false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quarter_ellipses_run_along_one_axis_then_the_other() {
|
||||
// A terminator: straight top and bottom, rounded ends.
|
||||
let d = open(&shape(
|
||||
r#"draw:type="flowchart-terminator" draw:enhanced-path="M 3470 21600 X 0 10800 3470 0 L 18130 0 X 21600 10800 18130 21600 Z N""#,
|
||||
"",
|
||||
));
|
||||
let (_, paths) = &drawn(&d)[0];
|
||||
let pts = &paths[0].0;
|
||||
// Bulging out to the frame's left and right edges, never beyond.
|
||||
let (min_x, max_x) = pts.iter().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.0), b.max(p.0)));
|
||||
assert_eq!((min_x, max_x), (0.0, 72.0));
|
||||
assert!(pts.contains(&(0.0, 36.0)) && pts.contains(&(72.0, 36.0)), "{pts:?}");
|
||||
// The left end leaves the bottom point going left (along x) first: its next point is lower
|
||||
// than half way.
|
||||
let next = pts.get(1).copied().unwrap_or_default();
|
||||
assert!(next.0 < 12.0 && next.1 > 54.0, "{next:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipses_and_arcs_are_followed_in_steps() {
|
||||
// A circle (U: centre, radii, angles), and the upper half of the frame's ellipse, drawn
|
||||
// anticlockwise from its right to its left (A: box, from, to).
|
||||
let d =
|
||||
open(&shape(r#"draw:type="smiley" draw:enhanced-path="U 10800 10800 10800 10800 0 360 Z N B 0 0 21600 21600 21600 10800 0 10800 N""#, ""));
|
||||
let (_, paths) = &drawn(&d)[0];
|
||||
assert_eq!(paths.len(), 2, "{paths:?}");
|
||||
let circle = &paths[0].0;
|
||||
assert!(circle.len() > 32 && circle.iter().all(|(x, y)| ((x - 36.0).hypot(y - 36.0) - 36.0).abs() <= 1.0), "{circle:?}");
|
||||
let arc = &paths[1].0;
|
||||
assert_eq!((arc.first(), arc.last()), (Some(&(72.0, 36.0)), Some(&(0.0, 36.0))));
|
||||
// Anticlockwise on the page (y down): over the top.
|
||||
assert!(arc.iter().all(|(_, y)| *y <= 36.0), "{arc:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_shape_keeps_its_drawn_geometry_when_saved() {
|
||||
let d = open(&shape(r#"draw:type="octagon" draw:enhanced-path="M 0 0 L 21600 0 10800 21600 Z N""#, ""));
|
||||
let again = import(&export(&d).unwrap()).unwrap();
|
||||
assert_eq!(drawn(&again), drawn(&d));
|
||||
assert_eq!(drawn(&again)[0].0, ShapeKind::Freeform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_and_plain_types_keep_their_kinds() {
|
||||
let d = open(&format!(
|
||||
"{}{}",
|
||||
shape(r#"draw:type="ellipse" draw:enhanced-path="M 0 0 L 1 1 N""#, ""),
|
||||
shape(r#"draw:type="rectangle" draw:enhanced-path="M 0 0 L 21600 0 21600 21600 0 21600 Z N""#, "")
|
||||
));
|
||||
let kinds: Vec<ShapeKind> = drawn(&d).iter().map(|s| s.0).collect();
|
||||
assert_eq!(kinds, [ShapeKind::Ellipse, ShapeKind::Rectangle]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_geometry_never_panics_and_draws_what_it_can() {
|
||||
let deep = format!("{}1{}", "(".repeat(5000), ")".repeat(5000));
|
||||
let unary = "-".repeat(5000);
|
||||
let mut eqs = String::new();
|
||||
// A chain of equations each referring to the next, and one referring to itself.
|
||||
for i in 0..2000 {
|
||||
eqs.push_str(&format!(r#"<draw:equation draw:name="f{i}" draw:formula="?f{} +1"/>"#, i + 1));
|
||||
}
|
||||
eqs.push_str(r#"<draw:equation draw:name="me" draw:formula="?me"/>"#);
|
||||
for (path, eq) in [
|
||||
("M ?f0 0 L 10 10 N", eqs.as_str()),
|
||||
("M ?me ?nothing L $99 $-1 N", eqs.as_str()),
|
||||
("M 0 0 L ?d 1 N", &*format!(r#"<draw:equation draw:name="d" draw:formula="{deep}"/>"#)),
|
||||
("M 0 0 L ?u 1 N", &*format!(r#"<draw:equation draw:name="u" draw:formula="{unary}1"/>"#)),
|
||||
("M 1e308 1e308 L -1e308 1e308 C NaN inf 0 0 0 0 N", ""),
|
||||
("U 0 0 1e300 1e300 0 1e300 Z B 0 0 0 0 0 0 0 0 W 1 1 1 1 1 1 1 1 X Y X 5 5 Y", ""),
|
||||
("M 0 0 L 1", ""),
|
||||
("Q", ""),
|
||||
(&*"L 1 1 ".repeat(30_000), ""),
|
||||
(&*format!("M 0 0 {}", "U 10 10 10 10 0 360 ".repeat(5000)), ""),
|
||||
] {
|
||||
let d = open(&shape(&format!(r#"draw:type="hostile" svg:viewBox="0 0 21600 21600" draw:enhanced-path="{path}""#), eq));
|
||||
let _ = drawn(&d);
|
||||
let _ = export(&d).unwrap();
|
||||
}
|
||||
// An empty or broken view box falls back to a rectangle.
|
||||
let d = open(&shape(r#"draw:type="hostile" svg:viewBox="0 0 0 0" draw:enhanced-path="M 0 0 L 1 1 N""#, ""));
|
||||
assert_eq!(drawn(&d)[0].0, ShapeKind::Rectangle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_freeform_with_open_and_closed_paths_is_saved_as_a_custom_shape() {
|
||||
// A face and a mouth: the face filled, the mouth not. A path with an open part would be
|
||||
// drawn unfilled whole, so it is saved as a custom shape whose path marks the mouth unfilled.
|
||||
let d = open(&shape(r#"draw:type="smiley" draw:enhanced-path="M 0 0 L 21600 0 21600 21600 Z N M 5000 15000 L 16000 15000 F N""#, ""));
|
||||
let c = crate::odt::tests::content_of(&export(&d).unwrap());
|
||||
assert!(c.contains(r#"draw:type="non-primitive""#) && c.contains(" F N"), "{c}");
|
||||
assert!(!c.contains("<draw:path"), "{c}");
|
||||
let again = import(&export(&d).unwrap()).unwrap();
|
||||
assert_eq!(drawn(&again), drawn(&d));
|
||||
assert_eq!(drawn(&again)[0].1.iter().map(|p| p.1).collect::<Vec<_>>(), [true, false]);
|
||||
}
|
||||
@@ -104,12 +104,14 @@ fn regular_polygons_and_circles() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeforms_round_trip_as_paths() {
|
||||
fn freeforms_round_trip_as_paths_or_custom_shapes() {
|
||||
let d = open(
|
||||
r#"<draw:polygon text:anchor-type="paragraph" svg:width="2in" svg:height="1in" svg:viewBox="0 0 2000 1000" draw:points="0,0 2000,0 1000,1000"/><draw:path svg:width="1in" svg:height="1in" svg:viewBox="0 0 100 100" svg:d="M0 0 H50 v50 l-50 0 Z m60 0 L100 100"/>"#,
|
||||
);
|
||||
let bytes = export(&d).unwrap();
|
||||
assert_eq!(content_of(&bytes).matches("<draw:path ").count(), 2);
|
||||
// The second, with a closed and an open path, as a custom shape (see `custom_geometry`).
|
||||
let c = content_of(&bytes);
|
||||
assert_eq!((c.matches("<draw:path ").count(), c.matches("<draw:custom-shape ").count()), (1, 1));
|
||||
assert_eq!(drawn(&import(&bytes).unwrap()), drawn(&d));
|
||||
}
|
||||
|
||||
|
||||
@@ -68,8 +68,8 @@ fn shapes_open_with_their_kind_size_look_and_text() {
|
||||
// From (3in, 0) to (1in, 1in): 2in × 1in, rising from its bottom left.
|
||||
(ShapeKind::Line, (144.0, 72.0), None, Some((Rgb(0, 0, 0), 2.0)), true, true, String::new()),
|
||||
(ShapeKind::Heart, (72.0, 72.0), None, None, true, false, String::new()),
|
||||
// A geometry WordCraft has no shape for: a rectangle in its place.
|
||||
(ShapeKind::Rectangle, (72.0, 72.0), None, None, true, false, String::new()),
|
||||
// A geometry WordCraft has no shape for: drawn from its own path.
|
||||
(ShapeKind::Freeform, (72.0, 72.0), None, None, true, false, String::new()),
|
||||
]
|
||||
);
|
||||
// The shape's text isn't the paragraph's.
|
||||
|
||||
@@ -394,8 +394,12 @@ impl Writer {
|
||||
let (element, geometry) = match crate::odt::shapes::element_of(kind) {
|
||||
// A freeform without paths has nothing to draw but its frame.
|
||||
("draw:path", _) if path.is_none() => ("draw:rect", None),
|
||||
// Paths filled and unfilled: a custom shape, whose path says which is which
|
||||
// (LibreOffice fills no part of a path with an open part).
|
||||
("draw:path", _) if path.is_some_and(crate::odt::path::is_mixed) => ("draw:custom-shape", Some("non-primitive")),
|
||||
e => e,
|
||||
};
|
||||
let mut custom_path = String::new();
|
||||
let len = |v: f32, least: f32| if v.is_finite() { v.clamp(least, 1584.0) } else { 72.0 };
|
||||
let at = if element == "draw:line" || element == "draw:connector" {
|
||||
{
|
||||
@@ -421,6 +425,10 @@ impl Writer {
|
||||
let (vw, vh) = ((w * 100.0).round().max(1.0) as u32, (h * 100.0).round().max(1.0) as u32);
|
||||
at.push_str(&format!(" svg:viewBox=\"0 0 {vw} {vh}\" svg:d=\"{}\"", crate::odt::path::path_data(f, vw, vh)));
|
||||
}
|
||||
if let Some(f) = path.filter(|_| geometry == Some("non-primitive")) {
|
||||
let (vw, vh) = ((w * 100.0).round().max(1.0) as u32, (h * 100.0).round().max(1.0) as u32);
|
||||
custom_path = format!(" svg:viewBox=\"0 0 {vw} {vh}\" draw:enhanced-path=\"{}\"", crate::odt::path::enhanced_path_data(f, vw, vh));
|
||||
}
|
||||
at
|
||||
};
|
||||
// Mirrored, as a custom shape's geometry says (§20.112, §20.113).
|
||||
@@ -428,11 +436,10 @@ impl Writer {
|
||||
let (fh, fv) = float.map_or((false, false), |f| (f.flip_h, f.flip_v));
|
||||
let geometry = geometry
|
||||
.map(|t| {
|
||||
format!(
|
||||
"<draw:enhanced-geometry svg:viewBox=\"0 0 21600 21600\" draw:type=\"{t}\"{}{}/>",
|
||||
mirror(fh, "horizontal"),
|
||||
mirror(fv, "vertical")
|
||||
)
|
||||
if custom_path.is_empty() {
|
||||
custom_path = " svg:viewBox=\"0 0 21600 21600\"".into();
|
||||
}
|
||||
format!("<draw:enhanced-geometry{custom_path} draw:type=\"{t}\"{}{}/>", mirror(fh, "horizontal"), mirror(fv, "vertical"))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let conn = self.conn_attrs(kind, look);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments <!--> and <!---> at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import aLine truncated
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments <!--> and <!---> at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes aLine truncated
|
||||
|
||||
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
|
||||
share of a typical real-world file's content and formatting that survives (read: shows correctly;
|
||||
@@ -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 (and flat `.fodt`) | 2% | R/W | 🟡 | 🟡 | ~85% | 30–50 | `crates/formats/src/tests.rs`, `crates/formats/src/odt/tests.rs` and one file per feature in `crates/formats/src/odt/tests_more/`, `crates/engine/tests/it/odt_sections.rs`, `odt_toc.rs`, `odt_fields.rs`, `odt_charts.rs`, `odt_objects.rs`, `odt_password.rs`, `odt_forms.rs`; checked against LibreOffice's own documents (round trips compared page by page) and the ODF 1.3 schema | Ruby (`text:ruby`, §6.4: a base text with ruby text over it) is read as WordCraft's Phonetic Guide and written back as ODF ruby above its base, centred (one inside a tracked change is written as its base text, which the change marks); its own position and alignment aren't kept. A border around text (LibreOffice's `loext:border` or its sides, with `loext:padding`: ODF 1.3 has no attribute for it) is Word's text border, one line all round, and is written back so (a document with one is then extended ODF, not strict). Emphasis marks (`style:text-emphasize`) and rotated text (`style:text-rotation-angle`) aren't read. How text flows: a page break after a paragraph (`fo:break-after`, a page break before the next paragraph as Word has it), lines kept together (`fo:keep-together`), widow and orphan control (`fo:widows`/`fo:orphans`: on with any line count, off with none; the counts themselves aren't kept) and hyphenation (`fo:hyphenate`, a paragraph's in ODF and the document's in WordCraft: a document with a hyphenated paragraph hyphenates, its other paragraphs opting out) are read and written; a table's alignment and width (`table:align`, `style:rel-width`, or `style:width` for an aligned table) and its rows' heights (`style:row-height` exact, `style:min-row-height` at least) are read and written. Form controls (`office:forms` controls placed by `draw:control`, ODF 1.3 §13) open as content controls — text fields and text areas as plain text controls, check boxes as check boxes, radio buttons as check boxes with round symbols, list boxes as drop-down lists, combo boxes as combo boxes, date fields as date controls, each with its value, entries and selection, its name as the control's tag and a label as its title — and content controls of those kinds in the body are written back as form controls LibreOffice shows with their values (in headers and footers, as their text); buttons, images, hidden and other controls aren't read, and a control's size and look aren't kept. Password-protected ODT (ODF 1.3 Part 2 §3.4) opens with its password — AES-256-CBC with a SHA-256 or SHA-1 start key and PBKDF2, as LibreOffice writes it; the old Blowfish encryption is refused with a message saying so — and a document opened with a password, or saved with one (Save with password), is written encrypted the way LibreOffice encrypts (every file but the `mimetype` and manifest; each with its own salt and initialisation vector; SHA-256 checksums), which LibreOffice opens with the password. Flat OpenDocument text (`.fodt`: the whole document in one `office:document`, pictures inline as base64 `office:binary-data`, formulas inline) is opened and saved as a package is, both validating against the schema. Tables of contents and other indexes (`text:table-of-content`, `text:illustration-index`/`text:table-index`, `text:alphabetical-index`, `text:bibliography`) open as Word's `TOC` (levels from the source's `text:outline-level`; `\c` with the caption name for a table of figures), `INDEX` and `BIBLIOGRAPHY` fields — in the title paragraph when the index has one, as WordCraft inserts a table, else around the entries — so References › Update Table regenerates them; LibreOffice's Contents 1–10, Contents Heading and Figure Index 1 styles are WordCraft's TOC 1–9, TOC Heading and Table of Figures; WordCraft's tables are written back as ODF indexes with their source and entry templates (LibreOffice updates them), entries' links in the Index Link style; user and object indexes are read as their paragraphs; index marks are Word's `XE` fields — a mark at a point (`text:alphabetical-index-mark`, its `text:key1`/`text:key2` the levels above its entry) and one over text (`-mark-start`…`-mark-end` in one paragraph, its entry the marked text) — and `XE` fields are written as point marks, so References › Update Index rebuilds an ODT index from its entries (an index or other generated list held as a field around its entries, as ODT and Word files hold it, is first made the field's own paragraph, as WordCraft inserts one). 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 bLine truncated
|
||||
| `.odt` OpenDocument Text (and flat `.fodt`) | 2% | R/W | 🟡 | 🟡 | ~85% | 30–50 | `crates/formats/src/tests.rs`, `crates/formats/src/odt/tests.rs` and one file per feature in `crates/formats/src/odt/tests_more/`, `crates/engine/tests/it/odt_sections.rs`, `odt_toc.rs`, `odt_fields.rs`, `odt_charts.rs`, `odt_objects.rs`, `odt_password.rs`, `odt_forms.rs`; checked against LibreOffice's own documents (round trips compared page by page) and the ODF 1.3 schema | Ruby (`text:ruby`, §6.4: a base text with ruby text over it) is read as WordCraft's Phonetic Guide and written back as ODF ruby above its base, centred (one inside a tracked change is written as its base text, which the change marks); its own position and alignment aren't kept. A border around text (LibreOffice's `loext:border` or its sides, with `loext:padding`: ODF 1.3 has no attribute for it) is Word's text border, one line all round, and is written back so (a document with one is then extended ODF, not strict). Emphasis marks (`style:text-emphasize`) and rotated text (`style:text-rotation-angle`) aren't read. How text flows: a page break after a paragraph (`fo:break-after`, a page break before the next paragraph as Word has it), lines kept together (`fo:keep-together`), widow and orphan control (`fo:widows`/`fo:orphans`: on with any line count, off with none; the counts themselves aren't kept) and hyphenation (`fo:hyphenate`, a paragraph's in ODF and the document's in WordCraft: a document with a hyphenated paragraph hyphenates, its other paragraphs opting out) are read and written; a table's alignment and width (`table:align`, `style:rel-width`, or `style:width` for an aligned table) and its rows' heights (`style:row-height` exact, `style:min-row-height` at least) are read and written. Form controls (`office:forms` controls placed by `draw:control`, ODF 1.3 §13) open as content controls — text fields and text areas as plain text controls, check boxes as check boxes, radio buttons as check boxes with round symbols, list boxes as drop-down lists, combo boxes as combo boxes, date fields as date controls, each with its value, entries and selection, its name as the control's tag and a label as its title — and content controls of those kinds in the body are written back as form controls LibreOffice shows with their values (in headers and footers, as their text); buttons, images, hidden and other controls aren't read, and a control's size and look aren't kept. Password-protected ODT (ODF 1.3 Part 2 §3.4) opens with its password — AES-256-CBC with a SHA-256 or SHA-1 start key and PBKDF2, as LibreOffice writes it; the old Blowfish encryption is refused with a message saying so — and a document opened with a password, or saved with one (Save with password), is written encrypted the way LibreOffice encrypts (every file but the `mimetype` and manifest; each with its own salt and initialisation vector; SHA-256 checksums), which LibreOffice opens with the password. Flat OpenDocument text (`.fodt`: the whole document in one `office:document`, pictures inline as base64 `office:binary-data`, formulas inline) is opened and saved as a package is, both validating against the schema. Tables of contents and other indexes (`text:table-of-content`, `text:illustration-index`/`text:table-index`, `text:alphabetical-index`, `text:bibliography`) open as Word's `TOC` (levels from the source's `text:outline-level`; `\c` with the caption name for a table of figures), `INDEX` and `BIBLIOGRAPHY` fields — in the title paragraph when the index has one, as WordCraft inserts a table, else around the entries — so References › Update Table regenerates them; LibreOffice's Contents 1–10, Contents Heading and Figure Index 1 styles are WordCraft's TOC 1–9, TOC Heading and Table of Figures; WordCraft's tables are written back as ODF indexes with their source and entry templates (LibreOffice updates them), entries' links in the Index Link style; user and object indexes are read as their paragraphs; index marks are Word's `XE` fields — a mark at a point (`text:alphabetical-index-mark`, its `text:key1`/`text:key2` the levels above its entry) and one over text (`-mark-start`…`-mark-end` in one paragraph, its entry the marked text) — and `XE` fields are written as point marks, so References › Update Index rebuilds an ODT index from its entries (an index or other generated list held as a field around its entries, as ODT and Word files hold it, is first made the field's own paragraph, as WordCraft inserts one). 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 bLine 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: custom shapes of a type WordCraft has no kind for (hexagon, smiley, can, moon, lightning, block arc…) opened as rectangles; they are drawn from their own enhanced path and equations as freeforms, and a freeform with closed and open paths is saved as a custom shape so LibreOffice fills its closed parts; LibreOffice's preset shapes render as LibreOffice draws them (`odt::tests_more::custom_geometry`) |
|
||||
| 2026-11-01 | trivial | ODT: citations (`text:bibliography-mark`) were read as text and their sources lost; they are `CITATION` fields of the document's sources, so a bibliography updates from them, written back as marks carrying their source; a LibreOffice citation and bibliography round-trip pixel-identical (`odt::tests_more::fields::bibliography_marks_are_citations_of_sources`, `odt_toc.rs::an_odt_bibliography_updates_from_its_citations_sources`) |
|
||||
| 2026-11-01 | trivial | ODT: linked text boxes (`draw:chain-next-name`) lost their link, the following box's text gone; they stay linked both ways, a LibreOffice chain round-tripping pixel-identical (`odt::tests_more::text_boxes::linked_text_boxes_stay_linked`) |
|
||||
| 2026-11-01 | trivial | ODT: picture crops (`fo:clip`) and contour wrapping (`style:wrap-contour`, `draw:contour-polygon`) were dropped; they are read as WordCraft's crop and Tight/Through wrapping with its wrap polygon and written back; a cropped LibreOffice picture round-trips pixel-identical (`odt::tests_more::floats::a_cropped_picture_keeps_its_crop`, `contour_wrapping_and_its_outline_are_kept`) |
|
||||
|
||||
Reference in new issue
Block a user