Commit Graph
282 Commits
Author SHA1 Message Date
Steffen Becker 862ba22df2 feat: a section numbers its pages in its own format
A roman front matter before a decimal body is one page style before another
in ODF and one w:pgNumType per section in Word, not a document setting — read
as one, its pages printed 1, 3 where the reference prints i, iii. HfSet grows
pageNumberFormat, both exports write it back, and the DOCX importer takes the
document's own format from the first section, as it already does the paper.
2026-09-04 19:30:05 +02:00
Steffen Becker ae7bf878d8 fix: the parity harness reads the case a style paints, not the node's
A style's fo:text-transform / w:caps is a CSS transform in the browser, so
Range.getClientRects' text stayed lower case where the reference PDF has real
uppercase — three headings of one fixture reported a lineBreak that was not
one, and comparison stopped at each. The computed textTransform is applied to
the extracted word now. Reports rise where they were hidden behind such a line.
2026-09-04 19:30:05 +02:00
Steffen Becker 784dd5e06f fix: a table aligned to the margins fills the text width
ODF's table:align="margins" means the table spans between its own margins, and
style:width is then the width the producing page had. Read as a left-aligned
table, the undeclared right margin was derived from that width against ours and
the table came out narrow — measured 12.4cm on a 14.7cm text width, its column
rule 10.4mm left of where LibreOffice draws it.
2026-09-04 19:30:05 +02:00
Steffen Becker b3b8e58bd4 test: LibreOffice opens what we encrypt, and we open what it encrypts
Five legs in tests/lo-password.test.ts, driven through the repo's first UNO
helper because --convert-to cannot pass a password; self-skips without soffice
or python3-uno and runs with npm run test:lo. The probed format details live in
docs/architecture/encryption.md.
2026-09-04 19:30:04 +02:00
Steffen Becker 2a20f1d107 feat: a .docx can be saved encrypted too, and an encrypted one opened
Writes agile encryption with Word's own parameters (AES-256-CBC in 4096-byte
segments, 100000 SHA-512 rounds, HMAC integrity) into a compound file built
here — cfb.ts, MiniFAT and DIFAT included. Reads that and the older standard
encryption LibreOffice writes, whose package is AES-ECB. Verified both ways
against LibreOffice.
2026-09-04 19:30:04 +02:00
Steffen Becker 4b163e6d10 feat: a document can be saved encrypted with a password (ODF)
Writes LibreOffice's whole-package form: AES-256-GCM keyed by Argon2id over
SHA-256 of the password, the deflated package as one encrypted-package entry.
Reads that plus the per-entry AES-CBC/GCM form with PBKDF2 that every earlier
version writes; the nonce sits ahead of the ciphertext, which no manifest says.
Blowfish and unknown algorithms report an unsupported-encryption error.
2026-09-04 19:30:03 +02:00
Steffen Becker de71830922 feat: a comment or a change gets a balloon beside its own line
ReviewMarginLayer draws the cards in a strip right of the page, at the height
of the line each belongs to, with a dashed leader running through the gap
under it. A balloon is pushed down by the one above (balloonStack, measured
per card, unit-tested) and the column belongs to its page, so it never runs
past the sheet. The strip is only reserved where the margin has something to
show; the smoke test guards a card's content against its box.
2026-09-04 19:30:03 +02:00
Steffen Becker 6d5db7135b feat: one reading of the review view for every layer that draws it
storage/markup holds the display mode (all, simple, none, original) plus, per
kind, whether it shows and where its cards go; markupView folds that into what
the page acts on. Bars, leader and both panes ask reviewItems instead of the
document, the page takes the hiding attributes, and trackChanges drops its
author decoration where the mode shows a finished text.
2026-09-04 19:30:02 +02:00
Steffen Becker f338aec89c feat: a comment carries the answers to it
The thread rides the comment mark's attrs, so two runs of one comment still
merge back together and no second store is needed. ODF writes each answer as
its own annotation over the same range, naming its comment in
loext:parent-name; Word writes its own w:comment tied by w15:paraIdParent in
commentsExtended.xml (both probed). Either importer folds the answers back
into the comment they belong to, and the printed list carries them indented.
2026-09-04 19:30:02 +02:00
Steffen Becker 8c080b1d16 fix: a Word index takes the file's own entry styles
A TOC field regenerates its rows from the styles named `toc 1`…`toc 9`, and its
cached rows are skipped on import — so those styles were never seen and the index
fell back to the editor's 0.5cm per level, dropping the file's space-after with it.
Measured on a 60-row index: a 5.1mm row pitch against LibreOffice's 6.9mm.

