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", `` ); zip.file( "ppt/_rels/presentation.xml.rels", `` ); zip.file("ppt/slides/slide1.xml", slideXml("Первый файл", "Но второй по порядку")); zip.file("ppt/slides/slide2.xml", slideXml("Первый по порядку", "Содержимое первого слайда")); zip.file( "ppt/slides/_rels/slide2.xml.rels", `` ); 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 при импорте", async () => { const zip = new JSZip(); zip.file( "ppt/presentation.xml", `` ); zip.file( "ppt/_rels/presentation.xml.rels", `` ); zip.file("ppt/slides/slide1.xml", slideXmlWithMultilineSubtitle("Roadmap", ["Plan", "Build", "Ship"])); const blob = await zip.generateAsync({ type: "blob", mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation" }); const imported = await importPptxFile(new File([blob], "Roadmap.pptx", { type: blob.type })); expect(imported.slides[0].title).toBe("Roadmap"); expect(imported.slides[0].subtitle).toBe("Plan\nBuild\nShip"); }); it("сохраняет ручные переносы строк a:br внутри абзаца PPTX при импорте", async () => { const zip = new JSZip(); zip.file( "ppt/presentation.xml", `` ); zip.file( "ppt/_rels/presentation.xml.rels", `` ); zip.file("ppt/slides/slide1.xml", slideXmlWithManualBreakSubtitle("Roadmap", ["Plan", "Build", "Ship"])); const blob = await zip.generateAsync({ type: "blob", mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation" }); const imported = await importPptxFile(new File([blob], "Roadmap.pptx", { type: blob.type })); expect(imported.slides[0].title).toBe("Roadmap"); expect(imported.slides[0].subtitle).toBe("Plan\nBuild\nShip"); }); it("импортирует маркированные и нумерованные списки подзаголовка PPTX", async () => { const bulletZip = new JSZip(); bulletZip.file( "ppt/presentation.xml", `` ); bulletZip.file( "ppt/_rels/presentation.xml.rels", `` ); bulletZip.file("ppt/slides/slide1.xml", slideXmlWithListSubtitle("План", ["Собрать требования", "Проверить импорт"], "bullet")); const bulletBlob = await bulletZip.generateAsync({ type: "blob", mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation" }); const importedBullet = await importPptxFile(new File([bulletBlob], "Маркированный.pptx", { type: bulletBlob.type })); expect(importedBullet.slides[0].subtitle).toBe("Собрать требования\nПроверить импорт"); expect(importedBullet.slides[0].subtitleListStyle).toBe("bullet"); const numberZip = new JSZip(); numberZip.file( "ppt/presentation.xml", `` ); numberZip.file( "ppt/_rels/presentation.xml.rels", `` ); numberZip.file("ppt/slides/slide1.xml", slideXmlWithListSubtitle("План", ["Шаг один", "Шаг два"], "number")); const numberBlob = await numberZip.generateAsync({ type: "blob", mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation" }); const importedNumber = await importPptxFile(new File([numberBlob], "Нумерованный.pptx", { type: numberBlob.type })); expect(importedNumber.slides[0].subtitle).toBe("Шаг один\nШаг два"); expect(importedNumber.slides[0].subtitleListStyle).toBe("number"); }); it("экспортирует списки подзаголовка PPTX как OOXML bullets", async () => { const bulletBlob = await exportPptxBlob({ title: "Маркированный.pptx", slides: [ { id: "slide-1", title: "План", subtitle: "Собрать требования\nПроверить импорт", subtitleListStyle: "bullet", notes: "", theme: "classic" } ] }); const bulletZip = await JSZip.loadAsync(await bulletBlob.arrayBuffer()); const bulletSlideXml = (await bulletZip.file("ppt/slides/slide1.xml")?.async("string")) ?? ""; expect(bulletSlideXml).toContain(" { const zip = new JSZip(); zip.file( "ppt/presentation.xml", `` ); zip.file( "ppt/_rels/presentation.xml.rels", `` ); zip.file("ppt/slides/slide1.xml", slideXmlWithPicture("Слайд с изображением")); zip.file( "ppt/slides/_rels/slide1.xml.rels", `` ); 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 через hlinkClick relationships", async () => { const zip = new JSZip(); zip.file( "ppt/presentation.xml", `` ); zip.file( "ppt/_rels/presentation.xml.rels", `` ); zip.file("ppt/slides/slide1.xml", slideXmlWithLinkedPicture("Слайд с кликабельным изображением")); zip.file( "ppt/slides/_rels/slide1.xml.rels", `` ); 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].images?.[0]).toMatchObject({ alt: "Логотип", hyperlink: "https://example.com/logo" }); }); 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(""); expect(slideXml).toContain('descr="Логотип"'); expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"'); expect(mediaFiles.length).toBeGreaterThan(0); }); it("экспортирует внешние ссылки изображений PPTX", 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: "Логотип", hyperlink: "https://example.com/logo", 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 reimported = await importPptxFile(new File([blob], "Кликабельное изображение.pptx", { type: blob.type })); expect(slideXml).toContain("hlinkClick"); expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"'); expect(relationshipsXml).toContain('Target="https://example.com/logo"'); expect(reimported.slides[0].images?.[0]).toMatchObject({ hyperlink: "https://example.com/logo" }); }); it("экспортирует заметки докладчика в PPTX notesSlide relationships", async () => { const blob = await exportPptxBlob({ title: "Заметки.pptx", slides: [ { id: "slide-1", title: "Слайд с заметками", subtitle: "Проверка экспорта", notes: "Первый тезис\nВторой тезис", theme: "classic" } ] }); const zip = await JSZip.loadAsync(await blob.arrayBuffer()); const relationshipsXml = await zip.file("ppt/slides/_rels/slide1.xml.rels")?.async("string"); const notesPath = Object.keys(zip.files).find((path) => path.startsWith("ppt/notesSlides/notesSlide") && path.endsWith(".xml")); const notesXml = notesPath ? await zip.file(notesPath)?.async("string") : ""; expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide"'); expect(notesXml).toContain("Первый тезис"); expect(notesXml).toContain("Второй тезис"); }); it("импортирует и экспортирует пользовательский цвет фона слайда", async () => { const zip = new JSZip(); zip.file( "ppt/presentation.xml", `` ); zip.file( "ppt/_rels/presentation.xml.rels", `` ); zip.file("ppt/slides/slide1.xml", slideXmlWithBackground("Фон слайда", "FCE4D6")); const importedBlob = await zip.generateAsync({ type: "blob", mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation" }); const imported = await importPptxFile(new File([importedBlob], "Фон.pptx", { type: importedBlob.type })); expect(imported.slides[0].backgroundColor).toBe("FCE4D6"); const exportedBlob = await exportPptxBlob({ title: "Фон.pptx", slides: [ { id: "slide-1", title: "Фон слайда", subtitle: "", notes: "", theme: "classic", backgroundColor: "FCE4D6" } ] }); const exportedZip = await JSZip.loadAsync(await exportedBlob.arrayBuffer()); const exportedSlideXml = await exportedZip.file("ppt/slides/slide1.xml")?.async("string"); expect(exportedSlideXml).toContain("FCE4D6"); }); it("импортирует и экспортирует скрытые слайды PPTX через атрибут show", async () => { const zip = new JSZip(); zip.file( "ppt/presentation.xml", `` ); zip.file( "ppt/_rels/presentation.xml.rels", `` ); zip.file("ppt/slides/slide1.xml", slideXmlWithShow("Скрытый слайд", "0")); const importedBlob = await zip.generateAsync({ type: "blob", mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation" }); const imported = await importPptxFile(new File([importedBlob], "Скрытый.pptx", { type: importedBlob.type })); expect(imported.slides[0]).toMatchObject({ title: "Скрытый слайд", hidden: true }); const exportedBlob = await exportPptxBlob({ title: "Скрытый.pptx", slides: [ { id: "slide-1", title: "Скрытый слайд", subtitle: "", notes: "", theme: "classic", hidden: true } ] }); const exportedZip = await JSZip.loadAsync(await exportedBlob.arrayBuffer()); const exportedSlideXml = (await exportedZip.file("ppt/slides/slide1.xml")?.async("string")) ?? ""; const reimported = await importPptxFile(new File([exportedBlob], "Скрытый.pptx", { type: exportedBlob.type })); expect(exportedSlideXml).toContain('show="0"'); expect(reimported.slides[0].hidden).toBe(true); }); it("импортирует и экспортирует переходы слайдов PPTX через p:transition", async () => { const zip = new JSZip(); zip.file( "ppt/presentation.xml", `` ); zip.file( "ppt/_rels/presentation.xml.rels", `` ); zip.file("ppt/slides/slide1.xml", slideXmlWithTransition("Слайд с переходом", "fade")); const importedBlob = await zip.generateAsync({ type: "blob", mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation" }); const imported = await importPptxFile(new File([importedBlob], "Переход.pptx", { type: importedBlob.type })); expect(imported.slides[0]).toMatchObject({ title: "Слайд с переходом", transition: "fade" }); const exportedBlob = await exportPptxBlob({ title: "Переход.pptx", slides: [ { id: "slide-1", title: "Слайд с переходом", subtitle: "", notes: "", theme: "classic", transition: "wipe" } ] }); const exportedZip = await JSZip.loadAsync(await exportedBlob.arrayBuffer()); const exportedSlideXml = (await exportedZip.file("ppt/slides/slide1.xml")?.async("string")) ?? ""; const reimportedTransition = await importPptxFile(new File([exportedBlob], "Переход.pptx", { type: exportedBlob.type })); expect(exportedSlideXml).toContain(""); expect(reimportedTransition.slides[0].transition).toBe("wipe"); }); it("импортирует и экспортирует цвет и размер текста слайда", async () => { const zip = new JSZip(); zip.file( "ppt/presentation.xml", `` ); zip.file( "ppt/_rels/presentation.xml.rels", `` ); zip.file("ppt/slides/slide1.xml", slideXmlWithTextStyles("Заголовок", "Подзаголовок")); const importedBlob = await zip.generateAsync({ type: "blob", mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation" }); const imported = await importPptxFile(new File([importedBlob], "Текст.pptx", { type: importedBlob.type })); expect(imported.slides[0]).toMatchObject({ title: "Заголовок", subtitle: "Подзаголовок", titleColor: "C2410C", titleFontSize: 34, titleFontFamily: "Courier New", subtitleColor: "0F766E", subtitleFontSize: 22, subtitleFontFamily: "Times New Roman" }); const exportedBlob = await exportPptxBlob({ title: "Текст.pptx", slides: [ { id: "slide-1", title: "Заголовок", subtitle: "Подзаголовок", notes: "", theme: "classic", titleColor: "C2410C", titleFontSize: 34, titleFontFamily: "Courier New", subtitleColor: "0F766E", subtitleFontSize: 22, subtitleFontFamily: "Times New Roman" } ] }); const exportedZip = await JSZip.loadAsync(await exportedBlob.arrayBuffer()); const exportedSlideXml = await exportedZip.file("ppt/slides/slide1.xml")?.async("string"); expect(exportedSlideXml).toContain("C2410C"); expect(exportedSlideXml).toContain("0F766E"); expect(exportedSlideXml).toContain('sz="3400"'); expect(exportedSlideXml).toContain('sz="2200"'); expect(exportedSlideXml).toContain('typeface="Courier New"'); expect(exportedSlideXml).toContain('typeface="Times New Roman"'); }); it("импортирует и экспортирует начертание текста слайда PPTX", async () => { const zip = new JSZip(); zip.file( "ppt/presentation.xml", `` ); zip.file( "ppt/_rels/presentation.xml.rels", `` ); zip.file("ppt/slides/slide1.xml", slideXmlWithTextRunStyles("Заголовок", "Подзаголовок")); const importedBlob = await zip.generateAsync({ type: "blob", mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation" }); const imported = await importPptxFile(new File([importedBlob], "Начертание.pptx", { type: importedBlob.type })); expect(imported.slides[0]).toMatchObject({ titleBold: true, titleItalics: true, titleUnderline: true, titleStrike: true, subtitleBold: true, subtitleItalics: true, subtitleUnderline: true, subtitleStrike: true }); const exportedBlob = await exportPptxBlob({ title: "Начертание.pptx", slides: [ { id: "slide-1", title: "Заголовок", subtitle: "Подзаголовок", notes: "", theme: "classic", titleBold: false, titleItalics: true, titleUnderline: true, titleStrike: true, subtitleBold: true, subtitleItalics: true, subtitleUnderline: true, subtitleStrike: true } ] }); const exportedZip = await JSZip.loadAsync(await exportedBlob.arrayBuffer()); const exportedSlideXml = (await exportedZip.file("ppt/slides/slide1.xml")?.async("string")) ?? ""; expect(exportedSlideXml).toContain('i="1"'); expect(exportedSlideXml).toContain('u="sng"'); expect(exportedSlideXml).toContain('strike="sngStrike"'); expect(exportedSlideXml).toContain('b="1"'); const reimported = await importPptxFile(new File([exportedBlob], "Начертание.pptx", { type: exportedBlob.type })); expect(reimported.slides[0]).toMatchObject({ titleBold: false, titleItalics: true, titleUnderline: true, titleStrike: true, subtitleBold: true, subtitleItalics: true, subtitleUnderline: true, subtitleStrike: true }); }); it("импортирует внешние ссылки текста PPTX через hlinkClick relationships", async () => { const zip = new JSZip(); zip.file( "ppt/presentation.xml", `` ); zip.file( "ppt/_rels/presentation.xml.rels", `` ); zip.file("ppt/slides/slide1.xml", slideXmlWithTextHyperlinks("Roadmap", "Open report")); zip.file( "ppt/slides/_rels/slide1.xml.rels", `` ); const blob = await zip.generateAsync({ type: "blob", mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation" }); const imported = await importPptxFile(new File([blob], "Links.pptx", { type: blob.type })); expect(imported.slides[0]).toMatchObject({ title: "Roadmap", titleHyperlink: "https://example.com/title", subtitle: "Open report", subtitleHyperlink: "mailto:team@example.com" }); }); it("экспортирует внешние ссылки заголовка и подзаголовка PPTX", async () => { const blob = await exportPptxBlob({ title: "Links.pptx", slides: [ { id: "slide-1", title: "Roadmap", titleHyperlink: "https://example.com/title", subtitle: "Open report", subtitleHyperlink: "mailto:team@example.com", notes: "", theme: "classic" } ] }); 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 reimported = await importPptxFile(new File([blob], "Links.pptx", { type: blob.type })); expect(slideXml).toContain("hlinkClick"); expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"'); expect(relationshipsXml).toContain('Target="https://example.com/title"'); expect(relationshipsXml).toContain('Target="mailto:team@example.com"'); expect(reimported.slides[0]).toMatchObject({ titleHyperlink: "https://example.com/title", subtitleHyperlink: "mailto:team@example.com" }); }); it("нормализует пробелы в импортированном тексте", () => { expect(normalizeWhitespace(" Строка\tс \n пробелами ")).toBe("Строка с пробелами"); }); }); function slideXml(...texts: string[]) { return `${texts .map((text) => `${text}`) .join("")}`; } function slideXmlWithBackground(title: string, color: string) { return `${title}`; } function slideXmlWithShow(title: string, show: string) { return `${title}`; } function slideXmlWithTransition(title: string, transition: "fade" | "push" | "wipe") { return `${title}`; } function slideXmlWithTextStyles(title: string, subtitle: string) { return `${title}${subtitle}`; } function slideXmlWithTextRunStyles(title: string, subtitle: string) { return `${title}${subtitle}`; } function slideXmlWithTextHyperlinks(title: string, subtitle: string) { return `${title}${subtitle}`; } function slideXmlWithMultilineSubtitle(title: string, subtitleParagraphs: string[]) { return `${title}${subtitleParagraphs .map((text) => `${text}`) .join("")}`; } function slideXmlWithManualBreakSubtitle(title: string, subtitleLines: string[]) { return `${title}${subtitleLines .map((text, index) => `${index > 0 ? "" : ""}${text}`) .join("")}`; } function slideXmlWithListSubtitle(title: string, subtitleLines: string[], listStyle: "bullet" | "number") { const paragraphProperties = listStyle === "number" ? '' : ''; return `${title}${subtitleLines .map((text) => `${paragraphProperties}${text}`) .join("")}`; } function slideXmlWithPicture(title: string) { return `${title}`; } function slideXmlWithLinkedPicture(title: string) { return `${title}`; }