Files
QOffice/src/io/wordOffice.test.ts
T
2026-06-02 06:43:36 +03:00

421 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
<h3>Контроль качества</h3>
<h4>Сценарии</h4>
<h5>Шаги</h5>
<h6>Детали</h6>
<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: "heading", level: 3, text: "Контроль качества", runs: [{ text: "Контроль качества" }] },
{ type: "heading", level: 4, text: "Сценарии", runs: [{ text: "Сценарии" }] },
{ type: "heading", level: 5, text: "Шаги", runs: [{ text: "Шаги" }] },
{ type: "heading", level: 6, 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("записывает h3-h6 как стили Heading3-Heading6 в XML экспортированного DOCX", async () => {
const blob = await exportDocxBlob("Заголовки.docx", "<h3>Раздел</h3><h4>Подраздел</h4><h5>Сценарий</h5><h6>Деталь</h6>");
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toMatch(/<w:pStyle\b[^>]*w:val="Heading3"[^>]*\/>/);
expect(documentXml).toMatch(/<w:pStyle\b[^>]*w:val="Heading4"[^>]*\/>/);
expect(documentXml).toMatch(/<w:pStyle\b[^>]*w:val="Heading5"[^>]*\/>/);
expect(documentXml).toMatch(/<w:pStyle\b[^>]*w:val="Heading6"[^>]*\/>/);
});
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>");
});
});