They now land as Contents 1…10, and the DOCX export writes the rows back under
them instead of the fallback indent.
2026-09-04 19:30:02 +02:00
Steffen Becker 198c16f7ab fix: a Word bibliography no longer imports twice
Word caches a BIBLIOGRAPHY field's rows as a w:tbl inside the field, and
only the field's paragraphs were skipped — so the list arrived once as the
regenerated index node and once as that table. Measured on a 49-page
document: two pages of duplicate. StyleName IEEE now reads as the numbered
style it is, so its citations show [1] instead of the source key.
2026-09-04 19:30:02 +02:00
Steffen Becker f1f9a4df15 fix: a text box survives a paste from another window
The clipboard carries HTML, and in an HTML string any block tag inside a
<p> closes it — so a copied box arrived as loose paragraphs. It is now
serialized as an inline span carrying its blocks as JSON, read back by a
matching parse rule; its plain text stays in the span for readers that
cannot take the attribute back.
2026-09-04 19:30:02 +02:00
Steffen Becker 572e3a760d fix: an AutoText entry keeps a text box
Entries were stored as an HTML string, where any block tag inside a <p>
closes it — so a copied text box, which holds blocks, lost its place in
the line. They now keep the slice's nodes; an entry written earlier keeps
its HTML string, which insertContent takes just as well.
2026-09-04 19:30:01 +02:00
Steffen Becker 8d3139439c fix: the caret reaches the position a text box occupies
A box that starts its paragraph was a trap: there is no text position beside
it, so the browser pulled the caret meant for the box's own place in the line
into the frame and everything typed there landed inside it.

A frame nobody is editing is now an atom to the browser — a contenteditable
node decoration, dropped while the caret is in its text, the frame placing the
caret itself on the click that enters it. ArrowLeft/ArrowRight at the box's
first and last text position step out to it. The decoration walk enters blocks,
without which it never reached an inline box at all.
2026-09-04 19:30:01 +02:00
Steffen Becker f0cf382fb0 fix: only a band-wrapped text box gets the alignment buttons
A box in the line is a character its paragraph places, as a picture is, so the
three frame-toolbar buttons were the paragraph's own alignment under a frame
icon — a second place to look for the same thing. They now show for the band
wrap alone, where nothing else can express the position.

The ordinary command needed one correction to carry that: a selected box is one
node whose range covers its own paragraphs, so aligning it moved the frame and
re-set its text in the same keystroke.
2026-09-04 19:30:01 +02:00
Steffen Becker 060dcd7380 feat: a text box sits in the line of text
textBox becomes an inline node carrying block content, like the frame both
formats write, so "in line with text" really is in the line and a box reaches
a table cell or a list item through their paragraphs. Both exports write it
where it sits (an as-char draw:frame / a wp:inline drawing) and both imports
leave it there, which drops the hoisting machinery on either side.

Two guards the schema cannot express: no box inside a box, and a selection
reaching into a box grows to the box's bounds rather than dissolving it.
2026-09-04 19:30:01 +02:00
Steffen Becker 21999afbc6 fix: a DOCX text box keeps the height it declares
The shape body wrote spAutoFit, which LibreOffice reads as min-height 0
and then draws the box's text detached from the shape, over whatever
follows it — measured on a 90px box: the frame collapsed to one line and
its text landed 22pt below it, on the next paragraph. noAutofit is what
LibreOffice writes for the same frame; with it the text sits inside the
box at its declared height. Content taller than that is clipped, DOCX
having no fo:min-height for the ODF leg's auto-grow.
2026-09-04 19:30:01 +02:00
Steffen Becker 6081a85626 feat: set where an in-flow text box sits across the column
wrapAlign only ever came from a file, so a box inserted here stayed at
the left in both modes that leave it in flow. The floating toolbar now
offers left/centre/right for `inline` and `topBottom` (a side wrap names
the side itself) and drops a coordinate placement when one is picked.

