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