Commit Graph
115 Commits
Author SHA1 Message Date
Steffen Becker 20e6dcad0e feat: a reading can be printed over its base text
Ruby, as both products' phonetic guide writes it: one element holding a
base and its reading, so the editor keeps it as one inline atom and the
browser sets it as a real <ruby>.

ODF gets <text:ruby> plus a minted ruby-family style (centred, above —
probed: LibreOffice writes that style back unchanged); Word a <w:ruby> run
from a post-pack pass, the package having none. Both legs verified through
a LibreOffice re-save, our .docx to .odt included.
2026-08-15 10:40:52 +02:00
Steffen Becker ee801967f6 feat: words typed once are offered back
LibreOffice's Word Completion, its defaults included: words from eight
letters up are collected as they are finished, and the first three
letters offer the rest. The offer is a widget decoration after the caret,
so it is outside the document until Enter takes it — nothing to undo when
it is not taken.

Only while typing: a bare caret move drops the offer, or an Enter meant to
split the paragraph would complete a word instead. The settings share the
AutoCorrect dialog, as they share LibreOffice's.
2026-08-15 10:27:18 +02:00
Steffen Becker 1c4dec49f3 feat: a reviewing pane, and a colour per reviewer
Every recorded change in document order beside the comments pane: author,
date, the text, accept or reject in place. A change split by a paragraph
boundary is one row, so acceptRevision/rejectRevision take an id. The
author colour is a decoration, since a mark cannot know the document's
other authors; it nests inside the mark's span and carries the under/
strikethrough with it. shortcutHint no longer throws on an id nothing
binds, which heading 7-10 had just made reachable.
2026-08-15 09:40:26 +02:00
Steffen Becker 147034730f feat: headings go to level 10, as both word processors do
HTML stops at h6, so 7-10 render as unknown elements editor.css makes
blocks; their built-in styles continue level 6 at 12pt, since LibreOffice
writes these styles with no properties and resolves them from its own
pool. Both importers read a two-digit level again (the style-name regexes
stopped at one digit). Verified through a LibreOffice re-save of both
formats and in the live editor.
2026-08-15 01:21:23 +02:00
Steffen Becker 4afd5cd1d5 feat: a paragraph can opt out of automatic hyphenation
LibreOffice's Format > Paragraph > Text Flow checkbox. Only "off"
travels (Word has no per-paragraph "on") and only under a document that
hyphenates, or it would say what is already true. ODF writes
fo:hyphenate="false" into the paragraph's text properties, DOCX gains
w:suppressAutoHyphens through a post-pack pass — the docx package
exposes no option for it. Both survive a LibreOffice re-save.
2026-08-15 01:09:55 +02:00
Steffen Becker bf564fbab4 fix: footnote counting per page/chapter and collecting at the end really work
Both were selectable in the dialog and changed nothing — they only rode the
file formats, so an imported document numbered and placed its notes wrong.
noteLabels now counts within a bucket (class, chapter, or the page pageBreaks
publishes through setNoteAnchorPages); a collected footnote leaves position:
absolute and opens its own page, as LibreOffice does. Counting and position
move to the footnote tab, the only place LibreOffice offers them.

The separator's own alignment moves the line alone — probed, notes stay left.
2026-08-14 23:11:10 +02:00
Steffen Becker 46165b82f7 fix: a caption is boxed to its frame, and the frame stays clickable
Two faults on one picture. A block after a float keeps its box over
the float — only its lines move — so the caption on top swallowed
every click meant for the picture; the frame is lifted above the
in-flow boxes. And a caption can only sit under a side-wrapped frame
if it is boxed to it, so its span in the column is measured and
becomes the caption's indent, which also drops it clear of the float.
Measured at 100% and 150%: caption under the picture, its width, in
all four wrap modes.
2026-08-14 22:40:42 +02:00
Steffen Becker 3b24b14470 feat: lines and arrows, which both importers used to drop
Two endpoints rather than a box: no text, no fill, and the diagonal of
the frame with `flipV` picking which one. The three kinds share their
preset names, since the heads a file declares are what tell them apart.

