From 23a88f5236c016d04fe64dfb945632044bff02c9 Mon Sep 17 00:00:00 2001 From: chadtalsma-svg <332990041+chadtalsma-svg@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:44:23 -0600 Subject: [PATCH 1/2] feat(ocr): add the PaddleOCR-VL provider (#146) paddleocr-vl sends each image page to a PaddleOCR-VL /layout-parsing service (fileType 1). PNG and JPEG pass through; TIFF (every page), WebP, GIF, BMP and raw pixels are converted to PNG with sharp, loaded only when needed. Explicit only, never in auto; no retries; errors never echo the response. Settings fall back from HAVE_OCR_PADDLEOCR_* to HAVE_PDF_PADDLEOCR_*. Closes #146 Co-Authored-By: Claude Opus 5.5 --- .changeset/paddleocr-vl-provider.md | 5 + README.md | 31 +- docs/api/README.md | 2 + docs/api/classes/OCRFactory.md | 20 +- docs/api/classes/PaddleOCRVLProvider.md | 156 +++++++ docs/api/functions/getAvailableProviders.md | 2 +- docs/api/functions/getOCR.md | 2 +- docs/api/functions/getProviderInfo.md | 2 +- docs/api/functions/isProviderAvailable.md | 2 +- docs/api/functions/resetOCRFactory.md | 2 +- docs/api/interfaces/OCRFactoryOptions.md | 2 +- .../interfaces/PaddleOCRVLProviderConfig.md | 44 ++ src/index.ts | 4 +- src/node/paddleocr-vl.spec.ts | 296 ++++++++++++ src/node/paddleocr-vl.ts | 421 ++++++++++++++++++ src/shared/factory.ts | 20 +- src/shared/types.ts | 2 +- 17 files changed, 993 insertions(+), 20 deletions(-) create mode 100644 .changeset/paddleocr-vl-provider.md create mode 100644 docs/api/classes/PaddleOCRVLProvider.md create mode 100644 docs/api/interfaces/PaddleOCRVLProviderConfig.md create mode 100644 src/node/paddleocr-vl.spec.ts create mode 100644 src/node/paddleocr-vl.ts 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..a8a68bf --- /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:109](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L109) + +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:116](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L116) + +#### Parameters + +##### config? + +[`PaddleOCRVLProviderConfig`](../interfaces/PaddleOCRVLProviderConfig.md) = `{}` + +#### Returns + +`PaddleOCRVLProvider` + +## Properties + +### name + +> `readonly` **name**: `"paddleocr-vl"` = `'paddleocr-vl'` + +Defined in: [src/node/paddleocr-vl.ts:110](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L110) + +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:137](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L137) + +`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:183](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L183) + +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:211](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L211) + +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:236](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L236) + +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:240](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L240) + +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..c9bf1d5 --- /dev/null +++ b/src/node/paddleocr-vl.spec.ts @@ -0,0 +1,296 @@ +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); + }); +}); diff --git a/src/node/paddleocr-vl.ts b/src/node/paddleocr-vl.ts new file mode 100644 index 0000000..b443ec4 --- /dev/null +++ b/src/node/paddleocr-vl.ts @@ -0,0 +1,421 @@ +/** + * @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); +} + +/** 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 type = sniff(bytes); + if (type && PASSTHROUGH.has(type)) return [bytes]; + + const isRaw = + !type && + typeof image.width === 'number' && + typeof image.height === 'number' && + typeof image.channels === 'number'; + if (!type && !isRaw) { + throw new OCRProcessingError( + this.name, + 'Unsupported image: expected PNG, JPEG, TIFF, WebP, GIF, BMP or raw pixels with dimensions', + ); + } + + // Loaded only when a conversion is needed, so discovery and PNG/JPEG + // requests never load the native addon. + const { default: sharp } = await import('sharp'); + try { + if (isRaw) { + const channels = image.channels as 1 | 2 | 3 | 4; + return [ + await sharp(Buffer.from(bytes), { + raw: { + width: image.width as number, + height: image.height as number, + channels, + }, + }) + .png() + .toBuffer(), + ]; + } + const { pages = 1 } = await sharp(Buffer.from(bytes)).metadata(); + 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[]; From 0b89579c97220545e70f4b13ab6333fb5f5f8ed6 Mon Sep 17 00:00:00 2001 From: chadtalsma-svg <332990041+chadtalsma-svg@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:47:18 -0600 Subject: [PATCH 2/2] fix(ocr): keep sharp out of bundles and tighten paddleocr-vl input (#146) Load sharp through an opaque specifier so bundlers never resolve it, send only the first frame of an animated GIF or WebP (only TIFF pages are document pages), refuse raw pixels that are not 8-bit or do not match their declared size, and treat declared raw pixels as raw before sniffing. Co-Authored-By: Claude Opus 5.5 --- docs/api/classes/PaddleOCRVLProvider.md | 16 +++---- src/node/paddleocr-vl.spec.ts | 51 ++++++++++++++++++++++ src/node/paddleocr-vl.ts | 57 ++++++++++++++++++------- 3 files changed, 101 insertions(+), 23 deletions(-) diff --git a/docs/api/classes/PaddleOCRVLProvider.md b/docs/api/classes/PaddleOCRVLProvider.md index a8a68bf..20abfc5 100644 --- a/docs/api/classes/PaddleOCRVLProvider.md +++ b/docs/api/classes/PaddleOCRVLProvider.md @@ -6,7 +6,7 @@ # Class: PaddleOCRVLProvider -Defined in: [src/node/paddleocr-vl.ts:109](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L109) +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. @@ -25,7 +25,7 @@ not retried: a dropped connection can leave a GPU parse running. > **new PaddleOCRVLProvider**(`config?`): `PaddleOCRVLProvider` -Defined in: [src/node/paddleocr-vl.ts:116](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L116) +Defined in: [src/node/paddleocr-vl.ts:132](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L132) #### Parameters @@ -43,7 +43,7 @@ Defined in: [src/node/paddleocr-vl.ts:116](https://github.com/happyvertical/ocr/ > `readonly` **name**: `"paddleocr-vl"` = `'paddleocr-vl'` -Defined in: [src/node/paddleocr-vl.ts:110](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L110) +Defined in: [src/node/paddleocr-vl.ts:126](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L126) Provider name identifier @@ -57,7 +57,7 @@ Provider name identifier > **performOCR**(`images`, `_options?`): `Promise`\<[`OCRResult`](../interfaces/OCRResult.md)\> -Defined in: [src/node/paddleocr-vl.ts:137](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L137) +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 @@ -88,7 +88,7 @@ cold start. Use the provider's own `timeout`. > **checkDependencies**(): `Promise`\<[`DependencyCheckResult`](../interfaces/DependencyCheckResult.md)\> -Defined in: [src/node/paddleocr-vl.ts:183](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L183) +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. @@ -107,7 +107,7 @@ does not mean the model is loaded. > **checkCapabilities**(): `Promise`\<[`OCRCapabilities`](../interfaces/OCRCapabilities.md)\> -Defined in: [src/node/paddleocr-vl.ts:211](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L211) +Defined in: [src/node/paddleocr-vl.ts:227](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L227) Get provider capabilities @@ -125,7 +125,7 @@ Get provider capabilities > **getSupportedLanguages**(): `string`[] -Defined in: [src/node/paddleocr-vl.ts:236](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L236) +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. @@ -143,7 +143,7 @@ PaddleOCR-VL recognizes 100+ languages and needs no language hint. > **cleanup**(): `Promise`\<`void`\> -Defined in: [src/node/paddleocr-vl.ts:240](https://github.com/happyvertical/ocr/blob/main/src/node/paddleocr-vl.ts#L240) +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) diff --git a/src/node/paddleocr-vl.spec.ts b/src/node/paddleocr-vl.spec.ts index c9bf1d5..db844a5 100644 --- a/src/node/paddleocr-vl.spec.ts +++ b/src/node/paddleocr-vl.spec.ts @@ -293,4 +293,55 @@ describe('PaddleOCRVLProvider', () => { 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 index b443ec4..bd17305 100644 --- a/src/node/paddleocr-vl.ts +++ b/src/node/paddleocr-vl.ts @@ -51,6 +51,22 @@ 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]; @@ -292,40 +308,51 @@ export class PaddleOCRVLProvider implements OCRProvider { /** 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 type = sniff(bytes); - if (type && PASSTHROUGH.has(type)) return [bytes]; + 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 = - !type && - typeof image.width === 'number' && - typeof image.height === 'number' && - typeof image.channels === 'number'; + 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, - 'Unsupported image: expected PNG, JPEG, TIFF, WebP, GIF, BMP or raw pixels with dimensions', + 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', ); } - // Loaded only when a conversion is needed, so discovery and PNG/JPEG - // requests never load the native addon. - const { default: sharp } = await import('sharp'); + const sharp = await loadSharp(); try { if (isRaw) { - const channels = image.channels as 1 | 2 | 3 | 4; return [ await sharp(Buffer.from(bytes), { raw: { - width: image.width as number, - height: image.height as number, - channels, + width: width as number, + height: height as number, + channels: channels as 1 | 2 | 3 | 4, }, }) .png() .toBuffer(), ]; } - const { pages = 1 } = await sharp(Buffer.from(bytes)).metadata(); + // 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());