Commit Graph
74 Commits
Author SHA1 Message Date
Steffen Becker bc8f8d89d1 fix: the empty document's placeholder follows the UI language
The body said "Start typing…" in every language while the header and
footer zones were localized; switching the language now redraws it.
2026-09-04 19:30:12 +02:00
Steffen Becker 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 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 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 6bb9eb5416 fix: a page mirrors its margins only where two masters really alternate
style:page-usage="right" on a single layout is a page that starts on the right,
not one half of a book, and swapping its pair drew it 9mm off. It counts as
mirrored now only where masterPageHF found the twin it alternates with. The
even-page shift is per section and per first/rest page too — a cover or an index
between mirrored pages has margins of its own and mirrors nothing.
2026-09-04 19:30:08 +02:00
Steffen Becker ce9dc9bb4d fix: the header/footer band is measured, not counted in lines
The body's margin has to clear the band the zone renders at, and the
estimate counted paragraphs — blind to a line that wraps, to a run smaller
than the 12pt floor, and to the zone's padding. HeaderFooterLayer now lays
each section's zones out off-screen at their text width and reports the
height; the ODF zone's own gap to the body and its padding ride the
collapsed paragraph's spacing, which is where the export already puts them.
Measured on one file: a three-line band read as two, 14.9mm a page.
2026-09-04 19:30:05 +02:00
Steffen Becker 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 eb7f3c731d feat: a comment or change says which line it belongs to
Three pairings, the way both word processors draw them: a bar in the margin
beside every line a change or an open comment covers (ChangeBarLayer), the
reviewing pane's card marked and scrolled into view, and a dashed leader from
the text to that card (ConnectorLayer). The leader is drawn for the one at the
caret only, and dropped where a second pane sits between text and card.

commentIdAt/revisionIdAt compare ranges, not the marks at the caret: both marks
are non-inclusive, so the range a click on a card selects would match nothing.
2026-09-04 19:30:02 +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 6081a85626 feat: set where an in-flow text box sits across the column
wrapAlign only ever came from a file, so a box inserted here stayed at
the left in both modes that leave it in flow. The floating toolbar now
offers left/centre/right for `inline` and `topBottom` (a side wrap names
the side itself) and drops a coordinate placement when one is picked.

