fix: import correct font size of empty lines
This commit is contained in:
1 parent
a5bb2deaa2
commit
86376ca7e3
8 files changed
+218
-3
No files matched your search
@@ -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);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"';
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user