diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f1cc21..8322582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,7 @@ **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 +- A paragraph of its own can opt out of the document's automatic hyphenation (Format ▸ Paragraph ▸ Text Flow, as LibreOffice has it). Only "off" travels — Word cannot turn hyphenation *on* for one paragraph — and only under a document that hyphenates: below that switch it says what is already true. Round-trips as ODF `fo:hyphenate="false"` in the paragraph's **text** properties and Word's `w:suppressAutoHyphens` - Search & Replace (Ctrl+F / Ctrl+H): live match highlighting, match count, next / previous, match-case and whole-word options, replace current / replace all A `.*` toggle searches by regular expression, and the replacement expands `$1`…`$9` / `$&` from the match's captures (LibreOffice's syntax; Word has no equivalent) - 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 @@ -149,7 +150,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14. **Missing while writing** - One colour for every tracked change, where both word processors give each author their own — and no reviewing pane listing the revisions the way the comments pane lists comments -- 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 +- Hyphenation's zone and ladder count (`fo:hyphenation-ladder-count`, `w:hyphenationZone`) — CSS exposes neither - Restarting the page numbering **per section** (only the document's own format and start value exist) - Heading levels 7–10 (Word and LibreOffice go that far; HTML stops at `h6`) - A bibliography's citation style: the list is set the one way ("key: author, title, year"), where both word processors offer APA, MLA, Chicago and the rest, and numbered citations (`[1]`) instead of the source's short name diff --git a/src/lib/components/ParagraphDialog.svelte b/src/lib/components/ParagraphDialog.svelte index bafc4fe..fbeaa09 100644 --- a/src/lib/components/ParagraphDialog.svelte +++ b/src/lib/components/ParagraphDialog.svelte @@ -41,6 +41,8 @@ let widowControl = $derived(read('widowControl', null) !== false); let keepNext = $derived(read('keepNext', null) === true); let keepLines = $derived(read('keepLines', null) === true); + // LibreOffice's Hyphenation checkbox: on unless this paragraph opted out. + let hyphenate = $derived(read('noHyphenation', null) !== true); function read(attr: string, fallback: T): T | '' { if (tick < 0 || !editor) return fallback; @@ -68,6 +70,7 @@ { attr: 'widowControl', on: () => widowControl, label: () => t().paragraphDialog.widowControl, set: (v) => setAttr('widowControl', v ? null : false) }, { attr: 'keepNext', on: () => keepNext, label: () => t().paragraphDialog.keepNext, set: (v) => setAttr('keepNext', v || null) }, { attr: 'keepLines', on: () => keepLines, label: () => t().paragraphDialog.keepLines, set: (v) => setAttr('keepLines', v || null) }, + { attr: 'noHyphenation', on: () => hyphenate, label: () => t().paragraphDialog.hyphenate, set: (v) => setAttr('noHyphenation', v ? null : true) }, { attr: 'breakBefore', on: () => breakBefore, label: () => t().paragraphDialog.pageBreakBefore, set: (v) => setAttr('breakBefore', v ? 'page' : null) }, ]; diff --git a/src/lib/editor/extensions/pageBreak.ts b/src/lib/editor/extensions/pageBreak.ts index 457a604..c6771f8 100644 --- a/src/lib/editor/extensions/pageBreak.ts +++ b/src/lib/editor/extensions/pageBreak.ts @@ -66,6 +66,16 @@ export const PageBreak = Extension.create({ return { 'data-keep-lines': 'true' }; }, }, + // LibreOffice's Text Flow ▸ Hyphenation off for this paragraph, Word's + // w:suppressAutoHyphens. Only "off" travels: Word cannot turn hyphenation + // *on* for one paragraph, so the document switch is the only way in. + noHyphenation: { + default: null, + parseHTML: (element: HTMLElement) => + element.getAttribute('data-no-hyphenation') === 'true' ? true : null, + renderHTML: (attributes: Record) => + attributes.noHyphenation === true ? { 'data-no-hyphenation': 'true' } : {}, + }, // First block of a new section (w:sectPr, ODF style:master-page-name): what // gives it its own header/footer. Ordinal, so editing can't desync an index. sectionBreak: { diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 40c5f96..1ee7f05 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -58,4 +58,5 @@ U+E009 (columns), U+E00A (date/time fields), `STY` U+E00D (paragraph styles), `CMS`/`CME` U+E018/U+E019 (a comment's range), `SEQ` U+E01A (a caption's number), `TCI` U+E01B (a tracked insertion's range), `TCD` U+E01C (where a tracked deletion was), `TXBX_NUM` U+E01D (DOCX only: a numbering id a box's list has yet to be given), -`IXE` U+E01E (an alphabetical-index entry), `BIB` U+E01F (a citation). +`IXE` U+E01E (an alphabetical-index entry), `BIB` U+E01F (a citation), +`NOHYP` U+E020 (DOCX only: a paragraph whose w:pPr must gain w:suppressAutoHyphens). diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index f1f01f9..86f6d8b 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -158,6 +158,9 @@ const TBX = ''; // Wraps the index of a numbering definition minted for a box's list; the ids only // become known once the packed numbering.xml is in hand. const TXBX_NUM = ''; +// Marks a paragraph whose w:pPr must gain — the docx package +// has no option for it, so a post-pack pass writes it and drops this run. +const NOHYP = ''; const WP_NS = 'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"'; const A_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main'; @@ -1256,6 +1259,35 @@ function applyBidiDocx(bytes: Uint8Array): Uint8Array { return zipSync(out); } +// The w:pPr children that follow w:suppressAutoHyphens in CT_PPrBase — Word's schema is +// a sequence, so the flag goes in before the first of them. +const AFTER_SUPPRESS_HYPHENS = / in w:pPr, +// which the docx package does not expose. The paragraph carries the NOHYP run instead; +// this drops that run and writes the flag. +function applyNoHyphensDocx(bytes: Uint8Array): Uint8Array { + const files = unzipSync(bytes); + const docBytes = files['word/document.xml']; + if (!docBytes) return bytes; + let xml = strFromU8(docBytes); + if (!xml.includes(NOHYP)) return bytes; + xml = xml.replace(/]*>[\s\S]*?<\/w:p>/g, (para) => { + if (!para.includes(NOHYP)) return para; + const p = para.replace(new RegExp(`(?:(?!)[\\s\\S])*${NOHYP}(?:(?!)[\\s\\S])*`, 'g'), ''); + const pPr = /[\s\S]*?<\/w:pPr>/.exec(p); + if (!pPr) return p.replace(/^(]*>)/, '$1'); + const patched = AFTER_SUPPRESS_HYPHENS.test(pPr[0]) + ? pPr[0].replace(AFTER_SUPPRESS_HYPHENS, '$&') + : pPr[0].replace('', ''); + return p.replace(pPr[0], patched); + }); + files['word/document.xml'] = strToU8(xml); + const out: Record = {}; + for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }]; + return zipSync(out); +} + // ---- paragraphs ------------------------------------------------------------ function alignOf(attrs: TiptapNode['attrs']): (typeof AlignmentType)[keyof typeof AlignmentType] | undefined { switch (attrs?.textAlign) { @@ -1353,7 +1385,9 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph { shading: paraShadingOf(attrs), border: paraBordersOf(attrs), run: markSize || markFont ? { size: markSize, font: markFont } : undefined, - children: inlineToRuns(node.content, opts.force), + children: attrs.noHyphenation === true + ? [new TextRun(NOHYP), ...inlineToRuns(node.content, opts.force)] + : inlineToRuns(node.content, opts.force), }); } @@ -1963,7 +1997,7 @@ export async function buildDocx( const cited = applyBibliographyDocx(packed, docSources); const withNotes = docNoteIds.size ? applyNotePrDocx(cited, notesSettings) : cited; const mirrored = margins.mirrored ? applyMirrorMarginsDocx(withNotes) : withNotes; - const bidi = rtl ? applyBidiDocx(mirrored) : mirrored; + const bidi = applyNoHyphensDocx(rtl ? applyBidiDocx(mirrored) : mirrored); if (isEmptyPageDecor(decor)) return bidi; const dims = pageDimsCm(pageFormat, orientation); const pt = (cm: number) => (cm / 2.54) * 72; diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 837f1d3..71892e6 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -308,6 +308,7 @@ function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean { if (attrs.widowControl === false) return true; if (attrs.keepNext === true) return true; if (attrs.keepLines === true) return true; + if (attrs.noHyphenation === true) return true; if (attrs.dir === 'rtl' || attrs.dir === 'ltr') return true; for (const s of ['borderTop', 'borderRight', 'borderBottom', 'borderLeft']) if (typeof attrs[s] === 'string' && attrs[s] && attrs[s] !== 'none') return true; @@ -1618,20 +1619,21 @@ function applyEmptyLineFontSizes(odtBytes: Uint8Array): Uint8Array { // A top-level paragraph's box spec for the PBX sentinel: bg|borderTop|Right|Bottom|Left, // each the raw value (canonical border 'pt solid #RRGGBB' is a valid fo:border) or '', -// then a widow flag, the right indent, a keep-with-next flag and the writing mode. -// '' when it needs none. +// then a widow flag, the right indent, a keep-with-next flag, the writing mode and a +// no-hyphenation flag. '' when it needs none. function paraBoxSpec(attrs: TiptapNode['attrs']): string { const s = paraStyleFromAttrs(attrs); const noWidow = attrs?.widowControl === false; const keepNext = attrs?.keepNext === true; const keepLines = attrs?.keepLines === true; + const noHyphen = attrs?.noHyphenation === true; // odf-kit has a paragraph option for the left indent but none for the right one. const right = typeof attrs?.indentRight === 'number' && attrs.indentRight > 0 ? attrs.indentRight : 0; const wm = writingModeOf(s.dir); - if (!s.background && !s.borderTop && !s.borderRight && !s.borderBottom && !s.borderLeft && !noWidow && !right && !keepNext && !keepLines && !wm) return ''; + if (!s.background && !s.borderTop && !s.borderRight && !s.borderBottom && !s.borderLeft && !noWidow && !right && !keepNext && !keepLines && !wm && !noHyphen) return ''; return [s.background, s.borderTop, s.borderRight, s.borderBottom, s.borderLeft] .map((v) => v ?? '') - .concat(noWidow ? 'w0' : '', right ? `${right}cm` : '', keepNext ? 'k1' : '', keepLines ? 'g1' : '', wm ?? '').join('|'); + .concat(noWidow ? 'w0' : '', right ? `${right}cm` : '', keepNext ? 'k1' : '', keepLines ? 'g1' : '', wm ?? '', noHyphen ? 'h0' : '').join('|'); } function boxSpecToProps(spec: string): string { @@ -1655,6 +1657,7 @@ function boxSpecToProps(spec: string): string { // in ODF). Mirrors cloneStyleWithFontSize but for paragraph-properties. function cloneStyleWithParaProps(def: string, newName: string, props: string): string { const s = def.replace(/style:name="[^"]*"/, `style:name="${newName}"`); + if (!props) return s; if (/]*\/>/.test(s)) return s.replace(/]*?)\s*\/>/, ``); if (/`, - ); + : `${props ? `` : ''}`; + // ODF counts fo:hyphenate as a *text* property — in paragraph-properties LibreOffice + // ignores it and drops it on the next save. + if (spec.split('|')[10] === 'h0') style = upsertProps(style, 'text', { 'fo:hyphenate': 'false' }); + minted.push(style); return name; }; diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 0311792..88d31e2 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -547,6 +547,7 @@ const de: Messages = { widowControl: 'Absatzkontrolle', keepNext: 'Nicht vom nächsten Absatz trennen', keepLines: 'Zeilen nicht trennen', + hyphenate: 'Automatische Silbentrennung', pageBreakBefore: 'Seitenumbruch oberhalb', flowHint: 'Diese Optionen bestimmen, wo der Absatz über Seiten getrennt werden darf.', tabsButton: 'Tabstopps…', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index e0608fc..4df5d6c 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -551,6 +551,7 @@ const en = { widowControl: 'Widow/orphan control', keepNext: 'Keep with next', keepLines: 'Keep lines together', + hyphenate: 'Automatic hyphenation', pageBreakBefore: 'Page break before', flowHint: 'These control where the paragraph may be split across pages.', tabsButton: 'Tabs…', diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 64f076f..2894da6 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -68,6 +68,8 @@ type Ctx = { // The section's own direction: a block declaring the same one is inheriting, not // formatted, so only a block that differs carries a `dir` attr. pageRtl: boolean; + // Word's document-wide automatic hyphenation: a paragraph can only turn it off. + hyphenate: boolean; // The enclosing table style's w:pPr/w:spacing, applied to its cells' paragraphs. cellSpacing: ParaSpacing; // Whether w:tblInd is measured to the cell's text rather than the table's edge. @@ -184,7 +186,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = const sectPr = fc(body, 'sectPr'); const contentWidthCm = sectionContentWidthCm(sectPr); const leftMarginCm = twipToCm(intAttr(fc(sectPr, 'pgMar'), W, 'left') ?? 1440); - const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, pageRtl: sectPrRtl(sectPr), cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map(), openComments: new Map(), commentDefs: docxComments(files), bibSources: docxSources(files), notes: [], noteParts: { + const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, pageRtl: sectPrRtl(sectPr), hyphenate: docAutoHyphenation(files), cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map(), openComments: new Map(), commentDefs: docxComments(files), bibSources: docxSources(files), notes: [], noteParts: { footnote: noteParts(files, 'footnotes', 'footnote'), endnote: noteParts(files, 'endnotes', 'endnote'), } }; @@ -935,6 +937,10 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault: if (!level && (directKn ? onOff(directKn) : ctx.styles.paragraphKeepNext(styleId))) attrs.keepNext = true; const directKl = fc(ppr, 'keepLines'); if (!level && (directKl ? onOff(directKl) : ctx.styles.paragraphKeepLines(styleId))) attrs.keepLines = true; + // "Don't hyphenate this paragraph" — only formatting where the document hyphenates + // at all; below that switch it says what is already true. + const directSah = fc(ppr, 'suppressAutoHyphens'); + if (ctx.hyphenate && directSah && onOff(directSah)) attrs.noHyphenation = true; // w:bidi — the block's own base direction; the section's own is inheritance, not // formatting, so only a block that differs from it carries the attr. const directBidi = fc(ppr, 'bidi'); diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 5a4ed5e..5cb7029 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -1480,6 +1480,9 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault = // paragraph carries it — otherwise every heading would accrete the producer's flag. if (!isHeading && paraProps['fo:keep-with-next'] === 'always') attrs.keepNext = true; if (!isHeading && paraProps['fo:keep-together'] === 'always') attrs.keepLines = true; + // "Don't hyphenate this paragraph" — only meaningful where the document hyphenates at + // all; below that switch it is the default and no formatting. + if (baseTextProps['fo:hyphenate'] === 'false' && resolver.documentHyphenation()) attrs.noHyphenation = true; // style:writing-mode — the block's own base direction. "page" inherits, and the mode // the page already has is no formatting, so only a block that differs carries it. const wm = paraProps['style:writing-mode']; diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index e661759..73b7ac0 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -12,7 +12,7 @@ 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. - **`pageNumbering.ts`** — the page-number field's format and start value (`edentext-page-numbering`, absent at the 1/1 default). `HeaderFooterLayer.svelte` formats each page through `formatOrdinal`; the page **count** stays decimal, as the field both word processors write does. ODF keeps the format on the page layout and the start on the first paragraph's own style (`style:page-number` — probed, ODF has no document-level start); Word keeps both in the first section's `w:pgNumType`. -- **`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. +- **`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. A **paragraph** can only opt *out* (`noHyphenation`, pageBreak.ts): Word has no per-paragraph "on", and both importers drop the flag under a document that does not hyphenate, where it says what is already true. - **`tableOptions.svelte.ts`** — table number recognition (`edentext-number-recognition`, the key removed when off, as it is by default in LibreOffice). A reactive singleton like `trackChanges.svelte.ts`: the extension reads it on every transaction, both table surfaces flip it. It also holds the **number locale** — the document's language, which is what LibreOffice reads and writes a cell's number in — pushed by `App.svelte`'s language effect; the exporters take that locale from their own `language` argument instead, so they stay store-free. See `docs/architecture/tables.md`. - **`pageDecor.ts`** — the page's own decoration (`edentext-page-decor`, the key removed when all three are off): background colour, a border around the text area at `paddingCm` from it, and a text watermark (LibreOffice's Format ▸ Watermark, field for field). All three ride the ODF page layout — the watermark as a `fontwork-plain-text` `` named `PowerPlusWaterMarkObject` in the **master page's header**, the only place LibreOffice reads one from (probed: hung off the master page directly it is dropped, and an undeclared `xmlns:draw` in styles.xml drops it too). Word keeps the background on `w:document` (plus `w:displayBackgroundShape`), the border in `w:sectPr` **after `w:pgMar`** — the child order is fixed, and ahead of the header references Word's reader loses those — and the watermark as the VML `_x0000_t136` shape, whose definition has to travel or LibreOffice cannot resolve it. `PageDecorLayer.svelte` draws border and watermark per page; the background is a `--color-page-bg` override on `.paper`. - **`trackChanges.svelte.ts`** — whether the editor records revisions (`edentext-record-changes`, the key removed when off, as it is by default in both word processors). A reactive singleton because the extension reads it on every transaction while the Review tab flips it; the recorded changes themselves live in the document, on the marks. diff --git a/src/styles/editor.css b/src/styles/editor.css index c36bd56..f1108bf 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -134,6 +134,12 @@ margin-bottom: 0; } +/* One paragraph opting out of the document's automatic hyphenation (pageBreak.ts). + The host carries `hyphens: auto`, so only the paragraph has to say no. */ +.paper .tiptap [data-no-hyphenation] { + hyphens: none; +} + /* A page-anchored frame is placed from its page corner and is out of the flow, so the paragraph the importer hoists it into reserves no space (image.ts). !important: several other paragraph rules below set their own `position: relative` (line-height shift, the diff --git a/tests/unit/no-hyphenation.test.ts b/tests/unit/no-hyphenation.test.ts new file mode 100644 index 0000000..c21fc4b --- /dev/null +++ b/tests/unit/no-hyphenation.test.ts @@ -0,0 +1,54 @@ +// A paragraph opting out of the document's automatic hyphenation. Only "off" travels +// (Word has no per-paragraph "on"), and only where the document hyphenates at all — +// below that switch it is the default. ODF fo:hyphenate, Word w:suppressAutoHyphens. +import { describe, it, expect } from 'vitest'; +import { unzipSync, strFromU8 } from 'fflate'; +import { buildOdt } from '../../src/lib/export/odt'; +import { buildDocx } from '../../src/lib/export/docx'; +import { importOdt } from '../../src/lib/import/odt'; +import { importDocx } from '../../src/lib/import/docx'; + +type N = any; + +const P = (text: string, attrs?: N): N => + ({ type: 'paragraph', ...(attrs ? { attrs } : {}), content: [{ type: 'text', text }] }); + +const doc: N = { type: 'doc', content: [P('hyphenated'), P('whole words', { noHyphenation: true })] }; +const margins = { top: 2, bottom: 2, left: 2, right: 2 }; +const flagOf = (d: N, i: number) => d.content[i].attrs?.noHyphenation ?? null; + +// buildOdt/buildDocx take the document switch as their 13th argument. +const odt = (on: boolean) => buildOdt(doc, margins, 'portrait', undefined, undefined, 'A4', + undefined, undefined, undefined, false, undefined, undefined, on); +const docx = (on: boolean) => buildDocx(doc, margins, 'portrait', undefined, undefined, 'A4', + undefined, undefined, undefined, false, undefined, undefined, on); + +describe('per-paragraph hyphenation', () => { + it('round-trips through ODF', async () => { + const bytes = await odt(true); + const xml = strFromU8(unzipSync(bytes)['content.xml']); + // A text property, not a paragraph one — LibreOffice ignores it in the latter. + expect(xml).toMatch(/]*fo:hyphenate="false"/); + + const back = importOdt(bytes).content as N; + expect(flagOf(back, 0)).toBe(null); + expect(flagOf(back, 1)).toBe(true); + }); + + it('round-trips through DOCX', async () => { + const bytes = await docx(true); + const xml = strFromU8(unzipSync(bytes)['word/document.xml']); + expect(xml).toContain(''); + // The sentinel run is consumed, never left in the text. + expect(xml).not.toContain(''); + + const back = importDocx(bytes).content as N; + expect(flagOf(back, 0)).toBe(null); + expect(flagOf(back, 1)).toBe(true); + }); + + it('is dropped where the document does not hyphenate', async () => { + expect(flagOf(importOdt(await odt(false)).content as N, 1)).toBe(null); + expect(flagOf(importDocx(await docx(false)).content as N, 1)).toBe(null); + }); +});