The heading constants move to styles/headings.ts and the filename logic to
storage/documentName.ts, which were all the editor needed of the exporter
at startup. Main chunk 2.1 MB → 1.37 MB (gzip 615 KB → 411 KB).
A roman front matter before a decimal body is one page style before another
in ODF and one w:pgNumType per section in Word, not a document setting — read
as one, its pages printed 1, 3 where the reference prints i, iii. HfSet grows
pageNumberFormat, both exports write it back, and the DOCX importer takes the
document's own format from the first section, as it already does the paper.
The thread rides the comment mark's attrs, so two runs of one comment still
merge back together and no second store is needed. ODF writes each answer as
its own annotation over the same range, naming its comment in
loext:parent-name; Word writes its own w:comment tied by w15:paraIdParent in
commentsExtended.xml (both probed). Either importer folds the answers back
into the comment they belong to, and the printed list carries them indented.
textBox becomes an inline node carrying block content, like the frame both
formats write, so "in line with text" really is in the line and a box reaches
a table cell or a list item through their paragraphs. Both exports write it
where it sits (an as-char draw:frame / a wp:inline drawing) and both imports
leave it there, which drops the hoisting machinery on either side.
Two guards the schema cannot express: no box inside a box, and a selection
reaching into a box grows to the box's bounds rather than dissolving it.
The shape body wrote spAutoFit, which LibreOffice reads as min-height 0
and then draws the box's text detached from the shape, over whatever
follows it — measured on a 90px box: the frame collapsed to one line and
its text landed 22pt below it, on the next paragraph. noAutofit is what
LibreOffice writes for the same frame; with it the text sits inside the
box at its declared height. Content taller than that is clipped, DOCX
having no fo:min-height for the ODF leg's auto-grow.
wrapAlign only ever came from a file, so a box inserted here stayed at
the left in both modes that leave it in flow. The floating toolbar now
offers left/centre/right for `inline` and `topBottom` (a side wrap names
the side itself) and drops a coordinate placement when one is picked.
DOCX carried neither: `topBottom` now writes wp:align and an as-char box
the w:jc of its rebuilt marker paragraph, both read back on import. ODF
already wrote both. Measured centred at 297px of a 76…718px column, and
rendered through LibreOffice from .odt and .docx alike.
The margin carve-out under a page border, the bare note anchor and a
Writer frame keeping a list and a nested image through an soffice
re-save each get an asymmetric assertion — a symmetric export/import
pair had hidden all three.
The three index kinds exported as fields with an empty result; Word for
Mac never updates fields on open, so every index rendered as nothing.
They are now complex fields whose result is the editor's cached entries
(indent per level, leader tab, pages), the form Word itself writes; the
importer recognizes the complex INDEX/BIBLIOGRAPHY form and keeps the
attrs the fldSimple path set.
ODF: every part declares 1.3 (the version LibreOffice writes; header-first
is a 1.3 element), draw:image gains the mandatory xlink:type trio,
text:time-value becomes an xsd dateTime (import still reads the legacy
duration), index-entry-link-start/-end stay in TOC templates only, and
dont-balance-text-columns moves onto style:section-properties (probed: LO
reads and re-writes it there). DOCX: a final pass re-sorts w:settings into
the CT_Settings sequence, and comments.xml declares w14 mc:Ignorable.
The uniform LibreOffice definitions duplicated the dropdown formats plus
default nesting. The gallery now ships patterns the depth cycle cannot
produce: the outline chains Outline I.A.1 / A.I.1, the kind-mixing
Numbering with Bullets and Checklist, and the Diamond Bullets ladder,
all on the plain 1.27cm step. STYLE_SHEET_VERSION 12 swaps stored
built-ins while user styles survive.
effectiveListLevel resolved a level only for its own node species, so a
Tab-nested <ul> under a number level kept cycle bullets while the named
ODF definition (and LibreOffice) numbered it. The level's kind now wins:
the walk numbers a <ul>/bullets an <ol> (CSS keys generalized), both
exporters resolve clones and forks through eff.kind (new ODT pass
applyListLevelKinds switches L# level species), and setListStyle retypes
the subtree. Node marker attrs still override with their own species.
Fourth style family (LibreOffice's Listenformatvorlagen): per-level model in
styles/listStyles.ts, five probed LO built-ins, listStyleName on the outermost
list, one effective-level resolver shared by the decoration walk and the export.
ODF writes the full named text:list-style into styles.xml and repoints
unoverridden lists at it (overrides keep the resolved automatic clone);
import maps named list styles back to the attr + registry, attr-free.
Probed: LO keeps the named reference and display-name across a re-save.
Both edges help fold the sheet; the punch mark stays left, where the
pages are filed. The exports mirror them as EdenFoldMark<N>R lines
(page width from the ODF page layout / the DOCX page size), the
importers already match on the shared name prefix. Verified headlessly:
5mm from either edge at 87/192mm.
edentext-fold-marks flag: FoldMarkLayer draws the 87/192mm folds and the
148.5mm punch mark per page inside .paper, so print and PDF include them.
Toggled in the Layout tab. Exported as named EdenFoldMark* lines in the
master page's header (ODF page-relative draw:line, DOCX VML v:line);
both importers read the names back into the flag. Roundtrip Leg 32.
An inline atom drawn gray as ‹label›: a click selects the whole field,
typing replaces it, Tab/Shift-Tab jump between fields. Exports to ODF
<text:placeholder> (PLH sentinel U+E022) and a DOCX w:sdt content
control tagged edentext-placeholder; both importers read it back, the
export-minted gray stripped. Covered in roundtrip Leg 31 + fuzzDoc.
tests/fuzzDoc.ts generates documents from a seeded PRNG (marks, lists,
tables, indents, XML-hostile text); fuzz-roundtrip.test.ts runs 50 seeds
through buildOdt -> importOdt and diffs the normalized JSON, naming the
failing seed. normalize/firstDiff moved out of roundtrip.test.ts into
tests/normalize.ts for reuse. The generator documents what is lossy by
design: default-equal marks in headings, indents and start in nested lists.
A new document starts with it off and an import adopts the file's own
flag, so an editor left recording no longer marks the next document it
opens. Both formats carry it, probed against soffice: ODF on the change
registry (text:track-changes, written for the flag alone where nothing
is recorded yet), Word as w:trackRevisions in settings.xml — LibreOffice
writes and reads exactly those two, and reading w:trackChanges instead
would have found nothing.
Its picture nests a second w:drawing, and the importer's subtree
search found *that* one's wp:inline first: the box was read as the
bare picture, so every word of the caption in it was dropped —
which is what Word's own Insert Caption writes for a floating
picture. The root is now the drawing's direct child. The export
side gains the caption's SEQ field inside a box, so the running
number stays live there too. Leg 30 covers both.
Two faults on one picture. A block after a float keeps its box over
the float — only its lines move — so the caption on top swallowed
every click meant for the picture; the frame is lifted above the
in-flow boxes. And a caption can only sit under a side-wrapped frame
if it is boxed to it, so its span in the column is measured and
becomes the caption's indent, which also drops it clear of the float.
Measured at 100% and 150%: caption under the picture, its width, in
all four wrap modes.
It was inserted as a plain paragraph, so a centred or right-aligned
picture got its caption at the left edge of the text column.
LibreOffice frames picture and caption together; a loose paragraph
carries that over as the alignment of the block it captions.
Measured in the live editor: a centred 240px picture at 520…760
now has its caption text at 580…700.
Two endpoints rather than a box: no text, no fill, and the diagonal of
the frame with `flipV` picking which one. The three kinds share their
preset names, since the heads a file declares are what tell them apart.
ODF gives a line its own element and its heads a named marker, which
needs the draw: prefix declared in styles.xml — undeclared, the file
does not parse at all. Word keeps the frame and flips it.
The formula rides the cell, as ODF writes it; Word's field syntax is what
is stored, since it is the wider language. `ooow:` in the ODF attribute
value is a namespace prefix — undeclared, LibreOffice reads it as part of
the formula and prints "Expression is faulty".
Sort takes one key and detects the type per cell where both dialogs make
you pick. Number recognition is off by default, as it is in LibreOffice,
and a formula parses a cell's text either way — exactly as there.
The `docx` package swaps the pair itself for a landscape section, so it
wants the portrait box; handed the swapped one it wrote a portrait page
merely labelled landscape — LibreOffice renders it portrait (measured:
21×29.7cm out of a landscape document).
Per-section paper rides the same fix: an HfSet carries its own format and
orientation, ODF cloning the section's page layout as it already did for
the margins and DOCX writing it into each w:sectPr.
The citation is an inline atom holding the whole record, which is what ODF
writes on text:bibliography-mark — so no database travels beside the file.
The list is a new index family: one row per source, in document order and
without page numbers, as LibreOffice prints one.
DOCX cites by tag and keeps the sources in a custom-XML part the docx
package knows nothing about, so the post-pack pass mints the part, its
properties, both relationships and the content type itself.
References ▸ Index entry marks a place with the term the index should
list — under a key of its own where it belongs elsewhere — and the Index
in the same menu lists each term once, sorted case-insensitively, with
every page it was marked on.
Both formats write a place, not a range, so the entry is an inline atom:
ODF text:alphabetical-index-mark plus text:alphabetical-index, Word XE
plus INDEX. LibreOffice reads our file back unchanged. The bibliography,
the other half of that gap, is still open.
Twelve presets beside the three rectangular ones. Each outline is one
polygon in utils/shapes.ts, given once: the node view draws it as an SVG
path, the ODF export scales the same points into draw:enhanced-path, and
Word gets the preset's name and draws its own geometry.
A gallery in the box toolbar and the ribbon renders its tiles from that
same table, so a new shape needs no icon. Verified through LibreOffice
from both formats; a preset we can't draw still imports as a warning.
Two marks carry a revision with its author and date: an insertion is
marked text, a deletion is text kept and struck through. Consecutive
typing joins one change; accept/reject work at the cursor or on all.
ODF splits the pair — text:change-start/-end around an insertion, a
text:change where a deletion was, its text in the tracked-changes
registry. DOCX uses w:ins/w:del with w:delText. Both round-trip, and
accepting every change gives the document an unrecorded edit would.
Continuous or per page, every nth line, empty lines counted or not —
LibreOffice's Tools ▸ Line Numbering, round-tripping as its
text:linenumbering-configuration and Word's w:lnNumType. CSS exposes no line
boxes, so the layer Range-selects each block and takes the distinct tops of
its client rects as its lines; LibreOffice renders the exported file with the
numbers in the same places.
LibreOffice's Format ▸ Page Style ▸ Area/Borders and its Format ▸ Watermark,
all three round-tripping. Probed placements that are not guessable: ODF reads
a watermark only out of the master page's header (and only with xmlns:draw
declared), Word's w:sectPr wants pgBorders after pgMar or it loses the header
references, and the VML shapetype has to travel or LibreOffice cannot resolve
it. On screen the watermark is an SVG textLength, since a fontwork shape
stretches its text to its box rather than setting a font size.
The same generated index the table of contents is, over the captions of one
category instead of the headings — an `index` attr picks the family, so the
node view, the page numbers and the leader dots are shared. Round-trips as
ODF text:illustration-index / text:table-index, found by the counter's name
the way LibreOffice finds them, and Word's TOC \c "Figure" field.
A caption is a paragraph in the Caption style holding a sequence field —
one counter per category, in document order, renumbered as the document is
edited and on load (a file's cached numbers are unreadable wherever the
format is not arabic). Round-trips as an ODF text:sequence with
LibreOffice's own ooow:Illustration+1 formula and a Word SEQ field.
Caption style values probed off LibreOffice: 10pt italic, 0.212cm around.
The block's own direction, set in the Paragraph dialog where LibreOffice
keeps it, so a quoted Hebrew or Arabic paragraph turns inside a left-to-right
document. Rides ODF style:writing-mode / Word w:bidi through cells and list
items as well as top-level blocks; the direction the page already has is
inheritance, so neither importer stamps it on every block.
The structural header, beside the styling one: pageBreaks grows the
between-rows spacer by the first row's height and draws a copy of that
row — colgroup included, so the columns line up — at the foot of the
gap, which is the top of the continuation. Round-trips as ODF
table:table-header-rows and Word's w:tblHeader; LibreOffice keeps both.
Word's "don't allow row to break" needs nothing: a row that fits a page
is already an atomic leaf here.
The comment is a mark carrying its own author, date, body and resolved
flag — the bookmark pattern, so no second store and no schema node. The
pane lists them in document order and jumps to the text each marks.
ODF gets office:annotation + office:annotation-end and LibreOffice's
loext:resolved, DOCX the range pair plus word/comments.xml; a point
comment, which LibreOffice writes without a range, attaches to the run
before it. Ctrl+M no longer strips a comment, a bookmark or a link.
Layout > Page numbers: 1 / i / I / a / A and a first-page number, both
round-tripping. ODF has no document-level start — probed, LibreOffice
puts style:page-number on the first paragraph's own style — so the
export mints one there and the page layout carries style:num-format;
Word takes both in the first section's w:pgNumType. The page *count*
field stays decimal, as the one both of them write does.
Layout > Hyphenation, off by default as in both word processors. The
browser hyphenates in the document's own language (the body host gets
lang and hyphens: auto) — measured, a justified German paragraph takes
15 lines instead of 17. ODF keeps fo:hyphenate in the base style's TEXT
properties: probed, in paragraph-properties LibreOffice ignores it and
drops it on the next save. Word takes w:autoHyphenation.
LibreOffice's File > Properties / Word's File > Info, reachable from
both chromes' file menus. ODF writes them into the meta.xml odf-kit
already emits (one meta:keyword each, generator EdenText); DOCX passes
them to the package's core properties. Both importers read them back,
and LibreOffice shows every field from either format.
LibreOffice's style pool, probed: Heading 6 is 12pt bold italic and
Heading 4 italic too. MAX_HEADING_LEVEL drives the extension registry,
the shortcut plugin, the TOC depth and both importers' yardstick, so
only the constant table and the CSS selector lists had to grow.
Bookmarks/cross-references move to Implemented — they already are.
Neither word processor passes it into a cell — LibreOffice puts cell text in
its Table Contents style, Word's table styles carry w:spacing after=0 — so the
space filled the cell and vertical alignment had no slack left to move the text
in, which read as the alignment being ignored. The space above is LibreOffice's
alone, so only the add model zeroes that too. Both exporters write the zeros
onto the paragraph, and only when the default style has spacing at all.
Each class gets its own part via the docx package's footnotes/endnotes maps,
counted from 1, and the anchor becomes a reference run. Import reads both
parts, skipping the separator entries Word references from nowhere, and
flattens a multi-paragraph note to hard breaks. The numbering settings ride
w:footnotePr/w:endnotePr in settings.xml — a post-pack pass, as the package
exposes neither.
Anchors become an FNT sentinel and each note is hoisted to a top-level
paragraph, so its text rides every existing pass — marks, character styles,
links, bookmarks and formulas included; applyNotes cuts the region back out
into <text:note> at the anchor. styles.xml gains LibreOffice's own Footnote/
Endnote styles, the notes-configuration per class and the separator, and the
resolver reads all of it back. The importer builds the section instead of
warning that notes were dropped.
Two compounding bugs on a title/back-page background image:
- its host paragraph picks up position:relative from the line-height
rule, becoming the frame's containing block instead of the page and
offsetting it by a full page margin — now forced back to static.
- the frame always painted behind text (z-index -1); a graphic that
declares ODF's style:run-through="foreground" now paints in front,
as LibreOffice draws it, via a new inFront attr round-tripped through
ODF export/import.
Confirmed against LibreOffice's own render: the duplicated, ~2cm
displaced cover graphic now matches exactly.
Both exports wrote one geometry for the whole document, so a title page's
margins survived import and were lost on the next save. Each ODF section master
now names a page layout of its own — the document's, shifted by the section's
difference, so what the header/footer pass folded into it survives — and every
w:sectPr carries its section's w:pgMar. DOCX import takes the document pair from
the first section, the one .tiptap's padding draws.
The parity harness spread a 458-page document's word list into Math.max and blew
the stack; the longest fixture was never measured.
The zone is one paragraph, so its mark sizes every line of the band. Runs
that agree on a font now set that strut (applyUniformRunFont, as the body
already does), the zone schema carries blockFontSize so it renders, and
hfReachPx measures a line by the family's natural height instead of a flat
1.15. A 10pt footer reserved 12pt lines and sat 1.6mm high on every page.
The body margin reserved the zone's height *plus* its fo:margin-top/-bottom, so
every page with a footer lost a line — a fixture ran a page over. Probed against
soffice: moving a footer's fo:margin-top leaves the last body line exactly where
it was and only shifts the footer text down its band; style:dynamic-spacing
changes neither side. The band is the declared height, the gap sits in it.
One fixture paginates to LibreOffice's 63 pages again.
An as-char figure frame sits in a paragraph of its own; the importer lifts the
box to top level and dropped that paragraph whole, so its space above and below
went with it — every figure in a fixture sat 2.1mm too tight. The box now carries
them (ParagraphSpacing covers textBox), space above as padding so it adds to the
block above, and the export mints an anchor style to write both back.
One fixture: 142 → 100 parity issues.
Import read style:vertical-align but neither exporter wrote it, so a
mid-cell callout label jumped to the row's top on every save. odf-kit's
CellOptions and docx's TableCell both take it natively; DOCX import maps
w:vAlign in as well.
The TOC node regenerated over every heading level and minted a title of its
own. A file asking for level 1 only (text:outline-level, Word's TOC \o range)
got a 43-entry index instead of 12 — a full page where LibreOffice needs a
quarter, plus a second "Table of Contents" under the file's own heading.
maxLevel and an empty title now round-trip through both formats.
A formula node stores only its LaTeX source; the MathML on screen, the ODF
formula object and Word's OMML are derived from it, and both importers parse
foreign markup back into that source, so an imported formula stays editable.
Word documents were losing their formulas outright: m:oMath sits beside the w:r
runs in its own namespace, past convertInline's `namespaceURI !== W` guard.
Text boxes and shapes had no offsets at all, and for images the wrap side
overrode the x: a file that names the side its text flows on lost the
coordinate, our own export included. Side and place are read separately now,
and one helper turns both into the float's margins for every frame kind.