Commit Graph
469 Commits
Author SHA1 Message Date
Steffen Becker 5ee4eec5d7 feat: the editor records, shows and applies tracked changes
Two marks carry a revision with its author and date: an insertion is
marked text, a deletion is text kept and struck through. Consecutive
typing joins one change; accept/reject work at the cursor or on all.

ODF splits the pair — text:change-start/-end around an insertion, a
text:change where a deletion was, its text in the tracked-changes
registry. DOCX uses w:ins/w:del with w:delText. Both round-trip, and
accepting every change gives the document an unrecorded edit would.
2026-08-14 07:48:10 +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 f455a7c82f feat: a list of figures and a list of tables
The same generated index the table of contents is, over the captions of one
category instead of the headings — an `index` attr picks the family, so the
node view, the page numbers and the leader dots are shared. Round-trips as
ODF text:illustration-index / text:table-index, found by the counter's name
the way LibreOffice finds them, and Word's TOC \c "Figure" field.
2026-08-14 06:29:43 +02:00
Steffen Becker 53aacd9f77 feat: captions that number themselves
A caption is a paragraph in the Caption style holding a sequence field —
one counter per category, in document order, renumbered as the document is
edited and on load (a file's cached numbers are unreadable wherever the
format is not arabic). Round-trips as an ODF text:sequence with
LibreOffice's own ooow:Illustration+1 formula and a Word SEQ field.
Caption style values probed off LibreOffice: 10pt italic, 0.212cm around.
2026-08-14 06:21:30 +02:00
Steffen Becker 55f27da855 feat: a single paragraph can run right to left
The block's own direction, set in the Paragraph dialog where LibreOffice
keeps it, so a quoted Hebrew or Arabic paragraph turns inside a left-to-right
document. Rides ODF style:writing-mode / Word w:bidi through cells and list
items as well as top-level blocks; the direction the page already has is
inheritance, so neither importer stamps it on every block.
2026-08-14 06:04:12 +02:00
Steffen Becker cb1c61c900 feat: a table's header row repeats on every page
The structural header, beside the styling one: pageBreaks grows the
between-rows spacer by the first row's height and draws a copy of that
row — colgroup included, so the columns line up — at the foot of the
gap, which is the top of the continuation. Round-trips as ODF
table:table-header-rows and Word's w:tblHeader; LibreOffice keeps both.
Word's "don't allow row to break" needs nothing: a row that fits a page
is already an atomic leaf here.
2026-08-14 01:31:02 +02:00
Steffen Becker b2d1ddfb66 feat: comments, with a reviewing pane
The comment is a mark carrying its own author, date, body and resolved
flag — the bookmark pattern, so no second store and no schema node. The
pane lists them in document order and jumps to the text each marks.
ODF gets office:annotation + office:annotation-end and LibreOffice's
loext:resolved, DOCX the range pair plus word/comments.xml; a point
comment, which LibreOffice writes without a range, attaches to the run
before it. Ctrl+M no longer strips a comment, a bookmark or a link.
2026-08-14 01:23:54 +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 40efc73f86 feat: Find & Replace by regular expression
A .* toggle beside match-case and whole-word takes the term as a
pattern; a pattern that will not compile simply finds nothing. Each
match keeps its captures, so the replacement expands $1…$9 and $&
the way LibreOffice does — Word has no equivalent.
2026-08-14 01:04:23 +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 8d8bb858de feat: AutoCorrect while typing
LibreOffice's rule set: typographic quotes per document language, its
dash matrix, its replacement table, sentence capitalization with an
abbreviation exception list, and TWo INitials. One handleTextInput
plugin listed last, so every input rule still fires first; the rule
table itself is a pure function under test. Auto-URL and auto-list were
already TipTap's and now answer to the same checkboxes.
2026-08-14 00:53:57 +02:00
Steffen Becker 98d0d5c878 feat: the document carries its title, subject, author and keywords
LibreOffice's File > Properties / Word's File > Info, reachable from
both chromes' file menus. ODF writes them into the meta.xml odf-kit
already emits (one meta:keyword each, generator EdenText); DOCX passes
them to the package's core properties. Both importers read them back,
and LibreOffice shows every field from either format.
2026-08-14 00:44:57 +02:00
Steffen Becker 71c7b53281 feat: heading level 6, with LibreOffice's italic on 4 and 6
LibreOffice's style pool, probed: Heading 6 is 12pt bold italic and
Heading 4 italic too. MAX_HEADING_LEVEL drives the extension registry,
the shortcut plugin, the TOC depth and both importers' yardstick, so
only the constant table and the CSS selector lists had to grow.
Bookmarks/cross-references move to Implemented — they already are.
2026-08-14 00:38:49 +02:00
Steffen Becker f7d9d38204 feat: the debug dump reports every table cell's vertical alignment
Per cell the attr beside the computed value, cell and row height, the gap above
and below the content, and each block's margins — plus the style sheet and the
spacing model, which is what decides whether the content fills the box.
2026-08-13 23:39:37 +02:00
Steffen Becker 06c5f2b12a fix: a cell paragraph drops the default style's space below it
Neither word processor passes it into a cell — LibreOffice puts cell text in
its Table Contents style, Word's table styles carry w:spacing after=0 — so the
space filled the cell and vertical alignment had no slack left to move the text
in, which read as the alignment being ignored. The space above is LibreOffice's
alone, so only the add model zeroes that too. Both exporters write the zeros
onto the paragraph, and only when the default style has spacing at all.
2026-08-13 23:39:31 +02:00
Steffen Becker 102f2c6cac fix: the style gallery's menu drops under its own chevron
The panel anchors to its parent, which was the gallery wrapper — a flex:1
strip whose right edge is the ribbon's, measured 745px from the button.
2026-08-13 22:08:04 +02:00
Steffen Becker 0da75ed456 refactor: the project is called edentext
Renames the product in the docs, the page title and the DOCX creator, and moves
the whole storage namespace from odf-editor- to edentext-. The prefix is a
per-origin namespace, so it stays the product name rather than a generic one.
2026-08-13 21:43:12 +02:00
Steffen Becker 1018380f88 fix: the collapsed strip ends on the active tab's underline
The underline hangs 2.5px below its tab, onto the band. Collapsed there is no
band to hang onto, so it landed on the ribbon's bottom border with 1px of chrome
under it against 5.5px above. The strip lends it the room while it is alone.
2026-08-13 18:34:19 +00:00
Steffen Becker c469759e69 fix: the ribbon reserved height for air above every button
The band was a round 108px while its tallest control measured 65, so each tab
carried a strip of empty chrome above its buttons. Padding, gaps and the caret
slot come in, the band is cut to what the controls actually need, and the style
gallery stops short of the group label it was sitting on: 144px of chrome to
118px. Line-heights are pinned in px — as ratios they hand the band's height to
whatever font resolves, and the group footers hang out of it.
2026-08-13 18:32:24 +00:00
Steffen Becker d773801b43 docs: the first line of a page sits an ascent lower than LibreOffice's
A font carries two ascents and the engines place the first baseline
against different ones — OS/2 typo there, hhea here. Measured out of the
bundled TTFs: 0.2021 em for Carlito, 1.85mm at 26pt and 0.84mm at 12pt,
with every line after it matching on both sides.
2026-08-13 20:16:27 +02:00
Steffen Becker eb02be6196 fix: a columns section's first block starts flush with the column top
The rule that drops the space above it never applied to a Word document:
:is(p, h1, …, .toc) takes the class specificity of .toc, so the later
spacing-model rule tied at (0,4,0) and won on order — and the heading
opening a two-column section sat 3.5mm below the column beside it.
Zeroing the property both spacing models read settles it, and beats the
block's own inline value as a margin would not. Probed: LibreOffice
starts both columns level; a two-column page now matches it 45 lines
deep, where it diverged at line 8.
2026-08-13 20:14:26 +02:00
Steffen Becker 6957f5682b fix: a style kerns where its own w:kern says so, not only the document's
Word kerns nothing unless w:kern names a size to start at, and the Office
theme declares it on Title alone — but TextProps.kerning stored only the
off state, and a style inherits its parent's, so a title under a document
that kerns nothing could never turn it back on. Its 26pt line then ran
0.36mm over the text width and wrapped a word onto a second line where
LibreOffice sets it in one (measured 151.64mm kerned against 151.98).
Both states are stored now, and both formats read and write them.
2026-08-13 19:15:28 +02:00
Steffen Becker ab25abd492 fix: a column gap wider than the text is no gap at all
w:cols/@w:space is twips, and a producer writing EMU there (360000 = 1cm
in EMU) had the importer clamp its 635cm to the model's 5cm ceiling: a
two-column section came out with 51.3mm columns on a 152.4mm text width,
its second column 24.9mm right of where LibreOffice draws it. Below the
text width LibreOffice lays the declared gap out literally (probed from
720 to 8000 twips), so only the unholdable value is dropped — both sides
now start the second column at 108.0mm.
2026-08-13 19:15:21 +02:00
Steffen Becker 1a1505071c fix: the parity reference stops depending on which duplicate font it picks
A second copy of a family LibreOffice bundles makes its render
non-deterministic: it resolves the same family to either file per run — the PDF
names them Carlito and Carlito-Regular — and their vertical metrics differ, so a
page led by a large-font heading lands 1.85mm lower on some runs. Measured 5/5
identical once the duplicates were gone, against a 2-of-4 flip before, and one
fixture's count swinging 21↔62 with no code change. README now says to install
only what the local LibreOffice does not already ship.

