feat: a paragraph can opt out of automatic hyphenation

LibreOffice's Format > Paragraph > Text Flow checkbox. Only "off"
travels (Word has no per-paragraph "on") and only under a document that
hyphenates, or it would say what is already true. ODF writes
fo:hyphenate="false" into the paragraph's text properties, DOCX gains
w:suppressAutoHyphens through a post-pack pass — the docx package
exposes no option for it. Both survive a LibreOffice re-save.
This commit is contained in:
Steffen Becker committed 2026-08-15 01:09:55 +02:00
1 parent e1b2fefa55
commit 4afd5cd1d5
13 files changed
+139 -13

No files matched your search

+2 -1
View File
@@ -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
@@ -41,6 +41,8 @@
let widowControl = $derived(read<boolean | null>('widowControl', null) !== false);
let keepNext = $derived(read<boolean | null>('keepNext', null) === true);
let keepLines = $derived(read<boolean | null>('keepLines', null) === true);
// LibreOffice's Hyphenation checkbox: on unless this paragraph opted out.
let hyphenate = $derived(read<boolean | null>('noHyphenation', null) !== true);
function read<T>(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) },
];
</script>
+10
View File
@@ -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<string, unknown>) =>
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: {
+2 -1
View File
@@ -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).
+36 -2
View File
@@ -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 <w:suppressAutoHyphens/> — 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 = /<w:(kinsoku|wordWrap|overflowPunct|topLinePunct|autoSpace|bidi|adjustRightInd|snapToGrid|spacing|ind|contextualSpacing|mirrorIndents|suppressOverlap|jc|textDirection|textAlignment|textboxTightWrap|outlineLvl|divId|cnfStyle|rPr|sectPr)\b/;
// Post-pack pass: "don't hyphenate this paragraph" is <w:suppressAutoHyphens/> 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(/<w:p\b[^>]*>[\s\S]*?<\/w:p>/g, (para) => {
if (!para.includes(NOHYP)) return para;
const p = para.replace(new RegExp(`<w:r>(?:(?!</w:r>)[\\s\\S])*${NOHYP}(?:(?!</w:r>)[\\s\\S])*</w:r>`, 'g'), '');
const pPr = /<w:pPr>[\s\S]*?<\/w:pPr>/.exec(p);
if (!pPr) return p.replace(/^(<w:p\b[^>]*>)/, '$1<w:pPr><w:suppressAutoHyphens/></w:pPr>');
const patched = AFTER_SUPPRESS_HYPHENS.test(pPr[0])
? pPr[0].replace(AFTER_SUPPRESS_HYPHENS, '<w:suppressAutoHyphens/>$&')
: pPr[0].replace('</w:pPr>', '<w:suppressAutoHyphens/></w:pPr>');
return p.replace(pPr[0], patched);
});
files['word/document.xml'] = strToU8(xml);
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
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;
+13 -7
View File
@@ -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 '<W>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 (/<style:paragraph-properties\b[^>]*\/>/.test(s))
return s.replace(/<style:paragraph-properties\b([^>]*?)\s*\/>/, `<style:paragraph-properties$1 ${props}/>`);
if (/<style:paragraph-properties\b/.test(s))
@@ -1891,10 +1894,13 @@ function applyParagraphBoxes(odtBytes: Uint8Array): Uint8Array {
nameByKey.set(key, name);
const props = boxSpecToProps(spec);
const def = source ? findAutoStyle(content, source) : null;
minted.push(def
let style = def
? cloneStyleWithParaProps(def, name, props)
: `<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${source || 'Standard'}"><style:paragraph-properties ${props}/></style:style>`,
);
: `<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${source || 'Standard'}">${props ? `<style:paragraph-properties ${props}/>` : ''}</style:style>`;
// 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;
};
+1
View File
@@ -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…',
+1
View File
@@ -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…',
+7 -1
View File
@@ -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');
+3
View File
@@ -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'];
+1 -1
View File
@@ -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` `<draw:custom-shape>` 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.
+6
View File
@@ -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
+54
View File
@@ -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(/<style:text-properties[^>]*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('<w:suppressAutoHyphens/>');
// 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);
});
});