Files
QOffice/src/io/wordOffice.ts
T
2026-06-02 09:06:22 +03:00

2166 lines
68 KiB
TypeScript

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<string, unknown>;
type ZipObject = {
async: (type: "string" | "base64") => Promise<string>;
};
type ZipInstance = {
file: (path: string) => ZipObject | null;
};
type ZipConstructor = {
loadAsync: (data: ArrayBuffer) => Promise<ZipInstance>;
};
type XmlParserConstructor = new (options: Record<string, unknown>) => {
parse: (xml: string) => unknown;
};
type FastXmlParserModule = {
XMLParser: XmlParserConstructor;
};
type DocxModule = {
AlignmentType?: Record<string, string>;
Document: new (options: Record<string, unknown>) => unknown;
HeadingLevel: Record<string, string>;
LineRuleType?: Record<string, string>;
Packer: {
toBlob?: (document: unknown) => Promise<Blob>;
toBuffer?: (document: unknown) => Promise<ArrayBuffer | Uint8Array>;
};
Paragraph: new (options: Record<string, unknown>) => unknown;
PageBreak?: new () => unknown;
Bookmark?: new (options: { id: string; children: unknown[] }) => unknown;
Table: new (options: Record<string, unknown>) => unknown;
TableCell: new (options: Record<string, unknown>) => unknown;
TableRow: new (options: Record<string, unknown>) => unknown;
Tab?: new () => unknown;
TextRun: new (options: string | Record<string, unknown>) => unknown;
WidthType?: Record<string, string>;
ExternalHyperlink?: new (options: { children: unknown[]; link: string }) => unknown;
InternalHyperlink?: new (options: { children: unknown[]; anchor: string }) => unknown;
ImageRun?: new (options: Record<string, unknown>) => unknown;
};
const defaultParagraphSpacingAfterTwips = 160;
type InlineFormat = Omit<WordInlineRun, "text">;
type InlineSegment = { type: "runs"; runs: WordInlineRun[] } | { type: "image"; image: WordImageBlock };
type DocxInlineRecord = { run: unknown; text: string; href?: string };
type DocxImageDataByRelationshipId = Record<string, string>;
type DocxNumberingDefinitions = Record<string, { ordered: boolean }>;
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 = '<span data-qoffice-page-break="true"></span>';
export async function importDocxFile(file: File): Promise<ImportedWordDocument> {
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<Blob> {
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<string, unknown> = { 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<string, unknown> = {}) {
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<string, unknown>) {
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(`<main>${html}</main>`, "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<string, string> = {
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 !/<w:(pict|sdt)\b/i.test(documentXml);
}
function orderedDocxDocumentToHtml(
documentXml: unknown,
hyperlinkTargets: Record<string, string> = {},
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("")}</${tagName}>`);
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<string, string>,
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 ? `<a href="${escapeHtml(inline.href)}">${result.html}</a>` : 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: `<li${attributes ? ` ${attributes}` : ""}>${content}</li>`,
hasInlineColorOrHighlight,
hasParagraphAlignment,
hasBookmark,
hasImage,
hasLineBreak,
hasPageBreak,
hasTab,
hasQuote,
list
};
}
return {
html: `<${tagName}${attributes ? ` ${attributes}` : ""}>${content}</${tagName}>`,
hasInlineColorOrHighlight,
hasParagraphAlignment,
hasBookmark,
hasImage,
hasLineBreak,
hasPageBreak,
hasTab,
hasQuote,
list
};
}
function orderedDocxTableToHtml(
table: unknown,
hyperlinkTargets: Record<string, string>,
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 ? `<td>${content}</td>` : "<td></td>";
})
.join("");
return cells ? `<tr>${cells}</tr>` : "";
})
.filter(Boolean);
return { html: rows.length > 0 ? `<table><tbody>${rows.join("")}</tbody></table>` : "", 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<string, Record<number, { ordered: boolean }>> = {};
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<Record<number, { ordered: boolean }>>((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<DocxNumberingDefinitions>((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<string, string>) {
return orderedElementChildren(paragraph).flatMap<DocxInlineRecord>((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<string, string>; images: Record<string, string> } = { 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<string, string>) {
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<DocxImageDataByRelationshipId>((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<string, string> = {
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 = `<strong>${html}</strong>`;
}
if (hasDocxBoolean(properties, "w:i")) {
html = `<em>${html}</em>`;
}
if (hasDocxBoolean(properties, "w:u")) {
html = `<u>${html}</u>`;
}
if (hasStrike) {
html = `<s>${html}</s>`;
}
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 = `<span style="${styles.join(" ")}">${html}</span>`;
}
if (hasSubscript) {
html = `<sub>${html}</sub>`;
} else if (hasSuperscript) {
html = `<sup>${html}</sup>`;
}
}
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 `<img ${attributes.join(" ")}>`;
}
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<string, string> = {
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<T>(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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function escapeHtmlDocxText(value: string) {
return escapeHtml(value)
.split(pageBreakMarker)
.join(pageBreakHtml)
.replace(/\t/g, '<span data-qoffice-tab="true" style="white-space: pre;">\t</span>')
.replace(/\n/g, "<br>");
}
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<WordInlineRun[]>((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;
}