Gap 19: ODT rotated and mirrored shapes and pictures

Shapes and pictures turned by `draw:transform` (rotate, translate and
scale, applied in turn to the object's own coordinates) open turned: the
file's anticlockwise turn about the object's top left corner, followed by
a move, becomes WordCraft's clockwise turn about the centre of the
unrotated box at its offset, as Word places them. They are written back
the same way, in line as well as floating. LibreOffice writes no
`svg:x`/`svg:y` for a turned shape, so before, a turned shape lost both
its turn and its position.

Custom shapes keep their mirroring (`draw:mirror-horizontal`/`-vertical`
on their geometry), and a turned line is written by its turned ends. The
flow keeps the placement of an in-line object that is turned or mirrored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
Claude committed 2026-11-01 06:32:22 +00:00
1 parent 836bf6553b
commit f9d13f06dd
7 files changed
+269 -19

No files matched your search

+8 -2
View File
@@ -291,7 +291,7 @@ fn rich_inlines(doc: &Document, o: &InlineObject, f: &Fmt, opts: FlowOpts) -> Op
let inner = FlowOpts { notes: false, comments: false, boxes: false, ..opts };
let part = story.and_then(|id| doc.parts.get(&id));
let blocks = part.map(|p| flow_blocks(doc, &p.blocks, 0, inner)).unwrap_or_default();
let float = (float.wrap != wordcraft_doc::para::Wrap::Inline).then(|| Box::new(float.clone()));
let float = kept_float(float);
let look = BoxLook {
fill: fill.as_ref().filter(|_| kind != ShapeKind::Line).map(wordcraft_doc::fill::Fill::main_color),
line: stroke.filter(|_| stroke_width.is_finite() && *stroke_width > 0.0).map(|c| (c, stroke_width.min(12.0))),
@@ -309,6 +309,12 @@ fn rich_inlines(doc: &Document, o: &InlineObject, f: &Fmt, opts: FlowOpts) -> Op
/// Comment `c` (a reply to `parent`) of `doc` as a flow comment: its text holds no comments or
/// notes of its own.
/// The placement a flow keeps for a drawing: when it floats, or turns or mirrors it in line.
fn kept_float(f: &wordcraft_doc::para::Float) -> Option<Box<wordcraft_doc::para::Float>> {
let turned = f.rot.is_finite() && f.rot.rem_euclid(360.0) > 0.001 && f.rot.rem_euclid(360.0) < 359.999;
(f.wrap != wordcraft_doc::para::Wrap::Inline || turned || f.flip_h || f.flip_v).then(|| Box::new(f.clone()))
}
fn flow_comment(doc: &Document, c: &wordcraft_doc::Comment, parent: Option<u32>, opts: FlowOpts) -> FComment {
let story = doc.parts.get(&c.part).map(|p| &p.blocks);
let blocks = story.map(|b| flow_blocks(doc, b, 0, FlowOpts { notes: false, comments: false, ..opts })).unwrap_or_default();
@@ -440,7 +446,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
w: *w,
h: *h,
alt: alt.to_string(),
float: (opts.paras && float.wrap != wordcraft_doc::para::Wrap::Inline).then(|| Box::new(float.clone())),
float: kept_float(float).filter(|_| opts.paras),
})),
None if !alt.is_empty() => buf.push_str(alt),
None => {}
+26 -4
View File
@@ -289,6 +289,8 @@ pub(super) struct Body<'a> {
frame_look: model::BoxLook,
/// The kind of the drawing shape being read (its text is read as a text box's).
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),
/// The depth of the `draw:g` group being read: its shapes are placed by it, not anchored.
group_at: Option<usize>,
/// The note or comment being read.
@@ -405,6 +407,7 @@ impl<'a> Body<'a> {
frame_box: None,
frame_look: model::BoxLook::default(),
shape_kind: None,
shape_flip: (false, false),
group_at: None,
note: None,
comment_names: HashMap::new(),
@@ -801,6 +804,9 @@ impl Body<'_> {
if let Some(t) = get(a, "draw:type") {
self.shape_kind = Some(super::shapes::kind_of(t));
}
// 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"));
self.skip = 1;
return;
}
@@ -917,7 +923,7 @@ impl Body<'_> {
let h = get(a, "svg:height").and_then(length);
self.frame = Some((w, h, None, String::new(), None));
let g = get(a, "draw:style-name").map(|s| self.styles.graphic(s)).unwrap_or_default();
self.frame_float = Some(super::frames::float_of(get(a, "text:anchor-type"), get(a, "svg:x"), get(a, "svg:y"), &g));
self.frame_float = Some(super::frames::place(a, w.unwrap_or(0.0), h.unwrap_or(0.0), &g));
// Without a height, a text box in it grows to its text (`fo:min-height` below).
self.frame_look = g.look(h.is_none());
}
@@ -1415,7 +1421,17 @@ impl Body<'_> {
fn end_frame(&mut self) {
if let Some((w, h, img, alt, eq)) = self.frame.take() {
let boxed = self.frame_box.take();
let float = self.frame_float.take().filter(|f| f.wrap != wordcraft_doc::para::Wrap::Inline);
let mut float = self.frame_float.take();
if boxed.is_some()
&& self.shape_kind.is_some()
&& let Some(f) = &mut float
{
let (h, v) = std::mem::take(&mut self.shape_flip);
f.flip_h |= h;
f.flip_v ^= v;
}
// Kept when it floats, or turns or mirrors the object in line.
let float = float.filter(|f| f.wrap != wordcraft_doc::para::Wrap::Inline || super::frames::turned(f) || f.flip_h || f.flip_v);
let inline = match (boxed, eq) {
(Some(blocks), _) => {
let look = std::mem::take(&mut self.frame_look);
@@ -1447,9 +1463,15 @@ impl Body<'_> {
let kind = super::shapes::element_kind(local(q));
let g = self.styles.shape_graphic(get(a, "draw:style-name"));
let (w, h, x, y, rising) = super::shapes::geometry(kind, a);
let at = |v: Option<f32>| v.map(super::writer::pt);
let mut float = super::frames::float_of(get(a, "text:anchor-type"), at(x).as_deref(), at(y).as_deref(), &g);
let mut float = if kind == wordcraft_doc::para::ShapeKind::Line {
// A line is placed by its ends.
let at = |v: Option<f32>| v.map(super::writer::pt);
super::frames::float_of(get(a, "text:anchor-type"), at(x).as_deref(), at(y).as_deref(), &g)
} else {
super::frames::place(a, w, h, &g)
};
float.flip_v = rising;
self.shape_flip = (false, false);
self.frame_look = g.shape_look(kind);
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));
+103 -2
View File
@@ -217,9 +217,105 @@ pub(super) fn float_of(anchor: Option<&str>, x: Option<&str>, y: Option<&str>, g
}
}
/// Where a frame or shape `w` × `h` (points) sits: anchored as its `text:anchor-type` says, at
/// its `svg:x`/`svg:y`, or turned and moved by its `draw:transform`, in graphic style `g`.
pub(super) fn place(a: &Attrs, w: f32, h: f32, g: &Graphic) -> Float {
let turned = get(a, "draw:transform").and_then(|t| transform(t, w, h));
let (x, y) = match turned {
Some((x, y, _)) => (Some(pt(x)), Some(pt(y))),
None => (get(a, "svg:x").map(String::from), get(a, "svg:y").map(String::from)),
};
let mut f = float_of(get(a, "text:anchor-type"), x.as_deref(), y.as_deref(), g);
if let Some((.., rot)) = turned {
f.rot = rot;
}
f
}
/// Most operations of a `draw:transform` read.
const MAX_TRANSFORMS: usize = 16;
/// A `draw:transform` (ODF 1.3 §19.228) of an object `w` × `h`, as WordCraft places it: the
/// offset of its unrotated box and its clockwise turn about the box's centre, in degrees
/// (0–360). The file's operations apply in turn to the object's own coordinates, whose origin
/// is its top left corner: `rotate` turns it anticlockwise (radians), `translate` moves it and
/// `scale` scales it; `skewX`, `skewY` and `matrix` are left out. `None` when none reads.
pub(super) fn transform(v: &str, w: f32, h: f32) -> Option<(f32, f32, f32)> {
// x' = m0·x + m2·y + m4, y' = m1·x + m3·y + m5.
let mut m = [1.0f64, 0.0, 0.0, 1.0, 0.0, 0.0];
let mut angle = 0.0f64;
let mut any = false;
let mut rest = v;
for _ in 0..MAX_TRANSFORMS {
let (Some(open), Some(close)) = (rest.find('('), rest.find(')')) else { break };
let (Some(name), Some(args)) = (rest.get(..open), rest.get(open + 1..close)) else { break };
let args: Vec<&str> = args.split(|c: char| c.is_whitespace() || c == ',').filter(|s| !s.is_empty()).collect();
rest = rest.get(close + 1..).unwrap_or("");
let num = |i: usize| args.get(i).and_then(|a| a.parse::<f64>().ok()).filter(|v| v.is_finite());
let len = |i: usize| args.get(i).and_then(|a| length(a)).filter(|v| v.is_finite()).map(|v| f64::from(v.clamp(-MAX_LEN, MAX_LEN)));
match name.trim() {
"rotate" => {
let Some(a) = num(0) else { continue };
let a = a % std::f64::consts::TAU;
let (sin, cos) = a.sin_cos();
m = [
cos * m[0] + sin * m[1],
-sin * m[0] + cos * m[1],
cos * m[2] + sin * m[3],
-sin * m[2] + cos * m[3],
cos * m[4] + sin * m[5],
-sin * m[4] + cos * m[5],
];
angle += a;
}
"translate" => {
let Some(tx) = len(0) else { continue };
m[4] += tx;
m[5] += len(1).unwrap_or(0.0);
}
"scale" => {
let Some(sx) = num(0).map(|v| v.clamp(-100.0, 100.0)) else { continue };
let sy = num(1).map_or(sx, |v| v.clamp(-100.0, 100.0));
m = [m[0] * sx, m[1] * sy, m[2] * sx, m[3] * sy, m[4] * sx, m[5] * sy];
}
_ => continue,
}
any = true;
}
if !any {
return None;
}
let (hw, hh) = (f64::from(w) / 2.0, f64::from(h) / 2.0);
let centre = (m[0] * hw + m[2] * hh + m[4], m[1] * hw + m[3] * hh + m[5]);
let fit = |v: f64| if v.is_finite() { v.clamp(-f64::from(MAX_LEN), f64::from(MAX_LEN)) as f32 } else { 0.0 };
let rot = (-angle.to_degrees()).rem_euclid(360.0);
// Rounding can leave a whole turn.
let rot = if rot.is_finite() && rot < 359.9999 { rot as f32 } else { 0.0 };
Some((fit(centre.0 - hw), fit(centre.1 - hh), rot))
}
/// The `draw:transform` of an object `w` × `h` whose unrotated box is at `x`, `y` and turned `rot`
/// degrees clockwise about its centre (the reverse of [`transform`]).
pub(super) fn transform_attr(x: f32, y: f32, w: f32, h: f32, rot: f32) -> String {
let fin = |v: f32| if v.is_finite() { f64::from(v.clamp(-MAX_LEN, MAX_LEN)) } else { 0.0 };
let a = -fin(rot).to_radians();
let (sin, cos) = a.sin_cos();
let (hw, hh) = (fin(w) / 2.0, fin(h) / 2.0);
// The corner that ends up where the centre stays put.
let tx = fin(x) + hw - (cos * hw + sin * hh);
let ty = fin(y) + hh - (-sin * hw + cos * hh);
format!(" draw:transform=\"rotate ({a}) translate ({} {})\"", pt(tx as f32), pt(ty as f32))
}
/// Whether `f` turns its object.
pub(super) fn turned(f: &Float) -> bool {
f.rot.is_finite() && f.rot.rem_euclid(360.0) > 0.001 && f.rot.rem_euclid(360.0) < 359.999
}
/// How a floating picture is written: its `text:anchor-type`, its graphic style's
/// `style:graphic-properties` attributes, and the frame's `svg:x`/`svg:y` attributes.
pub(super) fn frame_of(f: &Float) -> (&'static str, String, String) {
/// `style:graphic-properties` attributes, and the frame's `svg:x`/`svg:y` attributes, or its
/// `draw:transform` when it is turned (`w` × `h` its size).
pub(super) fn frame_of(f: &Float, w: f32, h: f32) -> (&'static str, String, String) {
let anchor = if f.h_rel == Anchor::Page && f.v_rel == Anchor::Page {
"page"
} else if f.v_rel == Anchor::Line {
@@ -275,6 +371,11 @@ pub(super) fn frame_of(f: &Float) -> (&'static str, String, String) {
);
let mut xy = String::new();
let p = |v: f32| pt(if v.is_finite() { v.clamp(-MAX_LEN, MAX_LEN) } else { 0.0 });
if turned(f) {
let x = if f.h_align.is_none() { f.x } else { 0.0 };
let y = if f.v_align.is_none() { f.y } else { 0.0 };
return (anchor, props, transform_attr(x, y, w, h, f.rot));
}
if f.h_align.is_none() {
xy.push_str(&format!(" svg:x=\"{}\"", p(f.x)));
}
+1
View File
@@ -13,6 +13,7 @@ mod named_styles;
mod notes;
mod outline;
mod para_borders;
mod rotation;
mod rtl;
mod settings;
mod shapes;
@@ -0,0 +1,99 @@
//! Rotated and mirrored shapes and pictures (ODF 1.3 §19.228 `draw:transform`, §20.112
//! `draw:mirror-horizontal`/`-vertical` of a custom shape's geometry): a turn in the file
//! (anticlockwise, about the shape's top left corner, then moved) opens as WordCraft's
//! (clockwise about the centre of the unrotated box at its offset), and is written back.
use crate::odt::tests::{NS_T, content_of, package, zip_of};
use crate::odt::{export, import};
use wordcraft_doc::Document;
use wordcraft_doc::para::{Float, InlineObject, Wrap};
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" xmlns:xlink="http://www.w3.org/1999/xlink""#;
/// Each object's (offset, rotation, flips, floats), rounded.
fn placed(d: &Document) -> Vec<((f32, f32), f32, (bool, bool), 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 { float, .. } | InlineObject::Image { float, .. } => Some(float),
_ => None,
})
.map(|f: &Float| ((r(f.x), r(f.y)), r(f.rot), (f.flip_h, f.flip_v), f.wrap != Wrap::Inline))
.collect()
}
/// LibreOffice's own: a rectangle turned 30° and an arrow turned 90°, both anticlockwise, whose
/// unrotated boxes sit at (0.3937in, 0.1969in) and (2.756in, 0.1969in); an arrow mirrored.
fn open() -> Document {
let content = format!(
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><text:p>
<draw:custom-shape text:anchor-type="paragraph" svg:width="1.5752in" svg:height="0.5906in" draw:transform="rotate (0.523598775598299) translate (0.351388888888889in 0.629861111111111in)"><draw:enhanced-geometry draw:type="rectangle"/></draw:custom-shape>
<draw:custom-shape text:anchor-type="paragraph" svg:width="1.1815in" svg:height="0.5906in" draw:transform="rotate (1.5707963267949) translate (3.05138888888889in 1.08194444444444in)"><draw:enhanced-geometry draw:type="right-arrow"/></draw:custom-shape>
<draw:custom-shape text:anchor-type="paragraph" svg:width="1in" svg:height="0.5in" svg:x="1in" svg:y="2in"><draw:enhanced-geometry draw:type="right-arrow" draw:mirror-horizontal="true"/></draw:custom-shape>
<draw:custom-shape text:anchor-type="as-char" svg:width="1in" svg:height="0.5in" draw:transform="rotate (-0.785398163397448) translate (0in 0in)"><draw:enhanced-geometry draw:type="diamond" draw:mirror-vertical="true"/></draw:custom-shape>
text</text:p></office:text></office:body></office:document-content>"#
);
import(&package(&content, None)).unwrap()
}
#[test]
fn turned_shapes_open_turned_about_their_centre() {
assert_eq!(
placed(&open()),
[
((28.3, 14.2), 330.0, (false, false), true),
((198.4, 14.1), 270.0, (false, false), true),
((72.0, 144.0), 0.0, (true, false), true),
// In line with the text: turned 45° clockwise, mirrored, still in line.
((0.0, 0.0), 45.0, (false, true), false),
]
);
}
#[test]
fn turned_shapes_round_trip() {
let d = open();
let bytes = export(&d).unwrap();
let c = content_of(&bytes);
assert!(c.contains("draw:transform=\"rotate (") && c.contains("draw:mirror-horizontal=\"true\""), "{c}");
assert_eq!(placed(&import(&bytes).unwrap()), placed(&d));
}
#[test]
fn a_turned_picture_opens_and_saves_turned() {
let png = [b"\x89PNG\r\n\x1a\n".as_slice(), &[7u8; 64]].concat();
let content = format!(
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><text:p><draw:frame text:anchor-type="paragraph" svg:width="2in" svg:height="1in" draw:transform="rotate (1.5707963267949) translate (1in 3in)"><draw:image xlink:href="Pictures/a.png"/></draw:frame>Text.</text:p></office:text></office:body></office:document-content>"#
);
let d = import(&zip_of(&[("content.xml", content.as_bytes()), ("Pictures/a.png", &png)])).unwrap();
// Centre: (1in, 3in) + the half size turned a quarter anticlockwise, (0.5in, -1in): the
// unrotated box's top left is half its size back from (1.5in, 2in).
assert_eq!(placed(&d), [((36.0, 108.0), 270.0, (false, false), true)]);
assert_eq!(placed(&import(&export(&d).unwrap()).unwrap()), placed(&d));
}
#[test]
fn hostile_transforms_do_not_panic() {
for t in [
"rotate (NaN) translate (1e40in 2in)",
"rotate",
"rotate (1",
"translate (1in)",
"scale (2 2) rotate (1e30) skewX (3)",
"matrix (1 0 0 1 0 0)",
"rotate ((((",
"",
] {
let content = format!(
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><text:p><draw:custom-shape text:anchor-type="paragraph" svg:width="1in" svg:height="1in" draw:transform="{t}"><draw:enhanced-geometry draw:type="star5"/></draw:custom-shape></text:p></office:text></office:body></office:document-content>"#
);
let d = import(&package(&content, None)).unwrap();
for ((x, y), rot, ..) in placed(&d) {
assert!(x.is_finite() && y.is_finite() && rot.is_finite() && (0.0..360.0).contains(&rot), "{t}: {x} {y} {rot}");
}
let _ = export(&d).unwrap();
}
}
+30 -10
View File
@@ -442,12 +442,16 @@ fn box_look_attrs(look: &model::BoxLook) -> String {
a
}
/// How a frame or shape is anchored and placed: its `text:anchor-type`, its graphic style's
/// placement and its `svg:x`/`svg:y`; in line with the text when it doesn't float.
fn placement(float: Option<&wordcraft_doc::para::Float>) -> (&'static str, String, String) {
/// How a frame or shape `w` × `h` is anchored and placed: its `text:anchor-type`, its graphic
/// style's placement and its `svg:x`/`svg:y` (or `draw:transform` when it is turned); in line
/// with the text when it doesn't float.
fn placement(float: Option<&wordcraft_doc::para::Float>, w: f32, h: f32) -> (&'static str, String, String) {
match float {
Some(f) => super::frames::frame_of(f),
None => ("as-char", " style:vertical-pos=\"top\" style:vertical-rel=\"baseline\"".to_string(), String::new()),
Some(f) if f.wrap != wordcraft_doc::para::Wrap::Inline => super::frames::frame_of(f, w, h),
f => {
let turn = f.filter(|f| super::frames::turned(f)).map(|f| super::frames::transform_attr(0.0, 0.0, w, h, f.rot));
("as-char", " style:vertical-pos=\"top\" style:vertical-rel=\"baseline\"".to_string(), turn.unwrap_or_default())
}
}
}
@@ -853,7 +857,7 @@ impl Writer {
// A floating picture: anchored, in a graphic style that places and wraps it.
let (anchor, place) = match img.float.as_deref() {
Some(f) => {
let (anchor, props, xy) = super::frames::frame_of(f);
let (anchor, props, xy) = placement(Some(f), img.w, img.h);
(anchor, format!(" draw:style-name=\"{}\"{xy}", self.graphic_style(props)))
}
None => ("as-char", String::new()),
@@ -954,7 +958,7 @@ impl Writer {
let reopen = self.open_ins.as_ref().map(|(m, _)| m.clone());
self.close_ins(out);
let frame = box_look_attrs(look);
let (anchor, props, xy) = placement(float);
let (anchor, props, xy) = placement(float, size.0, size.1);
let style = self.graphic_style(format!("{props}{frame}"));
let mut body = self.story(blocks);
if body.is_empty() {
@@ -993,7 +997,7 @@ impl Writer {
) {
let reopen = self.open_ins.as_ref().map(|(m, _)| m.clone());
self.close_ins(out);
let (anchor, props, xy) = placement(float);
let (anchor, props, xy) = placement(float, size.0, size.1);
let style = self.graphic_style(format!("{props}{}", shape_look_attrs(look)));
let body = self.story(blocks);
self.boxes += 1;
@@ -1008,12 +1012,28 @@ impl Writer {
let (x, y) = float.map_or((0.0, 0.0), |f| (off(f.x, f.h_align.is_some()), off(f.y, f.v_align.is_some())));
let rises = float.is_some_and(|f| f.flip_v != f.flip_h);
let (y1, y2) = if rises { (y + h, y) } else { (y, y + h) };
format!(" svg:x1=\"{}\" svg:y1=\"{}\" svg:x2=\"{}\" svg:y2=\"{}\"", pt(x), pt(y1), pt(x + w), pt(y2))
// Turned about its centre: its ends turned with it.
let (cx, cy) = (x + w / 2.0, y + h / 2.0);
let (sin, cos) = float.filter(|f| super::frames::turned(f)).map_or(0.0, |f| f.rot.to_radians()).sin_cos();
let turn = |px: f32, py: f32| (cx + (px - cx) * cos - (py - cy) * sin, cy + (px - cx) * sin + (py - cy) * cos);
let ((x1, y1), (x2, y2)) = (turn(x, y1), turn(x + w, y2));
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 geometry = geometry.map(|t| format!("<draw:enhanced-geometry svg:viewBox=\"0 0 21600 21600\" draw:type=\"{t}\"/>")).unwrap_or_default();
// 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() };
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")
)
})
.unwrap_or_default();
out.push_str(&format!(
"<{element} draw:name=\"{}Shape{}\" draw:style-name=\"{style}\" text:anchor-type=\"{anchor}\"{at} draw:z-index=\"0\">{body}{geometry}</{element}>",
self.prefix, self.boxes,
+2 -1
View File
@@ -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: 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) |
| 2026-11-01 | trivial | ODT: drawing shapes (`draw:rect`, `draw:ellipse`, `draw:line`, `draw:custom-shape` with a predefined `draw:type`) open as WordCraft shapes with their size, placement, fill, line, text and direction instead of being skipped, and WordCraft's shapes are saved as them (rectangle, rounded rectangle, ellipse, flowchart or, triangles, diamond, block arrows, star, heart, line; connectors as lines); a shape keeps its own size in LibreOffice unless it resizes to fit its text (`draw:auto-grow-height`). A LibreOffice shapes document now round-trips with all nine shapes and the same text (13,864 px apart, mostly in-line shapes moved onto the baseline) |
| 2026-11-01 | trivial | ODT: a text box frame keeps its line, fill and padding from its graphic style chain (LibreOffice's Frame style included) instead of a fixed thin black line on white, and a frame without a height (`fo:min-height`) opens as a box that resizes to fit its text and is written back that way; floating boxes that resize to fit now take their text's height in layout too (a LibreOffice text-box document: 1,117 px apart after a round trip, now 736). A formula sub-document's `content.xml` stays a bare `math:math` root, as ODF 1.3 Part 3 §2.2.9 requires, and one wrapped in `office:math` still reads |
| 2026-11-01 | trivial | ODT: Asian and complex-script fonts, the complex script's size, weight and posture, and both script languages (`style:font-name-asian`/`-complex`, `style:font-size-complex`, `style:language-asian`/`-complex`…) are read and written, on runs and as the document defaults, so Hebrew, Arabic and CJK text keep their fonts. `formats::odt::tests_more::chars` |