Files
edentext/CHANGELOG.md
T
Steffen Becker 46ae0211af fix: a Word table draws only the borders it declares
The DOCX importer read w:tblBorders off the table but never off its table
style, so a table whose style declares none (Word's Normal Table, and the
thesis' Formel-tab around every formula) fell back to the editor's 0.5pt
black default. Resolved per side along the w:basedOn chain, and an
undeclared side is not drawn — LibreOffice writes fo:border="none" for
exactly those tables when it re-saves the file. The ODF importer already
treated an undeclared side that way.
2026-08-09 08:54:59 +00:00

22 KiB
Raw Blame History

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, fflate for (un)zipping .odt/.docx
  • docx for Word (.docx) export/import
  • jspdf + html2canvas for PDF export
  • Hunspell (hunspell-asm, WASM) with dictionary-en / dictionary-de for spell check
  • Vitest (+ jsdom) for testing; svelte-check for 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 tick counter (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 DOCX w:pStyle/w:rStyle with w: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 DOCX w:tblStyle, the options as ODF's table:use-*-styles and Word's w: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-levels and DOCX %1.%2. lvlText chains
  • Nested ordered levels advance the cycle relative to the level above and inherit its suffix (a level-1 a) gives i) then 1)); 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-char and DOCX w: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-stops and DOCX w: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 ODF fo:border-*/fo:background-color and DOCX w: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 DOCX w: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.xml and ODF chart: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 (minted number:date/time-style) and DOCX DATE/TIME fields; 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-content and 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 MathML annotation) and as Word's m: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:a round-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-shape and DOCX DrawingML wps:wsp/wps:txbx (imports Word's mc:AlternateContent and 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:columns and DOCX continuous sections with w: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 .ott templates (OpenDocument Text Template): read like an .odt, but as a new untitled document — the first Save writes a fresh .odt and never overwrites the template (Word/LibreOffice behavior)
  • Embedded font loading: fonts embedded in an opened .odt/.docx (Word .odttf de-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: DrawingML chartN.xml and ODF chart: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.ts applyTextBoxesDocx) 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:hyphenationZone are ignored and the editor sets no hyphens, 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.xml is 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; see tests/render-parity/README.md before 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.ts warns once and a reload restores the last version that still fit. IndexedDB (as embeddedFontStore.ts already 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 the zoom factor. 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: float is 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, and position: absolute takes 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-spacing was tried and reverted — it fixes the break but shortens every last line; see tests/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 in tests/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.