Commit Graph
2534 Commits
Author SHA1 Message Date
Claude adfcbcbf7c Gap 19: ODT pictures alone in a paragraph keep its size and their raise
Found with LibreOffice's elegant agenda:
- Paragraph size: a paragraph holding only pictures lost its own font
  size, since the flow model's picture runs carry no formatting, so its
  line grew. Such a paragraph (`Para::textless`) now keeps its formatting
  as the paragraph mark's, which its pictures' runs take too. Read and
  written as for empty paragraphs.
- Raise: an in-line frame's raise was dropped when a parent style gave
  it a relation no in-line frame has (the Graphics style's
  `style:vertical-rel="paragraph"`). It is now measured from the baseline,
  as LibreOffice reads it.

The agenda's round-trip difference falls from 6,542 to 1,331 pixels, and
two business fax templates' from about 5,300 to 270 and 350. Across the
corpus it falls from 185k to 155k. No document changes page count.
WordCraft's own rendering of a picture-only line is still taller than
LibreOffice's, a layout difference left for later.

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
2026-11-02 14:07:54 +00:00
Claude 8f65b867c0 Gap 19: ODT tables fit the margins and keep their spacing; header spacing kept
Found with LibreOffice's report templates.

Tables:
- A table filling the margins (`table:align="margins"`) but wider than
  them ran off the page. It is now fitted to the text width, its columns
  in proportion. Checked in LibreOffice, which fits such a table with or
  without relative column widths; the old test's claim otherwise is
  corrected.
- A table's own space above and below (`fo:margin-top` and
  `fo:margin-bottom` on the table) was dropped. It is now read into new
  `TableProps::space_before`/`space_after`, laid out (added to the
  spacing of the paragraphs around it, as LibreOffice does) and written
  back.

Headers and footers:
- A header taller than its band lost its spacing from the body, moving
  the text up. LibreOffice keeps the spacing below the content unless it
  is dynamic (`style:dynamic-spacing`, which behaves as Word does). New
  `SectionProps::header_gap`/`footer_gap` hold it. The layout keeps it
  past a tall header or footer, and the writer writes it back. Word keeps
  no gap, so DOCX documents are unchanged.

Across the 125-document corpus, the round-trip pixel difference falls
from 241k to 185k, and WordCraft's own rendering's difference from
LibreOffice's falls from 562k to 484k. No document changes page count or
gets worse; one report template falls from 7,646 to 602 pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 13:38:36 +00:00
Claude 2f08e8bb01 Gap 19: ODT frames take their anchor and place from their style
A frame that doesn't state its anchor or position now takes them from
its graphic style chain (ODF lets `style:graphic-properties` carry
`text:anchor-type`, `svg:x` and `svg:y`).

LibreOffice's business card template relies on this:
- its Labels style holds the card in line;
- its Graphics style puts the credit at the top of the page.

WordCraft had floated the card, so a save scrambled the layout and added
a page.

Across the 125-document corpus, no document now changes page count
through a save (3 did), and the round-trip pixel difference falls from
333k to 241k. 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
2026-11-02 12:51:36 +00:00
Claude 3164c33c28 Gap 19: ODT letters keep one page: default size, page frames, box styles, empty lines
Saving LibreOffice's letter and fax templates added a page and moved
their logos. Fixes:
- Default font size: the writer always wrote the default style's size as
  12 pt. It now writes Normal's size (11 pt in these letters).
- Page-anchored frames: a frame placed "from the paragraph" was moved by
  the page margin. A page has no paragraph, so it is now placed from the
  page, and "from the paragraph's content" from the page's text area, as
  LibreOffice places it.
- Text box styles: a custom style used only by paragraphs in text boxes
  (the fax's centred "Frame contents") was dropped. The style pass now
  walks text boxes and groups.
- Empty paragraphs: an empty paragraph's own font size (its height) was
  dropped. It is now kept as the paragraph mark's formatting, a new
  field in the shared flow model that the ODT reader and writer use.

The marine, fax and bottle letters keep one page after a save. Over the
125-document corpus the round-trip pixel difference falls from 408k to
333k, and the business letters' differences roughly halve.

Also moved a doc comment that 48c60a5 left on the wrong function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 12:05:17 +00:00
Claude 6a62c6142f Gap 19: docs: variables, emphasis marks, page backgrounds, tables, default size
gaps.md row 19 now lists what's left in ODT:
- turned characters;
- conditional text;
- control looks;
- embedded spreadsheets' contents;
- dates in non-US locales.
The ROADMAP progress log has the entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 11:09:38 +00:00
Claude 48c60a51b1 Gap 19: ODT default font size; dates in the locale's order
- The document's default font size (`fo:font-size` in
  `style:default-style`, or the Standard style's: the document's
  defaults) was ignored, leaving 12 pt; LibreOffice's letters are
  10 pt. It is now the document's default. A named style with no parent
  keeps the default style's size, as ODF inherits it.
