feat(pdf): extract standalone PDF ingest package (ESRC-WP-0001)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Bootstrap evidence-source from the citation-evidence src/source PDF slice.

- Headless core (src/pdf): ingest/extract/fingerprint, importing domain
  contracts from @citation-evidence/engine/shared (no local copies)
- Browser upload helpers isolated under src/browser behind a ./browser
  entry point, with an eslint boundary keeping the core browser-free
- pnpm/TS/vitest/eslint scaffold; 52 tests (contract + determinism)
- Fixtures resolved from the sibling citation-evidence checkout, not
  duplicated (real PII) — see docs/ADR-0002; suites skip when absent
- Boundary + fixture decisions recorded as docs/ADR-0001 / ADR-0002
- README/SCOPE rewritten; capability.infotech.pdf-evidence-ingest
  registered, NO_CAPABILITIES removed
- Follow-on workplans ESRC-WP-0002..0004 queued; ESRC-WP-0001 finished

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-08 20:55:28 +02:00
parent 2fd715ba45
commit cb93c322c0
31 changed files with 5066 additions and 105 deletions

View file

@ -0,0 +1,97 @@
import { describe, expect, it, vi } from "vitest";
import type { DocumentId } from "@citation-evidence/engine/shared";
import { createPdfByteStore } from "./byte-store";
function stubUrlHelpers() {
let counter = 0;
const created: string[] = [];
const revoked: string[] = [];
const createObjectURL = vi.fn(() => {
const url = `blob:stub-${++counter}`;
created.push(url);
return url;
});
const revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
return { createObjectURL, revokeObjectURL, created, revoked };
}
describe("PdfByteStore", () => {
it("put / get round-trips bytes and exposes a blob URL", () => {
const helpers = stubUrlHelpers();
const store = createPdfByteStore(helpers);
const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // %PDF
const record = store.put("doc_a" as DocumentId, bytes);
expect(record.blobUrl).toBe("blob:stub-1");
expect(store.get("doc_a" as DocumentId)?.bytes).toBe(bytes);
expect(store.has("doc_a" as DocumentId)).toBe(true);
expect(store.list()).toEqual(["doc_a"]);
expect(store.size()).toBe(4);
});
it("put replaces an existing entry and revokes the old URL", () => {
const helpers = stubUrlHelpers();
const store = createPdfByteStore(helpers);
const id = "doc_a" as DocumentId;
const first = store.put(id, new Uint8Array([1, 2]));
const second = store.put(id, new Uint8Array([3, 4, 5]));
expect(helpers.revoked).toEqual([first.blobUrl]);
expect(store.get(id)?.bytes).toHaveLength(3);
expect(second.blobUrl).not.toBe(first.blobUrl);
});
it("delete revokes the blob URL exactly once and is idempotent", () => {
const helpers = stubUrlHelpers();
const store = createPdfByteStore(helpers);
const id = "doc_a" as DocumentId;
const record = store.put(id, new Uint8Array([1, 2, 3]));
expect(store.delete(id)).toBe(true);
expect(helpers.revoked).toEqual([record.blobUrl]);
expect(store.delete(id)).toBe(false);
expect(helpers.revoked).toHaveLength(1);
expect(store.get(id)).toBeNull();
expect(store.has(id)).toBe(false);
});
it("clear revokes every URL and empties the store", () => {
const helpers = stubUrlHelpers();
const store = createPdfByteStore(helpers);
const a = store.put("doc_a" as DocumentId, new Uint8Array([1]));
const b = store.put("doc_b" as DocumentId, new Uint8Array([2]));
store.clear();
expect(helpers.revoked.sort()).toEqual([a.blobUrl, b.blobUrl].sort());
expect(store.list()).toEqual([]);
expect(store.size()).toBe(0);
});
it("uses URL.createObjectURL by default when no override is supplied", () => {
const createObjectURL = vi.fn(() => "blob:built-in");
const revokeObjectURL = vi.fn();
const originalURL = globalThis.URL;
Object.defineProperty(globalThis, "URL", {
configurable: true,
writable: true,
value: Object.assign(Object.create(originalURL.prototype as object), {
createObjectURL,
revokeObjectURL,
}),
});
try {
const store = createPdfByteStore();
const rec = store.put("doc_z" as DocumentId, new Uint8Array([9]));
expect(rec.blobUrl).toBe("blob:built-in");
expect(createObjectURL).toHaveBeenCalledTimes(1);
store.delete("doc_z" as DocumentId);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:built-in");
} finally {
Object.defineProperty(globalThis, "URL", {
configurable: true,
writable: true,
value: originalURL,
});
}
});
});

