From 46fb30249f2a35527028911ee27c059ec599b1d7 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 14 Aug 2026 01:02:12 +0200 Subject: [PATCH] feat: automatic hyphenation for the whole document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout > Hyphenation, off by default as in both word processors. The browser hyphenates in the document's own language (the body host gets lang and hyphens: auto) — measured, a justified German paragraph takes 15 lines instead of 17. ODF keeps fo:hyphenate in the base style's TEXT properties: probed, in paragraph-properties LibreOffice ignores it and drops it on the next save. Word takes w:autoHyphenation. --- CHANGELOG.md | 3 ++- src/App.svelte | 18 ++++++++++++--- src/lib/components/Editor.svelte | 7 ++++-- src/lib/components/ToolbarExpanded.svelte | 11 +++++++-- src/lib/components/ribbon/Ribbon.svelte | 4 +++- src/lib/components/ribbon/icons.ts | 2 ++ .../components/ribbon/tabs/LayoutTab.svelte | 10 ++++++++ src/lib/export/docx.ts | 3 +++ src/lib/export/odt.ts | 18 ++++++++++++--- src/lib/i18n/locales/de.ts | 2 ++ src/lib/i18n/locales/en.ts | 2 ++ src/lib/import/docx.ts | 13 +++++++++++ src/lib/import/odt.ts | 3 +++ src/lib/import/styleResolver.ts | 7 ++++++ src/lib/storage/CLAUDE.md | 1 + src/lib/storage/hyphenation.ts | 17 ++++++++++++++ tests/roundtrip.test.ts | 23 +++++++++++++++++++ 17 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 src/lib/storage/hyphenation.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 57fed11..9fbc26c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ - 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` - AutoCorrect while typing (LibreOffice's Tools ▸ AutoCorrect Options, one checkbox per rule): typographic quotes per document language (`"…"` / `„…“`), its dash matrix (`A - B` → `A – B`, `A--B` → `A—B`), its replacement table (`-->` → `→`, `(C)` → `©`, `...` → `…`), capitalize the first letter of a sentence — with an abbreviation exception list — and TWo INitial CApitals. URL recognition and auto-list ride the same switches +- Automatic hyphenation for the whole document (Layout ▸ Hyphenation): the browser hyphenates in the document's own language, which shortens a justified paragraph the way LibreOffice does. Round-trips as ODF `fo:hyphenate` on the base style — where LibreOffice keeps it, in its *text* properties — and Word's `w:autoHyphenation` - Manual line breaks (Shift+Enter) **Insert** @@ -126,7 +127,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08. - 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** -- 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 +- Hyphenation beyond the document switch: a per-paragraph "don't hyphenate", and the zone / ladder count (`fo:hyphenation-ladder-count`, `w:hyphenationZone`) — CSS exposes neither - 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 - Heading levels 7–10 (Word and LibreOffice go that far; HTML stops at `h6`) diff --git a/src/App.svelte b/src/App.svelte index da4635d..17cea5b 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -36,6 +36,7 @@ import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, loadDifferentFirstPage, saveDifferentFirstPage, loadDifferentOddEven, saveDifferentOddEven, hfIsEmpty, DEFAULT_HF_DISTANCES, loadExtraHfSections, saveExtraHfSections, type HfDoc, type HfZone, type HfDistances, type HfSet } from './lib/storage/headerFooter'; import { loadDocName, saveDocName, stripOdtExtension, sanitizeNameForFile } from './lib/storage/documentName'; import { loadDocProperties, saveDocProperties, EMPTY_DOC_PROPERTIES, type DocProperties } from './lib/storage/docProperties'; + import { loadHyphenation, saveHyphenation } from './lib/storage/hyphenation'; import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage'; import { spellController } from './lib/spell/controller'; import LanguagePicker from './lib/components/LanguagePicker.svelte'; @@ -152,6 +153,7 @@ let documentName: string = $state(loadDocName()); let docProps: DocProperties = $state(loadDocProperties()); let docPropsOpen = $state(false); + let hyphenate = $state(loadHyphenation()); let autoCorrectOpen = $state(false); // Width of the hidden mirror span (below), so the title input grows/shrinks @@ -215,6 +217,10 @@ savePageRtl(pageRtl); }); + $effect(() => { + saveHyphenation(hyphenate); + }); + $effect(() => { saveDocumentLanguage(documentLanguage); void spellController.setLanguage(documentLanguage); @@ -489,6 +495,7 @@ hfDistances = { ...DEFAULT_HF_DISTANCES }; extraHfSections = []; documentName = ''; + hyphenate = false; docProps = { ...EMPTY_DOC_PROPERTIES }; saveDocProperties(docProps); fileHandle = null; @@ -549,6 +556,7 @@ if (result.tabIntervalCm) tabIntervalCm = result.tabIntervalCm; spacingModel = result.spacingModel; pageRtl = result.rtl; + hyphenate = result.hyphenate; // Adopt the document's spell-check language (the $effect switches the // controller + loads its dictionary). null = file declared none; keep ours. if (result.language) documentLanguage = result.language; @@ -630,7 +638,7 @@ exportMenuOpen = false; const json = editor.getJSON() as Parameters[0]; try { - const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps); + const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate); fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -646,7 +654,7 @@ exportMenuOpen = false; const json = editor.getJSON() as Parameters[0]; try { - const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps); + const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate); fileHandle = await saveAsOdt(bytes, suggestedFilename(json)); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -664,7 +672,7 @@ try { const json = editor.getJSON() as Parameters[0]; const { buildDocx } = await import('./lib/export/docx'); - const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps); + const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate); await saveAsDocx(bytes, suggestedFilenameDocx(json)); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -878,6 +886,7 @@ bind:pageMargins bind:pageOrientation bind:pageFormat + bind:hyphenate {hfActive} onManageStyles={openStyleManager} onManageTableStyles={() => openStyleManager('table')} @@ -1119,6 +1128,7 @@ bind:pageMargins bind:pageOrientation bind:pageFormat + bind:hyphenate bind:hfDistances bind:differentFirstPage bind:differentOddEven @@ -1166,6 +1176,8 @@ {hfDistances} {tabIntervalCm} {spacingModel} + {hyphenate} + {documentLanguage} bind:extraHfSections {zoom} onZoom={setZoom} diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index bde2f43..74bc22e 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -22,6 +22,7 @@ import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval'; import { type Orientation } from '../storage/pageOrientation'; import { type SpacingModel } from '../storage/spacingModel'; + import { NO_LANGUAGE } from '../storage/documentLanguage'; import { applyNoteVars } from '../storage/noteSettings'; import { noteSettings } from '../storage/notes.svelte'; import { RESYNC_NOTES } from '../editor/extensions/notes'; @@ -42,7 +43,7 @@ let { editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1), zoom = 100, onZoom, showFormattingMarks = false, showRuler = true, pageMargins = DEFAULT_MARGINS, orientation = 'portrait', - pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', documentEpoch = 0, pageRtl = false, + pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', documentEpoch = 0, pageRtl = false, hyphenate = false, documentLanguage = 'en', headerDoc = $bindable(null), footerDoc = $bindable(null), hfDistances = DEFAULT_HF_DISTANCES, headerFirstDoc = $bindable(null), footerFirstDoc = $bindable(null), differentFirstPage = false, headerEvenDoc = $bindable(null), footerEvenDoc = $bindable(null), differentOddEven = false, @@ -56,6 +57,8 @@ documentEpoch?: number; /** A right-to-left page: the body's base direction, so its columns fill from the right. */ pageRtl?: boolean; + /** Automatic hyphenation; the browser needs the document language to pick its patterns. */ + hyphenate?: boolean; documentLanguage?: string; headerDoc?: HfDoc; footerDoc?: HfDoc; hfDistances?: HfDistances; headerFirstDoc?: HfDoc; footerFirstDoc?: HfDoc; differentFirstPage?: boolean; headerEvenDoc?: HfDoc; footerEvenDoc?: HfDoc; differentOddEven?: boolean; @@ -981,7 +984,7 @@
-
+
{#if gapStripeStyles.length}
{#each bandStyles as b} diff --git a/src/lib/components/ToolbarExpanded.svelte b/src/lib/components/ToolbarExpanded.svelte index b455682..6d04c60 100644 --- a/src/lib/components/ToolbarExpanded.svelte +++ b/src/lib/components/ToolbarExpanded.svelte @@ -30,8 +30,8 @@ import { t } from '../i18n/i18n.svelte'; import { shortcutHint, type ShortcutId } from '../editor/shortcuts'; - let { editor, tick, showFormattingMarks = $bindable(), showRuler = $bindable(true), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable('portrait'), pageFormat = $bindable('A4'), hfDistances = $bindable(DEFAULT_HF_DISTANCES), differentFirstPage = $bindable(false), differentOddEven = $bindable(false), hfActive = null, onEditZone, onDebugDump, onManageTableStyles, onNoteOptions }: - { editor: Editor | null; tick: number; showFormattingMarks: boolean; showRuler?: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; hfDistances?: HfDistances; differentFirstPage?: boolean; differentOddEven?: boolean; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void; onManageTableStyles?: () => void; onNoteOptions?: () => void } = $props(); + let { editor, tick, showFormattingMarks = $bindable(), showRuler = $bindable(true), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable('portrait'), pageFormat = $bindable('A4'), hyphenate = $bindable(false), hfDistances = $bindable(DEFAULT_HF_DISTANCES), differentFirstPage = $bindable(false), differentOddEven = $bindable(false), hfActive = null, onEditZone, onDebugDump, onManageTableStyles, onNoteOptions }: + { editor: Editor | null; tick: number; showFormattingMarks: boolean; showRuler?: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; hyphenate?: boolean; hfDistances?: HfDistances; differentFirstPage?: boolean; differentOddEven?: boolean; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void; onManageTableStyles?: () => void; onNoteOptions?: () => void } = $props(); const PAGE_FORMATS = Object.keys(PAGE_FORMAT_CM) as PageFormat[]; @@ -1417,6 +1417,11 @@ {#if layoutOpen}
+ +