ODF gives a line its own element and its heads a named marker, which
needs the draw: prefix declared in styles.xml — undeclared, the file
does not parse at all. Word keeps the frame and flips 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 c38320a108 feat: citations that carry their source, and a bibliography
The citation is an inline atom holding the whole record, which is what ODF
writes on text:bibliography-mark — so no database travels beside the file.
The list is a new index family: one row per source, in document order and
without page numbers, as LibreOffice prints one.

DOCX cites by tag and keeps the sources in a custom-XML part the docx
package knows nothing about, so the post-pack pass mints the part, its
properties, both relationships and the content type itself.
2026-08-14 10:16:59 +02:00
Steffen Becker bb8e938d35 fix: image/text-box selection and resize handles use the brand blue
They fell back to a generic #3b82f6, unlike the table resize handles
that already read --color-primary. Now all three agree.
2026-08-14 09:14:18 +02:00
Steffen Becker eaaec541af feat: an alphabetical index, marked word by word
References ▸ Index entry marks a place with the term the index should
list — under a key of its own where it belongs elsewhere — and the Index
in the same menu lists each term once, sorted case-insensitively, with
every page it was marked on.

Both formats write a place, not a range, so the entry is an inline atom:
ODF text:alphabetical-index-mark plus text:alphabetical-index, Word XE
plus INDEX. LibreOffice reads our file back unchanged. The bibliography,
the other half of that gap, is still open.
2026-08-14 08:44:00 +02:00
Steffen Becker 4cda289272 feat: a shape can be a triangle, a star or a block arrow
Twelve presets beside the three rectangular ones. Each outline is one
polygon in utils/shapes.ts, given once: the node view draws it as an SVG
path, the ODF export scales the same points into draw:enhanced-path, and
Word gets the preset's name and draws its own geometry.

A gallery in the box toolbar and the ribbon renders its tiles from that
same table, so a new shape needs no icon. Verified through LibreOffice
from both formats; a preset we can't draw still imports as a warning.
2026-08-14 08:02:08 +02:00
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 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 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 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 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 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 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 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 e58e8a180e fix: a page-anchored cover graphic sits in front, at the page's own corner
Two compounding bugs on a title/back-page background image:
- its host paragraph picks up position:relative from the line-height
  rule, becoming the frame's containing block instead of the page and
  offsetting it by a full page margin — now forced back to static.
- the frame always painted behind text (z-index -1); a graphic that
  declares ODF's style:run-through="foreground" now paints in front,
  as LibreOffice draws it, via a new inFront attr round-tripped through
  ODF export/import.

