Files
edentext/src/lib/export/docx.ts
T

1018 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Document, Packer, Paragraph, TextRun, ImageRun, ExternalHyperlink, Tab,
TableOfContents,
Table, TableRow, TableCell, Header, Footer, PageNumber, SimpleField,
AlignmentType, LevelFormat, UnderlineType, BorderStyle, ShadingType,
WidthType, HeightRule, PageOrientation, LineRuleType, TableLayoutType, SectionType,
HorizontalPositionAlign, VerticalPositionRelativeFrom, HorizontalPositionRelativeFrom,
TextWrappingType, TextWrappingSide,
} from 'docx';
import type { TiptapNode } from 'odf-kit';
import type {
IRunStylePropertiesOptions, ISpacingProperties, IIndentAttributesProperties,
ILevelsOptions, IFloating, IBorderOptions, IParagraphStyleOptions, ICharacterStyleOptions,
} from 'docx';
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { HF_DISTANCE_CM, hfIsEmpty } from '../storage/headerFooter';
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, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, twinFontName, 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
// the document language (Word recomputes an auto field on open using its own locale).
let docLangTag = 'en-US';
// DOCX export. Mirrors export/odt.ts feature-for-feature, but builds OOXML via the
// `docx` library instead of odf-kit. Lazy-loaded from App.svelte so neither this
// module nor `docx` enters the initial bundle. Import is a later, separate step.
type Writable<T> = { -readonly [P in keyof T]: T[P] };
// Font shown on screen (bundled, metric-identical to Times New Roman) vs. the font
// declared in the file — same trade-off as odt.ts.
const SCREEN_FONT = 'Liberation Serif';
const DOC_FONT = 'Times New Roman';
// Effective bullet glyph of a list node: its bulletChar attr, else the default cycle.
function bulletCharOf(node: TiptapNode, depth0: number): string {
const ch = node.attrs?.bulletChar;
return typeof ch === 'string' && ch ? ch : defaultBulletChar(depth0);
}
// List geometry: 0.5in left step per level, 0.25in hanging for the marker (Word defaults).
const LIST_LEFT_STEP_CM = 1.27;
const LIST_HANGING_CM = 0.635;
// ODF num-format char → Word numbering format.
const ORDERED_FORMAT: Record<string, (typeof LevelFormat)[keyof typeof LevelFormat]> = {
'1': LevelFormat.DECIMAL,
a: LevelFormat.LOWER_LETTER,
A: LevelFormat.UPPER_LETTER,
i: LevelFormat.LOWER_ROMAN,
I: LevelFormat.UPPER_ROMAN,
};
// ---- unit conversions ------------------------------------------------------
const TWIPS_PER_CM = 1440 / 2.54; // 566.929
const cmToTwip = (cm: number) => Math.round(cm * TWIPS_PER_CM);
const ptToTwip = (pt: number) => Math.round(pt * 20);
const pxToTwip = (px: number) => Math.round((px * 1440) / 96); // px @96dpi → twips
const EMU_PER_PX = 9525;
const EMU_PER_PT = 12700;
const EMU_PER_CM = 360000;
// Sentinel wrapping a text box's index in a marker paragraph (same trick as
// export/odt.ts): the docx library can't emit DrawingML shapes, so buildDocx swaps
// the marker for a hand-built <w:drawing><wps:wsp> in a zip post-processing pass.
const TBX = '';
// CSS font-size string ("12pt", "16px", "1.2em") → Word half-points. em is relative
// to the 12pt body. Returns undefined when unparseable.
function fontSizeToHalfPoints(css: string): number | undefined {
const m = /^([\d.]+)\s*(pt|px|em|rem)?$/.exec(css.trim());
if (!m) return undefined;
const n = parseFloat(m[1]);
if (!Number.isFinite(n)) return undefined;
const unit = m[2] || 'pt';
const pt = unit === 'px' ? n * 0.75 : unit === 'em' || unit === 'rem' ? n * 12 : n;
return Math.max(1, Math.round(pt * 2));
}
// normalizeColor → Word's #-less uppercase hex; named colors pass through (rarely used).
function hexColor(input: string): string | undefined {
const n = normalizeColor(input);
if (!n) return undefined;
return n.startsWith('#') ? n.slice(1) : n;
}
// ---- numbering registry ----------------------------------------------------
// docx needs all numbering definitions up front (passed to the Document). We build
// the body paragraphs first, allocating one reference per top-level list and filling
// in each nesting level's format the first time we meet a list node at that depth.
class Numbering {
readonly config: { reference: string; levels: ILevelsOptions[] }[] = [];
private map = new Map<string, ILevelsOptions[]>();
private mlRefs = new Set<string>();
private counter = 0;
newReference(): string {
const reference = `num-${this.counter++}`;
const levels: ILevelsOptions[] = [];
this.map.set(reference, levels);
this.config.push({ reference, levels });
return reference;
}
ensureLevel(reference: string, depth: number, node: TiptapNode, extraIndentCm: number, cycle: OrderedCycle): void {
// A multilevel top list turns the whole reference into a "%1.%2." chain; its
// attr-less nested lists inherit it (the top registers first — pre-order walk).
if (depth === 0 && node.attrs?.listStyleType === 'multilevel') this.mlRefs.add(reference);
const levels = this.map.get(reference)!;
if (levels.some((l) => l.level === depth)) return;
const indent: IIndentAttributesProperties = {
left: cmToTwip((depth + 1) * LIST_LEFT_STEP_CM + extraIndentCm),
hanging: cmToTwip(LIST_HANGING_CM),
};
if (node.type === 'orderedList') {
const attr = node.attrs?.listStyleType as string | null | undefined;
const chained = this.mlRefs.has(reference) && (depth === 0 || !attr);
const def = effectiveOrderedDefAt(attr === 'multilevel' ? 'decimal' : attr, cycle);
levels.push({
level: depth,
format: chained ? LevelFormat.DECIMAL : ORDERED_FORMAT[def.numFormat] ?? LevelFormat.DECIMAL,
text: chained
? Array.from({ length: depth + 1 }, (_, i) => `%${i + 1}.`).join('')
: `%${depth + 1}${def.numSuffix}`,
alignment: AlignmentType.LEFT,
start: typeof node.attrs?.start === 'number' ? node.attrs.start : 1,
style: { paragraph: { indent } },
});
} else {
levels.push({
level: depth,
format: LevelFormat.BULLET,
// The Unicode char goes into w:lvlText literally; Word renders it in the
// paragraph font (no Wingdings/Symbol rFonts needed).
text: bulletCharOf(node, depth),
alignment: AlignmentType.LEFT,
style: { paragraph: { indent } },
});
}
}
}
// ---- inline runs -----------------------------------------------------------
function markPresent(marks: TiptapNode['marks'], type: string): boolean {
return !!marks?.some((m) => m.type === type);
}
// TipTap marks + textStyle attrs → Word run properties. forceBold bakes bold onto
// every run (header-row cells), respecting an explicit fontWeight:normal un-bold.
function runPropsFromMarks(marks: TiptapNode['marks'] = [], forceBold = false): Writable<IRunStylePropertiesOptions> {
const ts = marks.find((m) => m.type === 'textStyle');
const props: Writable<IRunStylePropertiesOptions> & { style?: string } = {};
// A named character style (w:rStyle); the run's own marks below still win.
const charStyle = marks.find((m) => m.type === 'charStyle')?.attrs?.name;
if (typeof charStyle === 'string' && charStyle) props.style = docxStyleId(charStyle);
let bold: boolean | undefined = markPresent(marks, 'bold') || undefined;
const fw = ts?.attrs?.fontWeight;
if (fw != null) {
const s = String(fw);
if (s === 'normal' || s === '400') bold = false;
else if (s === 'bold' || /^[5-9]\d\d$/.test(s)) bold = true;
}
if (forceBold && bold !== false) bold = true;
if (bold !== undefined) props.bold = bold;
if (markPresent(marks, 'italic')) props.italics = true;
if (markPresent(marks, 'underline')) props.underline = { type: UnderlineType.SINGLE };
if (markPresent(marks, 'strike')) props.strike = true;
if (markPresent(marks, 'superscript')) props.superScript = true;
else if (markPresent(marks, 'subscript')) props.subScript = true;
const ff = ts?.attrs?.fontFamily;
if (ff) props.font = String(ff) === SCREEN_FONT ? DOC_FONT : String(ff);
const fs = ts?.attrs?.fontSize;
if (fs) {
const hp = fontSizeToHalfPoints(String(fs));
if (hp) props.size = hp;
}
const col = ts?.attrs?.color;
if (col) {
const h = hexColor(String(col));
if (h) props.color = h;
}
const hl = marks.find((m) => m.type === 'highlight');
if (hl?.attrs?.color) {
const h = hexColor(String(hl.attrs.color));
if (h) props.shading = { type: ShadingType.CLEAR, fill: h };
}
// Visible hyperlink styling (the ExternalHyperlink wrapper carries the target).
if (markPresent(marks, 'link')) {
if (!props.color) props.color = '0563C1';
if (!props.underline) props.underline = { type: UnderlineType.SINGLE };
}
return props;
}
// A text node → one run; tab chars become <w:tab/> via mixed children (a literal \t
// would otherwise be dropped). Returned as an array so a link wrapper can adopt it.
function textNodeToRuns(node: TiptapNode, forceBold: boolean): TextRun[] {
const text = node.text ?? '';
const props = runPropsFromMarks(node.marks, forceBold);
if (!text.includes('\t')) return [new TextRun({ text, ...props })];
const parts = text.split('\t');
const children: (string | Tab)[] = [];
parts.forEach((p, i) => {
if (i > 0) children.push(new Tab());
if (p) children.push(p);
});
return [new TextRun({ children, ...props })];
}
type Inline = TextRun | ImageRun | ExternalHyperlink | SimpleField;
// A date/time field. A fixed field is plain text (Word has no fixed-date field); an
// auto field is a DATE/TIME field with the picture switch and a cached value.
function dateTimeRun(node: TiptapNode): Inline {
const a = node.attrs ?? {};
const kind = a.kind === 'time' ? 'time' : 'date';
const fmt = findFormat(String(a.format ?? ''))
?? findFormat(kind === 'time' ? DEFAULT_TIME_FORMAT : DEFAULT_DATE_FORMAT)!;
const parsed = a.fixed && typeof a.value === 'string' && a.value ? new Date(a.value) : new Date();
const when = isNaN(parsed.getTime()) ? new Date() : parsed;
const text = renderFormat(fmt, when, docLangTag);
// A fixed field is a plain run, so carry its font/size/etc.; SimpleField takes no
// run props, so an auto field inherits the paragraph font (Word recomputes it).
if (a.fixed) return new TextRun({ text, ...runPropsFromMarks(node.marks) });
return new SimpleField(`${kind === 'time' ? 'TIME' : 'DATE'} \\@ "${docxPicture(fmt)}"`, text);
}
function inlineToRuns(content: TiptapNode[] = [], forceBold = false): Inline[] {
const out: Inline[] = [];
for (const node of content) {
if (node.type === 'text' && node.text) {
const runs = textNodeToRuns(node, forceBold);
const href = node.marks?.find((m) => m.type === 'link')?.attrs?.href;
if (href) out.push(new ExternalHyperlink({ link: String(href), children: runs }));
else out.push(...runs);
} else if (node.type === 'hardBreak') {
// Carry the run's props so an empty line between two breaks keeps its font size.
out.push(new TextRun({ break: 1, ...runPropsFromMarks(node.marks) }));
} else if (node.type === 'image') {
const img = imageRun(node);
if (img) out.push(img);
} else if (node.type === 'pageNumber') {
out.push(new TextRun({ children: [PageNumber.CURRENT], ...runPropsFromMarks(node.marks) }));
} else if (node.type === 'pageCount') {
out.push(new TextRun({ children: [PageNumber.TOTAL_PAGES], ...runPropsFromMarks(node.marks) }));
} else if (node.type === 'dateTimeField') {
out.push(dateTimeRun(node));
}
}
return out;
}
// ---- images ----------------------------------------------------------------
const DOCX_IMG_TYPE: Record<string, 'png' | 'jpg' | 'gif' | 'bmp'> = {
'image/png': 'png', 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/gif': 'gif', 'image/bmp': 'bmp',
};
function decodeDataUri(src: string): { bytes: Uint8Array; type: 'png' | 'jpg' | 'gif' | 'bmp' } | null {
const m = /^data:([^;,]+)(;[^,]*)?,([\s\S]*)$/.exec(src);
if (!m) return null;
const type = DOCX_IMG_TYPE[m[1] || 'image/png'];
if (!type) return null; // svg / unsupported → skip (Word needs a raster fallback)
const isB64 = (m[2] ?? '').includes('base64');
try {
if (isB64) {
const bin = atob(m[3]);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes.length ? { bytes, type } : null;
}
const bytes = new TextEncoder().encode(decodeURIComponent(m[3]));
return bytes.length ? { bytes, type } : null;
} catch {
return null;
}
}
function floatingFor(wrap: string): IFloating | undefined {
if (wrap === 'inline') return undefined;
const verticalPosition = { relative: VerticalPositionRelativeFrom.PARAGRAPH, offset: 0 };
if (wrap === 'topBottom') {
return {
horizontalPosition: { relative: HorizontalPositionRelativeFrom.MARGIN, align: HorizontalPositionAlign.LEFT },
verticalPosition,
wrap: { type: TextWrappingType.TOP_AND_BOTTOM },
allowOverlap: false,
};
}
// left: image at left, text on the right; right: mirror.
const align = wrap === 'right' ? HorizontalPositionAlign.RIGHT : HorizontalPositionAlign.LEFT;
const side = wrap === 'right' ? TextWrappingSide.LEFT : TextWrappingSide.RIGHT;
return {
horizontalPosition: { relative: HorizontalPositionRelativeFrom.MARGIN, align },
verticalPosition,
wrap: { type: TextWrappingType.SQUARE, side },
allowOverlap: false,
};
}
function imageRun(node: TiptapNode): ImageRun | null {
const src = node.attrs?.src;
if (typeof src !== 'string' || !src.startsWith('data:')) return null;
const decoded = decodeDataUri(src);
if (!decoded) return null;
const width = typeof node.attrs?.width === 'number' && node.attrs.width > 0 ? Math.round(node.attrs.width) : 200;
const height = typeof node.attrs?.height === 'number' && node.attrs.height > 0 ? Math.round(node.attrs.height) : 150;
const rotation = typeof node.attrs?.rotation === 'number' ? node.attrs.rotation : 0;
const wrap = String(node.attrs?.wrap ?? 'inline');
return new ImageRun({
type: decoded.type,
data: decoded.bytes,
altText: typeof node.attrs?.alt === 'string' && node.attrs.alt ? { name: node.attrs.alt, title: node.attrs.alt, description: node.attrs.alt } : undefined,
transformation: { width, height, rotation: rotation || undefined },
floating: floatingFor(wrap),
});
}
// ---- text boxes / shapes ---------------------------------------------------
// A textBox node collected by blocksToDocx; applyTextBoxesDocx rewrites its marker
// paragraph into DrawingML (wp:inline/wp:anchor > wps:wsp > wps:txbx) after packing.
type TextBoxDocx = {
widthPx: number;
heightPx: number;
rotationDeg: number;
wrap: 'inline' | 'left' | 'right' | 'topBottom';
shapeKind: 'textbox' | 'roundRect' | 'ellipse';
fill: string | null;
stroke: string | null;
strokeWidthPt: number;
content: TiptapNode[];
};
function textBoxDocxDescriptor(node: TiptapNode): TextBoxDocx {
const a = node.attrs ?? {};
const wrapAttr = a.wrap;
const kind = a.shapeKind;
return {
widthPx: typeof a.width === 'number' && a.width > 0 ? Math.round(a.width) : 280,
heightPx: typeof a.height === 'number' && a.height > 0 ? Math.round(a.height) : 96,
rotationDeg: typeof a.rotation === 'number' ? a.rotation : 0,
wrap: wrapAttr === 'left' || wrapAttr === 'right' || wrapAttr === 'topBottom' ? wrapAttr : 'inline',
shapeKind: kind === 'roundRect' || kind === 'ellipse' ? kind : 'textbox',
fill: typeof a.fillColor === 'string' && a.fillColor ? a.fillColor : null,
stroke: typeof a.strokeColor === 'string' && a.strokeColor ? a.strokeColor : null,
strokeWidthPt: typeof a.strokeWidthPt === 'number' && a.strokeWidthPt > 0 ? a.strokeWidthPt : 1,
content: node.content ?? [],
};
}
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Run properties for the hand-serialized txbxContent, in CT_RPr schema order
// (rFonts, b, i, strike, color, sz, u, shd, vertAlign). Mirrors runPropsFromMarks.
function txbxRunPropsXml(marks: TiptapNode['marks'] = []): string {
const ts = marks.find((m) => m.type === 'textStyle');
const parts: string[] = [];
const ff = ts?.attrs?.fontFamily;
if (ff) {
const f = escapeXml(String(ff) === SCREEN_FONT ? DOC_FONT : String(ff));
parts.push(`<w:rFonts w:ascii="${f}" w:hAnsi="${f}" w:cs="${f}"/>`);
}
let bold = markPresent(marks, 'bold');
const fw = ts?.attrs?.fontWeight;
if (fw != null) {
const s = String(fw);
if (s === 'normal' || s === '400') bold = false;
else if (s === 'bold' || /^[5-9]\d\d$/.test(s)) bold = true;
}
if (bold) parts.push('<w:b/>');
if (markPresent(marks, 'italic')) parts.push('<w:i/>');
if (markPresent(marks, 'strike')) parts.push('<w:strike/>');
const col = ts?.attrs?.color;
if (col) {
const h = hexColor(String(col));
if (h) parts.push(`<w:color w:val="${h}"/>`);
}
const fs = ts?.attrs?.fontSize;
if (fs) {
const hp = fontSizeToHalfPoints(String(fs));
if (hp) parts.push(`<w:sz w:val="${hp}"/><w:szCs w:val="${hp}"/>`);
}
if (markPresent(marks, 'underline')) parts.push('<w:u w:val="single"/>');
const hl = marks.find((m) => m.type === 'highlight');
if (hl?.attrs?.color) {
const h = hexColor(String(hl.attrs.color));
if (h) parts.push(`<w:shd w:val="clear" w:fill="${h}"/>`);
}
if (markPresent(marks, 'superscript')) parts.push('<w:vertAlign w:val="superscript"/>');
else if (markPresent(marks, 'subscript')) parts.push('<w:vertAlign w:val="subscript"/>');
return parts.length ? `<w:rPr>${parts.join('')}</w:rPr>` : '';
}
// One paragraph/heading of a text box. markerText prefixes a literal list marker
// (in-box lists are flattened, like ODT cell lists once were); images are dropped
// (media/rels can't be minted from a string pass).
function txbxParagraphXml(node: TiptapNode, indentTwip = 0, markerText = ''): string {
const attrs = node.attrs ?? {};
const pPr: string[] = [];
if (node.type === 'heading') {
const lvl = Math.min(MAX_HEADING_LEVEL, Math.max(1, Number(attrs.level) || 1));
pPr.push(`<w:pStyle w:val="Heading${lvl}"/>`);
}
if (indentTwip) pPr.push(`<w:ind w:left="${indentTwip}"/>`);
const ta = attrs.textAlign;
const jc = ta === 'center' ? 'center' : ta === 'right' ? 'right' : ta === 'justify' ? 'both' : '';
if (jc) pPr.push(`<w:jc w:val="${jc}"/>`);
let runs = markerText ? `<w:r><w:t xml:space="preserve">${escapeXml(markerText)}</w:t></w:r>` : '';
for (const child of node.content ?? []) {
if (child.type === 'text' && child.text) {
const rPr = txbxRunPropsXml(child.marks);
let inner = '';
child.text.split('\t').forEach((seg, i) => {
if (i > 0) inner += '<w:tab/>';
if (seg) inner += `<w:t xml:space="preserve">${escapeXml(seg)}</w:t>`;
});
runs += `<w:r>${rPr}${inner}</w:r>`;
} else if (child.type === 'hardBreak') {
runs += `<w:r>${txbxRunPropsXml(child.marks)}<w:br/></w:r>`;
}
}
return `<w:p>${pPr.length ? `<w:pPr>${pPr.join('')}</w:pPr>` : ''}${runs}</w:p>`;
}
// In-box lists flatten to literal-marker paragraphs (real numbering would need
// numbering.xml references, which the post-pack string pass can't mint).
// `mlPrefix`: parent chain ("1.2.") when inside a multilevel list, else null.
function txbxListXml(node: TiptapNode, depth: number, mlPrefix: string | null = null, cycle: OrderedCycle = ROOT_ORDERED_CYCLE): string {
const ordered = node.type === 'orderedList';
const attr = node.attrs?.listStyleType as string | null | undefined;
const chained = ordered && (attr === 'multilevel' || (mlPrefix !== null && !attr));
const def = effectiveOrderedDefAt(attr === 'multilevel' ? 'decimal' : attr, cycle);
const cChild = childCycle(cycle, attr, ordered);
let n = typeof node.attrs?.start === 'number' ? node.attrs.start : 1;
let out = '';
for (const item of node.content ?? []) {
if (item.type !== 'listItem') continue;
let first = true;
for (const child of item.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') {
out += txbxListXml(child, depth + 1, chained ? `${mlPrefix ?? ''}${n}.` : null, cChild);
} else if (child.type === 'paragraph' || child.type === 'heading') {
const ordinal = chained ? `${mlPrefix ?? ''}${n}. ` : `${formatOrdinal(n, def.numFormat)}${def.numSuffix} `;
const marker = first ? (ordered ? ordinal : `${bulletCharOf(node, depth)} `) : '';
out += txbxParagraphXml(child, cmToTwip((depth + 1) * LIST_HANGING_CM), marker);
first = false;
}
}
n++;
}
return out;
}
function txbxContentXml(blocks: TiptapNode[]): string {
let out = '';
for (const b of blocks) {
if (b.type === 'paragraph' || b.type === 'heading') out += txbxParagraphXml(b);
else if (b.type === 'bulletList' || b.type === 'orderedList') out += txbxListXml(b, 0);
}
return out || '<w:p/>';
}
const PRST_BY_KIND: Record<TextBoxDocx['shapeKind'], string> = {
textbox: 'rect',
roundRect: 'roundRect',
ellipse: 'ellipse',
};
// The full <w:drawing> for one text box. Namespaces are declared inline on the
// wp/a/wps elements so the output never depends on what the library declares on
// the document root.
function textBoxDrawingXml(box: TextBoxDocx, index: number): string {
const cx = Math.round(box.widthPx * EMU_PER_PX);
const cy = Math.round(box.heightPx * EMU_PER_PX);
const rot = box.rotationDeg ? ` rot="${Math.round(box.rotationDeg * 60000)}"` : '';
const fill = box.fill
? `<a:solidFill><a:srgbClr val="${hexColor(box.fill) ?? 'FFFFFF'}"/></a:solidFill>`
: '<a:noFill/>';
const ln = box.stroke
? `<a:ln w="${Math.round(box.strokeWidthPt * EMU_PER_PT)}"><a:solidFill><a:srgbClr val="${hexColor(box.stroke) ?? '000000'}"/></a:solidFill></a:ln>`
: '<a:ln><a:noFill/></a:ln>';
const inset = Math.round(TEXTBOX_PADDING_CM * EMU_PER_CM);
// Auto-grow only for plain text boxes, matching the ODT export.
const autofit = box.shapeKind === 'textbox' ? '<a:spAutoFit/>' : '';
const wsp =
`<wps:wsp xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">` +
`<wps:cNvSpPr txBox="1"/>` +
`<wps:spPr><a:xfrm${rot}><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm>` +
`<a:prstGeom prst="${PRST_BY_KIND[box.shapeKind]}"><a:avLst/></a:prstGeom>${fill}${ln}</wps:spPr>` +
`<wps:txbx><w:txbxContent>${txbxContentXml(box.content)}</w:txbxContent></wps:txbx>` +
`<wps:bodyPr rot="0" vert="horz" wrap="square" lIns="${inset}" tIns="${inset}" rIns="${inset}" bIns="${inset}" anchor="t">${autofit}</wps:bodyPr>` +
`</wps:wsp>`;
const graphic =
`<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">` +
`<a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">${wsp}</a:graphicData></a:graphic>`;
const docPr = `<wp:docPr id="${9001 + index}" name="TextBox${index + 1}"/>`;
const extent = `<wp:extent cx="${cx}" cy="${cy}"/>`;
const wpNs = 'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"';
if (box.wrap === 'inline') {
return `<w:drawing><wp:inline ${wpNs} distT="0" distB="0" distL="0" distR="0">${extent}${docPr}${graphic}</wp:inline></w:drawing>`;
}
// wrapText names the side TEXT flows on (inverse of the box side), like ODT.
const wrapEl = box.wrap === 'topBottom'
? '<wp:wrapTopAndBottom/>'
: `<wp:wrapSquare wrapText="${box.wrap === 'right' ? 'left' : 'right'}"/>`;
const align = box.wrap === 'right' ? 'right' : 'left';
return (
`<w:drawing><wp:anchor ${wpNs} distT="0" distB="0" distL="114300" distR="114300"` +
` simplePos="0" relativeHeight="${251658240 + index}" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="0">` +
`<wp:simplePos x="0" y="0"/>` +
`<wp:positionH relativeFrom="margin"><wp:align>${align}</wp:align></wp:positionH>` +
`<wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV>` +
`${extent}${wrapEl}${docPr}${graphic}</wp:anchor></w:drawing>`
);
}
// Post-pack pass: swap each marker paragraph in word/document.xml for its drawing.
// The tempered pattern keeps the match inside one paragraph.
function applyTextBoxesDocx(bytes: Uint8Array, boxes: TextBoxDocx[]): Uint8Array {
if (!boxes.length) return bytes;
const files = unzipSync(bytes);
const docBytes = files['word/document.xml'];
if (!docBytes) return bytes;
let xml = strFromU8(docBytes);
xml = xml.replace(
new RegExp(`<w:p\\b[^>]*?>(?:(?!</w:p>)[\\s\\S])*?${TBX}(\\d+)${TBX}(?:(?!</w:p>)[\\s\\S])*?</w:p>`, 'g'),
(_m, idx: string) => {
const box = boxes[Number(idx)];
return box ? `<w:p><w:r>${textBoxDrawingXml(box, Number(idx))}</w:r></w:p>` : '';
},
);
files['word/document.xml'] = strToU8(xml);
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }];
return zipSync(out);
}
// ---- paragraphs ------------------------------------------------------------
function alignOf(attrs: TiptapNode['attrs']): (typeof AlignmentType)[keyof typeof AlignmentType] | undefined {
switch (attrs?.textAlign) {
case 'center': return AlignmentType.CENTER;
case 'right': return AlignmentType.RIGHT;
case 'justify': return AlignmentType.BOTH;
case 'left': return AlignmentType.LEFT;
default: return undefined;
}
}
function spacingOf(attrs: TiptapNode['attrs']): ISpacingProperties | undefined {
const s: Writable<ISpacingProperties> = {};
if (typeof attrs?.spaceBefore === 'number') s.before = ptToTwip(attrs.spaceBefore);
if (typeof attrs?.spaceAfter === 'number') s.after = ptToTwip(attrs.spaceAfter);
const lh = attrs?.lineHeight;
if (lh != null) {
const n = parseFloat(String(lh));
if (Number.isFinite(n) && !/pt|px/.test(String(lh))) {
s.line = Math.round(n * 240);
s.lineRule = LineRuleType.AUTO;
} else if (Number.isFinite(n)) {
s.line = ptToTwip(/px/.test(String(lh)) ? n * 0.75 : n);
s.lineRule = LineRuleType.EXACT;
}
}
return Object.keys(s).length ? s : undefined;
}
type ParaOpts = { numbering?: { reference: string; level: number }; indentLeftTwip?: number; forceBold?: boolean };
// Paragraph background ("colored field") → w:shd; per-side borders ("rule line") → w:pBdr.
function paraShadingOf(attrs: TiptapNode['attrs']) {
const c = typeof attrs?.backgroundColor === 'string' ? hexColor(attrs.backgroundColor) : undefined;
return c ? { type: ShadingType.CLEAR, fill: c, color: 'auto' } : undefined;
}
function paraBordersOf(attrs: TiptapNode['attrs']) {
const out: { top?: IBorderOptions; right?: IBorderOptions; bottom?: IBorderOptions; left?: IBorderOptions } = {};
for (const [attr, side] of [['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left']] as const) {
const b = parseBorderAttr(attrs?.[attr] as string | null);
if (b && b !== 'none') out[side] = { style: BorderStyle.SINGLE, size: Math.max(2, Math.round(b.widthPt * 8)), color: hexColor(b.color) ?? '000000', space: 1 };
}
return Object.keys(out).length ? out : undefined;
}
function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph {
const attrs = node.attrs ?? {};
const indent: Writable<IIndentAttributesProperties> = {};
if (!opts.numbering) {
if (typeof attrs.indent === 'number' && attrs.indent > 0) indent.left = cmToTwip(attrs.indent);
else if (opts.indentLeftTwip) indent.left = opts.indentLeftTwip;
}
// 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({
style,
alignment: alignOf(attrs),
spacing: spacingOf(attrs),
indent: indent.left != null ? indent : undefined,
pageBreakBefore: attrs.breakBefore === 'page' || undefined,
numbering: opts.numbering,
shading: paraShadingOf(attrs),
border: paraBordersOf(attrs),
run: markSize ? { size: markSize } : undefined,
children: inlineToRuns(node.content, opts.forceBold),
});
}
// ---- lists -----------------------------------------------------------------
function listToParagraphs(
node: TiptapNode, depth: number, reference: string, extraIndentCm: number,
num: Numbering, out: (Paragraph | Table | TableOfContents)[], cycle: OrderedCycle = ROOT_ORDERED_CYCLE,
): void {
const indentCm = extraIndentCm + (typeof node.attrs?.indent === 'number' ? node.attrs.indent : 0);
num.ensureLevel(reference, depth, node, indentCm, cycle);
const levelLeftTwip = cmToTwip((depth + 1) * LIST_LEFT_STEP_CM + indentCm);
const cChild = childCycle(cycle, node.attrs?.listStyleType as string | null | undefined, node.type === 'orderedList');
for (const item of node.content ?? []) {
if (item.type !== 'listItem') continue;
let numberedFirst = false;
for (const child of item.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') {
listToParagraphs(child, depth + 1, reference, indentCm, num, out, cChild);
} else if (child.type === 'paragraph' || child.type === 'heading') {
if (!numberedFirst) {
out.push(paragraphToDocx(child, { numbering: { reference, level: depth } }));
numberedFirst = true;
} else {
out.push(paragraphToDocx(child, { indentLeftTwip: levelLeftTwip }));
}
}
}
}
}
// ---- tables ----------------------------------------------------------------
// Per-column widths (cm) from the first row's proportional colwidth weights, scaled
// to fill the text column exactly. Mirrors odt.ts tableColumnWidthsCm.
function columnWidthsCm(node: TiptapNode, contentWidthCm: number): number[] | undefined {
const firstRow = (node.content ?? []).find((r) => r.type === 'tableRow');
if (!firstRow) return undefined;
const weights: (number | null)[] = [];
for (const cell of firstRow.content ?? []) {
if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue;
const colspan = (cell.attrs?.colspan as number) ?? 1;
const cw = cell.attrs?.colwidth as number[] | null | undefined;
for (let k = 0; k < colspan; k++) weights.push(cw && cw[k] ? cw[k] : null);
}
if (weights.length === 0) return undefined;
const present = weights.filter((w): w is number => w != null);
const avg = present.length ? present.reduce((a, b) => a + b, 0) / present.length : 1;
const filled = weights.map((w) => (w != null ? w : avg));
const total = filled.reduce((a, b) => a + b, 0) || 1;
const round3 = (v: number) => Math.round(v * 1000) / 1000;
const cm = filled.map((w) => round3((w / total) * contentWidthCm));
const sum = cm.reduce((a, b) => a + b, 0);
cm[cm.length - 1] = round3(cm[cm.length - 1] + (contentWidthCm - sum));
return cm;
}
const cellBorder: IBorderOptions = { style: BorderStyle.SINGLE, size: 4, color: '000000' }; // 0.5pt
// A cell's per-side border attr → w:tcBorders options (size in eighth-points).
function docxCellBorder(attrs: Record<string, unknown> | undefined, side: BorderSide): IBorderOptions {
const b = parseBorderAttr(attrs?.[side] as string | null);
if (b === 'none') return { style: BorderStyle.NONE, size: 0, color: 'auto' };
if (b) return { style: BorderStyle.SINGLE, size: Math.max(2, Math.round(b.widthPt * 8)), color: hexColor(b.color) ?? '000000' };
return cellBorder;
}
function cellBlocksToDocx(content: TiptapNode[] = [], headerBold: boolean, num: Numbering, contentWidthCm: number): (Paragraph | Table)[] {
const out: (Paragraph | Table)[] = [];
for (const child of content) {
if (child.type === 'bulletList' || child.type === 'orderedList') {
listToParagraphs(child, 0, num.newReference(), 0, num, out);
} else if (child.type === 'table') {
out.push(tableToDocx(child, contentWidthCm, num));
} else if (child.type === 'paragraph' || child.type === 'heading') {
out.push(paragraphToDocx(child, { forceBold: headerBold }));
}
}
if (out.length === 0) out.push(new Paragraph({}));
return out;
}
function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering): Table {
const rows = (node.content ?? []).filter((r) => r.type === 'tableRow');
const colsCm = columnWidthsCm(node, contentWidthCm);
const colsTwip = colsCm?.map(cmToTwip);
const totalTwip = colsTwip ? colsTwip.reduce((a, b) => a + b, 0) : cmToTwip(contentWidthCm);
const tableRows = rows.map((row) => {
const rh = row.attrs?.rowHeight;
let col = 0;
const cells: TableCell[] = [];
for (const cell of row.content ?? []) {
if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue;
const colspan = (cell.attrs?.colspan as number) ?? 1;
const rowspan = (cell.attrs?.rowspan as number) ?? 1;
const bg = cell.attrs?.backgroundColor;
const fill = typeof bg === 'string' ? hexColor(bg) : undefined;
const widthTwip = colsTwip ? colsTwip.slice(col, col + colspan).reduce((a, b) => a + b, 0) : undefined;
col += colspan;
cells.push(new TableCell({
columnSpan: colspan > 1 ? colspan : undefined,
rowSpan: rowspan > 1 ? rowspan : undefined,
shading: fill ? { type: ShadingType.CLEAR, fill } : undefined,
width: widthTwip ? { size: widthTwip, type: WidthType.DXA } : undefined,
borders: {
top: docxCellBorder(cell.attrs, 'borderTop'),
bottom: docxCellBorder(cell.attrs, 'borderBottom'),
left: docxCellBorder(cell.attrs, 'borderLeft'),
right: docxCellBorder(cell.attrs, 'borderRight'),
},
children: cellBlocksToDocx(cell.content, bg === HEADER_SHADE, num, contentWidthCm),
}));
}
return new TableRow({
height: typeof rh === 'number' && rh > 0 ? { value: pxToTwip(rh), rule: HeightRule.ATLEAST } : undefined,
children: cells,
});
});
return new Table({
rows: tableRows,
columnWidths: colsTwip,
width: { size: totalTwip, type: WidthType.DXA },
layout: TableLayoutType.FIXED,
margins: { marginUnitType: WidthType.DXA, top: cmToTwip(0.05), bottom: cmToTwip(0.05), left: cmToTwip(0.1), right: cmToTwip(0.1) },
borders: {
top: cellBorder, bottom: cellBorder, left: cellBorder, right: cellBorder,
insideHorizontal: cellBorder, insideVertical: cellBorder,
},
});
}
// ---- top-level walk --------------------------------------------------------
function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: number, textBoxes: TextBoxDocx[]): (Paragraph | Table | TableOfContents)[] {
const out: (Paragraph | Table | TableOfContents)[] = [];
for (const node of content) {
if (node.type === 'paragraph' || node.type === 'heading') {
out.push(paragraphToDocx(node));
} else if (node.type === 'bulletList' || node.type === 'orderedList') {
listToParagraphs(node, 0, num.newReference(), 0, num, out);
} else if (node.type === 'table') {
out.push(tableToDocx(node, contentWidthCm, num));
} else if (node.type === 'image') {
out.push(new Paragraph({ children: inlineToRuns([node]) }));
} else if (node.type === 'textBox') {
// Marker paragraph; applyTextBoxesDocx swaps it for the DrawingML shape.
const i = textBoxes.length;
textBoxes.push(textBoxDocxDescriptor(node));
out.push(new Paragraph({ children: [new TextRun({ text: `${TBX}${i}${TBX}` })] }));
} else if (node.type === 'tableOfContents') {
// A real, recognized TOC field (levels 1–3, hyperlinked); Word/LibreOffice populate
// + link it on field update (features.updateFields does this on open). Title is a
// plain bold paragraph so it isn't itself listed; our importer regenerates the node.
out.push(new Paragraph({ children: [new TextRun({ text: 'Table of Contents', bold: true, size: 32 })], spacing: { after: cmToTwip(0.3) } }));
out.push(new TableOfContents('Table of Contents', { hyperlink: true, headingStyleRange: `1-${MAX_HEADING_LEVEL}` }));
}
}
return out;
}
// One body section: a run of ordinary blocks (columns: null) or one columns node's
// content. DOCX has no block-level column container — a mid-document multi-column
// region is its own section, delimited by continuous section breaks.
type BodyGroup = {
columns: { count: number; gapCm: number } | null;
children: (Paragraph | Table | TableOfContents)[];
};
function bodyGroups(content: TiptapNode[], num: Numbering, contentWidthCm: number, textBoxes: TextBoxDocx[]): BodyGroup[] {
const groups: BodyGroup[] = [];
let plain: TiptapNode[] = [];
// Adjacent equal-attr fragments (a columnsFlow page-split chain) = one section;
// their blocks are collected raw so page-split paragraphs merge before serializing.
let cols: { count: number; gapCm: number; blocks: TiptapNode[] } | null = null;
const flushPlain = () => {
if (!plain.length) return;
groups.push({ columns: null, children: blocksToDocx(plain, num, contentWidthCm, textBoxes) });
plain = [];
};
const flushCols = () => {
if (!cols) return;
groups.push({
columns: { count: cols.count, gapCm: cols.gapCm },
children: blocksToDocx(mergeJoinedParagraphsJson(cols.blocks), num, contentWidthCm, textBoxes),
});
cols = null;
};
for (const node of content) {
if (node.type === 'columns') {
const a = node.attrs ?? {};
const count = typeof a.count === 'number' ? Math.min(3, Math.max(2, Math.round(a.count))) : 2;
const gapCm = typeof a.gapCm === 'number' && Number.isFinite(a.gapCm) ? Math.min(5, Math.max(0, a.gapCm)) : 0.5;
if (cols && cols.count === count && cols.gapCm === gapCm) {
cols.blocks.push(...(node.content ?? []));
} else {
flushCols();
flushPlain();
cols = { count, gapCm, blocks: [...(node.content ?? [])] };
}
} else {
flushCols();
plain.push(node);
}
}
flushCols();
flushPlain();
if (!groups.length) groups.push({ columns: null, children: [] });
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 -------------------------------------------------------
// 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> = {};
// The registry holds the on-screen family; the file declares its metric twin.
if (t.fontFamily) run.font = twinFontName(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 {
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,
};
}
// One registry character style → a Word character style (w:type="character").
function characterStyleOf(style: Style): ICharacterStyleOptions {
const t = style.text;
const run: Writable<IRunStylePropertiesOptions> = {};
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('#', '');
return { id: docxStyleId(style.name), name: style.name, quickFormat: true, run };
}
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 } } },
},
// 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),
characterStyles: Object.values(sheet.character ?? {}).map(characterStyleOf),
};
}
// ---- entry point -----------------------------------------------------------
export async function buildDocx(
docJson: TiptapNode,
margins: PageMargins = DEFAULT_MARGINS,
orientation: Orientation = 'portrait',
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();
const landscape = orientation === 'landscape';
const { w: pageWidthCm, h: pageHeightCm } = pageDimsCm(pageFormat, orientation);
const contentWidthCm = pageWidthCm - margins.left - margins.right;
const textBoxes: TextBoxDocx[] = [];
const groups = bodyGroups(docJson.content ?? [], num, contentWidthCm, textBoxes);
// A TOC field is empty until its field is calculated; ask the reader to update fields
// on open so Word/LibreOffice populate + hyperlink it (standard for TOC fields).
const hasToc = (docJson.content ?? []).some(n => n.type === 'tableOfContents');
const headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
const footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
// Different first page (Word w:titlePg): page 1 gets its own header/footer.
const differentFirstPage = !!hf?.differentFirstPage;
const firstHeaderPara = differentFirstPage && !hfIsEmpty(hf?.headerFirst ?? null) ? (hf!.headerFirst!.content![0] as TiptapNode) : null;
const firstFooterPara = differentFirstPage && !hfIsEmpty(hf?.footerFirst ?? null) ? (hf!.footerFirst!.content![0] as TiptapNode) : null;
// Different odd & even pages (Word w:evenAndOddHeaders): even pages get their own zone.
const differentOddEven = !!hf?.differentOddEven;
const evenHeaderPara = differentOddEven && !hfIsEmpty(hf?.headerEven ?? null) ? (hf!.headerEven!.content![0] as TiptapNode) : null;
const evenFooterPara = differentOddEven && !hfIsEmpty(hf?.footerEven ?? null) ? (hf!.footerEven!.content![0] as TiptapNode) : null;
// Word's model: header/footer distance is from the page edge; the body still starts
// at the body margin. Clamp the distance below the margin (matches odt.ts).
const headerDist = Math.min(hf?.headerDistanceCm ?? HF_DISTANCE_CM, margins.top);
const footerDist = Math.min(hf?.footerDistanceCm ?? HF_DISTANCE_CM, margins.bottom);
// Identical page geometry on every sectPr (Word requires it per section). Fresh
// Header/Footer instances per section so each sectPr — including the body-final
// one our importer reads — carries its own references (Word's "Link to Previous").
const pageProps = {
size: { width: cmToTwip(pageWidthCm), height: cmToTwip(pageHeightCm), orientation: landscape ? PageOrientation.LANDSCAPE : PageOrientation.PORTRAIT },
margin: {
top: cmToTwip(margins.top), bottom: cmToTwip(margins.bottom),
left: cmToTwip(margins.left), right: cmToTwip(margins.right),
header: cmToTwip(headerDist), footer: cmToTwip(footerDist),
},
};
// Fresh instances per section (Word's per-sectPr references). A first-page variant
// rides `first:` and is activated by properties.titlePage below.
const mkHeaders = () => {
if (!headerPara && !firstHeaderPara && !evenHeaderPara) return undefined;
const h: { default?: Header; first?: Header; even?: Header } = {};
if (headerPara) h.default = new Header({ children: [paragraphToDocx(headerPara)] });
if (firstHeaderPara) h.first = new Header({ children: [paragraphToDocx(firstHeaderPara)] });
if (evenHeaderPara) h.even = new Header({ children: [paragraphToDocx(evenHeaderPara)] });
return h;
};
const mkFooters = () => {
if (!footerPara && !firstFooterPara && !evenFooterPara) return undefined;
const f: { default?: Footer; first?: Footer; even?: Footer } = {};
if (footerPara) f.default = new Footer({ children: [paragraphToDocx(footerPara)] });
if (firstFooterPara) f.first = new Footer({ children: [paragraphToDocx(firstFooterPara)] });
if (evenFooterPara) f.even = new Footer({ children: [paragraphToDocx(evenFooterPara)] });
return f;
};
const doc = new Document({
creator: 'Web ODF Editor',
defaultTabStop: cmToTwip(1.25),
...(differentOddEven ? { evenAndOddHeaderAndFooters: true } : {}),
...(hasToc ? { features: { updateFields: true } } : {}),
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.
sections: groups.map((g, i) => ({
properties: {
page: pageProps,
...(differentFirstPage ? { titlePage: true } : {}),
...(i > 0 ? { type: SectionType.CONTINUOUS } : {}),
...(g.columns
? { column: { count: g.columns.count, space: cmToTwip(g.columns.gapCm), equalWidth: true } }
: {}),
},
headers: mkHeaders(),
footers: mkFooters(),
children: g.children.length ? g.children : [new Paragraph({})],
})),
});
const blob = await Packer.toBlob(doc);
return applyTextBoxesDocx(new Uint8Array(await blob.arrayBuffer()), textBoxes);
}