diff --git a/.changeset/paddleocr-vl-provider.md b/.changeset/paddleocr-vl-provider.md new file mode 100644 index 0000000..95c4843 --- /dev/null +++ b/.changeset/paddleocr-vl-provider.md @@ -0,0 +1,5 @@ +--- +"@happyvertical/ocr": minor +--- + +Add the `paddleocr-vl` provider for a served PaddleOCR-VL `/layout-parsing` pipeline. It sends PNG and JPEG unchanged, converts TIFF (every page), WebP, GIF, BMP and raw pixels to PNG, and is selected only by name, never by `auto`. diff --git a/README.md b/README.md index f2c4691..7c89efa 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The `@happyvertical/ocr` package provides a unified Node.js interface for Optica ## Features -- **Multi-Provider Support**: Unified API for Tesseract.js, ONNX-based OCR engines (PaddleOCR PP-OCRv4), HappyVertical AI-backed vision OCR, and served Unlimited-OCR endpoints +- **Multi-Provider Support**: Unified API for Tesseract.js, ONNX-based OCR engines (PaddleOCR PP-OCRv4), HappyVertical AI-backed vision OCR, served Unlimited-OCR endpoints, and served PaddleOCR-VL layout parsing - **Intelligent Fallback**: Automatic provider selection and fallback when primary providers fail - **Node-First Runtime**: Optimized for Node.js packages and server-side workflows - **Environment Detection**: Selects compatible OCR providers based on runtime environment @@ -298,6 +298,35 @@ PY With the `sglang` backend, the provider sends `gundam` image mode for one image and `base` image mode for multi-image/multi-page requests by default. Override with `HAVE_OCR_UNLIMITED_IMAGE_MODE=base` or `gundam` when needed. +### PaddleOCR-VL Provider (Node.js, Served Layout Parsing) + +The `paddleocr-vl` provider sends images to a [PaddleOCR-VL](https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.6) `/layout-parsing` service: the full pipeline, with layout detection and the VL recognizer. `@happyvertical/pdf` reads PDFs through the same service (its `paddleocr` reader provider), so images and PDFs can share one model. + +```typescript +import { getOCR } from '@happyvertical/ocr'; + +const ocr = getOCR({ + provider: 'paddleocr-vl', + providerConfig: { + 'paddleocr-vl': { baseUrl: 'http://paddleocr-vl:8080' }, + }, +}); +const { text } = await ocr.performOCR([{ data: scanBytes }]); +``` + +- **Explicit only.** Images leave the process, so `auto` never selects this provider. Name it (`provider: 'paddleocr-vl'` or `HAVE_OCR_PROVIDER=paddleocr-vl`). +- **One request per page.** PNG and JPEG are sent unchanged. TIFF, WebP, GIF, BMP and raw pixel buffers are converted to PNG with Sharp first, and a multi-page TIFF (typical scanner output) is sent page by page, in order. Sharp loads only when a conversion is needed. Unknown or undecodable input is refused before any request. Remote image URLs are refused, never fetched. +- **Result.** `text` is the service's Markdown, with tables as HTML, and pages are joined by a blank line. `detections` has one entry per page. `confidence` is 100 for any text, because the service reports no score. +- **No retries.** A dropped connection can leave a GPU parse running. Error messages never include the service's response or document text. +- **Timeout.** The default is 65 minutes, which covers an on-demand cold start. `OCROptions.timeout` (and so `HAVE_OCR_TIMEOUT`) is ignored by this provider, because a limit sized for local engines would cut off a cold start. Set the provider's own `timeout` instead. +- `checkDependencies()` checks configuration only. A proxy `/health` success does not mean the model is loaded. + +| Setting | Environment variable (first found wins) | Default | +| --- | --- | --- | +| `baseUrl` | `HAVE_OCR_PADDLEOCR_URL`, `HAVE_PDF_PADDLEOCR_URL` | none (provider unavailable) | +| `apiKey` | `HAVE_OCR_PADDLEOCR_API_KEY`, `HAVE_PDF_PADDLEOCR_API_KEY` | none | +| `timeout` | `HAVE_OCR_PADDLEOCR_TIMEOUT` | `3900000` (65 min) | + ## Advanced Usage ### Multi-Language OCR diff --git a/docs/api/README.md b/docs/api/README.md index 5536cf4..ba31d63 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -71,6 +71,7 @@ result.detections?.forEach(detection => { ## Classes - [LiteLLMProvider](classes/LiteLLMProvider.md) +- [PaddleOCRVLProvider](classes/PaddleOCRVLProvider.md) - [UnlimitedOCRProvider](classes/UnlimitedOCRProvider.md) - [OCRFactory](classes/OCRFactory.md) - [OCRError](classes/OCRError.md) @@ -82,6 +83,7 @@ result.detections?.forEach(detection => { - [OAuth2Config](interfaces/OAuth2Config.md) - [LiteLLMProviderConfig](interfaces/LiteLLMProviderConfig.md) +- [PaddleOCRVLProviderConfig](interfaces/PaddleOCRVLProviderConfig.md) - [UnlimitedOCRProviderConfig](interfaces/UnlimitedOCRProviderConfig.md) - [OCROptions](interfaces/OCROptions.md) - [OCRImage](interfaces/OCRImage.md) diff --git a/docs/api/classes/OCRFactory.md b/docs/api/classes/OCRFactory.md index 5d18085..1302c55 100644 --- a/docs/api/classes/OCRFactory.md +++ b/docs/api/classes/OCRFactory.md @@ -125,7 +125,7 @@ const factory = new OCRFactory(); // Uses env vars > **getBestProvider**(): `Promise`\<[`OCRProvider`](../interfaces/OCRProvider.md) \| `null`\> -Defined in: [src/shared/factory.ts:314](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L314) +Defined in: [src/shared/factory.ts:331](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L331) Get the best available OCR provider for the current environment. @@ -158,7 +158,7 @@ if (provider) { > **performOCR**(`images`, `options?`): `Promise`\<[`OCRResult`](../interfaces/OCRResult.md)\> -Defined in: [src/shared/factory.ts:461](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L461) +Defined in: [src/shared/factory.ts:479](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L479) Perform OCR processing on one or more images. @@ -245,7 +245,7 @@ try { > **getProvidersInfo**(): `Promise`\<[`OCRProviderInfo`](../interfaces/OCRProviderInfo.md)[]\> -Defined in: [src/shared/factory.ts:557](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L557) +Defined in: [src/shared/factory.ts:575](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L575) Get detailed information about all OCR providers. @@ -280,7 +280,7 @@ providers.forEach(provider => { > **isOCRAvailable**(): `Promise`\<`boolean`\> -Defined in: [src/shared/factory.ts:609](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L609) +Defined in: [src/shared/factory.ts:627](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L627) Check if OCR functionality is available in the current environment. @@ -309,7 +309,7 @@ if (await factory.isOCRAvailable()) { > **getSupportedLanguages**(): `Promise`\<`string`[]\> -Defined in: [src/shared/factory.ts:634](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L634) +Defined in: [src/shared/factory.ts:652](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L652) Get array of supported language codes from the best available provider. @@ -341,7 +341,7 @@ const result = await factory.performOCR(images, { > **cleanup**(): `Promise`\<`void`\> -Defined in: [src/shared/factory.ts:671](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L671) +Defined in: [src/shared/factory.ts:689](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L689) Clean up all OCR providers and release their resources. @@ -382,7 +382,7 @@ process.on('SIGINT', async () => { > **addProvider**(`name`, `provider`): `void` -Defined in: [src/shared/factory.ts:709](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L709) +Defined in: [src/shared/factory.ts:727](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L727) Add a custom OCR provider to the factory. @@ -429,7 +429,7 @@ const customFactory = new OCRFactory({ provider: 'custom' }); > **removeProvider**(`name`): `Promise`\<`void`\> -Defined in: [src/shared/factory.ts:727](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L727) +Defined in: [src/shared/factory.ts:745](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L745) Remove an OCR provider from the factory. @@ -461,7 +461,7 @@ await factory.removeProvider('custom'); > **getAvailableProviderNames**(): `string`[] -Defined in: [src/shared/factory.ts:752](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L752) +Defined in: [src/shared/factory.ts:770](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L770) Get array of provider names that have been loaded in the current environment. @@ -490,7 +490,7 @@ console.log('Loaded providers:', providerNames); > **getEnvironment**(): [`OCREnvironment`](../type-aliases/OCREnvironment.md) -Defined in: [src/shared/factory.ts:771](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L771) +Defined in: [src/shared/factory.ts:789](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L789) Get the detected runtime environment. diff --git a/docs/api/classes/PaddleOCRVLProvider.md b/docs/api/classes/PaddleOCRVLProvider.md new file mode 100644 index 0000000..20abfc5 --- /dev/null +++ b/docs/api/classes/PaddleOCRVLProvider.md @@ -0,0 +1,156 @@ +[**@happyvertical/ocr**](../README.md) + +*** + +[@happyvertical/ocr](../README.md) / PaddleOCRVLProvider + +# Class: PaddleOCRVLProvider + +Defined in: [src/node/paddleocr-vl.ts:125](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L125) + +OCR provider for a PaddleOCR-VL `/layout-parsing` service. + +Each image page is one request (`fileType: 1`); results are joined in +order. PNG and JPEG are sent unchanged; TIFF (every page), WebP, GIF, BMP +and raw pixel buffers are converted to PNG with `sharp` first. Requests are +not retried: a dropped connection can leave a GPU parse running. + +## Implements + +- [`OCRProvider`](../interfaces/OCRProvider.md) + +## Constructors + +### Constructor + +> **new PaddleOCRVLProvider**(`config?`): `PaddleOCRVLProvider` + +Defined in: [src/node/paddleocr-vl.ts:132](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L132) + +#### Parameters + +##### config? + +[`PaddleOCRVLProviderConfig`](../interfaces/PaddleOCRVLProviderConfig.md) = `{}` + +#### Returns + +`PaddleOCRVLProvider` + +## Properties + +### name + +> `readonly` **name**: `"paddleocr-vl"` = `'paddleocr-vl'` + +Defined in: [src/node/paddleocr-vl.ts:126](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L126) + +Provider name identifier + +#### Implementation of + +[`OCRProvider`](../interfaces/OCRProvider.md).[`name`](../interfaces/OCRProvider.md#name) + +## Methods + +### performOCR() + +> **performOCR**(`images`, `_options?`): `Promise`\<[`OCRResult`](../interfaces/OCRResult.md)\> + +Defined in: [src/node/paddleocr-vl.ts:153](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L153) + +`options` carries nothing this service uses: it needs no language hint, +and `options.timeout` is ignored because the factory fills it from +`HAVE_OCR_TIMEOUT`, a limit sized for local engines that would cut off a +cold start. Use the provider's own `timeout`. + +#### Parameters + +##### images + +[`OCRImage`](../interfaces/OCRImage.md)[] + +##### \_options? + +[`OCROptions`](../interfaces/OCROptions.md) + +#### Returns + +`Promise`\<[`OCRResult`](../interfaces/OCRResult.md)\> + +#### Implementation of + +[`OCRProvider`](../interfaces/OCRProvider.md).[`performOCR`](../interfaces/OCRProvider.md#performocr) + +*** + +### checkDependencies() + +> **checkDependencies**(): `Promise`\<[`DependencyCheckResult`](../interfaces/DependencyCheckResult.md)\> + +Defined in: [src/node/paddleocr-vl.ts:199](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L199) + +Configuration only. The service is not probed: a proxy `/health` success +does not mean the model is loaded. + +#### Returns + +`Promise`\<[`DependencyCheckResult`](../interfaces/DependencyCheckResult.md)\> + +#### Implementation of + +[`OCRProvider`](../interfaces/OCRProvider.md).[`checkDependencies`](../interfaces/OCRProvider.md#checkdependencies) + +*** + +### checkCapabilities() + +> **checkCapabilities**(): `Promise`\<[`OCRCapabilities`](../interfaces/OCRCapabilities.md)\> + +Defined in: [src/node/paddleocr-vl.ts:227](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L227) + +Get provider capabilities + +#### Returns + +`Promise`\<[`OCRCapabilities`](../interfaces/OCRCapabilities.md)\> + +#### Implementation of + +[`OCRProvider`](../interfaces/OCRProvider.md).[`checkCapabilities`](../interfaces/OCRProvider.md#checkcapabilities) + +*** + +### getSupportedLanguages() + +> **getSupportedLanguages**(): `string`[] + +Defined in: [src/node/paddleocr-vl.ts:252](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L252) + +PaddleOCR-VL recognizes 100+ languages and needs no language hint. + +#### Returns + +`string`[] + +#### Implementation of + +[`OCRProvider`](../interfaces/OCRProvider.md).[`getSupportedLanguages`](../interfaces/OCRProvider.md#getsupportedlanguages) + +*** + +### cleanup() + +> **cleanup**(): `Promise`\<`void`\> + +Defined in: [src/node/paddleocr-vl.ts:256](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L256) + +Clean up provider resources (optional) + +#### Returns + +`Promise`\<`void`\> + +#### Implementation of + +[`OCRProvider`](../interfaces/OCRProvider.md).[`cleanup`](../interfaces/OCRProvider.md#cleanup) diff --git a/docs/api/functions/getAvailableProviders.md b/docs/api/functions/getAvailableProviders.md index 8d03629..728ba1c 100644 --- a/docs/api/functions/getAvailableProviders.md +++ b/docs/api/functions/getAvailableProviders.md @@ -8,7 +8,7 @@ > **getAvailableProviders**(): `Promise`\<`string`[]\> -Defined in: [src/shared/factory.ts:915](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L915) +Defined in: [src/shared/factory.ts:933](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L933) Get list of OCR provider names available in the current environment. diff --git a/docs/api/functions/getOCR.md b/docs/api/functions/getOCR.md index aa2f67c..1538288 100644 --- a/docs/api/functions/getOCR.md +++ b/docs/api/functions/getOCR.md @@ -8,7 +8,7 @@ > **getOCR**(`options?`): [`OCRFactory`](../classes/OCRFactory.md) -Defined in: [src/shared/factory.ts:851](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L851) +Defined in: [src/shared/factory.ts:869](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L869) Get or create an OCR factory instance with automatic provider selection. diff --git a/docs/api/functions/getProviderInfo.md b/docs/api/functions/getProviderInfo.md index 8ef1b06..1e99886 100644 --- a/docs/api/functions/getProviderInfo.md +++ b/docs/api/functions/getProviderInfo.md @@ -8,7 +8,7 @@ > **getProviderInfo**(`providerName`): `Promise`\<[`OCRProviderInfo`](../interfaces/OCRProviderInfo.md) \| `null`\> -Defined in: [src/shared/factory.ts:989](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L989) +Defined in: [src/shared/factory.ts:1007](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L1007) Get detailed information about a specific OCR provider. diff --git a/docs/api/functions/isProviderAvailable.md b/docs/api/functions/isProviderAvailable.md index 4584c89..536c983 100644 --- a/docs/api/functions/isProviderAvailable.md +++ b/docs/api/functions/isProviderAvailable.md @@ -8,7 +8,7 @@ > **isProviderAvailable**(`providerName`): `Promise`\<`boolean`\> -Defined in: [src/shared/factory.ts:943](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L943) +Defined in: [src/shared/factory.ts:961](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L961) Check if a specific OCR provider is available and ready to use. diff --git a/docs/api/functions/resetOCRFactory.md b/docs/api/functions/resetOCRFactory.md index 20e86cf..8a311e7 100644 --- a/docs/api/functions/resetOCRFactory.md +++ b/docs/api/functions/resetOCRFactory.md @@ -8,7 +8,7 @@ > **resetOCRFactory**(): `void` -Defined in: [src/shared/factory.ts:885](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L885) +Defined in: [src/shared/factory.ts:903](https://github.com/happyvertical/ocr/blob/main/src/shared/factory.ts#L903) Reset the global OCR factory instance. diff --git a/docs/api/interfaces/OCRFactoryOptions.md b/docs/api/interfaces/OCRFactoryOptions.md index 571b998..2fe2af5 100644 --- a/docs/api/interfaces/OCRFactoryOptions.md +++ b/docs/api/interfaces/OCRFactoryOptions.md @@ -18,7 +18,7 @@ OCR factory configuration options Defined in: [src/shared/types.ts:203](https://github.com/happyvertical/ocr/blob/main/src/shared/types.ts#L203) -Primary provider to use ('auto', 'tesseract', 'onnx', 'litellm', 'unlimited-ocr', etc.) +Primary provider to use ('auto', 'tesseract', 'onnx', 'litellm', 'unlimited-ocr', 'paddleocr-vl', etc.) *** diff --git a/docs/api/interfaces/PaddleOCRVLProviderConfig.md b/docs/api/interfaces/PaddleOCRVLProviderConfig.md new file mode 100644 index 0000000..6ca0325 --- /dev/null +++ b/docs/api/interfaces/PaddleOCRVLProviderConfig.md @@ -0,0 +1,44 @@ +[**@happyvertical/ocr**](../README.md) + +*** + +[@happyvertical/ocr](../README.md) / PaddleOCRVLProviderConfig + +# Interface: PaddleOCRVLProviderConfig + +Defined in: [src/node/paddleocr-vl.ts:27](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L27) + +Configuration for the PaddleOCR-VL provider. Each field falls back to +`HAVE_OCR_PADDLEOCR_*`, then to the `HAVE_PDF_PADDLEOCR_*` setting +`@happyvertical/pdf` uses, so one service setting serves both packages. + +## Properties + +### baseUrl? + +> `optional` **baseUrl?**: `string` + +Defined in: [src/node/paddleocr-vl.ts:29](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L29) + +Service root, e.g. `http://paddleocr-vl:8080` (the `/layout-parsing` path is appended). + +*** + +### apiKey? + +> `optional` **apiKey?**: `string` + +Defined in: [src/node/paddleocr-vl.ts:31](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L31) + +Optional bearer token for deployments behind an authenticating proxy. + +*** + +### timeout? + +> `optional` **timeout?**: `number` + +Defined in: [src/node/paddleocr-vl.ts:36](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L36) + +Deadline for each page request, including an on-demand cold start and the +response body. Default: 65 minutes. diff --git a/src/index.ts b/src/index.ts index 401290a..67d6dbd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -89,7 +89,9 @@ export type { } from './node/litellm'; // Export LiteLLM provider for direct instantiation export { LiteLLMProvider } from './node/litellm'; - +export type { PaddleOCRVLProviderConfig } from './node/paddleocr-vl'; +// Export PaddleOCR-VL provider for direct instantiation (loads sharp lazily) +export { PaddleOCRVLProvider } from './node/paddleocr-vl'; export type { UnlimitedOCRBackend, UnlimitedOCRImageMode, diff --git a/src/node/paddleocr-vl.spec.ts b/src/node/paddleocr-vl.spec.ts new file mode 100644 index 0000000..db844a5 --- /dev/null +++ b/src/node/paddleocr-vl.spec.ts @@ -0,0 +1,347 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createServer, type Server, type ServerResponse } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import sharp from 'sharp'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { getOCR } from '../shared/factory'; +import { OCRProcessingError } from '../shared/types'; +import { PaddleOCRVLProvider } from './paddleocr-vl'; + +const envelope = (text: string) => ({ + errorCode: 0, + result: { layoutParsingResults: [{ markdown: { text }, prunedResult: {} }] }, +}); + +let server: Server; +let baseUrl: string; +let calls: { url: string; body: Record; auth?: string }[]; +let respond: (res: ServerResponse, n: number) => void; + +const json = (res: ServerResponse, value: unknown) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(value)); +}; +const solid = (width = 32, height = 16) => + sharp({ + create: { width, height, channels: 3, background: '#ffffff' }, + }); +const kind = (call: { body: Record }) => + Buffer.from(String(call.body.file), 'base64') + .subarray(1, 4) + .toString('ascii'); + +beforeEach(async () => { + for (const name of [ + 'HAVE_OCR_PADDLEOCR_URL', + 'HAVE_OCR_PADDLEOCR_API_KEY', + 'HAVE_OCR_PADDLEOCR_TIMEOUT', + 'HAVE_PDF_PADDLEOCR_URL', + 'HAVE_PDF_PADDLEOCR_API_KEY', + 'HAVE_OCR_PROVIDER', + 'HAVE_OCR_TIMEOUT', + ]) { + vi.stubEnv(name, ''); + } + calls = []; + respond = (res, n) => json(res, envelope(`page ${n}`)); + server = createServer(async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + calls.push({ + url: req.url ?? '', + body: JSON.parse(Buffer.concat(chunks).toString()), + auth: req.headers.authorization, + }); + respond(res, calls.length); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address() as import('node:net').AddressInfo; + baseUrl = `http://127.0.0.1:${address.port}`; +}); + +afterEach(async () => { + vi.unstubAllEnvs(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); +}); + +describe('PaddleOCRVLProvider', () => { + test('sends a PNG unchanged to /layout-parsing as an image and returns its Markdown', async () => { + const png = await solid().png().toBuffer(); + const provider = new PaddleOCRVLProvider({ + baseUrl: `${baseUrl}/root/`, + apiKey: 'k', + }); + + const result = await provider.performOCR([{ data: png }]); + + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe('/root/layout-parsing'); + expect(calls[0].auth).toBe('Bearer k'); + expect(calls[0].body).toEqual({ + file: png.toString('base64'), + fileType: 1, + useLayoutDetection: true, + visualize: false, + }); + expect(result).toMatchObject({ + text: 'page 1', + confidence: 100, + detections: [{ text: 'page 1', confidence: 100 }], + metadata: { provider: 'paddleocr-vl', pages: 1 }, + }); + }); + + test('passes JPEG through and keeps image order', async () => { + const jpeg = await solid().jpeg().toBuffer(); + const png = await solid().png().toBuffer(); + const provider = new PaddleOCRVLProvider({ baseUrl }); + + const result = await provider.performOCR([{ data: jpeg }, { data: png }]); + + expect(result.text).toBe('page 1\n\npage 2'); + expect(Buffer.from(String(calls[0].body.file), 'base64').equals(jpeg)).toBe( + true, + ); + expect(result.detections).toHaveLength(2); + }); + + test('sends every page of a multi-page TIFF, in order, as PNG', async () => { + // Same size (TIFF frames are padded to one size); told apart by colour. + const colours = ['#ff0000', '#00ff00', '#0000ff']; + const pages = await Promise.all( + colours.map((background) => + sharp({ create: { width: 30, height: 10, channels: 3, background } }) + .png() + .toBuffer(), + ), + ); + const tiff = await sharp(pages, { join: { animated: true } }) + .tiff() + .toBuffer(); + expect((await sharp(tiff).metadata()).pages).toBe(3); + const provider = new PaddleOCRVLProvider({ baseUrl }); + + const result = await provider.performOCR([{ data: tiff }]); + + expect(result.text).toBe('page 1\n\npage 2\n\npage 3'); + expect(result.metadata?.pages).toBe(3); + expect(calls.map(kind)).toEqual(['PNG', 'PNG', 'PNG']); + const dominant = await Promise.all( + calls.map(async (call) => { + const { channels } = await sharp( + Buffer.from(String(call.body.file), 'base64'), + ).stats(); + return channels.slice(0, 3).findIndex((c) => c.mean > 200); + }), + ); + expect(dominant).toEqual([0, 1, 2]); + }); + + test('converts WebP and raw pixels to PNG', async () => { + const webp = await solid().webp().toBuffer(); + const raw = new Uint8Array(4 * 2 * 3).fill(255); + const provider = new PaddleOCRVLProvider({ baseUrl }); + + await provider.performOCR([ + { data: webp }, + { data: raw, width: 4, height: 2, channels: 3 }, + ]); + + expect(calls.map(kind)).toEqual(['PNG', 'PNG']); + }); + + test('reads base64, data URLs and local paths, and refuses remote URLs', async () => { + const png = await solid().png().toBuffer(); + const dir = await mkdtemp(join(tmpdir(), 'paddleocr-vl-')); + try { + const path = join(dir, 'scan.png'); + await writeFile(path, png); + const provider = new PaddleOCRVLProvider({ baseUrl }); + + await provider.performOCR([ + { data: png.toString('base64') }, + { data: `data:image/png;base64,${png.toString('base64')}` }, + { data: path }, + ]); + expect(calls).toHaveLength(3); + for (const call of calls) { + expect(call.body.file).toBe(png.toString('base64')); + } + + await expect( + provider.performOCR([{ data: 'https://example.com/a.png' }]), + ).rejects.toThrow(/URLs are not supported/); + expect(calls).toHaveLength(3); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test('refuses unknown and undecodable input before any request', async () => { + const provider = new PaddleOCRVLProvider({ baseUrl }); + await expect( + provider.performOCR([{ data: new Uint8Array([1, 2, 3, 4]) }]), + ).rejects.toThrow(/Unsupported image/); + const brokenTiff = Buffer.from([0x49, 0x49, 0x2a, 0x00, 1, 2, 3]); + await expect(provider.performOCR([{ data: brokenTiff }])).rejects.toThrow( + /could not be decoded \(image\/tiff\)/, + ); + expect(calls).toHaveLength(0); + }); + + test('returns an empty result for no images without a request', async () => { + const result = await new PaddleOCRVLProvider({ baseUrl }).performOCR([]); + expect(result).toMatchObject({ text: '', confidence: 0 }); + expect(calls).toHaveLength(0); + }); + + test('maps HTTP, API and envelope failures without echoing the response', async () => { + const png = await solid().png().toBuffer(); + const provider = new PaddleOCRVLProvider({ baseUrl }); + + respond = (res) => { + res.statusCode = 503; + res.end('secret document text'); + }; + const http = await provider.performOCR([{ data: png }]).catch((e) => e); + expect(http).toBeInstanceOf(OCRProcessingError); + expect(http.message).toContain('(503)'); + expect(http.message).not.toContain('secret'); + + respond = (res) => + json(res, { errorCode: 500, errorMsg: 'secret document text' }); + const api = await provider.performOCR([{ data: png }]).catch((e) => e); + expect(api.message).toContain('parsing failure'); + expect(api.message).not.toContain('secret'); + + respond = (res) => json(res, { errorCode: 0, result: {} }); + await expect(provider.performOCR([{ data: png }])).rejects.toThrow( + /Invalid PaddleOCR-VL response/, + ); + expect(calls).toHaveLength(3); + }); + + test('times out with its own deadline and ignores HAVE_OCR_TIMEOUT', async () => { + const png = await solid().png().toBuffer(); + respond = (res, n) => { + setTimeout(() => json(res, envelope(`page ${n}`)), 60); + }; + + const quick = new PaddleOCRVLProvider({ baseUrl, timeout: 10 }); + await expect(quick.performOCR([{ data: png }])).rejects.toThrow( + /timed out after 10ms/, + ); + + vi.stubEnv('HAVE_OCR_TIMEOUT', '1'); + vi.stubEnv('HAVE_OCR_PADDLEOCR_URL', baseUrl); + const viaFactory = getOCR({ provider: 'paddleocr-vl' }); + const result = await viaFactory.performOCR([{ data: png }]); + expect(result.text).toMatch(/^page \d$/); + }); + + test('takes its settings from HAVE_OCR_PADDLEOCR_*, then HAVE_PDF_PADDLEOCR_*', async () => { + const png = await solid().png().toBuffer(); + vi.stubEnv('HAVE_PDF_PADDLEOCR_URL', baseUrl); + vi.stubEnv('HAVE_PDF_PADDLEOCR_API_KEY', 'pdf-key'); + await new PaddleOCRVLProvider().performOCR([{ data: png }]); + expect(calls[0].auth).toBe('Bearer pdf-key'); + + vi.stubEnv('HAVE_OCR_PADDLEOCR_URL', `${baseUrl}/ocr`); + vi.stubEnv('HAVE_OCR_PADDLEOCR_API_KEY', 'ocr-key'); + await new PaddleOCRVLProvider().performOCR([{ data: png }]); + expect(calls[1]).toMatchObject({ + url: '/ocr/layout-parsing', + auth: 'Bearer ocr-key', + }); + }); + + test('reports unavailable without a valid base URL, without probing', async () => { + const none = await new PaddleOCRVLProvider().checkDependencies(); + expect(none.available).toBe(false); + expect(none.error).toContain('HAVE_OCR_PADDLEOCR_URL'); + + const bad = await new PaddleOCRVLProvider({ + baseUrl: 'http://user:pw@host/', + }).checkDependencies(); + expect(bad.available).toBe(false); + + const ok = await new PaddleOCRVLProvider({ baseUrl }).checkDependencies(); + expect(ok.available).toBe(true); + expect(calls).toHaveLength(0); + }); + + test('is selected by name through the factory and never by auto', async () => { + const png = await solid().png().toBuffer(); + vi.stubEnv('HAVE_OCR_PADDLEOCR_URL', baseUrl); + + const named = getOCR({ provider: 'paddleocr-vl' }); + const result = await named.performOCR([{ data: png }]); + expect(result.metadata?.provider).toBe('paddleocr-vl'); + + const configured = getOCR({ + provider: 'paddleocr-vl', + providerConfig: { 'paddleocr-vl': { baseUrl: `${baseUrl}/cfg` } }, + }); + await configured.performOCR([{ data: png }]); + expect(calls[1].url).toBe('/cfg/layout-parsing'); + + const auto = getOCR({ provider: 'auto' }) as unknown as { + getProviderPriority(): string[]; + }; + expect(auto.getProviderPriority()).not.toContain('paddleocr-vl'); + expect(calls).toHaveLength(2); + }); + + test('sends only the first frame of an animated GIF', async () => { + const frames = await Promise.all( + ['#ff0000', '#00ff00', '#0000ff'].map((background) => + sharp({ create: { width: 20, height: 10, channels: 3, background } }) + .png() + .toBuffer(), + ), + ); + const gif = await sharp(frames, { join: { animated: true } }) + .gif() + .toBuffer(); + expect((await sharp(gif).metadata()).pages).toBe(3); + + const result = await new PaddleOCRVLProvider({ baseUrl }).performOCR([ + { data: gif }, + ]); + + expect(calls).toHaveLength(1); + expect(result.metadata?.pages).toBe(1); + }); + + test('refuses 16-bit or mis-sized raw pixels, and treats declared raw as raw', async () => { + const provider = new PaddleOCRVLProvider({ baseUrl }); + await expect( + provider.performOCR([ + { + data: new Uint8Array(4 * 4 * 2), + width: 4, + height: 4, + channels: 1, + bitsPerComponent: 16, + }, + ]), + ).rejects.toThrow(/8-bit samples/); + await expect( + provider.performOCR([ + { data: new Uint8Array(5), width: 4, height: 4, channels: 1 }, + ]), + ).rejects.toThrow(/width x height x channels/); + expect(calls).toHaveLength(0); + + // Starts with "BM", which would sniff as BMP. + const raw = new Uint8Array(2 * 2 * 3).fill(255); + raw[0] = 0x42; + raw[1] = 0x4d; + await provider.performOCR([ + { data: raw, width: 2, height: 2, channels: 3 }, + ]); + expect(calls.map(kind)).toEqual(['PNG']); + }); +}); diff --git a/src/node/paddleocr-vl.ts b/src/node/paddleocr-vl.ts new file mode 100644 index 0000000..bd17305 --- /dev/null +++ b/src/node/paddleocr-vl.ts @@ -0,0 +1,448 @@ +/** + * @happyvertical/ocr - PaddleOCR-VL provider for a served layout-parsing pipeline + * + * Sends each image page to a PaddleOCR-VL `/layout-parsing` service (the full + * pipeline: layout detection plus the VL recognizer), the same service + * `@happyvertical/pdf` reads PDFs through. It is never part of `auto` + * selection: images leave the process, so a caller must name it. + */ + +import { existsSync, readFileSync } from 'node:fs'; + +import type { + DependencyCheckResult, + OCRCapabilities, + OCRImage, + OCROptions, + OCRProvider, + OCRResult, +} from '../shared/types'; +import { OCRProcessingError } from '../shared/types'; + +/** + * Configuration for the PaddleOCR-VL provider. Each field falls back to + * `HAVE_OCR_PADDLEOCR_*`, then to the `HAVE_PDF_PADDLEOCR_*` setting + * `@happyvertical/pdf` uses, so one service setting serves both packages. + */ +export interface PaddleOCRVLProviderConfig { + /** Service root, e.g. `http://paddleocr-vl:8080` (the `/layout-parsing` path is appended). */ + baseUrl?: string; + /** Optional bearer token for deployments behind an authenticating proxy. */ + apiKey?: string; + /** + * Deadline for each page request, including an on-demand cold start and the + * response body. Default: 65 minutes. + */ + timeout?: number; +} + +const DEFAULT_TIMEOUT_MS = 65 * 60 * 1000; +const PASSTHROUGH = new Set(['image/png', 'image/jpeg']); + +function env(...names: string[]): string | undefined { + for (const name of names) { + const value = process.env[name]?.trim(); + if (value) return value; + } + return undefined; +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +type Sharp = typeof import('sharp').default; + +/** + * Load sharp only when a conversion is needed, so discovery and PNG/JPEG + * requests never load the native addon. The specifier is kept opaque so + * browser and SSR bundlers do not pull sharp into a consumer's graph (the same + * pattern as the factory's `loadNodeOCRModule`). + */ +async function loadSharp(): Promise { + const specifier = ['sh', 'arp'].join(''); + const mod = (await import(/* @vite-ignore */ specifier)) as { + default?: Sharp; + } & Sharp; + return mod.default ?? mod; +} + +/** Recognize an encoded image by its signature. */ +function sniff(data: Uint8Array): string | null { + const at = (i: number) => data[i]; + if ( + data.length >= 8 && + at(0) === 0x89 && + at(1) === 0x50 && + at(2) === 0x4e && + at(3) === 0x47 + ) { + return 'image/png'; + } + if (data.length >= 3 && at(0) === 0xff && at(1) === 0xd8 && at(2) === 0xff) + return 'image/jpeg'; + if ( + data.length >= 4 && + ((at(0) === 0x49 && at(1) === 0x49 && at(2) === 0x2a && at(3) === 0x00) || + (at(0) === 0x4d && at(1) === 0x4d && at(2) === 0x00 && at(3) === 0x2a)) + ) { + return 'image/tiff'; + } + if ( + data.length >= 12 && + at(0) === 0x52 && + at(1) === 0x49 && + at(2) === 0x46 && + at(3) === 0x46 && + at(8) === 0x57 && + at(9) === 0x45 && + at(10) === 0x42 && + at(11) === 0x50 + ) { + return 'image/webp'; + } + if ( + data.length >= 4 && + at(0) === 0x47 && + at(1) === 0x49 && + at(2) === 0x46 && + at(3) === 0x38 + ) { + return 'image/gif'; + } + if (data.length >= 2 && at(0) === 0x42 && at(1) === 0x4d) return 'image/bmp'; + return null; +} + +/** + * OCR provider for a PaddleOCR-VL `/layout-parsing` service. + * + * Each image page is one request (`fileType: 1`); results are joined in + * order. PNG and JPEG are sent unchanged; TIFF (every page), WebP, GIF, BMP + * and raw pixel buffers are converted to PNG with `sharp` first. Requests are + * not retried: a dropped connection can leave a GPU parse running. + */ +export class PaddleOCRVLProvider implements OCRProvider { + readonly name = 'paddleocr-vl'; + + private readonly baseUrl?: string; + private readonly apiKey?: string; + private readonly timeout: number; + + constructor(config: PaddleOCRVLProviderConfig = {}) { + this.baseUrl = + config.baseUrl ?? env('HAVE_OCR_PADDLEOCR_URL', 'HAVE_PDF_PADDLEOCR_URL'); + this.apiKey = + config.apiKey ?? + env('HAVE_OCR_PADDLEOCR_API_KEY', 'HAVE_PDF_PADDLEOCR_API_KEY'); + const timeout = + config.timeout ?? + Number.parseInt(env('HAVE_OCR_PADDLEOCR_TIMEOUT') ?? '', 10); + this.timeout = + Number.isInteger(timeout) && timeout > 0 && timeout <= 2_147_483_647 + ? timeout + : DEFAULT_TIMEOUT_MS; + } + + /** + * `options` carries nothing this service uses: it needs no language hint, + * and `options.timeout` is ignored because the factory fills it from + * `HAVE_OCR_TIMEOUT`, a limit sized for local engines that would cut off a + * cold start. Use the provider's own `timeout`. + */ + async performOCR( + images: OCRImage[], + _options?: OCROptions, + ): Promise { + const started = Date.now(); + if (!images || images.length === 0) { + return { + text: '', + confidence: 0, + detections: [], + metadata: { processingTime: 0, provider: this.name, pages: 0 }, + }; + } + + const endpoint = this.endpoint(); + const pages: Uint8Array[] = []; + for (const image of images) { + pages.push(...(await this.toPages(image))); + } + + const texts: string[] = []; + for (const page of pages) { + texts.push(await this.parsePage(endpoint, page)); + } + + const text = texts.join('\n\n').trim(); + return { + text, + confidence: text ? 100 : 0, + detections: texts + .map((pageText) => pageText.trim()) + .filter(Boolean) + .map((pageText) => ({ text: pageText, confidence: 100 })), + metadata: { + processingTime: Date.now() - started, + provider: this.name, + pages: pages.length, + outputMode: 'markdown', + }, + }; + } + + /** + * Configuration only. The service is not probed: a proxy `/health` success + * does not mean the model is loaded. + */ + async checkDependencies(): Promise { + if (!this.baseUrl) { + return { + available: false, + error: + 'PaddleOCR-VL base URL is not configured. Set HAVE_OCR_PADDLEOCR_URL or HAVE_PDF_PADDLEOCR_URL.', + details: { baseUrl: false }, + }; + } + try { + this.endpoint(); + } catch (error) { + return { + available: false, + error: (error as Error).message, + details: { baseUrl: false }, + }; + } + return { + available: true, + details: { + baseUrl: true, + apiKey: Boolean(this.apiKey), + timeout: this.timeout, + }, + }; + } + + async checkCapabilities(): Promise { + return { + canPerformOCR: true, + supportedLanguages: this.getSupportedLanguages(), + supportedFormats: [ + 'png', + 'jpg', + 'jpeg', + 'tiff', + 'tif', + 'webp', + 'gif', + 'bmp', + ], + hasConfidenceScores: false, + hasBoundingBoxes: false, + providerSpecific: { + servedEndpoint: true, + layoutParsing: true, + outputMode: 'markdown', + }, + }; + } + + /** PaddleOCR-VL recognizes 100+ languages and needs no language hint. */ + getSupportedLanguages(): string[] { + return ['eng', 'fra', 'spa', 'deu', 'chi_sim', 'chi_tra', 'jpn', 'kor']; + } + + async cleanup(): Promise { + // Stateless HTTP provider; nothing to dispose. + } + + private endpoint(): URL { + if (!this.baseUrl) { + throw new OCRProcessingError( + this.name, + 'PaddleOCR-VL base URL is not configured', + ); + } + let url: URL; + try { + url = new URL(this.baseUrl); + } catch { + url = new URL('invalid:'); + } + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash + ) { + throw new OCRProcessingError( + this.name, + 'PaddleOCR-VL base URL must be an HTTP(S) service root without credentials, query or fragment', + ); + } + url.pathname = `${url.pathname.replace(/\/+$/, '')}/layout-parsing`; + return url; + } + + /** Bytes of one input image, before any conversion. */ + private bytesOf(image: OCRImage): Uint8Array { + const { data } = image; + if (typeof data !== 'string') return data; + if (data.startsWith('data:')) { + return Buffer.from(data.slice(data.indexOf(',') + 1), 'base64'); + } + if (/^https?:\/\//i.test(data)) { + // Remote URLs are never fetched: the provider must not reach out on a + // caller's behalf beyond the configured service. + throw new OCRProcessingError( + this.name, + 'Image URLs are not supported; pass bytes, base64 or a local path', + ); + } + if (existsSync(data)) return readFileSync(data); + return Buffer.from(data, 'base64'); + } + + /** One PNG or JPEG per page: multi-page TIFFs expand to every page. */ + private async toPages(image: OCRImage): Promise { + const bytes = this.bytesOf(image); + const { width, height, channels } = image; + + // Raw pixels are identified by their declared shape, not by sniffing: + // a pixel buffer can start with bytes that look like a BMP or GIF header. + const declaresRaw = + typeof width === 'number' && + typeof height === 'number' && + typeof channels === 'number'; + const isRaw = + declaresRaw && + [1, 2, 3, 4].includes(channels) && + bytes.byteLength === width * height * channels && + (image.bitsPerComponent === undefined || image.bitsPerComponent === 8); + const type = isRaw ? null : sniff(bytes); + if (type && PASSTHROUGH.has(type)) return [bytes]; + if (!type && !isRaw) { + throw new OCRProcessingError( + this.name, + declaresRaw + ? 'Unsupported raw pixels: expected 8-bit samples, 1 to 4 channels, and width x height x channels bytes' + : 'Unsupported image: expected PNG, JPEG, TIFF, WebP, GIF, BMP or raw pixels with dimensions', + ); + } + + const sharp = await loadSharp(); + try { + if (isRaw) { + return [ + await sharp(Buffer.from(bytes), { + raw: { + width: width as number, + height: height as number, + channels: channels as 1 | 2 | 3 | 4, + }, + }) + .png() + .toBuffer(), + ]; + } + // Only TIFF pages are document pages. For animated GIF or WebP, + // `pages` counts frames, so only the first frame is sent. + const { pages = 1 } = + type === 'image/tiff' + ? await sharp(Buffer.from(bytes)).metadata() + : { pages: 1 }; + const out: Uint8Array[] = []; + for (let page = 0; page < pages; page++) { + out.push(await sharp(Buffer.from(bytes), { page }).png().toBuffer()); + } + return out; + } catch { + // Decoder messages can name file details; report a plain refusal. + throw new OCRProcessingError( + this.name, + `Image could not be decoded (${type ?? 'raw pixels'})`, + ); + } + } + + private async parsePage(endpoint: URL, page: Uint8Array): Promise { + const timeout = this.timeout; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + timer.unref?.(); + try { + const response = await fetch(endpoint, { + method: 'POST', + redirect: 'error', + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}), + }, + body: JSON.stringify({ + file: Buffer.from( + page.buffer, + page.byteOffset, + page.byteLength, + ).toString('base64'), + fileType: 1, + useLayoutDetection: true, + visualize: false, + }), + }); + if (!response.ok) { + await response.body?.cancel(); + throw new OCRProcessingError( + this.name, + `PaddleOCR-VL request failed (${response.status})`, + { status: response.status }, + ); + } + return this.readMarkdown(await response.json().catch(() => undefined)); + } catch (error) { + if (controller.signal.aborted) { + throw new OCRProcessingError( + this.name, + `PaddleOCR-VL request timed out after ${timeout}ms`, + ); + } + if (error instanceof OCRProcessingError) throw error; + // Network causes can carry URLs or payload fragments. + throw new OCRProcessingError(this.name, 'PaddleOCR-VL request failed'); + } finally { + clearTimeout(timer); + } + } + + /** Validate the service envelope and return the page's Markdown. */ + private readMarkdown(value: unknown): string { + const invalid = () => + new OCRProcessingError(this.name, 'Invalid PaddleOCR-VL response'); + if (!record(value) || typeof value.errorCode !== 'number') throw invalid(); + if (value.errorCode !== 0) { + // Never echo the service's message: it can contain document text. + throw new OCRProcessingError( + this.name, + 'PaddleOCR-VL reported a parsing failure', + ); + } + const result = value.result; + if (!record(result) || !Array.isArray(result.layoutParsingResults)) { + throw invalid(); + } + return result.layoutParsingResults + .map((page) => { + if ( + !record(page) || + !record(page.markdown) || + typeof page.markdown.text !== 'string' + ) { + throw invalid(); + } + return page.markdown.text; + }) + .join('\n\n'); + } +} diff --git a/src/shared/factory.ts b/src/shared/factory.ts index 49705fc..1c216c9 100644 --- a/src/shared/factory.ts +++ b/src/shared/factory.ts @@ -273,6 +273,23 @@ export class OCRFactory { } catch { // Ignore if Unlimited-OCR provider fails to load } + + // PaddleOCR-VL provider (Node.js only) - a served layout-parsing + // pipeline. Explicit only: it is not in the auto priority list. + try { + const { PaddleOCRVLProvider } = await import( + '../node/paddleocr-vl.js' + ); + const providerOptions = this.providerConfig['paddleocr-vl'] as + | ConstructorParameters[0] + | undefined; + this.providers.set( + 'paddleocr-vl', + new PaddleOCRVLProvider(providerOptions), + ); + } catch { + // Ignore if the PaddleOCR-VL provider fails to load + } } else if (this.environment === 'browser') { // Browser-specific providers try { @@ -335,7 +352,8 @@ export class OCRFactory { */ private getDefaultProviderPriority(): string[] { if (this.environment === 'node') { - // LiteLLM last since it's API-based (network latency, costs money) + // LiteLLM last since it's API-based (network latency, costs money). + // paddleocr-vl is deliberately absent: it must be named explicitly. return ['onnx', 'tesseract', 'unlimited-ocr', 'litellm']; } if (this.environment === 'browser') { diff --git a/src/shared/types.ts b/src/shared/types.ts index 716ad6e..03f549c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -199,7 +199,7 @@ export interface OCRProvider { * OCR factory configuration options */ export interface OCRFactoryOptions { - /** Primary provider to use ('auto', 'tesseract', 'onnx', 'litellm', 'unlimited-ocr', etc.) */ + /** Primary provider to use ('auto', 'tesseract', 'onnx', 'litellm', 'unlimited-ocr', 'paddleocr-vl', etc.) */ provider?: string; /** Fallback providers to try if primary fails */ fallbackProviders?: string[];