- A date style ordered by the locale (`number:automatic-order`) showed
  its parts as listed: "2026November2". It now shows in US English
  order: "November 2, 2026", or "11/2/2026" with a numeric month, as
  LibreOffice shows it here.

WordCraft's own rendering of LibreOffice's letter templates comes much
closer: one letter goes from 11,377 to 6,237 differing pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 11:09:20 +00:00
Claude 7be387dabc Gap 19: ODT page background pictures
A page layout's background picture (`style:background-image`, §17.3:
LibreOffice's report templates' world map) was dropped. It is now
WordCraft's page picture (Design › Page Color › Fill Effects) and is
written back with its picture:
- repeated: tiled;
- stretched: stretched;
- not repeated: centred at its own size, a new fill that the screen
  renderer and PDF export draw in the middle of the page. DOCX writes
  it stretched, as VML has no centred fill.

LibreOffice's report template with the map goes from 11,719 to 3,602
differing pixels, and shows the map after a save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 10:36:41 +00:00
Claude 5cdf2608bf Gap 19: ODT tables fixed layout and starting a page; a page break before a table's first row moves it
- A table starting a page (`fo:break-before="page"` on the table,
  LibreOffice's agenda minutes) didn't. Its first paragraph now has a
  page break before it, and it is written back on the table.
- Layout: a page break before the paragraph in a table's first row
  now moves the whole table to the next page, as Word does. It was
  ignored; this matters for Word documents too.
- ODT tables are now fixed layout (Word's `w:tblLayout`).
  LibreOffice's columns never fit their content.

LibreOffice's 6orange agenda now round-trips with the same text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 10:08:59 +00:00
Claude 078945e10c Emphasis marks: modelled, drawn, set, and kept in DOCX and ODT
Word's Font › Emphasis mark (a mark over or under each character, used
to stress East Asian text) wasn't modelled. DOCX `w:em` and ODT
`style:text-emphasize` were dropped.
- Model: `CharProps::emphasis` (dot, comma, circle, dot below, or none
  to turn off a style's).
- Layout: a mark is drawn over (or under) each character, and the line
  is made taller to fit it.
- `format.emphasisMark {"mark": …}` sets it, and Format state reports
  it. The catalog lists it under Home › Font.
- DOCX: `w:em` is read, and written in its place in `w:rPr` (after
  `w:cs`, before `w:lang`).
- ODT: `style:text-emphasize` maps to it: an accent is the comma, a
  disc the dot, a dot below the under-dot.

Also fixes two problems from the previous commit (ODT variables):
- Field updates blanked a SET field's stored result. The layout
  already draws nothing for SET, so its result stays as it was, as
  `ask_and_set_fields_draw_nothing` pins.
- A clippy lint (manual char comparison) failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 09:46:23 +00:00
Claude 56188b97f4 Gap 19: ODT variables as SET/REF fields; SET evaluated; character styles keep what they turn off
- ODT variables (`text:variable-set`, `text:variable-get`, §7.4) were
  read as their text. A set is now Word's SET field, followed by a REF
  when it is displayed; a get is a REF. They are written back declared
  (`text:variable-decls`), the sets hidden with a get after them.
- The engine now evaluates SET (ECMA-376 §17.16.5.58). It shows
  nothing and gives its name a value. REF to a name no bookmark has
  shows that value; a REF before the SET shows the last value set, as
  Word's bookmark does after an update. This works for Word documents
  too.
- Custom character styles now keep what they turn off under a parent
  that turns it on (`fo:font-weight="normal"`…), as paragraph styles
  already do.

A LibreOffice document with variables round-trips with the same text,
and the saved file validates against the schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 08:37:37 +00:00
Claude bc5d9f9fa5 Gap 19: docs: ODT checked against LibreOffice's own templates
gaps.md row 19 now lists what's left in ODT after the template work:
- emphasis marks and turned characters, which have no WordCraft model;
- variable fields;
- control looks;
- embedded spreadsheets' contents;
- tables wider than the margins;
- what a character style turns off.
The ROADMAP progress log has the entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 08:02:33 +00:00
Claude e2b439ed17 Gap 19: ODT metafile pictures, pictures in text boxes, styles that turn things off
More from LibreOffice's bundled templates:
- WMF and EMF pictures (LibreOffice's fax and letter logos) were
  dropped. They now open as pictures, at their own size (a placeable
  WMF's or an EMF's frame) when the frame gives none, and are saved
  with their media types.
- A floating picture inside a text box, saved by WordCraft, didn't
  show in LibreOffice. Picture frames' styles now derive from the
  Graphics style (declared, like Frame), which LibreOffice needs to
  show it.
- A named paragraph style turning off its parent's bold (agenda
  dates under bold titles) showed bold, both in WordCraft and in
  LibreOffice after a save. What a style turns off (bold, italic,
  underline, strike-through) is now kept and written.
- Columns' shares (`style:rel-column-width`) are now written only for
  tables filling the margins. LibreOffice lays out an aligned table
  oddly with them: WordCraft's sample table lost its proportions.
- Drawings waiting for a paragraph now join only a paragraph of the
  text itself, never one in a text box or note.

With the harness's paper setting fixed (it had made LibreOffice draw
unsized pages as Letter), most of the 78 templates compare much closer
page by page. The CV template and the contour sample are now
pixel-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 08:02:11 +00:00
Claude 1dd4e64191 Gap 19: ODT page margins default as LibreOffice's; loose drawings join a paragraph
More fixes from LibreOffice's bundled templates:
- A page margin the file leaves unsaid was 1 in, and a page layout with
  no properties at all was ignored. Both now take LibreOffice's
  defaults: 2 cm margins (observed black-box). LibreOffice's CV template
  now lays out as LibreOffice draws it.
- Drawings anchored outside any paragraph (LibreOffice's
  page-anchored frames before the first paragraph) made a paragraph of
  their own. That paragraph started the document on the default page
  style rather than the one the first paragraph asks for, which put the
  running header on a letter's first page and pushed the letter onto
  two pages. They now join the next paragraph; before a table or at the
  end, they still get a paragraph of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 07:03:12 +00:00
Claude ab02c85701 Gap 19: ODT fixes found by round-tripping LibreOffice's own templates
LibreOffice ships 78 text templates (letters, faxes, agendas, reports,
CVs). Round-tripping them through WordCraft exposed these problems:
- Placeholders (`text:placeholder`, "<Recipient's name>") were
  printed text. They now open as plain-text content controls showing
  them, so a click selects one and typing replaces it. Their
  description becomes the control's title. They are written back as
  placeholders of their type while they still show; what was typed is
  written as text.
- A fixed date with neither a value nor text was lost. It is now a date
  field.
- `text:author-name` opens as USERNAME, which the engine left empty.
  USERNAME and USERINITIALS now show the user's name and initials
  from File › Options; a quoted text after the name is shown instead.
- A "First Page" master followed by another (`style:next-style-name`)
  put the running header on the first page. It is now a section with a
  different first page: its own (empty) header on page 1, the next
  master's after that.
- A page layout without a size was Letter. It is now the default
  page's, the user's region's, as LibreOffice uses its default paper.
  ODT import takes the regional page like the other importers.
- Style-only templates (no `content.xml`) failed to open. They now
  open empty with their styles.
- Table columns are now written with their share of the table
  (`style:rel-column-width`) too. A table filling the margins of a
  narrower page keeps its proportions in LibreOffice.

All 78 templates now open and round-trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 06:36:36 +00:00
Claude 7848757954 Gap 19: ODT hidden form controls kept; .ott templates opened and saved
- Hidden form controls (`form:hidden`: a name and value the form sends,
  not shown) were dropped. They are now kept verbatim, with their
  properties, and written back into the form with ids of their own.
- OpenDocument text templates (`.ott`) weren't opened. They now open
  as documents, and save as templates: the template's media type goes
  in `mimetype` and in the manifest's root entry (ODF 1.3 Part 2 §3.3).
  This works with a password too. `.ott` is listed wherever `.odt` is:
  open and save formats, file dialogs, export, the web app and its
  media type.

A LibreOffice template round-trips with the same text. 300 fuzzed
documents exercising custom shapes, cell formulas, kept parts, turned
text and forms caused no crashes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 05:49:16 +00:00
Claude ce516b04f6 Gap 19: docs: ODT fidelity re-estimated at ~90%
After custom shapes, cell formulas, kept package parts, turned text,
and control sizes and buttons: ODT is ~90% in file-format-parity (20–35
h left). gaps.md row 19 lists what's left in ODT:
- emphasis marks;
- turned characters;
- conditional and variable fields;
- hidden controls and control looks;
- embedded spreadsheets' contents.
The ROADMAP progress log has the entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 23:46:56 +00:00
Claude 78bd6b2b72 Gap 19: a kept ODT form control made another kind saves as that kind
Review fix. A button kept verbatim was still written as the button
after its content control was made another kind (a check box, a list).
The kept form XML is now used only while the control is still the
plain text control it was read as.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 23:46:56 +00:00
Claude 693ccff3b2 Gap 19: ODT form control sizes and buttons kept
On save, form controls were sized to their text. Buttons and other
controls WordCraft has no kind for were dropped.
- Sizes: each control keeps the size of its `draw:control`
  (`svg:width`, `svg:height`), stored on the content control.
- Buttons, image buttons, labels, file and value-range controls, frames
  and generic controls now show their label, locked, as a content
  control. Their form XML is kept verbatim and written back as it was,
  so LibreOffice's button round-trips as a button.
- A kept control that names a namespace WordCraft doesn't declare is
  left out. So is one longer than 64 KiB.
- The `ooo`, `oooc`, `script` and `dom` prefixes that LibreOffice's
  controls use are declared, so the content validates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 23:36:34 +00:00
Claude cb7de0227f Gap 19: ODT turned text in table cells and text boxes
Text turned down or up opened across. This covered table cells and text
boxes (`style:writing-mode` §20.404, and LibreOffice's own
`loext:writing-mode="bt-lr"` for upward text).
- Table cells now open and save with WordCraft's cell text direction.
- Text boxes now open and save with their text direction.

LibreOffice's turned cells render as LibreOffice draws them. LibreOffice
reads WordCraft's turned text boxes back identically.

Turned characters (`style:text-rotation-angle`) still open upright:
WordCraft has no turned characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 23:15:00 +00:00
Claude 2b826a288c Gap 19: ODT macros and unknown package parts kept
Package parts WordCraft doesn't read were dropped on save. These
include LibreOffice Basic macros and dialogs (`Basic/`, `Dialogs/`),
`manifest.rdf` and other applications' files. They are now kept as
they are and written back with their manifest entries. LibreOffice
finds a round-tripped document's macro intact; WordCraft never runs
macros.

Dropped on save: the old thumbnail, and digital signatures
(`META-INF/*signatures*.xml`), because they sign the package as it
was. Also dropped: paths that climb out of the package, and paths
WordCraft writes itself (pictures, embedded objects).

Kept parts are bounded: 1000 files, 64 MiB in all. They survive
password protection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 23:14:59 +00:00
Claude 2036ad2e99 Gap 19: ODT table cell formulas as Word formula fields
ODT cell formulas (`table:formula`) were lost, and only their value was
kept as text.
- Reading: LibreOffice's formulas, in its own syntax
  (`ooow:sum <A1:A3>`, `<B1>*<B2>+<B3>`, `mean`, `min`, `max`,
  `product`, `abs`, `sign`, `count`, `add`/`sub`/`mul`/`div`/`pow`, lists
  joined by `|`), open as Word formula fields (`=SUM(A1:A3)`) showing
  the cell's value. Update Field recalculates them.
- Writing: formula fields in cells are written back as cell formulas
  with their value. ABOVE, LEFT, BELOW and RIGHT become the cells they
  name.
- Cells holding a plain number are written as numbers, so LibreOffice
  calculates with them. The `ooow` prefix is declared; without it,
  LibreOffice reports "Expression is faulty".

A formula one side can't say keeps its value as text. A LibreOffice
table of sums round-trips with the same text, and the saved file
validates against the schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 22:57:17 +00:00
Claude 7c9d301541 Gap 19: ODT custom shape equations worked out once, points kept finite
Review fixes to the custom shape geometry:
- An equation that couldn't be worked out wasn't remembered. Equations
  each referring twice to the next, ending in one that refers to
  itself, took about 2^64 steps. Each equation is now worked out once,
  failures and those being worked out included.
- Points far outside the view box (1e300) became infinite as f32. They
  are now clamped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 22:57:17 +00:00
Claude 04ff4e3a54 Gap 19: ODT custom shapes drawn from their own geometry
A custom shape whose type WordCraft has no kind for (hexagon, octagon,
smiley, can, moon, lightning, block arc, cross, pentagon, flowchart
shapes…) opened as a rectangle. It is now drawn from its own geometry
(ODF 1.3 §10.6.2) as a freeform:
- the enhanced path (§19.145): moves, lines, curves, ellipses (T, U),
  arcs (A, B, W, V) and quarter ellipses (X, Y), arcs followed in
  short straight steps;
- its values worked out from the shape's equations (§10.6.3) and
  adjustment values: numbers, other equations, modifiers, the frame's
  keywords, + - * /, abs, sqrt, sin, cos, tan, atan, atan2, min, max
  and if.
Path length, equation count and nesting, and points are all bounded.

LibreOffice fills no part of a `draw:path` that has an open part. So a
freeform with both closed and open paths is now saved as a custom shape
whose path leaves the open ones unfilled (`F`).

LibreOffice's preset shapes render as LibreOffice draws them. The
saved file validates against the schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 22:37:45 +00:00
Claude 2877514d9e Gap 19: ODT bibliography marks as citations of sources
ODT citations (`text:bibliography-mark`) were read as their text, and
the sources they carry were lost. So an ODT bibliography had nothing
to update from.
- Reading: each mark is now a `CITATION` field of its source, which
  becomes one of the document's bibliography sources (the first mark
  of an identifier gives it). The identifier is the tag, the type the
  kind, plus author, title, year, publisher, address, journal, volume,
  pages, URL, editor, edition, number and month.
