@uploadcn/server 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ocr.d.ts ADDED
@@ -0,0 +1,154 @@
1
+ import { i as UploadStorage } from "./storage-CGGIhRB9.js";
2
+ import { FileDescriptor, OcrField, OcrLine, OcrPage, OcrResult, OcrResult as OcrResult$1 } from "@uploadcn/core";
3
+ //#region src/ocr.d.ts
4
+ export interface OcrInput {
5
+ data: Uint8Array;
6
+ /** MIME type, e.g. `image/png` or `application/pdf`. */
7
+ type: string;
8
+ name?: string;
9
+ signal?: AbortSignal;
10
+ }
11
+ /** A server-side OCR engine. */
12
+ export interface OcrProvider {
13
+ readonly name: string;
14
+ recognize(input: OcrInput): Promise<OcrResult$1>;
15
+ }
16
+ export declare class OcrError extends Error {
17
+ readonly status?: number | undefined;
18
+ readonly name = "OcrError";
19
+ constructor(message: string, status?: number | undefined);
20
+ }
21
+ /** Builds a provider from a function, for engines without a built-in one. */
22
+ export declare function createOcrProvider(name: string, recognize: (input: OcrInput) => Promise<Omit<OcrResult$1, "provider">>): OcrProvider;
23
+ /** Base64 without Node's Buffer, so providers run on edge runtimes too. */
24
+ export declare function toBase64(bytes: Uint8Array): string;
25
+ export interface GoogleVisionOptions {
26
+ /** API key restricted to the Vision API. Or use `accessToken`. */
27
+ apiKey?: string;
28
+ /** OAuth access token (e.g. from a service account), instead of `apiKey`. */
29
+ accessToken?: () => string | Promise<string>;
30
+ /** Language hints, e.g. `["en", "de"]`. */
31
+ languageHints?: string[];
32
+ fetch?: typeof fetch;
33
+ }
34
+ /**
35
+ * Google Cloud Vision `DOCUMENT_TEXT_DETECTION`. Images go through
36
+ * `images:annotate`; PDFs and TIFFs through `files:annotate` (first 5 pages).
37
+ */
38
+ export declare function googleVisionOcr(options: GoogleVisionOptions): OcrProvider;
39
+ export interface TextractOptions {
40
+ region: string;
41
+ accessKeyId: string;
42
+ secretAccessKey: string;
43
+ sessionToken?: string;
44
+ /**
45
+ * `"text"` (default): DetectDocumentText, lines of text.
46
+ * `"expense"`: AnalyzeExpense, which also returns receipt and invoice
47
+ * fields (merchant, date, total, tax…).
48
+ */
49
+ model?: "text" | "expense";
50
+ fetch?: typeof fetch;
51
+ }
52
+ /**
53
+ * AWS Textract, signed with aws4fetch (no AWS SDK). Synchronous APIs accept
54
+ * JPEG, PNG, TIFF and single-page PDFs up to 10 MB.
55
+ */
56
+ export declare function awsTextractOcr(options: TextractOptions): OcrProvider;
57
+ export interface AzureDocumentIntelligenceOptions {
58
+ /** Resource endpoint, e.g. `https://my-resource.cognitiveservices.azure.com`. */
59
+ endpoint: string;
60
+ apiKey: string;
61
+ /**
62
+ * `"prebuilt-read"` (default) for text, `"prebuilt-receipt"`,
63
+ * `"prebuilt-invoice"`, `"prebuilt-idDocument"`, `"prebuilt-layout"` or a
64
+ * custom model id.
65
+ */
66
+ model?: string;
67
+ apiVersion?: string;
68
+ /** Milliseconds to wait for the analysis. Default 60 000. */
69
+ timeout?: number;
70
+ fetch?: typeof fetch;
71
+ }
72
+ /** Azure AI Document Intelligence (formerly Form Recognizer). */
73
+ export declare function azureDocumentIntelligenceOcr(options: AzureDocumentIntelligenceOptions): OcrProvider;
74
+ export interface MistralOcrOptions {
75
+ apiKey: string;
76
+ /** Default `"mistral-ocr-latest"`. */
77
+ model?: string;
78
+ fetch?: typeof fetch;
79
+ }
80
+ /**
81
+ * Mistral OCR. Reads PDFs and images and returns Markdown that keeps
82
+ * headings, lists and tables, which is ideal input for an LLM.
83
+ */
84
+ export declare function mistralOcr(options: MistralOcrOptions): OcrProvider;
85
+ export interface HttpOcrOptions {
86
+ /** Endpoint that receives `multipart/form-data` with a `file` field. */
87
+ url: string;
88
+ headers?: Record<string, string>;
89
+ /** Maps the response to an {@link OcrResult}. Default: it already is one. */
90
+ parse?: (body: unknown) => Omit<OcrResult$1, "provider">;
91
+ name?: string;
92
+ fetch?: typeof fetch;
93
+ }
94
+ /**
95
+ * Forwards files to your own OCR backend: a Python service running
96
+ * PaddleOCR, docTR, EasyOCR or Tesseract, an internal model, or any other
97
+ * HTTP API.
98
+ */
99
+ export declare function httpOcr(options: HttpOcrOptions): OcrProvider;
100
+ /** Raster images and PDFs. SVG is deliberately excluded (it can carry script). */
101
+ export declare const OCR_TYPES: readonly ["image/png", "image/jpeg", "image/webp", "image/tiff", "image/gif", "image/bmp", "application/pdf"];
102
+ export interface OcrRouteOptions<TAuth = undefined> {
103
+ provider: OcrProvider;
104
+ /**
105
+ * Runs before every request: check the session, rate-limit, and for
106
+ * `{ key }` requests make sure the caller owns the file. Throw
107
+ * `OcrRouteError` to deny.
108
+ */
109
+ authorize?: (context: {
110
+ request: Request;
111
+ key?: string;
112
+ }) => TAuth | Promise<TAuth>;
113
+ /** Largest file in bytes. Default 20 MB. */
114
+ maxFileSize?: number;
115
+ /** Allowed types, in `<input accept>` syntax. Default {@link OCR_TYPES}. */
116
+ allowedTypes?: string | readonly string[];
117
+ /**
118
+ * Lets clients OCR files that are already uploaded, by posting
119
+ * `{ "key": "…" }` as JSON. The storage must be able to read objects.
120
+ */
121
+ storage?: UploadStorage;
122
+ /** Called with every result, e.g. to save it. Return a value to replace it. */
123
+ onResult?: (context: {
124
+ request: Request;
125
+ auth: TAuth;
126
+ file: FileDescriptor;
127
+ key?: string;
128
+ result: OcrResult$1;
129
+ }) => OcrResult$1 | void | Promise<OcrResult$1 | void>;
130
+ }
131
+ export declare class OcrRouteError extends Error {
132
+ readonly status: number;
133
+ readonly name = "OcrRouteError";
134
+ constructor(message: string, status?: number);
135
+ }
136
+ /**
137
+ * A POST endpoint that reads text from a file with any provider. Send
138
+ * `multipart/form-data` with a `file` field (what `ocrEndpoint` in
139
+ * `@uploadcn/core` does), or JSON `{ "key": "…" }` for a stored upload.
140
+ *
141
+ * @example
142
+ * // app/api/ocr/route.ts
143
+ * export const { POST } = createOcrRoute({
144
+ * provider: googleVisionOcr({ apiKey: process.env.GOOGLE_VISION_API_KEY! }),
145
+ * authorize: async ({ request }) => requireUser(request),
146
+ * })
147
+ */
148
+ export declare function createOcrRoute<TAuth = undefined>(options: OcrRouteOptions<TAuth>): {
149
+ POST: (request: Request) => Promise<Response>;
150
+ handler: (request: Request) => Promise<Response>;
151
+ };
152
+ //#endregion
153
+ export type { OcrField, OcrLine, OcrPage, OcrResult };
154
+ //# sourceMappingURL=ocr.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ocr.d.ts","names":["OcrResult"],"sources":["../src/ocr.ts"],"mappings":";;;iBAeiB;EACf,MAAM;;EAEN;EACA;EACA,SAAS;;;iBAIM;WACN;EACT,UAAU,OAAO,WAAW,QAAQA;;qBAGzB,iBAAiB;WAIjB;WAHO;EAEhB,YAAA,iBACS;;;wBAOG,kBACd,cACA,YAAY,OAAO,aAAa,QAAQ,KAAKA,4BAC5C;;wBAaa,SAAS,OAAO;iBAiGf;;EAEf;;EAEA,6BAA6B;;EAE7B;EACA,eAAe;;;;;;wBAYD,gBAAgB,SAAS,sBAAsB;iBA8G9C;EACf;EACA;EACA;EACA;;;;;;EAMA;EACA,eAAe;;;;;;wBAcD,eAAe,SAAS,kBAAkB;iBAwHzC;;EAEf;EACA;;;;;;EAMA;EACA;;EAEA;EACA,eAAe;;;wBAaD,6BACd,SAAS,mCACR;iBAoHc;EACf;;EAEA;EACA,eAAe;;;;;;wBAmBD,WAAW,SAAS,oBAAoB;iBAuDvC;;EAEf;EACA,UAAU;;EAEV,SAAS,kBAAkB,KAAKA;EAChC;EACA,eAAe;;;;;;;wBAQD,QAAQ,SAAS,iBAAiB;;qBAoCrC;iBAUI,gBAAgB;EAC/B,UAAU;;;;;;EAMV,aAAa;IACX,SAAS;IACT;QACI,QAAQ,QAAQ;;EAEtB;;EAEA;;;;;EAKA,UAAU;;EAEV,YAAY;IACV,SAAS;IACT,MAAM;IACN,MAAM;IACN;IACA,QAAQA;QACJA,qBAAmB,QAAQA;;qBAGtB,sBAAsB;WAItB;WAHO;EAEhB,YAAA,iBACS;;;;;;;;;;;;;;wBAwCG,eAAe,mBAC7B,SAAS,gBAAgB;EAaM,OAAA,SAAA,YAAU,QAAQ;EAAlB,UAAA,SAAA,YAAU,QAAQ"}
package/dist/ocr.js ADDED
@@ -0,0 +1,534 @@
1
+ import { readStream } from "./scan.js";
2
+ import { matchesAccept } from "@uploadcn/core";
3
+ import { AwsClient } from "aws4fetch";
4
+ //#region src/ocr.ts
5
+ var OcrError = class extends Error {
6
+ status;
7
+ name = "OcrError";
8
+ constructor(message, status) {
9
+ super(message);
10
+ this.status = status;
11
+ }
12
+ };
13
+ /** Builds a provider from a function, for engines without a built-in one. */
14
+ function createOcrProvider(name, recognize) {
15
+ return {
16
+ name,
17
+ async recognize(input) {
18
+ return {
19
+ ...await recognize(input),
20
+ provider: name
21
+ };
22
+ }
23
+ };
24
+ }
25
+ /** Base64 without Node's Buffer, so providers run on edge runtimes too. */
26
+ function toBase64(bytes) {
27
+ let binary = "";
28
+ for (let at = 0; at < bytes.length; at += 32768) binary += String.fromCharCode(...bytes.subarray(at, at + 32768));
29
+ return btoa(binary);
30
+ }
31
+ function average(values) {
32
+ const known = values.filter((value) => value != null);
33
+ return known.length ? known.reduce((total, value) => total + value, 0) / known.length : void 0;
34
+ }
35
+ function joinPages(pages) {
36
+ return pages.map((page) => page.text.trim()).filter(Boolean).join("\n\n");
37
+ }
38
+ const FIELD_NAMES = {
39
+ VENDOR_NAME: "merchant",
40
+ NAME: "merchant",
41
+ INVOICE_RECEIPT_DATE: "date",
42
+ TOTAL: "total",
43
+ AMOUNT_DUE: "amountDue",
44
+ SUBTOTAL: "subtotal",
45
+ TAX: "tax",
46
+ INVOICE_RECEIPT_ID: "invoiceId",
47
+ MerchantName: "merchant",
48
+ VendorName: "merchant",
49
+ TransactionDate: "date",
50
+ InvoiceDate: "date",
51
+ Total: "total",
52
+ InvoiceTotal: "total",
53
+ AmountDue: "amountDue",
54
+ Subtotal: "subtotal",
55
+ SubTotal: "subtotal",
56
+ TotalTax: "tax",
57
+ InvoiceId: "invoiceId"
58
+ };
59
+ function addField(fields, name, field) {
60
+ const key = FIELD_NAMES[name] ?? name;
61
+ const current = fields[key];
62
+ if (!current || (field.confidence ?? 0) > (current.confidence ?? 0)) fields[key] = field;
63
+ }
64
+ async function readJson(response, provider) {
65
+ const text = await response.text();
66
+ let body;
67
+ try {
68
+ body = JSON.parse(text);
69
+ } catch {
70
+ body = void 0;
71
+ }
72
+ if (!response.ok) {
73
+ const message = body?.error?.message ?? body?.message ?? text.slice(0, 200);
74
+ throw new OcrError(`${provider} answered ${response.status}: ${message}`, response.status);
75
+ }
76
+ return body;
77
+ }
78
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
79
+ const timer = setTimeout(resolve, ms);
80
+ signal?.addEventListener("abort", () => {
81
+ clearTimeout(timer);
82
+ reject(signal.reason);
83
+ }, { once: true });
84
+ });
85
+ /**
86
+ * Google Cloud Vision `DOCUMENT_TEXT_DETECTION`. Images go through
87
+ * `images:annotate`; PDFs and TIFFs through `files:annotate` (first 5 pages).
88
+ */
89
+ function googleVisionOcr(options) {
90
+ if (!options.apiKey && !options.accessToken) throw new Error("[uploadcn] googleVisionOcr needs apiKey or accessToken");
91
+ const fetcher = options.fetch ?? fetch;
92
+ const imageContext = options.languageHints ? { languageHints: options.languageHints } : void 0;
93
+ const post = async (path, body, signal) => {
94
+ const headers = { "content-type": "application/json" };
95
+ if (options.accessToken) headers.authorization = `Bearer ${await options.accessToken()}`;
96
+ else headers["x-goog-api-key"] = options.apiKey;
97
+ return readJson(await fetcher(`https://vision.googleapis.com/v1/${path}`, {
98
+ method: "POST",
99
+ headers,
100
+ body: JSON.stringify(body),
101
+ signal
102
+ }), "Google Vision");
103
+ };
104
+ const toPage = (annotation, number) => ({
105
+ number,
106
+ text: annotation?.text?.trim() ?? "",
107
+ lines: (annotation?.text ?? "").split("\n").filter((line) => line.trim()).map((line) => ({ text: line }))
108
+ });
109
+ return {
110
+ name: "google-vision",
111
+ async recognize({ data, type, signal }) {
112
+ const features = [{ type: "DOCUMENT_TEXT_DETECTION" }];
113
+ const content = toBase64(data);
114
+ let annotations;
115
+ if (type === "application/pdf" || type === "image/tiff" || type === "image/gif") {
116
+ const file = (await post("files:annotate", { requests: [{
117
+ inputConfig: {
118
+ content,
119
+ mimeType: type
120
+ },
121
+ features,
122
+ imageContext,
123
+ pages: [
124
+ 1,
125
+ 2,
126
+ 3,
127
+ 4,
128
+ 5
129
+ ]
130
+ }] }, signal)).responses?.[0];
131
+ if (file?.error?.message) throw new OcrError(`Google Vision: ${file.error.message}`);
132
+ annotations = (file?.responses ?? []).map((page) => page.fullTextAnnotation ?? {});
133
+ } else {
134
+ const image = (await post("images:annotate", { requests: [{
135
+ image: { content },
136
+ features,
137
+ imageContext
138
+ }] }, signal)).responses?.[0];
139
+ if (image?.error?.message) throw new OcrError(`Google Vision: ${image.error.message}`);
140
+ annotations = [image?.fullTextAnnotation ?? {}];
141
+ }
142
+ const pages = annotations.map((annotation, index) => toPage(annotation, index + 1));
143
+ return {
144
+ text: joinPages(pages),
145
+ pages,
146
+ confidence: average(annotations.flatMap((annotation) => annotation.pages?.map((page) => page.confidence) ?? [])),
147
+ provider: "google-vision"
148
+ };
149
+ }
150
+ };
151
+ }
152
+ /**
153
+ * AWS Textract, signed with aws4fetch (no AWS SDK). Synchronous APIs accept
154
+ * JPEG, PNG, TIFF and single-page PDFs up to 10 MB.
155
+ */
156
+ function awsTextractOcr(options) {
157
+ const client = new AwsClient({
158
+ accessKeyId: options.accessKeyId,
159
+ secretAccessKey: options.secretAccessKey,
160
+ sessionToken: options.sessionToken,
161
+ region: options.region,
162
+ service: "textract"
163
+ });
164
+ const endpoint = `https://textract.${options.region}.amazonaws.com/`;
165
+ const model = options.model ?? "text";
166
+ const call = async (target, body, signal) => {
167
+ const request = await client.sign(endpoint, {
168
+ method: "POST",
169
+ headers: {
170
+ "content-type": "application/x-amz-json-1.1",
171
+ "x-amz-target": `Textract.${target}`
172
+ },
173
+ body: JSON.stringify(body)
174
+ });
175
+ return readJson(await (options.fetch ?? fetch)(request, { signal }), "AWS Textract");
176
+ };
177
+ const pagesFrom = (blocks) => {
178
+ const byPage = /* @__PURE__ */ new Map();
179
+ for (const block of blocks) {
180
+ if (block.BlockType !== "LINE" || !block.Text) continue;
181
+ const page = block.Page ?? 1;
182
+ byPage.set(page, [...byPage.get(page) ?? [], {
183
+ text: block.Text,
184
+ confidence: block.Confidence != null ? block.Confidence / 100 : void 0
185
+ }]);
186
+ }
187
+ return [...byPage].sort(([a], [b]) => a - b).map(([number, lines]) => ({
188
+ number,
189
+ lines,
190
+ text: lines.map((line) => line.text).join("\n")
191
+ }));
192
+ };
193
+ return {
194
+ name: "aws-textract",
195
+ async recognize({ data, signal }) {
196
+ const Document = { Bytes: toBase64(data) };
197
+ if (model === "expense") {
198
+ const documents = (await call("AnalyzeExpense", { Document }, signal)).ExpenseDocuments ?? [];
199
+ const fields = {};
200
+ for (const document of documents) for (const field of document.SummaryFields ?? []) {
201
+ const name = field.Type?.Text;
202
+ const value = field.ValueDetection?.Text;
203
+ if (!name || !value || name === "OTHER") continue;
204
+ addField(fields, name, {
205
+ value,
206
+ confidence: field.ValueDetection?.Confidence != null ? field.ValueDetection.Confidence / 100 : void 0
207
+ });
208
+ if (field.Currency?.Code && !fields.currency) fields.currency = { value: field.Currency.Code };
209
+ }
210
+ const pages = pagesFrom(documents.flatMap((document) => document.Blocks ?? []));
211
+ return {
212
+ text: joinPages(pages),
213
+ pages,
214
+ confidence: average(pages.flatMap((page) => page.lines?.map((line) => line.confidence) ?? [])),
215
+ fields,
216
+ provider: "aws-textract"
217
+ };
218
+ }
219
+ const body = await call("DetectDocumentText", { Document }, signal);
220
+ const pages = pagesFrom(body.Blocks ?? []);
221
+ return {
222
+ text: joinPages(pages),
223
+ pages,
224
+ confidence: average(pages.flatMap((page) => page.lines?.map((line) => line.confidence) ?? [])),
225
+ provider: "aws-textract"
226
+ };
227
+ }
228
+ };
229
+ }
230
+ /** Azure AI Document Intelligence (formerly Form Recognizer). */
231
+ function azureDocumentIntelligenceOcr(options) {
232
+ const fetcher = options.fetch ?? fetch;
233
+ const base = options.endpoint.replace(/\/+$/, "");
234
+ const origin = new URL(base).origin;
235
+ const model = options.model ?? "prebuilt-read";
236
+ const version = options.apiVersion ?? "2024-11-30";
237
+ const headers = { "ocp-apim-subscription-key": options.apiKey };
238
+ return {
239
+ name: "azure-document-intelligence",
240
+ async recognize({ data, signal }) {
241
+ const response = await fetcher(`${base}/documentintelligence/documentModels/${encodeURIComponent(model)}:analyze?api-version=${version}`, {
242
+ method: "POST",
243
+ headers: {
244
+ ...headers,
245
+ "content-type": "application/json"
246
+ },
247
+ body: JSON.stringify({ base64Source: toBase64(data) }),
248
+ signal
249
+ });
250
+ if (response.status !== 202) {
251
+ await readJson(response, "Azure Document Intelligence");
252
+ throw new OcrError(`Azure Document Intelligence answered ${response.status}`);
253
+ }
254
+ const location = response.headers.get("operation-location");
255
+ if (!location || new URL(location).origin !== origin) throw new OcrError("Azure Document Intelligence returned an unexpected operation URL");
256
+ const deadline = Date.now() + (options.timeout ?? 6e4);
257
+ let delay = 1e3;
258
+ while (Date.now() < deadline) {
259
+ await sleep(delay, signal);
260
+ const poll = await fetcher(location, {
261
+ headers,
262
+ signal
263
+ });
264
+ const body = await readJson(poll, "Azure Document Intelligence");
265
+ if (body.status === "failed") throw new OcrError(`Azure Document Intelligence: ${body.error?.message ?? "analysis failed"}`);
266
+ if (body.status !== "succeeded") {
267
+ const retry = Number(poll.headers.get("retry-after"));
268
+ delay = Number.isFinite(retry) && retry > 0 ? retry * 1e3 : Math.min(delay * 1.5, 5e3);
269
+ continue;
270
+ }
271
+ const result = body.analyzeResult ?? {};
272
+ const pages = (result.pages ?? []).map((page) => {
273
+ const lines = (page.lines ?? []).map((line) => ({ text: line.content }));
274
+ return {
275
+ number: page.pageNumber,
276
+ lines,
277
+ text: lines.map((line) => line.text).join("\n")
278
+ };
279
+ });
280
+ const fields = {};
281
+ for (const document of result.documents ?? []) for (const [name, field] of Object.entries(document.fields ?? {})) {
282
+ const value = field.valueCurrency?.amount != null ? String(field.valueCurrency.amount) : field.valueDate ?? field.valueString ?? field.content ?? (field.valueNumber != null ? String(field.valueNumber) : void 0);
283
+ if (value == null) continue;
284
+ addField(fields, name, {
285
+ value,
286
+ confidence: field.confidence
287
+ });
288
+ if (field.valueCurrency?.currencyCode && !fields.currency) fields.currency = { value: field.valueCurrency.currencyCode };
289
+ }
290
+ return {
291
+ text: result.content?.trim() ?? joinPages(pages),
292
+ pages,
293
+ confidence: average((result.pages ?? []).flatMap((page) => page.words?.map((word) => word.confidence) ?? [])),
294
+ ...Object.keys(fields).length ? { fields } : {},
295
+ provider: "azure-document-intelligence"
296
+ };
297
+ }
298
+ throw new OcrError("Azure Document Intelligence timed out");
299
+ }
300
+ };
301
+ }
302
+ /** Strips Markdown syntax for the plain `text` field. */
303
+ function markdownToText(markdown) {
304
+ return markdown.replace(/!\[[^\]]*\]\([^)]*\)/g, "").replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/^#{1,6}\s+/gm, "").replace(/(\*\*|__|`)/g, "").replace(/^\|?\s*:?-{3,}.*$/gm, "").replace(/\n{3,}/g, "\n\n").trim();
305
+ }
306
+ /**
307
+ * Mistral OCR. Reads PDFs and images and returns Markdown that keeps
308
+ * headings, lists and tables, which is ideal input for an LLM.
309
+ */
310
+ function mistralOcr(options) {
311
+ return {
312
+ name: "mistral",
313
+ async recognize({ data, type, signal }) {
314
+ const url = `data:${type};base64,${toBase64(data)}`;
315
+ const document = type === "application/pdf" ? {
316
+ type: "document_url",
317
+ document_url: url
318
+ } : {
319
+ type: "image_url",
320
+ image_url: url
321
+ };
322
+ const body = await readJson(await (options.fetch ?? fetch)("https://api.mistral.ai/v1/ocr", {
323
+ method: "POST",
324
+ headers: {
325
+ authorization: `Bearer ${options.apiKey}`,
326
+ "content-type": "application/json"
327
+ },
328
+ body: JSON.stringify({
329
+ model: options.model ?? "mistral-ocr-latest",
330
+ document,
331
+ include_image_base64: false
332
+ }),
333
+ signal
334
+ }), "Mistral OCR");
335
+ const markdownPages = (body.pages ?? []).map((page) => page.markdown ?? "");
336
+ const pages = markdownPages.map((markdown, index) => {
337
+ const text = markdownToText(markdown);
338
+ return {
339
+ number: (body.pages?.[index]?.index ?? index) + 1,
340
+ text,
341
+ lines: text.split("\n").filter((line) => line.trim()).map((line) => ({ text: line }))
342
+ };
343
+ });
344
+ return {
345
+ text: joinPages(pages),
346
+ pages,
347
+ markdown: markdownPages.join("\n\n"),
348
+ provider: "mistral"
349
+ };
350
+ }
351
+ };
352
+ }
353
+ /**
354
+ * Forwards files to your own OCR backend: a Python service running
355
+ * PaddleOCR, docTR, EasyOCR or Tesseract, an internal model, or any other
356
+ * HTTP API.
357
+ */
358
+ function httpOcr(options) {
359
+ const name = options.name ?? "http";
360
+ return {
361
+ name,
362
+ async recognize({ data, type, name: fileName, signal }) {
363
+ const form = new FormData();
364
+ form.append("file", new Blob([data], { type }), fileName ?? "file");
365
+ const body = await readJson(await (options.fetch ?? fetch)(options.url, {
366
+ method: "POST",
367
+ headers: options.headers,
368
+ body: form,
369
+ signal
370
+ }), name);
371
+ const parsed = options.parse ? options.parse(body) : body;
372
+ if (typeof parsed?.text !== "string") throw new OcrError(`${name} returned an unexpected response`);
373
+ const pages = Array.isArray(parsed.pages) ? parsed.pages : [{
374
+ number: 1,
375
+ text: parsed.text
376
+ }];
377
+ return {
378
+ ...parsed,
379
+ pages,
380
+ provider: name
381
+ };
382
+ }
383
+ };
384
+ }
385
+ /** Raster images and PDFs. SVG is deliberately excluded (it can carry script). */
386
+ const OCR_TYPES = [
387
+ "image/png",
388
+ "image/jpeg",
389
+ "image/webp",
390
+ "image/tiff",
391
+ "image/gif",
392
+ "image/bmp",
393
+ "application/pdf"
394
+ ];
395
+ var OcrRouteError = class extends Error {
396
+ status;
397
+ name = "OcrRouteError";
398
+ constructor(message, status = 400) {
399
+ super(message);
400
+ this.status = status;
401
+ }
402
+ };
403
+ const EXTENSION_TYPES = {
404
+ png: "image/png",
405
+ jpg: "image/jpeg",
406
+ jpeg: "image/jpeg",
407
+ webp: "image/webp",
408
+ tif: "image/tiff",
409
+ tiff: "image/tiff",
410
+ gif: "image/gif",
411
+ bmp: "image/bmp",
412
+ pdf: "application/pdf"
413
+ };
414
+ function json(data, status = 200) {
415
+ return new Response(JSON.stringify(data), {
416
+ status,
417
+ headers: {
418
+ "content-type": "application/json",
419
+ "cache-control": "no-store"
420
+ }
421
+ });
422
+ }
423
+ /**
424
+ * A POST endpoint that reads text from a file with any provider. Send
425
+ * `multipart/form-data` with a `file` field (what `ocrEndpoint` in
426
+ * `@uploadcn/core` does), or JSON `{ "key": "…" }` for a stored upload.
427
+ *
428
+ * @example
429
+ * // app/api/ocr/route.ts
430
+ * export const { POST } = createOcrRoute({
431
+ * provider: googleVisionOcr({ apiKey: process.env.GOOGLE_VISION_API_KEY! }),
432
+ * authorize: async ({ request }) => requireUser(request),
433
+ * })
434
+ */
435
+ function createOcrRoute(options) {
436
+ const maxFileSize = options.maxFileSize ?? 20971520;
437
+ const allowedTypes = options.allowedTypes ?? OCR_TYPES;
438
+ const check = (file) => {
439
+ if (file.size > maxFileSize) throw new OcrRouteError("File is too large", 413);
440
+ if (!matchesAccept(file, allowedTypes)) throw new OcrRouteError("This file type can't be read", 415);
441
+ };
442
+ async function handle(request) {
443
+ try {
444
+ if (request.method !== "POST") return json({ error: "Method not allowed" }, 405);
445
+ const contentType = request.headers.get("content-type") ?? "";
446
+ if (Number(request.headers.get("content-length") ?? NaN) > maxFileSize + 65536) throw new OcrRouteError("File is too large", 413);
447
+ let file;
448
+ let data;
449
+ let key;
450
+ if (contentType.startsWith("multipart/form-data")) {
451
+ const auth = await options.authorize?.({ request });
452
+ let form;
453
+ try {
454
+ form = await request.formData();
455
+ } catch {
456
+ throw new OcrRouteError("Expected multipart/form-data");
457
+ }
458
+ const entry = form.get("file");
459
+ if (!(entry instanceof Blob)) throw new OcrRouteError("Missing \"file\"");
460
+ file = {
461
+ name: entry.name?.slice(0, 255) || "file",
462
+ type: entry.type || "application/octet-stream",
463
+ size: entry.size
464
+ };
465
+ check(file);
466
+ data = new Uint8Array(await entry.arrayBuffer());
467
+ return json(await run(request, auth, file, data));
468
+ }
469
+ if (contentType.startsWith("application/json")) {
470
+ if (!options.storage?.getObject) throw new OcrRouteError("Reading stored files isn't enabled", 400);
471
+ let body;
472
+ try {
473
+ body = await request.json();
474
+ } catch {
475
+ throw new OcrRouteError("Expected a JSON body");
476
+ }
477
+ key = body?.key;
478
+ if (typeof key !== "string" || !key || key.length > 1024) throw new OcrRouteError("Invalid \"key\"");
479
+ const auth = await options.authorize?.({
480
+ request,
481
+ key
482
+ });
483
+ const info = await options.storage.headObject(key);
484
+ if (info === null) throw new OcrRouteError("File not found", 404);
485
+ const extension = key.split(".").pop()?.toLowerCase() ?? "";
486
+ file = {
487
+ name: key.split("/").pop() ?? key,
488
+ type: info?.contentType || EXTENSION_TYPES[extension] || "application/octet-stream",
489
+ size: info?.size ?? 0
490
+ };
491
+ check(file);
492
+ const stream = await options.storage.getObject(key);
493
+ if (!stream) throw new OcrRouteError("File not found", 404);
494
+ try {
495
+ data = await readStream(stream, maxFileSize);
496
+ } catch {
497
+ throw new OcrRouteError("File is too large", 413);
498
+ }
499
+ return json(await run(request, auth, {
500
+ ...file,
501
+ size: data.byteLength
502
+ }, data, key));
503
+ }
504
+ throw new OcrRouteError("Send multipart/form-data or JSON", 415);
505
+ } catch (error) {
506
+ if (error instanceof OcrRouteError) return json({ error: error.message }, error.status);
507
+ console.error("[uploadcn] OCR failed", error);
508
+ return json({ error: "Couldn't read text from this file" }, 502);
509
+ }
510
+ }
511
+ async function run(request, auth, file, data, key) {
512
+ const result = await options.provider.recognize({
513
+ data,
514
+ type: file.type,
515
+ name: file.name,
516
+ signal: request.signal
517
+ });
518
+ return await options.onResult?.({
519
+ request,
520
+ auth,
521
+ file,
522
+ key,
523
+ result
524
+ }) ?? result;
525
+ }
526
+ return {
527
+ POST: handle,
528
+ handler: handle
529
+ };
530
+ }
531
+ //#endregion
532
+ export { OCR_TYPES, OcrError, OcrRouteError, awsTextractOcr, azureDocumentIntelligenceOcr, createOcrProvider, createOcrRoute, googleVisionOcr, httpOcr, mistralOcr, toBase64 };
533
+
534
+ //# sourceMappingURL=ocr.js.map