Confirmed against LibreOffice's own render: the duplicated, ~2cm
displaced cover graphic now matches exactly.
2026-08-11 23:48:52 +00:00
Steffen Becker 95d174501d fix: a Word hyperlink keeps the look its file gives it
Word paints a link through its Hyperlink character style, never for
being one, so the editor's own blue overwrote both the file's #0000FF
and the plain black of a link carrying no style at all. The DOCX
importer marks those `plain` and the CSS leaves them alone; LibreOffice
paints every ODF text:a, so those keep the visual.
2026-08-11 20:52:12 +00:00
Steffen Becker 3cf1d34035 fix: an index entry keeps the hard break its heading has
A book's chapter heading is "Chapter 1" and its title on two lines, and both
LibreOffice and Word copy that break into the index. We joined it into one line,
so a fixture's contents came out 18 rows short. The leader and page
number ride the last of an entry's lines (align-items: last baseline).
2026-08-11 19:52:02 +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 0830fff86f fix: a frame is alone on its line, not only on its block
The no-descent rule matched a block holding nothing but frames, so the
image-break-caption idiom kept a text descent under its picture — 0.9mm
per figure, and LibreOffice's own guides stack dozens of them in tables.
Test the run between two hard breaks instead and mark the frame itself.
2026-08-11 06:58:40 +00:00
Steffen Becker 0d3506a51c fix: alignment means nothing to a frame that is its whole line
Probed: an as-char frame alone in a paragraph makes a line exactly as tall as
itself whatever style:vertical-pos/-rel says — only from-top's offset still
lengthens it. The node view cannot see its siblings, so the block's class
overrides the alignment it wrote.
2026-08-10 23:22:15 +00:00
Steffen Becker 6c7f7834e9 fix: a frame alone on its line hangs no descent below it
LibreOffice sizes such a line max(frame, line-height) — nothing of the text
sits under the frame (probed: an image-only paragraph is exactly as tall as
its image, down to a small one, where the text line wins). Our baseline
alignment left the strut's descent below it, ~1.1mm per formula. Whitespace
beside the frame counts as nothing, which CSS cannot ask, so image.ts marks
the block.
2026-08-10 22:36:01 +00:00
Steffen Becker 72b39ca6ad fix: a break ends its block even inside a mark span
A run's own formatting wraps its hard break in a span, so the block's last
element was the span, not the BR — the trailing-break BR was hidden and the
empty line the break opens went missing. LibreOffice renders it (probed: a
Word paragraph closing on <w:br/> keeps a blank line before the next block).
2026-08-10 22:20:19 +00:00
Steffen Becker 3781a79d4a fix: a line-end space hangs past the paragraph mark
The ¶ reserved after every block sat in the line's flow, so a trailing space
was no longer at the line end and could not hang into the margin: a last line
that exactly filled its width pushed the zero-width mark onto a line of its
own, and the block came out one line too tall. Out of flow at its static
position the mark is invisible to line breaking.
2026-08-10 22:20:06 +00:00
Steffen Becker af144362fe feat: an as-char frame sits where the file says it does
ODF style:vertical-pos/-rel on an inline frame, which every LibreOffice
formula image carries: baseline, character area, or a plain offset from the
baseline. The default moves from the line-box bottom to the baseline, so a
line with a picture in it keeps the descent below it, as LibreOffice and
Word both do.
2026-08-10 20:48:31 +00:00
Steffen Becker 314f26a9d4 fix: a cell with no padding below still absorbs its border overshoot
A painted border is a whole pixel however thin the file declares it, and the
excess came off padding-bottom alone — a cell that declares none (a fixture's Mac
table) kept growing 0.25mm a row. What is left over now comes off padding-top.
2026-08-10 19:35:09 +00:00
Steffen Becker b134911e7b fix: pagination measures a text box's own spacing
The atomic leaf a text box becomes carried neither its space above nor its space
below, so the fit test ignored both and a page top never dropped the space above
the way it does for a paragraph. The box's space rides --space-top, which the
page-top decoration zeroes — the rotor centres itself in the padding box and has
to follow it.
2026-08-10 19:35:09 +00:00
Steffen Becker d6c3602429 fix: a lifted text box keeps its anchor paragraph's spacing
An as-char figure frame sits in a paragraph of its own; the importer lifts the
box to top level and dropped that paragraph whole, so its space above and below
went with it — every figure in a fixture sat 2.1mm too tight. The box now carries
them (ParagraphSpacing covers textBox), space above as padding so it adds to the
block above, and the export mints an anchor style to write both back.