Also skips the `~$name` lock file a word processor leaves beside a fixture it
has open, which the corpus was picking up as a document.
2026-08-13 18:07:57 +02:00
Steffen Becker f9eeeb25f0 fix: the parity harness reads a word broken mid-word as two words
getClientRects()[0] gave a token that wraps — one wider than its line, or one
carrying a hyphen or a slash — its whole text at its first fragment's position,
where pdftotext reads the two fragments as separate words on separate lines. So
every such break reported a lineBreak the two engines did not actually disagree
on, and hid the case where they break at different characters. Split by the line
each character lands on; the per-character walk only runs on a word with more
than one rect.

Corpus 900 → 849, no page count moved.
2026-08-13 17:21:41 +02:00
Steffen Becker 24d686232e fix: a table style's paragraph spacing no longer overrides the cell's own style
Word ranks a table style's w:pPr under the paragraph style, so a style zeroing
w:after does not take the space after off a Normal that declares one — probed
against soffice, which keeps 8pt there while its w:line=240 still beats
docDefaults' 276. Read as an override, every cell paragraph lost its spacing.
Spacing in a cell now layers docDefaults ← table style ← the paragraph's own
chain (baked in, since a cell carries no style name) ← direct w:pPr.

Corpus 934 → 900; the fixture it was measured on 66 → 34, its page count exact
for the first time (50 against 50, from 44).
2026-08-13 17:07:00 +02:00
Steffen Becker 3301aba30e fix: the parity harness finds Chromium on any platform
playwright-core resolves its own installed browser, so the hand-rolled
~/.cache/ms-playwright scan and its chrome-linux/chrome path can go — that
directory and binary name are Linux-only, and the harness threw before
rendering anything anywhere else. PARITY_CHROME still overrides.
2026-08-13 17:06:50 +02:00
Steffen Becker bbbe72bd22 feat: a note renders in the file's own style, marker included
The marker moves from a CSS ::before to a widget decoration: generated
content is no text node, so it reached neither the PDF nor a measurement.
The note also carries the paragraph style its file names, so font, size and
colour are the document's; only the hanging indent stays the editor's,
because the style model has no first-line half to pair with the margin.
Adds a unit test for the numbering and the settings, plus the architecture
doc and the two open parity findings.
2026-08-13 12:17:45 +00: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 3703cea1f2 feat: notes round-trip through DOCX
Each class gets its own part via the docx package's footnotes/endnotes maps,
counted from 1, and the anchor becomes a reference run. Import reads both
parts, skipping the separator entries Word references from nowhere, and
flattens a multi-paragraph note to hard breaks. The numbering settings ride
w:footnotePr/w:endnotePr in settings.xml — a post-pack pass, as the package
exposes neither.
2026-08-13 11:42:08 +00:00
Steffen Becker 5f0f5e7457 feat: notes round-trip through ODF
Anchors become an FNT sentinel and each note is hoisted to a top-level
paragraph, so its text rides every existing pass — marks, character styles,
links, bookmarks and formulas included; applyNotes cuts the region back out
into <text:note> at the anchor. styles.xml gains LibreOffice's own Footnote/
Endnote styles, the notes-configuration per class and the separator, and the
resolver reads all of it back. The importer builds the section instead of
warning that notes were dropped.
2026-08-13 11:30:27 +00:00
Steffen Becker f07e020f88 feat: a footnote is drawn at the foot of its anchor's page
The placement loop moves into placeLeaves(), which takes a per-page note
reservation off each contentEnd; a bounded fixed point re-runs it until the
reservation and the pages its own anchors land on agree. Each break now
carries the leaf offset it applies from, so an anchor below a line split
follows its half of the paragraph. Endnotes stay in flow and open their own
page, as LibreOffice collects them.
2026-08-13 11:10:00 +00:00
Steffen Becker 0c74fe94b1 feat: footnotes and endnotes can be inserted and edited
The anchor is an inline atom, the note text a block in the one noteSection
at the document end; a sync plugin keeps notes in anchor order, renumbers
them per class and drops the ones whose anchor is gone. Numbering, indent,
size and separator defaults are LibreOffice's, read out of a document it
saved. Endnotes still render in flow at the document end — lifting a
footnote to the foot of its page is the next step.
2026-08-13 09:22:17 +00:00
Steffen Becker 9d494b2966 fix: a vertically merged cell keeps the edges of the rows it covers
A cell's grid box claimed one row, so a w:vMerge reaching the table's last
row sat inside the banded region instead of on its bottom edge — and a band
declaring insideH nil then dropped the line. The merge extent is counted
before the covered rows are read.

