147 Commits

Author SHA1 Message Date
DKL
025c4c8942 Cleanup. Text formatting. Fallback picture annotation. 2025-11-24 15:17:39 +01:00
DKL
8d5892b176 Revamp UI to SSR.
Signed-off-by: DKL <dkl@zurich.ibm.com>
2025-11-21 16:15:36 +01:00
Michele Dolfi
e437e830c9 fix: Dependencies updates – Docling 2.63.0 (#443)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-11-21 10:31:56 +01:00
Michele Dolfi
2c23f65507 feat: version endpoint (#442)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-11-20 17:57:10 +01:00
Burt Holzman
5dc942f25b chore: docs typo (cude -> cuda) (#437)
Signed-off-by: Burt Holzman <burt@fnal.gov>
2025-11-17 08:31:44 +01:00
github-actions[bot]
ff310f2b13 chore: bump version to 1.8.0 [skip ci] 2025-10-31 17:01:56 +00:00
Michele Dolfi
bf132a3c3e feat: Docling with new standard pipeline with threading (#428)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-10-31 17:57:38 +01:00
Michele Dolfi
35319b0da7 docs: Expand automatic docs to nested objects. More complete usage docs. (#426)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-10-31 15:02:20 +01:00
Michele Dolfi
f3957aeb57 docs: add docs for docling parameters like performance and debug (#424)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-10-31 14:17:31 +01:00
github-actions[bot]
1ec44220f5 chore: bump version to 1.7.2 [skip ci] 2025-10-30 15:14:17 +00:00
Michele Dolfi
e9b41406c4 fix: Update locked dependencies. Docling fixes, Expose temperature parameter for vlm models (#423)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-10-30 16:09:21 +01:00
Michele Dolfi
a2e68d39ae test: check that processing time is not skipped (#416)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-10-27 08:29:05 +01:00
Michele Dolfi
7bf2e7b366 fix: temporary constrain fastapi version (#418)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-10-24 11:22:05 +02:00
github-actions[bot]
462ceff9d1 chore: bump version to 1.7.1 [skip ci] 2025-10-22 14:01:58 +00:00
Michele Dolfi
97613a1974 fix: Upgrade dependencies (#417)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-10-22 15:42:59 +02:00
Paweł Rein
0961f2c574 fix: makes task status shared across multiple instances in RQ mode, resolves #378 (#415)
Signed-off-by: Pawel Rein <pawel.rein@prezi.com>
2025-10-21 15:08:42 +02:00
Tiago Santana
9672f310b1 docs: Generate usage.md automatically (#340)
Signed-off-by: Tiago Santana <54704492+SantanaTiago@users.noreply.github.com>
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
Co-authored-by: Michele Dolfi <dol@zurich.ibm.com>
2025-10-21 14:27:01 +02:00
Michele Dolfi
56e8535a7a chore: publish release notes on Discord (#409)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-10-20 14:15:58 +02:00
Michele Dolfi
0f274ab135 fix: DOCLING_SERVE_SYNC_POLL_INTERVAL controls the synchronous polling time (#413)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-10-20 14:14:00 +02:00
Michele Dolfi
0427f71ef4 chore: allow to change the container runtime (#412)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-10-20 14:13:51 +02:00
github-actions[bot]
b6eece7ef0 chore: bump version to 1.7.0 [skip ci] 2025-10-17 12:16:37 +00:00
Michele Dolfi
f5af71e8f6 feat(UI): add auto and orcmac options in demo UI (#408)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-10-17 12:23:57 +02:00
Michele Dolfi
d95ea94087 feat: Docling with auto-ocr (#403)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-10-15 21:15:29 +02:00
sahlex
5344505718 fix: run docling ui behind a reverse proxy using a context path (#396)
Signed-off-by: Sahler.Alexander <Alexander.Sahler@m-net.de>
Signed-off-by: sahlex <1122279+sahlex@users.noreply.github.com>
Co-authored-by: Sahler.Alexander <Alexander.Sahler@m-net.de>
2025-10-09 16:07:02 +02:00
github-actions[bot]
5edc624fbf chore: bump version to 1.6.0 [skip ci] 2025-10-03 13:39:59 +00:00
Michele Dolfi
45f0f3c8f9 fix: update locked dependencies (#392)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-10-03 15:33:45 +02:00
Michele Dolfi
0595d31d5b feat: pin new version of jobkit with granite-docling and connectors (#391)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-10-03 14:24:51 +02:00
Michele Dolfi
f6b5f0e063 docs: fix docs for websocket breaking condition (#390)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-10-02 10:55:00 +02:00
Michele Dolfi
8b22a39141 fix(UI): allow both lowercase and uppercase extensions (#386)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-09-29 09:40:49 +02:00
erikmargaronis
d4eac053f9 fix: Correctly raise HTTPException for Gateway Timeout (#382)
Signed-off-by: Erik Margaronis <erik.margaronis@gmail.com>
2025-09-29 08:06:21 +02:00
Rui Dias Gomes
fa1c5f04f3 ci: improve caching steps (#371)
Signed-off-by: rmdg88 <rmdg88@gmail.com>
2025-09-23 18:15:12 +02:00
Viktor Kuropiatnyk
ba61af2359 fix: Pinning of higher version of dependencies to fix potential security issues (#363)
Signed-off-by: Viktor Kuropiatnyk <vku@zurich.ibm.com>
2025-09-18 08:57:41 +02:00
github-actions[bot]
6b6dd8a0d0 chore: bump version to 1.5.1 [skip ci] 2025-09-17 13:45:40 +00:00
Michele Dolfi
513ae0c119 fix: remove old dependencies, fixes in docling-parse and more minor dependencies upgrade (#362)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-09-17 15:36:23 +02:00
Rui Dias Gomes
bde040661f fix: updates rapidocr deps (#361)
Signed-off-by: rmdg88 <rmdg88@gmail.com>
2025-09-16 14:00:21 +02:00
github-actions[bot]
496f7ec26b chore: bump version to 1.5.0 [skip ci] 2025-09-09 08:46:36 +00:00
Michele Dolfi
9d6def0ec8 feat: add chunking endpoints (#353)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-09-09 08:38:54 +02:00
github-actions[bot]
a4fed2d965 chore: bump version to 1.4.1 [skip ci] 2025-09-08 10:28:12 +00:00
Michele Dolfi
b0360d723b fix: trigger fix after ci fixes (#355)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-09-08 12:23:07 +02:00
Michele Dolfi
4adc0dfa79 ci: fix use simple tag for testing (#354)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-09-08 11:29:55 +02:00
github-actions[bot]
40c7f1bcd3 chore: bump version to 1.4.0 [skip ci] 2025-09-05 17:57:08 +00:00
Michele Dolfi
d64a2a974a feat(docling): perfomance improvements in parsing, new layout model, fixes in html processing (#352)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-09-05 16:21:29 +02:00
Tiago Santana
0d4545a65a docs: add split processing example (#303)
Signed-off-by: Tiago Santana <54704492+SantanaTiago@users.noreply.github.com>
Co-authored-by: Michele Dolfi <dol@zurich.ibm.com>
2025-09-04 10:42:11 +02:00
Rui Dias Gomes
fe98338239 ci: fix runner disk space issue (#350)
Signed-off-by: Rui Dias Gomes <66125272+rmdg88@users.noreply.github.com>
2025-09-04 09:17:19 +02:00
Michele Dolfi
b844ce737e ci: remove mdlint (#348)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-09-03 15:42:55 +02:00
Antonio Pisano
27fdd7b85a docs: document DOCLING_NUM_THREADS environment variable (#341)
Signed-off-by: Antonio Pisano <antonio.pisano@wu.ac.at>
Co-authored-by: Antonio Pisano <antonio.pisano@wu.ac.at>
2025-09-03 11:00:28 +02:00
Rui Dias Gomes
1df62adf01 ci: workflow improvements (#310)
Signed-off-by: rmdg88 <rmdg88@gmail.com>
Signed-off-by: Rui Dias Gomes <66125272+rmdg88@users.noreply.github.com>
2025-09-03 10:06:30 +02:00
Michele Dolfi
e5449472b2 fix: upgrade to latest docling version with fixes (#335)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-08-25 10:55:43 +02:00
Michele Dolfi
81f0a8ddf8 docs: fix parameters typo (#333)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-08-22 14:59:12 +02:00
Michele Dolfi
a69cc867f5 docs: Describe how to use Docling MCP (#332)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-08-22 14:56:08 +02:00
github-actions[bot]
624f65d41b chore: bump version to 1.3.1 [skip ci] 2025-08-21 07:01:51 +00:00
Michele Dolfi
f02dbc0144 fix: configuration and performance fixes via upgrade of packages (#328)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-08-20 20:40:52 +02:00
Michele Dolfi
37fe02277b docs: fix parameter in api key docs (#323)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-08-15 11:00:05 +02:00
github-actions[bot]
783ada0580 chore: bump version to 1.3.0 [skip ci] 2025-08-14 14:26:57 +00:00
VIktor Kuropiantnyk
71edf41849 docs: example of docling-serve deployment in the RQ engine mode (#321)
Signed-off-by: Viktor Kuropiatnyk <vku@zurich.ibm.com>
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
Co-authored-by: Michele Dolfi <dol@zurich.ibm.com>
2025-08-14 16:10:39 +02:00
Michele Dolfi
9a64410552 feat: Add configuration option for apikey security (#322)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-08-14 15:25:53 +02:00
Michele Dolfi
6e9aa8c759 docs: handling models in docling-serve (#319)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-08-14 09:12:04 +02:00
Michele Dolfi
885f319d3a feat: Add RQ engine (#315)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-08-14 08:48:31 +02:00
Tiago Santana
d584895e11 docs: add Gradio cache usage (#312)
Signed-off-by: Tiago Santana <54704492+SantanaTiago@users.noreply.github.com>
2025-08-13 16:49:54 +02:00
github-actions[bot]
d26e6637d8 chore: bump version to 1.2.2 [skip ci] 2025-08-13 14:48:17 +00:00
VIktor Kuropiantnyk
7692eb2600 fix: update of transformers module to 4.55.1 (#316)
Signed-off-by: Viktor Kuropiatnyk <vku@zurich.ibm.com>
2025-08-13 16:07:52 +02:00
github-actions[bot]
3bd7828570 chore: bump version to 1.2.1 [skip ci] 2025-08-13 07:37:55 +00:00
Michele Dolfi
8b470cba8e fix: handling of vlm model options and update deps (#314)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-08-13 09:32:21 +02:00
Tiago Santana
8048f4589a fix: add missing response type in sync endpoints (#309)
Signed-off-by: Tiago Santana <54704492+SantanaTiago@users.noreply.github.com>
2025-08-08 12:32:19 +02:00
Thomas Vitale
b3058e91e0 docs: Update readme to use v1 (#306)
Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
2025-08-08 09:02:29 +02:00
Thomas Vitale
63da9eedeb docs: Update deployment examples to use v1 API (#308)
Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
2025-08-08 08:47:59 +02:00
Thomas Vitale
b15dc2529f docs: Fix typo in v1 migration instructions (#307)
Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
2025-08-08 08:44:09 +02:00
github-actions[bot]
4c7207be00 chore: bump version to 1.2.0 [skip ci] 2025-08-07 09:20:10 +00:00
Michele Dolfi
db3fdb5bc1 feat: workers without shared models and convert params (#304)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-08-07 11:16:06 +02:00
Rui Dias Gomes
fd1b987e8d feat: add rocm image build support and fix cuda (#292)
Signed-off-by: rmdg88 <rmdg88@gmail.com>
Signed-off-by: Rui-Dias-Gomes <rui.dias.gomes@ibm.com>
Co-authored-by: Rui-Dias-Gomes <rui.dias.gomes@ibm.com>
2025-07-31 14:22:42 +02:00
github-actions[bot]
ce15e0302b chore: bump version to 1.1.0 [skip ci] 2025-07-30 15:53:01 +00:00
Michele Dolfi
ecb1874a50 feat: Add docling-mcp in the distribution (#290)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-07-30 15:39:11 +02:00
Michele Dolfi
1333f71c9c fix: referenced paths relative to zip root (#289)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-07-30 14:49:26 +02:00
Tiago Santana
ec594d84fe feat: add 3.0 openapi endpoint (#287)
Signed-off-by: Tiago Santana <54704492+SantanaTiago@users.noreply.github.com>
2025-07-30 14:08:59 +02:00
Tiago Santana
3771c1b554 feat: add new source and target (#270)
Signed-off-by: Tiago Santana <54704492+SantanaTiago@users.noreply.github.com>
2025-07-29 14:44:49 +02:00
github-actions[bot]
24db461b14 chore: bump version to 1.0.1 [skip ci] 2025-07-21 07:34:14 +00:00
Michele Dolfi
8706706e87 fix: docling update v2.42.0 (#277)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-07-21 08:47:40 +02:00
Michele Dolfi
766adb2481 docs: typo in README (#276)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-07-18 14:37:54 +02:00
Michele Dolfi
8222cf8955 ci: add spellchecker with custom vocabulary and fix typos (#268)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-07-15 14:17:35 +02:00
github-actions[bot]
b922824e5b chore: bump version to 1.0.0 [skip ci] 2025-07-14 11:25:06 +00:00
Michele Dolfi
56e328baf7 feat!: v1 api with list of sources and target (#249)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-07-14 13:19:49 +02:00
Michele Dolfi
daa924a77e feat!: use orchestrators from jobkit (#248)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-07-10 15:47:22 +02:00
Eugene
e63197e89e chore: bump uv to 0.7.19 in container (#266)
Signed-off-by: Eugene <fogaprod@gmail.com>
2025-07-10 15:10:21 +02:00
github-actions[bot]
767ce0982b chore: bump version to 0.16.1 [skip ci] 2025-07-07 16:17:50 +00:00
Michele Dolfi
bfde1a0991 fix: upgrade deps including, docling v2.40.0 with locks in models init (#264)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-07-07 17:13:45 +02:00
VIktor Kuropiantnyk
eb3892ee14 fix: missing tesseract osd (#263)
Signed-off-by: Viktor Kuropiatnyk <vku@zurich.ibm.com>
2025-07-07 16:36:43 +02:00
tassadarliu
93b84712b2 docs: fix typo (#259)
Signed-off-by: tassadarliu <rhapsodyn@gmail.com>
2025-07-07 08:47:34 +02:00
Yishen Miao
c45b937064 docs: change the doc example (#258)
Signed-off-by: Yishen Miao <mys721tx@gmail.com>
2025-07-07 08:47:21 +02:00
Francisco Arceo
50e431f30f docs: Update typo (#247)
Signed-off-by: Francisco Arceo <arceofrancisco@gmail.com>
2025-06-27 16:58:37 +02:00
Michele Dolfi
149a8cb1c0 fix: properly load models at boot (#244)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-06-27 12:20:38 +02:00
github-actions[bot]
5f9c20a985 chore: bump version to 0.16.0 [skip ci] 2025-06-25 09:52:08 +00:00
Michele Dolfi
80755a7d59 docs: Update example resources and improve README (#231)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-06-25 07:56:14 +02:00
Michele Dolfi
30aca92298 feat: package updates and more cuda images (#229)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-06-24 16:59:05 +02:00
github-actions[bot]
717fb3a8d8 chore: bump version to 0.15.0 [skip ci] 2025-06-17 15:00:38 +00:00
Michele Dolfi
873d05aefe feat: use redocs and scalar as api docs (#228)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-06-17 16:54:00 +02:00
Ryan Fernandes
196c5ce42a fix: "tesserocr" instead of "tesseract_cli" in usage docs (#223)
Signed-off-by: Ryan Fernandes <ryan@fernandes.us>
2025-06-17 16:53:51 +02:00
github-actions[bot]
b5c5f47892 chore: bump version to 0.14.0 [skip ci] 2025-06-17 13:10:27 +00:00
23Ro
d5455b7f66 fix: Typo in Headline (#220)
Signed-off-by: 23Ro <m.n@23ro.de>
2025-06-17 14:55:27 +02:00
Michele Dolfi
7a682494d6 chore: dco advisor (#224)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-06-17 09:38:56 +02:00
Eugene
524f6a8997 feat: Read supported file extensions from docling (#214)
Signed-off-by: Eugene <fogaprod@gmail.com>
2025-06-05 09:38:28 +02:00
github-actions[bot]
9ccf8e3b5e chore: bump version to 0.13.0 [skip ci] 2025-06-04 12:24:40 +00:00
Michele Dolfi
ffea34732b feat: upgrade docling to 2.36 (#212)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-06-04 14:20:34 +02:00
github-actions[bot]
b299af002b chore: bump version to 0.12.0 [skip ci] 2025-06-03 16:30:28 +00:00
Michele Dolfi
c4c41f16df feat: Export annotations in markdown and html (Docling upgrade) (#202)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-06-03 18:24:27 +02:00
Michele Dolfi
7066f3520a fix: processing complex params in multipart-form (#210)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-06-03 18:24:05 +02:00
Rui Dias Gomes
6a8190c315 docs: add openshift replicasets examples (#209)
Signed-off-by: Rui-Dias-Gomes <rui.dias.gomes@ibm.com>
Co-authored-by: Rui-Dias-Gomes <rui.dias.gomes@ibm.com>
2025-06-03 17:43:41 +02:00
github-actions[bot]
060ecd8b0e chore: bump version to 0.11.0 [skip ci] 2025-05-23 13:45:54 +00:00
Michele Dolfi
32b8a809f3 feat: page break placeholder in markdown exports options (#194)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-05-23 15:26:27 +02:00
Michele Dolfi
de002dfcdc feat: clear results registry (#192)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-05-23 14:30:57 +02:00
Michele Dolfi
abe5aa03f5 feat: Upgrade to Docling 2.33.0 (#198)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-05-22 17:00:29 +02:00
VIktor Kuropiantnyk
3f090b7d15 docs: Example and instructions on how to load model weights to persistent volume (#197)
Signed-off-by: Viktor Kuropiatnyk <vku@zurich.ibm.com>
2025-05-21 13:04:46 +02:00
Michele Dolfi
21c1791e42 docs: async api usage and fixes (#195)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-05-19 13:57:35 +02:00
Michele Dolfi
00be428490 feat: api to trigger offloading the models (#188)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-05-14 15:02:18 +02:00
Kasper Dinkla
3ff1b2f983 feat: Figure annotations @ docling components 0.0.7 (#181)
Signed-off-by: DKL <dkl@zurich.ibm.com>
2025-05-08 16:31:10 +02:00
Michele Dolfi
8406fb9b59 fix: usage of hashlib for FIPS (#171)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-05-02 15:00:10 +02:00
github-actions[bot]
a2dcb0a20f chore: bump version to 0.10.1 [skip ci] 2025-04-30 16:04:30 +00:00
Michele Dolfi
36787bc061 fix: avoid missing specialized keys in the options hash (#166)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-04-30 13:14:34 +02:00
Michele Dolfi
509f4889f8 fix: allow users to set the area threshold for picture descriptions (#165)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
Signed-off-by: Michele Dolfi <97102151+dolfim-ibm@users.noreply.github.com>
Co-authored-by: Cesar Berrospi Ramis <75900930+ceberam@users.noreply.github.com>
2025-04-30 12:37:24 +02:00
Michele Dolfi
919cf5c041 fix: expose max wait time in sync endpoints (#164)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-04-30 12:30:11 +02:00
Michele Dolfi
35c2630c61 fix: add flash-attn for cuda images (#161)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-04-29 16:58:33 +02:00
github-actions[bot]
382d675631 chore: bump version to 0.10.0 [skip ci] 2025-04-28 10:06:42 +00:00
Michele Dolfi
c65f3c654c feat: add support for file upload and return as file in async endpoints (#152)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-04-28 11:18:19 +02:00
nkh0472
829effec1a docs: fix new default pdf_backend (#158)
Signed-off-by: nkh0472 <67589323+nkh0472@users.noreply.github.com>
2025-04-28 09:46:13 +02:00
nkh0472
494d66f992 chore: typo fix (#156)
Signed-off-by: nkh0472 <67589323+nkh0472@users.noreply.github.com>
2025-04-28 08:41:26 +02:00
Quang Nam Ta
14bafb2628 docs: fixing small typo in docs (#155)
Signed-off-by: Quang Nam Ta <work.quangnamta@gmail.com>
2025-04-28 08:35:40 +02:00
github-actions[bot]
37e2e1ad09 chore: bump version to 0.9.0 [skip ci] 2025-04-25 07:56:40 +00:00
Michele Dolfi
71c5fae505 fix: produce image artifacts in referenced mode (#151)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-04-24 17:33:36 +02:00
Michele Dolfi
91956cbf4e docs: vlm and picture description options (#149)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-04-24 14:42:06 +02:00
Michele Dolfi
4c9571a052 feat: expose picture description options (#148)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
Signed-off-by: Michele Dolfi <97102151+dolfim-ibm@users.noreply.github.com>
Co-authored-by: Cesar Berrospi Ramis <75900930+ceberam@users.noreply.github.com>
2025-04-24 13:49:44 +02:00
Tiago Santana
41624af09f test: add tests with fastapi client (#147)
Signed-off-by: Tiago Santana <54704492+SantanaTiago@users.noreply.github.com>
2025-04-24 10:25:29 +02:00
Michele Dolfi
26bef5bec0 feat: Add parameters for Kubeflow pipeline engine (WIP) (#107)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-04-23 14:59:53 +02:00
github-actions[bot]
40bb21d347 chore: bump version to 0.8.0 [skip ci] 2025-04-22 13:04:33 +00:00
Michele Dolfi
ee89ee4dae feat: Add option for vlm pipeline (#143)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-04-22 14:46:33 +02:00
Michele Dolfi
6b3d281f02 feat: Expose more conversion options (#142)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-04-22 10:41:47 +02:00
Tiago Santana
b598872e5c feat(UI): change UI to use async endpoints (#131)
Signed-off-by: Tiago Santana <54704492+SantanaTiago@users.noreply.github.com>
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
Co-authored-by: Michele Dolfi <dol@zurich.ibm.com>
2025-04-19 19:59:07 +02:00
Michele Dolfi
087417e5c2 docs: fix required permissions for oauth2-proxy requests (#141)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-04-19 18:46:28 +02:00
Michele Dolfi
57f9073bc0 fix(UI): use https when calling the api (#139)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-04-19 17:35:54 +02:00
Rui Dias Gomes
525a43ff6f docs: update deployment examples (#135)
Signed-off-by: rmdg88 <rmdg88@gmail.com>
Signed-off-by: Rui Dias Gomes <66125272+rmdg88@users.noreply.github.com>
2025-04-17 14:29:34 +02:00
Michele Dolfi
c1ce4719c9 fix: fix permissions in docker image (#136)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-04-17 14:27:43 +02:00
Kasper Dinkla
5dfb75d3b9 fix: picture caption visuals (#129)
Signed-off-by: DKL <dkl@zurich.ibm.com>
2025-04-15 13:17:00 +02:00
Michele Dolfi
420162e674 docs: fix image tag (#124)
Signed-off-by: Michele Dolfi <97102151+dolfim-ibm@users.noreply.github.com>
2025-04-11 16:19:39 +02:00
github-actions[bot]
ff75bab21b chore: bump version to 0.7.0 [skip ci] 2025-03-31 13:44:01 +00:00
Michele Dolfi
7a0fabae07 feat: Expose TLS settings and example deploy with oauth-proxy (#112)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-03-31 14:51:30 +02:00
Maxim Lysak
9ffe49a359 chore: Readme picture (#108)
Signed-off-by: Maksym Lysak <mly@zurich.ibm.com>
Co-authored-by: Maksym Lysak <mly@zurich.ibm.com>
2025-03-31 08:29:09 -04:00
Michele Dolfi
68772bb6f0 feat: Offline static files (#109)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-03-26 18:54:54 -04:00
Michele Dolfi
20ec87a63a feat: Update to Docling 2.28 (#106)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-03-24 20:00:25 -04:00
Eugene
e30f458923 fix: Move ARGs to prevent cache invalidation (#104)
Signed-off-by: Eugene <fogaprod@gmail.com>
2025-03-22 12:31:42 +01:00
88 changed files with 18248 additions and 4473 deletions

2
.github/dco.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
allowRemediationCommits:
individual: true

View File

@@ -3,32 +3,68 @@
set -e # trigger failure on error - do not remove!
set -x # display command on output
## debug
# TARGET_VERSION="1.2.x"
if [ -z "${TARGET_VERSION}" ]; then
>&2 echo "No TARGET_VERSION specified"
exit 1
fi
CHGLOG_FILE="${CHGLOG_FILE:-CHANGELOG.md}"
# update package version
# Update package version
uvx --from=toml-cli toml set --toml-path=pyproject.toml project.version "${TARGET_VERSION}"
uv lock --upgrade-package docling-serve
# collect release notes
# Extract all docling packages and versions from uv.lock
DOCVERSIONS=$(uvx --with toml python3 - <<'PY'
import toml
data = toml.load("uv.lock")
for pkg in data.get("package", []):
if pkg["name"].startswith("docling"):
print(f"{pkg['name']} {pkg['version']}")
PY
)
# Format docling versions list without trailing newline
DOCLING_VERSIONS="### Docling libraries included in this release:"
while IFS= read -r line; do
DOCLING_VERSIONS+="
- $line"
done <<< "$DOCVERSIONS"
# Collect release notes
REL_NOTES=$(mktemp)
uv run --no-sync semantic-release changelog --unreleased >> "${REL_NOTES}"
# update changelog
# Strip trailing blank lines from release notes and append docling versions
{
sed -e :a -e '/^\n*$/{$d;N;};/\n$/ba' "${REL_NOTES}"
printf "\n"
printf "%s" "${DOCLING_VERSIONS}"
printf "\n"
} > "${REL_NOTES}.tmp" && mv "${REL_NOTES}.tmp" "${REL_NOTES}"
# Update changelog
TMP_CHGLOG=$(mktemp)
TARGET_TAG_NAME="v${TARGET_VERSION}"
RELEASE_URL="$(gh repo view --json url -q ".url")/releases/tag/${TARGET_TAG_NAME}"
printf "## [${TARGET_TAG_NAME}](${RELEASE_URL}) - $(date -Idate)\n\n" >> "${TMP_CHGLOG}"
cat "${REL_NOTES}" >> "${TMP_CHGLOG}"
if [ -f "${CHGLOG_FILE}" ]; then
printf "\n" | cat - "${CHGLOG_FILE}" >> "${TMP_CHGLOG}"
fi
## debug
#RELEASE_URL="myrepo/releases/tag/${TARGET_TAG_NAME}"
# Strip leading blank lines from existing changelog to avoid multiple blank lines when appending
EXISTING_CL=$(sed -e :a -e '/^\n*$/{$d;N;};/\n$/ba' "${CHGLOG_FILE}")
{
printf "## [${TARGET_TAG_NAME}](${RELEASE_URL}) - $(date -Idate)\n\n"
cat "${REL_NOTES}"
printf "\n"
printf "%s\n" "${EXISTING_CL}"
} >> "${TMP_CHGLOG}"
mv "${TMP_CHGLOG}" "${CHGLOG_FILE}"
# push changes
# Push changes
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
git add pyproject.toml uv.lock "${CHGLOG_FILE}"
@@ -36,5 +72,5 @@ COMMIT_MSG="chore: bump version to ${TARGET_VERSION} [skip ci]"
git commit -m "${COMMIT_MSG}"
git push origin main
# create GitHub release (incl. Git tag)
# Create GitHub release (incl. Git tag)
gh release create "${TARGET_TAG_NAME}" -F "${REL_NOTES}"

View File

@@ -0,0 +1,40 @@
[Dd]ocling
precommit
asgi
async
(?i)urls
uvicorn
Config
[Ww]ebserver
RQ
(?i)url
keyfile
[Ww]ebsocket(s?)
[Kk]ubernetes
UI
(?i)vllm
APIs
[Ss]ubprocesses
(?i)api
Kubeflow
(?i)Jobkit
(?i)cpu
(?i)PyTorch
(?i)CUDA
(?i)NVIDIA
(?i)ROCm
(?i)env
Gradio
Podman
bool
Ollama
inbody
LGTMs
Dolfi
Lysak
Nikos
Nassar
Panos
Vagenas
Staar
Livathinos

11
.github/vale.ini vendored Normal file
View File

@@ -0,0 +1,11 @@
StylesPath = styles
MinAlertLevel = suggestion
; Packages = write-good, proselint
Vocab = Docling
[*.md]
BasedOnStyles = Vale
[CHANGELOG.md]
BasedOnStyles =

View File

@@ -13,7 +13,7 @@ jobs:
actionlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Download actionlint
id: get_actionlint
run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)

View File

@@ -11,11 +11,11 @@ jobs:
outputs:
TARGET_TAG_V: ${{ steps.version_check.outputs.TRGT_VERSION }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0 # for fetching tags, required for semantic-release
- name: Install uv and set the python version
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Install dependencies
@@ -40,12 +40,12 @@ jobs:
with:
app-id: ${{ vars.CI_APP_ID }}
private-key: ${{ secrets.CI_PRIVATE_KEY }}
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0 # for fetching tags, required for semantic-release
- name: Install uv and set the python version
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Install dependencies

View File

@@ -15,16 +15,28 @@ jobs:
spec:
- name: docling-project/docling-serve
build_args: |
UV_SYNC_EXTRA_ARGS=--no-extra cu124 --no-extra cpu
UV_SYNC_EXTRA_ARGS=--no-extra flash-attn
platforms: linux/amd64, linux/arm64
- name: docling-project/docling-serve-cpu
build_args: |
UV_SYNC_EXTRA_ARGS=--no-extra cu124
UV_SYNC_EXTRA_ARGS=--no-group pypi --group cpu --no-extra flash-attn
platforms: linux/amd64, linux/arm64
- name: docling-project/docling-serve-cu124
# - name: docling-project/docling-serve-cu124
# build_args: |
# UV_SYNC_EXTRA_ARGS=--no-group pypi --group cu124
# platforms: linux/amd64
- name: docling-project/docling-serve-cu126
build_args: |
UV_SYNC_EXTRA_ARGS=--no-extra cpu
UV_SYNC_EXTRA_ARGS=--no-group pypi --group cu126
platforms: linux/amd64
- name: docling-project/docling-serve-cu128
build_args: |
UV_SYNC_EXTRA_ARGS=--no-group pypi --group cu128
platforms: linux/amd64
# - name: docling-project/docling-serve-rocm
# build_args: |
# UV_SYNC_EXTRA_ARGS=--no-group pypi --group rocm --no-extra flash-attn
# platforms: linux/amd64
permissions:
packages: write

192
.github/workflows/dco-advisor.yml vendored Normal file
View File

@@ -0,0 +1,192 @@
name: DCO Advisor Bot
on:
pull_request_target:
types: [opened, reopened, synchronize]
permissions:
pull-requests: write
issues: write
jobs:
dco_advisor:
runs-on: ubuntu-latest
steps:
- name: Handle DCO check result
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const pr = context.payload.pull_request || context.payload.check_run?.pull_requests?.[0];
if (!pr) return;
const prNumber = pr.number;
const baseRef = pr.base.ref;
const headSha =
context.payload.check_run?.head_sha ||
pr.head?.sha;
const username = pr.user.login;
console.log("HEAD SHA:", headSha);
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// Poll until DCO check has a conclusion (max 6 attempts, 30s)
let dcoCheck = null;
for (let attempt = 0; attempt < 6; attempt++) {
const { data: checks } = await github.rest.checks.listForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: headSha
});
console.log("All check runs:");
checks.check_runs.forEach(run => {
console.log(`- ${run.name} (${run.status}/${run.conclusion}) @ ${run.head_sha}`);
});
dcoCheck = checks.check_runs.find(run =>
run.name.toLowerCase().includes("dco") &&
!run.name.toLowerCase().includes("dco_advisor") &&
run.head_sha === headSha
);
if (dcoCheck?.conclusion) break;
console.log(`Waiting for DCO check... (${attempt + 1})`);
await sleep(5000); // wait 5 seconds
}
if (!dcoCheck || !dcoCheck.conclusion) {
console.log("DCO check did not complete in time.");
return;
}
const isFailure = ["failure", "action_required"].includes(dcoCheck.conclusion);
console.log(`DCO check conclusion for ${headSha}: ${dcoCheck.conclusion} (treated as ${isFailure ? "failure" : "success"})`);
// Parse DCO output for commit SHAs and author
let badCommits = [];
let authorName = "";
let authorEmail = "";
let moreInfo = `More info: [DCO check report](${dcoCheck?.html_url})`;
if (isFailure) {
const { data: commits } = await github.rest.pulls.listCommits({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
for (const commit of commits) {
const commitMessage = commit.commit.message;
const signoffMatch = commitMessage.match(/^Signed-off-by:\s+.+<.+>$/m);
if (!signoffMatch) {
console.log(`Bad commit found ${commit.sha}`)
badCommits.push({
sha: commit.sha,
authorName: commit.commit.author.name,
authorEmail: commit.commit.author.email,
});
}
}
}
// If multiple authors are present, you could adapt the message accordingly
// For now, we'll just use the first one
if (badCommits.length > 0) {
authorName = badCommits[0].authorName;
authorEmail = badCommits[0].authorEmail;
}
// Generate remediation commit message if needed
let remediationSnippet = "";
if (badCommits.length && authorEmail) {
remediationSnippet = `git commit --allow-empty -s -m "DCO Remediation Commit for ${authorName} <${authorEmail}>\n\n` +
badCommits.map(c => `I, ${c.authorName} <${c.authorEmail}>, hereby add my Signed-off-by to this commit: ${c.sha}`).join('\n') +
`"`;
} else {
remediationSnippet = "# Unable to auto-generate remediation message. Please check the DCO check details.";
}
// Build comment
const commentHeader = '<!-- dco-advice-bot -->';
let body = "";
if (isFailure) {
body = [
commentHeader,
'❌ **DCO Check Failed**',
'',
`Hi @${username}, your pull request has failed the Developer Certificate of Origin (DCO) check.`,
'',
'This repository supports **remediation commits**, so you can fix this without rewriting history — but you must follow the required message format.',
'',
'---',
'',
'### 🛠 Quick Fix: Add a remediation commit',
'Run this command:',
'',
'```bash',
remediationSnippet,
'git push',
'```',
'',
'---',
'',
'<details>',
'<summary>🔧 Advanced: Sign off each commit directly</summary>',
'',
'**For the latest commit:**',
'```bash',
'git commit --amend --signoff',
'git push --force-with-lease',
'```',
'',
'**For multiple commits:**',
'```bash',
`git rebase --signoff origin/${baseRef}`,
'git push --force-with-lease',
'```',
'',
'</details>',
'',
moreInfo
].join('\n');
} else {
body = [
commentHeader,
'✅ **DCO Check Passed**',
'',
`Thanks @${username}, all your commits are properly signed off. 🎉`
].join('\n');
}
// Get existing comments on the PR
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber
});
// Look for a previous bot comment
const existingComment = comments.find(c =>
c.body.includes("<!-- dco-advice-bot -->")
);
if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body: body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: body
});
}

42
.github/workflows/discord-release.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
# .github/workflows/discord-release.yml
name: Notify Discord on Release
on:
release:
types: [published]
jobs:
discord:
runs-on: ubuntu-latest
steps:
- name: Send release info to Discord
env:
DISCORD_WEBHOOK: ${{ secrets.RELEASES_DISCORD_WEBHOOK }}
run: |
REPO_NAME=${{ github.repository }}
RELEASE_TAG=${{ github.event.release.tag_name }}
RELEASE_NAME="${{ github.event.release.name }}"
RELEASE_URL=${{ github.event.release.html_url }}
# Capture the body safely (handles backticks, $, ", etc.)
RELEASE_BODY=$(cat <<'EOF'
${{ github.event.release.body }}
EOF
)
# Fallback if release name is empty
if [ -z "$RELEASE_NAME" ]; then
RELEASE_NAME=$RELEASE_TAG
fi
PAYLOAD=$(jq -n \
--arg title "🚀 New Release: $RELEASE_NAME" \
--arg url "$RELEASE_URL" \
--arg desc "$RELEASE_BODY" \
--arg author_name "$REPO_NAME" \
--arg author_icon "https://github.com/docling-project.png" \
'{embeds: [{title: $title, url: $url, description: $desc, color: 5814783, author: {name: $author_name, icon_url: $author_icon}}]}')
curl -H "Content-Type: application/json" \
-d "$PAYLOAD" \
"$DISCORD_WEBHOOK"

View File

@@ -19,17 +19,28 @@ jobs:
spec:
- name: docling-project/docling-serve
build_args: |
UV_SYNC_EXTRA_ARGS=--no-extra cu124 --no-extra cpu
UV_SYNC_EXTRA_ARGS=--no-extra flash-attn
platforms: linux/amd64, linux/arm64
- name: docling-project/docling-serve-cpu
build_args: |
UV_SYNC_EXTRA_ARGS=--no-extra cu124
UV_SYNC_EXTRA_ARGS=--no-group pypi --group cpu --no-extra flash-attn
platforms: linux/amd64, linux/arm64
- name: docling-project/docling-serve-cu124
# - name: docling-project/docling-serve-cu124
# build_args: |
# UV_SYNC_EXTRA_ARGS=--no-group pypi --group cu124
# platforms: linux/amd64
- name: docling-project/docling-serve-cu126
build_args: |
UV_SYNC_EXTRA_ARGS=--no-extra cpu
UV_SYNC_EXTRA_ARGS=--no-group pypi --group cu126
platforms: linux/amd64
- name: docling-project/docling-serve-cu128
build_args: |
UV_SYNC_EXTRA_ARGS=--no-group pypi --group cu128
platforms: linux/amd64
# - name: docling-project/docling-serve-rocm
# build_args: |
# UV_SYNC_EXTRA_ARGS=--no-group pypi --group rocm --no-extra flash-attn
# platforms: linux/amd64
permissions:
packages: write
contents: read

View File

@@ -10,14 +10,14 @@ jobs:
matrix:
python-version: ['3.12']
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install uv and set the python version
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v6
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
- name: Install dependencies
run: uv sync --all-extras --no-extra cu124
run: uv sync --all-extras --no-extra flash-attn
- name: Build package
run: uv build
- name: Check content of wheel

View File

@@ -10,9 +10,9 @@ jobs:
matrix:
python-version: ['3.12']
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install uv and set the python version
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v6
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
@@ -25,10 +25,10 @@ jobs:
key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Install dependencies
run: uv sync --frozen --all-extras --no-extra cu124
run: uv sync --frozen --all-extras --no-extra flash-attn
- name: Run styling check
run: pre-commit run --all-files
run: uv run pre-commit run --all-files
build-package:
uses: ./.github/workflows/job-build.yml
@@ -47,21 +47,22 @@ jobs:
name: python-package-distributions
path: dist/
- name: Install uv and set the python version
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v6
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
- name: Create virtual environment
run: uv venv
- name: Install package
run: uv pip install dist/*.whl
- name: Create the server
run: python -c 'from docling_serve.app import create_app; create_app()'
markdown-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: markdownlint-cli2-action
uses: DavidAnson/markdownlint-cli2-action@v16
with:
globs: "**/*.md"
run: .venv/bin/python -c 'from docling_serve.app import create_app; create_app()'
# markdown-lint:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v5
# - name: markdownlint-cli2-action
# uses: DavidAnson/markdownlint-cli2-action@v16
# with:
# globs: "**/*.md"

View File

@@ -53,7 +53,7 @@ jobs:
df -h
- name: Check out the repo
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Log in to the GHCR container image registry
if: ${{ inputs.publish }}
@@ -88,19 +88,115 @@ jobs:
with:
images: ${{ env.GHCR_REGISTRY }}/${{ inputs.ghcr_image_name }}
# # Local test
# - name: Set metadata outputs for local testing ## comment out Free up space, Log in to cr, Cache Docker, Extract metadata, and quay blocks and run act
# id: ghcr_meta
# run: |
# echo "tags=ghcr.io/docling-project/docling-serve:pr-123" >> $GITHUB_OUTPUT
# echo "labels=org.opencontainers.image.source=https://github.com/docling-project/docling-serve" >> $GITHUB_OUTPUT
- name: Build and push image to ghcr.io
id: ghcr_push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
push: ${{ inputs.publish }}
push: ${{ inputs.publish }} # set 'false' for local test
tags: ${{ steps.ghcr_meta.outputs.tags }}
labels: ${{ steps.ghcr_meta.outputs.labels }}
platforms: ${{ inputs.platforms}}
platforms: ${{ inputs.platforms }}
cache-from: type=gha
cache-to: type=gha,mode=max
file: Containerfile
build-args: ${{ inputs.build_args }}
pull: true
##
## This stage runs after the build, so it leverages all build cache
##
- name: Export built image for testing
id: ghcr_export_built_image
uses: docker/build-push-action@v6
with:
context: .
push: false
load: true
tags: ${{ env.GHCR_REGISTRY }}/${{ inputs.ghcr_image_name }}:${{ github.sha }}-test
labels: |
org.opencontainers.image.title=docling-serve
org.opencontainers.image.test=true
platforms: linux/amd64 # when 'load' is true, we can't use a list ${{ inputs.platforms }}
cache-from: type=gha
cache-to: type=gha,mode=max
file: Containerfile
build-args: ${{ inputs.build_args }}
- name: Test image
if: steps.ghcr_export_built_image.outcome == 'success'
run: |
set -e
IMAGE_TAG="${{ env.GHCR_REGISTRY }}/${{ inputs.ghcr_image_name }}:${{ github.sha }}-test"
echo "Testing local image: $IMAGE_TAG"
# Remove existing container if any
docker rm -f docling-serve-test-container 2>/dev/null || true
echo "Starting container..."
docker run -d -p 5001:5001 --name docling-serve-test-container "$IMAGE_TAG"
echo "Waiting 15s for container to boot..."
sleep 15
# Health check
echo "Checking service health..."
for i in {1..20}; do
HEALTH_RESPONSE=$(curl -s http://localhost:5001/health || true)
echo "Health check response [$i]: $HEALTH_RESPONSE"
if echo "$HEALTH_RESPONSE" | grep -q '"status":"ok"'; then
echo "Service is healthy!"
# Install pytest and dependencies
echo "Installing pytest and dependencies..."
pip install uv
uv venv --allow-existing
source .venv/bin/activate
uv sync --all-extras --no-extra flash-attn
# Run pytest tests
echo "Running tests..."
# Test import
python -c 'from docling_serve.app import create_app; create_app()'
# Run pytest and check result directly
if ! pytest -sv -k "test_convert_url" tests/test_1-url-async.py \
--disable-warnings; then
echo "Tests failed!"
docker logs docling-serve-test-container
docker rm -f docling-serve-test-container
exit 1
fi
echo "Tests passed successfully!"
break
else
echo "Waiting for service... [$i/20]"
sleep 3
fi
done
# Final health check if service didn't pass earlier
if ! echo "$HEALTH_RESPONSE" | grep -q '"status":"ok"'; then
echo "Service did not become healthy in time."
docker logs docling-serve-test-container
docker rm -f docling-serve-test-container
exit 1
fi
# Cleanup
echo "Cleaning up test container..."
docker rm -f docling-serve-test-container
echo "Cleaning up test image..."
docker rmi "$IMAGE_TAG"
- name: Generate artifact attestation
if: ${{ inputs.publish }}
@@ -120,7 +216,7 @@ jobs:
- name: Build and push image to quay.io
if: ${{ inputs.publish }}
# id: push-serve-cpu-quay
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
push: ${{ inputs.publish }}
@@ -131,11 +227,8 @@ jobs:
cache-to: type=gha,mode=max
file: Containerfile
build-args: ${{ inputs.build_args }}
# - name: Inspect the image details
# run: |
# echo "${{ steps.ghcr_push.outputs.metadata }}"
pull: true
- name: Remove Local Docker Images
- name: Remove local Docker images
run: |
docker image prune -af

5
.gitignore vendored
View File

@@ -444,3 +444,8 @@ pip-selfcheck.json
# Makefile
.action-lint
.markdown-lint
cookies.txt
# Examples
/examples/splitted_pdf/*

View File

@@ -3,7 +3,7 @@ config:
no-emphasis-as-header: false
first-line-heading: false
MD033:
allowed_elements: ["details", "summary", "br"]
allowed_elements: ["details", "summary", "br", "a", "b", "p", "img"]
MD024:
siblings_only: true
globs:

View File

@@ -5,10 +5,14 @@ repos:
hooks:
# Run the Ruff formatter.
- id: ruff-format
name: "Ruff formatter"
args: [--config=pyproject.toml]
files: '^(docling_serve|tests|examples|scripts).*\.(py|ipynb)$'
# Run the Ruff linter.
- id: ruff
name: "Ruff linter"
args: [--exit-non-zero-on-fix, --fix, --config=pyproject.toml]
files: '^(docling_serve|tests|examples|scripts).*\.(py|ipynb)$'
- repo: local
hooks:
- id: system
@@ -17,8 +21,28 @@ repos:
pass_filenames: false
language: system
files: '\.py$'
- repo: local
hooks:
- id: update-docs-common-parameters
name: Update Documentation File
entry: uv run scripts/update_doc_usage.py
language: python
pass_filenames: false
# Fail the commit if documentation generation fails
require_serial: true
- repo: https://github.com/errata-ai/vale
rev: v3.12.0 # Use latest stable version
hooks:
- id: vale
name: vale sync
pass_filenames: false
args: [sync, "--config=.github/vale.ini"]
- id: vale
name: Spell and Style Check with Vale
args: ["--config=.github/vale.ini"]
files: \.md$
- repo: https://github.com/astral-sh/uv-pre-commit
# uv version.
rev: 0.6.1
# uv version, https://github.com/astral-sh/uv-pre-commit/releases
rev: 0.8.19
hooks:
- id: uv-lock

View File

@@ -1,3 +1,414 @@
## [v1.8.0](https://github.com/docling-project/docling-serve/releases/tag/v1.8.0) - 2025-10-31
### Feature
* Docling with new standard pipeline with threading ([#428](https://github.com/docling-project/docling-serve/issues/428)) ([`bf132a3`](https://github.com/docling-project/docling-serve/commit/bf132a3c3e615ddbe624841ea5b3a98593c00654))
### Documentation
* Expand automatic docs to nested objects. More complete usage docs. ([#426](https://github.com/docling-project/docling-serve/issues/426)) ([`35319b0`](https://github.com/docling-project/docling-serve/commit/35319b0da793a2a1a434fd2b60b7632e10ecced3))
* Add docs for docling parameters like performance and debug ([#424](https://github.com/docling-project/docling-serve/issues/424)) ([`f3957ae`](https://github.com/docling-project/docling-serve/commit/f3957aeb577097121fe9d0d21f75a50643f03369))
### Docling libraries included in this release:
- docling 2.60.0
- docling-core 2.50.0
- docling-ibm-models 3.10.2
- docling-jobkit 1.8.0
- docling-mcp 1.3.2
- docling-parse 4.7.0
- docling-serve 1.8.0
## [v1.7.2](https://github.com/docling-project/docling-serve/releases/tag/v1.7.2) - 2025-10-30
### Fix
* Update locked dependencies. Docling fixes, Expose temperature parameter for vlm models ([#423](https://github.com/docling-project/docling-serve/issues/423)) ([`e9b4140`](https://github.com/docling-project/docling-serve/commit/e9b41406c4116ff79a212877ff6484a1151e144d))
* Temporary constrain fastapi version ([#418](https://github.com/docling-project/docling-serve/issues/418)) ([`7bf2e7b`](https://github.com/docling-project/docling-serve/commit/7bf2e7b366470e0cf1c4900df7c84becd6a96991))
### Docling libraries included in this release:
- docling 2.59.0
- docling-core 2.50.0
- docling-ibm-models 3.10.2
- docling-jobkit 1.7.1
- docling-mcp 1.3.2
- docling-parse 4.7.0
- docling-serve 1.7.2
## [v1.7.1](https://github.com/docling-project/docling-serve/releases/tag/v1.7.1) - 2025-10-22
### Fix
* Upgrade dependencies ([#417](https://github.com/docling-project/docling-serve/issues/417)) ([`97613a1`](https://github.com/docling-project/docling-serve/commit/97613a19748e8c152db4a0f62b5a57fca807a33a))
* Makes task status shared across multiple instances in RQ mode, resolves #378 ([#415](https://github.com/docling-project/docling-serve/issues/415)) ([`0961f2c`](https://github.com/docling-project/docling-serve/commit/0961f2c57425859c76130da3ea8a871d65df4b26))
* `DOCLING_SERVE_SYNC_POLL_INTERVAL` controls the synchronous polling time ([#413](https://github.com/docling-project/docling-serve/issues/413)) ([`0f274ab`](https://github.com/docling-project/docling-serve/commit/0f274ab135a9bb41accd05db3c12a9dcce220ad9))
### Documentation
* Generate usage.md automatically ([#340](https://github.com/docling-project/docling-serve/issues/340)) ([`9672f31`](https://github.com/docling-project/docling-serve/commit/9672f310b1bb7030af8a276f14691e46f7da0e9e))
### Docling libraries included in this release:
- docling 2.58.0
- docling-core 2.49.0
- docling-ibm-models 3.10.1
- docling-jobkit 1.7.0
- docling-mcp 1.3.2
- docling-parse 4.7.0
- docling-serve 1.7.1
## [v1.7.0](https://github.com/docling-project/docling-serve/releases/tag/v1.7.0) - 2025-10-17
### Feature
* **UI:** Add auto and orcmac options in demo UI ([#408](https://github.com/docling-project/docling-serve/issues/408)) ([`f5af71e`](https://github.com/docling-project/docling-serve/commit/f5af71e8f6de00d7dd702471a3eea2e94d882410))
* Docling with auto-ocr ([#403](https://github.com/docling-project/docling-serve/issues/403)) ([`d95ea94`](https://github.com/docling-project/docling-serve/commit/d95ea940870af0d8df689061baa50f6026efce28))
### Fix
* Run docling ui behind a reverse proxy using a context path ([#396](https://github.com/docling-project/docling-serve/issues/396)) ([`5344505`](https://github.com/docling-project/docling-serve/commit/53445057184aa731ee7456b33b70bc0ecf82f2a6))
### Docling libraries included in this release:
- docling 2.57.0
- docling-core 2.48.4
- docling-ibm-models 3.9.1
- docling-jobkit 1.6.0
- docling-mcp 1.3.2
- docling-parse 4.5.0
- docling-serve 1.7.0
## [v1.6.0](https://github.com/docling-project/docling-serve/releases/tag/v1.6.0) - 2025-10-03
### Feature
* Pin new version of jobkit with granite-docling and connectors ([#391](https://github.com/docling-project/docling-serve/issues/391)) ([`0595d31`](https://github.com/docling-project/docling-serve/commit/0595d31d5b357553426215ca6771796a47e41324))
### Fix
* Update locked dependencies ([#392](https://github.com/docling-project/docling-serve/issues/392)) ([`45f0f3c`](https://github.com/docling-project/docling-serve/commit/45f0f3c8f95d418ac30e3744d27d02a63f9e4490))
* **UI:** Allow both lowercase and uppercase extensions ([#386](https://github.com/docling-project/docling-serve/issues/386)) ([`8b22a39`](https://github.com/docling-project/docling-serve/commit/8b22a391418d22c1a4d706f880341f28702057b5))
* Correctly raise HTTPException for Gateway Timeout ([#382](https://github.com/docling-project/docling-serve/issues/382)) ([`d4eac05`](https://github.com/docling-project/docling-serve/commit/d4eac053f9ce0a60f9070127335bdd56e193d7fa))
* Pinning of higher version of dependencies to fix potential security issues ([#363](https://github.com/docling-project/docling-serve/issues/363)) ([`ba61af2`](https://github.com/docling-project/docling-serve/commit/ba61af23591eff200481aa2e532cf7d0701f0ea4))
### Documentation
* Fix docs for websocket breaking condition ([#390](https://github.com/docling-project/docling-serve/issues/390)) ([`f6b5f0e`](https://github.com/docling-project/docling-serve/commit/f6b5f0e06354d2db7d03d274b114499e3407dccf))
### Docling libraries included in this release:
- docling 2.55.1
- docling-core 2.48.4
- docling-ibm-models 3.9.1
- docling-jobkit 1.6.0
- docling-mcp 1.3.2
- docling-parse 4.5.0
- docling-serve 1.6.0
## [v1.5.1](https://github.com/docling-project/docling-serve/releases/tag/v1.5.1) - 2025-09-17
### Fix
* Remove old dependencies, fixes in docling-parse and more minor dependencies upgrade ([#362](https://github.com/docling-project/docling-serve/issues/362)) ([`513ae0c`](https://github.com/docling-project/docling-serve/commit/513ae0c119b66d3b17cf9a5d371a0f7971f43be7))
* Updates rapidocr deps ([#361](https://github.com/docling-project/docling-serve/issues/361)) ([`bde0406`](https://github.com/docling-project/docling-serve/commit/bde040661fb65c67699326cd6281c0e6232e26f2))
### Docling libraries included in this release:
- docling 2.52.0
- docling-core 2.48.1
- docling-ibm-models 3.9.1
- docling-jobkit 1.5.0
- docling-mcp 1.2.0
- docling-parse 4.5.0
- docling-serve 1.5.1
## [v1.5.0](https://github.com/docling-project/docling-serve/releases/tag/v1.5.0) - 2025-09-09
### Feature
* Add chunking endpoints ([#353](https://github.com/docling-project/docling-serve/issues/353)) ([`9d6def0`](https://github.com/docling-project/docling-serve/commit/9d6def0ec8b1804ad31aa71defa17658d73d29a1))
### Docling libraries included in this release:
- docling 2.46.0
- docling 2.51.0
- docling-core 2.47.0
- docling-ibm-models 3.9.1
- docling-jobkit 1.5.0
- docling-mcp 1.2.0
- docling-parse 4.4.0
- docling-serve 1.5.0
## [v1.4.1](https://github.com/docling-project/docling-serve/releases/tag/v1.4.1) - 2025-09-08
### Fix
* Trigger fix after ci fixes ([#355](https://github.com/docling-project/docling-serve/issues/355)) ([`b0360d7`](https://github.com/docling-project/docling-serve/commit/b0360d723bff202dcf44a25a3173ec1995945fc2))
### Docling libraries included in this release:
- docling 2.46.0
- docling 2.51.0
- docling-core 2.47.0
- docling-ibm-models 3.9.1
- docling-jobkit 1.4.1
- docling-mcp 1.2.0
- docling-parse 4.4.0
- docling-serve 1.4.1
## [v1.4.0](https://github.com/docling-project/docling-serve/releases/tag/v1.4.0) - 2025-09-05
### Feature
* **docling:** Perfomance improvements in parsing, new layout model, fixes in html processing ([#352](https://github.com/docling-project/docling-serve/issues/352)) ([`d64a2a9`](https://github.com/docling-project/docling-serve/commit/d64a2a974a276c7ae3b105c448fd79f77a653d20))
### Fix
* Upgrade to latest docling version with fixes ([#335](https://github.com/docling-project/docling-serve/issues/335)) ([`e544947`](https://github.com/docling-project/docling-serve/commit/e5449472b2a3e71796f41c8a58c251d8229305c1))
### Documentation
* Add split processing example ([#303](https://github.com/docling-project/docling-serve/issues/303)) ([`0d4545a`](https://github.com/docling-project/docling-serve/commit/0d4545a65a5a941fc1fdefda57e39cfb1ea106ab))
* Document DOCLING_NUM_THREADS environment variable ([#341](https://github.com/docling-project/docling-serve/issues/341)) ([`27fdd7b`](https://github.com/docling-project/docling-serve/commit/27fdd7b85ab18b3eece428366f46dc5cf0995e38))
* Fix parameters typo ([#333](https://github.com/docling-project/docling-serve/issues/333)) ([`81f0a8d`](https://github.com/docling-project/docling-serve/commit/81f0a8ddf80a532042d550ae4568f891458b45e7))
* Describe how to use Docling MCP ([#332](https://github.com/docling-project/docling-serve/issues/332)) ([`a69cc86`](https://github.com/docling-project/docling-serve/commit/a69cc867f5a3fb76648803ca866d65cc3a75c6b8))
### Docling libraries included in this release:
- docling 2.46.0
- docling 2.51.0
- docling-core 2.47.0
- docling-ibm-models 3.9.1
- docling-jobkit 1.4.1
- docling-mcp 1.2.0
- docling-parse 4.4.0
- docling-serve 1.4.0
## [v1.3.1](https://github.com/docling-project/docling-serve/releases/tag/v1.3.1) - 2025-08-21
### Fix
* Configuration and performance fixes via upgrade of packages ([#328](https://github.com/docling-project/docling-serve/issues/328)) ([`f02dbc0`](https://github.com/docling-project/docling-serve/commit/f02dbc01449fe1caf3fb4a73c0a5f4adf8265faf))
### Documentation
* Fix parameter in api key docs ([#323](https://github.com/docling-project/docling-serve/issues/323)) ([`37fe022`](https://github.com/docling-project/docling-serve/commit/37fe02277b3e2358eced28e15b4360e7c82d3b43))
## [v1.3.0](https://github.com/docling-project/docling-serve/releases/tag/v1.3.0) - 2025-08-14
### Feature
* Add configuration option for apikey security ([#322](https://github.com/docling-project/docling-serve/issues/322)) ([`9a64410`](https://github.com/docling-project/docling-serve/commit/9a644105523d312431993ded8dd88e064550a5db))
* Add RQ engine ([#315](https://github.com/docling-project/docling-serve/issues/315)) ([`885f319`](https://github.com/docling-project/docling-serve/commit/885f319d3a3488a4090869560447437a4104f14e))
### Documentation
* Example of docling-serve deployment in the RQ engine mode ([#321](https://github.com/docling-project/docling-serve/issues/321)) ([`71edf41`](https://github.com/docling-project/docling-serve/commit/71edf4184960d8664ef9da20617e2d0f91793d36))
* Handling models in docling-serve ([#319](https://github.com/docling-project/docling-serve/issues/319)) ([`6e9aa8c`](https://github.com/docling-project/docling-serve/commit/6e9aa8c759220458281c7fe4c87443ac41023eee))
* Add Gradio cache usage ([#312](https://github.com/docling-project/docling-serve/issues/312)) ([`d584895`](https://github.com/docling-project/docling-serve/commit/d584895e1108d71a0f45deadcd3c669eb0a58133))
## [v1.2.2](https://github.com/docling-project/docling-serve/releases/tag/v1.2.2) - 2025-08-13
### Fix
* Update of transformers module to 4.55.1 ([#316](https://github.com/docling-project/docling-serve/issues/316)) ([`7692eb2`](https://github.com/docling-project/docling-serve/commit/7692eb26006fd4deaa021180c99e23a1b65de506))
## [v1.2.1](https://github.com/docling-project/docling-serve/releases/tag/v1.2.1) - 2025-08-13
### Fix
* Handling of vlm model options and update deps ([#314](https://github.com/docling-project/docling-serve/issues/314)) ([`8b470cb`](https://github.com/docling-project/docling-serve/commit/8b470cba8ef500c271eb84c8368c8a1a1a5a6d6a))
* Add missing response type in sync endpoints ([#309](https://github.com/docling-project/docling-serve/issues/309)) ([`8048f45`](https://github.com/docling-project/docling-serve/commit/8048f4589a91de2b2b391ab33a326efd1b29f25b))
### Documentation
* Update readme to use v1 ([#306](https://github.com/docling-project/docling-serve/issues/306)) ([`b3058e9`](https://github.com/docling-project/docling-serve/commit/b3058e91e0c56e27110eb50f22cbdd89640bf398))
* Update deployment examples to use v1 API ([#308](https://github.com/docling-project/docling-serve/issues/308)) ([`63da9ee`](https://github.com/docling-project/docling-serve/commit/63da9eedebae3ad31d04e65635e573194e413793))
* Fix typo in v1 migration instructions ([#307](https://github.com/docling-project/docling-serve/issues/307)) ([`b15dc25`](https://github.com/docling-project/docling-serve/commit/b15dc2529f78d68a475e5221c37408c3f77d8588))
## [v1.2.0](https://github.com/docling-project/docling-serve/releases/tag/v1.2.0) - 2025-08-07
### Feature
* Workers without shared models and convert params ([#304](https://github.com/docling-project/docling-serve/issues/304)) ([`db3fdb5`](https://github.com/docling-project/docling-serve/commit/db3fdb5bc1a0ae250afd420d737abc4071a7546c))
* Add rocm image build support and fix cuda ([#292](https://github.com/docling-project/docling-serve/issues/292)) ([`fd1b987`](https://github.com/docling-project/docling-serve/commit/fd1b987e8dc174f1a6013c003dde33e9acbae39a))
## [v1.1.0](https://github.com/docling-project/docling-serve/releases/tag/v1.1.0) - 2025-07-30
### Feature
* Add docling-mcp in the distribution ([#290](https://github.com/docling-project/docling-serve/issues/290)) ([`ecb1874`](https://github.com/docling-project/docling-serve/commit/ecb1874a507bef83d102e0e031e49fed34298637))
* Add 3.0 openapi endpoint ([#287](https://github.com/docling-project/docling-serve/issues/287)) ([`ec594d8`](https://github.com/docling-project/docling-serve/commit/ec594d84fe36df23e7d010a2fcf769856c43600b))
* Add new source and target ([#270](https://github.com/docling-project/docling-serve/issues/270)) ([`3771c1b`](https://github.com/docling-project/docling-serve/commit/3771c1b55403bd51966d07d8f760d5c4fbcc1760))
### Fix
* Referenced paths relative to zip root ([#289](https://github.com/docling-project/docling-serve/issues/289)) ([`1333f71`](https://github.com/docling-project/docling-serve/commit/1333f71c9c6495342b2169d574e921f828446f15))
## [v1.0.1](https://github.com/docling-project/docling-serve/releases/tag/v1.0.1) - 2025-07-21
### Fix
* Docling update v2.42.0 ([#277](https://github.com/docling-project/docling-serve/issues/277)) ([`8706706`](https://github.com/docling-project/docling-serve/commit/8706706e8797b0a06ec4baa7cf87988311be68b6))
### Documentation
* Typo in README ([#276](https://github.com/docling-project/docling-serve/issues/276)) ([`766adb2`](https://github.com/docling-project/docling-serve/commit/766adb248113c7bd5144d14b3c82929a2ad29f8e))
## [v1.0.0](https://github.com/docling-project/docling-serve/releases/tag/v1.0.0) - 2025-07-14
### Feature
* V1 api with list of sources and target ([#249](https://github.com/docling-project/docling-serve/issues/249)) ([`56e328b`](https://github.com/docling-project/docling-serve/commit/56e328baf76b4bb0476fc6ca820b52034e4f97bf))
* Use orchestrators from jobkit ([#248](https://github.com/docling-project/docling-serve/issues/248)) ([`daa924a`](https://github.com/docling-project/docling-serve/commit/daa924a77e56d063ef17347dfd8a838872a70529))
### Breaking
* v1 api with list of sources and target ([#249](https://github.com/docling-project/docling-serve/issues/249)) ([`56e328b`](https://github.com/docling-project/docling-serve/commit/56e328baf76b4bb0476fc6ca820b52034e4f97bf))
* use orchestrators from jobkit ([#248](https://github.com/docling-project/docling-serve/issues/248)) ([`daa924a`](https://github.com/docling-project/docling-serve/commit/daa924a77e56d063ef17347dfd8a838872a70529))
## [v0.16.1](https://github.com/docling-project/docling-serve/releases/tag/v0.16.1) - 2025-07-07
### Fix
* Upgrade deps including, docling v2.40.0 with locks in models init ([#264](https://github.com/docling-project/docling-serve/issues/264)) ([`bfde1a0`](https://github.com/docling-project/docling-serve/commit/bfde1a0991c2da53b72c4f131ff74fa10f6340de))
* Missing tesseract osd ([#263](https://github.com/docling-project/docling-serve/issues/263)) ([`eb3892e`](https://github.com/docling-project/docling-serve/commit/eb3892ee141eb2c941d580b095d8a266f2d2610c))
* Properly load models at boot ([#244](https://github.com/docling-project/docling-serve/issues/244)) ([`149a8cb`](https://github.com/docling-project/docling-serve/commit/149a8cb1c0a16c1e0b7d17f40b88b4d6e8f0109d))
### Documentation
* Fix typo ([#259](https://github.com/docling-project/docling-serve/issues/259)) ([`93b8471`](https://github.com/docling-project/docling-serve/commit/93b84712b2c6d180908a197847b52b217a7ff05f))
* Change the doc example ([#258](https://github.com/docling-project/docling-serve/issues/258)) ([`c45b937`](https://github.com/docling-project/docling-serve/commit/c45b93706466a073ab4a5c75aa8a267110873e26))
* Update typo ([#247](https://github.com/docling-project/docling-serve/issues/247)) ([`50e431f`](https://github.com/docling-project/docling-serve/commit/50e431f30fbffa33f43727417fe746d20cbb9d6b))
## [v0.16.0](https://github.com/docling-project/docling-serve/releases/tag/v0.16.0) - 2025-06-25
### Feature
* Package updates and more cuda images ([#229](https://github.com/docling-project/docling-serve/issues/229)) ([`30aca92`](https://github.com/docling-project/docling-serve/commit/30aca92298ab0d86bb4debcfcacb2dd8b9040a27))
### Documentation
* Update example resources and improve README ([#231](https://github.com/docling-project/docling-serve/issues/231)) ([`80755a7`](https://github.com/docling-project/docling-serve/commit/80755a7d5955f7d0c53df8e558fdd852dd1f5b75))
## [v0.15.0](https://github.com/docling-project/docling-serve/releases/tag/v0.15.0) - 2025-06-17
### Feature
* Use redocs and scalar as api docs ([#228](https://github.com/docling-project/docling-serve/issues/228)) ([`873d05a`](https://github.com/docling-project/docling-serve/commit/873d05aefe141c63b9c1cf53b23b4fa8c96de05d))
### Fix
* "tesserocr" instead of "tesseract_cli" in usage docs ([#223](https://github.com/docling-project/docling-serve/issues/223)) ([`196c5ce`](https://github.com/docling-project/docling-serve/commit/196c5ce42a04d77234a4212c3d9b9772d2c2073e))
## [v0.14.0](https://github.com/docling-project/docling-serve/releases/tag/v0.14.0) - 2025-06-17
### Feature
* Read supported file extensions from docling ([#214](https://github.com/docling-project/docling-serve/issues/214)) ([`524f6a8`](https://github.com/docling-project/docling-serve/commit/524f6a8997b86d2f869ca491ec8fb40585b42ca4))
### Fix
* Typo in Headline ([#220](https://github.com/docling-project/docling-serve/issues/220)) ([`d5455b7`](https://github.com/docling-project/docling-serve/commit/d5455b7f66de39ea1f8b8927b5968d2baa23ca88))
## [v0.13.0](https://github.com/docling-project/docling-serve/releases/tag/v0.13.0) - 2025-06-04
### Feature
* Upgrade docling to 2.36 ([#212](https://github.com/docling-project/docling-serve/issues/212)) ([`ffea347`](https://github.com/docling-project/docling-serve/commit/ffea34732b24fdd438fabd6df02d3d9ce66b4534))
## [v0.12.0](https://github.com/docling-project/docling-serve/releases/tag/v0.12.0) - 2025-06-03
### Feature
* Export annotations in markdown and html (Docling upgrade) ([#202](https://github.com/docling-project/docling-serve/issues/202)) ([`c4c41f1`](https://github.com/docling-project/docling-serve/commit/c4c41f16dff83c5d2a0b8a4c625b5de19b36b7c5))
### Fix
* Processing complex params in multipart-form ([#210](https://github.com/docling-project/docling-serve/issues/210)) ([`7066f35`](https://github.com/docling-project/docling-serve/commit/7066f3520a88c07df1c80a0cc6c4339eaac4d6a7))
### Documentation
* Add openshift replicasets examples ([#209](https://github.com/docling-project/docling-serve/issues/209)) ([`6a8190c`](https://github.com/docling-project/docling-serve/commit/6a8190c315792bd1e0e2b0af310656baaa5551e5))
## [v0.11.0](https://github.com/docling-project/docling-serve/releases/tag/v0.11.0) - 2025-05-23
### Feature
* Page break placeholder in markdown exports options ([#194](https://github.com/docling-project/docling-serve/issues/194)) ([`32b8a80`](https://github.com/docling-project/docling-serve/commit/32b8a809f348bf9fbde657f93589a56935d3749d))
* Clear results registry ([#192](https://github.com/docling-project/docling-serve/issues/192)) ([`de002df`](https://github.com/docling-project/docling-serve/commit/de002dfcdc111c942a08b156c84b7fa22b3fbaf3))
* Upgrade to Docling 2.33.0 ([#198](https://github.com/docling-project/docling-serve/issues/198)) ([`abe5aa0`](https://github.com/docling-project/docling-serve/commit/abe5aa03f54d44ecf5c6d76e3258028997a53e68))
* Api to trigger offloading the models ([#188](https://github.com/docling-project/docling-serve/issues/188)) ([`00be428`](https://github.com/docling-project/docling-serve/commit/00be4284904d55b78c75c5475578ef11c2ade94c))
* Figure annotations @ docling components 0.0.7 ([#181](https://github.com/docling-project/docling-serve/issues/181)) ([`3ff1b2f`](https://github.com/docling-project/docling-serve/commit/3ff1b2f9834aca37472a895a0e3da47560457d77))
### Fix
* Usage of hashlib for FIPS ([#171](https://github.com/docling-project/docling-serve/issues/171)) ([`8406fb9`](https://github.com/docling-project/docling-serve/commit/8406fb9b59d83247b8379974cabed497703dfc4d))
### Documentation
* Example and instructions on how to load model weights to persistent volume ([#197](https://github.com/docling-project/docling-serve/issues/197)) ([`3f090b7`](https://github.com/docling-project/docling-serve/commit/3f090b7d15eaf696611d89bbbba5b98569610828))
* Async api usage and fixes ([#195](https://github.com/docling-project/docling-serve/issues/195)) ([`21c1791`](https://github.com/docling-project/docling-serve/commit/21c1791e427f5b1946ed46c68dfda03c957dca8f))
## [v0.10.1](https://github.com/docling-project/docling-serve/releases/tag/v0.10.1) - 2025-04-30
### Fix
* Avoid missing specialized keys in the options hash ([#166](https://github.com/docling-project/docling-serve/issues/166)) ([`36787bc`](https://github.com/docling-project/docling-serve/commit/36787bc0616356a6199da618d8646de51636b34e))
* Allow users to set the area threshold for picture descriptions ([#165](https://github.com/docling-project/docling-serve/issues/165)) ([`509f488`](https://github.com/docling-project/docling-serve/commit/509f4889f8ed4c0f0ce25bec4126ef1f1199797c))
* Expose max wait time in sync endpoints ([#164](https://github.com/docling-project/docling-serve/issues/164)) ([`919cf5c`](https://github.com/docling-project/docling-serve/commit/919cf5c0414f2f11eb8012f451fed7a8f582b7ad))
* Add flash-attn for cuda images ([#161](https://github.com/docling-project/docling-serve/issues/161)) ([`35c2630`](https://github.com/docling-project/docling-serve/commit/35c2630c613cf229393fc67b6938152b063ff498))
## [v0.10.0](https://github.com/docling-project/docling-serve/releases/tag/v0.10.0) - 2025-04-28
### Feature
* Add support for file upload and return as file in async endpoints ([#152](https://github.com/docling-project/docling-serve/issues/152)) ([`c65f3c6`](https://github.com/docling-project/docling-serve/commit/c65f3c654c76c6b64b6aada1f0a153d74789d629))
### Documentation
* Fix new default pdf_backend ([#158](https://github.com/docling-project/docling-serve/issues/158)) ([`829effe`](https://github.com/docling-project/docling-serve/commit/829effec1a1b80320ccaf2c501be8015169b6fa3))
* Fixing small typo in docs ([#155](https://github.com/docling-project/docling-serve/issues/155)) ([`14bafb2`](https://github.com/docling-project/docling-serve/commit/14bafb26286b94f80b56846c50d6e9a6d99a9763))
## [v0.9.0](https://github.com/docling-project/docling-serve/releases/tag/v0.9.0) - 2025-04-25
### Feature
* Expose picture description options ([#148](https://github.com/docling-project/docling-serve/issues/148)) ([`4c9571a`](https://github.com/docling-project/docling-serve/commit/4c9571a052d5ec0044e49225bc5615e13cdb0a56))
* Add parameters for Kubeflow pipeline engine (WIP) ([#107](https://github.com/docling-project/docling-serve/issues/107)) ([`26bef5b`](https://github.com/docling-project/docling-serve/commit/26bef5bec060f0afd8d358816b68c3f2c0dd4bc2))
### Fix
* Produce image artifacts in referenced mode ([#151](https://github.com/docling-project/docling-serve/issues/151)) ([`71c5fae`](https://github.com/docling-project/docling-serve/commit/71c5fae505366459fd481d2ecdabc5ebed94d49c))
### Documentation
* Vlm and picture description options ([#149](https://github.com/docling-project/docling-serve/issues/149)) ([`91956cb`](https://github.com/docling-project/docling-serve/commit/91956cbf4e91cf82bb4d54ace397cdbbfaf594ba))
## [v0.8.0](https://github.com/docling-project/docling-serve/releases/tag/v0.8.0) - 2025-04-22
### Feature
* Add option for vlm pipeline ([#143](https://github.com/docling-project/docling-serve/issues/143)) ([`ee89ee4`](https://github.com/docling-project/docling-serve/commit/ee89ee4daee5e916bd6a3bdb452f78934cd03f60))
* Expose more conversion options ([#142](https://github.com/docling-project/docling-serve/issues/142)) ([`6b3d281`](https://github.com/docling-project/docling-serve/commit/6b3d281f02905c195ab75f25bb39f5c4d4e7b680))
* **UI:** Change UI to use async endpoints ([#131](https://github.com/docling-project/docling-serve/issues/131)) ([`b598872`](https://github.com/docling-project/docling-serve/commit/b598872e5c48928ac44417a11bb7acc0e5c3f0c6))
### Fix
* **UI:** Use https when calling the api ([#139](https://github.com/docling-project/docling-serve/issues/139)) ([`57f9073`](https://github.com/docling-project/docling-serve/commit/57f9073bc0daf72428b068ea28e2bec7cd76c37b))
* Fix permissions in docker image ([#136](https://github.com/docling-project/docling-serve/issues/136)) ([`c1ce471`](https://github.com/docling-project/docling-serve/commit/c1ce4719c933179ba3c59d73d0584853bbd6fa6a))
* Picture caption visuals ([#129](https://github.com/docling-project/docling-serve/issues/129)) ([`5dfb75d`](https://github.com/docling-project/docling-serve/commit/5dfb75d3b9a7022d1daad12edbb8ec7bbf9aa264))
### Documentation
* Fix required permissions for oauth2-proxy requests ([#141](https://github.com/docling-project/docling-serve/issues/141)) ([`087417e`](https://github.com/docling-project/docling-serve/commit/087417e5c2387d4ed95500222058f34d8a8702aa))
* Update deployment examples ([#135](https://github.com/docling-project/docling-serve/issues/135)) ([`525a43f`](https://github.com/docling-project/docling-serve/commit/525a43ff6f04b7cc80f9dd6a0e653a8d8c4ab317))
* Fix image tag ([#124](https://github.com/docling-project/docling-serve/issues/124)) ([`420162e`](https://github.com/docling-project/docling-serve/commit/420162e674cc38b4c3c13673ffbee4c20a1b15f1))
## [v0.7.0](https://github.com/docling-project/docling-serve/releases/tag/v0.7.0) - 2025-03-31
### Feature
* Expose TLS settings and example deploy with oauth-proxy ([#112](https://github.com/docling-project/docling-serve/issues/112)) ([`7a0faba`](https://github.com/docling-project/docling-serve/commit/7a0fabae07020c2659dbb22c3b0359909051a74c))
* Offline static files ([#109](https://github.com/docling-project/docling-serve/issues/109)) ([`68772bb`](https://github.com/docling-project/docling-serve/commit/68772bb6f0a87b71094a08ff851f5754c6ca6163))
* Update to Docling 2.28 ([#106](https://github.com/docling-project/docling-serve/issues/106)) ([`20ec87a`](https://github.com/docling-project/docling-serve/commit/20ec87a63a99145bc0ad7931549af8a0c30db641))
### Fix
* Move ARGs to prevent cache invalidation ([#104](https://github.com/docling-project/docling-serve/issues/104)) ([`e30f458`](https://github.com/docling-project/docling-serve/commit/e30f458923d34c169db7d5a5c296848716e8cac4))
## [v0.6.0](https://github.com/docling-project/docling-serve/releases/tag/v0.6.0) - 2025-03-17
### Feature

View File

@@ -1,16 +1,17 @@
ARG BASE_IMAGE=quay.io/sclorg/python-312-c9s:c9s
FROM ${BASE_IMAGE}
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.8.19
ARG MODELS_LIST="layout tableformer picture_classifier easyocr" \
UV_SYNC_EXTRA_ARGS=""
ARG UV_SYNC_EXTRA_ARGS=""
USER 0
FROM ${BASE_IMAGE} AS docling-base
###################################################################################################
# OS Layer #
###################################################################################################
USER 0
RUN --mount=type=bind,source=os-packages.txt,target=/tmp/os-packages.txt \
dnf -y install --best --nodocs --setopt=install_weak_deps=False dnf-plugins-core && \
dnf config-manager --best --nodocs --setopt=install_weak_deps=False --save && \
@@ -20,18 +21,23 @@ RUN --mount=type=bind,source=os-packages.txt,target=/tmp/os-packages.txt \
dnf -y clean all && \
rm -rf /var/cache/dnf
RUN /usr/bin/fix-permissions /opt/app-root/src/.cache
ENV TESSDATA_PREFIX=/usr/share/tesseract/tessdata/
FROM ${UV_IMAGE} AS uv_stage
###################################################################################################
# Docling layer #
###################################################################################################
FROM docling-base
USER 1001
WORKDIR /opt/app-root/src
ENV \
# On container environments, always set a thread budget to avoid undesired thread congestion.
OMP_NUM_THREADS=4 \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
@@ -41,25 +47,33 @@ ENV \
UV_PROJECT_ENVIRONMENT=/opt/app-root \
DOCLING_SERVE_ARTIFACTS_PATH=/opt/app-root/src/.cache/docling/models
RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \
ARG UV_SYNC_EXTRA_ARGS
RUN --mount=from=uv_stage,source=/uv,target=/bin/uv \
--mount=type=cache,target=/opt/app-root/src/.cache/uv,uid=1001 \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project --no-dev --all-extras ${UV_SYNC_EXTRA_ARGS}
umask 002 && \
UV_SYNC_ARGS="--frozen --no-install-project --no-dev --all-extras" && \
uv sync ${UV_SYNC_ARGS} ${UV_SYNC_EXTRA_ARGS} --no-extra flash-attn && \
FLASH_ATTENTION_SKIP_CUDA_BUILD=TRUE uv sync ${UV_SYNC_ARGS} ${UV_SYNC_EXTRA_ARGS} --no-build-isolation-package=flash-attn
ARG MODELS_LIST="layout tableformer picture_classifier rapidocr easyocr"
RUN echo "Downloading models..." && \
HF_HUB_DOWNLOAD_TIMEOUT="90" \
HF_HUB_ETAG_TIMEOUT="90" \
docling-tools models download -o "${DOCLING_SERVE_ARTIFACTS_PATH}" ${MODELS_LIST} && \
chown -R 1001:0 /opt/app-root/src/.cache && \
chmod -R g=u /opt/app-root/src/.cache
chown -R 1001:0 ${DOCLING_SERVE_ARTIFACTS_PATH} && \
chmod -R g=u ${DOCLING_SERVE_ARTIFACTS_PATH}
COPY --chown=1001:0 ./docling_serve ./docling_serve
RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \
RUN --mount=from=uv_stage,source=/uv,target=/bin/uv \
--mount=type=cache,target=/opt/app-root/src/.cache/uv,uid=1001 \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-dev --all-extras ${UV_SYNC_EXTRA_ARGS}
umask 002 && uv sync --frozen --no-dev --all-extras ${UV_SYNC_EXTRA_ARGS}
EXPOSE 5001

View File

@@ -1,11 +1,11 @@
# MAINTAINERS
- Christoph Auer - [@cau-git](https://github.com/cau-git)
- Michele Dolfi - [@dolfim-ibm](https://github.com/dolfim-ibm)
- Maxim Lysak - [@maxmnemonic](https://github.com/maxmnemonic)
- Nikos Livathinos - [@nikos-livathinos](https://github.com/nikos-livathinos)
- Ahmed Nassar - [@nassarofficial](https://github.com/nassarofficial)
- Panos Vagenas - [@vagenas](https://github.com/vagenas)
- Peter Staar - [@PeterStaar-IBM](https://github.com/PeterStaar-IBM)
- Christoph Auer - [`@cau-git`](https://github.com/cau-git)
- Michele Dolfi - [`@dolfim-ibm`](https://github.com/dolfim-ibm)
- Maxim Lysak - [`@maxmnemonic`](https://github.com/maxmnemonic)
- Nikos Livathinos - [`@nikos-livathinos`](https://github.com/nikos-livathinos)
- Ahmed Nassar - [`@nassarofficial`](https://github.com/nassarofficial)
- Panos Vagenas - [`@vagenas`](https://github.com/vagenas)
- Peter Staar - [`@PeterStaar-IBM`](https://github.com/PeterStaar-IBM)
Maintainers can be contacted at [deepsearch-core@zurich.ibm.com](mailto:deepsearch-core@zurich.ibm.com).

View File

@@ -16,7 +16,11 @@ else
PIPE_DEV_NULL=
endif
# Container runtime - can be overridden: make CONTAINER_RUNTIME=podman cmd
CONTAINER_RUNTIME ?= docker
TAG=$(shell git rev-parse HEAD)
BRANCH_TAG=$(shell git rev-parse --abbrev-ref HEAD)
action-lint-file:
$(CMD_PREFIX) touch .action-lint
@@ -25,25 +29,46 @@ md-lint-file:
$(CMD_PREFIX) touch .markdown-lint
.PHONY: docling-serve-image
docling-serve-image: Containerfile
docling-serve-image: Containerfile ## Build docling-serve container image
$(ECHO_PREFIX) printf " %-12s Containerfile\n" "[docling-serve]"
$(CMD_PREFIX) docker build --load --build-arg "UV_SYNC_EXTRA_ARGS=--no-extra cu124 --no-extra cpu" -f Containerfile -t ghcr.io/docling-project/docling-serve:$(TAG) .
$(CMD_PREFIX) docker tag ghcr.io/docling-project/docling-serve:$(TAG) ghcr.io/docling-project/docling-serve:main
$(CMD_PREFIX) docker tag ghcr.io/docling-project/docling-serve:$(TAG) quay.io/docling-project/docling-serve:main
$(CMD_PREFIX) $(CONTAINER_RUNTIME) build --load -f Containerfile -t ghcr.io/docling-project/docling-serve:$(TAG) .
$(CMD_PREFIX) $(CONTAINER_RUNTIME) tag ghcr.io/docling-project/docling-serve:$(TAG) ghcr.io/docling-project/docling-serve:$(BRANCH_TAG)
$(CMD_PREFIX) $(CONTAINER_RUNTIME) tag ghcr.io/docling-project/docling-serve:$(TAG) quay.io/docling-project/docling-serve:$(BRANCH_TAG)
.PHONY: docling-serve-cpu-image
docling-serve-cpu-image: Containerfile ## Build docling-serve "cpu only" container image
$(ECHO_PREFIX) printf " %-12s Containerfile\n" "[docling-serve CPU]"
$(CMD_PREFIX) docker build --load --build-arg "UV_SYNC_EXTRA_ARGS=--no-extra cu124" -f Containerfile -t ghcr.io/docling-project/docling-serve-cpu:$(TAG) .
$(CMD_PREFIX) docker tag ghcr.io/docling-project/docling-serve-cpu:$(TAG) ghcr.io/docling-project/docling-serve-cpu:main
$(CMD_PREFIX) docker tag ghcr.io/docling-project/docling-serve-cpu:$(TAG) quay.io/docling-project/docling-serve-cpu:main
$(CMD_PREFIX) $(CONTAINER_RUNTIME) build --load --build-arg "UV_SYNC_EXTRA_ARGS=--no-group pypi --group cpu --no-extra flash-attn" -f Containerfile -t ghcr.io/docling-project/docling-serve-cpu:$(TAG) .
$(CMD_PREFIX) $(CONTAINER_RUNTIME) tag ghcr.io/docling-project/docling-serve-cpu:$(TAG) ghcr.io/docling-project/docling-serve-cpu:$(BRANCH_TAG)
$(CMD_PREFIX) $(CONTAINER_RUNTIME) tag ghcr.io/docling-project/docling-serve-cpu:$(TAG) quay.io/docling-project/docling-serve-cpu:$(BRANCH_TAG)
.PHONY: docling-serve-cu124-image
docling-serve-cu124-image: Containerfile ## Build docling-serve container image with GPU support
docling-serve-cu124-image: Containerfile ## Build docling-serve container image with CUDA 12.4 support
$(ECHO_PREFIX) printf " %-12s Containerfile\n" "[docling-serve with Cuda 12.4]"
$(CMD_PREFIX) docker build --load --build-arg "UV_SYNC_EXTRA_ARGS=--no-extra cpu" -f Containerfile --platform linux/amd64 -t ghcr.io/docling-project/docling-serve-cu124:$(TAG) .
$(CMD_PREFIX) docker tag ghcr.io/docling-project/docling-serve-cu124:$(TAG) ghcr.io/docling-project/docling-serve-cu124:main
$(CMD_PREFIX) docker tag ghcr.io/docling-project/docling-serve-cu124:$(TAG) quay.io/docling-project/docling-serve-cu124:main
$(CMD_PREFIX) $(CONTAINER_RUNTIME) build --load --build-arg "UV_SYNC_EXTRA_ARGS=--no-group pypi --group cu124" -f Containerfile --platform linux/amd64 -t ghcr.io/docling-project/docling-serve-cu124:$(TAG) .
$(CMD_PREFIX) $(CONTAINER_RUNTIME) tag ghcr.io/docling-project/docling-serve-cu124:$(TAG) ghcr.io/docling-project/docling-serve-cu124:$(BRANCH_TAG)
$(CMD_PREFIX) $(CONTAINER_RUNTIME) tag ghcr.io/docling-project/docling-serve-cu124:$(TAG) quay.io/docling-project/docling-serve-cu124:$(BRANCH_TAG)
.PHONY: docling-serve-cu126-image
docling-serve-cu126-image: Containerfile ## Build docling-serve container image with CUDA 12.6 support
$(ECHO_PREFIX) printf " %-12s Containerfile\n" "[docling-serve with Cuda 12.6]"
$(CMD_PREFIX) $(CONTAINER_RUNTIME) build --load --build-arg "UV_SYNC_EXTRA_ARGS=--no-group pypi --group cu126" -f Containerfile --platform linux/amd64 -t ghcr.io/docling-project/docling-serve-cu126:$(TAG) .
$(CMD_PREFIX) $(CONTAINER_RUNTIME) tag ghcr.io/docling-project/docling-serve-cu126:$(TAG) ghcr.io/docling-project/docling-serve-cu126:$(BRANCH_TAG)
$(CMD_PREFIX) $(CONTAINER_RUNTIME) tag ghcr.io/docling-project/docling-serve-cu126:$(TAG) quay.io/docling-project/docling-serve-cu126:$(BRANCH_TAG)
.PHONY: docling-serve-cu128-image
docling-serve-cu128-image: Containerfile ## Build docling-serve container image with CUDA 12.8 support
$(ECHO_PREFIX) printf " %-12s Containerfile\n" "[docling-serve with Cuda 12.8]"
$(CMD_PREFIX) $(CONTAINER_RUNTIME) build --load --build-arg "UV_SYNC_EXTRA_ARGS=--no-group pypi --group cu128" -f Containerfile --platform linux/amd64 -t ghcr.io/docling-project/docling-serve-cu128:$(TAG) .
$(CMD_PREFIX) $(CONTAINER_RUNTIME) tag ghcr.io/docling-project/docling-serve-cu128:$(TAG) ghcr.io/docling-project/docling-serve-cu128:$(BRANCH_TAG)
$(CMD_PREFIX) $(CONTAINER_RUNTIME) tag ghcr.io/docling-project/docling-serve-cu128:$(TAG) quay.io/docling-project/docling-serve-cu128:$(BRANCH_TAG)
.PHONY: docling-serve-rocm-image
docling-serve-rocm-image: Containerfile ## Build docling-serve container image with ROCm support
$(ECHO_PREFIX) printf " %-12s Containerfile\n" "[docling-serve with ROCm 6.3]"
$(CMD_PREFIX) $(CONTAINER_RUNTIME) build --load --build-arg "UV_SYNC_EXTRA_ARGS=--no-group pypi --group rocm --no-extra flash-attn" -f Containerfile --platform linux/amd64 -t ghcr.io/docling-project/docling-serve-rocm:$(TAG) .
$(CMD_PREFIX) $(CONTAINER_RUNTIME) tag ghcr.io/docling-project/docling-serve-rocm:$(TAG) ghcr.io/docling-project/docling-serve-rocm:$(BRANCH_TAG)
$(CMD_PREFIX) $(CONTAINER_RUNTIME) tag ghcr.io/docling-project/docling-serve-rocm:$(TAG) quay.io/docling-project/docling-serve-rocm:$(BRANCH_TAG)
.PHONY: action-lint
action-lint: .action-lint ## Lint GitHub Action workflows
@@ -66,7 +91,7 @@ action-lint: .action-lint ## Lint GitHub Action workflows
md-lint: .md-lint ## Lint markdown files
.md-lint: $(wildcard */**/*.md) | md-lint-file
$(ECHO_PREFIX) printf " %-12s ./...\n" "[MD LINT]"
$(CMD_PREFIX) docker run --rm -v $$(pwd):/workdir davidanson/markdownlint-cli2:v0.16.0 "**/*.md" "#.venv"
$(CMD_PREFIX) $(CONTAINER_RUNTIME) run --rm -v $$(pwd):/workdir davidanson/markdownlint-cli2:v0.16.0 "**/*.md" "#.venv"
$(CMD_PREFIX) touch $@
.PHONY: py-Lint
@@ -82,13 +107,34 @@ py-lint: ## Lint Python files
.PHONY: run-docling-cpu
run-docling-cpu: ## Run the docling-serve container with CPU support and assign a container name
$(ECHO_PREFIX) printf " %-12s Removing existing container if it exists...\n" "[CLEANUP]"
$(CMD_PREFIX) docker rm -f docling-serve-cpu 2>/dev/null || true
$(CMD_PREFIX) $(CONTAINER_RUNTIME) rm -f docling-serve-cpu 2>/dev/null || true
$(ECHO_PREFIX) printf " %-12s Running docling-serve container with CPU support on port 5001...\n" "[RUN CPU]"
$(CMD_PREFIX) docker run -it --name docling-serve-cpu -p 5001:5001 ghcr.io/docling-project/docling-serve-cpu:main
$(CMD_PREFIX) $(CONTAINER_RUNTIME) run -it --name docling-serve-cpu -p 5001:5001 ghcr.io/docling-project/docling-serve-cpu:main
.PHONY: run-docling-gpu
run-docling-gpu: ## Run the docling-serve container with GPU support and assign a container name
.PHONY: run-docling-cu124
run-docling-cu124: ## Run the docling-serve container with GPU support and assign a container name
$(ECHO_PREFIX) printf " %-12s Removing existing container if it exists...\n" "[CLEANUP]"
$(CMD_PREFIX) docker rm -f docling-serve-gpu 2>/dev/null || true
$(ECHO_PREFIX) printf " %-12s Running docling-serve container with GPU support on port 5001...\n" "[RUN GPU]"
$(CMD_PREFIX) docker run -it --name docling-serve-gpu -p 5001:5001 ghcr.io/docling-project/docling-serve:main
$(CMD_PREFIX) $(CONTAINER_RUNTIME) rm -f docling-serve-cu124 2>/dev/null || true
$(ECHO_PREFIX) printf " %-12s Running docling-serve container with GPU support on port 5001...\n" "[RUN CUDA 12.4]"
$(CMD_PREFIX) $(CONTAINER_RUNTIME) run -it --name docling-serve-cu124 -p 5001:5001 ghcr.io/docling-project/docling-serve-cu124:main
.PHONY: run-docling-cu126
run-docling-cu126: ## Run the docling-serve container with GPU support and assign a container name
$(ECHO_PREFIX) printf " %-12s Removing existing container if it exists...\n" "[CLEANUP]"
$(CMD_PREFIX) $(CONTAINER_RUNTIME) rm -f docling-serve-cu126 2>/dev/null || true
$(ECHO_PREFIX) printf " %-12s Running docling-serve container with GPU support on port 5001...\n" "[RUN CUDA 12.6]"
$(CMD_PREFIX) $(CONTAINER_RUNTIME) run -it --name docling-serve-cu126 -p 5001:5001 ghcr.io/docling-project/docling-serve-cu126:main
.PHONY: run-docling-cu128
run-docling-cu128: ## Run the docling-serve container with GPU support and assign a container name
$(ECHO_PREFIX) printf " %-12s Removing existing container if it exists...\n" "[CLEANUP]"
$(CMD_PREFIX) $(CONTAINER_RUNTIME) rm -f docling-serve-cu128 2>/dev/null || true
$(ECHO_PREFIX) printf " %-12s Running docling-serve container with GPU support on port 5001...\n" "[RUN CUDA 12.8]"
$(CMD_PREFIX) $(CONTAINER_RUNTIME) run -it --name docling-serve-cu128 -p 5001:5001 ghcr.io/docling-project/docling-serve-cu128:main
.PHONY: run-docling-rocm
run-docling-rocm: ## Run the docling-serve container with GPU support and assign a container name
$(ECHO_PREFIX) printf " %-12s Removing existing container if it exists...\n" "[CLEANUP]"
$(CMD_PREFIX) $(CONTAINER_RUNTIME) rm -f docling-serve-rocm 2>/dev/null || true
$(ECHO_PREFIX) printf " %-12s Running docling-serve container with GPU support on port 5001...\n" "[RUN ROCm 6.3]"
$(CMD_PREFIX) $(CONTAINER_RUNTIME) run -it --name docling-serve-rocm -p 5001:5001 ghcr.io/docling-project/docling-serve-rocm:main

View File

@@ -1,70 +1,92 @@
<p align="center">
<a href="https://github.com/docling-project/docling-serve">
<img loading="lazy" alt="Docling" src="https://github.com/docling-project/docling-serve/raw/main/docs/assets/docling-serve-pic.png" width="30%"/>
</a>
</p>
# Docling Serve
Running [Docling](https://github.com/docling-project/docling) as an API service.
📚 [Docling Serve documentation](./docs/README.md)
- Learning how to [configure the webserver](./docs/configuration.md)
- Get to know all [runtime options](./docs/usage.md) of the API
- Explore useful [deployment examples](./docs/deployment.md)
- And more
> [!NOTE]
> **Migration to the `v1` API.** Docling Serve now has a stable v1 API. Read more on the [migration to v1](./docs/v1_migration.md).
## Getting started
Install the `docling-serve` package and run the server.
```bash
# Using the python package
pip install "docling-serve"
docling-serve run
pip install "docling-serve[ui]"
docling-serve run --enable-ui
# Using container images, e.g. with Podman
podman run -p 5001:5001 quay.io/docling-project/docling-serve
podman run -p 5001:5001 -e DOCLING_SERVE_ENABLE_UI=1 quay.io/docling-project/docling-serve
```
The server is available at
- API <http://127.0.0.1:5001>
- API documentation <http://127.0.0.1:5001/docs>
![swagger.png](img/swagger.png)
- UI playground <http://127.0.0.1:5001/ui>
![API documentation](img/fastapi-ui.png)
Try it out with a simple conversion:
```bash
curl -X 'POST' \
'http://localhost:5001/v1alpha/convert/source' \
'http://localhost:5001/v1/convert/source' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"http_sources": [{"url": "https://arxiv.org/pdf/2501.17887"}]
"sources": [{"kind": "http", "url": "https://arxiv.org/pdf/2501.17887"}]
}'
```
### Container images
### Container Images
Available container images:
The following container images are available for running **Docling Serve** with different hardware and PyTorch configurations:
| Name | Description | Arch | Size |
| -----|-------------|------|------|
| [`ghcr.io/docling-project/docling-serve`](https://github.com/docling-project/docling-serve/pkgs/container/docling-serve) <br /> [`quay.io/docling-project/docling-serve`](https://quay.io/repository/docling-project/docling-serve) | Simple image for Docling Serve, installing all packages from the official pypi.org index. | `linux/amd64`, `linux/arm64` | 3.6 GB |
| [`ghcr.io/docling-project/docling-serve-cpu`](https://github.com/docling-project/docling-serve/pkgs/container/docling-serve-cpu) <br /> [`quay.io/docling-project/docling-serve-cpu`](https://quay.io/repository/docling-project/docling-serve-cpu) | Cpu-only image which installs `torch` from the pytorch cpu index. | `linux/amd64`, `linux/arm64` | 3.6 GB |
| [`ghcr.io/docling-project/docling-serve-cu124`](https://github.com/docling-project/docling-serve/pkgs/container/docling-serve-cu124) <br /> [`quay.io/docling-project/docling-serve-cu124`](https://quay.io/repository/docling-project/docling-serve-cu124) | Cuda 12.4 image which installs `torch` from the pytorch cu124 index. | `linux/amd64` | 8.7 GB |
#### 📦 Distributed Images
| Image | Description | Architectures | Size |
|-------|-------------|----------------|------|
| [`ghcr.io/docling-project/docling-serve`](https://github.com/docling-project/docling-serve/pkgs/container/docling-serve) <br> [`quay.io/docling-project/docling-serve`](https://quay.io/repository/docling-project/docling-serve) | Base image with all packages installed from the official PyPI index. | `linux/amd64`, `linux/arm64` | 4.4 GB (arm64) <br> 8.7 GB (amd64) |
| [`ghcr.io/docling-project/docling-serve-cpu`](https://github.com/docling-project/docling-serve/pkgs/container/docling-serve-cpu) <br> [`quay.io/docling-project/docling-serve-cpu`](https://quay.io/repository/docling-project/docling-serve-cpu) | CPU-only variant, using `torch` from the PyTorch CPU index. | `linux/amd64`, `linux/arm64` | 4.4 GB |
| [`ghcr.io/docling-project/docling-serve-cu126`](https://github.com/docling-project/docling-serve/pkgs/container/docling-serve-cu126) <br> [`quay.io/docling-project/docling-serve-cu126`](https://quay.io/repository/docling-project/docling-serve-cu126) | CUDA 12.6 build with `torch` from the cu126 index. | `linux/amd64` | 10.0 GB |
| [`ghcr.io/docling-project/docling-serve-cu128`](https://github.com/docling-project/docling-serve/pkgs/container/docling-serve-cu128) <br> [`quay.io/docling-project/docling-serve-cu128`](https://quay.io/repository/docling-project/docling-serve-cu128) | CUDA 12.8 build with `torch` from the cu128 index. | `linux/amd64` | 11.4 GB |
#### 🚫 Not Distributed
An image for AMD ROCm 6.3 (`docling-serve-rocm`) is supported but **not published** due to its large size.
To build it locally:
```bash
git clone --branch main git@github.com:docling-project/docling-serve.git
cd docling-serve/
make docling-serve-rocm-image
```
For deployment using Docker Compose, see [docs/deployment.md](docs/deployment.md).
Coming soon: `docling-serve-slim` images will reduce the size by skipping the model weights download.
### Demonstration UI
```bash
# Install the Python package with the extra dependencies
pip install "docling-serve[ui]"
docling-serve run --enable-ui
# Run the container image with the extra env parameters
podman run -p 5001:5001 -e DOCLING_SERVE_ENABLE_UI=true quay.io/docling-project/docling-serve
```
An easy to use UI is available at the `/ui` endpoint.
![ui-input.png](img/ui-input.png)
![Input controllers in the UI](img/ui-input.png)
![ui-output.png](img/ui-output.png)
## Documentation and advance usages
Visit the [Docling Serve documentation](./docs/README.md) for learning how to [configure the webserver](./docs/configuration.md), use all the [runtime options](./docs/usage.md) of the API and [deployment examples](./docs/deployment.md).
![Output visualization in the UI](img/ui-output.png)
## Get help and support

View File

@@ -11,6 +11,7 @@ import uvicorn
from rich.console import Console
from docling_serve.settings import docling_serve_settings, uvicorn_settings
from docling_serve.storage import get_scratch
warnings.filterwarnings(action="ignore", category=UserWarning, module="pydantic|torch")
warnings.filterwarnings(action="ignore", category=FutureWarning, module="easyocr")
@@ -29,7 +30,8 @@ logger = logging.getLogger(__name__)
def version_callback(value: bool) -> None:
if value:
docling_serve_version = importlib.metadata.version("docling_serve")
docling_serve_version = importlib.metadata.version("docling-serve")
docling_jobkit_version = importlib.metadata.version("docling-jobkit")
docling_version = importlib.metadata.version("docling")
docling_core_version = importlib.metadata.version("docling-core")
docling_ibm_models_version = importlib.metadata.version("docling-ibm-models")
@@ -38,6 +40,7 @@ def version_callback(value: bool) -> None:
py_impl_version = sys.implementation.cache_tag
py_lang_version = platform.python_version()
console.print(f"Docling Serve version: {docling_serve_version}")
console.print(f"Docling Jobkit version: {docling_jobkit_version}")
console.print(f"Docling version: {docling_version}")
console.print(f"Docling Core version: {docling_core_version}")
console.print(f"Docling IBM Models version: {docling_ibm_models_version}")
@@ -86,6 +89,11 @@ def _run(
uvicorn_settings.workers is not None and uvicorn_settings.workers > 1
) or uvicorn_settings.reload
run_ssl = (
uvicorn_settings.ssl_certfile is not None
and uvicorn_settings.ssl_keyfile is not None
)
if run_subprocess and docling_serve_settings.artifacts_path != artifacts_path:
err_console.print(
"\n[yellow]:warning: The server will run with reload or multiple workers. \n"
@@ -105,13 +113,16 @@ def _run(
docling_serve_settings.enable_ui = enable_ui
# Print documentation
url = f"http://{uvicorn_settings.host}:{uvicorn_settings.port}"
protocol = "https" if run_ssl else "http"
url = f"{protocol}://{uvicorn_settings.host}:{uvicorn_settings.port}"
url_docs = f"{url}/docs"
url_scalar = f"{url}/scalar"
url_ui = f"{url}/ui"
console.print("")
console.print(f"Server started at [link={url}]{url}[/]")
console.print(f"Documentation at [link={url_docs}]{url_docs}[/]")
console.print(f"Scalar docs at [link={url_docs}]{url_scalar}[/]")
if docling_serve_settings.enable_ui:
console.print(f"UI at [link={url_ui}]{url_ui}[/]")
@@ -136,6 +147,9 @@ def _run(
root_path=uvicorn_settings.root_path,
proxy_headers=uvicorn_settings.proxy_headers,
timeout_keep_alive=uvicorn_settings.timeout_keep_alive,
ssl_certfile=uvicorn_settings.ssl_certfile,
ssl_keyfile=uvicorn_settings.ssl_keyfile,
ssl_keyfile_password=uvicorn_settings.ssl_keyfile_password,
)
@@ -190,6 +204,15 @@ def dev(
timeout_keep_alive: Annotated[
int, typer.Option(help="Timeout for the server response.")
] = uvicorn_settings.timeout_keep_alive,
ssl_certfile: Annotated[
Optional[Path], typer.Option(help="SSL certificate file")
] = uvicorn_settings.ssl_certfile,
ssl_keyfile: Annotated[
Optional[Path], typer.Option(help="SSL key file")
] = uvicorn_settings.ssl_keyfile,
ssl_keyfile_password: Annotated[
Optional[str], typer.Option(help="SSL keyfile password")
] = uvicorn_settings.ssl_keyfile_password,
# docling options
artifacts_path: Annotated[
Optional[Path],
@@ -218,6 +241,9 @@ def dev(
uvicorn_settings.root_path = root_path
uvicorn_settings.proxy_headers = proxy_headers
uvicorn_settings.timeout_keep_alive = timeout_keep_alive
uvicorn_settings.ssl_certfile = ssl_certfile
uvicorn_settings.ssl_keyfile = ssl_keyfile
uvicorn_settings.ssl_keyfile_password = ssl_keyfile_password
_run(
command="dev",
@@ -285,6 +311,15 @@ def run(
timeout_keep_alive: Annotated[
int, typer.Option(help="Timeout for the server response.")
] = uvicorn_settings.timeout_keep_alive,
ssl_certfile: Annotated[
Optional[Path], typer.Option(help="SSL certificate file")
] = uvicorn_settings.ssl_certfile,
ssl_keyfile: Annotated[
Optional[Path], typer.Option(help="SSL key file")
] = uvicorn_settings.ssl_keyfile,
ssl_keyfile_password: Annotated[
Optional[str], typer.Option(help="SSL keyfile password")
] = uvicorn_settings.ssl_keyfile_password,
# docling options
artifacts_path: Annotated[
Optional[Path],
@@ -316,6 +351,9 @@ def run(
uvicorn_settings.root_path = root_path
uvicorn_settings.proxy_headers = proxy_headers
uvicorn_settings.timeout_keep_alive = timeout_keep_alive
uvicorn_settings.ssl_certfile = ssl_certfile
uvicorn_settings.ssl_keyfile = ssl_keyfile
uvicorn_settings.ssl_keyfile_password = ssl_keyfile_password
_run(
command="run",
@@ -324,6 +362,42 @@ def run(
)
@app.command()
def rq_worker() -> Any:
"""
Run the [bold]Docling JobKit[/bold] RQ worker.
"""
from docling_jobkit.convert.manager import DoclingConverterManagerConfig
from docling_jobkit.orchestrators.rq.orchestrator import RQOrchestratorConfig
from docling_jobkit.orchestrators.rq.worker import run_worker
rq_config = RQOrchestratorConfig(
redis_url=docling_serve_settings.eng_rq_redis_url,
results_prefix=docling_serve_settings.eng_rq_results_prefix,
sub_channel=docling_serve_settings.eng_rq_sub_channel,
scratch_dir=get_scratch(),
)
cm_config = DoclingConverterManagerConfig(
artifacts_path=docling_serve_settings.artifacts_path,
options_cache_size=docling_serve_settings.options_cache_size,
enable_remote_services=docling_serve_settings.enable_remote_services,
allow_external_plugins=docling_serve_settings.allow_external_plugins,
max_num_pages=docling_serve_settings.max_num_pages,
max_file_size=docling_serve_settings.max_file_size,
queue_max_size=docling_serve_settings.queue_max_size,
ocr_batch_size=docling_serve_settings.ocr_batch_size,
layout_batch_size=docling_serve_settings.layout_batch_size,
table_batch_size=docling_serve_settings.table_batch_size,
batch_polling_interval_seconds=docling_serve_settings.batch_polling_interval_seconds,
)
run_worker(
rq_config=rq_config,
cm_config=cm_config,
)
def main() -> None:
app()

File diff suppressed because it is too large Load Diff

89
docling_serve/auth.py Normal file
View File

@@ -0,0 +1,89 @@
from typing import Any
from fastapi import HTTPException, Request, Response, status
from fastapi.security import APIKeyCookie, APIKeyHeader
from pydantic import BaseModel
class AuthenticationResult(BaseModel):
valid: bool
errors: list[str] = []
detail: Any | None = None
class KeyValidator:
def __init__(
self,
api_key: str,
field_name: str = "X-Api-Key",
fail_on_unauthorized: bool = True,
) -> None:
self.api_key = api_key
self.field_name = field_name
self.fail_on_unauthorized = fail_on_unauthorized
async def __call__(self, candidate_key: str | None):
if candidate_key is None:
return self._error(f"Missing field {self.field_name}.")
candidate_key = candidate_key.strip()
# Otherwise check the apikey
if candidate_key == self.api_key or self.api_key == "":
return AuthenticationResult(
valid=True,
detail=candidate_key, # Remove?
)
else:
return self._error("The provided API Key is invalid.")
def _error(self, error: str):
if self.fail_on_unauthorized and self.api_key:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, error)
else:
return AuthenticationResult(
valid=False,
errors=[error],
)
class APIKeyHeaderAuth(APIKeyHeader):
"""
FastAPI dependency which evaluates a status API Key in a header.
"""
def __init__(self, validator: str | KeyValidator) -> None:
self.validator = (
KeyValidator(validator) if isinstance(validator, str) else validator
)
super().__init__(name=self.validator.field_name, auto_error=False)
async def __call__(self, request: Request) -> AuthenticationResult: # type: ignore
key = await super().__call__(request=request)
return await self.validator(key)
class APIKeyCookieAuth(APIKeyCookie):
"""
FastAPI dependency which evaluates a status API Key in a cookie.
"""
def __init__(self, validator: str | KeyValidator) -> None:
self.validator = (
KeyValidator(validator) if isinstance(validator, str) else validator
)
super().__init__(name=self.validator.field_name, auto_error=False)
async def __call__(self, request: Request) -> AuthenticationResult: # type: ignore
api_key = await super().__call__(request=request)
return await self.validator(api_key)
def _set_api_key(self, response: Response, api_key: str, expires=24 * 3600):
response.set_cookie(
key=self.validator.field_name,
value=api_key,
expires=expires,
secure=True,
httponly=True,
samesite="strict",
)

View File

@@ -1,218 +1,40 @@
# Define the input options for the API
from typing import Annotated, Optional
from typing import Annotated
from pydantic import BaseModel, Field
from pydantic import Field
from docling.datamodel.base_models import InputFormat, OutputFormat
from docling.datamodel.pipeline_options import OcrEngine, PdfBackend, TableFormerMode
from docling_core.types.doc import ImageRefMode
from docling.datamodel.pipeline_options import (
EasyOcrOptions,
)
from docling.models.factories import get_ocr_factory
from docling_jobkit.datamodel.convert import ConvertDocumentsOptions
from docling_serve.settings import docling_serve_settings
ocr_factory = get_ocr_factory(
allow_external_plugins=docling_serve_settings.allow_external_plugins
)
ocr_engines_enum = ocr_factory.get_enum()
class ConvertDocumentsOptions(BaseModel):
from_formats: Annotated[
list[InputFormat],
Field(
description=(
"Input format(s) to convert from. String or list of strings. "
f"Allowed values: {', '.join([v.value for v in InputFormat])}. "
"Optional, defaults to all formats."
),
examples=[[v.value for v in InputFormat]],
),
] = list(InputFormat)
to_formats: Annotated[
list[OutputFormat],
Field(
description=(
"Output format(s) to convert to. String or list of strings. "
f"Allowed values: {', '.join([v.value for v in OutputFormat])}. "
"Optional, defaults to Markdown."
),
examples=[[OutputFormat.MARKDOWN]],
),
] = [OutputFormat.MARKDOWN]
image_export_mode: Annotated[
ImageRefMode,
Field(
description=(
"Image export mode for the document (in case of JSON,"
" Markdown or HTML). "
f"Allowed values: {', '.join([v.value for v in ImageRefMode])}. "
"Optional, defaults to Embedded."
),
examples=[ImageRefMode.EMBEDDED.value],
# pattern="embedded|placeholder|referenced",
),
] = ImageRefMode.EMBEDDED
do_ocr: Annotated[
bool,
Field(
description=(
"If enabled, the bitmap content will be processed using OCR. "
"Boolean. Optional, defaults to true"
),
# examples=[True],
),
] = True
force_ocr: Annotated[
bool,
Field(
description=(
"If enabled, replace existing text with OCR-generated "
"text over content. Boolean. Optional, defaults to false."
),
# examples=[False],
),
] = False
# TODO: use a restricted list based on what is installed on the system
ocr_engine: Annotated[
OcrEngine,
class ConvertDocumentsRequestOptions(ConvertDocumentsOptions):
ocr_engine: Annotated[ # type: ignore
ocr_engines_enum,
Field(
description=(
"The OCR engine to use. String. "
"Allowed values: easyocr, tesseract, rapidocr. "
f"Allowed values: {', '.join([v.value for v in ocr_engines_enum])}. "
"Optional, defaults to easyocr."
),
examples=[OcrEngine.EASYOCR],
examples=[EasyOcrOptions.kind],
),
] = OcrEngine.EASYOCR
] = ocr_engines_enum(EasyOcrOptions.kind) # type: ignore
ocr_lang: Annotated[
Optional[list[str]],
Field(
description=(
"List of languages used by the OCR engine. "
"Note that each OCR engine has "
"different values for the language names. String or list of strings. "
"Optional, defaults to empty."
),
examples=[["fr", "de", "es", "en"]],
),
] = None
pdf_backend: Annotated[
PdfBackend,
Field(
description=(
"The PDF backend to use. String. "
f"Allowed values: {', '.join([v.value for v in PdfBackend])}. "
f"Optional, defaults to {PdfBackend.DLPARSE_V2.value}."
),
examples=[PdfBackend.DLPARSE_V2],
),
] = PdfBackend.DLPARSE_V2
table_mode: Annotated[
TableFormerMode,
Field(
TableFormerMode.FAST,
description=(
"Mode to use for table structure, String. "
f"Allowed values: {', '.join([v.value for v in TableFormerMode])}. "
"Optional, defaults to fast."
),
examples=[TableFormerMode.FAST],
# pattern="fast|accurate",
),
] = TableFormerMode.FAST
abort_on_error: Annotated[
bool,
Field(
description=(
"Abort on error if enabled. Boolean. Optional, defaults to false."
),
# examples=[False],
),
] = False
return_as_file: Annotated[
bool,
Field(
description=(
"Return the output as a zip file "
"(will happen anyway if multiple files are generated). "
"Boolean. Optional, defaults to false."
),
examples=[False],
),
] = False
do_table_structure: Annotated[
bool,
Field(
description=(
"If enabled, the table structure will be extracted. "
"Boolean. Optional, defaults to true."
),
examples=[True],
),
] = True
include_images: Annotated[
bool,
Field(
description=(
"If enabled, images will be extracted from the document. "
"Boolean. Optional, defaults to true."
),
examples=[True],
),
] = True
images_scale: Annotated[
document_timeout: Annotated[
float,
Field(
description="Scale factor for images. Float. Optional, defaults to 2.0.",
examples=[2.0],
description="The timeout for processing each document, in seconds.",
gt=0,
le=docling_serve_settings.max_document_timeout,
),
] = 2.0
do_code_enrichment: Annotated[
bool,
Field(
description=(
"If enabled, perform OCR code enrichment. "
"Boolean. Optional, defaults to false."
),
examples=[False],
),
] = False
do_formula_enrichment: Annotated[
bool,
Field(
description=(
"If enabled, perform formula OCR, return Latex code. "
"Boolean. Optional, defaults to false."
),
examples=[False],
),
] = False
do_picture_classification: Annotated[
bool,
Field(
description=(
"If enabled, classify pictures in documents. "
"Boolean. Optional, defaults to false."
),
examples=[False],
),
] = False
do_picture_description: Annotated[
bool,
Field(
description=(
"If enabled, describe pictures in documents. "
"Boolean. Optional, defaults to false."
),
examples=[False],
),
] = False
] = docling_serve_settings.max_document_timeout

View File

@@ -1,30 +0,0 @@
import enum
from typing import Optional
from pydantic import BaseModel
from docling_serve.datamodel.requests import ConvertDocumentsRequest
from docling_serve.datamodel.responses import ConvertDocumentResponse
class TaskStatus(str, enum.Enum):
SUCCESS = "success"
PENDING = "pending"
STARTED = "started"
FAILURE = "failure"
class AsyncEngine(str, enum.Enum):
LOCAL = "local"
class Task(BaseModel):
task_id: str
task_status: TaskStatus = TaskStatus.PENDING
request: Optional[ConvertDocumentsRequest]
result: Optional[ConvertDocumentResponse] = None
def is_completed(self) -> bool:
if self.task_status in [TaskStatus.SUCCESS, TaskStatus.FAILURE]:
return True
return False

View File

@@ -1,62 +1,130 @@
import base64
from io import BytesIO
from typing import Annotated, Any, Union
import enum
from functools import cache
from typing import Annotated, Generic, Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from pydantic_core import PydanticCustomError
from typing_extensions import Self, TypeVar
from docling.datamodel.base_models import DocumentStream
from docling_jobkit.datamodel.chunking import (
BaseChunkerOptions,
)
from docling_jobkit.datamodel.http_inputs import FileSource, HttpSource
from docling_jobkit.datamodel.s3_coords import S3Coordinates
from docling_jobkit.datamodel.task_targets import (
InBodyTarget,
PutTarget,
S3Target,
ZipTarget,
)
from docling_serve.datamodel.convert import ConvertDocumentsOptions
from docling_serve.datamodel.convert import ConvertDocumentsRequestOptions
from docling_serve.settings import AsyncEngine, docling_serve_settings
## Sources
class DocumentsConvertBase(BaseModel):
options: ConvertDocumentsOptions = ConvertDocumentsOptions()
class FileSourceRequest(FileSource):
kind: Literal["file"] = "file"
class HttpSource(BaseModel):
url: Annotated[
str,
Field(
description="HTTP url to process",
examples=["https://arxiv.org/pdf/2206.01062"],
),
]
headers: Annotated[
dict[str, Any],
Field(
description="Additional headers used to fetch the urls, "
"e.g. authorization, agent, etc"
),
] = {}
class HttpSourceRequest(HttpSource):
kind: Literal["http"] = "http"
class FileSource(BaseModel):
base64_string: Annotated[
str,
Field(
description="Content of the file serialized in base64. "
"For example it can be obtained via "
"`base64 -w 0 /path/to/file/pdf-to-convert.pdf`."
),
]
filename: Annotated[
str,
Field(description="Filename of the uploaded document", examples=["file.pdf"]),
]
def to_document_stream(self) -> DocumentStream:
buf = BytesIO(base64.b64decode(self.base64_string))
return DocumentStream(stream=buf, name=self.filename)
class S3SourceRequest(S3Coordinates):
kind: Literal["s3"] = "s3"
class ConvertDocumentHttpSourcesRequest(DocumentsConvertBase):
http_sources: list[HttpSource]
## Multipart targets
class TargetName(str, enum.Enum):
INBODY = InBodyTarget().kind
ZIP = ZipTarget().kind
class ConvertDocumentFileSourcesRequest(DocumentsConvertBase):
file_sources: list[FileSource]
ConvertDocumentsRequest = Union[
ConvertDocumentFileSourcesRequest, ConvertDocumentHttpSourcesRequest
## Aliases
SourceRequestItem = Annotated[
FileSourceRequest | HttpSourceRequest | S3SourceRequest, Field(discriminator="kind")
]
TargetRequest = Annotated[
InBodyTarget | ZipTarget | S3Target | PutTarget,
Field(discriminator="kind"),
]
## Complete Source request
class ConvertDocumentsRequest(BaseModel):
options: ConvertDocumentsRequestOptions = ConvertDocumentsRequestOptions()
sources: list[SourceRequestItem]
target: TargetRequest = InBodyTarget()
@model_validator(mode="after")
def validate_s3_source_and_target(self) -> Self:
for source in self.sources:
if isinstance(source, S3SourceRequest):
if docling_serve_settings.eng_kind != AsyncEngine.KFP:
raise PydanticCustomError(
"error source", 'source kind "s3" requires engine kind "KFP"'
)
if self.target.kind != "s3":
raise PydanticCustomError(
"error source", 'source kind "s3" requires target kind "s3"'
)
if isinstance(self.target, S3Target):
for source in self.sources:
if isinstance(source, S3SourceRequest):
return self
raise PydanticCustomError(
"error target", 'target kind "s3" requires source kind "s3"'
)
return self
## Source chunking requests
class BaseChunkDocumentsRequest(BaseModel):
convert_options: Annotated[
ConvertDocumentsRequestOptions, Field(description="Conversion options.")
] = ConvertDocumentsRequestOptions()
sources: Annotated[
list[SourceRequestItem],
Field(description="List of input document sources to process."),
]
include_converted_doc: Annotated[
bool,
Field(
description="If true, the output will include both the chunks and the converted document."
),
] = False
target: Annotated[
TargetRequest, Field(description="Specification for the type of output target.")
] = InBodyTarget()
ChunkingOptT = TypeVar("ChunkingOptT", bound=BaseChunkerOptions)
class GenericChunkDocumentsRequest(BaseChunkDocumentsRequest, Generic[ChunkingOptT]):
chunking_options: ChunkingOptT
@cache
def make_request_model(
opt_type: type[ChunkingOptT],
) -> type[GenericChunkDocumentsRequest[ChunkingOptT]]:
"""
Dynamically create (and cache) a subclass of GenericChunkDocumentsRequest[opt_type]
with chunking_options having a default factory.
"""
return type(
f"{opt_type.__name__}DocumentsRequest",
(GenericChunkDocumentsRequest[opt_type],), # type: ignore[valid-type]
{
"__annotations__": {"chunking_options": opt_type},
"chunking_options": Field(
default_factory=opt_type, description="Options specific to the chunker."
),
},
)

View File

@@ -5,7 +5,12 @@ from pydantic import BaseModel
from docling.datamodel.document import ConversionStatus, ErrorItem
from docling.utils.profiling import ProfilingItem
from docling_core.types.doc import DoclingDocument
from docling_jobkit.datamodel.result import (
ChunkedDocumentResultItem,
ExportDocumentResponse,
ExportResult,
)
from docling_jobkit.datamodel.task_meta import TaskProcessingMeta, TaskType
# Status
@@ -13,31 +18,41 @@ class HealthCheckResponse(BaseModel):
status: str = "ok"
class DocumentResponse(BaseModel):
filename: str
md_content: Optional[str] = None
json_content: Optional[DoclingDocument] = None
html_content: Optional[str] = None
text_content: Optional[str] = None
doctags_content: Optional[str] = None
class ClearResponse(BaseModel):
status: str = "ok"
class ConvertDocumentResponse(BaseModel):
document: DocumentResponse
document: ExportDocumentResponse
status: ConversionStatus
errors: list[ErrorItem] = []
processing_time: float
timings: dict[str, ProfilingItem] = {}
class PresignedUrlConvertDocumentResponse(BaseModel):
processing_time: float
num_converted: int
num_succeeded: int
num_failed: int
class ConvertDocumentErrorResponse(BaseModel):
status: ConversionStatus
class ChunkDocumentResponse(BaseModel):
chunks: list[ChunkedDocumentResultItem]
documents: list[ExportResult]
processing_time: float
class TaskStatusResponse(BaseModel):
task_id: str
task_type: TaskType
task_status: str
task_position: Optional[int] = None
task_meta: Optional[TaskProcessingMeta] = None
class MessageKind(str, enum.Enum):

View File

@@ -1,210 +0,0 @@
import hashlib
import json
import logging
from collections.abc import Iterable, Iterator
from functools import lru_cache
from pathlib import Path
from typing import Any, Optional, Union
from fastapi import HTTPException
from docling.backend.docling_parse_backend import DoclingParseDocumentBackend
from docling.backend.docling_parse_v2_backend import DoclingParseV2DocumentBackend
from docling.backend.pdf_backend import PdfDocumentBackend
from docling.backend.pypdfium2_backend import PyPdfiumDocumentBackend
from docling.datamodel.base_models import DocumentStream, InputFormat
from docling.datamodel.document import ConversionResult
from docling.datamodel.pipeline_options import (
EasyOcrOptions,
OcrEngine,
OcrOptions,
PdfBackend,
PdfPipelineOptions,
RapidOcrOptions,
TableFormerMode,
TesseractOcrOptions,
)
from docling.document_converter import DocumentConverter, FormatOption, PdfFormatOption
from docling_core.types.doc import ImageRefMode
from docling_serve.datamodel.convert import ConvertDocumentsOptions
from docling_serve.helper_functions import _to_list_of_strings
from docling_serve.settings import docling_serve_settings
_log = logging.getLogger(__name__)
# Custom serializer for PdfFormatOption
# (model_dump_json does not work with some classes)
def _hash_pdf_format_option(pdf_format_option: PdfFormatOption) -> bytes:
data = pdf_format_option.model_dump()
# pipeline_options are not fully serialized by model_dump, dedicated pass
if pdf_format_option.pipeline_options:
data["pipeline_options"] = pdf_format_option.pipeline_options.model_dump()
# Replace `artifacts_path` with a string representation
data["pipeline_options"]["artifacts_path"] = repr(
data["pipeline_options"]["artifacts_path"]
)
# Replace `pipeline_cls` with a string representation
data["pipeline_cls"] = repr(data["pipeline_cls"])
# Replace `backend` with a string representation
data["backend"] = repr(data["backend"])
# Handle `device` in `accelerator_options`
if "accelerator_options" in data and "device" in data["accelerator_options"]:
data["accelerator_options"]["device"] = repr(
data["accelerator_options"]["device"]
)
# Serialize the dictionary to JSON with sorted keys to have consistent hashes
serialized_data = json.dumps(data, sort_keys=True)
options_hash = hashlib.sha1(serialized_data.encode()).digest()
return options_hash
# Cache of DocumentConverter objects
_options_map: dict[bytes, PdfFormatOption] = {}
@lru_cache(maxsize=docling_serve_settings.options_cache_size)
def _get_converter_from_hash(options_hash: bytes) -> DocumentConverter:
pdf_format_option = _options_map[options_hash]
format_options: dict[InputFormat, FormatOption] = {
InputFormat.PDF: pdf_format_option,
InputFormat.IMAGE: pdf_format_option,
}
return DocumentConverter(format_options=format_options)
def get_converter(pdf_format_option: PdfFormatOption) -> DocumentConverter:
options_hash = _hash_pdf_format_option(pdf_format_option)
_options_map[options_hash] = pdf_format_option
return _get_converter_from_hash(options_hash)
# Computes the PDF pipeline options and returns the PdfFormatOption and its hash
def get_pdf_pipeline_opts( # noqa: C901
request: ConvertDocumentsOptions,
) -> PdfFormatOption:
if request.ocr_engine == OcrEngine.EASYOCR:
try:
import easyocr # noqa: F401
except ImportError:
raise HTTPException(
status_code=400,
detail="The requested OCR engine"
f" (ocr_engine={request.ocr_engine.value})"
" is not available on this system. Please choose another OCR engine "
"or contact your system administrator.",
)
ocr_options: OcrOptions = EasyOcrOptions(force_full_page_ocr=request.force_ocr)
elif request.ocr_engine == OcrEngine.TESSERACT:
try:
import tesserocr # noqa: F401
except ImportError:
raise HTTPException(
status_code=400,
detail="The requested OCR engine"
f" (ocr_engine={request.ocr_engine.value})"
" is not available on this system. Please choose another OCR engine "
"or contact your system administrator.",
)
ocr_options = TesseractOcrOptions(force_full_page_ocr=request.force_ocr)
elif request.ocr_engine == OcrEngine.RAPIDOCR:
try:
from rapidocr_onnxruntime import RapidOCR # noqa: F401
except ImportError:
raise HTTPException(
status_code=400,
detail="The requested OCR engine"
f" (ocr_engine={request.ocr_engine.value})"
" is not available on this system. Please choose another OCR engine "
"or contact your system administrator.",
)
ocr_options = RapidOcrOptions(force_full_page_ocr=request.force_ocr)
else:
raise RuntimeError(f"Unexpected OCR engine type {request.ocr_engine}")
if request.ocr_lang is not None:
if isinstance(request.ocr_lang, str):
ocr_options.lang = _to_list_of_strings(request.ocr_lang)
else:
ocr_options.lang = request.ocr_lang
pipeline_options = PdfPipelineOptions(
do_ocr=request.do_ocr,
ocr_options=ocr_options,
do_table_structure=request.do_table_structure,
do_code_enrichment=request.do_code_enrichment,
do_formula_enrichment=request.do_formula_enrichment,
do_picture_classification=request.do_picture_classification,
do_picture_description=request.do_picture_description,
)
pipeline_options.table_structure_options.do_cell_matching = True # do_cell_matching
pipeline_options.table_structure_options.mode = TableFormerMode(request.table_mode)
if request.image_export_mode != ImageRefMode.PLACEHOLDER:
pipeline_options.generate_page_images = True
if request.images_scale:
pipeline_options.images_scale = request.images_scale
if request.pdf_backend == PdfBackend.DLPARSE_V1:
backend: type[PdfDocumentBackend] = DoclingParseDocumentBackend
elif request.pdf_backend == PdfBackend.DLPARSE_V2:
backend = DoclingParseV2DocumentBackend
elif request.pdf_backend == PdfBackend.PYPDFIUM2:
backend = PyPdfiumDocumentBackend
else:
raise RuntimeError(f"Unexpected PDF backend type {request.pdf_backend}")
if docling_serve_settings.artifacts_path is not None:
if str(docling_serve_settings.artifacts_path.absolute()) == "":
_log.info(
"artifacts_path is an empty path, model weights will be dowloaded "
"at runtime."
)
pipeline_options.artifacts_path = None
elif docling_serve_settings.artifacts_path.is_dir():
_log.info(
"artifacts_path is set to a valid directory. "
"No model weights will be downloaded at runtime."
)
pipeline_options.artifacts_path = docling_serve_settings.artifacts_path
else:
_log.warning(
"artifacts_path is set to an invalid directory. "
"The system will download the model weights at runtime."
)
pipeline_options.artifacts_path = None
else:
_log.info(
"artifacts_path is unset. "
"The system will download the model weights at runtime."
)
pdf_format_option = PdfFormatOption(
pipeline_options=pipeline_options,
backend=backend,
)
return pdf_format_option
def convert_documents(
sources: Iterable[Union[Path, str, DocumentStream]],
options: ConvertDocumentsOptions,
headers: Optional[dict[str, Any]] = None,
):
pdf_format_option = get_pdf_pipeline_opts(options)
converter = get_converter(pdf_format_option)
results: Iterator[ConversionResult] = converter.convert_all(
sources,
headers=headers,
)
return results

View File

@@ -1,8 +0,0 @@
from functools import lru_cache
from docling_serve.engines.async_local.orchestrator import AsyncLocalOrchestrator
@lru_cache
def get_orchestrator() -> AsyncLocalOrchestrator:
return AsyncLocalOrchestrator()

View File

@@ -1,101 +0,0 @@
import asyncio
import logging
import uuid
from typing import Optional
from fastapi import WebSocket
from docling_serve.datamodel.engines import Task, TaskStatus
from docling_serve.datamodel.requests import ConvertDocumentsRequest
from docling_serve.datamodel.responses import (
MessageKind,
TaskStatusResponse,
WebsocketMessage,
)
from docling_serve.engines.async_local.worker import AsyncLocalWorker
from docling_serve.engines.base_orchestrator import BaseOrchestrator
from docling_serve.settings import docling_serve_settings
_log = logging.getLogger(__name__)
class OrchestratorError(Exception):
pass
class TaskNotFoundError(OrchestratorError):
pass
class AsyncLocalOrchestrator(BaseOrchestrator):
def __init__(self):
self.task_queue = asyncio.Queue()
self.tasks: dict[str, Task] = {}
self.queue_list: list[str] = []
self.task_subscribers: dict[str, set[WebSocket]] = {}
async def enqueue(self, request: ConvertDocumentsRequest) -> Task:
task_id = str(uuid.uuid4())
task = Task(task_id=task_id, request=request)
self.tasks[task_id] = task
self.queue_list.append(task_id)
self.task_subscribers[task_id] = set()
await self.task_queue.put(task_id)
return task
async def queue_size(self) -> int:
return self.task_queue.qsize()
async def get_queue_position(self, task_id: str) -> Optional[int]:
return (
self.queue_list.index(task_id) + 1 if task_id in self.queue_list else None
)
async def task_status(self, task_id: str, wait: float = 0.0) -> Task:
if task_id not in self.tasks:
raise TaskNotFoundError()
return self.tasks[task_id]
async def task_result(self, task_id: str):
if task_id not in self.tasks:
raise TaskNotFoundError()
return self.tasks[task_id].result
async def process_queue(self):
# Create a pool of workers
workers = []
for i in range(docling_serve_settings.eng_loc_num_workers):
_log.debug(f"Starting worker {i}")
w = AsyncLocalWorker(i, self)
worker_task = asyncio.create_task(w.loop())
workers.append(worker_task)
# Wait for all workers to complete (they won't, as they run indefinitely)
await asyncio.gather(*workers)
_log.debug("All workers completed.")
async def notify_task_subscribers(self, task_id: str):
if task_id not in self.task_subscribers:
raise RuntimeError(f"Task {task_id} does not have a subscribers list.")
task = self.tasks[task_id]
task_queue_position = await self.get_queue_position(task_id)
msg = TaskStatusResponse(
task_id=task.task_id,
task_status=task.task_status,
task_position=task_queue_position,
)
for websocket in self.task_subscribers[task_id]:
await websocket.send_text(
WebsocketMessage(message=MessageKind.UPDATE, task=msg).model_dump_json()
)
if task.is_completed():
await websocket.close()
async def notify_queue_positions(self):
for task_id in self.task_subscribers.keys():
# notify only pending tasks
if self.tasks[task_id].task_status != TaskStatus.PENDING:
continue
await self.notify_task_subscribers(task_id)

View File

@@ -1,116 +0,0 @@
import asyncio
import logging
import time
from typing import TYPE_CHECKING, Any, Optional, Union
from fastapi import BackgroundTasks
from docling.datamodel.base_models import DocumentStream
from docling_serve.datamodel.engines import TaskStatus
from docling_serve.datamodel.requests import ConvertDocumentFileSourcesRequest
from docling_serve.datamodel.responses import ConvertDocumentResponse
from docling_serve.docling_conversion import convert_documents
from docling_serve.response_preparation import process_results
if TYPE_CHECKING:
from docling_serve.engines.async_local.orchestrator import AsyncLocalOrchestrator
_log = logging.getLogger(__name__)
class AsyncLocalWorker:
def __init__(self, worker_id: int, orchestrator: "AsyncLocalOrchestrator"):
self.worker_id = worker_id
self.orchestrator = orchestrator
async def loop(self):
_log.debug(f"Starting loop for worker {self.worker_id}")
while True:
task_id: str = await self.orchestrator.task_queue.get()
self.orchestrator.queue_list.remove(task_id)
if task_id not in self.orchestrator.tasks:
raise RuntimeError(f"Task {task_id} not found.")
task = self.orchestrator.tasks[task_id]
try:
task.task_status = TaskStatus.STARTED
_log.info(f"Worker {self.worker_id} processing task {task_id}")
# Notify clients about task updates
await self.orchestrator.notify_task_subscribers(task_id)
# Notify clients about queue updates
await self.orchestrator.notify_queue_positions()
# Get the current event loop
asyncio.get_event_loop()
# Define a callback function to send progress updates to the client.
# TODO: send partial updates, e.g. when a document in the batch is done
def run_conversion():
sources: list[Union[str, DocumentStream]] = []
headers: Optional[dict[str, Any]] = None
if isinstance(task.request, ConvertDocumentFileSourcesRequest):
for file_source in task.request.file_sources:
sources.append(file_source.to_document_stream())
else:
for http_source in task.request.http_sources:
sources.append(http_source.url)
if headers is None and http_source.headers:
headers = http_source.headers
# Note: results are only an iterator->lazy evaluation
results = convert_documents(
sources=sources,
options=task.request.options,
headers=headers,
)
# The real processing will happen here
response = process_results(
background_tasks=BackgroundTasks(),
conversion_options=task.request.options,
conv_results=results,
)
return response
# Run the prediction in a thread to avoid blocking the event loop.
start_time = time.monotonic()
# future = asyncio.run_coroutine_threadsafe(
# run_conversion(),
# loop=loop
# )
# response = future.result()
response = await asyncio.to_thread(
run_conversion,
)
processing_time = time.monotonic() - start_time
if not isinstance(response, ConvertDocumentResponse):
_log.error(
f"Worker {self.worker_id} got un-processable "
"result for {task_id}: {type(response)}"
)
task.result = response
task.request = None
task.task_status = TaskStatus.SUCCESS
_log.info(
f"Worker {self.worker_id} completed job {task_id} "
f"in {processing_time:.2f} seconds"
)
except Exception as e:
_log.error(
f"Worker {self.worker_id} failed to process job {task_id}: {e}"
)
task.task_status = TaskStatus.FAILURE
finally:
await self.orchestrator.notify_task_subscribers(task_id)
self.orchestrator.task_queue.task_done()
_log.debug(f"Worker {self.worker_id} completely done with {task_id}")

View File

@@ -1,21 +0,0 @@
from abc import ABC, abstractmethod
from docling_serve.datamodel.engines import Task
class BaseOrchestrator(ABC):
@abstractmethod
async def enqueue(self, task) -> Task:
pass
@abstractmethod
async def queue_size(self) -> int:
pass
@abstractmethod
async def task_status(self, task_id: str) -> Task:
pass
@abstractmethod
async def task_result(self, task_id: str):
pass

View File

@@ -1,707 +0,0 @@
import importlib
import json
import logging
import tempfile
from pathlib import Path
import gradio as gr
import requests
from docling_serve.helper_functions import _to_list_of_strings
from docling_serve.settings import uvicorn_settings
logger = logging.getLogger(__name__)
##############################
# Head JS for web components #
##############################
head = """
<script src="https://unpkg.com/@docling/docling-components@0.0.3" type="module"></script>
"""
#################
# CSS and theme #
#################
css = """
#logo {
border-style: none;
background: none;
box-shadow: none;
min-width: 80px;
}
#dark_mode_column {
display: flex;
align-content: flex-end;
}
#title {
text-align: left;
display:block;
height: auto;
padding-top: 5px;
line-height: 0;
}
.title-text h1 > p, .title-text p {
margin-top: 0px !important;
margin-bottom: 2px !important;
}
#custom-container {
border: 0.909091px solid;
padding: 10px;
border-radius: 4px;
}
#custom-container h4 {
font-size: 14px;
}
#file_input_zone {
height: 140px;
}
docling-img::part(pages) {
gap: 1rem;
}
docling-img::part(page) {
box-shadow: 0 0.5rem 1rem 0 rgba(0, 0, 0, 0.2);
}
"""
theme = gr.themes.Default(
text_size="md",
spacing_size="md",
font=[
gr.themes.GoogleFont("Red Hat Display"),
"ui-sans-serif",
"system-ui",
"sans-serif",
],
font_mono=[
gr.themes.GoogleFont("Red Hat Mono"),
"ui-monospace",
"Consolas",
"monospace",
],
)
#############
# Variables #
#############
gradio_output_dir = None # Will be set by FastAPI when mounted
file_output_path = None # Will be set when a new file is generated
#############
# Functions #
#############
def health_check():
response = requests.get(f"http://localhost:{uvicorn_settings.port}/health")
if response.status_code == 200:
return "Healthy"
return "Unhealthy"
def set_options_visibility(x):
return gr.Accordion("Options", open=x)
def set_outputs_visibility_direct(x, y):
content = gr.Row(visible=x)
file = gr.Row(visible=y)
return content, file
def set_outputs_visibility_process(x):
content = gr.Row(visible=not x)
file = gr.Row(visible=x)
return content, file
def set_download_button_label(label_text: gr.State):
return gr.DownloadButton(label=str(label_text), scale=1)
def clear_outputs():
markdown_content = ""
json_content = ""
json_rendered_content = ""
html_content = ""
text_content = ""
doctags_content = ""
return (
markdown_content,
markdown_content,
json_content,
json_rendered_content,
html_content,
html_content,
text_content,
doctags_content,
)
def clear_url_input():
return ""
def clear_file_input():
return None
def auto_set_return_as_file(url_input, file_input, image_export_mode):
# If more than one input source is provided, return as file
if (
(len(url_input.split(",")) > 1)
or (file_input and len(file_input) > 1)
or (image_export_mode == "referenced")
):
return True
else:
return False
def change_ocr_lang(ocr_engine):
if ocr_engine == "easyocr":
return "en,fr,de,es"
elif ocr_engine == "tesseract_cli":
return "eng,fra,deu,spa"
elif ocr_engine == "tesseract":
return "eng,fra,deu,spa"
elif ocr_engine == "rapidocr":
return "english,chinese"
def process_url(
input_sources,
to_formats,
image_export_mode,
ocr,
force_ocr,
ocr_engine,
ocr_lang,
pdf_backend,
table_mode,
abort_on_error,
return_as_file,
do_code_enrichment,
do_formula_enrichment,
do_picture_classification,
do_picture_description,
):
parameters = {
"http_sources": [{"url": source} for source in input_sources.split(",")],
"options": {
"to_formats": to_formats,
"image_export_mode": image_export_mode,
"ocr": ocr,
"force_ocr": force_ocr,
"ocr_engine": ocr_engine,
"ocr_lang": _to_list_of_strings(ocr_lang),
"pdf_backend": pdf_backend,
"table_mode": table_mode,
"abort_on_error": abort_on_error,
"return_as_file": return_as_file,
"do_code_enrichment": do_code_enrichment,
"do_formula_enrichment": do_formula_enrichment,
"do_picture_classification": do_picture_classification,
"do_picture_description": do_picture_description,
},
}
if (
not parameters["http_sources"]
or len(parameters["http_sources"]) == 0
or parameters["http_sources"][0]["url"] == ""
):
logger.error("No input sources provided.")
raise gr.Error("No input sources provided.", print_exception=False)
try:
response = requests.post(
f"http://localhost:{uvicorn_settings.port}/v1alpha/convert/source",
json=parameters,
)
except Exception as e:
logger.error(f"Error processing URL: {e}")
raise gr.Error(f"Error processing URL: {e}", print_exception=False)
if response.status_code != 200:
data = response.json()
error_message = data.get("detail", "An unknown error occurred.")
logger.error(f"Error processing file: {error_message}")
raise gr.Error(f"Error processing file: {error_message}", print_exception=False)
output = response_to_output(response, return_as_file)
return output
def process_file(
files,
to_formats,
image_export_mode,
ocr,
force_ocr,
ocr_engine,
ocr_lang,
pdf_backend,
table_mode,
abort_on_error,
return_as_file,
do_code_enrichment,
do_formula_enrichment,
do_picture_classification,
do_picture_description,
):
if not files or len(files) == 0 or files[0] == "":
logger.error("No files provided.")
raise gr.Error("No files provided.", print_exception=False)
files_data = [("files", (file.name, open(file.name, "rb"))) for file in files]
parameters = {
"to_formats": to_formats,
"image_export_mode": image_export_mode,
"ocr": str(ocr).lower(),
"force_ocr": str(force_ocr).lower(),
"ocr_engine": ocr_engine,
"ocr_lang": _to_list_of_strings(ocr_lang),
"pdf_backend": pdf_backend,
"table_mode": table_mode,
"abort_on_error": str(abort_on_error).lower(),
"return_as_file": str(return_as_file).lower(),
"do_code_enrichment": str(do_code_enrichment).lower(),
"do_formula_enrichment": str(do_formula_enrichment).lower(),
"do_picture_classification": str(do_picture_classification).lower(),
"do_picture_description": str(do_picture_description).lower(),
}
try:
response = requests.post(
f"http://localhost:{uvicorn_settings.port}/v1alpha/convert/file",
files=files_data,
data=parameters,
)
except Exception as e:
logger.error(f"Error processing file(s): {e}")
raise gr.Error(f"Error processing file(s): {e}", print_exception=False)
if response.status_code != 200:
data = response.json()
error_message = data.get("detail", "An unknown error occurred.")
logger.error(f"Error processing file: {error_message}")
raise gr.Error(f"Error processing file: {error_message}", print_exception=False)
output = response_to_output(response, return_as_file)
return output
def response_to_output(response, return_as_file):
markdown_content = ""
json_content = ""
json_rendered_content = ""
html_content = ""
text_content = ""
doctags_content = ""
download_button = gr.DownloadButton(visible=False, label="Download Output", scale=1)
if return_as_file:
filename = (
response.headers.get("Content-Disposition").split("filename=")[1].strip('"')
)
tmp_output_dir = Path(tempfile.mkdtemp(dir=gradio_output_dir, prefix="ui_"))
file_output_path = f"{tmp_output_dir}/{filename}"
# logger.info(f"Saving file to: {file_output_path}")
with open(file_output_path, "wb") as f:
f.write(response.content)
download_button = gr.DownloadButton(
visible=True, label=f"Download {filename}", scale=1, value=file_output_path
)
else:
full_content = response.json()
markdown_content = full_content.get("document").get("md_content")
json_content = json.dumps(
full_content.get("document").get("json_content"), indent=2
)
# Embed document JSON and trigger load at client via an image.
json_rendered_content = f"""
<docling-img id="dclimg" pagenumbers tooltip="parsed"></docling-img>
<script id="dcljson" type="application/json" onload="document.getElementById('dclimg').src = JSON.parse(document.getElementById('dcljson').textContent);">{json_content}</script>
<img src onerror="document.getElementById('dclimg').src = JSON.parse(document.getElementById('dcljson').textContent);" />
"""
html_content = full_content.get("document").get("html_content")
text_content = full_content.get("document").get("text_content")
doctags_content = full_content.get("document").get("doctags_content")
return (
markdown_content,
markdown_content,
json_content,
json_rendered_content,
html_content,
html_content,
text_content,
doctags_content,
download_button,
)
############
# UI Setup #
############
with gr.Blocks(
head=head,
css=css,
theme=theme,
title="Docling Serve",
delete_cache=(3600, 3600), # Delete all files older than 1 hour every hour
) as ui:
# Constants stored in states to be able to pass them as inputs to functions
processing_text = gr.State("Processing your document(s), please wait...")
true_bool = gr.State(True)
false_bool = gr.State(False)
# Banner
with gr.Row(elem_id="check_health"):
# Logo
with gr.Column(scale=1, min_width=90):
try:
gr.Image(
"https://raw.githubusercontent.com/docling-project/docling/refs/heads/main/docs/assets/logo.svg",
height=80,
width=80,
show_download_button=False,
show_label=False,
show_fullscreen_button=False,
container=False,
elem_id="logo",
scale=0,
)
except Exception:
logger.warning("Logo not found.")
# Title
with gr.Column(scale=1, min_width=200):
gr.Markdown(
f"# Docling Serve \n(docling version: "
f"{importlib.metadata.version('docling')})",
elem_id="title",
elem_classes=["title-text"],
)
# Dark mode button
with gr.Column(scale=16, elem_id="dark_mode_column"):
dark_mode_btn = gr.Button("Dark/Light Mode", scale=0)
dark_mode_btn.click(
None,
None,
None,
js="""() => {
if (document.querySelectorAll('.dark').length) {
document.querySelectorAll('.dark').forEach(
el => el.classList.remove('dark')
);
} else {
document.querySelector('body').classList.add('dark');
}
}""",
show_api=False,
)
# URL Processing Tab
with gr.Tab("Convert URL(s)"):
with gr.Row():
with gr.Column(scale=4):
url_input = gr.Textbox(
label="Input Sources (comma-separated URLs)",
placeholder="https://arxiv.org/pdf/2206.01062",
)
with gr.Column(scale=1):
url_process_btn = gr.Button("Process URL(s)", scale=1)
url_reset_btn = gr.Button("Reset", scale=1)
# File Processing Tab
with gr.Tab("Convert File(s)"):
with gr.Row():
with gr.Column(scale=4):
file_input = gr.File(
elem_id="file_input_zone",
label="Upload Files",
file_types=[
".pdf",
".docx",
".pptx",
".html",
".xlsx",
".asciidoc",
".txt",
".md",
".jpg",
".jpeg",
".png",
".gif",
],
file_count="multiple",
scale=4,
)
with gr.Column(scale=1):
file_process_btn = gr.Button("Process File(s)", scale=1)
file_reset_btn = gr.Button("Reset", scale=1)
# Options
with gr.Accordion("Options") as options:
with gr.Row():
with gr.Column(scale=1):
to_formats = gr.CheckboxGroup(
[
("Markdown", "md"),
("Docling (JSON)", "json"),
("HTML", "html"),
("Plain Text", "text"),
("Doc Tags", "doctags"),
],
label="To Formats",
value=["md"],
)
with gr.Column(scale=1):
image_export_mode = gr.Radio(
[
("Embedded", "embedded"),
("Placeholder", "placeholder"),
("Referenced", "referenced"),
],
label="Image Export Mode",
value="embedded",
)
with gr.Row():
with gr.Column(scale=1, min_width=200):
ocr = gr.Checkbox(label="Enable OCR", value=True)
force_ocr = gr.Checkbox(label="Force OCR", value=False)
with gr.Column(scale=1):
ocr_engine = gr.Radio(
[
("EasyOCR", "easyocr"),
("Tesseract", "tesseract"),
("RapidOCR", "rapidocr"),
],
label="OCR Engine",
value="easyocr",
)
with gr.Column(scale=1, min_width=200):
ocr_lang = gr.Textbox(
label="OCR Language (beware of the format)", value="en,fr,de,es"
)
ocr_engine.change(change_ocr_lang, inputs=[ocr_engine], outputs=[ocr_lang])
with gr.Row():
with gr.Column(scale=2):
pdf_backend = gr.Radio(
["pypdfium2", "dlparse_v1", "dlparse_v2"],
label="PDF Backend",
value="dlparse_v2",
)
with gr.Column(scale=2):
table_mode = gr.Radio(
["fast", "accurate"], label="Table Mode", value="fast"
)
with gr.Column(scale=1):
abort_on_error = gr.Checkbox(label="Abort on Error", value=False)
return_as_file = gr.Checkbox(label="Return as File", value=False)
with gr.Row():
with gr.Column():
do_code_enrichment = gr.Checkbox(
label="Enable code enrichment", value=False
)
do_formula_enrichment = gr.Checkbox(
label="Enable formula enrichment", value=False
)
with gr.Column():
do_picture_classification = gr.Checkbox(
label="Enable picture classification", value=False
)
do_picture_description = gr.Checkbox(
label="Enable picture description", value=False
)
# Document output
with gr.Row(visible=False) as content_output:
with gr.Tab("Markdown"):
output_markdown = gr.Code(
language="markdown", wrap_lines=True, show_label=False
)
with gr.Tab("Markdown-Rendered"):
output_markdown_rendered = gr.Markdown(label="Response")
with gr.Tab("Docling (JSON)"):
output_json = gr.Code(language="json", wrap_lines=True, show_label=False)
with gr.Tab("Docling-Rendered"):
output_json_rendered = gr.HTML()
with gr.Tab("HTML"):
output_html = gr.Code(language="html", wrap_lines=True, show_label=False)
with gr.Tab("HTML-Rendered"):
output_html_rendered = gr.HTML(label="Response")
with gr.Tab("Text"):
output_text = gr.Code(wrap_lines=True, show_label=False)
with gr.Tab("DocTags"):
output_doctags = gr.Code(wrap_lines=True, show_label=False)
# File download output
with gr.Row(visible=False) as file_output:
download_file_btn = gr.DownloadButton(label="Placeholder", scale=1)
##############
# UI Actions #
##############
# Handle Return as File
url_input.change(
auto_set_return_as_file,
inputs=[url_input, file_input, image_export_mode],
outputs=[return_as_file],
)
file_input.change(
auto_set_return_as_file,
inputs=[url_input, file_input, image_export_mode],
outputs=[return_as_file],
)
image_export_mode.change(
auto_set_return_as_file,
inputs=[url_input, file_input, image_export_mode],
outputs=[return_as_file],
)
# URL processing
url_process_btn.click(
set_options_visibility, inputs=[false_bool], outputs=[options]
).then(
set_download_button_label, inputs=[processing_text], outputs=[download_file_btn]
).then(
set_outputs_visibility_process,
inputs=[return_as_file],
outputs=[content_output, file_output],
).then(
clear_outputs,
inputs=None,
outputs=[
output_markdown,
output_markdown_rendered,
output_json,
output_json_rendered,
output_html,
output_html_rendered,
output_text,
output_doctags,
],
).then(
process_url,
inputs=[
url_input,
to_formats,
image_export_mode,
ocr,
force_ocr,
ocr_engine,
ocr_lang,
pdf_backend,
table_mode,
abort_on_error,
return_as_file,
do_code_enrichment,
do_formula_enrichment,
do_picture_classification,
do_picture_description,
],
outputs=[
output_markdown,
output_markdown_rendered,
output_json,
output_json_rendered,
output_html,
output_html_rendered,
output_text,
output_doctags,
download_file_btn,
],
)
url_reset_btn.click(
clear_outputs,
inputs=None,
outputs=[
output_markdown,
output_markdown_rendered,
output_json,
output_json_rendered,
output_html,
output_html_rendered,
output_text,
output_doctags,
],
).then(set_options_visibility, inputs=[true_bool], outputs=[options]).then(
set_outputs_visibility_direct,
inputs=[false_bool, false_bool],
outputs=[content_output, file_output],
).then(clear_url_input, inputs=None, outputs=[url_input])
# File processing
file_process_btn.click(
set_options_visibility, inputs=[false_bool], outputs=[options]
).then(
set_download_button_label, inputs=[processing_text], outputs=[download_file_btn]
).then(
set_outputs_visibility_process,
inputs=[return_as_file],
outputs=[content_output, file_output],
).then(
clear_outputs,
inputs=None,
outputs=[
output_markdown,
output_markdown_rendered,
output_json,
output_json_rendered,
output_html,
output_html_rendered,
output_text,
output_doctags,
],
).then(
process_file,
inputs=[
file_input,
to_formats,
image_export_mode,
ocr,
force_ocr,
ocr_engine,
ocr_lang,
pdf_backend,
table_mode,
abort_on_error,
return_as_file,
do_code_enrichment,
do_formula_enrichment,
do_picture_classification,
do_picture_description,
],
outputs=[
output_markdown,
output_markdown_rendered,
output_json,
output_json_rendered,
output_html,
output_html_rendered,
output_text,
output_doctags,
download_file_btn,
],
)
file_reset_btn.click(
clear_outputs,
inputs=None,
outputs=[
output_markdown,
output_markdown_rendered,
output_json,
output_json_rendered,
output_html,
output_html_rendered,
output_text,
output_doctags,
],
).then(set_options_visibility, inputs=[true_bool], outputs=[options]).then(
set_outputs_visibility_direct,
inputs=[false_bool, false_bool],
outputs=[content_output, file_output],
).then(clear_file_input, inputs=None, outputs=[file_input])

View File

@@ -1,36 +1,117 @@
import importlib.metadata
import inspect
import json
import platform
import re
from typing import Union
import sys
from typing import Union, get_args, get_origin
from fastapi import Depends, Form
from pydantic import BaseModel
from pydantic import BaseModel, TypeAdapter
DOCLING_VERSIONS = {
"docling-serve": importlib.metadata.version("docling-serve"),
"docling-jobkit": importlib.metadata.version("docling-jobkit"),
"docling": importlib.metadata.version("docling"),
"docling-core": importlib.metadata.version("docling-core"),
"docling-ibm-models": importlib.metadata.version("docling-ibm-models"),
"docling-parse": importlib.metadata.version("docling-parse"),
"python": f"{sys.implementation.cache_tag} ({platform.python_version()})",
"plaform": platform.platform(),
}
def is_pydantic_model(type_):
try:
if inspect.isclass(type_) and issubclass(type_, BaseModel):
return True
origin = get_origin(type_)
if origin is Union:
args = get_args(type_)
return any(
inspect.isclass(arg) and issubclass(arg, BaseModel)
for arg in args
if arg is not type(None)
)
except Exception:
pass
return False
# Adapted from
# https://github.com/fastapi/fastapi/discussions/8971#discussioncomment-7892972
def FormDepends(cls: type[BaseModel]):
def FormDepends(
cls: type[BaseModel], prefix: str = "", excluded_fields: list[str] = []
):
new_parameters = []
for field_name, model_field in cls.model_fields.items():
if field_name in excluded_fields:
continue
annotation = model_field.annotation
description = model_field.description
default = (
Form(..., description=description, examples=model_field.examples)
if model_field.is_required()
else Form(
model_field.default,
examples=model_field.examples,
description=description,
)
)
# Flatten nested Pydantic models by accepting them as JSON strings
if is_pydantic_model(annotation):
annotation = str
default = Form(
None
if model_field.default is None
else json.dumps(model_field.default.model_dump(mode="json")),
description=description,
examples=None
if not model_field.examples
else [
json.dumps(ex.model_dump(mode="json"))
for ex in model_field.examples
],
)
new_parameters.append(
inspect.Parameter(
name=field_name,
name=f"{prefix}{field_name}",
kind=inspect.Parameter.POSITIONAL_ONLY,
default=(
Form(...)
if model_field.is_required()
else Form(model_field.default)
),
annotation=model_field.annotation,
default=default,
annotation=annotation,
)
)
async def as_form_func(**data):
return cls(**data)
newdata = {}
for field_name, model_field in cls.model_fields.items():
if field_name in excluded_fields:
continue
value = data.get(f"{prefix}{field_name}")
newdata[field_name] = value
annotation = model_field.annotation
# Parse nested models from JSON string
if value is not None and is_pydantic_model(annotation):
try:
validator = TypeAdapter(annotation)
newdata[field_name] = validator.validate_json(value)
except Exception as e:
raise ValueError(f"Invalid JSON for field '{field_name}': {e}")
return cls(**newdata)
sig = inspect.signature(as_form_func)
sig = sig.replace(parameters=new_parameters)
as_form_func.__signature__ = sig # type: ignore
return Depends(as_form_func)

View File

@@ -0,0 +1,336 @@
import json
import logging
from functools import lru_cache
from typing import Any, Optional
import redis.asyncio as redis
from docling_jobkit.datamodel.task import Task
from docling_jobkit.datamodel.task_meta import TaskStatus
from docling_jobkit.orchestrators.base_orchestrator import (
BaseOrchestrator,
TaskNotFoundError,
)
from docling_serve.settings import AsyncEngine, docling_serve_settings
from docling_serve.storage import get_scratch
_log = logging.getLogger(__name__)
class RedisTaskStatusMixin:
tasks: dict[str, Task]
_task_result_keys: dict[str, str]
config: Any
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.redis_prefix = "docling:tasks:"
self._redis_pool = redis.ConnectionPool.from_url(
self.config.redis_url,
max_connections=10,
socket_timeout=2.0,
)
async def task_status(self, task_id: str, wait: float = 0.0) -> Task:
"""
Get task status by checking Redis first, then falling back to RQ verification.
When Redis shows 'pending' but RQ shows 'success', we update Redis
and return the RQ status for cross-instance consistency.
"""
_log.info(f"Task {task_id} status check")
# Always check RQ directly first - this is the most reliable source
rq_task = await self._get_task_from_rq_direct(task_id)
if rq_task:
_log.info(f"Task {task_id} in RQ: {rq_task.task_status}")
# Update memory registry
self.tasks[task_id] = rq_task
# Store/update in Redis for other instances
await self._store_task_in_redis(rq_task)
return rq_task
# If not in RQ, check Redis (maybe it's cached from another instance)
task = await self._get_task_from_redis(task_id)
if task:
_log.info(f"Task {task_id} in Redis: {task.task_status}")
# CRITICAL FIX: Check if Redis status might be stale
# STARTED tasks might have completed since they were cached
if task.task_status in [TaskStatus.PENDING, TaskStatus.STARTED]:
_log.debug(f"Task {task_id} verifying stale status")
# Try to get fresh status from RQ
fresh_rq_task = await self._get_task_from_rq_direct(task_id)
if fresh_rq_task and fresh_rq_task.task_status != task.task_status:
_log.info(
f"Task {task_id} status updated: {fresh_rq_task.task_status}"
)
# Update memory and Redis with fresh status
self.tasks[task_id] = fresh_rq_task
await self._store_task_in_redis(fresh_rq_task)
return fresh_rq_task
else:
_log.debug(f"Task {task_id} status consistent")
return task
# Fall back to parent implementation
try:
parent_task = await super().task_status(task_id, wait) # type: ignore[misc]
_log.debug(f"Task {task_id} from parent: {parent_task.task_status}")
# Store in Redis for other instances to find
await self._store_task_in_redis(parent_task)
return parent_task
except TaskNotFoundError:
_log.warning(f"Task {task_id} not found")
raise
async def _get_task_from_redis(self, task_id: str) -> Optional[Task]:
try:
async with redis.Redis(connection_pool=self._redis_pool) as r:
task_data = await r.get(f"{self.redis_prefix}{task_id}:metadata")
if not task_data:
return None
data: dict[str, Any] = json.loads(task_data)
meta = data.get("processing_meta") or {}
meta.setdefault("num_docs", 0)
meta.setdefault("num_processed", 0)
meta.setdefault("num_succeeded", 0)
meta.setdefault("num_failed", 0)
return Task(
task_id=data["task_id"],
task_type=data["task_type"],
task_status=TaskStatus(data["task_status"]),
processing_meta=meta,
)
except Exception as e:
_log.error(f"Redis get task {task_id}: {e}")
return None
async def _get_task_from_rq_direct(self, task_id: str) -> Optional[Task]:
try:
_log.debug(f"Checking RQ for task {task_id}")
temp_task = Task(
task_id=task_id,
task_type="convert",
task_status=TaskStatus.PENDING,
processing_meta={
"num_docs": 0,
"num_processed": 0,
"num_succeeded": 0,
"num_failed": 0,
},
)
original_task = self.tasks.get(task_id)
self.tasks[task_id] = temp_task
try:
await super()._update_task_from_rq(task_id) # type: ignore[misc]
updated_task = self.tasks.get(task_id)
if updated_task and updated_task.task_status != TaskStatus.PENDING:
_log.debug(f"RQ task {task_id}: {updated_task.task_status}")
# Store result key if available
if task_id in self._task_result_keys:
try:
async with redis.Redis(
connection_pool=self._redis_pool
) as r:
await r.set(
f"{self.redis_prefix}{task_id}:result_key",
self._task_result_keys[task_id],
ex=86400,
)
_log.debug(f"Stored result key for {task_id}")
except Exception as e:
_log.error(f"Store result key {task_id}: {e}")
return updated_task
return None
finally:
# Restore original task state
if original_task:
self.tasks[task_id] = original_task
elif task_id in self.tasks and self.tasks[task_id] == temp_task:
# Only remove if it's still our temp task
del self.tasks[task_id]
except Exception as e:
_log.error(f"RQ check {task_id}: {e}")
return None
async def get_raw_task(self, task_id: str) -> Task:
if task_id in self.tasks:
return self.tasks[task_id]
task = await self._get_task_from_redis(task_id)
if task:
self.tasks[task_id] = task
return task
try:
parent_task = await super().get_raw_task(task_id) # type: ignore[misc]
await self._store_task_in_redis(parent_task)
return parent_task
except TaskNotFoundError:
raise
async def _store_task_in_redis(self, task: Task) -> None:
try:
meta: Any = task.processing_meta
if hasattr(meta, "model_dump"):
meta = meta.model_dump()
elif not isinstance(meta, dict):
meta = {
"num_docs": 0,
"num_processed": 0,
"num_succeeded": 0,
"num_failed": 0,
}
data: dict[str, Any] = {
"task_id": task.task_id,
"task_type": task.task_type.value
if hasattr(task.task_type, "value")
else str(task.task_type),
"task_status": task.task_status.value,
"processing_meta": meta,
}
async with redis.Redis(connection_pool=self._redis_pool) as r:
await r.set(
f"{self.redis_prefix}{task.task_id}:metadata",
json.dumps(data),
ex=86400,
)
except Exception as e:
_log.error(f"Store task {task.task_id}: {e}")
async def enqueue(self, **kwargs): # type: ignore[override]
task = await super().enqueue(**kwargs) # type: ignore[misc]
await self._store_task_in_redis(task)
return task
async def task_result(self, task_id: str): # type: ignore[override]
result = await super().task_result(task_id) # type: ignore[misc]
if result is not None:
return result
try:
async with redis.Redis(connection_pool=self._redis_pool) as r:
result_key = await r.get(f"{self.redis_prefix}{task_id}:result_key")
if result_key:
self._task_result_keys[task_id] = result_key.decode("utf-8")
return await super().task_result(task_id) # type: ignore[misc]
except Exception as e:
_log.error(f"Redis result key {task_id}: {e}")
return None
async def _update_task_from_rq(self, task_id: str) -> None:
original_status = (
self.tasks[task_id].task_status if task_id in self.tasks else None
)
await super()._update_task_from_rq(task_id) # type: ignore[misc]
if task_id in self.tasks:
new_status = self.tasks[task_id].task_status
if original_status != new_status:
_log.debug(f"Task {task_id} status: {original_status} -> {new_status}")
await self._store_task_in_redis(self.tasks[task_id])
if task_id in self._task_result_keys:
try:
async with redis.Redis(connection_pool=self._redis_pool) as r:
await r.set(
f"{self.redis_prefix}{task_id}:result_key",
self._task_result_keys[task_id],
ex=86400,
)
except Exception as e:
_log.error(f"Store result key {task_id}: {e}")
@lru_cache
def get_async_orchestrator() -> BaseOrchestrator:
if docling_serve_settings.eng_kind == AsyncEngine.LOCAL:
from docling_jobkit.convert.manager import (
DoclingConverterManager,
DoclingConverterManagerConfig,
)
from docling_jobkit.orchestrators.local.orchestrator import (
LocalOrchestrator,
LocalOrchestratorConfig,
)
local_config = LocalOrchestratorConfig(
num_workers=docling_serve_settings.eng_loc_num_workers,
shared_models=docling_serve_settings.eng_loc_share_models,
scratch_dir=get_scratch(),
)
cm_config = DoclingConverterManagerConfig(
artifacts_path=docling_serve_settings.artifacts_path,
options_cache_size=docling_serve_settings.options_cache_size,
enable_remote_services=docling_serve_settings.enable_remote_services,
allow_external_plugins=docling_serve_settings.allow_external_plugins,
max_num_pages=docling_serve_settings.max_num_pages,
max_file_size=docling_serve_settings.max_file_size,
queue_max_size=docling_serve_settings.queue_max_size,
ocr_batch_size=docling_serve_settings.ocr_batch_size,
layout_batch_size=docling_serve_settings.layout_batch_size,
table_batch_size=docling_serve_settings.table_batch_size,
batch_polling_interval_seconds=docling_serve_settings.batch_polling_interval_seconds,
)
cm = DoclingConverterManager(config=cm_config)
return LocalOrchestrator(config=local_config, converter_manager=cm)
elif docling_serve_settings.eng_kind == AsyncEngine.RQ:
from docling_jobkit.orchestrators.rq.orchestrator import (
RQOrchestrator,
RQOrchestratorConfig,
)
class RedisAwareRQOrchestrator(RedisTaskStatusMixin, RQOrchestrator): # type: ignore[misc]
pass
rq_config = RQOrchestratorConfig(
redis_url=docling_serve_settings.eng_rq_redis_url,
results_prefix=docling_serve_settings.eng_rq_results_prefix,
sub_channel=docling_serve_settings.eng_rq_sub_channel,
scratch_dir=get_scratch(),
)
return RedisAwareRQOrchestrator(config=rq_config)
elif docling_serve_settings.eng_kind == AsyncEngine.KFP:
from docling_jobkit.orchestrators.kfp.orchestrator import (
KfpOrchestrator,
KfpOrchestratorConfig,
)
kfp_config = KfpOrchestratorConfig(
endpoint=docling_serve_settings.eng_kfp_endpoint,
token=docling_serve_settings.eng_kfp_token,
ca_cert_path=docling_serve_settings.eng_kfp_ca_cert_path,
self_callback_endpoint=docling_serve_settings.eng_kfp_self_callback_endpoint,
self_callback_token_path=docling_serve_settings.eng_kfp_self_callback_token_path,
self_callback_ca_cert_path=docling_serve_settings.eng_kfp_self_callback_ca_cert_path,
)
return KfpOrchestrator(config=kfp_config)
raise RuntimeError(f"Engine {docling_serve_settings.eng_kind} not recognized.")

View File

@@ -1,225 +1,82 @@
import asyncio
import logging
import os
import shutil
import tempfile
import time
from collections.abc import Iterable
from pathlib import Path
from typing import Union
from fastapi import BackgroundTasks, HTTPException
from fastapi.responses import FileResponse
from fastapi import BackgroundTasks, Response
from docling.datamodel.base_models import OutputFormat
from docling.datamodel.document import ConversionResult, ConversionStatus
from docling_core.types.doc import ImageRefMode
from docling_jobkit.datamodel.result import (
ChunkedDocumentResult,
DoclingTaskResult,
ExportResult,
RemoteTargetResult,
ZipArchiveResult,
)
from docling_jobkit.orchestrators.base_orchestrator import (
BaseOrchestrator,
)
from docling_serve.datamodel.convert import ConvertDocumentsOptions
from docling_serve.datamodel.responses import ConvertDocumentResponse, DocumentResponse
from docling_serve.datamodel.responses import (
ChunkDocumentResponse,
ConvertDocumentResponse,
PresignedUrlConvertDocumentResponse,
)
from docling_serve.settings import docling_serve_settings
_log = logging.getLogger(__name__)
def _export_document_as_content(
conv_res: ConversionResult,
export_json: bool,
export_html: bool,
export_md: bool,
export_txt: bool,
export_doctags: bool,
image_mode: ImageRefMode,
):
document = DocumentResponse(filename=conv_res.input.file.name)
if conv_res.status == ConversionStatus.SUCCESS:
new_doc = conv_res.document._make_copy_with_refmode(Path(), image_mode)
# Create the different formats
if export_json:
document.json_content = new_doc
if export_html:
document.html_content = new_doc.export_to_html(image_mode=image_mode)
if export_txt:
document.text_content = new_doc.export_to_markdown(
strict_text=True, image_mode=image_mode
)
if export_md:
document.md_content = new_doc.export_to_markdown(image_mode=image_mode)
if export_doctags:
document.doctags_content = new_doc.export_to_document_tokens()
elif conv_res.status == ConversionStatus.SKIPPED:
raise HTTPException(status_code=400, detail=conv_res.errors)
else:
raise HTTPException(status_code=500, detail=conv_res.errors)
return document
def _export_documents_as_files(
conv_results: Iterable[ConversionResult],
output_dir: Path,
export_json: bool,
export_html: bool,
export_md: bool,
export_txt: bool,
export_doctags: bool,
image_export_mode: ImageRefMode,
):
success_count = 0
failure_count = 0
for conv_res in conv_results:
if conv_res.status == ConversionStatus.SUCCESS:
success_count += 1
doc_filename = conv_res.input.file.stem
# Export JSON format:
if export_json:
fname = output_dir / f"{doc_filename}.json"
_log.info(f"writing JSON output to {fname}")
conv_res.document.save_as_json(
filename=fname, image_mode=image_export_mode
)
# Export HTML format:
if export_html:
fname = output_dir / f"{doc_filename}.html"
_log.info(f"writing HTML output to {fname}")
conv_res.document.save_as_html(
filename=fname, image_mode=image_export_mode
)
# Export Text format:
if export_txt:
fname = output_dir / f"{doc_filename}.txt"
_log.info(f"writing TXT output to {fname}")
conv_res.document.save_as_markdown(
filename=fname,
strict_text=True,
image_mode=ImageRefMode.PLACEHOLDER,
)
# Export Markdown format:
if export_md:
fname = output_dir / f"{doc_filename}.md"
_log.info(f"writing Markdown output to {fname}")
conv_res.document.save_as_markdown(
filename=fname, image_mode=image_export_mode
)
# Export Document Tags format:
if export_doctags:
fname = output_dir / f"{doc_filename}.doctags"
_log.info(f"writing Doc Tags output to {fname}")
conv_res.document.save_as_document_tokens(filename=fname)
else:
_log.warning(f"Document {conv_res.input.file} failed to convert.")
failure_count += 1
_log.info(
f"Processed {success_count + failure_count} docs, "
f"of which {failure_count} failed"
)
def process_results(
async def prepare_response(
task_id: str,
task_result: DoclingTaskResult,
orchestrator: BaseOrchestrator,
background_tasks: BackgroundTasks,
conversion_options: ConvertDocumentsOptions,
conv_results: Iterable[ConversionResult],
) -> Union[ConvertDocumentResponse, FileResponse]:
# Let's start by processing the documents
try:
start_time = time.monotonic()
# Convert the iterator to a list to count the number of results and get timings
# As it's an iterator (lazy evaluation), it will also start the conversion
conv_results = list(conv_results)
processing_time = time.monotonic() - start_time
_log.info(
f"Processed {len(conv_results)} docs in {processing_time:.2f} seconds."
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if len(conv_results) == 0:
raise HTTPException(
status_code=500, detail="No documents were generated by Docling."
)
# We have some results, let's prepare the response
response: Union[FileResponse, ConvertDocumentResponse]
# Booleans to know what to export
export_json = OutputFormat.JSON in conversion_options.to_formats
export_html = OutputFormat.HTML in conversion_options.to_formats
export_md = OutputFormat.MARKDOWN in conversion_options.to_formats
export_txt = OutputFormat.TEXT in conversion_options.to_formats
export_doctags = OutputFormat.DOCTAGS in conversion_options.to_formats
# Only 1 document was processed, and we are not returning it as a file
if len(conv_results) == 1 and not conversion_options.return_as_file:
conv_res = conv_results[0]
document = _export_document_as_content(
conv_res,
export_json=export_json,
export_html=export_html,
export_md=export_md,
export_txt=export_txt,
export_doctags=export_doctags,
image_mode=conversion_options.image_export_mode,
)
):
response: (
Response
| ConvertDocumentResponse
| PresignedUrlConvertDocumentResponse
| ChunkDocumentResponse
)
if isinstance(task_result.result, ExportResult):
response = ConvertDocumentResponse(
document=document,
status=conv_res.status,
processing_time=processing_time,
timings=conv_res.timings,
document=task_result.result.content,
status=task_result.result.status,
processing_time=task_result.processing_time,
timings=task_result.result.timings,
errors=task_result.result.errors,
)
elif isinstance(task_result.result, ZipArchiveResult):
response = Response(
content=task_result.result.content,
media_type="application/zip",
headers={
"Content-Disposition": 'attachment; filename="converted_docs.zip"'
},
)
elif isinstance(task_result.result, RemoteTargetResult):
response = PresignedUrlConvertDocumentResponse(
processing_time=task_result.processing_time,
num_converted=task_result.num_converted,
num_succeeded=task_result.num_succeeded,
num_failed=task_result.num_failed,
)
elif isinstance(task_result.result, ChunkedDocumentResult):
response = ChunkDocumentResponse(
chunks=task_result.result.chunks,
documents=task_result.result.documents,
processing_time=task_result.processing_time,
)
# Multiple documents were processed, or we are forced returning as a file
else:
# Temporary directory to store the outputs
work_dir = Path(tempfile.mkdtemp(prefix="docling_"))
output_dir = work_dir / "output"
output_dir.mkdir(parents=True, exist_ok=True)
raise ValueError("Unknown result type")
# Worker pid to use in archive identification as we may have multiple workers
os.getpid()
if docling_serve_settings.single_use_results:
# Export the documents
_export_documents_as_files(
conv_results=conv_results,
output_dir=output_dir,
export_json=export_json,
export_html=export_html,
export_md=export_md,
export_txt=export_txt,
export_doctags=export_doctags,
image_export_mode=conversion_options.image_export_mode,
)
async def _remove_task_impl():
await asyncio.sleep(docling_serve_settings.result_removal_delay)
await orchestrator.delete_task(task_id=task_id)
files = os.listdir(output_dir)
async def _remove_task():
asyncio.create_task(_remove_task_impl()) # noqa: RUF006
if len(files) == 0:
raise HTTPException(status_code=500, detail="No documents were exported.")
file_path = work_dir / "converted_docs.zip"
shutil.make_archive(
base_name=str(file_path.with_suffix("")),
format="zip",
root_dir=output_dir,
)
# Other cleanups after the response is sent
# Output directory
background_tasks.add_task(shutil.rmtree, work_dir, ignore_errors=True)
response = FileResponse(
file_path, filename=file_path.name, media_type="application/zip"
)
background_tasks.add_task(_remove_task)
return response

View File

@@ -1,9 +1,11 @@
import enum
import sys
from pathlib import Path
from typing import Optional, Union
from pydantic import AnyUrl, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from docling_serve.datamodel.engines import AsyncEngine
from typing_extensions import Self
class UvicornSettings(BaseSettings):
@@ -17,9 +19,18 @@ class UvicornSettings(BaseSettings):
root_path: str = ""
proxy_headers: bool = True
timeout_keep_alive: int = 60
ssl_certfile: Optional[Path] = None
ssl_keyfile: Optional[Path] = None
ssl_keyfile_password: Optional[str] = None
workers: Union[int, None] = None
class AsyncEngine(str, enum.Enum):
LOCAL = "local"
KFP = "kfp"
RQ = "rq"
class DoclingServeSettings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="DOCLING_SERVE_",
@@ -29,15 +40,74 @@ class DoclingServeSettings(BaseSettings):
)
enable_ui: bool = False
api_host: str = "localhost"
artifacts_path: Optional[Path] = None
static_path: Optional[Path] = None
scratch_path: Optional[Path] = None
single_use_results: bool = True
result_removal_delay: float = 300 # 5 minutes
load_models_at_boot: bool = True
options_cache_size: int = 2
enable_remote_services: bool = False
allow_external_plugins: bool = False
show_version_info: bool = True
api_key: str = ""
max_document_timeout: float = 3_600 * 24 * 7 # 7 days
max_num_pages: int = sys.maxsize
max_file_size: int = sys.maxsize
# Threading pipeline
queue_max_size: Optional[int] = None
ocr_batch_size: Optional[int] = None
layout_batch_size: Optional[int] = None
table_batch_size: Optional[int] = None
batch_polling_interval_seconds: Optional[float] = None
sync_poll_interval: int = 2 # seconds
max_sync_wait: int = 120 # 2 minutes
cors_origins: list[str] = ["*"]
cors_methods: list[str] = ["*"]
cors_headers: list[str] = ["*"]
eng_kind: AsyncEngine = AsyncEngine.LOCAL
# Local engine
eng_loc_num_workers: int = 2
eng_loc_share_models: bool = False
# RQ engine
eng_rq_redis_url: str = ""
eng_rq_results_prefix: str = "docling:results"
eng_rq_sub_channel: str = "docling:updates"
# KFP engine
eng_kfp_endpoint: Optional[AnyUrl] = None
eng_kfp_token: Optional[str] = None
eng_kfp_ca_cert_path: Optional[str] = None
eng_kfp_self_callback_endpoint: Optional[str] = None
eng_kfp_self_callback_token_path: Optional[Path] = None
eng_kfp_self_callback_ca_cert_path: Optional[Path] = None
eng_kfp_experimental: bool = False
@model_validator(mode="after")
def engine_settings(self) -> Self:
# Validate KFP engine settings
if self.eng_kind == AsyncEngine.KFP:
if self.eng_kfp_endpoint is None:
raise ValueError("KFP endpoint is required when using the KFP engine.")
if self.eng_kind == AsyncEngine.KFP:
if not self.eng_kfp_experimental:
raise ValueError(
"KFP is not yet working. To enable the development version, you must set DOCLING_SERVE_ENG_KFP_EXPERIMENTAL=true."
)
if self.eng_kind == AsyncEngine.RQ:
if not self.eng_rq_redis_url:
raise ValueError("RQ Redis url is required when using the RQ engine.")
return self
uvicorn_settings = UvicornSettings()

16
docling_serve/storage.py Normal file
View File

@@ -0,0 +1,16 @@
import tempfile
from functools import lru_cache
from pathlib import Path
from docling_serve.settings import docling_serve_settings
@lru_cache
def get_scratch() -> Path:
scratch_dir = (
docling_serve_settings.scratch_path
if docling_serve_settings.scratch_path is not None
else Path(tempfile.mkdtemp(prefix="docling_"))
)
scratch_dir.mkdir(exist_ok=True, parents=True)
return scratch_dir

278
docling_serve/ui/app.py Normal file
View File

@@ -0,0 +1,278 @@
import io
import logging
from pathlib import Path
from typing import Annotated
from fastapi import (
BackgroundTasks,
Depends,
FastAPI,
Form,
HTTPException,
Request,
UploadFile,
status,
)
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import AnyHttpUrl
from pyjsx import auto_setup # type: ignore
from starlette.exceptions import HTTPException as StarletteHTTPException
from docling.datamodel.base_models import OutputFormat
from docling_core.types.doc.document import (
FloatingItem,
PageItem,
RefItem,
)
from docling_jobkit.orchestrators.base_orchestrator import (
BaseOrchestrator,
)
from docling_serve.auth import APIKeyCookieAuth, AuthenticationResult
from docling_serve.datamodel.convert import ConvertDocumentsRequestOptions
from docling_serve.datamodel.requests import ConvertDocumentsRequest, HttpSourceRequest
from docling_serve.helper_functions import FormDepends
from docling_serve.orchestrator_factory import get_async_orchestrator
from docling_serve.settings import docling_serve_settings
from .convert import ConvertPage # type: ignore
from .pages import AuthPage, StatusPage, TaskPage, TasksPage # type: ignore
# Initialize JSX.
auto_setup
_log = logging.getLogger(__name__)
# TODO: Isolate passed functions into a controller?
def create_ui_app(process_file, process_url, task_result, task_status_poll) -> FastAPI: # noqa: C901
ui_app = FastAPI()
require_auth = APIKeyCookieAuth(docling_serve_settings.api_key)
# Static files.
ui_app.mount(
"/static",
StaticFiles(directory=Path(__file__).parent.absolute() / "static"),
name="static",
)
# Convert page.
@ui_app.get("/")
async def get_root():
return RedirectResponse(url="convert")
@ui_app.get("/convert", response_class=HTMLResponse)
async def get_convert(
auth: Annotated[AuthenticationResult, Depends(require_auth)],
):
return str(ConvertPage())
@ui_app.post("/convert", response_class=HTMLResponse)
async def post_convert(
auth: Annotated[AuthenticationResult, Depends(require_auth)],
orchestrator: Annotated[BaseOrchestrator, Depends(get_async_orchestrator)],
background_tasks: BackgroundTasks,
options: Annotated[
ConvertDocumentsRequestOptions, FormDepends(ConvertDocumentsRequestOptions)
],
files: Annotated[list[UploadFile], Form()],
url: Annotated[str, Form()],
page_min: Annotated[str, Form()],
page_max: Annotated[str, Form()],
):
# Refined model options and behavior.
if len(page_min) > 0:
options.page_range = (int(page_min), options.page_range[1])
if len(page_max) > 0:
options.page_range = (options.page_range[0], int(page_max))
options.ocr_lang = [
sub_lang.strip()
for lang in options.ocr_lang or []
for sub_lang in lang.split(",")
if len(sub_lang.strip()) > 0
]
files = [f for f in files if f.size]
if len(files) > 0:
# Directly uploaded documents.
response = await process_file(
auth=auth,
orchestrator=orchestrator,
background_tasks=background_tasks,
files=files,
options=options,
)
elif len(url.strip()) > 0:
# URLs of documents.
source = HttpSourceRequest(url=AnyHttpUrl(url))
request = ConvertDocumentsRequest(options=options, sources=[source])
response = await process_url(
auth=auth,
orchestrator=orchestrator,
conversion_request=request,
)
else:
validation = {
"files": "Upload files or enter a URL",
"url": "Enter a URL or upload files",
}
return str(ConvertPage(options=options, validation=validation))
return RedirectResponse(f"tasks/{response.task_id}/", status.HTTP_303_SEE_OTHER)
# Task overview page.
@ui_app.get("/tasks/", response_class=HTMLResponse)
async def get_tasks(
auth: Annotated[AuthenticationResult, Depends(require_auth)],
orchestrator: Annotated[BaseOrchestrator, Depends(get_async_orchestrator)],
):
tasks = sorted(orchestrator.tasks.values(), key=lambda t: t.created_at)
return str(TasksPage(tasks))
# Task specific page.
@ui_app.get("/tasks/{task_id}/", response_class=HTMLResponse)
async def get_task(
auth: Annotated[AuthenticationResult, Depends(require_auth)],
orchestrator: Annotated[BaseOrchestrator, Depends(get_async_orchestrator)],
background_tasks: BackgroundTasks,
task_id: str,
):
poll = await task_status_poll(auth, orchestrator, task_id)
result = None
if poll.task_status in ["success", "failure"]:
try:
result = await task_result(
auth, orchestrator, background_tasks, task_id
)
except Exception as ex:
logging.error(ex)
return str(TaskPage(poll, result))
# Poll task via HTTP status.
@ui_app.get("/tasks/{task_id}/poll", response_class=Response)
async def poll_task(
auth: Annotated[AuthenticationResult, Depends(require_auth)],
orchestrator: Annotated[BaseOrchestrator, Depends(get_async_orchestrator)],
task_id: str,
):
poll = await task_status_poll(auth, orchestrator, task_id)
return Response(
status_code=status.HTTP_202_ACCEPTED
if poll.task_status == "started"
else status.HTTP_200_OK
)
# Download the contents of zipped documents.
@ui_app.get("/tasks/{task_id}/documents.zip")
async def get_task_zip(
auth: Annotated[AuthenticationResult, Depends(require_auth)],
orchestrator: Annotated[BaseOrchestrator, Depends(get_async_orchestrator)],
background_tasks: BackgroundTasks,
task_id: str,
):
return await task_result(auth, orchestrator, background_tasks, task_id)
# Get the output of a task, as a converted document in a specific format.
@ui_app.get("/tasks/{task_id}/document.{format}")
async def get_task_document_format(
auth: Annotated[AuthenticationResult, Depends(require_auth)],
orchestrator: Annotated[BaseOrchestrator, Depends(get_async_orchestrator)],
background_tasks: BackgroundTasks,
task_id: str,
format: str,
):
if format not in [f.value for f in OutputFormat]:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Output format not found.")
else:
response = await task_result(auth, orchestrator, background_tasks, task_id)
# TODO: Make this compatible with base_models FormatToMimeType?
mimes = {
"html": "text/html",
"md": "text/markdown",
"json": "application/json",
}
content = (
response.document.json_content.export_to_dict()
if format == OutputFormat.JSON
else response.document.dict()[f"{format}_content"]
)
return Response(
content=str(content),
media_type=mimes.get(format, "text/plain"),
)
@ui_app.get("/tasks/{task_id}/document/{cref:path}")
async def get_task_document_item(
request: Request,
auth: Annotated[AuthenticationResult, Depends(require_auth)],
orchestrator: Annotated[BaseOrchestrator, Depends(get_async_orchestrator)],
background_tasks: BackgroundTasks,
task_id: str,
cref: str,
):
response = await task_result(auth, orchestrator, background_tasks, task_id)
doc = response.document.json_content
item = RefItem(cref=f"#/{cref}").resolve(doc) # type: ignore
if "image/*" in (request.headers.get("Accept") or "") and isinstance(
item, FloatingItem | PageItem
):
content = io.BytesIO()
if (
isinstance(item, PageItem)
and (img_ref := item.image)
and img_ref.pil_image
):
img_ref.pil_image.save(content, format="PNG")
elif isinstance(item, FloatingItem) and (img := item.get_image(doc)):
img.save(content, format="PNG")
return Response(content=content.getvalue(), media_type="image/png")
else:
return item
# Page not found; catch all.
@ui_app.api_route("/{path_name:path}")
def no_page(
auth: Annotated[AuthenticationResult, Depends(require_auth)],
):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Page not found.")
# Exception and auth pages.
@ui_app.exception_handler(StarletteHTTPException)
@ui_app.exception_handler(Exception)
async def exception_page(request: Request, ex: Exception):
if not isinstance(ex, StarletteHTTPException):
# Internal error.
ex = HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
if request.method == "POST":
# Authorization required -> API key dialog.
form = await request.form()
form_api_key = form.get("api_key")
if isinstance(form_api_key, str):
response = RedirectResponse(request.url, status.HTTP_303_SEE_OTHER)
require_auth._set_api_key(response, form_api_key)
return response
if ex.status_code == status.HTTP_401_UNAUTHORIZED:
return HTMLResponse(str(AuthPage()), status.HTTP_401_UNAUTHORIZED)
# HTTP exception page; avoid referer loop.
referer = request.headers.get("Referer")
if referer == request.url:
referer = None
return HTMLResponse(str(StatusPage(ex, referer)), ex.status_code)
return ui_app

251
docling_serve/ui/convert.px Normal file
View File

@@ -0,0 +1,251 @@
import json
import sys
from pyjsx import jsx, JSX
from docling.datamodel.base_models import FormatToExtensions, OutputFormat
from docling.datamodel.pipeline_options import PdfBackend, ProcessingPipeline, TableFormerMode
from docling_core.types.doc import ImageRefMode
from docling_serve.datamodel.convert import ConvertDocumentsRequestOptions, ocr_engines_enum
from .forms import EnumCheckboxes, EnumRadios, EnumSelect, ocr_engine_languages, ValidatedInput
from .pages import Header, Page
base_convert_options = ConvertDocumentsRequestOptions()
base_convert_options.to_formats.append(OutputFormat.JSON)
def ConvertPage(
options: ConvertDocumentsRequestOptions = base_convert_options,
validation: None | dict[str, str] = None
) -> JSX:
file_accept = ",".join([f".{ext}" for exts in FormatToExtensions.values() for ext in exts])
return (
<Page title="Convert">
<main class="container">
<Header />
<form class="convert" method="post" enctype="multipart/form-data">
<legend>
<b>Documents</b>
</legend>
<fieldset class="grid">
<ValidatedInput
name="files"
type="file"
multiple
accept={file_accept}
validation={validation}
/>
<ValidatedInput
name="url"
placeholder="or enter a URL: https://arxiv.org/pdf/2501.17887"
validation={validation}
/>
</fieldset>
<fieldset class="grid">
<EnumSelect
enum={ProcessingPipeline}
selected={options.pipeline}
name="pipeline"
title="Pipeline"
/>
<EnumSelect
enum={PdfBackend}
selected={options.pdf_backend}
name="pdf_backend"
title="PDF Backend"
/>
<div>
<label>Pages</label>
<div role="group">
<input
type="number"
name="page_min"
min={1}
step={1}
placeholder="1"
value={None if options.page_range[0] <= 1 else options.page_range[0]}
/>
<input
type="number"
name="page_max"
min={1}
step={1}
placeholder="max."
value={None if options.page_range[1] >= sys.maxsize else options.page_range[1]}
/>
</div>
</div>
<div>
<label>Timeout<small>in seconds</small></label>
<input
type="number"
name="document_timeout"
min={1}
step={1}
value={int(options.document_timeout)}
/>
</div>
</fieldset>
<div class="grid">
<EnumCheckboxes
enum={OutputFormat}
selected={options.to_formats}
name="to_formats"
title={<b>Output</b>}
/>
<div>
<fieldset>
<label>
<input
type="checkbox"
name="do_ocr"
checked={options.do_ocr}
/>
<b>OCR</b>
</label>
<label display-when="do_ocr">
<input
type="checkbox"
name="do_code_enrichment"
checked={options.do_code_enrichment}
/>
Code
</label>
<label display-when="do_ocr">
<input
type="checkbox"
name="do_formula_enrichment"
checked={options.do_formula_enrichment}
/>
Formulas
</label>
</fieldset>
<EnumSelect
display-when="do_ocr"
enum={ocr_engines_enum}
selected={options.ocr_engine}
name="ocr_engine"
title="Engine"
/>
<label display-when="do_ocr">Language</label>
<input
display-when="do_ocr"
name="ocr_lang"
dep-on="ocr_engine"
dep-values={json.dumps(ocr_engine_languages)}
pattern="[\w+]*[,\w+]*"
title="A comma separated list of language codes, of which the format depends on the selected engine."
/>
<label display-when="do_ocr">
<input
type="checkbox"
name="force_ocr"
checked={options.force_ocr}
/>
Force
</label>
</div>
<div>
<fieldset>
<label>
<input
type="checkbox"
name="include_images"
checked={options.include_images}
/>
<b>Images</b>
</label>
<label display-when="include_images">
<input
type="checkbox"
name="do_picture_classification"
checked={options.do_picture_classification}
/>
Classification
</label>
<label display-when="include_images">
<input
type="checkbox"
name="do_picture_description"
checked={options.do_picture_description}
/>
Description
</label>
<label display-when="include_images,do_picture_description">Area threshold</label>
<input
display-when="include_images,do_picture_description"
type="number"
name="picture_description_area_threshold"
min={0}
max={1}
step={0.01}
value={options.picture_description_area_threshold}
/>
</fieldset>
<EnumSelect
display-when="include_images"
enum={ImageRefMode}
selected={options.image_export_mode}
name="image_export_mode"
title="Export"
/>
<label display-when="include_images">Scale</label>
<input
display-when="include_images"
type="number"
name="images_scale"
min={0}
step={0.1}
value={options.images_scale}
/>
</div>
<div>
<fieldset>
<label>
<input
type="checkbox"
name="do_table_structure"
checked={options.do_table_structure}
/>
<b>Tables</b>
</label>
<label display-when="do_table_structure">
<input
type="checkbox"
name="table_cell_matching"
checked={options.table_cell_matching}
/>
Cell matching
</label>
</fieldset>
<EnumSelect
display-when="do_table_structure"
enum={TableFormerMode}
selected={options.table_mode}
name="table_mode"
title="Mode"
/>
</div>
</div>
<div class="sticky-footer">
<input type="submit" value="Convert" />
</div>
</form>
</main>
</Page>
)

127
docling_serve/ui/forms.px Normal file
View File

@@ -0,0 +1,127 @@
from enum import Enum
from typing import Type
from pyjsx import jsx, JSX
from docling.datamodel.pipeline_options import OcrOptions
from docling_serve.datamodel.convert import ConvertDocumentsRequestOptions
ocr_engine_languages = {
SubOptions.kind: ",".join(SubOptions().lang)
for SubOptions in OcrOptions.__subclasses__()
}
def _format_label(label: str) -> str:
return label.replace("_", " ").lower()
def option_example(field_name: str) -> str | None:
field = ConvertDocumentsRequestOptions.model_fields[field_name]
return (field.examples or [])[0]
def ValidatedInput(validation: None | dict[str, str], name: str, **kwargs) -> JSX:
if validation:
invalid = "true" if name in validation else "false"
content = [<input name={name} aria-invalid={invalid} {...kwargs} />]
if name in validation:
content.append(<small>{validation[name]}</small>)
return <div>{content}</div>
else:
return <input name={name} {...kwargs} />
def EnumCheckboxes(
children,
enum: Type[Enum],
selected: list[Enum],
name: str,
title: JSX = None,
**kwargs
) -> JSX:
return (
<fieldset {...kwargs}>
{
<legend>{title}</legend>
if title
else None
}
{[
<label>
<input
type="checkbox"
name={name}
value={e.value}
checked={e.value in selected}
/>
{_format_label(e.name)}
</label>
for e in enum
]}
</fieldset>
)
def EnumRadios(
children,
enum: Type[Enum],
selected: Enum,
name: str,
title: JSX = None,
**kwargs
) -> JSX:
return (
<fieldset {...kwargs}>
{
<legend>{title}</legend>
if title
else None
}
{[
<label>
<input
type="radio"
name={name}
value={e.value}
checked={e.value == selected}
/>
{_format_label(e.name)}
</label>
for e in enum
]}
</fieldset>
)
def EnumSelect(
children,
enum: Type[Enum],
selected: Enum,
name: str,
title: JSX = None,
**kwargs
) -> JSX:
return (
<div {...kwargs}>
{
<label>{title}</label>
if title
else None
}
<select name={name}>
{[
<option value={e.value} selected={e.value == selected}>
{_format_label(e.name)}
</option>
for e in enum
]}
</select>
</div>
)

220
docling_serve/ui/pages.px Normal file
View File

@@ -0,0 +1,220 @@
from importlib import metadata
from fastapi import FastAPI, HTTPException, Response
from pyjsx import jsx, JSX
from docling.datamodel.base_models import OutputFormat
from docling.datamodel.pipeline_options import PdfBackend, ProcessingPipeline, TableFormerMode
from docling_jobkit.datamodel.task import Task
from docling_serve.datamodel.responses import ConvertDocumentResponse
from .preview import DocPreview
def Header(children, classname: str = "") -> JSX:
return (
<header class={classname}>
<span class="title">
D<img src="/ui/static/logo.svg" />CLING SERVE
</span>
<span class="version" title="Docling version">
{metadata.version('docling')}
</span>
<nav>
<ul>
<li><a href="/ui/convert">Convert</a></li>
<li><a href="/ui/tasks/">Tasks</a></li>
</ul>
</nav>
</header>
)
def Page(children, title: str, poll: bool = False) -> JSX:
return (
<html lang="en" id="root">
<head>
<title>{title}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/ui/static/style.css" />
<script src="/ui/static/main.js" />
</head>
<body onload={'setInterval(async () => { if ((await fetch("poll")).status == 200) location.reload(); }, 3000)' if poll else None}>
{children}
</body>
</html>
)
def AuthPage():
return (
<Page title="Authenticate">
<form method="post">
<dialog open>
<article>
<header>
<h4>Authenticate</h4>
</header>
<input
type="password"
name="api_key"
placeholder="Enter API key"
required autofocus
/>
<footer>
<input type="submit" value="Confirm" />
</footer>
</article>
</dialog>
</form>
</Page>
)
def TasksPage(tasks: list[Task]) -> JSX:
return (
<Page title="Tasks">
<main class="container">
<Header />
{(
<p>There are no active tasks. <a href="../convert">Convert</a> a document to create a new task.</p>
) if len(tasks) == 0 else (
<table>
<thead>
<tr>
<th>Task</th>
<th>Status</th>
<th>ID</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{
<tr>
<td>{task.task_type.name}</td>
<td>{task.task_status.name}</td>
<td>
<a href={f"{task.task_id}/"}>{task.task_id}</a>
</td>
<td>{task.created_at.strftime("%d-%m-%Y, %H:%M:%S")}</td>
</tr>
for task in tasks
}
</tbody>
</table>
)}
</main>
</Page>
)
def TaskPage(poll, task: ConvertDocumentResponse) -> JSX:
def PlainPage(children, poll = False) -> JSX:
return (
<Page title="Task" poll={poll}>
<main class="container">
<Header classname={"loading" if poll else None} />
{children}
</main>
</Page>
)
if isinstance(task, Response):
return (
<PlainPage>
<p>
<ins>Converted multiple documents successfully</ins>
</p>
<a href="documents.zip">documents.zip</a>
</PlainPage>
)
else:
match poll.task_status:
case "success":
doc = task.document.dict()
doc_json = task.document.json_content
return (
<Page title={task.document.filename}>
<main class="preview">
<Header />
<div class="status">
<div>
<span>Task</span>
<b>{poll.task_id}</b>
</div>
<div>
<span>converted</span>
<b>{task.document.filename}</b>
</div>
<div>
<span>in</span>
<b>{round(task.processing_time)} seconds</b>
</div>
</div>
<div class="formats">
{[
<a class="secondary" href={f"document.{f.value}"} target="_blank">
<button>{f.name}</button>
</a>
for f in OutputFormat
if doc.get(f"{f.value}_content")
]}
<label class="configDarkImg">
<input type="checkbox" name="invert-images" persist="preview" />
Invert images
</label>
</div>
{
<DocPreview doc={doc_json} />
if doc_json
else (<p>No document preview because JSON is missing as an output format.</p>)
}
</main>
</Page>
)
case "started":
return (
<PlainPage poll>
<p class="progress">Task <b>{poll.task_id}</b> is in progress...</p>
<progress />
</PlainPage>
)
case _:
return (
<PlainPage>
<p class="fail">
Task <b>{poll.task_id}</b> failed.
</p>
<button onclick="history.back()">
Go back
</button>
</PlainPage>
)
def StatusPage(ex: HTTPException, referer: str | None) -> JSX:
return (
<Page title={ex.status_code}>
<main class="container">
<Header />
<h4>{ex.status_code}</h4>
<p>{ex.detail}</p>
<p>
<a href={referer or ".."}>
<button>Go back</button>
</a>
</p>
</main>
</Page>
)

347
docling_serve/ui/preview.px Normal file
View File

@@ -0,0 +1,347 @@
from collections import defaultdict
from html import escape
from typing import Type
from docling_core.types.doc.document import (
BaseAnnotation,
CodeItem,
ContentLayer,
DescriptionAnnotation,
DoclingDocument,
DocItem,
FloatingItem,
Formatting,
FormulaItem,
GroupItem,
GroupLabel,
ListGroup,
ListItem,
NodeItem,
PictureClassificationData,
PictureItem,
ProvenanceItem,
RefItem,
Script,
SectionHeaderItem,
TableCell,
TableItem,
TextItem,
TitleItem
)
from pyjsx import jsx, JSX, JSXComponent
from .svg import image, path, rect, text
_node_components: dict[str, JSXComponent] = {}
def component(*node_types: list[Type[BaseAnnotation | NodeItem]]):
def decorator(component):
for t in node_types:
_node_components[t.__name__] = component
return decorator
def AnnotationComponent(children, annotation: BaseAnnotation):
Comp = _node_components.get(annotation.__class__.__name__)
element = Comp(annotation=annotation, children=[]) if Comp else (
<code>{escape(annotation.model_dump_json(indent=2))}</code>
)
element.props["class"] = element.props.get("class", "") + " annotation"
element.props["data-kind"] = annotation.kind
return element
def NodeComponent(children, node: NodeItem | RefItem, doc: DoclingDocument):
# Specific component or fallback.
Comp = _node_components.get(node.__class__.__name__)
element = Comp(node=node, doc=doc, children=[]) if Comp else (
<span class="void"></span>
)
# Wrap item component with annotations, if any.
if isinstance(node, DocItem) and (anns := node.get_annotations()):
element = (
<div class="annotated">
{element}
{[<AnnotationComponent annotation={ann} /> for ann in anns]}
</div>
)
# Extend interaction and styling.
id = node.self_ref[2:]
element.props["id"] = id
element.props["onclick"] = "clickId(event)"
classes = ["item", node.content_layer.value]
element.props["class"] = f"{element.props.get("class", "")} {" ".join(classes)}"
return element
def node_provs(node: NodeItem, doc: DoclingDocument) -> ProvenanceItem:
return node.prov if isinstance(node, DocItem) else [
p
for c in node.children
if isinstance(c.resolve(doc), DocItem)
for p in c.resolve(doc).prov
]
def DocPage(children, page_no: int, items: list[NodeItem], doc: DoclingDocument):
page = doc.pages[page_no]
exclusive_items = [
item
for item in items
if min([p.page_no for p in node_provs(item, doc)]) == page_no
]
comps = []
for i in range(len(exclusive_items)):
item = exclusive_items[i]
id = item.self_ref[2:]
kind, *index = id.split("/")
parent_class = ""
if isinstance(item, GroupItem):
parent_class = "group"
else:
parent = item.parent.resolve(doc)
if isinstance(parent, GroupItem) and parent.label is not GroupLabel.UNSPECIFIED:
parent_class = "grouped"
comps.append(
<div class={f"item-markers {parent_class} {item.content_layer.value}"} data-id={id}>
<span>{"/".join(index)}</span>
<span>{item.label.replace("_", " ")}</span>
{
<span>{item.content_layer.value.replace("_", " ")}</span>
if item.content_layer is not ContentLayer.BODY
else None
}
<a href={f"document/{id}"} target="_blank">{"{;}"}</a>
</div>
)
comps.append(<NodeComponent node={item} doc={doc} />)
pages = set([p.page_no for p in node_provs(item, doc)])
page_mark_class = "page-marker"
if i == 0 or len(pages) > 1:
page_mark_class += " border"
comps.append(<div class={page_mark_class}></div>)
def ItemBox(children, item: DocItem, prov: ProvenanceItem):
item_id = item.self_ref[2:]
sub_items = [
(item_id, prov.bbox.to_top_left_origin(page.size.height))
]
# Table cells.
if isinstance(item, TableItem):
for cell in item.data.table_cells:
sub_items.append(
(f"{item_id}/{cell.start_col_offset_idx}/{cell.start_row_offset_idx}", cell.bbox)
)
return [
<rect
data-id={id}
x={bbox.l - 1}
y={bbox.t - 1}
width={bbox.width + 2}
height={bbox.height + 2}
vector-effect="non-scaling-stroke"
onclick="clickId(event)"
/>
for id, bbox in sub_items
]
# Span extra row to fill up excess space.
comps.append(
<svg
class="page-image"
style={{ "grid-row": f"span {len(exclusive_items) + 1}" }}
width="50vw"
viewBox={f"0 0 {page.size.width} {page.size.height}"}
>
<image
href={f"document/pages/{page_no}"}
width={page.size.width}
height={page.size.height}
/>
{[
<ItemBox item={item} prov={prov} />
for item in items
if isinstance(item, DocItem)
for prov in item.prov
if prov.page_no == page_no
]}
<text class="top-no" x={5} y={5}>{page_no}</text>
<text class="bottom-no" x={5} y={page.size.height - 5}>{page_no}</text>
</svg>
)
return <div class="page">{comps}</div>
def DocPreview(children, doc: DoclingDocument):
page_items: dict[int, list[NodeItem]] = defaultdict(list)
for item, level in doc.iterate_items(
with_groups=True,
included_content_layers={*ContentLayer}
):
if not isinstance(item, GroupItem) or item.label is not GroupLabel.UNSPECIFIED:
pages = set([p.page_no for p in node_provs(item, doc)])
for page in pages:
page_items[page].append(item)
return [
<DocPage page_no={page_no} items={page_items[page_no]} doc={doc} />
for page_no in sorted(page_items.keys())
]
def _text_classes(node: TextItem) -> str:
classes = [node.label]
if frmt := node.formatting:
formats = {
"bold": frmt.bold,
"italic": frmt.italic,
"underline": frmt.underline,
"strikethrough": frmt.strikethrough
}
classes.extend([cls for cls, active in formats.items() if active])
classes.append(frmt.script)
return " ".join(classes)
@component(TextItem)
def TextComponent(children, node: TextItem, doc: DoclingDocument):
return <p class={_text_classes(node)}>{escape(node.text)}</p>
@component(TitleItem)
def TitleComponent(children, node: TitleItem, doc: DoclingDocument):
return <h1 class={_text_classes(node)}>{escape(node.text)}</h1>
@component(SectionHeaderItem)
def SectionHeaderComponent(children, node: SectionHeaderItem, doc: DoclingDocument):
return <h4 class={_text_classes(node)}>{escape(node.text)}</h4>
@component(ListItem)
def ListComponent(children, node: ListItem, doc: DoclingDocument):
return (
<li>
<b>{node.marker}</b>
<span class={_text_classes(node)}>{escape(node.text)}</span>
</li>
)
@component(CodeItem)
def CodeComponent(children, node: CodeItem, doc: DoclingDocument):
return (
<figure>
<code class={_text_classes(node)}>
{escape(node.text or node.orig)}
</code>
</figure>
)
@component(FormulaItem)
def FormulaComponent(children, node: FormulaItem, doc: DoclingDocument):
return (
<figure>
<code class={_text_classes(node)}>
{escape(node.text or node.orig)}
</code>
</figure>
)
@component(PictureItem)
def PictureComponent(children, node: PictureItem, doc: DoclingDocument):
return <figure><img src={f"document/{node.self_ref[2:]}"} loading="lazy" /></figure>
@component(PictureClassificationData)
def PictureClassificationComponent(children, annotation: PictureClassificationData):
return (
<table>
<tbody>
{[
<tr>
<td>{cls.class_name.replace("_", " ")}</td>
<td>{f"{cls.confidence:.2f}"}</td>
</tr>
for cls in annotation.predicted_classes
if cls.confidence > 0.01
]}
</tbody>
</table>
)
@component(DescriptionAnnotation)
def DescriptionAnnotation(children, annotation: DescriptionAnnotation):
return <span>{escape(annotation.text)}</span>
@component(TableItem)
def TableComponent(children, node: TableItem, doc: DoclingDocument):
covered_cells: set[(int, int)] = set()
def check_cover(cell: TableCell):
is_covered = (cell.start_col_offset_idx, cell.start_row_offset_idx) in covered_cells
if not is_covered:
for x in range(cell.start_col_offset_idx, cell.end_col_offset_idx):
for y in range(cell.start_row_offset_idx, cell.end_row_offset_idx):
covered_cells.add((x, y))
return is_covered
def Cell(children, cell: TableCell):
id = f"{node.self_ref[2:]}/{cell.start_col_offset_idx}/{cell.start_row_offset_idx}"
return (
<td
id={id}
class={"header" if cell.column_header or cell.row_header else None}
colspan={cell.col_span or 1}
rowspan={cell.row_span or 1}
onclick="clickId(event)"
>
{escape(cell.text)}
</td>
)
return (
<div class="table">
<table>
<tbody>
{[
<tr>
{[
<Cell cell={cell} />
for cell in row
if not check_cover(cell)
]}
</tr>
for row in node.data.grid
]}
</tbody>
</table>
</div>
)

View File

@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
<g id="Docling" transform="matrix(1.07666,0,0,1.07666,-35.9018,-84.1562)">
<g id="Outline" transform="matrix(1,0,0,1,-0.429741,55.0879)">
<path d="M394.709,69.09C417.34,35.077 467.97,30.178 478.031,55.609C486.35,55.043 494.726,54.701 503.158,54.589C533.157,45.238 560.496,47.419 584.65,60.732C800.941,96.66 966.069,284.814 966.069,511.232C966.069,763.284 761.435,967.918 509.383,967.918C433.692,967.918 362.277,949.464 299.385,916.808L242.3,931.993C203.092,943.242 187.715,928.369 208.575,891.871C208.935,891.24 216.518,879.37 223.997,867.677C119.604,783.975 52.698,655.355 52.698,511.232C52.698,298.778 198.086,120.013 394.709,69.09Z" style="fill:white;"/>
</g>
<g id="Color" transform="matrix(1.02317,0,0,1.02317,-11.55,-17.8333)">
<path d="M284.8,894.232L179.735,783.955L130.222,645.203L125.538,504.726L185.211,385.816C209.006,322.738 249.951,278.973 302.281,248.028L406.684,203.333L413.483,175.767L436.637,152.428L451.408,153.312L457.726,183.183L485.164,165.379L526.92,159.699L557.014,177.545L612.652,211.018C679.009,226.066 740.505,264.146 797.138,325.26L862.813,423.477L891.583,560.826L883.273,683.32L814.268,809.924L734.431,894.384L644.495,926.906L497.146,954.121L361.064,940.647L284.8,894.232Z" style="fill:url(#_Linear1);"/>
<path d="M699.932,887.255L634.427,825.291L597.884,782.352L594.906,738.956L610.14,709.396L643.207,699.954L685,710.111L730.425,736.425L765.204,778.79L775.166,849.531L719.381,894.082L699.932,887.255Z" style="fill:url(#_Linear2);"/>
<g transform="matrix(-0.765945,0,0,1,839.727,5.47434)">
<clipPath id="_clip3">
<path d="M699.932,887.255L634.427,825.291L597.884,782.352L594.906,738.956L610.14,709.396L643.207,699.954L685,710.111L730.425,736.425L765.204,778.79L775.166,849.531L719.381,894.082L699.932,887.255Z"/>
</clipPath>
<g clip-path="url(#_clip3)">
<g transform="matrix(-1.18516,0,0,0.907769,1039.04,88.3496)">
<use xlink:href="#_Image4" x="223.969" y="674.21" width="152.098px" height="213.852px" transform="matrix(0.994105,0,0,0.999308,0,0)"/>
</g>
</g>
</g>
<path d="M311.699,713.521C189.178,639.091 164.299,526.77 191.824,394.113L135.136,476.434L122.004,547.53C143.022,614.014 174.522,676.199 225.005,730.598C210.601,754.156 201.894,776.601 197.955,798.114L245.803,841.67C247.274,812.1 254.934,783.047 270.614,754.664L311.699,713.521Z" style="fill-opacity:0.22;"/>
<g transform="matrix(-1,0,0,1,1022.04,2.74442)">
<path d="M311.699,713.521C189.178,639.091 164.299,526.77 191.824,394.113L135.136,476.434L122.004,547.53C143.022,614.014 174.522,676.199 225.005,730.598C210.601,754.156 201.894,776.601 197.955,798.114L245.803,841.67C247.274,812.1 254.934,783.047 270.614,754.664L311.699,713.521Z" style="fill-opacity:0.22;"/>
</g>
<path d="M354.92,650.818L420.009,663.185L493.368,666.379L554.826,665.251L620.19,658.511L658.169,651.428L671.428,644.802L673.265,627.093L659.898,611.845L625.422,609.244L599.275,591.212L568.632,556.79L542.9,534.336L515.052,528.253L480.412,532.71L455.2,552.337L428.514,578.155L405.312,599.359L374.228,612.097L355.342,614.456L340.75,630.308L341.568,645.341L354.92,650.818Z" style="fill:url(#_Linear5);"/>
<path d="M257.168,949.32L317.434,876.747L364.928,810.6L384.1,743.934L378.759,714.719L376.844,685.849L374.836,659.954L448.734,664.2L511.462,667.602L571.339,665.091L632.796,658.836L648.232,656.882L649.937,697.808L608.105,717.702L598.45,738.594L592.286,761.642L604.743,796.309L639.595,825.803L649.872,840.757L558.219,895.152L502.124,907.569L425.781,923.496L333.29,931.298L286.269,936.907L257.168,949.32Z" style="fill:url(#_Linear6);"/>
<g transform="matrix(1,0,0,1.30081,-1.77636e-15,-196.488)">
<path d="M374.165,685.268C463.946,706.599 553.728,707.491 643.51,688.593L641.903,653.199C549.263,671.731 459.645,672.22 373.059,654.611L374.165,685.268Z" style="fill-opacity:0.18;"/>
</g>
<path d="M459.633,571.457C476.7,536.091 530.064,535.913 553.1,568.767C520.703,551.407 489.553,552.374 459.633,571.457Z" style="fill:white;"/>
<g transform="matrix(1,0,0,1,0.223468,-2.61949)">
<path d="M355.3,267.232C500.64,173.156 720.699,241.362 793.691,423.582C766.716,384.84 735.725,357.078 697.53,349.014L717.306,335.248C698.537,321.49 675.794,320.957 651.039,327.119C652.235,315.768 658.995,306.991 674.188,302.115C641.864,287.427 617.356,289.473 596.258,298.818C597.049,286.116 605.827,278.087 620.068,273.254C589.192,267.477 564.13,270.926 544.651,283.232C545.822,271.831 550.709,260.943 560.913,250.79C517.498,257.095 492.995,267.925 482.892,282.202C477.311,269.499 477.274,257.221 487.625,245.739C439.161,252.932 421.555,265.094 410.355,278.286C407.697,269.01 407.705,260.632 410.853,253.316C389.633,254.773 372.178,260.663 355.3,267.232Z" style="fill:rgb(255,213,95);"/>
</g>
<path d="M475.656,209.175C479.639,175.037 503.437,173.299 532.412,180.026C507.242,183.404 486.969,195.251 473.705,219.215L475.656,209.175Z" style="fill:rgb(255,215,101);"/>
<g transform="matrix(0.114323,-0.655229,0.82741,0.144365,224.632,497.317)">
<path d="M475.656,209.175C479.639,175.037 503.437,173.299 532.412,180.026C507.242,183.404 486.969,195.251 473.705,219.215L475.656,209.175Z" style="fill:rgb(255,215,101);"/>
</g>
<g transform="matrix(1.6739,1.15217e-16,-1.15217e-16,-0.733075,-341.46,1039.77)">
<path d="M447.449,560.911C468.179,536.963 546.237,539.305 565.638,560.831C533.166,555.541 477.296,553.494 447.449,560.911Z" style="fill:white;"/>
</g>
<path d="M348.201,622.341C395.549,653.534 622.351,660.854 661.936,616.729L677.568,633.834L667.044,650.308L557.802,667.518L498.074,670.562L446.718,666.416L391.404,658.406L348.154,652.501L340.161,637.119L348.201,622.341Z" style="fill:rgb(199,68,6);"/>
</g>
<g id="Black-outline" serif:id="Black outline" transform="matrix(1.02317,0,0,1.02317,-11.55,-17.8333)">
<path d="M373.389,657.919C376.285,676.334 377.04,695.016 375.326,714.008" style="fill:none;stroke:black;stroke-width:15.73px;"/>
<path d="M645.931,654.961C646.158,669.958 647.22,684.853 648.975,699.661" style="fill:none;stroke:black;stroke-width:15.73px;"/>
<path d="M290.084,534.662C276.554,533.535 264.892,530.024 254.279,525.175C276.732,555.341 305.316,569.76 338.631,572.029L290.084,534.662Z"/>
<g transform="matrix(0.94177,0,0,0.94909,28.8868,3.79501)">
<ellipse cx="338.022" cy="510.34" rx="88.911" ry="89.412"/>
</g>
<g transform="matrix(0.112099,0.0552506,-0.0673118,0.136571,455.367,509.409)">
<ellipse cx="338.022" cy="510.34" rx="88.911" ry="89.412"/>
</g>
<g transform="matrix(-0.112099,0.0552506,0.0673118,0.136571,560.529,509.492)">
<ellipse cx="338.022" cy="510.34" rx="88.911" ry="89.412"/>
</g>
<g transform="matrix(-1,0,0,1,1013.33,-1.15187)">
<path d="M290.084,534.662C276.554,533.535 264.892,530.024 254.279,525.175C276.732,555.341 305.316,569.76 338.631,572.029L290.084,534.662Z"/>
</g>
<g transform="matrix(-0.94177,0,0,0.94909,984.44,2.64314)">
<ellipse cx="338.022" cy="510.34" rx="88.911" ry="89.412"/>
</g>
<g transform="matrix(1,0,0,1,1.9047,-5.57346)">
<path d="M277.021,489.604C279.828,554.545 355.855,583.508 405.306,537.851C354.458,599.537 263.881,560.914 277.021,489.604Z" style="fill:white;"/>
</g>
<g transform="matrix(-1,0,0,1,1011.43,-5.7284)">
<path d="M277.021,489.604C279.828,554.545 355.855,583.508 405.306,537.851C354.458,599.537 263.881,560.914 277.021,489.604Z" style="fill:white;"/>
</g>
<g transform="matrix(0.973815,0,0,1.00246,4.71761,-0.508759)">
<path d="M407.22,206.891C107.655,339.384 134.447,630.03 314.615,708.305" style="fill:none;stroke:black;stroke-width:29.39px;"/>
</g>
<g transform="matrix(-0.973815,0,0,1.00246,1006.67,-1.31695)">
<path d="M461.559,196.756C119.768,256.762 111.059,642.544 320.305,711.486" style="fill:none;stroke:black;stroke-width:29.39px;"/>
</g>
<g id="vector-duck" serif:id="vector duck">
<path d="M240.912,850.71C248.043,740.231 325.609,685.992 371.268,715.193C386.487,724.926 392.506,757.72 358.575,816.753C327.005,871.68 300.465,894.596 288.329,903.447" style="fill:none;stroke:black;stroke-width:21.79px;"/>
<path d="M638.382,843.426C427.991,964.695 389.022,902.942 251.512,947.641L307.759,889.573" style="fill:none;stroke:black;stroke-width:15.73px;"/>
<path d="M770.991,853.754C779.364,764.998 730.67,727.923 666.385,704.966C629.568,691.819 580.483,723.886 595.974,772.596C606.285,805.016 650.54,839.029 707.786,886.778" style="fill:none;stroke:black;stroke-width:21.79px;"/>
<g transform="matrix(1,0,0,1,-1.87208,0.908099)">
<path d="M603.287,772.415C614.237,757.963 627.553,750.285 642.878,748.352C628.356,760.968 617.23,775.676 620.632,799.336C635.815,785.15 650.367,779.457 664.396,780.801C651.715,790.7 639.329,803.279 641.039,818.089C641.247,819.891 647.043,823.996 647.595,825.837C659.897,816.37 672.867,811.065 689.234,809.472C676.577,822.659 668.021,834.011 674.478,848.729L664.333,847.825L625.643,812.604L603.629,786.218L603.287,772.415Z"/>
</g>
<g transform="matrix(-0.969851,0.2437,0.2437,0.969851,773.329,-138.212)">
<path d="M603.287,772.415C614.237,757.963 627.553,750.285 642.878,748.352C628.356,760.968 617.23,775.676 620.632,799.336C635.815,785.15 650.367,779.457 664.396,780.801C651.715,790.7 639.329,803.279 641.039,818.089C641.247,819.891 647.043,823.996 647.595,825.837C659.897,816.37 672.867,811.065 689.234,809.472C676.577,822.659 668.021,834.011 674.478,848.729L664.333,847.825L625.643,812.604L603.629,786.218L603.287,772.415Z"/>
</g>
<path d="M511.787,670.044C461.061,671.835 411.878,662.84 361.322,653.92C329.071,648.229 335.56,616.432 361.693,615.181C391.498,613.754 411.83,601.737 437.593,569.084C459.063,541.872 482.443,528.143 506.834,529.767" style="fill:none;stroke:black;stroke-width:15.73px;"/>
<g transform="matrix(-1,0,0,1,1014.44,-0.213451)">
<path d="M511.787,670.044C461.061,671.835 411.878,662.84 361.322,653.92C329.071,648.229 335.56,616.432 361.693,615.181C391.498,613.754 411.83,601.737 437.593,569.084C459.063,541.872 482.443,528.143 506.834,529.767" style="fill:none;stroke:black;stroke-width:15.73px;"/>
</g>
</g>
<g transform="matrix(2.4586,0,0,2.5497,-444.527,-690.434)">
<ellipse cx="312.566" cy="450.751" rx="10.63" ry="10.48" style="fill:white;"/>
</g>
<g transform="matrix(2.4586,0,0,2.5497,-127.75,-690.991)">
<ellipse cx="312.566" cy="450.751" rx="10.63" ry="10.48" style="fill:white;"/>
</g>
<path d="M505.738,698.061L578.879,713.989" style="fill:none;stroke:black;stroke-width:12.1px;"/>
<path d="M422.781,709.6L568.438,743.041" style="fill:none;stroke:black;stroke-width:12.1px;"/>
<path d="M419.941,738.409L565.688,772.989" style="fill:none;stroke:black;stroke-width:12.1px;"/>
<path d="M408.6,787.08L510.634,810.689" style="fill:none;stroke:black;stroke-width:12.1px;"/>
<path d="M397.571,815.956L500.93,840.219" style="fill:none;stroke:black;stroke-width:12.1px;"/>
<path d="M386.763,844.926L454.065,861.974" style="fill:none;stroke:black;stroke-width:12.1px;"/>
<path d="M459.169,919.169C512.194,898.262 539.171,867.298 535.241,824.402C568.052,818.31 598.499,817.058 625.84,822.165" style="fill:none;stroke:black;stroke-width:16.95px;"/>
<path d="M366.219,241.106C389.605,229.261 413.371,220.601 438.247,217.5C416.795,202.419 418.72,174.582 444.22,162.47C442.086,178.175 447.633,193.354 464.772,207.738C468.721,167.57 530.015,162.087 545.674,184.112C526.45,189.314 513.082,197.344 504.566,207.717C522.403,208.119 540.706,207.86 556.2,210.609L566.935,168.471C536.388,146.208 495.718,142.166 464.65,166.705C467.703,133.264 419.536,128.364 404.624,178.47L366.219,241.106Z"/>
<path d="M392.617,924.576C428.953,936.938 467.84,943.636 508.258,943.636C708.944,943.636 871.876,778.49 871.876,575.076C871.876,382.463 725.788,224.162 539.898,207.895L554.137,173.696L554.485,168.187C757.218,191.602 914.895,366.003 914.895,577.383C914.895,804.698 732.549,989.249 507.949,989.249C435.381,989.249 367.223,969.983 308.199,936.232L392.617,924.576ZM279.206,917.988C171.663,843.819 101.002,718.887 101.002,577.383C101.002,383.006 234.333,219.898 413.398,176.712L424.375,216.389C264.082,254.803 144.64,400.913 144.64,575.076C144.64,703.735 209.822,817.086 308.514,883.023L279.206,917.988Z"/>
<path d="M714.938,895.223L647.287,836.693L616.06,855.308L549.158,889.412L459.845,919.216L390.213,928.828L429.291,950.712L535.832,960.1L586.137,952.591L662.254,931.896L714.938,895.223Z"/>
<path d="M423.538,929.39C509.164,917.593 580.815,890.465 640.827,850.566C635.677,886.828 622.639,918.218 594.006,939.977C530.254,930.953 474.955,928.632 423.538,929.39Z" style="fill:url(#_Linear7);"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-52.3962,375.121,-375.121,-52.3962,471.134,384.463)"><stop offset="0" style="stop-color:rgb(255,176,44);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(255,73,2);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(28.6198,-84.8913,84.8913,28.6198,647.831,831.55)"><stop offset="0" style="stop-color:rgb(255,73,2);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(255,176,44);stop-opacity:1"/></linearGradient>
<image id="_Image4" width="153px" height="214px" xlink:href="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCADWAJkDAREAAhEBAxEB/8QAGQABAQEBAQEAAAAAAAAAAAAAAwACBwYF/8QAGBABAQEBAQAAAAAAAAAAAAAAAgABEhH/xAAbAQADAQADAQAAAAAAAAAAAAAAAQMCBAUGB//EABYRAQEBAAAAAAAAAAAAAAAAAAABEf/aAAwDAQACEQMRAD8A63fAX1BQFAUBQFAUBQFAUBQFAZShqQSUNyBSmpIJK0pIJqakgUptyCampIx1DWPS0XSqAoCgKAoCgKAoCgKAwlDUgkobkE1aVkClNuQbU1JAtTUkElNSQTU25GOptY9Vcd0KgKAoCgKAoCgKAoDDUNSCSmpIJqakgUptyCampIJKakgWpqSCSm3IJKakjHU2sewuM86oCgKAoCgKAoCgMJQ1IJqakgWpqSCSmpIJqbcgUpqSCSmpIJqbcgmpqSCSmpIx1PGse1uK8yoCgKAoCgKAoA2obkGlNuQLU1JBJTUkElPG5AtTUkElNSQSU1JBNTbkClNSQSU1JGOptY93cR5VQFAUBQFAUAbUNyCam3IJKakgmpqSBampIJKbcgmpqSBampIJKakgmptyBampINKakjHU8ax0C4byKgKAoCgLd8gDShuQTU25AtTVkElNuQTU1JBNTUkElNuQLU1JBNTUkElNSQLU25BJWlJBJTUkY6hrHRrhPGqAoCgLd8gDahuQSU1JAtTUkE1NuQSU1JBNTUkClNSQSU25BNTUkC1NSQSVpSQSUNyCatKSBSmpIx1DWOmXBeJUBQFu+QBtQ3IFqakgkpqSCam3IFqakgkpqSCampIJqbcgUpqSCampIJq0pIJKakgWptyCampIJKakjHU2sdRuveFUBbvkASUNyCSmpIJqakgkpqSBam3IJqakgkpqSCam3IFqakgmpqSCampIFq0pIJKbcgkpqSBSmpIJKakjHUNY6vde8Ct3yAJKG5BNTUkE1NSQLU1JBJTUkE1NuQLU1JBJWlJBJQpIJq03IFKakgkp4pIJqakgmptyBSmpIJqakgkpqSMdQeOt7vl1z5/INKG5BNTbkClPFJBJTUkE1NSQKU1JBJTbkE1NSQLU1JBtTbkC1aUkE1NSQSU1JAtTUkElNuQSU1JBJTUkC1NSRjqbWOupXWPnsgmpqSBSmpIJqbcgkpqSBampIJK0pIJKbcgWoUkE1aUkElNSQTU25ApTUkElNSQSU25AtTUkElNSQTU1JApTUkZ6g8dcautfPpBJTUkE1NSQLU25BJTUkE1aUkC1NuQSU1JBNTUkClNSQSU25BJTUkE1NSQSU1JAtTbkE1NSQSU8UkClNSQe77NtQHWErrXgJBJTUkE1NuQSU8UkClNSQTVpuQSU1JBJTUkC1NSQTU1JBJTbkE1NSQKU1JBJTUkElNuQLU1JBJTUkHu+zbUBQHU2rrnhJBJWlJAtTbkElNSQTU1JBJTbkC1NSQSU1JBNTUkElNuQKU1JBJTUkE1NSQSU1JAtTbkElNSQe77NtQFAUB01q694iQSU1JBNWm5BNTUkClNSQTU25BJTUkElNSQKU25BNTVkElNuQTU1JApTUkElNSQSU25B7vs21AUBQFAdIauC8ZIJKeNyCampIFKakgmp4pIJKbcgWpqSCSnikgmpqSCSm3IFKakgkpqSCampIFKakjG77NpQFAUBQFAdCauE8fIJKakgkpqSCampIFKakgkptyCSmpIFqakg0ptyBampIJqakgWpqSCSmpIxNpQFAUBQFAUB71q4bycgkpqSBampIJKakgmpqSCSm3IFqakgkpqSCSmpIJqbcgWrSkgkoUkYm0oCgKAoCgKAoD3CVxHl5AtTUkElNSQTU1JApTbkElNSQSU1JApTUkElNuQTU1JBJWlJGIaUBQFAUBQFAUBQHsmrivNyBSmpIJKakgkptyCatKyCSm3IFqFJBNWlJBJTUkElNuRiGlAUBQFAUBQFAUBQHrErjPPyCampIJKakgmpqSBatNyCShSQTU1JAtWlJBJQ3IzNpQFAUBQFAUBQFAUBQHp2rjujkElaUkClNSQTU25BJTUkElCkgWrSkgkpqSMwagKAoCgKAoCgKAoCgKA9ElQdPIFq0pIJKakgmobkC1aUkElNSQSU1JGYNQFAUBQFAUBQFAUBQFAUB9xqk6uQTU1JApTxSQTUNyBatKSDSmpIzBqAoCgKAoCgKAoCgKAoCgKA+u1TdfIFKcUkE1NuQTU1JBLZqSMwagKAoCgKAoCgKAoCgKAoCgKA/9k="/>
<linearGradient id="_Linear5" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-39.3403,137.423,-137.423,-39.3403,545.523,573.246)"><stop offset="0" style="stop-color:rgb(255,200,41);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(255,73,2);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear6" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.01113,-68.2054,68.2054,1.01113,482.996,741.463)"><stop offset="0" style="stop-color:white;stop-opacity:1"/><stop offset="1" style="stop-color:rgb(179,179,179);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear7" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-7.13599,-34.117,34.117,-7.13599,578.793,922.144)"><stop offset="0" style="stop-color:rgb(164,164,164);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(106,106,106);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,115 @@
// Propagate URL hash to CSS target class for elements with the same id or data-id.
window.addEventListener("hashchange", function (event) {
[
["remove", "oldURL"],
["add", "newURL"],
].forEach(([op, tense]) => {
const hash = new URL(event[tense]).hash.slice(1);
document
.querySelectorAll(`[data-id="${hash}"], [id="${hash}"]`)
.forEach((el) => el.classList[op]("target"));
});
});
// Navigate document items with cursor keys.
document.addEventListener("keydown", function (event) {
const target = document.querySelector("*:target");
const tbounds = target?.getBoundingClientRect();
const filters = {
ArrowUp: (_x, y) => y < tbounds.top,
ArrowDown: (_x, y) => y > tbounds.bottom,
ArrowLeft: (x, _y) => x < tbounds.left,
ArrowRight: (x, _y) => x > tbounds.right,
};
if (target && filters[event.key]) {
const elements = [...document.querySelectorAll(".item[id], .item *[id]")];
let minEl, minDist;
for (const el of elements) {
const elBounds = el.getBoundingClientRect();
if (
filters[event.key](
(elBounds.left + elBounds.right) / 2,
(elBounds.top + elBounds.bottom) / 2
)
) {
const elDist =
Math.abs(tbounds.x - elBounds.x) + Math.abs(tbounds.y - elBounds.y);
if (el != target && elDist < (minDist ?? Number.MAX_VALUE)) {
minEl = el;
minDist = elDist;
}
}
}
if (minEl) {
event.preventDefault();
location.href = `#${minEl.id}`;
}
}
});
// Navigate to item with id when it is clicked.
function clickId(e) {
e.stopPropagation();
const id = e.currentTarget.getAttribute("data-id") ?? e.currentTarget.id;
location.href = `#${id}`;
}
window.onload = () => {
// (Re-)set the value of input[data-dep-on] to conform to a value of another input[name="data-dep-on"].
document.querySelectorAll("input[dep-on]").forEach((element) => {
const onName = element.getAttribute("dep-on");
const onElement = document.getElementsByName(onName)[0];
const depMap = JSON.parse(element.getAttribute("dep-values") ?? "{}");
if (onElement && depMap) {
// On load.
element.value = depMap[onElement.value] ?? "";
// On change.
onElement.addEventListener(
"change",
(event) => (element.value = depMap[event.currentTarget.value] ?? "")
);
}
});
// Toggle display of input[data-display-when] when it requires a different input[type=checkbox] to be checked.
document.querySelectorAll("*[display-when]").forEach((element) => {
const whenElements = element
.getAttribute("display-when")
.split(",")
.flatMap((whenName) => [...document.getElementsByName(whenName.trim())]);
function update() {
const allChecked = whenElements.every((el) => el.checked);
element.classList[allChecked ? "remove" : "add"]("hidden");
}
// On load.
update();
// On change.
whenElements.forEach((whenElement) =>
whenElement.addEventListener("change", update)
);
});
// Persist input value in local storage.
document
.querySelectorAll("input[type=checkbox][persist]")
.forEach((element) => {
const prefix = element.getAttribute("persist");
const name = element.getAttribute("name");
const key = `docling-serve-${prefix}-${name}`;
element.checked = localStorage.getItem(key) === "true";
element.addEventListener("change", (event) =>
localStorage.setItem(key, event.target.checked)
);
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,429 @@
@import "pico.css";
@view-transition {
navigation: auto;
}
:root {
--pico-font-size: 16px;
--highlight-factor: 0.8;
--target: hsl(240, 100%, 34%);
--mark: hsl(29, 100%, 35%);
}
@media (prefers-color-scheme: dark) {
:root {
--highlight-factor: 1.5;
--target: hsl(240, 100%, 70%);
--mark: hsl(29, 100%, 70%);
}
}
/* Utilities. */
.hidden {
display: none;
}
.sticky-footer {
position: sticky;
bottom: 0;
padding-top: var(--pico-spacing);
background: var(--pico-background-color);
}
html {
scroll-behavior: smooth;
}
header {
position: relative;
display: flex;
gap: 5rem;
margin-bottom: 2rem;
> .title {
white-space: nowrap;
font-size: 2rem;
font-weight: 300;
line-height: 1.75;
img {
display: inline-block;
max-height: 0.8em;
margin: -0.2rem -0.2em 0.25rem -0.2em;
}
}
&.loading img {
animation: shake 0.5s ease-in-out alternate infinite;
scale: 1.5;
translate: 0 1.5rem;
}
> .version {
position: absolute;
left: 6.25rem;
bottom: -0.5rem;
padding: 0 0.25rem;
font-size: 0.65rem;
line-height: 1rem;
border: solid 1px var(--pico-color);
border-radius: 0.3rem;
}
@media (prefers-color-scheme: dark) {
--glow: hsl(29, 100%, 70%);
> .title {
text-shadow: 0 0 0.25rem white, 0 0 0.5rem var(--glow),
0 0 0.75rem var(--glow), 0 0 1rem var(--glow);
color: white;
img {
filter: drop-shadow(0 0 0.05rem white)
drop-shadow(0 0 0.1rem var(--glow))
drop-shadow(0 0 0.15rem var(--glow))
drop-shadow(0 0 0.2rem var(--glow));
}
}
> .version {
color: white;
border-color: white;
text-shadow: 0 0 0.05rem white, 0 0 0.1rem var(--glow),
0 0 0.15rem var(--glow), 0 0 0.2rem var(--glow);
box-shadow: 0 0 0.05rem white, 0 0 0.1rem var(--glow),
0 0 0.15rem var(--glow), 0 0 0.2rem var(--glow);
}
}
}
@keyframes shake {
50% {
transform: rotate(-20deg);
}
100% {
transform: rotate(20deg);
}
}
label > small {
margin-left: var(--pico-spacing);
opacity: 0.75;
}
/* Conversion results. */
.progress,
.fail {
margin-top: 3rem;
}
.fail {
color: var(--pico-del-color);
}
main.preview {
display: grid;
grid:
auto / 1fr 0.5rem minmax(20ch, 70ch) 0.5rem minmax(min-content, auto)
minmax(0.5rem, 1fr);
grid-auto-flow: dense;
align-content: start;
}
/* Header and task status. */
main.preview {
> header {
grid-column: 3;
padding: 0 0.5rem;
}
> .status {
grid-row: 2;
grid-column: 3;
display: inline-block;
margin: 0 0.5rem 3rem 0.5rem;
span {
display: inline-block;
min-width: calc(5 * var(--pico-spacing));
padding-right: calc(0.5 * var(--pico-spacing));
}
}
> .formats {
grid-row: 2;
grid-column: 5;
margin-bottom: 3rem;
display: flex;
align-items: flex-end;
gap: 1rem;
> .configDarkImg {
display: none;
grid-row: 2;
grid-column: 6;
margin-left: auto;
}
@media (prefers-color-scheme: dark) {
> .configDarkImg {
display: block;
}
}
}
}
/* Invert images in dark mode (option). */
@media (prefers-color-scheme: dark) {
main.preview:has(.configDarkImg > input:checked) {
--img-hover-border: white;
svg.page-image {
--mark: hsl(29, 100%, 70%)
}
image,
img {
filter: invert(1) hue-rotate(180deg) saturate(1.25);
}
}
}
/* Document contents. */
main.preview {
--img-hover-border: black;
*[id] {
scroll-margin-top: 20vh;
}
> .page {
position: relative;
display: grid;
grid-template-columns: subgrid;
grid-auto-flow: dense;
grid-column: 1 / span 6;
> .item {
grid-column: 3;
width: 100%;
min-height: 3rem;
max-height: fit-content;
margin: 0;
padding: 0.5rem;
text-align: justify;
background-color: var(--pico-background-color);
cursor: pointer;
&:hover {
filter: brightness(var(--highlight-factor));
}
&.target {
outline: 2px solid var(--target);
z-index: 10;
}
}
> .item.void {
visibility: hidden;
}
> .item.annotated {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
/* Formatting. */
.bold {
font-weight: bold;
}
.italic {
font-style: italic;
}
.underline {
text-decoration: underline;
}
.strikethrough {
text-decoration: line-through;
}
.underline.strikethrough {
text-decoration: underline line-through;
}
.sub {
font-size: smaller;
vertical-align: sub;
}
.super {
font-size: smaller;
vertical-align: super;
}
/* Items out of content layer. */
> .item:not(.body),
> .item-markers:not(.body) {
opacity: 0.5;
}
> li.item {
list-style-type: none;
}
> .item.caption {
padding: 0.5rem 1.5rem;
font-size: 0.9rem;
}
> .item.table {
min-width: 0;
overflow-x: auto;
table {
font-size: 0.75rem;
border-collapse: collapse;
td {
vertical-align: top;
}
td.header {
font-weight: bold;
background-color: var(--pico-code-background-color);
}
td.target {
outline: solid 2px var(--target);
}
}
}
.annotation {
margin: 0;
&::before {
content: attr(data-kind);
opacity: 0.7;
}
&,
* {
font-size: 0.9rem;
color: var(--mark);
}
}
.annotation[data-kind="description"],
code.annotation {
white-space: pre-line;
}
.annotation[data-kind="classification"] {
width: fit-content;
}
> .item-markers {
position: relative;
grid-column: 2;
padding-top: 0.125rem;
padding-right: 0.5rem;
display: flex;
flex-direction: column;
align-items: flex-end;
font-family: monospace;
font-size: 0.675rem;
line-height: 1.25;
letter-spacing: 0;
color: var(--mark);
white-space: nowrap;
border-top: solid 1px var(--mark);
> * {
margin-right: 0.5rem;
}
> a {
padding: 0.125rem 0.25rem;
margin-top: 0.5rem;
border-radius: 0.125rem;
color: var(--pico-contrast-inverse);
background-color: var(--target);
text-decoration: none;
}
> a:hover {
filter: brightness(--highlight-factor);
}
&:not(.target) > a {
display: none;
}
&.group,
&.grouped {
border-left: 1px dashed var(--mark);
}
&.group {
margin-top: 0.5rem;
border-top: none;
}
}
> .page-marker {
grid-column: 4;
&.border {
transform: translateY(-1px);
border-top: solid 1px var(--mark);
}
}
> svg.page-image {
--mark: hsl(29, 100%, 35%);
grid-column: 5;
position: sticky;
top: 0.5rem;
width: 100%;
max-height: calc(100vh - 1rem);
outline: 1px solid var(--mark);
rect {
stroke: var(--mark);
stroke-width: 1px;
fill: var(--target);
fill-opacity: 0.0001; /* To activate hover. */
cursor: pointer;
&:hover {
filter: brightness(0.8);
fill-opacity: 0.1;
stroke: var(--img-hover-border);
stroke-width: 3px;
}
}
rect.target {
stroke: var(--target);
stroke-width: 3px;
stroke-dasharray: none;
}
text {
font-size: 0.675rem;
color: var(--mark);
&.top-no {
alignment-baseline: hanging;
}
}
}
}
}

20
docling_serve/ui/svg.py Normal file
View File

@@ -0,0 +1,20 @@
from pyjsx import JSX # type: ignore
def _tag(name: str):
def factory(children, **args) -> JSX:
props = " ".join([f'{k}="{v}"' for k, v in args.items()])
if children:
child_renders = "".join([str(c) for c in children])
return f"<{name} {props}>{child_renders}</{name}>"
else:
return f"<{name} {props} />"
return factory
image = _tag("image")
path = _tag("path")
rect = _tag("rect")
text = _tag("text")

View File

@@ -0,0 +1,76 @@
from fastapi import WebSocket
from docling_jobkit.datamodel.task_meta import TaskStatus
from docling_jobkit.orchestrators.base_notifier import BaseNotifier
from docling_jobkit.orchestrators.base_orchestrator import BaseOrchestrator
from docling_serve.datamodel.responses import (
MessageKind,
TaskStatusResponse,
WebsocketMessage,
)
class WebsocketNotifier(BaseNotifier):
def __init__(self, orchestrator: BaseOrchestrator):
super().__init__(orchestrator)
self.task_subscribers: dict[str, set[WebSocket]] = {}
async def add_task(self, task_id: str):
self.task_subscribers[task_id] = set()
async def remove_task(self, task_id: str):
if task_id in self.task_subscribers:
for websocket in self.task_subscribers[task_id]:
await websocket.close()
del self.task_subscribers[task_id]
async def notify_task_subscribers(self, task_id: str):
if task_id not in self.task_subscribers:
raise RuntimeError(f"Task {task_id} does not have a subscribers list.")
try:
# Get task status from Redis or RQ directly instead of in-memory registry
task = await self.orchestrator.task_status(task_id=task_id)
task_queue_position = await self.orchestrator.get_queue_position(task_id)
msg = TaskStatusResponse(
task_id=task.task_id,
task_type=task.task_type,
task_status=task.task_status,
task_position=task_queue_position,
task_meta=task.processing_meta,
)
for websocket in self.task_subscribers[task_id]:
await websocket.send_text(
WebsocketMessage(
message=MessageKind.UPDATE, task=msg
).model_dump_json()
)
if task.is_completed():
await websocket.close()
except Exception as e:
# Log the error but don't crash the notifier
import logging
_log = logging.getLogger(__name__)
_log.error(f"Error notifying subscribers for task {task_id}: {e}")
async def notify_queue_positions(self):
"""Notify all subscribers of pending tasks about queue position updates."""
for task_id in self.task_subscribers.keys():
try:
# Check task status directly from Redis or RQ
task = await self.orchestrator.task_status(task_id)
# Notify only pending tasks
if task.task_status == TaskStatus.PENDING:
await self.notify_task_subscribers(task_id)
except Exception as e:
# Log the error but don't crash the notifier
import logging
_log = logging.getLogger(__name__)
_log.error(
f"Error checking task {task_id} status for queue position notification: {e}"
)

View File

@@ -1,8 +1,11 @@
# Dolcing Serve documentation
# Docling Serve documentation
This documentation pages explore the webserver configurations, runtime options, deployment examples as well as development best practices.
- [Configuration](./configuration.md)
- [Advance usage](./usage.md)
- [Handling models](./models.md)
- [Usage](./usage.md)
- [Deployment](./deployment.md)
- [MCP](./mcp.md)
- [Development](./development.md)
- [`v1` migration](./v1_migration.md)

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

View File

@@ -7,7 +7,7 @@ server and the actual app-specific configurations.
> [!WARNING]
> When the server is running with `reload` or with multiple `workers`, uvicorn
> will spawn multiple subprocessed. This invalides all the values configured
> will spawn multiple subprocesses. This invalidates all the values configured
> via the CLI command line options. Please use environment variables in this
> type of deployments.
@@ -25,6 +25,9 @@ The following table shows the options which are propagated directly to the
| `--root-path` | `UVICORN_ROOT_PATH` | `""` | The root path is used to tell your app that it is being served to the outside world with some |
| `--proxy-headers` | `UVICORN_PROXY_HEADERS` | `true` | Enable/Disable X-Forwarded-Proto, X-Forwarded-For, X-Forwarded-Port to populate remote address info. |
| `--timeout-keep-alive` | `UVICORN_TIMEOUT_KEEP_ALIVE` | `60` | Timeout for the server response. |
| `--ssl-certfile` | `UVICORN_SSL_CERTFILE` | | SSL certificate file. |
| `--ssl-keyfile` | `UVICORN_SSL_KEYFILE` | | SSL key file. |
| `--ssl-keyfile-password` | `UVICORN_SSL_KEYFILE_PASSWORD` | | SSL keyfile password. |
## Docling Serve configuration
@@ -33,8 +36,85 @@ THe following table describes the options to configure the Docling Serve app.
| CLI option | ENV | Default | Description |
| -----------|-----|---------|-------------|
| `--artifacts-path` | `DOCLING_SERVE_ARTIFACTS_PATH` | unset | If set to a valid directory, the model weights will be loaded from this path |
| | `DOCLING_SERVE_STATIC_PATH` | unset | If set to a valid directory, the static assets for the docs and UI will be loaded from this path |
| | `DOCLING_SERVE_SCRATCH_PATH` | | If set, this directory will be used as scratch workspace, e.g. storing the results before they get requested. If unset, a temporary created is created for this purpose. |
| `--enable-ui` | `DOCLING_SERVE_ENABLE_UI` | `false` | Enable the demonstrator UI. |
| | `DOCLING_SERVE_SHOW_VERSION_INFO` | `true` | If enabled, the `/version` endpoint will provide the Docling package versions, otherwise it will return a forbidden 403 error. |
| | `DOCLING_SERVE_ENABLE_REMOTE_SERVICES` | `false` | Allow pipeline components making remote connections. For example, this is needed when using a vision-language model via APIs. |
| | `DOCLING_SERVE_ALLOW_EXTERNAL_PLUGINS` | `false` | Allow the selection of third-party plugins. |
| | `DOCLING_SERVE_SINGLE_USE_RESULTS` | `true` | If true, results can be accessed only once. If false, the results accumulate in the scratch directory. |
| | `DOCLING_SERVE_RESULT_REMOVAL_DELAY` | `300` | When `DOCLING_SERVE_SINGLE_USE_RESULTS` is active, this is the delay before results are removed from the task registry. |
| | `DOCLING_SERVE_MAX_DOCUMENT_TIMEOUT` | `604800` (7 days) | The maximum time for processing a document. |
| | `DOCLING_SERVE_MAX_NUM_PAGES` | | The maximum number of pages for a document to be processed. |
| | `DOCLING_SERVE_MAX_FILE_SIZE` | | The maximum file size for a document to be processed. |
| | `DOCLING_SERVE_SYNC_POLL_INTERVAL` | `2` | Number of seconds to sleep between polling the task status in the sync endpoints. |
| | `DOCLING_SERVE_MAX_SYNC_WAIT` | `120` | Max number of seconds a synchronous endpoint is waiting for the task completion. |
| | `DOCLING_SERVE_LOAD_MODELS_AT_BOOT` | `True` | If enabled, the models for the default options will be loaded at boot. |
| | `DOCLING_SERVE_OPTIONS_CACHE_SIZE` | `2` | How many DocumentConveter objects (including their loaded models) to keep in the cache. |
| | `DOCLING_SERVE_QUEUE_MAX_SIZE` | | Size of the pages queue. Potentially so many pages opened at the same time. |
| | `DOCLING_SERVE_OCR_BATCH_SIZE` | | Batch size for the OCR stage. |
| | `DOCLING_SERVE_LAYOUT_BATCH_SIZE` | | Batch size for the layout detection stage. |
| | `DOCLING_SERVE_TABLE_BATCH_SIZE` | | Batch size for the table structure stage. |
| | `DOCLING_SERVE_BATCH_POLLING_INTERVAL_SECONDS` | | Wait time for gathering pages before starting a stage processing. |
| | `DOCLING_SERVE_CORS_ORIGINS` | `["*"]` | A list of origins that should be permitted to make cross-origin requests. |
| | `DOCLING_SERVE_CORS_METHODS` | `["*"]` | A list of HTTP methods that should be allowed for cross-origin requests. |
| | `DOCLING_SERVE_CORS_HEADERS` | `["*"]` | A list of HTTP request headers that should be supported for cross-origin requests. |
| | `DOCLING_SERVE_API_KEY` | | If specified, all the API requests must contain the header `X-Api-Key` with this value. |
| | `DOCLING_SERVE_ENG_KIND` | `local` | The compute engine to use for the async tasks. Possible values are `local`, `rq` and `kfp`. See below for more configurations of the engines. |
### Docling configuration
Some Docling settings, mostly about performance, are exposed as environment variable which can be used also when running Docling Serve.
| ENV | Default | Description |
| ----|---------|-------------|
| `DOCLING_NUM_THREADS` | `4` | Number of concurrent threads used for the `torch` CPU execution. |
| `DOCLING_DEVICE` | | Device used for the model execution. Valid values are `cpu`, `cuda`, `mps`. When unset, the best device is chosen. For CUDA-enabled environments, you can choose which GPU using the syntax `cuda:0`, `cuda:1`, ... |
| `DOCLING_PERF_PAGE_BATCH_SIZE` | `4` | Number of pages processed in the same batch. |
| `DOCLING_PERF_ELEMENTS_BATCH_SIZE` | `8` | Number of document items/elements processed in the same batch during enrichment. |
| `DOCLING_DEBUG_PROFILE_PIPELINE_TIMINGS` | `false` | When enabled, Docling will provide detailed timings information. |
### Compute engine
Docling Serve can be deployed with several possible of compute engine.
The selected compute engine will be running all the async jobs.
#### Local engine
The following table describes the options to configure the Docling Serve local engine.
| ENV | Default | Description |
|-----|---------|-------------|
| `DOCLING_SERVE_ENG_LOC_NUM_WORKERS` | 2 | Number of workers/threads processing the incoming tasks. |
| `DOCLING_SERVE_ENG_LOC_SHARE_MODELS` | False | If true, each process will share the same models among all thread workers. Otherwise, one instance of the models is allocated for each worker thread. |
#### RQ engine
The following table describes the options to configure the Docling Serve RQ engine.
| ENV | Default | Description |
|-----|---------|-------------|
| `DOCLING_SERVE_ENG_RQ_REDIS_URL` | (required) | The connection Redis url, e.g. `redis://localhost:6373/` |
| `DOCLING_SERVE_ENG_RQ_RESULTS_PREFIX` | `docling:results` | The prefix used for storing the results in Redis. |
| `DOCLING_SERVE_ENG_RQ_SUB_CHANNEL` | `docling:updates` | The channel key name used for storing communicating updates between the workers and the orchestrator. |
#### KFP engine
The following table describes the options to configure the Docling Serve KFP engine.
| ENV | Default | Description |
|-----|---------|-------------|
| `DOCLING_SERVE_ENG_KFP_ENDPOINT` | | Must be set to the Kubeflow Pipeline endpoint. When using the in-cluster deployment, make sure to use the cluster endpoint, e.g. `https://NAME.NAMESPACE.svc.cluster.local:8888` |
| `DOCLING_SERVE_ENG_KFP_TOKEN` | | The authentication token for KFP. For in-cluster deployment, the app will load automatically the token of the ServiceAccount. |
| `DOCLING_SERVE_ENG_KFP_CA_CERT_PATH` | | Path to the CA certificates for the KFP endpoint. For in-cluster deployment, the app will load automatically the internal CA. |
| `DOCLING_SERVE_ENG_KFP_SELF_CALLBACK_ENDPOINT` | | If set, it enables internal callbacks providing status update of the KFP job. Usually something like `https://NAME.NAMESPACE.svc.cluster.local:5001/v1/callback/task/progress`. |
| `DOCLING_SERVE_ENG_KFP_SELF_CALLBACK_TOKEN_PATH` | | The token used for authenticating the progress callback. For cluster-internal workloads, use `/run/secrets/kubernetes.io/serviceaccount/token`. |
| `DOCLING_SERVE_ENG_KFP_SELF_CALLBACK_CA_CERT_PATH` | | The CA certificate for the progress callback. For cluster-inetrnal workloads, use `/var/run/secrets/kubernetes.io/serviceaccount/service-ca.crt`. |
#### Gradio UI
When using Gradio UI and using the option to output conversion as file, Gradio uses cache to prevent files to be overwritten ([more info here](https://www.gradio.app/guides/file-access#the-gradio-cache)), and we defined the cache clean frequency of one hour to clean files older than 10hours. For situations that files need to be available to download from UI older than 10 hours, there is two options:
- Increase the older age of files to clean [here](https://github.com/docling-project/docling-serve/blob/main/docling_serve/gradio_ui.py#L483) to suffice the age desired;
- Or set the clean up manually by defining the temporary dir of Gradio to use the same as `DOCLING_SERVE_SCRATCH_PATH` absolute path. This can be achieved by setting the environment variable `GRADIO_TEMP_DIR`, that can be done via command line `export GRADIO_TEMP_DIR="<same_path_as_scratch>"` or in `Dockerfile` using `ENV GRADIO_TEMP_DIR="<same_path_as_scratch>"`. After this, set the clean of cache to `None` [here](https://github.com/docling-project/docling-serve/blob/main/docling_serve/gradio_ui.py#L483). Now, the clean up of `DOCLING_SERVE_SCRATCH_PATH` will also clean the Gradio temporary dir. (If you use this option, please remember when reversing changes to remove the environment variable `GRADIO_TEMP_DIR`, otherwise may lead to files not be available to download).

View File

@@ -0,0 +1,21 @@
# AMD ROCm deployment
services:
docling-serve:
image: ghcr.io/docling-project/docling-serve-rocm:main
container_name: docling-serve
ports:
- "5001:5001"
environment:
DOCLING_SERVE_ENABLE_UI: "true"
ROCR_VISIBLE_DEVICES: "0" # https://rocm.docs.amd.com/en/latest/conceptual/gpu-isolation.html#rocr-visible-devices
## This section is for compatibility with older cards
# HSA_OVERRIDE_GFX_VERSION: "11.0.0"
# HSA_ENABLE_SDMA: "0"
devices:
- /dev/kfd:/dev/kfd
- /dev/dri:/dev/dri
group_add:
- 44 # video group GID from host
- 992 # render group GID from host
restart: always

View File

@@ -0,0 +1,20 @@
# NVIDIA CUDA deployment
services:
docling-serve:
image: ghcr.io/docling-project/docling-serve-cu126:main
container_name: docling-serve
ports:
- "5001:5001"
environment:
DOCLING_SERVE_ENABLE_UI: "true"
NVIDIA_VISIBLE_DEVICES: "all" # https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/docker-specialized.html
# deploy: # This section is for compatibility with Swarm
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
runtime: nvidia
restart: always

View File

@@ -0,0 +1,47 @@
kind: Deployment
apiVersion: apps/v1
metadata:
name: docling-serve
labels:
app: docling-serve
component: docling-serve-api
spec:
replicas: 1
selector:
matchLabels:
app: docling-serve
component: docling-serve-api
template:
metadata:
labels:
app: docling-serve
component: docling-serve-api
spec:
restartPolicy: Always
containers:
- name: api
resources:
limits:
cpu: 2
memory: 4Gi
requests:
cpu: 250m
memory: 1Gi
env:
- name: DOCLING_SERVE_ENABLE_UI
value: 'true'
- name: DOCLING_SERVE_ARTIFACTS_PATH
value: '/modelcache'
ports:
- name: http
containerPort: 5001
protocol: TCP
imagePullPolicy: Always
image: 'ghcr.io/docling-project/docling-serve-cpu'
volumeMounts:
- name: docling-model-cache
mountPath: /modelcache
volumes:
- name: docling-model-cache
persistentVolumeClaim:
claimName: docling-model-cache-pvc

View File

@@ -0,0 +1,33 @@
apiVersion: batch/v1
kind: Job
metadata:
name: docling-model-cache-load
spec:
selector: {}
template:
metadata:
name: docling-model-load
spec:
containers:
- name: loader
image: ghcr.io/docling-project/docling-serve-cpu:main
command:
- docling-tools
- models
- download
- '--output-dir=/modelcache'
- 'layout'
- 'tableformer'
- 'code_formula'
- 'picture_classifier'
- 'smolvlm'
- 'granite_vision'
- 'easyocr'
volumeMounts:
- name: docling-model-cache
mountPath: /modelcache
volumes:
- name: docling-model-cache
persistentVolumeClaim:
claimName: docling-model-cache-pvc
restartPolicy: Never

View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: docling-model-cache-pvc
spec:
accessModes:
- ReadWriteOnce
volumeMode: Filesystem
resources:
requests:
storage: 10Gi

View File

@@ -0,0 +1,192 @@
# This example deployment configures Docling Serve with a OAuth-Proxy sidecar and TLS termination
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: docling-serve
labels:
app: docling-serve
annotations:
serviceaccounts.openshift.io/oauth-redirectreference.primary: '{"kind":"OAuthRedirectReference","apiVersion":"v1","reference":{"kind":"Route","name":"docling-serve"}}'
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: docling-serve-oauth
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: system:auth-delegator
subjects:
- kind: ServiceAccount
name: docling-serve
namespace: docling
---
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: docling-serve
labels:
app: docling-serve
component: docling-serve-api
spec:
to:
kind: Service
name: docling-serve
port:
targetPort: oauth
tls:
termination: Reencrypt
---
apiVersion: v1
kind: Service
metadata:
name: docling-serve
labels:
app: docling-serve
component: docling-serve-api
annotations:
service.alpha.openshift.io/serving-cert-secret-name: docling-serve-tls
spec:
ports:
- name: oauth
port: 8443
targetPort: oauth
- name: http
port: 5001
targetPort: http
selector:
app: docling-serve
component: docling-serve-api
---
kind: Deployment
apiVersion: apps/v1
metadata:
name: docling-serve
labels:
app: docling-serve
component: docling-serve-api
spec:
replicas: 1
selector:
matchLabels:
app: docling-serve
component: docling-serve-api
template:
metadata:
labels:
app: docling-serve
component: docling-serve-api
spec:
restartPolicy: Always
serviceAccountName: docling-serve
containers:
- name: api
resources:
limits:
cpu: 2000m
memory: 4Gi
requests:
cpu: 800m
memory: 1Gi
readinessProbe:
httpGet:
path: /health
port: http
scheme: HTTPS
initialDelaySeconds: 10
timeoutSeconds: 2
periodSeconds: 5
successThreshold: 1
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: http
scheme: HTTPS
initialDelaySeconds: 3
timeoutSeconds: 4
periodSeconds: 10
successThreshold: 1
failureThreshold: 5
env:
- name: NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: DOCLING_SERVE_ENABLE_UI
value: 'true'
- name: DOCLING_SERVE_API_HOST
value: 'docling-serve.$(NAMESPACE).svc.cluster.local'
- name: UVICORN_SSL_CERTFILE
value: '/etc/tls/private/tls.crt'
- name: UVICORN_SSL_KEYFILE
value: '/etc/tls/private/tls.key'
ports:
- name: http
containerPort: 5001
protocol: TCP
volumeMounts:
- name: proxy-tls
mountPath: /etc/tls/private
imagePullPolicy: Always
image: 'ghcr.io/docling-project/docling-serve-cpu:fix-ui-with-https'
- name: oauth-proxy
resources:
limits:
cpu: 100m
memory: 256Mi
requests:
cpu: 100m
memory: 256Mi
readinessProbe:
httpGet:
path: /oauth/healthz
port: oauth
scheme: HTTPS
initialDelaySeconds: 5
timeoutSeconds: 1
periodSeconds: 5
successThreshold: 1
failureThreshold: 3
livenessProbe:
httpGet:
path: /oauth/healthz
port: oauth
scheme: HTTPS
initialDelaySeconds: 30
timeoutSeconds: 1
periodSeconds: 5
successThreshold: 1
failureThreshold: 3
ports:
- name: oauth
containerPort: 8443
protocol: TCP
imagePullPolicy: IfNotPresent
volumeMounts:
- name: proxy-tls
mountPath: /etc/tls/private
env:
- name: NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
image: 'registry.redhat.io/openshift4/ose-oauth-proxy:v4.13'
args:
- '--https-address=:8443'
- '--provider=openshift'
- '--openshift-service-account=docling-serve'
- '--upstream=https://docling-serve.$(NAMESPACE).svc.cluster.local:5001'
- '--upstream-ca=/var/run/secrets/kubernetes.io/serviceaccount/service-ca.crt'
- '--tls-cert=/etc/tls/private/tls.crt'
- '--tls-key=/etc/tls/private/tls.key'
- '--cookie-secret=SECRET'
- '--openshift-delegate-urls={"/": {"group":"route.openshift.io","resource":"routes","verb":"get","name":"docling-serve","namespace":"$(NAMESPACE)"}}'
- '--openshift-sar={"namespace":"$(NAMESPACE)","resource":"routes","resourceName":"docling-serve","verb":"get","resourceAPIGroup":"route.openshift.io"}'
- '--skip-auth-regex=''(^/health|^/docs)'''
volumes:
- name: proxy-tls
secret:
secretName: docling-serve-tls
defaultMode: 420

View File

@@ -0,0 +1,76 @@
# This example deployment configures Docling Serve with a Route + Sticky sessions, a Service and cpu image
---
kind: Route
apiVersion: route.openshift.io/v1
metadata:
name: docling-serve
labels:
app: docling-serve
component: docling-serve-api
annotations:
haproxy.router.openshift.io/disable_cookies: "false" # this annotation enables the sticky sessions
spec:
path: /
to:
kind: Service
name: docling-serve
port:
targetPort: http
tls:
termination: edge
insecureEdgeTerminationPolicy: Redirect
---
apiVersion: v1
kind: Service
metadata:
name: docling-serve
labels:
app: docling-serve
component: docling-serve-api
spec:
ports:
- name: http
port: 5001
targetPort: http
selector:
app: docling-serve
component: docling-serve-api
---
kind: Deployment
apiVersion: apps/v1
metadata:
name: docling-serve
labels:
app: docling-serve
component: docling-serve-api
spec:
replicas: 3
selector:
matchLabels:
app: docling-serve
component: docling-serve-api
template:
metadata:
labels:
app: docling-serve
component: docling-serve-api
spec:
restartPolicy: Always
containers:
- name: api
resources:
limits:
cpu: 1
memory: 4Gi
requests:
cpu: 250m
memory: 1Gi
env:
- name: DOCLING_SERVE_ENABLE_UI
value: 'true'
ports:
- name: http
containerPort: 5001
protocol: TCP
imagePullPolicy: Always
image: 'ghcr.io/docling-project/docling-serve'

View File

@@ -0,0 +1,192 @@
# This example deployment configures Docling Serve with a Service and RQ workers
# Create following secret
# kubectl create secret generic docling-serve-rq-secrets --from-literal=REDIS_PASSWORD=myredispassword --from-literal=RQ_REDIS_URL=redis://:myredispassword@docling-serve-redis-service:6373/
---
apiVersion: v1
kind: Service
metadata:
name: docling-serve
labels:
app: docling-serve
component: docling-serve-api
spec:
ports:
- name: http
port: 5001
targetPort: http
selector:
app: docling-serve
component: docling-serve-api
---
kind: Deployment
apiVersion: apps/v1
metadata:
name: docling-serve
labels:
app: docling-serve
component: docling-serve-api
spec:
replicas: 1
selector:
matchLabels:
app: docling-serve
component: docling-serve-api
template:
metadata:
labels:
app: docling-serve
component: docling-serve-api
spec:
restartPolicy: Always
containers:
- name: api
resources:
limits:
cpu: 1
memory: 8Gi
requests:
cpu: 250m
memory: 1Gi
env:
- name: DOCLING_SERVE_ENABLE_UI
value: 'true'
- name: DOCLING_SERVE_ENG_KIND
value: 'rq'
- name: DOCLING_SERVE_ENG_RQ_REDIS_URL
valueFrom:
secretKeyRef:
name: docling-serve-rq-secrets
key: RQ_REDIS_URL
ports:
- name: http
containerPort: 5001
protocol: TCP
imagePullPolicy: Always
image: 'ghcr.io/docling-project/docling-serve-cpu'
---
kind: Deployment
apiVersion: apps/v1
metadata:
name: docling-serve-rq-workers
labels:
app: docling-serve-rq-workers
component: docling-serve-rq-worker
spec:
replicas: 2
selector:
matchLabels:
app: docling-serve-rq-workers
component: docling-serve-rq-worker
template:
metadata:
labels:
app: docling-serve-rq-workers
component: docling-serve-rq-worker
spec:
restartPolicy: Always
containers:
- name: worker
resources:
limits:
cpu: 1
memory: 4Gi
requests:
cpu: 250m
memory: 1Gi
env:
- name: DOCLING_SERVE_ENG_KIND
value: 'rq'
- name: DOCLING_SERVE_ENG_RQ_REDIS_URL
valueFrom:
secretKeyRef:
name: docling-serve-rq-secrets
key: RQ_REDIS_URL
ports:
- name: http
containerPort: 5001
protocol: TCP
imagePullPolicy: Always
image: 'ghcr.io/docling-project/docling-serve-cpu'
command: ["docling-serve"]
args: ["rq-worker"]
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: docling-serve-redis
labels:
app: docling-serve-redis
spec:
replicas: 1
selector:
matchLabels:
app: docling-serve-redis
template:
metadata:
labels:
app: docling-serve-redis
spec:
restartPolicy: Always
terminationGracePeriodSeconds: 30
containers:
- name: redis
resources:
limits:
cpu: 1
memory: 1Gi
requests:
cpu: 250m
memory: 100Mi
image: redis:latest
command: ["redis-server"]
args:
- "--port"
- "6373"
- "--dir"
- "/mnt/redis/data"
- "--appendonly"
- "yes"
- "--requirepass"
- "$(REDIS_PASSWORD)"
ports:
- containerPort: 6373
env:
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: docling-serve-rq-secrets
key: REDIS_PASSWORD
volumeMounts:
- name: redis-data
mountPath: /mnt/redis/data
securityContext:
fsGroup: 1004
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefault
volumes:
- name: redis-data
emptyDir:
medium: Memory
sizeLimit: 2Gi
---
apiVersion: v1
kind: Service
metadata:
name: docling-serve-redis-service
labels:
app: docling-serve-redis
spec:
type: NodePort
ports:
- name: redis-service
protocol: TCP
port: 6373
targetPort: 6373
selector:
app: docling-serve-redis

View File

@@ -0,0 +1,58 @@
# This example deployment configures Docling Serve with a Service and cuda image
---
apiVersion: v1
kind: Service
metadata:
name: docling-serve
labels:
app: docling-serve
component: docling-serve-api
spec:
ports:
- name: http
port: 5001
targetPort: http
selector:
app: docling-serve
component: docling-serve-api
---
kind: Deployment
apiVersion: apps/v1
metadata:
name: docling-serve
labels:
app: docling-serve
component: docling-serve-api
spec:
replicas: 1
selector:
matchLabels:
app: docling-serve
component: docling-serve-api
template:
metadata:
labels:
app: docling-serve
component: docling-serve-api
spec:
restartPolicy: Always
containers:
- name: api
resources:
limits:
cpu: 1
memory: 4Gi
nvidia.com/gpu: 1 # Limit to one GPU
requests:
cpu: 250m
memory: 1Gi
nvidia.com/gpu: 1 # Limit to one GPU
env:
- name: DOCLING_SERVE_ENABLE_UI
value: 'true'
ports:
- name: http
containerPort: 5001
protocol: TCP
imagePullPolicy: Always
image: 'ghcr.io/docling-project/docling-serve-cu124'

View File

@@ -1,12 +1,330 @@
# Deployment
# Deployment Examples
## Kubernetes and OpenShift
This document provides deployment examples for running the application in different environments.
### Knative
Choose the deployment option that best fits your setup.
The following manifest will launch Docling Serve using Knative to expose the application
with an external ingress endpoint.
- **[Local GPU NVIDIA](#local-gpu-nvidia)**: For deploying the application locally on a machine with a supported NVIDIA GPU (using Docker Compose).
- **[Local GPU AMD](#local-gpu-amd)**: For deploying the application locally on a machine with a supported AMD GPU (using Docker Compose).
- **[OpenShift](#openshift)**: For deploying the application on an OpenShift cluster, designed for cloud-native environments.
```yaml
# TODO
---
## Local GPU NVIDIA
### Docker compose
Manifest example: [compose-nvidia.yaml](./deploy-examples/compose-nvidia.yaml)
This deployment has the following features:
- NVIDIA cuda enabled
Install the app with:
```sh
docker compose -f docs/deploy-examples/compose-nvidia.yaml up -d
```
For using the API:
```sh
# Make a test query
curl -X 'POST' \
"localhost:5001/v1/convert/source/async" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"sources": [{"kind": "http", "url": "https://arxiv.org/pdf/2501.17887"}]
}'
```
<details>
<summary><b>Requirements</b></summary>
- debian/ubuntu/rhel/fedora/opensuse
- docker
- nvidia drivers >=550.54.14
- nvidia-container-toolkit
Docs:
- [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/supported-platforms.html)
- [CUDA Toolkit Release Notes](https://docs.nvidia.com/cuda/cuda-toolkit-release-notes/index.html#id6)
</details>
<details>
<summary><b>Steps</b></summary>
1. Check driver version and which GPU you want to use 0/1/2/n (and update [compose-nvidia.yaml](./deploy-examples/compose-nvidia.yaml) file or use `count: all`)
```sh
nvidia-smi
```
2. Check if the NVIDIA Container Toolkit is installed/updated
```sh
# debian
dpkg -l | grep nvidia-container-toolkit
```
```sh
# rhel
rpm -q nvidia-container-toolkit
```
NVIDIA Container Toolkit install steps can be found here:
<https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html>
3. Check which runtime is being used by Docker
```sh
# docker
docker info | grep -i runtime
```
4. If the default Docker runtime changes back from 'nvidia' to 'default' after restarting the Docker service (optional):
Backup the daemon.json file:
```sh
sudo cp /etc/docker/daemon.json /etc/docker/daemon.json.bak
```
Update the daemon.json file:
```sh
echo '{
"runtimes": {
"nvidia": {
"path": "nvidia-container-runtime"
}
},
"default-runtime": "nvidia"
}' | sudo tee /etc/docker/daemon.json > /dev/null
```
Restart the Docker service:
```sh
sudo systemctl restart docker
```
Confirm 'nvidia' is the default runtime used by Docker by repeating step 3.
5. Run the container:
```sh
docker compose -f docs/deploy-examples/compose-nvidia.yaml up -d
```
</details>
## Local GPU AMD
### Docker compose
Manifest example: [compose-amd.yaml](./deploy-examples/compose-amd.yaml)
This deployment has the following features:
- AMD rocm enabled
Install the app with:
```sh
docker compose -f docs/deploy-examples/compose-amd.yaml up -d
```
For using the API:
```sh
# Make a test query
curl -X 'POST' \
"localhost:5001/v1/convert/source/async" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"sources": [{"kind": "http", "url": "https://arxiv.org/pdf/2501.17887"}]
}'
```
<details>
<summary><b>Requirements</b></summary>
- debian/ubuntu/rhel/fedora/opensuse
- docker
- AMDGPU driver >=6.3
- AMD ROCm >=6.3
Docs:
- [AMD ROCm installation](https://rocm.docs.amd.com/projects/install-on-linux/en/latest/install/quick-start.html)
</details>
<details>
<summary><b>Steps</b></summary>
1. Check driver version and which GPU you want to use 0/1/2/n (and update [compose-amd.yaml](./deploy-examples/compose-amd.yaml) file)
```sh
rocm-smi --showdriverversion
rocminfo | grep -i "ROCm version"
```
2. Find both video group GID and render group GID from host (and update [compose-amd.yaml](./deploy-examples/compose-amd.yaml) file)
```sh
getent group video
getent group render
```
3. Build the image locally (and update [compose-amd.yaml](./deploy-examples/compose-amd.yaml) file)
```sh
make docling-serve-rocm-image
```
</details>
## OpenShift
### Simple deployment
Manifest example: [docling-serve-simple.yaml](./deploy-examples/docling-serve-simple.yaml)
This deployment example has the following features:
- Deployment configuration
- Service configuration
- NVIDIA cuda enabled
Install the app with:
```sh
oc apply -f docs/deploy-examples/docling-serve-simple.yaml
```
For using the API:
```sh
# Port-forward the service
oc port-forward svc/docling-serve 5001:5001
# Make a test query
curl -X 'POST' \
"localhost:5001/v1/convert/source/async" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"sources": [{"kind": "http", "url": "https://arxiv.org/pdf/2501.17887"}]
}'
```
### Multiple workers with RQ
Manifest example: [`docling-serve-rq-workers.yaml`](./deploy-examples/docling-serve-rq-workers.yaml)
This deployment example has the following features:
- Deployment configuration
- Service configuration
- Redis deployment
- Multiple (2 by default) worker Pods
Install the app with:
- create k8s secret:
```sh
kubectl create secret generic docling-serve-rq-secrets --from-literal=REDIS_PASSWORD=myredispassword --from-literal=RQ_REDIS_URL=redis://:myredispassword@docling-serve-redis-service:6373/
```
- apply deployment manifest:
```sh
oc apply -f docs/deploy-examples/docling-serve-rq-workers.yaml
```
### Secure deployment with `oauth-proxy`
Manifest example: [docling-serve-oauth.yaml](./deploy-examples/docling-serve-oauth.yaml)
This deployment has the following features:
- TLS encryption between all components (using the cluster-internal CA authority).
- Authentication via a secure `oauth-proxy` sidecar.
- Expose the service using a secure OpenShift `Route`
Install the app with:
```sh
oc apply -f docs/deploy-examples/docling-serve-oauth.yaml
```
For using the API:
```sh
# Retrieve the endpoint
DOCLING_NAME=docling-serve
DOCLING_ROUTE="https://$(oc get routes ${DOCLING_NAME} --template={{.spec.host}})"
# Retrieve the authentication token
OCP_AUTH_TOKEN=$(oc whoami --show-token)
# Make a test query
curl -X 'POST' \
"${DOCLING_ROUTE}/v1/convert/source/async" \
-H "Authorization: Bearer ${OCP_AUTH_TOKEN}" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"sources": [{"kind": "http", "url": "https://arxiv.org/pdf/2501.17887"}]
}'
```
### ReplicaSets with `sticky sessions`
Manifest example: [docling-serve-replicas-w-sticky-sessions.yaml](./deploy-examples/docling-serve-replicas-w-sticky-sessions.yaml)
This deployment has the following features:
- Deployment configuration with 3 replicas
- Service configuration
- Expose the service using a OpenShift `Route` and enables sticky sessions
Install the app with:
```sh
oc apply -f docs/deploy-examples/docling-serve-replicas-w-sticky-sessions.yaml
```
For using the API:
```sh
# Retrieve the endpoint
DOCLING_NAME=docling-serve
DOCLING_ROUTE="https://$(oc get routes $DOCLING_NAME --template={{.spec.host}})"
# Make a test query, store the cookie and taskid
task_id=$(curl -s -X 'POST' \
"${DOCLING_ROUTE}/v1/convert/source/async" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"sources": [{"kind": "http", "url": "https://arxiv.org/pdf/2501.17887"}]
}' \
-c cookies.txt | grep -oP '"task_id":"\K[^"]+')
```
```sh
# Grab the taskid and cookie to check the task status
curl -v -X 'GET' \
"${DOCLING_ROUTE}/v1/status/poll/$task_id?wait=0" \
-H "accept: application/json" \
-b "cookies.txt"
```

22
docs/examples.md Normal file
View File

@@ -0,0 +1,22 @@
# Examples
## Split processing
The example of provided of split processing demonstrates how to split a PDF into chunks of pages and send them for conversion. At the end, it concatenates all split pages into a single conversion `JSON`.
At beginning of file there's variables to be used (and modified) such as:
| Variable | Description |
| ---------|-------------|
| `path_to_pdf`| Path to PDF file to be split |
| `pages_per_file`| The number of pages per chunk to split PDF |
| `base_url`| Base url of the `docling-serve` host |
| `out_dir`| The output folder of each conversion `JSON` of split PDF and the final concatenated `JSON` |
The example follows the following logic:
- Get the number of pages of the `PDF`
- Based on the number of chunks of pages, send each chunk to conversion using `page_range` parameter
- Wait all conversions to finish
- Get all conversion results
- Save each conversion `JSON` result into a `JSON` file
- Concatenate all `JSONs` into a single `JSON` using `docling` concatenate method
- Save concatenated `JSON` into a `JSON` file

39
docs/mcp.md Normal file
View File

@@ -0,0 +1,39 @@
# Docling MCP in Docling Serve
The `docling-serve` container image includes all MCP (Model Communication Protocol) features starting from version v1.1.0. To leverage these features, you simply need to use a different entrypoint—no custom image builds or additional installations are required. The image provides the `docling-mcp-server` executable, which enables MCP functionality out of the box as of version v1.1.0 ([changelog](https://github.com/docling-project/docling-serve/blob/624f65d41b734e8b39ff267bc8bf6e766c376d6d/CHANGELOG.md)).
Read more on [Docling MCP](https://github.com/docling-project/docling-mcp) in its dedicated repository.
## Launching the MCP Service
By default, the container runs `docling-serve run` and exposes port 5001. To start the MCP service, override the entrypoint and specify your desired port mapping. For example:
```sh
podman run -p 8000:8000 quay.io/docling-project/docling-serve -- docling-mcp-server --transport streamable-http --port 8000 --host 0.0.0.0
```
This command starts the MCP server on port 8000, accessible at `http://localhost:8000/mcp`. Adjust the port and host as needed. Key arguments for `docling-mcp-server` include `--transport streamable-http` (HTTP transport for client connections), `--port <PORT>`, and `--host <HOST>` (use `0.0.0.0` to accept connections from any interface).
## Configuring MCP Clients
Most MCP-compatible clients, such as LM Studio and Claude Desktop, allow you to specify custom MCP server endpoints. The standard configuration uses a JSON block to define available MCP servers. For example, to connect to the Docling MCP server running on port 8000:
```json
{
"mcpServers": {
"docling": {
"url": "http://localhost:8000/mcp"
}
}
}
```
Insert this configuration in your client's settings where MCP servers are defined. Update the URL if you use a different port.
### LM Studio and Claude Desktop
Both LM Studio and Claude Desktop support MCP endpoints via configuration files or UI settings. Paste the above JSON block into the appropriate configuration section. For Claude Desktop, add the MCP server in the "Custom Model" or "MCP Server" section. For LM Studio, refer to its documentation for the location of the MCP server configuration.
### Other MCP Clients
Other clients, such as Continue Coding Assistant, also support custom MCP endpoints. Use the same configuration pattern: provide the MCP server URL ending with `/mcp` and ensure the port matches your container setup. See the [Docling MCP docs](https://github.com/docling-project/docling-mcp/tree/main/docs/integrations) for more details.

175
docs/models.md Normal file
View File

@@ -0,0 +1,175 @@
# Handling Models in Docling Serve
When enabling steps in Docling Serve that require extra models (such as picture classification, picture description, table detection, code recognition, formula extraction, or vision-language modules), you must ensure those models are available in the runtime environment. The standard container image includes only the default models. Any additional models must be downloaded and made available before use. If required models are missing, Docling Serve will raise runtime errors rather than downloading them automatically. This default choice wants to guarantee the system is not calling external services.
## Model Storage Location
Docling Serve loads models from the directory specified by the `DOCLING_SERVE_ARTIFACTS_PATH` environment variable. This path must be consistent across model download and runtime. When running with multiple workers or reload enabled, you must use the environment variable rather than the CLI argument for configuration [[source]](./configuration.md).
## Approaches for Making Extra Models Available
There are several ways to ensure required models are present:
### 1. Disable Local Models (Trigger Auto-Download)
You can configure the container to download all models at startup by clearing the artifacts path:
```sh
podman run -d -p 5001:5001 --name docling-serve \
-e DOCLING_SERVE_ARTIFACTS_PATH="" \
-e DOCLING_SERVE_ENABLE_UI=true \
quay.io/docling-project/docling-serve
```
This approach is simple for local development but not recommended for production, as it increases startup time and depends on network availability.
### 2. Build a Custom Image with Pre-Downloaded Models
You can create a new image that includes the required models:
```Dockerfile
FROM quay.io/docling-project/docling-serve
RUN docling-tools models download smolvlm
```
This method is suitable for production, as it ensures all models are present in the image and avoids runtime downloads.
### 3. Update the Entrypoint to Download Models Before Startup
You can override the entrypoint to download models before starting the service:
```sh
podman run -p 5001:5001 -e DOCLING_SERVE_ENABLE_UI=true \
quay.io/docling-project/docling-serve \
-- sh -c 'exec docling-tools models download smolvlm && exec docling-serve run'
```
This is useful for environments where you want to keep the base image unchanged but still automate model preparation.
### 4. Mount a Volume with Pre-Downloaded Models
Download models locally and mount them into the container:
```sh
# Download the models locally
docling-tools models download --all -o models
# Start the container with the local models folder
podman run -p 5001:5001 \
-v $(pwd)/models:/opt/app-root/src/models \
-e DOCLING_SERVE_ARTIFACTS_PATH="/opt/app-root/src/models" \
-e DOCLING_SERVE_ENABLE_UI=true \
quay.io/docling-project/docling-serve
```
This approach is robust for both local and production deployments, especially when using persistent storage.
## Kubernetes/Cluster Deployments
For Kubernetes or OpenShift clusters, the recommended approach is to use a PersistentVolumeClaim (PVC) for model storage, a Kubernetes Job to download models, and mount the volume into the deployment. This ensures models persist across pod restarts and scale-out scenarios.
### Example: PersistentVolumeClaim
```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: docling-model-cache-pvc
spec:
accessModes:
- ReadWriteOnce
volumeMode: Filesystem
resources:
requests:
storage: 10Gi
```
If you don't want to use default storage class, set your custom storage class with following:
```yaml
spec:
...
storageClassName: <Storage Class Name>
```
Manifest example: [docling-model-cache-pvc.yaml](./deploy-examples/docling-model-cache-pvc.yaml)
### Example: Model Download Job
```yaml
apiVersion: batch/v1
kind: Job
metadata:
name: docling-model-cache-load
spec:
template:
spec:
containers:
- name: loader
image: ghcr.io/docling-project/docling-serve-cpu:main
command:
- docling-tools
- models
- download
- '--output-dir=/modelcache'
- 'layout'
- 'tableformer'
- 'code_formula'
- 'picture_classifier'
- 'smolvlm'
- 'granite_vision'
- 'easyocr'
volumeMounts:
- name: docling-model-cache
mountPath: /modelcache
volumes:
- name: docling-model-cache
persistentVolumeClaim:
claimName: docling-model-cache-pvc
restartPolicy: Never
```
The job will mount the previously created persistent volume and execute command similar to how we would load models locally:
`docling-tools models download --output-dir <MOUNT-PATH> [LIST_OF_MODELS]`
In manifest, we specify desired models individually, or we can use `--all` parameter to download all models.
Manifest example: [docling-model-cache-job.yaml](./deploy-examples/docling-model-cache-job.yaml)
### Example: Deployment with Mounted Volume
```yaml
spec:
template:
spec:
containers:
- name: api
env:
- name: DOCLING_SERVE_ARTIFACTS_PATH
value: '/modelcache'
volumeMounts:
- name: docling-model-cache
mountPath: /modelcache
volumes:
- name: docling-model-cache
persistentVolumeClaim:
claimName: docling-model-cache-pvc
```
The value of `DOCLING_SERVE_ARTIFACTS_PATH` must match the mount path where models are stored.
Now, when docling-serve is executing tasks, the underlying docling installation will load model weights from mounted volume.
Manifest example: [docling-model-cache-deployment.yaml](./deploy-examples/docling-model-cache-deployment.yaml)
## Local Docker Execution
For local Docker or Podman execution, you can use any of the approaches above. Mounting a local directory with pre-downloaded models is the most reliable for repeated runs and avoids network dependencies.
## Troubleshooting and Best Practices
- If a required model is missing from the artifacts path, Docling Serve will raise a runtime error.
- Always ensure the value of `DOCLING_SERVE_ARTIFACTS_PATH` matches the directory where models are stored and mounted.
- For production and cluster environments, prefer persistent storage and pre-loading models via a dedicated job.
For more details and YAML manifest examples, see the [deployment documentation](./deployment.md).

View File

@@ -4,28 +4,99 @@ The API provides two endpoints: one for urls, one for files. This is necessary t
## Common parameters
On top of the source of file (see below), both endpoints support the same parameters, which are almost the same as the Docling CLI.
On top of the source of file (see below), both endpoints support the same parameters.
- `from_format` (List[str]): Input format(s) to convert from. Allowed values: `docx`, `pptx`, `html`, `image`, `pdf`, `asciidoc`, `md`. Defaults to all formats.
- `to_formats` (List[str]): Output format(s) to convert to. Allowed values: `md`, `json`, `html`, `text`, `doctags`. Defaults to `md`.
- `do_ocr` (bool): If enabled, the bitmap content will be processed using OCR. Defaults to `True`.
- `image_export_mode`: Image export mode for the document (only in case of JSON, Markdown or HTML). Allowed values: embedded, placeholder, referenced. Optional, defaults to `embedded`.
- `force_ocr` (bool): If enabled, replace any existing text with OCR-generated text over the full content. Defaults to `False`.
- `ocr_engine` (str): OCR engine to use. Allowed values: `easyocr`, `tesseract_cli`, `tesseract`, `rapidocr`, `ocrmac`. Defaults to `easyocr`.
- `ocr_lang` (List[str]): List of languages used by the OCR engine. Note that each OCR engine has different values for the language names. Defaults to empty.
- `pdf_backend` (str): PDF backend to use. Allowed values: `pypdfium2`, `dlparse_v1`, `dlparse_v2`. Defaults to `dlparse_v2`.
- `table_mode` (str): Table mode to use. Allowed values: `fast`, `accurate`. Defaults to `fast`.
- `abort_on_error` (bool): If enabled, abort on error. Defaults to false.
- `return_as_file` (boo): If enabled, return the output as a file. Defaults to false.
- `do_table_structure` (bool): If enabled, the table structure will be extracted. Defaults to true.
- `include_images` (bool): If enabled, images will be extracted from the document. Defaults to true.
- `images_scale` (float): Scale factor for images. Defaults to 2.0.
<!-- begin: parameters-docs -->
<h4>ConvertDocumentsRequestOptions</h4>
| Field Name | Type | Description |
|------------|------|-------------|
| `from_formats` | List[InputFormat] | Input format(s) to convert from. String or list of strings. Allowed values: `docx`, `pptx`, `html`, `image`, `pdf`, `asciidoc`, `md`, `csv`, `xlsx`, `xml_uspto`, `xml_jats`, `mets_gbs`, `json_docling`, `audio`, `vtt`. Optional, defaults to all formats. |
| `to_formats` | List[OutputFormat] | Output format(s) to convert to. String or list of strings. Allowed values: `md`, `json`, `html`, `html_split_page`, `text`, `doctags`. Optional, defaults to Markdown. |
| `image_export_mode` | ImageRefMode | Image export mode for the document (in case of JSON, Markdown or HTML). Allowed values: `placeholder`, `embedded`, `referenced`. Optional, defaults to Embedded. |
| `do_ocr` | bool | If enabled, the bitmap content will be processed using OCR. Boolean. Optional, defaults to true |
| `force_ocr` | bool | If enabled, replace existing text with OCR-generated text over content. Boolean. Optional, defaults to false. |
| `ocr_engine` | `ocr_engines_enum` | The OCR engine to use. String. Allowed values: `auto`, `easyocr`, `ocrmac`, `rapidocr`, `tesserocr`, `tesseract`. Optional, defaults to `easyocr`. |
| `ocr_lang` | List[str] or NoneType | List of languages used by the OCR engine. Note that each OCR engine has different values for the language names. String or list of strings. Optional, defaults to empty. |
| `pdf_backend` | PdfBackend | The PDF backend to use. String. Allowed values: `pypdfium2`, `dlparse_v1`, `dlparse_v2`, `dlparse_v4`. Optional, defaults to `dlparse_v4`. |
| `table_mode` | TableFormerMode | Mode to use for table structure, String. Allowed values: `fast`, `accurate`. Optional, defaults to accurate. |
| `table_cell_matching` | bool | If true, matches table cells predictions back to PDF cells. Can break table output if PDF cells are merged across table columns. If false, let table structure model define the text cells, ignore PDF cells. |
| `pipeline` | ProcessingPipeline | Choose the pipeline to process PDF or image files. |
| `page_range` | Tuple | Only convert a range of pages. The page number starts at 1. |
| `document_timeout` | float | The timeout for processing each document, in seconds. |
| `abort_on_error` | bool | Abort on error if enabled. Boolean. Optional, defaults to false. |
| `do_table_structure` | bool | If enabled, the table structure will be extracted. Boolean. Optional, defaults to true. |
| `include_images` | bool | If enabled, images will be extracted from the document. Boolean. Optional, defaults to true. |
| `images_scale` | float | Scale factor for images. Float. Optional, defaults to 2.0. |
| `md_page_break_placeholder` | str | Add this placeholder between pages in the markdown output. |
| `do_code_enrichment` | bool | If enabled, perform OCR code enrichment. Boolean. Optional, defaults to false. |
| `do_formula_enrichment` | bool | If enabled, perform formula OCR, return LaTeX code. Boolean. Optional, defaults to false. |
| `do_picture_classification` | bool | If enabled, classify pictures in documents. Boolean. Optional, defaults to false. |
| `do_picture_description` | bool | If enabled, describe pictures in documents. Boolean. Optional, defaults to false. |
| `picture_description_area_threshold` | float | Minimum percentage of the area for a picture to be processed with the models. |
| `picture_description_local` | PictureDescriptionLocal or NoneType | Options for running a local vision-language model in the picture description. The parameters refer to a model hosted on Hugging Face. This parameter is mutually exclusive with `picture_description_api`. |
| `picture_description_api` | PictureDescriptionApi or NoneType | API details for using a vision-language model in the picture description. This parameter is mutually exclusive with `picture_description_local`. |
| `vlm_pipeline_model` | VlmModelType or NoneType | Preset of local and API models for the `vlm` pipeline. This parameter is mutually exclusive with `vlm_pipeline_model_local` and `vlm_pipeline_model_api`. Use the other options for more parameters. |
| `vlm_pipeline_model_local` | VlmModelLocal or NoneType | Options for running a local vision-language model for the `vlm` pipeline. The parameters refer to a model hosted on Hugging Face. This parameter is mutually exclusive with `vlm_pipeline_model_api` and `vlm_pipeline_model`. |
| `vlm_pipeline_model_api` | VlmModelApi or NoneType | API details for using a vision-language model for the `vlm` pipeline. This parameter is mutually exclusive with `vlm_pipeline_model_local` and `vlm_pipeline_model`. |
<h4>VlmModelApi</h4>
| Field Name | Type | Description |
|------------|------|-------------|
| `url` | AnyUrl | Endpoint which accepts openai-api compatible requests. |
| `headers` | Dict[str, str] | Headers used for calling the API endpoint. For example, it could include authentication headers. |
| `params` | Dict[str, Any] | Model parameters. |
| `timeout` | float | Timeout for the API request. |
| `concurrency` | int | Maximum number of concurrent requests to the API. |
| `prompt` | str | Prompt used when calling the vision-language model. |
| `scale` | float | Scale factor of the images used. |
| `response_format` | ResponseFormat | Type of response generated by the model. |
| `temperature` | float | Temperature parameter controlling the reproducibility of the result. |
<h4>VlmModelLocal</h4>
| Field Name | Type | Description |
|------------|------|-------------|
| `repo_id` | str | Repository id from the Hugging Face Hub. |
| `prompt` | str | Prompt used when calling the vision-language model. |
| `scale` | float | Scale factor of the images used. |
| `response_format` | ResponseFormat | Type of response generated by the model. |
| `inference_framework` | InferenceFramework | Inference framework to use. |
| `transformers_model_type` | TransformersModelType | Type of transformers auto-model to use. |
| `extra_generation_config` | Dict[str, Any] | Config from https://huggingface.co/docs/transformers/en/main_classes/text_generation#transformers.GenerationConfig |
| `temperature` | float | Temperature parameter controlling the reproducibility of the result. |
<h4>PictureDescriptionApi</h4>
| Field Name | Type | Description |
|------------|------|-------------|
| `url` | AnyUrl | Endpoint which accepts openai-api compatible requests. |
| `headers` | Dict[str, str] | Headers used for calling the API endpoint. For example, it could include authentication headers. |
| `params` | Dict[str, Any] | Model parameters. |
| `timeout` | float | Timeout for the API request. |
| `concurrency` | int | Maximum number of concurrent requests to the API. |
| `prompt` | str | Prompt used when calling the vision-language model. |
<h4>PictureDescriptionLocal</h4>
| Field Name | Type | Description |
|------------|------|-------------|
| `repo_id` | str | Repository id from the Hugging Face Hub. |
| `prompt` | str | Prompt used when calling the vision-language model. |
| `generation_config` | Dict[str, Any] | Config from https://huggingface.co/docs/transformers/en/main_classes/text_generation#transformers.GenerationConfig |
<!-- end: parameters-docs -->
### Authentication
When authentication is activated (see the parameter `DOCLING_SERVE_API_KEY` in [configuration.md](./configuration.md)), all the API requests **must** provide the header `X-Api-Key` with the correct secret key.
## Convert endpoints
### Source endpoint
The endpoint is `/v1alpha/convert/source`, listening for POST requests of JSON payloads.
The endpoint is `/v1/convert/source`, listening for POST requests of JSON payloads.
On top of the above parameters, you must send the URL(s) of the document you want process with either the `http_sources` or `file_sources` fields.
The first is fetching URL(s) (optionally using with extra headers), the second allows to provide documents as base64-encoded strings.
@@ -56,7 +127,6 @@ Simple payload example:
"pdf_backend": "dlparse_v2",
"table_mode": "fast",
"abort_on_error": false,
"return_as_file": false,
},
"http_sources": [{"url": "https://arxiv.org/pdf/2206.01062"}]
}
@@ -70,7 +140,7 @@ Simple payload example:
```sh
curl -X 'POST' \
'http://localhost:5001/v1alpha/convert/source' \
'http://localhost:5001/v1/convert/source' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
@@ -99,7 +169,6 @@ curl -X 'POST' \
"pdf_backend": "dlparse_v2",
"table_mode": "fast",
"abort_on_error": false,
"return_as_file": false,
"do_table_structure": true,
"include_images": true,
"images_scale": 2
@@ -117,7 +186,7 @@ curl -X 'POST' \
import httpx
async_client = httpx.AsyncClient(timeout=60.0)
url = "http://localhost:5001/v1alpha/convert/source"
url = "http://localhost:5001/v1/convert/source"
payload = {
"options": {
"from_formats": ["docx", "pptx", "html", "image", "pdf", "asciidoc", "md", "xlsx"],
@@ -130,7 +199,6 @@ payload = {
"pdf_backend": "dlparse_v2",
"table_mode": "fast",
"abort_on_error": False,
"return_as_file": False,
},
"http_sources": [{"url": "https://arxiv.org/pdf/2206.01062"}]
}
@@ -169,7 +237,7 @@ cat <<EOF > /tmp/request_body.json
EOF
# 3. POST the request to the docling service
curl -X POST "localhost:5001/v1alpha/convert/source" \
curl -X POST "localhost:5001/v1/convert/source" \
-H "Content-Type: application/json" \
-d @/tmp/request_body.json
```
@@ -178,14 +246,14 @@ curl -X POST "localhost:5001/v1alpha/convert/source" \
### File endpoint
The endpoint is: `/v1alpha/convert/file`, listening for POST requests of Form payloads (necessary as the files are sent as multipart/form data). You can send one or multiple files.
The endpoint is: `/v1/convert/file`, listening for POST requests of Form payloads (necessary as the files are sent as multipart/form data). You can send one or multiple files.
<details>
<summary>CURL example:</summary>
```sh
curl -X 'POST' \
'http://127.0.0.1:5001/v1alpha/convert/file' \
'http://127.0.0.1:5001/v1/convert/file' \
-H 'accept: application/json' \
-H 'Content-Type: multipart/form-data' \
-F 'ocr_engine=easyocr' \
@@ -201,7 +269,6 @@ curl -X 'POST' \
-F 'abort_on_error=false' \
-F 'to_formats=md' \
-F 'to_formats=text' \
-F 'return_as_file=false' \
-F 'do_ocr=true'
```
@@ -214,7 +281,7 @@ curl -X 'POST' \
import httpx
async_client = httpx.AsyncClient(timeout=60.0)
url = "http://localhost:5001/v1alpha/convert/file"
url = "http://localhost:5001/v1/convert/file"
parameters = {
"from_formats": ["docx", "pptx", "html", "image", "pdf", "asciidoc", "md", "xlsx"],
"to_formats": ["md", "json", "html", "text", "doctags"],
@@ -226,7 +293,6 @@ parameters = {
"pdf_backend": "dlparse_v2",
"table_mode": "fast",
"abort_on_error": False,
"return_as_file": False
}
current_dir = os.path.dirname(__file__)
@@ -236,7 +302,7 @@ files = {
'files': ('2206.01062v1.pdf', open(file_path, 'rb'), 'application/pdf'),
}
response = await async_client.post(url, files=files, data={"parameters": json.dumps(parameters)})
response = await async_client.post(url, files=files, data=parameters)
assert response.status_code == 200, "Response should be 200 OK"
data = response.json()
@@ -244,6 +310,79 @@ data = response.json()
</details>
### Picture description options
When the picture description enrichment is activated, users may specify which model and which execution mode to use for this task. There are two choices for the execution mode: _local_ will run the vision-language model directly, _api_ will invoke an external API endpoint.
The local option is specified with:
```jsonc
{
"picture_description_local": {
"repo_id": "", // Repository id from the Hugging Face Hub.
"generation_config": {"max_new_tokens": 200, "do_sample": false}, // HF generation config.
"prompt": "Describe this image in a few sentences. ", // Prompt used when calling the vision-language model.
}
}
```
The possible values for `generation_config` are documented in the [Hugging Face text generation docs](https://huggingface.co/docs/transformers/en/main_classes/text_generation#transformers.GenerationConfig).
The api option is specified with:
```jsonc
{
"picture_description_api": {
"url": "", // Endpoint which accepts openai-api compatible requests.
"headers": {}, // Headers used for calling the API endpoint. For example, it could include authentication headers.
"params": {}, // Model parameters.
"timeout": 20, // Timeout for the API request.
"prompt": "Describe this image in a few sentences. ", // Prompt used when calling the vision-language model.
}
}
```
Example URLs are:
- `http://localhost:8000/v1/chat/completions` for the local vllm api, with example `picture_description_api`:
- the `HuggingFaceTB/SmolVLM-256M-Instruct` model
```json
{
"url": "http://localhost:8000/v1/chat/completions",
"params": {
"model": "HuggingFaceTB/SmolVLM-256M-Instruct",
"max_completion_tokens": 200,
}
}
```
- the `ibm-granite/granite-vision-3.2-2b` model
```json
{
"url": "http://localhost:8000/v1/chat/completions",
"params": {
"model": "ibm-granite/granite-vision-3.2-2b",
"max_completion_tokens": 200,
}
}
```
- `http://localhost:11434/v1/chat/completions` for the local Ollama api, with example `picture_description_api`:
- the `granite3.2-vision:2b` model
```json
{
"url": "http://localhost:11434/v1/chat/completions",
"params": {
"model": "granite3.2-vision:2b"
}
}
```
Note that when using `picture_description_api`, the server must be launched with `DOCLING_SERVE_ENABLE_REMOTE_SERVICES=true`.
## Response format
The response can be a JSON Document or a File.
@@ -271,9 +410,97 @@ The response can be a JSON Document or a File.
`processing_time` is the Docling processing time in seconds, and `timings` (when enabled in the backend) provides the detailed
timing of all the internal Docling components.
- If you set the parameter `return_as_file` to True, the response will be a zip file.
- If multiple files are generated (multiple inputs, or one input but multiple outputs with `return_as_file` True), the response will be a zip file.
- If you set the parameter `target` to the zip mode, the response will be a zip file.
- If multiple files are generated (multiple inputs, or one input but multiple outputs with the zip target mode), the response will be a zip file.
## Asynchronous API
TBA
Both `/v1/convert/source` and `/v1/convert/file` endpoints are available as asynchronous variants.
The advantage of the asynchronous endpoints is the possible to interrupt the connection, check for the progress update and fetch the result.
This approach is more resilient against network instabilities and allows the client application logic to easily interleave conversion with other tasks.
Launch an asynchronous conversion with:
- `POST /v1/convert/source/async` when providing the input as sources.
- `POST /v1/convert/file/async` when providing the input as multipart-form files.
The response format is a task detail:
```jsonc
{
"task_id": "<task_id>", // the task_id which can be used for the next operations
"task_status": "pending|started|success|failure", // the task status
"task_position": 1, // the position in the queue
"task_meta": null, // metadata e.g. how many documents are in the total job and how many have been converted
}
```
### Polling status
For checking the progress of the conversion task and wait for its completion, use the endpoint:
- `GET /v1/status/poll/{task_id}`
<details>
<summary>Example waiting loop:</summary>
```python
import time
import httpx
# ...
# response from the async task submission
task = response.json()
while task["task_status"] not in ("success", "failure"):
response = httpx.get(f"{base_url}/status/poll/{task['task_id']}")
task = response.json()
time.sleep(5)
```
<details>
### Subscribe with websockets
Using websocket you can get the client application being notified about updates of the conversion task.
To start the websocket connection, use the endpoint:
- `/v1/status/ws/{task_id}`
Websocket messages are JSON object with the following structure:
```jsonc
{
"message": "connection|update|error", // type of message being sent
"task": {}, // the same content of the task description
"error": "", // description of the error
}
```
<details>
<summary>Example websocket usage:</summary>
```python
from websockets.sync.client import connect
uri = f"ws://{base_url}/v1/status/ws/{task['task_id']}"
with connect(uri) as websocket:
for message in websocket:
try:
payload = json.loads(message)
if payload["message"] == "error":
break
if payload["message"] == "update" and payload["task"]["task_status"] in ("success", "failure"):
break
except:
break
```
</details>
### Fetch results
When the task is completed, the result can be fetched with the endpoint:
- `GET /v1/result/{task_id}`

80
docs/v1_migration.md Normal file
View File

@@ -0,0 +1,80 @@
# Migration to the `v1` API
Docling Serve from the initial prototype `v1alpha` API to the stable `v1` API.
This page provides simple instructions to upgrade your application to the new API.
## API changes
The breaking changes introduced in the `v1` release of Docling Serve are designed to provide a stable schema which
allows the project to provide new capabilities as new type of input sources, targets and also the definition of callback for event-driven applications.
### Endpoint names
All endpoints are renamed from `/v1alpha/` to `/v1/`.
### Sources
When using the `/v1/convert/source` endpoint, input documents have to be specified with the `sources: []` argument, which is replacing the usage of `file_sources` and `http_sources`.
Old version:
```jsonc
{
"options": {}, // conversion options
"file_sources": [ // input documents provided as base64-encoded strings
{"base64_string": "abc123...", "filename": "file.pdf"}
],
"http_sources": [ // input documents provided as http urls
{"url": "https://..."}
]
}
```
New version:
```jsonc
{
"options": {}, // conversion options
"sources": [
// input document provided as base64-encoded string
{"kind": "file", "base64_string": "abc123...", "filename": "file.pdf"},
// input document provided as http urls
{"kind": "http", "url": "https://..."},
]
}
```
### Targets
Switching between output formats, i.e. from the JSON inbody response to the zip archive response, users have to specify the `target` argument, which is replacing the usage of `options.return_as_file`.
Old version:
```jsonc
{
"options": {
"return_as_file": true // <-- to be removed
},
// ...
}
```
New version:
```jsonc
{
"options": {},
"target": {"kind": "zip"}, // <-- add this
// ...
}
```
## Continue with the old API
If you are not able to apply the changes above to your application, please consider pinning of the previous `v0.x` container images, e.g.
```sh
podman run -p 5001:5001 -e DOCLING_SERVE_ENABLE_UI=1 quay.io/docling-project/docling-serve:v0.16.1
```
_Note that the old prototype API will not be supported in new `v1.x` versions._

View File

@@ -0,0 +1,124 @@
import json
import time
from pathlib import Path
import httpx
from pydantic import BaseModel
from pypdf import PdfReader
from docling_core.types.doc.document import DoclingDocument
# Variables to use
path_to_pdf = Path("./tests/2206.01062v1.pdf")
pages_per_file = 4
base_url = "http://localhost:5001/v1"
out_dir = Path("examples/splitted_pdf/")
class ConvertedSplittedPdf(BaseModel):
task_id: str
conversion_finished: bool = False
result: dict | None = None
def get_task_result(task_id: str):
response = httpx.get(
f"{base_url}/result/{task_id}",
timeout=15,
)
return response.json()
def check_task_status(task_id: str):
response = httpx.get(f"{base_url}/status/poll/{task_id}", timeout=15)
task = response.json()
task_status = task["task_status"]
task_finished = False
if task_status == "success":
task_finished = True
if task_status in ("failure", "revoked"):
raise RuntimeError("A conversion failed")
time.sleep(5)
return task_finished
def post_file(file_path: Path, start_page: int, end_page: int):
payload = {
"to_formats": ["json"],
"image_export_mode": "placeholder",
"ocr": False,
"abort_on_error": False,
"page_range": [start_page, end_page],
}
files = {
"files": (file_path.name, file_path.open("rb"), "application/pdf"),
}
response = httpx.post(
f"{base_url}/convert/file/async",
files=files,
data=payload,
timeout=15,
)
task = response.json()
return task["task_id"]
def main():
filename = path_to_pdf
splitted_pdfs: list[ConvertedSplittedPdf] = []
with open(filename, "rb") as input_pdf_file:
pdf_reader = PdfReader(input_pdf_file)
total_pages = len(pdf_reader.pages)
for start_page in range(0, total_pages, pages_per_file):
task_id = post_file(
filename, start_page + 1, min(start_page + pages_per_file, total_pages)
)
splitted_pdfs.append(ConvertedSplittedPdf(task_id=task_id))
all_files_converted = False
while not all_files_converted:
found_conversion_running = False
for splitted_pdf in splitted_pdfs:
if not splitted_pdf.conversion_finished:
found_conversion_running = True
print("checking conversion status...")
splitted_pdf.conversion_finished = check_task_status(
splitted_pdf.task_id
)
if not found_conversion_running:
all_files_converted = True
for splitted_pdf in splitted_pdfs:
splitted_pdf.result = get_task_result(splitted_pdf.task_id)
files = []
for i, splitted_pdf in enumerate(splitted_pdfs):
json_content = json.dumps(
splitted_pdf.result.get("document").get("json_content"), indent=2
)
doc = DoclingDocument.model_validate_json(json_content)
filename = f"{out_dir}/splited_json_{i}.json"
doc.save_as_json(filename=filename)
files.append(filename)
docs = [DoclingDocument.load_from_json(filename=f) for f in files]
concate_doc = DoclingDocument.concatenate(docs=docs)
exp_json_file = Path(f"{out_dir}/concatenated.json")
concate_doc.save_as_json(exp_json_file)
print("Finished")
if __name__ == "__main__":
main()

BIN
img/fastapi-ui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -1,6 +1,7 @@
tesseract
tesseract-devel
tesseract-langpack-eng
tesseract-osd
leptonica-devel
libglvnd-glx
glib2

View File

@@ -1,6 +1,6 @@
[project]
name = "docling-serve"
version = "0.6.0" # DO NOT EDIT, updated automatically
version = "1.8.0" # DO NOT EDIT, updated automatically
description = "Running Docling as a service"
license = {text = "MIT"}
authors = [
@@ -8,7 +8,6 @@ authors = [
{name="Guillaume Moutier", email="gmoutier@redhat.com"},
{name="Anil Vishnoi", email="avishnoi@redhat.com"},
{name="Panos Vagenas", email="pva@zurich.ibm.com"},
{name="Panos Vagenas", email="pva@zurich.ibm.com"},
{name="Christoph Auer", email="cau@zurich.ibm.com"},
{name="Peter Staar", email="taa@zurich.ibm.com"},
]
@@ -23,15 +22,21 @@ readme = "README.md"
classifiers = [
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
# "Development Status :: 5 - Production/Stable",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Typing :: Typed",
"Programming Language :: Python :: 3"
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
requires-python = ">=3.10"
dependencies = [
"docling~=2.25.1",
"fastapi[standard]~=0.115",
"docling~=2.38",
"docling-core>=2.45.0",
"docling-jobkit[kfp,rq,vlm]>=1.8.0,<2.0.0",
"fastapi[standard]<0.119.0", # ~=0.115
"httpx~=0.28",
"pydantic~=2.10",
"pydantic-settings~=2.4",
@@ -39,32 +44,34 @@ dependencies = [
"typer~=0.12",
"uvicorn[standard]>=0.29.0,<1.0.0",
"websockets~=14.0",
"scalar-fastapi>=1.0.3",
"docling-mcp>=1.0.0",
]
[project.optional-dependencies]
ui = [
"gradio~=5.9"
"python-jsx>=0.2.0",
]
tesserocr = [
"tesserocr~=2.7"
]
easyocr = [
"easyocr>=1.7",
]
rapidocr = [
"rapidocr-onnxruntime~=1.4; python_version<'3.13'",
"onnxruntime~=1.7",
"rapidocr (>=3.3,<4.0.0) ; python_version < '3.14'",
"onnxruntime (>=1.7.0,<2.0.0)",
]
cpu = [
"torch>=2.6.0",
"torchvision>=0.21.0",
]
cu124 = [
"torch>=2.6.0",
"torchvision>=0.21.0",
flash-attn = [
"flash-attn~=2.8.2; sys_platform == 'linux' and platform_machine == 'x86_64'"
]
[dependency-groups]
dev = [
"asgi-lifespan~=2.0",
"mypy~=1.11",
"pre-commit-uv~=4.1",
"pypdf>=6.0.0",
"pytest~=8.3",
"pytest-asyncio~=0.24",
"pytest-check~=2.4",
@@ -72,33 +79,110 @@ dev = [
"ruff>=0.9.6",
]
pypi = [
"torch>=2.7.1",
"torchvision>=0.22.1",
]
cpu = [
"torch>=2.7.1",
"torchvision>=0.22.1",
]
# cu124 = [
# "torch>=2.6.0",
# "torchvision>=0.21.0",
# ]
cu126 = [
"torch>=2.7.1",
"torchvision>=0.22.1",
]
cu128 = [
"torch>=2.7.1",
"torchvision>=0.22.1",
]
rocm = [
"torch>=2.7.1",
"torchvision>=0.22.1",
"pytorch-triton-rocm>=3.3.1 ; sys_platform == 'linux' and platform_machine == 'x86_64'",
]
[tool.uv]
package = true
default-groups = ["dev", "pypi"]
conflicts = [
[
{ extra = "cpu" },
{ extra = "cu124" },
{ group = "pypi" },
{ group = "cpu" },
# { group = "cu124" },
{ group = "cu126" },
{ group = "cu128" },
{ group = "rocm" },
],
]
environments = ["sys_platform != 'darwin' or platform_machine != 'x86_64'"]
override-dependencies = [
"urllib3~=2.0",
"xgrammar>=0.1.24"
]
[tool.uv.sources]
torch = [
{ index = "pytorch-cpu", extra = "cpu" },
{ index = "pytorch-cu124", extra = "cu124" },
{ index = "pytorch-pypi", group = "pypi" },
{ index = "pytorch-cpu", group = "cpu" },
# { index = "pytorch-cu124", group = "cu124", marker = "sys_platform == 'linux'" },
{ index = "pytorch-cu126", group = "cu126", marker = "sys_platform == 'linux'" },
{ index = "pytorch-cu128", group = "cu128", marker = "sys_platform == 'linux'" },
{ index = "pytorch-rocm", group = "rocm", marker = "sys_platform == 'linux'" },
]
torchvision = [
{ index = "pytorch-cpu", extra = "cpu" },
{ index = "pytorch-cu124", extra = "cu124" },
{ index = "pytorch-pypi", group = "pypi" },
{ index = "pytorch-cpu", group = "cpu" },
# { index = "pytorch-cu124", group = "cu124", marker = "sys_platform == 'linux'" },
{ index = "pytorch-cu126", group = "cu126", marker = "sys_platform == 'linux'" },
{ index = "pytorch-cu128", group = "cu128", marker = "sys_platform == 'linux'" },
{ index = "pytorch-rocm", group = "rocm", marker = "sys_platform == 'linux'" },
]
pytorch-triton-rocm = [
{ index = "pytorch-rocm", marker = "sys_platform == 'linux'" },
]
# docling-jobkit = { git = "https://github.com/docling-project/docling-jobkit/", rev = "main" }
# docling-jobkit = { path = "../docling-jobkit", editable = true }
[[tool.uv.index]]
name = "pytorch-pypi"
url = "https://pypi.org/simple"
explicit = true
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
# [[tool.uv.index]]
# name = "pytorch-cu124"
# url = "https://download.pytorch.org/whl/cu124"
# explicit = true
[[tool.uv.index]]
name = "pytorch-cu124"
url = "https://download.pytorch.org/whl/cu124"
name = "pytorch-cu126"
url = "https://download.pytorch.org/whl/cu126"
explicit = true
[[tool.uv.index]]
name = "pytorch-cu128"
url = "https://download.pytorch.org/whl/cu128"
explicit = true
[[tool.uv.index]]
name = "pytorch-rocm"
url = "https://download.pytorch.org/whl/rocm6.3"
explicit = true
[tool.setuptools.packages.find]
@@ -169,7 +253,7 @@ ignore = [
max-complexity = 15
[tool.ruff.lint.isort.sections]
"docling" = ["docling", "docling_core"]
"docling" = ["docling", "docling_core", "docling_jobkit"]
[tool.ruff.lint.isort]
combine-as-imports = true
@@ -195,6 +279,11 @@ module = [
"tesserocr.*",
"rapidocr_onnxruntime.*",
"requests.*",
"kfp.*",
"kfp_server_api.*",
"mlx_vlm.*",
"mlx.*",
"scalar_fastapi.*",
]
ignore_missing_imports = true

199
scripts/update_doc_usage.py Normal file
View File

@@ -0,0 +1,199 @@
import re
from typing import Annotated, Any, Union, get_args, get_origin
from pydantic import BaseModel
from docling_serve.datamodel.convert import ConvertDocumentsRequestOptions
DOCS_FILE = "docs/usage.md"
VARIABLE_WORDS: list[str] = [
"picture_description_local",
"vlm_pipeline_model",
"vlm",
"vlm_pipeline_model_api",
"ocr_engines_enum",
"easyocr",
"dlparse_v4",
"fast",
"picture_description_api",
"vlm_pipeline_model_local",
]
def format_variable_names(text: str) -> str:
"""Format specific words in description to be code-formatted."""
sorted_words = sorted(VARIABLE_WORDS, key=len, reverse=True)
escaped_words = [re.escape(word) for word in sorted_words]
for word in escaped_words:
pattern = rf"(?<!`)\b{word}\b(?!`)"
text = re.sub(pattern, f"`{word}`", text)
return text
def format_allowed_values_description(description: str) -> str:
"""Format description to code-format allowed values."""
# Regex pattern to find text after "Allowed values:"
match = re.search(r"Allowed values:(.+?)(?:\.|$)", description, re.DOTALL)
if match:
# Extract the allowed values
values_str = match.group(1).strip()
# Split values, handling both comma and 'and' separators
values = re.split(r"\s*(?:,\s*|\s+and\s+)", values_str)
# Remove any remaining punctuation and whitespace
values = [value.strip("., ") for value in values]
# Create code-formatted values
formatted_values = ", ".join(f"`{value}`" for value in values)
# Replace the original allowed values with formatted version
formatted_description = re.sub(
r"(Allowed values:)(.+?)(?:\.|$)",
f"\\1 {formatted_values}.",
description,
flags=re.DOTALL,
)
return formatted_description
return description
def _format_type(type_hint: Any) -> str:
"""Format type ccrrectly, like Annotation or Union."""
if get_origin(type_hint) is Annotated:
base_type = get_args(type_hint)[0]
return _format_type(base_type)
if hasattr(type_hint, "__origin__"):
origin = type_hint.__origin__
args = get_args(type_hint)
if origin is list:
return f"List[{_format_type(args[0])}]"
elif origin is dict:
return f"Dict[{_format_type(args[0])}, {_format_type(args[1])}]"
elif str(origin).__contains__("Union") or str(origin).__contains__("Optional"):
return " or ".join(_format_type(arg) for arg in args)
elif origin is None:
return "null"
if hasattr(type_hint, "__name__"):
return type_hint.__name__
return str(type_hint)
def _unroll_types(tp) -> list[type]:
"""
Unrolls typing.Union and typing.Optional types into a flat list of types.
"""
origin = get_origin(tp)
if origin is Union:
# Recursively unroll each type inside the Union
types = []
for arg in get_args(tp):
types.extend(_unroll_types(arg))
# Remove duplicates while preserving order
return list(dict.fromkeys(types))
else:
# If it's not a Union, just return it as a single-element list
return [tp]
def generate_model_doc(model: type[BaseModel]) -> str:
"""Generate documentation for a Pydantic model."""
models_stack = [model]
doc = ""
while models_stack:
current_model = models_stack.pop()
doc += f"<h4>{current_model.__name__}</h4>\n"
doc += "\n| Field Name | Type | Description |\n"
doc += "|------------|------|-------------|\n"
base_models = []
if hasattr(current_model, "__mro__"):
base_models = current_model.__mro__
else:
base_models = [current_model]
for base_model in base_models:
# Check if this is a Pydantic model
if hasattr(base_model, "model_fields"):
# Iterate through fields of this model
for field_name, field in base_model.model_fields.items():
# Extract description from Annotated field if possible
description = field.description or "No description provided."
description = format_allowed_values_description(description)
description = format_variable_names(description)
# Handle Annotated types
original_type = field.annotation
if get_origin(original_type) is Annotated:
# Extract base type and additional metadata
type_args = get_args(original_type)
base_type = type_args[0]
else:
base_type = original_type
field_type = _format_type(base_type)
field_type = format_variable_names(field_type)
doc += f"| `{field_name}` | {field_type} | {description} |\n"
for field_type in _unroll_types(base_type):
if issubclass(field_type, BaseModel):
models_stack.append(field_type)
# stop iterating the base classes
break
doc += "\n"
return doc
def update_documentation():
"""Update the documentation file with model information."""
doc_request = generate_model_doc(ConvertDocumentsRequestOptions)
with open(DOCS_FILE) as f:
content = f.readlines()
# Prepare to update the content
new_content = []
in_cp_section = False
for line in content:
if line.startswith("<!-- begin: parameters-docs -->"):
in_cp_section = True
new_content.append(line)
new_content.append(doc_request)
continue
if in_cp_section and line.strip() == "<!-- end: parameters-docs -->":
in_cp_section = False
if not in_cp_section:
new_content.append(line)
# Only write to the file if new_content is different from content
if "".join(new_content) != "".join(content):
with open(DOCS_FILE, "w") as f:
f.writelines(new_content)
print(f"Documentation updated in {DOCS_FILE}")
else:
print("No changes detected. Documentation file remains unchanged.")
if __name__ == "__main__":
update_documentation()

View File

@@ -6,17 +6,22 @@ import pytest
import pytest_asyncio
from pytest_check import check
from docling_serve.settings import docling_serve_settings
@pytest_asyncio.fixture
async def async_client():
async with httpx.AsyncClient(timeout=60.0) as client:
headers = {}
if docling_serve_settings.api_key:
headers["X-Api-Key"] = docling_serve_settings.api_key
async with httpx.AsyncClient(timeout=60.0, headers=headers) as client:
yield client
@pytest.mark.asyncio
async def test_convert_file(async_client):
"""Test convert single file to all outputs"""
url = "http://localhost:5001/v1alpha/convert/file"
url = "http://localhost:5001/v1/convert/file"
options = {
"from_formats": [
"docx",
@@ -37,7 +42,6 @@ async def test_convert_file(async_client):
"pdf_backend": "dlparse_v2",
"table_mode": "fast",
"abort_on_error": False,
"return_as_file": False,
}
current_dir = os.path.dirname(__file__)
@@ -47,9 +51,7 @@ async def test_convert_file(async_client):
"files": ("2206.01062v1.pdf", open(file_path, "rb"), "application/pdf"),
}
response = await async_client.post(
url, files=files, data={"options": json.dumps(options)}
)
response = await async_client.post(url, files=files, data=options)
assert response.status_code == 200, "Response should be 200 OK"
data = response.json()
@@ -92,16 +94,11 @@ async def test_convert_file(async_client):
msg=f'JSON document should contain \'{{\\n "schema_name": "DoclingDocument\'". Received: {safe_slice(data["document"]["json_content"])}',
)
# HTML check
check.is_in(
"html_content",
data.get("document", {}),
msg=f"Response should contain 'html_content' key. Received keys: {list(data.get('document', {}).keys())}",
)
if data.get("document", {}).get("html_content") is not None:
check.is_in(
'<!DOCTYPE html>\n<html lang="en">\n<head>',
"<!DOCTYPE html>\n<html>\n<head>",
data["document"]["html_content"],
msg=f"HTML document should contain '<!DOCTYPE html>\\n<html lang=\"en'>. Received: {safe_slice(data['document']['html_content'])}",
msg=f"HTML document should contain '<!DOCTYPE html>\\n<html>'. Received: {safe_slice(data['document']['html_content'])}",
)
# Text check
check.is_in(
@@ -123,7 +120,7 @@ async def test_convert_file(async_client):
)
if data.get("document", {}).get("doctags_content") is not None:
check.is_in(
"<document>\n<section_header_level_1><location>",
"<doctag><page_header><loc",
data["document"]["doctags_content"],
msg=f"DocTags document should contain '<document>\\n<section_header_level_1><location>'. Received: {safe_slice(data['document']['doctags_content'])}",
msg=f"DocTags document should contain '<doctag><page_header><loc'. Received: {safe_slice(data['document']['doctags_content'])}",
)

View File

@@ -0,0 +1,75 @@
import json
import time
from pathlib import Path
import httpx
import pytest
import pytest_asyncio
from docling_serve.settings import docling_serve_settings
@pytest_asyncio.fixture
async def async_client():
headers = {}
if docling_serve_settings.api_key:
headers["X-Api-Key"] = docling_serve_settings.api_key
async with httpx.AsyncClient(timeout=60.0, headers=headers) as client:
yield client
@pytest.mark.asyncio
async def test_convert_url(async_client):
"""Test convert URL to all outputs"""
base_url = "http://localhost:5001/v1"
payload = {
"to_formats": ["md", "json", "html"],
"image_export_mode": "placeholder",
"ocr": False,
"abort_on_error": False,
}
file_path = Path(__file__).parent / "2206.01062v1.pdf"
files = {
"files": (file_path.name, file_path.open("rb"), "application/pdf"),
}
for n in range(1):
response = await async_client.post(
f"{base_url}/convert/file/async", files=files, data=payload
)
assert response.status_code == 200, "Response should be 200 OK"
task = response.json()
print(json.dumps(task, indent=2))
while task["task_status"] not in ("success", "failure"):
response = await async_client.get(f"{base_url}/status/poll/{task['task_id']}")
assert response.status_code == 200, "Response should be 200 OK"
task = response.json()
print(f"{task['task_status']=}")
print(f"{task['task_position']=}")
time.sleep(2)
assert task["task_status"] == "success"
print(f"Task completed with status {task['task_status']=}")
result_resp = await async_client.get(f"{base_url}/result/{task['task_id']}")
assert result_resp.status_code == 200, "Response should be 200 OK"
result = result_resp.json()
print("Got result.")
assert "md_content" in result["document"]
assert result["document"]["md_content"] is not None
assert len(result["document"]["md_content"]) > 10
assert "html_content" in result["document"]
assert result["document"]["html_content"] is not None
assert len(result["document"]["html_content"]) > 10
assert "json_content" in result["document"]
assert result["document"]["json_content"] is not None
assert result["document"]["json_content"]["schema_name"] == "DoclingDocument"

View File

@@ -5,17 +5,22 @@ import pytest
import pytest_asyncio
from pytest_check import check
from docling_serve.settings import docling_serve_settings
@pytest_asyncio.fixture
async def async_client():
async with httpx.AsyncClient(timeout=60.0) as client:
headers = {}
if docling_serve_settings.api_key:
headers["X-Api-Key"] = docling_serve_settings.api_key
async with httpx.AsyncClient(timeout=60.0, headers=headers) as client:
yield client
@pytest.mark.asyncio
async def test_convert_url(async_client):
"""Test convert URL to all outputs"""
url = "http://localhost:5001/v1alpha/convert/source"
url = "http://localhost:5001/v1/convert/source"
payload = {
"options": {
"from_formats": [
@@ -37,9 +42,8 @@ async def test_convert_url(async_client):
"pdf_backend": "dlparse_v2",
"table_mode": "fast",
"abort_on_error": False,
"return_as_file": False,
},
"http_sources": [{"url": "https://arxiv.org/pdf/2206.01062"}],
"sources": [{"kind": "http", "url": "https://arxiv.org/pdf/2206.01062"}],
}
print(json.dumps(payload, indent=2))
@@ -93,9 +97,9 @@ async def test_convert_url(async_client):
)
if data.get("document", {}).get("html_content") is not None:
check.is_in(
'<!DOCTYPE html>\n<html lang="en">\n<head>',
"<!DOCTYPE html>\n<html>\n<head>",
data["document"]["html_content"],
msg=f"HTML document should contain '<!DOCTYPE html>\\n<html lang=\"en'>. Received: {safe_slice(data['document']['html_content'])}",
msg=f"HTML document should contain '<!DOCTYPE html>\\n<html>'. Received: {safe_slice(data['document']['html_content'])}",
)
# Text check
check.is_in(
@@ -117,7 +121,7 @@ async def test_convert_url(async_client):
)
if data.get("document", {}).get("doctags_content") is not None:
check.is_in(
"<document>\n<section_header_level_1><location>",
"<doctag><page_header><loc",
data["document"]["doctags_content"],
msg=f"DocTags document should contain '<document>\\n<section_header_level_1><location>'. Received: {safe_slice(data['document']['doctags_content'])}",
msg=f"DocTags document should contain '<doctag><page_header><loc'. Received: {safe_slice(data['document']['doctags_content'])}",
)

View File

@@ -6,31 +6,54 @@ import pytest
import pytest_asyncio
from websockets.sync.client import connect
from docling_serve.settings import docling_serve_settings
@pytest_asyncio.fixture
async def async_client():
async with httpx.AsyncClient(timeout=60.0) as client:
headers = {}
if docling_serve_settings.api_key:
headers["X-Api-Key"] = docling_serve_settings.api_key
async with httpx.AsyncClient(timeout=60.0, headers=headers) as client:
yield client
@pytest.mark.asyncio
async def test_convert_url(async_client: httpx.AsyncClient):
"""Test convert URL to all outputs"""
headers = {}
if docling_serve_settings.api_key:
headers["X-Api-Key"] = docling_serve_settings.api_key
doc_filename = Path("tests/2408.09869v5.pdf")
encoded_doc = base64.b64encode(doc_filename.read_bytes()).decode()
base_url = "http://localhost:5001/v1alpha"
base_url = "http://localhost:5001/v1"
payload = {
"options": {
"to_formats": ["md", "json"],
"image_export_mode": "placeholder",
"ocr": True,
"abort_on_error": False,
"return_as_file": False,
# "do_picture_description": True,
# "picture_description_api": {
# "url": "http://localhost:11434/v1/chat/completions",
# "params": {
# "model": "granite3.2-vision:2b",
# }
# },
# "picture_description_local": {
# "repo_id": "HuggingFaceTB/SmolVLM-256M-Instruct",
# },
},
# "http_sources": [{"url": "https://arxiv.org/pdf/2501.17887"}],
"file_sources": [{"base64_string": encoded_doc, "filename": doc_filename.name}],
# "sources": [{"kind": "http", "url": "https://arxiv.org/pdf/2501.17887"}],
"sources": [
{
"kind": "file",
"base64_string": encoded_doc,
"filename": doc_filename.name,
}
],
}
# print(json.dumps(payload, indent=2))
@@ -42,7 +65,13 @@ async def test_convert_url(async_client: httpx.AsyncClient):
task = response.json()
uri = f"ws://localhost:5001/v1alpha/status/ws/{task['task_id']}"
uri = f"ws://localhost:5001/v1/status/ws/{task['task_id']}?api_key={docling_serve_settings.api_key}"
with connect(uri) as websocket:
for message in websocket:
print(message)
result_resp = await async_client.get(f"{base_url}/result/{task['task_id']}")
assert result_resp.status_code == 200, "Response should be 200 OK"
result = result_resp.json()
print(f"{result['processing_time']=}")
assert result["processing_time"] > 1.0

View File

@@ -6,10 +6,15 @@ import httpx
import pytest
import pytest_asyncio
from docling_serve.settings import docling_serve_settings
@pytest_asyncio.fixture
async def async_client():
async with httpx.AsyncClient(timeout=60.0) as client:
headers = {}
if docling_serve_settings.api_key:
headers["X-Api-Key"] = docling_serve_settings.api_key
async with httpx.AsyncClient(timeout=60.0, headers=headers) as client:
yield client
@@ -25,20 +30,19 @@ async def test_convert_url(async_client):
"https://arxiv.org/pdf/2311.18481",
]
base_url = "http://localhost:5001/v1alpha"
base_url = "http://localhost:5001/v1"
payload = {
"options": {
"to_formats": ["md", "json"],
"image_export_mode": "placeholder",
"ocr": True,
"abort_on_error": False,
"return_as_file": False,
},
"http_sources": [{"url": random.choice(example_docs)}],
"sources": [{"kind": "http", "url": random.choice(example_docs)}],
}
print(json.dumps(payload, indent=2))
for n in range(5):
for n in range(3):
response = await async_client.post(
f"{base_url}/convert/source/async", json=payload
)
@@ -58,3 +62,60 @@ async def test_convert_url(async_client):
time.sleep(2)
assert task["task_status"] == "success"
@pytest.mark.asyncio
@pytest.mark.parametrize("include_converted_doc", [False, True])
async def test_chunk_url(async_client, include_converted_doc: bool):
"""Test chunk URL"""
example_docs = [
"https://arxiv.org/pdf/2311.18481",
]
base_url = "http://localhost:5001/v1"
payload = {
"sources": [{"kind": "http", "url": random.choice(example_docs)}],
"include_converted_doc": include_converted_doc,
}
response = await async_client.post(
f"{base_url}/chunk/hybrid/source/async", json=payload
)
assert response.status_code == 200, "Response should be 200 OK"
task = response.json()
print(json.dumps(task, indent=2))
while task["task_status"] not in ("success", "failure"):
response = await async_client.get(f"{base_url}/status/poll/{task['task_id']}")
assert response.status_code == 200, "Response should be 200 OK"
task = response.json()
print(f"{task['task_status']=}")
print(f"{task['task_position']=}")
time.sleep(2)
assert task["task_status"] == "success"
result_resp = await async_client.get(f"{base_url}/result/{task['task_id']}")
assert result_resp.status_code == 200, "Response should be 200 OK"
result = result_resp.json()
print("Got result.")
assert "chunks" in result
assert len(result["chunks"]) > 0
assert "documents" in result
assert len(result["documents"]) > 0
assert result["documents"][0]["status"] == "success"
if include_converted_doc:
assert result["documents"][0]["content"]["json_content"] is not None
assert (
result["documents"][0]["content"]["json_content"]["schema_name"]
== "DoclingDocument"
)
else:
assert result["documents"][0]["content"]["json_content"] is None

View File

@@ -1,4 +1,3 @@
import json
import os
import httpx
@@ -6,17 +5,22 @@ import pytest
import pytest_asyncio
from pytest_check import check
from docling_serve.settings import docling_serve_settings
@pytest_asyncio.fixture
async def async_client():
async with httpx.AsyncClient(timeout=60.0) as client:
headers = {}
if docling_serve_settings.api_key:
headers["X-Api-Key"] = docling_serve_settings.api_key
async with httpx.AsyncClient(timeout=60.0, headers=headers) as client:
yield client
@pytest.mark.asyncio
async def test_convert_file(async_client):
"""Test convert single file to all outputs"""
url = "http://localhost:5001/v1alpha/convert/file"
url = "http://localhost:5001/v1/convert/file"
options = {
"from_formats": [
"docx",
@@ -37,7 +41,6 @@ async def test_convert_file(async_client):
"pdf_backend": "dlparse_v2",
"table_mode": "fast",
"abort_on_error": False,
"return_as_file": False,
}
current_dir = os.path.dirname(__file__)
@@ -48,9 +51,7 @@ async def test_convert_file(async_client):
("files", ("2408.09869v5.pdf", open(file_path, "rb"), "application/pdf")),
]
response = await async_client.post(
url, files=files, data={"options": json.dumps(options)}
)
response = await async_client.post(url, files=files, data=options)
assert response.status_code == 200, "Response should be 200 OK"
# Check for zip file attachment

View File

@@ -3,17 +3,22 @@ import pytest
import pytest_asyncio
from pytest_check import check
from docling_serve.settings import docling_serve_settings
@pytest_asyncio.fixture
async def async_client():
async with httpx.AsyncClient(timeout=60.0) as client:
headers = {}
if docling_serve_settings.api_key:
headers["X-Api-Key"] = docling_serve_settings.api_key
async with httpx.AsyncClient(timeout=60.0, headers=headers) as client:
yield client
@pytest.mark.asyncio
async def test_convert_url(async_client):
"""Test convert URL to all outputs"""
url = "http://localhost:5001/v1alpha/convert/source"
url = "http://localhost:5001/v1/convert/source"
payload = {
"options": {
"from_formats": [
@@ -35,12 +40,12 @@ async def test_convert_url(async_client):
"pdf_backend": "dlparse_v2",
"table_mode": "fast",
"abort_on_error": False,
"return_as_file": False,
},
"http_sources": [
{"url": "https://arxiv.org/pdf/2206.01062"},
{"url": "https://arxiv.org/pdf/2408.09869"},
"sources": [
{"kind": "http", "url": "https://arxiv.org/pdf/2206.01062"},
{"kind": "http", "url": "https://arxiv.org/pdf/2408.09869"},
],
"target": {"kind": "zip"},
}
response = await async_client.post(url, json=payload)

View File

@@ -0,0 +1,93 @@
import json
import time
import httpx
import pytest
import pytest_asyncio
from pytest_check import check
from docling_serve.settings import docling_serve_settings
@pytest_asyncio.fixture
async def async_client():
headers = {}
if docling_serve_settings.api_key:
headers["X-Api-Key"] = docling_serve_settings.api_key
async with httpx.AsyncClient(timeout=60.0, headers=headers) as client:
yield client
@pytest.mark.asyncio
async def test_convert_url(async_client):
"""Test convert URL to all outputs"""
base_url = "http://localhost:5001/v1"
payload = {
"options": {
"from_formats": [
"docx",
"pptx",
"html",
"image",
"pdf",
"asciidoc",
"md",
"xlsx",
],
"to_formats": ["md", "json", "html", "text", "doctags"],
"image_export_mode": "placeholder",
"ocr": True,
"force_ocr": False,
"ocr_engine": "easyocr",
"ocr_lang": ["en"],
"pdf_backend": "dlparse_v2",
"table_mode": "fast",
"abort_on_error": False,
},
"sources": [
{"kind": "http", "url": "https://arxiv.org/pdf/2206.01062"},
{"kind": "http", "url": "https://arxiv.org/pdf/2408.09869"},
],
"target": {"kind": "zip"},
}
response = await async_client.post(f"{base_url}/convert/source/async", json=payload)
assert response.status_code == 200, "Response should be 200 OK"
task = response.json()
print(json.dumps(task, indent=2))
while task["task_status"] not in ("success", "failure"):
response = await async_client.get(f"{base_url}/status/poll/{task['task_id']}")
assert response.status_code == 200, "Response should be 200 OK"
task = response.json()
print(f"{task['task_status']=}")
print(f"{task['task_position']=}")
time.sleep(2)
assert task["task_status"] == "success"
result_resp = await async_client.get(f"{base_url}/result/{task['task_id']}")
assert result_resp.status_code == 200, "Response should be 200 OK"
# Check for zip file attachment
content_disposition = result_resp.headers.get("content-disposition")
with check:
assert content_disposition is not None, (
"Content-Disposition header should be present"
)
with check:
assert "attachment" in content_disposition, "Response should be an attachment"
with check:
assert 'filename="converted_docs.zip"' in content_disposition, (
"Attachment filename should be 'converted_docs.zip'"
)
content_type = result_resp.headers.get("content-type")
with check:
assert content_type == "application/zip", (
"Content-Type should be 'application/zip'"
)

View File

@@ -0,0 +1,214 @@
import asyncio
import io
import json
import os
import zipfile
import pytest
import pytest_asyncio
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
from pytest_check import check
from docling_core.types.doc import DoclingDocument, PictureItem
from docling_serve.app import create_app
from docling_serve.settings import docling_serve_settings
@pytest.fixture(scope="session")
def event_loop():
return asyncio.get_event_loop()
@pytest.fixture(scope="session")
def auth_headers():
headers = {}
if docling_serve_settings.api_key:
headers["X-Api-Key"] = docling_serve_settings.api_key
return headers
@pytest_asyncio.fixture(scope="session")
async def app():
app = create_app()
async with LifespanManager(app) as manager:
print("Launching lifespan of app.")
yield manager.app
@pytest_asyncio.fixture(scope="session")
async def client(app):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://app.io"
) as client:
print("Client is ready")
yield client
@pytest.mark.asyncio
async def test_health(client: AsyncClient):
response = await client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
@pytest.mark.asyncio
async def test_openapijson(client: AsyncClient):
response = await client.get("/openapi.json")
assert response.status_code == 200
schema = response.json()
assert "openapi" in schema
@pytest.mark.asyncio
async def test_convert_file(client: AsyncClient, auth_headers: dict):
"""Test convert single file to all outputs"""
endpoint = "/v1/convert/file"
options = {
"from_formats": [
"docx",
"pptx",
"html",
"image",
"pdf",
"asciidoc",
"md",
"xlsx",
],
"to_formats": ["md", "json", "html", "text", "doctags"],
"image_export_mode": "placeholder",
"ocr": True,
"force_ocr": False,
"ocr_engine": "easyocr",
"ocr_lang": ["en"],
"pdf_backend": "dlparse_v2",
"table_mode": "fast",
"abort_on_error": False,
}
current_dir = os.path.dirname(__file__)
file_path = os.path.join(current_dir, "2206.01062v1.pdf")
files = {
"files": ("2206.01062v1.pdf", open(file_path, "rb"), "application/pdf"),
}
response = await client.post(
endpoint, files=files, data=options, headers=auth_headers
)
assert response.status_code == 200, "Response should be 200 OK"
data = response.json()
# Response content checks
# Helper function to safely slice strings
def safe_slice(value, length=100):
if isinstance(value, str):
return value[:length]
return str(value) # Convert non-string values to string for debug purposes
# Document check
check.is_in(
"document",
data,
msg=f"Response should contain 'document' key. Received keys: {list(data.keys())}",
)
# MD check
check.is_in(
"md_content",
data.get("document", {}),
msg=f"Response should contain 'md_content' key. Received keys: {list(data.get('document', {}).keys())}",
)
if data.get("document", {}).get("md_content") is not None:
check.is_in(
"## DocLayNet: ",
data["document"]["md_content"],
msg=f"Markdown document should contain 'DocLayNet: '. Received: {safe_slice(data['document']['md_content'])}",
)
# JSON check
check.is_in(
"json_content",
data.get("document", {}),
msg=f"Response should contain 'json_content' key. Received keys: {list(data.get('document', {}).keys())}",
)
if data.get("document", {}).get("json_content") is not None:
check.is_in(
'{"schema_name": "DoclingDocument"',
json.dumps(data["document"]["json_content"]),
msg=f'JSON document should contain \'{{\\n "schema_name": "DoclingDocument\'". Received: {safe_slice(data["document"]["json_content"])}',
)
# HTML check
check.is_in(
"html_content",
data.get("document", {}),
msg=f"Response should contain 'html_content' key. Received keys: {list(data.get('document', {}).keys())}",
)
if data.get("document", {}).get("html_content") is not None:
check.is_in(
"<!DOCTYPE html>\n<html>\n<head>",
data["document"]["html_content"],
msg=f"HTML document should contain '<!DOCTYPE html>\n<html>\n<head>'. Received: {safe_slice(data['document']['html_content'])}",
)
# Text check
check.is_in(
"text_content",
data.get("document", {}),
msg=f"Response should contain 'text_content' key. Received keys: {list(data.get('document', {}).keys())}",
)
if data.get("document", {}).get("text_content") is not None:
check.is_in(
"DocLayNet: A Large Human-Annotated Dataset",
data["document"]["text_content"],
msg=f"Text document should contain 'DocLayNet: A Large Human-Annotated Dataset'. Received: {safe_slice(data['document']['text_content'])}",
)
# DocTags check
check.is_in(
"doctags_content",
data.get("document", {}),
msg=f"Response should contain 'doctags_content' key. Received keys: {list(data.get('document', {}).keys())}",
)
if data.get("document", {}).get("doctags_content") is not None:
check.is_in(
"<doctag><page_header>",
data["document"]["doctags_content"],
msg=f"DocTags document should contain '<doctag><page_header>'. Received: {safe_slice(data['document']['doctags_content'])}",
)
@pytest.mark.asyncio
async def test_referenced_artifacts(client: AsyncClient, auth_headers: dict):
"""Test that paths in the zip file are relative to the zip file root."""
endpoint = "/v1/convert/file"
options = {
"to_formats": ["json"],
"image_export_mode": "referenced",
"target_type": "zip",
"ocr": False,
}
current_dir = os.path.dirname(__file__)
file_path = os.path.join(current_dir, "2206.01062v1.pdf")
files = {
"files": ("2206.01062v1.pdf", open(file_path, "rb"), "application/pdf"),
}
response = await client.post(
endpoint, files=files, data=options, headers=auth_headers
)
assert response.status_code == 200, "Response should be 200 OK"
with zipfile.ZipFile(io.BytesIO(response.content)) as zip_file:
namelist = zip_file.namelist()
for file in namelist:
if file.endswith(".json"):
doc = DoclingDocument.model_validate(json.loads(zip_file.read(file)))
for item, _level in doc.iterate_items():
if isinstance(item, PictureItem):
assert item.image is not None
print(f"{item.image.uri}=")
assert str(item.image.uri) in namelist

88
tests/test_file_opts.py Normal file
View File

@@ -0,0 +1,88 @@
import asyncio
import json
import os
import pytest
import pytest_asyncio
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
from docling_core.types import DoclingDocument
from docling_core.types.doc.document import PictureDescriptionData
from docling_serve.app import create_app
from docling_serve.settings import docling_serve_settings
@pytest.fixture(scope="session")
def event_loop():
return asyncio.get_event_loop()
@pytest.fixture(scope="session")
def auth_headers():
headers = {}
if docling_serve_settings.api_key:
headers["X-Api-Key"] = docling_serve_settings.api_key
return headers
@pytest_asyncio.fixture(scope="session")
async def app():
app = create_app()
async with LifespanManager(app) as manager:
print("Launching lifespan of app.")
yield manager.app
@pytest_asyncio.fixture(scope="session")
async def client(app):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://app.io"
) as client:
print("Client is ready")
yield client
@pytest.mark.asyncio
async def test_convert_file(client: AsyncClient, auth_headers: dict):
"""Test convert single file to all outputs"""
endpoint = "/v1/convert/file"
options = {
"to_formats": ["md", "json"],
"image_export_mode": "placeholder",
"ocr": False,
"do_picture_description": True,
"picture_description_api": json.dumps(
{
"url": "http://localhost:11434/v1/chat/completions", # ollama
"params": {"model": "granite3.2-vision:2b"},
"timeout": 60,
"prompt": "Describe this image in a few sentences. ",
}
),
}
current_dir = os.path.dirname(__file__)
file_path = os.path.join(current_dir, "2206.01062v1.pdf")
files = {
"files": ("2206.01062v1.pdf", open(file_path, "rb"), "application/pdf"),
}
response = await client.post(
endpoint, files=files, data=options, headers=auth_headers
)
assert response.status_code == 200, "Response should be 200 OK"
data = response.json()
doc = DoclingDocument.model_validate(data["document"]["json_content"])
for pic in doc.pictures:
for ann in pic.annotations:
if isinstance(ann, PictureDescriptionData):
print(f"{pic.self_ref}")
print(ann.text)

157
tests/test_results_clear.py Normal file
View File

@@ -0,0 +1,157 @@
import asyncio
import base64
import json
from pathlib import Path
import pytest
import pytest_asyncio
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
from docling_serve.app import create_app
from docling_serve.settings import docling_serve_settings
@pytest.fixture(scope="session")
def event_loop():
return asyncio.get_event_loop()
@pytest.fixture(scope="session")
def auth_headers():
headers = {}
if docling_serve_settings.api_key:
headers["X-Api-Key"] = docling_serve_settings.api_key
return headers
@pytest_asyncio.fixture(scope="session")
async def app():
app = create_app()
async with LifespanManager(app) as manager:
print("Launching lifespan of app.")
yield manager.app
@pytest_asyncio.fixture(scope="session")
async def client(app):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://app.io"
) as client:
print("Client is ready")
yield client
async def convert_file(client: AsyncClient, auth_headers: dict):
doc_filename = Path("tests/2408.09869v5.pdf")
encoded_doc = base64.b64encode(doc_filename.read_bytes()).decode()
payload = {
"options": {
"to_formats": ["json"],
},
"sources": [
{
"kind": "file",
"base64_string": encoded_doc,
"filename": doc_filename.name,
}
],
}
response = await client.post(
"/v1/convert/source/async", json=payload, headers=auth_headers
)
assert response.status_code == 200, "Response should be 200 OK"
task = response.json()
print(json.dumps(task, indent=2))
while task["task_status"] not in ("success", "failure"):
response = await client.get(
f"/v1/status/poll/{task['task_id']}", headers=auth_headers
)
assert response.status_code == 200, "Response should be 200 OK"
task = response.json()
print(f"{task['task_status']=}")
print(f"{task['task_position']=}")
await asyncio.sleep(2)
assert task["task_status"] == "success"
return task
@pytest.mark.asyncio
async def test_clear_results(client: AsyncClient, auth_headers: dict):
"""Test removal of task."""
# Set long delay deletion
docling_serve_settings.result_removal_delay = 100
# Convert and wait for completion
task = await convert_file(client, auth_headers=auth_headers)
# Get result once
result_response = await client.get(
f"/v1/result/{task['task_id']}", headers=auth_headers
)
assert result_response.status_code == 200, "Response should be 200 OK"
print("Result 1 ok.")
result = result_response.json()
assert result["document"]["json_content"]["schema_name"] == "DoclingDocument"
# Get result twice
result_response = await client.get(
f"/v1/result/{task['task_id']}", headers=auth_headers
)
assert result_response.status_code == 200, "Response should be 200 OK"
print("Result 2 ok.")
result = result_response.json()
assert result["document"]["json_content"]["schema_name"] == "DoclingDocument"
# Clear
clear_response = await client.get(
"/v1/clear/results?older_then=0", headers=auth_headers
)
assert clear_response.status_code == 200, "Response should be 200 OK"
print("Clear ok.")
# Get deleted result
result_response = await client.get(
f"/v1/result/{task['task_id']}", headers=auth_headers
)
assert result_response.status_code == 404, "Response should be removed"
print("Result was no longer found.")
@pytest.mark.asyncio
async def test_delay_remove(client: AsyncClient, auth_headers: dict):
"""Test automatic removal of task with delay."""
# Set short delay deletion
docling_serve_settings.result_removal_delay = 5
# Convert and wait for completion
task = await convert_file(client, auth_headers=auth_headers)
# Get result once
result_response = await client.get(
f"/v1/result/{task['task_id']}", headers=auth_headers
)
assert result_response.status_code == 200, "Response should be 200 OK"
print("Result ok.")
result = result_response.json()
assert result["document"]["json_content"]["schema_name"] == "DoclingDocument"
print("Sleeping to wait the automatic task deletion.")
await asyncio.sleep(10)
# Get deleted result
result_response = await client.get(
f"/v1/result/{task['task_id']}", headers=auth_headers
)
assert result_response.status_code == 404, "Response should be removed"

10323
uv.lock generated

File diff suppressed because one or more lines are too long