100 Commits
Author SHA1 Message Date
Steffen Becker 7a9e30d998 chore: release 0.4.0
Everything since 0.3.0 on 2026-09-09: a cover page as the file draws it — page-placed
frames, themed shape colours, floating tables, drawing groups — the run-through wrap
modes as pickable frames, a text box's vertical anchor, the fonts a saved document
carries with it, the header band's height and the table inside it, and the selection
surviving an attribute change.
2026-09-12 02:35:43 +02:00
Steffen Becker f33e708b84 feat: a text box anchors its text top, middle or bottom
Both formats carry a vertical anchor on the box body — ODF
draw:textarea-vertical-align, DOCX wps:bodyPr/@anchor — and both
sides of the I/O hard-coded "top", so an imported box dropped its
centering and a saved one never got it. New textVAlign attr, read
and written in both formats, rendered by flexing the rotor (the
handles are absolute and stay out of that flow), set in the Shape
Format tab beside the vertical-text button.
2026-09-12 02:35:02 +02:00
Steffen Becker fc5c39322f fix: the event-opened dialogs live where they are always mounted
The ribbon mounts only the open tab, so the link, bookmark, cross-
reference and formula dialogs in InsertTab.svelte heard neither Ctrl+K,
nor the context menu, nor a double-click on a formula while any other
tab was up. They move to Ribbon.svelte; the Insert tab's buttons fire
the same events, and a formula event without a position opens the empty
dialog the button used to open directly.
2026-09-12 02:35:01 +02:00
Steffen Becker 57dccb26fa fix: editing a formula no longer deselects it
Same leaf-node defect as the frames: setNodeMarkup replaces the atom and
the node selection maps off it, so the dialog's Apply dropped the
selection ring from the formula it had just changed. The frames' helper
is now shared as atomAttrTr and carries the leaf's own marks.
2026-09-12 02:35:01 +02:00
Steffen Becker b06357536a fix: an attribute change no longer deselects a picture
setNodeMarkup replaces a leaf node outright, so the node selection maps
off it: setting the wrap mode, resizing or rotating closed the frame's
floating toolbar and the ribbon's contextual tab, which made picking a
second mode take an extra click on the picture. frameAttrTr puts the
selection back where it stood.
2026-09-12 02:35:01 +02:00
Steffen Becker 5b287ef57b feat: behind and in front of the text, pickable and draggable
Both frame toolbars and the ribbon's Arrange group now offer the two
run-through entries; they are one wrap mode, told apart by inFront.
Picking any mode by hand drops the offsets and the coordinate system
they were measured in (droppedFrameAttrs), so a page-placed frame set
to a side wrap no longer exports page-relative coordinates for a
paragraph-relative offset.
A frame out of the flow has no text position to re-anchor to, so the
drag adds the pointer delta to its own offsets (startFreeMove).
2026-09-12 02:35:00 +02:00
Steffen Becker a260969138 ci: a version tag publishes the built dist/ as a release archive
Serving the app then needs no Node toolchain: download the archive from the
release, unzip, point a web server at the folder it unpacks as. The notes come
from the changelog section for that version. The tag is held against
package.json first, whose version the build stamps into the app.
2026-09-12 00:51:08 +02:00
Steffen Becker 4476b530a4 docs: update contributors 2026-09-12 00:50:31 +02:00
Steffen Becker a8ecd8ce06 feat: a saved file carries the fonts it was opened with
Both importers already read a package's embedded faces and register them, but
nothing wrote them back: a document set in a font nobody has installed lost its
look on the next machine. ODF gets plain TTFs under Fonts/ with a manifest entry
each, merged into the font-face declaration the name already has; Word gets a
per-part GUID, an obfuscated .odttf and its w:embed... in the family's w:font.
The picker lists the families too, and a slow store read no longer replaces the
faces of a file opened while it was still running.
2026-09-12 00:36:03 +02:00
Steffen Becker 1b7d2e0de8 feat: a drawing group opens as the frames it holds
The editor has no group node, so wpg:wgp is mapped out of its child
coordinate space: the first child keeps the group's own placement — which is
what reserves an inline group's box in the line — and the rest run through
the text over it at their place in the group. A label over a picture needs
the box in front of the text, so inFront now rides a text box through both
formats as well.
2026-09-11 21:31:00 +02:00
Steffen Becker 1789505431 fix: a page-placed frame is measured from the page's corner
Word's positionH never counts from the anchor character, and the page a
frame lands on changes as pagination inserts spacers above it. placeFromPage
measures the frame's static position and turns both offsets into the margins
that reach the page corner — x from the text column, y from the page top —
and every pagination pass re-places them. A run-through text box can now sit
in front of the text (inFront) instead of always behind it.
2026-09-11 21:30:53 +02:00
Steffen Becker 280726f730 docs: run only the test legs a change can break
A new rule in CLAUDE.md: weigh which legs the change touches, run each once
when its input is final, no up-front baseline round. Reclaimed the four lines
it costs by tightening the commit-message and documenting-a-change rules.

Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
2026-09-11 20:26:32 +02:00
Steffen Becker fd82080115 feat: a floating table opens as the frame that holds it
Word's w:tblpPr takes a table out of the flow; the editor kept it in, so
whatever followed — and every frame anchored to it — sat as far down as the
table is tall. It now arrives as the box whose content a table may be, which
is the shape LibreOffice keeps such a table in, and both exporters write it
back as the file's own floating one. A drawing in one of its cells is
ordinary table text, so the box-in-box guard lets it stand.

Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
2026-09-11 20:06:22 +02:00
Steffen Becker 2a40d4383f test: the typing budget is per engine, and the 90th percentile
One absolute number for three engines could not hold: they are three times apart on
this document (laptop median 19 ms in Gecko, 40 in Blink, 55 in WebKit) and a CI runner
is about twice a laptop, so WebKit failed at 116 ms against 100. Each engine now holds
double what CI measures, on the 90th percentile — the first stroke of a burst warms
caches and a shared runner stalls once in a while, which no typist feels.

