Initial QOffice implementation
This commit is contained in:
@@ -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)]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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("текст");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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>Строка 1<br>Строка & 2</p><ul><li>Пункт</li></ul></body></html>",
|
||||
"--alt--"
|
||||
].join("\r\n");
|
||||
|
||||
expect(htmlToPlainText("<p>Строка 1<br>Строка & 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 текст</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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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) : "";
|
||||
}
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,804 @@
|
||||
import { DOCX_MIME_TYPE, officeTitleFromFileName, readOfficeFileAsArrayBuffer } from "./fileHelpers";
|
||||
|
||||
export interface ImportedWordDocument {
|
||||
title: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export interface WordInlineRun {
|
||||
text: string;
|
||||
bold?: boolean;
|
||||
italics?: boolean;
|
||||
underline?: boolean;
|
||||
href?: string;
|
||||
color?: string;
|
||||
highlightColor?: string;
|
||||
}
|
||||
|
||||
export interface WordListItem {
|
||||
text: string;
|
||||
runs: WordInlineRun[];
|
||||
}
|
||||
|
||||
export interface WordImageBlock {
|
||||
type: "image";
|
||||
src: string;
|
||||
alt?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export type WordParagraphAlignment = "left" | "center" | "right" | "both";
|
||||
|
||||
export type WordBlock =
|
||||
| { type: "heading"; level: 1 | 2; text: string; runs: WordInlineRun[]; alignment?: WordParagraphAlignment }
|
||||
| { type: "paragraph"; text: string; runs: WordInlineRun[]; alignment?: WordParagraphAlignment }
|
||||
| { type: "list"; ordered: boolean; items: WordListItem[] }
|
||||
| { type: "table"; rows: string[][] }
|
||||
| WordImageBlock;
|
||||
|
||||
type MammothModule = {
|
||||
convertToHtml: (
|
||||
input: { arrayBuffer: ArrayBuffer },
|
||||
options?: { styleMap?: string[]; convertImage?: unknown }
|
||||
) => Promise<{ value: string; messages?: unknown[] }>;
|
||||
images?: {
|
||||
dataUri?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type DocxModule = {
|
||||
AlignmentType?: Record<string, string>;
|
||||
Document: new (options: Record<string, unknown>) => unknown;
|
||||
HeadingLevel: Record<string, string>;
|
||||
Packer: {
|
||||
toBlob?: (document: unknown) => Promise<Blob>;
|
||||
toBuffer?: (document: unknown) => Promise<ArrayBuffer | Uint8Array>;
|
||||
};
|
||||
Paragraph: new (options: Record<string, unknown>) => unknown;
|
||||
Table: new (options: Record<string, unknown>) => unknown;
|
||||
TableCell: new (options: Record<string, unknown>) => unknown;
|
||||
TableRow: new (options: Record<string, unknown>) => unknown;
|
||||
TextRun: new (options: string | Record<string, unknown>) => unknown;
|
||||
WidthType?: Record<string, string>;
|
||||
ExternalHyperlink?: new (options: { children: unknown[]; link: string }) => unknown;
|
||||
ImageRun?: new (options: Record<string, unknown>) => unknown;
|
||||
};
|
||||
|
||||
type InlineFormat = Omit<WordInlineRun, "text">;
|
||||
type InlineSegment = { type: "runs"; runs: WordInlineRun[] } | { type: "image"; image: WordImageBlock };
|
||||
|
||||
const mammothStyleMap = [
|
||||
"p[style-name='Title'] => h1:fresh",
|
||||
"p[style-name='Heading 1'] => h1:fresh",
|
||||
"p[style-name='Heading 2'] => h2:fresh"
|
||||
];
|
||||
|
||||
export async function importDocxFile(file: File): Promise<ImportedWordDocument> {
|
||||
if (!file.name.toLowerCase().endsWith(".docx")) {
|
||||
throw new Error("QWord импортирует только файлы Microsoft Word .docx.");
|
||||
}
|
||||
|
||||
const mammoth = await loadMammoth();
|
||||
const arrayBuffer = await readOfficeFileAsArrayBuffer(file);
|
||||
const result = await mammoth.convertToHtml(
|
||||
{ arrayBuffer },
|
||||
{
|
||||
styleMap: mammothStyleMap,
|
||||
...(mammoth.images?.dataUri ? { convertImage: mammoth.images.dataUri } : {})
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
title: officeTitleFromFileName(file.name, "Документ QWord.docx"),
|
||||
html: normalizeImportedDocxHtml(result.value)
|
||||
};
|
||||
}
|
||||
|
||||
export async function exportDocxBlob(title: string, html: string): Promise<Blob> {
|
||||
const docx = await loadDocx();
|
||||
const blocks = htmlToWordBlocks(html);
|
||||
const children = blocks.flatMap((block) => wordBlockToDocxChildren(block, docx));
|
||||
const documentTitle = title.trim() || "Документ QWord";
|
||||
|
||||
const document = new docx.Document({
|
||||
creator: "QOffice",
|
||||
title: documentTitle,
|
||||
numbering: {
|
||||
config: [
|
||||
{
|
||||
reference: "qoffice-numbered-list",
|
||||
levels: [
|
||||
{
|
||||
level: 0,
|
||||
format: "decimal",
|
||||
text: "%1.",
|
||||
alignment: docx.AlignmentType?.LEFT ?? "left",
|
||||
style: {
|
||||
paragraph: {
|
||||
indent: { left: 720, hanging: 260 }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
properties: {},
|
||||
children:
|
||||
children.length > 0
|
||||
? children
|
||||
: [
|
||||
new docx.Paragraph({
|
||||
children: [new docx.TextRun("")]
|
||||
})
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (docx.Packer.toBlob) {
|
||||
return docx.Packer.toBlob(document);
|
||||
}
|
||||
|
||||
if (docx.Packer.toBuffer) {
|
||||
const buffer = await docx.Packer.toBuffer(document);
|
||||
return new Blob([buffer as BlobPart], { type: DOCX_MIME_TYPE });
|
||||
}
|
||||
|
||||
throw new Error("Не удалось создать DOCX: библиотека docx не вернула поддерживаемый упаковщик.");
|
||||
}
|
||||
|
||||
export function normalizeImportedDocxHtml(html: string) {
|
||||
return html.trim();
|
||||
}
|
||||
|
||||
export function htmlToWordBlocks(html: string): WordBlock[] {
|
||||
const container = parseHtmlFragment(html);
|
||||
const blocks: WordBlock[] = [];
|
||||
|
||||
Array.from(container.childNodes).forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = normalizeWhitespace(node.textContent ?? "");
|
||||
if (text) {
|
||||
blocks.push({ type: "paragraph", text, runs: [{ text }] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(node instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
if (tagName === "img") {
|
||||
blocks.push(imageBlockFromElement(node));
|
||||
return;
|
||||
}
|
||||
|
||||
if (tagName === "h1" || tagName === "h2") {
|
||||
const runs = elementInlineRuns(node);
|
||||
const text = inlineRunsText(runs);
|
||||
const alignment = paragraphAlignmentFromElement(node);
|
||||
if (text) {
|
||||
blocks.push({ type: "heading", level: tagName === "h1" ? 1 : 2, text, runs, ...(alignment ? { alignment } : {}) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tagName === "p" || tagName === "div") {
|
||||
blocks.push(...paragraphElementToBlocks(node));
|
||||
return;
|
||||
}
|
||||
|
||||
if (tagName === "ul" || tagName === "ol") {
|
||||
const items = Array.from(node.children)
|
||||
.filter((child) => child.tagName.toLowerCase() === "li")
|
||||
.map((child) => {
|
||||
const runs = elementInlineRuns(child);
|
||||
return { text: inlineRunsText(runs), runs };
|
||||
})
|
||||
.filter((item) => item.text);
|
||||
if (items.length > 0) {
|
||||
blocks.push({ type: "list", ordered: tagName === "ol", items });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tagName === "table") {
|
||||
const rows = tableRows(node);
|
||||
if (rows.length > 0) {
|
||||
blocks.push({ type: "table", rows });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const text = elementText(node);
|
||||
if (text) {
|
||||
blocks.push({ type: "paragraph", text, runs: [{ text }] });
|
||||
}
|
||||
});
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function wordBlockToDocxChildren(block: WordBlock, docx: DocxModule) {
|
||||
if (block.type === "heading") {
|
||||
return [
|
||||
new docx.Paragraph({
|
||||
heading: block.level === 1 ? docx.HeadingLevel.HEADING_1 : docx.HeadingLevel.HEADING_2,
|
||||
spacing: { after: 160 },
|
||||
...docxAlignmentOption(block.alignment, docx),
|
||||
children: wordInlineRunsToTextRuns(block.runs, docx)
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
if (block.type === "paragraph") {
|
||||
return [
|
||||
new docx.Paragraph({
|
||||
spacing: { after: 160 },
|
||||
...docxAlignmentOption(block.alignment, docx),
|
||||
children: wordInlineRunsToTextRuns(block.runs, docx)
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
if (block.type === "list") {
|
||||
return block.items.map(
|
||||
(item) =>
|
||||
new docx.Paragraph({
|
||||
spacing: { after: 80 },
|
||||
...(block.ordered ? { numbering: { reference: "qoffice-numbered-list", level: 0 } } : { bullet: { level: 0 } }),
|
||||
children: wordInlineRunsToTextRuns(item.runs, docx)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "image") {
|
||||
const imageRun = wordImageBlockToImageRun(block, docx);
|
||||
if (!imageRun) {
|
||||
return block.alt
|
||||
? [
|
||||
new docx.Paragraph({
|
||||
spacing: { after: 160 },
|
||||
children: [new docx.TextRun(block.alt)]
|
||||
})
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
return [
|
||||
new docx.Paragraph({
|
||||
spacing: { after: 160 },
|
||||
children: [imageRun]
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
new docx.Table({
|
||||
width: {
|
||||
size: 100,
|
||||
type: docx.WidthType?.PERCENTAGE ?? "pct"
|
||||
},
|
||||
rows: block.rows.map(
|
||||
(row) =>
|
||||
new docx.TableRow({
|
||||
children: row.map(
|
||||
(cell) =>
|
||||
new docx.TableCell({
|
||||
children: [
|
||||
new docx.Paragraph({
|
||||
children: [new docx.TextRun(cell)]
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
export function wordInlineRunsToTextRunOptions(runs: WordInlineRun[]) {
|
||||
return runs.map(wordInlineRunToTextRunOption);
|
||||
}
|
||||
|
||||
function wordInlineRunToTextRunOption(run: WordInlineRun) {
|
||||
const options: Record<string, unknown> = { text: run.text };
|
||||
if (run.bold) {
|
||||
options.bold = true;
|
||||
}
|
||||
if (run.italics) {
|
||||
options.italics = true;
|
||||
}
|
||||
if (run.underline) {
|
||||
options.underline = {};
|
||||
}
|
||||
if (run.color) {
|
||||
options.color = run.color;
|
||||
}
|
||||
if (run.highlightColor) {
|
||||
options.shading = { fill: run.highlightColor };
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function wordInlineRunsToTextRuns(runs: WordInlineRun[], docx: DocxModule) {
|
||||
const normalizedRuns = runs.length > 0 ? runs : [{ text: "" }];
|
||||
return normalizedRuns.map((run) => {
|
||||
const option = wordInlineRunToTextRunOption(run);
|
||||
const href = normalizedHyperlinkTarget(run.href);
|
||||
if (href && docx.ExternalHyperlink) {
|
||||
return new docx.ExternalHyperlink({
|
||||
link: href,
|
||||
children: [new docx.TextRun({ ...option, style: "Hyperlink" })]
|
||||
});
|
||||
}
|
||||
|
||||
return new docx.TextRun(option);
|
||||
});
|
||||
}
|
||||
|
||||
function docxAlignmentOption(alignment: WordParagraphAlignment | undefined, docx: DocxModule) {
|
||||
const value = docxAlignmentValue(alignment, docx);
|
||||
return value ? { alignment: value } : {};
|
||||
}
|
||||
|
||||
function docxAlignmentValue(alignment: WordParagraphAlignment | undefined, docx: DocxModule) {
|
||||
if (!alignment) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const alignmentType = docx.AlignmentType ?? {};
|
||||
if (alignment === "center") {
|
||||
return alignmentType.CENTER ?? "center";
|
||||
}
|
||||
if (alignment === "right") {
|
||||
return alignmentType.RIGHT ?? "right";
|
||||
}
|
||||
if (alignment === "both") {
|
||||
return alignmentType.BOTH ?? alignmentType.JUSTIFIED ?? "both";
|
||||
}
|
||||
|
||||
return alignmentType.LEFT ?? "left";
|
||||
}
|
||||
|
||||
function wordImageBlockToImageRun(block: WordImageBlock, docx: DocxModule) {
|
||||
if (!docx.ImageRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const image = parseDataUriImage(block.src);
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dimensions = imageDimensions(block, image.bytes);
|
||||
return new docx.ImageRun({
|
||||
type: image.type,
|
||||
data: image.bytes,
|
||||
transformation: dimensions,
|
||||
...(block.alt ? { altText: { title: block.alt, description: block.alt } } : {})
|
||||
});
|
||||
}
|
||||
|
||||
function parseHtmlFragment(html: string) {
|
||||
if (typeof DOMParser !== "undefined") {
|
||||
return new DOMParser().parseFromString(`<main>${html}</main>`, "text/html").body.firstElementChild as HTMLElement;
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
const container = document.createElement("main");
|
||||
container.innerHTML = html;
|
||||
return container;
|
||||
}
|
||||
|
||||
throw new Error("Не удалось обработать HTML: DOMParser недоступен в этой среде.");
|
||||
}
|
||||
|
||||
function tableRows(table: Element) {
|
||||
return Array.from(table.querySelectorAll("tr"))
|
||||
.map((row) =>
|
||||
Array.from(row.querySelectorAll("th,td"))
|
||||
.map(elementText)
|
||||
.filter((cell) => cell.length > 0)
|
||||
)
|
||||
.filter((row) => row.length > 0);
|
||||
}
|
||||
|
||||
function paragraphElementToBlocks(element: Element): WordBlock[] {
|
||||
const segments = collectInlineSegments(Array.from(element.childNodes), {});
|
||||
const alignment = paragraphAlignmentFromElement(element);
|
||||
const blocks: WordBlock[] = [];
|
||||
let pendingRuns: WordInlineRun[] = [];
|
||||
|
||||
const flushParagraph = () => {
|
||||
const runs = normalizeInlineRuns(pendingRuns);
|
||||
const text = inlineRunsText(runs);
|
||||
if (text) {
|
||||
blocks.push({ type: "paragraph", text, runs, ...(alignment ? { alignment } : {}) });
|
||||
}
|
||||
pendingRuns = [];
|
||||
};
|
||||
|
||||
segments.forEach((segment) => {
|
||||
if (segment.type === "runs") {
|
||||
pendingRuns.push(...segment.runs);
|
||||
return;
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
blocks.push(segment.image);
|
||||
});
|
||||
|
||||
flushParagraph();
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function elementInlineRuns(element: Element) {
|
||||
return normalizeInlineRuns(collectInlineRuns(Array.from(element.childNodes), {}));
|
||||
}
|
||||
|
||||
function paragraphAlignmentFromElement(element: Element): WordParagraphAlignment | undefined {
|
||||
const rawAlignment = element.getAttribute("align") || cssTextAlignValue(element.getAttribute("style") ?? "");
|
||||
const normalized = rawAlignment.trim().toLowerCase();
|
||||
if (normalized === "center" || normalized === "right" || normalized === "left") {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized === "justify" ? "both" : undefined;
|
||||
}
|
||||
|
||||
function cssTextAlignValue(style: string) {
|
||||
return /(?:^|;)\s*text-align\s*:\s*([^;]+)/i.exec(style)?.[1] ?? "";
|
||||
}
|
||||
|
||||
function cssColorValue(style: string, property: "color" | "background" | "background-color") {
|
||||
const value = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "i").exec(style)?.[1] ?? "";
|
||||
return normalizedCssColor(value);
|
||||
}
|
||||
|
||||
function normalizedCssColor(value: string) {
|
||||
const raw = value.trim().toLowerCase();
|
||||
if (!raw || ["transparent", "inherit", "initial", "currentcolor", "auto"].includes(raw)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw)?.[1];
|
||||
if (hex) {
|
||||
return hex.length === 3
|
||||
? hex
|
||||
.split("")
|
||||
.map((char) => `${char}${char}`)
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
: hex.toUpperCase();
|
||||
}
|
||||
|
||||
const rgb = /^rgba?\(([^)]+)\)$/i.exec(raw)?.[1];
|
||||
if (rgb) {
|
||||
const channels = rgb
|
||||
.split(",")
|
||||
.slice(0, 3)
|
||||
.map((channel) => Number.parseFloat(channel.trim()));
|
||||
if (channels.length === 3 && channels.every((channel) => Number.isFinite(channel) && channel >= 0 && channel <= 255)) {
|
||||
return channels.map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join("").toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
const namedColors: Record<string, string> = {
|
||||
black: "000000",
|
||||
blue: "0000FF",
|
||||
cyan: "00FFFF",
|
||||
gray: "808080",
|
||||
green: "008000",
|
||||
magenta: "FF00FF",
|
||||
orange: "FFA500",
|
||||
purple: "800080",
|
||||
red: "FF0000",
|
||||
white: "FFFFFF",
|
||||
yellow: "FFFF00"
|
||||
};
|
||||
|
||||
return namedColors[raw] ?? "";
|
||||
}
|
||||
|
||||
function collectInlineRuns(nodes: ChildNode[], format: InlineFormat): WordInlineRun[] {
|
||||
return collectInlineSegments(nodes, format).flatMap((segment) => (segment.type === "runs" ? segment.runs : []));
|
||||
}
|
||||
|
||||
function collectInlineSegments(nodes: ChildNode[], format: InlineFormat): InlineSegment[] {
|
||||
return nodes.flatMap((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = normalizeInlineWhitespace(node.textContent ?? "");
|
||||
return text ? [{ type: "runs", runs: [{ text, ...format }] }] : [];
|
||||
}
|
||||
|
||||
if (!(node instanceof Element)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (node.tagName.toLowerCase() === "br") {
|
||||
return [{ type: "runs", runs: [{ text: " ", ...format }] }];
|
||||
}
|
||||
|
||||
if (node.tagName.toLowerCase() === "img") {
|
||||
return [{ type: "image", image: imageBlockFromElement(node) }];
|
||||
}
|
||||
|
||||
return collectInlineSegments(Array.from(node.childNodes), formatForElement(node, format));
|
||||
});
|
||||
}
|
||||
|
||||
function formatForElement(element: Element, inherited: InlineFormat): InlineFormat {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const style = element.getAttribute("style") ?? "";
|
||||
const href = tagName === "a" ? normalizedHyperlinkTarget(element.getAttribute("href")) : inherited.href;
|
||||
const color = cssColorValue(style, "color") || inherited.color;
|
||||
const highlightColor =
|
||||
cssColorValue(style, "background-color") || cssColorValue(style, "background") || inherited.highlightColor;
|
||||
|
||||
return cleanInlineFormat({
|
||||
bold: inherited.bold || tagName === "b" || tagName === "strong" || /font-weight\s*:\s*(bold|[6-9]00)\b/i.test(style),
|
||||
italics: inherited.italics || tagName === "i" || tagName === "em" || /font-style\s*:\s*italic\b/i.test(style),
|
||||
underline: inherited.underline || tagName === "u" || /text-decoration(?:-line)?\s*:[^;]*underline/i.test(style),
|
||||
...(href ? { href } : {}),
|
||||
...(color ? { color } : {}),
|
||||
...(highlightColor ? { highlightColor } : {})
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeInlineRuns(runs: WordInlineRun[]) {
|
||||
const normalized = runs
|
||||
.map((run) => ({ ...run, text: normalizeInlineWhitespace(run.text) }))
|
||||
.filter((run) => run.text.length > 0);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
normalized[0] = { ...normalized[0], text: normalized[0].text.trimStart() };
|
||||
const lastIndex = normalized.length - 1;
|
||||
normalized[lastIndex] = { ...normalized[lastIndex], text: normalized[lastIndex].text.trimEnd() };
|
||||
|
||||
return normalized
|
||||
.filter((run) => run.text.length > 0)
|
||||
.reduce<WordInlineRun[]>((merged, run) => {
|
||||
const previous = merged[merged.length - 1];
|
||||
if (previous && sameInlineFormat(previous, run)) {
|
||||
previous.text += run.text;
|
||||
return merged;
|
||||
}
|
||||
|
||||
merged.push(run);
|
||||
return merged;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function sameInlineFormat(first: InlineFormat, second: InlineFormat) {
|
||||
return (
|
||||
Boolean(first.bold) === Boolean(second.bold) &&
|
||||
Boolean(first.italics) === Boolean(second.italics) &&
|
||||
Boolean(first.underline) === Boolean(second.underline) &&
|
||||
(first.href ?? "") === (second.href ?? "") &&
|
||||
(first.color ?? "") === (second.color ?? "") &&
|
||||
(first.highlightColor ?? "") === (second.highlightColor ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
function cleanInlineFormat(format: InlineFormat): InlineFormat {
|
||||
return {
|
||||
...(format.bold ? { bold: true } : {}),
|
||||
...(format.italics ? { italics: true } : {}),
|
||||
...(format.underline ? { underline: true } : {}),
|
||||
...(format.href ? { href: format.href } : {}),
|
||||
...(format.color ? { color: format.color } : {}),
|
||||
...(format.highlightColor ? { highlightColor: format.highlightColor } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedHyperlinkTarget(value?: string | null) {
|
||||
const raw = value?.trim();
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
const protocol = url.protocol.toLowerCase();
|
||||
return ["http:", "https:", "mailto:", "tel:", "ftp:"].includes(protocol) ? url.href : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function imageBlockFromElement(element: Element): WordImageBlock {
|
||||
return {
|
||||
type: "image",
|
||||
src: element.getAttribute("src") ?? "",
|
||||
...(element.getAttribute("alt") ? { alt: element.getAttribute("alt") ?? "" } : {}),
|
||||
...imageElementDimensions(element)
|
||||
};
|
||||
}
|
||||
|
||||
function imageElementDimensions(element: Element) {
|
||||
const width = numericAttribute(element, "width") ?? cssPixelValue(element.getAttribute("style") ?? "", "width");
|
||||
const height = numericAttribute(element, "height") ?? cssPixelValue(element.getAttribute("style") ?? "", "height");
|
||||
|
||||
return {
|
||||
...(width ? { width } : {}),
|
||||
...(height ? { height } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function numericAttribute(element: Element, name: string) {
|
||||
const value = Number.parseFloat(element.getAttribute(name) ?? "");
|
||||
return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined;
|
||||
}
|
||||
|
||||
function cssPixelValue(style: string, property: "width" | "height") {
|
||||
const match = new RegExp(`${property}\\s*:\\s*(\\d+(?:\\.\\d+)?)px`, "i").exec(style);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = Number.parseFloat(match[1]);
|
||||
return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined;
|
||||
}
|
||||
|
||||
function parseDataUriImage(src: string) {
|
||||
const match = /^data:(image\/(?:png|jpe?g|gif|bmp));base64,([a-z0-9+/=\s]+)$/i.exec(src.trim());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = imageTypeFromMime(match[1]);
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
bytes: base64ToBytes(match[2].replace(/\s+/g, ""))
|
||||
};
|
||||
}
|
||||
|
||||
function imageTypeFromMime(mimeType: string) {
|
||||
const normalized = mimeType.toLowerCase();
|
||||
if (normalized === "image/jpeg" || normalized === "image/jpg") {
|
||||
return "jpg";
|
||||
}
|
||||
if (normalized === "image/png") {
|
||||
return "png";
|
||||
}
|
||||
if (normalized === "image/gif") {
|
||||
return "gif";
|
||||
}
|
||||
if (normalized === "image/bmp") {
|
||||
return "bmp";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function imageDimensions(block: WordImageBlock, bytes: Uint8Array) {
|
||||
const detected = detectImageDimensions(bytes);
|
||||
const width = Math.min(block.width ?? detected?.width ?? 520, 620);
|
||||
const height =
|
||||
block.height ??
|
||||
(detected ? Math.max(1, Math.round((width / detected.width) * detected.height)) : undefined) ??
|
||||
260;
|
||||
|
||||
return {
|
||||
width,
|
||||
height
|
||||
};
|
||||
}
|
||||
|
||||
function detectImageDimensions(bytes: Uint8Array) {
|
||||
const png = pngDimensions(bytes);
|
||||
if (png) {
|
||||
return png;
|
||||
}
|
||||
|
||||
return jpegDimensions(bytes);
|
||||
}
|
||||
|
||||
function pngDimensions(bytes: Uint8Array) {
|
||||
const isPng =
|
||||
bytes.length >= 24 &&
|
||||
bytes[0] === 0x89 &&
|
||||
bytes[1] === 0x50 &&
|
||||
bytes[2] === 0x4e &&
|
||||
bytes[3] === 0x47 &&
|
||||
bytes[12] === 0x49 &&
|
||||
bytes[13] === 0x48 &&
|
||||
bytes[14] === 0x44 &&
|
||||
bytes[15] === 0x52;
|
||||
if (!isPng) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
width: readUInt32BE(bytes, 16),
|
||||
height: readUInt32BE(bytes, 20)
|
||||
};
|
||||
}
|
||||
|
||||
function jpegDimensions(bytes: Uint8Array) {
|
||||
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let offset = 2;
|
||||
while (offset + 9 < bytes.length) {
|
||||
if (bytes[offset] !== 0xff) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const marker = bytes[offset + 1];
|
||||
const length = (bytes[offset + 2] << 8) + bytes[offset + 3];
|
||||
if (length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isStartOfFrame = marker >= 0xc0 && marker <= 0xc3;
|
||||
if (isStartOfFrame) {
|
||||
return {
|
||||
height: (bytes[offset + 5] << 8) + bytes[offset + 6],
|
||||
width: (bytes[offset + 7] << 8) + bytes[offset + 8]
|
||||
};
|
||||
}
|
||||
|
||||
offset += 2 + length;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readUInt32BE(bytes: Uint8Array, offset: number) {
|
||||
return ((bytes[offset] << 24) >>> 0) + (bytes[offset + 1] << 16) + (bytes[offset + 2] << 8) + bytes[offset + 3];
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string) {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function inlineRunsText(runs: WordInlineRun[]) {
|
||||
return normalizeWhitespace(runs.map((run) => run.text).join(""));
|
||||
}
|
||||
|
||||
function elementText(element: Element) {
|
||||
return normalizeWhitespace(element.textContent ?? "");
|
||||
}
|
||||
|
||||
function normalizeWhitespace(value: string) {
|
||||
return value.replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeInlineWhitespace(value: string) {
|
||||
return value.replace(/\u00a0/g, " ").replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
async function loadMammoth() {
|
||||
const module = (await import("mammoth")) as { default?: MammothModule } & MammothModule;
|
||||
return module.default ?? module;
|
||||
}
|
||||
|
||||
async function loadDocx() {
|
||||
const module = (await import("docx")) as unknown as { default?: DocxModule } & DocxModule;
|
||||
return module.default ?? module;
|
||||
}
|
||||
Reference in New Issue
Block a user