Gap 19: ODT connectors read and written glued

ODT connectors (`draw:connector`) were left out. They now open as
WordCraft connectors: straight (`line`), curved (`curve`), or with
bends (`standard`, `lines`).
- Gluing: each end is glued to the shape it names (`draw:start-shape`,
  `draw:end-shape`). The shape's four default glue points (§9.2.19:
  top, right, bottom, left) become its connection sites. An end glued
  to a glue point of the shape's own, or to a shape not read, is left
  unglued.
- Placement: a connector's own coordinates are in LibreOffice's drawing
  layer, not its anchor's. So a connector glued at both ends to shapes
  anchored in its paragraph is placed between their sites.
- Writing: connectors are written back as `draw:connector` with their
  type and glue, and the shapes they glue to get ids
  (`xml:id`/`draw:id`). LibreOffice re-routes them from the glue.

LibreOffice's connector document round-trips with its connectors
drawn between the shapes, and WordCraft draws them between the right
sites. Other formats still get connectors as straight lines.

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 20:15:27 +00:00
1 parent f683e308c6
commit 3d464f3ebe
15 files changed
+314 -18

No files matched your search

+3 -3
View File
@@ -27,9 +27,9 @@ pub use from_doc::{
pub use media::{base64_decode, base64_encode, data_uri, decode_bounded, image_px, make_img, mime_of, percent_decode, sized_img, sniff_image};
pub use to_doc::{char_props, part_blocks, to_doc, to_doc_in};
pub use types::{
Affix, BoxLook, CODE_STYLE, Cell, Chart, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind, Label, ListInfo, MAX_DEPTH,
MAX_FONT_NAME, MAX_GROUP_DEPTH, MAX_LINK, MONO_FONT, Member, Meta, OdfObject, Para, RUBY_BASE_SIZE, RevMark, Spacing, TableExtra, clean_text,
equations_as_text, flatten_tables, kept_font, kept_link, ranged_anchors, ruby_as_text, ruby_object,
Affix, BoxLook, CODE_STYLE, Cell, Chart, Conn, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind, Label, ListInfo,
MAX_DEPTH, MAX_FONT_NAME, MAX_GROUP_DEPTH, MAX_LINK, MONO_FONT, Member, Meta, OdfObject, Para, RUBY_BASE_SIZE, RevMark, Spacing, TableExtra,
clean_text, equations_as_text, flatten_tables, kept_font, kept_link, ranged_anchors, ruby_as_text, ruby_object,
};
pub(crate) use types::{Dropped, begin_import, dropped};
+5 -2
View File
@@ -302,9 +302,10 @@ fn drawing_inline(doc: &Document, o: &InlineObject, opts: FlowOpts, depth: usize
InlineObject::Shape { kind, w, h, float, story, fill, stroke, stroke_width, extra, freeform, .. }
if *kind != ShapeKind::Freeform || freeform.is_some() =>
{
// A connector is a straight line.
// A connector stays one in formats that glue them (ODT, with boxes); elsewhere it is a
// straight line.
let kind = match kind {
ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => ShapeKind::Line,
ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector if !opts.boxes => ShapeKind::Line,
k => *k,
};
let inner = FlowOpts { notes: false, comments: false, boxes: false, ..opts };
@@ -317,6 +318,8 @@ fn drawing_inline(doc: &Document, o: &InlineObject, opts: FlowOpts, depth: usize
arrows: (extra.arrow_start, extra.arrow_end),
inset: part.and_then(|p| p.body.inset.is_some().then(|| p.body.insets_pt())),
auto_fit: part.is_some_and(|p| p.body.auto_fit),
conn: (opts.boxes && (extra.id != 0 || extra.start.is_some() || extra.end.is_some()))
.then(|| Box::new(crate::model::Conn { id: extra.id, start: extra.start, end: extra.end })),
};
let path = freeform.clone().filter(|_| kind == ShapeKind::Freeform);
Some(Inline::Shape { kind, w: w * sx, h: h * sy, float: kept_float(float), blocks, look, path })
+4
View File
@@ -406,6 +406,10 @@ impl<'a> Builder<'a> {
dash: look.dash.clone(),
arrow_start: look.arrows.0,
arrow_end: look.arrows.1,
// Its drawing id, and what a connector's ends are glued to (sites bounded).
id: look.conn.as_ref().map_or(0, |c| c.id),
start: look.conn.as_ref().and_then(|c| c.start).filter(|e| e.site <= wordcraft_doc::connector::MAX_SITE),
end: look.conn.as_ref().and_then(|c| c.end).filter(|e| e.site <= wordcraft_doc::connector::MAX_SITE),
..Default::default()
},
};
+12
View File
@@ -300,6 +300,17 @@ pub struct BoxLook {
pub inset: Option<[f32; 4]>,
/// The box grows to its text; its height is the least it is.
pub auto_fit: bool,
/// The drawing id connectors glue to, and a connector's glued ends (see
/// [`wordcraft_doc::connector`]).
pub conn: Option<Box<Conn>>,
}
/// A shape's drawing id (0: none) and, for a connector, what its ends are glued to.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Conn {
pub id: u32,
pub start: Option<wordcraft_doc::connector::ConnEnd>,
pub end: Option<wordcraft_doc::connector::ConnEnd>,
}
impl Default for BoxLook {
@@ -312,6 +323,7 @@ impl Default for BoxLook {
arrows: (false, false),
inset: None,
auto_fit: false,
conn: None,
}
}
}
+16 -1
View File
@@ -33,6 +33,13 @@ pub(super) struct Body<'a> {
pub(super) entries: &'a [String],
/// The paragraph before had a page break after it: the next one starts a page.
break_next: bool,
/// Shapes' ids (`xml:id`, `draw:id`) as WordCraft drawing ids, and where the shapes with one
/// sit (kind, frame relative to its anchor, turn, placement, the paragraph they are in), for
/// connectors glued to them.
pub(super) conn_ids: HashMap<String, u32>,
pub(super) shape_places: HashMap<u32, (wordcraft_doc::para::ShapeKind, [f32; 4], wordcraft_doc::para::Float, usize)>,
/// Paragraphs started so far: which paragraph a shape is in.
pub(super) para_seq: usize,
/// Index entries over text whose end hasn't come (in the paragraph being read): their id,
/// keys and where they start: an inline, and a byte offset in it when it is text.
index_marks: Vec<(String, (Option<String>, Option<String>), (usize, usize))>,
@@ -139,6 +146,9 @@ impl<'a> Body<'a> {
break_next: false,
ruby: None,
index_marks: Vec::new(),
conn_ids: HashMap::new(),
shape_places: HashMap::new(),
para_seq: 0,
manifest: None,
containers: vec![Vec::new()],
fmt: Vec::new(),
@@ -367,6 +377,10 @@ impl Body<'_> {
fn start_para(&mut self, style: Option<&str>, heading: Option<u8>) {
self.end_para();
// Paragraphs of the text (not of a shape's, a note's or a comment's) say where shapes are.
if self.note.is_none() {
self.para_seq = self.para_seq.wrapping_add(1);
}
let look = style.map(|s| self.styles.para(s)).unwrap_or_default();
let (mut kind, align, mut pb, rule, keep_next, base) = (look.kind, look.align, look.page_break, look.rule, look.keep_next, look.fmt);
// The section break starts the new page; no page break besides.
@@ -453,6 +467,7 @@ impl Body<'_> {
// An index entry is marked within one paragraph.
self.index_marks.clear();
let Some(mut p) = self.para.take() else { return };
self.glue_connectors(&mut p);
self.fmt.pop();
// A paragraph ending inside an open insertion has an inserted mark. (Paragraphs that
// `place_deletion` splits off keep only the marks read inside their region.)
@@ -682,7 +697,7 @@ impl Body<'_> {
self.skip = 1;
}
// Drawing shapes (ODF 1.3 §10.3) anchored in the text, with their text.
"rect" | "ellipse" | "circle" | "line" | "custom-shape" | "polygon" | "polyline" | "path" | "regular-polygon"
"rect" | "ellipse" | "circle" | "line" | "custom-shape" | "polygon" | "polyline" | "path" | "regular-polygon" | "connector"
if q.starts_with("draw:") && self.frame.is_none() && self.note.is_none() && self.del.is_none() && !self.in_tc =>
{
self.start_shape(q, a);
+88 -4
View File
@@ -71,6 +71,9 @@ pub(in crate::odt) struct OpenGroup {
/// Largest size or offset read in a group, points (22 inches).
pub(in crate::odt) const MAX_LEN: f32 = 1584.0;
/// Most shape ids connectors can glue to.
const MAX_CONN_IDS: usize = 10_000;
impl Body<'_> {
/// End a frame or drawing shape: its picture, equation, text box or shape goes into the
/// paragraph.
@@ -99,7 +102,11 @@ impl Body<'_> {
let empty = blocks.iter().all(|b| matches!(b, FBlock::Para(p) if p.inlines.is_empty()));
let blocks = if kind != wordcraft_doc::para::ShapeKind::TextBox && empty { Vec::new() } else { blocks };
let path = self.shape_path.take().filter(|_| kind == wordcraft_doc::para::ShapeKind::Freeform);
Some(Inline::Shape { kind, w: w.unwrap_or(72.0), h: h.unwrap_or(1.0), float: float.map(Box::new), blocks, look, path })
let (w, h) = (w.unwrap_or(72.0), h.unwrap_or(1.0));
if self.member_at.is_none() {
self.note_place(kind, (w, h), &look, float.as_ref());
}
Some(Inline::Shape { kind, w, h, float: float.map(Box::new), blocks, look, path })
}
(None, Some((linear, display))) => Some(Inline::Equation { linear, display }),
(None, None) if let Some(spec) = chart => Some(Inline::Chart(model::Chart {
@@ -143,6 +150,79 @@ impl Body<'_> {
self.frame = None;
}
/// A shape's drawing id (its `xml:id` or `draw:id`) and, for a connector, its glued ends (§19.228
/// `draw:start-shape`, §19.229 `draw:start-glue-point`…), their glue points as written until
/// the paragraph ends (see [`Self::glue_connectors`]). `None` for a shape with neither.
fn conn_of(&mut self, a: &Attrs) -> Option<model::Conn> {
let mut id_of = |name: &str| -> u32 {
let next = u32::try_from(self.conn_ids.len()).unwrap_or(u32::MAX).saturating_add(1);
match self.conn_ids.get(name) {
Some(id) => *id,
None if self.conn_ids.len() < MAX_CONN_IDS => {
self.conn_ids.insert(name.to_string(), next);
next
}
None => 0,
}
};
let id = get(a, "xml:id").or_else(|| get(a, "draw:id")).map(str::trim).filter(|n| !n.is_empty()).map_or(0, &mut id_of);
let mut end = |shape: &str, glue: &str| {
let target = get(a, shape).map(str::trim).filter(|n| !n.is_empty()).map(&mut id_of).filter(|id| *id != 0)?;
let site = get(a, glue).and_then(|g| g.trim().parse::<u32>().ok()).filter(|g| *g <= wordcraft_doc::connector::MAX_SITE)?;
Some(wordcraft_doc::connector::ConnEnd { id: target, site })
};
let (start, finish) = (end("draw:start-shape", "draw:start-glue-point"), end("draw:end-shape", "draw:end-glue-point"));
(id != 0 || start.is_some() || finish.is_some()).then_some(model::Conn { id, start, end: finish })
}
/// Where a shape with a drawing id (`look`) sits, for connectors glued to it.
pub(in crate::odt) fn note_place(
&mut self,
kind: wordcraft_doc::para::ShapeKind,
(w, h): (f32, f32),
look: &model::BoxLook,
float: Option<&wordcraft_doc::para::Float>,
) {
if let (Some(c), Some(f)) = (look.conn.as_deref().filter(|c| c.id != 0), float)
&& self.shape_places.len() < MAX_CONN_IDS
{
self.shape_places.insert(c.id, (kind, [f.x, f.y, w, h], f.clone(), self.para_seq));
}
}
/// The connectors of paragraph `p` glued to shapes (see [`Self::conn_of`]): each end's glue
/// point made WordCraft's site of the shape it is glued to (an end glued to a shape not read,
/// or to a glue point of the shape's own, is unglued); and a connector glued at both ends to
/// shapes anchored in this paragraph, placed alike, runs between their sites. (A connector's
/// own coordinates are in LibreOffice's drawing layer, not its anchor's.)
pub(in crate::odt) fn glue_connectors(&self, p: &mut model::Para) {
use crate::odt::shapes::site_of_glue;
for i in &mut p.inlines {
let Inline::Shape { kind, w, h, float, look, .. } = i else { continue };
let Some(c) = look.conn.as_deref_mut().filter(|_| kind.is_connector()) else { continue };
let resolve = |e: Option<wordcraft_doc::connector::ConnEnd>| {
let e = e?;
let place = self.shape_places.get(&e.id)?;
site_of_glue(place.0, e.site).map(|site| (wordcraft_doc::connector::ConnEnd { id: e.id, site }, place))
};
let (start, end) = (resolve(c.start), resolve(c.end));
(c.start, c.end) = (start.map(|(e, _)| e), end.map(|(e, _)| e));
let (Some((s, sp)), Some((e, ep)), Some(f)) = (start, end, float.as_deref_mut()) else { continue };
let same = |pl: &(wordcraft_doc::para::ShapeKind, [f32; 4], wordcraft_doc::para::Float, usize)| {
pl.3 == self.para_seq && pl.2.h_rel == f.h_rel && pl.2.v_rel == f.v_rel
};
if !(same(sp) && same(ep)) {
continue;
}
let point = |pl: &(wordcraft_doc::para::ShapeKind, [f32; 4], wordcraft_doc::para::Float, usize), site| {
wordcraft_doc::connector::site_point(pl.0, pl.1, pl.2.spin(), site)
};
let (Some((x1, y1)), Some((x2, y2))) = (point(sp, s.site), point(ep, e.site)) else { continue };
(f.x, f.y, f.flip_h, f.flip_v) = (x1.min(x2), y1.min(y2), x2 < x1, y2 < y1);
(*w, *h) = ((x2 - x1).abs().min(MAX_LEN), (y2 - y1).abs().min(MAX_LEN));
}
}
/// A frame or shape `w` × `h` (attributes `a`) begins: in a group (not in a shape's text), where
/// it sits in it, from its `svg:x`/`svg:y` or `draw:transform` (a line's from its ends, `line`).
pub(in crate::odt) fn member_start(&mut self, a: &Attrs, w: f32, h: f32, line: Option<(Option<f32>, Option<f32>)>) {
@@ -193,10 +273,13 @@ impl Body<'_> {
/// Begin a drawing shape (ODF 1.3 §10.3): its size, placement and look, and its text read
/// as a text box's. A custom shape's kind comes from its geometry, read inside it.
pub(in crate::odt) fn start_shape(&mut self, q: &str, a: &Attrs) {
let kind = crate::odt::shapes::element_kind(local(q));
let mut kind = crate::odt::shapes::element_kind(local(q));
if kind.is_connector() {
kind = crate::odt::shapes::connector_kind(get(a, "draw:type"));
}
let g = self.styles.shape_graphic(get(a, "draw:style-name"));
let (w, h, x, y, (flip_h, flip_v)) = crate::odt::shapes::geometry(kind, a);
let mut float = if kind == wordcraft_doc::para::ShapeKind::Line {
let mut float = if kind.is_open() {
// A line is placed by its ends.
let at = |v: Option<f32>| v.map(crate::odt::writer::pt);
crate::odt::frames::float_of(get(a, "text:anchor-type"), at(x).as_deref(), at(y).as_deref(), &g)
@@ -205,11 +288,12 @@ impl Body<'_> {
};
(float.flip_h, float.flip_v) = (flip_h, flip_v);
self.shape_flip = (false, false);
self.member_start(a, w, h, (kind == wordcraft_doc::para::ShapeKind::Line).then_some((x, y)));
self.member_start(a, w, h, kind.is_open().then_some((x, y)));
if let Some(rot) = self.member_rot.take() {
float.rot = rot;
}
self.frame_look = g.shape_look(kind, &self.styles.defs);
self.frame_look.conn = self.conn_of(a).map(Box::new);
self.shape_path = crate::odt::shapes::freeform(local(q), a, w, h).map(std::sync::Arc::new);
self.shape_kind = Some(kind);
let frame = (Some((Some(w), Some(h), None, String::new(), None, None, None)), Some(float), std::mem::take(&mut self.frame_look));
+2 -1
View File
@@ -168,7 +168,7 @@ impl Graphic {
};
// Only a shape that says it grows to its text does: WordCraft's fit shrinks it too.
let (dash, arrows) = self.stroke_extra(line.map_or(HAIRLINE, |l| l.1), defs);
BoxLook { fill, line, dash, arrows, inset: Some(self.padding.map(|p| p.unwrap_or(0.0))), auto_fit: self.grow == Some(true) }
BoxLook { fill, line, dash, arrows, inset: Some(self.padding.map(|p| p.unwrap_or(0.0))), auto_fit: self.grow == Some(true), conn: None }
}
/// The look of a text box in a frame of this style, growing to its text when `auto_fit`.
@@ -181,6 +181,7 @@ impl Graphic {
arrows: (false, false),
inset: Some(self.padding.map(|p| p.unwrap_or(0.0))),
auto_fit,
conn: None,
}
}
}
+30
View File
@@ -502,6 +502,35 @@ fn flow_sections(blocks: &[FBlock], last: &SectionProps) -> Vec<(SectionProps, s
}
/// Write an `.odt` package.
/// The kinds of the shapes with drawing ids in `blocks` (paragraphs, tables, groups), by id.
fn conn_kinds(blocks: &[FBlock]) -> super::HashMap<u32, wordcraft_doc::para::ShapeKind> {
fn walk(blocks: &[FBlock], depth: usize, out: &mut super::HashMap<u32, wordcraft_doc::para::ShapeKind>) {
if depth > model::MAX_DEPTH {
return;
}
for b in blocks {
match b {
FBlock::Para(p) => p.inlines.iter().for_each(|i| inline(i, 0, out)),
FBlock::Table(t) => t.rows.iter().flatten().for_each(|c| walk(&c.blocks, depth + 1, out)),
}
}
}
fn inline(i: &super::Inline, depth: usize, out: &mut super::HashMap<u32, wordcraft_doc::para::ShapeKind>) {
match i {
super::Inline::Shape { kind, look, .. } => {
if let Some(c) = look.conn.as_deref().filter(|c| c.id != 0) {
out.insert(c.id, *kind);
}
}
super::Inline::Group { members, .. } if depth < model::MAX_GROUP_DEPTH => members.iter().for_each(|m| inline(&m.inline, depth + 1, out)),
_ => {}
}
}
let mut out = super::HashMap::new();
walk(blocks, 0, &mut out);
out
}
pub fn export(doc: &Document) -> Result<Vec<u8>, String> {
let flow = model::from_doc_with(
doc,
@@ -510,6 +539,7 @@ pub fn export(doc: &Document) -> Result<Vec<u8>, String> {
let accents = doc.settings.theme_colors.get(4..10).map(<[_]>::to_vec).unwrap_or_default();
let mut w = Writer::default();
w.accents.clone_from(&accents);
w.conn_kinds = conn_kinds(&flow.blocks);
w.forms_allowed = true;
w.ranged = model::ranged_anchors(&flow.blocks);
let mut body = String::new();
+43 -2
View File
@@ -22,6 +22,7 @@ pub(super) fn is_shape(q: &str) -> bool {
| "draw:polyline"
| "draw:path"
| "draw:regular-polygon"
| "draw:connector"
)
}
@@ -30,6 +31,7 @@ pub(super) fn element_kind(name: &str) -> ShapeKind {
match name {
"ellipse" | "circle" => ShapeKind::Ellipse,
"line" => ShapeKind::Line,
"connector" => ShapeKind::ElbowConnector,
"polygon" | "polyline" | "path" | "regular-polygon" => ShapeKind::Freeform,
_ => ShapeKind::Rectangle,
}
@@ -58,12 +60,51 @@ pub(super) fn kind_of(t: &str) -> ShapeKind {
}
}
/// A connector's kind by its `draw:type` (§19.229): `line` straight, `curve` curved, `standard`
/// (the default) and `lines` with bends.
pub(super) fn connector_kind(t: Option<&str>) -> ShapeKind {
match t.map(str::trim) {
Some("line") => ShapeKind::StraightConnector,
Some("curve") => ShapeKind::CurvedConnector,
_ => ShapeKind::ElbowConnector,
}
}
/// A connector kind's `draw:type`.
pub(super) fn connector_type(kind: ShapeKind) -> &'static str {
match kind {
ShapeKind::StraightConnector => "line",
ShapeKind::CurvedConnector => "curve",
_ => "standard",
}
}
/// WordCraft's connection site of a `kind` shape for ODF glue point `g` (§9.2.19: a shape's
/// default glue points are 0 top, 1 right, 2 bottom, 3 left; see
/// [`wordcraft_doc::connector::sites`]). `None` for a glue point of its own (4 on) or a shape
/// without sides.
pub(super) fn site_of_glue(kind: ShapeKind, g: u32) -> Option<u32> {
let sides: [u32; 4] = match kind {
ShapeKind::Ellipse => [0, 6, 4, 2],
ShapeKind::Triangle => [0, 5, 3, 1],
k if k.is_open() || k == ShapeKind::RightTriangle => return None,
_ => [0, 3, 2, 1],
};
sides.get(g as usize).copied()
}
/// The ODF glue point of site `site` of a `kind` shape (the inverse of [`site_of_glue`]).
pub(super) fn glue_of_site(kind: ShapeKind, site: u32) -> Option<u32> {
(0..4).find(|g| site_of_glue(kind, *g) == Some(site))
}
/// How shape `kind` is written: its element, and a custom shape's `draw:type`.
pub(super) fn element_of(kind: ShapeKind) -> (&'static str, Option<&'static str>) {
let custom = |t| ("draw:custom-shape", Some(t));
match kind {
ShapeKind::Ellipse => ("draw:ellipse", None),
ShapeKind::Line | ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => ("draw:line", None),
ShapeKind::Line => ("draw:line", None),
ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => ("draw:connector", None),
ShapeKind::RoundedRectangle => custom("round-rectangle"),
ShapeKind::FlowchartOr => custom("flowchart-or"),
ShapeKind::Triangle => custom("isosceles-triangle"),
@@ -86,7 +127,7 @@ pub(super) fn element_of(kind: ShapeKind) -> (&'static str, Option<&'static str>
/// or upwards is flipped that way (its arrowheads stay at its ends).
pub(super) fn geometry(kind: ShapeKind, a: &Attrs) -> (f32, f32, Option<f32>, Option<f32>, (bool, bool)) {
let len = |k: &str| get(a, k).and_then(length).filter(|v| v.is_finite()).map(|v| v.clamp(-MAX_LEN, MAX_LEN));
if kind == ShapeKind::Line {
if kind.is_open() {
let [x1, y1, x2, y2] = ["svg:x1", "svg:y1", "svg:x2", "svg:y2"].map(|k| len(k).unwrap_or(0.0));
let size = |d: f32| d.abs().min(MAX_LEN);
return (size(x2 - x1), size(y2 - y1), Some(x1.min(x2)), Some(y1.min(y2)), (x2 < x1, y2 < y1));
+1
View File
@@ -5,6 +5,7 @@ mod char_styles;
mod chars;
mod charts;
mod comments;
mod connectors;
mod drop_caps;
mod encryption;
mod equations;
@@ -0,0 +1,77 @@
//! Connectors (ODF 1.3 §10.3.10 `draw:connector`): lines glued to other shapes' glue points
//! (§9.2.19), read as WordCraft connectors glued to the shapes' connection sites, placed between
//! them, and written back glued.
use crate::odt::tests::{NS_T, content_of, package};
use crate::odt::{export, import};
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 rectangle (`id1`) and an ellipse (`id2`) anchored to one paragraph, and a connector of
/// `kind` from the rectangle's bottom (glue point 2) to the ellipse's top (0), its own
/// coordinates in LibreOffice's drawing layer (far from the shapes).
fn doc(kind: &str) -> wordcraft_doc::Document {
let content = format!(
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><text:p><draw:rect text:anchor-type="paragraph" xml:id="id1" draw:id="id1" svg:width="1in" svg:height="0.5in" svg:x="0in" svg:y="0in"/><draw:ellipse text:anchor-type="paragraph" xml:id="id2" draw:id="id2" svg:width="1in" svg:height="0.5in" svg:x="3in" svg:y="1in"/><draw:connector text:anchor-type="paragraph"{kind} svg:x1="2in" svg:y1="4in" svg:x2="5in" svg:y2="5in" draw:start-shape="id1" draw:start-glue-point="2" draw:end-shape="id2" draw:end-glue-point="0"/>Text.</text:p></office:text></office:body></office:document-content>"#
);
import(&package(&content, None)).unwrap()
}
/// The paragraph's shapes: (kind, frame x, y, w, h, flips, drawing id, glued ends).
#[allow(clippy::type_complexity)]
fn shapes(d: &wordcraft_doc::Document) -> Vec<(ShapeKind, [f32; 4], (bool, bool), u32, Option<(u32, u32)>, Option<(u32, u32)>)> {
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, float, extra, .. } => Some((
*kind,
[float.x, float.y, *w, *h].map(|v| (v * 10.0).round() / 10.0),
(float.flip_h, float.flip_v),
extra.id,
extra.start.map(|e| (e.id, e.site)),
extra.end.map(|e| (e.id, e.site)),
)),
_ => None,
})
.collect()
}
#[test]
fn a_connector_is_glued_between_its_shapes() {
let s = shapes(&doc(""));
assert_eq!(s.len(), 3);
assert_eq!((s[0].0, s[0].3), (ShapeKind::Rectangle, 1));
assert_eq!((s[1].0, s[1].3), (ShapeKind::Ellipse, 2));
// From the rectangle's bottom (its site 2) to the ellipse's top (site 0), and placed so:
// (36, 36) to (252, 72).
assert_eq!(s[2], (ShapeKind::ElbowConnector, [36.0, 36.0, 216.0, 36.0], (false, false), 0, Some((1, 2)), Some((2, 0))));
// Its type: straight and curved.
assert_eq!(shapes(&doc(r#" draw:type="line""#))[2].0, ShapeKind::StraightConnector);
assert_eq!(shapes(&doc(r#" draw:type="curve""#))[2].0, ShapeKind::CurvedConnector);
}
#[test]
fn connectors_are_written_glued_and_read_back() {
let d = doc("");
let c = content_of(&export(&d).unwrap());
assert!(c.contains(r#"<draw:rect"#) && c.contains(r#"xml:id="shape1" draw:id="shape1""#), "{c}");
assert!(
c.contains(r#"draw:type="standard" draw:start-shape="shape1" draw:start-glue-point="2" draw:end-shape="shape2" draw:end-glue-point="0""#),
"{c}"
);
assert_eq!(shapes(&import(&export(&d).unwrap()).unwrap()), shapes(&d));
}
#[test]
fn glue_to_nothing_or_a_glue_point_of_its_own_is_dropped() {
let content = format!(
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><text:p><draw:rect text:anchor-type="paragraph" xml:id="a" svg:width="1in" svg:height="1in" svg:x="0in" svg:y="0in"/><draw:connector text:anchor-type="paragraph" svg:x1="0in" svg:y1="0in" svg:x2="1in" svg:y2="1in" draw:start-shape="a" draw:start-glue-point="7" draw:end-shape="missing" draw:end-glue-point="1"/><draw:connector text:anchor-type="paragraph" svg:x1="0in" svg:y1="0in" svg:x2="1in" svg:y2="1in" draw:start-shape="a" draw:start-glue-point="99999999999"/></text:p></office:text></office:body></office:document-content>"#
);
let s = shapes(&import(&package(&content, None)).unwrap());
assert_eq!((s[1].4, s[1].5), (None, None));
assert_eq!(s[2].4, None);
}
+2
View File
@@ -159,6 +159,8 @@ pub(super) struct Writer {
/// kept objects' folders with their media types, for the manifest.
pub(super) object_names: usize,
pub(super) object_dirs: Vec<(String, String)>,
/// The kinds of the shapes with drawing ids, which connectors' glue points depend on.
pub(super) conn_kinds: HashMap<u32, wordcraft_doc::para::ShapeKind>,
/// Ruby was written: its automatic ruby style is.
ruby_used: bool,
/// Form controls written (`form:*` elements for `office:forms`), and whether this writer's
+27 -2
View File
@@ -367,7 +367,7 @@ impl Writer {
e => e,
};
let len = |v: f32, least: f32| if v.is_finite() { v.clamp(least, 1584.0) } else { 72.0 };
let at = if element == "draw:line" {
let at = if element == "draw:line" || element == "draw:connector" {
{
// A line by its ends: from its box's top left to its bottom right, or bottom left
// to top right when it rises.
@@ -405,8 +405,9 @@ impl Writer {
)
})
.unwrap_or_default();
let conn = self.conn_attrs(kind, look);
out.push_str(&format!(
"<{element} draw:name=\"{}Shape{}\" draw:style-name=\"{style}\"{anchor}{at} draw:z-index=\"0\">{body}{geometry}</{element}>",
"<{element} draw:name=\"{}Shape{}\" draw:style-name=\"{style}\"{anchor}{at}{conn} draw:z-index=\"0\">{body}{geometry}</{element}>",
self.prefix, self.boxes,
));
if let Some(m) = reopen {
@@ -414,6 +415,30 @@ impl Writer {
}
}
/// A shape's drawing id as the id connectors name (`xml:id`, `draw:id`), and a connector's
/// type and glued ends (§19.228–19.229), each end's site as the ODF glue point of the shape it
/// is glued to (an end glued to a shape not written, or to a site ODF has no glue point for,
/// is left unglued).
fn conn_attrs(&self, kind: wordcraft_doc::para::ShapeKind, look: &model::BoxLook) -> String {
let mut a = String::new();
if kind.is_connector() {
a.push_str(&format!(" draw:type=\"{}\"", crate::odt::shapes::connector_type(kind)));
}
let Some(c) = look.conn.as_deref() else { return a };
if c.id != 0 {
a.push_str(&format!(" xml:id=\"{0}shape{1}\" draw:id=\"{0}shape{1}\"", self.prefix, c.id));
}
if kind.is_connector() {
for (which, end) in [("start", c.start), ("end", c.end)] {
let glue = end.and_then(|e| self.conn_kinds.get(&e.id).and_then(|k| crate::odt::shapes::glue_of_site(*k, e.site)).map(|g| (e.id, g)));
if let Some((id, g)) = glue {
a.push_str(&format!(" draw:{which}-shape=\"{}shape{id}\" draw:{which}-glue-point=\"{g}\"", self.prefix));
}
}
}
a
}
/// The paragraphs of a text box's or shape's text, written apart from the paragraph being
/// written.
pub(in crate::odt) fn story(&mut self, blocks: &[FBlock]) -> String {
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** 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 and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private Line truncated
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** 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 and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casLine 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 | 🟡 | 🟡 | ~82% | 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 | 🟡 | 🟡 | ~82% | 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: connectors (`draw:connector`) were left out; they open as WordCraft connectors glued to their shapes' connection sites and placed between them, and are written back glued, which LibreOffice re-routes (`odt::tests_more::connectors`) |
| 2026-11-01 | trivial | ODT: a border around text (LibreOffice's `loext:border`) is read as Word's text border and written back, which LibreOffice draws (`odt::tests_more::chars::a_border_around_text_is_read_and_written`) |
| 2026-11-01 | trivial | ODT: index marks (`text:alphabetical-index-mark`, at a point or over text) were dropped, so updating an opened index emptied it; they are read as `XE` fields and written back as marks, and Update Index rebuilds a list held as a field around its entries (ODT's and Word's form) instead of leaving it as it was (`odt::tests_more::indexes::index_marks_are_xe_fields_and_written_back`, `odt_toc.rs::an_odt_index_updates_from_its_marks`) |
| 2026-11-01 | trivial | ODT: ruby (`text:ruby`) was read as its base and ruby text run together and written as plain text; it is read as a Phonetic Guide and written back as ODF ruby, which LibreOffice shows over its base (`odt::tests_more::ruby`) |
+1 -1
View File
@@ -81,7 +81,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc
| 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189; Word's own `Sources.xml` master list imports into Manage Sources, #2640) | | #790 stores sources in `customXml` and reads them back; `crates/zotero`; `references.sources` `import` reads a Word bibliography file (`b:Sources`) | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) |
| 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) |
| 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) |
| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; ODT formatting revisions done; RTF section/table revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT is read and written nearly whole (~82%, see [file-format-parity.md](file-format-parity.md)): sections, headers and footers, page setup, styles (named paragraph and character styles), character and paragraph formatting, lists and heading numbering, tab stops, tables (borders, padding, alignment, width, row heights, header rows), notes, comments, tracked changes, fields (dates, sequences, chapters, references, index marks), bookmarks, tables of contents and indexes, pictures, text boxes, shapes, groups, rotation, strokes and gradients, equations, charts, embedded objects kept whole, form controls, ruby, drop caps, password protection, flat `.fodt`; checked against LibreOffice by round trips compared page by page and the ODF 1.3 schema. Left in ODT: emphasis marks, rotated text, conditional and variable fields as fields, connectors, control sizes, drawing embedded spreadsheets' contents; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259) | | ~2,100 (RTF) and ~11,000 (ODT, plus ~7,000 of tests) lines in `crates/formats`; RTF code pages (#896) and page setup (#902) landed; open PRs: RTF notes and comments (#1056) | Users exchanging with LibreOffice or older tools | 10–16 | [file-format-parity.md](file-format-parity.md) |
| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; ODT formatting revisions done; RTF section/table revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT is read and written nearly whole (~82%, see [file-format-parity.md](file-format-parity.md)): sections, headers and footers, page setup, styles (named paragraph and character styles), character and paragraph formatting, lists and heading numbering, tab stops, tables (borders, padding, alignment, width, row heights, header rows), notes, comments, tracked changes, fields (dates, sequences, chapters, references, index marks), bookmarks, tables of contents and indexes, pictures, text boxes, shapes, groups, rotation, strokes and gradients, equations, charts, embedded objects kept whole, form controls, ruby, drop caps, password protection, flat `.fodt`; checked against LibreOffice by round trips compared page by page and the ODF 1.3 schema. Left in ODT: emphasis marks, rotated text, conditional and variable fields as fields, control sizes, drawing embedded spreadsheets' contents; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259) | | ~2,100 (RTF) and ~11,000 (ODT, plus ~7,000 of tests) lines in `crates/formats`; RTF code pages (#896) and page setup (#902) landed; open PRs: RTF notes and comments (#1056) | Users exchanging with LibreOffice or older tools | 10–16 | [file-format-parity.md](file-format-parity.md) |
| 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) |
| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | DOCX comment saves avoid repeated paragraph-ID and durable-ID reservation searches; large threaded-comment fixtures retain metadata through save/reopen. Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds; opening a document with pictures no longer copies them (92 MB with 20 pictures: about 90 MB less); opening a document with many relationships now costs the size of its part and not that size times the number of relationships (1024 relationships and a 64 MiB part: 224 s → 0.2 s, #3411); a word with no break opportunity in it no longer lays out quadratically (1 MiB: 29.4 s → 0.35 s, #3412); saving a document with many content controls now costs the time the controls take (32,000 controls: 3.6 s → 0.1 s, #13) | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) |
| 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778), finds records and can show only the merged ones (#1576) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) |