115
src/browser/byte-store.ts Normal file
View file

@ -0,0 +1,115 @@
/**
* `PdfByteStore` in-memory store for uploaded PDF bytes, keyed by
* `DocumentId`.
*
* BROWSER SURFACE not part of the headless ingest core. This mints and
* revokes `blob:` URLs and is meant to back an interactive upload/viewer
* session. Headless callers should use `src/pdf/` directly and never import
* this module.
*
* Uploaded PDFs are stored in memory only. Bytes survive within a tab
* session; reloading the page loses them unless the session was exported.
*
* One store instance per active session. The session-management layer is
* responsible for swapping the active store when the user switches sessions.
* The store also owns a small registry of issued `blob:` URLs so it can
* revoke them on delete/clear no cross-cutting cleanup at the app layer.
*/
import type { DocumentId } from "@citation-evidence/engine/shared";
export interface PdfByteRecord {
readonly bytes: Uint8Array;
/** A `blob:` URL the viewer can consume directly. */
readonly blobUrl: string;
}
export interface PdfByteStore {
put(documentId: DocumentId, bytes: Uint8Array): PdfByteRecord;
get(documentId: DocumentId): PdfByteRecord | null;
has(documentId: DocumentId): boolean;
delete(documentId: DocumentId): boolean;
list(): readonly DocumentId[];
/** Revoke every blob URL and clear the store. */
clear(): void;
/** Total bytes currently held — useful for UI dashboards. */
size(): number;
}
export interface CreatePdfByteStoreOptions {
/**
* Mint a URL for the given bytes. Defaults to `URL.createObjectURL` in
* environments that have it; tests can inject a deterministic stub.
*/
readonly createObjectURL?: (blob: Blob) => string;
/** Revoke a URL previously minted by `createObjectURL`. */
readonly revokeObjectURL?: (url: string) => void;
}
export function createPdfByteStore(
options: CreatePdfByteStoreOptions = {},
): PdfByteStore {
const createUrl =
options.createObjectURL ??
((blob: Blob) => {
if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
throw new Error(
"createPdfByteStore: URL.createObjectURL not available — inject a stub via options",
);
}
return URL.createObjectURL(blob);
});
const revokeUrl =
options.revokeObjectURL ??
((url: string) => {
if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
URL.revokeObjectURL(url);
}
});
const records = new Map<DocumentId, PdfByteRecord>();
return {
put(documentId, bytes) {
// Replace previous record (revoking the prior URL) if any.
const prior = records.get(documentId);
if (prior) revokeUrl(prior.blobUrl);
// Cast: Blob() does accept Uint8Array at runtime, but TS narrows the
// buffer type to ArrayBufferLike (could be SharedArrayBuffer) and
// refuses without help. The bytes here always come from a fresh
// arrayBuffer() call, so a regular ArrayBuffer is guaranteed.
const blob = new Blob([bytes as unknown as ArrayBuffer], {
type: "application/pdf",
});
const blobUrl = createUrl(blob);
const record: PdfByteRecord = { bytes, blobUrl };
records.set(documentId, record);
return record;
},
get(documentId) {
return records.get(documentId) ?? null;
},
has(documentId) {
return records.has(documentId);
},
delete(documentId) {
const record = records.get(documentId);
if (!record) return false;
revokeUrl(record.blobUrl);
records.delete(documentId);
return true;
},
list() {
return [...records.keys()];
},
clear() {
for (const record of records.values()) revokeUrl(record.blobUrl);
records.clear();
},
size() {
let total = 0;
for (const r of records.values()) total += r.bytes.byteLength;
return total;
},
};
}

