883 Commits
Author SHA1 Message Date
Steffen Becker 066737463d perf: the ODT exporter and both importers load on first use
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).
2026-09-04 19:30:12 +02:00
Steffen Becker 9f2da39048 fix: both importers drop a link whose scheme the editor never opens
The render-time guard of the link mark was the only thing between a file's
javascript: href and the page; the importers now read such a link as text.
2026-09-04 19:30:12 +02:00
Steffen Becker efed52440d test: the smoke run lets the autosave drain before it writes the key by hand
Leaving the page now flushes a pending autosave, which overwrote the
document the balloon leg had just put into storage.
2026-09-04 19:30:12 +02:00
Steffen Becker 0435fdf285 fix: a document that hung the editor is offered again before it is parked
A reload that cut a slow start short looked like a freeze, and the parked
document was reachable only through the browser's storage tools. The user
is asked for another try; only declining parks it.
2026-09-04 19:30:12 +02:00
Steffen Becker bc8f8d89d1 fix: the empty document's placeholder follows the UI language
The body said "Start typing…" in every language while the header and
footer zones were localized; switching the language now redraws it.
2026-09-04 19:30:12 +02:00
Steffen Becker 9335f392ce fix: the document's format survives a reload
A .docx document came back as .odt in the title and the save picker after
a reload, since only the session knew its format.
2026-09-04 19:30:12 +02:00
Steffen Becker b2cb216af0 fix: a save that fails falls back to a new file only for a lost handle
Any error but an encryption one went to Save As, which hid exporter bugs
and silently turned a .docx document into .odt. Only a handle that lost
its permission or its file prompts again now, in the document's format.
2026-09-04 19:30:12 +02:00
Steffen Becker 137fce047a fix: the autosave writes what is pending when the page is hidden
The 1 s debounce lost a tab closed inside it. Writes now queue behind each
other, so an older write finishing late can neither store an older document
nor sweep the pictures a newer one added; a page revived from the
back/forward cache writes again.
2026-09-04 19:30:12 +02:00
Steffen Becker 57b543806c fix: an opened file's password becomes the document's only once the file is loaded
Declining to replace the current document, or an import that throws, left
the foreign password on the document that stayed open, and an unprotected
file kept the previous document's password. Both now travel with the load.
2026-09-04 19:30:12 +02:00
Steffen Becker 1efe981872 fix: the running head keeps the chapter name its field cached
A STYLEREF field's result runs are the chapter a reader shows before it
repaginates. The DOCX import dropped them and set the field's text empty, so a
header saved as .docx came back with a blank running head. ODF kept it.

Found by comparing the header and footer zones on a round trip, which nothing
did — they are documents of their own and hold what only they can. The corpus
gains two legs besides: a second save, and Word's dialect on its way out as ODT.
2026-09-04 19:30:11 +02:00
Steffen Becker a66ee78ba2 test: what a save carries besides the tree, and the kitchen sink read back
The corpus legs pass seven of the exporters' nineteen arguments, so the other
twelve — note configuration, page decoration, line and page numbering, the
header/footer zones, document properties — were never round-tripped from a
file. A new unit test sets all of them and reads them back out of both formats.

The kitchen sink had only ever been written, never imported; it now makes both
round trips and comes back identical. Two fixture repairs it needed: notes in
anchor order, and a section break with sections to open.

Caught: a document holding no note yet lost its note configuration on .docx.
2026-09-04 19:30:11 +02:00
Steffen Becker 759759a997 test: the cross-format leg compares the look, not only the words
The corpus leg out of one format and into the other compared block types and
text, so anything the trip dropped below that passed. It now compares every
attribute each node carries, with the values a format states in its own units
excluded: proportional vs absolute column widths, and a formula's inline flag,
which ODF has no place for.

Two the stricter leg caught: a note lost the paragraph style it names and fell
to the stock 10pt, and a link stored an attribute equal to its default.
2026-09-04 19:30:11 +02:00
Steffen Becker 7e037c03a8 fix: a document keeps its look across the formats, not only its words
A header row is w:tblHeader on the row, the same thing ODF spells with
<table:table-header-rows>. An index's leader and tab stop ride its first row,
the level styles are a family per kind, and the stop is written even with no
page number running to it. A box carries its own ring (w:bodyPr/@lIns) and
where it sits across the column when behind the text.