One fixture: 142 → 100 parity issues.
2026-08-10 18:54:37 +00:00
Steffen Becker b7f6328005 fix: a table takes the vertical space LibreOffice gives it
Two ways a table's height was off. Its space above was a margin, so it
collapsed against the paragraph above instead of adding to it — now it rides
--space-before like a paragraph's. And every cell border paints a whole pixel
however thin the file declares it, where LibreOffice reserves the declared
width; the excess now comes off the cell's padding-bottom, 0.25mm a row.
2026-08-10 18:34:32 +00:00
Steffen Becker 7a4174cd07 fix: a table cell's metrics follow LibreOffice, not the browser
Two measured deviations, both worth ~1mm a row on a fixture: a cell's first block
got the space above that LibreOffice adds nowhere in a cell, and a repeating
header row rendered centred and bold because it is a <th> and the UA says so.
Pagination's page-top-drop detection now skips cell leaves, or the space that
is never there would read as a decoration to add back.
2026-08-10 13:49:12 +00:00
Steffen Becker 7b8c7e21f5 fix: an index's entries follow the styles its own file names
The TOC rendered every level with our 0.5cm-per-level indent, an editor-only
1.5 line height and no space above — so a fixture's contents drifted 7.6mm off
LibreOffice down one page and pushed entries onto the wrong one. The per-level
entry template names a paragraph style; the rows now carry it as data-style
and the document stylesheet does the rest. Our own indents stay the fallback
for an index whose file names none.
2026-08-10 13:12:17 +00:00
Steffen Becker c26aac1bee fix: the index's leader fills its own gap, at LibreOffice's indents
Every row carried 200 dots — clipped on screen, but the clipboard, the
PDF and any measurement got all of them. Cut each to what its gap holds,
and step the levels by 0.5cm, which is what LibreOffice's Contents styles
use. The parity runner now waits for the page numbers to settle too.
2026-08-10 09:32:49 +00:00
Steffen Becker a350b8f59e fix: a page-anchored frame sits on its page instead of in the text
ODF text:anchor-type="page" is a cover graphic or a watermark: out of
flow, placed from its page's corner, behind the text. In flow, the two
full-page frames of a fixture's covers cost three pages.
2026-08-10 07:38:54 +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 8c15f37490 fix: nothing is drawn past the edge of the sheet
A formula wider than the text area painted over the app background
instead of being cut at the page edge, where LibreOffice cuts it
(thesis fixture, page 16). `overflow-x: clip` so .paper does not
become a scroll box.
2026-08-09 05:59:58 +00:00
Steffen Becker 3154a9b7a2 feat: a list label takes the alignment the file gives it
Word's w:lvlJc / ODF's fo:text-align on the level: set right, the label grows
into the margin instead of over the text it labels. That is what the built-in
Roman numberings use, and a fixture's numerals collided without it.
2026-08-08 23:09:58 +00:00
Steffen Becker ee1b0dd180 fix: a line's extra leading sits below it, not half above
A spacing above single puts all of its leading after the line (probed against
LibreOffice at 150%); CSS splits it. The block is shifted back up by that half,
which leaves the flow — and pagination — where it was.
2026-08-08 22:51:56 +00:00
Steffen Becker ee56a348ee feat: formulas can be inserted and edited, and survive both formats
A formula node stores only its LaTeX source; the MathML on screen, the ODF
formula object and Word's OMML are derived from it, and both importers parse
foreign markup back into that source, so an imported formula stays editable.

Word documents were losing their formulas outright: m:oMath sits beside the w:r
runs in its own namespace, past convertInline's `namespaceURI !== W` guard.
2026-08-08 21:50:36 +00:00
Steffen Becker ae2290e22c feat: a header or footer keeps its own tab stops
The left⇥centre⇥right layout every letterhead uses rides on them, and a
zone had none: Word keeps them on the Header style, ODF on the zone
paragraph's, and neither was read or written.

An inactive zone is static generateHTML output no plugin reaches, so it
is measured straight on the DOM. Two bugs surfaced doing it: a segment
its own advance had pushed onto the next line measured as wrapped, and
the alignment then had no way back — it takes the sum of the segment's
client rects now. And the scale came from offsetHeight, which rounds
half a percent away on a zone one line tall.
2026-08-08 15:13:58 +00:00
Steffen Becker ea39dde58b feat: a tab lands on the file's own grid, and can draw its leader
The default tab interval was fixed at 1.25cm, so a file's own
w:defaultTabStop / style:tab-stop-distance was ignored and the DOCX
export wrote 1.25 back over Word's 1.27. It is a document setting now.

A stop can also carry the character it repeats across its gap. odf-kit
has no leader option and mints one automatic style per option set, so
the leader rides style:type behind a sentinel — matching on position
and alignment alone gave every stop at 6cm the same leader, which
LibreOffice drew back plain as day.
2026-08-08 14:40:26 +00:00