fix: import correct font size of empty lines

This commit is contained in:
Steffen Becker committed 2026-07-02 23:10:02 +02:00
1 parent a5bb2deaa2
commit 86376ca7e3
8 files changed
+218 -3

No files matched your search

+3
View File
@@ -25,6 +25,7 @@ import TextAlign from '@tiptap/extension-text-align';
import { PageBreaks } from './extensions/pageBreaks';
import { LineHeight } from './extensions/lineHeight';
import { ParagraphSpacing } from './extensions/paragraphSpacing';
import { BlockFontSize } from './extensions/blockFontSize';
import { PageBreak } from './extensions/pageBreak';
import { Indent } from './extensions/indent';
import { FormattingMarks } from './extensions/formattingMarks';
@@ -71,6 +72,8 @@ export const extensions = [
TableOfContents,
LineHeight,
ParagraphSpacing,
// Paragraph-mark font size: sizes empty lines (and text typed into them).
BlockFontSize,
PageBreak,
Indent,
Heading.configure({ levels: [1, 2, 3] }),
@@ -0,0 +1,56 @@
import { Extension } from '@tiptap/core';
// Font size of the paragraph mark (Word's w:pPr/w:rPr sz, ODF paragraph
// fo:font-size): governs an empty line's height and the size of text typed into it.
// A CSS length (e.g. "22pt") rendered as font-size on the block; null = style default.
declare module '@tiptap/core' {
interface Commands<ReturnType> {
blockFontSize: {
setBlockFontSize: (size: string | null) => ReturnType;
};
}
}
export const BlockFontSize = Extension.create({
name: 'blockFontSize',
addOptions() {
return {
types: ['paragraph', 'heading'] as string[],
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
fontSize: {
default: null,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-block-font-size') || null,
renderHTML: (attributes: Record<string, unknown>) => {
if (!attributes.fontSize) return {};
const size = String(attributes.fontSize);
return {
'data-block-font-size': size,
style: `font-size: ${size}`,
};
},
},
},
},
];
},
addCommands() {
return {
setBlockFontSize: (size: string | null) => ({ commands }) => {
return this.options.types
.map((type) => commands.updateAttributes(type, { fontSize: size }))
.some((r) => r);
},
};
},
});
+3
View File
@@ -316,6 +316,8 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph {
else if (opts.indentLeftTwip) indent.left = opts.indentLeftTwip;
}
const heading = node.type === 'heading' ? HEADING_LEVEL[Math.min(3, Math.max(1, Number(attrs.level) || 1))] : undefined;
// Paragraph-mark run props carry an empty line's font size (see import/docx.ts).
const markSize = typeof attrs.fontSize === 'string' ? fontSizeToHalfPoints(attrs.fontSize) : undefined;
return new Paragraph({
heading,
alignment: alignOf(attrs),
@@ -323,6 +325,7 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph {
indent: indent.left != null ? indent : undefined,
pageBreakBefore: attrs.breakBefore === 'page' || undefined,
numbering: opts.numbering,
run: markSize ? { size: markSize } : undefined,
children: inlineToRuns(node.content, opts.forceBold),
});
}
+87 -3
View File
@@ -64,6 +64,11 @@ const IMG = '';
// fo:break-before="page" paragraph style for it, and strips the sentinel. U+E005.
const PGB = '';
// Sentinel wrapping an empty line's font size (FSZ<size>FSZ), the sole text of a
// top-level empty CUST_P/CUST_H; applyEmptyLineFontSizes turns it into a paragraph
// style fo:font-size (odf-kit has no paragraph font-size option). U+E007.
const FSZ = '\uE007';
// Sentinel wrapping a table-of-contents index (TOC{i}TOC), emitted as a marker
// paragraph's run text by replaceTableOfContents so it rides odf-kit's paragraph path;
// applyToc rewrites the whole marker <text:p> to a <text:table-of-content>. U+E006.
@@ -99,6 +104,7 @@ function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean {
if (attrs.lineHeight) return true;
if (attrs.spaceBefore != null) return true;
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;
const ta = attrs.textAlign;
return ta === 'left' || ta === 'center' || ta === 'right' || ta === 'justify';
@@ -556,6 +562,78 @@ function applyPageBreaks(odtBytes: Uint8Array): Uint8Array {
return rezipOdt(files);
}
// fo:font-size (+ asian/complex aliases) attribute string for a paragraph style.
function fontSizeProps(size: string): string {
return `fo:font-size="${size}" style:font-size-asian="${size}" style:font-size-complex="${size}"`;
}
// Clone a <style:style> def under newName, adding fo:font-size to its text-properties.
function cloneStyleWithFontSize(def: string, newName: string, size: string): string {
const s = def.replace(/style:name="[^"]*"/, `style:name="${newName}"`);
const props = fontSizeProps(size);
if (/<style:text-properties\b[^>]*\/>/.test(s)) {
return s.replace(/<style:text-properties\b([^>]*?)\s*\/>/, `<style:text-properties$1 ${props}/>`);
}
if (/<style:text-properties\b/.test(s)) {
return s.replace(/<style:text-properties\b([^>]*)>/, `<style:text-properties$1 ${props}>`);
}
if (/^<style:style\b[^>]*\/>\s*$/.test(s)) {
return s.replace(/\s*\/>\s*$/, `><style:text-properties ${props}/></style:style>`);
}
return s.replace('</style:style>', `<style:text-properties ${props}/></style:style>`);
}
// Resolve FSZ sentinels (empty-line font size): per (source style, size) mint a style
// cloning the source's props plus that fo:font-size, reassign the block's style-name,
// and strip the sentinel. Mirrors applyPageBreaks.
function applyEmptyLineFontSizes(odtBytes: Uint8Array): Uint8Array {
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
if (!content.includes(FSZ)) return odtBytes;
const minted: string[] = [];
const nameByKey = new Map<string, string>();
let counter = 0;
const sizeStyleFor = (source: string, size: string): string => {
const key = `${source}|${size}`;
const existing = nameByKey.get(key);
if (existing) return existing;
const name = `EF${++counter}`;
nameByKey.set(key, name);
const def = source ? findAutoStyle(content, source) : null;
minted.push(def
? cloneStyleWithFontSize(def, name, size)
: `<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${source || 'Standard'}"><style:text-properties ${fontSizeProps(size)}/></style:style>`,
);
return name;
};
content = content.replace(
new RegExp(`<text:(p|h)\\b([^>]*)>${FSZ}([^${FSZ}]*)${FSZ}`, 'g'),
(_m, tag: string, attrs: string, size: string) => {
const sm = /text:style-name="([^"]*)"/.exec(attrs);
const name = sizeStyleFor(sm ? sm[1] : '', size);
const newAttrs = sm
? attrs.replace(/text:style-name="[^"]*"/, `text:style-name="${name}"`)
: ` text:style-name="${name}"${attrs}`;
return `<text:${tag}${newAttrs}>`;
},
);
// Drop any sentinels not consumed above (defensive — never legitimate text).
content = content.replace(new RegExp(`${FSZ}[^${FSZ}]*${FSZ}`, 'g'), '');
if (minted.length) {
content = content.replace('</office:automatic-styles>', `${minted.join('')}</office:automatic-styles>`);
}
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
// odf-kit always emits ordered lists as "1." (num-format="1" num-suffix="."). This
// rewrites the matching L# list style to the user's chosen format; formats[] has one
// entry per top-level list in odf-kit's order (null for bullets/default 'decimal').
@@ -1468,16 +1546,21 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
opts.indentLeft = `${node.attrs.indent}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.
const emptyFs = content.length === 0 && typeof node.attrs?.fontSize === 'string' && node.attrs.fontSize
? `${FSZ}${node.attrs.fontSize}${FSZ}` : '';
if (node.type === CUST_P) {
if (content.length === 0) {
doc.addParagraph('', opts);
doc.addParagraph(emptyFs, opts);
} else {
doc.addParagraph((p: ParagraphBuilder) => applyRuns(p, content), opts);
}
} else if (node.type === CUST_H) {
const level = (node.attrs?.level as number) ?? 1;
doc.addHeading((p: ParagraphBuilder) => applyRuns(p, content), level, opts);
if (content.length === 0) doc.addHeading(emptyFs, level, opts);
else doc.addHeading((p: ParagraphBuilder) => applyRuns(p, content), level, opts);
}
},
});
@@ -1515,7 +1598,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const withImages = applyImages(withBreaks, images);
const withToc = applyToc(withImages, tocs, contentWidthCm);
const withPageBreaks = applyPageBreaks(withToc);
const withStyles = rewriteStylesXml(withPageBreaks, language ?? null);
const withEmptyFontSizes = applyEmptyLineFontSizes(withPageBreaks);
const withStyles = rewriteStylesXml(withEmptyFontSizes, language ?? null);
return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist);
}
+20
View File
@@ -304,6 +304,14 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault:
const baseRun = mergeRunProps(ctx.styles.defaultRun(), ctx.styles.styleOwn(pStyle ? wVal(pStyle) : null));
const content = convertInline(el, ctx, baseRun, level, false, boldByDefault);
// An empty line's height comes from the paragraph mark's own run props (w:pPr/w:rPr),
// which convertInline never sees (there are no runs). Carry its font size as a block
// attr so the empty line renders at the right height (and typed text inherits it).
if (content.length === 0) {
const fs = emptyLineFontSize(ppr, ctx, baseRun, level);
if (fs) attrs.fontSize = fs;
}
const node: Node = { type: level ? 'heading' : 'paragraph' };
if (level) attrs.level = level;
if (Object.keys(attrs).length) node.attrs = attrs;
@@ -311,6 +319,18 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault:
return node;
}
// The paragraph mark's resolved font size (w:pPr/w:rPr, incl. its rStyle), as a CSS
// pt string, or null when it matches the block's default (suppressed like run sizes).
function emptyLineFontSize(ppr: Element | null, ctx: Ctx, baseRun: RunProps, level: number | null): string | null {
const rPr = fc(ppr, 'rPr');
const rStyle = fc(rPr, 'rStyle');
const props = mergeRunProps(mergeRunProps(baseRun, ctx.styles.styleOwn(rStyle ? wVal(rStyle) : null)), parseRunProps(rPr));
if (props.sizeHalfPt == null) return null;
const sizePt = props.sizeHalfPt / 2;
const defSize = level != null ? HEADING_SIZES[level - 1] : BODY_FONT_SIZE_PT;
return Math.abs(sizePt - defSize) > 0.05 ? `${Math.round(sizePt * 10) / 10}pt` : null;
}
// Heading + clamped level. Detect via the paragraph style id (fast path for our own
// files / English Word), then the style's resolved outline level (locale-independent,
// catches LibreOffice/localized heading styles), then a direct paragraph outline level.
+8
View File
@@ -353,6 +353,14 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
const attrs = blockAttrs(paraProps, baseTextProps, isHeading ? level : null, kind);
const content = convertInline(el, ctx, baseTextProps, isHeading ? level : null, false, boldByDefault);
// An empty line has no runs, so its height comes from the paragraph style's own
// font size; carry it as a block attr (mirrors the docx paragraph-mark size).
if (content.length === 0) {
const sizePt = lengthToPt(baseTextProps['fo:font-size']);
const defSize = isHeading ? HEADING_DEFAULTS[level - 1].fontSizePt : BODY_FONT_SIZE_PT;
if (sizePt != null && Math.abs(sizePt - defSize) > 0.05) attrs.fontSize = formatPt(sizePt);
}
const node: Node = { type: isHeading ? 'heading' : 'paragraph' };
if (isHeading) attrs.level = level;
if (Object.keys(attrs).length) node.attrs = attrs;
+34
View File
@@ -38,6 +38,7 @@ describe('DOCX export → import round trip', () => {
text(' '),
text('site', [{ type: 'link', attrs: { href: 'https://example.com' } }]),
], { spaceBefore: 6, spaceAfter: 6, lineHeight: '1.5', indent: 1 }),
{ type: 'paragraph', attrs: { fontSize: '22pt', textAlign: 'center' } }, // empty sized line
para([text('a\tb')]),
para([text('line1'), { type: 'hardBreak' }, text('line2')]),
{ type: 'bulletList', content: [li(para('one')), li(para('two'), { type: 'bulletList', content: [li(para('nested'))] })] },
@@ -78,6 +79,12 @@ describe('DOCX export → import round trip', () => {
expect(p.attrs.indent).toBe(1);
});
it('round-trips an empty line\'s font size (paragraph-mark size)', () => {
const empty = doc.content!.find((n) => n.type === 'paragraph' && !n.content && n.attrs?.fontSize);
expect(empty).toBeTruthy();
expect(empty!.attrs.fontSize).toBe('22pt');
});
it('round-trips run marks (bold, color, highlight, link)', () => {
const texts = walk(doc, 'text');
expect(hasMark(texts.find((t) => t.text === 'bold')!, 'bold')).toBe(true);
@@ -185,6 +192,33 @@ describe('DOCX import of a foreign Word document', () => {
});
});
describe('DOCX import: empty line keeps its paragraph-mark font size', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
// An empty paragraph whose only formatting is the paragraph mark (w:pPr/w:rPr) — 44
// half-points = 22pt, like a title's blank spacer lines. And a plain empty paragraph.
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:pPr><w:jc w:val="center"/><w:rPr><w:sz w:val="44"/></w:rPr></w:pPr></w:p>
<w:p><w:pPr><w:rPr><w:sz w:val="24"/></w:rPr></w:pPr></w:p>
<w:p/>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
</w:body></w:document>`;
const bytes = zipSync({ 'word/document.xml': strToU8(documentXml) });
const doc = importDocx(bytes).content as N;
it('carries the 22pt paragraph-mark size onto the empty line', () => {
const p = doc.content![0];
expect(p.type).toBe('paragraph');
expect(p.content).toBeUndefined(); // still empty
expect(p.attrs.fontSize).toBe('22pt');
expect(p.attrs.textAlign).toBe('center');
});
it('suppresses a paragraph-mark size equal to the 12pt body default', () => {
expect(doc.content![1].attrs?.fontSize ?? null).toBeNull();
expect(doc.content![2].attrs?.fontSize ?? null).toBeNull();
});
});
describe('DOCX import resolves theme fonts (Word default = Calibri)', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
const A = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"';
+7
View File
@@ -42,6 +42,8 @@ const fixture: N = {
type: 'doc',
content: [
H({ level: 1, textAlign: 'center' }, T('Invoice Report 2026')),
P({ fontSize: '22pt' }), // empty sized line (bare)
P({ fontSize: '18pt', textAlign: 'center' }), // empty sized line + centered
P(null,
T('Plain '),
T('bold', { type: 'bold' }),
@@ -195,6 +197,11 @@ describe('Leg 1: editor → buildOdt → importOdt', () => {
const diff = firstDiff(normalize(fixture), normalize(res.content));
check('document JSON round-trips', diff === null, diff);
// Empty lines keep their paragraph font size (drives the empty line's height).
const sized = (res.content.content ?? []).filter((n: N) => n.type === 'paragraph' && !n.content?.length && n.attrs?.fontSize);
check('empty sized lines round-trip', sized.length === 2, sized.map((n: N) => n.attrs.fontSize));
check('bare empty line keeps 22pt', sized.some((n: N) => n.attrs.fontSize === '22pt'), sized);
// Images: src bytes (data-URI) and px size must round-trip exactly (body + cell).
const logoPara = (res.content.content ?? []).find((n: N) => n.content?.some((c: N) => c.type === 'image'));
const bodyImg = logoPara?.content?.find((c: N) => c.type === 'image');