Files
edentext/src/lib/import/docx.ts
T
Steffen Becker 5ee4eec5d7 feat: the editor records, shows and applies tracked changes
Two marks carry a revision with its author and date: an insertion is
marked text, a deletion is text kept and struck through. Consecutive
typing joins one change; accept/reject work at the cursor or on all.

ODF splits the pair — text:change-start/-end around an insertion, a
text:change where a deletion was, its text in the tracked-changes
registry. DOCX uses w:ins/w:del with w:delText. Both round-trip, and
accepting every change gives the document an unrecorded edit would.
2026-08-14 07:48:10 +02:00

2658 lines
128 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { unzipSync, strFromU8 } from 'fflate';
import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, readTabStops, toggle as onOff, wVal, W, R, WP, A, WPS, MC, VML, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles';
import { lengthToPt, WATERMARK_NAME } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt';
import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { fitInlineImage, framePx } from '../editor/extensions/image';
import { formatTabStops } from '../editor/extensions/tabStops';
import type { CapsMode, LineStyle } from '../editor/extensions/textEffects';
import { tableLookAttr } from '../styles/tableStyles';
import { formatOrdinal, orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes';
import { DATE_FORMATS, TIME_FORMATS, docxPicture, toDateValue } from '../utils/dateTime';
import { imageDataUrl, placeholderImage, type ConvertedImages } from './imageFormats';
import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { formatFromCm, type PageFormat } from '../storage/pageFormat';
import { clampTabInterval, DOCX_IMPLIED_TAB_CM } from '../storage/tabInterval';
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
import { EMPTY_HF_SET, type HfDoc, type HfSet } from '../storage/headerFooter';
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings';
import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties';
import { clampPageStart, DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
import { applyUniformRunFont, pairAlignedFrames, sinkOffsetFrames, type OdtImportResult } from './odt';
import { chartDataUrl } from './chart';
import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts';
import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
import { ODF_SEQ_CATEGORY } from '../editor/extensions/caption';
import type { IndexKind } from '../editor/extensions/tableOfContents';
import { normalizePageDecor, type PageDecor } from '../storage/pageDecor';
import { DEFAULT_LINE_NUMBERING, normalizeLineNumbering, type LineNumbering } from '../storage/lineNumbering';
import { clampColumnGap } from '../editor/extensions/columns';
import { astToLatex } from '../math/latex';
import { parseOmml, OMML_NS } from '../math/omml';
// .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 };
// pendingBlocks: text boxes/shapes found inside runs — block nodes that convertBlocks
// flushes after the anchor paragraph (mirrors import/odt.ts).
type Ctx = {
styles: DocxStyles;
// Word styleId → registry name, and the ids blocks actually reference.
styleNames: Map<string, string>;
usedStyles: Set<string>;
charStyleNames: Map<string, string>;
usedCharStyles: Set<string>;
warnings: Set<string>;
files: Record<string, Uint8Array>;
rels: Map<string, RelInfo>;
imageCache: Map<string, string>;
convertedImages: ConvertedImages;
pendingBlocks: Node[];
listCounters: Map<number, Map<number, number>>; // numId → ilvl → last number used
// Text width (cm) of the file's page setup; a table's margins are relative to it.
contentWidthCm: number;
// Left page margin (cm), the origin a page-relative frame offset is measured against.
leftMarginCm: number;
// The section's own direction: a block declaring the same one is inheriting, not
// formatted, so only a block that differs carries a `dir` attr.
pageRtl: boolean;
// The enclosing table style's w:pPr/w:spacing, applied to its cells' paragraphs.
cellSpacing: ParaSpacing;
// Whether w:tblInd is measured to the cell's text rather than the table's edge.
tblIndToText: boolean;
// theme1.xml's accent1..6, the colours a chart series names instead of an sRGB.
accents: string[];
// Bookmarks open at this point of the walk (w:id → name). A range may start beside a
// paragraph and end inside a later one, so the state outlives both walks.
openBookmarks: Map<string, string>;
// Comment ranges currently open, by Word's numeric id → the mark's attrs.
openComments: Map<string, Record<string, unknown>>;
// word/comments.xml, by id.
commentDefs: Map<string, Record<string, unknown>>;
// word/footnotes.xml and endnotes.xml by w:id, and the notes the body referenced, in
// anchor order — the editor keeps them in one section at the document end (notes.ts).
noteParts: Record<NoteKind, Map<string, Element>>;
notes: { id: string; kind: NoteKind; text: string; content: Node[]; styleName: string | null }[];
};
// The real notes of a part, by w:id: Word's own separator entries carry a w:type and
// are referenced by nothing.
function noteParts(files: Record<string, Uint8Array>, part: string, tag: string): Map<string, Element> {
const out = new Map<string, Element>();
const bytes = files[`word/${part}.xml`];
if (!bytes) return out;
const root = parseXml(strFromU8(bytes)).documentElement;
for (const el of Array.from(root.getElementsByTagNameNS(W, tag))) {
if (el.getAttributeNS(W, 'type')) continue;
const id = el.getAttributeNS(W, 'id');
if (id != null) out.set(id, el);
}
return out;
}
// ---- 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 cmToEmu = (cm: number) => cm * 360000;
const round2 = (v: number) => Math.round(v * 100) / 100;
// A run-level <w:br w:type="page"/> becomes this sentinel inline node in convertInline;
// splitParaAtPageBreaks consumes it (body only) into breakBefore, and it never survives.
const PB_MARKER = '__docxPageBreak__';
const BODY_FONT_SIZE_PT = 12;
// Rounded to half points: that is all Word can store, so it is what our own export
// writes and what an imported heading must be compared against.
const HEADING_SIZES = HEADING_STYLE_OVERRIDES.map((h) => Math.round(lengthToPt(h.fontSize)! * 2) / 2);
const HEADING_ITALIC = HEADING_STYLE_OVERRIDES.map((h) => h.italic === true);
const DEFAULT_FONTS = new Set(['times new roman', 'liberation serif']);
// Headings render sans (HEADING_FONT); Word writes Arial, LibreOffice Liberation Sans.
const DEFAULT_HEADING_FONTS = new Set(['arial', 'liberation sans']);
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
// ---- 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);
}
// ---- entry ------------------------------------------------------------------
export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = new Map()): 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 styleNames = new Map<string, string>();
const defaultStyleId = styles.defaultParagraphStyle();
for (const [id, def] of styles.namedParagraphStyles()) styleNames.set(id, registryName(id, def.name, id === defaultStyleId));
const charStyleNames = styles.namedCharacterStyles();
const body = docDoc.getElementsByTagNameNS(W, 'body')[0];
if (!body) throw new Error('Not a Word document (no w:body).');
const sectPr = fc(body, 'sectPr');
const contentWidthCm = sectionContentWidthCm(sectPr);
const leftMarginCm = twipToCm(intAttr(fc(sectPr, 'pgMar'), W, 'left') ?? 1440);
const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, pageRtl: sectPrRtl(sectPr), cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map(), openComments: new Map(), commentDefs: docxComments(files), notes: [], noteParts: {
footnote: noteParts(files, 'footnotes', 'footnote'),
endnote: noteParts(files, 'endnotes', 'endnote'),
} };
// Mid-body sectPr paragraphs delimit sections; a section whose w:cols declares
// more than one column becomes a columns node (the trailing group is described
// by the body-final sectPr, covering whole-document multi-column files).
const { groups, midSectPrs } = splitBodySections(Array.from(body.children));
const finalSectPr = fc(body, 'sectPr');
const blocks: Node[] = [];
groups.forEach((g, gi) => {
const inner = convertBlocks(g.els, ctx, 'body');
// A section's own w:type says how it begins: a page-starting break (nextPage/odd/even,
// or the default) puts its first block on a new page; continuous/nextColumn flow on.
if (gi > 0 && inner.length) {
const first = inner[0];
if (first.type === 'paragraph' || first.type === 'heading') {
first.attrs = { ...(first.attrs ?? {}), sectionBreak: true };
if (sectionStartsNewPage(g.sectPr ?? finalSectPr)) first.attrs.breakBefore = 'page';
}
}
const cols = sectPrColumns(g.sectPr ?? finalSectPr, ctx);
if (cols) pushColumnRuns(inner, cols, blocks, ctx);
else blocks.push(...inner);
});
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
pairAlignedFrames(blocks, Math.floor(cmToPx(ctx.contentWidthCm)));
// The notes the walk collected, in anchor order, as the one section the editor keeps
// at the document end.
if (ctx.notes.length) {
blocks.push({ type: 'noteSection', content: ctx.notes.map((n) => ({
type: 'note',
attrs: { id: n.id, kind: n.kind, label: null, text: n.text, styleName: n.styleName },
...(n.content.length ? { content: n.content } : {}),
})) });
}
// Odd/even pages: a document-level setting (settings.xml), not a section property.
const oddEven = docHasEvenOddHeaders(files);
const mirrored = docHasMirrorMargins(files);
// Page size stays document-wide, from the body-final sectPr; the margins are the
// first section's, since that is the pair .tiptap's padding draws and every later
// section is measured against (Editor.svelte's --pb-section-inset).
const sect = parseSectPr(finalSectPr, ctx, oddEven);
const hfSections = sectionHfSets(groups.map((g) => g.sectPr ?? finalSectPr), ctx, oddEven);
const first = hfSections[0];
// A first-page/even zone reserves the header/footer band even when its default is empty;
// the distance is document-wide, so any section having one is enough.
const hasHeader = hfSections.some((s) => s.header || (s.differentFirstPage && s.headerFirst) || (s.differentOddEven && s.headerEven));
const hasFooter = hfSections.some((s) => s.footer || (s.differentFirstPage && s.footerFirst) || (s.differentOddEven && s.footerEven));
return {
content: { type: 'doc', content: blocks },
styles: collectStyleSheet(ctx),
notes: docNoteSettings(files),
margins: withMirror(first.margins ?? sect.margins, mirrored),
rtl: sectPrRtl(finalSectPr),
decor: docxPageDecor(docDoc, finalSectPr, files),
lineNumbering: docxLineNumbering(finalSectPr),
hyphenate: docAutoHyphenation(files),
pageNumbering: docxPageNumbering(sectPr),
orientation: sect.orientation,
format: sect.format,
tabIntervalCm: docTabInterval(files),
// Word takes the larger of the two spacings, and LibreOffice follows it for a
// Word document — its own ODF default adds them (probed).
spacingModel: 'max' as const,
header: first.header,
footer: first.footer,
headerFirst: first.differentFirstPage ? first.headerFirst : null,
footerFirst: first.differentFirstPage ? first.footerFirst : null,
differentFirstPage: first.differentFirstPage,
headerEven: first.differentOddEven ? first.headerEven : null,
footerEven: first.differentOddEven ? first.footerEven : null,
differentOddEven: first.differentOddEven,
hfSections,
headerDistanceCm: hasHeader ? sect.headerDistCm : null,
footerDistanceCm: hasFooter ? sect.footerDistCm : null,
language: documentLanguage(stylesDoc, warnings),
props: docxDocProperties(files),
fonts: extractDocxFonts(files),
warnings: [...warnings],
};
}
// Fonts embedded via word/fontTable.xml: each <w:font> may reference regular/bold/italic/
// bold-italic .odttf binaries (obfuscated). Resolve r:id → package path, read + de-obfuscate.
function extractDocxFonts(files: Record<string, Uint8Array>): EmbeddedFont[] {
const ftBytes = files['word/fontTable.xml'];
if (!ftBytes) return [];
let doc: Document;
try { doc = parseXml(strFromU8(ftBytes)); } catch { return []; }
const rels = parseRels(files['word/_rels/fontTable.xml.rels']);
const embeds: [string, 'normal' | 'bold', 'normal' | 'italic'][] = [
['embedRegular', 'normal', 'normal'], ['embedBold', 'bold', 'normal'],
['embedItalic', 'normal', 'italic'], ['embedBoldItalic', 'bold', 'italic'],
];
const out: EmbeddedFont[] = [];
for (const font of Array.from(doc.getElementsByTagNameNS(W, 'font'))) {
const family = font.getAttributeNS(W, 'name');
if (!family) continue;
for (const [tag, weight, style] of embeds) {
const el = fc(font, tag);
const rel = el ? rels.get(el.getAttributeNS(R, 'id') ?? '') : undefined;
if (!el || !rel || rel.external) continue;
const data = files['word/' + rel.target.replace(/^\/+/, '')];
if (!data) continue;
const key = el.getAttributeNS(W, 'fontKey');
out.push({ family, weight, style, data: key ? deobfuscateOdttf(data, key) : data });
}
}
return out;
}
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) --------------------------
// A TOC is a `TOC` field spanning several paragraphs, each entry a nested PAGEREF field,
// so field depth is tracked (across one convertBlocks call) to match the TOC's own end.
type TocFieldState = { fieldDepth: number; tocDepth: number; instr: string[] };
// The deepest heading level a TOC field lists: the end of its `\o "1-3"` range (Word's
// own default is 1-9). Anything deeper only inflates the block on screen.
function tocMaxLevel(instr: string): number {
const m = /\\o\s+"?\s*\d+\s*-\s*(\d+)/.exec(instr);
const n = m ? Number(m[1]) : NaN;
return n >= 1 ? Math.min(MAX_HEADING_LEVEL, n) : MAX_HEADING_LEVEL;
}
// The TOC field's \c switch names a caption label, which makes it a list of figures or
// tables rather than a contents. Word's own labels and LibreOffice's travel alike.
function tocIndexKind(instr: string): IndexKind {
const m = /\\c\s+"?([^"\\]+)/.exec(instr);
const cat = m ? ODF_SEQ_CATEGORY[m[1].trim()] : undefined;
return cat === 'table' ? 'tables' : cat === 'figure' ? 'figures' : 'toc';
}
const instrTextOf = (el: Element): string =>
Array.from(el.getElementsByTagNameNS(W, 'instrText')).map(i => i.textContent ?? '').join('');
function scanTocField(p: Element, st: TocFieldState): { emit: boolean } {
let emit = false;
for (const r of Array.from(p.getElementsByTagNameNS(W, 'r'))) {
for (const c of Array.from(r.children)) {
if (c.namespaceURI !== W) continue;
if (c.localName === 'fldChar') {
const t = c.getAttributeNS(W, 'fldCharType');
if (t === 'begin') { st.fieldDepth++; st.instr[st.fieldDepth] = ''; }
else if (t === 'end') {
if (st.tocDepth === st.fieldDepth) st.tocDepth = -1;
st.instr[st.fieldDepth] = '';
st.fieldDepth = Math.max(0, st.fieldDepth - 1);
}
} else if (c.localName === 'instrText' && st.fieldDepth > 0) {
st.instr[st.fieldDepth] += c.textContent ?? '';
if (st.tocDepth < 0 && /\bTOC\b/.test(st.instr[st.fieldDepth])) {
st.tocDepth = st.fieldDepth;
emit = true;
}
}
}
}
return { emit };
}
// docx-lib (and Word) wrap a TOC in a content control; detect it by its gallery type or
// a TOC field instruction inside its content.
function sdtIsToc(sdt: Element): boolean {
const gallery = fc(sdt, 'sdtPr')?.getElementsByTagNameNS(W, 'docPartGallery')[0];
if (gallery && /table of contents/i.test(gallery.getAttributeNS(W, 'val') ?? '')) return true;
const content = fc(sdt, 'sdtContent');
if (!content) return false;
return Array.from(content.getElementsByTagNameNS(W, 'instrText')).some(i => /\bTOC\b/.test(i.textContent ?? ''));
}
// The heading Word caches above a TOC's entries ("Inhalt", "Sommaire", …): the control's
// first paragraph, when it is plain text rather than part of the field itself.
function tocHeading(content: Element | null): string | null {
const first = content ? fcAll(content, 'p')[0] : null;
if (!first || first.getElementsByTagNameNS(W, 'fldChar').length || first.getElementsByTagNameNS(W, 'instrText').length) return null;
const text = (first.textContent ?? '').trim();
return text && text.length <= 60 ? text : null;
}
// Word's own cursor bookkeeping, never a reference target.
const BOOKMARK_SKIP = new Set(['_GoBack']);
// w:bookmarkStart/w:bookmarkEnd sit beside runs, beside paragraphs, or split across
// both, so both walks feed the same open-range map. Returns true when the element was
// one of them.
function trackBookmark(el: Element, ctx: Ctx): boolean {
if (el.localName === 'bookmarkStart') {
const id = el.getAttributeNS(W, 'id');
const name = el.getAttributeNS(W, 'name');
if (id && name && !BOOKMARK_SKIP.has(name)) ctx.openBookmarks.set(id, name);
return true;
}
if (el.localName === 'bookmarkEnd') {
const id = el.getAttributeNS(W, 'id');
if (id) ctx.openBookmarks.delete(id);
return true;
}
return false;
}
// w:commentRangeStart/-End bracket the annotated runs; w:commentReference marks the
// anchor and needs no handling. Returns true when the element was one of the three.
function trackComment(el: Element, ctx: Ctx): boolean {
if (el.localName === 'commentRangeStart') {
const id = el.getAttributeNS(W, 'id');
const def = id ? ctx.commentDefs.get(id) : undefined;
if (id && def) ctx.openComments.set(id, def);
return true;
}
if (el.localName === 'commentRangeEnd') {
const id = el.getAttributeNS(W, 'id');
if (id) ctx.openComments.delete(id);
return true;
}
return el.localName === 'commentReference';
}
// A mark can hold one comment, so overlapping ranges collapse to the outermost.
function openCommentMark(ctx: Ctx): Mark | null {
const attrs = ctx.openComments.values().next().value;
return attrs ? { type: 'comment', attrs } : null;
}
// A mark can hold one bookmark, so overlapping ranges collapse to the outermost.
function openBookmarkMark(ctx: Ctx): Mark | null {
const name = ctx.openBookmarks.values().next().value;
return name ? { type: 'bookmark', attrs: { name } } : null;
}
function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDefault = false): Node[] {
const out: Node[] = [];
const stack: { ilvl: number; numId: number; list: Node }[] = [];
// A page break ending one paragraph moves the next block to a new page (breakBefore).
// Only body paragraphs/headings carry it; other block kinds clear it (break dropped).
let breakPending = false;
const applyBreakBefore = (node: Node | undefined) => {
if (node && (node.type === 'paragraph' || node.type === 'heading')) {
node.attrs = { ...(node.attrs ?? {}), breakBefore: 'page' };
}
};
// Table-of-contents field tracking (see scanTocField). A TOC node is emitted for the
// body only; its cached paragraphs are skipped. The node view regenerates entries live.
const tocState: TocFieldState = { fieldDepth: 0, tocDepth: -1, instr: [] };
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(); };
// Emit an anchor paragraph, then any text boxes found inside it. In the body they
// follow the anchor at top level — and an empty anchor (our own export's wrapper
// paragraph) is dropped; elsewhere their blocks are unwrapped in place.
const pushWithPending = (anchor: Node | null) => {
const pending = ctx.pendingBlocks.splice(0);
const anchorIsEmpty = anchor?.type === 'paragraph' && !anchor.content?.length;
if (anchor && !(pending.length && anchorIsEmpty)) out.push(anchor);
if (!pending.length) return;
if (kind === 'body') {
out.push(...pending);
} else {
ctx.warnings.add('Text boxes nested in table cells or other text boxes were flattened');
for (const box of pending) out.push(...(box.content ?? [{ type: 'paragraph' }]));
}
};
for (const el of children) {
if (el.namespaceURI !== W) continue;
if (trackBookmark(el, ctx) || trackComment(el, ctx)) continue;
if (el.localName === 'p') {
// A paragraph owned by a TOC field is skipped; the field emits one node.
const startedInToc = tocState.tocDepth >= 0;
const { emit } = scanTocField(el, tocState);
if (emit && kind === 'body') {
flush();
// The field carries no heading of its own — Word's sits in a separate paragraph.
const instr = instrTextOf(el);
out.push({ type: 'tableOfContents', attrs: { entries: [], title: '', maxLevel: tocMaxLevel(instr), index: tocIndexKind(instr) } });
}
if (startedInToc || emit) continue;
const num = paragraphNum(el, ctx);
if (num) {
breakPending = false; // a break before a list item can't be modeled; drop it
const para = splitParaAtPageBreaks(convertParagraph(el, ctx, 'list', boldByDefault), 'list').blocks[0];
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;
}
const targetList = stack[stack.length - 1].list;
const number = nextListNumber(ctx, num.numId, num.ilvl);
// A non-list paragraph splits one Word list into separate nodes here; carry the
// running count onto a fresh ordered node so numbering continues, not restarts at 1.
if (targetList.type === 'orderedList' && targetList.content!.length === 0 && number > 1) {
targetList.attrs = { ...(targetList.attrs ?? {}), start: number };
}
targetList.content!.push({ type: 'listItem', content: [para] });
} else {
flush();
const { blocks, trailingBreak } = splitParaAtPageBreaks(convertParagraph(el, ctx, kind, boldByDefault), kind);
if (breakPending) { applyBreakBefore(blocks[0]); breakPending = false; }
for (const b of blocks) pushWithPending(b);
breakPending = trailingBreak;
}
} else if (el.localName === 'tbl') {
breakPending = false; // a break before a table can't be modeled; drop it
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') {
breakPending = false;
flush();
if (sdtIsToc(el)) {
// A content-control-wrapped TOC → one node (regenerated live); skip its content.
if (kind === 'body') {
const content = fc(el, 'sdtContent');
const instr = content ? instrTextOf(content) : '';
out.push({ type: 'tableOfContents', attrs: {
entries: [],
title: tocHeading(content) ?? '',
maxLevel: tocMaxLevel(instr),
index: tocIndexKind(instr),
} });
}
} else {
const content = fc(el, 'sdtContent');
if (content) out.push(...convertBlocks(Array.from(content.children), ctx, kind, boldByDefault));
}
}
}
flush();
// Boxes anchored inside list items land after the whole list.
pushWithPending(null);
return out;
}
// Word numbering is document-global per numId: a level keeps counting across intervening
// non-list paragraphs (which split the list into separate nodes here) and resets its deeper
// levels whenever a shallower level advances. Returns this item's number.
function nextListNumber(ctx: Ctx, numId: number, ilvl: number): number {
let per = ctx.listCounters.get(numId);
if (!per) { per = new Map(); ctx.listCounters.set(numId, per); }
const cur = per.get(ilvl);
const n = cur == null ? (ctx.styles.level(numId, ilvl).start ?? 1) : cur + 1;
per.set(ilvl, n);
for (const l of Array.from(per.keys())) if (l > ilvl) per.delete(l);
return n;
}
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"
}
// Cycle position of level `ilvl` — walks the shallower levels of the same numbering,
// advancing one slot each and re-anchoring slot + suffix at explicit ordered formats
// (like the ODT importer), so an attr-less nested default advances past its parent.
function listBaseCycle(ctx: Ctx, numId: number, ilvl: number): OrderedCycle {
let cycle = ROOT_ORDERED_CYCLE;
for (let l = 0; l < ilvl; l++) {
const d = ctx.styles.level(numId, l);
const bullet = !d.numFmt || d.numFmt === 'bullet' || d.numFmt === 'none';
const key = bullet ? null : orderedTypeFromFormat(wordFmtChar(d.numFmt), lvlSuffix(d.lvlText));
cycle = childCycle(cycle, key, !bullet);
}
return cycle;
}
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 ch = bulletCharAttr(bulletCharFromDocx(def.lvlText, def.bulletFont), ilvl);
if (ch) attrs.bulletChar = ch;
} else {
const placeholders = (def.lvlText?.match(/%\d/g) ?? []).length;
// Multilevel chains ("%1.%2." lvlText below level 0): the attr sits on the top
// list only; chain members stay attr-less.
const chainBelow = ((ctx.styles.level(numId, 1).lvlText ?? '').match(/%\d/g) ?? []).length > 1;
if (ilvl === 0 && chainBelow) {
attrs.listStyleType = 'multilevel';
} else if (placeholders <= 1) {
const key = orderedTypeFromFormat(wordFmtChar(def.numFmt), lvlSuffix(def.lvlText));
const attr = orderedTypeAttrAt(key, listBaseCycle(ctx, numId, ilvl));
if (attr) attrs.listStyleType = attr;
}
if (def.start != null && def.start > 1) attrs.start = def.start;
}
if (def.leftTwip != null) {
// A level's w:ind w:left is absolute, the editor nests one LIST_LEFT_STEP_CM per
// level — so the attr is this level's step past the one above. Signed (w:left="360"
// is a common one) and floored there, so the list stays in the text column.
const leftCm = (l: number) => {
const t = ctx.styles.level(numId, l).leftTwip;
return t != null ? twipToCm(t) : (l + 1) * LIST_LEFT_STEP_CM;
};
const step = leftCm(ilvl) - (ilvl === 0 ? 0 : leftCm(ilvl - 1));
const extra = round2(Math.max(-LIST_LEFT_STEP_CM, step - LIST_LEFT_STEP_CM));
if (Math.abs(extra) > LIST_INDENT_EPS_CM) attrs.indent = extra;
}
if (def.rightAligned) attrs.markerAlign = 'right';
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) === ')' ? ')' : '.';
}
// Split a converted body paragraph at run-level page breaks (PB_MARKER): each break
// starts a new block via breakBefore: 'page'. A break at the paragraph's end reports
// trailingBreak so the caller moves the NEXT block. Cells/lists just strip the markers.
function splitParaAtPageBreaks(para: Node, kind: BlockKind): { blocks: Node[]; trailingBreak: boolean } {
const content = para.content ?? [];
if (!content.some((n) => n.type === PB_MARKER)) return { blocks: [para], trailingBreak: false };
if (kind !== 'body') {
const kept = content.filter((n) => n.type !== PB_MARKER);
if (kept.length) para.content = kept; else delete para.content;
return { blocks: [para], trailingBreak: false };
}
const segs: Node[][] = [[]];
for (const n of content) {
if (n.type === PB_MARKER) segs.push([]);
else segs[segs.length - 1].push(n);
}
let trailingBreak = false;
if (segs.length > 1 && segs[segs.length - 1].length === 0) { segs.pop(); trailingBreak = true; }
const blocks = segs.map((seg, i) => {
const node: Node = { type: para.type };
const attrs = { ...(para.attrs ?? {}) };
if (i > 0) attrs.breakBefore = 'page';
if (Object.keys(attrs).length) node.attrs = attrs;
if (seg.length) node.content = seg;
return node;
});
return { blocks, trailingBreak };
}
// What a block's named style already provides — the yardstick for "is this direct
// formatting?" (mirrors import/odt.ts blockDefaults).
type BlockDefaults = {
fontSizePt: number;
boldByDefault: boolean;
fonts: Set<string>;
color: string;
italic: boolean;
underline: boolean;
strike: boolean;
caps: CapsMode | null;
};
const FONT_TWINS: Record<string, string[]> = {
'times new roman': ['liberation serif'],
'liberation serif': ['times new roman'],
arial: ['liberation sans'],
'liberation sans': ['arial'],
};
// baseRun = docDefaults ← the paragraph style's basedOn chain, i.e. exactly what the
// style gives its runs.
function blockDefaults(baseRun: RunProps, headingLevel: number | null, boldByDefault: boolean): BlockDefaults {
const fonts = new Set(headingLevel != null ? DEFAULT_HEADING_FONTS : DEFAULT_FONTS);
const family = baseRun.font?.toLowerCase();
if (family) {
fonts.add(family);
for (const twin of FONT_TWINS[family] ?? []) fonts.add(twin);
}
return {
fontSizePt: baseRun.sizeHalfPt != null ? baseRun.sizeHalfPt / 2
: headingLevel != null ? HEADING_SIZES[headingLevel - 1] : BODY_FONT_SIZE_PT,
boldByDefault: baseRun.bold ?? (headingLevel != null || boldByDefault),
fonts,
color: hexColor(baseRun.color) ?? '#000000',
italic: baseRun.italic ?? (headingLevel != null && HEADING_ITALIC[headingLevel - 1]),
underline: !!baseRun.underline,
strike: !!baseRun.strike,
caps: baseRun.caps || null,
};
}
// The style a paragraph names, else Word's default paragraph style.
function styleIdOf(ppr: Element | null, ctx: Ctx): string | null {
const ps = fc(ppr, 'pStyle');
return (ps ? wVal(ps) : null) ?? ctx.styles.defaultParagraphStyle();
}
// Word's own names for the standard styles map onto the editor's registry names.
// `isDefault` marks the document's default paragraph style (Word calls it Normal,
// LibreOffice writes styleId "Standard" with the name "Normal").
function registryName(id: string, wordName: string, isDefault: boolean): string {
if (isDefault || id === 'Normal' || /^normal$/i.test(wordName)) return DEFAULT_STYLE;
const heading = /^Heading\s?([1-9])$/i.exec(id) ?? /^heading\s?([1-9])$/i.exec(wordName);
if (heading) return `Heading ${heading[1]}`;
if (/^Title$/i.test(id)) return 'Title';
if (/^Subtitle$/i.test(id)) return 'Subtitle';
if (/^Quote$/i.test(id) || /^Quotations?$/i.test(id)) return 'Quotations';
return wordName || id;
}
// What a style declares itself: its resolved props minus the parent's.
function ownProps<T extends object>(resolved: T, parent: T): T {
const out = {} as T;
for (const key of Object.keys(resolved) as (keyof T)[]) {
if (resolved[key] !== undefined && resolved[key] !== parent[key]) out[key] = resolved[key];
}
return out;
}
function stylePara(ctx: Ctx, id: string | null): ParaProps {
if (!id) return {};
const out: ParaProps = {};
const sp = ctx.styles.paragraphSpacing(id);
if (sp.before != null) out.spaceBefore = snapPt(twipToPt(sp.before));
if (sp.after != null) out.spaceAfter = snapPt(twipToPt(sp.after));
// Not suppressed against single spacing — the document default may be something else
// (Word writes 1.08), and a style declaring 1 would then inherit that instead. What
// the parent style already supplies is dropped by the caller's ownProps.
if (sp.line != null && (!sp.lineRule || sp.lineRule === 'auto')) {
out.lineHeight = String(round2(sp.line / 240));
}
const jc = ctx.styles.paragraphAlign(id);
if (jc === 'center') out.textAlign = 'center';
else if (jc === 'both' || jc === 'distribute') out.textAlign = 'justify';
else if (jc === 'right' || jc === 'end') out.textAlign = 'right';
else if (jc === 'left' || jc === 'start') out.textAlign = 'left';
const ind = ctx.styles.styleIndentTwip(id);
if (ind != null) out.indent = round2(twipToCm(ind));
// The style's own rule lines and colored field — a Title's rule lives here, not on the
// block, so every paragraph the style governs draws it (styleCss).
const shd = ctx.styles.paragraphShading(id);
const fill = shd ? hexColor(shd.getAttributeNS(W, 'fill')) : undefined;
if (fill) out.backgroundColor = fill;
let space: number | null = null;
for (const [wSide, attr] of PARA_BORDER_SIDES) {
for (const pBdr of ctx.styles.paragraphBorders(id)) {
const side = fc(pBdr, wSide);
if (!side) continue;
const v = paraBorderAttr(side);
if (v) {
out[attr] = v;
// w:space is in whole points, per side; the widest gap is the paragraph's.
space = Math.max(space ?? 0, intAttr(side, W, 'space') ?? 0);
}
break; // the nearest level that declares the side decides it
}
}
if (space) out.borderPadding = space;
return out;
}
// `own` reads the style's own chain without docDefaults — a character style adds to the
// paragraph's formatting, so the document defaults are not its properties.
function styleText(ctx: Ctx, id: string | null, own = false): TextProps {
if (!id) return {};
// A character style adds to the run it decorates, so its own chain saying nothing
// about kerning means "unchanged", not Word's off.
if (own) { const t = runTextProps(ctx.styles.styleOwn(id)); delete t.kerning; return t; }
// A paragraph style naming no font uses the document's theme (minorHAnsi for body,
// majorHAnsi for headings) — the family single line spacing is measured against.
const run = ctx.styles.paragraphRun(id);
const font = run.font ?? ctx.styles.themeFont(run.fontTheme ?? 'minor');
return runTextProps(font ? { ...run, font } : run);
}
function runTextProps(run: RunProps): TextProps {
const out: TextProps = {};
// Our own export declares the metric twin; keep the registry on the on-screen name.
if (run.font) out.fontFamily = run.font === 'Times New Roman' ? 'Liberation Serif'
: run.font === 'Arial' ? 'Liberation Sans' : run.font;
if (run.sizeHalfPt != null) out.fontSizePt = Math.round((run.sizeHalfPt / 2) * 10) / 10;
if (run.spacingTwip) out.letterSpacingPt = Math.round((run.spacingTwip / 20) * 100) / 100;
// Word kerns nothing unless w:kern names the size to start at, and a document that
// says so for headings only leaves body text unkerned (probed against LibreOffice).
const kern = run.kernHalfPt ?? 0;
out.kerning = kern > 0 && (run.sizeHalfPt == null || run.sizeHalfPt >= kern);
if (run.bold != null) out.bold = run.bold;
if (run.italic != null) out.italic = run.italic;
if (run.underline != null) out.underline = run.underline;
if (run.strike != null) out.strike = run.strike;
const color = hexColor(run.color);
if (color) out.color = color;
if (run.caps) out.caps = run.caps;
return out;
}
// The document's style registry: the built-ins with the file's own definitions merged
// over them — only the styles blocks reference, plus their parent chains.
function collectStyleSheet(ctx: Ctx): StyleSheet {
const defs = ctx.styles.namedParagraphStyles();
const sheet = builtinStyleSheet();
const keep = new Set<string>();
// Body paragraphs carry no w:pStyle, so the default style never reaches usedStyles —
// yet run suppression resolves against it, so Standard has to carry it or the file's
// body font is dropped without any run mark taking its place.
const roots = new Set(ctx.usedStyles);
const defaultId = ctx.styles.defaultParagraphStyle();
if (defaultId) roots.add(defaultId);
for (const id of roots) {
let cur: string | null = id;
const seen = new Set<string>();
while (cur && defs.has(cur) && !seen.has(cur)) {
seen.add(cur);
keep.add(cur);
cur = defs.get(cur)!.basedOn;
}
}
for (const id of keep) {
const def = defs.get(id)!;
const name = ctx.styleNames.get(id) ?? def.name;
const parentId = def.basedOn;
const parent = parentId ? ctx.styleNames.get(parentId) ?? parentId : null;
const builtin = sheet.paragraph[name];
const style: Style = {
name,
parent: parent && parent !== name ? parent : builtin?.parent ?? null,
next: builtin?.next ?? null,
builtin: builtin?.builtin,
para: ownProps(stylePara(ctx, id), stylePara(ctx, parentId)),
text: ownProps(styleText(ctx, id), styleText(ctx, parentId)),
};
const level = /^Heading (\d)$/.exec(name);
if (level) style.outlineLevel = Number(level[1]);
sheet.paragraph[name] = style;
}
// w:docDefaults alone can carry the body font (a file need not declare a default
// style), and it is what run suppression compares against — so Standard tracks it.
const standard = sheet.paragraph[DEFAULT_STYLE];
if (standard) standard.text = { ...standard.text, ...runTextProps(ctx.styles.paragraphRun(null)) };
for (const id of ctx.usedCharStyles) {
const name = ctx.charStyleNames.get(id) ?? id;
const builtin = sheet.character[name];
sheet.character[name] = {
name, parent: null, next: null, builtin: builtin?.builtin,
para: {}, text: styleText(ctx, id, true),
};
}
return sheet;
}
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);
// Alignment: direct w:pPr/w:jc wins, else resolve it from the style chain (the default
// paragraph style commonly carries justify), so style-level alignment isn't lost.
const directJc = fc(ppr, 'jc');
const jcVal = directJc ? wVal(directJc) : ctx.styles.paragraphAlign(pStyle ? wVal(pStyle) : null);
const styleId = styleIdOf(ppr, ctx);
// Only DIRECT w:pPr counts as formatting on the block; the style's own lives in the
// registry — except in a cell, which carries no style name, so its chain is baked in
// over the table style's w:pPr (probed: that ranks *below* the paragraph style).
const attrs = blockAttrs(ppr, kind, level, directJc ? jcVal : null,
kind === 'cell' ? ctx.styles.paragraphSpacing(styleId, ctx.cellSpacing) : {});
applyContextualSpacing(el, ppr, ctx, styleId, attrs);
// Widow-orphan control has no registry home, so the resolved value rides the block.
const directWc = fc(ppr, 'widowControl');
if (!(directWc ? onOff(directWc) : ctx.styles.paragraphWidowControl(styleId))) {
attrs.widowControl = false;
}
const directKn = fc(ppr, 'keepNext');
if (!level && (directKn ? onOff(directKn) : ctx.styles.paragraphKeepNext(styleId))) attrs.keepNext = true;
const directKl = fc(ppr, 'keepLines');
if (!level && (directKl ? onOff(directKl) : ctx.styles.paragraphKeepLines(styleId))) attrs.keepLines = true;
// w:bidi — the block's own base direction; the section's own is inheritance, not
// formatting, so only a block that differs from it carries the attr.
const directBidi = fc(ppr, 'bidi');
const bidi = directBidi ? onOff(directBidi) : ctx.styles.paragraphBidi(styleId);
if (bidi != null && bidi !== ctx.pageRtl) attrs.dir = bidi ? 'rtl' : 'ltr';
// Tab stops: a direct w:tabs replaces the style's, which the resolver walks for.
const directTabs = fc(ppr, 'tabs');
const stops = formatTabStops(directTabs ? readTabStops(directTabs) : ctx.styles.paragraphTabs(styleId));
if (stops) attrs.tabStops = stops;
const baseRun = ctx.styles.paragraphRun(styleId);
const name = styleId && kind === 'body' ? ctx.styleNames.get(styleId) : undefined;
// A block that cannot carry its style's name — one in a cell or a text box — has to
// carry the formatting instead, so it is measured against the default style: the only
// thing re-applied on import. A caption's italic and colour live nowhere else.
const defaults = blockDefaults(name ? baseRun : ctx.styles.paragraphRun(ctx.styles.defaultParagraphStyle()), level, boldByDefault);
// A run inherits the block's own size, not the default style's, so that is what it is
// measured against — else a size the block overrides is suppressed and lost (odt.ts).
const ownSizePt = blockDefaults(baseRun, level, boldByDefault).fontSizePt;
const runDefaults = Math.abs(ownSizePt - defaults.fontSizePt) > 0.05
? { ...defaults, fontSizePt: ownSizePt }
: defaults;
const content = convertInline(el, ctx, baseRun, runDefaults, false);
if (name) {
ctx.usedStyles.add(styleId!);
if (name !== (level ? `Heading ${level}` : DEFAULT_STYLE)) attrs.styleName = name;
}
// The paragraph mark's own run props (w:pPr/w:rPr) set the line-height floor for
// every line, not just an empty one — a block whose text is smaller than its style
// would otherwise keep the style's taller strut. Carried as a block attr.
const fs = paragraphMarkFontSize(ppr, ctx, baseRun, defaults.fontSizePt);
if (fs) attrs.fontSize = fs;
const ff = paragraphMarkFont(ppr, ctx, baseRun, defaults.fonts);
if (ff) attrs.fontFamily = ff;
applyUniformRunFont(attrs, content);
sinkOffsetFrames(content);
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;
}
// w:contextualSpacing drops a paragraph's own spacing towards a neighbour of the same
// style — Word's List Paragraph carries it, which is why list points sit line-tight. The
// editor has no such mode, so the suppressed side becomes an explicit 0.
function applyContextualSpacing(el: Element, ppr: Element | null, ctx: Ctx, styleId: string | null, attrs: Record<string, unknown>): void {
const direct = fc(ppr, 'contextualSpacing');
if (!(direct ? onOff(direct) : ctx.styles.paragraphContextualSpacing(styleId))) return;
const sameStyle = (sib: Element | null) =>
!!sib && sib.namespaceURI === W && sib.localName === 'p' && styleIdOf(fc(sib, 'pPr'), ctx) === styleId;
if (sameStyle(el.previousElementSibling)) attrs.spaceBefore = 0;
if (sameStyle(el.nextElementSibling)) attrs.spaceAfter = 0;
}
// The paragraph mark's resolved font size (w:pPr/w:rPr, incl. its rStyle), as a CSS
// pt string, or null when it matches what the block renders at anyway (suppressed
// like run sizes, against the same yardstick).
function paragraphMarkFontSize(ppr: Element | null, ctx: Ctx, baseRun: RunProps, defaultPt: number): string | null {
const rPr = fc(ppr, 'rPr');
const rStyle = fc(rPr, 'rStyle');
const props = mergeRunProps(mergeRunProps(baseRun, ctx.styles.styleOwn(rStyle ? wVal(rStyle) : null)), parseRunProps(rPr));
if (props.sizeHalfPt == null) return null;
const sizePt = props.sizeHalfPt / 2;
return Math.abs(sizePt - defaultPt) > 0.05 ? `${Math.round(sizePt * 10) / 10}pt` : null;
}
// The paragraph mark's resolved font family (w:pPr/w:rPr/w:rFonts, incl. its rStyle),
// or null when it is what the block renders at anyway.
function paragraphMarkFont(ppr: Element | null, ctx: Ctx, baseRun: RunProps, blockFonts: Set<string>): string | null {
const rPr = fc(ppr, 'rPr');
const rStyle = fc(rPr, 'rStyle');
const props = mergeRunProps(mergeRunProps(baseRun, ctx.styles.styleOwn(rStyle ? wVal(rStyle) : null)), parseRunProps(rPr));
const font = props.font ?? ctx.styles.themeFont(props.fontTheme ?? 'minor');
return !font || blockFonts.has(font.toLowerCase()) ? null : font;
}
// 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(MAX_HEADING_LEVEL, 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;
}
// Spacing = the style chain's w:spacing (styleSpacing, resolved by the caller) overridden
// per-attribute by DIRECT w:pPr; indent comes from direct w:pPr only. jcVal is resolved
// through the chain by the caller.
function blockAttrs(ppr: Element | null, kind: BlockKind, headingLevel: number | null, jcVal: string | null, styleSpacing: ParaSpacing): Record<string, unknown> {
const attrs: Record<string, unknown> = {};
if (jcVal === 'center') attrs.textAlign = 'center';
else if (jcVal === 'both' || jcVal === 'distribute') attrs.textAlign = 'justify';
else if (jcVal === 'right' || jcVal === 'end') attrs.textAlign = 'right';
const sp = ppr ? fc(ppr, 'spacing') : null;
const before = (sp ? intAttr(sp, W, 'before') : null) ?? styleSpacing.before ?? null;
const after = (sp ? intAttr(sp, W, 'after') : null) ?? styleSpacing.after ?? null;
const line = (sp ? intAttr(sp, W, 'line') : null) ?? styleSpacing.line ?? null;
const rule = (sp && sp.getAttributeNS(W, 'lineRule')) || styleSpacing.lineRule || null;
// An attribute no layer sets is Word's implied 0, which is also the editor's paragraph
// default (editor.css) — so it stays unset rather than accreting an explicit 0.
if (before != null) attrs.spaceBefore = snapPt(twipToPt(before));
if (after != null) attrs.spaceAfter = snapPt(twipToPt(after));
// Single spacing is written out too: the block's own style may set another one, and
// then an unset attr is not the same thing.
if (line != null && (!rule || rule === 'auto')) attrs.lineHeight = String(round2(line / 240));
if (!ppr) return attrs;
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; }
const right = ind ? intAttr(ind, W, 'right') ?? intAttr(ind, W, 'end') : null;
if (right != null) { const cm = round2(twipToCm(right)); if (cm > LIST_INDENT_EPS_CM) attrs.indentRight = cm; }
// w:hanging outdents the first line, w:firstLine indents it; they are exclusive.
const hanging = ind ? intAttr(ind, W, 'hanging') : null;
const firstLine = ind ? intAttr(ind, W, 'firstLine') : null;
const first = hanging != null ? -twipToCm(hanging) : firstLine != null ? twipToCm(firstLine) : null;
if (first != null && Math.abs(first) > LIST_INDENT_EPS_CM) attrs.indentFirst = round2(first);
}
if (kind === 'body') {
const pb = fc(ppr, 'pageBreakBefore');
if (pb && onOff(pb)) attrs.breakBefore = 'page';
}
// Paragraph background ("colored field", w:shd) + per-side borders ("rule line", w:pBdr).
Object.assign(attrs, readParaBox(ppr));
return attrs;
}
// Paragraph background + per-side border attrs (paragraphBox.ts) from direct w:pPr.
// Shading: w:shd/@w:fill; borders: w:pBdr/w:{top,right,bottom,left} (w:sz in eighths of a
// point). Sides with w:val none/nil are skipped. Matches the ODF paraBoxAttrs shape.
function readParaBox(ppr: Element | null): Record<string, string> {
const out: Record<string, string> = {};
if (!ppr) return out;
const shd = fc(ppr, 'shd');
const fill = shd ? hexColor(shd.getAttributeNS(W, 'fill')) : undefined;
if (fill) out.backgroundColor = fill;
const pBdr = fc(ppr, 'pBdr');
if (pBdr) {
for (const [wSide, attr] of PARA_BORDER_SIDES) {
const v = paraBorderAttr(fc(pBdr, wSide));
if (v) out[attr] = v;
}
}
return out;
}
const PARA_BORDER_SIDES = [
['top', 'borderTop'], ['right', 'borderRight'], ['bottom', 'borderBottom'], ['left', 'borderLeft'],
] as const;
// One w:pBdr side → the canonical '<W>pt solid #RRGGBB'. null when it draws nothing:
// unlike a table cell, a paragraph has no 0.5pt-black default to collapse against.
function paraBorderAttr(b: Element | null): string | null {
if (!b) return null;
const val = b.getAttributeNS(W, 'val');
if (!val || val === 'none' || val === 'nil') return null;
const sz = intAttr(b, W, 'sz'); // eighths of a point
const widthPt = sz != null ? Math.round((sz / 8) * 100) / 100 : 0.5;
return `${widthPt}pt solid ${hexColor(b.getAttributeNS(W, 'color')) ?? '#000000'}`;
}
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) -----------------------
// Word wraps a citation or any content control in a w:sdt holding ordinary inline
// content — walk through the wrapper, or those runs never reach the paragraph.
function inlineChildren(el: Element): Element[] {
return Array.from(el.children).flatMap((c) => {
if (c.namespaceURI !== W || c.localName !== 'sdt') return [c];
const content = fc(c, 'sdtContent');
return content ? inlineChildren(content) : [];
});
}
// A w:footnoteReference → the anchor node, with the note's own paragraphs collected
// into ctx for the section the document end gets. Word's numbering is implicit, so the
// citation is counted here; several paragraphs flatten to hard breaks.
function noteRefNode(wid: string | null, kind: NoteKind, ctx: Ctx, baseRun: RunProps, defaults: BlockDefaults): Node | null {
const note = wid == null ? null : ctx.noteParts[kind].get(wid);
if (!note) return null;
const content: Node[] = [];
for (const para of Array.from(note.children)) {
if (para.namespaceURI !== W || para.localName !== 'p') continue;
const runs = convertInline(para, ctx, baseRun, defaults, false);
if (content.length && runs.length) content.push({ type: 'hardBreak' });
content.push(...runs);
}
// Word opens the note with its own marker run and a tab; the editor draws both from
// the note's own indent, so the leading tab would be a second one.
const first = content[0];
if (first?.type === 'text' && typeof first.text === 'string') {
first.text = first.text.replace(/^\t+/, '');
if (!first.text) content.shift();
}
const seen = ctx.notes.filter((n) => n.kind === kind).length;
const text = formatOrdinal(seen + 1, kind === 'endnote' ? 'i' : '1');
const id = `${kind}${wid}`;
// The note renders at the file's own size and indent: its first paragraph names the
// style (Word's FootnoteText), and collectStyleSheet only keeps a style in use.
const firstPara = Array.from(note.children).find((c) => c.namespaceURI === W && c.localName === 'p');
const pStyle = fc(fc(firstPara ?? null, 'pPr'), 'pStyle');
const styleId = pStyle ? wVal(pStyle) : null;
const styleName = styleId ? ctx.styleNames.get(styleId) ?? null : null;
if (styleId && styleName) ctx.usedStyles.add(styleId);
ctx.notes.push({ id, kind, text, content, styleName });
return { type: 'noteRef', attrs: { id, kind, text } };
}
function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockDefaults, hfFields: boolean): Node[] {
const out: Node[] = [];
let fieldMode: 'none' | 'instr' | 'result' = 'none';
let fieldInstr = '';
// A recognized body date/time field: its cached result runs are dropped and a live
// dateTimeField node is emitted in their place when the field ends.
let fieldDateTime: Node | null = null;
// Same for a REF/PAGEREF field and a caption's SEQ: the cached result runs are
// collected as the node's display text instead of being pushed.
let fieldCrossRef: Node | null = null;
let fieldSeq: Node | null = null;
let fieldResultText = '';
const pushText = (text: string, marks: Mark[]) => {
if (!text) return;
// The one-paragraph header/footer schema has neither a bookmark nor a comment mark.
const bookmark = hfFields ? null : openBookmarkMark(ctx);
const comment = hfFields ? null : openCommentMark(ctx);
const all = [...marks, ...(bookmark ? [bookmark] : []), ...(comment ? [comment] : [])];
const node: Node = { type: 'text', text };
if (all.length) node.marks = all;
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.
// Resolved marks for a run element (font/size/color/etc.), including a link mark.
const runMarks = (r: Element, linkHref?: string): Mark[] => {
const rPr = fc(r, 'rPr');
const rStyle = fc(rPr, 'rStyle');
const charId = rStyle ? wVal(rStyle) : null;
const charName = charId ? ctx.charStyleNames.get(charId) : undefined;
// A named character style: its formatting belongs to the style, so it joins the
// yardstick and the run only keeps what goes beyond it.
const styleRun = ctx.styles.styleOwn(charId);
const runDefaults = charName ? blockDefaults(mergeRunProps(baseRun, styleRun), null, defaults.boldByDefault) : defaults;
const props = mergeRunProps(mergeRunProps(baseRun, styleRun), parseRunProps(rPr));
// No font resolved anywhere: fall back to the document's own theme (not the editor
// default) — Word's implicit default is the minor font for body text, the major one
// for headings.
if (!props.font) {
props.font = ctx.styles.themeFont(props.fontTheme ?? (runDefaults.boldByDefault ? 'major' : 'minor'));
}
const marks = marksFor(props, runDefaults, !!linkHref);
if (charName) {
ctx.usedCharStyles.add(charId!);
marks.push({ type: 'charStyle', attrs: { name: charName } });
}
if (linkHref) {
// Word never paints a hyperlink for being one: the blue is its Hyperlink character
// style, which arrives as an ordinary run mark, so the editor must add nothing of
// its own. Only our exporter's blue is an editor link — it is stripped above.
const ours = hexColor(props.color)?.toUpperCase() === LINK_BLUE;
marks.push({ type: 'link', attrs: { href: linkHref, plain: !ours } });
}
return marks;
};
const handleRun = (r: Element, linkHref?: string) => {
const marks = runMarks(r, linkHref);
// Hide a field's cached result: always for a hf field, and for a recognized body
// date/time field (replaced by its live node).
const skipResult = () => fieldMode === 'result' && (hfFields || !!fieldDateTime || !!fieldCrossRef);
// Route a drawing/pict result: an image is inline, a text box a block node riding
// ctx.pendingBlocks. The one-paragraph header/footer zone takes as-char images only —
// boxes and floating page-sized drawings (backgrounds, watermarks) are dropped there.
const pushDrawn = (n: Node | null, floating: boolean) => {
if (!n) return;
if (hfFields) {
if (n.type === 'image' && !floating) { out.push({ ...n, attrs: { ...n.attrs, wrap: 'inline' } }); return; }
ctx.warnings.add('Drawings were removed');
return;
}
if (n.type !== 'textBox') { out.push(n); return; }
ctx.pendingBlocks.push(n);
};
for (const child of Array.from(r.children)) {
// Word wraps every shape in mc:AlternateContent; use only the mc:Choice branch
// (the mc:Fallback VML duplicates it and would double-import).
if (child.namespaceURI === MC && child.localName === 'AlternateContent') {
const choice = Array.from(child.children).find((c) => c.namespaceURI === MC && c.localName === 'Choice');
const drawing = choice?.getElementsByTagNameNS(W, 'drawing')[0];
if (drawing) pushDrawn(convertDrawing(drawing, ctx), drawingIsFloating(drawing));
else {
const pict = Array.from(child.children)
.find((c) => c.namespaceURI === MC && c.localName === 'Fallback')
?.getElementsByTagNameNS(W, 'pict')[0];
if (pict) pushDrawn(convertPict(pict, ctx), drawingIsFloating(pict));
else ctx.warnings.add('Drawings were removed');
}
continue;
}
if (child.namespaceURI !== W) continue;
switch (child.localName) {
case 'fldChar': {
const t = child.getAttributeNS(W, 'fldCharType');
if (t === 'begin') { fieldMode = 'instr'; fieldInstr = ''; fieldDateTime = null; fieldCrossRef = null; fieldSeq = null; fieldResultText = ''; }
else if (t === 'separate') {
fieldMode = 'result';
if (!hfFields) {
fieldDateTime = dateTimeFieldFromInstr(fieldInstr);
fieldSeq = fieldDateTime ? null : seqFieldFromInstr(fieldInstr);
fieldCrossRef = fieldDateTime || fieldSeq ? null : crossRefFromInstr(fieldInstr);
fieldResultText = '';
// Carry the field run's marks so the atom renders in the run's font.
const field = fieldDateTime ?? fieldSeq ?? fieldCrossRef;
if (field && marks.length) field.marks = marks;
}
} else if (t === 'end') {
// A reference Word never resolved has no w:separate and so no cached result
// (it shows nothing until the field is updated); the node view fills it in
// when the bookmark is still there.
if (!hfFields && !fieldDateTime && !fieldCrossRef && !fieldSeq) {
fieldCrossRef = crossRefFromInstr(fieldInstr);
if (fieldCrossRef && marks.length) fieldCrossRef.marks = marks;
}
if (fieldDateTime) out.push(fieldDateTime);
else if (fieldSeq) out.push({ ...fieldSeq, attrs: { ...fieldSeq.attrs, number: seqNumberOf(fieldResultText) } });
else if (fieldCrossRef) out.push({ ...fieldCrossRef, attrs: { ...fieldCrossRef.attrs, text: fieldResultText } });
else emitField(out, fieldInstr, hfFields, marks);
fieldMode = 'none';
fieldDateTime = null;
fieldCrossRef = null;
fieldSeq = null;
}
break;
}
case 'instrText': if (fieldMode === 'instr') fieldInstr += child.textContent ?? ''; break;
// w:delText is a deleted run's text — the same content, kept because the
// revision is only recorded, not applied.
case 'delText':
case 't':
if ((fieldCrossRef || fieldSeq) && fieldMode === 'result') fieldResultText += child.textContent ?? '';
else if (!skipResult()) pushText(child.textContent ?? '', marks);
break;
case 'tab': if (!skipResult()) pushText('\t', marks); break;
case 'br': out.push(child.getAttributeNS(W, 'type') === 'page' ? { type: PB_MARKER } : hardBreakNode(marks)); break;
case 'cr': out.push(hardBreakNode(marks)); break;
case 'drawing': pushDrawn(convertDrawing(child, ctx), drawingIsFloating(child)); break;
case 'pict': pushDrawn(convertPict(child, ctx), drawingIsFloating(child)); break;
case 'footnoteReference':
case 'endnoteReference': {
// The zone schema has no notes, and Word's own separator notes are referenced
// by nothing — only a real anchor reaches here.
const kind: NoteKind = child.localName === 'endnoteReference' ? 'endnote' : 'footnote';
const ref = hfFields ? null : noteRefNode(child.getAttributeNS(W, 'id'), kind, ctx, baseRun, defaults);
if (ref) out.push(ref);
break;
}
}
}
};
for (const el of inlineChildren(p)) {
// Math is its own namespace and sits beside the w:r runs, so it has to be picked
// up before the w:-only guard below drops it.
if (el.namespaceURI === OMML_NS && (el.localName === 'oMath' || el.localName === 'oMathPara')) {
const formula = formulaNode(el, ctx);
if (formula) out.push(formula);
continue;
}
if (el.namespaceURI !== W) continue;
if (trackBookmark(el, ctx) || trackComment(el, ctx)) continue;
switch (el.localName) {
case 'r': handleRun(el); break;
case 'hyperlink': {
const rid = el.getAttributeNS(R, 'id');
// No relationship id: an internal link to a bookmark in this document.
const anchor = el.getAttributeNS(W, 'anchor');
const href = rid ? ctx.rels.get(rid)?.target : anchor ? `#${anchor}` : undefined;
for (const r of fcAll(el, 'r')) handleRun(r, href);
break;
}
case 'fldSimple': {
const instr = el.getAttributeNS(W, 'instr') ?? '';
if (hfFields) { const first = fcAll(el, 'r')[0]; emitField(out, instr, true, first ? runMarks(first) : []); break; }
const xref = crossRefFromInstr(instr);
if (xref) {
const first = fcAll(el, 'r')[0];
const m = first ? runMarks(first) : [];
if (m.length) xref.marks = m;
out.push({ ...xref, attrs: { ...xref.attrs, text: el.textContent ?? '' } });
break;
}
const seq = seqFieldFromInstr(instr);
if (seq) {
const first = fcAll(el, 'r')[0];
const m = first ? runMarks(first) : [];
if (m.length) seq.marks = m;
out.push({ ...seq, attrs: { ...seq.attrs, number: seqNumberOf(el.textContent ?? '') } });
break;
}
const field = dateTimeFieldFromInstr(instr);
if (field) {
const first = fcAll(el, 'r')[0];
const m = first ? runMarks(first) : [];
if (m.length) field.marks = m;
out.push(field);
break;
}
for (const r of fcAll(el, 'r')) handleRun(r); // body: keep the shown value
break;
}
case 'ins':
case 'del': {
// A recorded revision. Its runs keep their formatting and take the mark; a
// deletion's text sits in w:delText, which handleRun reads as ordinary text.
const kind = el.localName === 'del' ? 'deletion' : 'insertion';
const attrs = {
id: el.getAttributeNS(W, 'id') ?? '',
author: el.getAttributeNS(W, 'author') ?? '',
date: el.getAttributeNS(W, 'date') ?? '',
};
const before = out.length;
for (const r of fcAll(el, 'r')) handleRun(r);
if (!hfFields) {
for (let i = before; i < out.length; i++) {
if (out[i].type !== 'text') continue;
out[i].marks = [...(out[i].marks ?? []), { type: kind, attrs }];
}
}
break;
}
case 'smartTag':
for (const r of fcAll(el, 'r')) handleRun(r);
break;
}
}
return mergeAdjacentText(out);
}
// <m:oMath> / <m:oMathPara> → a formula node. Word stores no size for a formula, so
// the node view measures it once on screen and writes the ODF frame geometry back.
function formulaNode(el: Element, ctx: Ctx): Node | null {
const { ast, display } = parseOmml(el);
// Stored verbatim, not trimmed: a macro's own trailing space is what makes the
// source re-serialize to itself, so trimming it would rewrite the formula on edit.
const latex = astToLatex(ast);
if (!latex.trim()) { ctx.warnings.add('Some formulas could not be read and were skipped'); return null; }
return { type: 'formula', attrs: { latex, display } };
}
function emitField(out: Node[], instr: string, hfFields: boolean, marks: Mark[] = []): void {
if (!hfFields) return;
// The atom carries the field run's marks so its digits render in the run's font/size.
const push = (type: string) => out.push(marks.length ? { type, marks } : { type });
if (/\bNUMPAGES\b/.test(instr)) push('pageCount');
else if (/\bPAGE\b/.test(instr)) push('pageNumber');
// Word's running head: STYLEREF on a heading style → the live chapter field. The
// level rides in the style name ("Heading 2", or a localized "Überschrift 2").
else {
const m = /\bSTYLEREF\s+"?[^"\d]*(\d)/i.exec(instr);
if (m) out.push({ type: 'chapterField', attrs: { level: Number(m[1]), text: '' }, ...(marks.length ? { marks } : {}) });
}
}
// A REF/PAGEREF field instruction → a cross-reference node; its cached result text is
// filled in by the caller when the field closes. Every other field (SEQ, CITATION, …)
// returns null and keeps showing the text the producer cached.
function crossRefFromInstr(instr: string): Node | null {
const m = /^\s*(PAGEREF|REF)\s+(\S+)/i.exec(instr);
if (!m) return null;
return { type: 'crossRef', attrs: { name: m[2], format: /^PAGEREF$/i.test(m[1]) ? 'page' : 'text', text: '' } };
}
// Word's numeric-picture switch → the ODF num-format the editor stores. Anything else
// counts in arabic, which is what Word itself falls back to.
const DOCX_SEQ_FORMAT: Record<string, NoteNumFormat> = {
alphabetic: 'a', ALPHABETIC: 'A', roman: 'i', ROMAN: 'I',
};
// `SEQ Figure \* ARABIC` → a caption's running number. A counter under a name the
// editor doesn't count (a footnote sequence, a user's own) stays the field's text.
function seqFieldFromInstr(instr: string): Node | null {
const m = /^\s*SEQ\s+("[^"]*"|\S+)/i.exec(instr);
if (!m) return null;
const category = ODF_SEQ_CATEGORY[m[1].replace(/"/g, '')];
if (!category) return null;
const sw = /\\\*\s*(\w+)/.exec(instr)?.[1] ?? '';
return { type: 'sequenceField', attrs: { category, format: DOCX_SEQ_FORMAT[sw] ?? '1', number: 1 } };
}
// The rank a field cached, from its result text. Only an arabic one reads back; the
// editor's own numbering pass fixes the rest on load.
function seqNumberOf(text: string): number {
const n = parseInt(text.replace(/\D/g, ''), 10);
return Number.isFinite(n) && n > 0 ? n : 1;
}
// A DATE/TIME field instruction with a picture switch we recognize → a live
// dateTimeField node (always auto: Word writes a fixed date as plain text). Returns
// null for an unknown picture so the caller keeps the field's shown text.
function dateTimeFieldFromInstr(instr: string): Node | null {
if (!/\b(DATE|TIME)\b/i.test(instr)) return null;
const m = /\\@\s*"([^"]*)"/.exec(instr);
if (!m) return null;
const fmt = [...DATE_FORMATS, ...TIME_FORMATS].find((f) => docxPicture(f) === m[1]);
if (!fmt) return null;
return { type: 'dateTimeField', attrs: { kind: fmt.kind, format: fmt.key, fixed: false, value: toDateValue(new Date()) } };
}
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;
}
// A hardBreak carries its run's marks so an empty line between two breaks keeps the run's
// font size (Word/LibreOffice render such a line at that size, not the paragraph default).
function hardBreakNode(marks: Mark[]): Node {
const n: Node = { type: 'hardBreak' };
if (marks.length) n.marks = marks;
return n;
}
function marksFor(props: RunProps, defaults: BlockDefaults, 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 (defaults.boldByDefault) {
if (props.bold === false) textStyle.fontWeight = 'normal';
} else if (props.bold === true) {
marks.push({ type: 'bold' });
}
// Marks the block's named style already renders need no mark of their own.
if (props.italic && !defaults.italic) marks.push({ type: 'italic' });
if (props.underline && !inLink && !defaults.underline) { // link underline is the CSS default
const line = underlineAttrs(props);
marks.push(line ? { type: 'underline', attrs: line } : { type: 'underline' });
}
if (props.strike && !defaults.strike) {
marks.push({ type: 'strike', ...(props.doubleStrike ? { attrs: { lineStyle: 'double' } } : {}) });
}
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 !== defaults.color) textStyle.color = color;
const sizePt = props.sizeHalfPt != null ? props.sizeHalfPt / 2 : null;
if (sizePt != null && Math.abs(sizePt - defaults.fontSizePt) > 0.05) textStyle.fontSize = `${Math.round(sizePt * 10) / 10}pt`;
if (props.font && !defaults.fonts.has(props.font.toLowerCase())) textStyle.fontFamily = props.font;
if (props.caps && props.caps !== defaults.caps) textStyle.caps = props.caps;
if (props.positionPt) textStyle.textPosition = props.positionPt;
if (Object.keys(textStyle).length) marks.push({ type: 'textStyle', attrs: textStyle });
return marks;
}
// Word names a dozen underline styles; CSS draws four shapes, so each name maps to the
// one it looks like. 'single' is the default and needs no attr.
const UNDERLINE_STYLE: Record<string, LineStyle> = {
double: 'double', wavyDouble: 'double',
dotted: 'dotted', dottedHeavy: 'dotted', dotDash: 'dotted', dotDotDash: 'dotted',
dash: 'dashed', dashedHeavy: 'dashed', dashLong: 'dashed', dashLongHeavy: 'dashed',
dashDotHeavy: 'dashed', dashDotDotHeavy: 'dashed',
wave: 'wavy', wavyHeavy: 'wavy',
};
function underlineAttrs(props: RunProps): Record<string, unknown> | undefined {
const attrs: Record<string, unknown> = {};
const style = props.underlineVal ? UNDERLINE_STYLE[props.underlineVal] : undefined;
if (style) attrs.lineStyle = style;
const color = hexColor(props.underlineColor);
if (color) attrs.lineColor = color;
return Object.keys(attrs).length ? attrs : undefined;
}
// ---- images -----------------------------------------------------------------
function loadImageDataUrl(path: string, ctx: Ctx): string | null {
const cached = ctx.imageCache.get(path);
if (cached) return cached;
// A format the browser can't render may have been pre-decoded to PNG by the async pass.
const converted = ctx.convertedImages.get(path);
if (converted) { ctx.imageCache.set(path, converted); return converted; }
const bytes = ctx.files[path];
if (!bytes) return null;
const url = imageDataUrl(bytes, path);
if (!url) return null;
ctx.imageCache.set(path, url);
return url;
}
// True when a drawing floats rather than sitting in the text flow: DrawingML `wp:anchor`,
// or a VML shape with `position:absolute`. Header/footer import drops these (see pushDrawn).
function drawingIsFloating(el: Element): boolean {
if (el.localName === 'drawing') return el.getElementsByTagNameNS(WP, 'anchor').length > 0;
const shape = Array.from(el.getElementsByTagNameNS(VML, '*')).find((c) =>
['shape', 'rect', 'oval', 'roundrect'].includes(c.localName),
);
return /(^|;)\s*position\s*:\s*absolute/i.test(shape?.getAttribute('style') ?? '');
}
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;
// A wordprocessingShape (text box / preset shape) has no blip — convert it first.
const wsp = root.getElementsByTagNameNS(WPS, 'wsp')[0];
if (wsp) return convertWpsShape(wsp, root, !!anchor, ctx);
const extentEl = root.getElementsByTagNameNS(WP, 'extent')[0];
const boxPx = {
w: framePx(emuToPx(intAttr(extentEl, '', 'cx') ?? 0)),
h: framePx(emuToPx(intAttr(extentEl, '', 'cy') ?? 0)),
};
const blip = drawing.getElementsByTagNameNS(A, 'blip')[0];
if (!blip) {
if (boxPx.w > 0 && boxPx.h > 0) {
// A chart is drawn from its own part as a picture; anything else the editor
// cannot draw still occupies its box, as a placeholder of that size.
const drawn = chartImage(drawing, boxPx, ctx);
if (drawn) return frameNode(drawn, boxPx, 'Chart', anchor, ctx);
ctx.warnings.add('Charts and other drawings were replaced by a placeholder');
return frameNode(placeholderImage('Chart', boxPx.w, boxPx.h), boxPx, 'Chart', anchor, ctx);
}
ctx.warnings.add('Drawings were removed');
return null;
}
// Candidate media: the primary blip plus any SVG alternative (Word 2016+ stores an
// svgBlip beside a raster fallback) — prefer whichever the browser can display.
const candidates: string[] = [];
const pushEmbed = (id: string | null) => {
const rel = id ? ctx.rels.get(id) : undefined;
if (rel && !rel.external) candidates.push(`word/${rel.target.replace(/^\/+/, '')}`);
};
pushEmbed(blip.getAttributeNS(R, 'embed'));
for (const sb of Array.from(blip.getElementsByTagName('*')))
if (sb.localName === 'svgBlip') pushEmbed(sb.getAttributeNS(R, 'embed'));
if (!candidates.length) { ctx.warnings.add('Some images could not be read and were skipped'); return null; }
const path = candidates.find(p => loadImageDataUrl(p, ctx)) ?? candidates[0];
const src = loadImageDataUrl(path, ctx);
if (!src) {
if (boxPx.w > 0 && boxPx.h > 0) {
ctx.warnings.add('Images in a format the browser can’t display (e.g. WMF, EMF) were replaced by a placeholder');
return frameNode(placeholderImage('Image', boxPx.w, boxPx.h), boxPx, 'Image', anchor, ctx);
}
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 = framePx(emuToPx(cx));
if (cy) attrs.height = framePx(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) {
const { wrap, offsetCm, offsetYCm, alignH, distCm } = anchorWrap(anchor, ctx);
attrs.wrap = wrap;
if (offsetCm != null) attrs.wrapOffset = offsetCm;
if (offsetYCm != null) attrs.wrapOffsetY = offsetYCm;
if (distCm != null) attrs.wrapDist = distCm;
if (alignH && wrap === 'topBottom') attrs.wrapAlign = alignH;
} else {
fitInlineImage(attrs, Math.floor(cmToPx(ctx.contentWidthCm)));
}
return { type: 'image', attrs };
}
// theme1.xml's accent1..6 as #RRGGBB, in order — a chart series names them by role.
function themeAccents(theme: Document | null): string[] {
const scheme = theme?.getElementsByTagNameNS(A, 'clrScheme')[0];
return [1, 2, 3, 4, 5, 6].map((n) => {
const el = scheme ? Array.from(scheme.children).find((c) => c.localName === `accent${n}`) : null;
const srgb = el ? el.getElementsByTagNameNS(A, 'srgbClr')[0]?.getAttribute('val') : null;
return srgb ? `#${srgb}` : '';
}).filter(Boolean);
}
// <c:chart r:id> → the chart part, drawn as a picture of the frame's own size.
function chartImage(drawing: Element, box: { w: number; h: number }, ctx: Ctx): string | null {
const ref = Array.from(drawing.getElementsByTagName('*')).find((e) => e.localName === 'chart' && e.getAttributeNS(R, 'id'));
const rel = ref ? ctx.rels.get(ref.getAttributeNS(R, 'id')!) : undefined;
if (!rel || rel.external) return null;
const bytes = ctx.files[`word/${rel.target.replace(/^\/+/, '')}`];
return bytes ? chartDataUrl(strFromU8(bytes), box.w, box.h, ctx.accents) : null;
}
// A frame the editor draws itself (a chart) or cannot draw at all, as an image node of
// the same size, wrapped like the real one.
function frameNode(src: string, box: { w: number; h: number }, label: string, anchor: Element | undefined, ctx: Ctx): Node {
const attrs: Record<string, unknown> = { src, width: box.w, height: box.h, alt: label };
if (anchor) {
const { wrap, offsetCm, offsetYCm, alignH, distCm } = anchorWrap(anchor, ctx);
attrs.wrap = wrap;
if (offsetCm != null) attrs.wrapOffset = offsetCm;
if (offsetYCm != null) attrs.wrapOffsetY = offsetYCm;
if (distCm != null) attrs.wrapDist = distCm;
if (alignH && wrap === 'topBottom') attrs.wrapAlign = alignH;
} else {
fitInlineImage(attrs, Math.floor(cmToPx(ctx.contentWidthCm)));
}
return { type: 'image', attrs };
}
// How far below its anchor paragraph the frame sits. Only the paragraph- and
// line-relative forms have a CSS equivalent (the float's top margin); page- or
// margin-relative ones are absolute on the sheet, which a frame in flow cannot be.
function anchorOffsetY(anchor: Element): number | null {
const posV = anchor.getElementsByTagNameNS(WP, 'positionV')[0];
const from = posV?.getAttribute('relativeFrom');
if (from !== 'paragraph' && from !== 'line') return null;
const off = parseInt(posV?.getElementsByTagNameNS(WP, 'posOffset')[0]?.textContent ?? '', 10);
return Number.isFinite(off) && off > 0 ? round2(off / 360000) : null;
}
// The frame's own x in the text column, cm from its left edge. null where the file
// names an alignment instead of a coordinate. A page-relative offset counts from the
// sheet edge — shift it to the column so one number serves both.
function anchorOffsetX(anchor: Element, ctx: Ctx): number | null {
const posH = anchor.getElementsByTagNameNS(WP, 'positionH')[0];
if (posH?.getElementsByTagNameNS(WP, 'align')[0]) return null;
const off = parseInt(posH?.getElementsByTagNameNS(WP, 'posOffset')[0]?.textContent ?? '', 10);
if (!Number.isFinite(off)) return null;
const base = posH?.getAttribute('relativeFrom') === 'page' ? -cmToEmu(ctx.leftMarginCm) : 0;
return round2((off + base) / 360000);
}
// Wrap mode and place are independent: the mode is what the file's wrap element says,
// the place its position offsets. Only where neither names a side does the frame's own
// x decide which half of the column it fills (text flows on one side of a CSS float).
function anchorWrap(anchor: Element, ctx: Ctx): { wrap: 'left' | 'right' | 'topBottom'; offsetCm: number | null; offsetYCm: number | null; alignH: 'left' | 'right' | null; distCm: number | null } {
const offsetYCm = anchorOffsetY(anchor);
const offsetCm = anchorOffsetX(anchor, ctx);
const align = anchor.getElementsByTagNameNS(WP, 'positionH')[0]
?.getElementsByTagNameNS(WP, 'align')[0]?.textContent?.trim();
const alignH: 'left' | 'right' | null = align === 'right' || align === 'outside' ? 'right'
: align === 'left' || align === 'inside' ? 'left' : null;
// Only the side the text flows on: the gap on the other one is the frame's offset.
const distOf = (wrap: string) => {
const emu = parseInt(anchor.getAttribute(wrap === 'right' ? 'distL' : 'distR') ?? '', 10);
return Number.isFinite(emu) && emu > 0 ? round2(emu / 360000) : null;
};
const at = (wrap: 'left' | 'right' | 'topBottom') => ({ wrap, offsetCm, offsetYCm, alignH, distCm: distOf(wrap) });
if (anchor.getElementsByTagNameNS(WP, 'wrapTopAndBottom')[0]) return at('topBottom');
const wt = anchor.getElementsByTagNameNS(WP, 'wrapSquare')[0]?.getAttribute('wrapText');
if (wt === 'right') return at('left'); // text on right ⇒ image on left
if (wt === 'left') return at('right');
if (align === 'right' || align === 'outside') return at('right');
if (align) return at('left');
if (offsetCm == null) return at('left');
const cx = intAttr(anchor.getElementsByTagNameNS(WP, 'extent')[0], '', 'cx') ?? 0;
return at(cmToEmu(offsetCm) + cx / 2 > cmToEmu(ctx.contentWidthCm) / 2 ? 'right' : 'left');
}
// ---- text boxes / shapes ------------------------------------------------------
function nsChild(el: Element | null, ns: string, localName: string): Element | null {
if (!el) return null;
for (const c of Array.from(el.children)) if (c.namespaceURI === ns && c.localName === localName) return c;
return null;
}
const KIND_BY_PRST: Record<string, 'textbox' | 'roundRect' | 'ellipse'> = {
rect: 'textbox',
roundRect: 'roundRect',
ellipse: 'ellipse',
};
// Fill/stroke attrs from a shape's spPr (or VML equivalents), suppressing the editor
// defaults (white fill, 1pt black stroke); none → explicit null, since omitting the
// attr would re-apply the default.
function setShapeStyleAttrs(attrs: Record<string, unknown>, fill: string | null, stroke: string | null, strokeWidthPt: number | null): void {
if (fill !== '#FFFFFF') attrs.fillColor = fill;
if (stroke !== '#000000') attrs.strokeColor = stroke;
if (stroke && strokeWidthPt != null && Math.abs(strokeWidthPt - 1) > 0.1) {
attrs.strokeWidthPt = Math.round(strokeWidthPt * 100) / 100;
}
}
// A DrawingML <wps:wsp> (text box or preset shape) → a textBox node. Unsupported
// preset geometries (stars, arrows, …) are dropped with a warning. All property
// lookups are scoped to spPr so a nested image's fill/xfrm can't leak in.
function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ctx): Node | null {
const spPr = nsChild(wsp, WPS, 'spPr');
const prst = nsChild(spPr, A, 'prstGeom')?.getAttribute('prst') ?? 'rect';
const kind = KIND_BY_PRST[prst];
if (!kind) { ctx.warnings.add('Unsupported shapes were removed'); return null; }
const attrs: Record<string, unknown> = {};
if (kind !== 'textbox') attrs.shapeKind = kind;
const extent = root.getElementsByTagNameNS(WP, 'extent')[0];
const cx = intAttr(extent, '', 'cx');
const cy = intAttr(extent, '', 'cy');
if (cx) attrs.width = framePx(emuToPx(cx));
if (cy) attrs.height = framePx(emuToPx(cy));
const rot = intAttr(nsChild(spPr, A, 'xfrm'), '', 'rot');
if (rot) attrs.rotation = ((Math.round(rot / 60000) % 360) + 360) % 360;
if (isAnchor) {
const { wrap, offsetCm, offsetYCm, distCm } = anchorWrap(root, ctx);
attrs.wrap = wrap;
if (offsetCm != null) attrs.wrapOffset = offsetCm;
if (offsetYCm != null) attrs.wrapOffsetY = offsetYCm;
if (distCm != null) attrs.wrapDist = distCm;
}
const fillClr = nsChild(nsChild(spPr, A, 'solidFill'), A, 'srgbClr')?.getAttribute('val');
const fill = fillClr ? hexColor(fillClr) ?? null : null;
const ln = nsChild(spPr, A, 'ln');
const lnClr = nsChild(nsChild(ln, A, 'solidFill'), A, 'srgbClr')?.getAttribute('val');
const stroke = ln && !nsChild(ln, A, 'noFill') ? hexColor(lnClr ?? '000000') ?? '#000000' : null;
const lnW = intAttr(ln, '', 'w');
setShapeStyleAttrs(attrs, fill, stroke, lnW != null ? lnW / 12700 : null);
const txbxContent = nsChild(nsChild(wsp, WPS, 'txbx'), W, 'txbxContent');
const blocks = txbxContent ? convertBlocks(Array.from(txbxContent.children), ctx, 'cell') : [];
return { type: 'textBox', attrs, content: blocks.length ? blocks : [{ type: 'paragraph' }] };
}
// Legacy VML (w:pict): v:rect/v:oval/v:roundrect/v:shape with a v:textbox. Geometry
// comes from the CSS-ish style string; shapes without text content are dropped.
function convertPict(pict: Element, ctx: Ctx): Node | null {
const shape = Array.from(pict.children).find(
(c) => c.namespaceURI === VML && ['shape', 'rect', 'oval', 'roundrect'].includes(c.localName),
);
if (!shape) { ctx.warnings.add('Drawings were removed'); return null; }
// The watermark rides the page decoration (storage/pageDecor.ts), not the header's
// content, so it must not also arrive here as a shape.
if (shape.getAttribute('id') === WATERMARK_NAME) return null;
const style = shape.getAttribute('style') ?? '';
const dimPx = (key: string): number | null => {
const m = new RegExp(`(?:^|;)\\s*${key}:\\s*([\\d.]+)(pt|in|cm|mm|px)?`).exec(style);
if (!m) return null;
const v = parseFloat(m[1]);
const u = m[2] ?? 'pt';
const pt = u === 'pt' ? v : u === 'in' ? v * 72 : u === 'cm' ? (v * 72) / 2.54 : u === 'mm' ? (v * 7.2) / 2.54 : v * 0.75;
return Math.round((pt * 96) / 72);
};
const w = dimPx('width');
const h = dimPx('height');
// A v:imagedata is a legacy inline picture (no text box) → an image node.
const imagedata = shape.getElementsByTagNameNS(VML, 'imagedata')[0];
const imgRel = imagedata ? ctx.rels.get(imagedata.getAttributeNS(R, 'id') ?? '') : undefined;
if (imagedata && imgRel && !imgRel.external) {
const path = `word/${imgRel.target.replace(/^\/+/, '')}`;
const src = loadImageDataUrl(path, ctx);
if (!src) {
ctx.warnings.add(ctx.files[path]
? 'Images in a format the browser can’t display (e.g. WMF, EMF, TIFF) were removed'
: 'Some images could not be read and were skipped');
return null;
}
const imgAttrs: Record<string, unknown> = { src };
if (w) imgAttrs.width = w;
if (h) imgAttrs.height = h;
return { type: 'image', attrs: imgAttrs };
}
const txbxContent = nsChild(shape.getElementsByTagNameNS(VML, 'textbox')[0] ?? null, W, 'txbxContent');
if (!txbxContent) { ctx.warnings.add('Drawings were removed'); return null; }
const attrs: Record<string, unknown> = {};
const kind = shape.localName === 'oval' ? 'ellipse' : shape.localName === 'roundrect' ? 'roundRect' : 'textbox';
if (kind !== 'textbox') attrs.shapeKind = kind;
if (w) attrs.width = w;
if (h) attrs.height = h;
const fillAttr = shape.getAttribute('fillcolor');
const fill = fillAttr ? normalizeColor(fillAttr) ?? null : null;
const stroked = shape.getAttribute('stroked');
const stroke = stroked === 'f' || stroked === 'false'
? null
: normalizeColor(shape.getAttribute('strokecolor') ?? '#000000') ?? '#000000';
const swm = /^([\d.]+)\s*(pt)?$/.exec(shape.getAttribute('strokeweight') ?? '');
setShapeStyleAttrs(attrs, fill, stroke, swm ? parseFloat(swm[1]) : null);
const blocks = convertBlocks(Array.from(txbxContent.children), ctx, 'cell');
return { type: 'textBox', attrs, content: blocks.length ? blocks : [{ type: 'paragraph' }] };
}
// ---- tables -----------------------------------------------------------------
// A w:tcBorders/w:tblBorders side element → border attr value: null = the editor
// default (0.5pt black), 'none', or '<W>pt solid #RRGGBB' (w:sz is eighth-points).
// undefined when the element is absent (side not declared at this level).
function docxBorderAttr(el: Element | null): string | null | undefined {
if (!el) return undefined;
const val = el.getAttributeNS(W, 'val');
if (!val || val === 'none' || val === 'nil') return 'none';
const sz = intAttr(el, W, 'sz');
const w = Math.round(((sz != null ? sz / 8 : 0.5)) * 100) / 100;
const c = (hexColor(el.getAttributeNS(W, 'color')) ?? '#000000').toUpperCase();
if (Math.abs(w - 0.5) < 0.11 && c === '#000000') return null;
return `${w}pt solid ${c}`;
}
// A conditional table-style area (w:tblStylePr) and the grid box it covers. A band's box
// is the whole banded region, not the single band: that is what makes its insideH the
// line *between* two band rows, which is how Word and LibreOffice draw one.
type GridBox = { row: number; col: number; rowEnd: number; colEnd: number };
type CondArea = { el: Element; box: GridBox };
type LookFlags = { firstRow: boolean; lastRow: boolean; firstCol: boolean; lastCol: boolean; hBand: boolean; vBand: boolean };
// w:tblLook: Word 2010+ writes the named attributes, older files only the w:val bitmask.
// No element at all means no conditional area applies.
function docxLookFlags(tblPr: Element | null): LookFlags {
const el = fc(tblPr, 'tblLook');
const bits = parseInt(el?.getAttributeNS(W, 'val') ?? '', 16);
const on = (name: string, bit: number) => {
const v = el?.getAttributeNS(W, name);
if (v != null) return v === '1' || v === 'true';
return Number.isFinite(bits) && (bits & bit) !== 0;
};
if (!el) return { firstRow: false, lastRow: false, firstCol: false, lastCol: false, hBand: false, vBand: false };
return {
firstRow: on('firstRow', 0x020), lastRow: on('lastRow', 0x040),
firstCol: on('firstColumn', 0x080), lastCol: on('lastColumn', 0x100),
hBand: !on('noHBand', 0x200), vBand: !on('noVBand', 0x400),
};
}
// The areas covering one cell, lowest precedence first. Banding counts from the first body
// row/column, so the row under a header row is the first band (as in Word and LibreOffice).
function condAreasFor(
conds: Map<string, Element>, flags: LookFlags, band: { row: number; col: number },
cell: GridBox, rows: number, cols: number,
): CondArea[] {
const out: CondArea[] = [];
const push = (type: string, box: GridBox) => {
const el = conds.get(type);
if (el) out.push({ el, box });
};
const r0 = flags.firstRow ? 1 : 0, r1 = flags.lastRow ? rows - 2 : rows - 1;
const c0 = flags.firstCol ? 1 : 0, c1 = flags.lastCol ? cols - 2 : cols - 1;
if (flags.vBand && cell.col >= c0 && cell.col <= c1) {
const even = Math.floor((cell.col - c0) / band.col) % 2 === 0;
push(even ? 'band1Vert' : 'band2Vert', { row: 0, rowEnd: rows, col: c0, colEnd: c1 + 1 });
}
if (flags.hBand && cell.row >= r0 && cell.row <= r1) {
const even = Math.floor((cell.row - r0) / band.row) % 2 === 0;
push(even ? 'band1Horz' : 'band2Horz', { row: r0, rowEnd: r1 + 1, col: 0, colEnd: cols });
}
if (flags.lastCol && cell.colEnd === cols) push('lastCol', { row: 0, rowEnd: rows, col: cols - 1, colEnd: cols });
if (flags.firstCol && cell.col === 0) push('firstCol', { row: 0, rowEnd: rows, col: 0, colEnd: 1 });
if (flags.lastRow && cell.rowEnd === rows) push('lastRow', { row: rows - 1, rowEnd: rows, col: 0, colEnd: cols });
if (flags.firstRow && cell.row === 0) push('firstRow', { row: 0, rowEnd: 1, col: 0, colEnd: cols });
return out;
}
// How many rows a vertically merged cell covers. Its box has to say so before the covered
// rows are read, or a merge reaching the table's last row reads as an inside line there —
// which a style whose band declares insideH nil then drops.
function vMergeRows(trs: Element[], from: number, col: number): number {
let n = 1;
for (let ri = from + 1; ri < trs.length; ri++) {
let c = 0;
let covered = false;
for (const tc of fcAll(trs[ri], 'tc')) {
if (c >= col) {
const el = fc(fc(tc, 'tcPr'), 'vMerge');
covered = c === col && !!el && (wVal(el) ?? 'continue') !== 'restart';
break;
}
c += intAttr(fc(fc(tc, 'tcPr'), 'gridSpan'), W, 'val') ?? 1;
}
if (!covered) break;
n++;
}
return n;
}
// Which side of an area a cell's edge is: its own outer side where the cell sits on the
// area's edge, the area's inside line otherwise.
function areaSide(area: GridBox, cell: GridBox, side: 'top' | 'bottom' | 'left' | 'right'): string {
if (side === 'top') return cell.row <= area.row ? 'top' : 'insideH';
if (side === 'bottom') return cell.rowEnd >= area.rowEnd ? 'bottom' : 'insideH';
if (side === 'left') return cell.col <= area.col ? 'left' : 'insideV';
return cell.colEnd >= area.colEnd ? 'right' : 'insideV';
}
// A cell's shading and run properties from the table style, layered over its areas. The
// runs are baked below: a file's own table style has no entry in the editor's registry.
function condPaint(areas: CondArea[]): { fill?: string; run: RunProps } {
let fill: string | undefined;
let run: RunProps = {};
for (const a of areas) {
const shd = fc(fc(a.el, 'tcPr'), 'shd');
if (shd) fill = hexColor(shd.getAttributeNS(W, 'fill'));
run = mergeRunProps(run, parseRunProps(fc(a.el, 'rPr')));
}
return { fill, run };
}
// A conditional area's bold/italic/colour as real marks on the cell's text, skipping what
// a run already declares — the file's own formatting outranks its table style.
function bakeCellRuns(nodes: Node[], props: RunProps): void {
const color = hexColor(props.color);
if (!props.bold && !props.italic && !color) return;
for (const n of nodes) {
if (n.content) bakeCellRuns(n.content, props);
if (n.type !== 'text') continue;
const marks = n.marks ?? [];
const has = (t: string) => marks.some((m) => m.type === t);
if (props.bold && !has('bold')) marks.push({ type: 'bold' });
if (props.italic && !has('italic')) marks.push({ type: 'italic' });
if (color) {
const ts = marks.find((m) => m.type === 'textStyle');
if (!ts) marks.push({ type: 'textStyle', attrs: { color } });
else if (!ts.attrs?.color) ts.attrs = { ...ts.attrs, color };
}
if (marks.length) n.marks = marks;
}
}
function convertTable(tbl: Element, ctx: Ctx): Node | null {
// The table style's own w:pPr/w:spacing reaches its cells' paragraphs (Word's Table Grid
// zeroes the space after and the line spacing), under their own style chain. Restored
// for a nested table.
const outerSpacing = ctx.cellSpacing;
const tblStyleEl = fc(fc(tbl, 'tblPr'), 'tblStyle');
ctx.cellSpacing = ctx.styles.tableSpacing(tblStyleEl ? wVal(tblStyleEl) : null);
try {
return buildTable(tbl, ctx);
} finally {
ctx.cellSpacing = outerSpacing;
}
}
// A w:tblCellMar / w:tcMar's four sides in cm, taking the first element that declares
// each. w:type="nil" is an explicit zero; a side nobody declares comes back null.
function cellMarginsCm(els: (Element | null)[]): (number | null)[] {
return [['top'], ['right', 'end'], ['bottom'], ['left', 'start']].map((names) => {
for (const el of els) {
for (const name of names) {
const s = el && fc(el, name);
if (!s) continue;
if (s.getAttributeNS(W, 'type') === 'nil') return 0;
const w = intAttr(s, W, 'w');
if (w != null) return twipToCm(w);
}
}
return null;
});
}
// The table's own cell margins: direct before the table style's.
function docxCellPadding(tbl: Element, styleId: string | null, ctx: Ctx): CellPadding | null {
return cellPaddingAttr(cellMarginsCm([fc(fc(tbl, 'tblPr'), 'tblCellMar'), ctx.styles.tableCellMar(styleId)]));
}
function buildTable(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 styleEl = fc(fc(tbl, 'tblPr'), 'tblStyle');
const styleId = styleEl ? wVal(styleEl) : null;
const pad = docxCellPadding(tbl, styleId, ctx);
const padBase = pad ?? DEFAULT_CELL_PADDING;
// The table's own w:tblBorders, then its style's; a side nobody declares is not drawn
// (Word's Normal Table has no border — its on-screen gridlines are not printed).
const tblBorders = fc(fc(tbl, 'tblPr'), 'tblBorders');
const tblBorderEls = [tblBorders, ...ctx.styles.tableBorders(styleId)];
const rows: Node[] = [];
const pending: (Node | null)[] = []; // origin cell per grid column, for vMerge spans
const trs = fcAll(tbl, 'tr');
// The table style's conditional areas paint what only the style declares, so they are
// baked into the cells: a foreign style is not in the editor's registry, which is where
// an assigned table style's regions otherwise come from (refreshTableStyles).
const conds = ctx.styles.tableConditional(styleId);
const flags = docxLookFlags(fc(tbl, 'tblPr'));
const band = ctx.styles.tableBandSize(styleId);
const gridCols = useWeights?.length
?? Math.max(1, ...trs.map((tr) => fcAll(tr, 'tc').reduce((n, tc) => n + (intAttr(fc(fc(tc, 'tcPr'), 'gridSpan'), W, 'val') ?? 1), 0)));
for (let ri = 0; ri < trs.length; ri++) {
const tr = trs[ri];
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 rowEnd = ri + (vMerge === 'restart' ? vMergeRows(trs, ri, col) : 1);
const box: GridBox = { row: ri, col, rowEnd, colEnd: col + colspan };
const areas = conds.size ? condAreasFor(conds, flags, band, box, trs.length, gridCols) : [];
const paint = condPaint(areas);
const fill = fc(tcPr, 'shd')?.getAttributeNS(W, 'fill');
const bg = fill ? hexColor(fill) : paint.fill;
const blocks = convertBlocks(Array.from(tc.children), ctx, 'cell', bg === HEADER_SHADE);
bakeCellRuns(blocks, paint.run);
const attrs: Record<string, unknown> = { colspan, rowspan: 1 };
if (bg) attrs.backgroundColor = bg;
// w:vAlign — Word's "center" is the editor's "middle"; "top"/"both" stay the default.
const vAlignEl = fc(tcPr, 'vAlign');
const vAlign = vAlignEl ? wVal(vAlignEl) : null;
if (vAlign === 'center') attrs.verticalAlign = 'middle';
else if (vAlign === 'bottom') attrs.verticalAlign = 'bottom';
// The cell's own margins (w:tcMar), kept only where they differ from the table's.
const ownPad = cellPaddingAttr(cellMarginsCm([fc(tcPr, 'tcMar')]), padBase);
if (ownPad) attrs.cellPadding = ownPad;
// Per side: the table's borders, then each conditional area over them, then the
// cell's own w:tcBorders. An edge of the box a layer covers takes that layer's
// outer side, anything inside it the layer's insideH/insideV.
const tcBorders = fc(tcPr, 'tcBorders');
const table: GridBox = { row: 0, col: 0, rowEnd: trs.length, colEnd: gridCols };
const layers: { els: (Element | null)[]; box: GridBox }[] = [
{ els: tblBorderEls, box: table },
...areas.map((a) => ({ els: [fc(fc(a.el, 'tcPr'), 'tcBorders')], box: a.box })),
{ els: [tcBorders], box },
];
const resolve = (side: 'top' | 'bottom' | 'left' | 'right') => {
let v: string | null | undefined;
for (const layer of layers) {
const name = areaSide(layer.box, box, side);
for (const el of layer.els) {
const got = docxBorderAttr(fc(el, name));
if (got !== undefined) { v = got; break; }
}
}
return v === undefined ? 'none' : v;
};
const sides: [string, string | null][] = [
['borderTop', resolve('top')], ['borderBottom', resolve('bottom')],
['borderLeft', resolve('left')], ['borderRight', resolve('right')],
];
// undefined (nothing declared) and null (= editor default) both leave the attr off.
for (const [attr, v] of sides) if (typeof v === 'string') attrs[attr] = v;
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);
}
if (rows.length === 0) return null;
const named = ctx.styles.tableStyleName(styleId);
const attrs: Record<string, unknown> = { ...(tableMargins(tbl, useWeights, ctx, padBase[3]) ?? {}) };
if (pad) attrs.cellPadding = pad;
// w:tblHeader on the first row: Word repeats it at the top of every page the table
// continues on. Only a leading run of rows can carry it, so the first one decides.
const firstTrPr = fc(fcAll(tbl, 'tr')[0] ?? null, 'trPr');
const hdr = fc(firstTrPr, 'tblHeader');
if (hdr && onOff(hdr)) attrs.repeatHeader = true;
if (named) {
attrs.tableStyle = named;
const look = docxTableLook(fc(tbl, 'tblPr'));
if (look) attrs.tableLook = look;
}
return Object.keys(attrs).length ? { type: 'table', attrs, content: rows } : { type: 'table', content: rows };
}
// Word's w:tblLook → the editor's tableLook attr (its band flags are inverted).
// null when the file declares none, so the default look applies.
function docxTableLook(tblPr: Element | null): string | null {
if (!fc(tblPr, 'tblLook')) return null;
const f = docxLookFlags(tblPr);
return tableLookAttr({
headerRow: f.firstRow, lastRow: f.lastRow, firstColumn: f.firstCol,
lastColumn: f.lastCol, bandedRow: f.hBand, bandedColumn: f.vBand,
});
}
// A table narrower than the text width: w:tblInd is its left indent, the grid (or
// w:tblW) its width — the rest becomes the editor's right margin. Under the older
// compatibility mode the indent is measured to the cell's *text*, so the table hangs its
// left cell margin into the page margin (tblIndIsToText).
function tableMargins(tbl: Element, weights: number[] | null, ctx: Ctx, leftPadCm: number): { marginLeft: number; marginRight: number } | null {
const tblPr = fc(tbl, 'tblPr');
const content = ctx.contentWidthCm;
const dxa = (el: Element | null) => (el?.getAttributeNS(W, 'type') ?? 'dxa') === 'dxa' ? intAttr(el, W, 'w') : null;
const left = twipToCm(dxa(fc(tblPr, 'tblInd')) ?? 0) - (ctx.tblIndToText ? leftPadCm : 0);
const declared = dxa(fc(tblPr, 'tblW'));
const width = declared && declared > 0
? twipToCm(declared)
: weights ? twipToCm(weights.reduce((a, b) => a + b, 0)) : null;
const right = width != null ? content - left - width : 0;
if (Math.abs(left) < 0.05 && Math.abs(right) < 0.05) return null;
if (left + right > content - 1) return null;
const round2 = (v: number) => Math.round(v * 100) / 100;
return { marginLeft: round2(left), marginRight: round2(right) };
}
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 ------------------------------
// Text width of a section (A4 with Word's 2.54cm margins when it declares none).
function sectionContentWidthCm(sect: Element | null): number {
const w = intAttr(fc(sect, 'pgSz'), W, 'w') ?? 11906;
const pgMar = fc(sect, 'pgMar');
const left = intAttr(pgMar, W, 'left') ?? 1440;
const right = intAttr(pgMar, W, 'right') ?? 1440;
return Math.max(1, twipToCm(w - left - right));
}
// Split body children into sections: a <w:p> whose pPr carries a sectPr ends the
// group before it (dropped unless it has run content). The trailing group has
// sectPr null — the caller pairs it with the body-final <w:sectPr>.
function splitBodySections(children: Element[]): {
groups: { els: Element[]; sectPr: Element | null }[];
midSectPrs: Element[];
} {
const groups: { els: Element[]; sectPr: Element | null }[] = [];
const midSectPrs: Element[] = [];
let els: Element[] = [];
for (const el of children) {
if (el.namespaceURI === W && el.localName === 'sectPr') continue; // body-final
const sectPr = el.namespaceURI === W && el.localName === 'p' ? fc(fc(el, 'pPr'), 'sectPr') : null;
if (sectPr) {
if (fc(el, 'r') || fc(el, 'hyperlink')) els.push(el);
groups.push({ els, sectPr });
midSectPrs.push(sectPr);
els = [];
continue;
}
els.push(el);
}
groups.push({ els, sectPr: null });
return { groups, midSectPrs };
}
// The page's own decoration: Word keeps the background on w:document, the border in the
// section, and the watermark as a VML fontwork shape in a header part — the same three
// places LibreOffice writes them (probed).
function docxPageDecor(docDoc: Document, sectPr: Element | null, files: Record<string, Uint8Array>): PageDecor {
const bg = fc(docDoc.documentElement, 'background')?.getAttributeNS(W, 'color');
const borders = fc(sectPr, 'pgBorders');
const top = borders ? fc(borders, 'top') : null;
const sz = top ? intAttr(top, W, 'sz') : null;
const color = top?.getAttributeNS(W, 'color');
return normalizePageDecor({
background: bg && bg !== 'auto' ? `#${bg}` : null,
// w:sz is eighths of a point, w:space whole points from the text.
border: sz && top && wVal(top) !== 'none'
? { widthPt: Math.round((sz / 8) * 100) / 100,
color: color && color !== 'auto' ? `#${color}` : '#000000',
paddingCm: Math.round(((intAttr(top, W, 'space') ?? 0) / 72) * 2.54 * 100) / 100 }
: null,
watermark: docxWatermark(files),
});
}
// The VML shape Word and LibreOffice both name PowerPlusWaterMarkObject, in whichever
// header part carries it. Its text rides v:textpath, its angle the style's rotation.
function docxWatermark(files: Record<string, Uint8Array>): unknown {
for (const path of Object.keys(files)) {
if (!/^word\/header\d*\.xml$/.test(path)) continue;
const xml = strFromU8(files[path]);
const i = xml.indexOf(WATERMARK_NAME);
if (i < 0) continue;
const shape = xml.slice(i, xml.indexOf('</v:shape>', i));
const text = /\bstring="([^"]*)"/.exec(shape)?.[1];
if (!text) continue;
const style = /<v:textpath[^>]*\bstyle="([^"]*)"/.exec(shape)?.[1] ?? '';
const rotation = Number(/rotation:\s*(-?[\d.]+)/.exec(shape)?.[1] ?? '0');
const opacity = Number(/<v:fill[^>]*\bopacity="([\d.]+)"/.exec(shape)?.[1] ?? '1');
return {
text: decodeXml(text),
font: decodeXml(/font-family:\s*&quot;?([^;&"]+)/.exec(style)?.[1] ?? '').trim() || undefined,
color: /\bfillcolor="([^"]*)"/.exec(shape)?.[1],
// VML counts clockwise, the editor (and ODF) counter-clockwise.
angle: -(((rotation % 360) + 360) % 360) + (rotation > 180 ? 360 : 0),
transparency: Math.round((1 - opacity) * 100),
};
}
return null;
}
const decodeXml = (s: string) =>
s.replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
// <w:lnNumType> in the section: Word's line numbers. Absent = not numbered, which is
// also ODF's meaning for a missing configuration element.
function docxLineNumbering(sectPr: Element | null): LineNumbering {
const ln = fc(sectPr, 'lnNumType');
if (!ln) return DEFAULT_LINE_NUMBERING;
return normalizeLineNumbering({
on: true,
interval: intAttr(ln, W, 'countBy') ?? DEFAULT_LINE_NUMBERING.interval,
distanceCm: twipToCm(intAttr(ln, W, 'distance') ?? 283),
restart: ln.getAttributeNS(W, 'restart') === 'newPage' ? 'page' : 'continuous',
// Word has no "count empty lines" switch; it always does, as ODF defaults to.
countEmpty: true,
});
}
// A right-to-left section (w:bidi): the columns fill from the right, and it is the
// direction a block inherits when it declares none of its own.
function sectPrRtl(sectPr: Element | null): boolean {
const b = fc(sectPr, 'bidi');
return !!b && onOff(b);
}
// A section break's w:type: 'continuous'/'nextColumn' flow within the page; anything
// else (nextPage/oddPage/evenPage, or absent = the default nextPage) begins a new page.
function sectionStartsNewPage(sectPr: Element | null): boolean {
const t = fc(sectPr, 'type');
const val = t ? wVal(t) : null;
return val !== 'continuous' && val !== 'nextColumn';
}
// A sectPr's w:cols → columns attrs when it declares more than one column.
function sectPrColumns(sectPr: Element | null, ctx: Ctx): { count: number; gapCm: number } | null {
const cols = fc(sectPr, 'cols');
if (!cols) return null;
const colEls = fcAll(cols, 'col');
const num = intAttr(cols, W, 'num') ?? (colEls.length > 1 ? colEls.length : null);
if (!num || num <= 1) return null;
let count = num;
if (count > 3) {
ctx.warnings.add('Sections with more than 3 columns were reduced to 3 columns');
count = 3;
}
const space = intAttr(cols, W, 'space') ?? (colEls[0] ? intAttr(colEls[0], W, 'space') : null) ?? 283;
// A gap the section's text has no room for is a producer's unit slip — EMU written into
// a twips attribute — and lays out as no gap at all, not as columns too narrow to hold a
// word. Below that LibreOffice honours the declared value literally (probed).
const gapCm = round2(twipToCm(space));
const fits = gapCm > 0 && (count - 1) * gapCm < sectionContentWidthCm(sectPr);
return { count, gapCm: fits ? clampColumnGap(gapCm) : 0 };
}
// Block types a columns node can contain (mirrors import/odt.ts).
const COLUMNS_ALLOWED = new Set(['paragraph', 'heading', 'bulletList', 'orderedList']);
// Wrap a multi-column section's converted blocks into columns nodes: maximal runs of
// allowed types become one node each; anything else is emitted between them.
function pushColumnRuns(inner: Node[], cols: { count: number; gapCm: number }, out: Node[], ctx: Ctx): void {
let run: Node[] = [];
const flush = () => {
if (run.length) out.push({ type: 'columns', attrs: { count: cols.count, gapCm: cols.gapCm }, content: run });
run = [];
};
for (const block of inner) {
if (COLUMNS_ALLOWED.has(block.type)) {
run.push(block);
} else {
ctx.warnings.add('Tables and text boxes inside a multi-column layout were moved out of the columns');
flush();
out.push(block);
}
}
flush();
}
// Word's mirror margins are a document setting too: w:pgMar's left/right then read as
// the inner/outer pair and an even page swaps them.
// Word 2013 (compatibilityMode 15) made w:tblInd the table's own edge; before that it was
// measured to the cell's text, so the table hangs its left cell margin into the page
// margin. Probed against `soffice`, which follows the setting: the same table sits at the
// margin under 15 and 2.1mm left of it under 14. No setting at all is an old file.
function tblIndIsToText(files: Record<string, Uint8Array>): boolean {
const bytes = files['word/settings.xml'];
if (!bytes) return true;
try {
for (const el of Array.from(parseXml(strFromU8(bytes)).getElementsByTagNameNS(W, 'compatSetting'))) {
if (el.getAttributeNS(W, 'name') !== 'compatibilityMode') continue;
const n = parseInt(el.getAttributeNS(W, 'val') ?? '', 10);
return !Number.isFinite(n) || n < 15;
}
} catch { /* an unreadable settings.xml is no setting */ }
return true;
}
function docHasMirrorMargins(files: Record<string, Uint8Array>): boolean {
const bytes = files['word/settings.xml'];
if (!bytes) return false;
try {
const el = parseXml(strFromU8(bytes)).getElementsByTagNameNS(W, 'mirrorMargins')[0];
return !!el && onOff(el);
} catch {
return false;
}
}
// Word's "Different Odd & Even Pages" toggle lives in settings.xml, not the sectPr.
// Word's num-format ids, reversed onto the five ODF ones.
const NUM_FMT_FROM_DOCX: Record<string, NoteNumFormat> = {
decimal: '1', lowerLetter: 'a', upperLetter: 'A', lowerRoman: 'i', upperRoman: 'I',
};
// The document-wide <w:footnotePr>/<w:endnotePr> in settings.xml. The separator is not
// Word's to describe — it draws a fixed one — so it keeps the editor's.
function docNoteSettings(files: Record<string, Uint8Array>): NoteSettings {
const bytes = files['word/settings.xml'];
if (!bytes) return DEFAULT_NOTE_SETTINGS;
const out: NoteSettings = {
footnote: { ...DEFAULT_NOTE_SETTINGS.footnote },
endnote: { ...DEFAULT_NOTE_SETTINGS.endnote },
separator: { ...DEFAULT_NOTE_SETTINGS.separator },
};
try {
const root = parseXml(strFromU8(bytes)).documentElement;
for (const kind of ['footnote', 'endnote'] as NoteKind[]) {
const el = root.getElementsByTagNameNS(W, `${kind}Pr`)[0];
if (!el) continue;
const cls = out[kind];
const val = (name: string) => { const c = fc(el, name); return c ? wVal(c) : null; };
const fmt = NUM_FMT_FROM_DOCX[val('numFmt') ?? ''];
if (fmt) cls.numFormat = fmt;
const start = Number(val('numStart'));
if (Number.isFinite(start) && start > 0) cls.startAt = start;
const restart = val('numRestart');
if (restart === 'eachPage') cls.restart = 'page';
else if (restart === 'eachSect') cls.restart = 'chapter';
else if (restart === 'continuous') cls.restart = 'document';
if (kind === 'footnote' && val('pos') === 'docEnd') cls.position = 'document';
}
} catch { /* a malformed settings part just keeps the defaults */ }
return out;
}
function docHasEvenOddHeaders(files: Record<string, Uint8Array>): boolean {
const bytes = files['word/settings.xml'];
if (!bytes) return false;
try {
const el = parseXml(strFromU8(bytes)).getElementsByTagNameNS(W, 'evenAndOddHeaders')[0];
return !!el && onOff(el);
} catch {
return false;
}
}
// The grid every tab past the last custom stop falls on, also from settings.xml. A file
// that declares none gets Word's own fallback of 720 twips.
function docTabInterval(files: Record<string, Uint8Array>): number {
const bytes = files['word/settings.xml'];
if (!bytes) return DOCX_IMPLIED_TAB_CM;
try {
const el = parseXml(strFromU8(bytes)).getElementsByTagNameNS(W, 'defaultTabStop')[0];
const tw = el && intAttr(el, W, 'val');
return tw ? clampTabInterval(round2(twipToCm(tw))) : DOCX_IMPLIED_TAB_CM;
} catch {
return DOCX_IMPLIED_TAB_CM;
}
}
// w:pgNumType on the first section: how the page-number field counts.
const DOCX_PAGE_NUM_FORMAT: Record<string, NoteNumFormat> = {
decimal: '1', lowerRoman: 'i', upperRoman: 'I', lowerLetter: 'a', upperLetter: 'A',
};
function docxPageNumbering(sectPr: Element | null): PageNumbering {
const el = fc(sectPr, 'pgNumType');
if (!el) return { ...DEFAULT_PAGE_NUMBERING };
const start = intAttr(el, W, 'start');
return {
format: DOCX_PAGE_NUM_FORMAT[el.getAttributeNS(W, 'fmt') ?? ''] ?? '1',
start: start != null ? clampPageStart(start) : 1,
};
}
// Word's Layout ▸ Hyphenation, from settings.xml (absent = off, as in Word).
function docAutoHyphenation(files: Record<string, Uint8Array>): boolean {
const bytes = files['word/settings.xml'];
if (!bytes) return false;
try {
const el = parseXml(strFromU8(bytes)).getElementsByTagNameNS(W, 'autoHyphenation')[0];
return !!el && onOff(el);
} catch {
return false;
}
}
// word/comments.xml → the comment mark's attrs, by Word's numeric id.
function docxComments(files: Record<string, Uint8Array>): Map<string, Record<string, unknown>> {
const out = new Map<string, Record<string, unknown>>();
const bytes = files['word/comments.xml'];
if (!bytes) return out;
let doc: Document;
try { doc = parseXml(strFromU8(bytes)); } catch { return out; }
for (const c of Array.from(doc.getElementsByTagNameNS(W, 'comment'))) {
const id = c.getAttributeNS(W, 'id');
if (!id) continue;
out.set(id, {
id: `w${id}`,
author: c.getAttributeNS(W, 'author') ?? '',
date: c.getAttributeNS(W, 'date') ?? '',
text: fcAll(c, 'p').map((p) => p.textContent ?? '').join('\n').trim(),
// Word 365 marks a resolved comment in commentsExtended.xml; not read.
resolved: false,
});
}
return out;
}
// docProps/core.xml → the document's descriptive properties (Word's File ▸ Info).
const DC_NS = 'http://purl.org/dc/elements/1.1/';
const CP_NS = 'http://schemas.openxmlformats.org/package/2006/metadata/core-properties';
function docxDocProperties(files: Record<string, Uint8Array>): DocProperties {
const bytes = files['docProps/core.xml'];
if (!bytes) return { ...EMPTY_DOC_PROPERTIES };
let doc: Document;
try { doc = parseXml(strFromU8(bytes)); } catch { return { ...EMPTY_DOC_PROPERTIES }; }
const text = (ns: string, name: string) =>
doc.getElementsByTagNameNS(ns, name)[0]?.textContent?.trim() ?? '';
return {
title: text(DC_NS, 'title'),
subject: text(DC_NS, 'subject'),
author: text(DC_NS, 'creator'),
keywords: text(CP_NS, 'keywords'),
description: text(DC_NS, 'description'),
};
}
function parseSectPr(sect: Element | null, ctx: Ctx, oddEven = false): {
margins: PageMargins | null; orientation: Orientation | null; format: PageFormat | null;
header: HfDoc; footer: HfDoc; headerFirst: HfDoc; footerFirst: HfDoc; differentFirstPage: boolean;
headerEven: HfDoc; footerEven: HfDoc; differentOddEven: boolean;
headerDistCm: number | null; footerDistCm: number | null;
} {
const empty = { margins: null, orientation: null, format: null, header: null, footer: null, headerFirst: null, footerFirst: null, differentFirstPage: false, headerEven: null, footerEven: null, differentOddEven: false, 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 format: PageFormat | null = w && h ? (formatFromCm(twipToCm(w), twipToCm(h)) ?? 'A4') : null;
const pgMar = fc(sect, 'pgMar');
const clampCm = (tw: number | null) => (tw == null ? null : Math.min(10, Math.max(0, round2(twipToCm(tw)))));
const margins = sectMargins(sect);
// Different first page: w:titlePg turns on the "first"-type refs for page 1.
const titlePgEl = fc(sect, 'titlePg');
const titlePg = !!titlePgEl && onOff(titlePgEl);
const refId = (type: string, variant: 'default' | 'first' | 'even' = 'default') => {
const ref = fcAll(sect, `${type}Reference`).find((r) => (r.getAttributeNS(W, 'type') ?? 'default') === variant);
return ref?.getAttributeNS(R, 'id') ?? null;
};
const header = convertHfPart(refId('header'), ctx);
const footer = convertHfPart(refId('footer'), ctx);
const headerFirst = titlePg ? convertHfPart(refId('header', 'first'), ctx) : null;
const footerFirst = titlePg ? convertHfPart(refId('footer', 'first'), ctx) : null;
// Odd/even pages: settings.xml w:evenAndOddHeaders turns on the "even"-type refs.
const headerEven = oddEven ? convertHfPart(refId('header', 'even'), ctx) : null;
const footerEven = oddEven ? convertHfPart(refId('footer', 'even'), ctx) : null;
return {
margins, orientation, format, header, footer,
headerFirst, footerFirst, differentFirstPage: titlePg,
headerEven, footerEven, differentOddEven: oddEven,
headerDistCm: clampCm(intAttr(pgMar, W, 'header')),
footerDistCm: clampCm(intAttr(pgMar, W, 'footer')),
};
}
// The section's own page margins (w:pgMar). Word has no first-page variant of them.
const withMirror = (m: PageMargins | null, mirrored: boolean): PageMargins | null =>
m && mirrored ? { ...m, mirrored: true } : m;
function sectMargins(sect: Element | null): PageMargins | null {
const pgMar = sect ? fc(sect, 'pgMar') : null;
if (!pgMar) return null;
const cm = (a: string, fallback: number) => {
const tw = intAttr(pgMar, W, a);
return tw == null ? fallback : Math.min(10, Math.max(0, round2(twipToCm(tw))));
};
return { top: cm('top', 2.54), bottom: cm('bottom', 2.54), left: cm('left', 2.12), right: cm('right', 2.12) };
}
// One HfSet per section, in body order, resolving Word's "Link to Previous": a section
// that declares no reference of a type keeps the previous section's. w:titlePg is per
// section, odd/even is document-wide (settings.xml).
function sectionHfSets(sectPrs: (Element | null)[], ctx: Ctx, oddEven: boolean): HfSet[] {
const out: HfSet[] = [];
for (const sect of sectPrs) {
const prev = out[out.length - 1] ?? EMPTY_HF_SET;
const titlePgEl = sect ? fc(sect, 'titlePg') : null;
const titlePg = !!titlePgEl && onOff(titlePgEl);
const zone = (type: 'header' | 'footer', variant: string, inherited: HfDoc): HfDoc => {
const ref = sect
? fcAll(sect, `${type}Reference`).find((r) => (r.getAttributeNS(W, 'type') ?? 'default') === variant)
: null;
return ref ? convertHfPart(ref.getAttributeNS(R, 'id'), ctx) : inherited;
};
out.push({
margins: sectMargins(sect),
header: zone('header', 'default', prev.header),
footer: zone('footer', 'default', prev.footer),
headerFirst: titlePg ? zone('header', 'first', prev.headerFirst) : null,
footerFirst: titlePg ? zone('footer', 'first', prev.footerFirst) : null,
differentFirstPage: titlePg,
headerEven: oddEven ? zone('header', 'even', prev.headerEven) : null,
footerEven: oddEven ? zone('footer', 'even', prev.footerEven) : null,
differentOddEven: oddEven,
});
}
return out.length ? out : [{ ...EMPTY_HF_SET }];
}
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]) };
// One line per source paragraph, joined by hard breaks below: an empty leading line
// is a line of the zone too, and the body starts under the whole of it.
const lines: Node[][] = [];
let textAlign: string | null = null;
let stops: string | null = null;
const boxMaps: Record<string, string>[] = [];
for (const p of hfParagraphs(root)) {
const ppr = fc(p, 'pPr');
// The zone is one paragraph, so the first line's stops are the zone's. Word puts a
// header's centre/right pair on the Header style rather than the paragraph.
const tabs = ppr && fc(ppr, 'tabs');
stops ??= formatTabStops(tabs ? readTabStops(tabs)
: hfCtx.styles.paragraphTabs(fc(ppr, 'pStyle') ? wVal(fc(ppr, 'pStyle')!) : null));
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' : '';
}
boxMaps.push(readParaBox(ppr));
const baseRun = hfCtx.styles.paragraphRun(fc(ppr, 'pStyle') ? wVal(fc(ppr, 'pStyle')!) : null);
// The zone carries no styleName and no style CSS reaches it, so the yardstick is the
// editor's own defaults — what the Header/Footer style provides has to become marks
// (mirrors odt.ts convertHfZone, which passes no style name either).
lines.push(convertInline(p, hfCtx, baseRun, blockDefaults({}, null, false), true).filter((n) => n.type !== PB_MARKER));
}
// An all-empty zone is dropped unless it carries a background/rule line (a footer that
// is just a colored line has no text). The zone collapses to one paragraph (mergeHfBox).
const box = mergeHfBox(boxMaps);
if (lines.every((l) => l.length === 0) && Object.keys(box).length === 0) return null;
const inline: Node[] = [];
lines.forEach((line, i) => {
if (i) inline.push({ type: 'hardBreak' });
inline.push(...line);
});
const para: Node = { type: 'paragraph', content: inline };
const attrs: Record<string, string> = {};
// The zone is one paragraph here, so its strut is the whole band's line height —
// runs that agree on a size must set it, or a 10pt footer reserves 12pt lines.
applyUniformRunFont(attrs, inline);
if (textAlign) attrs.textAlign = textAlign;
if (stops) attrs.tabStops = stops;
Object.assign(attrs, box);
if (Object.keys(attrs).length) para.attrs = attrs;
return { type: 'doc', content: [para] };
}
// A zone's paragraphs in document order, unwrapping the content controls Word puts
// around an inserted page number — its w:p is not a child of w:hdr/w:ftr.
function hfParagraphs(el: Element): Element[] {
const out: Element[] = [];
for (const c of Array.from(el.children)) {
if (c.namespaceURI !== W) continue;
if (c.localName === 'p') out.push(c);
else if (c.localName === 'sdt') {
const content = fc(c, 'sdtContent');
if (content) out.push(...hfParagraphs(content));
}
}
return out;
}
// Collapse several source paragraphs' box props into one (mirror of odt.ts mergeHfBox).
function mergeHfBox(maps: Record<string, string>[]): Record<string, string> {
const out: Record<string, string> = {};
const first = (k: string) => maps.find((m) => m[k] !== undefined)?.[k];
const last = (k: string) => [...maps].reverse().find((m) => m[k] !== undefined)?.[k];
for (const [k, v] of [
['backgroundColor', first('backgroundColor')],
['borderTop', first('borderTop')],
['borderLeft', first('borderLeft')],
['borderRight', first('borderRight')],
['borderBottom', last('borderBottom')],
] as const) {
if (v !== undefined) out[k] = v;
}
return out;
}