402 lines
19 KiB
TypeScript
402 lines
19 KiB
TypeScript
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("сохраняет HTML id абзацев и заголовков как закладки DOCX", () => {
|
||
const blocks = htmlToWordBlocks('<h1 id="Section1">Раздел</h1><p id="Summary">Итог</p>');
|
||
|
||
expect(blocks).toEqual([
|
||
{ type: "heading", level: 1, text: "Раздел", runs: [{ text: "Раздел" }], bookmark: "Section1" },
|
||
{ type: "paragraph", text: "Итог", runs: [{ text: "Итог" }], bookmark: "Summary" }
|
||
]);
|
||
});
|
||
|
||
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("сохраняет HTML-переносы строк как inline runs для DOCX", () => {
|
||
const blocks = htmlToWordBlocks("<p>Line 1<br>Line 2<br><strong>Line 3</strong></p>");
|
||
|
||
expect(blocks).toEqual([
|
||
{
|
||
type: "paragraph",
|
||
text: "Line 1 Line 2 Line 3",
|
||
runs: [{ text: "Line 1\nLine 2\n" }, { text: "Line 3", bold: true }]
|
||
}
|
||
]);
|
||
});
|
||
|
||
it("сохраняет служебные HTML-разрывы страниц как inline runs для DOCX", () => {
|
||
const blocks = htmlToWordBlocks('<p>Before<span data-qoffice-page-break="true"></span>After</p>');
|
||
|
||
expect(blocks).toEqual([
|
||
{
|
||
type: "paragraph",
|
||
text: "Before After",
|
||
runs: [{ text: "Before\fAfter" }]
|
||
}
|
||
]);
|
||
});
|
||
|
||
it("сохраняет служебные HTML-табы как inline runs для DOCX", () => {
|
||
const blocks = htmlToWordBlocks('<p>Before<span data-qoffice-tab="true" style="white-space: pre;">\t</span>After</p>');
|
||
|
||
expect(blocks).toEqual([
|
||
{
|
||
type: "paragraph",
|
||
text: "Before After",
|
||
runs: [{ text: "Before\tAfter" }]
|
||
}
|
||
]);
|
||
});
|
||
|
||
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="#Section1">раздел</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: ", " },
|
||
{ text: "раздел", href: "#Section1" },
|
||
{ 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("записывает HTML-переносы строк как w:br в XML экспортированного DOCX", async () => {
|
||
const blob = await exportDocxBlob("Переносы.docx", "<p>Line 1<br>Line 2</p>");
|
||
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||
const documentXml = await zip.file("word/document.xml")?.async("string");
|
||
|
||
expect(documentXml).toContain("<w:br/>");
|
||
expect(documentXml).toMatch(/<w:t[^>]*>Line 1<\/w:t>[\s\S]*<w:br\/>[\s\S]*<w:t[^>]*>Line 2<\/w:t>/);
|
||
});
|
||
|
||
it("записывает HTML-разрывы страниц как w:br w:type page в XML экспортированного DOCX", async () => {
|
||
const blob = await exportDocxBlob("Разрыв.docx", '<p>Before<span data-qoffice-page-break="true"></span>After</p>');
|
||
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||
const documentXml = await zip.file("word/document.xml")?.async("string");
|
||
|
||
expect(documentXml).toMatch(/<w:br\b[^>]*w:type="page"[^>]*\/>/);
|
||
expect(documentXml).toMatch(/<w:t[^>]*>Before<\/w:t>[\s\S]*<w:br\b[^>]*w:type="page"[^>]*\/>[\s\S]*<w:t[^>]*>After<\/w:t>/);
|
||
});
|
||
|
||
it("записывает служебные HTML-табы как w:tab в XML экспортированного DOCX", async () => {
|
||
const blob = await exportDocxBlob("Табуляция.docx", '<p>Before<span data-qoffice-tab="true" style="white-space: pre;">\t</span>After</p>');
|
||
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||
const documentXml = await zip.file("word/document.xml")?.async("string");
|
||
|
||
expect(documentXml).toContain("<w:tab/>");
|
||
expect(documentXml).toMatch(/<w:t[^>]*>Before<\/w:t>[\s\S]*<w:tab\/>[\s\S]*<w:t[^>]*>After<\/w:t>/);
|
||
});
|
||
|
||
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("записывает внутренние HTML-гиперссылки как w:hyperlink anchor без внешних relationships", async () => {
|
||
const blob = await exportDocxBlob("Внутренняя ссылка.docx", '<p>См. <a href="#Section1">раздел</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).toMatch(/<w:hyperlink\b[^>]*w:anchor="Section1"[^>]*>/);
|
||
expect(documentXml).toContain('w:rStyle w:val="Hyperlink"');
|
||
expect(relationshipsXml).not.toContain('Target="#Section1"');
|
||
});
|
||
|
||
it("записывает HTML id как DOCX bookmarkStart/bookmarkEnd", async () => {
|
||
const blob = await exportDocxBlob("Закладка.docx", '<h1 id="Section1">Раздел</h1><p><a href="#Section1">К разделу</a></p>');
|
||
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||
const documentXml = await zip.file("word/document.xml")?.async("string");
|
||
|
||
expect(documentXml).toMatch(/<w:bookmarkStart\b[^>]*w:name="Section1"[^>]*\/>/);
|
||
expect(documentXml).toContain("<w:bookmarkEnd");
|
||
expect(documentXml).toMatch(/<w:hyperlink\b[^>]*w:anchor="Section1"[^>]*>/);
|
||
});
|
||
|
||
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 inline-стилей для DOCX", () => {
|
||
const blocks = htmlToWordBlocks(`
|
||
<p>Normal <span style="font-size: 18pt;">Large</span> <span style="font-size: 24px;">Pixel size</span></p>
|
||
`);
|
||
|
||
expect(blocks).toEqual([
|
||
{
|
||
type: "paragraph",
|
||
text: "Normal Large Pixel size",
|
||
runs: [
|
||
{ text: "Normal " },
|
||
{ text: "Large", fontSize: 18 },
|
||
{ text: " " },
|
||
{ text: "Pixel size", fontSize: 18 }
|
||
]
|
||
}
|
||
]);
|
||
});
|
||
|
||
it("преобразует размер шрифта inline runs в half-point значение docx", () => {
|
||
expect(wordInlineRunsToTextRunOptions([{ text: "Размер", fontSize: 18 }])).toEqual([{ text: "Размер", size: 36 }]);
|
||
});
|
||
|
||
it("преобразует семейство шрифта inline runs в параметр docx font", () => {
|
||
expect(wordInlineRunsToTextRunOptions([{ text: "Arial", fontFamily: "Arial" }])).toEqual([{ text: "Arial", font: "Arial" }]);
|
||
});
|
||
|
||
it("записывает размер шрифта в XML экспортированного DOCX", async () => {
|
||
const blob = await exportDocxBlob("Размер.docx", '<p><span style="font-size: 18pt;">Крупный текст</span></p>');
|
||
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||
const documentXml = await zip.file("word/document.xml")?.async("string");
|
||
|
||
expect(documentXml).toContain('w:sz w:val="36"');
|
||
});
|
||
|
||
it("сохраняет семейство шрифта из HTML inline-стилей для DOCX", () => {
|
||
const blocks = htmlToWordBlocks('<p><span style="font-family: Arial, sans-serif;">Arial text</span></p>');
|
||
|
||
expect(blocks).toEqual([
|
||
{
|
||
type: "paragraph",
|
||
text: "Arial text",
|
||
runs: [{ text: "Arial text", fontFamily: "Arial" }]
|
||
}
|
||
]);
|
||
});
|
||
|
||
it("записывает семейство шрифта в XML экспортированного DOCX", async () => {
|
||
const blob = await exportDocxBlob("Шрифт.docx", '<p><span style="font-family: Arial;">Текст Arial</span></p>');
|
||
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||
const documentXml = await zip.file("word/document.xml")?.async("string");
|
||
|
||
expect(documentXml).toContain('w:ascii="Arial"');
|
||
});
|
||
|
||
it("сохраняет зачеркнутый текст из HTML для DOCX", () => {
|
||
const blocks = htmlToWordBlocks('<p><s>Deleted</s> <span style="text-decoration-line: line-through;">Styled</span></p>');
|
||
|
||
expect(blocks).toEqual([
|
||
{
|
||
type: "paragraph",
|
||
text: "Deleted Styled",
|
||
runs: [
|
||
{ text: "Deleted", strike: true },
|
||
{ text: " " },
|
||
{ text: "Styled", strike: true }
|
||
]
|
||
}
|
||
]);
|
||
});
|
||
|
||
it("преобразует зачеркнутый inline run в параметр docx strike", () => {
|
||
expect(wordInlineRunsToTextRunOptions([{ text: "Deleted", strike: true }])).toEqual([{ text: "Deleted", strike: true }]);
|
||
});
|
||
|
||
it("записывает зачеркнутый текст в XML экспортированного DOCX", async () => {
|
||
const blob = await exportDocxBlob("Зачеркнутый.docx", "<p><s>Удаленный текст</s></p>");
|
||
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||
const documentXml = await zip.file("word/document.xml")?.async("string");
|
||
|
||
expect(documentXml).toContain("<w:strike/>");
|
||
});
|
||
|
||
it("сохраняет HTML-отступы абзацев и заголовков для DOCX", () => {
|
||
const blocks = htmlToWordBlocks(`
|
||
<h1 style="margin-left: 36pt; text-indent: 18pt;">Title</h1>
|
||
<p style="margin-left: 48px; text-indent: -12pt;">Indented paragraph</p>
|
||
`);
|
||
|
||
expect(blocks).toEqual([
|
||
{ type: "heading", level: 1, text: "Title", runs: [{ text: "Title" }], indent: { left: 720, firstLine: 360 } },
|
||
{ type: "paragraph", text: "Indented paragraph", runs: [{ text: "Indented paragraph" }], indent: { left: 720, hanging: 240 } }
|
||
]);
|
||
});
|
||
|
||
it("записывает отступы абзацев в w:ind экспортированного DOCX", async () => {
|
||
const blob = await exportDocxBlob(
|
||
"Indent.docx",
|
||
'<p style="margin-left: 36pt; text-indent: 18pt;">Indented</p><p style="margin-left: 48px; text-indent: -12pt;">Hanging</p>'
|
||
);
|
||
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||
const documentXml = await zip.file("word/document.xml")?.async("string");
|
||
|
||
expect(documentXml).toMatch(/<w:ind\b(?=[^>]*w:left="720")(?=[^>]*w:firstLine="360")[^>]*\/>/);
|
||
expect(documentXml).toMatch(/<w:ind\b(?=[^>]*w:left="720")(?=[^>]*w:hanging="240")[^>]*\/>/);
|
||
});
|
||
|
||
it("нормализует HTML после импорта DOCX", () => {
|
||
expect(normalizeImportedDocxHtml(" <p>Готово</p>\n")).toBe("<p>Готово</p>");
|
||
});
|
||
});
|