Initial QOffice implementation

This commit is contained in:
Курнат Андрей
2026-06-01 05:31:05 +03:00
commit 9bf4dd3672
44 changed files with 16854 additions and 0 deletions
+804
View File
@@ -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;
}