From c014a73da28511e7278d236e9f626c9bc94ba356 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 6 Aug 2026 19:58:33 +0000 Subject: [PATCH] feat: support first-line and hanging indents Only the block's left margin was read, so w:firstLine, w:hanging and fo:text-indent were dropped: every hanging-indent paragraph rendered with its first line flush with the rest instead of outdented. Stored as the signed indentFirst attr in cm (negative = hanging) and rendered as CSS text-indent. Word splits it into two exclusive attributes by sign, ODF keeps one signed value. --- src/lib/editor/extensions/indent.ts | 21 +++++++++++++ src/lib/export/docx.ts | 7 ++++- src/lib/export/odt.ts | 6 ++++ src/lib/import/docx.ts | 5 +++ src/lib/import/odt.ts | 3 ++ tests/unit/indent-first.test.ts | 49 +++++++++++++++++++++++++++++ 6 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 tests/unit/indent-first.test.ts diff --git a/src/lib/editor/extensions/indent.ts b/src/lib/editor/extensions/indent.ts index b375035..2cf98d1 100644 --- a/src/lib/editor/extensions/indent.ts +++ b/src/lib/editor/extensions/indent.ts @@ -108,6 +108,13 @@ function parseIndent(value: string | null): number | null { return Number.isFinite(n) && n > 0 ? n : null; } +// A first-line indent may be negative (Word's hanging indent), unlike a left margin. +function parseSignedIndent(value: string | null): number | null { + if (value == null || value === '') return null; + const n = parseFloat(value); + return Number.isFinite(n) && n !== 0 ? n : null; +} + export const Indent = Extension.create({ name: 'indent', @@ -139,6 +146,20 @@ export const Indent = Extension.create({ }; }, }, + // First-line indent in cm; negative is Word's hanging indent. Independent of + // `indent`, which is the whole block's left margin. + indentFirst: { + default: null, + parseHTML: (element: HTMLElement) => parseSignedIndent(element.getAttribute('data-indent-first')), + renderHTML: (attributes: Record) => { + if (attributes.indentFirst == null) return {}; + const cm = Number(attributes.indentFirst); + return { + 'data-indent-first': String(cm), + style: `text-indent: ${cm}cm`, + }; + }, + }, }, }, ]; diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index fb1d9dd..0ef1a66 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -607,6 +607,11 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph { if (!opts.numbering) { if (typeof attrs.indent === 'number' && attrs.indent > 0) indent.left = cmToTwip(attrs.indent); else if (opts.indentLeftTwip) indent.left = opts.indentLeftTwip; + // Word splits the first-line indent into two exclusive attributes by sign. + if (typeof attrs.indentFirst === 'number' && attrs.indentFirst !== 0) { + if (attrs.indentFirst < 0) indent.hanging = cmToTwip(-attrs.indentFirst); + else indent.firstLine = cmToTwip(attrs.indentFirst); + } } // The block's named style (a heading style id is what HeadingLevel references anyway). const style = docxStyleId(styleOf(node)); @@ -616,7 +621,7 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph { style, alignment: alignOf(attrs), spacing: spacingOf(attrs), - indent: indent.left != null ? indent : undefined, + indent: indent.left != null || indent.hanging != null || indent.firstLine != null ? indent : undefined, pageBreakBefore: attrs.breakBefore === 'page' || undefined, widowControl: attrs.widowControl === false ? false : undefined, numbering: opts.numbering, diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 35e08a1..d8f2ae8 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -223,6 +223,7 @@ function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean { if (attrs.spaceAfter != null) return true; if (typeof attrs.fontSize === 'string' && attrs.fontSize) return true; if (typeof attrs.indent === 'number' && attrs.indent > 0) return true; + if (typeof attrs.indentFirst === 'number' && attrs.indentFirst !== 0) return true; if (typeof attrs.backgroundColor === 'string' && attrs.backgroundColor) return true; if (attrs.widowControl === false) return true; for (const s of ['borderTop', 'borderRight', 'borderBottom', 'borderLeft']) @@ -2801,6 +2802,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU spaceBefore?: string; spaceAfter?: string; indentLeft?: string; + indentFirst?: string; } = {}; if (node.attrs?.lineHeight != null) { const lhRaw = String(node.attrs.lineHeight); @@ -2819,6 +2821,10 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU if (typeof node.attrs?.indent === 'number' && node.attrs.indent > 0) { opts.indentLeft = `${node.attrs.indent}cm`; } + // First-line indent → fo:text-indent; negative is a hanging indent. + if (typeof node.attrs?.indentFirst === 'number' && node.attrs.indentFirst !== 0) { + opts.indentFirst = `${node.attrs.indentFirst}cm`; + } const content = node.content ?? []; // An empty line's font size (its paragraph-mark size) rides as an FSZ sentinel // that applyEmptyLineFontSizes turns into a paragraph-style fo:font-size. diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 362e23e..38d0b1d 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -772,6 +772,11 @@ function blockAttrs(ppr: Element | null, kind: BlockKind, headingLevel: number | const ind = fc(ppr, 'ind'); const left = ind ? intAttr(ind, W, 'left') ?? intAttr(ind, W, 'start') : null; if (left != null) { const cm = round2(twipToCm(left)); if (cm > LIST_INDENT_EPS_CM) attrs.indent = cm; } + // w:hanging outdents the first line, w:firstLine indents it; they are exclusive. + const hanging = ind ? intAttr(ind, W, 'hanging') : null; + const firstLine = ind ? intAttr(ind, W, 'firstLine') : null; + const first = hanging != null ? -twipToCm(hanging) : firstLine != null ? twipToCm(firstLine) : null; + if (first != null && Math.abs(first) > LIST_INDENT_EPS_CM) attrs.indentFirst = round2(first); } if (kind === 'body') { diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 2378158..a65c9a9 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -929,6 +929,9 @@ function blockAttrs(paraProps: PropMap, textProps: PropMap, defaults: BlockDefau if (kind !== 'list') { const ml = lengthToPt(paraProps['fo:margin-left']) ?? 0; if (Math.abs(ml - defaults.indentPt) > EPS_PT) attrs.indent = Math.round((ml / 72) * 2.54 * 100) / 100; + // fo:text-indent is the first line only; negative is a hanging indent. + const ti = lengthToCm(paraProps['fo:text-indent']); + if (ti != null && Math.abs(ti) > 0.02) attrs.indentFirst = Math.round(ti * 100) / 100; } // Manual page break (fo:break-before). Honored for top-level blocks only diff --git a/tests/unit/indent-first.test.ts b/tests/unit/indent-first.test.ts new file mode 100644 index 0000000..5a5bad9 --- /dev/null +++ b/tests/unit/indent-first.test.ts @@ -0,0 +1,49 @@ +// First-line indent: Word splits it into w:firstLine / w:hanging by sign, ODF keeps +// one signed fo:text-indent. jsdom (vitest `environment`) supplies the global DOMParser. +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('plain'), + P('hanging', { indent: 2.5, indentFirst: -2.5 }), + P('first line in', { indentFirst: 1.25 }), + ], +}; + +const margins = { top: 2, bottom: 2, left: 2, right: 2 }; +const firstOf = (d: N, i: number) => d.content[i].attrs?.indentFirst ?? null; + +describe('first-line indent', () => { + it('round-trips through ODF', async () => { + const bytes = await buildOdt(doc, margins, 'portrait'); + expect(strFromU8(unzipSync(bytes)['content.xml'])).toMatch(/fo:text-indent="-2\.5cm"/); + + const back = importOdt(bytes).content as N; + expect(firstOf(back, 0)).toBe(null); + expect(firstOf(back, 1)).toBe(-2.5); + expect(firstOf(back, 2)).toBe(1.25); + }); + + it('round-trips through DOCX', async () => { + const bytes = await buildDocx(doc, margins, 'portrait'); + const xml = strFromU8(unzipSync(bytes)['word/document.xml']); + expect(xml).toMatch(/w:hanging="1417"/); + expect(xml).toMatch(/w:firstLine="709"/); + + const back = importDocx(bytes).content as N; + expect(firstOf(back, 0)).toBe(null); + expect(firstOf(back, 1)).toBe(-2.5); + expect(firstOf(back, 2)).toBe(1.25); + }); +});