openmd-cli 0.1.0__py3-none-any.whl

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 (177) hide show
  1. openmd/__init__.py +3 -0
  2. openmd/__main__.py +5 -0
  3. openmd/adapters/__init__.py +1 -0
  4. openmd/adapters/base.py +95 -0
  5. openmd/adapters/bilibili/__init__.py +11 -0
  6. openmd/adapters/bilibili/adapter.py +104 -0
  7. openmd/adapters/bilibili/discovery.py +33 -0
  8. openmd/adapters/douyin/__init__.py +11 -0
  9. openmd/adapters/douyin/adapter.py +117 -0
  10. openmd/adapters/douyin/discovery.py +36 -0
  11. openmd/adapters/douyin/sidecar.py +77 -0
  12. openmd/adapters/generic_media/__init__.py +14 -0
  13. openmd/adapters/high_privacy.py +77 -0
  14. openmd/adapters/local_audio/__init__.py +14 -0
  15. openmd/adapters/local_audio/adapter.py +169 -0
  16. openmd/adapters/local_audio/asr.py +58 -0
  17. openmd/adapters/local_audio/backends/__init__.py +1 -0
  18. openmd/adapters/local_audio/backends/auto.py +177 -0
  19. openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
  20. openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
  21. openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
  22. openmd/adapters/opencli.py +770 -0
  23. openmd/adapters/pdf/__init__.py +11 -0
  24. openmd/adapters/pdf/adapter.py +153 -0
  25. openmd/adapters/pdf/backends/__init__.py +1 -0
  26. openmd/adapters/pdf/backends/markitdown.py +165 -0
  27. openmd/adapters/pdf/parser.py +56 -0
  28. openmd/adapters/qq_group/__init__.py +11 -0
  29. openmd/adapters/qq_group/adapter.py +289 -0
  30. openmd/adapters/qq_group/parsers/__init__.py +1 -0
  31. openmd/adapters/qq_group/parsers/html.py +60 -0
  32. openmd/adapters/qq_group/parsers/json.py +71 -0
  33. openmd/adapters/registry.py +149 -0
  34. openmd/adapters/screenshot/__init__.py +11 -0
  35. openmd/adapters/screenshot/adapter.py +154 -0
  36. openmd/adapters/screenshot/backends/__init__.py +1 -0
  37. openmd/adapters/screenshot/backends/paddle.py +15 -0
  38. openmd/adapters/screenshot/backends/rapidocr.py +176 -0
  39. openmd/adapters/screenshot/backends/tesseract.py +15 -0
  40. openmd/adapters/screenshot/ocr.py +84 -0
  41. openmd/adapters/wechat_group/__init__.py +11 -0
  42. openmd/adapters/wechat_group/adapter.py +160 -0
  43. openmd/adapters/wechat_group/wx_cli.py +268 -0
  44. openmd/adapters/wechat_moments/__init__.py +11 -0
  45. openmd/adapters/wechat_moments/adapter.py +167 -0
  46. openmd/adapters/wechat_moments/wx_cli.py +265 -0
  47. openmd/adapters/weixin/__init__.py +11 -0
  48. openmd/adapters/weixin/adapter.py +148 -0
  49. openmd/adapters/weixin/discovery.py +510 -0
  50. openmd/adapters/weixin/report.py +278 -0
  51. openmd/adapters/xiaohongshu.py +158 -0
  52. openmd/backends/__init__.py +1 -0
  53. openmd/backends/base.py +204 -0
  54. openmd/backends/bilibili.py +1480 -0
  55. openmd/backends/bilibili_wbi.py +277 -0
  56. openmd/backends/discourse.py +426 -0
  57. openmd/backends/douyin.py +868 -0
  58. openmd/backends/douyin_detail.py +124 -0
  59. openmd/backends/douyin_providers.py +602 -0
  60. openmd/backends/executor.py +546 -0
  61. openmd/backends/feishu.py +861 -0
  62. openmd/backends/generic_media.py +77 -0
  63. openmd/backends/interactive.py +60 -0
  64. openmd/backends/ocr.py +41 -0
  65. openmd/backends/opencli.py +82 -0
  66. openmd/backends/pdf.py +122 -0
  67. openmd/backends/registry.py +234 -0
  68. openmd/backends/screenshot.py +79 -0
  69. openmd/backends/video_metadata.py +167 -0
  70. openmd/backends/web_article.py +363 -0
  71. openmd/backends/wechat_group.py +35 -0
  72. openmd/backends/wechat_moments.py +35 -0
  73. openmd/backends/weixin.py +767 -0
  74. openmd/backends/x.py +455 -0
  75. openmd/backends/xiaoe.py +940 -0
  76. openmd/backends/xiaohongshu.py +911 -0
  77. openmd/backends/xiaohongshu_providers.py +876 -0
  78. openmd/backends/xiaoyuzhou.py +1017 -0
  79. openmd/backends/youtube.py +280 -0
  80. openmd/backends/youtube_discovery.py +1245 -0
  81. openmd/backends/youtube_transcript.py +213 -0
  82. openmd/browser_extension/background.js +231 -0
  83. openmd/browser_extension/icons/icon-128.png +0 -0
  84. openmd/browser_extension/icons/icon-16.png +0 -0
  85. openmd/browser_extension/icons/icon-32.png +0 -0
  86. openmd/browser_extension/icons/icon-48.png +0 -0
  87. openmd/browser_extension/icons/icon-512.png +0 -0
  88. openmd/browser_extension/idle.html +6 -0
  89. openmd/browser_extension/manifest.json +43 -0
  90. openmd/browser_extension/options.html +42 -0
  91. openmd/browser_extension/options.js +117 -0
  92. openmd/browser_extension/popup.css +71 -0
  93. openmd/browser_extension/popup.html +17 -0
  94. openmd/browser_extension/popup.js +59 -0
  95. openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
  96. openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
  97. openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
  98. openmd/browser_host.py +503 -0
  99. openmd/browser_setup.py +106 -0
  100. openmd/cli.py +2012 -0
  101. openmd/console.py +32 -0
  102. openmd/core/__init__.py +1 -0
  103. openmd/core/capabilities.py +162 -0
  104. openmd/core/content_validation.py +323 -0
  105. openmd/core/coverage.py +163 -0
  106. openmd/core/detector.py +247 -0
  107. openmd/core/doctor.py +104 -0
  108. openmd/core/execution_policy.py +164 -0
  109. openmd/core/filename.py +64 -0
  110. openmd/core/input_validation.py +91 -0
  111. openmd/core/local_config.py +96 -0
  112. openmd/core/manifest.py +156 -0
  113. openmd/core/models.py +168 -0
  114. openmd/core/opencli_output.py +34 -0
  115. openmd/core/operation_contract.py +60 -0
  116. openmd/core/output.py +194 -0
  117. openmd/core/package_contract.py +335 -0
  118. openmd/core/package_validator.py +489 -0
  119. openmd/core/package_writer.py +666 -0
  120. openmd/core/planner.py +345 -0
  121. openmd/core/preferences.py +61 -0
  122. openmd/core/readable.py +129 -0
  123. openmd/core/receipt.py +285 -0
  124. openmd/core/renderer.py +185 -0
  125. openmd/core/runlog.py +208 -0
  126. openmd/core/secrets.py +313 -0
  127. openmd/doctor_view.py +68 -0
  128. openmd/interactive.py +453 -0
  129. openmd/ocr/__init__.py +1 -0
  130. openmd/ocr/breakers.py +84 -0
  131. openmd/ocr/config.py +54 -0
  132. openmd/ocr/documents.py +522 -0
  133. openmd/ocr/endpoint.py +72 -0
  134. openmd/ocr/layout.py +92 -0
  135. openmd/ocr/layout_filter.py +118 -0
  136. openmd/ocr/layout_worker.py +89 -0
  137. openmd/ocr/native_math.py +220 -0
  138. openmd/ocr/office.py +305 -0
  139. openmd/ocr/ollama.py +200 -0
  140. openmd/ocr/otsl.py +51 -0
  141. openmd/ocr/ovis-prompt.txt +1 -0
  142. openmd/ocr/pipeline.py +384 -0
  143. openmd/ocr/settings.json +25 -0
  144. openmd/ocr/tiling.py +631 -0
  145. openmd/post_video.py +154 -0
  146. openmd/probe.py +397 -0
  147. openmd/routing/__init__.py +1 -0
  148. openmd/routing/cards/builtin.json +620 -0
  149. openmd/routing/cards/defuddle.json +70 -0
  150. openmd/routing/cards/x-tweet-fetcher.json +71 -0
  151. openmd/routing/cards/xyz-dl.json +60 -0
  152. openmd/routing/cards/yutto.json +68 -0
  153. openmd/routing/entry_shim.py +71 -0
  154. openmd/routing/lifecycle.py +459 -0
  155. openmd/routing/loader.py +138 -0
  156. openmd/routing/providers.py +158 -0
  157. openmd/routing/readers.py +108 -0
  158. openmd/routing/routes.json +504 -0
  159. openmd/routing/runner.py +192 -0
  160. openmd/routing/view.py +40 -0
  161. openmd/runtime/__init__.py +1 -0
  162. openmd/runtime/_windows_job.py +234 -0
  163. openmd/runtime/asr.py +217 -0
  164. openmd/runtime/asr_worker.py +119 -0
  165. openmd/runtime/gpu_lock.py +81 -0
  166. openmd/runtime/gpu_quarantine.py +85 -0
  167. openmd/runtime/interpreters.py +407 -0
  168. openmd/runtime/managed_tools.py +204 -0
  169. openmd/runtime/models.py +202 -0
  170. openmd/runtime/services.py +175 -0
  171. openmd/runtime/tempdirs.py +227 -0
  172. openmd_cli-0.1.0.dist-info/METADATA +168 -0
  173. openmd_cli-0.1.0.dist-info/RECORD +177 -0
  174. openmd_cli-0.1.0.dist-info/WHEEL +5 -0
  175. openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
  176. openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  177. openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,522 @@
