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
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { base64ToBytes, bytesToBase64 } from "./desktopOfficeFiles";
describe("desktopOfficeFiles helpers", () => {
it("кодирует и декодирует бинарные данные без потерь", () => {
const source = new Uint8Array([0, 1, 2, 127, 128, 255]);
expect([...base64ToBytes(bytesToBase64(source))]).toEqual([...source]);
});
it("поддерживает данные больше одного chunk", () => {
const source = new Uint8Array(70000);
source.forEach((_, index) => {
source[index] = index % 251;
});
expect([...base64ToBytes(bytesToBase64(source)).slice(69990)]).toEqual([...source.slice(69990)]);
});
});
+56
View File
@@ -0,0 +1,56 @@
export type OfficeFileKind = "docx" | "xlsx" | "pptx" | "eml" | "mail";
const mimeTypes: Record<OfficeFileKind, string> = {
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
eml: "message/rfc822",
mail: "application/octet-stream"
};
export function isDesktopOfficeBridgeAvailable() {
return Boolean(window.qoffice?.isDesktop && window.qoffice.openOfficeFile && window.qoffice.saveOfficeFile);
}
export async function openDesktopOfficeFile(kind: OfficeFileKind): Promise<File | null> {
const result = await window.qoffice?.openOfficeFile?.(kind);
if (!result || result.canceled) {
return null;
}
if (!result.base64Data) {
throw new Error("Desktop-оболочка QOffice не вернула данные файла.");
}
return new File([base64ToBytes(result.base64Data)], result.fileName ?? `qoffice.${kind}`, {
type: mimeTypes[kind]
});
}
export async function saveDesktopOfficeFile(kind: OfficeFileKind, defaultFileName: string, blob: Blob) {
const bridge = window.qoffice;
if (!bridge?.saveOfficeFile) {
throw new Error("Desktop-оболочка QOffice недоступна.");
}
const base64Data = bytesToBase64(new Uint8Array(await blob.arrayBuffer()));
return bridge.saveOfficeFile(kind, defaultFileName, base64Data);
}
export function bytesToBase64(bytes: Uint8Array) {
let binary = "";
const chunkSize = 0x8000;
for (let index = 0; index < bytes.length; index += chunkSize) {
binary += String.fromCharCode(...bytes.slice(index, index + chunkSize));
}
return btoa(binary);
}
export function base64ToBytes(base64Data: string) {
const binary = atob(base64Data);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
+377
View File
@@ -0,0 +1,377 @@
import { describe, expect, it } from "vitest";
import {
buildChartXml,
buildCellXml,
buildHyperlinksXml,
buildMergeCellsXml,
createCellStyleRegistry,
buildWorksheetRelationshipsXml,
buildWorksheetXml,
exportXlsxBlob,
importXlsxFile,
chartDataPoints,
chartXmlToSheetChart,
workbookRelationshipTargets,
workbookSheetRefs,
worksheetXmlToCells,
worksheetXmlToCellFormats,
worksheetXmlToHyperlinks,
worksheetXmlToMergedCells,
xlsxStylesXmlToCellFormatTable,
xlsxCellToText
} from "./excellOffice";
describe("excellOffice OOXML helpers", () => {
it("сохраняет формулу при импорте как строку с '='", () => {
expect(xlsxCellToText({ r: "C1", f: "SUM(A1:B1)", v: "3" }, [])).toBe("=SUM(A1:B1)");
expect(xlsxCellToText({ r: "C2", f: { "#text": "A2*2" }, v: "8" }, [])).toBe("=A2*2");
});
it("читает shared string, inline string, число и boolean", () => {
expect(xlsxCellToText({ r: "A1", t: "s", v: "1" }, ["Первый", "Второй"])).toBe("Второй");
expect(xlsxCellToText({ r: "A2", t: "inlineStr", is: { t: "Текст" } }, [])).toBe("Текст");
expect(xlsxCellToText({ r: "A3", v: "12500" }, [])).toBe("12500");
expect(xlsxCellToText({ r: "A4", t: "b", v: "1" }, [])).toBe("true");
});
it("записывает формулу в тег <f>", () => {
expect(buildCellXml("c1", "=SUM(A1:B1)")).toBe('<c r="C1"><f>SUM(A1:B1)</f></c>');
});
it("собирает XML листа с отсортированными ячейками", () => {
const xml = buildWorksheetXml({
B2: "200",
A1: "Статья",
C2: "=B2*2"
});
expect(xml).toContain('<row r="1"><c r="A1" t="inlineStr"><is><t>Статья</t></is></c></row>');
expect(xml).toContain('<row r="2"><c r="B2"><v>200</v></c><c r="C2"><f>B2*2</f></c></row>');
});
it("сохраняет merged cells в XML листа", () => {
const xml = buildWorksheetXml({ A1: "Заголовок" }, [{ start: "A1", end: "C2" }]);
expect(buildMergeCellsXml([{ start: "C2", end: "A1" }, { start: "A1", end: "C2" }])).toBe(
'<mergeCells count="1"><mergeCell ref="A1:C2"/></mergeCells>'
);
expect(xml).toContain('<mergeCells count="1"><mergeCell ref="A1:C2"/></mergeCells>');
});
it("сохраняет внешние гиперссылки XLSX в XML листа и relationships", () => {
const hyperlinks = {
A1: "https://example.com/report",
B2: "javascript:alert(1)"
};
const xml = buildWorksheetXml({ A1: "Отчет", B2: "Скрипт" }, [], hyperlinks);
const relationshipsXml = buildWorksheetRelationshipsXml(hyperlinks);
expect(buildHyperlinksXml(hyperlinks)).toBe('<hyperlinks><hyperlink ref="A1" r:id="rId1"/></hyperlinks>');
expect(xml).toContain('<hyperlinks><hyperlink ref="A1" r:id="rId1"/></hyperlinks>');
expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"');
expect(relationshipsXml).toContain('Target="https://example.com/report"');
expect(relationshipsXml).toContain('TargetMode="External"');
expect(relationshipsXml).not.toContain("javascript:alert");
});
it("сохраняет базовое форматирование ячеек XLSX в XML листа", () => {
const cellFormats = {
A1: { bold: true, fillColor: "fff2cc" },
B2: { italics: true, underline: true },
C3: { fillColor: "bad-color" }
};
const registry = createCellStyleRegistry([cellFormats]);
const xml = buildWorksheetXml({ A1: "Итог", B2: "Комментарий", C3: "Без стиля" }, [], {}, cellFormats, registry);
const emptyCellXml = buildWorksheetXml({}, [], {}, { C3: { underline: true } });
expect(xml).toContain('<c r="A1" s="1" t="inlineStr"><is><t>Итог</t></is></c>');
expect(xml).toContain('<c r="B2" s="2" t="inlineStr"><is><t>Комментарий</t></is></c>');
expect(xml).toContain('<c r="C3" t="inlineStr"><is><t>Без стиля</t></is></c>');
expect(emptyCellXml).toContain('<c r="C3" s="1"/>');
});
it("читает базовое форматирование ячеек XLSX из styles.xml и XML листа", () => {
const styleTable = xlsxStylesXmlToCellFormatTable({
styleSheet: {
numFmts: {
numFmt: { numFmtId: "164", formatCode: "dd.mm.yyyy" }
},
fonts: {
font: [{}, { b: {}, i: {}, u: {} }]
},
fills: {
fill: [{}, {}, { patternFill: { fgColor: { rgb: "FFFFF2CC" } } }]
},
cellXfs: {
xf: [{ fontId: "0", fillId: "0" }, { fontId: "1", fillId: "2", numFmtId: "164" }, { fontId: "0", fillId: "0", numFmtId: "14" }]
}
}
});
expect(styleTable[1]).toEqual({ bold: true, italics: true, underline: true, fillColor: "FFF2CC", numberFormat: "dd.mm.yyyy" });
expect(styleTable[2]).toEqual({ numberFormat: "m/d/yy" });
expect(
worksheetXmlToCellFormats(
{
worksheet: {
sheetData: {
row: {
r: "1",
c: [{ r: "A1", s: "1", t: "inlineStr", is: { t: "Итог" } }]
}
}
}
},
styleTable
)
).toEqual({ A1: { bold: true, italics: true, underline: true, fillColor: "FFF2CC", numberFormat: "dd.mm.yyyy" } });
});
it("преобразует XML листа в модель QExcell", () => {
const cells = worksheetXmlToCells(
{
worksheet: {
sheetData: {
row: {
r: "1",
c: [
{ r: "A1", t: "inlineStr", is: { t: "Статья" } },
{ r: "B1", v: "1000" },
{ r: "C1", f: "B1*2" }
]
}
}
}
},
[]
);
expect(cells).toEqual({
A1: "Статья",
B1: "1000",
C1: "=B1*2"
});
});
it("читает merged cells из XML листа", () => {
expect(
worksheetXmlToMergedCells({
worksheet: {
mergeCells: {
mergeCell: [{ ref: "C2:A1" }, { ref: "D4:D4" }, { ref: "bad" }]
}
}
})
).toEqual([{ start: "A1", end: "C2" }]);
});
it("читает внешние гиперссылки XLSX из XML листа и relationships", () => {
expect(
worksheetXmlToHyperlinks(
{
worksheet: {
hyperlinks: {
hyperlink: [
{ ref: "A1", "r:id": "rIdLink" },
{ ref: "B2", "r:id": "rIdUnsafe" },
{ ref: "bad", "r:id": "rIdLink" }
]
}
}
},
{
Relationships: {
Relationship: [
{
Id: "rIdLink",
Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
Target: "https://example.com/report",
TargetMode: "External"
},
{
Id: "rIdUnsafe",
Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
Target: "javascript:alert(1)",
TargetMode: "External"
}
]
}
}
)
).toEqual({ A1: "https://example.com/report" });
});
it("читает ссылки листов и relationships из workbook XML", () => {
expect(
workbookSheetRefs({
workbook: {
sheets: {
sheet: { name: "Бюджет", sheetId: "7", "r:id": "rId3" }
}
}
})
).toEqual([{ id: "sheet-7", name: "Бюджет", relationshipId: "rId3" }]);
expect(
workbookRelationshipTargets({
Relationships: {
Relationship: [{ Id: "rId3", Target: "worksheets/sheet7.xml" }]
}
})
).toEqual({ rId3: "worksheets/sheet7.xml" });
});
it("создает OOXML chart part для диаграммы XLSX", () => {
const xml = buildChartXml(
{
id: "chart-1",
type: "bar",
title: "Итого",
labelRange: "A2:A4",
valueRange: "E2:E4"
},
"Бюджет",
{
A2: "Базовый",
A3: "Команды",
A4: "Корпоративный",
E2: "43600",
E3: "27200",
E4: "64800"
}
);
expect(xml).toContain("<c:barChart>");
expect(xml).toContain("<c:f>'Бюджет'!$A$2:$A$4</c:f>");
expect(xml).toContain("<c:f>'Бюджет'!$E$2:$E$4</c:f>");
expect(xml).toContain("<c:v>Базовый</c:v>");
expect(xml).toContain("<c:v>43600</c:v>");
});
it("читает базовую диаграмму из chart XML", () => {
expect(
chartXmlToSheetChart(
{
"c:chartSpace": {
"c:chart": {
"c:title": {
"c:tx": {
"c:rich": {
"a:p": { "a:r": { "a:t": "Продажи" } }
}
}
},
"c:plotArea": {
"c:lineChart": {
"c:ser": {
"c:cat": { "c:strRef": { "c:f": "'Бюджет'!$A$2:$A$4" } },
"c:val": { "c:numRef": { "c:f": "'Бюджет'!$E$2:$E$4" } }
}
}
}
}
}
},
"Бюджет",
0
)
).toEqual({
id: "xlsx-chart-1",
type: "line",
title: "Продажи",
labelRange: "A2:A4",
valueRange: "E2:E4"
});
});
it("готовит точки диаграммы из диапазонов листа", () => {
expect(
chartDataPoints(
{
id: "chart-1",
type: "bar",
title: "Итого",
labelRange: "A2:A3",
valueRange: "E2:E3"
},
{ A2: "Базовый", A3: "Команды", E2: "=SUM(B2:D2)", B2: "10", C2: "20", D2: "30", E3: "25" }
)
).toEqual([
{ label: "Базовый", value: 60 },
{ label: "Команды", value: 25 }
]);
});
it("сохраняет и импортирует несколько листов с дальними ячейками", async () => {
const blob = await exportXlsxBlob({
id: "workbook",
title: "Бюджет.xlsx",
updatedAt: "2026-05-31T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{
id: "sheet-1",
name: "Бюджет",
cells: {
A1: "Статья",
AA20: "7",
AB20: "8",
AC20: "=SUM(AA20:AB20)",
D20: "44927",
E20: "0.25"
},
mergedCells: [{ start: "A1", end: "C1" }],
hyperlinks: { A1: "https://example.com/budget" },
cellFormats: {
A1: { bold: true, fillColor: "FFF2CC" },
AC20: { underline: true },
D20: { numberFormat: "dd.mm.yyyy" },
E20: { numberFormat: "0%" }
},
charts: [
{
id: "chart-budget",
type: "bar",
title: "Итого",
labelRange: "A2:A3",
valueRange: "AA20:AB20"
}
]
},
{
id: "sheet-2",
name: "Справочник",
cells: {
B2: "Проект"
}
}
]
});
const imported = await importXlsxFile(new File([blob], "Бюджет.xlsx", { type: blob.type }));
expect(imported.title).toBe("Бюджет.xlsx");
expect(imported.sheets).toHaveLength(2);
expect(imported.sheets[0].name).toBe("Бюджет");
expect(imported.sheets[0].cells.AC20).toBe("=SUM(AA20:AB20)");
expect(imported.sheets[0].mergedCells).toEqual([{ start: "A1", end: "C1" }]);
expect(imported.sheets[0].hyperlinks).toEqual({ A1: "https://example.com/budget" });
expect(imported.sheets[0].cellFormats).toEqual({
A1: { bold: true, fillColor: "FFF2CC" },
AC20: { underline: true },
D20: { numberFormat: "dd.mm.yyyy" },
E20: { numberFormat: "0%" }
});
expect(imported.sheets[0].charts).toEqual([
{
id: "xlsx-chart-1",
type: "bar",
title: "Итого",
labelRange: "A2:A3",
valueRange: "AA20:AB20"
}
]);
expect(imported.sheets[1].name).toBe("Справочник");
expect(imported.sheets[1].cells.B2).toBe("Проект");
});
});
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { officeTitleFromFileName, readOfficeFileAsArrayBuffer, replaceFileExtension } from "./fileHelpers";
describe("fileHelpers", () => {
it("получает название из имени файла", () => {
expect(officeTitleFromFileName("C:\\Документы\\Отчет.docx", "Без названия")).toBe("Отчет.docx");
expect(officeTitleFromFileName(" ", "Без названия")).toBe("Без названия");
});
it("заменяет расширение файла", () => {
expect(replaceFileExtension("Отчет.docx", ".pdf")).toBe("Отчет.pdf");
expect(replaceFileExtension("Книга", "xlsx")).toBe("Книга.xlsx");
});
it("читает File как ArrayBuffer без выбора файла в браузере", async () => {
const file = new File(["текст"], "Проверка.txt", { type: "text/plain" });
const buffer = await readOfficeFileAsArrayBuffer(file);
expect(new TextDecoder().decode(buffer)).toBe("текст");
});
});
+52
View File
@@ -0,0 +1,52 @@
export const DOCX_MIME_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
export const XLSX_MIME_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
export const PPTX_MIME_TYPE = "application/vnd.openxmlformats-officedocument.presentationml.presentation";
export const EML_MIME_TYPE = "message/rfc822;charset=utf-8";
const pathSeparatorPattern = /[/\\]/;
export function officeTitleFromFileName(fileName: string, fallbackTitle: string) {
const trimmed = fileName.trim();
if (!trimmed) {
return fallbackTitle;
}
const parts = trimmed.split(pathSeparatorPattern).filter(Boolean);
return parts[parts.length - 1] ?? fallbackTitle;
}
export function replaceFileExtension(fileName: string, extension: string) {
const normalizedExtension = extension.startsWith(".") ? extension : `.${extension}`;
const baseName = fileName.trim() || "Документ";
const withoutExtension = baseName.replace(/\.[^.\\/]+$/, "");
return `${withoutExtension}${normalizedExtension}`;
}
export async function readOfficeFileAsArrayBuffer(file: File) {
if (typeof file.arrayBuffer !== "function") {
return readFileWithFileReader(file);
}
return file.arrayBuffer();
}
function readFileWithFileReader(file: File): Promise<ArrayBuffer> {
if (typeof FileReader === "undefined") {
throw new Error("Не удалось прочитать файл: среда не поддерживает File.arrayBuffer() и FileReader.");
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error("Не удалось прочитать файл."));
reader.onload = () => {
if (reader.result instanceof ArrayBuffer) {
resolve(reader.result);
return;
}
reject(new Error("Не удалось прочитать файл как ArrayBuffer."));
};
reader.readAsArrayBuffer(file);
});
}
+325
View File
@@ -0,0 +1,325 @@
import { describe, expect, it } from "vitest";
import {
attachmentToBlob,
buildEmlSource,
createMailAttachmentFromFile,
decodeMimeWords,
decodeTransferBody,
extractMessageAttachments,
extractMessageBody,
htmlToPlainText,
importEmlFile,
isSupportedEmlFileName,
isSupportedMailFileName,
isSupportedMsgFileName,
msgDataToMailMessage,
parseContentType,
parseEmlHeaders,
parseEmlSource,
parseMultipartBody
} from "./outlookMail";
import type { MailMessage } from "../types";
describe("outlookMail helpers", () => {
it("разбирает folded EML headers", () => {
expect(parseEmlHeaders("Subject: Первая строка\r\n\tвторая строка\r\nFrom: user@example.com")).toEqual({
subject: "Первая строка вторая строка",
from: "user@example.com"
});
});
it("разделяет заголовки и тело EML", () => {
const parsed = parseEmlSource("From: a@example.com\r\nTo: b@example.com\r\n\r\nТекст письма");
expect(parsed.headers.from).toBe("a@example.com");
expect(parsed.body).toBe("Текст письма");
});
it("декодирует UTF-8 MIME words и transfer encodings", () => {
expect(decodeMimeWords("=?UTF-8?B?0J/RgNC40LLQtdGC?=")).toBe("Привет");
expect(decodeTransferBody("0J/RgNC40LLQtdGC", "base64")).toBe("Привет");
expect(decodeTransferBody("=D0=9F=D1=80=D0=B8=D0=B2=D0=B5=D1=82", "quoted-printable")).toBe("Привет");
});
it("разбирает Content-Type с quoted boundary и charset", () => {
expect(parseContentType('multipart/alternative; boundary="----=_Next;Part"; charset=utf-8')).toEqual({
mediaType: "multipart/alternative",
parameters: {
boundary: "----=_Next;Part",
charset: "utf-8"
}
});
});
it("разбирает multipart body на MIME-части", () => {
const parts = parseMultipartBody(
[
"Преамбула",
"--qoffice-boundary",
"Content-Type: text/plain; charset=utf-8",
"",
"Текстовая версия",
"--qoffice-boundary",
"Content-Type: text/html; charset=utf-8",
"",
"<p>HTML версия</p>",
"--qoffice-boundary--"
].join("\r\n"),
"qoffice-boundary"
);
expect(parts).toEqual([
{ headers: { "content-type": "text/plain; charset=utf-8" }, body: "Текстовая версия" },
{ headers: { "content-type": "text/html; charset=utf-8" }, body: "<p>HTML версия</p>" }
]);
});
it("выбирает text/plain из multipart/alternative перед HTML", () => {
const body = [
"--alt",
"Content-Type: text/html; charset=utf-8",
"Content-Transfer-Encoding: quoted-printable",
"",
"<p>=D0=92=D0=B5=D1=80=D1=81=D0=B8=D1=8F HTML</p>",
"--alt",
"Content-Type: text/plain; charset=utf-8",
"",
"Текстовая версия",
"--alt--"
].join("\r\n");
expect(extractMessageBody({ "content-type": 'multipart/alternative; boundary="alt"' }, body)).toBe("Текстовая версия");
});
it("преобразует HTML body в читаемый текст, если text/plain отсутствует", () => {
const body = [
"--alt",
"Content-Type: text/html; charset=utf-8",
"",
"<html><body><h1>Тема</h1><p>Строка&nbsp;1<br>Строка &amp; 2</p><ul><li>Пункт</li></ul></body></html>",
"--alt--"
].join("\r\n");
expect(htmlToPlainText("<p>Строка&nbsp;1<br>Строка &amp; 2</p>")).toBe("Строка 1\nСтрока & 2");
expect(extractMessageBody({ "content-type": "multipart/alternative; boundary=alt" }, body)).toBe("Тема\nСтрока 1\nСтрока & 2\n• Пункт");
});
it("извлекает MIME-вложения из multipart/mixed и не смешивает их с текстом письма", async () => {
const body = [
"--mixed",
"Content-Type: text/plain; charset=utf-8",
"",
"Текст письма",
"--mixed",
"Content-Type: text/plain; name*=utf-8''%D0%BE%D1%82%D1%87%D0%B5%D1%82.txt",
"Content-Disposition: attachment; filename*=utf-8''%D0%BE%D1%82%D1%87%D0%B5%D1%82.txt",
"Content-Transfer-Encoding: base64",
"",
"0J/RgNC40LLQtdGC",
"--mixed--"
].join("\r\n");
const headers = { "content-type": 'multipart/mixed; boundary="mixed"' };
const attachments = extractMessageAttachments(headers, body);
expect(extractMessageBody(headers, body)).toBe("Текст письма");
expect(attachments).toHaveLength(1);
expect(attachments[0]).toMatchObject({
fileName: "отчет.txt",
contentType: "text/plain",
size: new TextEncoder().encode("Привет").byteLength,
contentBase64: "0J/RgNC40LLQtdGC",
disposition: "attachment"
});
await expect(attachmentToBlob(attachments[0]).text()).resolves.toBe("Привет");
});
it("импортирует multipart EML как письмо QOutlook", async () => {
const eml = [
"From: sender@example.com",
"To: reader@example.com",
"Subject: =?UTF-8?B?0JzRg9C70YzRgtC40L/QsNGA0YI=?=",
"Date: Mon, 01 Jun 2026 10:00:00 +0300",
'Content-Type: multipart/alternative; boundary="alt"',
"",
"--alt",
"Content-Type: text/html; charset=utf-8",
"",
"<p>HTML</p>",
"--alt",
"Content-Type: text/plain; charset=utf-8",
"Content-Transfer-Encoding: base64",
"",
"0J/RgNC40LLQtdGCLCDQv9C40YHRjNC80L4=",
"--alt--"
].join("\r\n");
const imported = await importEmlFile(new File([eml], "message.eml", { type: "message/rfc822" }));
expect(imported.subject).toBe("Мультипарт");
expect(imported.body).toBe("Привет, письмо");
expect(imported.from).toBe("sender@example.com");
expect(imported.to).toBe("reader@example.com");
});
it("импортирует multipart/mixed EML с вложением", async () => {
const eml = [
"From: sender@example.com",
"To: reader@example.com",
"Subject: Отчет",
"Date: Mon, 01 Jun 2026 10:00:00 +0300",
'Content-Type: multipart/mixed; boundary="mixed"',
"",
"--mixed",
"Content-Type: text/plain; charset=utf-8",
"",
"Письмо с файлом",
"--mixed",
"Content-Type: application/octet-stream; name=\"report.bin\"",
"Content-Disposition: attachment; filename=\"report.bin\"",
"Content-Transfer-Encoding: base64",
"",
"SGVsbG8=",
"--mixed--"
].join("\r\n");
const imported = await importEmlFile(new File([eml], "message.eml", { type: "message/rfc822" }));
expect(imported.body).toBe("Письмо с файлом");
expect(imported.attachments).toEqual([
{
id: "attachment-report-bin-1",
fileName: "report.bin",
contentType: "application/octet-stream",
size: 5,
contentBase64: "SGVsbG8=",
disposition: "attachment"
}
]);
});
it("преобразует данные Outlook MSG в письмо QOutlook с вложениями", async () => {
const message = msgDataToMailMessage(
{
getAttachment: () => ({
fileName: "report.txt",
content: new TextEncoder().encode("Hello")
})
},
{
senderSmtpAddress: "sender@example.com",
subject: "MSG отчет",
bodyHtml: "<p>HTML&nbsp;текст</p>",
messageDeliveryTime: "Mon, 01 Jun 2026 10:00:00 GMT",
recipients: [
{ smtpAddress: "reader@example.com", recipType: "to" },
{ smtpAddress: "copy@example.com", recipType: "cc" }
],
attachments: [{ fileName: "report.txt", attachMimeTag: "text/plain", contentLength: 5 }]
}
);
expect(message).toMatchObject({
folder: "inbox",
from: "sender@example.com",
to: "reader@example.com",
subject: "MSG отчет",
body: "HTML текст",
time: "Mon, 01 Jun 2026 10:00:00 GMT",
read: false,
flagged: false
});
expect(message.id).toMatch(/^msg-/);
expect(message.attachments).toEqual([
{
id: "attachment-report-txt-1",
fileName: "report.txt",
contentType: "text/plain",
size: 5,
contentBase64: "SGVsbG8=",
disposition: "attachment"
}
]);
await expect(attachmentToBlob(message.attachments?.[0]!).text()).resolves.toBe("Hello");
});
it("собирает EML source с UTF-8 subject encoding", () => {
const message: MailMessage = {
id: "1",
folder: "sent",
from: "sender@example.com",
to: "reader@example.com",
subject: "Тема",
body: "Текст",
time: "Mon, 01 Jan 2024 10:00:00 +0000",
read: true,
flagged: false
};
const eml = buildEmlSource(message);
expect(eml).toContain("From: sender@example.com");
expect(eml).toContain("Subject: =?UTF-8?B?");
expect(eml.endsWith("Текст")).toBe(true);
});
it("собирает multipart/mixed EML с вложениями и RFC 2231 filename", () => {
const message: MailMessage = {
id: "1",
folder: "sent",
from: "sender@example.com",
to: "reader@example.com",
subject: "Тема",
body: "Текст",
time: "Mon, 01 Jan 2024 10:00:00 +0000",
read: true,
flagged: false,
attachments: [
{
id: "a1",
fileName: "отчет.txt",
contentType: "text/plain",
size: 5,
contentBase64: "SGVsbG8=",
disposition: "attachment"
}
]
};
const eml = buildEmlSource(message);
const parsed = parseEmlSource(eml);
const attachments = extractMessageAttachments(parsed.headers, parsed.body);
expect(eml).toContain("Content-Type: multipart/mixed");
expect(eml).toContain("filename*=utf-8''%D0%BE%D1%82%D1%87%D0%B5%D1%82%2E%74%78%74");
expect(extractMessageBody(parsed.headers, parsed.body)).toBe("Текст");
expect(attachments[0]).toMatchObject({
fileName: "отчет.txt",
contentType: "text/plain",
contentBase64: "SGVsbG8=",
size: 5
});
});
it("создает вложение из File для новых писем", async () => {
const attachment = await createMailAttachmentFromFile(new File(["Привет"], "note.txt", { type: "text/plain" }));
expect(attachment).toMatchObject({
fileName: "note.txt",
contentType: "text/plain",
size: new TextEncoder().encode("Привет").byteLength,
contentBase64: "0J/RgNC40LLQtdGC",
disposition: "attachment"
});
expect(attachment.id).toMatch(/^attachment-note-txt-/);
});
it("принимает EML и MSG filenames для импорта почты", () => {
expect(isSupportedEmlFileName("message.eml")).toBe(true);
expect(isSupportedMsgFileName("message.msg")).toBe(true);
expect(isSupportedMailFileName("message.eml")).toBe(true);
expect(isSupportedMailFileName("message.msg")).toBe(true);
expect(isSupportedEmlFileName("archive.pst")).toBe(false);
expect(isSupportedMailFileName("archive.pst")).toBe(false);
});
});
+797
View File
@@ -0,0 +1,797 @@
import type { MailAttachment, MailMessage } from "../types";
import { EML_MIME_TYPE } from "./fileHelpers";
type MailHeaders = Record<string, string>;
interface ParsedContentType {
mediaType: string;
parameters: Record<string, string>;
}
interface ParsedHeaderParameters {
value: string;
parameters: Record<string, string>;
}
interface MailPart {
headers: MailHeaders;
body: string;
}
export interface MsgAttachmentSummary {
fileName?: string;
fileNameShort?: string;
name?: string;
attachMimeTag?: string;
contentLength?: number;
pidContentId?: string;
attachmentHidden?: boolean;
}
export interface MsgRecipient {
name?: string;
email?: string;
smtpAddress?: string;
recipType?: "to" | "cc" | "bcc";
}
export interface MsgFileData {
senderEmail?: string;
senderSmtpAddress?: string;
senderName?: string;
subject?: string;
body?: string;
bodyHtml?: string;
html?: Uint8Array;
headers?: string;
creationTime?: string;
lastModificationTime?: string;
clientSubmitTime?: string;
messageDeliveryTime?: string;
recipients?: MsgRecipient[];
attachments?: MsgAttachmentSummary[];
error?: string;
}
export interface MsgReaderInstance {
getFileData: () => MsgFileData;
getAttachment: (attachment: MsgAttachmentSummary) => { fileName?: string; content: Uint8Array };
}
type MsgReaderConstructor = new (arrayBuffer: ArrayBuffer) => MsgReaderInstance;
export type ImportedMailMessage = MailMessage;
export async function importMailFile(file: File): Promise<ImportedMailMessage> {
if (isSupportedEmlFileName(file.name)) {
return importEmlFile(file);
}
if (isSupportedMsgFileName(file.name)) {
return importMsgFile(file);
}
throw new Error("QOutlook импортирует файлы .eml и одиночные сообщения Outlook .msg. Файлы PST сейчас не поддерживаются.");
}
export async function importEmlFile(file: File): Promise<ImportedMailMessage> {
if (!isSupportedEmlFileName(file.name)) {
throw new Error("QOutlook импортирует только файлы .eml. Используйте общий импорт QOutlook для файлов .msg.");
}
const raw = await file.text();
const parsed = parseEmlSource(raw);
return {
id: `eml-${Date.now()}`,
folder: "inbox",
from: decodeMimeWords(parsed.headers.from || ""),
to: decodeMimeWords(parsed.headers.to || ""),
subject: decodeMimeWords(parsed.headers.subject || "Без темы"),
body: extractMessageBody(parsed.headers, parsed.body),
time: parsed.headers.date || new Date().toLocaleString("ru-RU"),
read: false,
flagged: false,
attachments: extractMessageAttachments(parsed.headers, parsed.body)
};
}
export async function importMsgFile(file: File): Promise<ImportedMailMessage> {
if (!isSupportedMsgFileName(file.name)) {
throw new Error("QOutlook импортирует только одиночные сообщения Outlook .msg.");
}
const MsgReader = await loadMsgReader();
const reader = new MsgReader(await file.arrayBuffer());
const data = reader.getFileData();
if (data.error) {
throw new Error(`Не удалось прочитать MSG: ${data.error}`);
}
return msgDataToMailMessage(reader, data);
}
export function msgDataToMailMessage(reader: Pick<MsgReaderInstance, "getAttachment">, data: MsgFileData): MailMessage {
const headerFields = data.headers ? parseEmlHeaders(data.headers) : {};
return {
id: `msg-${Date.now()}`,
folder: "inbox",
from: firstText(data.senderSmtpAddress, data.senderEmail, data.senderName, decodeMimeWords(headerFields.from || "")),
to: msgRecipientList(data.recipients, "to") || decodeMimeWords(headerFields.to || ""),
subject: firstText(data.subject, decodeMimeWords(headerFields.subject || ""), "Без темы"),
body: msgBodyText(data),
time: firstText(data.messageDeliveryTime, data.clientSubmitTime, data.creationTime, data.lastModificationTime, headerFields.date, new Date().toLocaleString("ru-RU")),
read: false,
flagged: false,
attachments: msgAttachments(reader, data.attachments)
};
}
export function exportEmlBlob(message: MailMessage): Blob {
return new Blob([buildEmlSource(message)], { type: EML_MIME_TYPE });
}
export function isSupportedEmlFileName(fileName: string): boolean {
return fileName.toLowerCase().endsWith(".eml");
}
export function isSupportedMsgFileName(fileName: string): boolean {
return fileName.toLowerCase().endsWith(".msg");
}
export function isSupportedMailFileName(fileName: string): boolean {
return isSupportedEmlFileName(fileName) || isSupportedMsgFileName(fileName);
}
export function parseEmlSource(source: string): { headers: MailHeaders; body: string } {
const normalized = normalizeMailNewlines(source);
const separatorIndex = normalized.indexOf("\n\n");
if (separatorIndex === -1) {
throw new Error("Не удалось прочитать EML: не найден раздел заголовков письма.");
}
return {
headers: parseEmlHeaders(normalized.slice(0, separatorIndex)),
body: normalized.slice(separatorIndex + 2)
};
}
export function parseEmlHeaders(source: string): MailHeaders {
const headers: MailHeaders = {};
let activeHeader = "";
for (const line of normalizeMailNewlines(source).split("\n")) {
if (!line) {
continue;
}
if (/^[ \t]/.test(line) && activeHeader) {
headers[activeHeader] = `${headers[activeHeader]} ${line.trim()}`;
continue;
}
const colonIndex = line.indexOf(":");
if (colonIndex === -1) {
continue;
}
activeHeader = line.slice(0, colonIndex).trim().toLowerCase();
headers[activeHeader] = line.slice(colonIndex + 1).trim();
}
return headers;
}
export function extractMessageBody(headers: MailHeaders, body: string): string {
const contentType = parseContentType(headers["content-type"]);
if (contentType.mediaType.startsWith("multipart/")) {
const boundary = contentType.parameters.boundary;
if (!boundary) {
return "";
}
const bodyParts = parseMultipartBody(body, boundary)
.filter((part) => !isAttachmentPart(part.headers))
.map((part) => {
const partType = parseContentType(part.headers["content-type"]);
return {
mediaType: partType.mediaType,
text: extractMessageBody(part.headers, part.body)
};
})
.filter((part) => part.text.trim());
return (
bodyParts.find((part) => part.mediaType === "text/plain")?.text ??
bodyParts.find((part) => part.mediaType === "text/html")?.text ??
bodyParts[0]?.text ??
""
);
}
const decoded = decodeTransferBody(body, headers["content-transfer-encoding"], contentType.parameters.charset);
return contentType.mediaType === "text/html" ? htmlToPlainText(decoded) : normalizeMailNewlines(decoded).trim();
}
export function extractMessageAttachments(headers: MailHeaders, body: string): MailAttachment[] {
return collectAttachmentParts(headers, body).map((part, index) => attachmentFromPart(part, index));
}
export async function createMailAttachmentFromFile(file: File): Promise<MailAttachment> {
return {
id: createAttachmentId(file.name),
fileName: file.name || "Вложение",
contentType: file.type || "application/octet-stream",
size: file.size,
contentBase64: bytesToBase64(new Uint8Array(await file.arrayBuffer())),
disposition: "attachment"
};
}
export function attachmentToBlob(attachment: MailAttachment): Blob {
const bytes = base64ToBytes(attachment.contentBase64);
const arrayBuffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(arrayBuffer).set(bytes);
return new Blob([arrayBuffer], {
type: attachment.contentType || "application/octet-stream"
});
}
function msgBodyText(data: MsgFileData): string {
const plainBody = firstText(data.body);
if (plainBody) {
return normalizeMailNewlines(plainBody).trim();
}
const htmlBody = firstText(data.bodyHtml);
if (htmlBody) {
return htmlToPlainText(htmlBody);
}
if (data.html && data.html.byteLength > 0) {
return htmlToPlainText(decodeBytes(data.html, msgCharset(data)));
}
return "";
}
function msgCharset(data: MsgFileData) {
const headerFields = data.headers ? parseEmlHeaders(data.headers) : {};
return parseContentType(headerFields["content-type"]).parameters.charset || "utf-8";
}
function msgRecipientList(recipients: MsgRecipient[] = [], type: MsgRecipient["recipType"] = "to") {
return recipients
.filter((recipient) => !recipient.recipType || recipient.recipType === type)
.map((recipient) => firstText(recipient.smtpAddress, recipient.email, recipient.name))
.filter(Boolean)
.join(", ");
}
function msgAttachments(reader: Pick<MsgReaderInstance, "getAttachment">, attachments: MsgAttachmentSummary[] = []): MailAttachment[] {
return attachments
.filter((attachment) => !attachment.attachmentHidden)
.flatMap((attachment, index) => {
try {
const data = reader.getAttachment(attachment);
const fileName = firstText(data.fileName, attachment.fileName, attachment.fileNameShort, attachment.name, `Вложение ${index + 1}`);
return [
{
id: createAttachmentId(fileName, index),
fileName,
contentType: firstText(attachment.attachMimeTag, mimeTypeFromFileName(fileName)),
size: data.content.byteLength,
contentBase64: bytesToBase64(data.content),
disposition: "attachment" as const,
...(attachment.pidContentId ? { contentId: attachment.pidContentId } : {})
}
];
} catch {
return [];
}
});
}
function mimeTypeFromFileName(fileName: string) {
const extension = fileName.toLowerCase().split(".").pop() ?? "";
const mimeTypesByExtension: Record<string, string> = {
csv: "text/csv",
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
gif: "image/gif",
html: "text/html",
jpg: "image/jpeg",
jpeg: "image/jpeg",
pdf: "application/pdf",
png: "image/png",
ppt: "application/vnd.ms-powerpoint",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
txt: "text/plain",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
zip: "application/zip"
};
return mimeTypesByExtension[extension] ?? "application/octet-stream";
}
function firstText(...values: Array<string | undefined | null>) {
return values.map((value) => value?.trim() ?? "").find(Boolean) ?? "";
}
export function parseMultipartBody(body: string, boundary: string): MailPart[] {
const marker = `--${boundary}`;
const closingMarker = `--${boundary}--`;
const parts: string[] = [];
let activePart: string[] = [];
let insidePart = false;
for (const line of normalizeMailNewlines(body).split("\n")) {
if (line === marker || line === closingMarker) {
if (insidePart && activePart.length > 0) {
parts.push(activePart.join("\n"));
}
activePart = [];
insidePart = line === marker;
if (line === closingMarker) {
break;
}
continue;
}
if (insidePart) {
activePart.push(line);
}
}
return parts.flatMap((part) => {
try {
return [parseEmlSource(part)];
} catch {
return [];
}
});
}
function collectAttachmentParts(headers: MailHeaders, body: string): MailPart[] {
const contentType = parseContentType(headers["content-type"]);
if (contentType.mediaType.startsWith("multipart/")) {
const boundary = contentType.parameters.boundary;
if (!boundary) {
return [];
}
return parseMultipartBody(body, boundary).flatMap((part) => collectAttachmentParts(part.headers, part.body));
}
return isAttachmentPart(headers) ? [{ headers, body }] : [];
}
function attachmentFromPart(part: MailPart, index: number): MailAttachment {
const contentType = parseContentType(part.headers["content-type"]);
const disposition = parseContentDisposition(part.headers["content-disposition"]);
const bytes = decodeTransferBytes(part.body, part.headers["content-transfer-encoding"]);
const fileName =
decodeMimeParameter(disposition.parameters, "filename") ??
decodeMimeParameter(contentType.parameters, "name") ??
`Вложение ${index + 1}`;
const contentId = part.headers["content-id"]?.replace(/^<|>$/g, "");
return {
id: createAttachmentId(fileName, index),
fileName,
contentType: contentType.mediaType || "application/octet-stream",
size: bytes.byteLength,
contentBase64: bytesToBase64(bytes),
disposition: disposition.value === "inline" ? "inline" : "attachment",
...(contentId ? { contentId } : {})
};
}
export function parseContentType(value = "text/plain"): ParsedContentType {
const parsed = parseHeaderParameters(value, "text/plain");
return {
mediaType: parsed.value.toLowerCase(),
parameters: parsed.parameters
};
}
export function buildEmlSource(message: MailMessage): string {
const attachments = message.attachments ?? [];
if (attachments.length > 0) {
const boundary = createMultipartBoundary(message);
const lines = [
`From: ${sanitizeHeaderValue(message.from)}`,
`To: ${sanitizeHeaderValue(message.to)}`,
`Subject: ${encodeHeaderValue(message.subject || "Без темы")}`,
`Date: ${sanitizeHeaderValue(message.time || new Date().toUTCString())}`,
"MIME-Version: 1.0",
`Content-Type: multipart/mixed; boundary="${boundary}"`,
"",
`--${boundary}`,
"Content-Type: text/plain; charset=utf-8",
"Content-Transfer-Encoding: 8bit",
"",
normalizeMailNewlines(message.body || ""),
...attachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)),
`--${boundary}--`,
""
];
return lines.join("\r\n");
}
const lines = [
`From: ${sanitizeHeaderValue(message.from)}`,
`To: ${sanitizeHeaderValue(message.to)}`,
`Subject: ${encodeHeaderValue(message.subject || "Без темы")}`,
`Date: ${sanitizeHeaderValue(message.time || new Date().toUTCString())}`,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8",
"Content-Transfer-Encoding: 8bit",
"",
normalizeMailNewlines(message.body || "")
];
return lines.join("\r\n");
}
export function decodeMimeWords(value: string): string {
return value.replace(/=\?([^?]+)\?([bqBQ])\?([^?]+)\?=/g, (_match, charset: string, encoding: string, encoded: string) => {
const normalizedCharset = charset.toLowerCase();
if (normalizedCharset !== "utf-8" && normalizedCharset !== "utf8") {
return _match;
}
if (encoding.toLowerCase() === "b") {
return decodeUtf8Bytes(base64ToBytes(encoded));
}
return decodeUtf8Bytes(quotedPrintableToBytes(encoded.replace(/_/g, " ")));
});
}
export function decodeTransferBody(body: string, encoding = "", charset = "utf-8"): string {
const normalizedEncoding = encoding.toLowerCase();
if (normalizedEncoding === "base64" || normalizedEncoding === "quoted-printable") {
return decodeBytes(decodeTransferBytes(body, normalizedEncoding), charset);
}
return normalizeMailNewlines(body);
}
export function htmlToPlainText(html: string): string {
const withBreaks = html
.replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, " ")
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
.replace(/<\s*\/\s*(p|div|li|tr|h[1-6])\s*>/gi, "\n")
.replace(/<\s*li[^>]*>/gi, "• ")
.replace(/<[^>]+>/g, " ");
return decodeHtmlEntities(withBreaks)
.replace(/[ \t\f\v]+/g, " ")
.replace(/\n[ \t]+/g, "\n")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
export function normalizeMailNewlines(value: string): string {
return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
function parseContentDisposition(value = ""): ParsedHeaderParameters {
return parseHeaderParameters(value, "");
}
function parseHeaderParameters(value: string, fallbackValue: string): ParsedHeaderParameters {
const [rawValue, ...rawParameters] = splitMimeHeaderParameters(value || fallbackValue);
const parameters: Record<string, string> = {};
rawParameters.forEach((parameter) => {
const equalsIndex = parameter.indexOf("=");
if (equalsIndex === -1) {
return;
}
const key = parameter.slice(0, equalsIndex).trim().toLowerCase();
const parameterValue = unquoteHeaderValue(parameter.slice(equalsIndex + 1).trim());
if (key) {
parameters[key] = parameterValue;
}
});
return {
value: (rawValue || fallbackValue).trim(),
parameters
};
}
function decodeMimeParameter(parameters: Record<string, string>, name: string): string | undefined {
const normalizedName = name.toLowerCase();
const encodedDirectValue = parameters[`${normalizedName}*`];
if (encodedDirectValue) {
return decodeRfc2231Value(encodedDirectValue);
}
const directValue = parameters[normalizedName];
if (directValue) {
return decodeMimeWords(directValue);
}
const continuations: Array<{ value: string; encoded: boolean }> = [];
for (let index = 0; ; index += 1) {
const encodedKey = `${normalizedName}*${index}*`;
const plainKey = `${normalizedName}*${index}`;
if (parameters[encodedKey] !== undefined) {
continuations.push({ value: parameters[encodedKey], encoded: true });
continue;
}
if (parameters[plainKey] !== undefined) {
continuations.push({ value: parameters[plainKey], encoded: false });
continue;
}
break;
}
if (continuations.length === 0) {
return undefined;
}
const combinedValue = continuations.map((part) => part.value).join("");
return continuations.some((part) => part.encoded) ? decodeRfc2231Value(combinedValue) : decodeMimeWords(combinedValue);
}
function decodeRfc2231Value(value: string): string {
const charsetMatch = /^([^']*)'[^']*'(.*)$/.exec(value);
const charset = charsetMatch?.[1] || "utf-8";
const encodedValue = charsetMatch?.[2] ?? value;
return decodeBytes(percentEncodedToBytes(encodedValue), charset);
}
function percentEncodedToBytes(value: string): Uint8Array {
const bytes: number[] = [];
for (let index = 0; index < value.length; index += 1) {
const char = value[index];
if (char === "%" && /^[\dA-Fa-f]{2}$/.test(value.slice(index + 1, index + 3))) {
bytes.push(Number.parseInt(value.slice(index + 1, index + 3), 16));
index += 2;
continue;
}
for (const byte of new TextEncoder().encode(char)) {
bytes.push(byte);
}
}
return new Uint8Array(bytes);
}
function decodeTransferBytes(body: string, encoding = ""): Uint8Array {
const normalizedEncoding = encoding.toLowerCase();
if (normalizedEncoding === "base64") {
return base64ToBytes(body.replace(/\s+/g, ""));
}
if (normalizedEncoding === "quoted-printable") {
return quotedPrintableToBytes(body);
}
return new TextEncoder().encode(normalizeMailNewlines(body));
}
function createMultipartBoundary(message: MailMessage): string {
const seed = `${message.id}-${message.subject}-${message.attachments?.length ?? 0}`;
const normalizedSeed = seed.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
return `qoffice-${normalizedSeed || "message"}`;
}
function buildAttachmentPartLines(boundary: string, attachment: MailAttachment): string[] {
const safeContentType = sanitizeMimeType(attachment.contentType);
const disposition = attachment.disposition === "inline" ? "inline" : "attachment";
const contentId = sanitizeHeaderValue(attachment.contentId ?? "");
return [
`--${boundary}`,
`Content-Type: ${safeContentType}; ${formatHeaderParameter("name", attachment.fileName)}`,
"Content-Transfer-Encoding: base64",
`Content-Disposition: ${disposition}; ${formatHeaderParameter("filename", attachment.fileName)}`,
...(contentId ? [`Content-ID: <${contentId}>`] : []),
"",
wrapBase64(attachment.contentBase64)
];
}
function formatHeaderParameter(name: string, value: string): string {
const sanitized = sanitizeHeaderValue(value || "Вложение");
if (/^[\x20-\x7E]*$/.test(sanitized)) {
return `${name}="${sanitized.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
return `${name}*=utf-8''${percentEncodeUtf8(sanitized)}`;
}
function percentEncodeUtf8(value: string): string {
return Array.from(new TextEncoder().encode(value))
.map((byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`)
.join("");
}
function sanitizeMimeType(value = "application/octet-stream"): string {
const mediaType = parseContentType(value).mediaType;
return /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i.test(mediaType) ? mediaType : "application/octet-stream";
}
function wrapBase64(value: string): string {
return (value.match(/.{1,76}/g) ?? [""]).join("\r\n");
}
function createAttachmentId(fileName: string, index?: number): string {
const normalizedName = fileName.toLowerCase().replace(/[^a-z0-9а-яё]+/gi, "-").replace(/^-+|-+$/g, "");
const suffix = index === undefined ? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` : String(index + 1);
return `attachment-${normalizedName || "file"}-${suffix}`;
}
function encodeHeaderValue(value: string): string {
if (/^[\x20-\x7E]*$/.test(value)) {
return sanitizeHeaderValue(value);
}
const bytes = new TextEncoder().encode(value);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return `=?UTF-8?B?${btoa(binary)}?=`;
}
function sanitizeHeaderValue(value: string): string {
return value.replace(/[\r\n]+/g, " ").trim();
}
function splitMimeHeaderParameters(value: string): string[] {
const segments: string[] = [];
let current = "";
let quoted = false;
let escaped = false;
for (const char of value) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === "\\") {
current += char;
escaped = quoted;
continue;
}
if (char === '"') {
quoted = !quoted;
current += char;
continue;
}
if (char === ";" && !quoted) {
segments.push(current.trim());
current = "";
continue;
}
current += char;
}
segments.push(current.trim());
return segments;
}
function unquoteHeaderValue(value: string): string {
if (value.startsWith('"') && value.endsWith('"')) {
return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
}
return value;
}
function isAttachmentPart(headers: MailHeaders): boolean {
const disposition = parseContentDisposition(headers["content-disposition"]);
const contentType = parseContentType(headers["content-type"]);
return (
disposition.value.toLowerCase() === "attachment" ||
Boolean(decodeMimeParameter(disposition.parameters, "filename")) ||
Boolean(decodeMimeParameter(contentType.parameters, "name"))
);
}
function decodeHtmlEntities(value: string): string {
const namedEntities: Record<string, string> = {
amp: "&",
gt: ">",
lt: "<",
nbsp: " ",
quot: '"',
apos: "'"
};
return value.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => {
const normalized = entity.toLowerCase();
if (normalized.startsWith("#x")) {
return String.fromCodePoint(Number.parseInt(normalized.slice(2), 16));
}
if (normalized.startsWith("#")) {
return String.fromCodePoint(Number.parseInt(normalized.slice(1), 10));
}
return namedEntities[normalized] ?? match;
});
}
function base64ToBytes(value: string): Uint8Array {
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 bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const chunkSize = 0x8000;
for (let index = 0; index < bytes.length; index += chunkSize) {
binary += String.fromCharCode(...bytes.slice(index, index + chunkSize));
}
return btoa(binary);
}
function quotedPrintableToBytes(value: string): Uint8Array {
const unfolded = value.replace(/=\n/g, "");
const bytes: number[] = [];
for (let index = 0; index < unfolded.length; index += 1) {
const char = unfolded[index];
if (char === "=" && /^[\dA-Fa-f]{2}$/.test(unfolded.slice(index + 1, index + 3))) {
bytes.push(Number.parseInt(unfolded.slice(index + 1, index + 3), 16));
index += 2;
continue;
}
bytes.push(char.charCodeAt(0));
}
return new Uint8Array(bytes);
}
function decodeUtf8Bytes(bytes: Uint8Array): string {
return decodeBytes(bytes, "utf-8");
}
function decodeBytes(bytes: Uint8Array, charset = "utf-8"): string {
try {
return new TextDecoder(charset || "utf-8", { fatal: false }).decode(bytes);
} catch {
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
}
}
async function loadMsgReader(): Promise<MsgReaderConstructor> {
const module = (await import("@kenjiuno/msgreader")) as unknown as { default?: MsgReaderConstructor } & MsgReaderConstructor;
return module.default ?? module;
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { pdfFileName } from "./pdfExport";
describe("pdfExport helpers", () => {
it("заменяет расширение на PDF", () => {
expect(pdfFileName("Документ QWord.docx")).toBe("Документ QWord.pdf");
expect(pdfFileName("Книга QExcell.xlsx")).toBe("Книга QExcell.pdf");
});
it("добавляет расширение PDF к имени без расширения", () => {
expect(pdfFileName("Презентация")).toBe("Презентация.pdf");
});
});
+18
View File
@@ -0,0 +1,18 @@
import { replaceFileExtension } from "./fileHelpers";
export function isDesktopPdfExportAvailable() {
return Boolean(window.qoffice?.isDesktop && window.qoffice.exportPdf);
}
export async function exportPdfOrPrint(defaultFileName: string) {
if (isDesktopPdfExportAvailable()) {
await window.qoffice?.exportPdf?.(pdfFileName(defaultFileName));
return;
}
window.print();
}
export function pdfFileName(fileName: string) {
return replaceFileExtension(fileName || "QOffice", ".pdf");
}
+217
View File
@@ -0,0 +1,217 @@
import { describe, expect, it } from "vitest";
import JSZip from "jszip";
import {
exportPptxBlob,
extractTextBlocks,
importPptxFile,
listSlidePaths,
normalizeWhitespace,
presentationSlidePaths,
relationshipTargetByType,
resolvePptTarget
} from "./powerPointOffice";
const tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
describe("powerPointOffice helpers", () => {
it("извлекает текстовые runs из OOXML-подобных узлов", () => {
const parsedSlide = {
sld: {
cSld: {
spTree: {
sp: [
{ txBody: { p: { r: { t: "Заголовок" } } } },
{ txBody: { p: [{ r: { t: "Первый пункт" } }, { r: { t: "Второй пункт" } }] } }
]
}
}
}
};
expect(extractTextBlocks(parsedSlide)).toEqual(["Заголовок", "Первый пункт", "Второй пункт"]);
});
it("сортирует пути слайдов по числовому индексу", () => {
const zip = {
files: {
"ppt/slides/slide10.xml": {},
"ppt/slides/slide2.xml": {},
"ppt/slides/slide1.xml": {},
"ppt/notesSlides/notesSlide1.xml": {}
},
file: () => null
};
expect(listSlidePaths(zip)).toEqual([
{ path: "ppt/slides/slide1.xml", index: 1 },
{ path: "ppt/slides/slide2.xml", index: 2 },
{ path: "ppt/slides/slide10.xml", index: 10 }
]);
});
it("читает порядок слайдов из presentation relationships", () => {
const presentationXml = {
presentation: {
sldIdLst: {
sldId: [{ id: "300", "r:id": "rIdB" }, { id: "200", "r:id": "rIdA" }]
}
}
};
const relationshipsXml = {
Relationships: {
Relationship: [
{
Id: "rIdA",
Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide",
Target: "slides/slide1.xml"
},
{
Id: "rIdB",
Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide",
Target: "slides/slide10.xml"
}
]
}
};
expect(presentationSlidePaths(presentationXml, relationshipsXml)).toEqual(["ppt/slides/slide10.xml", "ppt/slides/slide1.xml"]);
expect(listSlidePaths({ files: {}, file: () => null }, presentationXml, relationshipsXml)).toEqual([
{ path: "ppt/slides/slide10.xml", index: 10 },
{ path: "ppt/slides/slide1.xml", index: 1 }
]);
});
it("разрешает относительные targets заметок от части слайда", () => {
expect(resolvePptTarget("ppt/slides", "../notesSlides/notesSlide7.xml")).toBe("ppt/notesSlides/notesSlide7.xml");
expect(
relationshipTargetByType(
{
Relationships: {
Relationship: {
Id: "rIdNotes",
Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide",
Target: "../notesSlides/notesSlide7.xml"
}
}
},
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide",
"ppt/slides/slide3.xml"
)
).toBe("ppt/notesSlides/notesSlide7.xml");
});
it("импортирует PPTX в порядке presentation.xml и берет заметки через slide rels", async () => {
const zip = new JSZip();
zip.file(
"ppt/presentation.xml",
`<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst><p:sldId id="256" r:id="rId2"/><p:sldId id="255" r:id="rId1"/></p:sldIdLst></p:presentation>`
);
zip.file(
"ppt/_rels/presentation.xml.rels",
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide2.xml"/></Relationships>`
);
zip.file("ppt/slides/slide1.xml", slideXml("Первый файл", "Но второй по порядку"));
zip.file("ppt/slides/slide2.xml", slideXml("Первый по порядку", "Содержимое первого слайда"));
zip.file(
"ppt/slides/_rels/slide2.xml.rels",
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdNotes" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" Target="../notesSlides/notesSlide9.xml"/></Relationships>`
);
zip.file("ppt/notesSlides/notesSlide9.xml", slideXml("Заметка докладчика"));
const blob = await zip.generateAsync({
type: "blob",
mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
});
const imported = await importPptxFile(new File([blob], "Дорожная карта.pptx", { type: blob.type }));
expect(imported.slides.map((slide) => slide.title)).toEqual(["Первый по порядку", "Первый файл"]);
expect(imported.slides[0].subtitle).toBe("Содержимое первого слайда");
expect(imported.slides[0].notes).toBe("Заметка докладчика");
});
it("импортирует изображения слайда из PPTX media relationships", async () => {
const zip = new JSZip();
zip.file(
"ppt/presentation.xml",
`<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst></p:presentation>`
);
zip.file(
"ppt/_rels/presentation.xml.rels",
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/></Relationships>`
);
zip.file("ppt/slides/slide1.xml", slideXmlWithPicture("Слайд с изображением"));
zip.file(
"ppt/slides/_rels/slide1.xml.rels",
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdImage" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/logo.png"/></Relationships>`
);
zip.file("ppt/media/logo.png", tinyPngBase64, { base64: true });
const blob = await zip.generateAsync({
type: "blob",
mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
});
const imported = await importPptxFile(new File([blob], "Изображения.pptx", { type: blob.type }));
expect(imported.slides[0].title).toBe("Слайд с изображением");
expect(imported.slides[0].images).toEqual([
{
id: "pptx-slide-1-image-1",
src: `data:image/png;base64,${tinyPngBase64}`,
alt: "Логотип",
x: 1,
y: 2,
width: 3,
height: 1.5
}
]);
});
it("экспортирует изображения слайда в PPTX media relationships", async () => {
const blob = await exportPptxBlob({
title: "Слайды с изображением.pptx",
slides: [
{
id: "slide-1",
title: "Слайд с изображением",
subtitle: "Проверка экспорта",
notes: "",
theme: "classic",
images: [
{
id: "image-1",
src: `data:image/png;base64,${tinyPngBase64}`,
alt: "Логотип",
x: 1,
y: 2,
width: 3,
height: 1.5
}
]
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const slideXml = await zip.file("ppt/slides/slide1.xml")?.async("string");
const relationshipsXml = await zip.file("ppt/slides/_rels/slide1.xml.rels")?.async("string");
const mediaFiles = Object.keys(zip.files).filter((path) => path.startsWith("ppt/media/") && path.endsWith(".png"));
expect(slideXml).toContain("<p:pic>");
expect(slideXml).toContain('descr="Логотип"');
expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"');
expect(mediaFiles.length).toBeGreaterThan(0);
});
it("нормализует пробелы в импортированном тексте", () => {
expect(normalizeWhitespace(" Строка\tс \n пробелами ")).toBe("Строка с пробелами");
});
});
function slideXml(...texts: string[]) {
return `<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><p:cSld><p:spTree>${texts
.map((text) => `<p:sp><p:txBody><a:p><a:r><a:t>${text}</a:t></a:r></a:p></p:txBody></p:sp>`)
.join("")}</p:spTree></p:cSld></p:sld>`;
}
function slideXmlWithPicture(title: string) {
return `<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:cSld><p:spTree><p:sp><p:txBody><a:p><a:r><a:t>${title}</a:t></a:r></a:p></p:txBody></p:sp><p:pic><p:nvPicPr><p:cNvPr id="4" name="Picture 1" descr="Логотип"/></p:nvPicPr><p:blipFill><a:blip r:embed="rIdImage"/></p:blipFill><p:spPr><a:xfrm><a:off x="914400" y="1828800"/><a:ext cx="2743200" cy="1371600"/></a:xfrm></p:spPr></p:pic></p:spTree></p:cSld></p:sld>`;
}
+507
View File
@@ -0,0 +1,507 @@
import type { Slide, SlideImage } from "../types";
import { officeTitleFromFileName, PPTX_MIME_TYPE, readOfficeFileAsArrayBuffer } from "./fileHelpers";
export interface ImportedPowerPointDeck {
title: string;
slides: Slide[];
}
export interface ExportablePowerPointDeck {
title: string;
slides: Slide[];
}
type XmlParserConstructor = new (options?: Record<string, unknown>) => { parse: (xml: string) => unknown };
type ZipEntry = {
async(type: "string"): Promise<string>;
async(type: "base64"): Promise<string>;
};
type ZipArchive = { files?: Record<string, unknown>; file: (path: string) => ZipEntry | null };
const slidePathPattern = /^ppt\/slides\/slide(\d+)\.xml$/;
const slideRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide";
const notesRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide";
const imageRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
const emuPerInch = 914400;
const themeColors: Record<Slide["theme"], { background: string; title: string; subtitle: string }> = {
classic: { background: "FFFFFF", title: "202124", subtitle: "3C4043" },
ocean: { background: "EAF6FF", title: "075985", subtitle: "0F766E" },
graphite: { background: "1F2937", title: "F9FAFB", subtitle: "CBD5E1" }
};
export async function importPptxFile(file: File): Promise<ImportedPowerPointDeck> {
if (!file.name.toLowerCase().endsWith(".pptx")) {
throw new Error("QPowerPoint импортирует только файлы Microsoft PowerPoint .pptx.");
}
const zip = await loadZip(file);
const parser = await createXmlParser();
const title = officeTitleFromFileName(file.name, "Презентация QPowerPoint.pptx");
const presentationXml = await readOptionalParsedXml(zip, parser, "ppt/presentation.xml");
const presentationRelationshipsXml = await readOptionalParsedXml(zip, parser, "ppt/_rels/presentation.xml.rels");
const slidePaths = listSlidePaths(zip, presentationXml, presentationRelationshipsXml);
if (slidePaths.length === 0) {
throw new Error("В файле PowerPoint не найдены слайды.");
}
const slides = await Promise.all(
slidePaths.map(async ({ path, index }) => {
const slideXml = await readRequiredZipText(zip, path, "Не удалось прочитать слайд PowerPoint.");
const parsedSlide = parser.parse(slideXml);
const slideTextBlocks = extractTextBlocks(parsedSlide);
const images = await extractSlideImages(zip, parser, parsedSlide, path, index);
const notesPath = await slideNotesPath(zip, parser, path);
const notesXml = notesPath ? await readOptionalZipText(zip, notesPath) : await readOptionalZipText(zip, `ppt/notesSlides/notesSlide${index}.xml`);
const notes = notesXml ? extractTextBlocks(parser.parse(notesXml)).join("\n") : "";
return {
id: `pptx-slide-${index}`,
title: slideTextBlocks[0] || `Слайд ${index}`,
subtitle: slideTextBlocks.slice(1).join("\n"),
notes,
theme: "classic" as const,
images
};
})
);
return { title, slides };
}
export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Blob> {
const moduleName = "pptxgenjs";
const pptxModule = (await import(moduleName)) as { default?: new () => Record<string, unknown> };
const PptxGenJS = pptxModule.default ?? (pptxModule as unknown as new () => Record<string, unknown>);
const pptx = new PptxGenJS() as Record<string, unknown> & {
layout?: string;
author?: string;
subject?: string;
title?: string;
company?: string;
addSlide: () => {
background?: { color: string };
addText: (text: string, options: Record<string, unknown>) => void;
addImage?: (options: Record<string, unknown>) => void;
addNotes?: (notes: string) => void;
};
write: (options: { outputType: "blob" | "arraybuffer" }) => Promise<Blob | ArrayBuffer>;
};
pptx.layout = "LAYOUT_WIDE";
pptx.author = "QOffice";
pptx.company = "QOffice";
pptx.subject = "Экспорт QPowerPoint";
pptx.title = deck.title;
for (const sourceSlide of deck.slides) {
const colors = themeColors[sourceSlide.theme] ?? themeColors.classic;
const slide = pptx.addSlide();
slide.background = { color: colors.background };
sourceSlide.images?.forEach((image, index) => {
const data = normalizedImageData(image.src);
if (!data || !slide.addImage) {
return;
}
slide.addImage({
data,
x: image.x,
y: image.y,
w: image.width,
h: image.height,
altText: image.alt || `Изображение ${index + 1}`
});
});
slide.addText(sourceSlide.title || "Без заголовка", {
x: 0.7,
y: 0.65,
w: 11.9,
h: 0.75,
fontFace: "Arial",
fontSize: 30,
bold: true,
color: colors.title,
margin: 0.04,
breakLine: false
});
slide.addText(sourceSlide.subtitle || "", {
x: 0.75,
y: 1.65,
w: 11.5,
h: 4.35,
fontFace: "Arial",
fontSize: 18,
color: colors.subtitle,
valign: "top",
fit: "shrink",
breakLine: false
});
if (sourceSlide.notes.trim() && slide.addNotes) {
slide.addNotes(sourceSlide.notes);
}
}
const result = await pptx.write({ outputType: "blob" });
return result instanceof Blob ? result : new Blob([result], { type: PPTX_MIME_TYPE });
}
export function extractTextBlocks(xmlNode: unknown): string[] {
const blocks: string[] = [];
collectTextBlocks(xmlNode, blocks);
return blocks.map(normalizeWhitespace).filter(Boolean);
}
export function listSlidePaths(
zip: ZipArchive,
presentationXml?: unknown,
presentationRelationshipsXml?: unknown
): Array<{ path: string; index: number }> {
const orderedSlidePaths = presentationSlidePaths(presentationXml, presentationRelationshipsXml);
if (orderedSlidePaths.length > 0) {
return orderedSlidePaths.map((path, index) => ({ path, index: slideIndexFromPath(path) ?? index + 1 }));
}
return Object.keys(zip.files ?? {})
.map((path) => {
const match = slidePathPattern.exec(path);
return match ? { path, index: Number(match[1]) } : null;
})
.filter((entry): entry is { path: string; index: number } => entry !== null)
.sort((a, b) => a.index - b.index);
}
export function presentationSlidePaths(presentationXml: unknown, presentationRelationshipsXml: unknown): string[] {
const relationshipTargets = relationshipTargetsById(presentationRelationshipsXml, slideRelationshipType);
const slideIds = toArray(childRecord(childRecord(presentationXml, "presentation"), "sldIdLst").sldId)
.map((entry) => relationshipId(asRecord(entry)))
.filter(Boolean);
return slideIds.map((id) => relationshipTargets[id]).filter((target): target is string => Boolean(target));
}
export function relationshipTargetsById(relationshipsXml: unknown, type?: string, sourcePartPath = "ppt/presentation.xml"): Record<string, string> {
const relationships = childRecord(relationshipsXml, "Relationships");
const targets: Record<string, string> = {};
const baseDirectory = partDirectory(sourcePartPath);
toArray(relationships.Relationship).forEach((relationship) => {
const record = asRecord(relationship);
const id = stringValue(record.Id ?? record.id ?? record["@_Id"] ?? record["@_id"]);
const target = stringValue(record.Target ?? record.target ?? record["@_Target"] ?? record["@_target"]);
const relationshipType = stringValue(record.Type ?? record.type ?? record["@_Type"] ?? record["@_type"]);
if (id && target && (!type || relationshipType === type)) {
targets[id] = resolvePptTarget(baseDirectory, target);
}
});
return targets;
}
export function relationshipTargetByType(relationshipsXml: unknown, type: string, sourcePartPath: string): string {
const relationships = childRecord(relationshipsXml, "Relationships");
const relationship = toArray(relationships.Relationship)
.map(asRecord)
.find((record) => stringValue(record.Type ?? record.type ?? record["@_Type"] ?? record["@_type"]) === type);
return relationship ? resolvePptTarget(partDirectory(sourcePartPath), stringValue(relationship.Target ?? relationship.target ?? relationship["@_Target"] ?? relationship["@_target"])) : "";
}
export function resolvePptTarget(baseDirectory: string, target: string): string {
const normalizedBase = baseDirectory.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedTarget = target.replace(/\\/g, "/");
const rawPath = normalizedTarget.startsWith("/") ? normalizedTarget.replace(/^\/+/, "") : `${normalizedBase}/${normalizedTarget}`;
const segments: string[] = [];
rawPath.split("/").forEach((segment) => {
if (!segment || segment === ".") {
return;
}
if (segment === "..") {
segments.pop();
return;
}
segments.push(segment);
});
return segments.join("/");
}
export function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
async function extractSlideImages(
zip: ZipArchive,
parser: { parse: (xml: string) => unknown },
slideXml: unknown,
slidePath: string,
slideIndex: number
): Promise<SlideImage[]> {
const relationshipsPath = `${partDirectory(slidePath)}/_rels/${partFileName(slidePath)}.rels`;
const relationshipsXml = await readOptionalParsedXml(zip, parser, relationshipsPath);
const imageTargets = relationshipTargetsById(relationshipsXml, imageRelationshipType, slidePath);
const pictures = recordsByKey(slideXml, "pic");
const images = await Promise.all(
pictures.map(async (picture, imageIndex) => slideImageFromPicture(zip, picture, imageTargets, slideIndex, imageIndex))
);
return images.filter((image): image is SlideImage => Boolean(image));
}
async function slideImageFromPicture(
zip: ZipArchive,
picture: Record<string, unknown>,
imageTargets: Record<string, string>,
slideIndex: number,
imageIndex: number
): Promise<SlideImage | null> {
const relationshipId = pictureRelationshipId(picture);
const target = relationshipId ? imageTargets[relationshipId] : "";
const src = target ? await zipImageDataUri(zip, target) : "";
if (!src) {
return null;
}
return {
id: `pptx-slide-${slideIndex}-image-${imageIndex + 1}`,
src,
alt: pictureAltText(picture) || `Изображение ${imageIndex + 1}`,
...pictureBounds(picture)
};
}
function pictureRelationshipId(picture: Record<string, unknown>) {
const blip = firstRecordByKey(picture, "blip");
return stringValue(
blip["r:embed"] ??
blip.embed ??
blip["@_r:embed"] ??
blip["@_embed"] ??
blip["r:link"] ??
blip.link ??
blip["@_r:link"] ??
blip["@_link"]
);
}
function pictureAltText(picture: Record<string, unknown>) {
const cNvPr = firstRecordByKey(picture, "cNvPr");
return stringValue(cNvPr.descr ?? cNvPr["@_descr"] ?? cNvPr.name ?? cNvPr["@_name"]);
}
function pictureBounds(picture: Record<string, unknown>) {
const xfrm = firstRecordByKey(picture, "xfrm");
const off = childRecord(xfrm, "off");
const ext = childRecord(xfrm, "ext");
return {
x: emuToInches(numberAttribute(off, "x") ?? 914400),
y: emuToInches(numberAttribute(off, "y") ?? 3429000),
width: emuToInches(numberAttribute(ext, "cx") ?? 3657600),
height: emuToInches(numberAttribute(ext, "cy") ?? 2057400)
};
}
async function zipImageDataUri(zip: ZipArchive, path: string) {
const mimeType = imageMimeTypeFromPath(path);
if (!mimeType) {
return "";
}
const data = (await zip.file(path)?.async("base64")) ?? "";
return data ? `data:${mimeType};base64,${data}` : "";
}
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",
svg: "image/svg+xml"
};
return mimeTypes[extension] ?? "";
}
function normalizedImageData(src: string) {
const value = src.trim();
return /^data:image\/(?:png|jpe?g|gif|bmp|svg\+xml);base64,/i.test(value) ? value : "";
}
function numberAttribute(record: Record<string, unknown>, name: string) {
const value = Number.parseFloat(stringValue(record[name] ?? record[`@_${name}`]));
return Number.isFinite(value) ? value : undefined;
}
function emuToInches(value: number) {
return Math.max(0, Math.round((value / emuPerInch) * 1000) / 1000);
}
async function loadZip(file: File): Promise<ZipArchive> {
const moduleName = "jszip";
const jsZipModule = (await import(moduleName)) as { default?: { loadAsync: (data: ArrayBuffer) => Promise<ZipArchive> } };
const jsZip = jsZipModule.default ?? (jsZipModule as unknown as { loadAsync: (data: ArrayBuffer) => Promise<ZipArchive> });
return jsZip.loadAsync(await readOfficeFileAsArrayBuffer(file));
}
async function createXmlParser(): Promise<{ parse: (xml: string) => unknown }> {
const moduleName = "fast-xml-parser";
const parserModule = (await import(moduleName)) as { XMLParser: XmlParserConstructor };
return new parserModule.XMLParser({
ignoreAttributes: false,
removeNSPrefix: true,
textNodeName: "#text",
trimValues: true
});
}
async function readOptionalParsedXml(zip: ZipArchive, parser: { parse: (xml: string) => unknown }, path: string): Promise<unknown> {
const xml = await readOptionalZipText(zip, path);
return xml ? parser.parse(xml) : undefined;
}
async function readRequiredZipText(zip: ZipArchive, path: string, errorMessage: string): Promise<string> {
const text = await readOptionalZipText(zip, path);
if (!text) {
throw new Error(errorMessage);
}
return text;
}
async function readOptionalZipText(zip: ZipArchive, path: string): Promise<string> {
return (await zip.file(path)?.async("string")) ?? "";
}
async function slideNotesPath(zip: ZipArchive, parser: { parse: (xml: string) => unknown }, slidePath: string): Promise<string> {
const relationshipsPath = `${partDirectory(slidePath)}/_rels/${partFileName(slidePath)}.rels`;
const relationshipsXml = await readOptionalParsedXml(zip, parser, relationshipsPath);
return relationshipTargetByType(relationshipsXml, notesRelationshipType, slidePath);
}
function collectTextBlocks(node: unknown, blocks: string[]): void {
if (node === null || node === undefined) {
return;
}
if (typeof node === "string" || typeof node === "number") {
blocks.push(String(node));
return;
}
if (Array.isArray(node)) {
for (const item of node) {
collectTextBlocks(item, blocks);
}
return;
}
if (typeof node !== "object") {
return;
}
const record = node as Record<string, unknown>;
if (typeof record.t === "string" || typeof record.t === "number") {
blocks.push(String(record.t));
return;
}
if (typeof record["#text"] === "string" || typeof record["#text"] === "number") {
blocks.push(String(record["#text"]));
return;
}
for (const [key, value] of Object.entries(record)) {
if (isXmlAttributeKey(key)) {
continue;
}
collectTextBlocks(value, blocks);
}
}
function isXmlAttributeKey(key: string) {
return key.startsWith("@_") || ["id", "name", "descr", "x", "y", "cx", "cy", "embed", "link"].includes(key);
}
function slideIndexFromPath(path: string): number | undefined {
const match = slidePathPattern.exec(path);
return match ? Number(match[1]) : undefined;
}
function partDirectory(path: string): string {
return path.replace(/\\/g, "/").replace(/\/[^/]*$/, "");
}
function partFileName(path: string): string {
return path.replace(/\\/g, "/").split("/").pop() ?? path;
}
function relationshipId(record: Record<string, unknown>): string {
return stringValue(record["r:id"] ?? record.id ?? record.rId ?? record["@_r:id"] ?? record["@_id"] ?? record["@_rId"]);
}
function childRecord(parent: unknown, key: string): Record<string, unknown> {
const record = asRecord(parent);
return asRecord(record[key]);
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function toArray(value: unknown): unknown[] {
if (value === undefined || value === null) {
return [];
}
return Array.isArray(value) ? value : [value];
}
function recordsByKey(node: unknown, key: string): Array<Record<string, unknown>> {
const records: Array<Record<string, unknown>> = [];
collectRecordsByKey(node, key, records);
return records;
}
function firstRecordByKey(node: unknown, key: string): Record<string, unknown> {
return recordsByKey(node, key)[0] ?? {};
}
function collectRecordsByKey(node: unknown, key: string, records: Array<Record<string, unknown>>): void {
if (node === null || node === undefined) {
return;
}
if (Array.isArray(node)) {
node.forEach((item) => collectRecordsByKey(item, key, records));
return;
}
if (typeof node !== "object") {
return;
}
Object.entries(node as Record<string, unknown>).forEach(([entryKey, value]) => {
if (entryKey === key) {
toArray(value).forEach((item) => {
const record = asRecord(item);
if (Object.keys(record).length > 0) {
records.push(record);
}
});
}
collectRecordsByKey(value, key, records);
});
}
function stringValue(value: unknown): string {
return typeof value === "string" || typeof value === "number" ? String(value) : "";
}
+199
View File
@@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import JSZip from "jszip";
import { exportDocxBlob, htmlToWordBlocks, normalizeImportedDocxHtml, wordInlineRunsToTextRunOptions } from "./wordOffice";
const tinyPngDataUri =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
describe("wordOffice helpers", () => {
it("разбирает заголовки, абзацы и списки", () => {
const blocks = htmlToWordBlocks(`
<h1>План проекта</h1>
<h2>Задачи</h2>
<p>Первый абзац.</p>
<ul><li>Подготовить документ</li><li>Проверить таблицу</li></ul>
`);
expect(blocks).toEqual([
{ type: "heading", level: 1, text: "План проекта", runs: [{ text: "План проекта" }] },
{ type: "heading", level: 2, text: "Задачи", runs: [{ text: "Задачи" }] },
{ type: "paragraph", text: "Первый абзац.", runs: [{ text: "Первый абзац." }] },
{
type: "list",
ordered: false,
items: [
{ text: "Подготовить документ", runs: [{ text: "Подготовить документ" }] },
{ text: "Проверить таблицу", runs: [{ text: "Проверить таблицу" }] }
]
}
]);
});
it("сохраняет базовое inline-форматирование текста для DOCX", () => {
const blocks = htmlToWordBlocks(`
<p>Обычный <strong>полужирный</strong> <em>курсив</em> <u>подчеркнутый</u> <span style="font-weight: 700; font-style: italic; text-decoration-line: underline; color: #0f5fae; background-color: rgb(255, 242, 204);">все стили</span></p>
`);
expect(blocks).toEqual([
{
type: "paragraph",
text: "Обычный полужирный курсив подчеркнутый все стили",
runs: [
{ text: "Обычный " },
{ text: "полужирный", bold: true },
{ text: " ", },
{ text: "курсив", italics: true },
{ text: " " },
{ text: "подчеркнутый", underline: true },
{ text: " " },
{ text: "все стили", bold: true, italics: true, underline: true, color: "0F5FAE", highlightColor: "FFF2CC" }
]
}
]);
});
it("сохраняет выравнивание абзацев и заголовков для DOCX", () => {
const blocks = htmlToWordBlocks(`
<h1 align="right">Итоги</h1>
<p style="text-align: center;">Центрированный абзац</p>
<p style="text-align: justify;">Текст по ширине</p>
`);
expect(blocks).toEqual([
{ type: "heading", level: 1, text: "Итоги", runs: [{ text: "Итоги" }], alignment: "right" },
{ type: "paragraph", text: "Центрированный абзац", runs: [{ text: "Центрированный абзац" }], alignment: "center" },
{ type: "paragraph", text: "Текст по ширине", runs: [{ text: "Текст по ширине" }], alignment: "both" }
]);
});
it("сохраняет HTML-гиперссылки как inline runs", () => {
const blocks = htmlToWordBlocks(`
<p>Откройте <a href="https://example.com/report"><strong>отчет</strong></a> и <a href="javascript:alert(1)">небезопасную ссылку</a>.</p>
`);
expect(blocks).toEqual([
{
type: "paragraph",
text: "Откройте отчет и небезопасную ссылку.",
runs: [
{ text: "Откройте " },
{ text: "отчет", bold: true, href: "https://example.com/report" },
{ text: " и небезопасную ссылку." }
]
}
]);
});
it("разбирает изображения из HTML как отдельные блоки", () => {
const blocks = htmlToWordBlocks(`<p>Перед <img src="${tinyPngDataUri}" alt="Логотип" width="120" height="40"> После</p>`);
expect(blocks).toEqual([
{ type: "paragraph", text: "Перед", runs: [{ text: "Перед" }] },
{ type: "image", src: tinyPngDataUri, alt: "Логотип", width: 120, height: 40 },
{ type: "paragraph", text: "После", runs: [{ text: "После" }] }
]);
});
it("преобразует inline runs в параметры TextRun библиотеки docx", () => {
expect(
wordInlineRunsToTextRunOptions([
{ text: "Жирный", bold: true },
{ text: "Курсив", italics: true },
{ text: "Подчеркнутый", underline: true },
{ text: "Цвет", color: "0F5FAE", highlightColor: "FFF2CC" }
])
).toEqual([
{ text: "Жирный", bold: true },
{ text: "Курсив", italics: true },
{ text: "Подчеркнутый", underline: {} },
{ text: "Цвет", color: "0F5FAE", shading: { fill: "FFF2CC" } }
]);
});
it("записывает bold, italic и underline в XML экспортированного DOCX", async () => {
const blob = await exportDocxBlob(
"Форматирование.docx",
"<p><strong>Жирный</strong> <em>Курсив</em> <u>Подчеркнутый</u></p>"
);
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toContain("<w:b/>");
expect(documentXml).toContain("<w:i/>");
expect(documentXml).toContain("<w:u");
});
it("записывает цвет текста и подсветку в XML экспортированного DOCX", async () => {
const blob = await exportDocxBlob(
"Цвет.docx",
'<p><span style="color: #0f5fae; background-color: #fff2cc;">Цветной фрагмент</span></p>'
);
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toContain('w:color w:val="0F5FAE"');
expect(documentXml).toContain('w:fill="FFF2CC"');
});
it("записывает выравнивание абзацев в XML экспортированного DOCX", async () => {
const blob = await exportDocxBlob("Выравнивание.docx", '<p style="text-align: center;">Центр</p><p align="right">Право</p>');
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toContain('<w:jc w:val="center"/>');
expect(documentXml).toContain('<w:jc w:val="right"/>');
});
it("записывает внешние гиперссылки в DOCX relationships", async () => {
const blob = await exportDocxBlob(
"Ссылки.docx",
'<p>См. <a href="https://example.com/report"><strong>отчет</strong></a> и <a href="javascript:alert(1)">скрипт</a>.</p>'
);
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
const relationshipsXml = await zip.file("word/_rels/document.xml.rels")?.async("string");
expect(documentXml).toContain("<w:hyperlink");
expect(documentXml).toContain('w:rStyle w:val="Hyperlink"');
expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"');
expect(relationshipsXml).toContain('Target="https://example.com/report"');
expect(relationshipsXml).toContain('TargetMode="External"');
expect(relationshipsXml).not.toContain("javascript:alert");
});
it("записывает data URI изображения в DOCX media relationships", async () => {
const blob = await exportDocxBlob("Изображение.docx", `<p>Логотип</p><img src="${tinyPngDataUri}" alt="Логотип" width="32" height="32">`);
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
const relationshipsXml = await zip.file("word/_rels/document.xml.rels")?.async("string");
const mediaFiles = Object.keys(zip.files).filter((path) => path.startsWith("word/media/") && path.endsWith(".png"));
expect(documentXml).toContain("<w:drawing>");
expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"');
expect(mediaFiles).toHaveLength(1);
expect(await zip.file(mediaFiles[0])?.async("uint8array")).toHaveLength(68);
});
it("разбирает простую таблицу", () => {
const blocks = htmlToWordBlocks(`
<table>
<tr><th>Этап</th><th>Ответственный</th></tr>
<tr><td>Планирование</td><td>Анна</td></tr>
</table>
`);
expect(blocks).toEqual([
{
type: "table",
rows: [
["Этап", "Ответственный"],
["Планирование", "Анна"]
]
}
]);
});
it("нормализует HTML после импорта DOCX", () => {
expect(normalizeImportedDocxHtml(" <p>Готово</p>\n")).toBe("<p>Готово</p>");
});
});
+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;
}