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.
This commit is contained in:
1 parent
3a68a144cb
commit
c014a73da2
6 files changed
+90
-1
No files matched your search
@@ -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<string, unknown>) => {
|
||||
if (attributes.indentFirst == null) return {};
|
||||
const cm = Number(attributes.indentFirst);
|
||||
return {
|
||||
'data-indent-first': String(cm),
|
||||
style: `text-indent: ${cm}cm`,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user