feat(pdf): migrate to PP-DocLayoutV3 ONNX model and update PDF block taxonomy

Switch PDF layout parsing to use the PP-DocLayoutV3 ONNX model, replacing the previous Docling-based approach. Update all environment variables, model fetching, and manifest handling for the new model and its artifacts. Refactor block kind taxonomy throughout the codebase, tests, and UI to align with PP-DocLayoutV3 labels, including expanded and renamed block types. Revise document settings, block filtering, and stitching logic to support the new set of block kinds. Update documentation and environment variable references to reflect the model transition.

BREAKING CHANGE: PDF parsing now requires PP-DocLayoutV3 ONNX model and updated environment variables; block kind names and settings have changed throughout the system.
This commit is contained in:
Richard R 2026-05-18 04:26:39 -06:00
parent 766c04d08d
commit 37a90b734d
23 changed files with 464 additions and 218 deletions

View file

@ -88,8 +88,11 @@ OPENREADER_COMPUTE_MODE=local
# OPENREADER_COMPUTE_WORKER_URL= # OPENREADER_COMPUTE_WORKER_URL=
# OPENREADER_COMPUTE_WORKER_TOKEN= # OPENREADER_COMPUTE_WORKER_TOKEN=
# Optional override for Docling layout model download URL # Optional overrides for PDF layout model artifacts
# OPENREADER_DOCLING_MODEL_URL=https://huggingface.co/ds4sd/docling-layout-heron/resolve/main/model.onnx # OPENREADER_PDF_LAYOUT_MODEL_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx
# OPENREADER_PDF_LAYOUT_MODEL_DATA_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx.data
# OPENREADER_PDF_LAYOUT_CONFIG_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/config.json
# OPENREADER_PDF_LAYOUT_PREPROCESSOR_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/preprocessor_config.json
# (Optional) Override ffmpeg binary path used for audiobook processing # (Optional) Override ffmpeg binary path used for audiobook processing
FFMPEG_BIN= FFMPEG_BIN=

View file

@ -73,7 +73,7 @@ COPY --from=app-builder /app/THIRD_PARTY_LICENSES /licenses
# Include SeaweedFS license text for the copied weed binary. # Include SeaweedFS license text for the copied weed binary.
COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LICENSE.txt COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LICENSE.txt
# Include static model notices for runtime-downloaded assets. # Include static model notices for runtime-downloaded assets.
COPY src/lib/server/pdf-layout/model/LICENSE.txt /licenses/docling-layout-LICENSE.txt COPY src/lib/server/pdf-layout/model/LICENSE.txt /licenses/pp-doclayoutv3-LICENSE.txt
# Copy the compiled whisper.cpp build output into the runtime image # Copy the compiled whisper.cpp build output into the runtime image
# (includes whisper-cli and its shared libraries, e.g. libwhisper.so, libggml.so) # (includes whisper-cli and its shared libraries, e.g. libwhisper.so, libggml.so)

View file

@ -21,7 +21,7 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader
- 🎯 **Multi-provider TTS** with OpenAI-compatible endpoints and cloud providers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI or OpenAI, Replicate, DeepInfra). - 🎯 **Multi-provider TTS** with OpenAI-compatible endpoints and cloud providers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI or OpenAI, Replicate, DeepInfra).
- 📖 **Read-along playback** for PDF/EPUB with sentence-aware narration. - 📖 **Read-along playback** for PDF/EPUB with sentence-aware narration.
- ⏱️ **Word-by-word highlighting** via optional `whisper.cpp` timestamps (`OPENREADER_COMPUTE_MODE=local` + `WHISPER_CPP_BIN`). - ⏱️ **Word-by-word highlighting** via optional `whisper.cpp` timestamps (`OPENREADER_COMPUTE_MODE=local` + `WHISPER_CPP_BIN`).
- 🧱 **Layout-aware PDF parsing** (Docling ONNX) with structured blocks for cleaner TTS/chaptering. - 🧱 **Layout-aware PDF parsing** (PP-DocLayoutV3 ONNX) with structured blocks for cleaner TTS/chaptering.
- 🛜 **Sync + library import** to bring docs across devices and from server-mounted folders. - 🛜 **Sync + library import** to bring docs across devices and from server-mounted folders.
- 🗂️ **Flexible storage** with embedded SeaweedFS or external S3-compatible backends. - 🗂️ **Flexible storage** with embedded SeaweedFS or external S3-compatible backends.
- 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing. - 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing.

View file

@ -261,7 +261,7 @@ For all environment variables, see [Environment Variables](../reference/environm
::: :::
:::tip Optional model prefetch :::tip Optional model prefetch
To pre-populate the Docling ONNX model cache before first PDF parse, run `pnpm fetch-models`. To pre-populate the PDF layout ONNX model cache before first PDF parse, run `pnpm fetch-models`.
::: :::
See [Auth](../configure/auth) for app/auth behavior. See [Auth](../configure/auth) for app/auth behavior.

View file

@ -56,7 +56,10 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o
| `OPENREADER_COMPUTE_MODE` | Heavy compute backend | `local` | Set to `none` to disable whisper alignment + PDF layout parsing | | `OPENREADER_COMPUTE_MODE` | Heavy compute backend | `local` | Set to `none` to disable whisper alignment + PDF layout parsing |
| `OPENREADER_COMPUTE_WORKER_URL` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) | | `OPENREADER_COMPUTE_WORKER_URL` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) |
| `OPENREADER_COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) | | `OPENREADER_COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) |
| `OPENREADER_DOCLING_MODEL_URL` | PDF layout model | Docling HF URL | Override ONNX download URL for `ensureModel()` | | `OPENREADER_PDF_LAYOUT_MODEL_URL` | PDF layout model | PP-DocLayoutV3 ONNX URL | Override ONNX model URL for `ensureModel()` |
| `OPENREADER_PDF_LAYOUT_MODEL_DATA_URL` | PDF layout model | PP-DocLayoutV3 ONNX data URL | Override ONNX external data URL for `ensureModel()` |
| `OPENREADER_PDF_LAYOUT_CONFIG_URL` | PDF layout model | PP-DocLayoutV3 config URL | Override model config URL for `ensureModel()` |
| `OPENREADER_PDF_LAYOUT_PREPROCESSOR_URL` | PDF layout model | PP-DocLayoutV3 preprocessor URL | Override model preprocessor URL for `ensureModel()` |
| `WHISPER_CPP_BIN` | Word timing (local mode) | unset | Set to enable `whisper.cpp` timestamps in `OPENREADER_COMPUTE_MODE=local` | | `WHISPER_CPP_BIN` | Word timing (local mode) | unset | Set to enable `whisper.cpp` timestamps in `OPENREADER_COMPUTE_MODE=local` |
| `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path | | `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path |
@ -374,13 +377,31 @@ Reserved bearer token for future external compute worker mode.
- Used only when `OPENREADER_COMPUTE_MODE=worker` (not implemented in v1) - Used only when `OPENREADER_COMPUTE_MODE=worker` (not implemented in v1)
- Leave unset in v1 - Leave unset in v1
### OPENREADER_DOCLING_MODEL_URL ### OPENREADER_PDF_LAYOUT_MODEL_URL
Override URL for the Docling ONNX layout model downloaded by `ensureModel()`. Override URL for the PP-DocLayoutV3 ONNX model downloaded by `ensureModel()`.
- Default: `https://huggingface.co/ds4sd/docling-layout-heron/resolve/main/model.onnx` - Default: `https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx`
- Optional for custom mirrors/air-gapped workflows - Optional for custom mirrors/air-gapped workflows
- You can also pre-populate the model cache via `pnpm fetch-models`
### OPENREADER_PDF_LAYOUT_MODEL_DATA_URL
Override URL for the PP-DocLayoutV3 ONNX external data file downloaded by `ensureModel()`.
- Default: `https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx.data`
### OPENREADER_PDF_LAYOUT_CONFIG_URL
Override URL for the PP-DocLayoutV3 `config.json` downloaded by `ensureModel()`.
- Default: `https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/config.json`
### OPENREADER_PDF_LAYOUT_PREPROCESSOR_URL
Override URL for the PP-DocLayoutV3 `preprocessor_config.json` downloaded by `ensureModel()`.
- Default: `https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/preprocessor_config.json`
- You can pre-populate the model cache via `pnpm fetch-models`
### WHISPER_CPP_BIN ### WHISPER_CPP_BIN

