Initial QOffice implementation
This commit is contained in:
@@ -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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user