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(`
План проекта
Задачи
Контроль качества
Сценарии
Шаги
Детали
Первый абзац.
- Подготовить документ
- Проверить таблицу
`);
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('Раздел
Итог
');
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(`
Обычный полужирный курсив подчеркнутый все стили
`);
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("Line 1
Line 2
Line 3
");
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('BeforeAfter
');
expect(blocks).toEqual([
{
type: "paragraph",
text: "Before After",
runs: [{ text: "Before\fAfter" }]
}
]);
});
it("сохраняет служебные HTML-табы как inline runs для DOCX", () => {
const blocks = htmlToWordBlocks('Before\tAfter
');
expect(blocks).toEqual([
{
type: "paragraph",
text: "Before After",
runs: [{ text: "Before\tAfter" }]
}
]);
});
it("сохраняет выравнивание абзацев и заголовков для DOCX", () => {
const blocks = htmlToWordBlocks(`
Итоги
Центрированный абзац
Текст по ширине
`);
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(`
Откройте отчет, раздел и небезопасную ссылку.
`);
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(`Перед
После
`);
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",
"Жирный Курсив Подчеркнутый
"
);
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toContain("");
expect(documentXml).toContain("");
expect(documentXml).toContain(" {
const blob = await exportDocxBlob("Переносы.docx", "Line 1
Line 2
");
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toContain("");
expect(documentXml).toMatch(/]*>Line 1<\/w:t>[\s\S]*[\s\S]*]*>Line 2<\/w:t>/);
});
it("записывает h3-h6 как стили Heading3-Heading6 в XML экспортированного DOCX", async () => {
const blob = await exportDocxBlob("Заголовки.docx", "Раздел
Подраздел
Сценарий
Деталь
");
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toMatch(/]*w:val="Heading3"[^>]*\/>/);
expect(documentXml).toMatch(/]*w:val="Heading4"[^>]*\/>/);
expect(documentXml).toMatch(/]*w:val="Heading5"[^>]*\/>/);
expect(documentXml).toMatch(/]*w:val="Heading6"[^>]*\/>/);
});
it("записывает HTML-разрывы страниц как w:br w:type page в XML экспортированного DOCX", async () => {
const blob = await exportDocxBlob("Разрыв.docx", 'BeforeAfter
');
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toMatch(/]*w:type="page"[^>]*\/>/);
expect(documentXml).toMatch(/]*>Before<\/w:t>[\s\S]*]*w:type="page"[^>]*\/>[\s\S]*]*>After<\/w:t>/);
});
it("записывает служебные HTML-табы как w:tab в XML экспортированного DOCX", async () => {
const blob = await exportDocxBlob("Табуляция.docx", 'Before\tAfter
');
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toContain("");
expect(documentXml).toMatch(/]*>Before<\/w:t>[\s\S]*[\s\S]*]*>After<\/w:t>/);
});
it("записывает цвет текста и подсветку в XML экспортированного DOCX", async () => {
const blob = await exportDocxBlob(
"Цвет.docx",
'Цветной фрагмент
'
);
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", 'Центр
Право
');
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toContain('');
expect(documentXml).toContain('');
});
it("записывает внешние гиперссылки в DOCX relationships", async () => {
const blob = await exportDocxBlob(
"Ссылки.docx",
'См. отчет и скрипт.
'
);
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(" {
const blob = await exportDocxBlob("Внутренняя ссылка.docx", 'См. раздел.
');
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: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", 'Раздел
К разделу
');
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toMatch(/]*w:name="Section1"[^>]*\/>/);
expect(documentXml).toContain("]*w:anchor="Section1"[^>]*>/);
});
it("записывает data URI изображения в DOCX media relationships", async () => {
const blob = await exportDocxBlob("Изображение.docx", `Логотип
`);
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("");
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(`
| Этап | Ответственный |
| Планирование | Анна |
`);
expect(blocks).toEqual([
{
type: "table",
rows: [
["Этап", "Ответственный"],
["Планирование", "Анна"]
]
}
]);
});
it("сохраняет размер шрифта из HTML inline-стилей для DOCX", () => {
const blocks = htmlToWordBlocks(`
Normal Large Pixel size
`);
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", 'Крупный текст
');
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('Arial text
');
expect(blocks).toEqual([
{
type: "paragraph",
text: "Arial text",
runs: [{ text: "Arial text", fontFamily: "Arial" }]
}
]);
});
it("записывает семейство шрифта в XML экспортированного DOCX", async () => {
const blob = await exportDocxBlob("Шрифт.docx", 'Текст Arial
');
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('Deleted Styled
');
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", "Удаленный текст
");
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toContain("");
});
it("сохраняет HTML-отступы абзацев и заголовков для DOCX", () => {
const blocks = htmlToWordBlocks(`
Title
Indented paragraph
`);
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",
'Indented
Hanging
'
);
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toMatch(/]*w:left="720")(?=[^>]*w:firstLine="360")[^>]*\/>/);
expect(documentXml).toMatch(/]*w:left="720")(?=[^>]*w:hanging="240")[^>]*\/>/);
});
it("нормализует HTML после импорта DOCX", () => {
expect(normalizeImportedDocxHtml(" Готово
\n")).toBe("Готово
");
});
});