Add QPowerPoint slide size support
This commit is contained in:
+5
-1
@@ -53,11 +53,15 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState {
|
|||||||
state.word.id === initialState.word.id &&
|
state.word.id === initialState.word.id &&
|
||||||
sampleWordHtml.includes("Ключевой результат") &&
|
sampleWordHtml.includes("Ключевой результат") &&
|
||||||
!state.word.html.includes("Ключевой результат");
|
!state.word.html.includes("Ключевой результат");
|
||||||
|
const needsDeckSlideSize = !state.deck.slideSize;
|
||||||
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
if (needsSampleWordColor) {
|
if (needsSampleWordColor) {
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
if (needsDeckSlideSize) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
const messages = state.mail.messages.map((message) => {
|
const messages = state.mail.messages.map((message) => {
|
||||||
if (!launchChecklistAttachments || message.id !== "mail-1" || (message.attachments?.length ?? 0) > 0) {
|
if (!launchChecklistAttachments || message.id !== "mail-1" || (message.attachments?.length ?? 0) > 0) {
|
||||||
return message;
|
return message;
|
||||||
@@ -117,7 +121,7 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState {
|
|||||||
...state,
|
...state,
|
||||||
word: needsSampleWordColor ? { ...state.word, html: sampleWordHtml } : state.word,
|
word: needsSampleWordColor ? { ...state.word, html: sampleWordHtml } : state.word,
|
||||||
workbook: { ...state.workbook, sheets },
|
workbook: { ...state.workbook, sheets },
|
||||||
deck: { ...state.deck, slides },
|
deck: { ...state.deck, slideSize: state.deck.slideSize ?? initialState.deck.slideSize ?? "wide", slides },
|
||||||
mail: { ...state.mail, messages }
|
mail: { ...state.mail, messages }
|
||||||
}
|
}
|
||||||
: state;
|
: state;
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export const initialState: QOfficeState = {
|
|||||||
title: "Дорожная карта.qpptx",
|
title: "Дорожная карта.qpptx",
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
activeSlideId: "slide-1",
|
activeSlideId: "slide-1",
|
||||||
|
slideSize: "wide",
|
||||||
slides: [
|
slides: [
|
||||||
{
|
{
|
||||||
id: "slide-1",
|
id: "slide-1",
|
||||||
|
|||||||
@@ -55,6 +55,21 @@ describe("QPowerPoint", () => {
|
|||||||
expect(nextDeck.slides[0].images?.[0].hyperlink).toBe("https://example.com/updated-logo");
|
expect(nextDeck.slides[0].images?.[0].hyperlink).toBe("https://example.com/updated-logo");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("переключает размер слайда презентации", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const view = render(<QPowerPoint deck={deckWithLinkedImage()} onChange={onChange} />);
|
||||||
|
const slideSizeSelect = view.container.querySelector('select[aria-label="Размер слайда"]') as HTMLSelectElement;
|
||||||
|
const canvas = view.container.querySelector(".slide-canvas") as HTMLElement;
|
||||||
|
|
||||||
|
expect(slideSizeSelect).toHaveValue("wide");
|
||||||
|
expect(canvas.style.aspectRatio).toBe("13.333 / 7.5");
|
||||||
|
|
||||||
|
fireEvent.change(slideSizeSelect, { target: { value: "standard" } });
|
||||||
|
|
||||||
|
const nextDeck = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as SlideDeck;
|
||||||
|
expect(nextDeck.slideSize).toBe("standard");
|
||||||
|
});
|
||||||
|
|
||||||
it("переключает начертание текста слайда", () => {
|
it("переключает начертание текста слайда", () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const view = render(<QPowerPoint deck={deckWithLinkedImage()} onChange={onChange} />);
|
const view = render(<QPowerPoint deck={deckWithLinkedImage()} onChange={onChange} />);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { AlignCenter, AlignLeft, AlignRight, Bold, Copy, Download, EyeOff, FileText, FileUp, Image as ImageIcon, Italic, Link, List, ListOrdered, ListX, Palette, Plus, Save, Strikethrough, Trash2, Underline } from "lucide-react";
|
import { AlignCenter, AlignLeft, AlignRight, Bold, Copy, Download, EyeOff, FileText, FileUp, Image as ImageIcon, Italic, Link, List, ListOrdered, ListX, Palette, Plus, Save, Strikethrough, Trash2, Underline } from "lucide-react";
|
||||||
import type { Slide, SlideDeck, SlideImage, SlideListStyle, SlideTextAlign, SlideTransition } from "../../types";
|
import type { Slide, SlideDeck, SlideImage, SlideListStyle, SlideSizeId, SlideTextAlign, SlideTransition } from "../../types";
|
||||||
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
||||||
import { exportPptxBlob, importPptxFile } from "../../io/powerPointOffice";
|
import { exportPptxBlob, importPptxFile } from "../../io/powerPointOffice";
|
||||||
import { replaceFileExtension } from "../../io/fileHelpers";
|
import { replaceFileExtension } from "../../io/fileHelpers";
|
||||||
@@ -58,8 +58,20 @@ const slideTransitionOptions: Array<{ value: SlideTransition | ""; label: string
|
|||||||
{ value: "wipe", label: "Шторка" }
|
{ value: "wipe", label: "Шторка" }
|
||||||
];
|
];
|
||||||
|
|
||||||
const slideWidthInches = 13.333;
|
const slideSizeOptions: Array<{ id: SlideSizeId; label: string; width: number; height: number }> = [
|
||||||
const slideHeightInches = 7.5;
|
{ id: "wide", label: "Широкий 16:9", width: 13.333, height: 7.5 },
|
||||||
|
{ id: "standard", label: "Стандартный 4:3", width: 10, height: 7.5 }
|
||||||
|
];
|
||||||
|
|
||||||
|
type SlideDimensions = { width: number; height: number };
|
||||||
|
|
||||||
|
function normalizedSlideSizeId(value?: SlideSizeId): SlideSizeId {
|
||||||
|
return value === "standard" ? "standard" : "wide";
|
||||||
|
}
|
||||||
|
|
||||||
|
function slideDimensions(value?: SlideSizeId): SlideDimensions {
|
||||||
|
return slideSizeOptions.find((option) => option.id === normalizedSlideSizeId(value)) ?? slideSizeOptions[0];
|
||||||
|
}
|
||||||
|
|
||||||
function readFileAsDataUrl(file: File) {
|
function readFileAsDataUrl(file: File) {
|
||||||
return new Promise<string>((resolve, reject) => {
|
return new Promise<string>((resolve, reject) => {
|
||||||
@@ -99,18 +111,21 @@ function roundSlideNumber(value: number) {
|
|||||||
return Math.round(value * 10) / 10;
|
return Math.round(value * 10) / 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
function slideImageStyle(image: SlideImage) {
|
function slideImageStyle(image: SlideImage, dimensions: SlideDimensions) {
|
||||||
return {
|
return {
|
||||||
left: `${(image.x / slideWidthInches) * 100}%`,
|
left: `${(image.x / dimensions.width) * 100}%`,
|
||||||
top: `${(image.y / slideHeightInches) * 100}%`,
|
top: `${(image.y / dimensions.height) * 100}%`,
|
||||||
width: `${(image.width / slideWidthInches) * 100}%`,
|
width: `${(image.width / dimensions.width) * 100}%`,
|
||||||
height: `${(image.height / slideHeightInches) * 100}%`
|
height: `${(image.height / dimensions.height) * 100}%`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function slideCanvasStyle(slide: Slide) {
|
function slideCanvasStyle(slide: Slide, dimensions: SlideDimensions): CSSProperties {
|
||||||
const backgroundColor = normalizedHexColor(slide.backgroundColor);
|
const backgroundColor = normalizedHexColor(slide.backgroundColor);
|
||||||
return backgroundColor ? { background: `#${backgroundColor}` } : undefined;
|
return {
|
||||||
|
aspectRatio: `${dimensions.width} / ${dimensions.height}`,
|
||||||
|
...(backgroundColor ? { background: `#${backgroundColor}` } : {})
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function slideTextStyle(
|
function slideTextStyle(
|
||||||
@@ -167,6 +182,7 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
const activeSlide = deck.slides.find((slide) => slide.id === deck.activeSlideId) ?? deck.slides[0];
|
const activeSlide = deck.slides.find((slide) => slide.id === deck.activeSlideId) ?? deck.slides[0];
|
||||||
const activeImages = activeSlide.images ?? [];
|
const activeImages = activeSlide.images ?? [];
|
||||||
const selectedImage = activeImages.find((image) => image.id === selectedImageId);
|
const selectedImage = activeImages.find((image) => image.id === selectedImageId);
|
||||||
|
const activeSlideSize = slideDimensions(deck.slideSize);
|
||||||
|
|
||||||
function updateSlide(nextSlide: Slide) {
|
function updateSlide(nextSlide: Slide) {
|
||||||
onChange({
|
onChange({
|
||||||
@@ -266,6 +282,7 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
...deck,
|
...deck,
|
||||||
title: imported.title,
|
title: imported.title,
|
||||||
activeSlideId: imported.slides[0]?.id ?? "slide-1",
|
activeSlideId: imported.slides[0]?.id ?? "slide-1",
|
||||||
|
slideSize: imported.slideSize ?? deck.slideSize ?? "wide",
|
||||||
slides: imported.slides.length > 0 ? imported.slides : deck.slides
|
slides: imported.slides.length > 0 ? imported.slides : deck.slides
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -289,8 +306,8 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
id: `slide-image-${Date.now()}`,
|
id: `slide-image-${Date.now()}`,
|
||||||
src,
|
src,
|
||||||
alt: file.name,
|
alt: file.name,
|
||||||
x: roundSlideNumber(Math.max(0.4, slideWidthInches - size.width - 0.9)),
|
x: roundSlideNumber(Math.max(0.4, activeSlideSize.width - size.width - 0.9)),
|
||||||
y: roundSlideNumber(Math.max(1.4, slideHeightInches - size.height - 0.8)),
|
y: roundSlideNumber(Math.max(1.4, activeSlideSize.height - size.height - 0.8)),
|
||||||
...size
|
...size
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -466,6 +483,20 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="office-field">
|
||||||
|
<span>Размер</span>
|
||||||
|
<select
|
||||||
|
value={normalizedSlideSizeId(deck.slideSize)}
|
||||||
|
onChange={(event) => onChange({ ...deck, slideSize: event.target.value as SlideSizeId })}
|
||||||
|
aria-label="Размер слайда"
|
||||||
|
>
|
||||||
|
{slideSizeOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<div className="slide-background-swatches" aria-label="Фон слайда">
|
<div className="slide-background-swatches" aria-label="Фон слайда">
|
||||||
<Palette size={16} aria-hidden="true" />
|
<Palette size={16} aria-hidden="true" />
|
||||||
{backgroundColorSwatches.map((swatch) => (
|
{backgroundColorSwatches.map((swatch) => (
|
||||||
@@ -783,10 +814,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max={slideWidthInches}
|
max={activeSlideSize.width}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={selectedImage.x}
|
value={selectedImage.x}
|
||||||
onChange={(event) => updateSelectedImageNumber("x", event.target.value, slideWidthInches)}
|
onChange={(event) => updateSelectedImageNumber("x", event.target.value, activeSlideSize.width)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -794,10 +825,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max={slideHeightInches}
|
max={activeSlideSize.height}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={selectedImage.y}
|
value={selectedImage.y}
|
||||||
onChange={(event) => updateSelectedImageNumber("y", event.target.value, slideHeightInches)}
|
onChange={(event) => updateSelectedImageNumber("y", event.target.value, activeSlideSize.height)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -805,10 +836,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max={slideWidthInches}
|
max={activeSlideSize.width}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={selectedImage.width}
|
value={selectedImage.width}
|
||||||
onChange={(event) => updateSelectedImageNumber("width", event.target.value, slideWidthInches)}
|
onChange={(event) => updateSelectedImageNumber("width", event.target.value, activeSlideSize.width)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -816,10 +847,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max={slideHeightInches}
|
max={activeSlideSize.height}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={selectedImage.height}
|
value={selectedImage.height}
|
||||||
onChange={(event) => updateSelectedImageNumber("height", event.target.value, slideHeightInches)}
|
onChange={(event) => updateSelectedImageNumber("height", event.target.value, activeSlideSize.height)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -854,13 +885,13 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`slide-canvas theme-${activeSlide.theme}`} style={slideCanvasStyle(activeSlide)}>
|
<div className={`slide-canvas theme-${activeSlide.theme}`} style={slideCanvasStyle(activeSlide, activeSlideSize)}>
|
||||||
{activeImages.map((image) => (
|
{activeImages.map((image) => (
|
||||||
<button
|
<button
|
||||||
key={image.id}
|
key={image.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={`slide-image-object ${image.id === selectedImageId ? "is-selected" : ""}`}
|
className={`slide-image-object ${image.id === selectedImageId ? "is-selected" : ""}`}
|
||||||
style={slideImageStyle(image)}
|
style={slideImageStyle(image, activeSlideSize)}
|
||||||
onClick={() => setSelectedImageId(image.id)}
|
onClick={() => setSelectedImageId(image.id)}
|
||||||
title={image.alt || "Изображение"}
|
title={image.alt || "Изображение"}
|
||||||
aria-label={image.alt || "Изображение слайда"}
|
aria-label={image.alt || "Изображение слайда"}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
listSlidePaths,
|
listSlidePaths,
|
||||||
normalizeWhitespace,
|
normalizeWhitespace,
|
||||||
presentationSlidePaths,
|
presentationSlidePaths,
|
||||||
|
presentationSlideSize,
|
||||||
relationshipTargetByType,
|
relationshipTargetByType,
|
||||||
resolvePptTarget
|
resolvePptTarget
|
||||||
} from "./powerPointOffice";
|
} 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 заметок от части слайда", () => {
|
it("разрешает относительные targets заметок от части слайда", () => {
|
||||||
expect(resolvePptTarget("ppt/slides", "../notesSlides/notesSlide7.xml")).toBe("ppt/notesSlides/notesSlide7.xml");
|
expect(resolvePptTarget("ppt/slides", "../notesSlides/notesSlide7.xml")).toBe("ppt/notesSlides/notesSlide7.xml");
|
||||||
expect(
|
expect(
|
||||||
@@ -104,7 +129,7 @@ describe("powerPointOffice helpers", () => {
|
|||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
zip.file(
|
zip.file(
|
||||||
"ppt/presentation.xml",
|
"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(
|
zip.file(
|
||||||
"ppt/_rels/presentation.xml.rels",
|
"ppt/_rels/presentation.xml.rels",
|
||||||
@@ -125,6 +150,7 @@ describe("powerPointOffice helpers", () => {
|
|||||||
const imported = await importPptxFile(new File([blob], "Дорожная карта.pptx", { type: blob.type }));
|
const imported = await importPptxFile(new File([blob], "Дорожная карта.pptx", { type: blob.type }));
|
||||||
|
|
||||||
expect(imported.slides.map((slide) => slide.title)).toEqual(["Первый по порядку", "Первый файл"]);
|
expect(imported.slides.map((slide) => slide.title)).toEqual(["Первый по порядку", "Первый файл"]);
|
||||||
|
expect(imported.slideSize).toBe("standard");
|
||||||
expect(imported.slides[0].subtitle).toBe("Содержимое первого слайда");
|
expect(imported.slides[0].subtitle).toBe("Содержимое первого слайда");
|
||||||
expect(imported.slides[0].notes).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("нормализует пробелы в импортированном тексте", () => {
|
it("нормализует пробелы в импортированном тексте", () => {
|
||||||
expect(normalizeWhitespace(" Строка\tс \n пробелами ")).toBe("Строка с пробелами");
|
expect(normalizeWhitespace(" Строка\tс \n пробелами ")).toBe("Строка с пробелами");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
import { officeTitleFromFileName, PPTX_MIME_TYPE, readOfficeFileAsArrayBuffer } from "./fileHelpers";
|
||||||
|
|
||||||
export interface ImportedPowerPointDeck {
|
export interface ImportedPowerPointDeck {
|
||||||
title: string;
|
title: string;
|
||||||
|
slideSize?: SlideSizeId;
|
||||||
slides: Slide[];
|
slides: Slide[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportablePowerPointDeck {
|
export interface ExportablePowerPointDeck {
|
||||||
title: string;
|
title: string;
|
||||||
|
slideSize?: SlideSizeId;
|
||||||
slides: Slide[];
|
slides: Slide[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +49,10 @@ const imageRelationshipType = "http://schemas.openxmlformats.org/officeDocument/
|
|||||||
const hyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
|
const hyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
|
||||||
const emuPerInch = 914400;
|
const emuPerInch = 914400;
|
||||||
const slideTransitions = ["fade", "push", "wipe"] as const;
|
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 }> = {
|
const themeColors: Record<Slide["theme"], { background: string; title: string; subtitle: string }> = {
|
||||||
classic: { background: "FFFFFF", title: "202124", subtitle: "3C4043" },
|
classic: { background: "FFFFFF", title: "202124", subtitle: "3C4043" },
|
||||||
ocean: { background: "EAF6FF", title: "075985", subtitle: "0F766E" },
|
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 presentationXml = await readOptionalParsedXml(zip, parser, "ppt/presentation.xml");
|
||||||
const presentationRelationshipsXml = await readOptionalParsedXml(zip, parser, "ppt/_rels/presentation.xml.rels");
|
const presentationRelationshipsXml = await readOptionalParsedXml(zip, parser, "ppt/_rels/presentation.xml.rels");
|
||||||
const slidePaths = listSlidePaths(zip, presentationXml, presentationRelationshipsXml);
|
const slidePaths = listSlidePaths(zip, presentationXml, presentationRelationshipsXml);
|
||||||
|
const slideSize = presentationSlideSize(presentationXml);
|
||||||
|
|
||||||
if (slidePaths.length === 0) {
|
if (slidePaths.length === 0) {
|
||||||
throw new Error("В файле PowerPoint не найдены слайды.");
|
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> {
|
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>;
|
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.author = "QOffice";
|
||||||
pptx.company = "QOffice";
|
pptx.company = "QOffice";
|
||||||
pptx.subject = "Экспорт QPowerPoint";
|
pptx.subject = "Экспорт QPowerPoint";
|
||||||
@@ -196,7 +205,7 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
|
|||||||
slide.addText(sourceSlide.title || "Без заголовка", {
|
slide.addText(sourceSlide.title || "Без заголовка", {
|
||||||
x: 0.7,
|
x: 0.7,
|
||||||
y: 0.65,
|
y: 0.65,
|
||||||
w: 11.9,
|
w: Math.max(1, slideLayout.width - 1.4),
|
||||||
h: 0.75,
|
h: 0.75,
|
||||||
fontFace: titleFontFamily,
|
fontFace: titleFontFamily,
|
||||||
fontSize: titleFontSize,
|
fontSize: titleFontSize,
|
||||||
@@ -213,8 +222,8 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
|
|||||||
slide.addText(sourceSlide.subtitle || "", {
|
slide.addText(sourceSlide.subtitle || "", {
|
||||||
x: 0.75,
|
x: 0.75,
|
||||||
y: 1.65,
|
y: 1.65,
|
||||||
w: 11.5,
|
w: Math.max(1, slideLayout.width - 1.5),
|
||||||
h: 4.35,
|
h: Math.max(1, slideLayout.height - 3.15),
|
||||||
fontFace: subtitleFontFamily,
|
fontFace: subtitleFontFamily,
|
||||||
fontSize: subtitleFontSize,
|
fontSize: subtitleFontSize,
|
||||||
...(subtitleAlign ? { align: subtitleAlign } : {}),
|
...(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));
|
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> {
|
export function relationshipTargetsById(relationshipsXml: unknown, type?: string, sourcePartPath = "ppt/presentation.xml"): Record<string, string> {
|
||||||
const relationships = childRecord(relationshipsXml, "Relationships");
|
const relationships = childRecord(relationshipsXml, "Relationships");
|
||||||
const targets: Record<string, string> = {};
|
const targets: Record<string, string> = {};
|
||||||
@@ -467,6 +489,10 @@ export function normalizeWhitespace(value: string): string {
|
|||||||
return value.replace(/\s+/g, " ").trim();
|
return value.replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedSlideSizeId(value?: SlideSizeId): SlideSizeId {
|
||||||
|
return value === "standard" ? "standard" : "wide";
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTextWithLineBreaks(value: string): string {
|
function normalizeTextWithLineBreaks(value: string): string {
|
||||||
return value
|
return value
|
||||||
.split("\n")
|
.split("\n")
|
||||||
|
|||||||
@@ -97,11 +97,14 @@ export interface SheetChart {
|
|||||||
valueRange: string;
|
valueRange: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SlideSizeId = "wide" | "standard";
|
||||||
|
|
||||||
export interface SlideDeck {
|
export interface SlideDeck {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
activeSlideId: string;
|
activeSlideId: string;
|
||||||
|
slideSize?: SlideSizeId;
|
||||||
slides: Slide[];
|
slides: Slide[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user