Claude-Session: https://claude.ai/code/session_01Gpw1ctsR8FrdNMrpqePtik
2026-09-11 16:28:21 +02:00
Steffen Becker 01501398ad perf: the zone's own box carries the margin-band hit area
A second element per zone doubled the header/footer layer — 150 absolutely placed
boxes in the 75-page window, laid out again on every pass. The zone's ::after grows
out to the page edges instead, which keeps the hover hint and the double-click on
the element that already handles them.

Claude-Session: https://claude.ai/code/session_01Gpw1ctsR8FrdNMrpqePtik
2026-09-11 16:28:21 +02:00
Steffen Becker 6d53e4f5c4 fix: a long ribbon label widens its button instead of overpainting
The 78px cap cannot wrap a word without a space in it, so a German compound
painted past the button and across its neighbour. A hugging label drops the
cap and sets its own width.
2026-09-11 15:42:16 +02:00
Steffen Becker e2ef65d8cd fix: the whole margin band opens the header/footer
A double-click only reached the zone box (~28px of a 76px band), so the part
nearest the page edge did nothing; a hit rect now covers the band edge to edge.
The trailing-break discount no longer applies to an empty zone, where that break
is the caret line itself — the placeholder sat a line below the typed text and
jumped up on the first keystroke.
2026-09-11 15:40:04 +02:00
Steffen Becker 5c0ac978f4 fix: Enter keeps an empty line inside the columns section
The base Enter chain's liftEmptyBlock lifted an empty block out of the
section (and split the fragment mid-run), so the caret left column 1 for
the page below instead of moving on to column 2. Inside a section Enter
now only splits the block; a list keeps its own escape.

Claude-Session: https://claude.ai/code/session_01Gpw1ctsR8FrdNMrpqePtik
2026-09-11 14:40:26 +02:00
Steffen Becker e70c1e9c6c fix: only the primary button picks a frame behind the text
A right-click over one selected it and swallowed the mousedown, where the
browser's own context-menu handling should have the point.

Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
2026-09-11 09:10:29 +02:00
Steffen Becker 85036b9559 feat: opening a document says when a floating table was flattened
Word's w:tblpPr takes a table out of the flow — a cover page's layout table is
one — and the editor's block table cannot, so what follows it sits as far down
as the table is tall, a frame anchored there with it. Nothing in the document
said so; the import now warns, as it does for the other shapes it cannot hold.

Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
2026-09-11 09:10:29 +02:00
Steffen Becker 98530afe3b fix: a divider line is saved flat, not across its frame
A line shape carries no height, and both exporters stood in the 96px default a
text box gets — so a horizontal divider was written across the frame's
diagonal and came back as one. A line kind keeps the 0 it has.

Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
2026-09-11 09:10:29 +02:00
Steffen Becker 9c9362ac8c fix: a .docx zone keeps the space above its own line
The zone importer read the band's height off a table's rows but not off the
paragraph itself, so the value our own export writes for it came back as
nothing: saving a document with a tall header and opening it again put the
body 8mm up and the rule with it. The first line's w:spacing (direct, else its
style's) now joins the rows' extra, as its alignment and tab stops already do.

Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
2026-09-11 09:10:29 +02:00
Steffen Becker 1e7d38e561 fix: a heading in a table cell keeps the spacing its file gives it
No style name reaches a cell, so the editor draws the level's own 12pt/6pt
margins there — but a Word heading whose style chain sets no spacing has none,
and the chain is baked into the cell's blocks, so its silence is a real 0. It
is written as one now; a cell paragraph's 0 stays unset, as it was. A subtitle
under a heading came out 4.2mm low, and everything under it with it.

Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
2026-09-11 09:10:28 +02:00
Steffen Becker b233f0e160 fix: a header zone is as tall as the row the file draws it in
The zone's table rows carry their height in w:trHeight, which the collapsed
paragraph had no way to hold: a 2.2cm header band read as one line and every
page's body started 8mm high. The height its lines do not fill now rides the
paragraph's space above, which the band measurement already takes — and a
header draws that space too, so the rule line the cell carries lands at the
foot of the band instead of under the first line. A footer's space above stays
a measurement: it holds the ODF zone's own gap to the body.

