Initial QOffice implementation

This commit is contained in:
Курнат Андрей
2026-06-01 05:31:05 +03:00
commit 9bf4dd3672
44 changed files with 16854 additions and 0 deletions
+199
View File
@@ -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>");
});
});