ducpy 3.4.0__tar.gz → 3.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {ducpy-3.4.0 → ducpy-3.5.1}/Cargo.lock +1 -1
  2. {ducpy-3.4.0 → ducpy-3.5.1}/PKG-INFO +7 -1
  3. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducpy/crate/Cargo.toml +1 -1
  4. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/schema/duc.sql +1 -1
  5. ducpy-3.5.1/packages/ducrs/schema/migrations/3000004_to_3000005.sql +51 -0
  6. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/schema/search.sql +49 -1
  7. {ducpy-3.4.0 → ducpy-3.5.1}/pyproject.toml +15 -0
  8. ducpy-3.5.1/src/ducpy/search/__init__.py +19 -0
  9. ducpy-3.5.1/src/ducpy/search/image_ocr.py +189 -0
  10. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/search/search_elements.py +398 -89
  11. ducpy-3.5.1/src/ducpy/search/search_external_files.py +608 -0
  12. ducpy-3.5.1/src/ducpy/search/search_pdf.py +95 -0
  13. ducpy-3.4.0/src/ducpy/search/__init__.py +0 -13
  14. {ducpy-3.4.0 → ducpy-3.5.1}/Cargo.toml +0 -0
  15. {ducpy-3.4.0 → ducpy-3.5.1}/LICENSE +0 -0
  16. {ducpy-3.4.0 → ducpy-3.5.1}/README.md +0 -0
  17. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducpy/crate/src/lib.rs +0 -0
  18. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/Cargo.toml +0 -0
  19. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/LICENSE +0 -0
  20. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/README.md +0 -0
  21. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/build.rs +0 -0
  22. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/schema/migrations/3000000_to_3000001.sql +0 -0
  23. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/schema/migrations/3000001_to_3000002.sql +0 -0
  24. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/schema/migrations/3000002_to_3000003.sql +0 -0
  25. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/schema/migrations/3000003_to_3000004.sql +0 -0
  26. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/schema/version_control.sql +0 -0
  27. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/src/api/document.rs +0 -0
  28. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/src/api/meta.rs +0 -0
  29. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/src/api/mod.rs +0 -0
  30. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/src/api/version_control.rs +0 -0
  31. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/src/db/bootstrap.rs +0 -0
  32. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/src/db/mod.rs +0 -0
  33. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/src/db/native.rs +0 -0
  34. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/src/db/wasm.rs +0 -0
  35. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/src/lib.rs +0 -0
  36. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/src/parse.rs +0 -0
  37. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/src/serde_utils.rs +0 -0
  38. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/src/serialize.rs +0 -0
  39. {ducpy-3.4.0 → ducpy-3.5.1}/packages/ducrs/src/types.rs +0 -0
  40. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/__init__.py +0 -0
  41. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/builders/__init__.py +0 -0
  42. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/builders/block_instance_builder.py +0 -0
  43. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/builders/block_utils.py +0 -0
  44. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/builders/element_builders.py +0 -0
  45. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/builders/mutate_builder.py +0 -0
  46. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/builders/sql_builder.py +0 -0
  47. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/builders/state_builders.py +0 -0
  48. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/builders/style_builders.py +0 -0
  49. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/classes/DataStateClass.py +0 -0
  50. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/classes/ElementsClass.py +0 -0
  51. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/classes/__init__.py +0 -0
  52. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/enums.py +0 -0
  53. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/parse.py +0 -0
  54. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/serialize.py +0 -0
  55. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/utils/__init__.py +0 -0
  56. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/utils/constants.py +0 -0
  57. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/utils/convert.py +0 -0
  58. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/utils/io.py +0 -0
  59. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/utils/mutate_utils.py +0 -0
  60. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy/utils/rand_utils.py +0 -0
  61. {ducpy-3.4.0 → ducpy-3.5.1}/src/ducpy_native/__init__.py +0 -0
