Commit Graph
259 Commits
Author SHA1 Message Date
Steffen Becker 84eb7d6bf3 fix: an index lists the levels it declares, not all five
The TOC node regenerated over every heading level and minted a title of its
own. A file asking for level 1 only (text:outline-level, Word's TOC \o range)
got a 43-entry index instead of 12 — a full page where LibreOffice needs a
quarter, plus a second "Table of Contents" under the file's own heading.

maxLevel and an empty title now round-trip through both formats.
2026-08-09 14:54:58 +00:00
Steffen Becker 2dcf8b8a20 fix: a title-page master governs one page, not the whole document
An ODF master page whose style:next-style-name names another master applies
to a single page. Read as the section's running header, this file's full-page
title background repeated on every page and its height pushed each block onto
its own — 276 pages instead of 16.

Its zones now become the first-page variants, the successor's the running
ones, and a positioned frame in a zone is dropped as in DOCX.
2026-08-09 14:32:53 +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
Steffen Becker 848c18fbe3 fix: the last spacer goes when the last page does
Deleting a document down to one paragraph left its page-break spacer in the
DOM, so an empty second page kept showing. The ping-pong guard reset its
memory to '', which is also the key of a spacer-free layout, so the pass that
removed every spacer looked like an oscillation and never dispatched. The
reset now uses null.
2026-08-09 14:08:29 +00:00
Steffen Becker 78e35cef2c feat: a reference finds the text it points at, and follows it
Bookmarks are a mark, not a point: both formats store a range, and only a
range lets a REF recompute its text. A cross-reference caches its display
like a Word field and resolves live on each pagination settle — the
bookmark's text, or the page it renders on — keeping the cached value when
the target is gone. Ctrl+click jumps to it, internal #name links too.

ODF splices its sentinels into the run text so cells and custom paragraphs
need no separate emitter; DOCX needs no post-pass at all.
2026-08-09 13:56:22 +00:00
Steffen Becker 46ae0211af fix: a Word table draws only the borders it declares
The DOCX importer read w:tblBorders off the table but never off its table
style, so a table whose style declares none (Word's Normal Table, and the
thesis' Formel-tab around every formula) fell back to the editor's 0.5pt
black default. Resolved per side along the w:basedOn chain, and an
undeclared side is not drawn — LibreOffice writes fo:border="none" for
exactly those tables when it re-saves the file. The ODF importer already
treated an undeclared side that way.
2026-08-09 08:54:59 +00:00
Steffen Becker 3be4333517 feat: two frames set against opposite ends share one band
A top-and-bottom frame carrying positionH align / style:horizontal-pos
keeps it only where a nearby frame is set against the other end
(pairAlignedFrames, both importers); such a pair floats to its own side
instead of filling the band, so the two sit side by side as they do in
LibreOffice. A pair slightly wider than the column is scaled to it —
Word lets the two overlap, two floats cannot.
2026-08-09 05:59:58 +00:00
Steffen Becker 29d82ee657 feat: a metafile is drawn instead of a grey placeholder
An EMF is replayed into SVG (import/emf.ts): window/viewport plus world
transform, a DC stack, pens/brushes/fonts, the poly and path records,
EXTTEXTOUT and embedded DIBs. It rides the lazy pre-pass both importers
already use for TIFF, so a document without one pays nothing.

a fixture fixture's seven MATLAB plots arrive as pictures now.
2026-08-09 05:59:44 +00:00
Steffen Becker 2380ed699b feat: an ODF chart object is drawn too
<chart:chart> in the object's sub-document, read through the same picture as
the DOCX leg: series values by cell range out of its own local-table, colours
from the series style, bounds from the axis style.
2026-08-08 23:54:59 +00:00
Steffen Becker bf98014a3a feat: a chart is drawn from its own part instead of a placeholder
word/charts/chartN.xml → an SVG picture of the frame's size: series from the
producer's numCache, theme accent colours, titles, gridlines and the axis
bounds the file fixes. Bar, line, area, scatter and pie; the rest keep the box.
2026-08-08 23:49:13 +00:00
Steffen Becker 4088bffbaf fix: a styled paragraph in a cell or text box keeps its formatting
Only a body block carries a styleName, so measuring the others against their
own style suppressed everything the style gave them and nothing re-applied it.
Word's caption style lost its italic and colour in every text box that way.
2026-08-08 23:27:10 +00:00
Steffen Becker 2fe762a594 fix: a citation inside a content control keeps its text
Word wraps every citation in a w:sdt; the inline walk ignored the wrapper, so
its runs never reached the paragraph. a fixture fixture lost every [7]-style
reference with them, and now matches LibreOffice's page count.
2026-08-08 23:17:47 +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 2830e8a9d4 feat: a top-and-bottom frame is drawn where the file places it
Both anchor offsets now render: the x moves the rotor inside the full-width
float, the y the float's top. Chromium puts every line after such a float
below it, so a frame set below the paragraph top is sunk behind the text at
import and the node view measures what those lines already cover.
2026-08-08 22:51:39 +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 e9e543fe8c fix: an image stays on its side of the page break
An as-character image is now a line box in getLineRects, so a paragraph of
stacked images breaks between them instead of overflowing the page. Only a
floated image keeps its paragraph atomic, and the leaf's height reaches down
to the lowest float — a float hangs out of the paragraph's box, so
offsetHeight alone let the image walk off the page foot. prevPlacementsKey
damps the ping-pong that overhang causes between two layouts.
2026-08-08 20:57:41 +00:00
Steffen Becker 0af3488e6e feat: every list level takes its indent from the file
Only the top level's own w:ind w:left (ODF: the label-alignment margin)
was read; every level below it was forced to the editor's 1.27cm step.

Both formats store a level's indent absolutely while the editor nests one
step per level, so import now subtracts the level above and the ODF
export adds the running sum back per level (DOCX already accumulated).

Measured: a 0.5cm/-0.6cm pair puts LibreOffice's two levels at 1.77cm and
2.44cm, and the editor draws them at 1.769 and 2.44. Parity corpus
unchanged.
2026-08-08 17:47:17 +00:00
Steffen Becker ca3767f3d4 feat: a cell keeps its own margins where they differ from the table's
w:tcMar and an ODF cell style's own fo:padding were both dropped: the
table's value (in ODF the first cell's) stood for every cell.

cellPadding is now an attr on tableCell/tableHeader too, measured against
the table's rather than the editor default, so only a real disagreement
carries one. --cell-pad inherits, so the cell's own declaration wins for
its own td and the CSS is unchanged.

Checked against LibreOffice: a 1.2cm cell in a 0.3cm table puts its text
at 332.2pt where the grid column starts at 297.6.
2026-08-08 17:47:06 +00:00
Steffen Becker aad4e7a3e4 feat: a later section's header and footer can be edited
The zones already imported, rendered and exported per section, but only
section 1 was editable state — the rest were marked read-only.

Editing now targets the section of the page it starts on: a double-click
edits the zone under the pointer, the Layout-panel buttons the current
page's. zoneKey/writeZone route the single live editor's read and its
write-back to either the six per-zone props or the extraHfSections entry.
2026-08-08 17:00:49 +00:00
Steffen Becker 7d5bc5e0c9 feat: a run of tabs too wide for its line carries on the next
LibreOffice restarts the tab grid on the continuation line; Chromium
hangs the leftover tabs and starts the next line at the margin, 160mm
away in the repro. The run is walked from the pen before it — the one
thing the fix can't move, so the answer holds whether or not the break
is already in place — and a break widget goes where the line runs out.

Measuring this turned up why the two disagreed in the first place: a
file that declares no tab interval falls back to ODF's own 2cm, not to
LibreOffice's UI default of 1.25, so leaving ours implied on export
rendered every tab elsewhere. Both exports declare it now, and both
imports fall back to their format's value rather than to ours.

Only runs of two or more are walked: reading a block's geometry forces
a reflow, and doing it per tab across 48 pages slowed pagination enough
that the parity harness compared a layout still settling.
2026-08-08 15:58:40 +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
Steffen Becker 6accc7268f feat: every section keeps its own header and footer
A document had one set for all of it, taken from whichever section
declared one last — so a fixture lost its letterhead entirely
and page 1 got the empty header its final section asked for.

A body block now carries `sectionBreak`, ordinal rather than indexed so
editing cannot desync it. Import resolves Word's "Link to Previous" per
reference type; export writes a set per sectPr, and on the ODF side each
section past the first gets its own master page, which the block switches
to. The layer picks a page's set from where pageBreaks reports the
sections starting, and each section's zone reaches ride
`--pb-section-reach` so a later section's letterhead makes room for
itself: page 10 of a fixture was 24.9mm out, now 1.1mm.

Editing still reaches section 1 only; the rest render read-only.
2026-08-08 14:02:36 +00:00
Steffen Becker 60980fa4d6 docs: load CLAUDE.md detail only where it is needed
The root file is read at every session start and had grown to 87 KB, so every
session paid for knowledge it did not use. Move the per-directory detail into
nested CLAUDE.md files, which load when a file in that directory is touched,
and the five deep-dive topics into docs/. The root keeps the commands, the hard
rules and an index of where the rest lives.

Content moved verbatim — the probed Word/LibreOffice behaviour, sentinel order
and shared constants are what the code cannot say. CI fails the root over 120
lines, since a prose budget alone is what let it grow.
2026-08-08 15:33:46 +02:00
Steffen Becker 63149d52d7 feat: a list marker sits at the level's hanging indent
CSS gives ::marker no position, so Chromium set it flush against the
text — about 4mm right of where Word and LibreOffice draw it. The marker
is now the item block's ::before, placed at the hanging indent both
exports already write (LIST_HANGING_CM 0.635cm): measured against LO's
own render, the three bullet levels land within 0.04mm.

Out of flow, because in flow it grew every list line by a pixel and cost
a fixture fixture a page. `list-style: none` drops only the marker box,
so the list-item counter and <ol start> keep numbering; a bullet's own
char rides an inherited property, since attr() in a pseudo would read the
block rather than the list.
2026-08-08 12:57:24 +00:00
Steffen Becker db0fe71cdb feat: a table keeps the cell margins its file gave it
The cell padding was a constant in three places that disagreed with each
other, so a narrow column lost 0.38cm it did not have: a bibliography
marker column of 0.71cm wrapped "[2] " onto three lines and every entry
grew with it, costing a fixture a whole page.

The table now carries its own margins (w:tblCellMar, direct or from the
table style; fo:padding on the ODF cell style) and both exports write
them back. A side within a twentieth of a millimetre of the default is
the default, or Word's 108 twips would tag every table.
2026-08-08 12:18:16 +00:00
Steffen Becker e4b6133d46 feat: keep a block's lines together, and stop reserving a frame's drop
keepLines (w:keepLines, fo:keep-together) is keepNext's sibling and was the
one of the pair nobody read: a block that declares it now paginates whole,
unless it is taller than a page, where Word splits it too.

The vertical frame offset is data only from here on. A line box avoids a
float's whole margin box, so applying it made dead space where Word flows
text — measured: a fixture's figure page went from 131mm off to 187mm.
2026-08-08 10:28:54 +00:00
Steffen Becker 5968b6a04b fix: a frame keeps its place whatever kind of frame it is
Text boxes and shapes had no offsets at all, and for images the wrap side
overrode the x: a file that names the side its text flows on lost the
coordinate, our own export included. Side and place are read separately now,
and one helper turns both into the float's margins for every frame kind.
2026-08-08 10:17:21 +00:00
Steffen Becker b61fadca48 feat: the character effects Word and LO carry beyond bold and italic
Letter case, the underline's shape and colour, a double strikethrough and a
freely raised run all had no home in the model, so every one of them was
dropped on import. They ride the marks now, render in CSS, and round-trip.

odf-kit's run formatting has a field for none of them, so a sentinel folds
them into the run's own automatic style — the route the character styles
already take, and it reaches every path odf-kit serializes.
2026-08-08 10:06:31 +00:00
Steffen Becker 5549d625f7 feat: the index reads like the one a word processor generates
Its leader is real dots now, not a border, so it scales with the font and
reaches the PDF as text. Title and entries follow the document's Heading 1
and default style, which is what the index styles of Word and LO are based on.
2026-08-08 09:33:25 +00:00
Steffen Becker 34c529e3c4 feat: a floating frame keeps its distance from the anchor paragraph
Word's positionV and ODF's svg:y say how far below its paragraph a frame
sits; ignoring it put every figure at the anchor itself. It is the float's
top margin, so lines that fit above the frame still flow there, as in Word.
2026-08-08 09:22:03 +00:00
Steffen Becker 993e64e933 fix: keep the highlighter pen a docx run carries
Word's highlighter is w:highlight, a palette name; only w:shd's hex fill was
read, so 121 yellow runs of a fixture imported unmarked. 'none' maps to the
same 'auto' sentinel an empty w:shd gives, so it still cancels a style's pen.
2026-08-08 08:56:38 +00:00
Steffen Becker 09f57c5326 feat: keep the space a chart or a metafile reserves
A drawing the editor cannot render was dropped outright, and everything below
it moved up: a thesis with five charts and four EMF figures lost 102cm of
layout, five whole pages of it. They now come in as a labelled box of the
drawing's own size, so the pages hold what they hold in Word.
2026-08-08 08:30:06 +00:00
Steffen Becker b217de06c1 feat: a table of contents keeps the heading its file gave it
A German document's "Inhalt" came back as "Table of Contents" — the node
carried no title of its own, so the generated one always won. Now the
imported heading rides on the node and goes back out with it.
2026-08-08 08:30:06 +00:00
Steffen Becker f62559707c fix: a header/footer zone keeps its blank lines and its logo's height
A letterhead header is usually a couple of empty lines, the logo, then one
more — the empty leading ones were dropped on import, and the body's content
area counted every line as one text line, logo included. Page 1 of a contract
form started 19mm too high.
2026-08-08 08:30:06 +00:00
Steffen Becker d26fcae94d feat: keep a block with the next one across a page break
A heading whose successor no longer fits below it was left stranded at the
page foot; Word and LibreOffice move it down with them, which is why their
heading styles all carry w:keepNext / fo:keep-with-next. Headings do it by
default here, so the attr only marks the other blocks that ask for it.

One level deep: a run of kept-together blocks would need the pagination pass
to reconsider leaves it has already placed.
2026-08-08 08:30:05 +00:00
Steffen Becker c82a78cc6a feat: character spacing, and a line box that multiplies its two halves
Word's Title style tightens by -0.5pt, so a title came out ~3mm wide of
LibreOffice's. letterSpacingPt joins TextProps and rides both formats
(w:spacing, fo:letter-spacing).

Line spacing needed the same care: it is proportional to the font's natural
line height, so the factor and that height are now separate CSS variables and
editor.css multiplies them in one place. Before, whichever of the two was
declared nearer simply overwrote the other — a block carrying its own font
silently dropped the style's spacing.
2026-08-08 08:30:05 +00:00
Steffen Becker e2e885e1e8 feat: the paragraph mark carries the block's font, not only its size
The block's CSS strut is the floor under every line, so a paragraph whose runs
are smaller or of another family than the style rendered too tall — 03-fonts
drifted 4mm over a page. The mark's family now rides the block like its size
already did (Word's w:pPr/w:rPr/w:rFonts, ODF the paragraph's own text
properties), and supplies the natural line height single spacing means.

A mark that declares nothing but whose runs all agree takes their font: that
is the paragraph's font, and it is what a word processor struts with.
2026-08-08 08:30:05 +00:00
Steffen Becker d2701d38ae feat: place a floating image at its x in the text column
Word anchors a wrapped frame at an offset; the model only knew a side, so a
frame the file puts 91mm in snapped to the margin and the text beside it came
out 20mm too narrow. wrapOffset carries that x (cm from the column's left
edge, Word's posOffset / ODF svg:x) and the float's outer margin renders it —
against the column vars, so an indented anchor paragraph cannot skew it.

Picking a wrap side in the toolbar clears the offset: that gesture means
"put it there".
2026-08-08 08:30:05 +00:00
Steffen Becker 273654640c fix: fit an imported as-character image to the text column
An inline atom wider than the column is pushed onto a line of its own and
leaves the empty line above it in the document — 5mm per image. A picture the
file sizes to the column lands there anyway once its cm/EMU width is rounded
to whole px, which is how a full-width Word picture triggered it.
2026-08-08 08:30:05 +00:00
Steffen Becker 58eadc3a2e fix: read a header/footer wrapped in a content control
Word's inserted page number sits in a w:sdt, so the paragraph is not a child
of w:ftr and the whole zone came back empty. Mirrors what convertBlocks
already does for the body.
2026-08-08 08:30:04 +00:00
Steffen Becker 9d6b77a876 fix: take a wrapped frame's side from its own x
wrapText="bothSides" says nothing about the side, so every such frame floated
left — including the ones Word puts in the right half of the column, which
then pushed the text off the page's right edge instead of flowing beside it.
2026-08-08 08:30:04 +00:00
Steffen Becker 3344d20481 fix: apply a table style's paragraph spacing to its cells
Word's Table Grid — the style nearly every real table names — sets space
after 0 and single line spacing in its own w:pPr, which is why a table's rows
are line-tight. The cell paragraphs were taking the document default instead.

An explicit single spacing is now written out as well: it only used to be
dropped as redundant, but the block's own style may set another one.
2026-08-08 08:30:04 +00:00
Steffen Becker 9f6736d5a8 fix: honour w:contextualSpacing on import
Word's List Paragraph carries it, so consecutive list points sit line-tight.
Without it every one of them took the document default's 8pt after, and a
document that is mostly bullets drifted more than a page.
2026-08-08 08:30:04 +00:00
Steffen Becker 821688c2cb fix: carry the theme font into an imported paragraph style
A run with no font already fell back to the document's theme; the style did
not, so the registry held Standard and the headings without a family. Single
line spacing is measured against that family, so it resolved to the wrong one.
2026-08-08 08:30:04 +00:00
Steffen Becker 869556e531 feat: render Calibri Light with the bundled Carlito
Word's default theme heading font, so every Word file with a Title or a
heading references it. Without a face it dropped to the browser's serif —
a serif heading at the wrong width.
2026-08-08 08:30:04 +00:00
Steffen Becker 3a71491b58 fix: scale a named style's line spacing by the font's natural line height
A proportional line spacing multiplies the font's own single line height in
Word and LibreOffice, CSS multiplies the font size. The node attr already
scaled by Liberation Serif's 1.15; a style's line-height went out raw, so an
imported 1.08 rendered ~20% tighter than the file asks for and the drift
accumulated down the page.
2026-08-08 08:30:04 +00:00
Steffen Becker be96ee626c docs: apply rules for comments 2026-08-08 08:28:55 +00:00
Steffen Becker cd1da67586 fix: carry a character style through a list item on ODF export
odf-kit's list builder reads a run's marks itself and knows nothing about
charStyle, and applyRuns — which handles the style on every other path — never
sees a list paragraph, so a character-styled list item exported as plain text.

A pre-pass now does both of applyRuns' jobs: bake the resolved props into direct
marks (the run's own marks staying on top), which is what gets the run a span at
all, and prefix the CST sentinel so applyCharacterStyles re-points that span at
the named style. Formatting and name both round-trip.
2026-08-07 20:27:31 +00:00