1
+ """Optional PyMuPDF page probing/rendering and mixed native/OCR extraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import importlib.util
7
+ import math
8
+ import re
9
+ import statistics
10
+ import time
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from openmd.adapters.local_audio.adapter import local_path_from_input
15
+ from openmd.core.models import TranscriptResult
16
+ from .config import settings
17
+ from .pipeline import LocalOCRBackend, han_ratio, ocr_notes
18
+
19
+
20
+ class PDFDependencyMissing(RuntimeError):
21
+ pass
22
+
23
+
24
+ def pymupdf():
25
+ if importlib.util.find_spec("pymupdf") is None:
26
+ raise PDFDependencyMissing('PyMuPDF missing. Install explicitly: python -m pip install "openmd-cli[ocr-router]" (AGPL; assess licensing before distribution).')
27
+ import pymupdf as module
28
+
29
+ return module
30
+
31
+
32
+ def text_is_reliable(text: str, config: dict) -> bool:
33
+ chars = [c for c in text if not c.isspace()]
34
+ if len(chars) < config["textlayer_min_chars"]:
35
+ return False
36
+ good = sum(c.isprintable() and c not in {"\ufffd", "\x00"} for c in chars)
37
+ return good / len(chars) >= config["textlayer_min_printable"] and sum(c.isalnum() for c in chars) / len(chars) >= 0.4
38
+
39
+
40
+ def _covered_area(rectangles: list[tuple[float, float, float, float]], bounds) -> float:
41
+ """Union clipped image bounds; overlapping images cannot inflate coverage."""
42
+ clipped = [(max(bounds.x0, x0), max(bounds.y0, y0), min(bounds.x1, x1), min(bounds.y1, y1)) for x0, y0, x1, y1 in rectangles]
43
+ clipped = [rect for rect in clipped if rect[2] > rect[0] and rect[3] > rect[1]]
44
+ edges = sorted({x for rect in clipped for x in (rect[0], rect[2])})
45
+ area = 0.0
46
+ for left, right in zip(edges, edges[1:]):
47
+ intervals = sorted((y0, y1) for x0, y0, x1, y1 in clipped if x0 < right and x1 > left)
48
+ covered, end = 0.0, float("-inf")
49
+ for start, stop in intervals:
50
+ covered += max(0.0, stop - max(start, end))
51
+ end = max(end, stop)
52
+ area += (right - left) * covered
53
+ return area
54
+
55
+
56
+ def _span_rgb(span: dict) -> tuple[float, float, float]:
57
+ color = span.get("color", (0.0,))
58
+ if isinstance(color, (int, float)):
59
+ color = (color,)
60
+ if len(color) == 1:
61
+ return (color[0],) * 3
62
+ if len(color) == 4:
63
+ c, m, y, k = color
64
+ return tuple(1 - min(1, component + k) for component in (c, m, y))
65
+ return tuple(color[:3])
66
+
67
+
68
+ def _pixels(pixmap, page, box):
69
+ """Read clipped page coordinates from one bounded, low DPI RGB raster."""
70
+ scale_x, scale_y = pixmap.width / page.rect.width, pixmap.height / page.rect.height
71
+ x0 = max(0, math.floor((box[0] - page.rect.x0) * scale_x))
72
+ y0 = max(0, math.floor((box[1] - page.rect.y0) * scale_y))
73
+ x1 = min(pixmap.width, math.ceil((box[2] - page.rect.x0) * scale_x))
74
+ y1 = min(pixmap.height, math.ceil((box[3] - page.rect.y0) * scale_y))
75
+ return [(x, y, pixmap.pixel(x, y)[:3]) for y in range(y0, y1) for x in range(x0, x1)]
76
+
77
+
78
+ def _rendered_ink(pixmap, page, box, delta):
79
+ pixels = _pixels(pixmap, page, box)
80
+ if not pixels:
81
+ return []
82
+ # The dominant colour *inside* a glyph box is its background. Sampling only
83
+ # outside the span mistakes a later coloured cover for the original glyph.
84
+ background = tuple(statistics.median(p[2][channel] for p in pixels) for channel in range(3))
85
+ return [(x, y) for x, y, rgb in pixels if max(abs(a - b) for a, b in zip(rgb, background)) > delta * 255]
86
+
87
+
88
+ def _intersection_area(a, b):
89
+ return max(0, min(a[2], b[2]) - max(a[0], b[0])) * max(0, min(a[3], b[3]) - max(a[1], b[1]))
90
+
91
+
92
+ def _clip_operator(data: bytes) -> bool:
93
+ """Conservatively recognize path/text clips without parsing string contents.
94
+
95
+ MuPDF's drawing device does not report text clipping (Tr 4..7). Comments,
96
+ nested literal strings, escaped delimiters, hex strings and names are PDF
97
+ lexical objects, not operators. Unknown Tr operands also fail closed.
98
+ """
99
+ delimiters = b"\x00\t\n\f\r ()<>[]{}/%"
100
+ known = set(b"w J j M d ri i gs q Q cm m l c v y h re S s f F f* B B* b b* n BT ET Tc Tw Tz TL Tf Tr Ts Td TD Tm T* Tj TJ ' \" d0 d1 CS cs SC SCN sc scn G g RG rg K k sh Do MP DP BMC BDC EMC BX EX true false null R".split())
101
+ index, previous = 0, None
102
+ while index < len(data):
103
+ char = data[index]
104
+ if char in b"\x00\t\n\f\r ":
105
+ index += 1
106
+ elif char == ord("%"):
107
+ match = re.search(rb"[\r\n]", data[index:])
108
+ index = index + match.end() if match else len(data)
109
+ elif char == ord("("):
110
+ index, depth = index + 1, 1
111
+ while index < len(data) and depth:
112
+ if data[index] == ord("\\"):
113
+ index += 2
114
+ continue
115
+ depth += (data[index] == ord("(")) - (data[index] == ord(")"))
116
+ index += 1
117
+ if depth:
118
+ return True
119
+ previous = None
120
+ elif char == ord("<") and data[index:index + 2] != b"<<":
121
+ stop = data.find(b">", index + 1)
122
+ if stop < 0:
123
+ return True
124
+ index = len(data) if stop < 0 else stop + 1
125
+ previous = None
126
+ elif char == ord("/"):
127
+ index += 1
128
+ while index < len(data) and data[index] not in delimiters:
129
+ index += 1
130
+ previous = None
131
+ elif char in b"<>[]{}":
132
+ index += 1
133
+ previous = None
134
+ else:
135
+ end = index + 1
136
+ while end < len(data) and data[end] not in delimiters:
137
+ end += 1
138
+ token, index = data[index:end], end
139
+ # Inline image payloads are binary rather than PDF lexical tokens.
140
+ # Without a full content interpreter their following graphics state
141
+ # cannot be proved; reject the user page rather than scan through it.
142
+ if token in {b"W", b"W*", b"BI", b"ID", b"EI"}:
143
+ return True
144
+ if token not in known and not re.fullmatch(rb"[+-]?(?:\d+(?:\.\d*)?|\.\d+)", token):
145
+ # Do not guess how a repairing renderer interprets malformed
146
+ # or unknown instructions (including concatenated '4Tr').
147
+ return True
148
+ if token == b"Tr":
149
+ try:
150
+ if float(previous) not in {0, 1, 2, 3}:
151
+ return True
152
+ except (ValueError, TypeError):
153
+ return True
154
+ previous = token
155
+ return False
156
+
157
+
158
+ def _content_has_clip(page) -> bool:
159
+ # The resource inventory includes nested Form XObjects. A clip inside any
160
+ # referenced Form is enough to reject; resource reachability is not guessed.
161
+ xrefs = set(page.get_contents()) | {entry[0] for entry in page.get_xobjects()}
162
+ for xref in xrefs:
163
+ data = page.parent.xref_stream(xref)
164
+ if not isinstance(data, bytes):
165
+ raise ValueError("unreadable_content_stream")
166
+ if _clip_operator(data):
167
+ return True
168
+ return False
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class _OfficePDFOrigin:
173
+ """An in-process capability issued at the converter call site, never metadata."""
174
+
175
+ path: Path
176
+ sha256: str
177
+
178
+
179
+ def _office_pdf_origin(pdf: Path, source: Path) -> _OfficePDFOrigin | None:
180
+ # XLSX is deliberately outside the approved glyph-alternative exemption.
181
+ if source.suffix.lower() not in {".docx", ".pptx"}:
182
+ return None
183
+ return _OfficePDFOrigin(pdf.resolve(), hashlib.sha256(pdf.read_bytes()).hexdigest())
184
+
185
+
186
+ def _office_vector_alternative(span, drawings, ink, page, pixmap):
187
+ """Recognize converter extraction alternatives ONLY behind origin validation.
188
+
189
+ Nearby vector ink is not a proof of character identity on arbitrary PDFs.
190
+ The caller must hold the capability for this invocation's converted bytes.
191
+ """
192
+ if span.get("type") != 3 or not ink:
193
+ return False
194
+ box, seqno = span["bbox"], span.get("seqno", -1)
195
+ candidates = [drawing["rect"] for drawing in drawings
196
+ if drawing.get("type") in {"f", "fs"}
197
+ and drawing.get("fill_opacity", 0) > 0.01
198
+ and drawing.get("seqno", seqno) < seqno
199
+ and _intersection_area(box, drawing["rect"]) > 0]
200
+ if not candidates:
201
+ return False
202
+ def supported(point):
203
+ x = page.rect.x0 + (point[0] + 0.5) * page.rect.width / pixmap.width
204
+ y = page.rect.y0 + (point[1] + 0.5) * page.rect.height / pixmap.height
205
+ return any(rect.x0 - 1 <= x <= rect.x1 + 1 and rect.y0 - 1 <= y <= rect.y1 + 1 for rect in candidates)
206
+ return all(supported(point) for point in ink)
207
+
208
+
209
+ def _text_paint_groups(page, spans):
210
+ """Identify two device traces emitted from one interpreter text object.
211
+
212
+ MuPDF flushes a fill+stroke text operation as two callbacks carrying the
213
+ SAME retained fz_text and transform (Tr2 / Tr6). Independent overlapping
214
+ text has no such identity. Retaining the object is essential: allocator
215
+ address reuse must never turn a later draw into the earlier operation.
216
+ No string, font or geometry equality can grant this exemption.
217
+ """
218
+ if not any(s.get("type") == 0 for s in spans) or not any(s.get("type") == 1 for s in spans):
219
+ return {}
220
+ module = pymupdf()
221
+ try:
222
+ class PaintTrace(module.JM_new_texttrace_device):
223
+ def __init__(self, out):
224
+ super().__init__(out)
225
+ self.fill = None
226
+ self.groups = {}
227
+
228
+ def fill_text(self, ctx, text, ctm, *args):
229
+ retained = module.mupdf.FzText(module.mupdf.ll_fz_keep_text(text))
230
+ self.fill = (retained, int(text.this), self.seqno,
231
+ (ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f))
232
+ super().fill_text(ctx, text, ctm, *args)
233
+
234
+ def stroke_text(self, ctx, text, stroke, ctm, *args):
235
+ if self.fill is not None:
236
+ retained, pointer, seqno, matrix = self.fill
237
+ if (self.seqno == seqno + 1 and int(text.this) == pointer
238
+ and matrix == (ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f)):
239
+ self.groups[seqno] = self.groups[self.seqno] = seqno
240
+ self.fill = None
241
+ super().stroke_text(ctx, text, stroke, ctm, *args)
242
+
243
+ out = []
244
+ dev = PaintTrace(out)
245
+ rotation = page.rotation
246
+ try:
247
+ page.set_rotation(0)
248
+ prect = module.mupdf.fz_bound_page(page.this)
249
+ dev.ptm = module.mupdf.FzMatrix(1, 0, 0, -1, 0, prect.y1)
250
+ module.mupdf.fz_run_page(page.this, dev, module.mupdf.FzMatrix(), module.mupdf.FzCookie())
251
+ finally:
252
+ module.mupdf.fz_close_device(dev)
253
+ page.set_rotation(rotation)
254
+ # Version/parser mismatches grant no waiver. Identity comes from the
255
+ # callbacks; this checks their sequence agrees with get_texttrace.
256
+ if [(s["seqno"], s["type"]) for s in out] != [(s.get("seqno"), s.get("type")) for s in spans]:
257
+ return {}
258
+ # A text buffer can coalesce multiple source shows. Require the parsed
259
+ # source show count to match the distinct device paints after pairing;
260
+ # otherwise even a shared buffer cannot prove one source operation.
261
+ # Forms/Type3 can expand or replay nested shows: no count-based waiver.
262
+ if page.get_xobjects() or any(font[2] == "Type3" for font in page.get_fonts(full=True)):
263
+ return {}
264
+
265
+ class Shows(module.mupdf.PdfProcessor2):
266
+ def __init__(self):
267
+ super().__init__()
268
+ self.count = 0
269
+ for name in ("Tj", "TJ", "squote", "dquote"):
270
+ getattr(self, "use_virtual_op_" + name)()
271
+
272
+ def show(self, *args):
273
+ self.count += 1
274
+
275
+ op_Tj = op_TJ = op_squote = op_dquote = show
276
+
277
+ shows = Shows()
278
+ pdf_page = module.mupdf.pdf_page_from_fz_page(page.this)
279
+ doc = module.mupdf.pdf_document_from_fz_document(page.parent.this)
280
+ module.mupdf.pdf_process_contents(shows, doc, module.mupdf.pdf_page_resources(pdf_page),
281
+ module.mupdf.pdf_page_contents(pdf_page), module.mupdf.FzCookie())
282
+ paints = {dev.groups.get(s["seqno"], s["seqno"]) for s in out}
283
+ if shows.count != len(paints):
284
+ return {}
285
+ return dev.groups
286
+ except (AttributeError, TypeError, ValueError, RuntimeError):
287
+ return {}
288
+
289
+
290
+ def _paint_self_overlap(chars):
291
+ """A buffered fz_text may contain several overlapping source text shows.
292
+
293
+ Nonadjacent overlaps cannot be explained by ordinary adjacent kerning.
294
+ This check only rejects: it never grants paint identity. Pairing a combined
295
+ paint separately requires one parsed source show per device paint above.
296
+ """
297
+ active = []
298
+ for index, box in sorted(enumerate(char[3] for char in chars), key=lambda pair: pair[1][0]):
299
+ active = [(i, other) for i, other in active if other[2] > box[0]]
300
+ if any(abs(index - i) > 1 and _intersection_area(box, other) > 0.001 for i, other in active):
301
+ return True
302
+ active.append((index, box))
303
+ return False
304
+
305
+
306
+ def _visibility(page, config: dict, images: list, *, office_generated: bool = False) -> dict:
307
+ """Reject whole-page visibility uncertainty; pixels alone never waive clips."""
308
+ spans = page.get_texttrace()
309
+ paint_groups = _text_paint_groups(page, spans) if not office_generated else {}
310
+ drawings = page.get_drawings(extended=True)
311
+ paint = page.get_bboxlog()
312
+ total = invisible = low_contrast = obscured = vector_alternatives = glyph_alternatives = image_overlap = unproven = paint_overlap = 0
313
+ # Clip paths may be arbitrary, nested, text-derived, or in a Form XObject.
314
+ # Do not approximate the clipped glyph identity from its surviving pixels.
315
+ clips = sum(drawing.get("type") == "clip" for drawing in drawings)
316
+ content_clip = not office_generated and _content_has_clip(page)
317
+ overlays = [entry.rect for entry in (page.annots() or ())]
318
+ overlays.extend(entry.rect for entry in (page.widgets() or ()))
319
+ pixmap = page.get_pixmap(dpi=72, alpha=False, colorspace=pymupdf().csRGB)
320
+ for span in spans:
321
+ chars = [char for char in span.get("chars", ()) if char[0] > 32 and not chr(char[0]).isspace()]
322
+ count = len(chars)
323
+ total += count
324
+ if not count:
325
+ continue
326
+ box = span["bbox"]
327
+ # MuPDF exposes a converter ligature's extra Unicode characters with
328
+ # glyph id -1 and zero advance at the painted glyph's same origin.
329
+ # Like outlined alternatives, this is only trusted for our conversion.
330
+ cluster_alternatives = [char for char in chars if office_generated and char[1] == -1
331
+ and any(other[1] >= 0 and other[2] == char[2]
332
+ and _intersection_area(other[3], page.rect) > 0
333
+ for other in chars)]
334
+ glyph_alternatives += len(cluster_alternatives)
335
+ if span.get("layer") or span.get("type") not in {0, 1, 3}:
336
+ unproven += count
337
+ for char in chars:
338
+ charbox = char[3]
339
+ chararea = (charbox[2] - charbox[0]) * (charbox[3] - charbox[1])
340
+ if char not in cluster_alternatives and (chararea <= 0 or _intersection_area(charbox, page.rect) < chararea - 0.001):
341
+ unproven += 1
342
+ if any(_intersection_area(charbox, image["bbox"]) > 0 for image in images if image.get("bbox")):
343
+ image_overlap += 1
344
+ # Every later overlapping paint can hide or alter a character.
345
+ # This includes strokes, transparent fills, shades and images; do not
346
+ # guess cover identity from shape, colour, or percentage of bbox area.
347
+ later_paint = any(kind not in {"ignore-text", "fill-text", "stroke-text"}
348
+ and _intersection_area(box, bounds) > 0
349
+ for kind, bounds in paint[span.get("seqno", -1) + 1:])
350
+ if not office_generated:
351
+ # bboxlog pads glyph paint boxes and span boxes include spaces;
352
+ # those overlap ordinary adjacent words. Actual nonspace character
353
+ # boxes must overlap before another text span can be an occluder.
354
+ later_paint = later_paint or any(
355
+ later.get("seqno", -1) > span.get("seqno", -1)
356
+ and not (span.get("seqno") in paint_groups
357
+ and paint_groups.get(later.get("seqno")) == paint_groups[span["seqno"]])
358
+ and later.get("type") != 3 and _intersection_area(box, later["bbox"]) > 0
359
+ and any(_intersection_area(char[3], other[3]) > 0 for char in chars
360
+ for other in later.get("chars", ()) if other[0] > 32 and not chr(other[0]).isspace())
361
+ for later in spans)
362
+ later_paint = later_paint or _paint_self_overlap(chars)
363
+ if later_paint:
364
+ paint_overlap += count
365
+ if any(_intersection_area(box, rect) > 0 for rect in overlays):
366
+ unproven += count
367
+ ink = _rendered_ink(pixmap, page, box, config["textlayer_background_delta"])
368
+ if office_generated and not later_paint and _office_vector_alternative(span, drawings, ink, page, pixmap):
369
+ vector_alternatives += count
370
+ continue
371
+ if span.get("type") == 3 or span.get("opacity", 1) < 0.99:
372
+ invisible += count
373
+ continue
374
+ if not ink or any(not _rendered_ink(pixmap, page, char[3], config["textlayer_background_delta"]) for char in chars if char not in cluster_alternatives):
375
+ obscured += count
376
+ x0, y0, x1, y1 = box
377
+ samples = []
378
+ for x in (x0 - 2, (x0 + x1) / 2, x1 + 2):
379
+ for y in (y0 - 2, y1 + 2):
380
+ px = int((x - page.rect.x0) * pixmap.width / page.rect.width)
381
+ py = int((y - page.rect.y0) * pixmap.height / page.rect.height)
382
+ if 0 <= px < pixmap.width and 0 <= py < pixmap.height:
383
+ samples.append(pixmap.pixel(px, py)[:3])
384
+ if samples:
385
+ background = tuple(statistics.median(sample[channel] for sample in samples) / 255 for channel in range(3))
386
+ if max(abs(a - b) for a, b in zip(_span_rgb(span), background)) <= config["textlayer_background_delta"]:
387
+ low_contrast += count
388
+ return {"invisible_text_chars": invisible, "low_contrast_text_ratio": round(low_contrast / max(total, 1), 4), "low_contrast_text_chars": low_contrast,
389
+ "render_obscured_text_chars": obscured, "text_image_overlap_chars": image_overlap,
390
+ "vector_alternative_text_chars": vector_alternatives, "render_consistency_dpi": 72,
391
+ "office_glyph_alternative_text_chars": glyph_alternatives,
392
+ "clip_paths": clips, "overlapping_paint_text_chars": paint_overlap,
393
+ "content_clip_operator": content_clip,
394
+ "unproven_text_chars": unproven, "traced_text_chars": total,
395
+ "textlayer_origin": "openmd_office_conversion" if office_generated else "user_pdf"}
396
+
397
+
398
+ def probe_page(page, config: dict, *, _office_generated: bool = False) -> dict:
399
+ start = time.monotonic()
400
+ # Preserve the PDF's native content stream. Forced y/x sorting interleaves
401
+ # adjacent columns and changes source order on native multicolumn pages.
402
+ text = page.get_text("text", sort=False)
403
+ reliable = text_is_reliable(text, config)
404
+ images = page.get_image_info()
405
+ area = max(1, page.rect.width * page.rect.height)
406
+ image_area = _covered_area([item["bbox"] for item in images if item.get("bbox")], page.rect)
407
+ words = page.get_text("words")
408
+ text_area = sum(max(0, w[2] - w[0]) * max(0, w[3] - w[1]) for w in words)
409
+ reasons = []
410
+ visibility = {"invisible_text_chars": 0, "low_contrast_text_ratio": 0.0, "low_contrast_text_chars": 0, "render_obscured_text_chars": 0, "text_image_overlap_chars": 0, "vector_alternative_text_chars": 0, "render_consistency_dpi": 72,
411
+ "clip_paths": 0, "content_clip_operator": False, "overlapping_paint_text_chars": 0, "unproven_text_chars": 0,
412
+ "traced_text_chars": 0, "office_glyph_alternative_text_chars": 0, "textlayer_origin": "openmd_office_conversion" if _office_generated else "user_pdf"}
413
+ if text.strip():
414
+ # Large scans may carry plausible but contradictory OCR text layers.
415
+ # Their visible pixels are authoritative regardless of text density.
416
+ if image_area / area >= config["textlayer_image_coverage"]:
417
+ reasons.append("image_coverage")
418
+ try:
419
+ visibility = _visibility(page, config, images, office_generated=_office_generated)
420
+ except (ValueError, RuntimeError, OverflowError):
421
+ # A visibility parser failure is not permission to return its text.
422
+ # Keep the page eligible for visual OCR, including mixed documents.
423
+ reasons.append("visibility_probe_failed")
424
+ if visibility["invisible_text_chars"]:
425
+ reasons.append("invisible_render_mode")
426
+ if visibility["low_contrast_text_chars"]:
427
+ reasons.append("low_contrast_text")
428
+ if visibility["render_obscured_text_chars"]:
429
+ reasons.append("rendered_text_obscured")
430
+ if visibility["text_image_overlap_chars"]:
431
+ reasons.append("text_image_overlap")
432
+ if (visibility["clip_paths"] or visibility["content_clip_operator"]) and not _office_generated:
433
+ reasons.append("clip_visibility_unproven")
434
+ if visibility["overlapping_paint_text_chars"]:
435
+ reasons.append("overlapping_paint")
436
+ if visibility["unproven_text_chars"] or not visibility["traced_text_chars"]:
437
+ reasons.append("text_visibility_unproven")
438
+ if not reliable:
439
+ reasons.append("text_quality")
440
+ reliable = reliable and not reasons
441
+ blank = not text.strip() and not images and not page.get_drawings()
442
+ return {"page": page.number + 1, "route": "textlayer" if reliable or blank else "ocr", "reliable": reliable or blank, "blank": blank, "text": text, "text_chars": len(text.strip()), "native_reading_order": "pdf_content_stream", "image_area_ratio": round(min(1, image_area / area), 4), "text_area_ratio": round(min(1, text_area / area), 4), "textlayer_rejected_reason": reasons[0] if reasons else None, "textlayer_rejected_reasons": reasons, **visibility, "probe_seconds": round(time.monotonic() - start, 4)}
443
+
444
+
445
+ def inspect_pdf(path: Path, config: dict | None = None) -> list[dict]:
446
+ module = pymupdf()
447
+ with module.open(path) as document:
448
+ if document.needs_pass:
449
+ raise ValueError("pdf_password_required")
450
+ return [{key: value for key, value in probe_page(page, config or settings()).items() if key != "text"} for page in document]
451
+
452
+
453
+ def document_failure(backend: str, title: str, code: str, message: str) -> TranscriptResult:
454
+ return TranscriptResult(text="", status="failed", source_level="partial_extraction", backend=backend, title=title, error_code=code, error_summary=message, source_notes=[message], metadata={"profile": "document"})
455
+
456
+
457
+ def _render_and_recognize(page, module, config, backend, title):
458
+ try:
459
+ from PIL import Image
460
+
461
+ pixmap = page.get_pixmap(dpi=config["pdf_dpi"], alpha=False, colorspace=module.csRGB)
462
+ image = Image.frombytes("RGB", (pixmap.width, pixmap.height), pixmap.samples)
463
+ return LocalOCRBackend(backend).recognize_image(image, title=title, page=page.number + 1, tile=False)
464
+ except (ImportError, OSError, ValueError, RuntimeError):
465
+ return document_failure(backend, title, "pdf_page_render_failed", "A PDF page could not be rendered/recognized; other native pages were retained.")
466
+
467
+
468
+ def extract_pdf(item, *, backend: str = "ocr.chain", allow_ocr: bool = False, _office_origin: _OfficePDFOrigin | None = None) -> TranscriptResult:
469
+ path = local_path_from_input(item.raw_meta.get("local_path") or item.url)
470
+ title = item.title or (path.stem if path else "PDF document")
471
+ if path is None or not path.is_file() or path.suffix.lower() != ".pdf":
472
+ return document_failure(backend, title, "pdf_local_file_required", "PDF page routing requires an existing local PDF.")
473
+ try:
474
+ module = pymupdf()
475
+ config = settings()
476
+ office_generated = (isinstance(_office_origin, _OfficePDFOrigin)
477
+ and path.resolve() == _office_origin.path
478
+ and hashlib.sha256(path.read_bytes()).hexdigest() == _office_origin.sha256)
479
+ pages, bodies, notes = [], [], []
480
+ abort_code = None
481
+ with module.open(path) as document:
482
+ if document.needs_pass:
483
+ return document_failure(backend, title, "pdf_password_required", "Encrypted PDF requires an unlocked local copy.")
484
+ for page in document:
485
+ if abort_code:
486
+ pages.append({"page": page.number + 1, "route": "ocr", "status": "partial", "engine": None, "reason": "gpu_quarantined_file_aborted", "fallback_used": False, "ovis_seconds": 0.0, "fallback_seconds": 0.0, "slice_parameters": {}, "breakers": []})
487
+ continue
488
+ probe = probe_page(page, config, _office_generated=office_generated)
489
+ text = probe.pop("text")
490
+ if probe["route"] == "textlayer":
491
+ record = {**probe, "status": "verified", "engine": "PyMuPDF", "model": None, "digest": None, "fallback_used": False, "reason": None, "ovis_seconds": 0.0, "fallback_seconds": 0.0, "peak_vram_mb": None, "han_ratio": han_ratio(text), "seconds": probe["probe_seconds"], "slice_parameters": {}, "breakers": []}
492
+ elif backend == "pdf.textlayer":
493
+ failure = document_failure(backend, title, "pdf_ocr_required", "One or more pages have no reliable native text; continue to ocr.chain.")
494
+ failure.metadata["pages"] = [{**probe, "status": "partial"}]
495
+ return failure
496
+ elif not allow_ocr:
497
+ text = ""
498
+ record = {**probe, "status": "partial", "engine": backend, "fallback_used": False, "reason": "ocr_permission_required", "ovis_seconds": 0.0, "fallback_seconds": 0.0, "peak_vram_mb": None, "han_ratio": 0.0, "slice_parameters": {}, "breakers": []}
499
+ notes.append(f"Page {page.number + 1}: use --enable-network-backends to call the configured Ollama API.")
500
+ else:
501
+ result = _render_and_recognize(page, module, config, backend, title)
502
+ text = result.text
503
+ record = result.metadata.get("pages", [{"page": page.number + 1, "route": "ocr", "status": "partial", "engine": backend, "fallback_used": False, "reason": result.error_code, "ovis_seconds": 0.0, "fallback_seconds": 0.0, "peak_vram_mb": None, "han_ratio": 0.0, "slice_parameters": {}, "breakers": []}])[0]
504
+ record.update({"dpi": config["pdf_dpi"], "probe_seconds": probe["probe_seconds"], "textlayer_rejected_reason": probe["textlayer_rejected_reason"], "textlayer_rejected_reasons": probe["textlayer_rejected_reasons"], "image_area_ratio": probe["image_area_ratio"], "invisible_text_chars": probe["invisible_text_chars"], "low_contrast_text_ratio": probe["low_contrast_text_ratio"]})
505
+ record.update({key: probe[key] for key in ("render_obscured_text_chars", "text_image_overlap_chars", "vector_alternative_text_chars", "render_consistency_dpi", "clip_paths", "content_clip_operator", "overlapping_paint_text_chars", "unproven_text_chars", "textlayer_origin", "low_contrast_text_chars", "office_glyph_alternative_text_chars")})
506
+ if result.status != "verified":
507
+ record["status"] = "partial"
508
+ notes.extend(result.source_notes)
509
+ if result.metadata.get("abort_file"):
510
+ abort_code = result.error_code or "gpu_quarantined"
511
+ pages.append(record)
512
+ if text.strip():
513
+ bodies.append(text.strip())
514
+ if not pages:
515
+ return document_failure(backend, title, "pdf_empty", "PDF has no pages.")
516
+ has_ocr = any(p["route"] == "ocr" for p in pages)
517
+ partial = any(p["status"] != "verified" for p in pages) or not bodies
518
+ return TranscriptResult(text="\n\n".join(bodies), status="partial" if partial else "verified", source_level="ocr" if has_ocr else "native_text", backend=backend, title=title, error_code=abort_code or ("pdf_partial" if partial else None), source_notes=list(dict.fromkeys(["Pages retained in source order; reliable text layers use PyMuPDF, other pages use 144 DPI OCR.", "Mixed documents use source_level=ocr; per-page route records distinguish native text from OCR.", *(ocr_notes(pages) if has_ocr else []), *notes])), metadata={"profile": "document", "source_format": "pdf", "pages": pages, "page_mapping": "source_order", "mixed_routes": has_ocr and any(p["route"] == "textlayer" for p in pages), "pymupdf_version": module.VersionBind, "terminal_partial": partial, "abort_file": bool(abort_code)})
519
+ except PDFDependencyMissing as exc:
520
+ return document_failure(backend, title, "pymupdf_missing", str(exc))
521
+ except (ImportError, OSError, ValueError, RuntimeError) as exc:
522
+ return document_failure(backend, title, "pdf_page_extraction_failed", f"PDF page routing failed ({type(exc).__name__}); inspect dependencies and the local document.")
openmd/ocr/endpoint.py ADDED
@@ -0,0 +1,72 @@
1
+ """Fail closed before sending document bytes to an Ollama endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import re
7
+ import socket
8
+ from urllib.parse import urlsplit, urlunsplit
9
+
10
+
11
+ class EndpointError(ValueError):
12
+ def __init__(self, code: str, message: str):
13
+ self.code = code
14
+ super().__init__(f"{code}: {message}")
15
+
16
+
17
+ def _loopback_address(value: str):
18
+ address = ipaddress.ip_address(value)
19
+ # Mapped IPv4 and scoped IPv6 are deliberately outside the endpoint contract.
20
+ if "%" in value or getattr(address, "ipv4_mapped", None) is not None or not address.is_loopback:
21
+ raise EndpointError("ocr_ollama_url_not_loopback", "Ollama must use an unscoped loopback address")
22
+ return address
23
+
24
+
25
+ def validate_ollama_url(url: str) -> str:
26
+ """Validate and pin a URL to a numeric loopback address.
27
+
28
+ DNS names are accepted only when *every* returned address is loopback. The
29
+ returned numeric URL prevents a second DNS lookup from changing the target
30
+ between validation and the connection. Call this at each HTTP entry point;
31
+ callers must also reject HTTP redirects rather than follow unvalidated URLs.
32
+ """
33
+ invalid = "ocr_ollama_url_invalid"
34
+ if not isinstance(url, str) or any(c.isspace() or ord(c) < 32 for c in url) or "\\" in url:
35
+ raise EndpointError(invalid, "expected HTTP(S) loopback URL without credentials")
36
+ try:
37
+ parsed = urlsplit(url)
38
+ host = parsed.hostname
39
+ port = parsed.port
40
+ except ValueError as exc:
41
+ raise EndpointError(invalid, "invalid host or port") from exc
42
+ if parsed.scheme not in {"http", "https"} or not host or parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment or "%" in host or (port is not None and port < 1):
43
+ raise EndpointError(invalid, "expected HTTP(S) loopback URL without credentials, query or fragment")
44
+ try:
45
+ address = ipaddress.ip_address(host)
46
+ except ValueError:
47
+ # OS resolvers also accept legacy numeric IPv4 forms (2130706433,
48
+ # 0177.0.0.1, 127.1, 0x7f000001). Never pass these to the resolver.
49
+ if ":" in host or re.fullmatch(r"(?:0[xX][0-9a-fA-F]+|[0-9]+)(?:\.(?:0[xX][0-9a-fA-F]+|[0-9]+))*\.?", host):
50
+ raise EndpointError("ocr_ollama_url_not_loopback", "noncanonical IP address is not allowed")
51
+ try:
52
+ answers = socket.getaddrinfo(host, port or (443 if parsed.scheme == "https" else 80), type=socket.SOCK_STREAM)
53
+ except OSError as exc:
54
+ raise EndpointError("ocr_ollama_url_resolution_failed", "cannot establish that every Ollama address is loopback") from exc
55
+ if not answers:
56
+ raise EndpointError("ocr_ollama_url_resolution_failed", "Ollama hostname resolved to no addresses")
57
+ addresses = []
58
+ for family, _, _, _, sockaddr in answers:
59
+ if family not in {socket.AF_INET, socket.AF_INET6}:
60
+ raise EndpointError("ocr_ollama_url_not_loopback", "unsupported Ollama address family")
61
+ try:
62
+ addresses.append(_loopback_address(sockaddr[0]))
63
+ except ValueError as exc:
64
+ if isinstance(exc, EndpointError):
65
+ raise
66
+ raise EndpointError("ocr_ollama_url_not_loopback", "invalid resolved Ollama address") from exc
67
+ address = addresses[0]
68
+ address = _loopback_address(str(address))
69
+ authority = f"[{address}]" if address.version == 6 else str(address)
70
+ if port is not None:
71
+ authority += f":{port}"
72
+ return urlunsplit((parsed.scheme, authority, parsed.path.rstrip("/"), "", ""))