20
src/browser/index.ts Normal file
View file

@ -0,0 +1,20 @@
/**
* `evidence-source/browser` browser-facing upload helpers.
*
* BROWSER SURFACE. These helpers assume a browser session: they mint and
* revoke `blob:` URLs and hold uploaded bytes in memory for a viewer to
* mount. They are deliberately separated from the headless core (the package
* root, `src/index.ts`) so that headless consumers never pull in
* `URL.createObjectURL`/`Blob` upload machinery.
*/
export {
createPdfByteStore,
type CreatePdfByteStoreOptions,
type PdfByteRecord,
type PdfByteStore,
} from "./byte-store";
export {
ingestPdfFromFile,
type IngestPdfFromFileOptions,
} from "./upload";

View file

@ -0,0 +1,82 @@
/**
* `ingestPdfFromFile` end-to-end: pipes a fixture PDF through the upload path,
* asserts the byte store keeps the bytes and the document record carries the
* minted `blob:` URL. Corpus resolution/skip lives in `tests/fixtures.ts`.
*/
import { describe, expect, it, vi } from "vitest";
import { createPdfByteStore } from "./byte-store";
import { ingestPdfFromFile } from "./upload";
import { fixtureBytes, fixtureCorpusAvailable } from "../../tests/fixtures";
const hasCorpus = fixtureCorpusAvailable();
const FIXTURE_FILE = "Fristsetzung zur Bezifferung GÜ an Gegenseite 3 Wochen.pdf";
class FakeFile {
readonly name: string;
private readonly bytes: Uint8Array;
constructor(bytes: Uint8Array, name: string) {
this.bytes = bytes;
this.name = name;
}
async arrayBuffer(): Promise<ArrayBuffer> {
const out = new ArrayBuffer(this.bytes.byteLength);
new Uint8Array(out).set(this.bytes);
return out;
}
}
describe.skipIf(!hasCorpus)("ingestPdfFromFile", () => {
it("round-trips a fixture PDF through ingest + byte store + blob URL", async () => {
const bytes = fixtureBytes(FIXTURE_FILE);
const file = new FakeFile(bytes, "demo.pdf") as unknown as File;
let counter = 0;
const store = createPdfByteStore({
createObjectURL: () => `blob:upload-stub-${++counter}`,
revokeObjectURL: () => {},
});
const { document, representation } = await ingestPdfFromFile(file, store);
const stored = store.get(document.id);
expect(stored).not.toBeNull();
expect(stored!.bytes.byteLength).toBe(bytes.byteLength);
expect(document.uri).toBe(`blob:upload-stub-${counter}`);
expect(document.title).toBe("demo.pdf");
expect(document.fingerprint).toMatch(/^[0-9a-f]{64}$/);
expect(representation.representationType).toBe("pdf-text");
expect((representation.canonicalText ?? "").length).toBeGreaterThan(0);
}, 30_000);
it("falls through to ingestPdf with no filename when given a plain Blob", async () => {
const bytes = fixtureBytes(FIXTURE_FILE);
const blob = {
async arrayBuffer() {
const out = new ArrayBuffer(bytes.byteLength);
new Uint8Array(out).set(bytes);
return out;
},
} as Blob;
const store = createPdfByteStore({
createObjectURL: () => "blob:no-name",
revokeObjectURL: () => {},
});
const { document } = await ingestPdfFromFile(blob, store);
expect(document.title).toBeUndefined();
expect(document.uri).toBe("blob:no-name");
}, 30_000);
it("explicit title option overrides the filename", async () => {
const bytes = fixtureBytes(FIXTURE_FILE);
const file = new FakeFile(bytes, "anonymous-name.pdf") as unknown as File;
const store = createPdfByteStore({
createObjectURL: vi.fn(() => "blob:override"),
revokeObjectURL: vi.fn(),
});
const { document } = await ingestPdfFromFile(file, store, { title: "Custom" });
expect(document.title).toBe("Custom");
}, 30_000);
});

43
src/browser/upload.ts Normal file
View file