View file

@ -3,13 +3,21 @@ import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
const modelDir = path.join(process.cwd(), 'docstore', 'model'); const modelDir = path.join(process.cwd(), 'docstore', 'model');
const modelPath = path.join(modelDir, 'docling-layout-heron.onnx'); const modelPath = path.join(modelDir, 'PP-DocLayoutV3.onnx');
const configPath = path.join(modelDir, 'docling-layout-heron.config.json'); const modelDataPath = path.join(modelDir, 'PP-DocLayoutV3.onnx.data');
const licensePath = path.join(modelDir, 'docling-layout-heron.LICENSE.txt'); const configPath = path.join(modelDir, 'pp-doclayoutv3.config.json');
const preprocessorPath = path.join(modelDir, 'pp-doclayoutv3.preprocessor_config.json');
const licensePath = path.join(modelDir, 'pp-doclayoutv3.LICENSE.txt');
const staticLicensePath = path.join(process.cwd(), 'src', 'lib', 'server', 'pdf-layout', 'model', 'LICENSE.txt'); const staticLicensePath = path.join(process.cwd(), 'src', 'lib', 'server', 'pdf-layout', 'model', 'LICENSE.txt');
const modelUrl = process.env.OPENREADER_DOCLING_MODEL_URL || 'https://huggingface.co/docling-project/docling-layout-heron-onnx/resolve/main/model.onnx'; const modelUrl = process.env.OPENREADER_PDF_LAYOUT_MODEL_URL
const configUrl = process.env.OPENREADER_DOCLING_CONFIG_URL || 'https://huggingface.co/docling-project/docling-layout-heron-onnx/resolve/main/config.json'; || 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx';
const modelDataUrl = process.env.OPENREADER_PDF_LAYOUT_MODEL_DATA_URL
|| 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx.data';
const configUrl = process.env.OPENREADER_PDF_LAYOUT_CONFIG_URL
|| 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/config.json';
const preprocessorUrl = process.env.OPENREADER_PDF_LAYOUT_PREPROCESSOR_URL
|| 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/preprocessor_config.json';
await mkdir(modelDir, { recursive: true }); await mkdir(modelDir, { recursive: true });
@ -19,12 +27,24 @@ if (!modelRes.ok) {
} }
await writeFile(modelPath, new Uint8Array(await modelRes.arrayBuffer())); await writeFile(modelPath, new Uint8Array(await modelRes.arrayBuffer()));
const modelDataRes = await fetch(modelDataUrl);
if (!modelDataRes.ok) {
throw new Error(`Failed to fetch model data: ${modelDataRes.status} ${modelDataRes.statusText}`);
}
await writeFile(modelDataPath, new Uint8Array(await modelDataRes.arrayBuffer()));
const configRes = await fetch(configUrl); const configRes = await fetch(configUrl);
if (!configRes.ok) { if (!configRes.ok) {
throw new Error(`Failed to fetch config: ${configRes.status} ${configRes.statusText}`); throw new Error(`Failed to fetch config: ${configRes.status} ${configRes.statusText}`);
} }
await writeFile(configPath, new Uint8Array(await configRes.arrayBuffer())); await writeFile(configPath, new Uint8Array(await configRes.arrayBuffer()));
const preprocessorRes = await fetch(preprocessorUrl);
if (!preprocessorRes.ok) {
throw new Error(`Failed to fetch preprocessor config: ${preprocessorRes.status} ${preprocessorRes.statusText}`);
}
await writeFile(preprocessorPath, new Uint8Array(await preprocessorRes.arrayBuffer()));
const staticLicense = await import('node:fs/promises').then((m) => m.readFile(staticLicensePath)); const staticLicense = await import('node:fs/promises').then((m) => m.readFile(staticLicensePath));
await writeFile(licensePath, staticLicense); await writeFile(licensePath, staticLicense);

View file

