feat: docx import/export of named paragraph styles
This commit is contained in:
1 parent
d79287c10a
commit
ebb76a5104
5 files changed
+344
-57
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user