Commit Graph
246 Commits
Author SHA1 Message Date
Steffen Becker dc06d88299 test: own soffice profile for the render-consistency leg
Run in parallel with lo-roundtrip, both soffice instances raced for
the shared user profile lock and the conversion died intermittently.
2026-08-30 10:05:43 +00:00
Steffen Becker 3f047efca8 fix: a styled table writes no table-level borders
Every cell already writes all four sides itself, and on a styled table
the table-level black default showed in Word, which lets table borders
beat a cell's "none" (only "nil" loses) — LibreOffice lets the cell
win, so only Word drew the grid over the style. Plain tables keep the
default grid via the per-cell fallback.
2026-08-30 10:05:43 +00:00
Steffen Becker 1e88427154 test: semantic package lint, LO style resolution, cross-format rendering
Three legs against the closed-loop blind spot the duplicate-style bug
exposed: package-lint.test.ts checks invariants the schemas cannot
express (unique style ids/names, dangling style/num/rel references,
balanced ranges, manifest completeness); lo-roundtrip gains a
soffice→fodt leg asserting the styles LibreOffice resolves (Heading
chain Arial/bold/sizes, Word defaults); lo-render-consistency.test.ts
renders the kitchen sink from both formats through LibreOffice and
compares pagination and pixels (ratchet thresholds). CI's LO job
gains poppler-utils; test:lo runs both LO files.
2026-08-30 10:05:43 +00:00
Steffen Becker 29a72e143e fix: splice table and numbering styles post-pack instead of importedStyles
The docx package's Styles merge spreads our options over its factory
set, so a passed importedStyles array replaced the whole set —
w:docDefaults (default font, size, language) and the Title/Heading1-6
factory slots included. Every document using a table or list style
exported without document defaults; Word fell back to its own. The
style XML is now spliced into styles.xml by a post-pack pass
(applyRawStylesDocx), and the regression test builds with a table
style and asserts w:docDefaults survives.
2026-08-30 10:05:43 +00:00
Steffen Becker 51f1ab372c fix: route Title/Heading1-6 through the docx factory's override slots
The docx package always emits its own Title and Heading1-6 styles; our
same-id copies in paragraphStyles made styles.xml define them twice, and
both Word and LibreOffice then dropped the basedOn chain — headings
rendered serif, regular, at the raw size. buildStyles now feeds those
seven through styles.default.title/headingN, leaving a single
definition with the chain intact. Regression test asserts unique
styleIds and Heading1's basedOn.
2026-08-27 20:15:44 +00:00
Steffen Becker 742b1cc2b0 test: kitchen-sink fixture + schema validation of both exports
tests/kitchenSink.ts authors one deterministic document using every
feature; schema-validation.test.ts validates its .odt against the ODF 1.3
RelaxNG and its .docx parts (after MCE resolution, as Word reads them)
against the ECMA-376 transitional XSDs — the structural half of Word's
strict reader, which LibreOffice silently forgives. Schemas vendored under
tests/schemas/ (sources and offline patches in its README); self-skips
without xmllint, which CI now installs.
2026-08-26 15:42:31 +00:00
Steffen Becker 3a6bc336f7 fix: schema-valid ODF and DOCX output
ODF: every part declares 1.3 (the version LibreOffice writes; header-first
is a 1.3 element), draw:image gains the mandatory xlink:type trio,
text:time-value becomes an xsd dateTime (import still reads the legacy
duration), index-entry-link-start/-end stay in TOC templates only, and
dont-balance-text-columns moves onto style:section-properties (probed: LO
reads and re-writes it there). DOCX: a final pass re-sorts w:settings into
the CT_Settings sequence, and comments.xml declares w14 mc:Ignorable.
2026-08-26 15:41:12 +00:00
Steffen Becker c566950258 fix: unwrap the imported style XML so w:style lands directly in w:styles
ImportedXmlComponent.fromXmlString returns a nameless wrapper that
serialized as a literal <undefined> element around every table and
numbering style. LibreOffice skips the unknown element; Word declares
styles.xml unreadable and rebuilds it with its own defaults, so the
recovered document also lost the 0-spacing Normal style. Regression
assertions on both DOCX style paths.
2026-08-26 14:28:53 +00:00
Steffen Becker 525c95f3a8 feat: add Numbering 1.a.i, the default cycle as a preset
The gallery avoids presets the dropdown plus default nesting already
covers, but the default cycle itself earns its slot: once a list wears
another style, switching back needs a style to switch to. No sheet
version bump — an added built-in survives the merge on its own.
2026-08-26 14:03:55 +00:00
Steffen Becker 6f57a8e7ed feat: level-varied preset gallery for the list-style built-ins
The uniform LibreOffice definitions duplicated the dropdown formats plus
default nesting. The gallery now ships patterns the depth cycle cannot
produce: the outline chains Outline I.A.1 / A.I.1, the kind-mixing
Numbering with Bullets and Checklist, and the Diamond Bullets ladder,
all on the plain 1.27cm step. STYLE_SHEET_VERSION 12 swaps stored
built-ins while user styles survive.
2026-08-26 09:58:33 +00:00
Steffen Becker 697eea4d86 fix: a list style's level decides the depth's kind
effectiveListLevel resolved a level only for its own node species, so a
Tab-nested <ul> under a number level kept cycle bullets while the named
ODF definition (and LibreOffice) numbered it. The level's kind now wins:
the walk numbers a <ul>/bullets an <ol> (CSS keys generalized), both
exporters resolve clones and forks through eff.kind (new ODT pass
applyListLevelKinds switches L# level species), and setListStyle retypes
the subtree. Node marker attrs still override with their own species.
2026-08-26 09:21:45 +00:00
Steffen Becker b0de0311a8 feat: named list styles — DOCX round-trip
One shared abstract per style (each list its own w:num instance, so numbering
restarts); a post-pack pass injects w:styleLink and the numbering style's real
numId, which the docx library cannot express. Import follows w:styleLink and
w:numStyleLink — a linked abstract used to resolve to {} and silently import
as a bullet list — maps the style onto listStyleName + sheet.list, and derives
no per-level attrs. Probed: LO ignores DOCX numbering styles both ways (mints
WWNum# on import, flattens to the look on export); ODF carries the identity.
2026-08-25 22:19:14 +00:00
Steffen Becker e2525237de feat: named list styles — model, editor resolution, ODF round-trip
Fourth style family (LibreOffice's Listenformatvorlagen): per-level model in
styles/listStyles.ts, five probed LO built-ins, listStyleName on the outermost
list, one effective-level resolver shared by the decoration walk and the export.
ODF writes the full named text:list-style into styles.xml and repoints
unoverridden lists at it (overrides keep the resolved automatic clone);
import maps named list styles back to the attr + registry, attr-free.
Probed: LO keeps the named reference and display-name across a re-save.
2026-08-25 20:38:15 +00:00
Steffen Becker 7dd997cbfa feat: four draft templates, shelved out of the gallery
Cover letter, CV, invoice and meeting minutes as builder modules on a shared
builders.ts (JSON helpers + the 45mm window-envelope arithmetic), localized
en/de. They sit in the registry's SHELVED list: compiled and schema-checked
per locale by tests/unit/templates.test.ts, but not offered in the gallery
until their content is settled. Gallery gains per-template thumbnails.
2026-08-25 16:03:56 +00:00
Steffen Becker 70888409da feat: draw the fold marks on the right edge too
Both edges help fold the sheet; the punch mark stays left, where the
pages are filed. The exports mirror them as EdenFoldMark<N>R lines
(page width from the ODF page layout / the DOCX page size), the
importers already match on the shared name prefix. Verified headlessly:
5mm from either edge at 87/192mm.
2026-08-24 17:47:01 +00:00
Steffen Becker a5c072db23 feat: fold and punch marks (Falzmarken, DIN 5008)
edentext-fold-marks flag: FoldMarkLayer draws the 87/192mm folds and the
148.5mm punch mark per page inside .paper, so print and PDF include them.
Toggled in the Layout tab. Exported as named EdenFoldMark* lines in the
master page's header (ODF page-relative draw:line, DOCX VML v:line);
both importers read the names back into the flag. Roundtrip Leg 32.
2026-08-24 16:27:27 +00:00
Steffen Becker 0753caa5a9 feat: placeholder fields (inline atom + ODF/DOCX round trip)
An inline atom drawn gray as ‹label›: a click selects the whole field,
typing replaces it, Tab/Shift-Tab jump between fields. Exports to ODF
<text:placeholder> (PLH sentinel U+E022) and a DOCX w:sdt content
control tagged edentext-placeholder; both importers read it back, the
export-minted gray stripped. Covered in roundtrip Leg 31 + fuzzDoc.
2026-08-24 16:16:24 +00:00
Steffen Becker ca29ff780b fix: make the recent-files list only offer files it can reopen
A file opened without a File System Access handle (no API — Firefox,
Safari, Brave — or a template) is no longer recorded: nothing could
ever reopen it, so every click failed and dropped the entry. A startup
prune clears such stale entries. Reopening now asks for 'read' instead
of 'readwrite' (Save re-prompts on its own), and a declined permission
prompt keeps the entry — only a moved or deleted file drops it.
2026-08-23 18:54:52 +00:00
Steffen Becker de7f63f269 fix: Enter frees the caret from a leading table
At the very first position of a table with no block before it, Enter
inserts a paragraph above the table and moves the caret there, as both
word processors do — previously the caret could never get before such
a table. Generic first-child check, so it also covers a table leading
a cell, text box or columns section.
2026-08-20 14:26:57 +02:00
Steffen Becker a87e7a4ada fix: a heading's style name never lands on a plain paragraph
styleName was kept on split, so Enter at the end of a heading left a paragraph
carrying it: drawn like a heading, absent from the navigator's outline, exported
as text:p. It is no longer kept there. setParagraphStyle also missed the name
whenever it switched the node type — a following updateAttributes looks for the
new type in the pre-chain state — so it now rides along with setNode. Levels 2–6
never reached that command at all: the digit class built from MAX_HEADING_LEVEL
(10) read as [0-1], so Heading's own Mod-Alt-N took the key and set a level with
no style; that keymap is dropped, running ahead of every plugin binding.
2026-08-19 18:41:21 +02:00
Steffen Becker 810846b00c test: the smoke test exports its PDF through the ribbon's File menu
The save chevron it clicked lives in the modern island, which a fresh
profile no longer mounts since the ribbon became the default.
2026-08-17 23:10:08 +02:00
Steffen Becker 10b650c64c refactor: the floating island's mode id is 'modern', matching its label
The id said 'classic' while the UI called the ribbon Classic and the
island Modern (beta). Type, i18n keys, comments and docs now agree;
a stored legacy 'classic' still selects the island.
2026-08-17 20:05:15 +02:00
Steffen Becker 847fe59d3d feat: a frame the text runs through reserves no space at all
Word's in-front-of / behind-text (wp:wrapNone + behindDoc) and ODF's
style:wrap="run-through" both read as a side float, so a near-column-wide
picture reserved its whole height and pushed the text 23-52mm down. They
are a wrap mode of their own now: out of flow at the static position it
was anchored at, z-order from the file (inFront). Both exports carry it
back; tests/unit/wrap-through.test.ts covers the round trip.
The image fixture 72 -> 38 over both formats and 7 pages -> 6, matching
the reference; corpus 1108 -> 1074, no other fixture moved.
2026-08-17 13:45:39 +02:00
Steffen Becker ac5b05e0fe feat: an index the file writes without page numbers keeps its rows text-only
Word's TOC \n and an ODF entry template naming no index-entry-page-number both
say the rows are text alone; we drew leaders and live numbers over them either
way. Carried as the node's own pageNumbers attr, off only — the node view had
the page-less row already, for the bibliography. The exports write the switch
back out: \n over the whole level range, and the ODF template drops the tab
stop with the number, which is the pair LibreOffice's dialog switches together.
2026-08-16 23:49:53 +02:00
Steffen Becker 8a9b391d76 fix: a heading keeps the bold its own run declares
Both importers drop a mark the block's style supplies, and a heading's bold was
taken as given. The registry style that renders it is the file's, though —
re-parented to Standard wherever the file declares a basedOn / parent-style-name
— so nothing supplied what the mark had been deleted for, and a 23pt title line
came out 149.7mm against LibreOffice's 161.7. Gated on that parent now; a
LibreOffice file, which writes fo:font-weight on every Heading_20_N, is
untouched. Parity corpus 1892 → 1889.
2026-08-16 23:12:34 +02:00
Steffen Becker eb3a9f95c1 feat: Word's horizontal line imports as the paragraph's rule
A v:rect flagged o:hr is the line itself, not a shape: its height and
fillcolor become the paragraph's borderBottom (1.5pt/#A0A0A0 where the file
names none), a real w:pBdr keeping precedence — only one line can be drawn.
It was dropped with the "Drawings were removed" warning until now, which the
changelog's gap list did not admit to either.
2026-08-16 22:44:40 +02:00
Steffen Becker 9b14d1654f feat: the style gallery lists heading levels 1–5 until asked for the rest
Both reference products define ten levels but only offer the first five;
visibleStyles() filters styleOrder for the gallery in either chrome, with a
session-only 'show all styles' toggle in the menu. A deeper level a block
already uses stays listed, so the caret always has an active style.
2026-08-16 14:01:30 +02:00
Steffen Becker 8f38d15633 test: fuzz round 8 — region attrs and cell overrides on styled tables
styledTable now materializes the region attr (paintTable's shape), keeps
runs heading-safe only in bold regions, and layers direct overrides over
the paint: replaced or cleared cell shading and an own bottom border.
2026-08-16 13:42:36 +02:00
Steffen Becker c473fd5f0f test: fuzz round 7 — indexes, table styles, frame pairs, vertical boxes
The generator now emits tableOfContents nodes of all five families, styled
tables painted through the real resolveTableCell (name + look + materialized
fill/borders), side-by-side topBottom image pairs (wrapAlign), vertical-text
boxes and the Caption paragraph style. normalize learns the index attr
defaults the DOCX TOC field has no switch for.
2026-08-16 13:17:47 +02:00
Steffen Becker 4c11a527c9 test: fuzz round 6 — columns, named styles, floating boxes, table extras
The generator now emits columns sections, Title/Subtitle/Quotations
paragraphs (heading-safe runs), floating text boxes with offsets, and
table margins, cell borders, cell padding and formula cells.
2026-08-16 12:50:15 +02:00
Steffen Becker 5bf470f4b7 test: fuzz round 5 — revisions, fields, citations, char styles, floats
Generator gains insertion/deletion marks, sequence/index/date-time fields,
citations (one record per identifier), builtin character styles (redundant
direct marks dropped — import suppresses them like heading defaults) and
floating images (wrapDist only beside a side wrap). normalize canonicalizes
revision ids/dates like comments, recounts sequence numbers per category,
and drops an auto date field's value and a citation's derived label.
2026-08-16 12:07:22 +02:00
Steffen Becker 1c5b6d73d5 test: fuzz round 4 — boxes, formulas, comments, bookmarks, ruby, row merges
Generator: text boxes (shape, fill, stroke), inline and display
formulas, comments, bookmarks with cross-references, ruby, rowspan
merges. Display rides a formula owning its line; a stroke width needs
a stroke color; a vertical merge keeps a second column so no row is
all covered cells. Normalize canonicalizes comment ids and dates
(naive local vs UTC are one instant) and trims a formula's latex
(the OMML side keeps the serializer's trailing space on purpose).
2026-08-16 11:22:30 +02:00
Steffen Becker 6c79ec1197 test: fuzz round 3 — paragraph boxes, flow flags, tab stops, note labels
New in the pool: paragraph shading/rule lines (also in cells and lists), rtl
paragraphs, keepNext/keepLines/widowControl, tab stops, custom bullet chars,
heading levels to 8, image alt/rotation, row heights and custom note labels.
noHyphenation stays out: without the document-wide hyphenate flag the
importers suppress it as meaningless. 1000 sweep seeds clean on both legs.
2026-08-16 10:30:00 +02:00
Steffen Becker 5dab73ac3a test: fuzz round 2 — cell merges, list types, inline images
colspan merges (grid-consistent), orderedList listStyleType (upper-* only on
nested lists: lower/decimal forms are depth defaults the importers suppress),
and inline PNG images. 300 sweep seeds clean on both legs.
2026-08-16 07:58:24 +02:00
Steffen Becker a7fe334900 test: widen the fuzz generator to effects, links, notes and cell props
New in the pool: underline/strike line styles, super/subscript, caps,
textPosition, links, page breaks, footnotes/endnotes, cell shading and
vertical alignment. normalize() canonicalizes importer-generated note ids and
drops the link mark's default plain:false; FUZZ_SEEDS widens the sweep beyond
the default 50. This is the suite that surfaced the last four export/import
fixes; all 300 sweep seeds now round-trip clean on both legs.
2026-08-16 07:50:22 +02:00
Steffen Becker 96863395f3 fix: recording a replacement keeps the text it replaced
Only a pure delete was turned into a deletion mark; typing or pasting over
a selection ran as it was, so the replaced text left the document unmarked
and rejecting the change lost the original for good. A one-step replacement
now marks its range deleted and puts the new content after it, as both word
processors do.
2026-08-16 02:36:31 +02:00
Steffen Becker 8b1bef9706 fix: paragraph styles apply to every block, not just the first
The document's content ended in an optional noteSection, so the content
match after the first child offered noteSection as its defaultType — and
that is what TipTap's clearNodes converts a block into. Every style command
on a top-level block past the first threw "Invalid content for node type
noteSection" and applied nothing. Spelling the same language as an
alternation puts a paragraph first again.
2026-08-16 02:18:55 +02:00
Steffen Becker 3cec6990ef feat: label feature nodes and marks in the history dropdowns
Replace the hardcoded node if/else chain in describeTransaction with a
NODE_LABELS map covering image, text box, formula, page break, TOC,
fields, cross-reference, index/bibliography entries, ruby and columns;
foot-/endnotes labelled via their kind attr. MARK_LABELS gains link,
comment, bookmark, character style and tracked change.
2026-08-15 23:31:22 +02:00
Steffen Becker bb15572b57 test: smoke-check the raster-PDF export
exportPdf gains a test seam: a window.__edentextPdfSink takes the bytes
instead of doc.save(), whose download hangs in headless Chromium. The
smoke test clicks the real export menu over the imported corpus document
and asserts %PDF magic and a plausible size. The test browser is pinned
to en-US so the labels it clicks are deterministic across machines.
2026-08-15 23:18:55 +02:00
Steffen Becker 32cf72a1d0 test: cover recentFiles and the history log
The two framework-free 0%-coverage modules: the recent-files list
(dedupe by name, eight-entry cap, corrupt-localStorage tolerance) and
the labelled undo/redo mirror against a live jsdom editor (labels per
edit kind, undo/redo moves, redo branch discarded by a fresh edit).
2026-08-15 23:16:08 +02:00
Steffen Becker 47bbbfc7dd test: cross-format fuzz leg, ODT vs DOCX round-trip
The same seeded documents go through buildOdt->importOdt and
buildDocx->importDocx; the two imports must agree, so one exporter
silently losing what the other keeps now fails with the seed named.
Column widths are ignored here (ODT leaves defaults implicit, DOCX
always writes w:tcW); each format's own leg still covers them.
2026-08-15 22:58:29 +02:00
Steffen Becker 76856f4aff fix: DOCX cell spacing accretion and nested-list kind flips
Found by the cross-format fuzz leg. importDocx baked the resolved cell
chain in as direct formatting even where it equals what an unset attr
already renders (0/0 spacing; single line height over a single default) —
every re-imported own file grew spacing attrs on every cell paragraph.
And a numbering level kept the first list kind it saw, so a nested bullet
list beside a nested ordered one came back ordered; a kind conflict now
forks its own numbering reference, as the ODT side mints a list style.
2026-08-15 22:58:29 +02:00
Steffen Becker 873af0794d test: fuzz round-trip leg over seeded random documents
tests/fuzzDoc.ts generates documents from a seeded PRNG (marks, lists,
tables, indents, XML-hostile text); fuzz-roundtrip.test.ts runs 50 seeds
through buildOdt -> importOdt and diffs the normalized JSON, naming the
failing seed. normalize/firstDiff moved out of roundtrip.test.ts into
tests/normalize.ts for reuse. The generator documents what is lossy by
design: default-equal marks in headings, indents and start in nested lists.
2026-08-15 22:17:35 +02:00
Steffen Becker 026f6b6d83 test: headless-Chromium smoke test for the UI layer
tests/smoke/run.mjs boots the production build in Playwright Chromium and
checks what jsdom cannot: the bundle boots, typing and the bold shortcut
reach ProseMirror, autosave survives a reload, a corpus ODT imports and
paginates; any uncaught page error fails the run. Wired as npm run
test:smoke and a CI job (build + chromium install), playwright-core is
now a devDependency.
2026-08-15 21:58:47 +02:00
Steffen Becker 44b351e865 feat: a thesaurus, LibreOffice's own synonyms
Ctrl+F7, Review ▸ Thesaurus and the context menu open one dialog: the
word at the caret, the sense groups it is in, click one to replace it,
and a box to look up any other word. The data is MyThes re-packed to one
group per line (de 3.0MB/37k groups, en 6.7MB/143k) — the format repeats
every group once per member, ten times the bytes. Loaded on first use for
the document's language and scanned, not indexed: ~4ms a lookup, where a
word→group index measured ~100MB of memory against the text's ~8MB.
2026-08-15 20:23:32 +02:00
Steffen Becker 5b88de8c19 test: reword comment tripping the gitignored-corpus CI guard
The CI grep for "render-parity/fixtures" outside tests/render-parity
flags any occurrence of that path, including comments. corpus.test.ts
only mentioned the path in prose; reworded it so the guard passes.
2026-08-15 19:52:56 +02:00
Steffen Becker cbb1805910 feat: multi-page view
View ▸ Multiple Pages (1–4): rows of pages filled left to right, scrolling
as one canvas, every page editable. Neither reference product stops at two —
LibreOffice takes a column count, Word fits what the window allows — and both
re-zoom for it, so a whole row is fitted when the count changes. A pane is a
grid cell clipped to one page, a view showing only a contiguous slice; two
rows of cells take turns as the upper one. Cost: columns x 2 live views, each
holding the whole document (measured: +94k nodes and +16ms per keystroke per
view at 177 pages).
2026-08-15 19:19:33 +02:00
Steffen Becker ad2a01ba75 feat: recording revisions belongs to the document, not the editor
A new document starts with it off and an import adopts the file's own
flag, so an editor left recording no longer marks the next document it
opens. Both formats carry it, probed against soffice: ODF on the change
registry (text:track-changes, written for the flag alone where nothing
is recorded yet), Word as w:trackRevisions in settings.xml — LibreOffice
writes and reads exactly those two, and reading w:trackChanges instead
would have found nothing.
2026-08-15 17:07:06 +02:00
Steffen Becker c771193c83 fix: an opened document is nobody's tracked insertion
With revision recording on, setContent inserted the whole file in one
transaction, so the recorder marked every run as the reader's own
insertion — the document then rendered underlined in the author colour
(#c69200) from the first character. Measured: 19 843 of 19 843 characters
on an 11-page import, 436 revision spans.

Both loads (new document, import) now carry the RECORDING meta; what is
typed after the open is still recorded, as in both word processors.
2026-08-15 16:42:28 +02:00
Steffen Becker 41ab0ef06c feat: two panes on one document, scrolled apart
View ▸ Split (Ctrl+Alt+S): a second live view of the editor's state, stacked
below the first and editable the same way. The panes share a width, so the
second renders the pagination the first measures — pageBreaks, columnsFlow
and tabStops skip the pass on a split pane rather than run it twice.

Every widget decoration now builds its DOM in a function: two views render
one decoration set, and a shared node is taken back and forth between the
two documents until the tab locks up (measured on a two-page document).
2026-08-15 13:15:37 +02:00