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
+217
View File
@@ -0,0 +1,217 @@
import { describe, expect, it } from "vitest";
import JSZip from "jszip";
import {
exportPptxBlob,
extractTextBlocks,
importPptxFile,
listSlidePaths,
normalizeWhitespace,
presentationSlidePaths,
relationshipTargetByType,
resolvePptTarget
} from "./powerPointOffice";
const tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
describe("powerPointOffice helpers", () => {
it("извлекает текстовые runs из OOXML-подобных узлов", () => {
const parsedSlide = {
sld: {
cSld: {
spTree: {
sp: [
{ txBody: { p: { r: { t: "Заголовок" } } } },
{ txBody: { p: [{ r: { t: "Первый пункт" } }, { r: { t: "Второй пункт" } }] } }
]
}
}
}
};
expect(extractTextBlocks(parsedSlide)).toEqual(["Заголовок", "Первый пункт", "Второй пункт"]);
});
it("сортирует пути слайдов по числовому индексу", () => {
const zip = {
files: {
"ppt/slides/slide10.xml": {},
"ppt/slides/slide2.xml": {},
"ppt/slides/slide1.xml": {},
"ppt/notesSlides/notesSlide1.xml": {}
},
file: () => null
};
expect(listSlidePaths(zip)).toEqual([
{ path: "ppt/slides/slide1.xml", index: 1 },
{ path: "ppt/slides/slide2.xml", index: 2 },
{ path: "ppt/slides/slide10.xml", index: 10 }
]);
});
it("читает порядок слайдов из presentation relationships", () => {
const presentationXml = {
presentation: {
sldIdLst: {
sldId: [{ id: "300", "r:id": "rIdB" }, { id: "200", "r:id": "rIdA" }]
}
}
};
const relationshipsXml = {
Relationships: {
Relationship: [
{
Id: "rIdA",
Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide",
Target: "slides/slide1.xml"
},
{
Id: "rIdB",
Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide",
Target: "slides/slide10.xml"
}
]
}
};
expect(presentationSlidePaths(presentationXml, relationshipsXml)).toEqual(["ppt/slides/slide10.xml", "ppt/slides/slide1.xml"]);
expect(listSlidePaths({ files: {}, file: () => null }, presentationXml, relationshipsXml)).toEqual([
{ path: "ppt/slides/slide10.xml", index: 10 },
{ path: "ppt/slides/slide1.xml", index: 1 }
]);
});
it("разрешает относительные targets заметок от части слайда", () => {
expect(resolvePptTarget("ppt/slides", "../notesSlides/notesSlide7.xml")).toBe("ppt/notesSlides/notesSlide7.xml");
expect(
relationshipTargetByType(
{
Relationships: {
Relationship: {
Id: "rIdNotes",
Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide",
Target: "../notesSlides/notesSlide7.xml"
}
}
},
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide",
"ppt/slides/slide3.xml"
)
).toBe("ppt/notesSlides/notesSlide7.xml");
});
it("импортирует PPTX в порядке presentation.xml и берет заметки через slide rels", async () => {
const zip = new JSZip();
zip.file(
"ppt/presentation.xml",
`<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst><p:sldId id="256" r:id="rId2"/><p:sldId id="255" r:id="rId1"/></p:sldIdLst></p:presentation>`
);
zip.file(
"ppt/_rels/presentation.xml.rels",
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide2.xml"/></Relationships>`
);
zip.file("ppt/slides/slide1.xml", slideXml("Первый файл", "Но второй по порядку"));
zip.file("ppt/slides/slide2.xml", slideXml("Первый по порядку", "Содержимое первого слайда"));
zip.file(
"ppt/slides/_rels/slide2.xml.rels",
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdNotes" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" Target="../notesSlides/notesSlide9.xml"/></Relationships>`
);
zip.file("ppt/notesSlides/notesSlide9.xml", slideXml("Заметка докладчика"));
const blob = await zip.generateAsync({
type: "blob",
mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
});
const imported = await importPptxFile(new File([blob], "Дорожная карта.pptx", { type: blob.type }));
expect(imported.slides.map((slide) => slide.title)).toEqual(["Первый по порядку", "Первый файл"]);
expect(imported.slides[0].subtitle).toBe("Содержимое первого слайда");
expect(imported.slides[0].notes).toBe("Заметка докладчика");
});
it("импортирует изображения слайда из PPTX media relationships", async () => {
const zip = new JSZip();
zip.file(
"ppt/presentation.xml",
`<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst></p:presentation>`
);
zip.file(
"ppt/_rels/presentation.xml.rels",
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/></Relationships>`
);
zip.file("ppt/slides/slide1.xml", slideXmlWithPicture("Слайд с изображением"));
zip.file(
"ppt/slides/_rels/slide1.xml.rels",
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdImage" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/logo.png"/></Relationships>`
);
zip.file("ppt/media/logo.png", tinyPngBase64, { base64: true });
const blob = await zip.generateAsync({
type: "blob",
mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
});
const imported = await importPptxFile(new File([blob], "Изображения.pptx", { type: blob.type }));
expect(imported.slides[0].title).toBe("Слайд с изображением");
expect(imported.slides[0].images).toEqual([
{
id: "pptx-slide-1-image-1",
src: `data:image/png;base64,${tinyPngBase64}`,
alt: "Логотип",
x: 1,
y: 2,
width: 3,
height: 1.5
}
]);
});
it("экспортирует изображения слайда в PPTX media relationships", async () => {
const blob = await exportPptxBlob({
title: "Слайды с изображением.pptx",
slides: [
{
id: "slide-1",
title: "Слайд с изображением",
subtitle: "Проверка экспорта",
notes: "",
theme: "classic",
images: [
{
id: "image-1",
src: `data:image/png;base64,${tinyPngBase64}`,
alt: "Логотип",
x: 1,
y: 2,
width: 3,
height: 1.5
}
]
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const slideXml = await zip.file("ppt/slides/slide1.xml")?.async("string");
const relationshipsXml = await zip.file("ppt/slides/_rels/slide1.xml.rels")?.async("string");
const mediaFiles = Object.keys(zip.files).filter((path) => path.startsWith("ppt/media/") && path.endsWith(".png"));
expect(slideXml).toContain("<p:pic>");
expect(slideXml).toContain('descr="Логотип"');
expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"');
expect(mediaFiles.length).toBeGreaterThan(0);
});
it("нормализует пробелы в импортированном тексте", () => {
expect(normalizeWhitespace(" Строка\tс \n пробелами ")).toBe("Строка с пробелами");
});
});
function slideXml(...texts: string[]) {
return `<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><p:cSld><p:spTree>${texts
.map((text) => `<p:sp><p:txBody><a:p><a:r><a:t>${text}</a:t></a:r></a:p></p:txBody></p:sp>`)
.join("")}</p:spTree></p:cSld></p:sld>`;
}
function slideXmlWithPicture(title: string) {
return `<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:cSld><p:spTree><p:sp><p:txBody><a:p><a:r><a:t>${title}</a:t></a:r></a:p></p:txBody></p:sp><p:pic><p:nvPicPr><p:cNvPr id="4" name="Picture 1" descr="Логотип"/></p:nvPicPr><p:blipFill><a:blip r:embed="rIdImage"/></p:blipFill><p:spPr><a:xfrm><a:off x="914400" y="1828800"/><a:ext cx="2743200" cy="1371600"/></a:xfrm></p:spPr></p:pic></p:spTree></p:cSld></p:sld>`;
}