Initial QOffice implementation

This commit is contained in:
Курнат Андрей
2026-06-01 05:31:05 +03:00
commit 9bf4dd3672
44 changed files with 16854 additions and 0 deletions
+507
View File
@@ -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) : "";
}