docling-serve-client 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +466 -164
- package/dist/capabilities.d.ts +10 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/client.d.ts +3 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/core/input.d.ts +1 -1
- package/dist/core/input.d.ts.map +1 -1
- package/dist/core/pdf.d.ts +2 -2
- package/dist/core/pdf.d.ts.map +1 -1
- package/dist/core/validate.d.ts +2 -2
- package/dist/core/validate.d.ts.map +1 -1
- package/dist/document.d.ts.map +1 -1
- package/dist/images.d.ts +6 -3
- package/dist/images.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +272 -78
- package/dist/index.js.map +12 -11
- package/dist/options.d.ts +37 -5
- package/dist/options.d.ts.map +1 -1
- package/dist/types.d.ts +169 -17
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/capabilities.ts +109 -0
- package/src/client.ts +254 -81
- package/src/core/http.ts +2 -4
- package/src/core/input.ts +1 -3
- package/src/core/pdf.ts +2 -3
- package/src/core/validate.ts +2 -3
- package/src/document.ts +7 -19
- package/src/images.ts +4 -6
- package/src/index.ts +9 -0
- package/src/options.ts +142 -27
- package/src/response.ts +2 -2
- package/src/types.ts +191 -30
package/src/client.ts
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
// Copyright 2026 Martin Winkler
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
+
import { readCapabilities } from './capabilities.js';
|
|
4
5
|
import { errorMessage, fetchJson } from './core/http.js';
|
|
5
6
|
import { detectInputFormat, type InputFormat } from './core/input.js';
|
|
6
7
|
import { findTruncatedXref } from './core/pdf.js';
|
|
7
8
|
import { ERR, fail, ok } from './core/result.js';
|
|
8
9
|
import { PAGE_IMAGE_SCALE } from './images.js';
|
|
9
10
|
import {
|
|
11
|
+
type DocumentCall,
|
|
10
12
|
isJobId,
|
|
13
|
+
type MarkdownCall,
|
|
14
|
+
type PollCall,
|
|
11
15
|
type ResolvedOptions,
|
|
12
16
|
resolveCheckOptions,
|
|
13
17
|
resolveConfig,
|
|
14
18
|
resolveDocumentCall,
|
|
15
19
|
resolveMarkdownCall,
|
|
20
|
+
resolvePollOptions,
|
|
21
|
+
resolveRunDocumentCall,
|
|
22
|
+
resolveRunMarkdownCall,
|
|
16
23
|
validateJob,
|
|
17
24
|
} from './options.js';
|
|
18
25
|
import { type DoclingFailureInfo, readDocument, readFailure, readMarkdown, readText } from './response.js';
|
|
@@ -23,12 +30,15 @@ import type {
|
|
|
23
30
|
ConvertInput,
|
|
24
31
|
DoclingClient,
|
|
25
32
|
DoclingConfig,
|
|
33
|
+
DoclingSettings,
|
|
26
34
|
DocumentJob,
|
|
27
|
-
|
|
35
|
+
JobRef,
|
|
28
36
|
JobState,
|
|
29
37
|
MarkdownJob,
|
|
30
38
|
Result,
|
|
39
|
+
ServerCapabilities,
|
|
31
40
|
ServerVersions,
|
|
41
|
+
WithSettings,
|
|
32
42
|
} from './types.js';
|
|
33
43
|
|
|
34
44
|
// -------------------------------------------------------------------
|
|
@@ -55,15 +65,30 @@ type FormatFields = {
|
|
|
55
65
|
pageImages: boolean;
|
|
56
66
|
};
|
|
57
67
|
|
|
68
|
+
/** Parsed JSON answer of a conversion POST, with the settings its form was built from. */
|
|
69
|
+
type Posted = {
|
|
70
|
+
body: unknown;
|
|
71
|
+
settings: DoclingSettings;
|
|
72
|
+
};
|
|
73
|
+
|
|
58
74
|
// -------------------------------------------------------------------
|
|
59
75
|
// --- 2. Constants
|
|
60
76
|
// -------------------------------------------------------------------
|
|
61
77
|
|
|
62
|
-
/**
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
78
|
+
/** Table structure model; pinned so the server's fallback preset cannot change table output. */
|
|
79
|
+
const TABLE_STRUCTURE_PRESET = 'tableformer_v1_accurate';
|
|
80
|
+
|
|
81
|
+
/** Model of `pipeline: 'vlm'`. */
|
|
82
|
+
const VLM_PIPELINE_PRESET = 'granite_docling';
|
|
83
|
+
|
|
84
|
+
/** Model of `chartExtraction`. */
|
|
85
|
+
const CHART_EXTRACTION_PRESET = 'granite_vision_v4';
|
|
86
|
+
|
|
87
|
+
/** Model of `formulaEnrichment` and `codeEnrichment`. */
|
|
88
|
+
const CODE_FORMULA_PRESET = 'codeformulav2';
|
|
89
|
+
|
|
90
|
+
/** Matches each upper-case letter of a camelCase settings key, for its snake_case form field name. */
|
|
91
|
+
const MATCH_UPPER_CASE = /[A-Z]/g;
|
|
67
92
|
|
|
68
93
|
/** Error message for missing, empty or unrecognized input bytes. */
|
|
69
94
|
const INVALID_INPUT = 'Expected a non-empty PDF, PNG, JPEG, TIFF or WebP as Uint8Array';
|
|
@@ -89,13 +114,14 @@ const MAX_SHOWN_ID_LENGTH = 100;
|
|
|
89
114
|
/**
|
|
90
115
|
* **docling-serve client factory**.
|
|
91
116
|
*
|
|
92
|
-
* - validates configuration once; its only state is
|
|
93
|
-
* - returned functions resolve to `Result`; invalid options reject with
|
|
117
|
+
* - validates configuration once; its only state is cached server versions
|
|
118
|
+
* - returned functions resolve to `Result`; invalid options reject with `TypeError` before any request
|
|
94
119
|
* - `timeoutMs` limits every request except `/version`, which has its own limit
|
|
120
|
+
* - `pollMs` sets default delay between job helper status polls, overridable per call
|
|
95
121
|
* @throws `TypeError` on invalid configuration.
|
|
96
122
|
*/
|
|
97
123
|
export function createDoclingClient(config: DoclingConfig): DoclingClient {
|
|
98
|
-
const { baseUrl, apiKey, fetch: fetchFn, timeoutMs, defaults } = resolveConfig(config);
|
|
124
|
+
const { baseUrl, apiKey, fetch: fetchFn, timeoutMs, pollMs, defaults } = resolveConfig(config);
|
|
99
125
|
const headers: Record<string, string> = apiKey === undefined ? {} : { 'X-Api-Key': apiKey };
|
|
100
126
|
/** Pending or resolved `/version` answer shared by all calls of this client. */
|
|
101
127
|
let versionsCache: Promise<ServerVersions | null> | undefined;
|
|
@@ -106,18 +132,32 @@ export function createDoclingClient(config: DoclingConfig): DoclingClient {
|
|
|
106
132
|
submitMarkdown,
|
|
107
133
|
submitDocument,
|
|
108
134
|
check: check as DoclingClient['check'],
|
|
135
|
+
runMarkdown,
|
|
136
|
+
runDocument,
|
|
137
|
+
waitForJob: waitForJob as DoclingClient['waitForJob'],
|
|
138
|
+
capabilities,
|
|
109
139
|
};
|
|
110
140
|
|
|
111
141
|
/** Converts PDF or image bytes to md in one request. */
|
|
112
|
-
async function convertMarkdown(
|
|
142
|
+
async function convertMarkdown(
|
|
143
|
+
input: ConvertInput,
|
|
144
|
+
options?: unknown,
|
|
145
|
+
): Promise<Result<WithSettings<ConvertedMarkdown>>> {
|
|
113
146
|
const call = resolveMarkdownCall(defaults, options);
|
|
114
147
|
const fields: FormatFields = { format: 'md', embedImages: call.output.images === 'extract', pageImages: false };
|
|
115
148
|
const res = await post('/v1/convert/file', input, call.options, fields, call.signal);
|
|
116
|
-
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
return res;
|
|
151
|
+
}
|
|
152
|
+
const converted = await withVersions<ConvertedMarkdown>(readMarkdown(res.data.body, call.output));
|
|
153
|
+
return converted.ok ? ok({ ...converted.data, settings: res.data.settings }) : converted;
|
|
117
154
|
}
|
|
118
155
|
|
|
119
156
|
/** Converts PDF or image bytes to a DoclingDocument in one request. */
|
|
120
|
-
async function convertDocument(
|
|
157
|
+
async function convertDocument(
|
|
158
|
+
input: ConvertInput,
|
|
159
|
+
options?: unknown,
|
|
160
|
+
): Promise<Result<WithSettings<ConvertedDocument>>> {
|
|
121
161
|
const call = resolveDocumentCall(defaults, options);
|
|
122
162
|
const fields: FormatFields = {
|
|
123
163
|
format: 'json',
|
|
@@ -125,46 +165,126 @@ export function createDoclingClient(config: DoclingConfig): DoclingClient {
|
|
|
125
165
|
pageImages: call.pageImages,
|
|
126
166
|
};
|
|
127
167
|
const res = await post('/v1/convert/file', input, call.options, fields, call.signal);
|
|
128
|
-
|
|
168
|
+
if (!res.ok) {
|
|
169
|
+
return res;
|
|
170
|
+
}
|
|
171
|
+
const converted = await withVersions<ConvertedDocument>(readDocument(res.data.body));
|
|
172
|
+
return converted.ok ? ok({ ...converted.data, settings: res.data.settings }) : converted;
|
|
129
173
|
}
|
|
130
174
|
|
|
131
|
-
/** Starts an async md conversion; the job carries the image handling for `check
|
|
175
|
+
/** Starts an async md conversion; the job carries the image handling for `check` and the sent settings. */
|
|
132
176
|
async function submitMarkdown(input: ConvertInput, options?: unknown): Promise<Result<MarkdownJob>> {
|
|
133
177
|
const call = resolveMarkdownCall(defaults, options);
|
|
134
|
-
|
|
135
|
-
const id = await submit(input, call.options, fields, call.signal);
|
|
136
|
-
return id.ok ? ok({ kind: 'markdown', id: id.data, output: call.output }) : id;
|
|
178
|
+
return await startMarkdownJob(input, call);
|
|
137
179
|
}
|
|
138
180
|
|
|
139
181
|
/** Starts an async document conversion; page images need no job field, `check` moves any it finds. */
|
|
140
182
|
async function submitDocument(input: ConvertInput, options?: unknown): Promise<Result<DocumentJob>> {
|
|
141
183
|
const call = resolveDocumentCall(defaults, options);
|
|
184
|
+
return await startDocumentJob(input, call);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Submits an md job and polls it until done; resolves like `convertMarkdown`, with the job's settings. */
|
|
188
|
+
async function runMarkdown(
|
|
189
|
+
input: ConvertInput,
|
|
190
|
+
options?: unknown,
|
|
191
|
+
): Promise<Result<WithSettings<ConvertedMarkdown>>> {
|
|
192
|
+
const call = resolveRunMarkdownCall(defaults, pollMs, options);
|
|
193
|
+
const job = await startMarkdownJob(input, call);
|
|
194
|
+
if (!job.ok) {
|
|
195
|
+
return job;
|
|
196
|
+
}
|
|
197
|
+
const done = await pollUntilDone<ConvertedMarkdown>(job.data, call);
|
|
198
|
+
return done.ok ? ok({ ...done.data, settings: job.data.settings }) : done;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Submits a document job and polls it until done; resolves like `convertDocument`, with the job's settings. */
|
|
202
|
+
async function runDocument(
|
|
203
|
+
input: ConvertInput,
|
|
204
|
+
options?: unknown,
|
|
205
|
+
): Promise<Result<WithSettings<ConvertedDocument>>> {
|
|
206
|
+
const call = resolveRunDocumentCall(defaults, pollMs, options);
|
|
207
|
+
const job = await startDocumentJob(input, call);
|
|
208
|
+
if (!job.ok) {
|
|
209
|
+
return job;
|
|
210
|
+
}
|
|
211
|
+
const done = await pollUntilDone<ConvertedDocument>(job.data, call);
|
|
212
|
+
return done.ok ? ok({ ...done.data, settings: job.data.settings }) : done;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Polls an existing job until done; the job is validated and copied once, so later changes to it have no effect. */
|
|
216
|
+
async function waitForJob(job: JobRef, options?: unknown): Promise<Result<ConvertedMarkdown | ConvertedDocument>> {
|
|
217
|
+
const call = resolvePollOptions(pollMs, options);
|
|
218
|
+
const valid = validateJob(job);
|
|
219
|
+
return valid.ok ? pollUntilDone(valid.data, call) : valid;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Posts an md job for a validated call. */
|
|
223
|
+
async function startMarkdownJob(input: ConvertInput, call: MarkdownCall): Promise<Result<MarkdownJob>> {
|
|
224
|
+
const fields: FormatFields = { format: 'md', embedImages: call.output.images === 'extract', pageImages: false };
|
|
225
|
+
const task = await submit(input, call.options, fields, call.signal);
|
|
226
|
+
return task.ok
|
|
227
|
+
? ok({ kind: 'markdown', id: task.data.id, output: call.output, settings: task.data.settings })
|
|
228
|
+
: task;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Posts a document job for a validated call. */
|
|
232
|
+
async function startDocumentJob(input: ConvertInput, call: DocumentCall): Promise<Result<DocumentJob>> {
|
|
142
233
|
const fields: FormatFields = {
|
|
143
234
|
format: 'json',
|
|
144
235
|
embedImages: call.images === 'embedded',
|
|
145
236
|
pageImages: call.pageImages,
|
|
146
237
|
};
|
|
147
|
-
const
|
|
148
|
-
return
|
|
238
|
+
const task = await submit(input, call.options, fields, call.signal);
|
|
239
|
+
return task.ok ? ok({ kind: 'document', id: task.data.id, settings: task.data.settings }) : task;
|
|
149
240
|
}
|
|
150
241
|
|
|
151
242
|
/**
|
|
152
|
-
* **Polls a job
|
|
243
|
+
* **Polls a job until done**: waits `pollMs` after each `queued` or `running` status.
|
|
153
244
|
*
|
|
154
|
-
* -
|
|
155
|
-
* -
|
|
156
|
-
*
|
|
245
|
+
* - `job` must be validated: it is polled as is, without a new check per poll
|
|
246
|
+
* - first poll runs immediately; at most one request in flight
|
|
247
|
+
* - resolves to `done` result without `status`, or first error unchanged
|
|
248
|
+
* - abort during wait resolves to `aborted` immediately; abort during request returns request error
|
|
157
249
|
*/
|
|
250
|
+
async function pollUntilDone<T>(job: JobRef, { pollMs: waitMs, signal }: PollCall): Promise<Result<T>> {
|
|
251
|
+
for (;;) {
|
|
252
|
+
const state = await fetchJobState(job, signal);
|
|
253
|
+
if (!state.ok) {
|
|
254
|
+
return state;
|
|
255
|
+
}
|
|
256
|
+
if (state.data.status === 'done') {
|
|
257
|
+
const { status: _done, ...result } = state.data;
|
|
258
|
+
return ok(result as T);
|
|
259
|
+
}
|
|
260
|
+
if (!(await sleep(waitMs, signal))) {
|
|
261
|
+
return fail(ERR.ABORTED(`Aborted while waiting for job ${job.id}`));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Polls a job and fetches its result when done; a malformed job is `invalid_input` without a request. */
|
|
158
267
|
async function check(
|
|
159
|
-
job:
|
|
268
|
+
job: JobRef,
|
|
160
269
|
options?: unknown,
|
|
161
270
|
): Promise<Result<JobState<ConvertedMarkdown | ConvertedDocument>>> {
|
|
162
271
|
const { signal } = resolveCheckOptions(options);
|
|
163
272
|
const valid = validateJob(job);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
273
|
+
return valid.ok ? fetchJobState(valid.data, signal) : valid;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* **Status request for a validated job; result download when done**.
|
|
278
|
+
*
|
|
279
|
+
* - unknown job id: `not_found` with `Unknown job id`; also once docling-serve deleted the task,
|
|
280
|
+
* `DOCLING_SERVE_RESULT_REMOVAL_DELAY` (default 300 s) after the first result fetch
|
|
281
|
+
* - task deleted between the status poll and the result fetch: `not_found` with `Result already fetched or expired`
|
|
282
|
+
*/
|
|
283
|
+
async function fetchJobState(
|
|
284
|
+
job: JobRef,
|
|
285
|
+
signal: AbortSignal | undefined,
|
|
286
|
+
): Promise<Result<JobState<ConvertedMarkdown | ConvertedDocument>>> {
|
|
287
|
+
const { id } = job;
|
|
168
288
|
const res = await get(`/v1/status/poll/${id}`, signal);
|
|
169
289
|
if (!res.ok) {
|
|
170
290
|
const error =
|
|
@@ -191,9 +311,8 @@ export function createDoclingClient(config: DoclingConfig): DoclingClient {
|
|
|
191
311
|
: result.error;
|
|
192
312
|
return failWithVersions(error);
|
|
193
313
|
}
|
|
194
|
-
const checked = valid.data;
|
|
195
314
|
const output =
|
|
196
|
-
|
|
315
|
+
job.kind === 'markdown' ? readMarkdown(result.data, job.output) : readDocument(result.data);
|
|
197
316
|
const done = await withVersions<ConvertedMarkdown | ConvertedDocument>(output);
|
|
198
317
|
return done.ok ? ok({ status: 'done', ...done.data }) : done;
|
|
199
318
|
}
|
|
@@ -209,18 +328,33 @@ export function createDoclingClient(config: DoclingConfig): DoclingClient {
|
|
|
209
328
|
}
|
|
210
329
|
}
|
|
211
330
|
|
|
212
|
-
/**
|
|
331
|
+
/**
|
|
332
|
+
* **Queries `GET /v1/capabilities` on demand**.
|
|
333
|
+
*
|
|
334
|
+
* - older servers without the endpoint answer `http` (404)
|
|
335
|
+
*/
|
|
336
|
+
async function capabilities(options?: unknown): Promise<Result<ServerCapabilities>> {
|
|
337
|
+
const { signal } = resolveCheckOptions(options);
|
|
338
|
+
const res = await get('/v1/capabilities', signal);
|
|
339
|
+
if (!res.ok) {
|
|
340
|
+
return failWithVersions(res.error);
|
|
341
|
+
}
|
|
342
|
+
const read = readCapabilities(res.data);
|
|
343
|
+
return read.ok ? read : failWithVersions(read.error);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Posts the input to the async endpoint; resolves to the task id and the sent settings. */
|
|
213
347
|
async function submit(
|
|
214
348
|
input: ConvertInput,
|
|
215
349
|
options: ResolvedOptions,
|
|
216
350
|
fields: FormatFields,
|
|
217
351
|
signal: AbortSignal | undefined,
|
|
218
|
-
): Promise<Result<string>> {
|
|
352
|
+
): Promise<Result<{ id: string; settings: DoclingSettings }>> {
|
|
219
353
|
const res = await post('/v1/convert/file/async', input, options, fields, signal);
|
|
220
354
|
if (!res.ok) {
|
|
221
355
|
return res;
|
|
222
356
|
}
|
|
223
|
-
const id: unknown = (res.data as DoclingTaskStatus | null)?.task_id;
|
|
357
|
+
const id: unknown = (res.data.body as DoclingTaskStatus | null)?.task_id;
|
|
224
358
|
if (readText(id) === undefined) {
|
|
225
359
|
return failWithVersions(ERR.INVALID_RESPONSE('Response has no task_id'));
|
|
226
360
|
}
|
|
@@ -228,7 +362,7 @@ export function createDoclingClient(config: DoclingConfig): DoclingClient {
|
|
|
228
362
|
const shown = JSON.stringify(id).slice(0, MAX_SHOWN_ID_LENGTH);
|
|
229
363
|
return failWithVersions(ERR.INVALID_RESPONSE(`Response has an unusable task_id: ${shown}`));
|
|
230
364
|
}
|
|
231
|
-
return ok(id);
|
|
365
|
+
return ok({ id, settings: res.data.settings });
|
|
232
366
|
}
|
|
233
367
|
|
|
234
368
|
/** Detects and checks the input format, then sends a multipart POST with timeout; errors already carry versions. */
|
|
@@ -238,14 +372,15 @@ export function createDoclingClient(config: DoclingConfig): DoclingClient {
|
|
|
238
372
|
options: ResolvedOptions,
|
|
239
373
|
fields: FormatFields,
|
|
240
374
|
signal: AbortSignal | undefined,
|
|
241
|
-
): Promise<Result<
|
|
375
|
+
): Promise<Result<Posted>> {
|
|
242
376
|
const format = readInputFormat(input);
|
|
243
377
|
if (!format.ok) {
|
|
244
378
|
return format;
|
|
245
379
|
}
|
|
380
|
+
const settings = buildSettings(format.data, options, fields);
|
|
246
381
|
let body: FormData;
|
|
247
382
|
try {
|
|
248
|
-
body = buildFormData(input, format.data,
|
|
383
|
+
body = buildFormData(input, format.data, settings);
|
|
249
384
|
} catch (error) {
|
|
250
385
|
return fail(ERR.INVALID_INPUT(`Input could not be prepared for upload: ${errorMessage(error)}`));
|
|
251
386
|
}
|
|
@@ -256,7 +391,7 @@ export function createDoclingClient(config: DoclingConfig): DoclingClient {
|
|
|
256
391
|
body,
|
|
257
392
|
...(limited === undefined ? {} : { signal: limited }),
|
|
258
393
|
});
|
|
259
|
-
return res.ok ? res : failWithVersions(res.error);
|
|
394
|
+
return res.ok ? ok({ body: res.data, settings }) : failWithVersions(res.error);
|
|
260
395
|
}
|
|
261
396
|
|
|
262
397
|
/** GET request; `limit: false` skips `timeoutMs` (used by `/version`). */
|
|
@@ -320,9 +455,33 @@ export function createDoclingClient(config: DoclingConfig): DoclingClient {
|
|
|
320
455
|
}
|
|
321
456
|
|
|
322
457
|
// -------------------------------------------------------------------
|
|
323
|
-
// --- 4.
|
|
458
|
+
// --- 4. Private Functions
|
|
324
459
|
// -------------------------------------------------------------------
|
|
325
460
|
|
|
461
|
+
/**
|
|
462
|
+
* **Waits `ms` unless `signal` aborts**.
|
|
463
|
+
*
|
|
464
|
+
* - resolves `true` after the wait, `false` at once on abort (also when already aborted)
|
|
465
|
+
* - clears its timer and removes its listener either way
|
|
466
|
+
*/
|
|
467
|
+
function sleep(ms: number, signal: AbortSignal | undefined): Promise<boolean> {
|
|
468
|
+
if (signal?.aborted) {
|
|
469
|
+
return Promise.resolve(false);
|
|
470
|
+
}
|
|
471
|
+
return new Promise((resolve) => {
|
|
472
|
+
const timer = setTimeout(() => {
|
|
473
|
+
signal?.removeEventListener('abort', onAbort);
|
|
474
|
+
resolve(true);
|
|
475
|
+
}, ms);
|
|
476
|
+
/** Ends the wait early. */
|
|
477
|
+
function onAbort() {
|
|
478
|
+
clearTimeout(timer);
|
|
479
|
+
resolve(false);
|
|
480
|
+
}
|
|
481
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
326
485
|
/** Caller signal combined with a `timeoutMs` limit; either alone when the other is unset. */
|
|
327
486
|
function withTimeout(signal: AbortSignal | undefined, timeoutMs: number | undefined): AbortSignal | undefined {
|
|
328
487
|
if (timeoutMs === undefined) {
|
|
@@ -354,60 +513,74 @@ function readInputFormat(input: unknown): Result<InputFormat> {
|
|
|
354
513
|
}
|
|
355
514
|
|
|
356
515
|
/**
|
|
357
|
-
* **
|
|
516
|
+
* **Every form field of a conversion request, as typed settings**.
|
|
358
517
|
*
|
|
359
|
-
* -
|
|
360
|
-
* - md: `md_compact_tables=true`, no page break placeholder
|
|
518
|
+
* - md: `md_compact_tables=true`, empty page break placeholder; json sends neither
|
|
361
519
|
* - images: `image_export_mode=embedded` when picture data or page images are requested, else `placeholder`;
|
|
362
520
|
* `include_images` and `include_page_images` pick which data docling sends
|
|
363
521
|
* - `images_scale` fixed at `PAGE_IMAGE_SCALE`; it sets picture and page image resolution only, OCR has its own scale
|
|
522
|
+
* - PDF input: `forceOcr` enables OCR even with `ocr: false` (docling skips `force_ocr` when `do_ocr=false`)
|
|
364
523
|
* - image input: OCR always on and never full-page, whatever `ocr` and `forceOcr` say
|
|
524
|
+
* - model presets pinned; a preset of a disabled stage is not sent
|
|
525
|
+
* - fields docling-serve would default silently are sent with that default
|
|
365
526
|
*/
|
|
366
|
-
function
|
|
367
|
-
|
|
368
|
-
{ fromFormat, mimeType, fileName }: InputFormat,
|
|
527
|
+
function buildSettings(
|
|
528
|
+
{ fromFormat }: InputFormat,
|
|
369
529
|
options: ResolvedOptions,
|
|
370
530
|
{ format, embedImages, pageImages }: FormatFields,
|
|
371
|
-
):
|
|
531
|
+
): DoclingSettings {
|
|
532
|
+
const isImage = fromFormat === 'image';
|
|
533
|
+
const isMarkdown = format === 'md';
|
|
534
|
+
return {
|
|
535
|
+
fromFormats: fromFormat,
|
|
536
|
+
toFormats: format,
|
|
537
|
+
imageExportMode: embedImages || pageImages ? 'embedded' : 'placeholder',
|
|
538
|
+
includeImages: embedImages,
|
|
539
|
+
includePageImages: pageImages,
|
|
540
|
+
imagesScale: PAGE_IMAGE_SCALE,
|
|
541
|
+
mdCompactTables: isMarkdown ? true : null,
|
|
542
|
+
mdPageBreakPlaceholder: isMarkdown ? '' : null,
|
|
543
|
+
doOcr: isImage || options.ocr || options.forceOcr,
|
|
544
|
+
forceOcr: !isImage && options.forceOcr,
|
|
545
|
+
ocrPreset: options.ocrEngine,
|
|
546
|
+
ocrLang: [...(options.ocrLang ?? [])],
|
|
547
|
+
pdfBackend: options.pdfBackend,
|
|
548
|
+
pipeline: options.pipeline,
|
|
549
|
+
vlmPipelinePreset: options.pipeline === 'vlm' ? VLM_PIPELINE_PRESET : null,
|
|
550
|
+
doTableStructure: true,
|
|
551
|
+
tableMode: 'accurate',
|
|
552
|
+
tableStructurePreset: TABLE_STRUCTURE_PRESET,
|
|
553
|
+
tableCellMatching: true,
|
|
554
|
+
doPdfHeadingHierarchy: options.headingHierarchy,
|
|
555
|
+
doFormulaEnrichment: options.formulaEnrichment,
|
|
556
|
+
doCodeEnrichment: options.codeEnrichment,
|
|
557
|
+
codeFormulaPreset: options.formulaEnrichment || options.codeEnrichment ? CODE_FORMULA_PRESET : null,
|
|
558
|
+
doChartExtraction: options.chartExtraction,
|
|
559
|
+
chartExtractionPreset: options.chartExtraction ? CHART_EXTRACTION_PRESET : null,
|
|
560
|
+
doPictureClassification: false,
|
|
561
|
+
doPictureDescription: false,
|
|
562
|
+
pageRange: options.pageRange === undefined ? null : [options.pageRange[0], options.pageRange[1]],
|
|
563
|
+
documentTimeout: options.documentTimeout ?? null,
|
|
564
|
+
abortOnError: false,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* **Builds the multipart body from the settings, so sent fields and returned settings cannot differ**.
|
|
570
|
+
*
|
|
571
|
+
* - field name: settings key in snake_case (`ocrPreset` → `ocr_preset`)
|
|
572
|
+
* - `null` not sent; lists as repeated fields (empty list: not sent); booleans and numbers as strings
|
|
573
|
+
* - input: file name and MIME type matching `from_formats`
|
|
574
|
+
*/
|
|
575
|
+
function buildFormData(input: ConvertInput, { mimeType, fileName }: InputFormat, settings: DoclingSettings): FormData {
|
|
372
576
|
const form = new FormData();
|
|
373
|
-
// Blob does not accept SharedArrayBuffer-backed input: copy only that into a fresh ArrayBuffer.
|
|
374
577
|
const bytes = input.buffer instanceof ArrayBuffer ? (input as Uint8Array<ArrayBuffer>) : new Uint8Array(input);
|
|
375
578
|
form.append('files', new Blob([bytes], { type: mimeType }), fileName);
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
if (format === 'md') {
|
|
382
|
-
form.append('md_compact_tables', 'true');
|
|
383
|
-
}
|
|
384
|
-
form.append('include_images', String(embedImages));
|
|
385
|
-
form.append('include_page_images', String(pageImages));
|
|
386
|
-
form.append('image_export_mode', embedImages || pageImages ? 'embedded' : 'placeholder');
|
|
387
|
-
form.append('images_scale', String(PAGE_IMAGE_SCALE));
|
|
388
|
-
form.append('do_pdf_heading_hierarchy', String(options.headingHierarchy));
|
|
389
|
-
form.append('do_formula_enrichment', String(options.formulaEnrichment));
|
|
390
|
-
form.append('do_code_enrichment', String(options.codeEnrichment));
|
|
391
|
-
if (options.formulaEnrichment || options.codeEnrichment) {
|
|
392
|
-
form.append('code_formula_preset', 'codeformulav2');
|
|
393
|
-
}
|
|
394
|
-
form.append('do_chart_extraction', String(options.chartExtraction));
|
|
395
|
-
form.append('pipeline', options.pipeline);
|
|
396
|
-
form.append('pdf_backend', options.pdfBackend);
|
|
397
|
-
// an image has no text layer: without OCR it yields no text, and full-page OCR merges its words
|
|
398
|
-
const isImage = fromFormat === 'image';
|
|
399
|
-
form.append('do_ocr', String(isImage || options.ocr));
|
|
400
|
-
form.append('force_ocr', String(!isImage && options.forceOcr));
|
|
401
|
-
// lists are sent as repeated fields
|
|
402
|
-
for (const lang of options.ocrLang ?? []) {
|
|
403
|
-
form.append('ocr_lang', lang);
|
|
404
|
-
}
|
|
405
|
-
if (options.pageRange !== undefined) {
|
|
406
|
-
form.append('page_range', String(options.pageRange[0]));
|
|
407
|
-
form.append('page_range', String(options.pageRange[1]));
|
|
408
|
-
}
|
|
409
|
-
if (options.documentTimeout !== undefined) {
|
|
410
|
-
form.append('document_timeout', String(options.documentTimeout));
|
|
579
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
580
|
+
const name = key.replace(MATCH_UPPER_CASE, (letter) => `_${letter.toLowerCase()}`);
|
|
581
|
+
for (const item of value === null ? [] : Array.isArray(value) ? value : [value]) {
|
|
582
|
+
form.append(name, String(item));
|
|
583
|
+
}
|
|
411
584
|
}
|
|
412
585
|
return form;
|
|
413
586
|
}
|
package/src/core/http.ts
CHANGED
|
@@ -61,7 +61,7 @@ export function errorMessage(e: unknown): string {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
// -------------------------------------------------------------------
|
|
64
|
-
// --- 3.
|
|
64
|
+
// --- 3. Private Functions
|
|
65
65
|
// -------------------------------------------------------------------
|
|
66
66
|
|
|
67
67
|
/** Checks whether an error indicates body size exceeded memory or maximum string length. */
|
|
@@ -89,8 +89,6 @@ function errorDetail(text: string): string {
|
|
|
89
89
|
const { detail } = body;
|
|
90
90
|
return typeof detail === 'string' ? detail : JSON.stringify(detail);
|
|
91
91
|
}
|
|
92
|
-
} catch {
|
|
93
|
-
// Not JSON: fall back to raw text
|
|
94
|
-
}
|
|
92
|
+
} catch {}
|
|
95
93
|
return text.slice(0, 500) || 'no response body';
|
|
96
94
|
}
|
package/src/core/input.ts
CHANGED
|
@@ -33,10 +33,8 @@ type ImageSignature = {
|
|
|
33
33
|
const IMAGE_SIGNATURES: readonly ImageSignature[] = [
|
|
34
34
|
{ mimeType: 'image/png', extension: 'png', parts: [[0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]]] },
|
|
35
35
|
{ mimeType: 'image/jpeg', extension: 'jpg', parts: [[0, [0xff, 0xd8, 0xff]]] },
|
|
36
|
-
// little-endian `II*\0` and big-endian `MM\0*`
|
|
37
36
|
{ mimeType: 'image/tiff', extension: 'tiff', parts: [[0, [0x49, 0x49, 0x2a, 0x00]]] },
|
|
38
37
|
{ mimeType: 'image/tiff', extension: 'tiff', parts: [[0, [0x4d, 0x4d, 0x00, 0x2a]]] },
|
|
39
|
-
// `RIFF` <size> `WEBP`
|
|
40
38
|
{
|
|
41
39
|
mimeType: 'image/webp',
|
|
42
40
|
extension: 'webp',
|
|
@@ -59,7 +57,7 @@ const PDF_FORMAT: InputFormat = { fromFormat: 'pdf', mimeType: 'application/pdf'
|
|
|
59
57
|
*
|
|
60
58
|
* - image signatures must start at byte 0; PDF: `%PDF-` within the first 1024 bytes
|
|
61
59
|
* - images are checked first, so an image whose data happens to contain `%PDF-` is still an image
|
|
62
|
-
*
|
|
60
|
+
* - returns `undefined` for empty, non-`Uint8Array` or unrecognized input
|
|
63
61
|
*/
|
|
64
62
|
export function detectInputFormat(bytes: unknown): InputFormat | undefined {
|
|
65
63
|
if (!(bytes instanceof Uint8Array) || bytes.length === 0) {
|
package/src/core/pdf.ts
CHANGED
|
@@ -25,7 +25,6 @@ export function isPdf(bytes: unknown): bytes is Uint8Array {
|
|
|
25
25
|
}
|
|
26
26
|
const head = bytes.subarray(0, HEADER_SEARCH_LENGTH);
|
|
27
27
|
for (let i = 0; i + 5 <= head.length; i++) {
|
|
28
|
-
// %PDF-
|
|
29
28
|
if (
|
|
30
29
|
head[i] === 0x25 &&
|
|
31
30
|
head[i + 1] === 0x50 &&
|
|
@@ -40,11 +39,11 @@ export function isPdf(bytes: unknown): bytes is Uint8Array {
|
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
/**
|
|
43
|
-
* **Returns cross-reference offset if PDF is truncated**: last
|
|
42
|
+
* **Returns cross-reference offset if PDF is truncated**: last `startxref` points at or past EOF.
|
|
44
43
|
*
|
|
45
44
|
* - typical for aborted downloads; docling then fails with a vague load error
|
|
46
45
|
* - only the last 2 KB are read, as latin1
|
|
47
|
-
* - returns
|
|
46
|
+
* - returns `undefined` when offset lies inside file or no `startxref` found
|
|
48
47
|
*/
|
|
49
48
|
export function findTruncatedXref(bytes: Uint8Array): number | undefined {
|
|
50
49
|
const tail = new TextDecoder('latin1').decode(bytes.subarray(Math.max(0, bytes.length - TAIL_LENGTH)));
|
package/src/core/validate.ts
CHANGED
|
@@ -47,7 +47,6 @@ export function validateKeys<T extends object>(
|
|
|
47
47
|
}
|
|
48
48
|
valid[name] = Array.isArray(value) ? [...value] : value;
|
|
49
49
|
}
|
|
50
|
-
// Every key was checked against its rule above.
|
|
51
50
|
return valid as Partial<T>;
|
|
52
51
|
}
|
|
53
52
|
|
|
@@ -74,12 +73,12 @@ export function isPlainObject(value: unknown): value is Record<string, unknown>
|
|
|
74
73
|
return prototype === Object.prototype || prototype === null;
|
|
75
74
|
}
|
|
76
75
|
|
|
77
|
-
/**
|
|
76
|
+
/** Returns true for boolean primitives (`true` or `false`). */
|
|
78
77
|
export function isBoolean(value: unknown): value is boolean {
|
|
79
78
|
return value === true || value === false;
|
|
80
79
|
}
|
|
81
80
|
|
|
82
|
-
/**
|
|
81
|
+
/** Returns true when string belongs to allowed set. */
|
|
83
82
|
export function isOneOf<T extends string>(value: unknown, allowed: readonly T[]): value is T {
|
|
84
83
|
return typeof value === 'string' && (allowed as readonly string[]).includes(value);
|
|
85
84
|
}
|
package/src/document.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// biome-ignore-all lint/style/useNamingConvention: mirrors docling-core's snake_case JSON
|
|
5
5
|
|
|
6
6
|
// -------------------------------------------------------------------
|
|
7
|
-
// --- 1.
|
|
7
|
+
// --- 1. Types
|
|
8
8
|
// -------------------------------------------------------------------
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -54,9 +54,7 @@ export type PageItem = {
|
|
|
54
54
|
page_no: number;
|
|
55
55
|
};
|
|
56
56
|
|
|
57
|
-
//
|
|
58
|
-
// --- 2. Shared Parts
|
|
59
|
-
// -------------------------------------------------------------------
|
|
57
|
+
// Shared Parts
|
|
60
58
|
|
|
61
59
|
/** JSON pointer to another item, e.g. `{ $ref: '#/texts/12' }` or `{ $ref: '#/body' }`. */
|
|
62
60
|
export type RefItem = { $ref: string };
|
|
@@ -182,9 +180,7 @@ type FloatingFields = {
|
|
|
182
180
|
image: ImageRef | null;
|
|
183
181
|
};
|
|
184
182
|
|
|
185
|
-
//
|
|
186
|
-
// --- 3. Groups
|
|
187
|
-
// -------------------------------------------------------------------
|
|
183
|
+
// Groups
|
|
188
184
|
|
|
189
185
|
/** Docling's group kinds. */
|
|
190
186
|
export type GroupLabel =
|
|
@@ -214,9 +210,7 @@ export type ListGroup = GroupItem & { label: 'list' };
|
|
|
214
210
|
/** Run of texts that belong to one line or paragraph, e.g. with mixed formatting. */
|
|
215
211
|
export type InlineGroup = GroupItem & { label: 'inline' };
|
|
216
212
|
|
|
217
|
-
//
|
|
218
|
-
// --- 4. Texts
|
|
219
|
-
// -------------------------------------------------------------------
|
|
213
|
+
// Texts
|
|
220
214
|
|
|
221
215
|
/** Labels of plain `TextItem`s; other text labels have their own item type. */
|
|
222
216
|
export type TextLabel =
|
|
@@ -284,9 +278,7 @@ export type AnyTextItem =
|
|
|
284
278
|
| FieldValueItem
|
|
285
279
|
| TextItem;
|
|
286
280
|
|
|
287
|
-
//
|
|
288
|
-
// --- 5. Tables
|
|
289
|
-
// -------------------------------------------------------------------
|
|
281
|
+
// Tables
|
|
290
282
|
|
|
291
283
|
/** Table cell; offsets are 0-based and end-exclusive, spans repeat the cell in `grid`. */
|
|
292
284
|
export type TableCell = {
|
|
@@ -331,9 +323,7 @@ export type TableItem = DocItem &
|
|
|
331
323
|
annotations: (DescriptionAnnotation | MiscAnnotation)[];
|
|
332
324
|
};
|
|
333
325
|
|
|
334
|
-
//
|
|
335
|
-
// --- 6. Pictures
|
|
336
|
-
// -------------------------------------------------------------------
|
|
326
|
+
// Pictures
|
|
337
327
|
|
|
338
328
|
/**
|
|
339
329
|
* **Model output on a picture**: description, classification, molecule or chart data.
|
|
@@ -379,9 +369,7 @@ export type PictureItem = DocItem &
|
|
|
379
369
|
annotations: PictureAnnotation[];
|
|
380
370
|
};
|
|
381
371
|
|
|
382
|
-
//
|
|
383
|
-
// --- 7. Forms and Key-Value Regions
|
|
384
|
-
// -------------------------------------------------------------------
|
|
372
|
+
// Forms and Key-Value Regions
|
|
385
373
|
|
|
386
374
|
/** Cell of a key-value or form graph. */
|
|
387
375
|
export type GraphCell = {
|