@uploadcn/server 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +38 -2
- package/dist/fs.d.ts +36 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +216 -0
- package/dist/fs.js.map +1 -0
- package/dist/index.d.ts +193 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +579 -0
- package/dist/index.js.map +1 -0
- package/dist/ocr.d.ts +154 -0
- package/dist/ocr.d.ts.map +1 -0
- package/dist/ocr.js +534 -0
- package/dist/ocr.js.map +1 -0
- package/dist/scan-B8LojfB0.d.ts +114 -0
- package/dist/scan-B8LojfB0.d.ts.map +1 -0
- package/dist/scan.d.ts +2 -0
- package/dist/scan.js +290 -0
- package/dist/scan.js.map +1 -0
- package/dist/signing-DZiuKRaJ.js +52 -0
- package/dist/signing-DZiuKRaJ.js.map +1 -0
- package/dist/storage-CGGIhRB9.d.ts +77 -0
- package/dist/storage-CGGIhRB9.d.ts.map +1 -0
- package/package.json +56 -3
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
|