feat: docx import/export of named paragraph styles

This commit is contained in:
Steffen Becker committed 2026-07-29 11:35:07 +02:00
1 parent d79287c10a
commit ebb76a5104
5 files changed
+344 -57

No files matched your search

+1 -1
View File
@@ -550,7 +550,7 @@
try {
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
const { buildDocx } = await import('./lib/export/docx');
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat);
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet());
await saveAsDocx(bytes, suggestedFilenameDocx(json));
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
+73 -19
View File
@@ -2,7 +2,7 @@ import {
Document, Packer, Paragraph, TextRun, ImageRun, ExternalHyperlink, Tab,
TableOfContents,
Table, TableRow, TableCell, Header, Footer, PageNumber, SimpleField,
AlignmentType, HeadingLevel, LevelFormat, UnderlineType, BorderStyle, ShadingType,
AlignmentType, LevelFormat, UnderlineType, BorderStyle, ShadingType,
WidthType, HeightRule, PageOrientation, LineRuleType, TableLayoutType, SectionType,
HorizontalPositionAlign, VerticalPositionRelativeFrom, HorizontalPositionRelativeFrom,
TextWrappingType, TextWrappingSide,
@@ -10,7 +10,7 @@ import {
import type { TiptapNode } from 'odf-kit';
import type {
IRunStylePropertiesOptions, ISpacingProperties, IIndentAttributesProperties,
ILevelsOptions, IFloating, IBorderOptions,
ILevelsOptions, IFloating, IBorderOptions, IParagraphStyleOptions,
} from 'docx';
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
@@ -22,7 +22,8 @@ import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { defaultBulletChar } from '../utils/bulletListTypes';
import { normalizeColor, HEADING_STYLE_OVERRIDES, HEADING_FONT, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, type HfExport } from './odt';
import { normalizeColor, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, type HfExport } from './odt';
import { builtinStyleSheet, DEFAULT_STYLE, type Style, type StyleSheet } from '../styles/styleSheet';
import { findFormat, renderFormat, docxPicture, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from '../utils/dateTime';
// BCP-47 tag for rendering a fixed field's cached text; set at buildDocx start from
@@ -573,10 +574,6 @@ function spacingOf(attrs: TiptapNode['attrs']): ISpacingProperties | undefined {
return Object.keys(s).length ? s : undefined;
}
const HEADING_LEVEL: Record<number, (typeof HeadingLevel)[keyof typeof HeadingLevel]> = {
1: HeadingLevel.HEADING_1, 2: HeadingLevel.HEADING_2, 3: HeadingLevel.HEADING_3,
4: HeadingLevel.HEADING_4, 5: HeadingLevel.HEADING_5,
};
type ParaOpts = { numbering?: { reference: string; level: number }; indentLeftTwip?: number; forceBold?: boolean };
@@ -602,11 +599,12 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph {
if (typeof attrs.indent === 'number' && attrs.indent > 0) indent.left = cmToTwip(attrs.indent);
else if (opts.indentLeftTwip) indent.left = opts.indentLeftTwip;
}
const heading = node.type === 'heading' ? HEADING_LEVEL[Math.min(MAX_HEADING_LEVEL, Math.max(1, Number(attrs.level) || 1))] : undefined;
// The block's named style (a heading style id is what HeadingLevel references anyway).
const style = docxStyleId(styleOf(node));
// 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,
style,
alignment: alignOf(attrs),
spacing: spacingOf(attrs),
indent: indent.left != null ? indent : undefined,
@@ -825,26 +823,81 @@ function bodyGroups(content: TiptapNode[], num: Numbering, contentWidthCm: numbe
return groups;
}
// The style name a block carries: its own, else the node type's default.
function styleOf(node: TiptapNode): string {
const own = node.attrs?.styleName;
if (typeof own === 'string' && own) return own;
return node.type === 'heading' ? `Heading ${(node.attrs?.level as number) ?? 1}` : DEFAULT_STYLE;
}
// Styles to define in the file: the built-ins (Word defines its standard styles too)
// plus the user styles the document references, each with its parent chain.
function usedStyleNames(doc: TiptapNode, sheet: StyleSheet): Set<string> {
const used = new Set<string>();
const addChain = (name: string) => {
let cur: string | null | undefined = name;
while (cur && sheet.paragraph[cur] && !used.has(cur)) {
used.add(cur);
cur = sheet.paragraph[cur].parent;
}
};
for (const style of Object.values(sheet.paragraph)) if (style.builtin) addChain(style.name);
const walk = (node: TiptapNode) => {
if (node.type === 'paragraph' || node.type === 'heading') addChain(styleOf(node));
node.content?.forEach(walk);
};
walk(doc);
return used;
}
// ---- document styles -------------------------------------------------------
function headingStyle(o: { fontSize: string; marginTop: string; marginBottom: string }) {
// Word style ids are XML names; "Heading 1" → "Heading1" (which is also what
// HeadingLevel.HEADING_1 references), "Standard" → Word's own default style "Normal".
export function docxStyleId(name: string): string {
return name === DEFAULT_STYLE ? 'Normal' : name.replace(/[^A-Za-z0-9]/g, '');
}
// One registry style → a Word paragraph style with its parent chain.
function paragraphStyleOf(style: Style): IParagraphStyleOptions {
const p = style.para;
const t = style.text;
const run: Writable<IRunStylePropertiesOptions> = {};
// Liberation Serif is the on-screen name; the file declares its metric twin.
if (t.fontFamily) run.font = t.fontFamily === 'Liberation Serif' ? DOC_FONT : t.fontFamily;
if (t.fontSizePt != null) run.size = Math.round(t.fontSizePt * 2);
if (t.bold != null) run.bold = t.bold;
if (t.italic != null) run.italics = t.italic;
if (t.underline) run.underline = {};
if (t.strike) run.strike = t.strike;
if (t.color) run.color = t.color.replace('#', '');
const spacing: Record<string, number> = {};
if (p.spaceBefore != null) spacing.before = ptToTwip(p.spaceBefore);
if (p.spaceAfter != null) spacing.after = ptToTwip(p.spaceAfter);
const paragraph: Record<string, unknown> = {};
if (Object.keys(spacing).length) paragraph.spacing = { ...spacing, line: 240, lineRule: LineRuleType.AUTO };
if (p.textAlign) paragraph.alignment = alignOf({ textAlign: p.textAlign });
if (p.indent != null) paragraph.indent = { left: cmToTwip(p.indent) };
if (style.outlineLevel) paragraph.keepNext = true;
return {
run: { bold: true, color: '000000', font: HEADING_FONT, size: Math.round(parseFloat(o.fontSize) * 2) },
paragraph: { spacing: { before: cmToTwip(parseFloat(o.marginTop)), after: cmToTwip(parseFloat(o.marginBottom)), line: 240, lineRule: LineRuleType.AUTO }, keepNext: true },
id: docxStyleId(style.name),
name: style.name,
basedOn: style.parent ? docxStyleId(style.parent) : undefined,
next: style.next ? docxStyleId(style.next) : undefined,
quickFormat: true,
run: Object.keys(run).length ? run : undefined,
paragraph: Object.keys(paragraph).length ? paragraph : undefined,
};
}
function buildStyles(language?: { language: string; country: string } | null) {
function buildStyles(sheet: StyleSheet, used: Set<string>, language?: { language: string; country: string } | null) {
const run: Writable<IRunStylePropertiesOptions> = { font: DOC_FONT, size: 24 };
if (language) run.language = { value: `${language.language}-${language.country}` };
return {
default: {
document: { run, paragraph: { spacing: { after: 0, line: 240, lineRule: LineRuleType.AUTO } } },
heading1: headingStyle(HEADING_STYLE_OVERRIDES[0]),
heading2: headingStyle(HEADING_STYLE_OVERRIDES[1]),
heading3: headingStyle(HEADING_STYLE_OVERRIDES[2]),
heading4: headingStyle(HEADING_STYLE_OVERRIDES[3]),
heading5: headingStyle(HEADING_STYLE_OVERRIDES[4]),
},
// The document's named styles, chain intact — Word shows them in its style list.
paragraphStyles: Object.values(sheet.paragraph).filter(st => used.has(st.name)).map(paragraphStyleOf),
};
}
@@ -856,6 +909,7 @@ export async function buildDocx(
hf?: HfExport,
language?: { language: string; country: string } | null,
pageFormat: PageFormat = 'A4',
styles: StyleSheet = builtinStyleSheet(),
): Promise<Uint8Array> {
docLangTag = localeTag(language ? language.language : 'en');
const num = new Numbering();
@@ -920,7 +974,7 @@ export async function buildDocx(
defaultTabStop: cmToTwip(1.25),
...(differentOddEven ? { evenAndOddHeaderAndFooters: true } : {}),
...(hasToc ? { features: { updateFields: true } } : {}),
styles: buildStyles(language),
styles: buildStyles(styles, usedStyleNames(docJson, styles), language),
numbering: { config: num.config },
// A columns group is its own section; w:type=continuous describes the break
// BEFORE a section, so it goes on every section but the first.
+177 -26
View File
@@ -2,7 +2,7 @@ import { unzipSync, strFromU8 } from 'fflate';
import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, wVal, W, R, WP, A, WPS, MC, VML, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles';
import { lengthToPt } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt';
import { builtinStyleSheet } from '../styles/styleSheet';
import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes';
@@ -29,6 +29,9 @@ type RelInfo = { target: string; external: boolean };
// flushes after the anchor paragraph (mirrors import/odt.ts).
type Ctx = {
styles: DocxStyles;
// Word styleId → registry name, and the ids blocks actually reference.
styleNames: Map<string, string>;
usedStyles: Set<string>;
warnings: Set<string>;
files: Record<string, Uint8Array>;
rels: Map<string, RelInfo>;
@@ -106,7 +109,10 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
const styles = new DocxStyles(stylesDoc, numberingDoc, themeDoc);
const warnings = new Set<string>();
const ctx: Ctx = { styles, warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map() };
const styleNames = new Map<string, string>();
const defaultStyleId = styles.defaultParagraphStyle();
for (const [id, def] of styles.namedParagraphStyles()) styleNames.set(id, registryName(id, def.name, id === defaultStyleId));
const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map() };
const body = docDoc.getElementsByTagNameNS(W, 'body')[0];
if (!body) throw new Error('Not a Word document (no w:body).');
@@ -158,9 +164,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
return {
content: { type: 'doc', content: blocks },
// DOCX styles still import as direct formatting (the ODF path leads); the document
// keeps the editor's built-in registry.
styles: builtinStyleSheet(),
styles: collectStyleSheet(ctx),
margins: sect.margins,
orientation: sect.orientation,
format: sect.format,
@@ -491,6 +495,146 @@ function splitParaAtPageBreaks(para: Node, kind: BlockKind): { blocks: Node[]; t
return { blocks, trailingBreak };
}
// What a block's named style already provides — the yardstick for "is this direct
// formatting?" (mirrors import/odt.ts blockDefaults).
type BlockDefaults = {
fontSizePt: number;
boldByDefault: boolean;
fonts: Set<string>;
color: string;
italic: boolean;
underline: boolean;
strike: boolean;
};
const FONT_TWINS: Record<string, string[]> = {
'times new roman': ['liberation serif'],
'liberation serif': ['times new roman'],
arial: ['liberation sans'],
'liberation sans': ['arial'],
};
// baseRun = docDefaults ← the paragraph style's basedOn chain, i.e. exactly what the
// style gives its runs.
function blockDefaults(baseRun: RunProps, headingLevel: number | null, boldByDefault: boolean): BlockDefaults {
const fonts = new Set(headingLevel != null ? DEFAULT_HEADING_FONTS : DEFAULT_FONTS);
const family = baseRun.font?.toLowerCase();
if (family) {
fonts.add(family);
for (const twin of FONT_TWINS[family] ?? []) fonts.add(twin);
}
return {
fontSizePt: baseRun.sizeHalfPt != null ? baseRun.sizeHalfPt / 2
: headingLevel != null ? HEADING_SIZES[headingLevel - 1] : BODY_FONT_SIZE_PT,
boldByDefault: baseRun.bold ?? (headingLevel != null || boldByDefault),
fonts,
color: hexColor(baseRun.color) ?? '#000000',
italic: !!baseRun.italic,
underline: !!baseRun.underline,
strike: !!baseRun.strike,
};
}
// The style a paragraph names, else Word's default paragraph style.
function styleIdOf(ppr: Element | null, ctx: Ctx): string | null {
const ps = fc(ppr, 'pStyle');
return (ps ? wVal(ps) : null) ?? ctx.styles.defaultParagraphStyle();
}
// Word's own names for the standard styles map onto the editor's registry names.
// `isDefault` marks the document's default paragraph style (Word calls it Normal,
// LibreOffice writes styleId "Standard" with the name "Normal").
function registryName(id: string, wordName: string, isDefault: boolean): string {
if (isDefault || id === 'Normal' || /^normal$/i.test(wordName)) return DEFAULT_STYLE;
const heading = /^Heading\s?([1-9])$/i.exec(id) ?? /^heading\s?([1-9])$/i.exec(wordName);
if (heading) return `Heading ${heading[1]}`;
if (/^Title$/i.test(id)) return 'Title';
if (/^Subtitle$/i.test(id)) return 'Subtitle';
if (/^Quote$/i.test(id) || /^Quotations?$/i.test(id)) return 'Quotations';
return wordName || id;
}
// What a style declares itself: its resolved props minus the parent's.
function ownProps<T extends object>(resolved: T, parent: T): T {
const out = {} as T;
for (const key of Object.keys(resolved) as (keyof T)[]) {
if (resolved[key] !== undefined && resolved[key] !== parent[key]) out[key] = resolved[key];
}
return out;
}
function stylePara(ctx: Ctx, id: string | null): ParaProps {
if (!id) return {};
const out: ParaProps = {};
const sp = ctx.styles.paragraphSpacing(id);
if (sp.before != null) out.spaceBefore = snapPt(twipToPt(sp.before));
if (sp.after != null) out.spaceAfter = snapPt(twipToPt(sp.after));
if (sp.line != null && (!sp.lineRule || sp.lineRule === 'auto')) {
const mult = round2(sp.line / 240);
if (Math.abs(mult - 1) > 0.01) out.lineHeight = String(mult);
}
const jc = ctx.styles.paragraphAlign(id);
if (jc === 'center') out.textAlign = 'center';
else if (jc === 'both' || jc === 'distribute') out.textAlign = 'justify';
else if (jc === 'right' || jc === 'end') out.textAlign = 'right';
else if (jc === 'left' || jc === 'start') out.textAlign = 'left';
const ind = ctx.styles.styleIndentTwip(id);
if (ind != null) out.indent = round2(twipToCm(ind));
return out;
}
function styleText(ctx: Ctx, id: string | null): TextProps {
if (!id) return {};
const run = ctx.styles.paragraphRun(id);
const out: TextProps = {};
// Our own export declares the metric twin; keep the registry on the on-screen name.
if (run.font) out.fontFamily = run.font === 'Times New Roman' ? 'Liberation Serif' : run.font;
if (run.sizeHalfPt != null) out.fontSizePt = Math.round((run.sizeHalfPt / 2) * 10) / 10;
if (run.bold != null) out.bold = run.bold;
if (run.italic != null) out.italic = run.italic;
if (run.underline != null) out.underline = run.underline;
if (run.strike != null) out.strike = run.strike;
const color = hexColor(run.color);
if (color) out.color = color;
return out;
}
// The document's style registry: the built-ins with the file's own definitions merged
// over them — only the styles blocks reference, plus their parent chains.
function collectStyleSheet(ctx: Ctx): StyleSheet {
const defs = ctx.styles.namedParagraphStyles();
const sheet = builtinStyleSheet();
const keep = new Set<string>();
for (const id of ctx.usedStyles) {
let cur: string | null = id;
const seen = new Set<string>();
while (cur && defs.has(cur) && !seen.has(cur)) {
seen.add(cur);
keep.add(cur);
cur = defs.get(cur)!.basedOn;
}
}
for (const id of keep) {
const def = defs.get(id)!;
const name = ctx.styleNames.get(id) ?? def.name;
const parentId = def.basedOn;
const parent = parentId ? ctx.styleNames.get(parentId) ?? parentId : null;
const builtin = sheet.paragraph[name];
const style: Style = {
name,
parent: parent && parent !== name ? parent : builtin?.parent ?? null,
next: builtin?.next ?? null,
builtin: builtin?.builtin,
para: ownProps(stylePara(ctx, id), stylePara(ctx, parentId)),
text: ownProps(styleText(ctx, id), styleText(ctx, parentId)),
};
const level = /^Heading (\d)$/.exec(name);
if (level) style.outlineLevel = Number(level[1]);
sheet.paragraph[name] = style;
}
return sheet;
}
function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault: boolean): Node {
const ppr = fc(el, 'pPr');
const pStyle = fc(ppr, 'pStyle');
@@ -499,13 +643,21 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault:
// paragraph style commonly carries justify), so style-level alignment isn't lost.
const directJc = fc(ppr, 'jc');
const jcVal = directJc ? wVal(directJc) : ctx.styles.paragraphAlign(pStyle ? wVal(pStyle) : null);
// Spacing: a paragraph style's own w:spacing (e.g. "No Spacing" after=0) is honored so
// it isn't lost to the editor default; direct w:pPr wins per attribute (in blockAttrs).
// Headings included — a heading style's spacing is the file's, not ours to override.
const styleSpacing = ctx.styles.paragraphSpacing(pStyle ? wVal(pStyle) : null);
const attrs = blockAttrs(ppr, kind, level, jcVal, styleSpacing);
const baseRun = ctx.styles.paragraphRun(pStyle ? wVal(pStyle) : null);
const content = convertInline(el, ctx, baseRun, level, false, boldByDefault);
// Spacing/alignment from the style now live in the style registry, so only DIRECT
// w:pPr counts as formatting on the block.
const attrs = blockAttrs(ppr, kind, level, directJc ? jcVal : null, {});
const styleId = styleIdOf(ppr, ctx);
const baseRun = ctx.styles.paragraphRun(styleId);
const defaults = blockDefaults(baseRun, level, boldByDefault);
const content = convertInline(el, ctx, baseRun, defaults, false);
if (styleId && kind === 'body') {
const name = ctx.styleNames.get(styleId);
if (name) {
ctx.usedStyles.add(styleId);
if (name !== (level ? `Heading ${level}` : DEFAULT_STYLE)) attrs.styleName = name;
}
}
// 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
@@ -631,7 +783,7 @@ function snapPt(v: number): number {
}
// ---- inline conversion (runs, marks, fields, images) -----------------------
function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: number | null, hfFields: boolean, boldByDefault: boolean): Node[] {
function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockDefaults, hfFields: boolean): Node[] {
const out: Node[] = [];
let fieldMode: 'none' | 'instr' | 'result' = 'none';
let fieldInstr = '';
@@ -657,9 +809,9 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: nu
// default) — Word's implicit default is the minor font for body text, the major one
// for headings.
if (!props.font) {
props.font = ctx.styles.themeFont(props.fontTheme ?? (headingLevel == null ? 'minor' : 'major'));
props.font = ctx.styles.themeFont(props.fontTheme ?? (defaults.boldByDefault ? 'major' : 'minor'));
}
const marks = marksFor(props, headingLevel, boldByDefault, !!linkHref);
const marks = marksFor(props, defaults, !!linkHref);
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
return marks;
};
@@ -809,20 +961,21 @@ function hardBreakNode(marks: Mark[]): Node {
return n;
}
function marksFor(props: RunProps, headingLevel: number | null, boldByDefault: boolean, inLink: boolean): Mark[] {
function marksFor(props: RunProps, defaults: BlockDefaults, inLink: boolean): Mark[] {
const marks: Mark[] = [];
const textStyle: Record<string, unknown> = {};
// Headings and header-row cells render bold via CSS: only a non-bold run needs a mark.
if (headingLevel != null || boldByDefault) {
if (defaults.boldByDefault) {
if (props.bold === false) textStyle.fontWeight = 'normal';
} else if (props.bold === true) {
marks.push({ type: 'bold' });
}
if (props.italic) marks.push({ type: 'italic' });
if (props.underline && !inLink) marks.push({ type: 'underline' }); // link underline is the CSS default
if (props.strike) marks.push({ type: 'strike' });
// Marks the block's named style already renders need no mark of their own.
if (props.italic && !defaults.italic) marks.push({ type: 'italic' });
if (props.underline && !inLink && !defaults.underline) marks.push({ type: 'underline' }); // link underline is the CSS default
if (props.strike && !defaults.strike) marks.push({ type: 'strike' });
if (props.vertAlign === 'superscript') marks.push({ type: 'superscript' });
else if (props.vertAlign === 'subscript') marks.push({ type: 'subscript' });
@@ -830,14 +983,12 @@ function marksFor(props: RunProps, headingLevel: number | null, boldByDefault: b
let color = hexColor(props.color);
if (inLink && color === LINK_BLUE) color = undefined; // strip the exporter's link visual
if (color && color !== '#000000') textStyle.color = color;
if (color && color !== defaults.color) textStyle.color = color;
const sizePt = props.sizeHalfPt != null ? props.sizeHalfPt / 2 : null;
const defSize = headingLevel != null ? HEADING_SIZES[headingLevel - 1] : BODY_FONT_SIZE_PT;
if (sizePt != null && Math.abs(sizePt - defSize) > 0.05) textStyle.fontSize = `${Math.round(sizePt * 10) / 10}pt`;
if (sizePt != null && Math.abs(sizePt - defaults.fontSizePt) > 0.05) textStyle.fontSize = `${Math.round(sizePt * 10) / 10}pt`;
const defaultFonts = headingLevel != null ? DEFAULT_HEADING_FONTS : DEFAULT_FONTS;
if (props.font && !defaultFonts.has(props.font.toLowerCase())) textStyle.fontFamily = props.font;
if (props.font && !defaults.fonts.has(props.font.toLowerCase())) textStyle.fontFamily = props.font;
if (Object.keys(textStyle).length) marks.push({ type: 'textStyle', attrs: textStyle });
return marks;
@@ -1286,7 +1437,7 @@ function convertHfPart(relId: string | null, ctx: Ctx): HfDoc {
}
boxMaps.push(readParaBox(ppr));
const baseRun = hfCtx.styles.paragraphRun(fc(ppr, 'pStyle') ? wVal(fc(ppr, 'pStyle')!) : null);
inline.push(...convertInline(p, hfCtx, baseRun, null, true, false).filter((n) => n.type !== PB_MARKER));
inline.push(...convertInline(p, hfCtx, baseRun, blockDefaults(baseRun, null, false), true).filter((n) => n.type !== PB_MARKER));
}
// An all-empty zone is dropped unless it carries a background/rule line (a footer that
// is just a colored line has no text). The zone collapses to one paragraph (mergeHfBox).
+32
View File
@@ -97,6 +97,8 @@ export class DocxStyles {
private ownOutline = new Map<string, number>(); // style's own w:outlineLvl (heading marker)
private ownAlign = new Map<string, string>(); // style's own w:pPr/w:jc
private ownSpacing = new Map<string, ParaSpacing>(); // style's own w:pPr/w:spacing
private ownIndentTwip = new Map<string, number>(); // style's own w:pPr/w:ind left
private paraStyleNames = new Map<string, string>(); // paragraph styleId → w:name
private defaultParaStyle: string | null = null; // the w:default="1" paragraph style
private defaultsAlign: string | null = null; // docDefaults w:pPrDefault/w:jc
private defaultsSpacing: ParaSpacing = {}; // docDefaults w:pPrDefault/w:spacing
@@ -156,9 +158,39 @@ export class DocxStyles {
if (jc) { const v = wVal(jc); if (v) this.ownAlign.set(id, v); }
const sp = ppr && firstChild(ppr, 'spacing');
if (sp) this.ownSpacing.set(id, readSpacing(sp));
const ind = ppr && firstChild(ppr, 'ind');
if (ind) {
const left = parseInt(ind.getAttributeNS(W, 'left') ?? ind.getAttributeNS(W, 'start') ?? '', 10);
if (Number.isFinite(left)) this.ownIndentTwip.set(id, left);
}
if (style.getAttributeNS(W, 'type') === 'paragraph') {
const nameEl = firstChild(style, 'name');
this.paraStyleNames.set(id, (nameEl && wVal(nameEl)) || id);
}
}
}
// Paragraph styles defined in the file: display name and parent, for the style registry.
namedParagraphStyles(): Map<string, { name: string; basedOn: string | null }> {
const out = new Map<string, { name: string; basedOn: string | null }>();
for (const [id, name] of this.paraStyleNames) out.set(id, { name, basedOn: this.basedOn.get(id) ?? null });
return out;
}
// The style's effective left indent (twips) along the basedOn chain.
styleIndentTwip(styleId: string | null | undefined, seen = new Set<string>()): number | null {
if (!styleId || seen.has(styleId)) return null;
seen.add(styleId);
const own = this.ownIndentTwip.get(styleId);
if (own != null) return own;
return this.styleIndentTwip(this.basedOn.get(styleId) ?? null, seen);
}
// The style Word applies when a paragraph names none.
defaultParagraphStyle(): string | null {
return this.defaultParaStyle;
}
private parseNumbering(doc: Document): void {
for (const abs of Array.from(doc.getElementsByTagNameNS(W, 'abstractNum'))) {
const id = abs.getAttributeNS(W, 'abstractNumId');
+61 -11
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import { zipSync, strToU8, unzipSync, strFromU8 } from 'fflate';
import { buildDocx } from '../src/lib/export/docx';
import { importDocx } from '../src/lib/import/docx';
import { builtinStyleSheet } from '../src/lib/styles/styleSheet';
import { HEADER_SHADE } from '../src/lib/editor/extensions/tableHeaderRow';
type N = { type: string; attrs?: any; content?: N[]; marks?: any[]; text?: string };
@@ -448,12 +449,13 @@ describe('DOCX import of a foreign Word document', () => {
expect(h.attrs.level).toBe(1);
const t = walk(h, 'text')[0];
expect(hasMark(t, 'bold')).toBe(false); // heading bold is presentational
expect(markAttrs(t, 'textStyle')?.fontSize).toBe('20pt'); // resolved from the style
// Arial is the heading default (LibreOffice's sans Heading style), so no font mark.
expect(markAttrs(t, 'textStyle')?.fontFamily).toBeUndefined();
// A heading style's own spacing is the file's, not ours to replace (480/120 twips).
expect(h.attrs.spaceBefore).toBe(24);
expect(h.attrs.spaceAfter).toBe(6);
// The style's own formatting stays in the style registry, not on the block.
expect(t.marks ?? []).toEqual([]);
expect(h.attrs.spaceBefore).toBeUndefined();
const style = result.styles.paragraph['Heading 1'];
expect(style.text.fontSizePt).toBe(20); // w:sz 40
expect(style.para.spaceBefore).toBe(24); // 480 twips
expect(style.para.spaceAfter).toBe(6); // 120 twips
});
it('maps Word symbol-font bullets and suppresses the default cycle', () => {
@@ -525,17 +527,21 @@ describe('DOCX import: alignment inherited from a paragraph style', () => {
<w:style w:type="paragraph" w:styleId="Heading1"><w:basedOn w:val="Standard"/><w:pPr><w:outlineLvl w:val="0"/></w:pPr></w:style>
</w:styles>`;
const bytes = zipSync({ 'word/document.xml': strToU8(documentXml), 'word/styles.xml': strToU8(stylesXml) });
const doc = importDocx(bytes).content as N;
const res = importDocx(bytes);
const doc = res.content as N;
it('inherits justify onto a body paragraph with no direct alignment', () => {
expect(doc.content![0].attrs.textAlign).toBe('justify');
it('keeps the default style\'s justify in the style, not on the block', () => {
expect(doc.content![0].attrs?.textAlign).toBeUndefined();
expect(res.styles.paragraph['Standard'].para.textAlign).toBe('justify');
});
it('lets a direct w:jc override the style', () => {
expect(doc.content![1].attrs.textAlign).toBe('center');
});
it('inherits justify onto a heading based on the default style', () => {
it('leaves a heading based on the default style free of direct alignment', () => {
expect(doc.content![2].type).toBe('heading');
expect(doc.content![2].attrs.textAlign).toBe('justify');
expect(doc.content![2].attrs.textAlign).toBeUndefined();
// It inherits justify through Heading 1 → Standard in the registry instead.
expect(res.styles.paragraph['Heading 1'].parent).toBe('Standard');
});
});
@@ -915,3 +921,47 @@ describe('DOCX import: a mid-body section break starts a new page', () => {
expect(two.attrs?.breakBefore).toBe('page');
});
});
describe('DOCX named paragraph styles', () => {
const sheet = builtinStyleSheet();
sheet.paragraph['Merksatz'] = {
name: 'Merksatz', parent: 'Quotations', next: 'Standard',
para: { spaceBefore: 8 }, text: { bold: true, color: '#0000AA' },
};
const fixture = {
type: 'doc',
content: [
heading(1, 'Kapitel'),
{ type: 'paragraph', attrs: { styleName: 'Merksatz' }, content: [text('gemerkt')] },
{ type: 'paragraph', attrs: { styleName: 'Merksatz', spaceAfter: 20 }, content: [text('mit Abstand')] },
{ type: 'paragraph', content: [text('normal')] },
],
} as any;
it('writes real Word styles and round-trips the assignment', async () => {
const bytes = await buildDocx(fixture, undefined, undefined, undefined, undefined, undefined, sheet);
const files = unzipSync(bytes);
const stylesXml = strFromU8(files['word/styles.xml']);
const documentXml = strFromU8(files['word/document.xml']);
const merk = stylesXml.match(/<w:style [^>]*w:styleId="Merksatz"[\s\S]*?<\/w:style>/)?.[0] ?? '';
expect(merk).toContain('<w:basedOn w:val="Quotations"/>');
expect(merk).toContain('<w:color w:val="0000AA"/>');
expect(documentXml).toContain('<w:pStyle w:val="Merksatz"/>');
expect(documentXml).toContain('<w:pStyle w:val="Heading1"/>');
const res = importDocx(bytes);
const blocks = (res.content as N).content!;
expect(blocks[1].attrs.styleName).toBe('Merksatz');
expect(blocks[1].content![0].marks ?? []).toEqual([]); // style formatting stays in the style
expect(blocks[2].attrs.spaceAfter).toBe(20); // hard formatting stays direct
expect(blocks[0].type).toBe('heading');
const style = res.styles.paragraph['Merksatz'];
expect(style.parent).toBe('Quotations');
expect(style.text.bold).toBe(true);
expect(style.text.color).toBe('#0000AA');
expect(style.para.spaceBefore).toBe(8);
});
});