Import read style:vertical-align but neither exporter wrote it, so a mid-cell callout label jumped to the row's top on every save. odf-kit's CellOptions and docx's TableCell both take it natively; DOCX import maps w:vAlign in as well.
2035 lines
97 KiB
TypeScript
2035 lines
97 KiB
TypeScript
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<string, unknown> };
|
||
type Node = { type: string; attrs?: Record<string, unknown>; content?: Node[]; marks?: Mark[]; text?: string };
|
||
type BlockKind = 'body' | 'list' | 'cell';
|
||
|
||
type RelInfo = { target: string; external: boolean };
|
||
// pendingBlocks: text boxes/shapes found inside runs — block nodes that convertBlocks
|
||
// flushes after the anchor paragraph (mirrors import/odt.ts).
|
||
type Ctx = {
|
||
styles: DocxStyles;
|
||
// Word styleId → registry name, and the ids blocks actually reference.
|
||
styleNames: Map<string, string>;
|
||
usedStyles: Set<string>;
|
||
charStyleNames: Map<string, string>;
|
||
usedCharStyles: Set<string>;
|
||
warnings: Set<string>;
|
||
files: Record<string, Uint8Array>;
|
||
rels: Map<string, RelInfo>;
|
||
imageCache: Map<string, string>;
|
||
convertedImages: ConvertedImages;
|
||
pendingBlocks: Node[];
|
||
listCounters: Map<number, Map<number, number>>; // numId → ilvl → last number used
|
||
// Text width (cm) of the file's page setup; a table's margins are relative to it.
|
||
contentWidthCm: number;
|
||
// Left page margin (cm), the origin a page-relative frame offset is measured against.
|
||
leftMarginCm: number;
|
||
// The 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<string, string>;
|
||
};
|
||
|
||
// ---- units & editor defaults to suppress -----------------------------------
|
||
const twipToCm = (tw: number) => (tw / 1440) * 2.54;
|
||
const twipToPt = (tw: number) => tw / 20;
|
||
const twipToPx = (tw: number) => (tw / 1440) * 96;
|
||
const emuToPx = (emu: number) => emu / 9525;
|
||
const cmToEmu = (cm: number) => cm * 360000;
|
||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||
|
||
// A run-level <w:br w:type="page"/> becomes this sentinel inline node in convertInline;
|
||
// splitParaAtPageBreaks consumes it (body only) into breakBefore, and it never survives.
|
||
const PB_MARKER = '__docxPageBreak__';
|
||
|
||
const BODY_FONT_SIZE_PT = 12;
|
||
// Rounded to half points: that is all Word can store, so it is what our own export
|
||
// writes and what an imported heading must be compared against.
|
||
const HEADING_SIZES = HEADING_STYLE_OVERRIDES.map((h) => Math.round(lengthToPt(h.fontSize)! * 2) / 2);
|
||
const DEFAULT_FONTS = new Set(['times new roman', 'liberation serif']);
|
||
// Headings render sans (HEADING_FONT); Word writes Arial, LibreOffice Liberation Sans.
|
||
const DEFAULT_HEADING_FONTS = new Set(['arial', 'liberation sans']);
|
||
const LIST_LEFT_STEP_CM = 1.27; // matches export/docx.ts
|
||
const LIST_INDENT_EPS_CM = 0.05;
|
||
const LINK_BLUE = '#0563C1'; // the visual the exporter paints on hyperlink runs
|
||
|
||
// ---- small DOM helpers ------------------------------------------------------
|
||
function fc(el: Element | null, localName: string): Element | null {
|
||
if (!el) return null;
|
||
for (const c of Array.from(el.children)) if (c.namespaceURI === W && c.localName === localName) return c;
|
||
return null;
|
||
}
|
||
function fcAll(el: Element, localName: string): Element[] {
|
||
return Array.from(el.children).filter((c) => c.namespaceURI === W && c.localName === localName);
|
||
}
|
||
function intAttr(el: Element | null, ns: string, name: string): number | null {
|
||
if (!el) return null;
|
||
const v = ns ? el.getAttributeNS(ns, name) : el.getAttribute(name);
|
||
if (v == null) return null;
|
||
const n = parseInt(v, 10);
|
||
return Number.isFinite(n) ? n : null;
|
||
}
|
||
function parseXml(xml: string): Document {
|
||
const doc = new DOMParser().parseFromString(xml, 'application/xml');
|
||
if (doc.getElementsByTagName('parsererror').length) throw new Error('Not a valid .docx file (malformed XML).');
|
||
return doc;
|
||
}
|
||
|
||
// '#RRGGBB' from a Word color (6-hex without #, or named). null for auto/empty.
|
||
function hexColor(v: string | null | undefined): string | undefined {
|
||
if (!v || v === 'auto') return undefined;
|
||
return /^[0-9a-fA-F]{6}$/.test(v) ? normalizeColor(`#${v}`) : normalizeColor(v);
|
||
}
|
||
|
||
// ---- entry ------------------------------------------------------------------
|
||
export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = new Map()): OdtImportResult {
|
||
let files: Record<string, Uint8Array>;
|
||
try {
|
||
files = unzipSync(bytes);
|
||
} catch {
|
||
throw new Error('Not a valid .docx file (could not read the archive).');
|
||
}
|
||
const docBytes = files['word/document.xml'];
|
||
if (!docBytes) throw new Error('Not a valid .docx file (word/document.xml is missing).');
|
||
|
||
const docDoc = parseXml(strFromU8(docBytes));
|
||
const stylesDoc = files['word/styles.xml'] ? parseXml(strFromU8(files['word/styles.xml'])) : null;
|
||
const numberingDoc = files['word/numbering.xml'] ? parseXml(strFromU8(files['word/numbering.xml'])) : null;
|
||
const themeDoc = files['word/theme/theme1.xml'] ? parseXml(strFromU8(files['word/theme/theme1.xml'])) : null;
|
||
const styles = new DocxStyles(stylesDoc, numberingDoc, themeDoc);
|
||
const warnings = new Set<string>();
|
||
|
||
const styleNames = new Map<string, string>();
|
||
const defaultStyleId = styles.defaultParagraphStyle();
|
||
for (const [id, def] of styles.namedParagraphStyles()) styleNames.set(id, registryName(id, def.name, id === defaultStyleId));
|
||
const charStyleNames = styles.namedCharacterStyles();
|
||
const body = docDoc.getElementsByTagNameNS(W, 'body')[0];
|
||
if (!body) throw new Error('Not a Word document (no w:body).');
|
||
|
||
const sectPr = fc(body, 'sectPr');
|
||
const contentWidthCm = sectionContentWidthCm(sectPr);
|
||
const leftMarginCm = twipToCm(intAttr(fc(sectPr, 'pgMar'), W, 'left') ?? 1440);
|
||
const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, 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 <w:font> may reference regular/bold/italic/
|
||
// bold-italic .odttf binaries (obfuscated). Resolve r:id → package path, read + de-obfuscate.
|
||
function extractDocxFonts(files: Record<string, Uint8Array>): EmbeddedFont[] {
|
||
const ftBytes = files['word/fontTable.xml'];
|
||
if (!ftBytes) return [];
|
||
let doc: Document;
|
||
try { doc = parseXml(strFromU8(ftBytes)); } catch { return []; }
|
||
const rels = parseRels(files['word/_rels/fontTable.xml.rels']);
|
||
const embeds: [string, 'normal' | 'bold', 'normal' | 'italic'][] = [
|
||
['embedRegular', 'normal', 'normal'], ['embedBold', 'bold', 'normal'],
|
||
['embedItalic', 'normal', 'italic'], ['embedBoldItalic', 'bold', 'italic'],
|
||
];
|
||
const out: EmbeddedFont[] = [];
|
||
for (const font of Array.from(doc.getElementsByTagNameNS(W, 'font'))) {
|
||
const family = font.getAttributeNS(W, 'name');
|
||
if (!family) continue;
|
||
for (const [tag, weight, style] of embeds) {
|
||
const el = fc(font, tag);
|
||
const rel = el ? rels.get(el.getAttributeNS(R, 'id') ?? '') : undefined;
|
||
if (!el || !rel || rel.external) continue;
|
||
const data = files['word/' + rel.target.replace(/^\/+/, '')];
|
||
if (!data) continue;
|
||
const key = el.getAttributeNS(W, 'fontKey');
|
||
out.push({ family, weight, style, data: key ? deobfuscateOdttf(data, key) : data });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function parseRels(bytes: Uint8Array | undefined): Map<string, RelInfo> {
|
||
const map = new Map<string, RelInfo>();
|
||
if (!bytes) return map;
|
||
let doc: Document;
|
||
try { doc = parseXml(strFromU8(bytes)); } catch { return map; }
|
||
for (const rel of Array.from(doc.getElementsByTagNameNS(PKG_REL, 'Relationship'))) {
|
||
const id = rel.getAttribute('Id');
|
||
const target = rel.getAttribute('Target');
|
||
if (id && target) map.set(id, { target, external: rel.getAttribute('TargetMode') === 'External' });
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function documentLanguage(stylesDoc: Document | null, warnings: Set<string>): DocumentLanguage | null {
|
||
const lang = stylesDoc?.getElementsByTagNameNS(W, 'docDefaults')[0]
|
||
?.getElementsByTagNameNS(W, 'lang')[0]?.getAttributeNS(W, 'val');
|
||
if (!lang) return null;
|
||
const [language, country] = lang.split('-');
|
||
const code = languageFromOdf(language, country);
|
||
if (code) return code;
|
||
warnings.add(`Spell-check language "${lang}" has no bundled dictionary — spell check was turned off`);
|
||
return NO_LANGUAGE;
|
||
}
|
||
|
||
// ---- block conversion (paragraphs, lists, tables) --------------------------
|
||
// A TOC is a `TOC` field spanning several paragraphs, each entry a nested PAGEREF field,
|
||
// so field depth is tracked (across one convertBlocks call) to match the TOC's own end.
|
||
type TocFieldState = { fieldDepth: number; tocDepth: number; instr: string[] };
|
||
|
||
// The deepest heading level a TOC field lists: the end of its `\o "1-3"` range (Word's
|
||
// own default is 1-9). Anything deeper only inflates the block on screen.
|
||
function tocMaxLevel(instr: string): number {
|
||
const m = /\\o\s+"?\s*\d+\s*-\s*(\d+)/.exec(instr);
|
||
const n = m ? Number(m[1]) : NaN;
|
||
return n >= 1 ? Math.min(MAX_HEADING_LEVEL, n) : MAX_HEADING_LEVEL;
|
||
}
|
||
|
||
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<string, unknown> = {};
|
||
if (bullet) {
|
||
const ch = bulletCharAttr(bulletCharFromDocx(def.lvlText, def.bulletFont), ilvl);
|
||
if (ch) attrs.bulletChar = ch;
|
||
} else {
|
||
const placeholders = (def.lvlText?.match(/%\d/g) ?? []).length;
|
||
// Multilevel chains ("%1.%2." lvlText below level 0): the attr sits on the top
|
||
// list only; chain members stay attr-less.
|
||
const chainBelow = ((ctx.styles.level(numId, 1).lvlText ?? '').match(/%\d/g) ?? []).length > 1;
|
||
if (ilvl === 0 && chainBelow) {
|
||
attrs.listStyleType = 'multilevel';
|
||
} else if (placeholders <= 1) {
|
||
const key = orderedTypeFromFormat(wordFmtChar(def.numFmt), lvlSuffix(def.lvlText));
|
||
const attr = orderedTypeAttrAt(key, listBaseCycle(ctx, numId, ilvl));
|
||
if (attr) attrs.listStyleType = attr;
|
||
}
|
||
if (def.start != null && def.start > 1) attrs.start = def.start;
|
||
}
|
||
if (def.leftTwip != null) {
|
||
// A level's w:ind w:left is absolute, the editor nests one LIST_LEFT_STEP_CM per
|
||
// level — so the attr is this level's step past the one above. Signed (w:left="360"
|
||
// is a common one) and floored there, so the list stays in the text column.
|
||
const leftCm = (l: number) => {
|
||
const t = ctx.styles.level(numId, l).leftTwip;
|
||
return t != null ? twipToCm(t) : (l + 1) * LIST_LEFT_STEP_CM;
|
||
};
|
||
const step = leftCm(ilvl) - (ilvl === 0 ? 0 : leftCm(ilvl - 1));
|
||
const extra = round2(Math.max(-LIST_LEFT_STEP_CM, step - LIST_LEFT_STEP_CM));
|
||
if (Math.abs(extra) > LIST_INDENT_EPS_CM) attrs.indent = extra;
|
||
}
|
||
if (def.rightAligned) attrs.markerAlign = 'right';
|
||
const node: Node = { type: bullet ? 'bulletList' : 'orderedList', content: [] };
|
||
if (Object.keys(attrs).length) node.attrs = attrs;
|
||
return node;
|
||
}
|
||
|
||
function wordFmtChar(fmt: string | undefined): string {
|
||
switch (fmt) {
|
||
case 'lowerLetter': return 'a';
|
||
case 'upperLetter': return 'A';
|
||
case 'lowerRoman': return 'i';
|
||
case 'upperRoman': return 'I';
|
||
default: return '1';
|
||
}
|
||
}
|
||
function lvlSuffix(lvlText: string | undefined): string {
|
||
if (!lvlText) return '.';
|
||
const trail = lvlText.replace(/^.*%\d+/, '');
|
||
return trail.charAt(0) === ')' ? ')' : '.';
|
||
}
|
||
|
||
// Split a converted body paragraph at run-level page breaks (PB_MARKER): each break
|
||
// starts a new block via breakBefore: 'page'. A break at the paragraph's end reports
|
||
// trailingBreak so the caller moves the NEXT block. Cells/lists just strip the markers.
|
||
function splitParaAtPageBreaks(para: Node, kind: BlockKind): { blocks: Node[]; trailingBreak: boolean } {
|
||
const content = para.content ?? [];
|
||
if (!content.some((n) => n.type === PB_MARKER)) return { blocks: [para], trailingBreak: false };
|
||
if (kind !== 'body') {
|
||
const kept = content.filter((n) => n.type !== PB_MARKER);
|
||
if (kept.length) para.content = kept; else delete para.content;
|
||
return { blocks: [para], trailingBreak: false };
|
||
}
|
||
const segs: Node[][] = [[]];
|
||
for (const n of content) {
|
||
if (n.type === PB_MARKER) segs.push([]);
|
||
else segs[segs.length - 1].push(n);
|
||
}
|
||
let trailingBreak = false;
|
||
if (segs.length > 1 && segs[segs.length - 1].length === 0) { segs.pop(); trailingBreak = true; }
|
||
const blocks = segs.map((seg, i) => {
|
||
const node: Node = { type: para.type };
|
||
const attrs = { ...(para.attrs ?? {}) };
|
||
if (i > 0) attrs.breakBefore = 'page';
|
||
if (Object.keys(attrs).length) node.attrs = attrs;
|
||
if (seg.length) node.content = seg;
|
||
return node;
|
||
});
|
||
return { blocks, trailingBreak };
|
||
}
|
||
|
||
// What a block's named style already provides — the yardstick for "is this direct
|
||
// formatting?" (mirrors import/odt.ts blockDefaults).
|
||
type BlockDefaults = {
|
||
fontSizePt: number;
|
||
boldByDefault: boolean;
|
||
fonts: Set<string>;
|
||
color: string;
|
||
italic: boolean;
|
||
underline: boolean;
|
||
strike: boolean;
|
||
caps: CapsMode | null;
|
||
};
|
||
|
||
const FONT_TWINS: Record<string, string[]> = {
|
||
'times new roman': ['liberation serif'],
|
||
'liberation serif': ['times new roman'],
|
||
arial: ['liberation sans'],
|
||
'liberation sans': ['arial'],
|
||
};
|
||
|
||
// baseRun = docDefaults ← the paragraph style's basedOn chain, i.e. exactly what the
|
||
// style gives its runs.
|
||
function blockDefaults(baseRun: RunProps, headingLevel: number | null, boldByDefault: boolean): BlockDefaults {
|
||
const fonts = new Set(headingLevel != null ? DEFAULT_HEADING_FONTS : DEFAULT_FONTS);
|
||
const family = baseRun.font?.toLowerCase();
|
||
if (family) {
|
||
fonts.add(family);
|
||
for (const twin of FONT_TWINS[family] ?? []) fonts.add(twin);
|
||
}
|
||
return {
|
||
fontSizePt: baseRun.sizeHalfPt != null ? baseRun.sizeHalfPt / 2
|
||
: headingLevel != null ? HEADING_SIZES[headingLevel - 1] : BODY_FONT_SIZE_PT,
|
||
boldByDefault: baseRun.bold ?? (headingLevel != null || boldByDefault),
|
||
fonts,
|
||
color: hexColor(baseRun.color) ?? '#000000',
|
||
italic: !!baseRun.italic,
|
||
underline: !!baseRun.underline,
|
||
strike: !!baseRun.strike,
|
||
caps: baseRun.caps || null,
|
||
};
|
||
}
|
||
|
||
// The style a paragraph names, else Word's default paragraph style.
|
||
function styleIdOf(ppr: Element | null, ctx: Ctx): string | null {
|
||
const ps = fc(ppr, 'pStyle');
|
||
return (ps ? wVal(ps) : null) ?? ctx.styles.defaultParagraphStyle();
|
||
}
|
||
|
||
// Word's own names for the standard styles map onto the editor's registry names.
|
||
// `isDefault` marks the document's default paragraph style (Word calls it Normal,
|
||
// LibreOffice writes styleId "Standard" with the name "Normal").
|
||
function registryName(id: string, wordName: string, isDefault: boolean): string {
|
||
if (isDefault || id === 'Normal' || /^normal$/i.test(wordName)) return DEFAULT_STYLE;
|
||
const heading = /^Heading\s?([1-9])$/i.exec(id) ?? /^heading\s?([1-9])$/i.exec(wordName);
|
||
if (heading) return `Heading ${heading[1]}`;
|
||
if (/^Title$/i.test(id)) return 'Title';
|
||
if (/^Subtitle$/i.test(id)) return 'Subtitle';
|
||
if (/^Quote$/i.test(id) || /^Quotations?$/i.test(id)) return 'Quotations';
|
||
return wordName || id;
|
||
}
|
||
|
||
// What a style declares itself: its resolved props minus the parent's.
|
||
function ownProps<T extends object>(resolved: T, parent: T): T {
|
||
const out = {} as T;
|
||
for (const key of Object.keys(resolved) as (keyof T)[]) {
|
||
if (resolved[key] !== undefined && resolved[key] !== parent[key]) out[key] = resolved[key];
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function stylePara(ctx: Ctx, id: string | null): ParaProps {
|
||
if (!id) return {};
|
||
const out: ParaProps = {};
|
||
const sp = ctx.styles.paragraphSpacing(id);
|
||
if (sp.before != null) out.spaceBefore = snapPt(twipToPt(sp.before));
|
||
if (sp.after != null) out.spaceAfter = snapPt(twipToPt(sp.after));
|
||
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<string>();
|
||
// Body paragraphs carry no w:pStyle, so the default style never reaches usedStyles —
|
||
// yet run suppression resolves against it, so Standard has to carry it or the file's
|
||
// body font is dropped without any run mark taking its place.
|
||
const roots = new Set(ctx.usedStyles);
|
||
const defaultId = ctx.styles.defaultParagraphStyle();
|
||
if (defaultId) roots.add(defaultId);
|
||
for (const id of roots) {
|
||
let cur: string | null = id;
|
||
const seen = new Set<string>();
|
||
while (cur && defs.has(cur) && !seen.has(cur)) {
|
||
seen.add(cur);
|
||
keep.add(cur);
|
||
cur = defs.get(cur)!.basedOn;
|
||
}
|
||
}
|
||
for (const id of keep) {
|
||
const def = defs.get(id)!;
|
||
const name = ctx.styleNames.get(id) ?? def.name;
|
||
const parentId = def.basedOn;
|
||
const parent = parentId ? ctx.styleNames.get(parentId) ?? parentId : null;
|
||
const builtin = sheet.paragraph[name];
|
||
const style: Style = {
|
||
name,
|
||
parent: parent && parent !== name ? parent : builtin?.parent ?? null,
|
||
next: builtin?.next ?? null,
|
||
builtin: builtin?.builtin,
|
||
para: ownProps(stylePara(ctx, id), stylePara(ctx, parentId)),
|
||
text: ownProps(styleText(ctx, id), styleText(ctx, parentId)),
|
||
};
|
||
const level = /^Heading (\d)$/.exec(name);
|
||
if (level) style.outlineLevel = Number(level[1]);
|
||
sheet.paragraph[name] = style;
|
||
}
|
||
// w:docDefaults alone can carry the body font (a file need not declare a default
|
||
// style), and it is what run suppression compares against — so Standard tracks it.
|
||
const standard = sheet.paragraph[DEFAULT_STYLE];
|
||
if (standard) standard.text = { ...standard.text, ...runTextProps(ctx.styles.paragraphRun(null)) };
|
||
for (const id of ctx.usedCharStyles) {
|
||
const name = ctx.charStyleNames.get(id) ?? id;
|
||
const builtin = sheet.character[name];
|
||
sheet.character[name] = {
|
||
name, parent: null, next: null, builtin: builtin?.builtin,
|
||
para: {}, text: styleText(ctx, id, true),
|
||
};
|
||
}
|
||
return sheet;
|
||
}
|
||
|
||
function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault: boolean): Node {
|
||
const ppr = fc(el, 'pPr');
|
||
const pStyle = fc(ppr, 'pStyle');
|
||
const level = headingLevelOf(ppr, ctx);
|
||
// Alignment: direct w:pPr/w:jc wins, else resolve it from the style chain (the default
|
||
// paragraph style commonly carries justify), so style-level alignment isn't lost.
|
||
const directJc = fc(ppr, 'jc');
|
||
const jcVal = directJc ? wVal(directJc) : ctx.styles.paragraphAlign(pStyle ? wVal(pStyle) : null);
|
||
// 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<string, unknown>): void {
|
||
const direct = fc(ppr, 'contextualSpacing');
|
||
if (!(direct ? onOff(direct) : ctx.styles.paragraphContextualSpacing(styleId))) return;
|
||
const sameStyle = (sib: Element | null) =>
|
||
!!sib && sib.namespaceURI === W && sib.localName === 'p' && styleIdOf(fc(sib, 'pPr'), ctx) === styleId;
|
||
if (sameStyle(el.previousElementSibling)) attrs.spaceBefore = 0;
|
||
if (sameStyle(el.nextElementSibling)) attrs.spaceAfter = 0;
|
||
}
|
||
|
||
// The paragraph mark's resolved font size (w:pPr/w:rPr, incl. its rStyle), as a CSS
|
||
// pt string, or null when it matches what the block renders at anyway (suppressed
|
||
// like run sizes, against the same yardstick).
|
||
function paragraphMarkFontSize(ppr: Element | null, ctx: Ctx, baseRun: RunProps, defaultPt: number): string | null {
|
||
const rPr = fc(ppr, 'rPr');
|
||
const rStyle = fc(rPr, 'rStyle');
|
||
const props = mergeRunProps(mergeRunProps(baseRun, ctx.styles.styleOwn(rStyle ? wVal(rStyle) : null)), parseRunProps(rPr));
|
||
if (props.sizeHalfPt == null) return null;
|
||
const sizePt = props.sizeHalfPt / 2;
|
||
return Math.abs(sizePt - defaultPt) > 0.05 ? `${Math.round(sizePt * 10) / 10}pt` : null;
|
||
}
|
||
|
||
// The paragraph mark's resolved font family (w:pPr/w:rPr/w:rFonts, incl. its rStyle),
|
||
// or null when it is what the block renders at anyway.
|
||
function paragraphMarkFont(ppr: Element | null, ctx: Ctx, baseRun: RunProps, blockFonts: Set<string>): string | null {
|
||
const rPr = fc(ppr, 'rPr');
|
||
const rStyle = fc(rPr, 'rStyle');
|
||
const props = mergeRunProps(mergeRunProps(baseRun, ctx.styles.styleOwn(rStyle ? wVal(rStyle) : null)), parseRunProps(rPr));
|
||
const font = props.font ?? ctx.styles.themeFont(props.fontTheme ?? 'minor');
|
||
return !font || blockFonts.has(font.toLowerCase()) ? null : font;
|
||
}
|
||
|
||
// Heading + clamped level. Detect via the paragraph style id (fast path for our own
|
||
// files / English Word), then the style's resolved outline level (locale-independent,
|
||
// catches LibreOffice/localized heading styles), then a direct paragraph outline level.
|
||
function headingLevelOf(ppr: Element | null, ctx: Ctx): number | null {
|
||
const clamp = (n: number) => Math.min(MAX_HEADING_LEVEL, Math.max(1, n));
|
||
if (!ppr) return null;
|
||
const ps = fc(ppr, 'pStyle');
|
||
const id = ps ? wVal(ps) : null;
|
||
if (id) {
|
||
const m = /^Heading\s?([1-9])$/i.exec(id);
|
||
if (m) return clamp(parseInt(m[1], 10));
|
||
const ol = ctx.styles.styleOutlineLvl(id);
|
||
if (ol != null && ol >= 0 && ol <= 8) return clamp(ol + 1);
|
||
}
|
||
const olEl = fc(ppr, 'outlineLvl');
|
||
const n = olEl ? parseInt(wVal(olEl) ?? '', 10) : NaN;
|
||
if (Number.isFinite(n) && n >= 0 && n <= 8) return clamp(n + 1);
|
||
return null;
|
||
}
|
||
|
||
// Spacing = the style chain's w:spacing (styleSpacing, resolved by the caller) overridden
|
||
// per-attribute by DIRECT w:pPr; indent comes from direct w:pPr only. jcVal is resolved
|
||
// through the chain by the caller.
|
||
function blockAttrs(ppr: Element | null, kind: BlockKind, headingLevel: number | null, jcVal: string | null, styleSpacing: ParaSpacing): Record<string, unknown> {
|
||
const attrs: Record<string, unknown> = {};
|
||
|
||
if (jcVal === 'center') attrs.textAlign = 'center';
|
||
else if (jcVal === 'both' || jcVal === 'distribute') attrs.textAlign = 'justify';
|
||
else if (jcVal === 'right' || jcVal === 'end') attrs.textAlign = 'right';
|
||
|
||
const sp = ppr ? fc(ppr, 'spacing') : null;
|
||
const before = (sp ? intAttr(sp, W, 'before') : null) ?? styleSpacing.before ?? null;
|
||
const after = (sp ? intAttr(sp, W, 'after') : null) ?? styleSpacing.after ?? null;
|
||
const line = (sp ? intAttr(sp, W, 'line') : null) ?? styleSpacing.line ?? null;
|
||
const rule = (sp && sp.getAttributeNS(W, 'lineRule')) || styleSpacing.lineRule || null;
|
||
// An attribute no layer sets is Word's implied 0, which is also the editor's paragraph
|
||
// default (editor.css) — so it stays unset rather than accreting an explicit 0.
|
||
if (before != null) attrs.spaceBefore = snapPt(twipToPt(before));
|
||
if (after != null) attrs.spaceAfter = snapPt(twipToPt(after));
|
||
// Single spacing is written out too: the block's own style may set another one, and
|
||
// then an unset attr is not the same thing.
|
||
if (line != null && (!rule || rule === 'auto')) attrs.lineHeight = String(round2(line / 240));
|
||
|
||
if (!ppr) return attrs;
|
||
|
||
if (kind !== 'list') {
|
||
const ind = fc(ppr, 'ind');
|
||
const left = ind ? intAttr(ind, W, 'left') ?? intAttr(ind, W, 'start') : null;
|
||
if (left != null) { const cm = round2(twipToCm(left)); if (cm > LIST_INDENT_EPS_CM) attrs.indent = cm; }
|
||
const right = ind ? intAttr(ind, W, 'right') ?? intAttr(ind, W, 'end') : null;
|
||
if (right != null) { const cm = round2(twipToCm(right)); if (cm > LIST_INDENT_EPS_CM) attrs.indentRight = cm; }
|
||
// w:hanging outdents the first line, w:firstLine indents it; they are exclusive.
|
||
const hanging = ind ? intAttr(ind, W, 'hanging') : null;
|
||
const firstLine = ind ? intAttr(ind, W, 'firstLine') : null;
|
||
const first = hanging != null ? -twipToCm(hanging) : firstLine != null ? twipToCm(firstLine) : null;
|
||
if (first != null && Math.abs(first) > LIST_INDENT_EPS_CM) attrs.indentFirst = round2(first);
|
||
}
|
||
|
||
if (kind === 'body') {
|
||
const pb = fc(ppr, 'pageBreakBefore');
|
||
if (pb && onOff(pb)) attrs.breakBefore = 'page';
|
||
}
|
||
|
||
// Paragraph background ("colored field", w:shd) + per-side borders ("rule line", w:pBdr).
|
||
Object.assign(attrs, readParaBox(ppr));
|
||
return attrs;
|
||
}
|
||
|
||
// Paragraph background + per-side border attrs (paragraphBox.ts) from direct w:pPr.
|
||
// Shading: w:shd/@w:fill; borders: w:pBdr/w:{top,right,bottom,left} (w:sz in eighths of a
|
||
// point). Sides with w:val none/nil are skipped. Matches the ODF paraBoxAttrs shape.
|
||
function readParaBox(ppr: Element | null): Record<string, string> {
|
||
const out: Record<string, string> = {};
|
||
if (!ppr) return out;
|
||
const shd = fc(ppr, 'shd');
|
||
const fill = shd ? hexColor(shd.getAttributeNS(W, 'fill')) : undefined;
|
||
if (fill) out.backgroundColor = fill;
|
||
|
||
const pBdr = fc(ppr, 'pBdr');
|
||
if (pBdr) {
|
||
for (const [wSide, attr] of [
|
||
['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);
|
||
}
|
||
|
||
// <m:oMath> / <m:oMathPara> → a formula node. Word stores no size for a formula, so
|
||
// the node view measures it once on screen and writes the ODF frame geometry back.
|
||
function formulaNode(el: Element, ctx: Ctx): Node | null {
|
||
const { ast, display } = parseOmml(el);
|
||
// Stored verbatim, not trimmed: a macro's own trailing space is what makes the
|
||
// source re-serialize to itself, so trimming it would rewrite the formula on edit.
|
||
const latex = astToLatex(ast);
|
||
if (!latex.trim()) { ctx.warnings.add('Some formulas could not be read and were skipped'); return null; }
|
||
return { type: 'formula', attrs: { latex, display } };
|
||
}
|
||
|
||
function emitField(out: Node[], instr: string, hfFields: boolean, marks: Mark[] = []): void {
|
||
if (!hfFields) return;
|
||
// The atom carries the field run's marks so its digits render in the run's font/size.
|
||
const push = (type: string) => out.push(marks.length ? { type, marks } : { type });
|
||
if (/\bNUMPAGES\b/.test(instr)) push('pageCount');
|
||
else if (/\bPAGE\b/.test(instr)) push('pageNumber');
|
||
}
|
||
|
||
// 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<string, unknown> = {};
|
||
|
||
// Headings and header-row cells render bold via CSS: only a non-bold run needs a mark.
|
||
if (defaults.boldByDefault) {
|
||
if (props.bold === false) textStyle.fontWeight = 'normal';
|
||
} else if (props.bold === true) {
|
||
marks.push({ type: 'bold' });
|
||
}
|
||
|
||
// Marks the block's named style already renders need no mark of their own.
|
||
if (props.italic && !defaults.italic) marks.push({ type: 'italic' });
|
||
if (props.underline && !inLink && !defaults.underline) { // link underline is the CSS default
|
||
const line = underlineAttrs(props);
|
||
marks.push(line ? { type: 'underline', attrs: line } : { type: 'underline' });
|
||
}
|
||
if (props.strike && !defaults.strike) {
|
||
marks.push({ type: 'strike', ...(props.doubleStrike ? { attrs: { lineStyle: 'double' } } : {}) });
|
||
}
|
||
if (props.vertAlign === 'superscript') marks.push({ type: 'superscript' });
|
||
else if (props.vertAlign === 'subscript') marks.push({ type: 'subscript' });
|
||
|
||
if (props.highlightFill) { const c = hexColor(props.highlightFill); if (c) marks.push({ type: 'highlight', attrs: { color: c } }); }
|
||
|
||
let color = hexColor(props.color);
|
||
if (inLink && color === LINK_BLUE) color = undefined; // strip the exporter's link visual
|
||
if (color && color !== defaults.color) textStyle.color = color;
|
||
|
||
const sizePt = props.sizeHalfPt != null ? props.sizeHalfPt / 2 : null;
|
||
if (sizePt != null && Math.abs(sizePt - defaults.fontSizePt) > 0.05) textStyle.fontSize = `${Math.round(sizePt * 10) / 10}pt`;
|
||
|
||
if (props.font && !defaults.fonts.has(props.font.toLowerCase())) textStyle.fontFamily = props.font;
|
||
|
||
if (props.caps && props.caps !== defaults.caps) textStyle.caps = props.caps;
|
||
if (props.positionPt) textStyle.textPosition = props.positionPt;
|
||
|
||
if (Object.keys(textStyle).length) marks.push({ type: 'textStyle', attrs: textStyle });
|
||
return marks;
|
||
}
|
||
|
||
// Word names a dozen underline styles; CSS draws four shapes, so each name maps to the
|
||
// one it looks like. 'single' is the default and needs no attr.
|
||
const UNDERLINE_STYLE: Record<string, LineStyle> = {
|
||
double: 'double', wavyDouble: 'double',
|
||
dotted: 'dotted', dottedHeavy: 'dotted', dotDash: 'dotted', dotDotDash: 'dotted',
|
||
dash: 'dashed', dashedHeavy: 'dashed', dashLong: 'dashed', dashLongHeavy: 'dashed',
|
||
dashDotHeavy: 'dashed', dashDotDotHeavy: 'dashed',
|
||
wave: 'wavy', wavyHeavy: 'wavy',
|
||
};
|
||
|
||
function underlineAttrs(props: RunProps): Record<string, unknown> | undefined {
|
||
const attrs: Record<string, unknown> = {};
|
||
const style = props.underlineVal ? UNDERLINE_STYLE[props.underlineVal] : undefined;
|
||
if (style) attrs.lineStyle = style;
|
||
const color = hexColor(props.underlineColor);
|
||
if (color) attrs.lineColor = color;
|
||
return Object.keys(attrs).length ? attrs : undefined;
|
||
}
|
||
|
||
// ---- images -----------------------------------------------------------------
|
||
function loadImageDataUrl(path: string, ctx: Ctx): string | null {
|
||
const cached = ctx.imageCache.get(path);
|
||
if (cached) return cached;
|
||
// A format the browser can't render may have been pre-decoded to PNG by the async pass.
|
||
const converted = ctx.convertedImages.get(path);
|
||
if (converted) { ctx.imageCache.set(path, converted); return converted; }
|
||
const bytes = ctx.files[path];
|
||
if (!bytes) return null;
|
||
const url = imageDataUrl(bytes, path);
|
||
if (!url) return null;
|
||
ctx.imageCache.set(path, url);
|
||
return url;
|
||
}
|
||
|
||
// True when a drawing floats rather than sitting in the text flow: DrawingML `wp:anchor`,
|
||
// or a VML shape with `position:absolute`. Header/footer import drops these (see pushDrawn).
|
||
function drawingIsFloating(el: Element): boolean {
|
||
if (el.localName === 'drawing') return el.getElementsByTagNameNS(WP, 'anchor').length > 0;
|
||
const shape = Array.from(el.getElementsByTagNameNS(VML, '*')).find((c) =>
|
||
['shape', 'rect', 'oval', 'roundrect'].includes(c.localName),
|
||
);
|
||
return /(^|;)\s*position\s*:\s*absolute/i.test(shape?.getAttribute('style') ?? '');
|
||
}
|
||
|
||
function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
|
||
const anchor = drawing.getElementsByTagNameNS(WP, 'anchor')[0];
|
||
const root = drawing.getElementsByTagNameNS(WP, 'inline')[0] ?? anchor;
|
||
if (!root) return null;
|
||
// A wordprocessingShape (text box / preset shape) has no blip — convert it first.
|
||
const wsp = root.getElementsByTagNameNS(WPS, 'wsp')[0];
|
||
if (wsp) return convertWpsShape(wsp, root, !!anchor, ctx);
|
||
const extentEl = root.getElementsByTagNameNS(WP, 'extent')[0];
|
||
const boxPx = {
|
||
w: 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<string, unknown> = { src };
|
||
const extent = root.getElementsByTagNameNS(WP, 'extent')[0];
|
||
const cx = intAttr(extent, '', 'cx');
|
||
const cy = intAttr(extent, '', 'cy');
|
||
if (cx) attrs.width = Math.round(emuToPx(cx));
|
||
if (cy) attrs.height = Math.round(emuToPx(cy));
|
||
const docPr = root.getElementsByTagNameNS(WP, 'docPr')[0];
|
||
const alt = docPr?.getAttribute('title') || docPr?.getAttribute('descr');
|
||
if (alt) attrs.alt = alt;
|
||
const xfrm = drawing.getElementsByTagNameNS(A, 'xfrm')[0];
|
||
const rot = xfrm ? parseInt(xfrm.getAttribute('rot') ?? '', 10) : NaN;
|
||
if (Number.isFinite(rot) && rot) attrs.rotation = ((Math.round(rot / 60000) % 360) + 360) % 360;
|
||
|
||
if (anchor) {
|
||
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);
|
||
}
|
||
|
||
// <c:chart r:id> → the chart part, drawn as a picture of the frame's own size.
|
||
function chartImage(drawing: Element, box: { w: number; h: number }, ctx: Ctx): string | null {
|
||
const ref = Array.from(drawing.getElementsByTagName('*')).find((e) => e.localName === 'chart' && e.getAttributeNS(R, 'id'));
|
||
const rel = ref ? ctx.rels.get(ref.getAttributeNS(R, 'id')!) : undefined;
|
||
if (!rel || rel.external) return null;
|
||
const bytes = ctx.files[`word/${rel.target.replace(/^\/+/, '')}`];
|
||
return bytes ? chartDataUrl(strFromU8(bytes), box.w, box.h, ctx.accents) : null;
|
||
}
|
||
|
||
// A frame the editor draws itself (a chart) or cannot draw at all, as an image node of
|
||
// the same size, wrapped like the real one.
|
||
function frameNode(src: string, box: { w: number; h: number }, label: string, anchor: Element | undefined, ctx: Ctx): Node {
|
||
const attrs: Record<string, unknown> = { src, width: box.w, height: box.h, alt: label };
|
||
if (anchor) {
|
||
const { wrap, offsetCm, offsetYCm, alignH } = 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<string, 'textbox' | 'roundRect' | 'ellipse'> = {
|
||
rect: 'textbox',
|
||
roundRect: 'roundRect',
|
||
ellipse: 'ellipse',
|
||
};
|
||
|
||
// Fill/stroke attrs from a shape's spPr (or VML equivalents), suppressing the editor
|
||
// defaults (white fill, 1pt black stroke); none → explicit null, since omitting the
|
||
// attr would re-apply the default.
|
||
function setShapeStyleAttrs(attrs: Record<string, unknown>, fill: string | null, stroke: string | null, strokeWidthPt: number | null): void {
|
||
if (fill !== '#FFFFFF') attrs.fillColor = fill;
|
||
if (stroke !== '#000000') attrs.strokeColor = stroke;
|
||
if (stroke && strokeWidthPt != null && Math.abs(strokeWidthPt - 1) > 0.1) {
|
||
attrs.strokeWidthPt = Math.round(strokeWidthPt * 100) / 100;
|
||
}
|
||
}
|
||
|
||
// A DrawingML <wps:wsp> (text box or preset shape) → a textBox node. Unsupported
|
||
// preset geometries (stars, arrows, …) are dropped with a warning. All property
|
||
// lookups are scoped to spPr so a nested image's fill/xfrm can't leak in.
|
||
function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ctx): Node | null {
|
||
const spPr = nsChild(wsp, WPS, 'spPr');
|
||
const prst = nsChild(spPr, A, 'prstGeom')?.getAttribute('prst') ?? 'rect';
|
||
const kind = KIND_BY_PRST[prst];
|
||
if (!kind) { ctx.warnings.add('Unsupported shapes were removed'); return null; }
|
||
|
||
const attrs: Record<string, unknown> = {};
|
||
if (kind !== 'textbox') attrs.shapeKind = kind;
|
||
const extent = root.getElementsByTagNameNS(WP, 'extent')[0];
|
||
const cx = intAttr(extent, '', 'cx');
|
||
const cy = intAttr(extent, '', 'cy');
|
||
if (cx) attrs.width = 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<string, unknown> = { src };
|
||
if (w) imgAttrs.width = w;
|
||
if (h) imgAttrs.height = h;
|
||
return { type: 'image', attrs: imgAttrs };
|
||
}
|
||
|
||
const txbxContent = nsChild(shape.getElementsByTagNameNS(VML, 'textbox')[0] ?? null, W, 'txbxContent');
|
||
if (!txbxContent) { ctx.warnings.add('Drawings were removed'); return null; }
|
||
|
||
const attrs: Record<string, unknown> = {};
|
||
const kind = shape.localName === 'oval' ? 'ellipse' : shape.localName === 'roundrect' ? 'roundRect' : 'textbox';
|
||
if (kind !== 'textbox') attrs.shapeKind = kind;
|
||
if (w) attrs.width = w;
|
||
if (h) attrs.height = h;
|
||
|
||
const fillAttr = shape.getAttribute('fillcolor');
|
||
const fill = fillAttr ? normalizeColor(fillAttr) ?? null : null;
|
||
const stroked = shape.getAttribute('stroked');
|
||
const stroke = stroked === 'f' || stroked === 'false'
|
||
? null
|
||
: normalizeColor(shape.getAttribute('strokecolor') ?? '#000000') ?? '#000000';
|
||
const swm = /^([\d.]+)\s*(pt)?$/.exec(shape.getAttribute('strokeweight') ?? '');
|
||
setShapeStyleAttrs(attrs, fill, stroke, swm ? parseFloat(swm[1]) : null);
|
||
|
||
const blocks = convertBlocks(Array.from(txbxContent.children), ctx, 'cell');
|
||
return { type: 'textBox', attrs, content: blocks.length ? blocks : [{ type: 'paragraph' }] };
|
||
}
|
||
|
||
// ---- tables -----------------------------------------------------------------
|
||
|
||
// A w:tcBorders/w:tblBorders side element → border attr value: null = the editor
|
||
// default (0.5pt black), 'none', or '<W>pt solid #RRGGBB' (w:sz is eighth-points).
|
||
// undefined when the element is absent (side not declared at this level).
|
||
function docxBorderAttr(el: Element | null): string | null | undefined {
|
||
if (!el) return undefined;
|
||
const val = el.getAttributeNS(W, 'val');
|
||
if (!val || val === 'none' || val === 'nil') return 'none';
|
||
const sz = intAttr(el, W, 'sz');
|
||
const w = Math.round(((sz != null ? sz / 8 : 0.5)) * 100) / 100;
|
||
const c = (hexColor(el.getAttributeNS(W, 'color')) ?? '#000000').toUpperCase();
|
||
if (Math.abs(w - 0.5) < 0.11 && c === '#000000') return null;
|
||
return `${w}pt solid ${c}`;
|
||
}
|
||
|
||
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<string, unknown> = { colspan, rowspan: 1 };
|
||
if (bg) attrs.backgroundColor = bg;
|
||
// w:vAlign — Word's "center" is the editor's "middle"; "top"/"both" stay the default.
|
||
const vAlignEl = fc(tcPr, 'vAlign');
|
||
const vAlign = vAlignEl ? wVal(vAlignEl) : null;
|
||
if (vAlign === 'center') attrs.verticalAlign = 'middle';
|
||
else if (vAlign === 'bottom') attrs.verticalAlign = 'bottom';
|
||
// The cell's own margins (w:tcMar), kept only where they differ from the table's.
|
||
const ownPad = cellPaddingAttr(cellMarginsCm([fc(tcPr, 'tcMar')]), padBase);
|
||
if (ownPad) attrs.cellPadding = ownPad;
|
||
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<string, unknown> = { ...(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 <w:p> whose pPr carries a sectPr ends the
|
||
// group before it (dropped unless it has run content). The trailing group has
|
||
// sectPr null — the caller pairs it with the body-final <w:sectPr>.
|
||
function splitBodySections(children: Element[]): {
|
||
groups: { els: Element[]; sectPr: Element | null }[];
|
||
midSectPrs: Element[];
|
||
} {
|
||
const groups: { els: Element[]; sectPr: Element | null }[] = [];
|
||
const midSectPrs: Element[] = [];
|
||
let els: Element[] = [];
|
||
for (const el of children) {
|
||
if (el.namespaceURI === W && el.localName === 'sectPr') continue; // body-final
|
||
const sectPr = el.namespaceURI === W && el.localName === 'p' ? fc(fc(el, 'pPr'), 'sectPr') : null;
|
||
if (sectPr) {
|
||
if (fc(el, 'r') || fc(el, 'hyperlink')) els.push(el);
|
||
groups.push({ els, sectPr });
|
||
midSectPrs.push(sectPr);
|
||
els = [];
|
||
continue;
|
||
}
|
||
els.push(el);
|
||
}
|
||
groups.push({ els, sectPr: null });
|
||
return { groups, midSectPrs };
|
||
}
|
||
|
||
// 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<string, Uint8Array>): boolean {
|
||
const bytes = files['word/settings.xml'];
|
||
if (!bytes) return false;
|
||
try {
|
||
const el = parseXml(strFromU8(bytes)).getElementsByTagNameNS(W, 'evenAndOddHeaders')[0];
|
||
return !!el && onOff(el);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// The grid every tab past the last custom stop falls on, also from settings.xml. A file
|
||
// that declares none gets Word's own fallback of 720 twips.
|
||
function docTabInterval(files: Record<string, Uint8Array>): number {
|
||
const bytes = files['word/settings.xml'];
|
||
if (!bytes) return DOCX_IMPLIED_TAB_CM;
|
||
try {
|
||
const el = parseXml(strFromU8(bytes)).getElementsByTagNameNS(W, 'defaultTabStop')[0];
|
||
const tw = el && intAttr(el, W, 'val');
|
||
return tw ? clampTabInterval(round2(twipToCm(tw))) : DOCX_IMPLIED_TAB_CM;
|
||
} catch {
|
||
return DOCX_IMPLIED_TAB_CM;
|
||
}
|
||
}
|
||
|
||
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<string, string>[] = [];
|
||
for (const p of hfParagraphs(root)) {
|
||
const ppr = fc(p, 'pPr');
|
||
// The zone is one paragraph, so the first line's stops are the zone's. Word puts a
|
||
// header's centre/right pair on the Header style rather than the paragraph.
|
||
const tabs = ppr && fc(ppr, 'tabs');
|
||
stops ??= formatTabStops(tabs ? readTabStops(tabs)
|
||
: hfCtx.styles.paragraphTabs(fc(ppr, 'pStyle') ? wVal(fc(ppr, 'pStyle')!) : null));
|
||
if (textAlign === null) {
|
||
const ta = (fc(ppr, 'jc') ? wVal(fc(ppr, 'jc')!) : null) ?? '';
|
||
textAlign = ta === 'center' || ta === 'both' ? (ta === 'both' ? 'justify' : 'center') : ta === 'right' || ta === 'end' ? 'right' : '';
|
||
}
|
||
boxMaps.push(readParaBox(ppr));
|
||
const baseRun = hfCtx.styles.paragraphRun(fc(ppr, 'pStyle') ? wVal(fc(ppr, 'pStyle')!) : null);
|
||
// The zone carries no styleName and no style CSS reaches it, so the yardstick is the
|
||
// editor's own defaults — what the Header/Footer style provides has to become marks
|
||
// (mirrors odt.ts convertHfZone, which passes no style name either).
|
||
lines.push(convertInline(p, hfCtx, baseRun, blockDefaults({}, null, false), true).filter((n) => n.type !== PB_MARKER));
|
||
}
|
||
// An all-empty zone is dropped unless it carries a background/rule line (a footer that
|
||
// is just a colored line has no text). The zone collapses to one paragraph (mergeHfBox).
|
||
const box = mergeHfBox(boxMaps);
|
||
if (lines.every((l) => l.length === 0) && Object.keys(box).length === 0) return null;
|
||
const inline: Node[] = [];
|
||
lines.forEach((line, i) => {
|
||
if (i) inline.push({ type: 'hardBreak' });
|
||
inline.push(...line);
|
||
});
|
||
|
||
const para: Node = { type: 'paragraph', content: inline };
|
||
const attrs: Record<string, string> = {};
|
||
if (textAlign) attrs.textAlign = textAlign;
|
||
if (stops) attrs.tabStops = stops;
|
||
Object.assign(attrs, box);
|
||
if (Object.keys(attrs).length) para.attrs = attrs;
|
||
return { type: 'doc', content: [para] };
|
||
}
|
||
|
||
// A zone's paragraphs in document order, unwrapping the content controls Word puts
|
||
// around an inserted page number — its w:p is not a child of w:hdr/w:ftr.
|
||
function hfParagraphs(el: Element): Element[] {
|
||
const out: Element[] = [];
|
||
for (const c of Array.from(el.children)) {
|
||
if (c.namespaceURI !== W) continue;
|
||
if (c.localName === 'p') out.push(c);
|
||
else if (c.localName === 'sdt') {
|
||
const content = fc(c, 'sdtContent');
|
||
if (content) out.push(...hfParagraphs(content));
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Collapse several source paragraphs' box props into one (mirror of odt.ts mergeHfBox).
|
||
function mergeHfBox(maps: Record<string, string>[]): Record<string, string> {
|
||
const out: Record<string, string> = {};
|
||
const first = (k: string) => maps.find((m) => m[k] !== undefined)?.[k];
|
||
const last = (k: string) => [...maps].reverse().find((m) => m[k] !== undefined)?.[k];
|
||
for (const [k, v] of [
|
||
['backgroundColor', first('backgroundColor')],
|
||
['borderTop', first('borderTop')],
|
||
['borderLeft', first('borderLeft')],
|
||
['borderRight', first('borderRight')],
|
||
['borderBottom', last('borderBottom')],
|
||
] as const) {
|
||
if (v !== undefined) out[k] = v;
|
||
}
|
||
return out;
|
||
}
|