2166 lines
68 KiB
TypeScript
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
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;
|
|
}
|