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:
1 parent
e1b2fefa55
commit
4afd5cd1d5
13 files changed
+139
-13
No files matched your search
+2
-1
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user