Add QPowerPoint slide size support

This commit is contained in:
Курнат Андрей
2026-06-04 22:24:33 +03:00
parent 5dfe97013d
commit 5487d04cca
7 changed files with 158 additions and 30 deletions
+49 -1
View File
@@ -7,6 +7,7 @@ import {
listSlidePaths,
normalizeWhitespace,
presentationSlidePaths,
presentationSlideSize,
relationshipTargetByType,
resolvePptTarget
} from "./powerPointOffice";
@@ -81,6 +82,30 @@ describe("powerPointOffice helpers", () => {
]);
});
it("читает размер слайда из presentation.xml", () => {
expect(
presentationSlideSize({
presentation: {
sldSz: { cx: "12192000", cy: "6858000" }
}
})
).toBe("wide");
expect(
presentationSlideSize({
presentation: {
sldSz: { "@_cx": "9144000", "@_cy": "6858000" }
}
})
).toBe("standard");
expect(
presentationSlideSize({
presentation: {
sldSz: { cx: "9144000", cy: "5143500" }
}
})
).toBeUndefined();
});
it("разрешает относительные targets заметок от части слайда", () => {
expect(resolvePptTarget("ppt/slides", "../notesSlides/notesSlide7.xml")).toBe("ppt/notesSlides/notesSlide7.xml");
expect(
@@ -104,7 +129,7 @@ describe("powerPointOffice helpers", () => {
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>`
`<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldSz cx="9144000" cy="6858000"/><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",
@@ -125,6 +150,7 @@ describe("powerPointOffice helpers", () => {
const imported = await importPptxFile(new File([blob], "Дорожная карта.pptx", { type: blob.type }));
expect(imported.slides.map((slide) => slide.title)).toEqual(["Первый по порядку", "Первый файл"]);
expect(imported.slideSize).toBe("standard");
expect(imported.slides[0].subtitle).toBe("Содержимое первого слайда");
expect(imported.slides[0].notes).toBe("Заметка докладчика");
});
@@ -772,6 +798,28 @@ describe("powerPointOffice helpers", () => {
});
});
it("экспортирует и переимпортирует стандартный размер слайда PPTX 4:3", async () => {
const blob = await exportPptxBlob({
title: "Standard.pptx",
slideSize: "standard",
slides: [
{
id: "slide-1",
title: "Standard",
subtitle: "4:3",
notes: "",
theme: "classic"
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const presentationXml = (await zip.file("ppt/presentation.xml")?.async("string")) ?? "";
const reimported = await importPptxFile(new File([blob], "Standard.pptx", { type: blob.type }));
expect(presentationXml).toContain('<p:sldSz cx="9144000" cy="6858000"/>');
expect(reimported.slideSize).toBe("standard");
});
it("нормализует пробелы в импортированном тексте", () => {
expect(normalizeWhitespace(" Строка\tс \n пробелами ")).toBe("Строка с пробелами");
});
+32 -6
View File
@@ -1,13 +1,15 @@
import type { Slide, SlideImage, SlideListStyle, SlideTextAlign } from "../types";
import type { Slide, SlideImage, SlideListStyle, SlideSizeId, SlideTextAlign } from "../types";
import { officeTitleFromFileName, PPTX_MIME_TYPE, readOfficeFileAsArrayBuffer } from "./fileHelpers";
export interface ImportedPowerPointDeck {
title: string;
slideSize?: SlideSizeId;
slides: Slide[];
}
export interface ExportablePowerPointDeck {
title: string;
slideSize?: SlideSizeId;
slides: Slide[];
}
@@ -47,6 +49,10 @@ const imageRelationshipType = "http://schemas.openxmlformats.org/officeDocument/
const hyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
const emuPerInch = 914400;
const slideTransitions = ["fade", "push", "wipe"] as const;
const slideSizeLayouts: Record<SlideSizeId, { width: number; height: number; cx: number; cy: number; pptxLayout: string }> = {
wide: { width: 13.333, height: 7.5, cx: 12192000, cy: 6858000, pptxLayout: "LAYOUT_WIDE" },
standard: { width: 10, height: 7.5, cx: 9144000, cy: 6858000, pptxLayout: "LAYOUT_4x3" }
};
const themeColors: Record<Slide["theme"], { background: string; title: string; subtitle: string }> = {
classic: { background: "FFFFFF", title: "202124", subtitle: "3C4043" },
ocean: { background: "EAF6FF", title: "075985", subtitle: "0F766E" },
@@ -65,6 +71,7 @@ export async function importPptxFile(file: File): Promise<ImportedPowerPointDeck
const presentationXml = await readOptionalParsedXml(zip, parser, "ppt/presentation.xml");
const presentationRelationshipsXml = await readOptionalParsedXml(zip, parser, "ppt/_rels/presentation.xml.rels");
const slidePaths = listSlidePaths(zip, presentationXml, presentationRelationshipsXml);
const slideSize = presentationSlideSize(presentationXml);
if (slidePaths.length === 0) {
throw new Error("В файле PowerPoint не найдены слайды.");
@@ -126,7 +133,7 @@ export async function importPptxFile(file: File): Promise<ImportedPowerPointDeck
})
);
return { title, slides };
return { title, ...(slideSize ? { slideSize } : {}), slides };
}
export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Blob> {
@@ -147,8 +154,10 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
};
write: (options: { outputType: "blob" | "arraybuffer" }) => Promise<Blob | ArrayBuffer>;
};
const slideSize = normalizedSlideSizeId(deck.slideSize);
const slideLayout = slideSizeLayouts[slideSize];
pptx.layout = "LAYOUT_WIDE";
pptx.layout = slideLayout.pptxLayout;
pptx.author = "QOffice";
pptx.company = "QOffice";
pptx.subject = "Экспорт QPowerPoint";
@@ -196,7 +205,7 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
slide.addText(sourceSlide.title || "Без заголовка", {
x: 0.7,
y: 0.65,
w: 11.9,
w: Math.max(1, slideLayout.width - 1.4),
h: 0.75,
fontFace: titleFontFamily,
fontSize: titleFontSize,
@@ -213,8 +222,8 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
slide.addText(sourceSlide.subtitle || "", {
x: 0.75,
y: 1.65,
w: 11.5,
h: 4.35,
w: Math.max(1, slideLayout.width - 1.5),
h: Math.max(1, slideLayout.height - 3.15),
fontFace: subtitleFontFamily,
fontSize: subtitleFontSize,
...(subtitleAlign ? { align: subtitleAlign } : {}),
@@ -411,6 +420,19 @@ export function presentationSlidePaths(presentationXml: unknown, presentationRel
return slideIds.map((id) => relationshipTargets[id]).filter((target): target is string => Boolean(target));
}
export function presentationSlideSize(presentationXml: unknown): SlideSizeId | undefined {
const slideSize = childRecord(childRecord(presentationXml, "presentation"), "sldSz");
const cx = numberAttribute(slideSize, "cx");
const cy = numberAttribute(slideSize, "cy");
if (!cx || !cy) {
return undefined;
}
return Object.entries(slideSizeLayouts).find(([, layout]) => Math.abs(cx - layout.cx) <= 5000 && Math.abs(cy - layout.cy) <= 5000)?.[0] as
| SlideSizeId
| undefined;
}
export function relationshipTargetsById(relationshipsXml: unknown, type?: string, sourcePartPath = "ppt/presentation.xml"): Record<string, string> {
const relationships = childRecord(relationshipsXml, "Relationships");
const targets: Record<string, string> = {};
@@ -467,6 +489,10 @@ export function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function normalizedSlideSizeId(value?: SlideSizeId): SlideSizeId {
return value === "standard" ? "standard" : "wide";
}
function normalizeTextWithLineBreaks(value: string): string {
return value
.split("\n")