@@ -412,7 +412,7 @@ dependencies = [
412
412
 
413
413
  [[package]]
414
414
  name = "ducpy-native"
415
- version = "3.4.0"
415
+ version = "3.5.1"
416
416
  dependencies = [
417
417
  "duc",
418
418
  "pyo3",
@@ -1,8 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ducpy
3
- Version: 3.4.0
3
+ Version: 3.5.1
4
4
  Classifier: License :: OSI Approved :: MIT License
5
5
  Requires-Dist: nanoid>=2.0.0
6
+ Requires-Dist: pypdf>=4.2.0
7
+ Requires-Dist: rapidocr==3.8.1 ; extra == 'ocr'
8
+ Requires-Dist: onnxruntime ; extra == 'ocr'
9
+ Requires-Dist: huggingface-hub>=0.20.0 ; extra == 'ocr'
10
+ Requires-Dist: numpy>=1.20.0 ; extra == 'ocr'
11
+ Provides-Extra: ocr
6
12
  License-File: LICENSE
7
13
  Summary: The library for the Duc 2D CAD file format
8
14
  Author: Jorge Soares
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ducpy-native"
3
- version = "3.4.0"
3
+ version = "3.5.1"
4
4
  edition = "2021"
5
5
  description = "Python native extension for the duc file format (used by ducpy)"
6
6
  publish = false
@@ -1,7 +1,7 @@
1
1
  -- "DUC_" in ASCII
2
2
  -- Apply in order: duc.sql → version_control.sql → search.sql
3
3
  PRAGMA application_id = 1146569567;
4
- PRAGMA user_version = 3000004;
4
+ PRAGMA user_version = 3000005;
5
5
  PRAGMA journal_mode = WAL;
6
6
  PRAGMA foreign_keys = ON;
7
7
  PRAGMA synchronous = NORMAL;
@@ -0,0 +1,51 @@
1
+ -- Migration: 3000003 → 3000004
2
+ -- Add searchable index for extracted external-file text (PDF content search).
3
+
4
+ CREATE TABLE IF NOT EXISTS external_file_text_index (
5
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6
+ file_id TEXT NOT NULL,
7
+ revision_id TEXT NOT NULL,
8
+ mime_type TEXT NOT NULL,
9
+ extracted_text TEXT NOT NULL DEFAULT '',
10
+ has_ocr INTEGER NOT NULL DEFAULT 0,
11
+ updated INTEGER NOT NULL,
12
+ UNIQUE (file_id, revision_id)
13
+ );
14
+
15
+ CREATE INDEX IF NOT EXISTS idx_external_file_text_index_file_id
16
+ ON external_file_text_index(file_id);
17
+
18
+ CREATE INDEX IF NOT EXISTS idx_external_file_text_index_revision_id
19
+ ON external_file_text_index(revision_id);
20
+
21
+ CREATE VIRTUAL TABLE IF NOT EXISTS search_external_file_text USING fts5(
22
+ extracted_text,
23
+ content='external_file_text_index',
24
+ content_rowid='id',
25
+ tokenize='unicode61 remove_diacritics 2',
26
+ prefix='2 3 4 5 6 7 8 9 10'
27
+ );
28
+
29
+ CREATE TRIGGER IF NOT EXISTS trg_external_file_text_index_ai
30
+ AFTER INSERT ON external_file_text_index BEGIN
31
+ INSERT INTO search_external_file_text(rowid, extracted_text)
32
+ VALUES (NEW.id, NEW.extracted_text);
33
+ END;
34
+
35
+ CREATE TRIGGER IF NOT EXISTS trg_external_file_text_index_ad
36
+ AFTER DELETE ON external_file_text_index BEGIN
37
+ INSERT INTO search_external_file_text(search_external_file_text, rowid, extracted_text)
38
+ VALUES ('delete', OLD.id, OLD.extracted_text);
39
+ END;
40
+
41
+ CREATE TRIGGER IF NOT EXISTS trg_external_file_text_index_au
42
+ AFTER UPDATE OF extracted_text ON external_file_text_index BEGIN
43
+ INSERT INTO search_external_file_text(search_external_file_text, rowid, extracted_text)
44
+ VALUES ('delete', OLD.id, OLD.extracted_text);
45
+ INSERT INTO search_external_file_text(rowid, extracted_text)
46
+ VALUES (NEW.id, NEW.extracted_text);
47
+ END;
48
+
49
+ INSERT INTO search_external_file_text(search_external_file_text) VALUES ('rebuild');
50
+
51
+ PRAGMA user_version = 3000005;
@@ -5,7 +5,8 @@
5
5
  -- Triggers keep the FTS index automatically in sync on every INSERT/UPDATE/DELETE.
6
6
  -- No application-level sync needed.
7
7
  --
8
- -- Depends on: duc.sql (elements, element_text, element_doc, element_model, blocks)
8
+ -- Depends on: duc.sql (elements, element_text, element_doc, element_model, blocks,
9
+ -- external_files, external_file_revisions, external_file_revision_data, document_grid_config)
9
10
  --
10
11
  -- Query examples:
11
12
  -- SELECT rowid, rank FROM search_elements WHERE search_elements MATCH 'motor';
@@ -129,6 +130,52 @@ CREATE TRIGGER IF NOT EXISTS trg_blocks_ai AFTER INSERT ON blocks BEGIN
129
130
  INSERT INTO search_blocks(rowid, label, description)
130
131
  VALUES (NEW.rowid, NEW.label, NEW.description);
131
132
  END;
133
+
134
+ -- Extracted text index for external file revisions (used for PDF content search).
135
+ CREATE TABLE IF NOT EXISTS external_file_text_index (
136
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
137
+ file_id TEXT NOT NULL,
138
+ revision_id TEXT NOT NULL,
139
+ mime_type TEXT NOT NULL,
140
+ extracted_text TEXT NOT NULL DEFAULT '',
141
+ has_ocr INTEGER NOT NULL DEFAULT 0,
142
+ updated INTEGER NOT NULL,
143
+ UNIQUE (file_id, revision_id)
144
+ );
145
+
146
+ CREATE INDEX IF NOT EXISTS idx_external_file_text_index_file_id
147
+ ON external_file_text_index(file_id);
148
+
149
+ CREATE INDEX IF NOT EXISTS idx_external_file_text_index_revision_id
150
+ ON external_file_text_index(revision_id);
151
+
152
+ CREATE VIRTUAL TABLE IF NOT EXISTS search_external_file_text USING fts5(
153
+ extracted_text,
154
+ content='external_file_text_index',
155
+ content_rowid='id',
156
+ tokenize='unicode61 remove_diacritics 2',
157
+ prefix='2 3 4 5 6 7 8 9 10'
158
+ );
159
+
160
+ CREATE TRIGGER IF NOT EXISTS trg_external_file_text_index_ai
161
+ AFTER INSERT ON external_file_text_index BEGIN
162
+ INSERT INTO search_external_file_text(rowid, extracted_text)
163
+ VALUES (NEW.id, NEW.extracted_text);
164
+ END;
165
+
166
+ CREATE TRIGGER IF NOT EXISTS trg_external_file_text_index_ad
167
+ AFTER DELETE ON external_file_text_index BEGIN
168
+ INSERT INTO search_external_file_text(search_external_file_text, rowid, extracted_text)
169
+ VALUES ('delete', OLD.id, OLD.extracted_text);
170
+ END;
171
+
172
+ CREATE TRIGGER IF NOT EXISTS trg_external_file_text_index_au
173
+ AFTER UPDATE OF extracted_text ON external_file_text_index BEGIN
174
+ INSERT INTO search_external_file_text(search_external_file_text, rowid, extracted_text)
175
+ VALUES ('delete', OLD.id, OLD.extracted_text);
176
+ INSERT INTO search_external_file_text(rowid, extracted_text)
177
+ VALUES (NEW.id, NEW.extracted_text);
178
+ END;
132
179
  CREATE TRIGGER IF NOT EXISTS trg_blocks_ad AFTER DELETE ON blocks BEGIN
133
180
  INSERT INTO search_blocks(search_blocks, rowid, label, description)
134
181
  VALUES ('delete', OLD.rowid, OLD.label, OLD.description);
@@ -146,3 +193,4 @@ INSERT INTO search_element_text(search_element_text) VALUES ('rebuild');
146
193
  INSERT INTO search_element_doc(search_element_doc) VALUES ('rebuild');
147
194
  INSERT INTO search_element_model(search_element_model) VALUES ('rebuild');
148
195
  INSERT INTO search_blocks(search_blocks) VALUES ('rebuild');
196
+ INSERT INTO search_external_file_text(search_external_file_text) VALUES ('rebuild');
@@ -6,6 +6,7 @@ readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  dependencies = [
8
8
  "nanoid>=2.0.0",
9
+ "pypdf>=4.2.0"
9
10
  ]
10
11
 
11
12
  license = { text = "MIT" }
@@ -19,6 +20,14 @@ classifiers = [
19
20
  "License :: OSI Approved :: MIT License",
20
21
  ]
21
22
 
23
+ [project.optional-dependencies]
24
+ ocr = [
25
+ "rapidocr==3.8.1",
26
+ "onnxruntime",
27
+ "huggingface-hub>=0.20.0",
28
+ "numpy>=1.20.0",
29
+ ]
30
+
22
31
  [project.urls]
23
32
  "Homepage" = "https://duc.ducflair.com"
24
33
  "Source" = "https://github.com/ducflair/duc/tree/main/packages/ducpy"
@@ -44,11 +53,14 @@ where = ["src"]
44
53
 
45
54
  [dependency-groups]
46
55
  dev = [
56
+ "ducpy[ocr]",
57
+ "maturin>=1.0,<2.0",
47
58
  "furo>=2024.8.6",
48
59
  "sphinx>=8.1.3",
49
60
  "sphinx-autoapi>=3.6.0",
50
61
  "pytest>=8.3.5",
51
62
  "rich>=13.0.0",
63
+ "numpy>=1.20.0",
52
64
  "typst>=0.14.9",
53
65
  "build123d>=0.10.0",
54
66
  "ocp-vscode>=3.4.0",
@@ -60,3 +72,6 @@ dev = [
60
72
  testpaths = ["src/tests"]
61
73
  python_files = "test_*.py"
62
74
  addopts = "-vv"
75
+ markers = [
76
+ "slow: marks tests as slow (OCR-heavy, etc.)",
77
+ ]
@@ -0,0 +1,19 @@
1
+ """Search helpers for DUC SQLite databases."""
2
+
3
+ from .search_elements import (
4
+ DucElementSearchResult,
5
+ DucFileSearchResult,
6
+ DucSearchResponse,
7
+ DucSearchResult,
8
+ ExternalFileSearchTarget,
9
+ search_duc_elements,
10
+ )
11
+
12
+ __all__ = [
13
+ "DucElementSearchResult",
14
+ "DucFileSearchResult",
15
+ "DucSearchResponse",
16
+ "DucSearchResult",
17
+ "ExternalFileSearchTarget",
18
+ "search_duc_elements",
19
+ ]
@@ -0,0 +1,189 @@
1
+ """Server-side image OCR helpers for DUC search.
2
+
3
+ Uses ``rapidocr-onnxruntime`` with custom PP-OCRv6_tiny ONNX models when the ``ocr`` extra is installed, otherwise
4
+ OCR is gracefully skipped.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import logging
11
+ from functools import lru_cache
12
+ from typing import Any
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ _RAPID_OCR_ENGINE: Any | None = None
17
+
18
+
19
+ @lru_cache(maxsize=1)
20
+ def server_side_ocr_available() -> bool:
21
+ """Return whether the ``ocr`` extra (RapidOCR) is installed and usable."""
22
+
23
+ try:
24
+ from rapidocr import RapidOCR # type: ignore
25
+ from huggingface_hub import snapshot_download # type: ignore # noqa: F401
26
+ from PIL import Image # type: ignore # noqa: F401
27
+ except Exception as exc:
28
+ logger.debug("OCR extra (rapidocr/huggingface-hub) unavailable: %s", exc)
29
+ return False
30
+
31
+ return True
32
+
33
+
34
+ def _get_local_cache_dir(repo_id: str) -> str | None:
35
+ import os
36
+ folder_name = "models--" + repo_id.replace("/", "--")
37
+ cache_dir = os.environ.get("HF_HUB_CACHE")
38
+ if not cache_dir:
39
+ hf_home = os.environ.get("HF_HOME")
40
+ if hf_home:
41
+ cache_dir = os.path.join(hf_home, "hub")
42
+ else:
43
+ cache_dir = os.path.expanduser("~/.cache/huggingface/hub")
44
+
45
+ repo_dir = os.path.join(cache_dir, folder_name)
46
+ if not os.path.isdir(repo_dir):
47
+ return None
48
+
49
+ # Read refs/main if it exists to get the current commit snapshot
50
+ refs_main = os.path.join(repo_dir, "refs", "main")
51
+ if os.path.isfile(refs_main):
52
+ try:
53
+ with open(refs_main, "r", encoding="utf-8") as f:
54
+ commit_hash = f.read().strip()
55
+ snapshot_dir = os.path.join(repo_dir, "snapshots", commit_hash)
56
+ if os.path.isdir(snapshot_dir):
57
+ return snapshot_dir
58
+ except Exception:
59
+ pass
60
+
61
+ # Fallback to the latest folder in snapshots/
62
+ snapshots_dir = os.path.join(repo_dir, "snapshots")
63
+ if os.path.isdir(snapshots_dir):
64
+ try:
65
+ subdirs = [os.path.join(snapshots_dir, d) for d in os.listdir(snapshots_dir)]
66
+ subdirs = [d for d in subdirs if os.path.isdir(d)]
67
+ if subdirs:
68
+ return max(subdirs, key=os.path.getmtime)
69
+ except Exception:
70
+ pass
71
+
72
+ return None
73
+
74
+
75
+ def _get_rapid_engine() -> Any:
76
+ global _RAPID_OCR_ENGINE
77
+ if _RAPID_OCR_ENGINE is None:
78
+ from rapidocr import RapidOCR # type: ignore
79
+ from rapidocr.utils.typings import EngineType # type: ignore
80
+ import os
81
+ import yaml
82
+
83
+ # Try to resolve cache locally first to avoid snapshot_download overhead (0.1ms vs 180ms)
84
+ det_dir = _get_local_cache_dir("PaddlePaddle/PP-OCRv6_tiny_det_onnx")
85
+ rec_dir = _get_local_cache_dir("PaddlePaddle/PP-OCRv6_tiny_rec_onnx")
86
+
87
+ # Fallback to snapshot_download if local files aren't fully resolved
88
+ if (
89
+ not det_dir
90
+ or not rec_dir
91
+ or not os.path.exists(os.path.join(det_dir, "inference.onnx"))
92
+ or not os.path.exists(os.path.join(rec_dir, "inference.onnx"))
93
+ ):
94
+ from huggingface_hub import snapshot_download # type: ignore
95
+ try:
96
+ det_dir = snapshot_download("PaddlePaddle/PP-OCRv6_tiny_det_onnx", local_files_only=True)
97
+ rec_dir = snapshot_download("PaddlePaddle/PP-OCRv6_tiny_rec_onnx", local_files_only=True)
98
+ except Exception:
99
+ det_dir = snapshot_download("PaddlePaddle/PP-OCRv6_tiny_det_onnx")
100
+ rec_dir = snapshot_download("PaddlePaddle/PP-OCRv6_tiny_rec_onnx")
101
+
102
+ det_model = os.path.join(det_dir, "inference.onnx")
103
+ rec_model = os.path.join(rec_dir, "inference.onnx")
104
+
105
+ # Load the dictionary from the model config to prevent IndexError
106
+ dict_path = os.path.join(rec_dir, "ppocrv6_keys.txt")
107
+ if not os.path.exists(dict_path):
108
+ yml_path = os.path.join(rec_dir, "inference.yml")
109
+ try:
110
+ with open(yml_path, "r", encoding="utf-8") as f:
111
+ cfg = yaml.safe_load(f)
112
+ char_list = cfg["PostProcess"]["character_dict"]
113
+ with open(dict_path, "w", encoding="utf-8") as f:
114
+ for char in char_list:
115
+ f.write(char + "\n")
116
+ except Exception as e:
117
+ logger.debug("Failed to extract character_dict from inference.yml: %s", e)
118
+ # Fallback to the en_dict.txt shipped in the repository if yaml parsing fails
119
+ dict_path = os.path.join(rec_dir, "en_dict.txt")
120
+
121
+ _RAPID_OCR_ENGINE = RapidOCR(
122
+ params={
123
+ "Global.use_cls": False,
124
+
125
+ "Det.model_path": det_model,
126
+ "Det.engine_type": EngineType.ONNXRUNTIME,
127
+
128
+ "Rec.model_path": rec_model,
129
+ "Rec.rec_keys_path": dict_path,
130
+ "Rec.engine_type": EngineType.ONNXRUNTIME,
131
+ }
132
+ )
133
+ return _RAPID_OCR_ENGINE
134
+
135
+
136
+
137
+ def extract_image_text_with_ocr(image_bytes: bytes, *, ocr_language: str) -> tuple[str, bool]:
138
+ """OCR an image using the ``ocr`` extra, or skip when unavailable."""
139
+
140
+ if not server_side_ocr_available():
141
+ return "", False
142
+
143
+ try:
144
+ import numpy as np # type: ignore
145
+ from PIL import Image # type: ignore
146
+ except Exception as exc:
147
+ logger.debug("Image OCR dependencies unavailable: %s", exc)
148
+ return "", False
149
+
150
+ try:
151
+ with Image.open(io.BytesIO(image_bytes)) as image_obj:
152
+ image_input = np.array(image_obj.convert("RGB"))
153
+
154
+ engine = _get_rapid_engine()
155
+ raw_res = engine(image_input)
156
+
157
+ if hasattr(raw_res, "txts"):
158
+ txts = raw_res.txts
159
+ elif isinstance(raw_res, tuple) and len(raw_res) == 2:
160
+ result, _ = raw_res
161
+ if hasattr(result, "txts"):
162
+ txts = result.txts
163
+ elif isinstance(result, (list, tuple)):
164
+ txts = [row[1] for row in result if isinstance(row, (tuple, list)) and len(row) >= 2]
165
+ else:
166
+ txts = None
167
+ elif isinstance(raw_res, (list, tuple)):
168
+ txts = [row[1] for row in raw_res if isinstance(row, (tuple, list)) and len(row) >= 2]
169
+ else:
170
+ txts = None
171
+
172
+ if not txts:
173
+ return "", False
174
+
175
+ lines: list[str] = []
176
+ for txt in txts:
177
+ value = str(txt or "").strip()
178
+ if value:
179
+ lines.append(value)
180
+ text = "\n".join(lines)
181
+ except Exception as exc:
182
+ logger.debug("Failed to OCR image bytes: %s", exc)
183
+ return "", False
184
+
185
+ return text, bool(text)
186
+
187
+
188
+ _server_side_ocr_available = server_side_ocr_available
189
+ _extract_image_text_with_ocr = extract_image_text_with_ocr