1018 lines
48 KiB
TypeScript
1018 lines
48 KiB
TypeScript
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
// 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);
|
||
}
|