diff --git a/CHANGELOG.md b/CHANGELOG.md
index 591ec75..195dab8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
diff --git a/src/App.svelte b/src/App.svelte
index b4eaba8..446b601 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -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 @@
diff --git a/src/lib/export/saveFile.ts b/src/lib/export/saveFile.ts
index 6a41932..3f3b4a5 100644
--- a/src/lib/export/saveFile.ts
+++ b/src/lib/export/saveFile.ts
@@ -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 };
diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts
new file mode 100644
index 0000000..307be45
--- /dev/null
+++ b/src/lib/import/docx.ts
@@ -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 };
+type Node = { type: string; attrs?: Record; content?: Node[]; marks?: Mark[]; text?: string };
+type BlockKind = 'body' | 'list' | 'cell';
+
+type RelInfo = { target: string; external: boolean };
+type Ctx = {
+ styles: DocxStyles;
+ warnings: Set;
+ files: Record;
+ rels: Map;
+ imageCache: Map;
+};
+
+// ---- 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 = {
+ 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;
+ 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();
+
+ 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 {
+ const map = new Map();
+ 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): 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 = {};
+ 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 {
+ const attrs: Record = {};
+ 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 = {};
+
+ // 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 = { 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 = { 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] };
+}
diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts
new file mode 100644
index 0000000..54cbfd2
--- /dev/null
+++ b/src/lib/import/docxStyles.ts
@@ -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(); // styleId → own w:rPr
+ private basedOn = new Map();
+ private memoOwn = new Map();
+ private styleNum = new Map();
+ private ownOutline = new Map(); // style's own w:outlineLvl (heading marker)
+ private numToAbstract = new Map();
+ private abstractLevels = new Map>();
+ 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();
+ 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()): 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 };
+}
diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts
new file mode 100644
index 0000000..cbaf042
--- /dev/null
+++ b/tests/docx-roundtrip.test.ts
@@ -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 = `
+ Foreign Title
+ First
+
+ AB
+ C
+
+
+ `;
+ const stylesXml = `
+
+
+
+ `;
+ const numberingXml = `
+
+
+ `;
+
+ 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 = `
+ Body text
+ `;
+ const stylesXml = `
+
+ `;
+ const themeXml = `
+
+
+ `;
+
+ 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 = `
+ Localised heading
+
+ `;
+ const stylesXml = `
+
+
+ `;
+ 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');
+ });
+});