DOCX carried neither: `topBottom` now writes wp:align and an as-char box
the w:jc of its rebuilt marker paragraph, both read back on import. ODF
already wrote both. Measured centred at 297px of a 76…718px column, and
rendered through LibreOffice from .odt and .docx alike.
2026-09-04 19:30:00 +02: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 f72be2f0e3 fix: resolve the chapter field per zone the way LibreOffice does
The header shows the chapter in force at the page's top (a heading
counts once it opens a page), the footer the last one begun on the
page. Both zones showed the footer's reading before.
2026-08-30 10:06:43 +00:00
Steffen Becker 9578dea57e fix: watermark and fold marks on first/even pages, border wraps the hf band
The decor shapes now ride every header variant: DOCX gets an empty
w:type=first/even header part for the post-passes to fill, ODT the
same splice into style:header-first/-left. The editor's page border
now starts at the zone's edge distance where a header/footer exists —
both word processors draw the band inside the border (probed).
2026-08-30 10:06:42 +00:00
Steffen Becker a5c072db23 feat: fold and punch marks (Falzmarken, DIN 5008)
edentext-fold-marks flag: FoldMarkLayer draws the 87/192mm folds and the
148.5mm punch mark per page inside .paper, so print and PDF include them.
Toggled in the Layout tab. Exported as named EdenFoldMark* lines in the
master page's header (ODF page-relative draw:line, DOCX VML v:line);
both importers read the names back into the flag. Roundtrip Leg 32.
2026-08-24 16:27:27 +00:00
Steffen Becker a7522547b7 fix: the table dialog flips below a table it would cover
The formula/sort/split popover hangs above the table's top-left corner;
with the table at the top of the page the keep-in-view clamp slid it
down over the table itself. With no room above, the dialog now opens
below the table when that fits the viewport, and only a table filling
the whole window keeps the old clamp.
2026-08-20 14:37:18 +02:00
Steffen Becker 8537fd0106 fix: zooming the page grid keeps the view anchored
The grid snapped the scroll to the top of the first row on every zoom,
losing the reader's place whenever it sat mid-row and ignoring the
pointer anchor of a trackpad zoom. It now uses the same anchor logic as
a single pane, measured against the grid canvas: the pointer for a
wheel zoom, else the viewport top; firstRow is re-read after the write
so the page cells re-aim immediately. Measured drift < 1px across a
92% -> 194% wheel burst.
2026-08-20 12:26:03 +02:00
Steffen Becker fb59a30b63 fix: defer the toolbar tick past Svelte's flush
Tearing down the focused pane's DOM on a layout switch fires blur
synchronously inside Svelte's commit; TipTap answers with a focus-meta
transaction, and onTransaction's tick write threw state_unsafe_mutation
there — aborting the handler before the other panes got the new state.
The tick now bumps in a microtask, after the flush, before paint.
2026-08-20 12:25:27 +02:00
Steffen Becker c6aae13a9e fix: the page count survives switching the pane layout
The pm-pagecount listener sat on the hosts[0] element, which Svelte
replaces when the pane layout changes (multi-page grid, split view).
The editor view moves to the new host, the listener died with the old
one — numPages froze, so no new page ever appeared in the grid.
Listen on the view's own DOM instead, which is only ever re-parented.
2026-08-20 11:57:20 +02:00
Steffen Becker 9c4b768982 fix: a page is the paper's exact height, not the pixel it rounds to
The sheet boxes rounded cm × PX_PER_CM where --user-page-height keeps it
exact, and pageBreaks builds its grid from the rounded value they publish
as --pb-section-page: A4 came out 1123px against 1122.52, so every page
ran 0.127mm long and the text crept down the flow — 1.8mm by the
sixteenth page, 6.0mm by the forty-eighth, against a reference whose
footer sits at the same height on every page.
Corpus 1772 -> 1108, eight fixtures improved, none rose.
2026-08-17 13:27:46 +02:00
Steffen Becker 6eb9032c46 fix: paste no longer stamps a font the document never had
transformPasted fell back to a DEFAULT_EDITOR_FONT of 'Georgia' — the
comment said it had to match ToolbarExpanded's, which is Liberation Serif.
Text pasted where the caret carries no font mark got Georgia as direct
formatting, and pasting into a heading overrode its sans font. Only an
explicit font at the caret is carried over now.
2026-08-16 01:28:28 +02:00
Steffen Becker cbb1805910 feat: multi-page view
View ▸ Multiple Pages (1–4): rows of pages filled left to right, scrolling
as one canvas, every page editable. Neither reference product stops at two —
LibreOffice takes a column count, Word fits what the window allows — and both
re-zoom for it, so a whole row is fitted when the count changes. A pane is a
grid cell clipped to one page, a view showing only a contiguous slice; two
rows of cells take turns as the upper one. Cost: columns x 2 live views, each
holding the whole document (measured: +94k nodes and +16ms per keystroke per
view at 177 pages).
2026-08-15 19:19:33 +02:00
Steffen Becker 41ab0ef06c feat: two panes on one document, scrolled apart
View ▸ Split (Ctrl+Alt+S): a second live view of the editor's state, stacked
below the first and editable the same way. The panes share a width, so the
second renders the pagination the first measures — pageBreaks, columnsFlow
and tabStops skip the pass on a split pane rather than run it twice.

Every widget decoration now builds its DOM in a function: two views render
one decoration set, and a shared node is taken back and forth between the
two documents until the tab locks up (measured on a two-page document).
2026-08-15 13:15:37 +02:00
Steffen Becker c800618299 feat: a text box can run its text top-to-bottom
Word's Text Direction and LibreOffice's, on the frame's own toolbar: the
browser lays the vertical flow out, so the box needs nothing but the mode.

