Preserve QPowerPoint text alignment in PPTX

This commit is contained in:
Курнат Андрей
2026-06-02 07:54:06 +03:00
parent b117e11431
commit f5ab0448c7
6 changed files with 182 additions and 8 deletions
@@ -69,4 +69,17 @@ describe("QPowerPoint", () => {
expect(onChange).toHaveBeenCalledTimes(1);
expect(nextDeck.slides[0].titleBold).toBe(false);
});
it("переключает выравнивание текста слайда", () => {
const onChange = vi.fn();
const view = render(<QPowerPoint deck={deckWithLinkedImage()} onChange={onChange} />);
const centerTitleButton = view.container.querySelector('button[aria-label="Заголовок по центру"]');
expect(centerTitleButton).toBeTruthy();
fireEvent.click(centerTitleButton as Element);
const nextDeck = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as SlideDeck;
expect(onChange).toHaveBeenCalledTimes(1);
expect(nextDeck.slides[0].titleAlign).toBe("center");
});
});
+71 -5
View File
@@ -1,7 +1,7 @@
import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import { 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, SlideTransition } from "../../types";
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 { downloadBlobFile, downloadTextFile } from "../../utils/download";
import { exportPptxBlob, importPptxFile } from "../../io/powerPointOffice";
import { replaceFileExtension } from "../../io/fileHelpers";
@@ -23,6 +23,7 @@ type SlideTextFormatField =
| "subtitleItalics"
| "subtitleUnderline"
| "subtitleStrike";
type SlideTextAlignField = "titleAlign" | "subtitleAlign";
const themeLabels: Record<Slide["theme"], string> = {
classic: "Классическая",
@@ -116,7 +117,7 @@ function slideTextStyle(
color?: string,
fontSize?: number,
fontFamily?: string,
textFormat: { bold?: boolean; italics?: boolean; underline?: boolean; strike?: boolean; defaultBold?: boolean } = {}
textFormat: { bold?: boolean; italics?: boolean; underline?: boolean; strike?: boolean; align?: SlideTextAlign; defaultBold?: boolean } = {}
): CSSProperties | undefined {
const textColor = normalizedHexColor(color);
const normalizedFontSize = normalizeFontSize(fontSize);
@@ -129,7 +130,8 @@ function slideTextStyle(
...(normalizedFamily ? { fontFamily: normalizedFamily } : {}),
...(effectiveBold !== undefined ? { fontWeight: effectiveBold ? 780 : 400 } : {}),
...(textFormat.italics ? { fontStyle: "italic" } : {}),
...(textDecoration ? { textDecoration } : {})
...(textDecoration ? { textDecoration } : {}),
...(textFormat.align ? { textAlign: textFormat.align } : {})
};
return Object.keys(style).length > 0 ? style : undefined;
@@ -178,6 +180,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
updateSlide({ ...activeSlide, [field]: !enabled });
}
function setSlideTextAlign(field: SlideTextAlignField, align: SlideTextAlign) {
updateSlide({ ...activeSlide, [field]: align });
}
function updateImages(images: SlideImage[]) {
updateSlide({ ...activeSlide, images });
}
@@ -560,6 +566,35 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
<Strikethrough size={16} />
</button>
</div>
<div className="cell-format-tools slide-format-tools" aria-label="Выравнивание заголовка">
<button
className={`icon-button format-toggle ${(activeSlide.titleAlign ?? "left") === "left" ? "is-active" : ""}`.trim()}
type="button"
onClick={() => setSlideTextAlign("titleAlign", "left")}
title="Заголовок по левому краю"
aria-label="Заголовок по левому краю"
>
<AlignLeft size={16} />
</button>
<button
className={`icon-button format-toggle ${activeSlide.titleAlign === "center" ? "is-active" : ""}`.trim()}
type="button"
onClick={() => setSlideTextAlign("titleAlign", "center")}
title="Заголовок по центру"
aria-label="Заголовок по центру"
>
<AlignCenter size={16} />
</button>
<button
className={`icon-button format-toggle ${activeSlide.titleAlign === "right" ? "is-active" : ""}`.trim()}
type="button"
onClick={() => setSlideTextAlign("titleAlign", "right")}
title="Заголовок по правому краю"
aria-label="Заголовок по правому краю"
>
<AlignRight size={16} />
</button>
</div>
<label className="office-field office-field-wide">
<span>
<Link size={14} />
@@ -654,6 +689,35 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
<Strikethrough size={16} />
</button>
</div>
<div className="cell-format-tools slide-format-tools" aria-label="Выравнивание подзаголовка">
<button
className={`icon-button format-toggle ${(activeSlide.subtitleAlign ?? "left") === "left" ? "is-active" : ""}`.trim()}
type="button"
onClick={() => setSlideTextAlign("subtitleAlign", "left")}
title="Подзаголовок по левому краю"
aria-label="Подзаголовок по левому краю"
>
<AlignLeft size={16} />
</button>
<button
className={`icon-button format-toggle ${activeSlide.subtitleAlign === "center" ? "is-active" : ""}`.trim()}
type="button"
onClick={() => setSlideTextAlign("subtitleAlign", "center")}
title="Подзаголовок по центру"
aria-label="Подзаголовок по центру"
>
<AlignCenter size={16} />
</button>
<button
className={`icon-button format-toggle ${activeSlide.subtitleAlign === "right" ? "is-active" : ""}`.trim()}
type="button"
onClick={() => setSlideTextAlign("subtitleAlign", "right")}
title="Подзаголовок по правому краю"
aria-label="Подзаголовок по правому краю"
>
<AlignRight size={16} />
</button>
</div>
<label className="office-field office-field-wide">
<span>
<Link size={14} />
@@ -813,6 +877,7 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
italics: activeSlide.titleItalics,
underline: activeSlide.titleUnderline,
strike: activeSlide.titleStrike,
align: activeSlide.titleAlign,
defaultBold: true
})}
onChange={(event) => updateSlide({ ...activeSlide, title: event.target.value })}
@@ -825,7 +890,8 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
bold: activeSlide.subtitleBold,
italics: activeSlide.subtitleItalics,
underline: activeSlide.subtitleUnderline,
strike: activeSlide.subtitleStrike
strike: activeSlide.subtitleStrike,
align: activeSlide.subtitleAlign
})}
onChange={(event) => updateSlide({ ...activeSlide, subtitle: event.target.value })}
/>
+54
View File
@@ -662,6 +662,56 @@ describe("powerPointOffice helpers", () => {
});
});
it("импортирует и экспортирует выравнивание текста слайда PPTX", 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", slideXmlWithTextAlignment("Заголовок", "Подзаголовок"));
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({
titleAlign: "center",
subtitleAlign: "right"
});
const exportedBlob = await exportPptxBlob({
title: "Выравнивание.pptx",
slides: [
{
id: "slide-1",
title: "Заголовок",
subtitle: "Подзаголовок",
notes: "",
theme: "classic",
titleAlign: "center",
subtitleAlign: "right"
}
]
});
const exportedZip = await JSZip.loadAsync(await exportedBlob.arrayBuffer());
const exportedSlideXml = (await exportedZip.file("ppt/slides/slide1.xml")?.async("string")) ?? "";
expect(exportedSlideXml).toContain('algn="ctr"');
expect(exportedSlideXml).toContain('algn="r"');
const reimported = await importPptxFile(new File([exportedBlob], "Выравнивание.pptx", { type: exportedBlob.type }));
expect(reimported.slides[0]).toMatchObject({
titleAlign: "center",
subtitleAlign: "right"
});
});
it("импортирует внешние ссылки текста PPTX через hlinkClick relationships", async () => {
const zip = new JSZip();
zip.file(
@@ -753,6 +803,10 @@ function slideXmlWithTextRunStyles(title: string, subtitle: 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><p:sp><p:txBody><a:p><a:r><a:rPr b="1" i="1" u="sng" strike="sngStrike"/><a:t>${title}</a:t></a:r></a:p></p:txBody></p:sp><p:sp><p:txBody><a:p><a:r><a:rPr b="1" i="1" u="sng" strike="sngStrike"/><a:t>${subtitle}</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>`;
}
function slideXmlWithTextAlignment(title: string, subtitle: 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><p:sp><p:txBody><a:p><a:pPr algn="ctr"/><a:r><a:t>${title}</a:t></a:r></a:p></p:txBody></p:sp><p:sp><p:txBody><a:p><a:pPr algn="r"/><a:r><a:t>${subtitle}</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>`;
}
function slideXmlWithTextHyperlinks(title: string, subtitle: 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:rPr><a:hlinkClick r:id="rIdTitleLink"/></a:rPr><a:t>${title}</a:t></a:r></a:p></p:txBody></p:sp><p:sp><p:txBody><a:p><a:r><a:rPr><a:hlinkClick r:id="rIdSubtitleLink"/></a:rPr><a:t>${subtitle}</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>`;
}
+39 -1
View File
@@ -1,4 +1,4 @@
import type { Slide, SlideImage, SlideListStyle } from "../types";
import type { Slide, SlideImage, SlideListStyle, SlideTextAlign } from "../types";
import { officeTitleFromFileName, PPTX_MIME_TYPE, readOfficeFileAsArrayBuffer } from "./fileHelpers";
export interface ImportedPowerPointDeck {
@@ -19,6 +19,7 @@ interface SlideTextBlock {
italics?: boolean;
underline?: boolean;
strike?: boolean;
align?: SlideTextAlign;
color?: string;
fontSize?: number;
fontFamily?: string;
@@ -107,6 +108,7 @@ export async function importPptxFile(file: File): Promise<ImportedPowerPointDeck
...(slideTextBlocks[0]?.italics ? { titleItalics: true } : {}),
...(slideTextBlocks[0]?.underline ? { titleUnderline: true } : {}),
...(slideTextBlocks[0]?.strike ? { titleStrike: true } : {}),
...(slideTextBlocks[0]?.align ? { titleAlign: slideTextBlocks[0].align } : {}),
...(slideTextBlocks[0]?.color ? { titleColor: slideTextBlocks[0].color } : {}),
...(slideTextBlocks[0]?.fontSize ? { titleFontSize: slideTextBlocks[0].fontSize } : {}),
...(slideTextBlocks[0]?.fontFamily ? { titleFontFamily: slideTextBlocks[0].fontFamily } : {}),
@@ -114,6 +116,7 @@ export async function importPptxFile(file: File): Promise<ImportedPowerPointDeck
...(slideTextBlocks[1]?.italics ? { subtitleItalics: true } : {}),
...(slideTextBlocks[1]?.underline ? { subtitleUnderline: true } : {}),
...(slideTextBlocks[1]?.strike ? { subtitleStrike: true } : {}),
...(slideTextBlocks[1]?.align ? { subtitleAlign: slideTextBlocks[1].align } : {}),
...(slideTextBlocks[1]?.color ? { subtitleColor: slideTextBlocks[1].color } : {}),
...(slideTextBlocks[1]?.fontSize ? { subtitleFontSize: slideTextBlocks[1].fontSize } : {}),
...(slideTextBlocks[1]?.fontFamily ? { subtitleFontFamily: slideTextBlocks[1].fontFamily } : {}),
@@ -163,10 +166,12 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
const titleItalics = Boolean(sourceSlide.titleItalics);
const titleUnderline = Boolean(sourceSlide.titleUnderline);
const titleStrike = Boolean(sourceSlide.titleStrike);
const titleAlign = normalizedSlideTextAlign(sourceSlide.titleAlign);
const subtitleBold = Boolean(sourceSlide.subtitleBold);
const subtitleItalics = Boolean(sourceSlide.subtitleItalics);
const subtitleUnderline = Boolean(sourceSlide.subtitleUnderline);
const subtitleStrike = Boolean(sourceSlide.subtitleStrike);
const subtitleAlign = normalizedSlideTextAlign(sourceSlide.subtitleAlign);
const titleHyperlink = normalizedExternalHyperlinkTarget(sourceSlide.titleHyperlink);
const subtitleHyperlink = normalizedExternalHyperlinkTarget(sourceSlide.subtitleHyperlink);
const slide = pptx.addSlide();
@@ -195,6 +200,7 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
h: 0.75,
fontFace: titleFontFamily,
fontSize: titleFontSize,
...(titleAlign ? { align: titleAlign } : {}),
bold: titleBold,
italic: titleItalics,
...(titleUnderline ? { underline: { style: "sng" } } : {}),
@@ -211,6 +217,7 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
h: 4.35,
fontFace: subtitleFontFamily,
fontSize: subtitleFontSize,
...(subtitleAlign ? { align: subtitleAlign } : {}),
bold: subtitleBold,
italic: subtitleItalics,
...(subtitleUnderline ? { underline: { style: "sng" } } : {}),
@@ -256,6 +263,7 @@ function extractSlideTextBlocks(slideXml: unknown, hyperlinkTargets: Record<stri
const italics = runTextItalics(runProperties);
const underline = runTextUnderline(runProperties);
const strike = runTextStrike(runProperties);
const align = textBodyAlign(textBody);
const color = runTextColor(runProperties);
const fontSize = runTextFontSize(runProperties);
const fontFamily = runTextFontFamily(runProperties);
@@ -267,6 +275,7 @@ function extractSlideTextBlocks(slideXml: unknown, hyperlinkTargets: Record<stri
italics,
underline,
strike,
...(align ? { align } : {}),
...(color ? { color } : {}),
...(fontSize ? { fontSize } : {}),
...(fontFamily ? { fontFamily } : {})
@@ -318,6 +327,15 @@ function textBodyListStyle(textBody: Record<string, unknown>): SlideListStyle |
return paragraphStyles.includes("number") ? "number" : paragraphStyles[0];
}
function textBodyAlign(textBody: Record<string, unknown>): SlideTextAlign | undefined {
return toArray(textBody.p).map(paragraphAlign).find((align): align is SlideTextAlign => Boolean(align));
}
function paragraphAlign(paragraph: unknown): SlideTextAlign | undefined {
const paragraphProperties = childRecord(asRecord(paragraph), "pPr");
return slideTextAlignFromXml(stringValue(paragraphProperties.algn ?? paragraphProperties["@_algn"]));
}
function paragraphListStyle(paragraph: unknown): SlideListStyle | undefined {
const paragraphProperties = childRecord(asRecord(paragraph), "pPr");
if (Object.keys(childRecord(paragraphProperties, "buAutoNum")).length > 0) {
@@ -746,6 +764,26 @@ function normalizeFontFamily(value?: string) {
return normalized && normalized.toLowerCase() !== "arial" ? normalized : "";
}
function normalizedSlideTextAlign(value?: string) {
return value === "left" || value === "center" || value === "right" ? value : undefined;
}
function slideTextAlignFromXml(value: string): SlideTextAlign | undefined {
switch (value.trim().toLowerCase()) {
case "l":
case "left":
return "left";
case "ctr":
case "center":
return "center";
case "r":
case "right":
return "right";
default:
return undefined;
}
}
function numberAttribute(record: Record<string, unknown>, name: string) {
const value = Number.parseFloat(stringValue(record[name] ?? record[`@_${name}`]));
return Number.isFinite(value) ? value : undefined;
+3
View File
@@ -100,6 +100,7 @@ export interface Slide {
titleItalics?: boolean;
titleUnderline?: boolean;
titleStrike?: boolean;
titleAlign?: SlideTextAlign;
titleColor?: string;
titleFontSize?: number;
titleFontFamily?: string;
@@ -107,6 +108,7 @@ export interface Slide {
subtitleItalics?: boolean;
subtitleUnderline?: boolean;
subtitleStrike?: boolean;
subtitleAlign?: SlideTextAlign;
subtitleColor?: string;
subtitleFontSize?: number;
subtitleFontFamily?: string;
@@ -114,6 +116,7 @@ export interface Slide {
}
export type SlideListStyle = "bullet" | "number";
export type SlideTextAlign = "left" | "center" | "right";
export type SlideTransition = "fade" | "push" | "wipe";
export interface SlideImage {