Initial QOffice implementation
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
import type { Slide, SlideImage } from "../types";
|
||||
import { officeTitleFromFileName, PPTX_MIME_TYPE, readOfficeFileAsArrayBuffer } from "./fileHelpers";
|
||||
|
||||
export interface ImportedPowerPointDeck {
|
||||
title: string;
|
||||
slides: Slide[];
|
||||
}
|
||||
|
||||
export interface ExportablePowerPointDeck {
|
||||
title: string;
|
||||
slides: Slide[];
|
||||
}
|
||||
|
||||
type XmlParserConstructor = new (options?: Record<string, unknown>) => { parse: (xml: string) => unknown };
|
||||
type ZipEntry = {
|
||||
async(type: "string"): Promise<string>;
|
||||
async(type: "base64"): Promise<string>;
|
||||
};
|
||||
type ZipArchive = { files?: Record<string, unknown>; file: (path: string) => ZipEntry | null };
|
||||
|
||||
const slidePathPattern = /^ppt\/slides\/slide(\d+)\.xml$/;
|
||||
const slideRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide";
|
||||
const notesRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide";
|
||||
const imageRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
|
||||
const emuPerInch = 914400;
|
||||
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" },
|
||||
graphite: { background: "1F2937", title: "F9FAFB", subtitle: "CBD5E1" }
|
||||
};
|
||||
|
||||
export async function importPptxFile(file: File): Promise<ImportedPowerPointDeck> {
|
||||
if (!file.name.toLowerCase().endsWith(".pptx")) {
|
||||
throw new Error("QPowerPoint импортирует только файлы Microsoft PowerPoint .pptx.");
|
||||
}
|
||||
|
||||
const zip = await loadZip(file);
|
||||
const parser = await createXmlParser();
|
||||
const title = officeTitleFromFileName(file.name, "Презентация QPowerPoint.pptx");
|
||||
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);
|
||||
|
||||
if (slidePaths.length === 0) {
|
||||
throw new Error("В файле PowerPoint не найдены слайды.");
|
||||
}
|
||||
|
||||
const slides = await Promise.all(
|
||||
slidePaths.map(async ({ path, index }) => {
|
||||
const slideXml = await readRequiredZipText(zip, path, "Не удалось прочитать слайд PowerPoint.");
|
||||
const parsedSlide = parser.parse(slideXml);
|
||||
const slideTextBlocks = extractTextBlocks(parsedSlide);
|
||||
const images = await extractSlideImages(zip, parser, parsedSlide, path, index);
|
||||
const notesPath = await slideNotesPath(zip, parser, path);
|
||||
const notesXml = notesPath ? await readOptionalZipText(zip, notesPath) : await readOptionalZipText(zip, `ppt/notesSlides/notesSlide${index}.xml`);
|
||||
const notes = notesXml ? extractTextBlocks(parser.parse(notesXml)).join("\n") : "";
|
||||
|
||||
return {
|
||||
id: `pptx-slide-${index}`,
|
||||
title: slideTextBlocks[0] || `Слайд ${index}`,
|
||||
subtitle: slideTextBlocks.slice(1).join("\n"),
|
||||
notes,
|
||||
theme: "classic" as const,
|
||||
images
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return { title, slides };
|
||||
}
|
||||
|
||||
export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Blob> {
|
||||
const moduleName = "pptxgenjs";
|
||||
const pptxModule = (await import(moduleName)) as { default?: new () => Record<string, unknown> };
|
||||
const PptxGenJS = pptxModule.default ?? (pptxModule as unknown as new () => Record<string, unknown>);
|
||||
const pptx = new PptxGenJS() as Record<string, unknown> & {
|
||||
layout?: string;
|
||||
author?: string;
|
||||
subject?: string;
|
||||
title?: string;
|
||||
company?: string;
|
||||
addSlide: () => {
|
||||
background?: { color: string };
|
||||
addText: (text: string, options: Record<string, unknown>) => void;
|
||||
addImage?: (options: Record<string, unknown>) => void;
|
||||
addNotes?: (notes: string) => void;
|
||||
};
|
||||
write: (options: { outputType: "blob" | "arraybuffer" }) => Promise<Blob | ArrayBuffer>;
|
||||
};
|
||||
|
||||
pptx.layout = "LAYOUT_WIDE";
|
||||
pptx.author = "QOffice";
|
||||
pptx.company = "QOffice";
|
||||
pptx.subject = "Экспорт QPowerPoint";
|
||||
pptx.title = deck.title;
|
||||
|
||||
for (const sourceSlide of deck.slides) {
|
||||
const colors = themeColors[sourceSlide.theme] ?? themeColors.classic;
|
||||
const slide = pptx.addSlide();
|
||||
slide.background = { color: colors.background };
|
||||
sourceSlide.images?.forEach((image, index) => {
|
||||
const data = normalizedImageData(image.src);
|
||||
if (!data || !slide.addImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
slide.addImage({
|
||||
data,
|
||||
x: image.x,
|
||||
y: image.y,
|
||||
w: image.width,
|
||||
h: image.height,
|
||||
altText: image.alt || `Изображение ${index + 1}`
|
||||
});
|
||||
});
|
||||
slide.addText(sourceSlide.title || "Без заголовка", {
|
||||
x: 0.7,
|
||||
y: 0.65,
|
||||
w: 11.9,
|
||||
h: 0.75,
|
||||
fontFace: "Arial",
|
||||
fontSize: 30,
|
||||
bold: true,
|
||||
color: colors.title,
|
||||
margin: 0.04,
|
||||
breakLine: false
|
||||
});
|
||||
slide.addText(sourceSlide.subtitle || "", {
|
||||
x: 0.75,
|
||||
y: 1.65,
|
||||
w: 11.5,
|
||||
h: 4.35,
|
||||
fontFace: "Arial",
|
||||
fontSize: 18,
|
||||
color: colors.subtitle,
|
||||
valign: "top",
|
||||
fit: "shrink",
|
||||
breakLine: false
|
||||
});
|
||||
|
||||
if (sourceSlide.notes.trim() && slide.addNotes) {
|
||||
slide.addNotes(sourceSlide.notes);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await pptx.write({ outputType: "blob" });
|
||||
return result instanceof Blob ? result : new Blob([result], { type: PPTX_MIME_TYPE });
|
||||
}
|
||||
|
||||
export function extractTextBlocks(xmlNode: unknown): string[] {
|
||||
const blocks: string[] = [];
|
||||
collectTextBlocks(xmlNode, blocks);
|
||||
return blocks.map(normalizeWhitespace).filter(Boolean);
|
||||
}
|
||||
|
||||
export function listSlidePaths(
|
||||
zip: ZipArchive,
|
||||
presentationXml?: unknown,
|
||||
presentationRelationshipsXml?: unknown
|
||||
): Array<{ path: string; index: number }> {
|
||||
const orderedSlidePaths = presentationSlidePaths(presentationXml, presentationRelationshipsXml);
|
||||
if (orderedSlidePaths.length > 0) {
|
||||
return orderedSlidePaths.map((path, index) => ({ path, index: slideIndexFromPath(path) ?? index + 1 }));
|
||||
}
|
||||
|
||||
return Object.keys(zip.files ?? {})
|
||||
.map((path) => {
|
||||
const match = slidePathPattern.exec(path);
|
||||
return match ? { path, index: Number(match[1]) } : null;
|
||||
})
|
||||
.filter((entry): entry is { path: string; index: number } => entry !== null)
|
||||
.sort((a, b) => a.index - b.index);
|
||||
}
|
||||
|
||||
export function presentationSlidePaths(presentationXml: unknown, presentationRelationshipsXml: unknown): string[] {
|
||||
const relationshipTargets = relationshipTargetsById(presentationRelationshipsXml, slideRelationshipType);
|
||||
const slideIds = toArray(childRecord(childRecord(presentationXml, "presentation"), "sldIdLst").sldId)
|
||||
.map((entry) => relationshipId(asRecord(entry)))
|
||||
.filter(Boolean);
|
||||
|
||||
return slideIds.map((id) => relationshipTargets[id]).filter((target): target is string => Boolean(target));
|
||||
}
|
||||
|
||||
export function relationshipTargetsById(relationshipsXml: unknown, type?: string, sourcePartPath = "ppt/presentation.xml"): Record<string, string> {
|
||||
const relationships = childRecord(relationshipsXml, "Relationships");
|
||||
const targets: Record<string, string> = {};
|
||||
const baseDirectory = partDirectory(sourcePartPath);
|
||||
|
||||
toArray(relationships.Relationship).forEach((relationship) => {
|
||||
const record = asRecord(relationship);
|
||||
const id = stringValue(record.Id ?? record.id ?? record["@_Id"] ?? record["@_id"]);
|
||||
const target = stringValue(record.Target ?? record.target ?? record["@_Target"] ?? record["@_target"]);
|
||||
const relationshipType = stringValue(record.Type ?? record.type ?? record["@_Type"] ?? record["@_type"]);
|
||||
if (id && target && (!type || relationshipType === type)) {
|
||||
targets[id] = resolvePptTarget(baseDirectory, target);
|
||||
}
|
||||
});
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
export function relationshipTargetByType(relationshipsXml: unknown, type: string, sourcePartPath: string): string {
|
||||
const relationships = childRecord(relationshipsXml, "Relationships");
|
||||
const relationship = toArray(relationships.Relationship)
|
||||
.map(asRecord)
|
||||
.find((record) => stringValue(record.Type ?? record.type ?? record["@_Type"] ?? record["@_type"]) === type);
|
||||
|
||||
return relationship ? resolvePptTarget(partDirectory(sourcePartPath), stringValue(relationship.Target ?? relationship.target ?? relationship["@_Target"] ?? relationship["@_target"])) : "";
|
||||
}
|
||||
|
||||
export function resolvePptTarget(baseDirectory: string, target: string): string {
|
||||
const normalizedBase = baseDirectory.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedTarget = target.replace(/\\/g, "/");
|
||||
const rawPath = normalizedTarget.startsWith("/") ? normalizedTarget.replace(/^\/+/, "") : `${normalizedBase}/${normalizedTarget}`;
|
||||
const segments: string[] = [];
|
||||
|
||||
rawPath.split("/").forEach((segment) => {
|
||||
if (!segment || segment === ".") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (segment === "..") {
|
||||
segments.pop();
|
||||
return;
|
||||
}
|
||||
|
||||
segments.push(segment);
|
||||
});
|
||||
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
export function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
async function extractSlideImages(
|
||||
zip: ZipArchive,
|
||||
parser: { parse: (xml: string) => unknown },
|
||||
slideXml: unknown,
|
||||
slidePath: string,
|
||||
slideIndex: number
|
||||
): Promise<SlideImage[]> {
|
||||
const relationshipsPath = `${partDirectory(slidePath)}/_rels/${partFileName(slidePath)}.rels`;
|
||||
const relationshipsXml = await readOptionalParsedXml(zip, parser, relationshipsPath);
|
||||
const imageTargets = relationshipTargetsById(relationshipsXml, imageRelationshipType, slidePath);
|
||||
const pictures = recordsByKey(slideXml, "pic");
|
||||
const images = await Promise.all(
|
||||
pictures.map(async (picture, imageIndex) => slideImageFromPicture(zip, picture, imageTargets, slideIndex, imageIndex))
|
||||
);
|
||||
|
||||
return images.filter((image): image is SlideImage => Boolean(image));
|
||||
}
|
||||
|
||||
async function slideImageFromPicture(
|
||||
zip: ZipArchive,
|
||||
picture: Record<string, unknown>,
|
||||
imageTargets: Record<string, string>,
|
||||
slideIndex: number,
|
||||
imageIndex: number
|
||||
): Promise<SlideImage | null> {
|
||||
const relationshipId = pictureRelationshipId(picture);
|
||||
const target = relationshipId ? imageTargets[relationshipId] : "";
|
||||
const src = target ? await zipImageDataUri(zip, target) : "";
|
||||
if (!src) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `pptx-slide-${slideIndex}-image-${imageIndex + 1}`,
|
||||
src,
|
||||
alt: pictureAltText(picture) || `Изображение ${imageIndex + 1}`,
|
||||
...pictureBounds(picture)
|
||||
};
|
||||
}
|
||||
|
||||
function pictureRelationshipId(picture: Record<string, unknown>) {
|
||||
const blip = firstRecordByKey(picture, "blip");
|
||||
return stringValue(
|
||||
blip["r:embed"] ??
|
||||
blip.embed ??
|
||||
blip["@_r:embed"] ??
|
||||
blip["@_embed"] ??
|
||||
blip["r:link"] ??
|
||||
blip.link ??
|
||||
blip["@_r:link"] ??
|
||||
blip["@_link"]
|
||||
);
|
||||
}
|
||||
|
||||
function pictureAltText(picture: Record<string, unknown>) {
|
||||
const cNvPr = firstRecordByKey(picture, "cNvPr");
|
||||
return stringValue(cNvPr.descr ?? cNvPr["@_descr"] ?? cNvPr.name ?? cNvPr["@_name"]);
|
||||
}
|
||||
|
||||
function pictureBounds(picture: Record<string, unknown>) {
|
||||
const xfrm = firstRecordByKey(picture, "xfrm");
|
||||
const off = childRecord(xfrm, "off");
|
||||
const ext = childRecord(xfrm, "ext");
|
||||
|
||||
return {
|
||||
x: emuToInches(numberAttribute(off, "x") ?? 914400),
|
||||
y: emuToInches(numberAttribute(off, "y") ?? 3429000),
|
||||
width: emuToInches(numberAttribute(ext, "cx") ?? 3657600),
|
||||
height: emuToInches(numberAttribute(ext, "cy") ?? 2057400)
|
||||
};
|
||||
}
|
||||
|
||||
async function zipImageDataUri(zip: ZipArchive, path: string) {
|
||||
const mimeType = imageMimeTypeFromPath(path);
|
||||
if (!mimeType) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const data = (await zip.file(path)?.async("base64")) ?? "";
|
||||
return data ? `data:${mimeType};base64,${data}` : "";
|
||||
}
|
||||
|
||||
function imageMimeTypeFromPath(path: string) {
|
||||
const extension = path.toLowerCase().split(/[?#]/)[0].split(".").pop() ?? "";
|
||||
const mimeTypes: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
bmp: "image/bmp",
|
||||
svg: "image/svg+xml"
|
||||
};
|
||||
|
||||
return mimeTypes[extension] ?? "";
|
||||
}
|
||||
|
||||
function normalizedImageData(src: string) {
|
||||
const value = src.trim();
|
||||
return /^data:image\/(?:png|jpe?g|gif|bmp|svg\+xml);base64,/i.test(value) ? value : "";
|
||||
}
|
||||
|
||||
function numberAttribute(record: Record<string, unknown>, name: string) {
|
||||
const value = Number.parseFloat(stringValue(record[name] ?? record[`@_${name}`]));
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function emuToInches(value: number) {
|
||||
return Math.max(0, Math.round((value / emuPerInch) * 1000) / 1000);
|
||||
}
|
||||
|
||||
async function loadZip(file: File): Promise<ZipArchive> {
|
||||
const moduleName = "jszip";
|
||||
const jsZipModule = (await import(moduleName)) as { default?: { loadAsync: (data: ArrayBuffer) => Promise<ZipArchive> } };
|
||||
const jsZip = jsZipModule.default ?? (jsZipModule as unknown as { loadAsync: (data: ArrayBuffer) => Promise<ZipArchive> });
|
||||
return jsZip.loadAsync(await readOfficeFileAsArrayBuffer(file));
|
||||
}
|
||||
|
||||
async function createXmlParser(): Promise<{ parse: (xml: string) => unknown }> {
|
||||
const moduleName = "fast-xml-parser";
|
||||
const parserModule = (await import(moduleName)) as { XMLParser: XmlParserConstructor };
|
||||
return new parserModule.XMLParser({
|
||||
ignoreAttributes: false,
|
||||
removeNSPrefix: true,
|
||||
textNodeName: "#text",
|
||||
trimValues: true
|
||||
});
|
||||
}
|
||||
|
||||
async function readOptionalParsedXml(zip: ZipArchive, parser: { parse: (xml: string) => unknown }, path: string): Promise<unknown> {
|
||||
const xml = await readOptionalZipText(zip, path);
|
||||
return xml ? parser.parse(xml) : undefined;
|
||||
}
|
||||
|
||||
async function readRequiredZipText(zip: ZipArchive, path: string, errorMessage: string): Promise<string> {
|
||||
const text = await readOptionalZipText(zip, path);
|
||||
if (!text) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
async function readOptionalZipText(zip: ZipArchive, path: string): Promise<string> {
|
||||
return (await zip.file(path)?.async("string")) ?? "";
|
||||
}
|
||||
|
||||
async function slideNotesPath(zip: ZipArchive, parser: { parse: (xml: string) => unknown }, slidePath: string): Promise<string> {
|
||||
const relationshipsPath = `${partDirectory(slidePath)}/_rels/${partFileName(slidePath)}.rels`;
|
||||
const relationshipsXml = await readOptionalParsedXml(zip, parser, relationshipsPath);
|
||||
return relationshipTargetByType(relationshipsXml, notesRelationshipType, slidePath);
|
||||
}
|
||||
|
||||
function collectTextBlocks(node: unknown, blocks: string[]): void {
|
||||
if (node === null || node === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof node === "string" || typeof node === "number") {
|
||||
blocks.push(String(node));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) {
|
||||
collectTextBlocks(item, blocks);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof node !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = node as Record<string, unknown>;
|
||||
if (typeof record.t === "string" || typeof record.t === "number") {
|
||||
blocks.push(String(record.t));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof record["#text"] === "string" || typeof record["#text"] === "number") {
|
||||
blocks.push(String(record["#text"]));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (isXmlAttributeKey(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
collectTextBlocks(value, blocks);
|
||||
}
|
||||
}
|
||||
|
||||
function isXmlAttributeKey(key: string) {
|
||||
return key.startsWith("@_") || ["id", "name", "descr", "x", "y", "cx", "cy", "embed", "link"].includes(key);
|
||||
}
|
||||
|
||||
function slideIndexFromPath(path: string): number | undefined {
|
||||
const match = slidePathPattern.exec(path);
|
||||
return match ? Number(match[1]) : undefined;
|
||||
}
|
||||
|
||||
function partDirectory(path: string): string {
|
||||
return path.replace(/\\/g, "/").replace(/\/[^/]*$/, "");
|
||||
}
|
||||
|
||||
function partFileName(path: string): string {
|
||||
return path.replace(/\\/g, "/").split("/").pop() ?? path;
|
||||
}
|
||||
|
||||
function relationshipId(record: Record<string, unknown>): string {
|
||||
return stringValue(record["r:id"] ?? record.id ?? record.rId ?? record["@_r:id"] ?? record["@_id"] ?? record["@_rId"]);
|
||||
}
|
||||
|
||||
function childRecord(parent: unknown, key: string): Record<string, unknown> {
|
||||
const record = asRecord(parent);
|
||||
return asRecord(record[key]);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function toArray(value: unknown): unknown[] {
|
||||
if (value === undefined || value === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function recordsByKey(node: unknown, key: string): Array<Record<string, unknown>> {
|
||||
const records: Array<Record<string, unknown>> = [];
|
||||
collectRecordsByKey(node, key, records);
|
||||
return records;
|
||||
}
|
||||
|
||||
function firstRecordByKey(node: unknown, key: string): Record<string, unknown> {
|
||||
return recordsByKey(node, key)[0] ?? {};
|
||||
}
|
||||
|
||||
function collectRecordsByKey(node: unknown, key: string, records: Array<Record<string, unknown>>): void {
|
||||
if (node === null || node === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((item) => collectRecordsByKey(item, key, records));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof node !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(node as Record<string, unknown>).forEach(([entryKey, value]) => {
|
||||
if (entryKey === key) {
|
||||
toArray(value).forEach((item) => {
|
||||
const record = asRecord(item);
|
||||
if (Object.keys(record).length > 0) {
|
||||
records.push(record);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
collectRecordsByKey(value, key, records);
|
||||
});
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === "string" || typeof value === "number" ? String(value) : "";
|
||||
}
|
||||
Reference in New Issue
Block a user