Commit Graph
112 Commits
Author SHA1 Message Date
Steffen Becker 71ecc17a9e fix: a header/footer's gap to the body is inside its band, not above it
The body margin reserved the zone's height *plus* its fo:margin-top/-bottom, so
every page with a footer lost a line — a fixture ran a page over. Probed against
soffice: moving a footer's fo:margin-top leaves the last body line exactly where
it was and only shifts the footer text down its band; style:dynamic-spacing
changes neither side. The band is the declared height, the gap sits in it.

One fixture paginates to LibreOffice's 63 pages again.
2026-08-10 19:34:53 +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 819f51587e fix: direct formatting on a run beats its named character style
A character style is a stylesheet rule, direct formatting an inline style on the
run's span. The charStyle mark ranked below TextStyle, so its span sat *inside*
and the rule won over the ancestor's inline style — a 9pt style silently undid a
run's own 10pt. Priority 102 lifts it above TextStyle's 101.

One fixture: 259 → 172 parity issues, lineEnd 49 → 7.
2026-08-10 18:08:13 +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 a8dcd9b897 feat: a cell's vertical alignment survives saving
Import read style:vertical-align but neither exporter wrote it, so a
mid-cell callout label jumped to the row's top on every save. odf-kit's
CellOptions and docx's TableCell both take it natively; DOCX import maps
w:vAlign in as well.
2026-08-10 12:51:58 +00:00
Steffen Becker aa2f680ad7 docs: what the corpus still reports, and why 2026-08-10 11:42:41 +00:00
Steffen Becker ab83ad3c58 test: the parity runner compares a leader as one token, and polls
How many dots fill a tab or index leader is the fill's own business —
both engines end it at the same stop. The settle predicate now polls on a
timer: walking every spacer and index row each frame starved the layout
it was waiting for, and half the runs timed out.
2026-08-10 11:32:40 +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 4ad743dc28 test: the parity runner waits for the import, not just for a blank page
A 3.5MB fixture takes seconds to parse; until then the editor holds the
empty document, whose layout is stable enough to pass the settle window.
2026-08-10 08:17:40 +00:00
Steffen Becker 2e70e47912 test: the parity runner can reuse a dev server already listening
PARITY_PORT points it at an existing npm run dev instead of starting a
second one on 5199.
2026-08-10 07:38:31 +00:00
Steffen Becker aff3eaa812 fix: a named style's line spacing is the percentage the file declares
The style-registry path divided an ODF fo:line-height by 1.15 where the
block path, both DOCX paths and the export all treat it as the plain
percentage, so every paragraph whose spacing came from its style
rendered 15% too tight. Its suppression yardstick is the block's own
style too, so a 100% under a 115% style stays as direct formatting.
2026-08-09 20:43:34 +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 af299d8b9f fix: a minted automatic style reaches a document that had none
odf-kit writes `<office:automatic-styles/>` self-closed when it minted
nothing, and every post-pass injected by replacing the closing tag — so
in such a document the page-break, spacing, list and text-effect styles
were dropped and only the style-name reference survived. One
injectAutomaticStyles helper now serves all of them, content.xml and
styles.xml alike.
2026-08-09 15:28:48 +00:00
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 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 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 2f6e771f06 docs: record what a metafile brings and why a line still breaks early
The line-break finding is sharpened: LibreOffice compresses inter-word
spaces to fit a line (91 of 349 full-width justified lines on the
thesis, up to 0.83px per space), which CSS justification cannot. A
matching negative word-spacing was measured and reverted.
2026-08-09 05:59:58 +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 c53bef8cae docs: record why a hoisted text box's offset was not measured back 2026-08-09 00:13:41 +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 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 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 412701cdec docs: record what LibreOffice does with a nested level's text-indent 2026-08-08 17:47:17 +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 9d85ddb213 docs: record what dropping the block strut costs
Measured against LibreOffice on a purpose-built fixture the rule is
right, and on the corpus it is a loss: one fixture gains 22 issues.
The strut is also what holds up a line of no runs and, through the
reserved ::after, the paragraph mark on the last line.
2026-08-08 16:32:59 +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 dfa6f27638 docs: the marker is placed; its level's own hanging is not read 2026-08-08 12:57:44 +00:00
Steffen Becker eb4d15c02c docs: say which parity findings measurement did not support
Three of them described something the reference does not do. The trailing
space is counted by LibreOffice as well; neither engine breaks after a
slash or inside a run of dots; and the hard-break line was real but came
from the paragraph mark, not the break.

Each was checked against soffice on a one-page fixture, so the record now
says what was measured rather than what was inferred.
2026-08-08 12:40:26 +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 e4b679ad8b test: find whichever Chromium build is installed
The parity runner pinned one build number, so a fresh
`playwright-core install chromium` left it pointing at a directory that
is not there. PARITY_CHROME still overrides.
2026-08-08 12:17:51 +00:00
Steffen Becker a4209fd622 docs: bring the parity findings up to date
The index draws its leader now, and a frame's vertical offset is a kept
value rather than an ignored one — with the measurement that decided it.
2026-08-08 10:32:29 +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 f8c818c203 test: LibreOffice reads every character effect back
The re-save leg is the only place that proves what we write is what LO
understands. A raised run is stored as a percentage of the font size, so the
fixture picks one that stays whole — LO rounds the percentage on save.
2026-08-08 10:08:16 +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 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 58607d92e9 docs: say what the parity harness cannot see
It compares where text sits, so a dropped highlight or fill reads as green;
the visual check that catches those, and its two traps, are written down.
Also records the list marker's ~4mm offset, which the tolerance hides.
2026-08-08 09:08:58 +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 361b14dc17 test: compare the rendered page against LibreOffice
Everything else in tests/ compares documents at the model level; nothing checked
what a page actually looks like. run.mjs renders the same file through soffice
and through the real app in Chromium, reads both back as word boxes, and diffs
page count, line count, line text and line position.

The corpus stays out of the repo: make-fixtures.mjs authors the baseline .docx
files, and real-world documents are whatever you drop into the gitignored
fixtures/ yourself.
2026-08-08 08:35:34 +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 be96ee626c docs: apply rules for comments 2026-08-08 08:28:55 +00:00