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("разбирает HTML blockquote как цитату QWord", () => { const blocks = htmlToWordBlocks('
Цитата важная
'); expect(blocks).toEqual([ { type: "paragraph", text: "Цитата важная", runs: [{ text: "Цитата " }, { text: "важная", bold: true }], bookmark: "Quote1", quote: true } ]); }); 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("preserves HTML subscript and superscript as inline runs for DOCX", () => { const blocks = htmlToWordBlocks('

H2O m2 x

'); expect(blocks).toEqual([ { type: "paragraph", text: "H2O m2 x", runs: [ { text: "H" }, { text: "2", subscript: true }, { text: "O m" }, { text: "2", superscript: true }, { text: " " }, { text: "x", superscript: true } ] } ]); }); 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("записывает blockquote как стиль Quote в 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="Quote"[^>]*\/>/); expect(documentXml).toContain("Важная цитата"); }); 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("converts subscript and superscript inline runs into docx parameters", () => { expect( wordInlineRunsToTextRunOptions([ { text: "2", subscript: true }, { text: "2", superscript: true } ]) ).toEqual([ { text: "2", subScript: true }, { text: "2", superScript: true } ]); }); it("writes subscript and superscript to exported DOCX XML", async () => { const blob = await exportDocxBlob("Indexes.docx", "

H2O m2

"); const zip = await JSZip.loadAsync(await blob.arrayBuffer()); const documentXml = await zip.file("word/document.xml")?.async("string"); expect(documentXml).toMatch(/]*w:val="subscript"[^>]*\/>/); expect(documentXml).toMatch(/]*w:val="superscript"[^>]*\/>/); }); 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("сохраняет HTML-интервалы абзацев и заголовков для DOCX", () => { const blocks = htmlToWordBlocks(`

Title spacing

Paragraph spacing

`); expect(blocks).toEqual([ { type: "heading", level: 2, text: "Title spacing", runs: [{ text: "Title spacing" }], spacing: { before: 120, after: 200 } }, { type: "paragraph", text: "Paragraph spacing", runs: [{ text: "Paragraph spacing" }], spacing: { before: 240, after: 360 } } ]); }); 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("записывает интервалы абзацев в w:spacing экспортированного DOCX", async () => { const blob = await exportDocxBlob( "Spacing.docx", '

Title spacing

Paragraph spacing

' ); const zip = await JSZip.loadAsync(await blob.arrayBuffer()); const documentXml = await zip.file("word/document.xml")?.async("string"); expect(documentXml).toMatch(/]*w:before="120")(?=[^>]*w:after="200")[^>]*\/>/); expect(documentXml).toMatch(/]*w:before="240")(?=[^>]*w:after="360")[^>]*\/>/); }); it("нормализует HTML после импорта DOCX", () => { expect(normalizeImportedDocxHtml("

Готово

\n")).toBe("

Готово

"); }); });