Measured on one template: 15 parity reports against LibreOffice down to 6.

Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
2026-09-11 09:10:19 +02:00
Steffen Becker 9dfaeb4032 fix: a frame behind the text can be picked again
The browser hit-tests a behind-text frame last of all — the page and every
paragraph over it come first — so a cover picture could not be clicked at all:
the caret landed in the text as if it were not there. A mousedown over one now
goes to the frame's own node view, which selects and drags it as any other
frame; text painted over the point still wins, as the caret does in both
products. A box's node view mirrors renderHTML's data-wrap for it, as an
image's already did.

Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
2026-09-11 00:03:04 +02:00
Steffen Becker 9f5ecd8071 fix: a frame placed against the page lands there
Word's positionV relativeFrom="page" and ODF's style:vertical-rel="page" — how
a cover page's own blocks are placed — were dropped by both importers, so every
such frame sat at its anchor paragraph instead: a title page's coloured blocks
landed wherever the text happened to end. The offset now rides `wrapFromPage`
on the frame, the node view resolves the page the anchor lands on from the page
grid, and both exports write the relation back.

A shape behind the text also goes under a picture behind it (z-index -2, the
sheet moving to -3): probed against LibreOffice, which paints a cover picture
over a shape whatever stacking order the file gives them.

Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
2026-09-10 23:53:26 +02:00
Steffen Becker 90132fd384 fix: a line shape of no height draws again
A divider line is written as a frame of zero height, and the node view handed
that straight to the SVG's viewBox and viewport — a zero-high viewport turns
rendering off altogether, so the line was simply absent. The viewport is now
at least the stroke, which it overflows by half as in any flat frame.

Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
2026-09-10 23:40:57 +02:00
Steffen Becker f4870ca4d3 fix: a shape's fill and stroke resolve the document theme
Only <a:srgbClr> was read, so every shape naming a theme slot — which is what
a Word template's own colours are — came back unfilled and its rules black or
gone: a cover page lost both its coloured blocks and its divider lines. The
scheme is read from theme1.xml by slot, with the text/background aliases
Word's default mapping gives them, and the lumMod/lumOff/tint/shade modifiers
applied per channel.

Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
2026-09-10 23:40:57 +02:00
Steffen Becker 825af46ba4 fix: a date/time format outside the catalog stays a live field
Both importers matched a file's format against the seven date and four time
formats the picker offers and kept anything else as the text the producer had
cached — a Word template's `DATE \@ "MMMM d"` then froze at the day it was
written. An unlisted format now rides its own DOCX picture as the key:
`findFormat` parses it, so display, ODF number style and DOCX picture are
derived from it as they are for a catalog entry, and it re-exports unchanged.

Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
2026-09-10 23:34:55 +02:00
Steffen Becker dca3c21e2c fix: a header/footer table's text and rule line reach the zone
A .docx zone holding a table lost everything in it: hfParagraphs walked only
the direct w:p children, so the one-cell table every Word template draws its
rule line with left the zone empty. Its cells' paragraphs now become lines of
the zone, as they already do on the ODF side, and the cell's own borders and
shading (its w:tcBorders over the table's w:tblBorders) become the collapsed
paragraph's box — which is what draws the rule.

Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
2026-09-10 23:31:49 +02:00
Steffen Becker fe00c8bc44 chore: release 0.3.0
Everything since 0.2.0 on 2026-09-06: the run of load and typing latency work,
each tab editing its own document, the format prompt on a first save, the
unsaved-close warning, and two fixes.
2026-09-09 23:18:08 +02:00
Steffen Becker 0dd66c977f perf: the whole-document spell check waits for the pagination to stop
The check was queued in idle time, which on a long document falls between two
pagination passes: its decoration swap redraws the view, and the pass behind it
pays for the redraw. It now waits until no pass is running or queued
(`isPaginating`), so an opened file paginates first and is checked after.

Measured on a 460-page file: settled 5.99 s → 5.10 s (interleaved A/B, median
of seven; a second set of nine gave 5.76 → 5.34). Spacer, index and text-box
geometry byte-identical on five files.
2026-09-09 20:53:20 +02:00
Steffen Becker e399a3a62c perf: the header/footer layer draws the pages being read
A long document placed a zone box on every page, and the browser laid all of
them out again on every pagination pass. Only a window around the current page
carries its zones now; a capture or a printout opens it to the whole document
first, so the PDF export and Cmd+P are unchanged.

Measured on a 460-page file: settled 6.17 s → 5.27 s (interleaved A/B, median
of seven). Spacer, index and text-box geometry byte-identical on five files.
2026-09-09 09:35:17 +02:00
Steffen Becker 2f51292587 refactor: a field round reads the page grid once
Both indexes and the cross-references each read it for themselves, twice per round in
the index's case: 59 readings of it over a load where 37 do. The round hands one to
every reader instead, so they all resolve their pages against the same grid, and the
lazy holders they kept go with it. No measurable time — the style pass a reading forces
is owed to the geometry read that follows it either way.
2026-09-09 09:01:29 +02:00
Steffen Becker fed2ccedad perf: the ribbon looks for a mounted panel where one can be
Reading an added node's position computes the style of the whole document, and the band
rebuilds its galleries whenever a document is opened: 300 ms of that during a load, for
40 gallery buttons and no panel at all. A click or a key in the band still classifies its
records at once; anything else is looked at a task later, where the style is there for
free. A 460-page file is on screen 270 ms earlier, every panel pinned as before.
2026-09-09 08:48:02 +02:00
Steffen Becker 7e09cf3b8b perf: the whole document is spell-checked in idle time
Checking every word of a 460-page file costs half a second, and at mount it was paid
before the document was on screen. The pass now waits for idle time (2 s timeout), as
does the one a language or dictionary change asks for; the check after an edit is
unchanged. The file is named 200 ms earlier and settles 700 ms earlier, with the same
page count, spacers, page numbers and references.
2026-09-09 01:12:56 +02:00
Steffen Becker 8cc6c6e20d perf: the caret's page is read after the frame, not before it
coordsAtPos called straight from the selection handler lays the whole document out
before anything is painted — half a second on a 460-page file, and the writes of the
load that follow it throw the result away. The read is queued as its own task and
coalesced, so a run of selection changes costs one.
2026-09-09 01:12:49 +02:00
Steffen Becker 7e687b6ccb perf: the document's fields answer a pass in one round
Every field resolving against the settled layout — each index's page numbers, every
cross-reference — registers with scheduleFieldRound instead of taking a frame of its
own. A round measures for all of them before any of them writes, since a write between
two measurements lays the whole document out again, and their cached results ride one
transaction: a dispatch of its own costs a view update over the document, 55 ms per
index on a 460-page file. That file settles about 220 ms earlier, with every spacer,
page number and reference exactly where it was on five documents.
2026-09-09 00:51:54 +02:00
Steffen Becker 50c7ec5236 perf: one refit for every frame, and a pass that moves nothing stays quiet
A single ResizeObserver refits the frames, reading each rotor before any wrapper is
written: a per-view one arrives in a callback of its own, so the browser laid the
document out again between two frames. pm-pagecount is announced only where the layout
differs from the last announcement — every reader re-reads the whole document from it and
writes back, which is another pass. Both node views also leave the DOM alone when update()
brings the same attrs object. A 460-page file holding 450 frames and two indexes settles
2.4 s earlier and is named 200 ms earlier, with every spacer and page number where it was.
2026-09-09 00:08:36 +02:00
Steffen Becker 6693d04313 perf: an opened file is inflated once and its pictures encoded by the browser
The format pre-pass and the importer read the same inflated archive, where each
unzipped the file for itself, and a picture is encoded through the browser's
Uint8Array.toBase64, the chunked loop standing in where there is none.
On a 14 MB file holding 600 pictures that takes 250 ms of inflating and 285 ms of
encoding off the open: it lands 480 ms earlier and settles 240 ms earlier, and a
small illustrated file lands 170 ms earlier.
2026-09-08 23:02:33 +02:00
Steffen Becker da857fcbb8 perf: the load passes batch their field work and flow a section whole
Cross-references resolve in one batch per pass, the index reads every leader gap
before filling one, and a zone's tabs lay out with the other zones' — and only
where a field asks for the page count or the chapter map.
List marker and list style decorations are rebuilt only for a transaction
reaching into a list; the unsaved dot compares documents structurally, and the
autosave serializes when it writes rather than per keystroke.
The columns flow applies every op it can judge in a pass, so a section pages in
one pass. Two long files that took 33 s and 20 s now settle in 9 s and 2 s.
2026-09-08 22:31:01 +02:00
Steffen Becker 3af5a00fed fix: a page-anchored frame no longer crashes the editor while a saved document loads
The image NodeView reads the page grid from the view ProseMirror hands it at
construction; editor.view is a throwing proxy until the view is built, and a
saved document with such a frame is passed in exactly then.
The dom leg loads its band document through the autosave, the path a reload
takes. The layout baseline is re-recorded for the two showcase files whose
second page was reworked after the baseline was taken.
2026-09-08 21:08:38 +02:00
Steffen Becker 5db1c37468 perf: the pagination pass no longer restyles or churns the whole document
A `:has()` left of `+` in editor.css made Chromium restyle every element
under .tiptap on each inserted child, so the band-clearing rule keys on a
data-wrap-band attribute the imageLine plugin writes. getLineRects reuses
one Range instead of leaving hundreds alive. TipTap's Placeholder, which
hit-tested the viewport on every keystroke, is replaced by placeholder.ts.
docPosBeforeElement is memoised per pass. At 124 pages the pass after a
split drops from 410 to 150 ms and the split keystroke from 219 to 50 ms.
2026-09-08 20:16:35 +02:00
Steffen Becker 1a4b321b5a perf: the spell check re-reads the edited paragraphs, not the document
The dirty ranges ride the plugin state and are mapped through every
transaction; the check after a pause swaps only those textblocks'
squiggles, and the whole document is checked on a language or
dictionary change. The pause after a keystroke in a 124-page document
drops from 363 ms to 11 ms (Chromium) and from 1.4 s to 17 ms (Firefox).
A unit test drives the recheck; the dom run asserts the pause is free.
2026-09-08 18:07:41 +02:00
Steffen Becker 2fd9c8a06c perf: typing waits out the pass, and a block as tall as before skips it
An edit's pass waits for 300 ms of quiet instead of running between keys, so
while the typing goes on only the keystroke itself costs. When the quiet comes,
edits that only put text into one textblock and left the content's bottom where
the last pass had it skip the pass: the block is as tall as before, so nothing
below it moved. A break inside the block still runs it. Measured at the top of
a 124-page document: 41 ms a keystroke in Chromium, no pass for a letter typed
and taken back. The dom run holds both.
2026-09-08 17:35:09 +02:00
Steffen Becker 50aeb4a570 perf: a keystroke keeps its spacers, and typing waits for a pause
The page-break decorations are mapped through every transaction and each
spacer widget carries a key, so a pass that lands the same spacers keeps
their DOM instead of rebuilding all of them. An edit within 150 ms of the
last pass waits for a pause in the typing. Measured at the top of a
124-page document: 1.5 s a keystroke before, 40 ms after. The dom run
holds the budget.
2026-09-08 16:48:41 +02:00
Steffen Becker b2dcfa3aaf chore: a sponsor button points at the project's PayPal link 2026-09-08 09:44:58 +02:00
Steffen Becker 15608896a6 feat: closing the tab warns about a document that has no file
The unload warning followed the changes a file had not got yet; a document
that never was a file got none, on the grounds that the autosave keeps it.
It now warns as soon as such a document has content — the same emptiness
test the confirm before a new document uses.
2026-09-08 00:19:36 +02:00
Steffen Becker 52d9ab5720 feat: saving a document with no file asks for the format
The format has to be settled before the bytes are built, so a save that goes
the download route cannot become .docx by renaming it in the browser's dialog.
Save on a document with no file behind it now opens SaveFormatDialog and hands
the choice to Save As; from then on it saves in that format without asking.
2026-09-08 00:14:44 +02:00
Steffen Becker 554f0f6674 feat: each tab edits its own document
Every document-scoped key now goes through docKey(), which hangs it off the
tab's document id; the first document keeps the empty id, so existing browser
copies read back unchanged. A fresh tab takes up the most recently used
document no live tab holds, else starts a new one — one signed timestamp per
document (edentext-live@id) says which, written only by the tab holding it.
Snapshots and embedded fonts get a database per document. The picture store
stays shared and content-addressed, so its sweep now drops only keys no stored
document names any more, instead of the open document's own.
2026-09-07 23:50:11 +02:00
Steffen Becker dc843db78f docs: the compatibility bullet leads with the layout, not the repair prompt 2026-09-06 23:05:52 +02:00
Steffen Becker 1ba47e0ff3 docs: the book spread shows a chapter opening beside a running page
The shot follows the chapter heading rather than a fixed page, so it keeps both
page designs in view if the break ever moves. The gallery notes why the folio
and the document page differ there.
2026-09-06 22:42:23 +02:00
Steffen Becker 92a5c7d713 docs: the showcase documents vary their running heads
The book carries the chapter and the title towards the outer edge and its page
number centred at the foot; the newsletter opens with the masthead and runs a
head with a rule from the second page on. The thesis keeps chapter and number
in the head, so the three read as three page designs.
2026-09-06 22:04:59 +02:00
Steffen Becker 1f38ff0e0e docs: the newsletter's schedule opens the second page
Both tables now sit on it, so the tables screenshot shows the page nearly full
instead of half white; the shot takes it at 120%.
2026-09-06 21:58:19 +02:00
Steffen Becker 629408e8fc docs: the vendored count.js carries its ISC notice in licenses.txt
The collector walks node_modules, so a file copied into public/ had no entry.
The copy has no copyright line of its own; the text is upstream's LICENSE
section for count.js, with the project's copyright holder.
2026-09-06 21:20:44 +02:00
Steffen Becker 284fdae43e docs: the showcase screenshots are shot at twice the pixel density
openApp() passes page options through, so the showcase run sets
deviceScaleFactor 2 — same CSS viewport and layout as the tests, 2800x2000 px
images that stay sharp where the README scales them down.
2026-09-06 21:19:14 +02:00
Steffen Becker d8be23cf68 fix: a bracket typed without \left keeps its glyph height
The MathML operator dictionary makes ( [ { | stretchy, so a plain bracket grew
to the tallest thing in its row — next to a sum with a fraction, C(b) came out
in full row height. Only a fence node emits stretchy brackets now.
2026-09-06 21:19:09 +02:00
Steffen Becker 855cc24371 feat: the site counts anonymous visits with GoatCounter
GoatCounter: no cookies, no IP or user-agent stored, EU-hosted. count.js is
vendored into public/ so no file loads from a foreign origin — only the count
request itself leaves. The /count endpoint is documented as staying
compatible, so the copy does not go stale.
2026-09-06 21:03:28 +02:00
Steffen Becker 52d20bb584 chore: release 0.2.0
Everything since the 0.1.0 launch on 2026-08-17: named list styles, chapter
numbering, the letter templates and DIN 5008 marks, password protection in both
formats, comment threads with margin balloons, the book layout, the inline text
box, and the run of Word / LibreOffice interoperability, round-trip and page-layout
fixes behind the parity corpus, the fuzzed export options and the browser runs.
0.1.0 keeps its own date and scope; its open-items list drops password protection,
which now ships.
2026-09-06 20:12:33 +02:00
Steffen Becker 6794533830 ci: LibreOffice builds formula objects, and node strips the TS import
libreoffice-writer alone has no Math component, so Writer dropped every formula
it imported — Word's own OMML included — and the fuzz leg saw the loss.
node 20 cannot import tests/schemaValidate.ts, which the monkey run does; 24
strips the types. node 20 is end of life anyway.
2026-09-06 20:11:55 +02:00
Steffen Becker b8afa9d36b fix: a section keeping the document's zones flows on in DOCX
A body sectPr without w:type means nextPage, so every section break started a
page the editor and the ODT export never make — the DOCX import then read that
break back as fo:break-before. It is continuous now unless the section has
zones or a page setup of its own, which is exactly when odt.ts mints it a
master page. Measured on LibreOffice 24.2: the kitchen sink paginates to five
pages in both formats again.
2026-09-06 20:11:55 +02:00
Steffen Becker 99f10c9399 docs: each showcase shot opens the tab its feature lives in
A shot names a ribbon tab and, optionally, where the caret goes: the tables
one lands in a cell, so the contextual Table Layout tab is up. The floating
table toolbar is hidden while shooting — it covered the table the ribbon is
already showing. `data-tab` on the tab buttons is what the script clicks.
2026-09-06 19:07:24 +02:00
Steffen Becker 9b426832e1 fix: the ruler stays over the page beside the balloon strip
`.paper-scaler` reserves page plus strip and centres the pair, so a document
with margin balloons sits left of centre — the ruler, sized from the page
alone, centred over a narrower box and hung to the right of the page it
measures. It now reserves the same strip as `padding-right`.
2026-09-06 19:07:24 +02:00
Steffen Becker 4b1a03b121 fix: every column starts level in a multi-column section
Inside `.columns-node` the space above a block is the margin and the space
below the padding: an engine truncates a margin at a column break but keeps
padding, so the columns of a section now begin at the same height and the
add model's two spacings still sum. `spaceAfter` carries `--space-after`
beside its margin, from the attr and from a named style, for the swap.
2026-09-06 19:07:24 +02:00
Steffen Becker bd38c955ae test: every fixture picture is a PNG a browser accepts
The 1x1 fixture carried an IDAT from a different image: its CRC fails and the
zlib stream is truncated. Chromium and WebKit render it anyway, Firefox reports
"Image corrupt or truncated", and the same bytes sat in eleven fixtures.
2026-09-06 19:07:24 +02:00
Steffen Becker 055839655e test: the monkey edits both zones, and the runs compare what is drawn
Header and footer go first, each with its own ops, and a variant flag is
flipped while a zone is open. The round trip compares a list's drawn kind and
marker rather than its node type and style name — a list that loses its name to
a formatted marker carries the style's own char — and skips the marks of an
inline atom, which no file keeps. A caret is snapped to a text position, a
swallowed File-menu click is retried, and WebKit's converging ResizeObserver
round is no page error. A new corpus pair brings all six zones through the
import path; the baseline holds it and a re-recorded showcase entry.
2026-09-06 19:07:23 +02:00
Steffen Becker 6d4a7e08df feat: the ribbon's Insert tab carries the zone options
The two variant flags (a first page of its own, separate even pages) and the
header/footer distances were reachable only from the expanded toolbar. The
ribbon mode now has them in an Options menu beside the zone buttons.
2026-09-06 19:07:23 +02:00
Steffen Becker cb39ef018c fix: both importers read back what the exporters write
A page break on a list paragraph is kept now, and a heading after a list item's
own paragraph reads back as a heading — the numbered chapter heading that *is*
the item stays a paragraph, which is how the editor numbers it. A cell
paragraph's break is dropped in both formats, since neither word processor
honours one there. The .docx importer strips exactly the one tab a note's text
carries, and a heading in a cell is measured against its own level's size
rather than the default style's, which made it carry that size as formatting.
2026-09-06 19:07:22 +02:00
Steffen Becker 40b3a33918 fix: a list item's own blocks and a note's runs go out whole
LibreOffice keeps the two vertical margins in one item, so a heading declaring
only one carries its style's partner — at the top level and in a cell. A list
item's page break, the own formatting of its blocks past the first, and a
heading nested in it now survive the .odt, and a list in a cell no longer shifts
every following body item's style. In the .docx a note's own text keeps its
formula and its ruby — both passes now cover the note parts — and a field's
cached run carries the marks around it instead of going out bare.
2026-09-06 19:07:22 +02:00
Steffen Becker 611ec76ba4 fix: the editor holds no shape either file would drop
A page break reaches the file only from a body block or a list item — a cell,
a frame and a note body all drop it — and an indent on a list item's paragraph
is the same; a plugin sweeps both. A note anchor keeps no marks and stays out
of a text box. Sub- and superscript exclude each other, a font stack the
contenteditable leaves behind keeps its first family, and a weight Bold already
claims is no second attribute. A table's first row is all header cells or none
(the promotion carrying each cell's own attrs), a replace into a table reads the
lost column weight back from the column, and a list item holds no table.
2026-09-06 19:07:20 +02:00
Steffen Becker 7e2eb53ddc test: a decorated section, a run under an underlining style, a wider sweep
Three regression tests for what a 5000-seed sweep turned up, each checked to
fail without its fix. A run repeating its style's own plain underline is
lossy by design, so the generator no longer puts one under a named style.
2026-09-06 19:07:19 +02:00
Steffen Becker ab133f7966 fix: page decor, an opening page's margin and a run's own underline
An empty first master page was swallowed by the next one's match, so the
watermark and the fold marks missed the first section entirely.
Every master of a section now writes the running zone, blank where its own
page has none — the layout takes the band off the margin for the whole
section, and an opening page lost 0.8cm of its own.
Both importers compared whether a run underlines, not how, so a red or wavy
line under a plainly underlining style came back plain.
2026-09-06 19:07:18 +02:00
Steffen Becker c4239a6e72 test: the layout run holds a baseline, and the monkey edits frames and columns
tests/layout/baseline.json records where each page starts and what the load cost, per
engine and platform; a page starting elsewhere is a layout change the run prints page by
page, a load over three times its recorded time a regression, and LAYOUT_UPDATE=1 records
it again. The monkey draws frame sizes, wraps, alignment and column counts too, holds the
history against the edits rather than the caches the editor recomputes, and leaves it
unchecked where the columns flow rewrote the document outside it. The differences both
importers make by design are named in the comparison; unhoist moved to normalize.ts.
2026-09-06 19:07:17 +02:00
Steffen Becker 76c9564028 fix: a list item's further blocks, a header row and a note's picture survive the save
An .odt kept only the first paragraph of a list item; the rest ride it SEG-separated and
are split back out inside the same item. A header row the table did not ask to repeat
came back as ordinary cells, and only DOCX wrote it. An endnote's picture pointed at a
relationship the package never minted, and neither note's pictures were read through
their own part's relationships. A frame band-wrapped flush left is at no side of its own.
Blocks wrapped into columns drop a page break nothing there honours, and a cell's content
expression now says what both exporters can write into one.
2026-09-06 19:07:16 +02:00
Steffen Becker d156482a3f test: LibreOffice, the layout lint and a monkey read the editor's work back
lo-fuzz.test.ts hands every fuzz seed to LibreOffice as .odt and .docx and
compares what comes back, listing the differences LibreOffice makes by design.
test:layout opens the corpus, the showcase and fuzz seeds in the browser, holds
the page count against LibreOffice and checks the lines for overlap, margin
escapes and stranded headings. test:monkey types at random on corpus documents
and asserts the schema, undo to the start, redo, and that both saved formats
read back. browser.mjs now carries the dev server and the settle helper.
2026-09-06 19:07:16 +02:00
Steffen Becker 3b6cb06e6f fix: LibreOffice reads back what both exporters write
A section opening on a side hands its one-page master over to a follow-up
master instead of asking for a right-only page, which doubled the page count.
DOCX names its default style Normal and marks it as the default, zone tabs
stay tabs, footnote numbering rides in the section properties, and w:position
counts half-points. The importer keeps content controls, shape text boxes and
index page numbers, and opens no phantom section on the leading master.
Editing rejects the nesting the formats cannot hold, note repairs ride the
undo history, and the showcase files are rebuilt.
2026-09-06 19:07:15 +02:00
Steffen Becker 0d5c17a704 test: the fuzz leg draws every export option and validates each export against the schemas
tests/fuzzOptions.ts generates styles, outline numbering, sections with
zones, margins, paper and page numbering, decor, line numbering, notes and
metadata; the round trip compares per-section effective values, and what a
format has no place for is listed as lossy. The xmllint part of
schema-validation.test.ts moves to tests/schemaValidate.ts (one call per
schema) and a third leg validates every seed as ODT and DOCX. FUZZ_SEEDS
and SCHEMA_SEEDS widen the sweep.
2026-09-06 19:07:14 +02:00
Steffen Becker 7b43ee71ad fix: sections, zones, styles and page numbering survive the round trip in both formats
Found with the showcase documents and by fuzzing the export options through
the round trip and the schemas. ODT: a style's line spacing as a percentage,
section masters laid out from their own zones, variants in schema order, the
watermark on section pages, NCName-encoded style names. DOCX: a section begins
a page, odd/even and w:fmt document-wide, styles with line spacing, shading and
borders, w:numPr and w:pBdr in order. Both importers: the next style, page
setup and text width per section. Editor: chapter counters via counter-set,
a heading in columns stays with its text.
2026-09-06 19:07:14 +02:00
Steffen Becker 406206c300 docs: showcase documents and screenshots replace the README's sample picture
scripts/showcase/run.mjs builds three sample documents plus a review
variant as .odt and .docx through the app's own exporters in headless
Chromium, opens each through the file dialog and takes the README
screenshots; docs/showcase/ holds the results. The README gets a
light/dark hero picture and a gallery of four tiles.
2026-09-06 19:07:13 +02:00
Steffen Becker 91a68ba3b0 docs: the README carries 0.2.0's features and a measured first-load size
Feature bullets grow by chapter numbering, list styles, password
protection, the letter gallery, threaded comments, the book layout and
a Word/LibreOffice interoperability line; the beta note links the open
items. The size claim is measured over the wire (under 2 MB, footnoted),
and a browser bullet says where Save writes back to the file.
2026-09-06 19:07:13 +02:00
Steffen Becker f6c906f9fa perf: the word lists travel as .txt, which the host compresses
GitHub Pages serves a .dic as text/x-c and leaves it uncompressed, while
text/plain goes out gzipped: the English list cost 552 KB over the wire
where 191 KB would do. Only the fetch path changes; the mount name the
checker sees stays <code>.dic. Measured first load 1.83 → 1.70 MB.
2026-09-06 19:07:11 +02:00
Steffen Becker 6b6b621a5a test: the browser runs take their engine from BROWSER, and CI runs all three
openApp() launches chromium, firefox or webkit from playwright-core; the smoke
and DOM runs pass in each. CI runs both per engine, WebKit on macOS for
Safari's text shaping. build.target is set explicitly to the browsers the
bundle promises. CLAUDE.md is back under its 120-line budget.
2026-09-04 19:30:14 +02:00
Steffen Becker abb4870544 fix: Save As is one entry per format, and the template follows the document's
Brave ships with the File System Access API off, so a save is a download and
the browser's dialog cannot hand a changed name back: the bytes must be built
in the format chosen beforehand, or a renamed file carries the other format.
One entry per format settles that on every route, the DOCX export entry goes
as its duplicate, and the DOM run covers the download route.
2026-09-04 19:30:13 +02:00
Steffen Becker 6693600f3b test: the DOM run places the caret through the editor and waits for its focus
A click lands wherever the element's centre happens to be, and a break before
the empty last paragraph yields no page; the editor's focus() lands on the next
animation frame, so a key sent before it was lost now and then.
2026-09-04 19:30:13 +02:00
Steffen Becker 131943dab1 fix: both document formats sit in one picker type, and the first save is Save As
Chrome's macOS save panel shows no format popup and admits only the first
picker type's extensions, so a second type could never be chosen there. Both
extensions now share one type, for documents and templates alike, and the
typed extension decides what is written. A document without a file saves
through that same picker, and the Save entry no longer names .odt.
2026-09-04 19:30:13 +02:00
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 ea327174e8 fix: the unsaved state says so in words instead of a dot
A mark has to be known to be read, so the name now carries "• Not saved"
("Nicht gespeichert") instead of a bare dot with a tooltip. In the ribbon
the label sits beside the name box rather than inside it: that box is
capped at 30% of the strip, which would take the width off the name.
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 2d2b321bad feat: the document name carries a dot while the file is behind
The dot shows in both chromes as soon as the text differs from what was
last written to a file, and goes when a save, an open or a new document
makes them agree again. Leaving the page warns only where the document
has a file to lose the changes from.
2026-09-04 19:30:12 +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 ad7b840662 fix: a picture pasted from a web page is fetched into the document
It came in by its URL, showed from the web and was dropped without a word
by every export. It is now fetched into a data-URI right after the paste,
sized like a picked file; one the site withholds is removed with a message.
2026-09-04 19:30:12 +02:00
Steffen Becker cb0db7dd96 fix: a reload keeps the document protected, and the first save asks for the password
The password itself is never stored, so a reload left the document open and
the next save wrote the protected file unencrypted without a word. The fact
of the protection now survives; the save dialog asks for the password again
or lets the protection go, and backing out cancels the save.
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