mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-06 18:51:26 +00:00
Gap 19: ODT form controls keep their place and lines; custom shapes their geometry
Form controls: - Placement: a control hanging from the baseline (as LibreOffice places those it makes) was saved with no placement. LibreOffice then puts it at the line's top, so every control row shrank. Its raise is now kept (`ContentControl::form_raise`, read as in-line frames are) and written, standing on the baseline when it had none. - Multi-line fields: these were saved without LibreOffice's `MultiLine` property, so they showed one centred line. They now carry it. - Text fields get LibreOffice's flat border. - Also: `pt()` no longer writes "-0pt". Custom shapes: - A shape drawn from its own geometry was saved as WordCraft's path of it, and a preset WordCraft draws as a rectangle (a cloud) was saved as a rectangle. The geometry as read (type, path, equations, handles) is now kept (`ShapeExtra::odf_geometry`) and written back, mirrored as the shape now is. - `shape.change` and the ink-to-shape conversion drop the kept geometry, since the shape is then another kind. `ShapeExtra` holds it as an `Arc<String>` to keep `Placed::Shape` small. - The reader's `end` hands skipped elements to `end_skipped`. Results: - The forms document round-trips pixel-identical (2,949 pixels before), with its text the same. - The shape presets fall from 2,467 to 1,220. The rest is LibreOffice's hairline outline, which WordCraft keeps as 0.5 pt. - Across the corpus the round-trip difference falls from 45k to 41k pixels. 42 of 125 documents round-trip pixel-identical, 92 with their text the same, and none gets worse. gaps.md row 19 and the ROADMAP log are updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
21 files changed
+238
-19
No files matched your search
+3
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #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 (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivialLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #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 (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -140,6 +140,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-11-02: ODT form controls keep their place on the line and multi-line text fields their lines; custom shapes keep their own geometry (LibreOffice draws its own shape after a save). In-line pictures placed against the text are raised from its size, and saved standing on the baseline. The corpus round-trip difference falls to 41k pixels, 90% below this morning's 408k; 42 of 125 documents round-trip pixel-identical (gap 19). Readiness unchanged.
|
||||
- 2026-11-02: ODT charts keep their whole look through a save: the chart object as read is written back while the chart is unchanged (edited, it's written from WordCraft's chart). Frames outlined by a drawn stroke keep it, and a header with no stated height is LibreOffice's 0.5 cm. The corpus round-trip difference falls to 47k pixels (408k this morning); 41 of 125 documents round-trip pixel-identical (gap 19). Readiness unchanged.
|
||||
- 2026-11-02: ODT: saving no longer makes a table's first row repeat on every page (a table style's header row had been taken for Repeat as header row), so LibreOffice's 16 agenda templates round-trip with their text the same; 91 of the 125 corpus documents now do, and the round-trip difference falls to 60k pixels (408k this morning) (gap 19). Readiness unchanged.
|
||||
- 2026-11-02: ODT documents open with LibreOffice's layout where it differs from Word (new `Settings::odf_layout`): a line of only pictures is as tall as they are, and a paragraph's padding without a border takes room. Unequal columns and column breaks are kept. The corpus round-trip difference falls to 82k pixels, 80% below this morning's 408k; LibreOffice's report templates and one-page résumé now round-trip almost pixel-identical (gap 19). Readiness unchanged.
|
||||
@@ -1092,6 +1093,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-23 | trivial | Progress log: "NaN" in the Font dialog's Size box no longer closes the app (#3663) |
|
||||
| 2026-10-23 | trivial | Progress log: a table cell span from a `.json` file no longer overflows (#3637) |
|
||||
| 2026-10-28 | trivial | Progress log: chart and SmartArt text the font lacks is drawn in a fallback face (#4720) |
|
||||
| 2026-11-02 | trivial | Progress log: ODT controls, custom shapes, in-line placement (gap 19) |
|
||||
| 2026-11-02 | trivial | Progress log: ODT charts kept, frame outlines, header height (gap 19) |
|
||||
| 2026-11-02 | trivial | Progress log: ODT header rows (gap 19) |
|
||||
| 2026-11-02 | trivial | Progress log: ODT layout setting, padding, columns (gap 19) |
|
||||
|
||||
@@ -83,6 +83,12 @@ pub struct ShapeExtra {
|
||||
/// main colour (the shape's `stroke`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outline_fill: Option<crate::fill::Fill>,
|
||||
/// An ODF custom shape's geometry as read (its `draw:enhanced-geometry`, equations and
|
||||
/// handles), whose path the shape's freeform was worked out from: saved back as it was, so
|
||||
/// LibreOffice draws its own shape (or a preset WordCraft draws as a rectangle: kept with
|
||||
/// it). Dropped when the shape is made another kind.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub odf_geometry: Option<std::sync::Arc<String>>,
|
||||
}
|
||||
|
||||
fn is_zero(v: &u32) -> bool {
|
||||
|
||||
@@ -258,6 +258,10 @@ pub struct ContentControl {
|
||||
/// `svg:height`), points: kept for saving back as one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub form_size: Option<[f32; 2]>,
|
||||
/// How far that form control stands raised from the baseline (points, negative lowered:
|
||||
/// an ODF control hanging from the baseline is lowered by its height), kept for saving back.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub form_raise: Option<f32>,
|
||||
/// A form control WordCraft has no kind for (an ODF button, image button, label…): its form
|
||||
/// XML, kept verbatim and saved back as it was. It shows its label, locked.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
|
||||
@@ -880,6 +880,7 @@ fn set_shape(s: &mut Session, pos: &Pos, found: &InkShape) -> Result<(), CmdErro
|
||||
};
|
||||
*kind = new_kind;
|
||||
*freeform = geometry;
|
||||
extra.odf_geometry = None;
|
||||
*fill = None;
|
||||
let [_, _, raw_w, raw_h] = found.bounds();
|
||||
(*w, *h) = if matches!(found, InkShape::Arrow { .. }) { (raw_w, raw_h) } else { (bw, bh) };
|
||||
|
||||
@@ -309,8 +309,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("shape.change", "Change Shape", "Shape Format › Insert Shapes", |s, v| {
|
||||
let kind: wordcraft_doc::para::ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Shape { kind: k, .. } = o {
|
||||
if let InlineObject::Shape { kind: k, extra, .. } = o {
|
||||
*k = kind;
|
||||
// A geometry kept from a file is no longer this shape's.
|
||||
extra.odf_geometry = None;
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6847,6 +6847,26 @@ fn password_protected_documents_round_trip() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_a_shape_drops_the_geometry_kept_from_a_file() {
|
||||
// An ODT custom shape keeps its geometry as read, to save back; made another shape, it isn't
|
||||
// that shape any more.
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.shape", json!({"kind": "rectangle"}));
|
||||
let geometry = |s: &Session| match cmd::objects::selected(s) {
|
||||
Some((_, wordcraft_doc::InlineObject::Shape { extra, .. })) => extra.odf_geometry.clone(),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
let Some((pos, _)) = cmd::objects::selected(&s) else { panic!() };
|
||||
let para = s.doc.para_mut(pos.story, &pos.path).unwrap();
|
||||
if let Some(wordcraft_doc::InlineObject::Shape { extra, .. }) = para.object_at_mut(pos.off) {
|
||||
extra.odf_geometry = Some(std::sync::Arc::new(r#"<draw:enhanced-geometry draw:type="cloud"></draw:enhanced-geometry>"#.to_string()));
|
||||
}
|
||||
assert!(geometry(&s).is_some());
|
||||
run(&mut s, "shape.change", json!({"kind": "ellipse"}));
|
||||
assert_eq!(geometry(&s), None);
|
||||
}
|
||||
|
||||
/// Shape Format › Shape Effects (#275): presets and custom values apply to the selected shape,
|
||||
/// an omitted key is kept, hostile numbers are clamped and each change is one undo step.
|
||||
#[test]
|
||||
|
||||
@@ -320,6 +320,8 @@ fn drawing_inline(doc: &Document, o: &InlineObject, opts: FlowOpts, depth: usize
|
||||
inset: part.and_then(|p| p.body.inset.is_some().then(|| p.body.insets_pt())),
|
||||
auto_fit: part.is_some_and(|p| p.body.auto_fit),
|
||||
vert: part.map(|p| p.body.vert).unwrap_or_default(),
|
||||
// Kept only while it is still the freeform it was read as.
|
||||
geometry: extra.odf_geometry.clone().filter(|_| (kind == ShapeKind::Freeform && freeform.is_some()) || kind == ShapeKind::Rectangle),
|
||||
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 })),
|
||||
// A text box is named after its story; a linked one names the box it continues in.
|
||||
|
||||
@@ -432,6 +432,10 @@ impl<'a> Builder<'a> {
|
||||
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),
|
||||
odf_geometry: look.geometry.clone().filter(|_| {
|
||||
(*kind == wordcraft_doc::para::ShapeKind::Freeform && path.is_some())
|
||||
|| *kind == wordcraft_doc::para::ShapeKind::Rectangle
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
@@ -314,6 +314,8 @@ pub struct BoxLook {
|
||||
pub chain: Option<Box<Chain>>,
|
||||
/// Which way a text box's text runs (turned down or up).
|
||||
pub vert: wordcraft_doc::props::TextVert,
|
||||
/// A custom shape's geometry as read (ODT; see `ShapeExtra::odf_geometry`).
|
||||
pub geometry: Option<std::sync::Arc<String>>,
|
||||
}
|
||||
|
||||
/// A text box's name, and the name of the box its text continues in ([`BoxLook::chain`]).
|
||||
@@ -344,6 +346,7 @@ impl Default for BoxLook {
|
||||
conn: None,
|
||||
chain: None,
|
||||
vert: Default::default(),
|
||||
geometry: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,10 @@ pub(super) struct Body<'a> {
|
||||
/// A custom shape's geometry being read, when its type has no WordCraft kind: drawn from its
|
||||
/// own path and equations.
|
||||
geometry: Option<Box<super::geometry::Pending>>,
|
||||
/// That geometry's markup as read (see `ShapeExtra::odf_geometry`): being read, then
|
||||
/// finished for the shape it ends in.
|
||||
geometry_xml: Option<String>,
|
||||
pub(in crate::odt) shape_geometry: Option<std::sync::Arc<String>>,
|
||||
/// The turn of a group's member being read, from its `draw:transform`.
|
||||
member_rot: Option<f32>,
|
||||
/// The depth of the `draw:g` group being read: its shapes are placed by it, not anchored.
|
||||
@@ -219,6 +223,8 @@ impl<'a> Body<'a> {
|
||||
shape_flip: (false, false),
|
||||
shape_path: None,
|
||||
geometry: None,
|
||||
geometry_xml: None,
|
||||
shape_geometry: None,
|
||||
member_rot: None,
|
||||
group_at: None,
|
||||
index: None,
|
||||
@@ -739,6 +745,12 @@ impl Body<'_> {
|
||||
{
|
||||
g.equation(a);
|
||||
}
|
||||
if self.skip == 1
|
||||
&& (q == "draw:equation" || q == "draw:handle")
|
||||
&& let Some(xml) = &mut self.geometry_xml
|
||||
{
|
||||
xml.push_str(&element_xml(q, a, true));
|
||||
}
|
||||
self.skip += 1;
|
||||
return;
|
||||
}
|
||||
@@ -762,6 +774,10 @@ impl Body<'_> {
|
||||
let t = get(a, "draw:type").unwrap_or_default();
|
||||
self.shape_kind = Some(super::shapes::kind_of(t));
|
||||
self.geometry = super::geometry::Pending::of(t, a);
|
||||
// Kept as read, its mirroring aside (written from the shape's own).
|
||||
let kept: Attrs = a.iter().filter(|(k, _)| !k.starts_with("draw:mirror-")).cloned().collect();
|
||||
self.geometry_xml = (self.geometry.is_some() || super::shapes::unknown_preset(t)).then(|| element_xml(q, &kept, false));
|
||||
self.shape_geometry = None;
|
||||
// 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"));
|
||||
@@ -958,6 +974,10 @@ impl Body<'_> {
|
||||
let len = |k: &str| get(a, k).and_then(length).filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(1584.0));
|
||||
if let (Some(w), Some(h)) = (len("svg:width"), len("svg:height")) {
|
||||
control.form_size = Some([w, h]);
|
||||
// Where it stands on the line (§20.387), as an in-line frame does.
|
||||
let g = self.styles.shape_graphic(get(a, "draw:style-name"));
|
||||
let raise = super::frames::place(a, w, h, &g, self.text_size()).y;
|
||||
control.form_raise = (raise.is_finite() && raise != 0.0).then_some(raise);
|
||||
}
|
||||
let fmt = self.cur_fmt();
|
||||
self.push_inline(Inline::Control { control: Box::new(control), text, fmt });
|
||||
@@ -1193,6 +1213,27 @@ impl Body<'_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// A skipped element ends; the end of a custom shape's geometry works out its path and
|
||||
/// keeps the geometry as read.
|
||||
fn end_skipped(&mut self) {
|
||||
self.skip -= 1;
|
||||
if self.skip > 0 {
|
||||
return;
|
||||
}
|
||||
let xml = self.geometry_xml.take();
|
||||
let keep = |x: Option<String>| x.filter(|x| x.len() < MAX_GEOMETRY_XML).map(|x| std::sync::Arc::new(format!("{x}</draw:enhanced-geometry>")));
|
||||
if let Some(g) = self.geometry.take()
|
||||
&& let Some(f) = g.freeform()
|
||||
{
|
||||
self.shape_kind = Some(wordcraft_doc::para::ShapeKind::Freeform);
|
||||
self.shape_path = Some(std::sync::Arc::new(f));
|
||||
self.shape_geometry = keep(xml);
|
||||
} else if self.shape_kind == Some(wordcraft_doc::para::ShapeKind::Rectangle) {
|
||||
// A preset WordCraft draws as a rectangle: its geometry kept as read.
|
||||
self.shape_geometry = keep(xml);
|
||||
}
|
||||
}
|
||||
|
||||
fn end(&mut self, q: &str) {
|
||||
self.depth = self.depth.saturating_sub(1);
|
||||
if self.end_note_or_field(q) {
|
||||
@@ -1215,14 +1256,7 @@ impl Body<'_> {
|
||||
return;
|
||||
}
|
||||
if self.skip > 0 {
|
||||
self.skip -= 1;
|
||||
if self.skip == 0
|
||||
&& let Some(g) = self.geometry.take()
|
||||
&& let Some(f) = g.freeform()
|
||||
{
|
||||
self.shape_kind = Some(wordcraft_doc::para::ShapeKind::Freeform);
|
||||
self.shape_path = Some(std::sync::Arc::new(f));
|
||||
}
|
||||
self.end_skipped();
|
||||
return;
|
||||
}
|
||||
if !self.in_text {
|
||||
@@ -1336,3 +1370,12 @@ impl Body<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The most markup a custom shape's kept geometry may take (see `ShapeExtra::odf_geometry`).
|
||||
const MAX_GEOMETRY_XML: usize = 256 * 1024;
|
||||
|
||||
/// Element `q` with attributes `a` as markup: empty (`<q …/>`) or an opening tag.
|
||||
fn element_xml(q: &str, a: &Attrs, empty: bool) -> String {
|
||||
let attrs: String = a.iter().map(|(k, v)| format!(" {k}=\"{}\"", super::writer::x(v))).collect();
|
||||
format!("<{q}{attrs}{}>", if empty { "/" } else { "" })
|
||||
}
|
||||
@@ -104,9 +104,14 @@ impl Body<'_> {
|
||||
float.filter(|f| f.wrap != wordcraft_doc::para::Wrap::Inline || crate::odt::frames::turned(f) || f.flip_h || f.flip_v || f.y != 0.0);
|
||||
let inline = match (boxed, eq) {
|
||||
(Some(blocks), _) => {
|
||||
let look = std::mem::take(&mut self.frame_look);
|
||||
let mut look = std::mem::take(&mut self.frame_look);
|
||||
// Without a height it grows from nothing to its text.
|
||||
let kind = self.shape_kind.take().unwrap_or(wordcraft_doc::para::ShapeKind::TextBox);
|
||||
// A custom shape's geometry as read, kept with the path worked out from it.
|
||||
let geometry = self.shape_geometry.take();
|
||||
if kind == wordcraft_doc::para::ShapeKind::Freeform || kind == wordcraft_doc::para::ShapeKind::Rectangle {
|
||||
look.geometry = geometry;
|
||||
}
|
||||
// A shape whose paragraphs are all empty (LibreOffice writes `<text:p/>` in
|
||||
// each) holds no text.
|
||||
let empty = blocks.iter().all(|b| matches!(b, FBlock::Para(p) if p.inlines.is_empty()));
|
||||
|
||||
@@ -301,7 +301,18 @@ pub(in crate::odt) fn form_xml(c: &ContentControl, text: &str, id: &str, n: usiz
|
||||
(format!("<form:text{common}{}{} form:current-value=\"{}\"/>", impl_("TextField"), label, x(&v)), width(&v), 16.0)
|
||||
}
|
||||
ControlKind::Text { multi_line: true } => {
|
||||
(format!("<form:textarea{common}{}{} form:current-value=\"{}\"/>", impl_("TextField"), label, x(&v)), 216.0, 48.0)
|
||||
// Several lines (LibreOffice's `MultiLine` property): without it LibreOffice shows
|
||||
// one line, centred in the box.
|
||||
(
|
||||
format!(
|
||||
"<form:textarea{common}{}{} form:current-value=\"{}\"><form:properties><form:property form:property-name=\"MultiLine\" office:value-type=\"boolean\" office:boolean-value=\"true\"/></form:properties></form:textarea>",
|
||||
impl_("TextField"),
|
||||
label,
|
||||
x(&v)
|
||||
),
|
||||
216.0,
|
||||
48.0,
|
||||
)
|
||||
}
|
||||
ControlKind::CheckBox { checked, checked_char, .. } if *checked_char == RADIO_ON => (
|
||||
format!("<form:radio{common}{}{label} form:current-selected=\"{checked}\" form:selected=\"{checked}\"/>", impl_("RadioButton")),
|
||||
|
||||
@@ -264,6 +264,7 @@ impl Graphic {
|
||||
conn: None,
|
||||
chain: None,
|
||||
vert: Default::default(),
|
||||
geometry: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +292,7 @@ impl Graphic {
|
||||
conn: None,
|
||||
chain: None,
|
||||
vert: self.vert.unwrap_or_default(),
|
||||
geometry: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,14 @@ pub(super) fn element_kind(name: &str) -> ShapeKind {
|
||||
/// The kind of a custom shape of `draw:type` `t`: LibreOffice's own type names, and the
|
||||
/// DrawingML preset names it writes as `ooxml-…` for shapes from Word documents. A geometry
|
||||
/// WordCraft has no shape for is a rectangle.
|
||||
/// A preset geometry type WordCraft has no kind for: drawn as a rectangle (see [`kind_of`]), its
|
||||
/// geometry kept to be written back.
|
||||
pub(super) fn unknown_preset(t: &str) -> bool {
|
||||
let t = t.trim();
|
||||
kind_of(t) == ShapeKind::Rectangle
|
||||
&& !matches!(t.strip_prefix("ooxml-").unwrap_or(t), "" | "rectangle" | "rect" | "flowchart-process" | "flowChartProcess" | "non-primitive")
|
||||
}
|
||||
|
||||
pub(super) fn kind_of(t: &str) -> ShapeKind {
|
||||
let t = t.trim();
|
||||
match t.strip_prefix("ooxml-").unwrap_or(t) {
|
||||
|
||||
@@ -160,7 +160,17 @@ fn hostile_geometry_never_panics_and_draws_what_it_can() {
|
||||
fn a_freeform_with_open_and_closed_paths_is_saved_as_a_custom_shape() {
|
||||
// A face and a mouth: the face filled, the mouth not. A path with an open part would be
|
||||
// drawn unfilled whole, so it is saved as a custom shape whose path marks the mouth unfilled.
|
||||
let d = open(&shape(r#"draw:type="smiley" draw:enhanced-path="M 0 0 L 21600 0 21600 21600 Z N M 5000 15000 L 16000 15000 F N""#, ""));
|
||||
let mut d = open(&shape(r#"draw:type="smiley" draw:enhanced-path="M 0 0 L 21600 0 21600 21600 Z N M 5000 15000 L 16000 15000 F N""#, ""));
|
||||
// As WordCraft's own freeform (the geometry read is written back whole otherwise).
|
||||
for b in d.body.iter_mut() {
|
||||
if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) {
|
||||
for o in &mut p.objects {
|
||||
if let InlineObject::Shape { extra, .. } = o {
|
||||
extra.odf_geometry = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let c = crate::odt::tests::content_of(&export(&d).unwrap());
|
||||
assert!(c.contains(r#"draw:type="non-primitive""#) && c.contains(" F N"), "{c}");
|
||||
assert!(!c.contains("<draw:path"), "{c}");
|
||||
@@ -191,3 +201,40 @@ fn points_far_outside_the_view_box_stay_finite() {
|
||||
let Some(InlineObject::Shape { freeform: Some(f), .. }) = d.body[0].as_para().unwrap().objects.first() else { panic!("a freeform") };
|
||||
assert!(f.paths.iter().flat_map(|p| p.pts.iter()).all(|[x, y]| x.is_finite() && y.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_shapes_geometry_is_written_back_as_it_was_read() {
|
||||
// LibreOffice's shape presets: saved as their own geometry (type, path, equations, handles),
|
||||
// so LibreOffice draws its own shape, not WordCraft's path of it; a preset WordCraft draws as
|
||||
// a rectangle (a cloud) keeps its geometry too. A shape made another kind loses it.
|
||||
let hexagon = shape(
|
||||
r#"svg:viewBox="0 0 21600 21600" draw:type="hexagon" draw:modifiers="5400" draw:mirror-horizontal="true" draw:enhanced-path="M ?f0 0 L ?f1 0 21600 10800 ?f1 21600 ?f0 21600 0 10800 Z N""#,
|
||||
r#"<draw:equation draw:name="f0" draw:formula="$0 "/><draw:equation draw:name="f1" draw:formula="right-$0"/><draw:handle draw:handle-position="$0 top"/>"#,
|
||||
);
|
||||
let cloud = r#"<draw:custom-shape text:anchor-type="paragraph" svg:width="1in" svg:height="1in"><draw:enhanced-geometry draw:type="cloud"/></draw:custom-shape>"#;
|
||||
let d = open(&format!("{hexagon}{cloud}"));
|
||||
assert_eq!(drawn(&d).iter().map(|s| s.0).collect::<Vec<_>>(), [ShapeKind::Freeform, ShapeKind::Rectangle]);
|
||||
let out = crate::odt::tests::content_of(&export(&d).unwrap());
|
||||
assert!(out.contains(r#"draw:type="hexagon" draw:modifiers="5400""#) && out.contains(r#"draw:formula="right-$0""#), "{out}");
|
||||
assert!(out.contains(r#"<draw:handle draw:handle-position="$0 top"/>"#), "{out}");
|
||||
// Its mirroring is the shape's own, written once.
|
||||
assert_eq!(out.matches("draw:mirror-horizontal=\"true\"").count(), 1, "{out}");
|
||||
assert!(out.contains(r#"<draw:enhanced-geometry draw:type="cloud"></draw:enhanced-geometry>"#), "{out}");
|
||||
// Read back, it is the same shape again.
|
||||
let back = import(&export(&d).unwrap()).unwrap();
|
||||
assert_eq!(drawn(&back), drawn(&d));
|
||||
// Made an ellipse: written as one.
|
||||
let mut changed = d.clone();
|
||||
for b in changed.body.iter_mut() {
|
||||
if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) {
|
||||
for o in &mut p.objects {
|
||||
if let InlineObject::Shape { kind, extra, .. } = o {
|
||||
*kind = ShapeKind::Ellipse;
|
||||
extra.odf_geometry = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = crate::odt::tests::content_of(&export(&changed).unwrap());
|
||||
assert!(!out.contains("hexagon") && !out.contains("cloud"), "{out}");
|
||||
}
|
||||
@@ -239,7 +239,7 @@ fn an_inline_frame_keeps_its_place_on_the_baseline() {
|
||||
let saved = crate::odt::tests::content_of(&crate::odt::export(&doc).unwrap());
|
||||
if want != 0.0 {
|
||||
assert!(saved.contains(r#"style:vertical-pos="from-top" style:vertical-rel="baseline""#), "{saved}");
|
||||
let top = -(72.0 + want);
|
||||
let top = -(72.0 + want) + 0.0;
|
||||
assert!(saved.contains(&format!(r#"svg:y="{}pt""#, top)), "{vpos}{y}: {saved}");
|
||||
}
|
||||
let again = crate::odt::import(&crate::odt::export(&doc).unwrap()).unwrap();
|
||||
|
||||
@@ -196,3 +196,28 @@ fn hidden_controls_are_kept() {
|
||||
let c = entry(&export(&import(&package(&only, None)).unwrap()).unwrap(), "content.xml");
|
||||
assert!(c.contains("<office:forms ") && c.contains("<form:hidden "), "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_keep_their_place_on_the_line_and_a_text_area_its_lines() {
|
||||
// LibreOffice hangs controls it makes from the baseline (`from-top`, `svg:y` 0); one saved
|
||||
// without a placement stands at the line's top. A text area is several lines only with its
|
||||
// `MultiLine` property.
|
||||
let content = doc_xml()
|
||||
.replace(r#"<office:body>"#, r#"<office:automatic-styles><style:style style:name="gr1" style:family="graphic"><style:graphic-properties style:vertical-pos="from-top"/></style:style></office:automatic-styles><office:body>"#)
|
||||
.replace(r#"<draw:control text:anchor-type="as-char""#, r#"<draw:control draw:style-name="gr1" text:anchor-type="as-char" svg:y="0in""#);
|
||||
let doc = import(&package(&content, None)).unwrap();
|
||||
let raises: Vec<Option<f32>> = doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| if let wordcraft_doc::para::InlineObject::ControlStart { control } = o { Some(control.form_raise) } else { None })
|
||||
.collect();
|
||||
assert!(!raises.is_empty() && raises.iter().all(|r| r.is_some_and(|v| (v + 14.4).abs() < 0.01)), "{raises:?}");
|
||||
let out = entry(&export(&doc).unwrap(), "content.xml");
|
||||
assert!(out.contains(r#"style:vertical-pos="from-top" style:vertical-rel="baseline""#) && out.contains(r#"svg:y="0pt""#), "{out}");
|
||||
assert!(out.contains(r#"form:property-name="MultiLine" office:value-type="boolean" office:boolean-value="true""#), "{out}");
|
||||
// Saved without a raise, a control still says it stands on the baseline.
|
||||
let plain = entry(&export(&import(&package(&doc_xml(), None)).unwrap()).unwrap(), "content.xml");
|
||||
assert!(plain.contains(r#"style:vertical-pos="top" style:vertical-rel="baseline""#), "{plain}");
|
||||
}
|
||||
@@ -98,7 +98,9 @@ fn starts_column(p: &Para) -> bool {
|
||||
|
||||
pub(super) fn pt(v: f32) -> String {
|
||||
let s = format!("{:.2}", v);
|
||||
format!("{}pt", s.trim_end_matches('0').trim_end_matches('.'))
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
// A value rounding to nothing is 0, not "-0".
|
||||
format!("{}pt", if s == "-0" { "0" } else { s })
|
||||
}
|
||||
|
||||
/// A section that starts a page: the master page it uses and where page numbering restarts.
|
||||
@@ -512,9 +514,18 @@ impl Writer {
|
||||
let id = format!("control{}", self.forms.len() + 1);
|
||||
match super::forms::form_xml(control, text, &id, self.forms.len() + 1).filter(|_| self.forms_allowed) {
|
||||
Some((xml, w, h)) => {
|
||||
// Text boxes with a flat border (`fo:border="solid"`, LibreOffice's for
|
||||
// its own text fields), not its sunken 3D one.
|
||||
let flat = if xml.starts_with("<form:text ") || xml.starts_with("<form:textarea") { " fo:border=\"solid\"" } else { "" };
|
||||
self.forms.push(xml);
|
||||
// Placed by a style, standing on the baseline or raised as it was read
|
||||
// (LibreOffice puts a control with no placement at the line's top).
|
||||
let raise = control.form_raise.filter(|r| r.is_finite()).map(|r| r.clamp(-1584.0, 1584.0)).unwrap_or(0.0);
|
||||
let float = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Inline, y: raise, ..Default::default() };
|
||||
let (_, props, xy) = drawing::placement(drawing::Place::Text(Some(&float)), w, h);
|
||||
let style = self.graphic_style(format!("{props}{flat}"));
|
||||
out.push_str(&format!(
|
||||
"<draw:control text:anchor-type=\"as-char\" draw:z-index=\"0\" svg:width=\"{}\" svg:height=\"{}\" draw:control=\"{id}\"/>",
|
||||
"<draw:control draw:style-name=\"{style}\" text:anchor-type=\"as-char\"{xy} draw:z-index=\"0\" svg:width=\"{}\" svg:height=\"{}\" draw:control=\"{id}\"/>",
|
||||
pt(w),
|
||||
pt(h)
|
||||
));
|
||||
|
||||
@@ -420,7 +420,13 @@ impl Writer {
|
||||
let style = self.graphic_style(format!("{props}{look_attrs}"));
|
||||
let body = self.story(blocks);
|
||||
self.boxes += 1;
|
||||
// A custom shape's geometry as read: written back whole, LibreOffice's own shape.
|
||||
let kept = look
|
||||
.geometry
|
||||
.as_deref()
|
||||
.filter(|_| (kind == wordcraft_doc::para::ShapeKind::Freeform && path.is_some()) || kind == wordcraft_doc::para::ShapeKind::Rectangle);
|
||||
let (element, geometry) = match crate::odt::shapes::element_of(kind) {
|
||||
_ if kept.is_some() => ("draw:custom-shape", None),
|
||||
// A freeform without paths has nothing to draw but its frame.
|
||||
("draw:path", _) if path.is_none() => ("draw:rect", None),
|
||||
// Paths filled and unfilled: a custom shape, whose path says which is which
|
||||
@@ -463,7 +469,22 @@ impl Writer {
|
||||
// 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
|
||||
let geometry = match kept {
|
||||
// Its own mirroring, now, in the kept opening tag.
|
||||
Some(g) => match g.find('>') {
|
||||
Some(i) if g.get(..i).is_some_and(|h| !h.ends_with('/')) => {
|
||||
format!(
|
||||
"{}{}{}{}",
|
||||
g.get(..i).unwrap_or_default(),
|
||||
mirror(fh, "horizontal"),
|
||||
mirror(fv, "vertical"),
|
||||
g.get(i..).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
_ => g.to_string(),
|
||||
},
|
||||
None => String::new(),
|
||||
} + &geometry
|
||||
.map(|t| {
|
||||
if custom_path.is_empty() {
|
||||
custom_path = " svg:viewBox=\"0 0 21600 21600\"".into();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previouslyLine truncated
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, paddiLine 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;
|
||||
@@ -280,6 +280,7 @@ Custom document properties retain their bookmark link targets through DOCX saves
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | trivial | ODT form controls: one hanging from the baseline (as LibreOffice places those it makes) was saved with no placement, which LibreOffice puts at the line's top; its place on the line is kept (`ContentControl::form_raise`) and written, standing on the baseline when it had none. A multi-line text field was saved without LibreOffice's `MultiLine` property, so it showed one centred line; it says so, and text fields have LibreOffice's flat border. ODT custom shapes: a shape drawn from its own geometry was saved as WordCraft's path of it, and a preset WordCraft draws as a rectangle (a cloud) as a rectangle; the geometry as read (type, path, equations, handles) is kept (`ShapeExtra::odf_geometry`) and written back, mirrored as the shape is now, until the shape is made another kind. The forms document round-trips pixel-identical (from 2,949), the shape presets to 1,220 (from 2,467; what's left is LibreOffice's hairline outline, which WordCraft keeps as 0.5 pt) (`forms::controls_keep_their_place_on_the_line_and_a_text_area_its_lines`, `custom_geometry::a_custom_shapes_geometry_is_written_back_as_it_was_read`, `engine::tests::changing_a_shape_drops_the_geometry_kept_from_a_file`) |
|
||||
| 2026-11-02 | trivial | ODT, found with LibreOffice's style templates: an in-line frame placed against the text (`style:vertical-rel="text"` or `"char"`: `middle`, `top`, `bottom`) stood on the baseline; it's raised or lowered against the text's height from its size (measured in LibreOffice: `middle` centres it on the text). An in-line picture or chart was saved with no placement, which LibreOffice puts at the line's top (measured); it's saved standing on the baseline, as WordCraft and Word show it. Two templates round-trip with a third to a half of their difference (`floats::an_inline_frame_placed_against_the_text_is_raised_from_its_size`, `floats::an_inline_picture_is_saved_standing_on_the_baseline`). Left in them: template instantiation (LibreOffice refreshes a template's fixed dates and fills its sender fields from the user's data when opening it), a first-page footer's frame placed against the page content, and a page background picture centred in the margins rather than on the page |
|
||||
| 2026-11-02 | trivial | ODT: a chart lost the look WordCraft doesn't model (its fonts and sizes, axis and grid lines) on save, written anew from WordCraft's chart. The chart object as read is kept (through drawing it again when it opens or is resized), and written back whole while the chart reads the same; an edited chart is written from WordCraft's chart as before. The chart documents round-trip pixel-identical (`charts::an_unchanged_chart_is_written_back_as_it_was_and_an_edited_one_anew`, `engine::tests::redrawing_a_chart_keeps_the_chart_as_read`). Found with LibreOffice's serif business letter: a text frame outlined by its drawn stroke (`draw:stroke`, no `fo:border`) lost its outline; it's the frame's line. A header or footer whose style states no height (or that has none) was 0 tall; it's LibreOffice's 0.5 cm (measured), so the text no longer moves up. The letter round-trips to 116 differing pixels (from 4,223) (`text_boxes::a_frames_drawn_stroke_is_its_line_when_it_has_no_border`, `headers::a_header_without_a_stated_height_is_half_a_centimetre`) |
|
||||
| 2026-11-02 | trivial | ODT, found with LibreOffice's agendas: saving wrote every table's first row as a header row (`table:table-header-rows`), repeated at the top of each page, because a table style's header row (on by default in Word's table look) was taken for Repeat as header row; a table crossing a page showed its first row twice. Only rows set to repeat are written so. The agendas round-trip to about 145 differing pixels (from 5,269 and 5,086), their text the same (`tables::only_rows_set_to_repeat_are_written_as_header_rows`) |
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** trivial (#19: ODT in-line frames placed against the text; template instantiation listed as left); previously trivial (#19: ODT charts kept as read; frame outlines; default header height); previously trivial (#19: ODT tables no longer gain repeated header rows); previously trivial (#19: ODT padding without a border, unequal columns, column breaks, picture-only lines); previously trivial (#19: ODT tables' margins and spacing, header spacing, pictures alone in a paragraph); previously trivial (#19: no LibreOffice template changes page count through a save); previously trivial (#19: ODT variables, emphasis marks, page backgrounds, fixed tables, default size); previously trivial (#19: ODT checked against LibreOffice's own templates: placeholders, first-page styles, unsized pages, metafile logos, `.ott`); previously trivial (#19: ODT at ~90%: custom shapes, cell formulas, macros kept, turned text, control sizes and buttons); previously trivial (#19 narrowed: ODT read and written nearly whole; estimate 18–27 h → 10–16 h); previously trivial (#10 narrowed: text in a turned text box is drawn turned with it); previously trivial (gap 19 narrowed: ODT heading numbering read and written); previously trivial (gap 19 narrowed: ODT table borders and padding read and written); previously trivial (gap 19 narrowed: ODT heading style looks read and written); previously trivial (gap 19 narrowed: ODT tab stops read and written); previously trivial (gap 19 narrowed: ODT character formatting read and written); previously trivial (gap 19 narrowed: ODT list formats read and written); previously trivial (gap 19 narrowed: ODT comments read and written); previously trivial (gap 19 narrowed: ODT footnotes and endnotes read and written); previously trivial (gap 19 narrowed: ODT headers and footers written, text fields and paragraph spacing read and written); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #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 (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its contenLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** trivial (#19: ODT form controls placed and multi-line; custom shapes keep their geometry); previously trivial (#19: ODT in-line frames placed against the text; template instantiation listed as left); previously trivial (#19: ODT charts kept as read; frame outlines; default header height); previously trivial (#19: ODT tables no longer gain repeated header rows); previously trivial (#19: ODT padding without a border, unequal columns, column breaks, picture-only lines); previously trivial (#19: ODT tables' margins and spacing, header spacing, pictures alone in a paragraph); previously trivial (#19: no LibreOffice template changes page count through a save); previously trivial (#19: ODT variables, emphasis marks, page backgrounds, fixed tables, default size); previously trivial (#19: ODT checked against LibreOffice's own templates: placeholders, first-page styles, unsized pages, metafile logos, `.ott`); previously trivial (#19: ODT at ~90%: custom shapes, cell formulas, macros kept, turned text, control sizes and buttons); previously trivial (#19 narrowed: ODT read and written nearly whole; estimate 18–27 h → 10–16 h); previously trivial (#10 narrowed: text in a turned text box is drawn turned with it); previously trivial (gap 19 narrowed: ODT heading numbering read and written); previously trivial (gap 19 narrowed: ODT table borders and padding read and written); previously trivial (gap 19 narrowed: ODT heading style looks read and written); previously trivial (gap 19 narrowed: ODT tab stops read and written); previously trivial (gap 19 narrowed: ODT character formatting read and written); previously trivial (gap 19 narrowed: ODT list formats read and written); previously trivial (gap 19 narrowed: ODT comments read and written); previously trivial (gap 19 narrowed: ODT footnotes and endnotes read and written); previously trivial (gap 19 narrowed: ODT headers and footers written, text fields and paragraph spacing read and written); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #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 (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formLine truncated
|
||||
> **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged.
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no lonLine truncated
|
||||
|
||||
@@ -370,6 +370,7 @@ Recipient label generation (#4880) now includes all selected recipients across a
|
||||
| 2026-10-12 | trivial | #811: large-table scrolling fixed; performance gap (#20) narrowed |
|
||||
| 2026-10-12 | trivial | #13: radar, stock and other undrawn chart types show a placeholder instead of blank space (#998) |
|
||||
| 2026-10-19 | trivial | Personal templates in File › New and Save as Word Template (#2852); no gap entry tracked it |
|
||||
| 2026-11-02 | trivial | #19 narrowed: ODT form controls keep their place on the line, and multi-line text fields their lines; custom shapes keep their own geometry. The corpus round-trip difference falls from 45k to 41k pixels; 42 of 125 documents round-trip pixel-identical, 92 with their text the same. Left: LibreOffice's hairline outline (kept as 0.5 pt) |
|
||||
| 2026-11-02 | trivial | #19 narrowed: ODT in-line frames placed against the text, and in-line pictures saved standing on the baseline. Left from LibreOffice's style templates: template instantiation (fixed dates refreshed and sender fields filled from the user's data when LibreOffice opens a template), a first-page footer's frame against the page content, a page background picture centred in the margins |
|
||||
| 2026-11-02 | trivial | #19 narrowed: ODT charts keep their look on save (the object as read, while unchanged); a frame's drawn outline is kept; a header with no stated height is LibreOffice's 0.5 cm. The corpus round-trip difference falls from 60k to 47k pixels; 41 of 125 documents round-trip pixel-identical |
|
||||
| 2026-11-02 | trivial | #19 narrowed: ODT tables no longer gain a repeated header row on save; all 16 LibreOffice agenda templates now round-trip with their text the same (91 of 125 corpus documents). The corpus round-trip difference falls from 82k to 60k pixels |
|
||||
|
||||
Reference in new issue
Block a user