Commit Graph
144 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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 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
Steffen Becker 503c9821d6 feat: a freeform drawing keeps its own outline
A polygon, a polyline, a curve or a connector's elbow used to be dropped
with a warning; it now rides the box as an SVG path in the 0…100 box a
preset's points use, and the editor draws it. Read from all four dialects
the two products write (ODF points and svg:d, a non-primitive
enhanced-path, DrawingML custGeom, VML's reversed-case path), written
back as enhanced-path and custGeom — LibreOffice renders both exactly as
the editor draws them. An open outline is stroked, never filled.
2026-08-15 12:09:12 +02:00
Steffen Becker 9be1578b97 feat: a formula cell prints a currency and a date too
Both come from the document's language, read off Intl: the symbol's side
and spacing, the short date's order and padding. Each minted data style
names that language — without it LibreOffice prints its own locale's
separators (probed: a German document's 1.234,00 € came out $1,234.00).
A date is a serial day from LibreOffice's day 0, 1899-12-30. Word's
formula dialog offers no date, so the DOCX leg uses the field's \@
picture. Also drops a document's name from three older comments.
2026-08-15 11:44:43 +02:00
Steffen Becker 9892377ee3 feat: Find & Replace also searches font, size and colour
The three attrs LibreOffice's Format… dialog is mostly about, on both
sides of the bar. They live together on one TextStyle mark and a mark of
one type replaces that type whole, so each run's own attrs are the base of
the new one — recolouring leaves the font alone.

F3 was bound twice from here on: it now expands an AutoText shortcut where
the caret has one, as it does in both word processors, and finds the next
match otherwise.
2026-08-15 11:19:02 +02:00
Steffen Becker c800618299 feat: a text box can run its text top-to-bottom
Word's Text Direction and LibreOffice's, on the frame's own toolbar: the
browser lays the vertical flow out, so the box needs nothing but the mode.

ODF keeps it in the frame style's *paragraph* properties — probed: in the
graphic properties LibreOffice drops it, there it keeps it and lays the box
out — and Word in the shape body's `vert`, the VML fallback's
`layout-flow:vertical` read too. Both survive a LibreOffice re-save.
2026-08-15 11:13:46 +02:00
Steffen Becker eda134f162 feat: a formula cell prints its result in its own number format
The Formula dialog gains the number format Word's has: whole numbers, two
decimals, grouped thousands and the two percentages beside the general
one.

ODF keeps it on the cell *style* as a reference to a data style — odf-kit
shares one cell style across every cell, so a formatted cell gets a copy
of it — and LibreOffice writes both back unchanged. Word keeps it on the
field as its \# switch; LibreOffice's DOCX filter neither reads nor writes
that switch, so that leg holds through our own round trip.
2026-08-15 10:53:30 +02:00
Steffen Becker 20e6dcad0e feat: a reading can be printed over its base text
Ruby, as both products' phonetic guide writes it: one element holding a
base and its reading, so the editor keeps it as one inline atom and the
browser sets it as a real <ruby>.

ODF gets <text:ruby> plus a minted ruby-family style (centred, above —
probed: LibreOffice writes that style back unchanged); Word a <w:ruby> run
from a post-pack pass, the package having none. Both legs verified through
a LibreOffice re-save, our .docx to .odt included.
2026-08-15 10:40:52 +02:00
Steffen Becker 405b175e49 feat: AutoText keeps a block of text under a shortcut
LibreOffice's Tools ▸ AutoText, in the ribbon where Word keeps its Quick
Parts: store the selection under a name and a shortcut, insert it from the
library or by typing the shortcut and pressing F3 — LibreOffice's key for
it.

An entry is HTML parsed back through the schema, so it keeps its marks and
blocks; the library is app-level, as it is in both products, so no file
carries it.
2026-08-15 10:33:16 +02:00
Steffen Becker ee801967f6 feat: words typed once are offered back
LibreOffice's Word Completion, its defaults included: words from eight
letters up are collected as they are finished, and the first three
letters offer the rest. The offer is a widget decoration after the caret,
so it is outside the document until Enter takes it — nothing to undo when
it is not taken.

Only while typing: a bare caret move drops the offer, or an Enter meant to
split the paragraph would complete a word instead. The settings share the
AutoCorrect dialog, as they share LibreOffice's.
2026-08-15 10:27:18 +02:00
Steffen Becker 2f087bc880 feat: Find & Replace searches and replaces formatting
The ¶ toggle adds a format to each side: bold / italic / underline and a
paragraph style, as LibreOffice's Format… and its Paragraph Styles box.
A format narrows a text search to the runs carrying it, and with no term
the formatting is the search — every such run, or every paragraph in the
style, empty ones included.

A replacement with no text only reformats, which is how a document gets
restyled without retyping it; a heading style switches the node type over
the way setParagraphStyle does, so the extension takes the same sheet.
2026-08-15 10:19:48 +02:00
Steffen Becker dab1d662c9 feat: the table sort gets its three keys and a sort type
Both dialogs offer three sort keys and let you pick the type; only the
first key was offered, and the type was always the detected one. A later
key decides only where the ones before tie, and the sort stays stable.

Alphanumeric is the plain collation ("10" before "2") — that is what
choosing it over the detected type is for, so the two collators differ in
`numeric`. Sorted numerically, a cell that is no number sorts last either
way, as an empty cell does in a spreadsheet.
2026-08-15 10:12:24 +02:00
Steffen Becker 24402dd372 feat: the bibliography gets a citation style
Cite by short name (LibreOffice's own), numbered, APA, MLA or Chicago:
the style sets the citation's label and the row's shape, and rides the
bibliography index node — ODF as the entry template LibreOffice
regenerates its rows from (verified through a re-save), Word as the
sources part's StyleName. Labels are derived and kept true by an
appendTransaction, so adding a citation renumbers the rest.
2026-08-15 10:02:54 +02:00
Steffen Becker 5f2e7fcbcf feat: a section can restart the page numbering
Word's Start at, per section: ODF writes style:page-number on the
paragraph that switches master page, DOCX a w:pgNumType w:start. That
section is no longer exported as a continuous break — probed, LibreOffice
drops the start off a continuous one, and neither word processor honours
it there. The footer counts from the nearest restart at or above the page.
2026-08-15 09:49:20 +02:00
Steffen Becker 1c4dec49f3 feat: a reviewing pane, and a colour per reviewer
Every recorded change in document order beside the comments pane: author,
date, the text, accept or reject in place. A change split by a paragraph
boundary is one row, so acceptRevision/rejectRevision take an id. The
author colour is a decoration, since a mark cannot know the document's
other authors; it nests inside the mark's span and carries the under/
strikethrough with it. shortcutHint no longer throws on an id nothing
binds, which heading 7-10 had just made reachable.
2026-08-15 09:40:26 +02:00
Steffen Becker 147034730f feat: headings go to level 10, as both word processors do
HTML stops at h6, so 7-10 render as unknown elements editor.css makes
blocks; their built-in styles continue level 6 at 12pt, since LibreOffice
writes these styles with no properties and resolves them from its own
pool. Both importers read a two-digit level again (the style-name regexes
stopped at one digit). Verified through a LibreOffice re-save of both
formats and in the live editor.
2026-08-15 01:21:23 +02:00
Steffen Becker 4afd5cd1d5 feat: a paragraph can opt out of automatic hyphenation
LibreOffice's Format > Paragraph > Text Flow checkbox. Only "off"
travels (Word has no per-paragraph "on") and only under a document that
hyphenates, or it would say what is already true. ODF writes
fo:hyphenate="false" into the paragraph's text properties, DOCX gains
w:suppressAutoHyphens through a post-pack pass — the docx package
exposes no option for it. Both survive a LibreOffice re-save.
2026-08-15 01:09:55 +02:00
Steffen Becker bf564fbab4 fix: footnote counting per page/chapter and collecting at the end really work
Both were selectable in the dialog and changed nothing — they only rode the
file formats, so an imported document numbered and placed its notes wrong.
noteLabels now counts within a bucket (class, chapter, or the page pageBreaks
publishes through setNoteAnchorPages); a collected footnote leaves position:
absolute and opens its own page, as LibreOffice does. Counting and position
move to the footnote tab, the only place LibreOffice offers them.

The separator's own alignment moves the line alone — probed, notes stay left.
2026-08-14 23:11:10 +02:00
Steffen Becker 46165b82f7 fix: a caption is boxed to its frame, and the frame stays clickable
Two faults on one picture. A block after a float keeps its box over
the float — only its lines move — so the caption on top swallowed
every click meant for the picture; the frame is lifted above the
in-flow boxes. And a caption can only sit under a side-wrapped frame
if it is boxed to it, so its span in the column is measured and
becomes the caption's indent, which also drops it clear of the float.
Measured at 100% and 150%: caption under the picture, its width, in
all four wrap modes.
2026-08-14 22:40:42 +02:00
Steffen Becker 61ddf69d54 fix: a caption follows the frame's place, not the paragraph's
A picture with "above and below" wrap is a frame centred in the
column while its paragraph carries no alignment at all, so the
caption still went to the left margin. framePlacement reads the
place off the frame: centred, the side it is set against, or its
offset as the caption's indent. The debug dump gains a frames
section — every other one measures the flow downwards, and this
is a sideways question.
2026-08-14 22:40:42 +02:00
Steffen Becker c4a74f92b1 fix: a caption stands under its picture, not at the margin
It was inserted as a plain paragraph, so a centred or right-aligned
picture got its caption at the left edge of the text column.
LibreOffice frames picture and caption together; a loose paragraph
carries that over as the alignment of the block it captions.
Measured in the live editor: a centred 240px picture at 520…760
now has its caption text at 580…700.
2026-08-14 22:40:42 +02:00
Steffen Becker 3b24b14470 feat: lines and arrows, which both importers used to drop
Two endpoints rather than a box: no text, no fill, and the diagonal of
the frame with `flipV` picking which one. The three kinds share their
preset names, since the heads a file declares are what tell them apart.

ODF gives a line its own element and its heads a named marker, which
needs the draw: prefix declared in styles.xml — undeclared, the file
does not parse at all. Word keeps the frame and flips it.
2026-08-14 22:11:49 +02:00
Steffen Becker 147342655d feat: sort a table, sum a column, recognise a number
The formula rides the cell, as ODF writes it; Word's field syntax is what
is stored, since it is the wider language. `ooow:` in the ODF attribute
value is a namespace prefix — undeclared, LibreOffice reads it as part of
the formula and prints "Expression is faulty".

Sort takes one key and detects the type per cell where both dialogs make
you pick. Number recognition is off by default, as it is in LibreOffice,
and a formula parses a cell's text either way — exactly as there.
2026-08-14 22:11:49 +02:00
Steffen Becker 639fac90fd feat: a section can have its own paper, landscape amid portrait
The page grid was one repeating cycle, so every page had to be the same
size. It is now a table of height runs the placement pass grows as each
section's first page becomes known, and the sheets are one box per page
instead of a repeating gradient — .paper reserves the widest section's
paper, a narrower one takes the difference as a right inset.

Layout ▸ Orientation offers "this section"; the size stays document-wide
in the ribbon but round-trips per section from a file.
2026-08-14 10:36:52 +02:00
Steffen Becker c38320a108 feat: citations that carry their source, and a bibliography
The citation is an inline atom holding the whole record, which is what ODF
writes on text:bibliography-mark — so no database travels beside the file.
The list is a new index family: one row per source, in document order and
without page numbers, as LibreOffice prints one.

DOCX cites by tag and keeps the sources in a custom-XML part the docx
package knows nothing about, so the post-pack pass mints the part, its
properties, both relationships and the content type itself.
2026-08-14 10:16:59 +02:00
Steffen Becker c97a38ec77 feat: a Navigator pane lists the chapters and moves them
F5 (LibreOffice's key) opens an outline of the document's headings:
click one to jump, and move it up or down or promote/demote its level,
each carrying everything under the heading. Below it the tables,
pictures and bookmarks LibreOffice's Navigator also lists.

A move swaps with the adjacent chapter at the same level and stops at a
heading above it, so a subheading never reparents itself. The pane docks
right beside the comments one — the classic chrome's Tools chip owns the
left edge.
2026-08-14 08:32:30 +02:00
Steffen Becker 49e485038e feat: the document saves as a template, and the File menu remembers
A .ott/.dotx is the built document with one label changed — ODF's
package media type, Word's content type for the document part — so the
picker offers both and the chosen extension decides which exporter runs.

Opening or saving also records the file in a recent list. The name is in
localStorage so the menu draws at once; the handle that reopens it is in
IndexedDB beside it. A file that has moved, or whose permission the
browser drops on reload, is reported and dropped from the list.
2026-08-14 08:23:09 +02:00