Commit Graph
184 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 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 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 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 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 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 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 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 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 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 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 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 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 4aef4d7641 fix: keep rows a cell spans on one page, and clear floats before an in-flow box
A break between spanned rows stretched the cell across the gap: the
merged table drew over the page gap and the next page's header. A
group too tall for a page still breaks per row. An in-flow text box
took the same treatment for floats — CSS lays a block box over one
instead of beside it, which had the vertical frame covering its
neighbour. Both match LibreOffice's render of the same document.
2026-08-30 10:06:44 +00:00
Steffen Becker 6fa05fa097 test: cover the status bar's text statistics
countText: word/char/paragraph counts, block boundaries as word
breaks that count as no character, atoms as no text, ranges.
2026-08-30 10:06:43 +00:00
Steffen Becker 104ec2366c fix: suppress the base direction's own edge when reading w:jc
The DOCX import dropped left as the default and kept right regardless
of w:bidi; an rtl paragraph's default edge is right. start/end read as
physical left/right — LibreOffice spells an rtl paragraph's physical
left as w:jc="start" (probed via its own DOCX export).
2026-08-30 10:06:43 +00:00
Steffen Becker 27a8f4a11e refactor: extract the chapter-field resolution into a pure helper
utils/chapterField owns the per-zone reading (header = the chapter at
the page top, footer = the last begun) so the probed LibreOffice
semantics is unit-tested; the DOM measurement feeding atTop stays in
Editor.svelte.
2026-08-30 10:06:43 +00:00
Steffen Becker 78e8b81bf7 test: pin the fixes the round trip alone could not see
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.
2026-08-30 10:06:43 +00:00
Steffen Becker 35059d8126 fix: export plain text boxes as Writer text frames
Without a parent chain to the Frame style LibreOffice read the box as a
drawing object, whose text loses lists, nested images and borders. The
frame takes fo:background-color/fo:border and its writing mode in the
graphic properties; an inline box's centring rides the anchor paragraph.
The import reads the frame spellings back and drops LO's from-top/0
respelling of the default alignment.
2026-08-30 10:06:43 +00:00
Steffen Becker d9496abe4f fix: write the cell-format picture with the document language's separators
Word reads a \# picture with the reader's regional separators, so a
German Word took "0.00"'s dot as grouping and printed 84 as "084".
The importer now reads a picture back by its shape, whichever locale
wrote it.
2026-08-30 10:06:43 +00:00
Steffen Becker 3d305a9920 fix: write STYLEREF by outline level, not style name
Word resolves the quoted style name against the localized built-in
name, so 'STYLEREF "Heading 1"' shows a field error in any
non-English Word. The numeric argument means outline level in Word
and LibreOffice alike (probed), and the importer already reads it.
2026-08-30 10:06:42 +00:00
Steffen Becker 525c95f3a8 feat: add Numbering 1.a.i, the default cycle as a preset
The gallery avoids presets the dropdown plus default nesting already
covers, but the default cycle itself earns its slot: once a list wears
another style, switching back needs a style to switch to. No sheet
version bump — an added built-in survives the merge on its own.
2026-08-26 14:03:55 +00:00
Steffen Becker 6f57a8e7ed feat: level-varied preset gallery for the list-style built-ins
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.
2026-08-26 09:58:33 +00:00
Steffen Becker 697eea4d86 fix: a list style's level decides the depth's kind
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.
2026-08-26 09:21:45 +00:00
Steffen Becker e2525237de feat: named list styles — model, editor resolution, ODF round-trip
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.
2026-08-25 20:38:15 +00:00
Steffen Becker 7dd997cbfa feat: four draft templates, shelved out of the gallery
Cover letter, CV, invoice and meeting minutes as builder modules on a shared
builders.ts (JSON helpers + the 45mm window-envelope arithmetic), localized
en/de. They sit in the registry's SHELVED list: compiled and schema-checked
per locale by tests/unit/templates.test.ts, but not offered in the gallery
until their content is settled. Gallery gains per-template thumbnails.
2026-08-25 16:03:56 +00:00
Steffen Becker ca29ff780b fix: make the recent-files list only offer files it can reopen
A file opened without a File System Access handle (no API — Firefox,
Safari, Brave — or a template) is no longer recorded: nothing could
ever reopen it, so every click failed and dropped the entry. A startup
prune clears such stale entries. Reopening now asks for 'read' instead
of 'readwrite' (Save re-prompts on its own), and a declined permission
prompt keeps the entry — only a moved or deleted file drops it.
2026-08-23 18:54:52 +00:00
Steffen Becker de7f63f269 fix: Enter frees the caret from a leading table
At the very first position of a table with no block before it, Enter
inserts a paragraph above the table and moves the caret there, as both
word processors do — previously the caret could never get before such
a table. Generic first-child check, so it also covers a table leading
a cell, text box or columns section.
2026-08-20 14:26:57 +02:00
Steffen Becker a87e7a4ada fix: a heading's style name never lands on a plain paragraph
styleName was kept on split, so Enter at the end of a heading left a paragraph
carrying it: drawn like a heading, absent from the navigator's outline, exported
as text:p. It is no longer kept there. setParagraphStyle also missed the name
whenever it switched the node type — a following updateAttributes looks for the
new type in the pre-chain state — so it now rides along with setNode. Levels 2–6
never reached that command at all: the digit class built from MAX_HEADING_LEVEL
(10) read as [0-1], so Heading's own Mod-Alt-N took the key and set a level with
no style; that keymap is dropped, running ahead of every plugin binding.
2026-08-19 18:41:21 +02:00
Steffen Becker 10b650c64c refactor: the floating island's mode id is 'modern', matching its label
The id said 'classic' while the UI called the ribbon Classic and the
island Modern (beta). Type, i18n keys, comments and docs now agree;
a stored legacy 'classic' still selects the island.
2026-08-17 20:05:15 +02:00
Steffen Becker 847fe59d3d feat: a frame the text runs through reserves no space at all
Word's in-front-of / behind-text (wp:wrapNone + behindDoc) and ODF's
style:wrap="run-through" both read as a side float, so a near-column-wide
picture reserved its whole height and pushed the text 23-52mm down. They
are a wrap mode of their own now: out of flow at the static position it
was anchored at, z-order from the file (inFront). Both exports carry it
back; tests/unit/wrap-through.test.ts covers the round trip.
The image fixture 72 -> 38 over both formats and 7 pages -> 6, matching
the reference; corpus 1108 -> 1074, no other fixture moved.
2026-08-17 13:45:39 +02:00