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.
22 KiB
22 KiB
Changelog
[0.1.0] · MVP
Stack
- Vite + Svelte 5 (runes mode) + TypeScript
- TipTap 3 / ProseMirror (individual extensions, no starter-kit)
- odf-kit for ODT generation,
fflatefor (un)zipping.odt/.docx docxfor Word (.docx) export/importjspdf+html2canvasfor PDF export- Hunspell (
hunspell-asm, WASM) withdictionary-en/dictionary-defor spell check - Vitest (+ jsdom) for testing;
svelte-checkfor type-checking - No backend — fully client-side
Implemented
Architecture
- WYSIWYG editor built on TipTap 3 (individual extensions, no starter-kit); toolbar active-states stay reactive via a
tickcounter (Svelte 5 same-reference workaround) - Fixed A4 paper canvas (794 × 1123 px @ 96 dpi) with CSS-simulated pagination and visual page breaks
- LocalStorage auto-save (debounced 1 s); restores document, theme, zoom, margins, orientation, etc. on reload
- Fully client-side / serverless; hand-coded SVG favicon
- Placeholder text on an empty document
Text formatting
- Bold, Italic, Underline, Strikethrough
- Subscript / Superscript
- Font family picker (lists only fonts installed on the machine; remembers recently used fonts)
- Font size
- Font color and highlight color (color picker with custom colors)
- Headings H1–H5, including un-bolding a heading
Styles
- Named paragraph styles with LibreOffice's inheritance model: a style has a parent, a follow-on style and its own properties; changing a style updates every paragraph using it. Built-ins Default text → Heading → Heading 1–5 / Title / Subtitle / Quote, with LibreOffice's values (18/16/14/13/12 pt headings)
- Named character styles (Emphasis / Strong Emphasis / Source Text + own ones) as a mark on a text run
- Style gallery in the toolbar lists both families; assigning a style keeps hard formatting (Word/LibreOffice behavior), Ctrl+M clears direct formatting but keeps the style
- Style manager ("Manage styles…"): inheritance tree, live property editing (empty field = inherit again), new / update from selection, rename, delete, reset a built-in — for paragraph and character styles alike
- Round-trips as real styles, not baked formatting: ODF
style:style(style:family="paragraph"/"text",style:parent-style-name,style:next-style-name) and DOCXw:pStyle/w:rStylewithw:basedOn/w:next; import adopts the file's used styles plus their parent chains, so only formatting beyond the style stays direct - Named table styles as the third family (Word's table styles / LibreOffice's AutoFormats): 14 built-ins — Grid (+ rows / columns only), List shaded / columns, Plain, four colored box lists, Grid & List Accent, Academic, Financial. Picking one from the gallery in the table toolbar paints header row, banded rows/columns, first/last column and total row, and a styled table re-bands itself when rows or columns are added
- Word's Table Style Options as six checkboxes beside the gallery (header row, total row, first/last column, banded rows/columns): an area is painted only when the style defines it and the table opts in, and turning the header row off shifts the banding by one row. The toolbar's header row / first column buttons drive the same flags, so both surfaces always agree
- Table styles are edited in the same style manager (third tab: area picker for fill and font, outer / row line / column line controls, new / rename / remove), also reachable from the insert-table dropdown
- Round-trip: the style name travels as an ODF
style:family="table"(style:parent-style-name) and DOCXw:tblStyle, the options as ODF'stable:use-*-stylesand Word'sw:tblLook. Fills and borders are materialized into the cells and the region's font is baked onto the runs, so the table looks right in Word/LibreOffice even though ODF has no banding concept — the style definitions themselves live in the app, as LibreOffice's AutoFormats do - A new document starts from the built-in styles, like Word's/LibreOffice's default template
Paragraphs & lists
- Text alignment: left, center, right, justify
- Line spacing and paragraph spacing (space before / after)
- Increase / decrease indent (paragraphs and lists)
- Bulleted and ordered lists — nested, with multiple numbering styles (decimal, alpha, roman;
.or)suffix) and whole-list indent; Tab / Shift-Tab to nest / un-nest - Word-style nesting defaults for ordered lists: an indented level numbers 1. → a. → i. (repeating) instead of restarting at "1." everywhere; explicit styles per level still override. Plus legal/outline numbering (1., 1.1., 1.2.1. …) as a list type — rendered via CSS counters, round-trips to ODF
text:display-levelsand DOCX%1.%2.lvlText chains - Nested ordered levels advance the cycle relative to the level above and inherit its suffix (a level-1
a)givesi)then1)); the numbering style chosen for a level is reused when you nest into it again - Customizable bullet symbols per list level (picker on the bullet-list split button: • ◦ ▪ ❖ ➢ ⇨ ✓ – >); round-trips to ODF
text:bullet-charand DOCXw:lvlText, and the DOCX/ODT import maps Wingdings/Symbol bullets (arrows, diamonds, checkmarks …) to their Unicode equivalents instead of flattening them to plain dots. Symbols Liberation Serif lacks render from a bundled 2 KB DejaVu Sans subset (EdenSymbols.woff2), so markers look compact and identical on every platform instead of a stretched OS fallback - Tab stops per paragraph: left, centre, right and decimal, honoured in the rendered text (CSS only has a fixed tab grid, so each tab is measured and placed); a hanging indent implies a stop at the text position. Set them on the ruler; round-trips to ODF
style:tab-stopsand DOCXw:tabs - Paragraph borders and shading: per-side borders with presets (all / single edges / none), width and color, plus a background fill, on paragraphs and headings; typing
---,___or===on a line of its own turns it into a rule line (Word/LibreOffice AutoCorrect). Round-trips to ODFfo:border-*/fo:background-colorand DOCXw:pBdr/w:shd - Manual line breaks (Shift+Enter)
Insert
- Tables: insert via size picker, Word-style row/column drag-resize, add / delete rows & columns, delete table, cell borders, merge cells and split cells (N×M, Word/LibreOffice-style), cell background shading, header row / first column toggles, named table styles (see Styles); a table splits cleanly across page boundaries
- Table border control (Word/LibreOffice-style): per-side cell borders with presets (all / outside / inside / single edges / none), line width and color; buttons show active states matching the current pen and toggle borders off; round-trips to ODF
fo:border-*and DOCXw:tcBorders - Images: inline or floating with text wrap (left / right / top-bottom), resize handles, rotation, live size badge; insert via toolbar, drag-and-drop, or paste. A floating frame is placed by the file's own offsets, and two top-and-bottom frames set against opposite ends of the text share one band, side by side
- EMF metafiles are drawn (
import/emf.ts): the picture is rebuilt as SVG from the metafile's own records — paths, filled shapes, text with its font and colour, embedded bitmaps — so a plot pasted out of MATLAB or Excel arrives as a picture instead of a placeholder - Anything wider than the sheet — an oversized formula, a frame reaching past the margin — is cut at the page edge, as it is in LibreOffice
- Charts are drawn from the file — DrawingML
chartN.xmland ODFchart:chart, bar / line / area / scatter / pie, with their titles, axis titles, gridlines, axis bounds and series colours (read-only; see the limitations below) - Special characters picker
- Date and time fields: picker with 7 date and 4 time formats (live samples) and an "update automatically" toggle — fixed fields keep the inserted moment, auto fields refresh on open. Round-trips to ODF
text:date/text:time(mintednumber:date/time-style) and DOCXDATE/TIMEfields; the field carries the surrounding font - Table of contents: generated from headings (H1–H5) with live page numbers and dot leaders, click an entry to jump to its heading; round-trips to ODF
text:table-of-contentand a Word TOC field - Formulas: dialog with a LaTeX field and live preview; only the LaTeX is stored, the MathML the browser typesets, the ODF formula object and the OMML are derived from it. Inline or as a centered display line, double-click to edit. Round-trips as a real embedded ODF formula object (
draw:object+Formula{n}/content.xml, our LaTeX kept in the MathMLannotation) and as Word'sm:oMath. STIX Two Math is bundled, so stretched brackets and ∑/∫ look the same on every platform - Hyperlinks: create / edit / remove (toolbar + Ctrl+K), Ctrl/Cmd+click to open, hover hint showing the URL; ODF
text:around-trip - Manual page break (Ctrl+Enter); round-trips to ODF
fo:break-before - Text boxes and basic shapes (rectangle / rounded rectangle / ellipse): editable block content, fill and border colors, border width, resize/rotate handles, text wrap (inline / left / right / top-bottom) like images; floating toolbar for wrap, shape kind and colors. Round-trips to ODF
draw:frame/draw:text-box+draw:custom-shapeand DOCX DrawingMLwps:wsp/wps:txbx(imports Word'smc:AlternateContentand legacy VML text boxes too)
Page & layout
- Page margins (cm) and page orientation (portrait / landscape)
- Page format picker (Layout panel): 15 Word/LibreOffice sizes — A3–A6, ISO B4–B6, JIS B4/B5, Letter, Legal, Tabloid, Executive, Folio, Statement — each showing its cm dimensions; drives the on-screen page, pagination, and ODT/DOCX/PDF export, and is detected & adopted on import
- Multi-column (newspaper) layout: 1–3 columns with adjustable gap, applied to the whole document (no selection) or to selected paragraphs, Word-style — text fills column 1 to the page bottom, then column 2, and flows across pages with mid-paragraph line breaks; a section followed by other content balances its columns. Round-trips to ODF
text:section/style:columnsand DOCX continuous sections withw:cols - Headers & footers: page-number / page-count fields, configurable edge distances, and variants — different first page and different odd & even pages (edit a page's zone directly; blank first/even zones supported). Press Enter to add blank lines that grow a zone into the page while body text reflows so the page break sits above the footer / below the header, never overlapping. Inline images/logos can be inserted in a zone. Round-trips to ODF (
style:header-first/-left,draw:frame) and DOCX (w:titlePg/w:evenAndOddHeaders,ImageRun) - Zoom (20–300 %)
- Horizontal ruler above the page: click to place a tab stop of the selected type, drag to move it, drag it off to remove it; three markers set the paragraph's first-line, left and right indent. Toggle in the extended toolbar
- Show formatting marks (spaces, tabs, paragraph marks)
Editing aids
- Word/LibreOffice keyboard shortcuts throughout: Ctrl+L/E/R/J alignment, Ctrl+Alt+1–5 headings and Ctrl+Shift+N default style, Ctrl+1/2/5 line spacing, Ctrl+Shift+P / Ctrl+Shift+B super/subscript, Ctrl+Shift+. / Ctrl+Shift+, grow/shrink font, Ctrl+Space clear formatting, Ctrl+Shift+Space non-breaking space, Ctrl+Shift+- soft hyphen, Alt+Shift+D / Alt+Shift+T date & time field, Ctrl+O open, F3 / Shift+F3 find next/previous, Ctrl+F10 formatting marks — alongside the existing Ctrl+B/I/U, Ctrl+M, Ctrl+K, Ctrl+Enter, Ctrl+F/H, Ctrl+S/P and Tab/Shift+Tab. All bindings live in one table, so tooltips stay in sync and they can be remapped later
- Undo / Redo with a labelled history dropdown
- Search & Replace (Ctrl+F / Ctrl+H): live match highlighting, match count, next / previous, match-case and whole-word options, replace current / replace all
- Right-click context menu for text: cut / copy / paste / paste without formatting, link insert-edit-remove, clear formatting — with the spelling suggestions merged in on top. Shift+right-click keeps the browser's own menu; images, text boxes and header/footer stay with their existing UI
- Spell check in English and German with squiggles and suggestions in the context menu (add / ignore word); selectable document language. Powered by Hunspell (WASM), so German compound words (Fußgänger, Krankenversicherung …) are recognised; dictionaries are lazy-loaded per language on demand
- Word / character count statistics (whole document and selection)
UI & theming
- Light / Dark / AllBlack / Auto appearance modes (settings menu in toolbar)
- Primary toolbar plus an expandable extended toolbar (horizontal scroll on narrow windows)
- Status bar with page number, word count, language and zoom controls
- About dialog
- UI internationalization (English / German): language picker, auto-detects the browser language on first load, persisted; covers toolbar, dialogs, menus, status bar and warnings
File & export
- ODT export via odf-kit with extensive content.xml / styles.xml post-processing (custom node types, tables with borders, images, fonts and colors); filename derived from the first heading (fallback
document.odt) - ODT import / open existing file: parses content.xml / styles.xml directly, adopts the file's margins and orientation, and reports graceful-degradation warnings
- Opens
.otttemplates (OpenDocument Text Template): read like an.odt, but as a new untitled document — the first Save writes a fresh.odtand never overwrites the template (Word/LibreOffice behavior) - Embedded font loading: fonts embedded in an opened
.odt/.docx(Word.odttfde-obfuscated) are registered via the FontFace API so text renders in its real face even when the font isn't installed; persisted per-document in IndexedDB so it survives a reload. Fonts the document only names but neither embeds nor installs are still flagged as substituted - Word (.docx) export and import — round-trips the editor's formatting (text, fonts, lists, tables, images, headers/footers, page geometry) and opens real Word documents
- PDF export — Raster (pixel-exact copy of the editor with a selectable text layer) and Vector (crisp, fonts embedded, via the browser print dialog)
- Print (printer button / Ctrl+P) — opens the browser print dialog with a pixel-exact raster of the document (tables, headers/footers and page breaks intact)
Not yet implemented
The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08.
Content an imported document loses
- Footnotes / endnotes: dropped on import with a warning (
text:note,w:footnotes), and there is no way to add one - Comments / annotations: dropped on import (
office:annotation,w:comment) - Track changes / revisions: no recording, no accept/reject, no author colors (
text:tracked-changes,w:ins/w:del); imported revisions are flattened to their current state - Bookmarks and cross-references: no anchor, no reference field ("see chapter 3 on page 7"), none survive an import — this also blocks internal hyperlinks
- Charts are drawn from the file (
import/chart.ts: DrawingMLchartN.xmland ODFchart:chart), but as a picture, not a chart object — a re-export carries the drawing and the numbers behind it are no longer editable. The same holds for an EMF metafile (import/emf.ts): it is drawn, but as the SVG picture it was rebuilt into, and only from the record set a plot consists of — a hatched brush, a clipping region or a rotated bitmap is skipped. WMF/SVM metafiles and OLE objects still keep their box and a placeholder label, and export writes that back out: WMF is a different (16-bit) record format, SVM is StarOffice-proprietary, and an OLE object cannot be rendered without its application - Shapes beyond rect / round-rect / ellipse: lines, arrows, connectors, polygons, freeform, and rotated shape text (dropped on import with a warning)
- Text boxes in the DOCX export: lists inside a box are flattened to literal-marker paragraphs (
•/1.) and images inside a box are dropped — the box XML is injected by a post-pack string pass (export/docx.tsapplyTextBoxesDocx) that mints no numbering.xml or media/rels entries. The ODT export has neither limitation
Missing while writing
- AutoCorrect while typing: only the
---rule line exists. Missing are typographic quotes ("…"/„…“per document language), en/em dash,--> → →, capitalize the first letter, auto-URL, auto-list - Automatic hyphenation:
fo:hyphenate/w:hyphenationZoneare ignored and the editor sets nohyphens, so a justified paragraph breaks its lines differently than LibreOffice — a systematic source of page-count drift in render parity - Captions with numbered figures/tables ("Abbildung 1: …") and the lists built from them (list of figures / tables). The TOC machinery exists, the other index families do not
- Page numbering options: the page field is decimal-only — no start value, no roman/alpha format, no restart per section
- Document properties: title, author, subject, keywords. No
meta.xmlis written and the DOCX creator is hard-coded, so an exported file carries no authorship - Heading level H6 (clamped to H5; Word and LibreOffice go to 10)
- Alphabetical index and bibliography
- Line numbering (LibreOffice Tools ▸ Line numbering / Word Layout ▸ Line numbers)
- Section-level page setup: a section with its own page size, orientation or margins. Page geometry is one document-wide setting, so a file whose sections disagree keeps the last one's (per-section headers/footers already work)
- Table extras: repeat the header row on every page, keep a row from splitting across pages, sort, sum/formula in a cell, number recognition
- A list level's own hanging indent: the marker sits at the 0.635 cm both exports write (
LIST_HANGING_CM), so a wider left-set marker overflows where Word moves the text to the next list tab (a right-set one —w:lvlJc, which is what the built-in Roman numberings use — grows into the margin and is fine). Reading the value back naively also moves the markers of our own ODT exports — LibreOffice draws its own flat hanging at exactly the value odf-kit writes for level 2; seetests/render-parity/README.mdbefore building on it - Images live as data-URIs in the autosaved JSON, so an image-heavy document can exceed the ~5 MB localStorage quota;
storage/autosave.tswarns once and a reload restores the last version that still fit. IndexedDB (asembeddedFontStore.tsalready uses) would lift the ceiling - Linked / chained text frames
- Find & Replace by regular expression or by formatting/style
- Save as template (
.ott/.dotx), multi-document management, recent-files list - Watermark; page background and page border
- Thesaurus, grammar check, word completion, AutoText / building blocks
- Right-to-left and vertical text, Asian typography (ruby)
- Password-protected ODT/DOCX; digital signatures
- Navigator / outline view, split view
- PWA / offline support
Out of scope for now
- Mail merge, data sources, form fields
- Master documents
- Real-time collaboration (there is no backend by design)
- Macros / scripting
Known limitations
Deviations from Word/LibreOffice the browser does not let us remove. Anything merely unimplemented belongs in the list above, not here.
- Zoom 100% does not visually match Word/LibreOffice at 100% on the same
screen. Root cause: the editor uses the browser's fixed 96 CSS DPI
(794×1123 px for A4 — see
editor.css,pageBreaks.ts), while Word and LibreOffice render against the OS-reported physical screen DPI so that 1 cm on paper ≈ 1 cm on screen. Possible future fix: add a user-side calibration (DPI value or visual ruler) that scales thezoomfactor. Decided 2026-05-26 not worth the effort for now. - An image or text box cannot be dropped at a free point on the page. It is
anchored to a text position: inline, or floating left / right / top-bottom,
and dragging it re-anchors it to the paragraph under the cursor rather than
placing it. Root cause:
floatis the only way to make browser text wrap around a box. CSS Exclusions, which would wrap around a freely placed one, are unimplemented in every engine, andposition: absolutetakes the frame out of flow so text runs underneath it. A file's own offsets are drawn: both round-trip (wrapOffset/wrapOffsetY=svg:x/svg:y,positionH/positionV) and both place the frame, the vertical one for top-and-bottom wrap, where no text sits beside it. What stays out of reach is a frame a word processor puts inside running text: Chromium moves every line after a full-width float below it, so such a frame lands after the paragraph's text instead. Noted 2026-08-08, revised 2026-08-09. - A line takes one word more or fewer than LibreOffice, for two reasons neither
of which CSS exposes. LibreOffice compresses inter-word spaces to fit a
line (measured on the thesis: 91 of 349 full-width justified lines, up to
0.83 px per space at 12 pt) where CSS justification only expands; and it
quantizes every glyph advance where Chromium keeps it fractional, so the two
drift apart along the line (identical word widths, 0.45 mm apart by the
150th mm). A matching negative
word-spacingwas tried and reverted — it fixes the break but shortens every last line; seetests/render-parity/README.md. Noted 2026-08-09. - A text box anchored inside a paragraph loses the vertical offset it was anchored by: it is a block node here, so the importer lifts it out and it simply follows that paragraph (measured 4.7 mm on the thesis' figure page). It also costs flow height a word processor does not spend: Word's picture caption is a text box declared 0.05 pt tall that overflows its own box, so LibreOffice reserves nothing for it and the empty paragraph after it holds the caption. Here it is a block, ~55 px — enough to push two lines off the thesis' page 15 and keep it one page behind through page 45. Noted 2026-08-09.
- Line height follows the paragraph, not the line: the block's CSS strut applies
to every line, where a word processor takes each line's own runs. A paragraph
whose runs all agree takes theirs, so only a paragraph of mixed sizes struts
too low. A fix (
line-height: 0+ inline decoration) measured right on a purpose-built fixture but cost the contract fixture 22 issues; reverted. Detail intests/render-parity/README.md. Noted 2026-08-08. - A plain text box's height is a minimum (content grows the box, like the editor). LibreOffice recomputes auto-grow heights on open, so a re-saved box keeps its content but sheds excess empty height. Shapes (rect/ellipse) export with fixed geometry instead. Noted 2026-07-03.