DOCX carried neither: `topBottom` now writes wp:align and an as-char box
the w:jc of its rebuilt marker paragraph, both read back on import. ODF
already wrote both. Measured centred at 297px of a 76…718px column, and
rendered through LibreOffice from .odt and .docx alike.
2026-09-04 19:30:00 +02:00
Steffen Becker 4aef4d7641 fix: keep rows a cell spans on one page, and clear floats before an in-flow box
A break between spanned rows stretched the cell across the gap: the
merged table drew over the page gap and the next page's header. A
group too tall for a page still breaks per row. An in-flow text box
took the same treatment for floats — CSS lays a block box over one
instead of beside it, which had the vertical frame covering its
neighbour. Both match LibreOffice's render of the same document.
2026-08-30 10:06:44 +00:00
Steffen Becker 7733b53068 test: add the Word re-save of the formula fixture
Completes corpus/word/: all fourteen documents now carry a Word-written
twin.
2026-08-30 10:06:44 +00:00
Steffen Becker bc3c25ece1 test: take Word re-saves of the corpus as a third author
corpus/word/ holds each document saved again by Word — rsids,
proofErr, separator notes, theme docDefaults — asserted to read like
the docx-lib original and survive our round trip. The formula fixture
is reauthored: a hand-wrapped MathNumerator doubled m:num/m:den, which
Word refuses to open, and the radical gets a visible degree.
2026-08-30 10:06:44 +00:00
Steffen Becker c19d082099 test: grow the corpus by marks, footnotes, images and formulas
Four new themed documents (11–14), authored by the docx lib with
LibreOffice ODT twins like the rest; make-fixtures takes a regex to
regenerate a subset without rewriting the committed files. The twins
need libreoffice-math — without it soffice drops every formula
silently.
2026-08-30 10:06:43 +00:00
Steffen Becker 6fa05fa097 test: cover the status bar's text statistics
countText: word/char/paragraph counts, block boundaries as word
breaks that count as no character, atoms as no text, ranges.
2026-08-30 10:06:43 +00:00
Steffen Becker 2ac8d58603 test: a page border must not move the rendered text
Renders the same document with and without the border through
LibreOffice and compares every word box — pins the fo:margin carve-out
at render level, where the round trip is blind (both sides symmetric).
2026-08-30 10:06:43 +00:00
Steffen Becker b1b33daca5 test: fuzz named list styles
The generator assigns a builtin (node species following the style's
level kinds, override-, start- and marker-format-free — each of those
drops the name by design) and no longer pairs textAlign right with an
rtl paragraph, where it is the suppressed default. Found the w:jc
suppression fix the commit before this one.
2026-08-30 10:06:43 +00:00
Steffen Becker 104ec2366c fix: suppress the base direction's own edge when reading w:jc
The DOCX import dropped left as the default and kept right regardless
of w:bidi; an rtl paragraph's default edge is right. start/end read as
physical left/right — LibreOffice spells an rtl paragraph's physical
left as w:jc="start" (probed via its own DOCX export).
2026-08-30 10:06:43 +00:00
Steffen Becker 27a8f4a11e refactor: extract the chapter-field resolution into a pure helper
utils/chapterField owns the per-zone reading (header = the chapter at
the page top, footer = the last begun) so the probed LibreOffice
semantics is unit-tested; the DOM measurement feeding atTop stays in
Editor.svelte.
2026-08-30 10:06:43 +00:00
Steffen Becker 78e8b81bf7 test: pin the fixes the round trip alone could not see
The margin carve-out under a page border, the bare note anchor and a
Writer frame keeping a list and a nested image through an soffice
re-save each get an asymmetric assertion — a symmetric export/import
pair had hidden all three.
2026-08-30 10:06:43 +00:00
Steffen Becker 35059d8126 fix: export plain text boxes as Writer text frames
Without a parent chain to the Frame style LibreOffice read the box as a
drawing object, whose text loses lists, nested images and borders. The
frame takes fo:background-color/fo:border and its writing mode in the
graphic properties; an inline box's centring rides the anchor paragraph.
The import reads the frame spellings back and drops LO's from-top/0
respelling of the default alignment.
2026-08-30 10:06:43 +00:00
Steffen Becker d9496abe4f fix: write the cell-format picture with the document language's separators
Word reads a \# picture with the reader's regional separators, so a
German Word took "0.00"'s dot as grouping and printed 84 as "084".
The importer now reads a picture back by its shape, whichever locale
wrote it.
2026-08-30 10:06:43 +00:00
Steffen Becker 43828d0d70 chore: drop a leftover scratch test 2026-08-30 10:06:42 +00:00
Steffen Becker 15049377a0 fix: cache the index rows in the DOCX TOC/INDEX/BIBLIOGRAPHY fields
The three index kinds exported as fields with an empty result; Word for
Mac never updates fields on open, so every index rendered as nothing.
They are now complex fields whose result is the editor's cached entries
(indent per level, leader tab, pages), the form Word itself writes; the
importer recognizes the complex INDEX/BIBLIOGRAPHY form and keeps the
attrs the fldSimple path set.
2026-08-30 10:06:42 +00:00
Steffen Becker 3d305a9920 fix: write STYLEREF by outline level, not style name
Word resolves the quoted style name against the localized built-in
name, so 'STYLEREF "Heading 1"' shows a field error in any
non-English Word. The numeric argument means outline level in Word
and LibreOffice alike (probed), and the importer already reads it.
2026-08-30 10:06:42 +00:00
Steffen Becker 43903319d9 fix: only one w:displayBackgroundShape in settings.xml
The docx package always writes the element; the page-background pass
prepended a second one, breaking the CT_Settings sequence so Word
repairs the file on open — and the repair drops every header and
footer reference. The schema test's DOCX leg now builds with the
page decor, line numbering and fold marks, which covers the pass.
2026-08-30 10:06:42 +00:00
Steffen Becker 9a6282fce9 fix: floor a DOCX anchor's paragraph posOffset at one twip
Anchored exactly on the paragraph top (posOffset 0), LibreOffice's wrap
layout derails and paints a neighbouring inline text box's content
outside its shape; LO itself never writes less than a twip there.
Probed by bisecting a broken box against LO's own DOCX export of the
same document. The importer rounds the sub-visible offset back to none,
so nothing accretes on the round trip.
2026-08-30 10:06:42 +00:00
Steffen Becker 7ce97b1d50 fix: an RTL block also carries fo:text-align="end" in ODT
style:writing-mode alone leaves the paragraph left-aligned in
LibreOffice; the mode+end pair is what it writes itself (probed), so
both export paths add it where the block sets no alignment of its own,
and the importer suppresses end/right against an RTL block's default —
the pair is its normal look, not direct formatting. The render-
consistency leg now compares under spacing model 'max', the one both
formats express; thresholds re-measured.
2026-08-30 10:06:41 +00:00
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