- Writing: a `CITATION` of a known source is written back as a mark
  carrying the source.

References › Update rebuilds an ODT bibliography from them. A
LibreOffice citation and bibliography round-trip pixel-identical and
validate against the schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 22:13:47 +00:00
Claude 27b7f34425 Gap 19: ODT linked text boxes stay linked
Linked text boxes (`draw:chain-next-name` on a `draw:text-box`, naming
the frame its text continues in) lost their link on open. The
following box's text was gone, since only the first box holds the
story.
- Reading: each box keeps its frame name and the name of the next box.
  Once all boxes are made, names are resolved to WordCraft's link
  (`TextBody::next`). A link to a missing box, to the box itself or
  round in a loop is dropped.
- Writing: each box is named after its story, and a linked box names
  the next one.

Flow model: `BoxLook::chain` (name, next).

A LibreOffice chain round-trips pixel-identical, and WordCraft flows
the text from the first box into the second. A linked box keeps its
own height where LibreOffice grows it to the rest of the text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 22:02:39 +00:00
Claude 4c0bb99016 Gap 19: ODT picture crops and contour wrapping
Two picture properties were dropped.
- Crop: `fo:clip` (top, right, bottom, left, in lengths of the
  picture's own size, its pixels at 96 per inch) is now WordCraft's
  crop fractions. A picture's crop is written back as `fo:clip`, with a
  graphic style even for an in-line picture.
- Contour wrapping: `style:wrap-contour` with
  `style:wrap-contour-mode` `outside` is Word's Tight wrapping, and
  `full` (the default) is Through. The frame's `draw:contour-polygon`
  becomes the wrap polygon, scaled from its view box. A polygon whose
  points lie well outside its view box is in another coordinate system
  (LibreOffice writes one for some pictures), so the outline is
  derived from the picture instead. All of this is written back, the
  polygon last in the frame.

A cropped LibreOffice picture round-trips pixel-identical, and a
contour-wrapped one round-trips with WordCraft wrapping text as
LibreOffice does. Both validate against the schema. `Body::start`'s
frame parts moved to a helper (clippy's complexity limit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 21:50:19 +00:00
Claude 2bc5874da0 Gap 19: docs: ODT fidelity re-estimated at ~85%
This round added flow breaks, table placement, ruby, index marks, text
borders, connectors and custom properties. A 20,000-paragraph document
round-trips in about a second with nothing lost, and 400 fuzzed
documents opened without a crash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 20:38:11 +00:00
Claude b114209d2b Gap 19: ODT date properties kept as full UTC dates
Review of the custom property commit: an ODF date property may be a
bare date, while Word's `filetime` (which a DOCX save writes) wants a
UTC date and time. A bare date is now midnight UTC, a date and time
without a zone is taken as UTC, and a value that isn't a date is kept
as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 20:32:04 +00:00
Claude 0b6b88f414 Gap 19: ODT custom document properties and user fields as DOCPROPERTY
ODT custom document properties (`meta:user-defined` in `meta.xml`) were
dropped, and the fields showing them or a user field were read as
plain text.
- Properties: `meta:user-defined` values (string, number, true/false,
  date) are now WordCraft's custom document properties, and so are
  user field declarations (`text:user-field-decl`).
- Fields: `text:user-defined` and `text:user-field-get` are Word's
  `DOCPROPERTY` fields, which WordCraft updates from those properties.
- Writing: custom properties go back into `meta.xml`, and a
  `DOCPROPERTY` naming one is written as `text:user-defined`.

A LibreOffice document with a custom property and a user field
round-trips pixel-identical, valid against the schema. Its user field
comes back as a document property.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 20:30:48 +00:00
Claude 1e9c11fb1c Gap 19: ODT connectors in groups glued by their shapes' sites
Review of the connector commit: a connector inside a drawing group
wasn't converted. Its group's shapes weren't recorded, so its ends
lost their glue. Group members are now recorded for their kind (never
for placement, since they sit in the group's coordinates), and
connectors in groups have their glue points made their shapes' sites
too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 20:18:10 +00:00
Claude 3d464f3ebe 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
2026-11-01 20:15:27 +00:00
Claude f683e308c6 Gap 19: ODT borders around text read and written
LibreOffice writes a border around text as `loext:border` (or its
sides) with `loext:padding`; ODF 1.3 has no attribute for it. It was
dropped. It is now read as Word's text border (one line all round, the
first side with a line, the padding its distance) and written back
the same way, which LibreOffice draws. A document with a text border
is then extended ODF rather than strict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 19:17:20 +00:00
Claude 5f7f89c187 Gap 19: docs: ODT status in gaps, roadmap progress log
Gap 19's row now says what ODT reads and writes and what is left, and
its estimate drops from 18–27 h to 10–16 h. The roadmap's progress log
covers the ODT work since text boxes: shapes through password
protection, forms, ruby and flow breaks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 19:05:48 +00:00
Claude 72e9593de9 Gap 19: ODT index marks as XE fields; updating an index held around its entries
ODT index marks (`text:alphabetical-index-mark`) were dropped. So
References › Update Index on an opened ODT index found no entries and
left the old list in place. Now:
- a mark at a point is Word's `XE` field, its `text:key1`/`text:key2`
  the levels above its entry;
- a mark over text (`-mark-start` … `-mark-end`, in one paragraph) is
  an `XE` field whose entry is the marked text, which stays text;
- `XE` fields are written back as point marks.

Engine: Update rebuilt only lists in WordCraft's own form, a paragraph
holding the field. A list held as a field around its entries, as ODT
and Word files hold an index, bibliography or table of figures, is now
first made the field's own paragraph, then rebuilt. That happens only
when the field starts its paragraph, so no document text is replaced.

A LibreOffice document with an index mark round-trips pixel-identical,
the mark kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 19:04:44 +00:00
Claude 6dff74990e Gap 19: ODT ruby read and written
Ruby (`text:ruby`, ODF 1.3 §6.4: a base text with ruby text over it,
furigana or pinyin) was read as its base and ruby text run together,
and the writer wrote any ruby as plain text (its comment wrongly said
ODF has none).

Ruby is now read as WordCraft's Phonetic Guide. It is written back as
ODF ruby, above its base and centred, with an automatic ruby style.
A ruby inside a tracked change is still written as its base text,
which the change marks, so the tracking isn't lost. LibreOffice shows
the round-tripped ruby over its base, and the content validates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 18:43:42 +00:00
Claude 6925818110 Gap 19: ODT page breaks after, kept lines, widows, hyphenation, table placement
Six features of LibreOffice documents were lost on open and save. A
two-page document came back as one page, because the page break after
a paragraph was dropped. Now read and written:
- a page break after a paragraph (`fo:break-after="page"`), as 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;
- hyphenation (`fo:hyphenate`): a paragraph's in ODF, the document's in
  WordCraft. A document with a hyphenated paragraph hyphenates, and its
  other paragraphs opt out;
- a table's alignment and width (`table:align`, `style:rel-width`);
- its rows' heights (`style:row-height` exact, `style:min-row-height` at
  least).

Flow model: `FTable::extra` (alignment, width, row heights), filled by
formats that keep them.

A LibreOffice document with all of these round-trips at 573 px
difference instead of 4,407, keeps its two pages, and validates
against the ODF 1.3 schema. No other test document changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 18:30:41 +00:00
Claude b87c80cdc7 Layout: text in a turned text box turns with it
A turned text box (free rotation) drew its frame turned and its text
upright. Its text now turns with it about the box's centre, as Word
draws it, on screen and in PNG and PDF export: upside down when the
box is flipped across, never mirrored by a side-to-side flip.

PDF export draws item by item, to tag each paragraph, so it turns each
of the box's lines the same way, and tags the turned lines as their
paragraph's text rather than as decoration.

Clicks and the caret inside a turned box still go by its upright text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 17:42:07 +00:00
Claude 8506d9da54 Gap 19: ODT in-line placement, leading and text box borders as LibreOffice draws them
Three causes of the largest remaining LibreOffice round-trip
differences:
- In-line frames and shapes centred on or hanging from the baseline
  (`style:vertical-pos` `middle`, `bottom`, or `from-top` with
  `svg:y`, relative to the baseline) were read as standing on it. They
  are now read as their run's lowering, which the layout honours, and
  written back as `from-top`.
- Leading: LibreOffice also leaves the leading after a paragraph's last
  line. It is now added to the paragraph's space after.
- Text box frames didn't derive from LibreOffice's Frame style, without
  which LibreOffice ignores a frame's `fo:border` and outlines it in
  its default shape colour. They now do, and an empty Frame style is
  declared.

LibreOffice round trips (pixel difference): shapes 13,864 → 1,273,
spacing 2,973 → 706, text box 736 → 0. No other test document changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 17:24:15 +00:00
Claude 45b852ad4a Layout: in-line drawings move with their run's raise or lowering
An in-line picture, shape or chart always stood on the baseline,
whatever its run's position. Word moves an in-line picture with its
run's position (`w:position`, Font › Advanced › Position):
- lowered, it reaches below the baseline (at most until its top is on
  the baseline) and the line's descent grows to hold it;
- raised, it stands clear of the baseline at its own height.

A drawing's cluster now reaches `obj_h` above the baseline and `obj_d`
below it (negative when raised). It is painted, hit-tested and wrapped
from both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 17:24:15 +00:00
Claude 7eee06a1ab Gap 19: ODT form controls opened and saved as content controls
ODT form controls (ODF 1.3 §13: controls in `office:forms`, each
placed in the text by a `draw:control`) were dropped on open, values
and all. They now open as WordCraft content controls, each with its
value, entries and selection; its name becomes the tag and its label
the title:
- text fields and text areas: plain text controls;
- check boxes: check boxes;
- radio buttons: check boxes with round symbols;
- list boxes: drop-down lists;
- combo boxes: combo boxes;
- date fields: date controls.
Saved as DOCX they become Word content controls.

Content controls of those kinds in the body are written back as form
controls with a `draw:control` anchor; in headers and footers they stay
their text. LibreOffice shows the round-tripped controls with their
values, and the content validates against the ODF 1.3 schema.

Not read: buttons, images, hidden and other controls. Controls' sizes
and looks aren't kept.

Flow model: new `Inline::Control`, kept only by flows that ask for
boxes (ODT); other formats get its text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 14:46:01 +00:00
Claude b36e0b9fe9 Gap 19: password-protected ODT opened and saved
A password-protected ODT (ODF 1.3 Part 2 §3.4) failed to open. The
package is now decrypted with the password into a plain one, which the
reader opens as before.
- Supported: AES-256-CBC with a SHA-256 or SHA-1 start key, PBKDF2
  with HMAC-SHA-1, and SHA-256 or SHA-1 1K checksums (LibreOffice's
  encryption).
- Blowfish (the old default) is refused with a message naming it.
- A missing or wrong password gives the same errors as Word files, so
  the app asks for the password.
- Hostile parameters are bounded: iteration count, IV size, ragged
  data, inflated size.

Saving with a password (and saving a document opened with one) writes
the ODT encrypted as LibreOffice does. Every file but the `mimetype`
and manifest is deflated and encrypted, each with its own salt and IV,
and the manifest validates against the ODF manifest schema.
LibreOffice opens WordCraft's encrypted files with the password and
refuses a wrong one; WordCraft opens LibreOffice's.

No new dependencies: the AES, SHA, HMAC, miniz and getrandom crates
the workspace already uses for Word encryption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 14:24:03 +00:00
Claude 16f0b6ed25 Gap 19: ODT headers and footers keep their drawings on save
Review of the objects commit found two gaps.
- The header and footer reader wasn't given the package's entry list,
  so embedded objects there were still lost on open. It now shares the
  body reader's list and manifest, borrowed rather than copied.
- Headers and footers were flattened for writing without drawings, so
  shapes, text boxes, charts and kept objects in them were dropped on
  save. They now go out as in the body.

The full LibreOffice round-trip harness shows no change on the other
test documents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 13:42:25 +00:00
Claude 275037932f Gap 19: ODT embedded spreadsheets and OLE objects kept whole
LibreOffice writes an embedded object's replacement picture in its own
metafile format, which WordCraft can't decode. So an embedded
spreadsheet (`draw:object`, ODF 1.3 §10.4.6) or OLE object
(`draw:object-ole`, §10.4.7) vanished on open, and saving lost it.

Such objects are now kept whole: their files, media type (from the
manifest or the sub-document's body) and replacement picture.
- Display: the replacement picture when WordCraft can show it, else a
  placeholder of the object's size drawn in code (ruled for a
  spreadsheet).
- ODT save: written back as the same object, with its manifest entries
  and original replacement. LibreOffice renders a round-tripped
  spreadsheet pixel-identically.
- Document model: the files live in `passthrough` under `odf-object/`,
  linked from the picture's `ole` by a marker. The DOCX writer skips
  the marker and writes the picture alone.

Flat ODT, a fix to the chart commit: inline sub-documents lacked the
required `office:mimetype`, so the schema rejected flat files holding
charts. Sub-documents now go inline as full `office:document`s (media
type, styles, settings) and are split back into their files on
opening. Replacement pictures and OLE files go inline as base64. A
picture's data is matched to the element that holds it, no longer to
the last `draw:image` before it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 13:35:59 +00:00
Claude 135ce2229d Gap 19: ODT charts in drawing groups drawn on open
Review of the chart commit: the engine drew opened charts only at the
paragraph level, so a chart that was a member of a `draw:g` group
opened blank. The pass now goes into groups (bounded depth).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 12:49:08 +00:00
Claude 3cc5185b1e Gap 19: ODT charts opened and saved as editable charts
A chart object (`draw:object` whose sub-document is an `office:chart`,
ODF 1.3 §10.4.3, Part 3 §11) used to open as its replacement picture.
It is now read into WordCraft's chart model: column, bar (stacked or
not), line, area, pie, doughnut and scatter charts, with categories,
series names and values (gaps included) taken from the chart's own
table through the series' cell range addresses, plus title, legend
position and data labels. The engine draws opened charts in the
document's theme colours, and Chart Design's commands edit them.
Charts WordCraft can't edit as they are (3D, percent-stacked, mixed
plot types, a second value axis, radar and the like) keep their
picture.

