import { DOCX_MIME_TYPE, officeTitleFromFileName, readOfficeFileAsArrayBuffer } from "./fileHelpers"; export interface ImportedWordDocument { title: string; html: string; } export interface WordInlineRun { text: string; bold?: boolean; italics?: boolean; underline?: boolean; strike?: boolean; subscript?: boolean; superscript?: boolean; href?: string; color?: string; highlightColor?: string; fontSize?: number; fontFamily?: string; } export interface WordListItem { text: string; runs: WordInlineRun[]; } export interface WordImageBlock { type: "image"; src: string; alt?: string; width?: number; height?: number; } export type WordParagraphAlignment = "left" | "center" | "right" | "both"; export interface WordParagraphIndent { left?: number; firstLine?: number; hanging?: number; } export interface WordParagraphSpacing { before?: number; after?: number; line?: number; lineRule?: WordParagraphLineRule; } export type WordParagraphLineRule = "auto" | "exact" | "atLeast"; export type WordHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; export type WordBlock = | { type: "heading"; level: WordHeadingLevel; text: string; runs: WordInlineRun[]; alignment?: WordParagraphAlignment; indent?: WordParagraphIndent; spacing?: WordParagraphSpacing; bookmark?: string; } | { type: "paragraph"; text: string; runs: WordInlineRun[]; alignment?: WordParagraphAlignment; indent?: WordParagraphIndent; spacing?: WordParagraphSpacing; bookmark?: string; quote?: boolean; } | { type: "list"; ordered: boolean; items: WordListItem[] } | { type: "table"; rows: string[][] } | WordImageBlock; type MammothModule = { convertToHtml: ( input: { arrayBuffer?: ArrayBuffer; buffer?: ArrayBuffer }, options?: { styleMap?: string[]; convertImage?: unknown } ) => Promise<{ value: string; messages?: unknown[] }>; images?: { dataUri?: unknown; }; }; type XmlRecord = Record; type ZipObject = { async: (type: "string" | "base64") => Promise; }; type ZipInstance = { file: (path: string) => ZipObject | null; }; type ZipConstructor = { loadAsync: (data: ArrayBuffer) => Promise; }; type XmlParserConstructor = new (options: Record) => { parse: (xml: string) => unknown; }; type FastXmlParserModule = { XMLParser: XmlParserConstructor; }; type DocxModule = { AlignmentType?: Record; Document: new (options: Record) => unknown; HeadingLevel: Record; LineRuleType?: Record; Packer: { toBlob?: (document: unknown) => Promise; toBuffer?: (document: unknown) => Promise; }; Paragraph: new (options: Record) => unknown; PageBreak?: new () => unknown; Bookmark?: new (options: { id: string; children: unknown[] }) => unknown; Table: new (options: Record) => unknown; TableCell: new (options: Record) => unknown; TableRow: new (options: Record) => unknown; Tab?: new () => unknown; TextRun: new (options: string | Record) => unknown; WidthType?: Record; ExternalHyperlink?: new (options: { children: unknown[]; link: string }) => unknown; InternalHyperlink?: new (options: { children: unknown[]; anchor: string }) => unknown; ImageRun?: new (options: Record) => unknown; }; const defaultParagraphSpacingAfterTwips = 160; type InlineFormat = Omit; type InlineSegment = { type: "runs"; runs: WordInlineRun[] } | { type: "image"; image: WordImageBlock }; type DocxInlineRecord = { run: unknown; text: string; href?: string }; type DocxImageDataByRelationshipId = Record; type DocxNumberingDefinitions = Record; type DocxListInfo = { ordered: boolean; level: number; key: string }; const mammothStyleMap = [ "p[style-name='Title'] => h1:fresh", "p[style-name='Heading 1'] => h1:fresh", "p[style-name='Heading 2'] => h2:fresh", "p[style-name='Heading 3'] => h3:fresh", "p[style-name='Heading 4'] => h4:fresh", "p[style-name='Heading 5'] => h5:fresh", "p[style-name='Heading 6'] => h6:fresh" ]; const xmlParserOptions = { ignoreAttributes: false, attributeNamePrefix: "", textNodeName: "#text", parseTagValue: false, parseAttributeValue: false, trimValues: false }; const wordHyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"; const wordImageRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"; const emptyDocxRelationshipTargets = { hyperlinks: {}, images: {} }; const pageBreakMarker = "\f"; const pageBreakHtml = ''; export async function importDocxFile(file: File): Promise { if (!file.name.toLowerCase().endsWith(".docx")) { throw new Error("QWord импортирует только файлы Microsoft Word .docx."); } const mammoth = await loadMammoth(); const arrayBuffer = await readOfficeFileAsArrayBuffer(file); const [result, styledHtml] = await Promise.all([ mammoth.convertToHtml( { arrayBuffer, buffer: arrayBuffer }, { styleMap: mammothStyleMap, ...(mammoth.images?.dataUri ? { convertImage: mammoth.images.dataUri } : {}) } ), styledDocxHtmlFromArrayBuffer(arrayBuffer) ]); return { title: officeTitleFromFileName(file.name, "Документ QWord.docx"), html: normalizeImportedDocxHtml(styledHtml || result.value) }; } export async function exportDocxBlob(title: string, html: string): Promise { const docx = await loadDocx(); const blocks = htmlToWordBlocks(html); const children = blocks.flatMap((block) => wordBlockToDocxChildren(block, docx)); const documentTitle = title.trim() || "Документ QWord"; const document = new docx.Document({ creator: "QOffice", title: documentTitle, numbering: { config: [ { reference: "qoffice-numbered-list", levels: [ { level: 0, format: "decimal", text: "%1.", alignment: docx.AlignmentType?.LEFT ?? "left", style: { paragraph: { indent: { left: 720, hanging: 260 } } } } ] } ] }, sections: [ { properties: {}, children: children.length > 0 ? children : [ new docx.Paragraph({ children: [new docx.TextRun("")] }) ] } ] }); if (docx.Packer.toBlob) { return docx.Packer.toBlob(document); } if (docx.Packer.toBuffer) { const buffer = await docx.Packer.toBuffer(document); return new Blob([buffer as BlobPart], { type: DOCX_MIME_TYPE }); } throw new Error("Не удалось создать DOCX: библиотека docx не вернула поддерживаемый упаковщик."); } export function normalizeImportedDocxHtml(html: string) { return html.trim(); } export function htmlToWordBlocks(html: string): WordBlock[] { const container = parseHtmlFragment(html); const blocks: WordBlock[] = []; Array.from(container.childNodes).forEach((node) => { if (node.nodeType === Node.TEXT_NODE) { const text = normalizeWhitespace(node.textContent ?? ""); if (text) { blocks.push({ type: "paragraph", text, runs: [{ text }] }); } return; } if (!(node instanceof Element)) { return; } const tagName = node.tagName.toLowerCase(); if (tagName === "img") { blocks.push(imageBlockFromElement(node)); return; } if (isQOfficePageBreakElement(node)) { blocks.push({ type: "paragraph", text: "", runs: [{ text: pageBreakMarker }] }); return; } if (isHeadingTagName(tagName)) { const runs = elementInlineRuns(node); const text = inlineRunsText(runs); const alignment = paragraphAlignmentFromElement(node); const indent = paragraphIndentFromElement(node); const spacing = paragraphSpacingFromElement(node); const bookmark = bookmarkFromElement(node); if (text) { blocks.push({ type: "heading", level: headingLevelFromTagName(tagName), text, runs, ...(alignment ? { alignment } : {}), ...(indent ? { indent } : {}), ...(spacing ? { spacing } : {}), ...(bookmark ? { bookmark } : {}) }); } return; } if (tagName === "p" || tagName === "div" || tagName === "blockquote") { blocks.push(...paragraphElementToBlocks(node, { quote: tagName === "blockquote" })); return; } if (tagName === "ul" || tagName === "ol") { const items = Array.from(node.children) .filter((child) => child.tagName.toLowerCase() === "li") .map((child) => { const runs = elementInlineRuns(child); return { text: inlineRunsText(runs), runs }; }) .filter((item) => item.text); if (items.length > 0) { blocks.push({ type: "list", ordered: tagName === "ol", items }); } return; } if (tagName === "table") { const rows = tableRows(node); if (rows.length > 0) { blocks.push({ type: "table", rows }); } return; } const text = elementText(node); if (text) { blocks.push({ type: "paragraph", text, runs: [{ text }] }); } }); return blocks; } function wordBlockToDocxChildren(block: WordBlock, docx: DocxModule) { if (block.type === "heading") { const children = wordInlineRunsToTextRuns(block.runs, docx); return [ new docx.Paragraph({ heading: docxHeadingLevel(block.level, docx), spacing: docxParagraphSpacing(block.spacing, defaultParagraphSpacingAfterTwips), ...docxAlignmentOption(block.alignment, docx), ...docxIndentOption(block.indent), children: bookmarkDocxChildren(block.bookmark, children, docx) }) ]; } if (block.type === "paragraph") { const children = wordInlineRunsToTextRuns(block.runs, docx); return [ new docx.Paragraph({ ...(block.quote ? { style: "Quote" } : {}), spacing: docxParagraphSpacing(block.spacing, defaultParagraphSpacingAfterTwips), ...docxAlignmentOption(block.alignment, docx), ...docxIndentOption(block.indent), children: bookmarkDocxChildren(block.bookmark, children, docx) }) ]; } if (block.type === "list") { return block.items.map( (item) => new docx.Paragraph({ spacing: { after: 80 }, ...(block.ordered ? { numbering: { reference: "qoffice-numbered-list", level: 0 } } : { bullet: { level: 0 } }), children: wordInlineRunsToTextRuns(item.runs, docx) }) ); } if (block.type === "image") { const imageRun = wordImageBlockToImageRun(block, docx); if (!imageRun) { return block.alt ? [ new docx.Paragraph({ spacing: { after: 160 }, children: [new docx.TextRun(block.alt)] }) ] : []; } return [ new docx.Paragraph({ spacing: { after: 160 }, children: [imageRun] }) ]; } return [ new docx.Table({ width: { size: 100, type: docx.WidthType?.PERCENTAGE ?? "pct" }, rows: block.rows.map( (row) => new docx.TableRow({ children: row.map( (cell) => new docx.TableCell({ children: [ new docx.Paragraph({ children: [new docx.TextRun(cell)] }) ] }) ) }) ) }) ]; } export function wordInlineRunsToTextRunOptions(runs: WordInlineRun[]) { return runs.map(wordInlineRunToTextRunOption); } function isHeadingTagName(tagName: string): tagName is `h${WordHeadingLevel}` { return /^h[1-6]$/.test(tagName); } function headingLevelFromTagName(tagName: `h${WordHeadingLevel}`): WordHeadingLevel { return Number.parseInt(tagName.slice(1), 10) as WordHeadingLevel; } function docxHeadingLevel(level: WordHeadingLevel, docx: DocxModule) { const headingKey = `HEADING_${level}`; return docx.HeadingLevel[headingKey] ?? docx.HeadingLevel.HEADING_2; } function wordInlineRunToTextRunOption(run: WordInlineRun) { const options: Record = { text: run.text }; if (run.bold) { options.bold = true; } if (run.italics) { options.italics = true; } if (run.underline) { options.underline = {}; } if (run.strike) { options.strike = true; } if (run.superscript) { options.superScript = true; } else if (run.subscript) { options.subScript = true; } if (run.color) { options.color = run.color; } if (run.highlightColor) { options.shading = { fill: run.highlightColor }; } const fontSize = normalizedPointSize(run.fontSize); if (fontSize) { options.size = Math.round(fontSize * 2); } if (run.fontFamily) { options.font = run.fontFamily; } return options; } function wordInlineRunsToTextRuns(runs: WordInlineRun[], docx: DocxModule) { const normalizedRuns = runs.length > 0 ? runs : [{ text: "" }]; return normalizedRuns.flatMap((run) => { const internalAnchor = normalizedInternalHyperlinkAnchor(run.href); if (internalAnchor && docx.InternalHyperlink) { return [ new docx.InternalHyperlink({ anchor: internalAnchor, children: wordInlineRunToTextRunsForRun(run, docx, { style: "Hyperlink" }) }) ]; } const href = normalizedExternalHyperlinkTarget(run.href); if (href && docx.ExternalHyperlink) { return [ new docx.ExternalHyperlink({ link: href, children: wordInlineRunToTextRunsForRun(run, docx, { style: "Hyperlink" }) }) ]; } return wordInlineRunToTextRunsForRun(run, docx); }); } function bookmarkDocxChildren(bookmark: string | undefined, children: unknown[], docx: DocxModule) { return bookmark && docx.Bookmark ? [new docx.Bookmark({ id: bookmark, children })] : children; } function wordInlineRunToTextRunsForRun(run: WordInlineRun, docx: DocxModule, optionOverrides: Record = {}) { const option = { ...wordInlineRunToTextRunOption(run), ...optionOverrides }; const text = String(option.text ?? ""); const baseOption = { ...option }; delete baseOption.text; const segments = text.split(/(\n|\f)/); const textRuns: unknown[] = []; segments.forEach((segment) => { if (segment === "\n") { textRuns.push(new docx.TextRun({ ...baseOption, break: 1 })); return; } if (segment === pageBreakMarker) { textRuns.push(docx.PageBreak ? new docx.PageBreak() : new docx.TextRun({ ...baseOption, break: 1 })); return; } if (segment || segments.length === 1) { textRuns.push(wordInlineTextRunForSegment(segment, docx, baseOption)); } }); return textRuns; } function wordInlineTextRunForSegment(text: string, docx: DocxModule, baseOption: Record) { if (!text.includes("\t") || !docx.Tab) { return new docx.TextRun({ ...baseOption, text }); } const children: unknown[] = []; text.split("\t").forEach((part, index) => { if (index > 0 && docx.Tab) { children.push(new docx.Tab()); } if (part) { children.push(part); } }); return new docx.TextRun({ ...baseOption, children }); } function docxAlignmentOption(alignment: WordParagraphAlignment | undefined, docx: DocxModule) { const value = docxAlignmentValue(alignment, docx); return value ? { alignment: value } : {}; } function docxIndentOption(indent: WordParagraphIndent | undefined) { const normalized = normalizedParagraphIndent(indent); return Object.keys(normalized).length > 0 ? { indent: normalized } : {}; } function docxParagraphSpacing(spacing: WordParagraphSpacing | undefined, defaultAfter: number) { const normalized = normalizedParagraphSpacing(spacing); return { after: normalized.after ?? defaultAfter, ...(normalized.before ? { before: normalized.before } : {}), ...(normalized.line ? { line: normalized.line, lineRule: normalized.lineRule } : {}) }; } function normalizedParagraphSpacing(spacing: WordParagraphSpacing | undefined) { const before = normalizedTwips(spacing?.before); const after = normalizedTwips(spacing?.after); const line = normalizedTwips(spacing?.line); const lineRule = normalizedParagraphLineRule(spacing?.lineRule); return { ...(before ? { before } : {}), ...(after !== undefined ? { after } : {}), ...(line ? { line, lineRule } : {}) }; } function normalizedParagraphLineRule(lineRule: WordParagraphLineRule | undefined) { return lineRule === "exact" || lineRule === "atLeast" ? lineRule : "auto"; } function normalizedParagraphIndent(indent: WordParagraphIndent | undefined) { const left = normalizedTwips(indent?.left); const firstLine = normalizedTwips(indent?.firstLine); const hanging = normalizedTwips(indent?.hanging); return { ...(left ? { left } : {}), ...(firstLine ? { firstLine } : {}), ...(!firstLine && hanging ? { hanging } : {}) }; } function docxAlignmentValue(alignment: WordParagraphAlignment | undefined, docx: DocxModule) { if (!alignment) { return ""; } const alignmentType = docx.AlignmentType ?? {}; if (alignment === "center") { return alignmentType.CENTER ?? "center"; } if (alignment === "right") { return alignmentType.RIGHT ?? "right"; } if (alignment === "both") { return alignmentType.BOTH ?? alignmentType.JUSTIFIED ?? "both"; } return alignmentType.LEFT ?? "left"; } function wordImageBlockToImageRun(block: WordImageBlock, docx: DocxModule) { if (!docx.ImageRun) { return null; } const image = parseDataUriImage(block.src); if (!image) { return null; } const dimensions = imageDimensions(block, image.bytes); return new docx.ImageRun({ type: image.type, data: image.bytes, transformation: dimensions, ...(block.alt ? { altText: { title: block.alt, description: block.alt } } : {}) }); } function parseHtmlFragment(html: string) { if (typeof DOMParser !== "undefined") { return new DOMParser().parseFromString(`
${html}
`, "text/html").body.firstElementChild as HTMLElement; } if (typeof document !== "undefined") { const container = document.createElement("main"); container.innerHTML = html; return container; } throw new Error("Не удалось обработать HTML: DOMParser недоступен в этой среде."); } function tableRows(table: Element) { return Array.from(table.querySelectorAll("tr")) .map((row) => Array.from(row.querySelectorAll("th,td")).map(elementText)) .filter((row) => row.length > 0); } function paragraphElementToBlocks(element: Element, options: { quote?: boolean } = {}): WordBlock[] { const segments = collectInlineSegments(Array.from(element.childNodes), {}); const alignment = paragraphAlignmentFromElement(element); const indent = paragraphIndentFromElement(element); const spacing = paragraphSpacingFromElement(element); const bookmark = bookmarkFromElement(element); const blocks: WordBlock[] = []; let pendingRuns: WordInlineRun[] = []; let bookmarkApplied = false; const flushParagraph = () => { const runs = normalizeInlineRuns(pendingRuns); const text = inlineRunsText(runs); const hasPageBreak = runs.some((run) => run.text.includes(pageBreakMarker)); if (text || hasPageBreak) { blocks.push({ type: "paragraph", text, runs, ...(alignment ? { alignment } : {}), ...(indent ? { indent } : {}), ...(spacing ? { spacing } : {}), ...(bookmark && !bookmarkApplied ? { bookmark } : {}), ...(options.quote ? { quote: true } : {}) }); bookmarkApplied = true; } pendingRuns = []; }; segments.forEach((segment) => { if (segment.type === "runs") { pendingRuns.push(...segment.runs); return; } flushParagraph(); blocks.push(segment.image); }); flushParagraph(); return blocks; } function elementInlineRuns(element: Element) { return normalizeInlineRuns(collectInlineRuns(Array.from(element.childNodes), {})); } function paragraphAlignmentFromElement(element: Element): WordParagraphAlignment | undefined { const rawAlignment = element.getAttribute("align") || cssTextAlignValue(element.getAttribute("style") ?? ""); const normalized = rawAlignment.trim().toLowerCase(); if (normalized === "center" || normalized === "right" || normalized === "left") { return normalized; } return normalized === "justify" ? "both" : undefined; } function paragraphIndentFromElement(element: Element): WordParagraphIndent | undefined { const style = element.getAttribute("style") ?? ""; const left = cssTwipsValue(style, "margin-left"); const textIndent = cssTwipsValue(style, "text-indent"); const indent = { ...(left && left > 0 ? { left } : {}), ...(textIndent && textIndent > 0 ? { firstLine: textIndent } : {}), ...(textIndent && textIndent < 0 ? { hanging: Math.abs(textIndent) } : {}) }; return Object.keys(indent).length > 0 ? indent : undefined; } function paragraphSpacingFromElement(element: Element): WordParagraphSpacing | undefined { const style = element.getAttribute("style") ?? ""; const before = cssTwipsValue(style, "margin-top"); const after = cssTwipsValue(style, "margin-bottom"); const lineHeight = cssLineHeightValue(style); const spacing = { ...(before && before > 0 ? { before } : {}), ...(after && after > 0 ? { after } : {}), ...(lineHeight ? lineHeight : {}) }; return Object.keys(spacing).length > 0 ? spacing : undefined; } function bookmarkFromElement(element: Element) { return normalizedDocxAnchor(element.getAttribute("id") || element.getAttribute("name")); } function cssTextAlignValue(style: string) { return /(?:^|;)\s*text-align\s*:\s*([^;]+)/i.exec(style)?.[1] ?? ""; } function cssTwipsValue(style: string, property: "margin-left" | "margin-top" | "margin-bottom" | "text-indent") { const raw = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "i").exec(style)?.[1]?.trim().toLowerCase() ?? ""; return cssLengthToTwips(raw); } function cssLineHeightValue(style: string): WordParagraphSpacing | undefined { const raw = /(?:^|;)\s*line-height\s*:\s*([^;]+)/i.exec(style)?.[1]?.trim().toLowerCase() ?? ""; if (!raw || ["inherit", "initial", "unset", "revert", "normal"].includes(raw)) { return undefined; } const ratioMatch = /^([0-9]+(?:\.[0-9]+)?)$/.exec(raw); if (ratioMatch) { const ratio = Number.parseFloat(ratioMatch[1]); return Number.isFinite(ratio) && ratio > 0 ? { line: Math.round(ratio * 240), lineRule: "auto" } : undefined; } const percentMatch = /^([0-9]+(?:\.[0-9]+)?)%$/.exec(raw); if (percentMatch) { const ratio = Number.parseFloat(percentMatch[1]) / 100; return Number.isFinite(ratio) && ratio > 0 ? { line: Math.round(ratio * 240), lineRule: "auto" } : undefined; } const line = cssLengthToTwips(raw); return line && line > 0 ? { line, lineRule: "exact" } : undefined; } function cssLengthToTwips(raw: string) { if (!raw || ["inherit", "initial", "unset", "revert", "auto"].includes(raw)) { return undefined; } const match = /^(-?[0-9]+(?:\.[0-9]+)?)(pt|px|in|cm|mm)$/i.exec(raw); if (!match) { return undefined; } const value = Number.parseFloat(match[1]); if (!Number.isFinite(value) || value === 0) { return undefined; } const unit = match[2].toLowerCase(); const twips = unit === "pt" ? value * 20 : unit === "px" ? value * 15 : unit === "in" ? value * 1440 : unit === "cm" ? (value * 1440) / 2.54 : (value * 1440) / 25.4; return Math.round(twips); } function cssColorValue(style: string, property: "color" | "background" | "background-color") { const value = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "i").exec(style)?.[1] ?? ""; return normalizedCssColor(value); } function cssFontSizeValue(style: string) { const raw = /(?:^|;)\s*font-size\s*:\s*([^;]+)/i.exec(style)?.[1]?.trim().toLowerCase() ?? ""; if (!raw || ["inherit", "initial", "unset", "revert"].includes(raw)) { return undefined; } const match = /^([0-9]+(?:\.[0-9]+)?)(pt|px)$/i.exec(raw); if (!match) { return undefined; } const value = Number.parseFloat(match[1]); if (!Number.isFinite(value) || value <= 0) { return undefined; } return normalizedPointSize(match[2].toLowerCase() === "px" ? value * 0.75 : value); } function cssFontFamilyStyleValue(style: string) { const raw = /(?:^|;)\s*font-family\s*:\s*([^;]+)/i.exec(style)?.[1] ?? ""; const firstFamily = raw .split(",") .map((value) => value.trim().replace(/^["']|["']$/g, "")) .find(Boolean); return normalizedFontFamily(firstFamily); } function cssVerticalAlignValue(style: string) { const raw = /(?:^|;)\s*vertical-align\s*:\s*([^;]+)/i.exec(style)?.[1]?.trim().toLowerCase() ?? ""; return ["sub", "super"].includes(raw) ? raw : ""; } function normalizedCssColor(value: string) { const raw = value.trim().toLowerCase(); if (!raw || ["transparent", "inherit", "initial", "currentcolor", "auto"].includes(raw)) { return ""; } const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw)?.[1]; if (hex) { return hex.length === 3 ? hex .split("") .map((char) => `${char}${char}`) .join("") .toUpperCase() : hex.toUpperCase(); } const rgb = /^rgba?\(([^)]+)\)$/i.exec(raw)?.[1]; if (rgb) { const channels = rgb .split(",") .slice(0, 3) .map((channel) => Number.parseFloat(channel.trim())); if (channels.length === 3 && channels.every((channel) => Number.isFinite(channel) && channel >= 0 && channel <= 255)) { return channels.map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join("").toUpperCase(); } } const namedColors: Record = { black: "000000", blue: "0000FF", cyan: "00FFFF", gray: "808080", green: "008000", magenta: "FF00FF", orange: "FFA500", purple: "800080", red: "FF0000", white: "FFFFFF", yellow: "FFFF00" }; return namedColors[raw] ?? ""; } async function styledDocxHtmlFromArrayBuffer(arrayBuffer: ArrayBuffer) { try { const [JSZip, XMLParser] = await Promise.all([loadJsZip(), loadXmlParser()]); const zip = await JSZip.loadAsync(arrayBuffer); const documentXml = await zip.file("word/document.xml")?.async("string"); if (!documentXml) { return ""; } if (!isSimpleStyledDocxDocument(documentXml)) { return ""; } const parser = new XMLParser(xmlParserOptions); const orderedParser = new XMLParser({ ...xmlParserOptions, preserveOrder: true }); const relationshipsXml = await zip.file("word/_rels/document.xml.rels")?.async("string"); const relationshipTargets = relationshipsXml ? docxRelationshipTargetsById(parser.parse(relationshipsXml)) : emptyDocxRelationshipTargets; const numberingXml = await zip.file("word/numbering.xml")?.async("string"); const numberingDefinitions = numberingXml ? docxNumberingDefinitions(parser.parse(numberingXml)) : {}; const imageDataByRelationshipId = await docxImageDataByRelationshipId(zip, relationshipTargets.images); const result = orderedDocxDocumentToHtml(orderedParser.parse(documentXml), relationshipTargets.hyperlinks, imageDataByRelationshipId, numberingDefinitions); return result.hasInlineColorOrHighlight || result.hasParagraphAlignment || result.hasBookmark || result.hasTable || result.hasImage || result.hasLineBreak || result.hasPageBreak || result.hasTab || result.hasList || result.hasQuote ? result.html : ""; } catch { return ""; } } function isSimpleStyledDocxDocument(documentXml: string) { return !/ = {}, imageDataByRelationshipId: DocxImageDataByRelationshipId = {}, numberingDefinitions: DocxNumberingDefinitions = {} ) { const document = firstOrderedElement(toArray(documentXml), "w:document"); const body = firstOrderedElement(orderedElementChildren(document), "w:body"); let hasInlineColorOrHighlight = false; let hasParagraphAlignment = false; let hasBookmark = false; let hasTable = false; let hasImage = false; let hasLineBreak = false; let hasPageBreak = false; let hasTab = false; let hasList = false; let hasQuote = false; const blocks: string[] = []; let openList: DocxListInfo | null = null; let listItems: string[] = []; const flushList = () => { if (!openList || listItems.length === 0) { openList = null; listItems = []; return; } const tagName = openList.ordered ? "ol" : "ul"; blocks.push(`<${tagName}>${listItems.join("")}`); openList = null; listItems = []; }; orderedElementChildren(body).forEach((block) => { const name = orderedElementName(block); if (name === "w:p") { const result = orderedDocxParagraphToHtml(block, hyperlinkTargets, imageDataByRelationshipId, numberingDefinitions); hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight; hasParagraphAlignment = hasParagraphAlignment || result.hasParagraphAlignment; hasBookmark = hasBookmark || result.hasBookmark; hasImage = hasImage || result.hasImage; hasLineBreak = hasLineBreak || result.hasLineBreak; hasPageBreak = hasPageBreak || result.hasPageBreak; hasTab = hasTab || result.hasTab; hasList = hasList || Boolean(result.list); hasQuote = hasQuote || result.hasQuote; if (!result.html) { return; } if (result.list) { if (!openList || openList.key !== result.list.key) { flushList(); openList = result.list; } listItems.push(result.html); return; } flushList(); blocks.push(result.html); return; } if (name === "w:tbl") { flushList(); const result = orderedDocxTableToHtml(block, hyperlinkTargets, imageDataByRelationshipId, numberingDefinitions); hasTable = hasTable || Boolean(result.html); hasImage = hasImage || result.hasImage; hasLineBreak = hasLineBreak || result.hasLineBreak; hasPageBreak = hasPageBreak || result.hasPageBreak; hasTab = hasTab || result.hasTab; if (result.html) { blocks.push(result.html); } } }); flushList(); return { html: blocks.join(""), hasInlineColorOrHighlight, hasParagraphAlignment, hasBookmark, hasTable, hasImage, hasLineBreak, hasPageBreak, hasTab, hasList, hasQuote }; } function orderedDocxParagraphToHtml( paragraph: unknown, hyperlinkTargets: Record, imageDataByRelationshipId: DocxImageDataByRelationshipId = {}, numberingDefinitions: DocxNumberingDefinitions = {} ) { const properties = orderedElementToRecord(firstOrderedElement(orderedElementChildren(paragraph), "w:pPr")); const tagName = docxParagraphTagName(properties); const style = docxParagraphStyleAttribute(properties); const bookmark = orderedDocxParagraphBookmark(paragraph); const list = docxParagraphListInfo(properties, numberingDefinitions); const hasParagraphAlignment = Boolean(style); const hasBookmark = Boolean(bookmark); const hasQuote = tagName === "blockquote"; let hasInlineColorOrHighlight = false; let hasImage = false; let hasLineBreak = false; let hasPageBreak = false; let hasTab = false; const runs = orderedDocxParagraphInlineRecords(paragraph, hyperlinkTargets).map((inline) => { const result = docxRunToHtml(inline.run, imageDataByRelationshipId, inline.text); hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight || Boolean(inline.href); hasImage = hasImage || result.hasImage; hasLineBreak = hasLineBreak || result.hasLineBreak; hasPageBreak = hasPageBreak || result.hasPageBreak; hasTab = hasTab || result.hasTab; return inline.href && result.html ? `${result.html}` : result.html; }); const content = runs.join(""); if (!content.trim()) { return { html: "", hasInlineColorOrHighlight, hasParagraphAlignment, hasBookmark, hasImage, hasLineBreak, hasPageBreak, hasTab, hasQuote, list }; } const attributes = [style ? `style="${style}"` : "", bookmark ? `id="${escapeHtml(bookmark)}"` : ""].filter(Boolean).join(" "); if (list) { return { html: `${content}`, hasInlineColorOrHighlight, hasParagraphAlignment, hasBookmark, hasImage, hasLineBreak, hasPageBreak, hasTab, hasQuote, list }; } return { html: `<${tagName}${attributes ? ` ${attributes}` : ""}>${content}`, hasInlineColorOrHighlight, hasParagraphAlignment, hasBookmark, hasImage, hasLineBreak, hasPageBreak, hasTab, hasQuote, list }; } function orderedDocxTableToHtml( table: unknown, hyperlinkTargets: Record, imageDataByRelationshipId: DocxImageDataByRelationshipId = {}, numberingDefinitions: DocxNumberingDefinitions = {} ) { let hasImage = false; let hasLineBreak = false; let hasPageBreak = false; let hasTab = false; const rows = orderedDirectElements(table, "w:tr") .map((row) => { const cells = orderedDirectElements(row, "w:tc") .map((cell) => { const paragraphs = orderedDirectElements(cell, "w:p") .map((paragraph) => { const result = orderedDocxParagraphToHtml(paragraph, hyperlinkTargets, imageDataByRelationshipId, numberingDefinitions); hasImage = hasImage || result.hasImage; hasLineBreak = hasLineBreak || result.hasLineBreak; hasPageBreak = hasPageBreak || result.hasPageBreak; hasTab = hasTab || result.hasTab; return result.html; }) .filter(Boolean); const content = paragraphs.length > 0 ? paragraphs.join("") : escapeHtml(orderedDocxCellText(cell)); return content ? `${content}` : ""; }) .join(""); return cells ? `${cells}` : ""; }) .filter(Boolean); return { html: rows.length > 0 ? `${rows.join("")}
` : "", hasImage, hasLineBreak, hasPageBreak, hasTab }; } function orderedDocxCellText(cell: unknown) { return orderedDirectElements(cell, "w:p") .flatMap((paragraph) => orderedDocxParagraphInlineRecords(paragraph, {})) .map((inline) => inline.text) .join(" ") .trim(); } function docxParagraphTagName(properties: XmlRecord) { const styleValue = docxAttribute(childRecord(properties, "w:pStyle"), "w:val").toLowerCase(); if (["title", "heading1", "heading 1"].includes(styleValue)) { return "h1"; } if (["heading2", "heading 2"].includes(styleValue)) { return "h2"; } if (["heading3", "heading 3"].includes(styleValue)) { return "h3"; } if (["heading4", "heading 4"].includes(styleValue)) { return "h4"; } if (["heading5", "heading 5"].includes(styleValue)) { return "h5"; } if (["heading6", "heading 6"].includes(styleValue)) { return "h6"; } if (styleValue === "quote") { return "blockquote"; } return "p"; } function docxParagraphStyleAttribute(properties: XmlRecord) { const alignment = docxAttribute(childRecord(properties, "w:jc"), "w:val"); const htmlAlignment = alignment === "both" ? "justify" : alignment; const styles = [ ["left", "center", "right", "justify"].includes(htmlAlignment) ? `text-align: ${htmlAlignment};` : "", ...docxParagraphIndentStyles(properties), ...docxParagraphSpacingStyles(properties) ].filter(Boolean); return styles.join(" "); } function docxParagraphIndentStyles(properties: XmlRecord) { const indent = childRecord(properties, "w:ind"); const left = docxTwipsAttribute(indent, ["w:left", "w:start"]); const firstLine = docxTwipsAttribute(indent, ["w:firstLine"]); const hanging = docxTwipsAttribute(indent, ["w:hanging"]); return [ left ? `margin-left: ${formatPointSize(left / 20)}pt;` : "", firstLine ? `text-indent: ${formatPointSize(firstLine / 20)}pt;` : "", !firstLine && hanging ? `text-indent: -${formatPointSize(hanging / 20)}pt;` : "" ].filter(Boolean); } function docxParagraphSpacingStyles(properties: XmlRecord) { const spacing = childRecord(properties, "w:spacing"); const before = docxTwipsAttribute(spacing, ["w:before"]); const after = docxTwipsAttribute(spacing, ["w:after"]); const line = docxTwipsAttribute(spacing, ["w:line"]); const lineRule = docxAttribute(spacing, "w:lineRule"); return [ before ? `margin-top: ${formatPointSize(before / 20)}pt;` : "", after && after !== defaultParagraphSpacingAfterTwips ? `margin-bottom: ${formatPointSize(after / 20)}pt;` : "", docxLineHeightStyle(line, lineRule) ].filter(Boolean); } function docxLineHeightStyle(line: number | undefined, lineRule: string) { if (!line || line <= 0) { return ""; } const normalizedRule = lineRule.trim().toLowerCase(); if (!normalizedRule || normalizedRule === "auto") { return `line-height: ${formatLineHeightRatio(line / 240)};`; } return `line-height: ${formatPointSize(line / 20)}pt;`; } function formatLineHeightRatio(value: number) { return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); } function docxParagraphListInfo(properties: XmlRecord, numberingDefinitions: DocxNumberingDefinitions): DocxListInfo | undefined { const numbering = childRecord(properties, "w:numPr"); const numId = docxAttribute(childRecord(numbering, "w:numId"), "w:val"); if (!numId) { return undefined; } const level = normalizedNumberingLevel(docxAttribute(childRecord(numbering, "w:ilvl"), "w:val")); const definition = numberingDefinitions[`${numId}:${level}`] ?? numberingDefinitions[`${numId}:0`]; const ordered = definition?.ordered ?? false; return { ordered, level, key: `${ordered ? "ol" : "ul"}:${numId}:${level}` }; } function docxNumberingDefinitions(numberingXml: unknown): DocxNumberingDefinitions { const numbering = childRecord(numberingXml, "w:numbering"); const abstractFormats: Record> = {}; toArray(numbering["w:abstractNum"]).forEach((abstractNumbering) => { const abstractRecord = asRecord(abstractNumbering); const abstractId = docxAttribute(abstractRecord, "w:abstractNumId"); if (!abstractId) { return; } const levels = toArray(abstractRecord["w:lvl"]).reduce>((formats, levelRecord) => { const level = asRecord(levelRecord); const levelIndex = normalizedNumberingLevel(docxAttribute(level, "w:ilvl")); const format = docxAttribute(childRecord(level, "w:numFmt"), "w:val").toLowerCase(); formats[levelIndex] = { ordered: format !== "bullet" }; return formats; }, {}); abstractFormats[abstractId] = levels; }); return toArray(numbering["w:num"]).reduce((definitions, numRecord) => { const numberingRecord = asRecord(numRecord); const numId = docxAttribute(numberingRecord, "w:numId"); const abstractId = docxAttribute(childRecord(numberingRecord, "w:abstractNumId"), "w:val"); if (!numId || !abstractId) { return definitions; } Object.entries(abstractFormats[abstractId] ?? {}).forEach(([level, definition]) => { definitions[`${numId}:${level}`] = definition; }); return definitions; }, {}); } function normalizedNumberingLevel(value: unknown) { const level = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(level) && level >= 0 ? Math.min(level, 8) : 0; } function orderedDocxParagraphBookmark(paragraph: unknown) { const bookmarkStart = firstOrderedElement(orderedElementChildren(paragraph), "w:bookmarkStart"); return normalizedDocxAnchor(docxAttribute(orderedElementAttributes(bookmarkStart), "w:name") || docxAttribute(orderedElementAttributes(bookmarkStart), "name")); } function orderedDocxParagraphInlineRecords(paragraph: unknown, hyperlinkTargets: Record) { return orderedElementChildren(paragraph).flatMap((child) => { const name = orderedElementName(child); if (name === "w:r") { return [{ run: orderedElementToRecord(child), text: orderedDocxRunText(child) }]; } if (name !== "w:hyperlink") { return []; } const attributes = orderedElementAttributes(child); const relationshipId = docxAttribute(attributes, "r:id") || docxAttribute(attributes, "id"); const anchor = normalizedDocxAnchor(docxAttribute(attributes, "w:anchor") || docxAttribute(attributes, "anchor")); const href = normalizedExternalHyperlinkTarget(hyperlinkTargets[relationshipId]) || (anchor ? `#${anchor}` : ""); return orderedDirectElements(child, "w:r").map((run) => ({ run: orderedElementToRecord(run), text: orderedDocxRunText(run), ...(href ? { href } : {}) })); }); } function orderedDocxRunText(run: unknown) { return orderedElementChildren(run) .map((child) => { const name = orderedElementName(child); if (name === "w:t") { return xmlNodeText(orderedElementToRecord(child)); } if (name === "w:tab") { return "\t"; } if (name === "w:br") { return isDocxPageBreakAttributes(orderedElementAttributes(child)) ? pageBreakMarker : "\n"; } return ""; }) .join(""); } function docxRelationshipTargetsById(relationshipsXml: unknown) { const relationships = childRecord(relationshipsXml, "Relationships"); const targets: { hyperlinks: Record; images: Record } = { hyperlinks: {}, images: {} }; toArray(relationships.Relationship).forEach((relationship) => { const record = asRecord(relationship); const id = relationshipAttribute(record, "Id"); const type = relationshipAttribute(record, "Type"); const target = relationshipAttribute(record, "Target"); if (!id || !target) { return; } if (type === wordHyperlinkRelationshipType) { const normalizedTarget = normalizedExternalHyperlinkTarget(target); if (normalizedTarget) { targets.hyperlinks[id] = normalizedTarget; } return; } if (type === wordImageRelationshipType) { targets.images[id] = resolveDocxTarget("word", target); } }); return targets; } async function docxImageDataByRelationshipId(zip: ZipInstance, imageTargets: Record) { const entries = await Promise.all( Object.entries(imageTargets).map(async ([id, path]) => { const mimeType = imageMimeTypeFromPath(path); const data = mimeType ? await zip.file(path)?.async("base64") : ""; return data ? ([id, `data:${mimeType};base64,${data}`] as const) : null; }) ); return entries.reduce((images, entry) => { if (entry) { images[entry[0]] = entry[1]; } return images; }, {}); } function resolveDocxTarget(baseDirectory: string, target: string) { const normalizedTarget = target.replace(/\\/g, "/"); const rawPath = normalizedTarget.startsWith("/") ? normalizedTarget.replace(/^\/+/, "") : `${baseDirectory}/${normalizedTarget}`; const segments: string[] = []; rawPath.split("/").forEach((segment) => { if (!segment || segment === ".") { return; } if (segment === "..") { segments.pop(); return; } segments.push(segment); }); return segments.join("/"); } function imageMimeTypeFromPath(path: string) { const extension = path.toLowerCase().split(/[?#]/)[0].split(".").pop() ?? ""; const mimeTypes: Record = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", bmp: "image/bmp" }; return mimeTypes[extension] ?? ""; } function relationshipAttribute(record: XmlRecord, name: string) { const value = record[name] ?? record[name.toLowerCase()] ?? record[`@_${name}`] ?? record[`@_${name.toLowerCase()}`]; return typeof value === "string" || typeof value === "number" ? String(value) : ""; } function firstOrderedElement(nodes: unknown[], name: string) { return nodes.find((node) => orderedElementName(node) === name); } function orderedDirectElements(parent: unknown, name: string) { return orderedElementChildren(parent).filter((node) => orderedElementName(node) === name); } function orderedElementName(node: unknown) { return Object.keys(asRecord(node)).find((key) => key !== ":@") ?? ""; } function orderedElementChildren(node: unknown): unknown[] { const name = orderedElementName(node); return name ? toArray(asRecord(node)[name]) : []; } function orderedElementAttributes(node: unknown): XmlRecord { return asRecord(asRecord(node)[":@"]); } function orderedElementToRecord(node: unknown): XmlRecord { const record: XmlRecord = { ...orderedElementAttributes(node) }; orderedElementChildren(node).forEach((child) => { const childObject = asRecord(child); if (Object.prototype.hasOwnProperty.call(childObject, "#text")) { record["#text"] = `${record["#text"] ?? ""}${xmlNodeText(childObject["#text"])}`; return; } const childName = orderedElementName(child); if (!childName) { return; } appendXmlChild(record, childName, orderedElementToRecord(child)); }); return record; } function appendXmlChild(record: XmlRecord, key: string, value: XmlRecord) { const existing = record[key]; if (existing === undefined) { record[key] = value; return; } record[key] = Array.isArray(existing) ? [...existing, value] : [existing, value]; } function docxRunToHtml(run: unknown, imageDataByRelationshipId: DocxImageDataByRelationshipId = {}, orderedText?: string) { const record = asRecord(run); const properties = childRecord(record, "w:rPr"); const text = orderedText ?? docxRunText(record); const imageHtml = docxRunImageHtml(record, imageDataByRelationshipId); const color = docxRunColor(properties); const highlightColor = docxRunHighlightColor(properties); const fontSize = docxRunFontSize(properties); const fontFamily = docxRunFontFamily(properties); const hasStrike = hasDocxBoolean(properties, "w:strike") || hasDocxBoolean(properties, "w:dstrike"); const verticalAlign = docxRunVerticalAlign(properties); const hasSubscript = verticalAlign === "subscript"; const hasSuperscript = verticalAlign === "superscript"; const hasInlineColorOrHighlight = Boolean(color || highlightColor || fontSize || fontFamily || hasStrike || hasSubscript || hasSuperscript); const hasLineBreak = text.includes("\n"); const hasPageBreak = text.includes(pageBreakMarker); const hasTab = text.includes("\t"); let html = escapeHtmlDocxText(text); if (!text && !imageHtml) { return { html: "", hasInlineColorOrHighlight, hasImage: false, hasLineBreak, hasPageBreak, hasTab }; } if (text) { if (hasDocxBoolean(properties, "w:b")) { html = `${html}`; } if (hasDocxBoolean(properties, "w:i")) { html = `${html}`; } if (hasDocxBoolean(properties, "w:u")) { html = `${html}`; } if (hasStrike) { html = `${html}`; } const styles = [ color ? `color: #${color};` : "", highlightColor ? `background-color: #${highlightColor};` : "", fontSize ? `font-size: ${formatPointSize(fontSize)}pt;` : "", fontFamily ? `font-family: ${cssFontFamilyValue(fontFamily)};` : "" ].filter(Boolean); if (styles.length > 0) { html = `${html}`; } if (hasSubscript) { html = `${html}`; } else if (hasSuperscript) { html = `${html}`; } } return { html: `${imageHtml}${text ? html : ""}`, hasInlineColorOrHighlight, hasImage: Boolean(imageHtml), hasLineBreak, hasPageBreak, hasTab }; } function docxRunImageHtml(run: XmlRecord, imageDataByRelationshipId: DocxImageDataByRelationshipId) { const relationshipId = firstNestedAttribute(run, ["r:embed", "embed", "@_r:embed", "@_embed"]); const src = relationshipId ? imageDataByRelationshipId[relationshipId] : ""; if (!src) { return ""; } const alt = firstNestedAttribute(run, ["descr", "@_descr", "title", "@_title", "name", "@_name"]); const dimensions = docxImageDimensions(run); const attributes = [ `src="${escapeHtml(src)}"`, alt ? `alt="${escapeHtml(alt)}"` : "", dimensions.width ? `width="${dimensions.width}"` : "", dimensions.height ? `height="${dimensions.height}"` : "" ].filter(Boolean); return ``; } function docxImageDimensions(run: XmlRecord) { const extent = firstNestedRecord(run, ["wp:extent", "extent"]); const width = emuToPixels(docxNumberAttribute(extent, "cx")); const height = emuToPixels(docxNumberAttribute(extent, "cy")); return { ...(width ? { width } : {}), ...(height ? { height } : {}) }; } function firstNestedAttribute(value: unknown, names: string[]): string { if (Array.isArray(value)) { for (const item of value) { const result = firstNestedAttribute(item, names); if (result) { return result; } } return ""; } if (!value || typeof value !== "object") { return ""; } const record = value as XmlRecord; for (const name of names) { const raw = record[name]; if (typeof raw === "string" || typeof raw === "number") { return String(raw); } } for (const child of Object.values(record)) { const result = firstNestedAttribute(child, names); if (result) { return result; } } return ""; } function firstNestedRecord(value: unknown, names: string[]): XmlRecord { if (Array.isArray(value)) { for (const item of value) { const result = firstNestedRecord(item, names); if (Object.keys(result).length > 0) { return result; } } return {}; } if (!value || typeof value !== "object") { return {}; } const record = value as XmlRecord; for (const name of names) { const child = record[name]; if (child && typeof child === "object" && !Array.isArray(child)) { return child as XmlRecord; } } for (const child of Object.values(record)) { const result = firstNestedRecord(child, names); if (Object.keys(result).length > 0) { return result; } } return {}; } function docxNumberAttribute(record: XmlRecord, name: string) { const value = Number.parseFloat(String(record[name] ?? record[`@_${name}`] ?? "")); return Number.isFinite(value) && value > 0 ? value : 0; } function emuToPixels(value: number) { return value > 0 ? Math.round(value / 9525) : 0; } function docxRunText(run: XmlRecord) { const text = toArray(run["w:t"]).map(xmlNodeText).join(""); const tabs = toArray(run["w:tab"]).map(() => "\t").join(""); const breaks = toArray(run["w:br"]) .map((breakRecord) => (isDocxPageBreakAttributes(asRecord(breakRecord)) ? pageBreakMarker : "\n")) .join(""); return `${text}${tabs}${breaks}`; } function docxRunColor(properties: XmlRecord) { const value = docxAttribute(childRecord(properties, "w:color"), "w:val"); return normalizedDocxHexColor(value); } function docxRunHighlightColor(properties: XmlRecord) { const shadingFill = normalizedDocxHexColor(docxAttribute(childRecord(properties, "w:shd"), "w:fill")); if (shadingFill) { return shadingFill; } const highlight = docxAttribute(childRecord(properties, "w:highlight"), "w:val").toLowerCase(); const highlightColors: Record = { black: "000000", blue: "0000FF", cyan: "00FFFF", darkblue: "00008B", darkcyan: "008B8B", darkgray: "A9A9A9", darkgreen: "006400", darkmagenta: "8B008B", darkred: "8B0000", darkyellow: "808000", green: "00FF00", lightgray: "D3D3D3", magenta: "FF00FF", red: "FF0000", white: "FFFFFF", yellow: "FFFF00" }; return highlightColors[highlight] ?? ""; } function docxRunFontSize(properties: XmlRecord) { const value = Number.parseFloat(docxAttribute(childRecord(properties, "w:sz"), "w:val")); if (!Number.isFinite(value) || value <= 0) { return undefined; } return normalizedPointSize(value / 2); } function docxRunFontFamily(properties: XmlRecord) { const fonts = childRecord(properties, "w:rFonts"); return normalizedFontFamily( docxAttribute(fonts, "w:ascii") || docxAttribute(fonts, "w:hAnsi") || docxAttribute(fonts, "w:eastAsia") || docxAttribute(fonts, "w:cs") ); } function docxRunVerticalAlign(properties: XmlRecord) { return docxAttribute(childRecord(properties, "w:vertAlign"), "w:val").trim().toLowerCase(); } function normalizedDocxHexColor(value: string) { const normalized = value.trim().toUpperCase(); if (!normalized || normalized === "AUTO" || normalized === "NONE") { return ""; } return /^[0-9A-F]{6}$/.test(normalized) ? normalized : ""; } function hasDocxBoolean(properties: XmlRecord, key: string) { if (!Object.prototype.hasOwnProperty.call(properties, key)) { return false; } const value = docxAttribute(childRecord(properties, key), "w:val") || docxAttribute(childRecord(properties, key), "val"); return !["0", "false", "off"].includes(value.toLowerCase()); } function childRecord(value: unknown, key: string): XmlRecord { const child = asRecord(value)[key]; if (Array.isArray(child)) { return asRecord(child[0]); } return asRecord(child); } function asRecord(value: unknown): XmlRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as XmlRecord) : {}; } function toArray(value: T | T[] | undefined): T[] { if (value === undefined) { return []; } return Array.isArray(value) ? value : [value]; } function docxAttribute(record: XmlRecord, name: string) { const value = record[name] ?? record[name.replace(/^w:/, "")]; return typeof value === "string" || typeof value === "number" ? String(value) : ""; } function docxTwipsAttribute(record: XmlRecord, names: string[]) { for (const name of names) { const value = normalizedTwips(docxAttribute(record, name)); if (value) { return value; } } return undefined; } function xmlNodeText(value: unknown): string { if (typeof value === "string" || typeof value === "number") { return String(value); } return String(asRecord(value)["#text"] ?? ""); } function escapeHtml(value: string) { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function escapeHtmlDocxText(value: string) { return escapeHtml(value) .split(pageBreakMarker) .join(pageBreakHtml) .replace(/\t/g, '\t') .replace(/\n/g, "
"); } function collectInlineRuns(nodes: ChildNode[], format: InlineFormat): WordInlineRun[] { return collectInlineSegments(nodes, format).flatMap((segment) => (segment.type === "runs" ? segment.runs : [])); } function collectInlineSegments(nodes: ChildNode[], format: InlineFormat): InlineSegment[] { return nodes.flatMap((node) => { if (node.nodeType === Node.TEXT_NODE) { const text = normalizeInlineWhitespace(node.textContent ?? ""); return text ? [{ type: "runs", runs: [{ text, ...format }] }] : []; } if (!(node instanceof Element)) { return []; } if (isQOfficePageBreakElement(node)) { return [{ type: "runs", runs: [{ text: pageBreakMarker, ...format }] }]; } if (node.tagName.toLowerCase() === "br") { return [{ type: "runs", runs: [{ text: "\n", ...format }] }]; } if (node.getAttribute("data-qoffice-tab") === "true") { return [{ type: "runs", runs: [{ text: "\t", ...format }] }]; } if (node.tagName.toLowerCase() === "img") { return [{ type: "image", image: imageBlockFromElement(node) }]; } return collectInlineSegments(Array.from(node.childNodes), formatForElement(node, format)); }); } function formatForElement(element: Element, inherited: InlineFormat): InlineFormat { const tagName = element.tagName.toLowerCase(); const style = element.getAttribute("style") ?? ""; const href = tagName === "a" ? normalizedHyperlinkTarget(element.getAttribute("href")) : inherited.href; const color = cssColorValue(style, "color") || inherited.color; const highlightColor = cssColorValue(style, "background-color") || cssColorValue(style, "background") || inherited.highlightColor; const fontSize = cssFontSizeValue(style) ?? inherited.fontSize; const fontFamily = cssFontFamilyStyleValue(style) || inherited.fontFamily; const verticalAlign = cssVerticalAlignValue(style); const isSubscript = tagName === "sub" || verticalAlign === "sub"; const isSuperscript = tagName === "sup" || verticalAlign === "super"; const subscript = isSubscript ? true : isSuperscript ? false : inherited.subscript; const superscript = isSuperscript ? true : isSubscript ? false : inherited.superscript; return cleanInlineFormat({ bold: inherited.bold || tagName === "b" || tagName === "strong" || /font-weight\s*:\s*(bold|[6-9]00)\b/i.test(style), italics: inherited.italics || tagName === "i" || tagName === "em" || /font-style\s*:\s*italic\b/i.test(style), underline: inherited.underline || tagName === "u" || /text-decoration(?:-line)?\s*:[^;]*underline/i.test(style), strike: inherited.strike || tagName === "s" || tagName === "strike" || tagName === "del" || /text-decoration(?:-line)?\s*:[^;]*line-through/i.test(style), ...(subscript ? { subscript } : {}), ...(superscript ? { superscript } : {}), ...(href ? { href } : {}), ...(color ? { color } : {}), ...(highlightColor ? { highlightColor } : {}), ...(fontSize ? { fontSize } : {}), ...(fontFamily ? { fontFamily } : {}) }); } function normalizeInlineRuns(runs: WordInlineRun[]) { const normalized = runs .map((run) => ({ ...run, text: normalizeInlineRunText(run.text) })) .filter((run) => run.text.length > 0); if (normalized.length === 0) { return []; } normalized[0] = { ...normalized[0], text: normalized[0].text.trimStart() }; const lastIndex = normalized.length - 1; normalized[lastIndex] = { ...normalized[lastIndex], text: normalized[lastIndex].text.trimEnd() }; return normalized .filter((run) => run.text.length > 0) .reduce((merged, run) => { const previous = merged[merged.length - 1]; if (previous && sameInlineFormat(previous, run)) { previous.text += run.text; return merged; } merged.push(run); return merged; }, []); } function sameInlineFormat(first: InlineFormat, second: InlineFormat) { return ( Boolean(first.bold) === Boolean(second.bold) && Boolean(first.italics) === Boolean(second.italics) && Boolean(first.underline) === Boolean(second.underline) && Boolean(first.strike) === Boolean(second.strike) && Boolean(first.subscript) === Boolean(second.subscript) && Boolean(first.superscript) === Boolean(second.superscript) && (first.href ?? "") === (second.href ?? "") && (first.color ?? "") === (second.color ?? "") && (first.highlightColor ?? "") === (second.highlightColor ?? "") && (first.fontSize ?? 0) === (second.fontSize ?? 0) && (first.fontFamily ?? "") === (second.fontFamily ?? "") ); } function cleanInlineFormat(format: InlineFormat): InlineFormat { return { ...(format.bold ? { bold: true } : {}), ...(format.italics ? { italics: true } : {}), ...(format.underline ? { underline: true } : {}), ...(format.strike ? { strike: true } : {}), ...(format.superscript ? { superscript: true } : format.subscript ? { subscript: true } : {}), ...(format.href ? { href: format.href } : {}), ...(format.color ? { color: format.color } : {}), ...(format.highlightColor ? { highlightColor: format.highlightColor } : {}), ...(format.fontSize ? { fontSize: format.fontSize } : {}), ...(format.fontFamily ? { fontFamily: format.fontFamily } : {}) }; } function normalizedPointSize(value: unknown) { const numeric = typeof value === "number" ? value : Number.parseFloat(String(value ?? "")); if (!Number.isFinite(numeric) || numeric <= 0) { return undefined; } return Math.round(numeric * 2) / 2; } function normalizedTwips(value: unknown) { const numeric = typeof value === "number" ? value : Number.parseFloat(String(value ?? "")); if (!Number.isFinite(numeric) || numeric <= 0) { return undefined; } return Math.min(31680, Math.round(numeric)); } function formatPointSize(value: number) { return Number.isInteger(value) ? String(value) : value.toFixed(1); } function normalizedFontFamily(value?: string) { const normalized = value?.trim().replace(/^["']|["']$/g, "") ?? ""; if (!normalized || normalized.length > 80 || /[<>;]/.test(normalized)) { return ""; } return normalized; } function cssFontFamilyValue(value: string) { return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`; } function normalizedHyperlinkTarget(value?: string | null) { const internalHref = normalizedInternalHyperlinkHref(value); if (internalHref) { return internalHref; } return normalizedExternalHyperlinkTarget(value); } function normalizedExternalHyperlinkTarget(value?: string | null) { const raw = value?.trim(); if (!raw) { return ""; } try { const url = new URL(raw); const protocol = url.protocol.toLowerCase(); return ["http:", "https:", "mailto:", "tel:", "ftp:"].includes(protocol) ? url.href : ""; } catch { return ""; } } function normalizedInternalHyperlinkHref(value?: string | null) { const anchor = normalizedInternalHyperlinkAnchor(value); return anchor ? `#${anchor}` : ""; } function normalizedInternalHyperlinkAnchor(value?: string | null) { const raw = value?.trim(); return raw?.startsWith("#") ? normalizedDocxAnchor(raw.slice(1)) : ""; } function normalizedDocxAnchor(value?: string | null) { const normalized = value?.trim() ?? ""; return /^[A-Za-z_][A-Za-z0-9_.:-]{0,79}$/.test(normalized) ? normalized : ""; } function imageBlockFromElement(element: Element): WordImageBlock { return { type: "image", src: element.getAttribute("src") ?? "", ...(element.getAttribute("alt") ? { alt: element.getAttribute("alt") ?? "" } : {}), ...imageElementDimensions(element) }; } function imageElementDimensions(element: Element) { const width = numericAttribute(element, "width") ?? cssPixelValue(element.getAttribute("style") ?? "", "width"); const height = numericAttribute(element, "height") ?? cssPixelValue(element.getAttribute("style") ?? "", "height"); return { ...(width ? { width } : {}), ...(height ? { height } : {}) }; } function numericAttribute(element: Element, name: string) { const value = Number.parseFloat(element.getAttribute(name) ?? ""); return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined; } function cssPixelValue(style: string, property: "width" | "height") { const match = new RegExp(`${property}\\s*:\\s*(\\d+(?:\\.\\d+)?)px`, "i").exec(style); if (!match) { return undefined; } const value = Number.parseFloat(match[1]); return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined; } function parseDataUriImage(src: string) { const match = /^data:(image\/(?:png|jpe?g|gif|bmp));base64,([a-z0-9+/=\s]+)$/i.exec(src.trim()); if (!match) { return null; } const type = imageTypeFromMime(match[1]); if (!type) { return null; } return { type, bytes: base64ToBytes(match[2].replace(/\s+/g, "")) }; } function imageTypeFromMime(mimeType: string) { const normalized = mimeType.toLowerCase(); if (normalized === "image/jpeg" || normalized === "image/jpg") { return "jpg"; } if (normalized === "image/png") { return "png"; } if (normalized === "image/gif") { return "gif"; } if (normalized === "image/bmp") { return "bmp"; } return ""; } function imageDimensions(block: WordImageBlock, bytes: Uint8Array) { const detected = detectImageDimensions(bytes); const width = Math.min(block.width ?? detected?.width ?? 520, 620); const height = block.height ?? (detected ? Math.max(1, Math.round((width / detected.width) * detected.height)) : undefined) ?? 260; return { width, height }; } function detectImageDimensions(bytes: Uint8Array) { const png = pngDimensions(bytes); if (png) { return png; } return jpegDimensions(bytes); } function pngDimensions(bytes: Uint8Array) { const isPng = bytes.length >= 24 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && bytes[12] === 0x49 && bytes[13] === 0x48 && bytes[14] === 0x44 && bytes[15] === 0x52; if (!isPng) { return null; } return { width: readUInt32BE(bytes, 16), height: readUInt32BE(bytes, 20) }; } function jpegDimensions(bytes: Uint8Array) { if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) { return null; } let offset = 2; while (offset + 9 < bytes.length) { if (bytes[offset] !== 0xff) { offset += 1; continue; } const marker = bytes[offset + 1]; const length = (bytes[offset + 2] << 8) + bytes[offset + 3]; if (length < 2) { return null; } const isStartOfFrame = marker >= 0xc0 && marker <= 0xc3; if (isStartOfFrame) { return { height: (bytes[offset + 5] << 8) + bytes[offset + 6], width: (bytes[offset + 7] << 8) + bytes[offset + 8] }; } offset += 2 + length; } return null; } function readUInt32BE(bytes: Uint8Array, offset: number) { return ((bytes[offset] << 24) >>> 0) + (bytes[offset + 1] << 16) + (bytes[offset + 2] << 8) + bytes[offset + 3]; } function base64ToBytes(value: string) { const binary = atob(value); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } return bytes; } function inlineRunsText(runs: WordInlineRun[]) { return normalizeWhitespace(runs.map((run) => run.text).join("")); } function elementText(element: Element) { return normalizeWhitespace(element.textContent ?? ""); } function normalizeWhitespace(value: string) { return value.replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim(); } function normalizeInlineWhitespace(value: string) { return value.replace(/\u00a0/g, " ").replace(/\s+/g, " "); } function normalizeInlineRunText(value: string) { return value .replace(/\u00a0/g, " ") .replace(/[^\S\n\t\f]+/g, " ") .replace(/ *\n */g, "\n") .replace(/ *\t */g, "\t") .replace(/ *\f */g, pageBreakMarker); } function isQOfficePageBreakElement(element: Element) { return element.getAttribute("data-qoffice-page-break") === "true"; } function isDocxPageBreakAttributes(attributes: XmlRecord) { return docxAttribute(attributes, "w:type").toLowerCase() === "page"; } async function loadMammoth() { const module = (await import("mammoth")) as { default?: MammothModule } & MammothModule; return module.default ?? module; } async function loadJsZip() { const module = (await import("jszip")) as { default?: ZipConstructor } & ZipConstructor; return module.default ?? module; } async function loadXmlParser() { const module = (await import("fast-xml-parser")) as FastXmlParserModule; return module.XMLParser; } async function loadDocx() { const module = (await import("docx")) as unknown as { default?: DocxModule } & DocxModule; return module.default ?? module; }