feat: automatic hyphenation for the whole document

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.
This commit is contained in:
Steffen Becker committed 2026-08-14 01:02:12 +02:00
1 parent 8d8bb858de
commit 46fb30249f
17 files changed
+132 -12

No files matched your search

+2 -1
View File
@@ -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`)
+15 -3
View File
@@ -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<typeof buildOdt>[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<typeof buildOdt>[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<typeof buildOdt>[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}
+5 -2
View File
@@ -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 @@
<div bind:this={paperEl} class="paper" data-spacing-model={spacingModel} class:show-formatting-marks={showFormattingMarks} class:hf-editing={hfActive} class:settling style="transform: scale({appliedZoom / 100});">
<!-- Dedicated mount point that TipTap fully owns — keeping it free of Svelte
content avoids Svelte and ProseMirror fighting over the same parent's DOM. -->
<div bind:this={element} class="tiptap-host" dir={pageRtl ? 'rtl' : null}></div>
<div bind:this={element} class="tiptap-host" dir={pageRtl ? 'rtl' : null} lang={documentLanguage === NO_LANGUAGE ? null : documentLanguage} style:hyphens={hyphenate ? 'auto' : null}></div>
{#if gapStripeStyles.length}
<div class="band-layer">
{#each bandStyles as b}
+9 -2
View File
@@ -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<Orientation>('portrait'), pageFormat = $bindable<PageFormat>('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<Orientation>('portrait'), pageFormat = $bindable<PageFormat>('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 @@
</button>
{#if layoutOpen}
<div class="layout-dropdown">
<div class="lh-section-label">{t().ribbon.hyphenation}</div>
<label class="hyphen-row" title={t().ribbon.hyphenationHint}>
<input type="checkbox" bind:checked={hyphenate} />
<span>{t().ribbon.hyphenationHint}</span>
</label>
<div class="lh-section-label">{t().toolbarExpanded.pageFormat}</div>
<select class="format-select" bind:value={pageFormat} title={t().toolbarExpanded.pageFormat}>
{#each PAGE_FORMATS as fmt}
@@ -2435,6 +2440,8 @@
background: var(--color-btn-hover);
}
.hyphen-row { display: flex; align-items: center; gap: 6px; padding: 2px 0 4px; cursor: pointer; }
.lh-section-label {
padding: 0.4rem 0.6rem 0.2rem;
font-size: 0.65rem;
+3 -1
View File
@@ -44,6 +44,7 @@
pageMargins = $bindable(DEFAULT_MARGINS),
pageOrientation = $bindable<Orientation>('portrait'),
pageFormat = $bindable<PageFormat>('A4'),
hyphenate = $bindable(false),
hfActive = null,
onManageStyles,
onManageTableStyles,
@@ -72,6 +73,7 @@
pageMargins?: PageMargins;
pageOrientation?: Orientation;
pageFormat?: PageFormat;
hyphenate?: boolean;
hfActive?: HfZone | null;
onManageStyles?: (family: StyleFamily) => void;
onManageTableStyles?: (family: StyleFamily) => void;
@@ -289,7 +291,7 @@
{:else if tab === 'insert'}
<InsertTab {editor} {tick} {hfActive} {pageMargins} {pageOrientation} {pageFormat} {onEditZone} {onManageTableStyles} />
{:else if tab === 'layout'}
<LayoutTab {editor} {tick} {hfActive} bind:pageMargins bind:pageOrientation bind:pageFormat onParagraphDialog={() => (paragraphDialogOpen = true)} />
<LayoutTab {editor} {tick} {hfActive} bind:pageMargins bind:pageOrientation bind:pageFormat bind:hyphenate onParagraphDialog={() => (paragraphDialogOpen = true)} />
{:else if tab === 'references'}
<ReferencesTab {editor} {tick} {hfActive} {onNoteOptions} />
{:else if tab === 'review'}
+2
View File
@@ -78,6 +78,8 @@ export const STROKED = {
toc: ['M2 3h6M2 6.5h5M2 10h6M2 13.5h4', 'M11 3h3M10 6.5h4M11 10h3M10 13.5h4'],
tocLevels: ['M2.5 3.5h11M4.5 7h9M6.5 10.5h7M8.5 14h5'],
wordCount: ['M2.5 2.5h11v11h-11z', 'M5 6h6M5 9h4'],
// A word broken at a hyphen: two stubs with the dash between them.
hyphenation: ['M2 8h3M11 8h3', 'M6.5 8h3', 'M2 4.5h12M2 11.5h6'],
// An A with a tick beside it: the letter AutoCorrect fixes, corrected.
autoCorrect: ['M1.5 12 5 3.5 8.5 12', 'M2.9 9.2h4.2', 'M10 8.5l2 2 3.5-4.5'],
ruler: ['M1.5 5.5h13v5h-13z', 'M4 5.5v2.2M6.5 5.5v3M9 5.5v2.2M11.5 5.5v3'],
@@ -17,6 +17,7 @@
pageMargins = $bindable(DEFAULT_MARGINS),
pageOrientation = $bindable<Orientation>('portrait'),
pageFormat = $bindable<PageFormat>('A4'),
hyphenate = $bindable(false),
onParagraphDialog,
}: {
editor: Editor | null;
@@ -25,6 +26,7 @@
pageMargins?: PageMargins;
pageOrientation?: Orientation;
pageFormat?: PageFormat;
hyphenate?: boolean;
onParagraphDialog?: () => void;
} = $props();
@@ -190,6 +192,14 @@
</div>
{/if}
</div>
<RibbonButton
icon="hyphenation"
label={t().ribbon.hyphenation}
title={t().ribbon.hyphenationHint}
active={hyphenate}
onclick={() => (hyphenate = !hyphenate)}
/>
</RibbonGroup>
<div class="ribbon-sep"></div>
+3
View File
@@ -1308,6 +1308,7 @@ export async function buildDocx(
rtl = false,
notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS,
props: DocProperties = EMPTY_DOC_PROPERTIES,
hyphenate = false,
): Promise<Uint8Array> {
docLangTag = localeTag(language ? language.language : 'en');
exportSheet = styles;
@@ -1405,6 +1406,8 @@ export async function buildDocx(
...(props.keywords.trim() ? { keywords: props.keywords.trim() } : {}),
...(props.description.trim() ? { description: props.description.trim() } : {}),
defaultTabStop: cmToTwip(tabIntervalCm),
// Word's Layout > Hyphenation (w:autoHyphenation in settings.xml).
...(hyphenate ? { hyphenation: { autoHyphenation: true } } : {}),
...(differentOddEven ? { evenAndOddHeaderAndFooters: true } : {}),
...(hasToc ? { features: { updateFields: true } } : {}),
styles: buildStyles(styles, usedStyleNames(docJson, styles), language, usedTableStyles(docJson, styles)),
+15 -3
View File
@@ -2212,7 +2212,7 @@ function rezipOdt(files: Record<string, Uint8Array>): Uint8Array {
// Rewrite styles.xml to match the editor's preview: default font Liberation Serif →
// Times New Roman (metric-identical; see EXPORT_FONT), and Heading_20_1/2/3 sizes &
// margins → the editor's values (odf-kit's defaults are larger; HEADING_STYLE_OVERRIDES).
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, mirrored = false, rtl = false, notes: NoteSettings = DEFAULT_NOTE_SETTINGS): Uint8Array {
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, mirrored = false, rtl = false, notes: NoteSettings = DEFAULT_NOTE_SETTINGS, hyphenate = false): Uint8Array {
const files = unzipSync(odtBytes);
const stylesBytes = files['styles.xml'];
if (!stylesBytes) return odtBytes;
@@ -2248,6 +2248,17 @@ function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; countr
);
}
// Automatic hyphenation. ODF counts fo:hyphenate as a *text* property, so it sits
// beside the language on the style every paragraph inherits from — probed: in
// paragraph-properties LibreOffice ignores it and drops it on the next save. Its two
// limits ride along at LibreOffice's own defaults.
if (hyphenate) {
styles = styles.replace(
/(<style:style style:name="Standard"[\s\S]*?<style:text-properties\b[^>]*?)\/>/,
'$1 fo:hyphenate="true" fo:hyphenation-remain-char-count="2" fo:hyphenation-push-char-count="2"/>',
);
}
// The only occurrences of "Liberation Serif" in styles.xml are the default
// font-face declaration and the Standard style's font-name attributes.
styles = styles.split(ODFKIT_DEFAULT_FONT).join(EXPORT_FONT);
@@ -2278,6 +2289,7 @@ function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; countr
`$1fo:margin-bottom="0cm"`,
);
// ODF requires a font-face declaration for every referenced font name.
styles = styles.replace(
'</office:font-face-decls>',
@@ -3546,7 +3558,7 @@ export type HfExport = {
};
// The full document → .odt pipeline, DOM-free; returns the .odt bytes.
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, spacingModel: SpacingModel = 'add', rtl = false, notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS, props: DocProperties = EMPTY_DOC_PROPERTIES): Promise<Uint8Array> {
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, spacingModel: SpacingModel = 'add', rtl = false, notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS, props: DocProperties = EMPTY_DOC_PROPERTIES, hyphenate = false): Promise<Uint8Array> {
// Images become IMG sentinels before serialization; applyImages resolves them and writes
// the Pictures/ + manifest entries. Text boxes and columns hoist after replacePageBreaks
// (so PGB misses their blocks) and before the inline passes (which then cover them).
@@ -3774,7 +3786,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
// Effects first: applyCharacterStyles then clones the style that already carries them.
const withNamedStyles = applyCharacterStyles(applyTextEffects(applyParagraphStyles(withParaBoxes)));
const usedTables = new Set(tableStyleNames.filter((t): t is TableStyleRef => !!t).map(t => t.name));
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, tabIntervalCm, margins.mirrored === true, rtl, notesSettings);
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, tabIntervalCm, margins.mirrored === true, rtl, notesSettings, hyphenate);
const withHf = applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara);
// Sections past the first get their own master page, which is where ODF keeps a
// section's header/footer; the SEC-marked block points at it.
+2
View File
@@ -621,6 +621,8 @@ const de: Messages = {
endnote: 'Endnote',
noteOptions: 'Optionen',
autoCorrect: 'AutoKorrektur',
hyphenation: 'Silbentrennung',
hyphenationHint: 'Automatisch trennen',
debugDump: 'Dump',
cellMargins: 'Zellenränder',
altText: 'Alternativtext',
+2
View File
@@ -625,6 +625,8 @@ const en = {
endnote: 'Endnote',
noteOptions: 'Options',
autoCorrect: 'AutoCorrect',
hyphenation: 'Hyphenation',
hyphenationHint: 'Hyphenate automatically',
debugDump: 'Dump',
cellMargins: 'Cell margins',
altText: 'Alt text',
+13
View File
@@ -227,6 +227,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
margins: withMirror(first.margins ?? sect.margins, mirrored),
// A right-to-left section (w:bidi): the columns fill from the right.
rtl: !!finalSectPr && (() => { const b = fc(finalSectPr, 'bidi'); return !!b && onOff(b); })(),
hyphenate: docAutoHyphenation(files),
orientation: sect.orientation,
format: sect.format,
tabIntervalCm: docTabInterval(files),
@@ -2216,6 +2217,18 @@ function docTabInterval(files: Record<string, Uint8Array>): number {
}
}
// Word's Layout ▸ Hyphenation, from settings.xml (absent = off, as in Word).
function docAutoHyphenation(files: Record<string, Uint8Array>): boolean {
const bytes = files['word/settings.xml'];
if (!bytes) return false;
try {
const el = parseXml(strFromU8(bytes)).getElementsByTagNameNS(W, 'autoHyphenation')[0];
return !!el && onOff(el);
} catch {
return false;
}
}
// docProps/core.xml → the document's descriptive properties (Word's File ▸ Info).
const DC_NS = 'http://purl.org/dc/elements/1.1/';
const CP_NS = 'http://schemas.openxmlformats.org/package/2006/metadata/core-properties';
+3
View File
@@ -50,6 +50,8 @@ export interface OdtImportResult {
// A right-to-left page (ODF style:writing-mode="rl-tb", Word w:bidi): the columns
// fill from the right and the body's base direction is RTL.
rtl: boolean;
// Automatic hyphenation (ODF fo:hyphenate on the base style, Word w:autoHyphenation).
hyphenate: boolean;
// The grid every tab past the last custom stop falls on; the format's own fallback
// when the file declares none.
tabIntervalCm: number;
@@ -673,6 +675,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
orientation: geometry?.orientation ?? null,
format: geometry?.format ?? null,
rtl: geometry?.rtl ?? false,
hyphenate: resolver.documentHyphenation(),
tabIntervalCm: resolver.defaultTabInterval(),
spacingModel: odfSpacingModel(files),
header,
+7
View File
@@ -338,6 +338,13 @@ export class StyleResolver {
return { language, country: props['fo:country'] ?? '' };
}
// Automatic hyphenation, from the same style — ODF counts it a text property, and
// LibreOffice keeps it there rather than document-wide, so that style's value is
// what "the document hyphenates" means.
documentHyphenation(): boolean {
return this.merged('paragraph', 'Standard').text['fo:hyphenate'] === 'true';
}
// The grid every tab past the last custom stop falls on, from the paragraph
// default-style (Standard may override it). A file that declares none gets ODF's own
// fallback, which is what LibreOffice renders it at.
+1
View File
@@ -11,6 +11,7 @@
`editor.css` turns `--space-before` into padding or margin accordingly (CSS margins can
only collapse, so `'add'` cannot be a margin).
- **`writingMode.ts`** — the page's text direction (`loadPageRtl`/`savePageRtl`, `edentext-page-rtl`), from ODF `style:writing-mode="rl-tb"` or Word's section-level `w:bidi`. `Editor.svelte` puts it on the body host as `dir`, which is what makes a multi-column page fill from the right; the header/footer band is left alone (LibreOffice lays those out unchanged — measured). The vertical modes are not read.
- **`hyphenation.ts`** — automatic hyphenation for the whole document (`edentext-hyphenation`, off by default as in both word processors). `Editor.svelte` puts `hyphens: auto` and the document `lang` on the body host — the browser needs the language to pick its patterns. ODF keeps it as `fo:hyphenate` in the **text** properties of the base paragraph style (probed: in paragraph-properties LibreOffice ignores it and drops it on the next save), Word as `w:autoHyphenation` in settings.xml.
- **`pageOrientation.ts`** — `'portrait' | 'landscape'`. `applyOrientationVars` sets `--user-page-{width,height}`; landscape swaps the A4 dimensions (matching odf-kit's automatic swap).
Both margins and orientation are passed into the ODT export so the exported document's geometry/line-wrapping matches the on-screen preview.
+17
View File
@@ -0,0 +1,17 @@
// Automatic hyphenation for the whole document: LibreOffice's Format ▸ Paragraph ▸
// Text Flow on the default paragraph style, Word's Layout ▸ Hyphenation. Off in both,
// so off here. ODF carries it as `fo:hyphenate` on that style, Word as
// `w:autoHyphenation` in settings.xml.
const KEY = 'edentext-hyphenation';
export const DEFAULT_HYPHENATION = false;
export function loadHyphenation(): boolean {
return localStorage.getItem(KEY) === 'true';
}
export function saveHyphenation(on: boolean): void {
if (on) localStorage.setItem(KEY, 'true');
else localStorage.removeItem(KEY);
}
+23
View File
@@ -1785,3 +1785,26 @@ describe('Leg 13: document properties (meta.xml / docProps/core.xml)', () => {
check('all empty', Object.values(back).every((v) => v === ''), back);
});
});
describe('Leg 14: automatic hyphenation', () => {
const hDoc: N = { type: 'doc', content: [P(null, T('Silbentrennung'))] };
const args = [margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(), 1.25, 'add', false, DEFAULT_NOTE_SETTINGS, undefined] as const;
it('ODT: fo:hyphenate rides the base style and comes back', async () => {
const on = await buildOdt(hDoc, ...args, true);
const styles = strFromU8(unzipSync(on)['styles.xml']);
check('written into Standard\'s text properties', /style:name="Standard"[\s\S]*?<style:text-properties[^>]*fo:hyphenate="true"/.test(styles), styles.slice(0, 600));
check('on round-trips', importOdt(on).hyphenate === true);
const off = await buildOdt(hDoc, ...args, false);
check('off writes nothing', !strFromU8(unzipSync(off)['styles.xml']).includes('fo:hyphenate'));
check('off round-trips', importOdt(off).hyphenate === false);
});
it('DOCX: w:autoHyphenation rides settings.xml and comes back', async () => {
const on = await buildDocx(hDoc, ...args, true);
check('in settings.xml', strFromU8(unzipSync(on)['word/settings.xml']).includes('autoHyphenation'));
check('on round-trips', importDocx(on).hyphenate === true);
const off = await buildDocx(hDoc, ...args, false);
check('off round-trips', importDocx(off).hyphenate === false);
});
});