ODF keeps it in the frame style's *paragraph* properties — probed: in the
graphic properties LibreOffice drops it, there it keeps it and lays the box
out — and Word in the shape body's `vert`, the VML fallback's
`layout-flow:vertical` read too. Both survive a LibreOffice re-save.
2026-08-15 11:13:46 +02:00
Steffen Becker eda134f162 feat: a formula cell prints its result in its own number format
The Formula dialog gains the number format Word's has: whole numbers, two
decimals, grouped thousands and the two percentages beside the general
one.

ODF keeps it on the cell *style* as a reference to a data style — odf-kit
shares one cell style across every cell, so a formatted cell gets a copy
of it — and LibreOffice writes both back unchanged. Word keeps it on the
field as its \# switch; LibreOffice's DOCX filter neither reads nor writes
that switch, so that leg holds through our own round trip.
2026-08-15 10:53:30 +02:00
Steffen Becker 0ec35b0168 feat: each page is centred, and a section can pick its own paper
A section narrower than the reserved sheet now splits the difference
between its insets instead of hanging it all on the right, and every
per-page layer draws from the box's own left — sheet, header/footer, page
border and line numbers. The ribbon's Size menu gains the same per-section
block Orientation already had.
2026-08-15 09:52:43 +02:00
Steffen Becker 8d866e4f1a fix: a table popover opens where it fits, in both chromes
The three dialogs placed themselves, so the ribbon's viewport
coordinates met CSS that hangs the box above its anchor: the
formula popover landed under the band, clipped. Each caller now
owns the placement — `anchored` in the ribbon, like every other
menu there, and a wrapper in the editor that clamps a tall one
into the scroll area instead of letting the overflow cut it.
2026-08-14 22:11:49 +02:00
Steffen Becker 147342655d feat: sort a table, sum a column, recognise a number
The formula rides the cell, as ODF writes it; Word's field syntax is what
is stored, since it is the wider language. `ooow:` in the ODF attribute
value is a namespace prefix — undeclared, LibreOffice reads it as part of
the formula and prints "Expression is faulty".

Sort takes one key and detects the type per cell where both dialogs make
you pick. Number recognition is off by default, as it is in LibreOffice,
and a formula parses a cell's text either way — exactly as there.
2026-08-14 22:11:49 +02:00
Steffen Becker 639fac90fd feat: a section can have its own paper, landscape amid portrait
The page grid was one repeating cycle, so every page had to be the same
size. It is now a table of height runs the placement pass grows as each
section's first page becomes known, and the sheets are one box per page
instead of a repeating gradient — .paper reserves the widest section's
paper, a narrower one takes the difference as a right inset.

Layout ▸ Orientation offers "this section"; the size stays document-wide
in the ribbon but round-trips per section from a file.
2026-08-14 10:36:52 +02:00
Steffen Becker 6a313e5692 feat: pictures are autosaved to IndexedDB, not localStorage
A handful of photos is the whole ~5 MB quota, and past it the document
stopped saving. The autosaved JSON now keeps an `idb:<key>` per picture
and the bytes live in their own store; the key is a sampled content
hash, so a picture used twice is stored once and each save sweeps what
the document no longer names.

