Commit Graph
315 Commits
Author SHA1 Message Date
Steffen Becker 6abc7f7704 refactor: the kept versions are offered at a failed load, not in the menu
A list of timestamps in both file menus paid for itself only on the day
something went wrong, and it was in the way on every other. The entries
are gone; loadDocument now offers the versions where the browser copy has
actually failed the reader — newest first, one question each, and the one
taken up starts under the boot flag like any other document. The store
loses its reactive singleton with them.
2026-09-04 19:30:13 +02:00
Steffen Becker e7d458aca5 fix: the unsaved dot counts a rename too
The name decides which file the next save writes, so it belongs in the
checksum beside the text and the page setup. The DOM run renames a
freshly opened document and expects the dot.
2026-09-04 19:30:13 +02:00
Steffen Becker 616851f62f fix: the unsaved dot counts the page setup and the styles too
It is now an FNV-1a checksum over the text plus everything exportArgs()
hands the exporter, so a margin preset marks the document as much as a
keystroke does and an undo back to the saved state clears it again. The
page count stays out — a late-loading font must not mark anything. The
beat is throttled, not debounced, and the baseline is taken on the spot
instead of in the timer, which a repagination can push past a first edit.
The DOM run checks all four states.
2026-09-04 19:30:13 +02:00
Steffen Becker c34299ca73 test: a browser run over the pagination and the editing invariants
npm run test:dom opens a corpus document in headless Chromium and holds
its page count against a settle, a reload, a zoom, a page break and its
undo, plus the unsaved dot — the components the vitest suite cannot see.
The smoke run now shares its boot with it (tests/browser.mjs).
2026-09-04 19:30:13 +02:00
Steffen Becker ef8d219cdd feat: the autosave keeps the last three versions of the document
One version every five minutes goes into an IndexedDB store, pictures
inline, and both file menus list them by time; a click puts that text
back and leaves the page setup, the styles and the zones alone. The
browser copy is one document deep, so a bad edit had nothing behind it.
2026-09-04 19:30:13 +02:00
Steffen Becker b8ff3d764f feat: Save As offers both document formats in one picker
The picker lists .odt and .docx, and the chosen extension decides which
exporter runs and which format the document keeps — no silent switch to
.odt for a file that came in as .docx. Both save paths now build their
bytes through one helper instead of repeating the argument list.
2026-09-04 19:30:12 +02:00
Steffen Becker 87743fdd57 perf: the word count and the name placeholder no longer walk the document per transaction
A caret move recounted every word; a blank title serialized the whole
document to JSON on every transaction. The count now runs once per changed
document, deferred off the keystroke, and the placeholder reads the first
heading straight off the document.
2026-09-04 19:30:12 +02:00
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 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 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 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 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 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 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 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 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 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 b3b8e58bd4 test: LibreOffice opens what we encrypt, and we open what it encrypts
Five legs in tests/lo-password.test.ts, driven through the repo's first UNO
helper because --convert-to cannot pass a password; self-skips without soffice
or python3-uno and runs with npm run test:lo. The probed format details live in
docs/architecture/encryption.md.
2026-09-04 19:30:04 +02:00
Steffen Becker 2a20f1d107 feat: a .docx can be saved encrypted too, and an encrypted one opened
Writes agile encryption with Word's own parameters (AES-256-CBC in 4096-byte
segments, 100000 SHA-512 rounds, HMAC integrity) into a compound file built
here — cfb.ts, MiniFAT and DIFAT included. Reads that and the older standard
encryption LibreOffice writes, whose package is AES-ECB. Verified both ways
against LibreOffice.
2026-09-04 19:30:04 +02:00
Steffen Becker 4b163e6d10 feat: a document can be saved encrypted with a password (ODF)
Writes LibreOffice's whole-package form: AES-256-GCM keyed by Argon2id over
SHA-256 of the password, the deflated package as one encrypted-package entry.
Reads that plus the per-entry AES-CBC/GCM form with PBKDF2 that every earlier
version writes; the nonce sits ahead of the ciphertext, which no manifest says.
Blowfish and unknown algorithms report an unsupported-encryption error.
2026-09-04 19:30:03 +02:00
Steffen Becker de71830922 feat: a comment or a change gets a balloon beside its own line
ReviewMarginLayer draws the cards in a strip right of the page, at the height
of the line each belongs to, with a dashed leader running through the gap
under it. A balloon is pushed down by the one above (balloonStack, measured
per card, unit-tested) and the column belongs to its page, so it never runs
past the sheet. The strip is only reserved where the margin has something to
show; the smoke test guards a card's content against its box.
2026-09-04 19:30:03 +02:00
Steffen Becker 6d5db7135b feat: one reading of the review view for every layer that draws it
storage/markup holds the display mode (all, simple, none, original) plus, per
kind, whether it shows and where its cards go; markupView folds that into what
the page acts on. Bars, leader and both panes ask reviewItems instead of the
document, the page takes the hiding attributes, and trackChanges drops its
author decoration where the mode shows a finished text.
2026-09-04 19:30:02 +02:00
Steffen Becker f338aec89c feat: a comment carries the answers to it
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.
2026-09-04 19:30:02 +02:00
Steffen Becker 8c080b1d16 fix: a Word index takes the file's own entry styles
A TOC field regenerates its rows from the styles named `toc 1`…`toc 9`, and its
cached rows are skipped on import — so those styles were never seen and the index
fell back to the editor's 0.5cm per level, dropping the file's space-after with it.
Measured on a 60-row index: a 5.1mm row pitch against LibreOffice's 6.9mm.

