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'); + }); +});