loadDocument is async as a result and Editor.svelte awaits it. Where
IndexedDB is unavailable the data-URIs stay inline exactly as before.
2026-08-14 08:17:06 +02:00
Steffen Becker da438a7655 feat: line numbering in the left margin
Continuous or per page, every nth line, empty lines counted or not —
LibreOffice's Tools ▸ Line Numbering, round-tripping as its
text:linenumbering-configuration and Word's w:lnNumType. CSS exposes no line
boxes, so the layer Range-selects each block and takes the distinct tops of
its client rects as its lines; LibreOffice renders the exported file with the
numbers in the same places.
2026-08-14 06:56:20 +02:00
Steffen Becker 96d827b2c7 feat: page background, page border and watermark
LibreOffice's Format ▸ Page Style ▸ Area/Borders and its Format ▸ Watermark,
all three round-tripping. Probed placements that are not guessable: ODF reads
a watermark only out of the master page's header (and only with xmlns:draw
declared), Word's w:sectPr wants pgBorders after pgMar or it loses the header
references, and the VML shapetype has to travel or LibreOffice cannot resolve
it. On screen the watermark is an SVG textLength, since a fontwork shape
stretches its text to its box rather than setting a font size.
2026-08-14 06:50:25 +02:00
Steffen Becker a7092cfd1d feat: page numbering in five formats, from a start value
Layout > Page numbers: 1 / i / I / a / A and a first-page number, both
round-tripping. ODF has no document-level start — probed, LibreOffice
puts style:page-number on the first paragraph's own style — so the
export mints one there and the page layout carries style:num-format;
Word takes both in the first section's w:pgNumType. The page *count*
field stays decimal, as the one both of them write does.
2026-08-14 01:11:42 +02:00
Steffen Becker 46fb30249f feat: automatic hyphenation for the whole document
Layout > Hyphenation, off by default as in both word processors. The
browser hyphenates in the document's own language (the body host gets
lang and hyphens: auto) — measured, a justified German paragraph takes
15 lines instead of 17. ODF keeps fo:hyphenate in the base style's TEXT
properties: probed, in paragraph-properties LibreOffice ignores it and
drops it on the next save. Word takes w:autoHyphenation.
2026-08-14 01:02:12 +02:00
Steffen Becker 1aa74c0a9f feat: a footnotes and endnotes options dialog
Numbering format, start value, restart, position, prefix/suffix and the two
styles per class, plus the separator's length, thickness, spacing, alignment
and colour. The separator rides :root custom properties, so editor.css draws
the line and pageBreaks.ts reserves its band from the same values. A settings
change is no document change, so it carries a resync meta — without it the
numbering kept the format it was typed under.
2026-08-13 11:50:53 +00:00
Steffen Becker 29667a6f0d feat: a right-to-left page fills its columns from the right
ODF style:writing-mode="rl-tb" and Word's section-level w:bidi were not
read, so a Hebrew two-column page put column 1 on the left where
LibreOffice puts it on the right — checked against pdftoppm of its own
PDF, since the harness bands both columns into one line and never gets
that far. It is a document setting now, applied as `dir` on the body
host, which is all CSS multi-column needs; it also gives bidi the base
direction the file means. Round-trips through both formats. The
header/footer band keeps its own direction, as LibreOffice lays it out.
2026-08-12 22:02:36 +00:00
Steffen Becker b35551acd0 fix: a document opens on the page it settles on
A pagination pass measures the DOM the last one changed, so a fresh
document searched for its layout in public — 12 of them over 550ms on a
two-column file, a phantom third page among them. Two real causes: the
flow's overflow test had no headroom, so it read its own last answer
(880px fits undecorated, 913px overflows once the decoration pins the
column height), and a split dropped the height decorations without
clearing their cache key. The rest is inherent to measuring rendered
DOM, so the page stays hidden until its layout repeats: one visible
layout instead of 12, and 21-139ms where there are no columns.
2026-08-12 21:50:15 +00:00
Steffen Becker f055fc4850 docs: every comment back inside the three-line rule
A sweep of the whole tree: 27 blocks ran to four lines or more. Trimmed
to what the code can't say, with two of them split back onto the
function each was really about.
2026-08-11 21:22:13 +00:00
Steffen Becker baf0865256 fix: a section keeps its own side margins too
.tiptap's padding draws one pair of side margins for the whole document, so a
title page declaring 3.18cm left sat 3.85mm off. The difference now rides a
--sec-inset-left/-right node decoration on the section's top-level blocks, and
everything that writes a block margin adds it in — an indent, a named style's
and a table's alignment all compose with it instead of losing to it.
2026-08-11 19:03:58 +00:00
Steffen Becker 9bce48f6e0 fix: a section keeps the top and bottom margins its own page setup gives it
Page geometry was document-wide, so a title page declaring 1cm started 1cm too
low. w:pgMar / the section's ODF page layout now ride its HfSet and feed the
per-page content area; an ODF master handing over (style:next-style-name) gives
its own layout to the section's first page and the successor's to the rest.

