426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
277 lines
7.8 KiB
TypeScript
277 lines
7.8 KiB
TypeScript
import type { IAgentRuntime } from "@elizaos/core";
|
|
import { logger, Service, ServiceType } from "@elizaos/core";
|
|
import { getDocumentProxy } from "unpdf";
|
|
|
|
import type {
|
|
PdfConversionResult,
|
|
PdfDocumentInfo,
|
|
PdfExtractionOptions,
|
|
PdfMetadata,
|
|
PdfPageInfo,
|
|
} from "../types";
|
|
|
|
type PdfTextItem = { str: string };
|
|
|
|
export const MAX_PDF_BUFFER_BYTES = 100 * 1024 * 1024;
|
|
|
|
const PDF_HEADER_BYTES = [0x25, 0x50, 0x44, 0x46, 0x2d] as const;
|
|
const PDF_HEADER_SCAN_BYTES = 1024;
|
|
|
|
function isTextItem(item: unknown): item is PdfTextItem {
|
|
return (
|
|
typeof item === "object" &&
|
|
item !== null &&
|
|
"str" in item &&
|
|
typeof (item as { str?: unknown }).str === "string"
|
|
);
|
|
}
|
|
|
|
function collectTextStrings(items: readonly unknown[]): string[] {
|
|
const textItems: string[] = [];
|
|
for (const item of items) {
|
|
if (isTextItem(item)) {
|
|
textItems.push(item.str);
|
|
}
|
|
}
|
|
return textItems;
|
|
}
|
|
|
|
function hasPdfHeader(input: Uint8Array): boolean {
|
|
const scanLength = Math.min(input.length, PDF_HEADER_SCAN_BYTES);
|
|
for (let offset = 0; offset <= scanLength - PDF_HEADER_BYTES.length; offset++) {
|
|
let matches = true;
|
|
for (let index = 0; index < PDF_HEADER_BYTES.length; index++) {
|
|
if (input[offset + index] !== PDF_HEADER_BYTES[index]) {
|
|
matches = false;
|
|
break;
|
|
}
|
|
}
|
|
if (matches) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function validatePdfInput(input: unknown): Uint8Array {
|
|
if (!(input instanceof Uint8Array)) {
|
|
throw new TypeError("PDF input must be a Buffer or Uint8Array");
|
|
}
|
|
|
|
if (input.length === 0) {
|
|
throw new RangeError("PDF input is empty");
|
|
}
|
|
|
|
if (input.byteLength > MAX_PDF_BUFFER_BYTES) {
|
|
throw new RangeError(`PDF input exceeds maximum size of ${MAX_PDF_BUFFER_BYTES} bytes`);
|
|
}
|
|
|
|
if (!hasPdfHeader(input)) {
|
|
throw new TypeError("PDF input is not a supported PDF document");
|
|
}
|
|
|
|
return input;
|
|
}
|
|
|
|
function validatePageOption(value: unknown, name: string): number | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (
|
|
typeof value !== "number" ||
|
|
!Number.isFinite(value) ||
|
|
!Number.isInteger(value) ||
|
|
value < 1
|
|
) {
|
|
throw new RangeError(`${name} must be a positive finite integer`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function normalizeExtractionOptions(
|
|
options: PdfExtractionOptions,
|
|
numPages: number
|
|
): {
|
|
startPage: number;
|
|
endPage: number;
|
|
} {
|
|
const requestedStartPage = validatePageOption(options.startPage, "startPage") ?? 1;
|
|
const requestedEndPage = validatePageOption(options.endPage, "endPage") ?? numPages;
|
|
if (requestedEndPage < requestedStartPage) {
|
|
throw new RangeError("endPage must be greater than or equal to startPage");
|
|
}
|
|
return {
|
|
startPage: Math.min(requestedStartPage, numPages),
|
|
endPage: Math.min(requestedEndPage, numPages),
|
|
};
|
|
}
|
|
|
|
function parseMetadataDate(value: string | Date | undefined): Date | undefined {
|
|
if (value === undefined) return undefined;
|
|
const date = value instanceof Date ? value : new Date(value);
|
|
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
}
|
|
|
|
export class PdfService extends Service {
|
|
static serviceType = ServiceType.PDF;
|
|
capabilityDescription = "The agent is able to convert PDF files to text";
|
|
|
|
static async start(runtime: IAgentRuntime): Promise<PdfService> {
|
|
const service = new PdfService(runtime);
|
|
return service;
|
|
}
|
|
|
|
static async stop(runtime: IAgentRuntime): Promise<void> {
|
|
const service = runtime.getService(ServiceType.PDF);
|
|
if (service) {
|
|
await service.stop();
|
|
}
|
|
}
|
|
|
|
async stop(): Promise<void> {}
|
|
|
|
async convertPdfToText(pdfBuffer: Buffer | Uint8Array): Promise<string> {
|
|
try {
|
|
const uint8Array = validatePdfInput(pdfBuffer);
|
|
const pdf = await getDocumentProxy(uint8Array);
|
|
const numPages = pdf.numPages;
|
|
|
|
const textPages: string[] = [];
|
|
|
|
for (let pageNum = 1; pageNum <= numPages; pageNum++) {
|
|
const page = await pdf.getPage(pageNum);
|
|
const textContent = await page.getTextContent();
|
|
const pageText = collectTextStrings(textContent.items).join(" ");
|
|
textPages.push(pageText);
|
|
}
|
|
|
|
const rawText = textPages.join("\n");
|
|
return this.cleanUpContent(rawText);
|
|
} catch (error) {
|
|
const bufferSize = pdfBuffer instanceof Uint8Array ? pdfBuffer.length : "unknown";
|
|
logger.error(
|
|
`PdfService: Failed to convert PDF to text - error: ${error}, bufferSize: ${bufferSize}`
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async convertPdfToTextWithOptions(
|
|
pdfBuffer: Buffer | Uint8Array,
|
|
options: PdfExtractionOptions = {}
|
|
): Promise<PdfConversionResult> {
|
|
try {
|
|
const uint8Array = validatePdfInput(pdfBuffer);
|
|
const pdf = await getDocumentProxy(uint8Array);
|
|
const numPages = pdf.numPages;
|
|
|
|
const { startPage, endPage } = normalizeExtractionOptions(options, numPages);
|
|
|
|
const textPages: string[] = [];
|
|
|
|
for (let pageNum = startPage; pageNum <= endPage; pageNum++) {
|
|
const page = await pdf.getPage(pageNum);
|
|
const textContent = await page.getTextContent();
|
|
const pageText = collectTextStrings(textContent.items).join(
|
|
options.preserveWhitespace ? "" : " "
|
|
);
|
|
textPages.push(pageText);
|
|
}
|
|
|
|
let text = textPages.join("\n");
|
|
|
|
if (options.cleanContent !== false) {
|
|
text = this.cleanUpContent(text);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
text,
|
|
pageCount: numPages,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
async getDocumentInfo(pdfBuffer: Buffer | Uint8Array): Promise<PdfDocumentInfo> {
|
|
const uint8Array = validatePdfInput(pdfBuffer);
|
|
const pdf = await getDocumentProxy(uint8Array);
|
|
const numPages = pdf.numPages;
|
|
|
|
const metadataResult = await pdf.getMetadata();
|
|
const info = metadataResult.info as Record<string, string | Date | undefined>;
|
|
|
|
const metadata: PdfMetadata = {
|
|
title: info.Title as string | undefined,
|
|
author: info.Author as string | undefined,
|
|
subject: info.Subject as string | undefined,
|
|
keywords: info.Keywords as string | undefined,
|
|
creator: info.Creator as string | undefined,
|
|
producer: info.Producer as string | undefined,
|
|
creationDate: parseMetadataDate(info.CreationDate),
|
|
modificationDate: parseMetadataDate(info.ModDate),
|
|
};
|
|
|
|
const pages: PdfPageInfo[] = [];
|
|
const allText: string[] = [];
|
|
|
|
for (let pageNum = 1; pageNum <= numPages; pageNum++) {
|
|
const page = await pdf.getPage(pageNum);
|
|
const viewport = page.getViewport({ scale: 1.0 });
|
|
const textContent = await page.getTextContent();
|
|
|
|
const pageText = collectTextStrings(textContent.items).join(" ");
|
|
|
|
pages.push({
|
|
pageNumber: pageNum,
|
|
width: viewport.width,
|
|
height: viewport.height,
|
|
text: this.cleanUpContent(pageText),
|
|
});
|
|
|
|
allText.push(pageText);
|
|
}
|
|
|
|
return {
|
|
pageCount: numPages,
|
|
metadata,
|
|
text: this.cleanUpContent(allText.join("\n")),
|
|
pages,
|
|
};
|
|
}
|
|
|
|
cleanUpContent(content: string): string {
|
|
try {
|
|
const filtered = content
|
|
.split("")
|
|
.filter((char) => {
|
|
const charCode = char.charCodeAt(0);
|
|
return !(
|
|
charCode === 0 ||
|
|
(charCode >= 1 && charCode <= 8) ||
|
|
(charCode >= 11 && charCode <= 12) ||
|
|
(charCode >= 14 && charCode <= 31) ||
|
|
charCode === 127
|
|
);
|
|
})
|
|
.join("");
|
|
|
|
const cleaned = filtered
|
|
.replace(/[^\S\r\n]+/g, " ")
|
|
.replace(/[ \t]+(\r?\n)/g, "$1")
|
|
.trim();
|
|
|
|
return cleaned;
|
|
} catch (error) {
|
|
logger.error(
|
|
`PdfService: Failed to clean up content - error: ${error}, contentLength: ${content.length}`
|
|
);
|
|
return content;
|
|
}
|
|
}
|
|
}
|
|
|
|
export default PdfService;
|