UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

271 lines (270 loc) • 11 kB
/** * PowerPoint (PPTX) Processing Utility * * Extracts text content from PowerPoint (.pptx) files by treating them * as ZIP archives and parsing the slide XML files within. * * PPTX files are ZIP archives containing: * - ppt/slides/slide1.xml, slide2.xml, ... — slide content * - ppt/slideMasters/ — master slide templates * - ppt/slideLayouts/ — slide layout definitions * * Text is extracted from `<a:t>` elements in the slide XML files. * Slides are sorted by number and presented in reading order. * * Uses `adm-zip` (already a project dependency) for ZIP extraction. * * **XML-entity attacks are not applicable here.** Unlike `WordProcessor` * (mammoth/@xmldom/xmldom) and `ExcelProcessor` (exceljs/saxes), this * processor never hands slide XML to a DOM or SAX parser at all — extraction * is a single regex (`TEXT_ELEMENT_REGEX`, below) run over the raw decoded * bytes. There is no DOCTYPE/ENTITY resolution path for a payload to reach, * so there is nothing for a `.pptx`-specific XXE or billion-laughs test to * falsify; one is deliberately not written (see * `test/continuous-test-suite-office-security.ts`'s header). * * @module processors/document/PptxProcessor * * @example * ```typescript * import { PptxProcessor } from "./PptxProcessor.js"; * * const text = await PptxProcessor.extractText(buffer); * if (text) { * console.log("Extracted text:", text); * } * ``` */ import AdmZip from "adm-zip"; import * as zlib from "node:zlib"; import { readZipEntryWithinLimit } from "../archive/zipEntryReader.js"; import { SIZE_LIMITS } from "../config/index.js"; /** * Total budget for everything read out of one presentation. * * This class is a static utility with no `BaseFileProcessor` behind it, so * there is no `maxSizeMB` to inherit and nothing else was bounding these * reads at all — not even a late check. A .pptx is a ZIP, so any entry in it * can declare whatever uncompressed size its author likes. */ const PPTX_MAX_TOTAL_BYTES = SIZE_LIMITS.DOCUMENT_MAX_MB * 1024 * 1024; /** * Read entries out of one presentation, refusing once the total passes budget. * * The budget spans the call rather than each entry: a deck of two hundred * slides that each sit just under a per-entry limit is the obvious way around * one. Returns null for an absent or unreadable entry, which every call site * already treats as "no content here". */ function createBoundedEntryReader() { let spent = 0; return (entry) => { if (!entry) { return null; } const remaining = PPTX_MAX_TOTAL_BYTES - spent; if (remaining <= 0) { throw new Error(`PPTX content exceeds the ${SIZE_LIMITS.DOCUMENT_MAX_MB}MB limit`); } const read = readZipEntryWithinLimit(entry, remaining, zlib); if (read.status === "too-large") { throw new Error(`PPTX content exceeds the ${SIZE_LIMITS.DOCUMENT_MAX_MB}MB limit`); } if (read.status !== "ok") { return null; } spent += read.buffer.length; return read.buffer.toString("utf-8"); }; } /** * Regex to match text content within PowerPoint XML `<a:t>` elements. * These elements contain the actual visible text on slides. */ const TEXT_ELEMENT_REGEX = /<a:t[^>]*>([\s\S]*?)<\/a:t>/g; /** * Regex to match slide filenames and extract slide number. * Matches entries like "ppt/slides/slide1.xml", "ppt/slides/slide12.xml". */ const SLIDE_ENTRY_REGEX = /^ppt\/slides\/slide(\d+)\.xml$/; /** * A slide's per-slide relationships file, e.g. * "ppt/slides/_rels/slide1.xml.rels", which links the slide to its speaker * notes part (among other relationships). */ const SLIDE_RELS_NAME = (n) => `ppt/slides/_rels/slide${n}.xml.rels`; /** Matches a single `<Relationship .../>` tag inside a .rels document. */ const RELATIONSHIP_TAG_REGEX = /<Relationship\b[^>]*\/?>/g; /** * Static utility class for extracting text from PPTX files. * * Designed as a static class (not extending BaseFileProcessor) because * PPTX processing is straightforward ZIP+XML extraction and does not * need the full download/validate/process pipeline of BaseFileProcessor. */ export class PptxProcessor { /** * Extract all text content from a PPTX buffer. * * @param content - Raw PPTX file buffer * @returns Formatted text content with slide headers, or null if no text found * @throws Error if the buffer is not a valid ZIP/PPTX file */ static async extractText(content) { const zip = new AdmZip(content); const entries = zip.getEntries(); const readEntry = createBoundedEntryReader(); // Collect slide entries with their slide numbers for sorting const slides = []; for (const entry of entries) { const match = entry.entryName.match(SLIDE_ENTRY_REGEX); if (match) { const slideNumber = parseInt(match[1], 10); const xmlContent = readEntry(entry); if (xmlContent !== null) { slides.push({ slideNumber, xml: xmlContent }); } } } // Sort slides by number (slide1, slide2, ...) slides.sort((a, b) => a.slideNumber - b.slideNumber); if (slides.length === 0) { return null; } const parts = []; parts.push(`Presentation: ${slides.length} slide(s)\n`); for (const slide of slides) { const texts = PptxProcessor.extractTextFromXml(slide.xml); const notes = PptxProcessor.extractNotesForSlide(zip, slide.slideNumber, readEntry); // Emit a slide section when it has either body text or speaker notes. if (texts.length > 0 || notes) { parts.push(`### Slide ${slide.slideNumber}`); if (texts.length > 0) { parts.push(texts.join("\n")); } if (notes) { parts.push(`**Speaker notes:** ${notes}`); } parts.push(""); // blank line between slides } } const result = parts.join("\n").trim(); return result || null; } /** * Extract the speaker notes for one slide. * * Speaker notes live in a separate `ppt/notesSlides/notesSlideN.xml` part * whose number is NOT tied to the slide number — the link is declared in the * slide's own relationships file (`ppt/slides/_rels/slideN.xml.rels`) via a * relationship of type `.../notesSlide`. We resolve that target, then pull the * `<a:t>` runs from the notes part. Returns null when the slide has no notes. * * @param zip - The opened PPTX archive * @param slideNumber - 1-indexed slide number * @returns The notes text (runs joined by a space), or null when absent */ static extractNotesForSlide(zip, slideNumber, readEntry) { const relsXml = readEntry(zip.getEntry(SLIDE_RELS_NAME(slideNumber))); if (relsXml === null) { return null; } let notesTarget = null; RELATIONSHIP_TAG_REGEX.lastIndex = 0; for (let match = RELATIONSHIP_TAG_REGEX.exec(relsXml); match !== null; match = RELATIONSHIP_TAG_REGEX.exec(relsXml)) { const tag = match[0]; if (/Type="[^"]*\/notesSlide"/.test(tag)) { notesTarget = tag.match(/Target="([^"]+)"/)?.[1] ?? null; break; } } if (!notesTarget) { return null; } // Targets are relative to the slide's folder (ppt/slides/), e.g. // "../notesSlides/notesSlide1.xml" → "ppt/notesSlides/notesSlide1.xml". const normalized = notesTarget .replace(/^\.\.\//, "ppt/") .replace(/^\/+/, ""); const notesXml = readEntry(zip.getEntry(normalized)); if (notesXml === null) { return null; } const notes = PptxProcessor.extractTextFromXml(notesXml).join(" "); return notes.trim() || null; } /** * Extract text strings from a slide XML document. * Finds all `<a:t>` elements and returns their text content. * * @param xml - Raw XML string from a slide file * @returns Array of text strings found in the slide */ static extractTextFromXml(xml) { const texts = []; // Reset regex state for re-entrant usage TEXT_ELEMENT_REGEX.lastIndex = 0; for (let match = TEXT_ELEMENT_REGEX.exec(xml); match !== null; match = TEXT_ELEMENT_REGEX.exec(xml)) { const text = match[1].trim(); if (text) { texts.push(text); } } return texts; } // =========================================================================== // TARGETED EXTRACTION API // =========================================================================== /** * Extract text from specific slides in a PPTX file. * * Called by the `extract_file_content` tool for targeted slide access. * * @param content - Raw PPTX file buffer * @param slideNumbers - Array of 1-indexed slide numbers to extract * @returns Formatted text from the requested slides */ static async extractSlides(content, slideNumbers) { const zip = new AdmZip(content); const entries = zip.getEntries(); const readEntry = createBoundedEntryReader(); // Collect all slides const slides = []; for (const entry of entries) { const match = entry.entryName.match(SLIDE_ENTRY_REGEX); if (match) { const slideNumber = parseInt(match[1], 10); if (slideNumbers.includes(slideNumber)) { const xmlContent = readEntry(entry); if (xmlContent !== null) { slides.push({ slideNumber, xml: xmlContent }); } } } } slides.sort((a, b) => a.slideNumber - b.slideNumber); if (slides.length === 0) { // List total slides to help the LLM let totalSlides = 0; for (const entry of entries) { if (SLIDE_ENTRY_REGEX.test(entry.entryName)) { totalSlides++; } } return `Slides ${slideNumbers.join(", ")} not found. This presentation has ${totalSlides} slide(s).`; } const parts = []; for (const slide of slides) { const texts = PptxProcessor.extractTextFromXml(slide.xml); parts.push(`### Slide ${slide.slideNumber}`); if (texts.length > 0) { parts.push(texts.join("\n")); } else { parts.push("(No text content on this slide)"); } parts.push(""); } return parts.join("\n").trim(); } }