Measured against LibreOffice: the bottom rule runs the full width under a
merged first column, which the editor now draws too.
2026-08-13 08:09:12 +00:00
Steffen Becker 04d1fed5f0 fix: banded rows start on the first body row
The stripe began one row late: regionsAt counted the second body row as the
first band. Probed on a five-row fixture with a red band1Horz — LibreOffice
shades row 0 where the style paints no header region, and the row under the
header where it does.

The corpus cannot see this (no fixture uses the editor's own registry), so it
stayed at 739; checked by eye and by the unit test's inverted expectations. A
document with a table style assigned re-bands on its next open.
2026-08-13 07:06:13 +00:00
Steffen Becker b6a8db8584 docs: note the cell width a collapsed border costs
With border-collapse Chromium takes the border's whole pixel off the cell's
content width, where a word processor lays the line out between the cell
margins regardless — 80.64px of text in a 95.77px column. Giving it back
through the padding fixes the line it was measured on and breaks one two
tables on, so the real offset is under 0.2mm.
2026-08-13 01:28:59 +00:00
Steffen Becker c7e1f91d46 fix: a rule line a paragraph style declares is drawn
Only direct w:pPr/w:pBdr was read, so a Title style's line went missing and
everything under it sat 1.9mm high. The box now goes to the style registry,
where ParaProps' borders already render and round-trip.

Two thirds of that gap is the file's own w:pBdr w:space, carried as
ParaProps.borderPadding (ODF fo:padding) and applied to the ruled sides only —
padding the top as well pushes the paragraph's first line down.
Corpus 761 -> 739.
2026-08-13 00:39:10 +00:00
Steffen Becker 0656b606fb fix: a table's indent is measured to its text, not its edge
w:tblInd means the distance to the cell's text up to Word 2010, so a table
indented 0 hangs its left cell margin into the page margin and its first column
lines up with the body. We drew every such table 2.2mm right of LibreOffice.
Import subtracts it, marginLeft may now be negative, and both exports carry the
edge as it stands.

Word 2013 (compatibilityMode 15) redefined it as the edge — probed both ways
against soffice on one file, 29.6mm against 31.75mm. Corpus 795 -> 761.
2026-08-12 22:57:16 +00:00
Steffen Becker 9d6aedb6ef feat: a Word table style paints the areas its file declares
w:tblStylePr — header row, banded rows, first/last column — was never read, so a
table styled in Word arrived as a bare grid. Import bakes each area into the cells
now (fill, borders, run marks): the file's own style has no registry entry to
re-derive regions from, and the bake is what a re-export carries.

A band's box is the whole banded region, so its insideH is the line between two
band rows; banding counts from the first body row, as in Word and LibreOffice.
Corpus 803 -> 795, page counts unchanged.
2026-08-12 22:30:02 +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 096de84b82 feat: mirrored page margins, so an even page is the left-hand one
ODF style:page-usage="mirrored" and Word's w:mirrorMargins make the
declared left/right the inner/outer pair, which an even page swaps.
Read, rendered and written back in both formats; the body rides the
per-block inset a section's own margins already use, the header and
footer band moves with it. Measured on a mirrored two-column file:
its page 2 went from 12.7-184.1mm to 31.8-203.2, which is LibreOffice's
to 0.1mm, and its running head with it.
2026-08-12 21:50:25 +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 efd4e9c8a7 feat: the table's four row/column inserts are big buttons
Rows & Columns follows Word: four big buttons carrying a 28px glyph and a
two-line label, with the three deletions folded into one Entfernen dropdown
beside them so the group keeps its width.

Two lines cost the band 12px (--w-ribbon-h 96 -> 108). The labels size to
min-content, not max-width, which a wrapped box ignores; and the inserts drop
the reserved caret slot, without which the group's foot label sits 8px below
every other group's.
2026-08-12 23:44:22 +02:00
Steffen Becker 2f3e48ecf9 docs: note mirrored page margins as unimplemented
The inner and outer margins are drawn as left and right on every page,
so an even page's text block sits 19mm off where LibreOffice puts it.
2026-08-12 20:57:56 +00:00
Steffen Becker 35f3c50541 fix: a column break lands on the line it was measured to
Three measurement bugs in columnsFlow, each worth a page on its own:
rects became lines by their top, so a raised run counted as a line of
its own (192 for 111); the split probed `left + 1`, which is a line's
*end* right-to-left, so it kept a word too many and the flow then shaved
one line per pass; and one median advance under-measured a paragraph
whose lines differ in height (2834px real against 2453 estimated), by
enough to budget 74 lines into room for 64. The chain used to flip
between one fragment and two forever; it now settles after one split.
2026-08-12 20:57:49 +00:00
Steffen Becker 16ce22d4b8 docs: note the hidden heading a running head still needs drawn
A paragraph a style hides is dropped on import now; a hidden heading is not,
because it is the outline the chapter field reads. Records what dropping it
measured and what a real fix would need.
2026-08-12 19:52:49 +00:00
Steffen Becker 9bfd579d65 fix: hidden text stays off the page, except the heading a running head reads
text:display="none" now drops the paragraph its style hides and the stored
value of a field that carries the attribute — LibreOffice lays out neither.
A hidden heading is the exception and still renders: it is the outline the
running head's chapter field reads, and dropping it blanked every head that
quotes one. The editor has no way to hold a block it does not draw.
2026-08-12 19:49:33 +00:00
Steffen Becker 99a14a1a7f fix: Hebrew and Arabic take the size their style declares for them
ODF carries western, asian and complex-script font properties side by side and
a word processor picks by the character's script; the importer read only the
western pair, so a complex-script run came out at the document default.
Decided per run — the finest split the model has — so a document with no
complex script is untouched, and our own export (all three written alike)
round-trips. Measured: a two-column page's line pitch 7.5mm -> 5.9 against
LibreOffice's 6.0.
2026-08-12 19:49:15 +00:00
Steffen Becker 06343103e4 merge: the Word-style ribbon lands on main
Brings the ribbon chrome, its tabs and controls, the Paragraph/Tabs dialogs
and the shared selection reader over from word_ui. No file overlap with
main's cover-graphic fix, so the merge is clean.
2026-08-12 19:56:19 +02:00