@ -0,0 +1,43 @@
/**
* Upload-side ingest path.
*
* BROWSER SURFACE not part of the headless ingest core. This is a thin
* wrapper over the headless `ingestPdf` that additionally pushes the bytes
* into a per-session `PdfByteStore`, which mints a `blob:` URL and stamps it
* onto `document.uri` so a viewer adapter can mount the PDF directly.
*
* 1. Read `file.arrayBuffer()` once into a `Uint8Array`.
* 2. Run the existing `ingestPdf(bytes, { filename })` pipeline to produce
* `{document, representation}`.
* 3. Push the bytes into the per-session `PdfByteStore`.
* 4. Hand the engine inputs back to the caller, which wires them via
* `engine.documents.register(...)`.
*
* Keeping URL-minting inside the byte store (rather than at the call site)
* means there is exactly one place that creates `blob:` URLs and exactly one
* place that revokes them.
*/
import { ingestPdf, type IngestPdfResult } from "../pdf/ingest";
import type { PdfByteStore } from "./byte-store";
export interface IngestPdfFromFileOptions {
/** Override the filename used as the document title. */
readonly title?: string;
}
export async function ingestPdfFromFile(
file: File | Blob,
store: PdfByteStore,
options: IngestPdfFromFileOptions = {},
): Promise<IngestPdfResult> {
const bytes = new Uint8Array(await file.arrayBuffer());
const filename = "name" in file && typeof file.name === "string" ? file.name : undefined;
const ingested = await ingestPdf(bytes, {
...(filename !== undefined ? { filename } : {}),
...(options.title !== undefined ? { title: options.title } : {}),
});
const record = store.put(ingested.document.id, bytes);
const document = { ...ingested.document, uri: record.blobUrl };
return { document, representation: ingested.representation };
}

20
src/index.ts Normal file
View file

@ -0,0 +1,20 @@
/**
* `evidence-source` headless document ingest core (PDF slice).
*
* This entry point exposes only runtime-agnostic ingest: pass PDF bytes,
* receive a `{ document, representation }` pair built entirely from
* engine-owned contracts (`@citation-evidence/engine/shared`). No browser,
* viewer, or persistence concerns leak through here.
*
* Browser-facing upload helpers live behind the separate
* `@citation-evidence/evidence-source/browser` entry point.
*/
export {
ingestPdf,
type IngestPdfInput,
type IngestPdfOptions,
type IngestPdfResult,
} from "./pdf/ingest";
export { extractPdf, type PdfExtractionResult } from "./pdf/extract";
export { fingerprintBytes } from "./pdf/fingerprint";

122
src/pdf/extract.ts Normal file
View file