WordCraft charts are written as ODF chart objects shaped like
LibreOffice's: a data table, each series and slice in its accent
colour, pies clockwise from the top. They validate against the ODF 1.3
schema, and LibreOffice draws them like WordCraft does. In flat
documents they are written inline as an `office:document`.

Bug fixed: flat ODT reading ended the body at the first nested
`</office:body>`, so an inline sub-document dropped the text after it.
Element lookup now follows nesting, with a bounded depth.

Flow model: new `Inline::Chart`, kept only by flows that ask for
boxes (ODT); other formats still get the alt text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 12:43:51 +00:00
Claude d2b7465b1e Gap 19: ODT leading read as an at-least spacing
LibreOffice's leading (`style:line-spacing`, extra space between lines)
has no WordCraft line spacing mode. It was read as a multiple of a
12 pt line, which spreads the extra space differently from LibreOffice,
so the paragraph started too high. It is now read as at least a 12 pt
line and the leading: the extra space goes above each line, as
LibreOffice draws leading (it also leaves some after the last line,
which isn't followed).

A LibreOffice spacing document went from 6,190 px apart after a round
trip to 2,973, and WordCraft's drawing of it from 8,570 to 5,521.

Regression test: `leading_is_at_least_a_line_and_the_leading`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 11:41:55 +00:00
Claude 86b1681b54 Gap 19: ODT tracked formatting changes; regions named by xml:id
Tracked formatting changes (`text:format-change` regions, ODF 1.3
§5.5.7, between `text:change-start`/`-end` marks) were dropped. The
text between the marks now gets a formatting revision with the change's
author and date. ODF records no old formatting, so the old formatting
is the text's own: the revision shows and can be accepted, and
rejecting it changes nothing (LibreOffice can't reject one after
reopening either). WordCraft's formatting revisions are written as
format changes around their text.

Fixed: changed regions were written with `text:id` only, but ODF 1.3
requires `xml:id` (which the change marks refer to), so any document
with tracked changes failed schema validation. Both are written now,
and a region named by `xml:id` alone is read.

A LibreOffice document with a tracked formatting change round-trips
pixel for pixel; documents with tracked insertions, deletions and
format changes validate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 11:36:42 +00:00
Claude fe5d3c92d2 Gap 19: ODT drop caps; tab stops no longer drop paragraph properties
Drop caps (`style:drop-cap`, ODF 1.3 §17.9) are read into WordCraft's
drop cap (lines and distance; one line is none) and written back as
the paragraph style's `style:drop-cap`. WordCraft drops one letter, so
a drop cap of several letters or a word opens as its first letter.

Fixed: the paragraph style key kept its child elements after a
separator, and the background, borders, contextual spacing and
right-to-left direction were added after the tab stops, so a paragraph
with its own tab stops wrote them into the tab stops' place instead of
onto the paragraph properties, and lost them. Attributes and child
elements are now gathered apart
(`tab_stops_and_other_paragraph_properties_together`).

A LibreOffice document with a drop cap round-trips pixel for pixel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 11:29:04 +00:00
Claude 694ef8da96 Gap 19: flat OpenDocument text (.fodt) opened and saved
A flat ODT holds the whole document in one `office:document` element
(ODF 1.3 Part 3 §2.1.2), its pictures inline as base64
`office:binary-data` and its formulas inline in their `draw:object`.
It is opened by taking it apart into a package's files (content,
styles, meta, settings; pictures and formulas as entries, each part's
root keeping the document's namespaces) and reading that package, and
saved by putting a package's files together: one set of automatic
styles, every part's namespaces declared once on the root, pictures and
formulas inline without the link attributes.

`.fodt` opens and saves from Open, Save As, the CLI and the web app.
LibreOffice's own flat documents round-trip as their packages do, and
the written files validate against the ODF 1.3 schema.

The parity doc's ODT fidelity is re-estimated at ~80%, and its status
line, which hadn't been updated since the Asian fonts change (a text
replacement that matched nothing), now lists every change since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 10:05:45 +00:00
Claude 5570236b70 Gap 19: ODT page settings read and written
- Page number format: the page layout's `style:num-format`, and a page
  number field's own (`PAGE \* Arabic`, `roman`, `ROMAN`,
  `alphabetic`, `ALPHABETIC`), both ways.
- Page colour (the first page style's `fo:background-color`).
- Page borders: ODF's sit at the margin with the text inside their
  padding; WordCraft measures a page border from the page edge and the
  text margin from the edge, so the border's space is ODF's margin and
  the margin takes in the border and padding, and back on save.
- Footnote and endnote numbering (`text:notes-configuration`: format,
  first number, counted from 0 in ODF, restart, page foot or document
  end); only what the document sets is written.
- Line numbering (`text:linenumbering-configuration`, on unless it says
  otherwise, ODF 1.3 §19.845: interval, distance, restart on each page).

A LibreOffice document with all of these round-trips pixel for pixel;
WordCraft draws it 7,213 px from LibreOffice (24,025 with the border at
the page edge). The written styles validate against the ODF schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 09:36:50 +00:00