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:
1 parent
8d8bb858de
commit
46fb30249f
17 files changed
+132
-12
No files matched your search
+2
-1
@@ -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
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user