@ -0,0 +1,122 @@
/**
* PDF text extraction canonical text + PageMap + OffsetMap.
*
* Implements `wiki/ArchitectureOverview.md` §3.4 ("extract canonical text /
* build format-specific maps") for the `pdf-text` representation
* (`wiki/SharedContracts.md` §1, §3) and §6 (canonical normalization).
*
* Runtime independence: the PDF.js worker must be configured by the host
* application (`GlobalWorkerOptions.workerSrc`) before this module is
* called. In Vite/browser code the worker is bundled via the viewer; in
* Node tests the test setup file points it at
* `pdfjs-dist/legacy/build/pdf.worker.mjs`. No worker setup happens here
* so the same module loads cleanly in both runtimes.
*
* Page boundary semantics: canonical text concatenates per-page normalized
* text with a single "\n\n" paragraph separator. The separator is treated
* as belonging to the *preceding* page in `OffsetMap`, so the map covers
* `[0, canonicalText.length)` with no gaps. The last page has no trailing
* separator. This means `pageLength = globalEnd - globalStart` for
* every page; for non-last pages it equals (normalized page text length +
* 2). See `PageOffsetRange` in the engine's shared document contract.
*/
import { getDocument } from "pdfjs-dist";
import type { PDFPageProxy } from "pdfjs-dist";
import {
normalize,
type OffsetMap,
type PageInfo,
type PageMap,
type PageOffsetRange,
} from "@citation-evidence/engine/shared";
const PAGE_SEPARATOR = "\n\n";
export interface PdfExtractionResult {
readonly canonicalText: string;
readonly pageMap: PageMap;
readonly offsetMap: OffsetMap;
readonly pageCount: number;
}
export async function extractPdf(bytes: Uint8Array): Promise<PdfExtractionResult> {
// PDF.js mutates the bytes buffer (transfers ownership). Pass a fresh copy
// so the caller's Uint8Array stays usable for fingerprinting after extract.
const data = new Uint8Array(bytes);
const loadingTask = getDocument({ data });
const doc = await loadingTask.promise;
try {
const pageCount = doc.numPages;
const pageInfos: PageInfo[] = [];
const pageNormalizedTexts: string[] = [];
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber++) {
const page = await doc.getPage(pageNumber);
try {
const viewport = page.getViewport({ scale: 1 });
pageInfos.push({
page: pageNumber,
width: viewport.width,
height: viewport.height,
});
const rawText = await extractPageText(page);
pageNormalizedTexts.push(normalize(rawText).text);
} finally {
page.cleanup();
}
}
const { canonicalText, offsetMap } = buildOffsetMap(pageNormalizedTexts);
return {
canonicalText,
pageMap: pageInfos,
offsetMap,
pageCount,
};
} finally {
await doc.destroy();
}
}
async function extractPageText(page: PDFPageProxy): Promise<string> {
const content = await page.getTextContent();
// textContent.items are TextItem | TextMarkedContent. We want only the
// TextItem strings (those have a `str` field); marked-content entries are
// structural anchors and have no visible text.
const parts: string[] = [];
for (const item of content.items) {
if ("str" in item) {
parts.push(item.str);
if (item.hasEOL) parts.push("\n");
}
}
return parts.join("");
}
function buildOffsetMap(pageTexts: readonly string[]): {
canonicalText: string;
offsetMap: OffsetMap;
} {
const ranges: PageOffsetRange[] = [];
let offset = 0;
for (let i = 0; i < pageTexts.length; i++) {
const text = pageTexts[i]!;
const isLast = i === pageTexts.length - 1;
const segmentLength = text.length + (isLast ? 0 : PAGE_SEPARATOR.length);
const globalStart = offset;
const globalEnd = offset + segmentLength;
ranges.push({
page: i + 1,
globalStart,
globalEnd,
pageLength: segmentLength,
});
offset = globalEnd;
}
const canonicalText = pageTexts.join(PAGE_SEPARATOR);
return { canonicalText, offsetMap: ranges };
}

View file

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { fingerprintBytes } from "./fingerprint";
describe("fingerprintBytes", () => {
it("produces a 64-char lowercase hex SHA-256", async () => {
const digest = await fingerprintBytes(new Uint8Array([0x25, 0x50, 0x44, 0x46]));
expect(digest).toMatch(/^[0-9a-f]{64}$/);
});
it("matches the known SHA-256 of the empty input", async () => {
// Well-known constant: SHA-256 of zero bytes.
const digest = await fingerprintBytes(new Uint8Array(0));
expect(digest).toBe(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
);
});
it("is deterministic across repeated calls on the same bytes", async () => {
const bytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
const a = await fingerprintBytes(bytes);
const b = await fingerprintBytes(bytes);
expect(a).toBe(b);
});
it("does not mutate or consume the input buffer", async () => {
const bytes = new Uint8Array([9, 8, 7]);
await fingerprintBytes(bytes);
expect([...bytes]).toEqual([9, 8, 7]);
});
it("distinguishes single-bit differences", async () => {
const a = await fingerprintBytes(new Uint8Array([0]));
const b = await fingerprintBytes(new Uint8Array([1]));
expect(a).not.toBe(b);
});
});

31
src/pdf/fingerprint.ts Normal file
View file

