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 } 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 } from '../editor/extensions/image'; import { formatTabStops } from '../editor/extensions/tabStops'; import type { CapsMode, LineStyle } from '../editor/extensions/textEffects'; import { tableLookAttr } from '../styles/tableStyles'; import { 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 { 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 { 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 }; type Node = { type: string; attrs?: Record; 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; usedStyles: Set; charStyleNames: Map; usedCharStyles: Set; warnings: Set; files: Record; rels: Map; imageCache: Map; convertedImages: ConvertedImages; pendingBlocks: Node[]; listCounters: Map>; // 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 enclosing table style's w:pPr/w:spacing, applied to its cells' paragraphs. cellSpacing: ParaSpacing; // 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; }; // ---- 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 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 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; try { files = unzipSync(bytes); } catch { throw new Error('Not a valid .docx file (could not read the archive).'); } const docBytes = files['word/document.xml']; if (!docBytes) throw new Error('Not a valid .docx file (word/document.xml is missing).'); const docDoc = parseXml(strFromU8(docBytes)); const stylesDoc = files['word/styles.xml'] ? parseXml(strFromU8(files['word/styles.xml'])) : null; const numberingDoc = files['word/numbering.xml'] ? parseXml(strFromU8(files['word/numbering.xml'])) : null; const themeDoc = files['word/theme/theme1.xml'] ? parseXml(strFromU8(files['word/theme/theme1.xml'])) : null; const styles = new DocxStyles(stylesDoc, numberingDoc, themeDoc); const warnings = new Set(); const styleNames = new Map(); 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, cellSpacing: {}, accents: themeAccents(themeDoc), openBookmarks: new Map() }; // 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))); // Odd/even pages: a document-level setting (settings.xml), not a section property. const oddEven = docHasEvenOddHeaders(files); // Page geometry stays document-wide, from the body-final sectPr; only the zones are // per section. A file whose sections disagree on geometry keeps the last one's. 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), margins: sect.margins, 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), fonts: extractDocxFonts(files), warnings: [...warnings], }; } // Fonts embedded via word/fontTable.xml: each may reference regular/bold/italic/ // bold-italic .odttf binaries (obfuscated). Resolve r:id → package path, read + de-obfuscate. function extractDocxFonts(files: Record): 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 { const map = new Map(); if (!bytes) return map; let doc: Document; try { doc = parseXml(strFromU8(bytes)); } catch { return map; } for (const rel of Array.from(doc.getElementsByTagNameNS(PKG_REL, 'Relationship'))) { const id = rel.getAttribute('Id'); const target = rel.getAttribute('Target'); if (id && target) map.set(id, { target, external: rel.getAttribute('TargetMode') === 'External' }); } return map; } function documentLanguage(stylesDoc: Document | null, warnings: Set): DocumentLanguage | null { const lang = stylesDoc?.getElementsByTagNameNS(W, 'docDefaults')[0] ?.getElementsByTagNameNS(W, 'lang')[0]?.getAttributeNS(W, 'val'); if (!lang) return null; const [language, country] = lang.split('-'); const code = languageFromOdf(language, country); if (code) return code; warnings.add(`Spell-check language "${lang}" has no bundled dictionary — spell check was turned off`); return NO_LANGUAGE; } // ---- block conversion (paragraphs, lists, tables) -------------------------- // 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; } 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; } // 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)) 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. out.push({ type: 'tableOfContents', attrs: { entries: [], title: '', maxLevel: tocMaxLevel(instrTextOf(el)) } }); } 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'); out.push({ type: 'tableOfContents', attrs: { entries: [], title: tocHeading(content) ?? '', maxLevel: tocMaxLevel(content ? instrTextOf(content) : ''), } }); } } 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 = {}; 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; color: string; italic: boolean; underline: boolean; strike: boolean; caps: CapsMode | null; }; const FONT_TWINS: Record = { '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, 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(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)); if (sp.line != null && (!sp.lineRule || sp.lineRule === 'auto')) { const mult = round2(sp.line / 240); if (Math.abs(mult - 1) > 0.01) out.lineHeight = String(mult); } 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)); 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 {}; if (own) return runTextProps(ctx.styles.styleOwn(id)); // 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; 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(); // 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(); 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); // Spacing/alignment from the style now live in the style registry, so only DIRECT // w:pPr counts as formatting on the block — except in a cell, where the table style's // w:pPr governs and no registry style reaches the cell's paragraphs. const attrs = blockAttrs(ppr, kind, level, directJc ? jcVal : null, kind === 'cell' ? ctx.cellSpacing : {}); const styleId = styleIdOf(ppr, ctx); 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; // 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); const content = convertInline(el, ctx, baseRun, defaults, 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): 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 | 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 { const attrs: Record = {}; 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 { const out: Record = {}; 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 [ ['top', 'borderTop'], ['right', 'borderRight'], ['bottom', 'borderBottom'], ['left', 'borderLeft'], ] as const) { const b = fc(pBdr, wSide); if (!b) continue; const val = b.getAttributeNS(W, 'val'); if (!val || val === 'none' || val === 'nil') continue; const sz = intAttr(b, W, 'sz'); // eighths of a point const widthPt = sz != null ? Math.round((sz / 8) * 100) / 100 : 0.5; const color = hexColor(b.getAttributeNS(W, 'color')) ?? '#000000'; out[attr] = `${widthPt}pt solid ${color}`; } } return out; } 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, a bibliography entry or any content control in a w:sdt whose // content is ordinary inline content — walk through the wrapper, or the runs inside it // (a citation's whole visible text) 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) : []; }); } 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: the cached result runs are collected as the node's // display text instead of being pushed. let fieldCrossRef: Node | null = null; let fieldCrossRefText = ''; const pushText = (text: string, marks: Mark[]) => { if (!text) return; // The one-paragraph header/footer schema has no bookmark mark. const bookmark = hfFields ? null : openBookmarkMark(ctx); const all = bookmark ? [...marks, bookmark] : marks; 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) marks.push({ type: 'link', attrs: { href: linkHref } }); 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; fieldCrossRefText = ''; } else if (t === 'separate') { fieldMode = 'result'; if (!hfFields) { fieldDateTime = dateTimeFieldFromInstr(fieldInstr); fieldCrossRef = fieldDateTime ? null : crossRefFromInstr(fieldInstr); fieldCrossRefText = ''; // Carry the field run's marks so the atom renders in the run's font. const field = fieldDateTime ?? 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) { fieldCrossRef = crossRefFromInstr(fieldInstr); if (fieldCrossRef && marks.length) fieldCrossRef.marks = marks; } if (fieldDateTime) out.push(fieldDateTime); else if (fieldCrossRef) out.push({ ...fieldCrossRef, attrs: { ...fieldCrossRef.attrs, text: fieldCrossRefText } }); else emitField(out, fieldInstr, hfFields, marks); fieldMode = 'none'; fieldDateTime = null; fieldCrossRef = null; } break; } case 'instrText': if (fieldMode === 'instr') fieldInstr += child.textContent ?? ''; break; case 't': if (fieldCrossRef && fieldMode === 'result') fieldCrossRefText += 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; } } }; 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)) 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 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': // accepted tracked-change insertion → keep its runs for (const r of fcAll(el, 'r')) handleRun(r); break; case 'smartTag': for (const r of fcAll(el, 'r')) handleRun(r); break; } } return mergeAdjacentText(out); } // / → 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'); } // 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: '' } }; } // 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 = {}; // 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 = { 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 | undefined { const attrs: Record = {}; 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: Math.round(emuToPx(intAttr(extentEl, '', 'cx') ?? 0)), h: Math.round(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 = { src }; const extent = root.getElementsByTagNameNS(WP, 'extent')[0]; const cx = intAttr(extent, '', 'cx'); const cy = intAttr(extent, '', 'cy'); if (cx) attrs.width = Math.round(emuToPx(cx)); if (cy) attrs.height = Math.round(emuToPx(cy)); const docPr = root.getElementsByTagNameNS(WP, 'docPr')[0]; const alt = docPr?.getAttribute('title') || docPr?.getAttribute('descr'); if (alt) attrs.alt = alt; const xfrm = drawing.getElementsByTagNameNS(A, 'xfrm')[0]; const rot = xfrm ? parseInt(xfrm.getAttribute('rot') ?? '', 10) : NaN; if (Number.isFinite(rot) && rot) attrs.rotation = ((Math.round(rot / 60000) % 360) + 360) % 360; if (anchor) { const { wrap, offsetCm, offsetYCm, alignH } = anchorWrap(anchor, ctx); attrs.wrap = wrap; if (offsetCm != null) attrs.wrapOffset = offsetCm; if (offsetYCm != null) attrs.wrapOffsetY = offsetYCm; 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); } // → 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 = { src, width: box.w, height: box.h, alt: label }; if (anchor) { const { wrap, offsetCm, offsetYCm, alignH } = anchorWrap(anchor, ctx); attrs.wrap = wrap; if (offsetCm != null) attrs.wrapOffset = offsetCm; if (offsetYCm != null) attrs.wrapOffsetY = offsetYCm; 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 } { 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; const at = (wrap: 'left' | 'right' | 'topBottom') => ({ wrap, offsetCm, offsetYCm, alignH }); 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 = { 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, 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 (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 = {}; 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 = Math.round(emuToPx(cx)); if (cy) attrs.height = Math.round(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 } = anchorWrap(root, ctx); attrs.wrap = wrap; if (offsetCm != null) attrs.wrapOffset = offsetCm; if (offsetYCm != null) attrs.wrapOffsetY = offsetYCm; } 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; } 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 = { 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 = {}; 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 '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}`; } function convertTable(tbl: Element, ctx: Ctx): Node | null { // The table style's own w:pPr/w:spacing governs its cells' paragraphs (Word's Table // Grid zeroes the space after and the line spacing). Restored for a nested table. const outerSpacing = ctx.cellSpacing; const tblStyleEl = fc(fc(tbl, 'tblPr'), 'tblStyle'); const tblStyleId = tblStyleEl ? wVal(tblStyleEl) : null; if (tblStyleId) ctx.cellSpacing = ctx.styles.paragraphSpacing(tblStyleId); 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; // Table-level border defaults; per-cell w:tcBorders override per side. Edge cells // fall back to the outer sides, interior cells to insideH/insideV. // Word's w:tblStyle: only the name comes back — the look rides on the cell attrs, and // the editor re-derives the regions from its own registry (refreshTableStyles). 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 tblSide = (name: string) => { for (const el of [tblBorders, ...ctx.styles.tableBorders(styleId)]) { const v = docxBorderAttr(fc(el, name)); if (v !== undefined) return v; } return 'none'; }; const tblDef = { top: tblSide('top'), bottom: tblSide('bottom'), left: tblSide('left'), right: tblSide('right'), insideH: tblSide('insideH'), insideV: tblSide('insideV'), }; const gridWidth = useWeights?.length ?? null; const rows: Node[] = []; const pending: (Node | null)[] = []; // origin cell per grid column, for vMerge spans const trs = fcAll(tbl, 'tr'); 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 fill = fc(tcPr, 'shd')?.getAttributeNS(W, 'fill'); const bg = fill ? hexColor(fill) : undefined; const blocks = convertBlocks(Array.from(tc.children), ctx, 'cell', bg === HEADER_SHADE); const attrs: Record = { colspan, rowspan: 1 }; if (bg) attrs.backgroundColor = bg; // 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; const tcBorders = fc(tcPr, 'tcBorders'); const isRight = gridWidth != null && col + colspan >= gridWidth; const resolve = (name: string, tblVal: string | null | undefined) => { const own = docxBorderAttr(fc(tcBorders, name)); return own !== undefined ? own : tblVal; }; const sides: [string, string | null | undefined][] = [ ['borderTop', resolve('top', ri === 0 ? tblDef.top : tblDef.insideH)], ['borderBottom', resolve('bottom', ri === trs.length - 1 ? tblDef.bottom : tblDef.insideH)], ['borderLeft', resolve('left', col === 0 ? tblDef.left : tblDef.insideV)], ['borderRight', resolve('right', isRight ? tblDef.right : tblDef.insideV)], ]; // 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 = { ...(tableMargins(tbl, useWeights, ctx) ?? {}) }; if (pad) attrs.cellPadding = pad; 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 { const el = fc(tblPr, 'tblLook'); if (!el) return null; const on = (name: string) => { const v = el.getAttributeNS(W, name); return v === '1' || v === 'true'; }; return tableLookAttr({ headerRow: on('firstRow'), lastRow: on('lastRow'), firstColumn: on('firstColumn'), lastColumn: on('lastColumn'), bandedRow: !on('noHBand'), bandedColumn: !on('noVBand'), }); } // 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. function tableMargins(tbl: Element, weights: number[] | null, ctx: Ctx): { 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 = Math.max(0, twipToCm(dxa(fc(tblPr, 'tblInd')) ?? 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 ? Math.max(0, content - left - width) : 0; if (left < 0.05 && 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 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 . 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 }; } // 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; return { count, gapCm: Math.min(5, Math.max(0, round2(twipToCm(space)))) }; } // 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 "Different Odd & Even Pages" toggle lives in settings.xml, not the sectPr. function docHasEvenOddHeaders(files: Record): 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): 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; } } 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: PageMargins | null = pgMar ? { top: clampCm(intAttr(pgMar, W, 'top')) ?? 2.54, bottom: clampCm(intAttr(pgMar, W, 'bottom')) ?? 2.54, left: clampCm(intAttr(pgMar, W, 'left')) ?? 2.12, right: clampCm(intAttr(pgMar, W, 'right')) ?? 2.12 } : null; // 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')), }; } // 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({ 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[] = []; 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 = {}; 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[]): Record { const out: Record = {}; 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; }