diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 686f81a..fc43a14 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -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] }), diff --git a/src/lib/editor/extensions/blockFontSize.ts b/src/lib/editor/extensions/blockFontSize.ts new file mode 100644 index 0000000..4e53e34 --- /dev/null +++ b/src/lib/editor/extensions/blockFontSize.ts @@ -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 { + 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) => { + 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); + }, + }; + }, +}); diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 8c157c6..910d69a 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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), }); } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index ea72d85..48e321d 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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 (FSZFSZ), 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 to a . 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 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 (/]*\/>/.test(s)) { + return s.replace(/]*?)\s*\/>/, ``); + } + if (/]*)>/, ``); + } + if (/^]*\/>\s*$/.test(s)) { + return s.replace(/\s*\/>\s*$/, `>`); + } + return s.replace('', ``); +} + +// 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(); + 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) + : ``, + ); + return name; + }; + + content = content.replace( + new RegExp(`]*)>${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 ``; + }, + ); + // 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('', `${minted.join('')}`); + } + + 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); } diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 3ab1a94..4fcb151 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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. diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index aed8e64..640fb30 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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; diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts index d116b04..a6a0bd1 100644 --- a/tests/docx-roundtrip.test.ts +++ b/tests/docx-roundtrip.test.ts @@ -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 = ` + + + + + `; + 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"'; diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 7b4b355..e64079c 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -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');