1072 lines
40 KiB
TypeScript
1072 lines
40 KiB
TypeScript
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[];
|
||
}
|
||
|
||
interface SlideTextBlock {
|
||
text: string;
|
||
listStyle?: SlideListStyle;
|
||
hyperlink?: string;
|
||
bold?: boolean;
|
||
italics?: boolean;
|
||
underline?: boolean;
|
||
strike?: boolean;
|
||
align?: SlideTextAlign;
|
||
color?: string;
|
||
fontSize?: number;
|
||
fontFamily?: string;
|
||
}
|
||
|
||
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 };
|
||
type WritableZipArchive = {
|
||
file: {
|
||
(path: string): ZipEntry | null;
|
||
(path: string, data: string): WritableZipArchive;
|
||
};
|
||
generateAsync: (options: { type: "blob"; mimeType: string }) => Promise<Blob>;
|
||
};
|
||
type ZipConstructor = { loadAsync: (data: ArrayBuffer) => Promise<WritableZipArchive> };
|
||
|
||
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 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" },
|
||
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 orderedParser = await createOrderedXmlParser();
|
||
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);
|
||
const slideSize = presentationSlideSize(presentationXml);
|
||
|
||
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 orderedSlide = orderedParser.parse(slideXml);
|
||
const relationshipsPath = `${partDirectory(path)}/_rels/${partFileName(path)}.rels`;
|
||
const slideRelationshipsXml = await readOptionalParsedXml(zip, parser, relationshipsPath);
|
||
const hyperlinkTargets = relationshipTargetsById(slideRelationshipsXml, hyperlinkRelationshipType, path);
|
||
const slideTextBlocks = mergeOrderedSlideTextBlocks(extractSlideTextBlocks(parsedSlide, hyperlinkTargets), extractOrderedTextBodyTexts(orderedSlide));
|
||
const images = await extractSlideImages(zip, parser, parsedSlide, path, index, slideRelationshipsXml);
|
||
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
|
||
? extractOrderedTextBodyTexts(orderedParser.parse(notesXml)).join("\n") || extractTextBlocks(parser.parse(notesXml)).join("\n")
|
||
: "";
|
||
const backgroundColor = slideBackgroundColor(parsedSlide);
|
||
const hidden = slideHidden(parsedSlide);
|
||
const transition = slideTransition(parsedSlide);
|
||
const subtitleListStyle = slideTextBlocks
|
||
.slice(1)
|
||
.map((block) => block.listStyle)
|
||
.find((style): style is SlideListStyle => Boolean(style));
|
||
|
||
return {
|
||
id: `pptx-slide-${index}`,
|
||
title: slideTextBlocks[0]?.text || `Слайд ${index}`,
|
||
...(slideTextBlocks[0]?.hyperlink ? { titleHyperlink: slideTextBlocks[0].hyperlink } : {}),
|
||
subtitle: slideTextBlocks.slice(1).map((block) => block.text).join("\n"),
|
||
...(slideTextBlocks[1]?.hyperlink ? { subtitleHyperlink: slideTextBlocks[1].hyperlink } : {}),
|
||
notes,
|
||
theme: "classic" as const,
|
||
...(hidden ? { hidden } : {}),
|
||
...(transition ? { transition } : {}),
|
||
...(backgroundColor ? { backgroundColor } : {}),
|
||
titleBold: slideTextBlocks[0]?.bold ?? false,
|
||
...(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 } : {}),
|
||
...(slideTextBlocks[1]?.bold ? { subtitleBold: true } : {}),
|
||
...(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 } : {}),
|
||
...(subtitleListStyle ? { subtitleListStyle } : {}),
|
||
images
|
||
};
|
||
})
|
||
);
|
||
|
||
return { title, ...(slideSize ? { slideSize } : {}), 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>;
|
||
};
|
||
const slideSize = normalizedSlideSizeId(deck.slideSize);
|
||
const slideLayout = slideSizeLayouts[slideSize];
|
||
|
||
pptx.layout = slideLayout.pptxLayout;
|
||
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 titleColor = normalizeHexColor(sourceSlide.titleColor) || colors.title;
|
||
const subtitleColor = normalizeHexColor(sourceSlide.subtitleColor) || colors.subtitle;
|
||
const titleFontSize = normalizeFontSize(sourceSlide.titleFontSize) || 30;
|
||
const subtitleFontSize = normalizeFontSize(sourceSlide.subtitleFontSize) || 18;
|
||
const titleFontFamily = normalizeFontFamily(sourceSlide.titleFontFamily) || "Arial";
|
||
const subtitleFontFamily = normalizeFontFamily(sourceSlide.subtitleFontFamily) || "Arial";
|
||
const titleBold = sourceSlide.titleBold ?? true;
|
||
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();
|
||
slide.background = { color: normalizeHexColor(sourceSlide.backgroundColor) || colors.background };
|
||
sourceSlide.images?.forEach((image, index) => {
|
||
const data = normalizedImageData(image.src);
|
||
if (!data || !slide.addImage) {
|
||
return;
|
||
}
|
||
|
||
const imageHyperlink = normalizedExternalHyperlinkTarget(image.hyperlink);
|
||
slide.addImage({
|
||
data,
|
||
x: image.x,
|
||
y: image.y,
|
||
w: image.width,
|
||
h: image.height,
|
||
altText: image.alt || `Изображение ${index + 1}`,
|
||
...(imageHyperlink ? { hyperlink: { url: imageHyperlink } } : {})
|
||
});
|
||
});
|
||
slide.addText(sourceSlide.title || "Без заголовка", {
|
||
x: 0.7,
|
||
y: 0.65,
|
||
w: Math.max(1, slideLayout.width - 1.4),
|
||
h: 0.75,
|
||
fontFace: titleFontFamily,
|
||
fontSize: titleFontSize,
|
||
...(titleAlign ? { align: titleAlign } : {}),
|
||
bold: titleBold,
|
||
italic: titleItalics,
|
||
...(titleUnderline ? { underline: { style: "sng" } } : {}),
|
||
...(titleStrike ? { strike: "sngStrike" } : {}),
|
||
color: titleColor,
|
||
margin: 0.04,
|
||
breakLine: false,
|
||
...(titleHyperlink ? { hyperlink: { url: titleHyperlink } } : {})
|
||
});
|
||
slide.addText(sourceSlide.subtitle || "", {
|
||
x: 0.75,
|
||
y: 1.65,
|
||
w: Math.max(1, slideLayout.width - 1.5),
|
||
h: Math.max(1, slideLayout.height - 3.15),
|
||
fontFace: subtitleFontFamily,
|
||
fontSize: subtitleFontSize,
|
||
...(subtitleAlign ? { align: subtitleAlign } : {}),
|
||
bold: subtitleBold,
|
||
italic: subtitleItalics,
|
||
...(subtitleUnderline ? { underline: { style: "sng" } } : {}),
|
||
...(subtitleStrike ? { strike: "sngStrike" } : {}),
|
||
color: subtitleColor,
|
||
valign: "top",
|
||
fit: "shrink",
|
||
breakLine: false,
|
||
...(subtitleHyperlink ? { hyperlink: { url: subtitleHyperlink } } : {})
|
||
});
|
||
|
||
if (sourceSlide.notes.trim() && slide.addNotes) {
|
||
slide.addNotes(sourceSlide.notes);
|
||
}
|
||
}
|
||
|
||
const result = await pptx.write({ outputType: "blob" });
|
||
const blob = result instanceof Blob ? result : new Blob([result], { type: PPTX_MIME_TYPE });
|
||
return deck.slides.some((slide) => slide.hidden || slide.transition || slide.subtitleListStyle)
|
||
? applySlideXmlOverridesToPptxBlob(blob, deck.slides)
|
||
: blob;
|
||
}
|
||
|
||
export function extractTextBlocks(xmlNode: unknown): string[] {
|
||
const blocks: string[] = [];
|
||
collectTextBlocks(xmlNode, blocks);
|
||
return blocks.map(normalizeWhitespace).filter(Boolean);
|
||
}
|
||
|
||
function extractSlideTextBlocks(slideXml: unknown, hyperlinkTargets: Record<string, string> = {}): SlideTextBlock[] {
|
||
const textBodies = slideTextBodyRecords(slideXml);
|
||
const blocks = textBodies
|
||
.map(({ textBody, hyperlinkSource }): SlideTextBlock | null => {
|
||
const text = extractTextBodyText(textBody);
|
||
if (!text) {
|
||
return null;
|
||
}
|
||
|
||
const runProperties = firstRecordByKey(textBody, "rPr");
|
||
const hyperlink = textBodyHyperlink(hyperlinkSource, hyperlinkTargets);
|
||
const listStyle = textBodyListStyle(textBody);
|
||
const bold = runTextBold(runProperties);
|
||
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);
|
||
return {
|
||
text,
|
||
...(listStyle ? { listStyle } : {}),
|
||
...(hyperlink ? { hyperlink } : {}),
|
||
bold,
|
||
italics,
|
||
underline,
|
||
strike,
|
||
...(align ? { align } : {}),
|
||
...(color ? { color } : {}),
|
||
...(fontSize ? { fontSize } : {}),
|
||
...(fontFamily ? { fontFamily } : {})
|
||
};
|
||
})
|
||
.filter((block): block is SlideTextBlock => Boolean(block));
|
||
|
||
return blocks.length > 0 ? blocks : extractTextBlocks(slideXml).map((text) => ({ text }));
|
||
}
|
||
|
||
function slideTextBodyRecords(slideXml: unknown): Array<{ textBody: Record<string, unknown>; hyperlinkSource: Record<string, unknown> }> {
|
||
const shapeEntries = recordsByKey(slideXml, "sp")
|
||
.map((shape) => {
|
||
const textBody = childRecord(shape, "txBody");
|
||
return Object.keys(textBody).length > 0 ? { textBody, hyperlinkSource: shape } : null;
|
||
})
|
||
.filter((entry): entry is { textBody: Record<string, unknown>; hyperlinkSource: Record<string, unknown> } => Boolean(entry));
|
||
|
||
return shapeEntries.length > 0 ? shapeEntries : recordsByKey(slideXml, "txBody").map((textBody) => ({ textBody, hyperlinkSource: textBody }));
|
||
}
|
||
|
||
function textBodyHyperlink(textBody: Record<string, unknown>, hyperlinkTargets: Record<string, string>) {
|
||
const hyperlink = recordsByKey(textBody, "hlinkClick")
|
||
.map((record) => {
|
||
const id = relationshipId(record);
|
||
return id ? normalizedExternalHyperlinkTarget(hyperlinkTargets[id]) : "";
|
||
})
|
||
.find(Boolean);
|
||
|
||
return hyperlink ?? "";
|
||
}
|
||
|
||
function mergeOrderedSlideTextBlocks(styledBlocks: SlideTextBlock[], orderedTexts: string[]) {
|
||
if (orderedTexts.length === 0) {
|
||
return styledBlocks;
|
||
}
|
||
|
||
return orderedTexts.map((text, index) => {
|
||
const styledBlock = styledBlocks[index] ?? { text };
|
||
return { ...styledBlock, text };
|
||
});
|
||
}
|
||
|
||
function textBodyListStyle(textBody: Record<string, unknown>): SlideListStyle | undefined {
|
||
const paragraphStyles = toArray(textBody.p)
|
||
.map(paragraphListStyle)
|
||
.filter((style): style is SlideListStyle => Boolean(style));
|
||
|
||
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) {
|
||
return "number";
|
||
}
|
||
if (Object.keys(childRecord(paragraphProperties, "buChar")).length > 0) {
|
||
return "bullet";
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
|
||
function extractOrderedTextBodyTexts(xmlNode: unknown) {
|
||
return orderedElementsByName(xmlNode, "txBody").map(orderedTextBodyText).filter(Boolean);
|
||
}
|
||
|
||
function orderedTextBodyText(textBody: unknown) {
|
||
const paragraphs = orderedDirectElements(textBody, "p").map(orderedParagraphText).filter(Boolean);
|
||
return paragraphs.length > 0 ? paragraphs.join("\n") : "";
|
||
}
|
||
|
||
function orderedParagraphText(paragraph: unknown) {
|
||
return normalizeTextWithLineBreaks(
|
||
orderedElementChildren(paragraph)
|
||
.map((child) => {
|
||
const name = orderedElementName(child);
|
||
if (name === "r" || name === "fld") {
|
||
return orderedXmlText(child);
|
||
}
|
||
if (name === "br") {
|
||
return "\n";
|
||
}
|
||
|
||
return "";
|
||
})
|
||
.join("")
|
||
);
|
||
}
|
||
|
||
function extractTextBodyText(textBody: Record<string, unknown>) {
|
||
const paragraphs = toArray(textBody.p)
|
||
.map((paragraph) => normalizeWhitespace(extractTextBlocks(paragraph).join("")))
|
||
.filter(Boolean);
|
||
|
||
return paragraphs.length > 0 ? paragraphs.join("\n") : normalizeWhitespace(extractTextBlocks(textBody).join("\n"));
|
||
}
|
||
|
||
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 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> = {};
|
||
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, "/");
|
||
if (/^[a-z][a-z0-9+.-]*:/i.test(normalizedTarget)) {
|
||
return normalizedTarget;
|
||
}
|
||
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();
|
||
}
|
||
|
||
function normalizedSlideSizeId(value?: SlideSizeId): SlideSizeId {
|
||
return value === "standard" ? "standard" : "wide";
|
||
}
|
||
|
||
function normalizeTextWithLineBreaks(value: string): string {
|
||
return value
|
||
.split("\n")
|
||
.map(normalizeWhitespace)
|
||
.join("\n")
|
||
.replace(/\n{3,}/g, "\n\n")
|
||
.trim();
|
||
}
|
||
|
||
async function extractSlideImages(
|
||
zip: ZipArchive,
|
||
parser: { parse: (xml: string) => unknown },
|
||
slideXml: unknown,
|
||
slidePath: string,
|
||
slideIndex: number,
|
||
relationshipsXml?: unknown
|
||
): Promise<SlideImage[]> {
|
||
const relationshipsPath = `${partDirectory(slidePath)}/_rels/${partFileName(slidePath)}.rels`;
|
||
const parsedRelationshipsXml = relationshipsXml ?? (await readOptionalParsedXml(zip, parser, relationshipsPath));
|
||
const imageTargets = relationshipTargetsById(parsedRelationshipsXml, imageRelationshipType, slidePath);
|
||
const hyperlinkTargets = relationshipTargetsById(parsedRelationshipsXml, hyperlinkRelationshipType, slidePath);
|
||
const pictures = recordsByKey(slideXml, "pic");
|
||
const images = await Promise.all(
|
||
pictures.map(async (picture, imageIndex) => slideImageFromPicture(zip, picture, imageTargets, hyperlinkTargets, slideIndex, imageIndex))
|
||
);
|
||
|
||
return images.filter((image): image is SlideImage => Boolean(image));
|
||
}
|
||
|
||
async function slideImageFromPicture(
|
||
zip: ZipArchive,
|
||
picture: Record<string, unknown>,
|
||
imageTargets: Record<string, string>,
|
||
hyperlinkTargets: 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;
|
||
}
|
||
|
||
const hyperlink = pictureHyperlink(picture, hyperlinkTargets);
|
||
return {
|
||
id: `pptx-slide-${slideIndex}-image-${imageIndex + 1}`,
|
||
src,
|
||
alt: pictureAltText(picture) || `Изображение ${imageIndex + 1}`,
|
||
...(hyperlink ? { hyperlink } : {}),
|
||
...pictureBounds(picture)
|
||
};
|
||
}
|
||
|
||
function pictureHyperlink(picture: Record<string, unknown>, hyperlinkTargets: Record<string, string>) {
|
||
return (
|
||
recordsByKey(picture, "hlinkClick")
|
||
.map((record) => {
|
||
const id = relationshipId(record);
|
||
return id ? normalizedExternalHyperlinkTarget(hyperlinkTargets[id]) : "";
|
||
})
|
||
.find(Boolean) ?? ""
|
||
);
|
||
}
|
||
|
||
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 slideBackgroundColor(slideXml: unknown) {
|
||
const background = firstRecordByKey(slideXml, "bg");
|
||
const backgroundProperties = childRecord(background, "bgPr");
|
||
const solidFill = childRecord(backgroundProperties, "solidFill");
|
||
const srgbColor = childRecord(solidFill, "srgbClr");
|
||
return normalizeHexColor(stringValue(srgbColor.val ?? srgbColor["@_val"]));
|
||
}
|
||
|
||
function slideHidden(slideXml: unknown) {
|
||
const slide = childRecord(slideXml, "sld");
|
||
return isFalseXmlBoolean(slide.show ?? slide["@_show"]);
|
||
}
|
||
|
||
function slideTransition(slideXml: unknown): Slide["transition"] | undefined {
|
||
const transition = childRecord(childRecord(slideXml, "sld"), "transition");
|
||
const type = Object.keys(transition).find((key) => isSlideTransition(key));
|
||
return isSlideTransition(type) ? type : undefined;
|
||
}
|
||
|
||
function isSlideTransition(value: unknown): value is NonNullable<Slide["transition"]> {
|
||
return typeof value === "string" && (slideTransitions as readonly string[]).includes(value);
|
||
}
|
||
|
||
async function applySlideXmlOverridesToPptxBlob(blob: Blob, slides: Slide[]) {
|
||
const JSZip = await loadJsZip();
|
||
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||
await Promise.all(
|
||
slides.map(async (slide, index) => {
|
||
if (!slide.hidden && !slide.transition && !slide.subtitleListStyle) {
|
||
return;
|
||
}
|
||
|
||
const path = `ppt/slides/slide${index + 1}.xml`;
|
||
const xml = await zip.file(path)?.async("string");
|
||
if (xml) {
|
||
zip.file(path, pptxSlideXmlWithOverrides(xml, slide));
|
||
}
|
||
})
|
||
);
|
||
|
||
return zip.generateAsync({ type: "blob", mimeType: PPTX_MIME_TYPE });
|
||
}
|
||
|
||
function pptxSlideXmlWithOverrides(xml: string, slide: Slide) {
|
||
const withHiddenState = slide.hidden ? pptxSlideXmlWithHiddenState(xml) : xml;
|
||
const withTransition = slide.transition ? pptxSlideXmlWithTransition(withHiddenState, slide.transition) : withHiddenState;
|
||
return slide.subtitleListStyle ? pptxSlideXmlWithSubtitleList(withTransition, slide.subtitleListStyle) : withTransition;
|
||
}
|
||
|
||
function pptxSlideXmlWithHiddenState(xml: string) {
|
||
return xml.replace(/<p:sld\b([^>]*)>/, (_match, attributes: string) => {
|
||
const withoutShow = attributes.replace(/\sshow="[^"]*"/, "");
|
||
return `<p:sld${withoutShow} show="0">`;
|
||
});
|
||
}
|
||
|
||
function pptxSlideXmlWithTransition(xml: string, transition: NonNullable<Slide["transition"]>) {
|
||
const transitionXml = `<p:transition><p:${transition}/></p:transition>`;
|
||
if (/<p:transition\b[\s\S]*?<\/p:transition>/.test(xml)) {
|
||
return xml.replace(/<p:transition\b[\s\S]*?<\/p:transition>/, transitionXml);
|
||
}
|
||
if (/<p:transition\b[^>]*\/>/.test(xml)) {
|
||
return xml.replace(/<p:transition\b[^>]*\/>/, transitionXml);
|
||
}
|
||
if (/<\/p:cSld>/.test(xml)) {
|
||
return xml.replace("</p:cSld>", `</p:cSld>${transitionXml}`);
|
||
}
|
||
|
||
return xml.replace(/<p:sld\b[^>]*>/, (match) => `${match}${transitionXml}`);
|
||
}
|
||
|
||
function pptxSlideXmlWithSubtitleList(xml: string, listStyle: SlideListStyle) {
|
||
let textBodyIndex = 0;
|
||
return xml.replace(/<p:txBody\b[\s\S]*?<\/p:txBody>/g, (textBodyXml) => {
|
||
textBodyIndex += 1;
|
||
return textBodyIndex === 2 ? pptxTextBodyXmlWithList(textBodyXml, listStyle) : textBodyXml;
|
||
});
|
||
}
|
||
|
||
function pptxTextBodyXmlWithList(textBodyXml: string, listStyle: SlideListStyle) {
|
||
return textBodyXml.replace(/<a:p>[\s\S]*?<\/a:p>/g, (paragraphXml) => pptxParagraphXmlWithList(paragraphXml, listStyle));
|
||
}
|
||
|
||
function pptxParagraphXmlWithList(paragraphXml: string, listStyle: SlideListStyle) {
|
||
const listXml = pptxParagraphListXml(listStyle);
|
||
if (/<a:pPr\b[^>]*\/>/.test(paragraphXml)) {
|
||
return paragraphXml.replace(/<a:pPr\b([^>]*)\/>/, `<a:pPr$1>${listXml}</a:pPr>`);
|
||
}
|
||
if (/<a:pPr\b/.test(paragraphXml)) {
|
||
return paragraphXml.replace(/<a:pPr\b([^>]*)>([\s\S]*?)<\/a:pPr>/, (_match, attributes: string, content: string) => {
|
||
const contentWithoutBullet = content.replace(/<a:bu[A-Za-z]+\b[^>]*\/>/g, "");
|
||
return `<a:pPr${attributes}>${listXml}${contentWithoutBullet}</a:pPr>`;
|
||
});
|
||
}
|
||
|
||
return paragraphXml.replace("<a:p>", `<a:p><a:pPr>${listXml}</a:pPr>`);
|
||
}
|
||
|
||
function pptxParagraphListXml(listStyle: SlideListStyle) {
|
||
return listStyle === "number" ? '<a:buAutoNum type="arabicPeriod"/>' : '<a:buChar char="•"/>';
|
||
}
|
||
|
||
function normalizeHexColor(value?: string) {
|
||
const raw = value?.trim().replace(/^#/, "").toUpperCase() ?? "";
|
||
return /^[0-9A-F]{6}$/.test(raw) ? raw : "";
|
||
}
|
||
|
||
function normalizedExternalHyperlinkTarget(value?: string) {
|
||
const raw = value?.trim();
|
||
if (!raw) {
|
||
return "";
|
||
}
|
||
|
||
try {
|
||
const url = new URL(raw);
|
||
return ["http:", "https:", "mailto:", "tel:", "ftp:"].includes(url.protocol.toLowerCase()) ? url.href : "";
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function runTextColor(runProperties: Record<string, unknown>) {
|
||
const solidFill = childRecord(runProperties, "solidFill");
|
||
const srgbColor = childRecord(solidFill, "srgbClr");
|
||
return normalizeHexColor(stringValue(srgbColor.val ?? srgbColor["@_val"]));
|
||
}
|
||
|
||
function runTextBold(runProperties: Record<string, unknown>) {
|
||
return trueXmlBooleanAttribute(runProperties, "b");
|
||
}
|
||
|
||
function runTextItalics(runProperties: Record<string, unknown>) {
|
||
return trueXmlBooleanAttribute(runProperties, "i");
|
||
}
|
||
|
||
function runTextUnderline(runProperties: Record<string, unknown>) {
|
||
const value = stringValue(runProperties.u ?? runProperties["@_u"]).trim().toLowerCase();
|
||
return Boolean(value && value !== "none");
|
||
}
|
||
|
||
function runTextStrike(runProperties: Record<string, unknown>) {
|
||
const value = stringValue(runProperties.strike ?? runProperties["@_strike"]).trim().toLowerCase();
|
||
return Boolean(value && value !== "nostrike");
|
||
}
|
||
|
||
function runTextFontSize(runProperties: Record<string, unknown>) {
|
||
const rawSize = numberAttribute(runProperties, "sz");
|
||
return rawSize ? normalizeFontSize(rawSize / 100) : undefined;
|
||
}
|
||
|
||
function runTextFontFamily(runProperties: Record<string, unknown>) {
|
||
return normalizeFontFamily(
|
||
[childRecord(runProperties, "latin"), childRecord(runProperties, "ea"), childRecord(runProperties, "cs")]
|
||
.map((font) => stringValue(font.typeface ?? font["@_typeface"]))
|
||
.find(Boolean)
|
||
);
|
||
}
|
||
|
||
function normalizeFontSize(value?: number) {
|
||
if (!Number.isFinite(value) || !value || value <= 0) {
|
||
return undefined;
|
||
}
|
||
|
||
return Math.round(value * 2) / 2;
|
||
}
|
||
|
||
function normalizeFontFamily(value?: string) {
|
||
const normalized = value?.trim().replace(/["<>]/g, "").slice(0, 80) ?? "";
|
||
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;
|
||
}
|
||
|
||
function isFalseXmlBoolean(value: unknown) {
|
||
const normalized = String(value ?? "").trim().toLowerCase();
|
||
return normalized === "0" || normalized === "false";
|
||
}
|
||
|
||
function trueXmlBooleanAttribute(record: Record<string, unknown>, name: string) {
|
||
const value = record[name] ?? record[`@_${name}`];
|
||
const normalized = String(value ?? "").trim();
|
||
return Boolean(normalized && !isFalseXmlBoolean(value));
|
||
}
|
||
|
||
function emuToInches(value: number) {
|
||
return Math.max(0, Math.round((value / emuPerInch) * 1000) / 1000);
|
||
}
|
||
|
||
async function loadZip(file: File): Promise<ZipArchive> {
|
||
const jsZip = await loadJsZip();
|
||
return jsZip.loadAsync(await readOfficeFileAsArrayBuffer(file));
|
||
}
|
||
|
||
async function loadJsZip(): Promise<ZipConstructor> {
|
||
const moduleName = "jszip";
|
||
const jsZipModule = (await import(moduleName)) as { default?: ZipConstructor } & ZipConstructor;
|
||
return jsZipModule.default ?? jsZipModule;
|
||
}
|
||
|
||
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 createOrderedXmlParser(): 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,
|
||
preserveOrder: 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 orderedElementsByName(node: unknown, name: string): unknown[] {
|
||
const elements: unknown[] = [];
|
||
visitOrderedElements(node, (element) => {
|
||
if (orderedElementName(element) === name) {
|
||
elements.push(element);
|
||
}
|
||
});
|
||
return elements;
|
||
}
|
||
|
||
function visitOrderedElements(node: unknown, visitor: (element: unknown) => void): void {
|
||
if (Array.isArray(node)) {
|
||
node.forEach((item) => visitOrderedElements(item, visitor));
|
||
return;
|
||
}
|
||
|
||
if (!node || typeof node !== "object") {
|
||
return;
|
||
}
|
||
|
||
visitor(node);
|
||
orderedElementChildren(node).forEach((child) => visitOrderedElements(child, visitor));
|
||
}
|
||
|
||
function orderedDirectElements(node: unknown, name: string) {
|
||
return orderedElementChildren(node).filter((child) => orderedElementName(child) === name);
|
||
}
|
||
|
||
function orderedElementName(node: unknown) {
|
||
return Object.keys(asRecord(node)).find((key) => key !== ":@") ?? "";
|
||
}
|
||
|
||
function orderedElementChildren(node: unknown): unknown[] {
|
||
const name = orderedElementName(node);
|
||
return name ? toArray(asRecord(node)[name]) : [];
|
||
}
|
||
|
||
function orderedXmlText(node: unknown): string {
|
||
if (node === null || node === undefined) {
|
||
return "";
|
||
}
|
||
|
||
if (typeof node === "string" || typeof node === "number") {
|
||
return String(node);
|
||
}
|
||
|
||
if (Array.isArray(node)) {
|
||
return node.map(orderedXmlText).join("");
|
||
}
|
||
|
||
if (typeof node !== "object") {
|
||
return "";
|
||
}
|
||
|
||
const record = node as Record<string, unknown>;
|
||
if (typeof record["#text"] === "string" || typeof record["#text"] === "number") {
|
||
return String(record["#text"]);
|
||
}
|
||
|
||
return orderedElementChildren(node).map(orderedXmlText).join("");
|
||
}
|
||
|
||
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) : "";
|
||
}
|