A section past the first now names its zones even when blank: a sectPr
without a headerReference is Word's Link to Previous, which put a chapter's
running head on the pages a blank section was meant for.
2026-09-04 19:30:11 +02:00
Steffen Becker 8b006d22a8 test: the corpus gains a document whose chapters are numbered
15-chapters carries what only a stylesheet holds: numbering on the heading
styles and two heading styles of the document's own. The round-trip legs now
carry that stylesheet too — a style the file names but the built-ins never
heard of can only be written from there — and one absolute assertion says the
chapters read as headings, which a round trip cannot: both its sides go
through the same importer, so a consistent misreading compares equal.

A numbered heading style rides the package's factory slot; a second definition
under the same id leaves two, and LibreOffice reads the first.
2026-09-04 19:30:11 +02:00
Steffen Becker e12a564f52 fix: a numbered heading crosses into .docx as a heading, not a list item
Chapter numbering rides the heading styles' w:numPr, so re-import read every
chapter as a list item — which swallowed the page break in front of it, since
the break is read for body paragraphs only. Headings now also write their own
w:outlineLvl, so a document setting its chapters in a style of its own
("Appendix 1") keeps them headings. SVG goes out rasterized: Word reads none,
and the importer makes one of every EMF too.

The corpus cross-format leg now carries the file's own stylesheet, where both
the numbering and a heading's style live; measured on one fixture, the leg
ODT → .docx → back loses 0 of 34 headings and 0 of 7 page breaks, was 7 and 7.
2026-09-04 19:30:11 +02:00
Steffen Becker f26de200fe test: the parity harness aligns two documents instead of indexing lines
Comparison stopped at the first divergence on a page, so one displaced line
reported every line behind it and a slipped page hid what followed: a
468-page reference reported 4 differences while hiding 465 pages. The line
streams are aligned instead — nearest resync within 60 lines, contiguous
divergences merged, neighbouring lines off by the same amount collapsed to
a count — and it reports 5766. Reference PDFs are cached by file hash
(corpus 9:00 cold against 5:41 cached), each run prints its counts against
the last, and --quick skips what measured over 50 pages.
2026-09-04 19:30:11 +02:00
Steffen Becker 7203d716af feat: a section can open on a right or left page, blank page and all
ODF says it with style:page-usage right/left on the section's page layout (read
only where the master is no half of a mirrored pair, which alternates by itself),
Word with a w:type oddPage/evenPage section break; both insert the blank page the
side needs, and so does pagination now — the printed number decides the side.
In the ribbon's Breaks menu under "This section". Measured against the reference
that keeps LibreOffice's own blank pages: the largest fixture 875 → 774 reports,
another 25 → 30 with its page count 27 → 29 against LibreOffice's 32.
2026-09-04 19:30:10 +02:00
Steffen Becker 2dacb1f86b fix: the parity reference keeps the blank pages LibreOffice inserts
Its PDF export drops auto-inserted blank pages unless IsSkipEmptyPages=false is
passed, so a document whose chapters open on a right page was measured against a
reference several sheets short of what LibreOffice lays out: 456 pages against 468
on the largest fixture, 28 against 32 on another. The corpus total rises on the
honest reference — that is drift the old one hid, not new drift.
2026-09-04 19:30:10 +02:00
Steffen Becker a5d185fa6d fix: the side of a page follows its printed number, not its sheet
Probed: restart the numbering at 2 on the third sheet of a mirrored document and
LibreOffice puts sheets 2 and 3 both at the outer margin with the left-page zone —
two left pages in a row. Restart at 5 and the parity stays with the sheets. Mirrored
margins, the page border and the odd/even zone now all read `printedPageNumber`.
On a fixture built for it the sheets went 15.0/30.1 → 30.1/15.0mm, LibreOffice's
30.0/15.0. One fixture rises 26 → 62 reports, all downstream of a page-count defect
there: its heads now agree with the numbers we print instead of another layout's sheets.
2026-09-04 19:30:10 +02:00
Steffen Becker 321e410cd9 feat: mirrored margins get a switch, so a new document can be a book
The inner/outer pair was read from a file, rendered and written back, but nothing
could turn it on — a book layout could only be inherited. Both margin UIs get a
checkbox beside the four fields, and while it is on the left/right fields read
Inner/Outer. A margin preset changes the values, not whether the setup is a book.
`withMirrored` keeps the key absent rather than false, so an unmirrored page setup
still compares equal. Driven headlessly: ticking it stores the flag, renames the
fields and moves an even page's text by the difference (1.5/3cm → 57px).
2026-09-04 19:30:10 +02:00
Steffen Becker 61d7e20a0d fix: a frame placed by coordinate floats on the side that has room
Where the file names no wrap side (parallel, dynamic) the importer guessed one
from horizontal-pos and then pushed it across with the frame's own x — so a
picture set two thirds into the column became a left float 9.1cm wide of its
own column, which the browser drops below the text: 64.8mm of empty page, and
everything under it followed. It reads the room on either side of the frame now.
The fixture's page then agrees with LibreOffice to 0.3mm and with its own DOCX
twin, whose count it now shares (24 against 21) — the comparison reaching 20
lines further is what its own number went up by.
2026-09-04 19:30:10 +02:00
Steffen Becker bc87912e5e docs: the page-start spacing switch reaches the space-above rule 2026-09-04 19:30:10 +02:00
Steffen Becker 976cd4beda docs: how to probe the harness with a document of your own
Two traps cost an afternoon: an uncompressed mimetype entry, or LibreOffice
produces no PDF for the file at all, and a font name with no font-face
declaration, which it then resolves differently from the editor — inventing a
0.4mm-per-line difference that no real document has. Plus the note that a
LibreOffice shipping its own Liberation/Carlito copies needs no font install.
2026-09-04 19:30:09 +02:00
Steffen Becker 586ff300aa fix: contextual spacing reaches across a list's nesting
"No space between paragraphs of the same style" stopped at the item a nested
list opens with: its own list-item has no previous sibling, so the walk gave up
and the space stayed. Every step of a nested procedure was ~2.5mm lower than
LibreOffice draws it, accumulating down the page. The walk climbs out of the
item and the list now, taking the nearest paragraph from whichever end it
arrives at. Fixture 85 → 68; what is left there is a line pitch of its own,
which without the extra space now costs the file a page (47 against 48).
2026-09-04 19:30:09 +02:00
Steffen Becker 5059e971ed fix: a text box in a zone keeps its lines instead of vanishing
The zone is a one-paragraph document, so a box's paragraphs had nowhere to go
and were dropped with the drawing — which took the band down with them: a
letterhead whose logo box holds two empty spacer lines came out 7.6mm short on
every page, while the same document's DOCX twin, where the converter had already
flattened the box, was right. They become lines of the zone ahead of the
paragraph that anchors it now, as that converter makes them. Fixture 48 → 44,
and the two formats agree again.
2026-09-04 19:30:09 +02:00
Steffen Becker 80f68f1883 feat: a document can turn off the spacing at the start of a page
LibreOffice's compatibility option (ODF AddParaTableSpacingAtStart, Word's
w:suppressSpBfAfterPgBrk read the other way round). Off, the block that opens a
page loses its space above however the page broke — probed by flipping the flag
in one 63-page document, whose chapter heading then moved from 25.4mm to 20.0mm,
which is where the editor had been drawing every one of them. On is the default
both products write, and both formats carry the flag back out. Fixture 244 → 160.
2026-09-04 19:30:09 +02:00
Steffen Becker 86cacc40b1 fix: the space below a zone's last paragraph is no part of its band
Probed: a header of one 10pt line with a 12mm bottom margin puts the body at the
band's min-height, not 12mm lower, and a footer's trailing margin is dropped the
same way — while a margin between two of the zone's paragraphs counts in full
(two lines 12mm apart give a 20.16mm band). The collapsed zone paragraph carries
only the margins between its source paragraphs now. One 16-page fixture went
183 reports → 5, its body having sat 10.3mm low on every page.
2026-09-04 19:30:09 +02:00
Steffen Becker 1d221c1c56 fix: a zone's tab stop aligns the part of its segment that stays on the line
A running head of left text, two tabs and a long title came out 17.9mm right of
where LibreOffice draws it. Three causes, all in the zone's tab pass: it measured
on the already-aligned line, so the advance it computed was self-referential; a
zone ending in a hard break had a rect on the next line, whose left edge inflated
the segment by its own offset; and a segment measured unwrapped is longer than
what the stop really aligns. Measured left-aligned now, per line, and re-measured
in the zone's own wrapping where the segment cannot reach the stop.
2026-09-04 19:30:09 +02:00
Steffen Becker 0c1eb2626c fix: a section's zones sit at the distance its own page setup gives them
The edge→zone distance was document-wide, so an index whose header starts 4cm
down had its body open 14.5mm too high and the page under it followed. HfSet
carries the section's own pair (and its first page's, where the page style hands
over): ODF's page-layout margin is that distance, Word's is w:pgMar
w:header/w:footer, and both exports write it back.
2026-09-04 19:30:08 +02:00
Steffen Becker 8b6e9fdfb3 fix: a zone of nothing but empty paragraphs is still that many lines tall
The zone collapses its source paragraphs into one, and a break was pushed only
where something had already come out — so a header of three empty paragraphs
came out empty and reserved a single line. The breaks are counted from the
paragraphs now, and the strut takes the style's own size where no run can say it.
2026-09-04 19:30:08 +02:00
Steffen Becker 6bb9eb5416 fix: a page mirrors its margins only where two masters really alternate
style:page-usage="right" on a single layout is a page that starts on the right,
not one half of a book, and swapping its pair drew it 9mm off. It counts as
mirrored now only where masterPageHF found the twin it alternates with. The
even-page shift is per section and per first/rest page too — a cover or an index
between mirrored pages has margins of its own and mirrors nothing.
2026-09-04 19:30:08 +02:00
Steffen Becker 34d5d6014d fix: an imported page margin is the one the file declares
Both importers capped every side at 10cm, which is the Layout panel's limit and
no file's: a back cover pushing its last lines to the page foot with a 21cm top
margin rendered 11cm high. fitMargins caps only what would leave the page no
strip of text at all, and the zone distances follow the page's own height.
2026-09-04 19:30:08 +02:00
Steffen Becker 1f4f5ad4b2 test: the parity harness bands a line by overlap, not by its top
A chapter number set far larger than its title shares the title's baseline but
starts a long way above it, so banding by the top alone read one line as two
and stopped the comparison at the first chapter opening. Words carry their
height now, and a word joins the line it overlaps by half the shorter box.
Corpus unchanged at 9.
2026-09-04 19:30:08 +02:00
Steffen Becker b80440de0e fix: a chapter number is set and placed the way the file describes it
The outline level's character style, its indent and the stop its label's tab
runs to were never read, so a 96pt grey chapter number rendered at the
heading's own size and everything under it sat ~30mm high. The label is an
inline block now — one set larger than its heading grows the line as both
products grow it — hung out of the paragraph's indent with the level's stop as
its minimum width. text-indent inherits into that block and would shrink it by
exactly what it hangs out, so the label zeroes it. Both formats carry all of it
back out (ODF's label alignment, Word's w:suff / w:ind / w:rPr).
2026-09-04 19:30:08 +02:00
Steffen Becker 4ea66725ef fix: a dynamic zone spacing is absorbed by the band, not added to it
style:dynamic-spacing="true" — what LibreOffice writes for a Word header —
makes the gap give way as the zone grows, so the band is max(height, content)
where a fixed gap gives max(height, content + gap). Probed at min-height
7.51mm, gap 6.51mm, 12pt lines: false → 11.37 / 21.11 / 35.71mm for 1 / 3 / 6
lines, true → 7.51 / 14.60 / 29.21. The previous note said it changed neither.
2026-09-04 19:30:08 +02:00
Steffen Becker 43d9a864d4 docs: the band is measured, a manual break keeps its space, headings number
Corrects two probed claims that measurement contradicted: a hard page break
does *not* swallow the block's space above (only an automatic one does), and
the header band is max(declared height, content + gap) rather than a line
count. Adds the chapter-numbering model, the mirrored master pair, and the
harness ceiling a CSS-drawn label is.
2026-09-04 19:30:06 +02:00
Steffen Becker 80463883f0 feat: chapter numbering — headings carry the number the file gives them
A numbered thesis lost every "1.", "2.1.4" and "A." on import: the outline
definition was read as a list wrapping headings and dropped. It is now a
document-level definition on the style sheet (format, prefix, suffix,
display-levels, start per level), drawn by CSS counters that reset down the
levels, and carried by both formats — ODF's text:outline-style, Word's
multilevel numbering bound to the heading styles. The contents rows count the
same way, since no text walk can read a CSS counter.
2026-09-04 19:30:06 +02:00
Steffen Becker 430bef5795 fix: two masters naming each other are a mirrored pair, not a title page
LibreOffice's Left Page / Right Page chain into each other through
style:next-style-name, which was read as the different-first-page idiom: one
master's geometry and zones governed the whole section, so a book-style body
lost its running head on every second page and its inner/outer margins with
it. A next-style cycle now becomes one mirrored setup — the right page's
geometry, the left page's zones as the even variant — and style:page-usage
left/right marks the margins as mirrored.
2026-09-04 19:30:06 +02:00
Steffen Becker 48f7825a29 fix: a section's header sits on the section's own margins
The zone box took the document's margins for its left edge, its width and
its height, so a section with page margins of its own — a mirrored body
after a wider front matter — drew its running head 9mm off the text it
belongs over, and the band was measured at the wrong width.
2026-09-04 19:30:06 +02:00
Steffen Becker 862ba22df2 feat: a section numbers its pages in its own format
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.
2026-09-04 19:30:05 +02:00
Steffen Becker ad12231bf0 fix: a left page keeps the header its master gives every page
Odd/even is per zone in ODF: a master declaring only style:footer-left still
repeats its header on left pages, and reading the pair as one flag blanked
the running head on every even page of a mirrored document. A left element
that is present but empty still blanks its zone.
2026-09-04 19:30:05 +02:00
Steffen Becker 4c8dd00629 fix: a manual page break keeps the block's space above
Only a break the flow had to make swallows it. Probed against the reference,
page top at 20.01mm: a block with 6/20mm above lands at 26.00/40.01 after
fo:break-before, and at 20.01 for either after an automatic break. Every
chapter opening of a book-style document is the manual case.
2026-09-04 19:30:05 +02:00
Steffen Becker ce9dc9bb4d fix: the header/footer band is measured, not counted in lines
The body's margin has to clear the band the zone renders at, and the
estimate counted paragraphs — blind to a line that wraps, to a run smaller
than the 12pt floor, and to the zone's padding. HeaderFooterLayer now lays
each section's zones out off-screen at their text width and reports the
height; the ODF zone's own gap to the body and its padding ride the
collapsed paragraph's spacing, which is where the export already puts them.
Measured on one file: a three-line band read as two, 14.9mm a page.
2026-09-04 19:30:05 +02:00
Steffen Becker 0cf43de1b1 fix: the page geometry is the body's master before the body is read
The dominant master was elected after convertBlocks, so the text width a
table's margins, an inline image's fit and the tab-stop suppression measure
against was whatever master the file declares first (17.0cm against a real
14.7cm on one file). A pre-pass over the body's paragraphs elects it up
front, and the post-conversion election is gone so the two cannot disagree.
2026-09-04 19:30:05 +02:00
Steffen Becker a7cf09a6be docs: the header band is max(declared height, content + its gap)
The note said a header's fo:margin-bottom just hangs into the body and does
not move it. Probed against soffice on one-page fixtures: a 20mm top margin
with a one-line 10pt header puts the body at 24.07mm with no gap and 29.07mm
with a 5mm one, three paragraphs at 37.21, a paragraph wrapping to two lines
at 33.14 (so rendered lines count, not paragraphs), and fo:min-height 20mm at
40.00 — the floor wins, the gap is not added on top. Padding counts too.
2026-09-04 19:30:05 +02:00
Steffen Becker ae7bf878d8 fix: the parity harness reads the case a style paints, not the node's
A style's fo:text-transform / w:caps is a CSS transform in the browser, so
Range.getClientRects' text stayed lower case where the reference PDF has real
uppercase — three headings of one fixture reported a lineBreak that was not
one, and comparison stopped at each. The computed textTransform is applied to
the extracted word now. Reports rise where they were hidden behind such a line.
2026-09-04 19:30:05 +02:00
Steffen Becker 784dd5e06f fix: a table aligned to the margins fills the text width
ODF's table:align="margins" means the table spans between its own margins, and
style:width is then the width the producing page had. Read as a left-aligned
table, the undeclared right margin was derived from that width against ours and
the table came out narrow — measured 12.4cm on a 14.7cm text width, its column
rule 10.4mm left of where LibreOffice draws it.
2026-09-04 19:30:05 +02:00
Steffen Becker ab8b887889 fix: a page set in Arial is not rendered in the fallback serif
Import maps a document's Arial to Liberation Sans (screenFontName) and the
model keeps it under that name, but only 'Arial' had an @font-face — so on a
machine without Liberation Sans installed every such run fell to the browser's
default serif. The bundled TTFs are now declared under their own family too,
as Liberation Serif already was.
Measured against LibreOffice on a 28-page fixture: its cover lines came out
7-16% narrow, 54 reports to 44.
2026-09-04 19:30:05 +02:00
Steffen Becker 583bc0ddd8 fix: a list number wider than the hanging indent pushes the text
The marker pseudo is floated into the indent instead of absolutely
positioned, so a label the indent cannot hold — a "1.1.1." outline number,
a Roman numeral — moves the first line right the way both word processors
do, rather than printing over the words it labels. Out of flow either way,
so no list line grows.

The PDF clone's injected marker follows suit, and its chain now reads the
effective numbering and walks to the outermost multilevel list: a list
numbered by a named style had none of its own and printed a bare separator.
2026-09-04 19:30:04 +02:00
Steffen Becker 7a17fc7995 fix: Paste is one button, and its menu always opens
Split in two, its command half pasted straight from the clipboard and only the
caret offered the plain-text variant. The whole button now opens the menu,
whose first entry is that same straight paste.
2026-09-04 19:30:04 +02:00