@ -0,0 +1,31 @@
/**
* SHA-256 fingerprint of raw document bytes.
*
* Implements the fingerprint half of `wiki/ArchitectureOverview.md` §3.4
* (the "compute fingerprint" pipeline step) and populates
* `Document.fingerprint` (`wiki/SharedContracts.md` §1).
*
* Uses Web Crypto's `crypto.subtle.digest`, which is available in browsers
* and in Node 20 (where it is exposed on `globalThis.crypto`). No
* platform branching the API is the same in both environments.
*/
export async function fingerprintBytes(bytes: Uint8Array): Promise<string> {
// Copy into a fresh ArrayBuffer (not SharedArrayBuffer) so the digest call
// satisfies TS's updated `BufferSource` type, which excludes
// `SharedArrayBuffer`. The copy is O(n) — fine even for large PDFs since
// SHA-256 itself is already O(n).
const ab = new ArrayBuffer(bytes.byteLength);
new Uint8Array(ab).set(bytes);
const digest = await crypto.subtle.digest("SHA-256", ab);
return bytesToHex(new Uint8Array(digest));
}
function bytesToHex(bytes: Uint8Array): string {
let hex = "";
for (let i = 0; i < bytes.length; i++) {
const b = bytes[i]!;
hex += (b < 0x10 ? "0" : "") + b.toString(16);
}
return hex;
}

119
src/pdf/ingest.test.ts Normal file
View file

@ -0,0 +1,119 @@
/**
* Fixture-driven contract tests for the PDF ingest pipeline.
*
* For each fixture in the shared corpus manifest (ADR-0002):
* 1. Read the PDF bytes from disk.
* 2. Run `ingestPdf` end-to-end.
* 3. Assert the resulting Document + DocumentRepresentation honour the
* manifest contract: media type is application/pdf, fingerprint is a
* 64-hex SHA-256, pageMap matches `page_count`, canonicalText contains
* `known_good_quote`, and the offsetMap covers `[0, canonicalText.length)`
* with no gaps.
*
* Corpus resolution and skip-on-absent behaviour live in `tests/fixtures.ts`.
*/
import { describe, expect, it } from "vitest";
import { ingestPdf } from "./ingest";
import { fingerprintBytes } from "./fingerprint";
import {
fixtureBytes,
fixtureCorpusAvailable,
loadFixtures,
} from "../../tests/fixtures";
const hasCorpus = fixtureCorpusAvailable();
const FIXTURES = hasCorpus ? loadFixtures() : [];
describe.skipIf(!hasCorpus)("ingestPdf — fixture corpus", { timeout: 30_000 }, () => {
for (const fixture of FIXTURES) {
describe(fixture.id, () => {
const bytes = fixtureBytes(fixture.filename);
it("produces a Document with PDF media type and SHA-256 fingerprint", async () => {
const { document } = await ingestPdf(bytes, { filename: fixture.filename });
expect(document.mediaType).toBe("application/pdf");
expect(document.fingerprint).toMatch(/^[0-9a-f]{64}$/);
expect(document.title).toBe(fixture.filename);
const expected = await fingerprintBytes(bytes);
expect(document.fingerprint).toBe(expected);
});
it("produces a pdf-text representation with the expected page count", async () => {
const { representation } = await ingestPdf(bytes);
expect(representation.representationType).toBe("pdf-text");
expect(representation.pageMap?.length).toBe(fixture.page_count);
expect(representation.offsetMap?.length).toBe(fixture.page_count);
});
it("canonical text contains the manifest's known-good quote", async () => {
const { representation } = await ingestPdf(bytes);
const text = representation.canonicalText ?? "";
expect(text).toContain(fixture.known_good_quote);
});
it("offsetMap is gap-free and covers [0, canonicalText.length)", async () => {
const { representation } = await ingestPdf(bytes);
const text = representation.canonicalText ?? "";
const offsets = representation.offsetMap ?? [];
expect(offsets.length).toBeGreaterThan(0);
expect(offsets[0]!.globalStart).toBe(0);
expect(offsets.at(-1)!.globalEnd).toBe(text.length);
for (let i = 0; i < offsets.length; i++) {
const r = offsets[i]!;
expect(r.page).toBe(i + 1);
expect(r.globalEnd - r.globalStart).toBe(r.pageLength);
if (i > 0) expect(r.globalStart).toBe(offsets[i - 1]!.globalEnd);
}
});
it("pageMap entries have positive width and height in user-space points", async () => {
const { representation } = await ingestPdf(bytes);
const pages = representation.pageMap ?? [];
for (let i = 0; i < pages.length; i++) {
const p = pages[i]!;
expect(p.page).toBe(i + 1);
expect(p.width).toBeGreaterThan(0);
expect(p.height).toBeGreaterThan(0);
}
});
});
}
});
describe.skipIf(!hasCorpus)("ingestPdf — option handling", { timeout: 30_000 }, () => {
const fixture = FIXTURES[0];
const bytes = fixture ? fixtureBytes(fixture.filename) : new Uint8Array();
it("uses explicit title over filename", async () => {
const { document } = await ingestPdf(bytes, {
filename: fixture!.filename,
title: "Custom Title",
});
expect(document.title).toBe("Custom Title");
});
it("omits title entirely when neither filename nor title is supplied", async () => {
const { document } = await ingestPdf(bytes);
expect(document.title).toBeUndefined();
});
it("propagates uri and metadata when supplied", async () => {
const { document } = await ingestPdf(bytes, {
uri: "file:///example.pdf",
metadata: { source: "test" },
});
expect(document.uri).toBe("file:///example.pdf");
expect(document.metadata).toEqual({ source: "test" });
});
it("accepts ArrayBuffer input", async () => {
const ab = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
const { document } = await ingestPdf(ab);
expect(document.fingerprint).toMatch(/^[0-9a-f]{64}$/);
});
});

