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.
- openmd/__init__.py +3 -0
- openmd/__main__.py +5 -0
- openmd/adapters/__init__.py +1 -0
- openmd/adapters/base.py +95 -0
- openmd/adapters/bilibili/__init__.py +11 -0
- openmd/adapters/bilibili/adapter.py +104 -0
- openmd/adapters/bilibili/discovery.py +33 -0
- openmd/adapters/douyin/__init__.py +11 -0
- openmd/adapters/douyin/adapter.py +117 -0
- openmd/adapters/douyin/discovery.py +36 -0
- openmd/adapters/douyin/sidecar.py +77 -0
- openmd/adapters/generic_media/__init__.py +14 -0
- openmd/adapters/high_privacy.py +77 -0
- openmd/adapters/local_audio/__init__.py +14 -0
- openmd/adapters/local_audio/adapter.py +169 -0
- openmd/adapters/local_audio/asr.py +58 -0
- openmd/adapters/local_audio/backends/__init__.py +1 -0
- openmd/adapters/local_audio/backends/auto.py +177 -0
- openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
- openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
- openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
- openmd/adapters/opencli.py +770 -0
- openmd/adapters/pdf/__init__.py +11 -0
- openmd/adapters/pdf/adapter.py +153 -0
- openmd/adapters/pdf/backends/__init__.py +1 -0
- openmd/adapters/pdf/backends/markitdown.py +165 -0
- openmd/adapters/pdf/parser.py +56 -0
- openmd/adapters/qq_group/__init__.py +11 -0
- openmd/adapters/qq_group/adapter.py +289 -0
- openmd/adapters/qq_group/parsers/__init__.py +1 -0
- openmd/adapters/qq_group/parsers/html.py +60 -0
- openmd/adapters/qq_group/parsers/json.py +71 -0
- openmd/adapters/registry.py +149 -0
- openmd/adapters/screenshot/__init__.py +11 -0
- openmd/adapters/screenshot/adapter.py +154 -0
- openmd/adapters/screenshot/backends/__init__.py +1 -0
- openmd/adapters/screenshot/backends/paddle.py +15 -0
- openmd/adapters/screenshot/backends/rapidocr.py +176 -0
- openmd/adapters/screenshot/backends/tesseract.py +15 -0
- openmd/adapters/screenshot/ocr.py +84 -0
- openmd/adapters/wechat_group/__init__.py +11 -0
- openmd/adapters/wechat_group/adapter.py +160 -0
- openmd/adapters/wechat_group/wx_cli.py +268 -0
- openmd/adapters/wechat_moments/__init__.py +11 -0
- openmd/adapters/wechat_moments/adapter.py +167 -0
- openmd/adapters/wechat_moments/wx_cli.py +265 -0
- openmd/adapters/weixin/__init__.py +11 -0
- openmd/adapters/weixin/adapter.py +148 -0
- openmd/adapters/weixin/discovery.py +510 -0
- openmd/adapters/weixin/report.py +278 -0
- openmd/adapters/xiaohongshu.py +158 -0
- openmd/backends/__init__.py +1 -0
- openmd/backends/base.py +204 -0
- openmd/backends/bilibili.py +1480 -0
- openmd/backends/bilibili_wbi.py +277 -0
- openmd/backends/discourse.py +426 -0
- openmd/backends/douyin.py +868 -0
- openmd/backends/douyin_detail.py +124 -0
- openmd/backends/douyin_providers.py +602 -0
- openmd/backends/executor.py +546 -0
- openmd/backends/feishu.py +861 -0
- openmd/backends/generic_media.py +77 -0
- openmd/backends/interactive.py +60 -0
- openmd/backends/ocr.py +41 -0
- openmd/backends/opencli.py +82 -0
- openmd/backends/pdf.py +122 -0
- openmd/backends/registry.py +234 -0
- openmd/backends/screenshot.py +79 -0
- openmd/backends/video_metadata.py +167 -0
- openmd/backends/web_article.py +363 -0
- openmd/backends/wechat_group.py +35 -0
- openmd/backends/wechat_moments.py +35 -0
- openmd/backends/weixin.py +767 -0
- openmd/backends/x.py +455 -0
- openmd/backends/xiaoe.py +940 -0
- openmd/backends/xiaohongshu.py +911 -0
- openmd/backends/xiaohongshu_providers.py +876 -0
- openmd/backends/xiaoyuzhou.py +1017 -0
- openmd/backends/youtube.py +280 -0
- openmd/backends/youtube_discovery.py +1245 -0
- openmd/backends/youtube_transcript.py +213 -0
- openmd/browser_extension/background.js +231 -0
- openmd/browser_extension/icons/icon-128.png +0 -0
- openmd/browser_extension/icons/icon-16.png +0 -0
- openmd/browser_extension/icons/icon-32.png +0 -0
- openmd/browser_extension/icons/icon-48.png +0 -0
- openmd/browser_extension/icons/icon-512.png +0 -0
- openmd/browser_extension/idle.html +6 -0
- openmd/browser_extension/manifest.json +43 -0
- openmd/browser_extension/options.html +42 -0
- openmd/browser_extension/options.js +117 -0
- openmd/browser_extension/popup.css +71 -0
- openmd/browser_extension/popup.html +17 -0
- openmd/browser_extension/popup.js +59 -0
- openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
- openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
- openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
- openmd/browser_host.py +503 -0
- openmd/browser_setup.py +106 -0
- openmd/cli.py +2012 -0
- openmd/console.py +32 -0
- openmd/core/__init__.py +1 -0
- openmd/core/capabilities.py +162 -0
- openmd/core/content_validation.py +323 -0
- openmd/core/coverage.py +163 -0
- openmd/core/detector.py +247 -0
- openmd/core/doctor.py +104 -0
- openmd/core/execution_policy.py +164 -0
- openmd/core/filename.py +64 -0
- openmd/core/input_validation.py +91 -0
- openmd/core/local_config.py +96 -0
- openmd/core/manifest.py +156 -0
- openmd/core/models.py +168 -0
- openmd/core/opencli_output.py +34 -0
- openmd/core/operation_contract.py +60 -0
- openmd/core/output.py +194 -0
- openmd/core/package_contract.py +335 -0
- openmd/core/package_validator.py +489 -0
- openmd/core/package_writer.py +666 -0
- openmd/core/planner.py +345 -0
- openmd/core/preferences.py +61 -0
- openmd/core/readable.py +129 -0
- openmd/core/receipt.py +285 -0
- openmd/core/renderer.py +185 -0
- openmd/core/runlog.py +208 -0
- openmd/core/secrets.py +313 -0
- openmd/doctor_view.py +68 -0
- openmd/interactive.py +453 -0
- openmd/ocr/__init__.py +1 -0
- openmd/ocr/breakers.py +84 -0
- openmd/ocr/config.py +54 -0
- openmd/ocr/documents.py +522 -0
- openmd/ocr/endpoint.py +72 -0
- openmd/ocr/layout.py +92 -0
- openmd/ocr/layout_filter.py +118 -0
- openmd/ocr/layout_worker.py +89 -0
- openmd/ocr/native_math.py +220 -0
- openmd/ocr/office.py +305 -0
- openmd/ocr/ollama.py +200 -0
- openmd/ocr/otsl.py +51 -0
- openmd/ocr/ovis-prompt.txt +1 -0
- openmd/ocr/pipeline.py +384 -0
- openmd/ocr/settings.json +25 -0
- openmd/ocr/tiling.py +631 -0
- openmd/post_video.py +154 -0
- openmd/probe.py +397 -0
- openmd/routing/__init__.py +1 -0
- openmd/routing/cards/builtin.json +620 -0
- openmd/routing/cards/defuddle.json +70 -0
- openmd/routing/cards/x-tweet-fetcher.json +71 -0
- openmd/routing/cards/xyz-dl.json +60 -0
- openmd/routing/cards/yutto.json +68 -0
- openmd/routing/entry_shim.py +71 -0
- openmd/routing/lifecycle.py +459 -0
- openmd/routing/loader.py +138 -0
- openmd/routing/providers.py +158 -0
- openmd/routing/readers.py +108 -0
- openmd/routing/routes.json +504 -0
- openmd/routing/runner.py +192 -0
- openmd/routing/view.py +40 -0
- openmd/runtime/__init__.py +1 -0
- openmd/runtime/_windows_job.py +234 -0
- openmd/runtime/asr.py +217 -0
- openmd/runtime/asr_worker.py +119 -0
- openmd/runtime/gpu_lock.py +81 -0
- openmd/runtime/gpu_quarantine.py +85 -0
- openmd/runtime/interpreters.py +407 -0
- openmd/runtime/managed_tools.py +204 -0
- openmd/runtime/models.py +202 -0
- openmd/runtime/services.py +175 -0
- openmd/runtime/tempdirs.py +227 -0
- openmd_cli-0.1.0.dist-info/METADATA +168 -0
- openmd_cli-0.1.0.dist-info/RECORD +177 -0
- openmd_cli-0.1.0.dist-info/WHEEL +5 -0
- openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
openmd/ocr/documents.py
ADDED
|
@@ -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("/"), "", ""))
|