A spacer can only push down, so a smaller margin than the document's needs the
opposite: the section's first block gets a lifting spacer where nothing was
pushed onto its page ahead of it.
2026-08-11 18:51:12 +00:00
Steffen Becker e2ee55cf35 fix: a header/footer band is as tall as its own font, not the body's
The zone is one paragraph, so its mark sizes every line of the band. Runs
that agree on a font now set that strut (applyUniformRunFont, as the body
already does), the zone schema carries blockFontSize so it renders, and
hfReachPx measures a line by the family's natural height instead of a flat
1.15. A 10pt footer reserved 12pt lines and sat 1.6mm high on every page.
2026-08-11 17:10:08 +00:00
Steffen Becker c41dce79cb fix: a zone reserves the band its own type needs
hfReachPx floored the zone's line at the 12pt body default and only ever grew
it, so a footer set throughout in 10pt still reserved a 12pt line and pushed
the body ~0.8mm too far up. The default now stands in per run, for one that
declares no size of its own.
2026-08-10 22:20:27 +00:00
Steffen Becker 0fe1a8395d fix: the header/footer band grows to hold the zone it carries
fo:min-height is a floor, not the band: LibreOffice grows it for the zone's
own lines and, in a footer, for the first paragraph's space above (probed —
a header's space below only hangs into the body). The zone paragraph now
carries those margins, so the reach counts them at its real font size and a
page with no zone of its own keeps the floor.
2026-08-10 21:22:56 +00:00
Steffen Becker d407febd4b feat: insert a chapter field, and set where a cell's content sits
Both round-tripped through save/load already but had no way in: the
header/footer bar gains a Chapter button next to page number/count, and the
table toolbar three buttons for the cell's vertical alignment.
2026-08-10 17:01:48 +00:00
Steffen Becker 3153c2f7cc feat: the running head names the chapter the page is in
A manual's footer carries text:chapter, which we imported as the string the
producer happened to cache — so every page of a fixture claimed the last chapter
of the book. It is now a live field: the layer resolves the last heading at
or above its outline level to have started by that page. Round-trips to ODF
text:chapter and Word STYLEREF; PDF keeps the cached name (Chrome's print
engine has no named strings).
2026-08-10 13:04:25 +00:00
Steffen Becker d6bc656c83 feat: the space between two blocks follows the document's own model
LibreOffice adds the upper block's space-below to the lower one's
space-above, and drops the space-above where a page break put the block
at a page top — but for a Word document it takes only the larger of the
two (all probed against soffice). CSS margins can only collapse, so
space-before rides --space-before and editor.css renders it as padding
('add') or margin ('max'); the model is read from settings.xml, assumed
for DOCX, and written back on ODF export.
2026-08-09 16:53:13 +00:00
Steffen Becker 30b5b7890e feat: a header's page background paints behind the page it belongs to
A positioned frame in a header is out of flow — the title-page background or
watermark. It keeps its wrap and its offset from the page corner, and the
layer paints it per page under the body instead of flowing it inline, where
it reserved a page-tall line of body space.

The page surface moves from .tiptap to .paper so the image has a layer
between the sheet and the text; ODF export writes it back page-anchored.
2026-08-09 15:13:46 +00:00
Steffen Becker 28a39e017a feat: a document that blocks the start is not loaded twice
An autosaved document that hangs or throws while the editor comes up was
reloaded from localStorage on every refresh, leaving no way back into the app.
Loading now raises a boot flag that only a completed startup clears; finding it
still set means the last attempt never finished, so the document moves to
odf-editor-doc-broken and the app starts empty with a message.
2026-08-09 14:13:17 +00:00