They now land as Contents 1…10, and the DOCX export writes the rows back under
them instead of the fallback indent.
2026-09-04 19:30:02 +02:00
Steffen Becker 198c16f7ab fix: a Word bibliography no longer imports twice
Word caches a BIBLIOGRAPHY field's rows as a w:tbl inside the field, and
only the field's paragraphs were skipped — so the list arrived once as the
regenerated index node and once as that table. Measured on a 49-page
document: two pages of duplicate. StyleName IEEE now reads as the numbered
style it is, so its citations show [1] instead of the source key.
2026-09-04 19:30:02 +02:00
Steffen Becker f1f9a4df15 fix: a text box survives a paste from another window
The clipboard carries HTML, and in an HTML string any block tag inside a
<p> closes it — so a copied box arrived as loose paragraphs. It is now
serialized as an inline span carrying its blocks as JSON, read back by a
matching parse rule; its plain text stays in the span for readers that
cannot take the attribute back.
2026-09-04 19:30:02 +02:00
Steffen Becker 572e3a760d fix: an AutoText entry keeps a text box
Entries were stored as an HTML string, where any block tag inside a <p>
closes it — so a copied text box, which holds blocks, lost its place in
the line. They now keep the slice's nodes; an entry written earlier keeps
its HTML string, which insertContent takes just as well.
2026-09-04 19:30:01 +02:00
Steffen Becker 8d3139439c fix: the caret reaches the position a text box occupies
A box that starts its paragraph was a trap: there is no text position beside
it, so the browser pulled the caret meant for the box's own place in the line
into the frame and everything typed there landed inside it.

A frame nobody is editing is now an atom to the browser — a contenteditable
node decoration, dropped while the caret is in its text, the frame placing the
caret itself on the click that enters it. ArrowLeft/ArrowRight at the box's
first and last text position step out to it. The decoration walk enters blocks,
without which it never reached an inline box at all.
2026-09-04 19:30:01 +02:00
Steffen Becker f0cf382fb0 fix: only a band-wrapped text box gets the alignment buttons
A box in the line is a character its paragraph places, as a picture is, so the
three frame-toolbar buttons were the paragraph's own alignment under a frame
icon — a second place to look for the same thing. They now show for the band
wrap alone, where nothing else can express the position.

The ordinary command needed one correction to carry that: a selected box is one
node whose range covers its own paragraphs, so aligning it moved the frame and
re-set its text in the same keystroke.
2026-09-04 19:30:01 +02:00
Steffen Becker 060dcd7380 feat: a text box sits in the line of text
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.
2026-09-04 19:30:01 +02:00
Steffen Becker 21999afbc6 fix: a DOCX text box keeps the height it declares
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.
2026-09-04 19:30:01 +02:00