@ -21,10 +21,27 @@ import { RefreshIcon } from '@/components/icons/Icons';
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf'; import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
const PDF_SKIP_KIND_OPTIONS: Array<{ kind: ParsedPdfBlockKind; label: string }> = [ const PDF_SKIP_KIND_OPTIONS: Array<{ kind: ParsedPdfBlockKind; label: string }> = [
{ kind: 'page-header', label: 'Page headers' }, { kind: 'header', label: 'Header' },
{ kind: 'page-footer', label: 'Page footers' }, { kind: 'footer', label: 'Footer' },
{ kind: 'footnote', label: 'Footnotes' }, { kind: 'footnote', label: 'Footnote' },
{ kind: 'caption', label: 'Captions' }, { kind: 'vision_footnote', label: 'Vision footnote' },
{ kind: 'figure_title', label: 'Figure title' },
{ kind: 'doc_title', label: 'Document title' },
{ kind: 'paragraph_title', label: 'Paragraph title' },
{ kind: 'abstract', label: 'Abstract' },
{ kind: 'algorithm', label: 'Algorithm' },
{ kind: 'aside_text', label: 'Aside text' },
{ kind: 'content', label: 'Content' },
{ kind: 'reference', label: 'Reference' },
{ kind: 'reference_content', label: 'Reference content' },
{ kind: 'text', label: 'Text' },
{ kind: 'number', label: 'Number' },
{ kind: 'formula', label: 'Formula' },
{ kind: 'formula_number', label: 'Formula number' },
{ kind: 'table', label: 'Table' },
{ kind: 'chart', label: 'Chart' },
{ kind: 'image', label: 'Image' },
{ kind: 'seal', label: 'Seal' },
]; ];
const viewTypeTextMapping = [ const viewTypeTextMapping = [

View file

@ -354,14 +354,26 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) {
const colorForKind = (kind: ParsedPdfBlock['kind']): string => { const colorForKind = (kind: ParsedPdfBlock['kind']): string => {
switch (kind) { switch (kind) {
case 'section-header': return 'rgba(34,197,94,0.20)'; case 'paragraph_title': return 'rgba(34,197,94,0.20)';
case 'title': return 'rgba(16,185,129,0.20)'; case 'doc_title': return 'rgba(16,185,129,0.20)';
case 'caption': return 'rgba(245,158,11,0.20)'; case 'figure_title': return 'rgba(245,158,11,0.20)';
case 'table': return 'rgba(59,130,246,0.20)'; case 'table': return 'rgba(59,130,246,0.20)';
case 'picture': return 'rgba(139,92,246,0.20)'; case 'chart':
case 'page-header': case 'image': return 'rgba(139,92,246,0.20)';
case 'page-footer': case 'header':
case 'footnote': return 'rgba(239,68,68,0.20)'; case 'footer':
case 'footnote':
case 'vision_footnote': return 'rgba(239,68,68,0.20)';
case 'formula':
case 'formula_number': return 'rgba(251,146,60,0.22)';
case 'abstract':
case 'algorithm':
case 'aside_text':
case 'content':
case 'reference':
case 'reference_content':
case 'text':
case 'number': return 'rgba(14,165,233,0.18)';
default: return 'rgba(14,165,233,0.18)'; default: return 'rgba(14,165,233,0.18)';
} }
}; };

View file

@ -60,6 +60,8 @@ function prepareParsedChapters({
let currentTitle = 'Introduction'; let currentTitle = 'Introduction';
let currentBlocks: ParsedPdfBlock[] = []; let currentBlocks: ParsedPdfBlock[] = [];
const chapterBoundaryKinds = new Set<string>(['paragraph_title', 'doc_title']);
const flush = () => { const flush = () => {
if (!currentBlocks.length) return; if (!currentBlocks.length) return;
const text = chapterTextFromBlocks(currentBlocks, smartSentenceSplitting, maxBlockLength); const text = chapterTextFromBlocks(currentBlocks, smartSentenceSplitting, maxBlockLength);
@ -74,7 +76,7 @@ function prepareParsedChapters({
}; };
for (const block of allBlocks) { for (const block of allBlocks) {
if (block.kind === 'section-header') { if (chapterBoundaryKinds.has(block.kind)) {
flush(); flush();
currentTitle = block.text.trim() || `Chapter ${chapters.length + 1}`; currentTitle = block.text.trim() || `Chapter ${chapters.length + 1}`;
currentBlocks.push(block); currentBlocks.push(block);

View file

@ -4,12 +4,16 @@ import { access, mkdir, rename, writeFile, readFile, unlink, copyFile } from 'fs
import { DOCSTORE_DIR } from '@/lib/server/storage/library-mount'; import { DOCSTORE_DIR } from '@/lib/server/storage/library-mount';
import manifest from '@/lib/server/pdf-layout/model/manifest.json'; import manifest from '@/lib/server/pdf-layout/model/manifest.json';
const DEFAULT_MODEL_URL = 'https://huggingface.co/docling-project/docling-layout-heron-onnx/resolve/main/model.onnx'; const DEFAULT_MODEL_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx';
const DEFAULT_CONFIG_URL = 'https://huggingface.co/docling-project/docling-layout-heron-onnx/resolve/main/config.json'; const DEFAULT_MODEL_DATA_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx.data';
const DEFAULT_CONFIG_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/config.json';
const DEFAULT_PREPROCESSOR_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/preprocessor_config.json';
const MODEL_DIR = path.join(DOCSTORE_DIR, 'model'); const MODEL_DIR = path.join(DOCSTORE_DIR, 'model');
const MODEL_PATH = path.join(MODEL_DIR, 'docling-layout-heron.onnx'); export const MODEL_PATH = path.join(MODEL_DIR, 'PP-DocLayoutV3.onnx');
const CONFIG_PATH = path.join(MODEL_DIR, 'docling-layout-heron.config.json'); export const MODEL_DATA_PATH = path.join(MODEL_DIR, 'PP-DocLayoutV3.onnx.data');
const LICENSE_PATH = path.join(MODEL_DIR, 'docling-layout-heron.LICENSE.txt'); export const MODEL_CONFIG_PATH = path.join(MODEL_DIR, 'pp-doclayoutv3.config.json');
export const MODEL_PREPROCESSOR_PATH = path.join(MODEL_DIR, 'pp-doclayoutv3.preprocessor_config.json');
const LICENSE_PATH = path.join(MODEL_DIR, 'pp-doclayoutv3.LICENSE.txt');
const STATIC_LICENSE_PATH = path.join(process.cwd(), 'src/lib/server/pdf-layout/model/LICENSE.txt'); const STATIC_LICENSE_PATH = path.join(process.cwd(), 'src/lib/server/pdf-layout/model/LICENSE.txt');
let inflight: Promise<string> | null = null; let inflight: Promise<string> | null = null;
@ -51,15 +55,22 @@ async function verifyFile(pathToFile: string, manifestPath: string): Promise<boo
async function ensureLicense(): Promise<void> { async function ensureLicense(): Promise<void> {
await copyFile(STATIC_LICENSE_PATH, LICENSE_PATH); await copyFile(STATIC_LICENSE_PATH, LICENSE_PATH);
if (!(await verifyFile(LICENSE_PATH, 'LICENSE.txt'))) { if (!(await verifyFile(LICENSE_PATH, 'LICENSE.txt'))) {
throw new Error('Docling model license checksum verification failed'); throw new Error('PDF layout model license checksum verification failed');
} }
} }
async function ensureModelInternal(): Promise<string> { async function ensureModelInternal(): Promise<string> {
try { try {
await access(MODEL_PATH); await access(MODEL_PATH);
await access(CONFIG_PATH); await access(MODEL_DATA_PATH);
if (await verifyFile(MODEL_PATH, 'model.onnx') && await verifyFile(CONFIG_PATH, 'config.json')) { await access(MODEL_CONFIG_PATH);
await access(MODEL_PREPROCESSOR_PATH);
if (
await verifyFile(MODEL_PATH, 'model.onnx')
&& await verifyFile(MODEL_DATA_PATH, 'model.onnx.data')
&& await verifyFile(MODEL_CONFIG_PATH, 'config.json')
&& await verifyFile(MODEL_PREPROCESSOR_PATH, 'preprocessor_config.json')
) {
await ensureLicense(); await ensureLicense();
return MODEL_PATH; return MODEL_PATH;
} }
@ -68,24 +79,44 @@ async function ensureModelInternal(): Promise<string> {
} }
await mkdir(MODEL_DIR, { recursive: true }); await mkdir(MODEL_DIR, { recursive: true });
const tmpPath = `${MODEL_PATH}.tmp`; const modelTmpPath = `${MODEL_PATH}.tmp`;
const configTmpPath = `${CONFIG_PATH}.tmp`; const modelDataTmpPath = `${MODEL_DATA_PATH}.tmp`;
const modelUrl = process.env.OPENREADER_DOCLING_MODEL_URL?.trim() || DEFAULT_MODEL_URL; const configTmpPath = `${MODEL_CONFIG_PATH}.tmp`;
const configUrl = process.env.OPENREADER_DOCLING_CONFIG_URL?.trim() || DEFAULT_CONFIG_URL; const preprocessorTmpPath = `${MODEL_PREPROCESSOR_PATH}.tmp`;
const modelUrl = process.env.OPENREADER_PDF_LAYOUT_MODEL_URL?.trim()
|| DEFAULT_MODEL_URL;
const modelDataUrl = process.env.OPENREADER_PDF_LAYOUT_MODEL_DATA_URL?.trim()
|| DEFAULT_MODEL_DATA_URL;
const configUrl = process.env.OPENREADER_PDF_LAYOUT_CONFIG_URL?.trim()
|| DEFAULT_CONFIG_URL;
const preprocessorUrl = process.env.OPENREADER_PDF_LAYOUT_PREPROCESSOR_URL?.trim()
|| DEFAULT_PREPROCESSOR_URL;
await downloadToFile(modelUrl, tmpPath); await downloadToFile(modelUrl, modelTmpPath);
if (!(await verifyFile(tmpPath, 'model.onnx'))) { if (!(await verifyFile(modelTmpPath, 'model.onnx'))) {
await unlink(tmpPath).catch(() => undefined); await unlink(modelTmpPath).catch(() => undefined);
throw new Error('Docling model checksum verification failed'); throw new Error('PDF layout model checksum verification failed');
}
await downloadToFile(modelDataUrl, modelDataTmpPath);
if (!(await verifyFile(modelDataTmpPath, 'model.onnx.data'))) {
await unlink(modelDataTmpPath).catch(() => undefined);
throw new Error('PDF layout model external data checksum verification failed');
} }
await downloadToFile(configUrl, configTmpPath); await downloadToFile(configUrl, configTmpPath);
if (!(await verifyFile(configTmpPath, 'config.json'))) { if (!(await verifyFile(configTmpPath, 'config.json'))) {
await unlink(configTmpPath).catch(() => undefined); await unlink(configTmpPath).catch(() => undefined);
throw new Error('Docling model config checksum verification failed'); throw new Error('PDF layout model config checksum verification failed');
}
await downloadToFile(preprocessorUrl, preprocessorTmpPath);
if (!(await verifyFile(preprocessorTmpPath, 'preprocessor_config.json'))) {
await unlink(preprocessorTmpPath).catch(() => undefined);
throw new Error('PDF layout model preprocessor checksum verification failed');
} }
await rename(tmpPath, MODEL_PATH); await rename(modelTmpPath, MODEL_PATH);
await rename(configTmpPath, CONFIG_PATH); await rename(modelDataTmpPath, MODEL_DATA_PATH);
await rename(configTmpPath, MODEL_CONFIG_PATH);
await rename(preprocessorTmpPath, MODEL_PREPROCESSOR_PATH);
await ensureLicense(); await ensureLicense();
return MODEL_PATH; return MODEL_PATH;
} }

View file

@ -1,15 +1,23 @@
import type { LayoutRegion, PdfTextItem } from '@/lib/server/pdf-layout/types'; import type { LayoutRegion, PdfTextItem } from '@/lib/server/pdf-layout/types';
const NON_TEXT_REGION_LABELS = new Set<LayoutRegion['label']>(['picture', 'table']); const NON_TEXT_REGION_LABELS = new Set<LayoutRegion['label']>(['chart', 'image', 'table', 'seal']);
const TEXT_ASSIGNABLE_LABELS = new Set<LayoutRegion['label']>([ const TEXT_ASSIGNABLE_LABELS = new Set<LayoutRegion['label']>([
'title', 'abstract',
'section-header', 'algorithm',
'paragraph', 'aside_text',
'list-item', 'content',
'caption', 'doc_title',
'page-header', 'figure_title',
'page-footer', 'footer',
'footnote', 'footnote',
'formula_number',
'header',
'number',
'paragraph_title',
'reference',
'reference_content',
'text',
'vision_footnote',
'formula', 'formula',
]); ]);

View file

@ -1,3 +1,3 @@
Docling layout model assets are distributed under Apache-2.0 / CDLA terms by the model publisher. PP-DocLayoutV3 ONNX model assets are distributed by the model publisher under Apache-2.0.
See the upstream release for the authoritative license text: See upstream for the authoritative license and terms:
https://huggingface.co/ds4sd/docling-layout-heron https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX

View file

@ -1,21 +1,31 @@
{ {
"name": "docling-layout-heron", "name": "pp-doclayoutv3",
"version": "docling-project/docling-layout-heron-onnx@main", "version": "Bei0001/PP-DocLayoutV3-ONNX@main",
"files": [ "files": [
{ {
"path": "model.onnx", "path": "model.onnx",
"sha256": "59c81a3a2923042d85034ffc487f8f47e4854117e879aef89b2b9f728fb4922a", "sha256": "c0721928ff08741bb208ebed539c77170db5234a68cb7e546e6cc9bc172a695b",
"size": 171220471 "size": 5088167
},
{
"path": "model.onnx.data",
"sha256": "34df3e4b79d7bbbf82abce1b4f3cde3d540fa57ad42ec8905c352b97c408d437",
"size": 136774480
}, },
{ {
"path": "config.json", "path": "config.json",
"sha256": "c6e67b6cdf64fa245779d0409bb994aa96e8c1790e15ec07a65843628e232180", "sha256": "3cf834b91d23a756b1519bce4db42c09e852f3e35c35092dd5a3e253a50c071a",
"size": 878 "size": 2460
},
{
"path": "preprocessor_config.json",
"sha256": "519fe0187a43a1ca429e3ad8317bab8700f0d5e8fb3a6e3a0a413ffac078ba42",
"size": 575
}, },
{ {
"path": "LICENSE.txt", "path": "LICENSE.txt",
"sha256": "8f5030e085c59609746fce1010230f12b4a565abb6bdc19a2c2d3735ba1710d9", "sha256": "578a6ba6f86b0692a8f719843f575a3eebf4705768ac5c37d149f441208f601f",
"size": 209 "size": 195
} }
] ]
} }

View file

@ -140,7 +140,7 @@ export async function parsePdf(input: ParsePdfInput): Promise<ParsedPdfDocument>
const doc: ParsedPdfDocument = { const doc: ParsedPdfDocument = {
schemaVersion: 1, schemaVersion: 1,
documentId: input.documentId, documentId: input.documentId,
parserVersion: 'docling-layout-heron@v1+pdfjs@4.8.69', parserVersion: 'pp-doclayoutv3-onnx@800+pdfjs@4.8.69',
parsedAt: Date.now(), parsedAt: Date.now(),
pages, pages,
}; };

View file

@ -1,8 +1,7 @@
import * as ort from 'onnxruntime-node'; import * as ort from 'onnxruntime-node';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
import path from 'path';
import type { LayoutRegion, PdfTextItem } from '@/lib/server/pdf-layout/types'; import type { LayoutRegion, PdfTextItem } from '@/lib/server/pdf-layout/types';
import { ensureModel } from '@/lib/server/pdf-layout/ensureModel'; import { ensureModel, MODEL_CONFIG_PATH, MODEL_PREPROCESSOR_PATH } from '@/lib/server/pdf-layout/ensureModel';
interface RunLayoutInput { interface RunLayoutInput {
pageWidth: number; pageWidth: number;
@ -11,42 +10,74 @@ interface RunLayoutInput {
pagePng: Buffer; pagePng: Buffer;
} }
const INPUT_SIZE = 640; const DEFAULT_INPUT_SIZE = 800;
const MIN_SCORE = 0.6; const MIN_SCORE = 0.5;
const CLASS_MIN_SCORE: Partial<Record<LayoutRegion['label'], number>> = {
header: 0.4,
footer: 0.4,
figure_title: 0.45,
footnote: 0.45,
vision_footnote: 0.45,
};
const LABEL_MAP: Record<string, LayoutRegion['label'] | null> = { const LABEL_MAP: Record<string, LayoutRegion['label'] | null> = {
caption: 'caption', // PP-DocLayoutV3 labels
abstract: 'abstract',
algorithm: 'algorithm',
aside_text: 'aside_text',
chart: 'chart',
content: 'content',
display_formula: 'formula',
doc_title: 'doc_title',
figure_title: 'figure_title',
footer: 'footer',
footer_image: 'footer',
footnote: 'footnote', footnote: 'footnote',
formula: 'formula', formula_number: 'formula_number',
list_item: 'list-item', header: 'header',
page_footer: 'page-footer', header_image: 'header',
page_header: 'page-header', image: 'image',
picture: 'picture', inline_formula: 'formula',
section_header: 'section-header', number: 'number',
paragraph_title: 'paragraph_title',
reference: 'reference',
reference_content: 'reference_content',
seal: 'seal',
table: 'table', table: 'table',
text: 'paragraph', text: 'text',
title: 'title', vertical_text: 'text',
document_index: null, vision_footnote: 'vision_footnote',
code: null,
checkbox_selected: null,
checkbox_unselected: null,
form: null,
key_value_region: null,
}; };
const MIN_REGION_SIZE: Partial<Record<LayoutRegion['label'], { minWidth: number; minHeight: number }>> = { const MIN_REGION_SIZE: Partial<Record<LayoutRegion['label'], { minWidth: number; minHeight: number }>> = {
paragraph: { minWidth: 24, minHeight: 14 }, abstract: { minWidth: 24, minHeight: 14 },
'section-header': { minWidth: 24, minHeight: 14 }, algorithm: { minWidth: 24, minHeight: 14 },
title: { minWidth: 24, minHeight: 14 }, aside_text: { minWidth: 24, minHeight: 14 },
'list-item': { minWidth: 18, minHeight: 12 }, content: { minWidth: 24, minHeight: 14 },
caption: { minWidth: 18, minHeight: 10 }, text: { minWidth: 24, minHeight: 14 },
reference: { minWidth: 24, minHeight: 14 },
reference_content: { minWidth: 24, minHeight: 14 },
paragraph_title: { minWidth: 24, minHeight: 14 },
doc_title: { minWidth: 24, minHeight: 14 },
number: { minWidth: 18, minHeight: 12 },
figure_title: { minWidth: 18, minHeight: 10 },
footnote: { minWidth: 18, minHeight: 10 }, footnote: { minWidth: 18, minHeight: 10 },
'page-header': { minWidth: 18, minHeight: 10 }, vision_footnote: { minWidth: 18, minHeight: 10 },
'page-footer': { minWidth: 18, minHeight: 10 }, header: { minWidth: 18, minHeight: 10 },
footer: { minWidth: 18, minHeight: 10 },
}; };
interface ModelPreprocessor {
inputWidth: number;
inputHeight: number;
rescaleFactor: number;
mean: [number, number, number];
std: [number, number, number];
}
let sessionPromise: Promise<ort.InferenceSession> | null = null; let sessionPromise: Promise<ort.InferenceSession> | null = null;
let idToLabelPromise: Promise<Record<number, string>> | null = null; let idToLabelPromise: Promise<Record<number, string>> | null = null;
let preprocessorPromise: Promise<ModelPreprocessor> | null = null;
let canvasFnsPromise: Promise<{ let canvasFnsPromise: Promise<{
createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }; createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D };
loadImageFn: (src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>; loadImageFn: (src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>;
@ -96,14 +127,13 @@ async function getSession(): Promise<ort.InferenceSession> {
async function getIdToLabel(): Promise<Record<number, string>> { async function getIdToLabel(): Promise<Record<number, string>> {
if (!idToLabelPromise) { if (!idToLabelPromise) {
idToLabelPromise = (async () => { idToLabelPromise = (async () => {
const modelPath = await ensureModel(); await ensureModel();
const configPath = path.join(path.dirname(modelPath), 'docling-layout-heron.config.json'); const raw = await readFile(MODEL_CONFIG_PATH, 'utf8');
const raw = await readFile(configPath, 'utf8');
const parsed = JSON.parse(raw) as { id2label?: Record<string, string> }; const parsed = JSON.parse(raw) as { id2label?: Record<string, string> };
const out: Record<number, string> = {}; const out: Record<number, string> = {};
for (const [key, value] of Object.entries(parsed.id2label ?? {})) { for (const [key, value] of Object.entries(parsed.id2label ?? {})) {
const n = Number(key); const n = Number(key);
if (Number.isFinite(n)) out[n] = value; if (Number.isFinite(n)) out[n] = String(value ?? '').trim();
} }
return out; return out;
})(); })();
@ -111,49 +141,74 @@ async function getIdToLabel(): Promise<Record<number, string>> {
return idToLabelPromise; return idToLabelPromise;
} }
function preprocessLetterboxed( async function getPreprocessor(): Promise<ModelPreprocessor> {
image: CanvasImageSource, if (!preprocessorPromise) {
createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }, preprocessorPromise = (async () => {
): { await ensureModel();
tensor: ort.Tensor; const raw = await readFile(MODEL_PREPROCESSOR_PATH, 'utf8');
scale: number; const parsed = JSON.parse(raw) as {
padX: number; size?: { width?: number; height?: number };
padY: number; rescale_factor?: number;
} { image_mean?: number[];
const sourceWidth = Math.max(1, Number((image as { width?: number }).width ?? INPUT_SIZE)); image_std?: number[];
const sourceHeight = Math.max(1, Number((image as { height?: number }).height ?? INPUT_SIZE)); };
const scale = Math.min(INPUT_SIZE / sourceWidth, INPUT_SIZE / sourceHeight);
const drawWidth = Math.max(1, Math.round(sourceWidth * scale));
const drawHeight = Math.max(1, Math.round(sourceHeight * scale));
const padX = Math.floor((INPUT_SIZE - drawWidth) / 2);
const padY = Math.floor((INPUT_SIZE - drawHeight) / 2);
const canvas = createCanvasFn(INPUT_SIZE, INPUT_SIZE); const inputWidth = Math.max(1, Number(parsed.size?.width ?? DEFAULT_INPUT_SIZE));
const inputHeight = Math.max(1, Number(parsed.size?.height ?? DEFAULT_INPUT_SIZE));
const rescaleFactor = Number.isFinite(parsed.rescale_factor) ? Number(parsed.rescale_factor) : (1 / 255);
const mean = [
Number(parsed.image_mean?.[0] ?? 0),
Number(parsed.image_mean?.[1] ?? 0),
Number(parsed.image_mean?.[2] ?? 0),
] as [number, number, number];
const std = [
Number(parsed.image_std?.[0] ?? 1),
Number(parsed.image_std?.[1] ?? 1),
Number(parsed.image_std?.[2] ?? 1),
] as [number, number, number];
return {
inputWidth,
inputHeight,
rescaleFactor,
mean,
std,
};
})();
}
return preprocessorPromise;
}
function preprocessResized(
image: CanvasImageSource,
preprocessor: ModelPreprocessor,
createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D },
): ort.Tensor {
const canvas = createCanvasFn(preprocessor.inputWidth, preprocessor.inputHeight);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, INPUT_SIZE, INPUT_SIZE); ctx.fillRect(0, 0, preprocessor.inputWidth, preprocessor.inputHeight);
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high'; ctx.imageSmoothingQuality = 'high';
ctx.drawImage(image, padX, padY, drawWidth, drawHeight); ctx.drawImage(image, 0, 0, preprocessor.inputWidth, preprocessor.inputHeight);
const imageData = ctx.getImageData(0, 0, INPUT_SIZE, INPUT_SIZE); const imageData = ctx.getImageData(0, 0, preprocessor.inputWidth, preprocessor.inputHeight);
const data = new Uint8Array(1 * 3 * INPUT_SIZE * INPUT_SIZE); const chw = new Float32Array(1 * 3 * preprocessor.inputWidth * preprocessor.inputHeight);
for (let y = 0; y < INPUT_SIZE; y += 1) { const channelSize = preprocessor.inputWidth * preprocessor.inputHeight;
for (let x = 0; x < INPUT_SIZE; x += 1) { for (let y = 0; y < preprocessor.inputHeight; y += 1) {
const pixelIndex = (y * INPUT_SIZE + x) * 4; for (let x = 0; x < preprocessor.inputWidth; x += 1) {
const chwIndex = y * INPUT_SIZE + x; const pixelIndex = (y * preprocessor.inputWidth + x) * 4;
data[0 * INPUT_SIZE * INPUT_SIZE + chwIndex] = imageData.data[pixelIndex]; const idx = y * preprocessor.inputWidth + x;
data[1 * INPUT_SIZE * INPUT_SIZE + chwIndex] = imageData.data[pixelIndex + 1]; const r = imageData.data[pixelIndex] * preprocessor.rescaleFactor;
data[2 * INPUT_SIZE * INPUT_SIZE + chwIndex] = imageData.data[pixelIndex + 2]; const g = imageData.data[pixelIndex + 1] * preprocessor.rescaleFactor;
const b = imageData.data[pixelIndex + 2] * preprocessor.rescaleFactor;
chw[idx] = (r - preprocessor.mean[0]) / Math.max(1e-8, preprocessor.std[0]);
chw[channelSize + idx] = (g - preprocessor.mean[1]) / Math.max(1e-8, preprocessor.std[1]);
chw[channelSize * 2 + idx] = (b - preprocessor.mean[2]) / Math.max(1e-8, preprocessor.std[2]);
} }
} }
return new ort.Tensor('float32', chw, [1, 3, preprocessor.inputHeight, preprocessor.inputWidth]);
return {
tensor: new ort.Tensor('uint8', data, [1, 3, INPUT_SIZE, INPUT_SIZE]),
scale,
padX,
padY,
};
} }
function clampBox( function clampBox(
@ -169,6 +224,36 @@ function clampBox(
return [x0, y0, x1, y1]; return [x0, y0, x1, y1];
} }
function softmaxMax(logits: Float32Array, offset: number, count: number): { index: number; score: number } {
let maxLogit = Number.NEGATIVE_INFINITY;
let maxIndex = 0;
for (let i = 0; i < count; i += 1) {
const value = logits[offset + i];
if (value > maxLogit) {
maxLogit = value;
maxIndex = i;
}
}
let sum = 0;
for (let i = 0; i < count; i += 1) {
sum += Math.exp(logits[offset + i] - maxLogit);
}
const score = sum > 0 ? (1 / sum) : 0;
return { index: maxIndex, score };
}
function normalizeModelLabel(rawLabel: string): string {
const normalized = rawLabel.trim().toLowerCase().replace(/[\s-]+/g, '_');
if (normalized.endsWith('_image')) {
const base = normalized.slice(0, -'_image'.length);
if (base === 'header' || base === 'footer') return normalized;
}
// Some exports suffix duplicate classes (e.g. header_1, footer_1, text_1).
return normalized.replace(/_\d+$/g, '');
}
export async function runLayoutModel(input: RunLayoutInput): Promise<LayoutRegion[]> { export async function runLayoutModel(input: RunLayoutInput): Promise<LayoutRegion[]> {
const { pageWidth, pageHeight, textItems, pagePng } = input; const { pageWidth, pageHeight, textItems, pagePng } = input;
if (!textItems.length) return []; if (!textItems.length) return [];
@ -177,44 +262,49 @@ export async function runLayoutModel(input: RunLayoutInput): Promise<LayoutRegio
} }
try { try {
const [session, idToLabel, canvasFns] = await Promise.all([getSession(), getIdToLabel(), getCanvasFns()]); const [session, idToLabel, preprocessor, canvasFns] = await Promise.all([
const pageImage = await canvasFns.loadImageFn(pagePng); getSession(),
const preprocess = preprocessLetterboxed(pageImage, canvasFns.createCanvasFn); getIdToLabel(),
const targetSizes = new ort.Tensor('int64', new BigInt64Array([BigInt(INPUT_SIZE), BigInt(INPUT_SIZE)]), [1, 2]); getPreprocessor(),
const output = await session.run({ getCanvasFns(),
images: preprocess.tensor, ]);
orig_target_sizes: targetSizes,
});
const labels = output.labels?.data as BigInt64Array | Int32Array | undefined; const pageImage = await canvasFns.loadImageFn(pagePng);
const boxes = output.boxes?.data as Float32Array | undefined; const pixelValues = preprocessResized(pageImage, preprocessor, canvasFns.createCanvasFn);
const scores = output.scores?.data as Float32Array | undefined; const output = await session.run({ pixel_values: pixelValues });
if (!labels || !boxes || !scores) return [];
const logits = output.logits?.data as Float32Array | undefined;
const predBoxes = output.pred_boxes?.data as Float32Array | undefined;
if (!logits || !predBoxes) return [];
const numQueries = Math.floor(predBoxes.length / 4);
if (numQueries <= 0) return [];
const classCount = Math.floor(logits.length / numQueries);
if (classCount <= 0) return [];
const regions: LayoutRegion[] = []; const regions: LayoutRegion[] = [];
const count = Math.min(scores.length, Math.floor(boxes.length / 4), labels.length);
for (let i = 0; i < count; i += 1) {
const score = scores[i];
if (!Number.isFinite(score) || score < MIN_SCORE) continue;
const rawLabel = Number(labels[i]); for (let queryIdx = 0; queryIdx < numQueries; queryIdx += 1) {
const labelName = idToLabel[rawLabel]; const cls = softmaxMax(logits, queryIdx * classCount, classCount);
if (!labelName) continue; const rawLabel = idToLabel[cls.index];
const mapped = LABEL_MAP[labelName]; if (!rawLabel) continue;
const mapped = LABEL_MAP[normalizeModelLabel(rawLabel)];
if (!mapped) continue; if (!mapped) continue;
const modelBox: [number, number, number, number] = [ const minScore = CLASS_MIN_SCORE[mapped] ?? MIN_SCORE;
boxes[i * 4 + 0], if (!Number.isFinite(cls.score) || cls.score < minScore) continue;
boxes[i * 4 + 1],
boxes[i * 4 + 2], const cx = predBoxes[queryIdx * 4 + 0] * pageWidth;
boxes[i * 4 + 3], const cy = predBoxes[queryIdx * 4 + 1] * pageHeight;
]; const w = predBoxes[queryIdx * 4 + 2] * pageWidth;
const h = predBoxes[queryIdx * 4 + 3] * pageHeight;
const rawBox: [number, number, number, number] = [ const rawBox: [number, number, number, number] = [
(modelBox[0] - preprocess.padX) / preprocess.scale, cx - w / 2,
(modelBox[1] - preprocess.padY) / preprocess.scale, cy - h / 2,
(modelBox[2] - preprocess.padX) / preprocess.scale, cx + w / 2,
(modelBox[3] - preprocess.padY) / preprocess.scale, cy + h / 2,
]; ];
const clamped = clampBox(rawBox, pageWidth, pageHeight); const clamped = clampBox(rawBox, pageWidth, pageHeight);
if (!clamped) continue; if (!clamped) continue;
@ -228,7 +318,7 @@ export async function runLayoutModel(input: RunLayoutInput): Promise<LayoutRegio
regions.push({ regions.push({
bbox: clamped, bbox: clamped,
label: mapped, label: mapped,
confidence: score, confidence: cls.score,
}); });
} }

View file

@ -1,5 +1,15 @@
import type { ParsedPdfDocument, ParsedPdfBlock } from '@/types/parsed-pdf'; import type { ParsedPdfDocument, ParsedPdfBlock } from '@/types/parsed-pdf';
const STITCHABLE_KINDS = new Set<ParsedPdfBlock['kind']>([
'text',
'content',
'reference_content',
'aside_text',
'abstract',
'algorithm',
'reference',
]);
function stripTrailingClosers(text: string): string { function stripTrailingClosers(text: string): string {
return text.trim().replace(/[\"'”’\]\)]+$/g, ''); return text.trim().replace(/[\"'”’\]\)]+$/g, '');
} }
@ -9,7 +19,7 @@ function isSentenceTerminal(text: string): boolean {
} }
function canStitch(a: ParsedPdfBlock, b: ParsedPdfBlock): boolean { function canStitch(a: ParsedPdfBlock, b: ParsedPdfBlock): boolean {
if (!['paragraph', 'list-item'].includes(a.kind)) return false; if (!STITCHABLE_KINDS.has(a.kind)) return false;
if (a.kind !== b.kind) return false; if (a.kind !== b.kind) return false;
if (isSentenceTerminal(a.text)) return false; if (isSentenceTerminal(a.text)) return false;
const next = b.text.trim(); const next = b.text.trim();
@ -18,13 +28,16 @@ function canStitch(a: ParsedPdfBlock, b: ParsedPdfBlock): boolean {
return true; return true;
} }
const HARD_BOUNDARY_KINDS = new Set<ParsedPdfBlock['kind']>(['section-header', 'title']); const HARD_BOUNDARY_KINDS = new Set<ParsedPdfBlock['kind']>([
'paragraph_title',
'doc_title',
]);
function findTailCandidateIndex(blocks: ParsedPdfBlock[]): number { function findTailCandidateIndex(blocks: ParsedPdfBlock[]): number {
for (let i = blocks.length - 1; i >= 0; i -= 1) { for (let i = blocks.length - 1; i >= 0; i -= 1) {
const block = blocks[i]; const block = blocks[i];
if (!block || !block.text.trim()) continue; if (!block || !block.text.trim()) continue;
if (block.kind === 'paragraph' || block.kind === 'list-item') return i; if (STITCHABLE_KINDS.has(block.kind)) return i;
} }
return -1; return -1;
} }
@ -33,7 +46,7 @@ function findHeadCandidateIndex(blocks: ParsedPdfBlock[]): number {
for (let i = 0; i < blocks.length; i += 1) { for (let i = 0; i < blocks.length; i += 1) {
const block = blocks[i]; const block = blocks[i];
if (!block || !block.text.trim()) continue; if (!block || !block.text.trim()) continue;
if (block.kind === 'paragraph' || block.kind === 'list-item') return i; if (STITCHABLE_KINDS.has(block.kind)) return i;
} }
return -1; return -1;
} }

View file

@ -2,25 +2,11 @@ import {
DEFAULT_DOCUMENT_SETTINGS, DEFAULT_DOCUMENT_SETTINGS,
type DocumentSettings, type DocumentSettings,
} from '@/types/document-settings'; } from '@/types/document-settings';
import type { ParsedPdfBlockKind } from '@/types/parsed-pdf'; import { PARSED_PDF_BLOCK_KINDS, type ParsedPdfBlockKind } from '@/types/parsed-pdf';
const PDF_BLOCK_KINDS: ParsedPdfBlockKind[] = [
'title',
'section-header',
'paragraph',
'list-item',
'caption',
'table',
'picture',
'page-header',
'page-footer',
'footnote',
'formula',
];
function normalizeSkipKinds(value: unknown): ParsedPdfBlockKind[] { function normalizeSkipKinds(value: unknown): ParsedPdfBlockKind[] {
if (!Array.isArray(value)) return [...(DEFAULT_DOCUMENT_SETTINGS.pdf?.skipBlockKinds ?? [])]; if (!Array.isArray(value)) return [...(DEFAULT_DOCUMENT_SETTINGS.pdf?.skipBlockKinds ?? [])];
const allow = new Set(PDF_BLOCK_KINDS); const allow = new Set(PARSED_PDF_BLOCK_KINDS);
const out: ParsedPdfBlockKind[] = []; const out: ParsedPdfBlockKind[] = [];
for (const entry of value) { for (const entry of value) {
if (typeof entry !== 'string') continue; if (typeof entry !== 'string') continue;

View file

@ -12,7 +12,7 @@ export interface DocumentSettings {
export const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = { export const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = {
schemaVersion: 1, schemaVersion: 1,
pdf: { pdf: {
skipBlockKinds: ['page-header', 'page-footer', 'footnote'], skipBlockKinds: ['header', 'footer', 'footnote', 'vision_footnote'],
chaptersFromSections: true, chaptersFromSections: true,
}, },
}; };

View file

@ -1,15 +1,49 @@
export type ParsedPdfBlockKind = export type ParsedPdfBlockKind =
| 'title' | 'abstract'
| 'section-header' | 'algorithm'
| 'paragraph' | 'aside_text'
| 'list-item' | 'chart'
| 'caption' | 'content'
| 'table' | 'formula'
| 'picture' | 'doc_title'
| 'page-header' | 'figure_title'
| 'page-footer' | 'footer'
| 'footnote' | 'footnote'
| 'formula'; | 'formula_number'
| 'header'
| 'image'
| 'number'
| 'paragraph_title'
| 'reference'
| 'reference_content'
| 'seal'
| 'table'
| 'text'
| 'vision_footnote';
export const PARSED_PDF_BLOCK_KINDS: ParsedPdfBlockKind[] = [
'abstract',
'algorithm',
'aside_text',
'chart',
'content',
'formula',
'doc_title',
'figure_title',
'footer',
'footnote',
'formula_number',
'header',
'image',
'number',
'paragraph_title',
'reference',
'reference_content',
'seal',
'table',
'text',
'vision_footnote',
];
export interface ParsedPdfBlockFragment { export interface ParsedPdfBlockFragment {
page: number; page: number;

View file

@ -4,7 +4,7 @@ import type { ParsedPdfDocument } from '../../src/types/parsed-pdf';
import type { DocumentSettings } from '../../src/types/document-settings'; import type { DocumentSettings } from '../../src/types/document-settings';
test.describe('pdf audiobook adapter', () => { test.describe('pdf audiobook adapter', () => {
test('builds chapters from section headers and filters skipped kinds', async () => { test('builds chapters from paragraph titles and filters skipped kinds', async () => {
const parsed: ParsedPdfDocument = { const parsed: ParsedPdfDocument = {
schemaVersion: 1, schemaVersion: 1,
documentId: 'doc-1', documentId: 'doc-1',
@ -18,31 +18,31 @@ test.describe('pdf audiobook adapter', () => {
blocks: [ blocks: [
{ {
id: 'b1', id: 'b1',
kind: 'section-header', kind: 'paragraph_title',
text: 'Intro', text: 'Intro',
fragments: [{ page: 1, bbox: [0, 80, 100, 90], text: 'Intro', readingOrder: 0 }], fragments: [{ page: 1, bbox: [0, 80, 100, 90], text: 'Intro', readingOrder: 0 }],
}, },
{ {
id: 'b2', id: 'b2',
kind: 'paragraph', kind: 'text',
text: 'Welcome text.', text: 'Welcome text.',
fragments: [{ page: 1, bbox: [0, 60, 100, 79], text: 'Welcome text.', readingOrder: 1 }], fragments: [{ page: 1, bbox: [0, 60, 100, 79], text: 'Welcome text.', readingOrder: 1 }],
}, },
{ {
id: 'b3', id: 'b3',
kind: 'page-header', kind: 'header',
text: 'Header line', text: 'Header line',
fragments: [{ page: 1, bbox: [0, 95, 100, 100], text: 'Header line', readingOrder: 2 }], fragments: [{ page: 1, bbox: [0, 95, 100, 100], text: 'Header line', readingOrder: 2 }],
}, },
{ {
id: 'b4', id: 'b4',
kind: 'section-header', kind: 'paragraph_title',
text: 'Second', text: 'Second',
fragments: [{ page: 1, bbox: [0, 40, 100, 50], text: 'Second', readingOrder: 3 }], fragments: [{ page: 1, bbox: [0, 40, 100, 50], text: 'Second', readingOrder: 3 }],
}, },
{ {
id: 'b5', id: 'b5',
kind: 'paragraph', kind: 'text',
text: 'More body.', text: 'More body.',
fragments: [{ page: 1, bbox: [0, 20, 100, 39], text: 'More body.', readingOrder: 4 }], fragments: [{ page: 1, bbox: [0, 20, 100, 39], text: 'More body.', readingOrder: 4 }],
}, },
@ -54,7 +54,7 @@ test.describe('pdf audiobook adapter', () => {
const settings: DocumentSettings = { const settings: DocumentSettings = {
schemaVersion: 1, schemaVersion: 1,
pdf: { pdf: {
skipBlockKinds: ['page-header'], skipBlockKinds: ['header'],
chaptersFromSections: true, chaptersFromSections: true,
}, },
}; };

View file

@ -11,26 +11,26 @@ test.describe('buildPageTextFromBlocks', () => {
blocks: [ blocks: [
{ {
id: 'b2', id: 'b2',
kind: 'page-header', kind: 'header',
text: 'Copyright Header', text: 'Copyright Header',
fragments: [{ page: 1, bbox: [0, 90, 100, 100], text: 'Copyright Header', readingOrder: 0 }], fragments: [{ page: 1, bbox: [0, 90, 100, 100], text: 'Copyright Header', readingOrder: 0 }],
}, },
{ {
id: 'b1', id: 'b1',
kind: 'paragraph', kind: 'text',
text: 'Body text', text: 'Body text',
fragments: [{ page: 1, bbox: [0, 20, 100, 80], text: 'Body text', readingOrder: 1 }], fragments: [{ page: 1, bbox: [0, 20, 100, 80], text: 'Body text', readingOrder: 1 }],
}, },
{ {
id: 'b3', id: 'b3',
kind: 'caption', kind: 'figure_title',
text: 'Figure caption', text: 'Figure caption',
fragments: [{ page: 1, bbox: [0, 5, 100, 19], text: 'Figure caption', readingOrder: 2 }], fragments: [{ page: 1, bbox: [0, 5, 100, 19], text: 'Figure caption', readingOrder: 2 }],
}, },
], ],
}; };
expect(buildPageTextFromBlocks(page, ['page-header'])).toBe('Body text Figure caption'); expect(buildPageTextFromBlocks(page, ['header'])).toBe('Body text Figure caption');
expect(buildPageTextFromBlocks(page, ['page-header', 'caption'])).toBe('Body text'); expect(buildPageTextFromBlocks(page, ['header', 'figure_title'])).toBe('Body text');
}); });
}); });

View file

@ -4,8 +4,8 @@ import { mergeTextWithRegions } from '../../src/lib/server/pdf-layout/mergeTextW
test.describe('mergeTextWithRegions', () => { test.describe('mergeTextWithRegions', () => {
test('assigns text items to containing regions by centroid and joins in reading order', () => { test('assigns text items to containing regions by centroid and joins in reading order', () => {
const regions = [ const regions = [
{ bbox: [0, 0, 100, 50] as [number, number, number, number], label: 'paragraph' as const }, { bbox: [0, 0, 100, 50] as [number, number, number, number], label: 'text' as const },
{ bbox: [0, 50, 100, 100] as [number, number, number, number], label: 'caption' as const }, { bbox: [0, 50, 100, 100] as [number, number, number, number], label: 'figure_title' as const },
]; ];
const textItems = [ const textItems = [

View file

@ -39,12 +39,12 @@ test.describe('stitchCrossPageBlocks', () => {
test('stitches paragraph continuation across footer/header noise', () => { test('stitches paragraph continuation across footer/header noise', () => {
const doc = makeDoc( const doc = makeDoc(
[ [
makeBlock('b1', 'paragraph', 'This sentence continues', 1, 0), makeBlock('b1', 'text', 'This sentence continues', 1, 0),
makeBlock('b2', 'page-footer', 'Footer text', 1, 1), makeBlock('b2', 'footer', 'Footer text', 1, 1),
], ],
[ [
makeBlock('b3', 'page-header', 'Header text', 2, 0), makeBlock('b3', 'header', 'Header text', 2, 0),
makeBlock('b4', 'paragraph', 'into the next page.', 2, 1), makeBlock('b4', 'text', 'into the next page.', 2, 1),
], ],
); );
@ -57,14 +57,14 @@ test.describe('stitchCrossPageBlocks', () => {
expect(page2?.blocks.map((b) => b.id)).toEqual(['b3']); expect(page2?.blocks.map((b) => b.id)).toEqual(['b3']);
}); });
test('does not stitch across section-header boundary', () => { test('does not stitch across paragraph-title boundary', () => {
const doc = makeDoc( const doc = makeDoc(
[ [
makeBlock('b1', 'paragraph', 'This sentence continues', 1, 0), makeBlock('b1', 'text', 'This sentence continues', 1, 0),
], ],
[ [
makeBlock('b2', 'section-header', '2 New Section', 2, 0), makeBlock('b2', 'paragraph_title', '2 New Section', 2, 0),
makeBlock('b3', 'paragraph', 'into the next page.', 2, 1), makeBlock('b3', 'text', 'into the next page.', 2, 1),
], ],
); );
@ -76,10 +76,10 @@ test.describe('stitchCrossPageBlocks', () => {
test('does not stitch when tail has sentence terminal', () => { test('does not stitch when tail has sentence terminal', () => {
const doc = makeDoc( const doc = makeDoc(
[ [
makeBlock('b1', 'paragraph', 'This sentence is complete.', 1, 0), makeBlock('b1', 'text', 'This sentence is complete.', 1, 0),
], ],
[ [
makeBlock('b2', 'paragraph', 'next sentence starts here', 2, 0), makeBlock('b2', 'text', 'next sentence starts here', 2, 0),
], ],
); );
@ -88,4 +88,3 @@ test.describe('stitchCrossPageBlocks', () => {
expect(stitched.pages[1]?.blocks.map((b) => b.id)).toEqual(['b2']); expect(stitched.pages[1]?.blocks.map((b) => b.id)).toEqual(['b2']);
}); });
}); });