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/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
- Job,
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
- /** Fixed conversion form fields sent with every request. */
63
- const FIXED_FORM_FIELDS: [name: string, value: string][] = [
64
- ['do_table_structure', 'true'],
65
- ['table_mode', 'accurate'],
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 the cached server versions
93
- * - returned functions resolve to `Result`; invalid options reject with a `TypeError` before any request
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(input: ConvertInput, options?: unknown): Promise<Result<ConvertedMarkdown>> {
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
- return res.ok ? withVersions(readMarkdown(res.data, call.output)) : res;
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(input: ConvertInput, options?: unknown): Promise<Result<ConvertedDocument>> {
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
- return res.ok ? withVersions(readDocument(res.data)) : res;
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
- const fields: FormatFields = { format: 'md', embedImages: call.output.images === 'extract', pageImages: false };
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 id = await submit(input, call.options, fields, call.signal);
148
- return id.ok ? ok({ kind: 'document', id: id.data }) : id;
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 and fetches its result when done**.
243
+ * **Polls a job until done**: waits `pollMs` after each `queued` or `running` status.
153
244
  *
154
- * - unknown job id: `not_found` with `Unknown job id`
155
- * - result fetched more than `DOCLING_SERVE_RESULT_REMOVAL_DELAY` (default 300 s) ago or expired: `not_found` with
156
- * `Result already fetched or expired`
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: 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
- if (!valid.ok) {
165
- return valid;
166
- }
167
- const { id } = valid.data;
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
- checked.kind === 'markdown' ? readMarkdown(result.data, checked.output) : readDocument(result.data);
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
- /** Posts the input to the async endpoint; resolves to the task id. */
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<unknown>> {
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, options, fields);
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. Internal Functions
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
- * **Builds the multipart body**: input file, fixed fields, output format and every docling option explicitly.
516
+ * **Every form field of a conversion request, as typed settings**.
358
517
  *
359
- * - input: `from_formats` (`pdf` or `image`) with a matching file name and MIME type
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 buildFormData(
367
- input: ConvertInput,
368
- { fromFormat, mimeType, fileName }: InputFormat,
527
+ function buildSettings(
528
+ { fromFormat }: InputFormat,
369
529
  options: ResolvedOptions,
370
530
  { format, embedImages, pageImages }: FormatFields,
371
- ): FormData {
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
- form.append('from_formats', fromFormat);
377
- for (const [name, value] of FIXED_FORM_FIELDS) {
378
- form.append(name, value);
379
- }
380
- form.append('to_formats', format);
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. Internal Functions
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
- * @returns `undefined` for empty, non-`Uint8Array` or unrecognized input
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 'startxref' points at or past EOF.
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 'undefined' when offset lies inside file or no 'startxref' found
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)));
@@ -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
- /** Checks whether value is a boolean primitive. */
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
- /** Type guard checking whether string is included in allowed set. */
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. Document
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 = {