diff --git a/src/App.svelte b/src/App.svelte index 378a557..2731023 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -550,7 +550,7 @@ try { const json = editor.getJSON() as Parameters[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; diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 27c46e4..a07bb06 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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 = { - 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 { + const used = new Set(); + 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 = {}; + // 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 = {}; + if (p.spaceBefore != null) spacing.before = ptToTwip(p.spaceBefore); + if (p.spaceAfter != null) spacing.after = ptToTwip(p.spaceAfter); + const paragraph: Record = {}; + 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, language?: { language: string; country: string } | null) { const run: Writable = { 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 { 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. diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 2e30af1..ae653a4 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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; + usedStyles: Set; warnings: Set; files: Record; rels: Map; @@ -106,7 +109,10 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = const styles = new DocxStyles(stylesDoc, numberingDoc, themeDoc); const warnings = new Set(); - 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(); + 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; + color: string; + italic: boolean; + underline: boolean; + strike: boolean; +}; + +const FONT_TWINS: Record = { + '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(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(); + for (const id of ctx.usedStyles) { + let cur: string | null = id; + const seen = new Set(); + 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 = {}; // 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). diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index d1baa3f..a8d675e 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -97,6 +97,8 @@ export class DocxStyles { private ownOutline = new Map(); // style's own w:outlineLvl (heading marker) private ownAlign = new Map(); // style's own w:pPr/w:jc private ownSpacing = new Map(); // style's own w:pPr/w:spacing + private ownIndentTwip = new Map(); // style's own w:pPr/w:ind left + private paraStyleNames = new Map(); // 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 { + const out = new Map(); + 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()): 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'); diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts index fb9a52a..765ca07 100644 --- a/tests/docx-roundtrip.test.ts +++ b/tests/docx-roundtrip.test.ts @@ -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', () => { `; 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:styleId="Merksatz"[\s\S]*?<\/w:style>/)?.[0] ?? ''; + expect(merk).toContain(''); + expect(merk).toContain(''); + expect(documentXml).toContain(''); + expect(documentXml).toContain(''); + + 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); + }); +});