feat: docx import

This commit is contained in:
Steffen Becker committed 2026-06-30 19:38:20 +02:00
1 parent 9700487353
commit a2c0e78de8
6 files changed
+1073 -6

No files matched your search

+2 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## [0.1.0] — 2026-05-25 · MVP
## [0.1.0] · MVP
### Stack
- Vite + Svelte 5 (runes mode) + TypeScript
@@ -60,6 +60,7 @@
**File & export**
- ODT export via odf-kit with extensive content.xml / styles.xml post-processing (custom node types, tables with borders, images, fonts and colors); filename derived from the first heading (fallback `document.odt`)
- ODT import / open existing file: parses content.xml / styles.xml directly, adopts the file's margins and orientation, and reports graceful-degradation warnings
- Word (.docx) export and import — round-trips the editor's formatting (text, fonts, lists, tables, images, headers/footers, page geometry) and opens real Word documents
- PDF export — Raster (pixel-exact copy of the editor with a selectable text layer) and Vector (crisp, fonts embedded, via the browser print dialog)
- Print (printer button / Ctrl+P) — opens the browser print dialog with a pixel-exact raster of the document (tables, headers/footers and page breaks intact)
+5 -3
View File
@@ -10,6 +10,7 @@
import { exportPdf, printPdf, printRaster } from './lib/export/pdf';
import { supportsFsAccess, saveOdt, saveAsOdt, saveAsDocx, openOdt } from './lib/export/saveFile';
import { importOdt } from './lib/import/odt';
import { importDocx } from './lib/import/docx';
import { getPageBreakDebug } from './lib/editor/extensions/pageBreaks';
import { getColorDebug } from './lib/utils/colorDebug';
import { countText, type TextStats } from './lib/utils/wordCount';
@@ -302,7 +303,8 @@
function applyImport(bytes: Uint8Array, handle: FileSystemFileHandle | null, sourceName?: string) {
if (!editor) return;
try {
const result = importOdt(bytes);
const isDocx = sourceName?.toLowerCase().endsWith('.docx');
const result = isDocx ? importDocx(bytes) : importOdt(bytes);
const hasContent = editor.state.doc.textContent.length > 0 || editor.state.doc.childCount > 1;
if (hasContent && !confirm('Opening this file will replace the current document. Continue?')) {
@@ -311,7 +313,7 @@
editor.commands.setContent(result.content); // onUpdate fires → autosave
// Adopt the opened file's name as the document name (drives the save filename).
if (sourceName) documentName = stripOdtExtension(sourceName);
if (sourceName) documentName = stripOdtExtension(sourceName).replace(/\.docx$/i, '');
// Adopt the document's page geometry; the $effects persist it and
// Editor.svelte re-paginates.
if (result.margins) pageMargins = result.margins;
@@ -709,7 +711,7 @@
<input
bind:this={fileInput}
type="file"
accept=".odt,application/vnd.oasis.opendocument.text"
accept=".odt,.docx,application/vnd.oasis.opendocument.text,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
class="file-input"
onchange={handleImportFile}
/>
+2 -2
View File
@@ -81,10 +81,10 @@ export async function saveAsDocx(bytes: Uint8Array, suggestedName: string): Prom
await writeHandle(handle, bytes);
}
// Prompt for an .odt to open, capturing its handle so a later save can overwrite
// Prompt for an .odt/.docx to open, capturing its handle so a later save can overwrite
// the same file. Returns null if cancelled. Only call when supportsFsAccess().
export async function openOdt(): Promise<{ bytes: Uint8Array; handle: FileSystemFileHandle; name: string } | null> {
const [handle] = await (window as WinFs).showOpenFilePicker!({ types: PICKER_TYPES, multiple: false });
const [handle] = await (window as WinFs).showOpenFilePicker!({ types: [...PICKER_TYPES, ...DOCX_PICKER_TYPES], multiple: false });
if (!handle) return null;
const file = await handle.getFile();
return { bytes: new Uint8Array(await file.arrayBuffer()), handle, name: file.name };
+616
View File
@@ -0,0 +1,616 @@
import { unzipSync, strFromU8 } from 'fflate';
import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, wVal, W, R, WP, A, PKG_REL, type RunProps } from './docxStyles';
import { lengthToPt } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { DEFAULT_ORDERED_TYPE, orderedTypeFromFormat } from '../utils/orderedListTypes';
import { PX_PER_CM, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
import type { HfDoc } from '../storage/headerFooter';
import type { OdtImportResult } from './odt';
// .docx → TipTap JSON, inverting export/docx.ts. Editor-expressible OOXML becomes its
// native node/mark/attr; values matching the editor's defaults are suppressed so round
// trips don't accrete attrs. Real Word/LibreOffice files degrade gracefully (reported).
type Mark = { type: string; attrs?: Record<string, unknown> };
type Node = { type: string; attrs?: Record<string, unknown>; content?: Node[]; marks?: Mark[]; text?: string };
type BlockKind = 'body' | 'list' | 'cell';
type RelInfo = { target: string; external: boolean };
type Ctx = {
styles: DocxStyles;
warnings: Set<string>;
files: Record<string, Uint8Array>;
rels: Map<string, RelInfo>;
imageCache: Map<string, string>;
};
// ---- units & editor defaults to suppress -----------------------------------
const twipToCm = (tw: number) => (tw / 1440) * 2.54;
const twipToPt = (tw: number) => tw / 20;
const twipToPx = (tw: number) => (tw / 1440) * 96;
const emuToPx = (emu: number) => emu / 9525;
const round2 = (v: number) => Math.round(v * 100) / 100;
const BODY_FONT_SIZE_PT = 12;
const HEADING_SIZES = HEADING_STYLE_OVERRIDES.map((h) => lengthToPt(h.fontSize)!); // [20,16,14]
const DEFAULT_FONTS = new Set(['times new roman', 'liberation serif']);
const LIST_LEFT_STEP_CM = 1.27; // matches export/docx.ts
const LIST_INDENT_EPS_CM = 0.05;
const LINK_BLUE = '#0563C1'; // the visual the exporter paints on hyperlink runs
const MIME_BY_EXT: Record<string, string> = {
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', bmp: 'image/bmp', svg: 'image/svg+xml', webp: 'image/webp',
};
// ---- small DOM helpers ------------------------------------------------------
function fc(el: Element | null, localName: string): Element | null {
if (!el) return null;
for (const c of Array.from(el.children)) if (c.namespaceURI === W && c.localName === localName) return c;
return null;
}
function fcAll(el: Element, localName: string): Element[] {
return Array.from(el.children).filter((c) => c.namespaceURI === W && c.localName === localName);
}
function intAttr(el: Element | null, ns: string, name: string): number | null {
if (!el) return null;
const v = ns ? el.getAttributeNS(ns, name) : el.getAttribute(name);
if (v == null) return null;
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : null;
}
function parseXml(xml: string): Document {
const doc = new DOMParser().parseFromString(xml, 'application/xml');
if (doc.getElementsByTagName('parsererror').length) throw new Error('Not a valid .docx file (malformed XML).');
return doc;
}
// '#RRGGBB' from a Word color (6-hex without #, or named). null for auto/empty.
function hexColor(v: string | null | undefined): string | undefined {
if (!v || v === 'auto') return undefined;
return /^[0-9a-fA-F]{6}$/.test(v) ? normalizeColor(`#${v}`) : normalizeColor(v);
}
function bytesToBase64(bytes: Uint8Array): string {
let bin = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) bin += String.fromCharCode(...bytes.subarray(i, i + chunk));
return btoa(bin);
}
// ---- entry ------------------------------------------------------------------
export function importDocx(bytes: Uint8Array): OdtImportResult {
let files: Record<string, Uint8Array>;
try {
files = unzipSync(bytes);
} catch {
throw new Error('Not a valid .docx file (could not read the archive).');
}
const docBytes = files['word/document.xml'];
if (!docBytes) throw new Error('Not a valid .docx file (word/document.xml is missing).');
const docDoc = parseXml(strFromU8(docBytes));
const stylesDoc = files['word/styles.xml'] ? parseXml(strFromU8(files['word/styles.xml'])) : null;
const numberingDoc = files['word/numbering.xml'] ? parseXml(strFromU8(files['word/numbering.xml'])) : null;
const themeDoc = files['word/theme/theme1.xml'] ? parseXml(strFromU8(files['word/theme/theme1.xml'])) : null;
const styles = new DocxStyles(stylesDoc, numberingDoc, themeDoc);
const warnings = new Set<string>();
const ctx: Ctx = { styles, warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map() };
const body = docDoc.getElementsByTagNameNS(W, 'body')[0];
if (!body) throw new Error('Not a Word document (no w:body).');
const blocks = convertBlocks(Array.from(body.children), ctx, 'body');
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
const sect = parseSectPr(fc(body, 'sectPr'), ctx);
return {
content: { type: 'doc', content: blocks },
margins: sect.margins,
orientation: sect.orientation,
header: sect.header,
footer: sect.footer,
headerDistanceCm: sect.header ? sect.headerDistCm : null,
footerDistanceCm: sect.footer ? sect.footerDistCm : null,
language: documentLanguage(stylesDoc, warnings),
warnings: [...warnings],
};
}
function parseRels(bytes: Uint8Array | undefined): Map<string, RelInfo> {
const map = new Map<string, RelInfo>();
if (!bytes) return map;
let doc: Document;
try { doc = parseXml(strFromU8(bytes)); } catch { return map; }
for (const rel of Array.from(doc.getElementsByTagNameNS(PKG_REL, 'Relationship'))) {
const id = rel.getAttribute('Id');
const target = rel.getAttribute('Target');
if (id && target) map.set(id, { target, external: rel.getAttribute('TargetMode') === 'External' });
}
return map;
}
function documentLanguage(stylesDoc: Document | null, warnings: Set<string>): DocumentLanguage | null {
const lang = stylesDoc?.getElementsByTagNameNS(W, 'docDefaults')[0]
?.getElementsByTagNameNS(W, 'lang')[0]?.getAttributeNS(W, 'val');
if (!lang) return null;
const [language, country] = lang.split('-');
const code = languageFromOdf(language, country);
if (code) return code;
warnings.add(`Spell-check language "${lang}" has no bundled dictionary — spell check was turned off`);
return NO_LANGUAGE;
}
// ---- block conversion (paragraphs, lists, tables) --------------------------
function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDefault = false): Node[] {
const out: Node[] = [];
const stack: { ilvl: number; numId: number; list: Node }[] = [];
const closeTop = () => {
const top = stack.pop()!;
if (stack.length) {
const parent = stack[stack.length - 1].list;
let item = parent.content![parent.content!.length - 1];
if (!item) { item = { type: 'listItem', content: [{ type: 'paragraph' }] }; parent.content!.push(item); }
item.content!.push(top.list);
} else {
out.push(top.list);
}
};
const flush = () => { while (stack.length) closeTop(); };
for (const el of children) {
if (el.namespaceURI !== W) continue;
if (el.localName === 'p') {
const num = paragraphNum(el, ctx);
if (num) {
const para = convertParagraph(el, ctx, 'list', boldByDefault);
while (stack.length && stack[stack.length - 1].ilvl > num.ilvl) closeTop();
let top = stack[stack.length - 1];
if (top && top.ilvl === num.ilvl && top.numId !== num.numId) { closeTop(); top = stack[stack.length - 1]; }
while (stack.length === 0 || stack[stack.length - 1].ilvl < num.ilvl) {
const ilvl = stack.length ? stack[stack.length - 1].ilvl + 1 : 0;
stack.push({ ilvl, numId: num.numId, list: makeListNode(ctx, num.numId, ilvl) });
if (ilvl === num.ilvl) break;
}
stack[stack.length - 1].list.content!.push({ type: 'listItem', content: [para] });
} else {
flush();
out.push(convertParagraph(el, ctx, kind, boldByDefault));
}
} else if (el.localName === 'tbl') {
flush();
if (kind === 'body') {
const t = convertTable(el, ctx);
if (t) out.push(t);
} else {
ctx.warnings.add('Nested tables were flattened to paragraphs');
out.push(...flattenTable(el, ctx));
}
} else if (el.localName === 'sdt') {
const content = fc(el, 'sdtContent');
if (content) { flush(); out.push(...convertBlocks(Array.from(content.children), ctx, kind, boldByDefault)); }
}
}
flush();
return out;
}
function paragraphNum(el: Element, ctx: Ctx): { numId: number; ilvl: number } | null {
const ppr = fc(el, 'pPr');
let np: { numId: number; ilvl: number } | null = null;
const numPr = fc(ppr, 'numPr');
if (numPr) np = readNumPr(numPr);
if (!np) { const ps = fc(ppr, 'pStyle'); np = ctx.styles.styleNumPr(ps ? wVal(ps) : null); }
return np && np.numId !== 0 ? np : null; // numId 0 = "no list"
}
function makeListNode(ctx: Ctx, numId: number, ilvl: number): Node {
const def = ctx.styles.level(numId, ilvl);
const bullet = !def.numFmt || def.numFmt === 'bullet' || def.numFmt === 'none';
const attrs: Record<string, unknown> = {};
if (!bullet) {
const lst = orderedTypeFromFormat(wordFmtChar(def.numFmt), lvlSuffix(def.lvlText));
if (lst !== DEFAULT_ORDERED_TYPE) attrs.listStyleType = lst;
if (def.start != null && def.start > 1) attrs.start = def.start;
}
if (ilvl === 0 && def.leftTwip != null) {
const extra = round2(twipToCm(def.leftTwip) - LIST_LEFT_STEP_CM);
if (extra > LIST_INDENT_EPS_CM) attrs.indent = extra;
}
const node: Node = { type: bullet ? 'bulletList' : 'orderedList', content: [] };
if (Object.keys(attrs).length) node.attrs = attrs;
return node;
}
function wordFmtChar(fmt: string | undefined): string {
switch (fmt) {
case 'lowerLetter': return 'a';
case 'upperLetter': return 'A';
case 'lowerRoman': return 'i';
case 'upperRoman': return 'I';
default: return '1';
}
}
function lvlSuffix(lvlText: string | undefined): string {
if (!lvlText) return '.';
const trail = lvlText.replace(/^.*%\d+/, '');
return trail.charAt(0) === ')' ? ')' : '.';
}
function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault: boolean): Node {
const ppr = fc(el, 'pPr');
const pStyle = fc(ppr, 'pStyle');
const level = headingLevelOf(ppr, ctx);
const attrs = blockAttrs(ppr, kind, level);
const baseRun = mergeRunProps(ctx.styles.defaultRun(), ctx.styles.styleOwn(pStyle ? wVal(pStyle) : null));
const content = convertInline(el, ctx, baseRun, level, false, boldByDefault);
const node: Node = { type: level ? 'heading' : 'paragraph' };
if (level) attrs.level = level;
if (Object.keys(attrs).length) node.attrs = attrs;
if (content.length) node.content = content;
return node;
}
// Heading + clamped level. Detect via the paragraph style id (fast path for our own
// files / English Word), then the style's resolved outline level (locale-independent,
// catches LibreOffice/localized heading styles), then a direct paragraph outline level.
function headingLevelOf(ppr: Element | null, ctx: Ctx): number | null {
const clamp = (n: number) => Math.min(3, Math.max(1, n));
if (!ppr) return null;
const ps = fc(ppr, 'pStyle');
const id = ps ? wVal(ps) : null;
if (id) {
const m = /^Heading\s?([1-9])$/i.exec(id);
if (m) return clamp(parseInt(m[1], 10));
const ol = ctx.styles.styleOutlineLvl(id);
if (ol != null && ol >= 0 && ol <= 8) return clamp(ol + 1);
}
const olEl = fc(ppr, 'outlineLvl');
const n = olEl ? parseInt(wVal(olEl) ?? '', 10) : NaN;
if (Number.isFinite(n) && n >= 0 && n <= 8) return clamp(n + 1);
return null;
}
// Block attrs from DIRECT w:pPr only — Word stores per-paragraph formatting inline
// (style-level defaults stay in the style and are left as the editor's defaults).
function blockAttrs(ppr: Element | null, kind: BlockKind, headingLevel: number | null): Record<string, unknown> {
const attrs: Record<string, unknown> = {};
if (!ppr) return attrs;
const jc = fc(ppr, 'jc');
const ta = jc ? wVal(jc) : null;
if (ta === 'center') attrs.textAlign = 'center';
else if (ta === 'both' || ta === 'distribute') attrs.textAlign = 'justify';
else if (ta === 'right' || ta === 'end') attrs.textAlign = 'right';
const sp = fc(ppr, 'spacing');
if (sp) {
const before = intAttr(sp, W, 'before');
const after = intAttr(sp, W, 'after');
if (before != null) attrs.spaceBefore = snapPt(twipToPt(before));
if (after != null) attrs.spaceAfter = snapPt(twipToPt(after));
const line = intAttr(sp, W, 'line');
const rule = sp.getAttributeNS(W, 'lineRule');
if (line != null && (!rule || rule === 'auto')) {
const mult = round2(line / 240);
if (Math.abs(mult - 1) > 0.01) attrs.lineHeight = String(mult);
}
}
if (kind !== 'list') {
const ind = fc(ppr, 'ind');
const left = ind ? intAttr(ind, W, 'left') ?? intAttr(ind, W, 'start') : null;
if (left != null) { const cm = round2(twipToCm(left)); if (cm > LIST_INDENT_EPS_CM) attrs.indent = cm; }
}
if (kind === 'body') {
const pb = fc(ppr, 'pageBreakBefore');
if (pb && wVal(pb) !== 'false' && wVal(pb) !== '0') attrs.breakBefore = 'page';
}
return attrs;
}
function snapPt(v: number): number {
const r = Math.round(v * 100) / 100;
const i = Math.round(r);
return Math.abs(r - i) <= 0.03 ? i : r;
}
// ---- inline conversion (runs, marks, fields, images) -----------------------
function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: number | null, hfFields: boolean, boldByDefault: boolean): Node[] {
const out: Node[] = [];
let fieldMode: 'none' | 'instr' | 'result' = 'none';
let fieldInstr = '';
const pushText = (text: string, marks: Mark[]) => {
if (!text) return;
const node: Node = { type: 'text', text };
if (marks.length) node.marks = marks;
out.push(node);
};
// A run may hold a whole field (begin/instrText/separate/end) or just part of one,
// so walk its children in order and keep the field state across runs.
const handleRun = (r: Element, linkHref?: string) => {
const rPr = fc(r, 'rPr');
const rStyle = fc(rPr, 'rStyle');
const props = mergeRunProps(mergeRunProps(baseRun, ctx.styles.styleOwn(rStyle ? wVal(rStyle) : null)), parseRunProps(rPr));
if (!props.font && props.fontTheme) props.font = ctx.styles.themeFont(props.fontTheme);
const marks = marksFor(props, headingLevel, boldByDefault, !!linkHref);
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
const skipResult = () => fieldMode === 'result' && hfFields; // hide a hf field's cached value
for (const child of Array.from(r.children)) {
if (child.namespaceURI !== W) continue;
switch (child.localName) {
case 'fldChar': {
const t = child.getAttributeNS(W, 'fldCharType');
if (t === 'begin') { fieldMode = 'instr'; fieldInstr = ''; }
else if (t === 'separate') fieldMode = 'result';
else if (t === 'end') { emitField(out, fieldInstr, hfFields); fieldMode = 'none'; }
break;
}
case 'instrText': if (fieldMode === 'instr') fieldInstr += child.textContent ?? ''; break;
case 't': if (!skipResult()) pushText(child.textContent ?? '', marks); break;
case 'tab': if (!skipResult()) pushText('\t', marks); break;
case 'br': case 'cr': out.push({ type: 'hardBreak' }); break;
case 'drawing': { const img = convertDrawing(child, ctx); if (img) out.push(img); break; }
}
}
};
for (const el of Array.from(p.children)) {
if (el.namespaceURI !== W) continue;
switch (el.localName) {
case 'r': handleRun(el); break;
case 'hyperlink': {
const rid = el.getAttributeNS(R, 'id');
const href = rid ? ctx.rels.get(rid)?.target : undefined;
for (const r of fcAll(el, 'r')) handleRun(r, href);
break;
}
case 'fldSimple': {
const instr = el.getAttributeNS(W, 'instr') ?? '';
if (hfFields) emitField(out, instr, true);
else for (const r of fcAll(el, 'r')) handleRun(r); // body: keep the shown value
break;
}
case 'ins': // accepted tracked-change insertion → keep its runs
for (const r of fcAll(el, 'r')) handleRun(r);
break;
case 'smartTag':
for (const r of fcAll(el, 'r')) handleRun(r);
break;
}
}
return mergeAdjacentText(out);
}
function emitField(out: Node[], instr: string, hfFields: boolean): void {
if (!hfFields) return;
if (/\bNUMPAGES\b/.test(instr)) out.push({ type: 'pageCount' });
else if (/\bPAGE\b/.test(instr)) out.push({ type: 'pageNumber' });
}
function mergeAdjacentText(nodes: Node[]): Node[] {
const out: Node[] = [];
for (const n of nodes) {
const prev = out[out.length - 1];
if (prev && n.type === 'text' && prev.type === 'text' && JSON.stringify(prev.marks ?? []) === JSON.stringify(n.marks ?? [])) {
prev.text! += n.text!;
} else {
out.push(n);
}
}
return out;
}
function marksFor(props: RunProps, headingLevel: number | null, boldByDefault: boolean, inLink: boolean): Mark[] {
const marks: Mark[] = [];
const textStyle: Record<string, unknown> = {};
// Headings and header-row cells render bold via CSS: only a non-bold run needs a mark.
if (headingLevel != null || boldByDefault) {
if (props.bold === false) textStyle.fontWeight = 'normal';
} else if (props.bold === true) {
marks.push({ type: 'bold' });
}
if (props.italic) marks.push({ type: 'italic' });
if (props.underline && !inLink) marks.push({ type: 'underline' }); // link underline is the CSS default
if (props.strike) marks.push({ type: 'strike' });
if (props.vertAlign === 'superscript') marks.push({ type: 'superscript' });
else if (props.vertAlign === 'subscript') marks.push({ type: 'subscript' });
if (props.highlightFill) { const c = hexColor(props.highlightFill); if (c) marks.push({ type: 'highlight', attrs: { color: c } }); }
let color = hexColor(props.color);
if (inLink && color === LINK_BLUE) color = undefined; // strip the exporter's link visual
if (color && color !== '#000000') textStyle.color = color;
const sizePt = props.sizeHalfPt != null ? props.sizeHalfPt / 2 : null;
const defSize = headingLevel != null ? HEADING_SIZES[headingLevel - 1] : BODY_FONT_SIZE_PT;
if (sizePt != null && Math.abs(sizePt - defSize) > 0.05) textStyle.fontSize = `${Math.round(sizePt * 10) / 10}pt`;
if (props.font && !DEFAULT_FONTS.has(props.font.toLowerCase())) textStyle.fontFamily = props.font;
if (Object.keys(textStyle).length) marks.push({ type: 'textStyle', attrs: textStyle });
return marks;
}
// ---- images -----------------------------------------------------------------
function loadImageDataUrl(path: string, ctx: Ctx): string | null {
const cached = ctx.imageCache.get(path);
if (cached) return cached;
const bytes = ctx.files[path];
if (!bytes) return null;
const ext = path.split('.').pop()?.toLowerCase() ?? '';
const url = `data:${MIME_BY_EXT[ext] ?? 'image/png'};base64,${bytesToBase64(bytes)}`;
ctx.imageCache.set(path, url);
return url;
}
function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
const anchor = drawing.getElementsByTagNameNS(WP, 'anchor')[0];
const root = drawing.getElementsByTagNameNS(WP, 'inline')[0] ?? anchor;
if (!root) return null;
const blip = drawing.getElementsByTagNameNS(A, 'blip')[0];
const embed = blip?.getAttributeNS(R, 'embed');
const rel = embed ? ctx.rels.get(embed) : undefined;
if (!rel || rel.external) { ctx.warnings.add('Some images could not be read and were skipped'); return null; }
const src = loadImageDataUrl(`word/${rel.target.replace(/^\/+/, '')}`, ctx);
if (!src) { ctx.warnings.add('Some images could not be read and were skipped'); return null; }
const attrs: Record<string, unknown> = { src };
const extent = root.getElementsByTagNameNS(WP, 'extent')[0];
const cx = intAttr(extent, '', 'cx');
const cy = intAttr(extent, '', 'cy');
if (cx) attrs.width = Math.round(emuToPx(cx));
if (cy) attrs.height = Math.round(emuToPx(cy));
const docPr = root.getElementsByTagNameNS(WP, 'docPr')[0];
const alt = docPr?.getAttribute('title') || docPr?.getAttribute('descr');
if (alt) attrs.alt = alt;
const xfrm = drawing.getElementsByTagNameNS(A, 'xfrm')[0];
const rot = xfrm ? parseInt(xfrm.getAttribute('rot') ?? '', 10) : NaN;
if (Number.isFinite(rot) && rot) attrs.rotation = ((Math.round(rot / 60000) % 360) + 360) % 360;
if (anchor) attrs.wrap = anchorWrap(anchor);
return { type: 'image', attrs };
}
function anchorWrap(anchor: Element): 'left' | 'right' | 'topBottom' {
if (anchor.getElementsByTagNameNS(WP, 'wrapTopAndBottom')[0]) return 'topBottom';
const sq = anchor.getElementsByTagNameNS(WP, 'wrapSquare')[0];
const wt = sq?.getAttribute('wrapText');
if (wt === 'right') return 'left'; // text on right ⇒ image on left
if (wt === 'left') return 'right';
const align = anchor.getElementsByTagNameNS(WP, 'align')[0]?.textContent;
return align === 'right' ? 'right' : 'left';
}
// ---- tables -----------------------------------------------------------------
function convertTable(tbl: Element, ctx: Ctx): Node | null {
const grid = fc(tbl, 'tblGrid');
const weights = grid ? fcAll(grid, 'gridCol').map((g) => Math.max(1, intAttr(g, W, 'w') ?? 1)) : null;
const useWeights = weights && weights.length ? weights : null;
const rows: Node[] = [];
const pending: (Node | null)[] = []; // origin cell per grid column, for vMerge spans
for (const tr of fcAll(tbl, 'tr')) {
const cells: Node[] = [];
let col = 0;
for (const tc of fcAll(tr, 'tc')) {
const tcPr = fc(tc, 'tcPr');
const colspan = intAttr(fc(tcPr, 'gridSpan'), W, 'val') ?? 1;
const vMergeEl = fc(tcPr, 'vMerge');
const vMerge = vMergeEl ? wVal(vMergeEl) ?? 'continue' : null;
if (vMerge && vMerge !== 'restart') {
const origin = pending[col];
if (origin) origin.attrs!.rowspan = ((origin.attrs!.rowspan as number) || 1) + 1;
col += colspan;
continue; // covered cell — dropped, span folded into its origin
}
const fill = fc(tcPr, 'shd')?.getAttributeNS(W, 'fill');
const bg = fill ? hexColor(fill) : undefined;
const blocks = convertBlocks(Array.from(tc.children), ctx, 'cell', bg === HEADER_SHADE);
const attrs: Record<string, unknown> = { colspan, rowspan: 1 };
if (bg) attrs.backgroundColor = bg;
if (useWeights) attrs.colwidth = useWeights.slice(col, col + colspan);
const cell: Node = { type: 'tableCell', attrs, content: blocks.length ? blocks : [{ type: 'paragraph' }] };
cells.push(cell);
for (let c = col; c < col + colspan; c++) pending[c] = vMerge === 'restart' ? cell : null;
col += colspan;
}
if (cells.length === 0) continue;
const row: Node = { type: 'tableRow', content: cells };
const h = intAttr(fc(fc(tr, 'trPr'), 'trHeight'), W, 'val');
if (h && h > 0) row.attrs = { rowHeight: Math.round(twipToPx(h)) };
rows.push(row);
}
return rows.length ? { type: 'table', content: rows } : null;
}
function flattenTable(tbl: Element, ctx: Ctx): Node[] {
const out: Node[] = [];
for (const tr of fcAll(tbl, 'tr')) for (const tc of fcAll(tr, 'tc')) out.push(...convertBlocks(Array.from(tc.children), ctx, 'cell'));
return out;
}
// ---- section: page geometry + headers/footers ------------------------------
function parseSectPr(sect: Element | null, ctx: Ctx): {
margins: PageMargins | null; orientation: Orientation | null;
header: HfDoc; footer: HfDoc; headerDistCm: number | null; footerDistCm: number | null;
} {
const empty = { margins: null, orientation: null, header: null, footer: null, headerDistCm: null, footerDistCm: null };
if (!sect) return empty;
const pgSz = fc(sect, 'pgSz');
const w = intAttr(pgSz, W, 'w');
const h = intAttr(pgSz, W, 'h');
const orientation: Orientation = pgSz?.getAttributeNS(W, 'orient') === 'landscape' || (w && h && w > h) ? 'landscape' : 'portrait';
const pgMar = fc(sect, 'pgMar');
const clampCm = (tw: number | null) => (tw == null ? null : Math.min(10, Math.max(0, round2(twipToCm(tw)))));
const margins: PageMargins | null = pgMar
? { top: clampCm(intAttr(pgMar, W, 'top')) ?? 2.54, bottom: clampCm(intAttr(pgMar, W, 'bottom')) ?? 2.54, left: clampCm(intAttr(pgMar, W, 'left')) ?? 2.12, right: clampCm(intAttr(pgMar, W, 'right')) ?? 2.12 }
: null;
const refId = (type: string) => {
for (const ref of fcAll(sect, `${type}Reference`)) {
const t = ref.getAttributeNS(W, 'type') ?? 'default';
if (t === 'first' || t === 'even') ctx.warnings.add('Per-page header/footer variants (first/even pages) are not supported — the default one was used');
}
const def = fcAll(sect, `${type}Reference`).find((r) => (r.getAttributeNS(W, 'type') ?? 'default') === 'default');
return def?.getAttributeNS(R, 'id') ?? null;
};
const header = convertHfPart(refId('header'), ctx);
const footer = convertHfPart(refId('footer'), ctx);
return {
margins, orientation, header, footer,
headerDistCm: clampCm(intAttr(pgMar, W, 'header')),
footerDistCm: clampCm(intAttr(pgMar, W, 'footer')),
};
}
function convertHfPart(relId: string | null, ctx: Ctx): HfDoc {
if (!relId) return null;
const target = ctx.rels.get(relId)?.target;
if (!target) return null;
const path = `word/${target.replace(/^\/+/, '')}`;
const bytes = ctx.files[path];
if (!bytes) return null;
let doc: Document;
try { doc = parseXml(strFromU8(bytes)); } catch { return null; }
const root = doc.getElementsByTagNameNS(W, 'hdr')[0] ?? doc.getElementsByTagNameNS(W, 'ftr')[0];
if (!root) return null;
const relsPath = path.replace(/^word\/(.*)$/, 'word/_rels/$1.rels');
const hfCtx: Ctx = { ...ctx, rels: parseRels(ctx.files[relsPath]) };
const inline: Node[] = [];
let textAlign: string | null = null;
for (const p of fcAll(root, 'p')) {
if (inline.length) inline.push({ type: 'hardBreak' });
const ppr = fc(p, 'pPr');
if (textAlign === null) {
const ta = (fc(ppr, 'jc') ? wVal(fc(ppr, 'jc')!) : null) ?? '';
textAlign = ta === 'center' || ta === 'both' ? (ta === 'both' ? 'justify' : 'center') : ta === 'right' || ta === 'end' ? 'right' : '';
}
const baseRun = mergeRunProps(hfCtx.styles.defaultRun(), hfCtx.styles.styleOwn(fc(ppr, 'pStyle') ? wVal(fc(ppr, 'pStyle')!) : null));
inline.push(...convertInline(p, hfCtx, baseRun, null, true, false));
}
while (inline[0]?.type === 'hardBreak') inline.shift();
while (inline[inline.length - 1]?.type === 'hardBreak') inline.pop();
if (inline.length === 0) return null;
const para: Node = { type: 'paragraph', content: inline };
if (textAlign) para.attrs = { textAlign };
return { type: 'doc', content: [para] };
}
+211
View File
@@ -0,0 +1,211 @@
// Resolves OOXML style indirection for the DOCX importer: Word/LibreOffice spread run
// formatting across w:docDefaults and named styles linked by w:basedOn, and store list
// numbering in numbering.xml. This flattens both so import/docx.ts reads effective values.
export const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
export const R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
export const WP = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing';
export const A = 'http://schemas.openxmlformats.org/drawingml/2006/main';
export const PKG_REL = 'http://schemas.openxmlformats.org/package/2006/relationships';
// A run's resolved character properties; only set keys are present so layers merge cleanly.
export type RunProps = {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
vertAlign?: 'superscript' | 'subscript';
color?: string; // raw w:val hex (no #) or 'auto'
sizeHalfPt?: number; // w:sz (half-points)
font?: string; // explicit w:rFonts w:ascii/hAnsi
fontTheme?: 'minor' | 'major'; // w:rFonts w:asciiTheme/hAnsiTheme → theme1.xml font
highlightFill?: string; // run-level w:shd w:fill (text highlight)
};
// A numbering level definition (numbering.xml w:lvl).
export type LevelDef = { numFmt?: string; lvlText?: string; leftTwip?: number; start?: number };
export function wVal(el: Element): string | null {
return el.getAttributeNS(W, 'val');
}
// A toggle property (w:b, w:i, …): present with no/true val = on; val false/0/off = off.
function toggle(el: Element): boolean {
const v = wVal(el);
return v == null || !(v === 'false' || v === '0' || v === 'off');
}
// w:rPr element → RunProps (later layers override earlier; absent keys inherit).
export function parseRunProps(rPr: Element | null | undefined): RunProps {
const p: RunProps = {};
if (!rPr) return p;
for (const child of Array.from(rPr.children)) {
if (child.namespaceURI !== W) continue;
switch (child.localName) {
case 'b': p.bold = toggle(child); break;
case 'i': p.italic = toggle(child); break;
case 'strike': p.strike = toggle(child); break;
case 'u': p.underline = wVal(child) !== 'none'; break;
case 'vertAlign': {
const v = wVal(child);
if (v === 'superscript' || v === 'subscript') p.vertAlign = v;
break;
}
case 'color': { const v = wVal(child); if (v) p.color = v; break; }
case 'sz': { const n = parseInt(wVal(child) ?? '', 10); if (Number.isFinite(n)) p.sizeHalfPt = n; break; }
case 'rFonts': {
const f = child.getAttributeNS(W, 'ascii') ?? child.getAttributeNS(W, 'hAnsi');
if (f) { p.font = f; break; }
// Word's default fonts are theme references (minorHAnsi = body, majorHAnsi = headings).
const theme = child.getAttributeNS(W, 'asciiTheme') ?? child.getAttributeNS(W, 'hAnsiTheme');
if (theme) p.fontTheme = theme.startsWith('major') ? 'major' : 'minor';
break;
}
case 'shd': { const f = child.getAttributeNS(W, 'fill'); if (f && f !== 'auto') p.highlightFill = f; break; }
}
}
return p;
}
export function mergeRunProps(base: RunProps, over: RunProps): RunProps {
const out = { ...base, ...over };
// A run's font is one logical choice (explicit name or theme ref): the nearer
// level's choice shadows both inherited forms.
if ('font' in over || 'fontTheme' in over) {
if (!('font' in over)) delete out.font;
if (!('fontTheme' in over)) delete out.fontTheme;
}
return out;
}
export class DocxStyles {
private defaultsRun: RunProps = {};
private ownRun = new Map<string, RunProps>(); // styleId → own w:rPr
private basedOn = new Map<string, string | null>();
private memoOwn = new Map<string, RunProps>();
private styleNum = new Map<string, { numId: number; ilvl: number }>();
private ownOutline = new Map<string, number>(); // style's own w:outlineLvl (heading marker)
private numToAbstract = new Map<string, string>();
private abstractLevels = new Map<string, Map<number, LevelDef>>();
private minorFont?: string; // theme1.xml body font (e.g. Calibri)
private majorFont?: string; // theme1.xml heading font (e.g. Calibri Light)
constructor(stylesDoc: Document | null, numberingDoc: Document | null, themeDoc: Document | null = null) {
if (stylesDoc) this.parseStyles(stylesDoc);
if (numberingDoc) this.parseNumbering(numberingDoc);
if (themeDoc) this.parseTheme(themeDoc);
}
private parseTheme(doc: Document): void {
const latin = (scheme: string) => {
const font = doc.getElementsByTagNameNS(A, scheme)[0];
const typeface = font?.getElementsByTagNameNS(A, 'latin')[0]?.getAttribute('typeface');
return typeface || undefined;
};
this.minorFont = latin('minorFont');
this.majorFont = latin('majorFont');
}
themeFont(kind: 'minor' | 'major'): string | undefined {
return kind === 'major' ? this.majorFont : this.minorFont;
}
private parseStyles(doc: Document): void {
const defs = doc.getElementsByTagNameNS(W, 'docDefaults')[0];
if (defs) {
const rPr = defs.getElementsByTagNameNS(W, 'rPrDefault')[0]?.getElementsByTagNameNS(W, 'rPr')[0];
this.defaultsRun = parseRunProps(rPr);
}
for (const style of Array.from(doc.getElementsByTagNameNS(W, 'style'))) {
const id = style.getAttributeNS(W, 'styleId');
if (!id) continue;
this.basedOn.set(id, firstChild(style, 'basedOn') ? wVal(firstChild(style, 'basedOn')!) : null);
this.ownRun.set(id, parseRunProps(firstChild(style, 'rPr')));
const ppr = firstChild(style, 'pPr');
const numPr = ppr && firstChild(ppr, 'numPr');
if (numPr) {
const np = readNumPr(numPr);
if (np) this.styleNum.set(id, np);
}
const ol = ppr && firstChild(ppr, 'outlineLvl');
if (ol) { const n = parseInt(wVal(ol) ?? '', 10); if (Number.isFinite(n)) this.ownOutline.set(id, n); }
}
}
private parseNumbering(doc: Document): void {
for (const abs of Array.from(doc.getElementsByTagNameNS(W, 'abstractNum'))) {
const id = abs.getAttributeNS(W, 'abstractNumId');
if (!id) continue;
const levels = new Map<number, LevelDef>();
for (const lvl of Array.from(abs.getElementsByTagNameNS(W, 'lvl'))) {
const ilvl = parseInt(lvl.getAttributeNS(W, 'ilvl') ?? '', 10);
if (!Number.isFinite(ilvl)) continue;
const def: LevelDef = {};
const fmt = firstChild(lvl, 'numFmt'); if (fmt) def.numFmt = wVal(fmt) ?? undefined;
const txt = firstChild(lvl, 'lvlText'); if (txt) def.lvlText = wVal(txt) ?? undefined;
const start = firstChild(lvl, 'start'); if (start) { const n = parseInt(wVal(start) ?? '', 10); if (Number.isFinite(n)) def.start = n; }
const ind = firstChild(lvl, 'pPr') && firstChild(firstChild(lvl, 'pPr')!, 'ind');
if (ind) { const l = parseInt(ind.getAttributeNS(W, 'left') ?? ind.getAttributeNS(W, 'start') ?? '', 10); if (Number.isFinite(l)) def.leftTwip = l; }
levels.set(ilvl, def);
}
this.abstractLevels.set(id, levels);
}
for (const num of Array.from(doc.getElementsByTagNameNS(W, 'num'))) {
const numId = num.getAttributeNS(W, 'numId');
const absId = firstChild(num, 'abstractNumId');
if (numId && absId) this.numToAbstract.set(numId, wVal(absId) ?? '');
}
}
// Chain-resolved own run props (basedOn root → leaf), without the docDefaults baseline.
styleOwn(styleId: string | null | undefined): RunProps {
if (!styleId) return {};
const memo = this.memoOwn.get(styleId);
if (memo) return memo;
this.memoOwn.set(styleId, {}); // break basedOn cycles
const parent = this.basedOn.get(styleId) ?? null;
const base = parent ? this.styleOwn(parent) : {};
const merged = mergeRunProps(base, this.ownRun.get(styleId) ?? {});
this.memoOwn.set(styleId, merged);
return merged;
}
defaultRun(): RunProps {
return { ...this.defaultsRun };
}
styleNumPr(styleId: string | null | undefined): { numId: number; ilvl: number } | null {
return styleId ? this.styleNum.get(styleId) ?? null : null;
}
// The style's effective outline level (w:outlineLvl) along the w:basedOn chain. 0–8
// marks a heading (Heading1 = 0); the standard, locale-independent heading signal.
styleOutlineLvl(styleId: string | null | undefined, seen = new Set<string>()): number | null {
if (!styleId || seen.has(styleId)) return null;
seen.add(styleId);
const own = this.ownOutline.get(styleId);
if (own != null) return own;
return this.styleOutlineLvl(this.basedOn.get(styleId) ?? null, seen);
}
level(numId: number, ilvl: number): LevelDef {
const abs = this.numToAbstract.get(String(numId));
if (!abs) return {};
return this.abstractLevels.get(abs)?.get(ilvl) ?? {};
}
}
function firstChild(el: Element, localName: string): Element | null {
for (const c of Array.from(el.children)) if (c.namespaceURI === W && c.localName === localName) return c;
return null;
}
export function readNumPr(numPr: Element): { numId: number; ilvl: number } | null {
const idEl = firstChild(numPr, 'numId');
if (!idEl) return null;
const numId = parseInt(wVal(idEl) ?? '', 10);
if (!Number.isFinite(numId)) return null;
const ilvlEl = firstChild(numPr, 'ilvl');
const ilvl = ilvlEl ? parseInt(wVal(ilvlEl) ?? '0', 10) : 0;
return { numId, ilvl: Number.isFinite(ilvl) ? ilvl : 0 };
}
+237
View File
@@ -0,0 +1,237 @@
import { describe, it, expect } from 'vitest';
import { zipSync, strToU8 } from 'fflate';
import { buildDocx } from '../src/lib/export/docx';
import { importDocx } from '../src/lib/import/docx';
import { HEADER_SHADE } from '../src/lib/editor/extensions/tableHeaderRow';
type N = { type: string; attrs?: any; content?: N[]; marks?: any[]; text?: string };
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
const text = (t: string, marks?: any[]) => ({ type: 'text', text: t, ...(marks ? { marks } : {}) });
const para = (content: any, attrs: any = {}) => ({ type: 'paragraph', attrs, content: Array.isArray(content) ? content : [text(content)] });
const heading = (level: number, t: string, attrs: any = {}) => ({ type: 'heading', attrs: { level, ...attrs }, content: [text(t)] });
const li = (...content: any[]) => ({ type: 'listItem', content });
const cell = (t: string, attrs: any = {}) => ({ type: 'tableCell', attrs, content: [para(t)] });
const headerCell = (t: string, attrs: any = {}) => ({ type: 'tableHeader', attrs: { backgroundColor: HEADER_SHADE, ...attrs }, content: [para(t)] });
function walk(node: N, type: string, out: N[] = []): N[] {
if (node.type === type) out.push(node);
for (const c of node.content ?? []) walk(c, type, out);
return out;
}
const hasMark = (n: N, type: string) => (n.marks ?? []).some((m: any) => m.type === type);
const markAttrs = (n: N, type: string) => (n.marks ?? []).find((m: any) => m.type === type)?.attrs;
describe('DOCX export → import round trip', () => {
const fixture = {
type: 'doc',
content: [
heading(1, 'Title', { textAlign: 'center' }),
para([
text('plain '),
text('bold', [{ type: 'bold' }]),
text(' '),
text('red', [{ type: 'textStyle', attrs: { color: '#FF0000' } }]),
text(' '),
text('hi', [{ type: 'highlight', attrs: { color: '#00FF00' } }]),
text(' '),
text('site', [{ type: 'link', attrs: { href: 'https://example.com' } }]),
], { spaceBefore: 6, spaceAfter: 6, lineHeight: '1.5', indent: 1 }),
para([text('a\tb')]),
para([text('line1'), { type: 'hardBreak' }, text('line2')]),
{ type: 'bulletList', content: [li(para('one')), li(para('two'), { type: 'bulletList', content: [li(para('nested'))] })] },
{ type: 'orderedList', attrs: { listStyleType: 'lower-alpha' }, content: [li(para('alpha'))] },
para([{ type: 'image', attrs: { src: PNG, width: 100, height: 80, wrap: 'left', alt: 'pic' } }]),
{ type: 'table', content: [
{ type: 'tableRow', content: [headerCell('Name', { colwidth: [6] }), headerCell('Qty', { colwidth: [3] })] },
{ type: 'tableRow', attrs: { rowHeight: 40 }, content: [cell('Widget', { backgroundColor: '#FFFF00', rowspan: 2 }), cell('1')] },
{ type: 'tableRow', content: [cell('2')] },
] },
],
} as any;
const hf = {
header: { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'right' }, content: [text('My header')] }] },
footer: { type: 'doc', content: [{ type: 'paragraph', content: [text('Page '), { type: 'pageNumber' }, text(' of '), { type: 'pageCount' }] }] },
pageCount: 1,
} as any;
let doc: N;
let result: any;
it('imports without throwing', async () => {
const bytes = await buildDocx(fixture, { top: 2.54, bottom: 2.54, left: 2.12, right: 2.12 }, 'portrait', hf, { language: 'en', country: 'US' });
result = importDocx(bytes);
doc = result.content;
expect(doc.type).toBe('doc');
});
it('round-trips headings + paragraph block attrs', () => {
const h = doc.content![0];
expect(h.type).toBe('heading');
expect(h.attrs.level).toBe(1);
expect(h.attrs.textAlign).toBe('center');
const p = doc.content![1];
expect(p.attrs.spaceBefore).toBe(6);
expect(p.attrs.spaceAfter).toBe(6);
expect(p.attrs.lineHeight).toBe('1.5');
expect(p.attrs.indent).toBe(1);
});
it('round-trips run marks (bold, color, highlight, link)', () => {
const texts = walk(doc, 'text');
expect(hasMark(texts.find((t) => t.text === 'bold')!, 'bold')).toBe(true);
expect(markAttrs(texts.find((t) => t.text === 'red')!, 'textStyle').color).toBe('#FF0000');
expect(markAttrs(texts.find((t) => t.text === 'hi')!, 'highlight').color).toBe('#00FF00');
expect(markAttrs(texts.find((t) => t.text === 'site')!, 'link').href).toBe('https://example.com');
});
it('round-trips tabs and hard breaks', () => {
expect(walk(doc, 'text').some((t) => t.text!.includes('\t'))).toBe(true);
expect(walk(doc, 'hardBreak').length).toBeGreaterThan(0);
});
it('reconstructs nested bullet lists and an ordered list type', () => {
const bullets = walk(doc, 'bulletList');
const top = bullets.find((b) => b.content!.length === 2)!;
expect(top).toBeTruthy();
// second item carries a nested bulletList with the "nested" paragraph
const nested = top.content![1].content!.find((c) => c.type === 'bulletList')!;
expect(nested).toBeTruthy();
expect(walk(nested, 'text')[0].text).toBe('nested');
const ol = walk(doc, 'orderedList')[0];
expect(ol.attrs.listStyleType).toBe('lower-alpha');
});
it('round-trips the image (size + floating wrap)', () => {
const img = walk(doc, 'image')[0];
expect(img.attrs.width).toBe(100);
expect(img.attrs.height).toBe(80);
expect(img.attrs.wrap).toBe('left');
expect(img.attrs.src.startsWith('data:image/png')).toBe(true);
});
it('round-trips the table: header shade, merged cell, covered-cell drop, row height', () => {
const table = walk(doc, 'table')[0];
const rows = table.content!;
expect(rows.length).toBe(3);
// header cells keep the shade and are NOT over-marked bold (CSS renders them)
const headerCells = rows[0].content!;
expect(headerCells[0].attrs.backgroundColor).toBe(HEADER_SHADE);
expect(hasMark(walk(headerCells[0], 'text')[0], 'bold')).toBe(false);
// rowspan reconstructed from vMerge; the covered cell in row 3 is dropped
const widget = rows[1].content![0];
expect(widget.attrs.rowspan).toBe(2);
expect(widget.attrs.backgroundColor).toBe('#FFFF00');
expect(rows[2].content!.length).toBe(1);
expect(rows[1].attrs.rowHeight).toBe(40);
});
it('round-trips page geometry + header/footer with page fields', () => {
expect(result.orientation).toBe('portrait');
expect(result.margins.top).toBeCloseTo(2.54, 1);
expect(result.margins.left).toBeCloseTo(2.12, 1);
expect(walk(result.header, 'text')[0].text).toBe('My header');
expect(result.header.content[0].attrs.textAlign).toBe('right');
expect(walk(result.footer, 'pageNumber').length).toBe(1);
expect(walk(result.footer, 'pageCount').length).toBe(1);
});
});
describe('DOCX import of a foreign Word document', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Foreign Title</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>First</w:t></w:r></w:p>
<w:tbl><w:tblGrid><w:gridCol w:w="4000"/><w:gridCol w:w="4000"/></w:tblGrid>
<w:tr><w:tc><w:tcPr><w:vMerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p/></w:tc><w:tc><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
</w:body></w:document>`;
const stylesXml = `<?xml version="1.0"?><w:styles ${W}>
<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Times New Roman"/><w:sz w:val="24"/></w:rPr></w:rPrDefault></w:docDefaults>
<w:style w:type="paragraph" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:basedOn w:val="Normal"/><w:rPr><w:b/><w:sz w:val="40"/><w:rFonts w:ascii="Arial"/></w:rPr></w:style>
</w:styles>`;
const numberingXml = `<?xml version="1.0"?><w:numbering ${W}>
<w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum>
<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>
</w:numbering>`;
const bytes = zipSync({
'word/document.xml': strToU8(documentXml),
'word/styles.xml': strToU8(stylesXml),
'word/numbering.xml': strToU8(numberingXml),
});
const result = importDocx(bytes);
const doc = result.content as N;
it('resolves a heading via its style chain (style-only run props)', () => {
const h = doc.content![0];
expect(h.type).toBe('heading');
expect(h.attrs.level).toBe(1);
const t = walk(h, 'text')[0];
expect(hasMark(t, 'bold')).toBe(false); // heading bold is presentational
expect(markAttrs(t, 'textStyle')?.fontFamily).toBe('Arial'); // resolved from the style
});
it('reconstructs a numbered list and a vertically-merged table', () => {
expect(walk(doc, 'orderedList').length).toBe(1);
const rows = walk(doc, 'table')[0].content!;
expect(rows[0].content![0].attrs.rowspan).toBe(2); // vMerge restart → rowspan
expect(rows[1].content!.length).toBe(1); // covered cell dropped
expect(result.margins.top).toBeCloseTo(2.54, 1);
});
});
describe('DOCX import resolves theme fonts (Word default = Calibri)', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
const A = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"';
// No explicit w:ascii anywhere — the font lives only in the theme, like a real Word doc.
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:r><w:t>Body text</w:t></w:r></w:p>
</w:body></w:document>`;
const stylesXml = `<?xml version="1.0"?><w:styles ${W}>
<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:asciiTheme="minorHAnsi" w:hAnsiTheme="minorHAnsi"/></w:rPr></w:rPrDefault></w:docDefaults>
</w:styles>`;
const themeXml = `<?xml version="1.0"?><a:theme ${A}><a:themeElements><a:fontScheme>
<a:majorFont><a:latin typeface="Calibri Light"/></a:majorFont>
<a:minorFont><a:latin typeface="Calibri"/></a:minorFont>
</a:fontScheme></a:themeElements></a:theme>`;
const bytes = zipSync({
'word/document.xml': strToU8(documentXml),
'word/styles.xml': strToU8(stylesXml),
'word/theme/theme1.xml': strToU8(themeXml),
});
const doc = importDocx(bytes).content as N;
it('tags body text with the theme body font instead of the editor default', () => {
const t = walk(doc, 'text')[0];
expect(t.text).toBe('Body text');
expect(markAttrs(t, 'textStyle')?.fontFamily).toBe('Calibri');
});
});
describe('DOCX import detects headings by outline level (non-"HeadingN" style ids)', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
// A heading style whose id is NOT "Heading1" — only its w:outlineLvl marks it a heading.
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:pPr><w:pStyle w:val="Titel1"/></w:pPr><w:r><w:t>Localised heading</w:t></w:r></w:p>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
</w:body></w:document>`;
const stylesXml = `<?xml version="1.0"?><w:styles ${W}>
<w:style w:type="paragraph" w:styleId="Standard"><w:name w:val="Standard"/></w:style>
<w:style w:type="paragraph" w:styleId="Titel1"><w:basedOn w:val="Standard"/><w:pPr><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>
</w:styles>`;
const bytes = zipSync({ 'word/document.xml': strToU8(documentXml), 'word/styles.xml': strToU8(stylesXml) });
const doc = importDocx(bytes).content as N;
it('maps an outline-level-0 paragraph to heading 1', () => {
const h = doc.content![0];
expect(h.type).toBe('heading');
expect(h.attrs.level).toBe(1);
expect(walk(h, 'text')[0].text).toBe('Localised heading');
});
});