88
src/pdf/ingest.ts Normal file
View file

@ -0,0 +1,88 @@
/**
* PDF ingest pipeline `{ document, representation }`.
*
* Implements `wiki/ArchitectureOverview.md` §3.4 ("Raw Source identify
* media type compute fingerprint extract metadata extract canonical
* text build format-specific maps persist Document +
* DocumentRepresentation") for the PDF source format.
*
* Ingest is a pure function over bytes: it does not persist anything. The
* caller (engine repositories, or the app layer) writes the returned
* Document + DocumentRepresentation into the chosen store.
*/
import {
newId,
type Document,
type DocumentRepresentation,
} from "@citation-evidence/engine/shared";
import { extractPdf } from "./extract";
import { fingerprintBytes } from "./fingerprint";
const PDF_MEDIA_TYPE = "application/pdf";
export interface IngestPdfOptions {
/** Original filename, used as the default title when no title is given. */
readonly filename?: string;
/** Optional pre-existing title (overrides filename). */
readonly title?: string;
/** Optional source URI (e.g. file:// or https://). */
readonly uri?: string;
/** Free-form metadata persisted on the Document record. */
readonly metadata?: Readonly<Record<string, unknown>>;
}
export interface IngestPdfResult {
readonly document: Document;
readonly representation: DocumentRepresentation;
}
export type IngestPdfInput = Uint8Array | ArrayBuffer | Blob;
export async function ingestPdf(
input: IngestPdfInput,
options: IngestPdfOptions = {},
): Promise<IngestPdfResult> {
const bytes = await toBytes(input);
const [fingerprint, extraction] = await Promise.all([
fingerprintBytes(bytes),
extractPdf(bytes),
]);
const now = new Date().toISOString();
const documentId = newId("document");
const representationId = newId("representation");
const title = options.title ?? options.filename;
const document: Document = {
id: documentId,
mediaType: PDF_MEDIA_TYPE,
fingerprint,
createdAt: now,
updatedAt: now,
...(title !== undefined ? { title } : {}),
...(options.uri !== undefined ? { uri: options.uri } : {}),
...(options.metadata !== undefined ? { metadata: options.metadata } : {}),
};
const representation: DocumentRepresentation = {
id: representationId,
documentId,
representationType: "pdf-text",
contentHash: fingerprint,
canonicalText: extraction.canonicalText,
pageMap: extraction.pageMap,
offsetMap: extraction.offsetMap,
generatedAt: now,
};
return { document, representation };
}
async function toBytes(input: IngestPdfInput): Promise<Uint8Array> {
if (input instanceof Uint8Array) return input;
if (input instanceof ArrayBuffer) return new Uint8Array(input);
// Blob (covers `File` in browsers — File extends Blob).
const buf = await input.arrayBuffer();
return new Uint8Array(buf);
}