Initial QOffice implementation
This commit is contained in:
@@ -0,0 +1,804 @@
|
||||
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;
|
||||
href?: string;
|
||||
color?: string;
|
||||
highlightColor?: 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 type WordBlock =
|
||||
| { type: "heading"; level: 1 | 2; text: string; runs: WordInlineRun[]; alignment?: WordParagraphAlignment }
|
||||
| { type: "paragraph"; text: string; runs: WordInlineRun[]; alignment?: WordParagraphAlignment }
|
||||
| { type: "list"; ordered: boolean; items: WordListItem[] }
|
||||
| { type: "table"; rows: string[][] }
|
||||
| WordImageBlock;
|
||||
|
||||
type MammothModule = {
|
||||
convertToHtml: (
|
||||
input: { arrayBuffer: ArrayBuffer },
|
||||
options?: { styleMap?: string[]; convertImage?: unknown }
|
||||
) => Promise<{ value: string; messages?: unknown[] }>;
|
||||
images?: {
|
||||
dataUri?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type DocxModule = {
|
||||
AlignmentType?: Record<string, string>;
|
||||
Document: new (options: Record<string, unknown>) => unknown;
|
||||
HeadingLevel: Record<string, string>;
|
||||
Packer: {
|
||||
toBlob?: (document: unknown) => Promise<Blob>;
|
||||
toBuffer?: (document: unknown) => Promise<ArrayBuffer | Uint8Array>;
|
||||
};
|
||||
Paragraph: new (options: Record<string, unknown>) => unknown;
|
||||
Table: new (options: Record<string, unknown>) => unknown;
|
||||
TableCell: new (options: Record<string, unknown>) => unknown;
|
||||
TableRow: new (options: Record<string, unknown>) => unknown;
|
||||
TextRun: new (options: string | Record<string, unknown>) => unknown;
|
||||
WidthType?: Record<string, string>;
|
||||
ExternalHyperlink?: new (options: { children: unknown[]; link: string }) => unknown;
|
||||
ImageRun?: new (options: Record<string, unknown>) => unknown;
|
||||
};
|
||||
|
||||
type InlineFormat = Omit<WordInlineRun, "text">;
|
||||
type InlineSegment = { type: "runs"; runs: WordInlineRun[] } | { type: "image"; image: WordImageBlock };
|
||||
|
||||
const mammothStyleMap = [
|
||||
"p[style-name='Title'] => h1:fresh",
|
||||
"p[style-name='Heading 1'] => h1:fresh",
|
||||
"p[style-name='Heading 2'] => h2:fresh"
|
||||
];
|
||||
|
||||
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 = await mammoth.convertToHtml(
|
||||
{ arrayBuffer },
|
||||
{
|
||||
styleMap: mammothStyleMap,
|
||||
...(mammoth.images?.dataUri ? { convertImage: mammoth.images.dataUri } : {})
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
title: officeTitleFromFileName(file.name, "Документ QWord.docx"),
|
||||
html: normalizeImportedDocxHtml(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 (tagName === "h1" || tagName === "h2") {
|
||||
const runs = elementInlineRuns(node);
|
||||
const text = inlineRunsText(runs);
|
||||
const alignment = paragraphAlignmentFromElement(node);
|
||||
if (text) {
|
||||
blocks.push({ type: "heading", level: tagName === "h1" ? 1 : 2, text, runs, ...(alignment ? { alignment } : {}) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tagName === "p" || tagName === "div") {
|
||||
blocks.push(...paragraphElementToBlocks(node));
|
||||
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") {
|
||||
return [
|
||||
new docx.Paragraph({
|
||||
heading: block.level === 1 ? docx.HeadingLevel.HEADING_1 : docx.HeadingLevel.HEADING_2,
|
||||
spacing: { after: 160 },
|
||||
...docxAlignmentOption(block.alignment, docx),
|
||||
children: wordInlineRunsToTextRuns(block.runs, docx)
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
if (block.type === "paragraph") {
|
||||
return [
|
||||
new docx.Paragraph({
|
||||
spacing: { after: 160 },
|
||||
...docxAlignmentOption(block.alignment, docx),
|
||||
children: wordInlineRunsToTextRuns(block.runs, 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 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.color) {
|
||||
options.color = run.color;
|
||||
}
|
||||
if (run.highlightColor) {
|
||||
options.shading = { fill: run.highlightColor };
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function wordInlineRunsToTextRuns(runs: WordInlineRun[], docx: DocxModule) {
|
||||
const normalizedRuns = runs.length > 0 ? runs : [{ text: "" }];
|
||||
return normalizedRuns.map((run) => {
|
||||
const option = wordInlineRunToTextRunOption(run);
|
||||
const href = normalizedHyperlinkTarget(run.href);
|
||||
if (href && docx.ExternalHyperlink) {
|
||||
return new docx.ExternalHyperlink({
|
||||
link: href,
|
||||
children: [new docx.TextRun({ ...option, style: "Hyperlink" })]
|
||||
});
|
||||
}
|
||||
|
||||
return new docx.TextRun(option);
|
||||
});
|
||||
}
|
||||
|
||||
function docxAlignmentOption(alignment: WordParagraphAlignment | undefined, docx: DocxModule) {
|
||||
const value = docxAlignmentValue(alignment, docx);
|
||||
return value ? { alignment: value } : {};
|
||||
}
|
||||
|
||||
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((cell) => cell.length > 0)
|
||||
)
|
||||
.filter((row) => row.length > 0);
|
||||
}
|
||||
|
||||
function paragraphElementToBlocks(element: Element): WordBlock[] {
|
||||
const segments = collectInlineSegments(Array.from(element.childNodes), {});
|
||||
const alignment = paragraphAlignmentFromElement(element);
|
||||
const blocks: WordBlock[] = [];
|
||||
let pendingRuns: WordInlineRun[] = [];
|
||||
|
||||
const flushParagraph = () => {
|
||||
const runs = normalizeInlineRuns(pendingRuns);
|
||||
const text = inlineRunsText(runs);
|
||||
if (text) {
|
||||
blocks.push({ type: "paragraph", text, runs, ...(alignment ? { alignment } : {}) });
|
||||
}
|
||||
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 cssTextAlignValue(style: string) {
|
||||
return /(?:^|;)\s*text-align\s*:\s*([^;]+)/i.exec(style)?.[1] ?? "";
|
||||
}
|
||||
|
||||
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 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] ?? "";
|
||||
}
|
||||
|
||||
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 (node.tagName.toLowerCase() === "br") {
|
||||
return [{ type: "runs", runs: [{ text: " ", ...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;
|
||||
|
||||
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),
|
||||
...(href ? { href } : {}),
|
||||
...(color ? { color } : {}),
|
||||
...(highlightColor ? { highlightColor } : {})
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeInlineRuns(runs: WordInlineRun[]) {
|
||||
const normalized = runs
|
||||
.map((run) => ({ ...run, text: normalizeInlineWhitespace(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) &&
|
||||
(first.href ?? "") === (second.href ?? "") &&
|
||||
(first.color ?? "") === (second.color ?? "") &&
|
||||
(first.highlightColor ?? "") === (second.highlightColor ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
function cleanInlineFormat(format: InlineFormat): InlineFormat {
|
||||
return {
|
||||
...(format.bold ? { bold: true } : {}),
|
||||
...(format.italics ? { italics: true } : {}),
|
||||
...(format.underline ? { underline: true } : {}),
|
||||
...(format.href ? { href: format.href } : {}),
|
||||
...(format.color ? { color: format.color } : {}),
|
||||
...(format.highlightColor ? { highlightColor: format.highlightColor } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedHyperlinkTarget(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 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, " ");
|
||||
}
|
||||
|
||||
async function loadMammoth() {
|
||||
const module = (await import("mammoth")) as { default?: MammothModule } & MammothModule;
|
||||
return module.default ?? module;
|
||||
}
|
||||
|
||||
async function loadDocx() {
|
||||
const module = (await import("docx")) as unknown as { default?: DocxModule } & DocxModule;
|
||||
return module.default ?? module;
|
||||
}
|
||||
Reference in New Issue
Block a user