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/office.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Keep MarkItDown Office defaults; recover suspected visual/text loss locally."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
from pathlib import Path, PurePosixPath
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
from xml.etree import ElementTree
|
|
14
|
+
from zipfile import BadZipFile, ZipFile
|
|
15
|
+
|
|
16
|
+
from openmd.core.local_config import load_local_config
|
|
17
|
+
from openmd.runtime.interpreters import owned_popen, process_options, terminate_quietly, wait_interruptibly
|
|
18
|
+
from .config import settings
|
|
19
|
+
|
|
20
|
+
from openmd.runtime.tempdirs import owned_temporary_directory
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_WORD_NAMESPACES = {
|
|
24
|
+
"http://schemas.openxmlformats.org/wordprocessingml/2006/main",
|
|
25
|
+
"http://purl.oclc.org/ooxml/wordprocessingml/main",
|
|
26
|
+
}
|
|
27
|
+
_REL_NAMESPACES = {
|
|
28
|
+
"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
|
29
|
+
"http://purl.oclc.org/ooxml/officeDocument/relationships",
|
|
30
|
+
}
|
|
31
|
+
# An exemption proves text-only use, rather than trusting a relationship Type.
|
|
32
|
+
_TEXT_LINK_TAGS = {"hyperlink", "r", "t", "rPr", "rStyle", "b", "bCs", "i", "iCs",
|
|
33
|
+
"u", "color", "sz", "szCs", "rFonts", "lang", "highlight", "vertAlign"}
|
|
34
|
+
_EXTERNAL_FIELD = re.compile(r"^\s*(INCLUDEPICTURE|INCLUDETEXT|LINK)\b", re.I)
|
|
35
|
+
_FIELD_TARGET = re.compile(r'''(?:[a-z][a-z0-9+.-]*://|\\\\|//)[^\s"'<>]+''', re.I)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _target_host(target: str) -> str | None:
|
|
39
|
+
"""Retain a destination hostname without userinfo, port, path or query."""
|
|
40
|
+
try:
|
|
41
|
+
parsed = urlsplit(target.strip().replace("\\", "/"))
|
|
42
|
+
return parsed.hostname
|
|
43
|
+
except ValueError:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _local_name(name: str) -> str:
|
|
48
|
+
return name.rsplit("}", 1)[-1]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _attribute(node, name: str) -> str:
|
|
52
|
+
prefix = node.tag.rsplit("}", 1)[0] + "}" if node.tag.startswith("{") else ""
|
|
53
|
+
return node.get(prefix + name, node.get(name, ""))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _field_instructions(root):
|
|
57
|
+
"""Read both simple and run-split Word fields without reading display text."""
|
|
58
|
+
stack: list[list[str]] = []
|
|
59
|
+
orphans: list[str] = []
|
|
60
|
+
for node in root.iter():
|
|
61
|
+
kind = _local_name(node.tag)
|
|
62
|
+
if kind == "fldSimple":
|
|
63
|
+
yield _attribute(node, "instr")
|
|
64
|
+
elif kind == "fldChar":
|
|
65
|
+
marker = _attribute(node, "fldCharType")
|
|
66
|
+
if marker == "begin":
|
|
67
|
+
stack.append([])
|
|
68
|
+
elif marker in {"separate", "end"} and stack:
|
|
69
|
+
yield "".join(stack[-1])
|
|
70
|
+
if marker == "end":
|
|
71
|
+
stack.pop()
|
|
72
|
+
elif kind in {"instrText", "delInstrText"}:
|
|
73
|
+
text = "".join(node.itertext())
|
|
74
|
+
if stack:
|
|
75
|
+
# Nested QUOTE/IF fields can supply a parent's resource target.
|
|
76
|
+
# Collect their instructions in every enclosing field as well.
|
|
77
|
+
for pieces in stack:
|
|
78
|
+
pieces.append(text)
|
|
79
|
+
else:
|
|
80
|
+
orphans.append(text)
|
|
81
|
+
yield text
|
|
82
|
+
for pieces in stack:
|
|
83
|
+
yield "".join(pieces)
|
|
84
|
+
# Incomplete fields can still be acted upon by a repairing converter.
|
|
85
|
+
yield "".join(orphans)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _field_requires_resource(instruction: str, command_end: int) -> bool:
|
|
89
|
+
"""Only exempt an explicit local drive target; dynamic/unknown fields deny.
|
|
90
|
+
|
|
91
|
+
In particular a single backslash may be a converter-repaired network path.
|
|
92
|
+
Resolving Word field expressions is not a safe preflight operation.
|
|
93
|
+
"""
|
|
94
|
+
target = instruction[command_end:].strip()
|
|
95
|
+
# LINK has an application identifier before its filename. Treat it as an
|
|
96
|
+
# external dependency whenever its target cannot be proved a local path.
|
|
97
|
+
if instruction[:command_end].strip().upper() == "LINK":
|
|
98
|
+
return True
|
|
99
|
+
return not any(re.fullmatch(pattern, target) for pattern in (
|
|
100
|
+
r'''"[a-zA-Z]:[\\/][^"'\r\n:{}]+"''',
|
|
101
|
+
r"'[a-zA-Z]:[\\/][^\"'\r\n:{}]+'",
|
|
102
|
+
r'''[a-zA-Z]:[\\/][^\s"':{}]+''',
|
|
103
|
+
))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _text_hyperlink(node) -> bool:
|
|
107
|
+
return bool("".join(node.itertext()).strip()) and all(
|
|
108
|
+
child.tag in {"{" + namespace + "}" + tag for namespace in _WORD_NAMESPACES for tag in _TEXT_LINK_TAGS}
|
|
109
|
+
for child in node.iter()
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _only_text_hyperlink_references(root, relation_id: str) -> bool:
|
|
114
|
+
if root is None or not relation_id:
|
|
115
|
+
return False
|
|
116
|
+
references = []
|
|
117
|
+
for node in root.iter():
|
|
118
|
+
for attribute, value in node.attrib.items():
|
|
119
|
+
if relation_id not in value.split():
|
|
120
|
+
continue
|
|
121
|
+
references.append(
|
|
122
|
+
node.tag in {"{" + namespace + "}hyperlink" for namespace in _WORD_NAMESPACES}
|
|
123
|
+
and attribute in {"{" + namespace + "}id" for namespace in _REL_NAMESPACES}
|
|
124
|
+
and value == relation_id
|
|
125
|
+
and _text_hyperlink(node)
|
|
126
|
+
)
|
|
127
|
+
return bool(references) and all(references)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _relationship_source(name: str) -> str | None:
|
|
131
|
+
path = PurePosixPath(name)
|
|
132
|
+
if path.parent.name != "_rels" or path.name == ".rels":
|
|
133
|
+
return None
|
|
134
|
+
return str(path.parent.parent / path.name.removesuffix(".rels"))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def inspect_external_resources(path: Path) -> dict:
|
|
138
|
+
"""Preflight every relationship and Word field before launching LibreOffice.
|
|
139
|
+
|
|
140
|
+
Relationship targets and field contents may contain credentials; receipts only
|
|
141
|
+
contain relationship/field types and hostnames. Parsing never resolves URLs.
|
|
142
|
+
Malformed containers/parts fail closed in the caller.
|
|
143
|
+
"""
|
|
144
|
+
relationship_types: set[str] = set()
|
|
145
|
+
field_types: set[str] = set()
|
|
146
|
+
target_hosts: set[str] = set()
|
|
147
|
+
inspected_parts = 0
|
|
148
|
+
hyperlinks = 0
|
|
149
|
+
with ZipFile(path) as archive:
|
|
150
|
+
roots = {}
|
|
151
|
+
for info in archive.infolist():
|
|
152
|
+
name = info.filename.replace("\\", "/")
|
|
153
|
+
if not name.lower().endswith((".rels", ".xml", ".vml")):
|
|
154
|
+
continue
|
|
155
|
+
if name in roots:
|
|
156
|
+
raise ValueError("duplicate_office_xml_part")
|
|
157
|
+
root = ElementTree.fromstring(archive.read(info))
|
|
158
|
+
roots[name] = root
|
|
159
|
+
inspected_parts += 1
|
|
160
|
+
for name, root in list(roots.items()):
|
|
161
|
+
if name.lower().endswith(".rels"):
|
|
162
|
+
source_name = _relationship_source(name)
|
|
163
|
+
source = roots.get(source_name)
|
|
164
|
+
# OPC parts need not have an .xml extension. Parse the actual
|
|
165
|
+
# referring source part before granting its exemption.
|
|
166
|
+
if source is None and source_name in archive.namelist():
|
|
167
|
+
source = ElementTree.fromstring(archive.read(source_name))
|
|
168
|
+
inspected_parts += 1
|
|
169
|
+
relation_ids = [node.get("Id", "") for node in root.iter() if _local_name(node.tag) == "Relationship"]
|
|
170
|
+
for node in root.iter():
|
|
171
|
+
if _local_name(node.tag) != "Relationship" or not any(
|
|
172
|
+
_local_name(key) == "TargetMode" and value.strip().lower() == "external"
|
|
173
|
+
for key, value in node.attrib.items()
|
|
174
|
+
):
|
|
175
|
+
continue
|
|
176
|
+
relation_type = node.get("Type", "")
|
|
177
|
+
relation_id = node.get("Id", "")
|
|
178
|
+
well_formed = (relation_id == relation_id.strip()
|
|
179
|
+
and all(node.get(key, "").strip() for key in ("Id", "Type", "Target", "TargetMode"))
|
|
180
|
+
and all(key in {"Id", "Type", "Target", "TargetMode"} for key in node.attrib))
|
|
181
|
+
if well_formed and relation_ids.count(relation_id) == 1 and _only_text_hyperlink_references(source, relation_id):
|
|
182
|
+
hyperlinks += 1
|
|
183
|
+
else:
|
|
184
|
+
relationship_types.add(relation_type or "unknown")
|
|
185
|
+
host = _target_host(node.get("Target", ""))
|
|
186
|
+
if host:
|
|
187
|
+
target_hosts.add(host)
|
|
188
|
+
if name.lower().startswith("word/") and name.lower().endswith((".xml", ".vml")):
|
|
189
|
+
for instruction in _field_instructions(root):
|
|
190
|
+
match = _EXTERNAL_FIELD.match(instruction)
|
|
191
|
+
if match and _field_requires_resource(instruction, match.end()):
|
|
192
|
+
field_types.add(match.group(1).upper())
|
|
193
|
+
for target in _FIELD_TARGET.findall(instruction[match.end():]):
|
|
194
|
+
host = _target_host(target)
|
|
195
|
+
if host:
|
|
196
|
+
target_hosts.add(host)
|
|
197
|
+
return {
|
|
198
|
+
"blocked": bool(relationship_types or field_types),
|
|
199
|
+
"relationship_types": sorted(relationship_types),
|
|
200
|
+
"field_types": sorted(field_types),
|
|
201
|
+
"target_hosts": sorted(target_hosts),
|
|
202
|
+
"hyperlinks": hyperlinks,
|
|
203
|
+
"inspected_parts": inspected_parts,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def soffice_path() -> str | None:
|
|
208
|
+
explicit = os.getenv("OPENMD_SOFFICE") or load_local_config().get("soffice")
|
|
209
|
+
if explicit:
|
|
210
|
+
path = Path(explicit).expanduser()
|
|
211
|
+
return str(path.resolve()) if path.is_file() else shutil.which(explicit)
|
|
212
|
+
discovered = shutil.which("soffice") or shutil.which("soffice.exe")
|
|
213
|
+
if discovered:
|
|
214
|
+
return discovered
|
|
215
|
+
for root in (os.getenv("PROGRAMFILES"), os.getenv("PROGRAMFILES(X86)")):
|
|
216
|
+
if root:
|
|
217
|
+
path = Path(root) / "LibreOffice/program/soffice.exe"
|
|
218
|
+
if path.is_file():
|
|
219
|
+
return str(path)
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def suspected_loss(path: Path, text: str, config: dict) -> dict:
|
|
224
|
+
objects = {"images": 0, "charts": 0, "smartart": 0}
|
|
225
|
+
try:
|
|
226
|
+
with ZipFile(path) as archive:
|
|
227
|
+
names = archive.namelist()
|
|
228
|
+
objects["images"] = sum("/media/" in name and not name.endswith("/") for name in names)
|
|
229
|
+
objects["charts"] = sum(bool(re.search(r"/charts/chart\d+\.xml$", name)) for name in names)
|
|
230
|
+
objects["smartart"] = sum(bool(re.search(r"/diagrams/data\d+\.xml$", name)) for name in names)
|
|
231
|
+
except (OSError, BadZipFile):
|
|
232
|
+
return {"suspected": False, "reason": "invalid_container", **objects}
|
|
233
|
+
visible = re.sub(r"<!--.*?-->|<[^>]+>|!\[[^\]]*\]\([^)]*\)", "", text, flags=re.S)
|
|
234
|
+
visible = re.sub(r"^#{1,6} .*?$", "", visible, flags=re.M)
|
|
235
|
+
count = sum(c.isalnum() for c in visible)
|
|
236
|
+
loss = count == 0 or (sum(objects.values()) > 0 and count < config["office_sparse_chars"])
|
|
237
|
+
return {"suspected": loss, "reason": "empty_result" if count == 0 else "visual_objects_sparse_text" if loss else None, "text_chars": count, **objects}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def recover_office(item, result, *, allow_ocr: bool, allow_external_resources: bool = False):
|
|
241
|
+
from .documents import extract_pdf
|
|
242
|
+
|
|
243
|
+
path = Path(item.raw_meta["local_path"])
|
|
244
|
+
detection = suspected_loss(path, result.text if result.has_usable_text else "", settings())
|
|
245
|
+
if not detection["suspected"]:
|
|
246
|
+
return replace(result, metadata={**result.metadata, "office_loss_check": detection})
|
|
247
|
+
resources = None
|
|
248
|
+
external_fetched = False
|
|
249
|
+
|
|
250
|
+
def external_metadata():
|
|
251
|
+
return {"office_external_resources_fetched": external_fetched,
|
|
252
|
+
**({"external_resources": resources, "external_resources_allowed": allow_external_resources} if resources is not None else {})}
|
|
253
|
+
|
|
254
|
+
def external_notes():
|
|
255
|
+
return ["Office content depends on external resources; --enable-network-backends allowed LibreOffice to fetch them. Resource availability and content may change."] if external_fetched else []
|
|
256
|
+
|
|
257
|
+
def incomplete(reason: str, extracted=None):
|
|
258
|
+
recovery = {"status": "blocked" if reason == "office_external_resource_blocked" else "unavailable", "reason": reason}
|
|
259
|
+
recovery.update(external_metadata())
|
|
260
|
+
pages = {"pages": extracted.metadata["pages"]} if extracted is not None and "pages" in extracted.metadata else {}
|
|
261
|
+
return replace(result, status="partial", error_code=reason, error_summary=reason, source_notes=[*result.source_notes, *external_notes(), "Office content may be missing: " + detection["reason"], "Office PDF recovery unavailable: " + reason + "; usable native text was retained. No automatic installation was attempted."], metadata={**result.metadata, **pages, "office_loss_check": detection, "office_external_resources_fetched": external_fetched, "office_recovery": recovery, "terminal_partial": True})
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
resources = inspect_external_resources(path)
|
|
265
|
+
except (OSError, BadZipFile, ElementTree.ParseError, RuntimeError, ValueError):
|
|
266
|
+
return incomplete("office_external_resource_check_failed")
|
|
267
|
+
if resources["blocked"] and not allow_external_resources:
|
|
268
|
+
return incomplete("office_external_resource_blocked")
|
|
269
|
+
executable = soffice_path()
|
|
270
|
+
|
|
271
|
+
if not executable:
|
|
272
|
+
return incomplete("libreoffice_missing")
|
|
273
|
+
try:
|
|
274
|
+
with owned_temporary_directory(prefix="openmd-office-") as tmp:
|
|
275
|
+
directory = Path(tmp)
|
|
276
|
+
with (directory / "convert.log").open("wb") as log:
|
|
277
|
+
process = owned_popen([executable, "-env:UserInstallation=" + (directory / "profile").as_uri(), "--headless", "--convert-to", "pdf", "--outdir", str(directory), str(path.resolve())], stdin=subprocess.DEVNULL, stdout=log, stderr=log, **process_options())
|
|
278
|
+
external_fetched = bool(resources["blocked"] and allow_external_resources)
|
|
279
|
+
try:
|
|
280
|
+
wait_interruptibly(process, 120)
|
|
281
|
+
except BaseException:
|
|
282
|
+
terminate_quietly(process)
|
|
283
|
+
raise
|
|
284
|
+
converted = directory / (path.stem + ".pdf")
|
|
285
|
+
if process.returncode or not converted.is_file():
|
|
286
|
+
return incomplete("libreoffice_conversion_failed")
|
|
287
|
+
document = replace(item, url=converted.as_uri(), raw_meta={**item.raw_meta, "local_path": str(converted)})
|
|
288
|
+
from .documents import _office_pdf_origin
|
|
289
|
+
|
|
290
|
+
origin = _office_pdf_origin(converted, path)
|
|
291
|
+
extracted = extract_pdf(document, allow_ocr=allow_ocr, _office_origin=origin)
|
|
292
|
+
if not extracted.has_usable_text:
|
|
293
|
+
return incomplete(extracted.error_code or "office_recovery_empty", extracted)
|
|
294
|
+
page_extractions = [{"page": page.get("page"), "route": page.get("route"), "engine": page.get("engine")} for page in extracted.metadata.get("pages", [])]
|
|
295
|
+
formula_metadata = {}
|
|
296
|
+
if path.suffix.lower() == ".docx" and page_extractions and all(page.get("route") == "textlayer" and page.get("status") == "verified" for page in extracted.metadata["pages"]):
|
|
297
|
+
from .native_math import restore_docx_math
|
|
298
|
+
|
|
299
|
+
text, formula_metadata = restore_docx_math(path, extracted.text)
|
|
300
|
+
extracted = replace(extracted, text=text)
|
|
301
|
+
if formula_metadata["restored"]:
|
|
302
|
+
extracted = replace(extracted, source_notes=[*extracted.source_notes, "Native formula structure restored from DOCX OMML only where reliable PDF text matched uniquely, character for character except whitespace; no model was used."])
|
|
303
|
+
return replace(extracted, backend=result.backend, title=item.title, source_notes=[*extracted.source_notes, *external_notes(), "Office loss suspected; rendered locally through existing LibreOffice and extracted page by page."], metadata={**extracted.metadata, "source_format": path.suffix.lstrip("."), "office_loss_check": detection, "office_external_resources_fetched": external_fetched, "office_recovery": {"status": "completed" if extracted.status == "verified" else "partial", "converter": "LibreOffice", "page_extractions": page_extractions, **({"native_formula_restoration": formula_metadata} if formula_metadata else {}), **external_metadata()}})
|
|
304
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
305
|
+
return incomplete("libreoffice_conversion_failed")
|
openmd/ocr/ollama.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Read-only model discovery and bounded, interruptible Ollama inference.
|
|
2
|
+
|
|
3
|
+
No server lifecycle or model download operations exist in this module.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import http.client
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import queue
|
|
14
|
+
import socket
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import urllib.request
|
|
18
|
+
from urllib.parse import urlsplit
|
|
19
|
+
|
|
20
|
+
from .breakers import breaker_reason, estimate_expected_chars
|
|
21
|
+
from .endpoint import validate_ollama_url
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OllamaError(RuntimeError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def api_json(url: str, path: str, payload: dict | None = None, *, timeout: float = 3) -> dict:
|
|
29
|
+
url = validate_ollama_url(url)
|
|
30
|
+
data = json.dumps(payload).encode() if payload is not None else None
|
|
31
|
+
request = urllib.request.Request(url + path, data=data, headers={"Content-Type": "application/json"})
|
|
32
|
+
# Local inference must not be routed through an ambient proxy.
|
|
33
|
+
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
34
|
+
def redirect_request(self, *args, **kwargs):
|
|
35
|
+
raise OllamaError("ocr_ollama_redirect_rejected")
|
|
36
|
+
|
|
37
|
+
with urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()).open(request, timeout=timeout) as response:
|
|
38
|
+
result = json.load(response)
|
|
39
|
+
if not isinstance(result, dict) or result.get("error"):
|
|
40
|
+
raise OllamaError("ollama_invalid_response")
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def model_identity(tags: dict, name: str) -> dict:
|
|
45
|
+
matches = [m for m in tags.get("models", []) if str(m.get("name", "")).removesuffix(":latest") == name.removesuffix(":latest")]
|
|
46
|
+
if len(matches) != 1 or not isinstance(matches[0].get("digest"), str) or not matches[0]["digest"]:
|
|
47
|
+
raise OllamaError("ollama_model_missing: " + name)
|
|
48
|
+
return {"name": matches[0]["name"], "digest": matches[0]["digest"]}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def probe_ollama(config: dict) -> dict:
|
|
52
|
+
report = {"url": config["ollama_url"], "reachable": False, "models": {}, "ready": False}
|
|
53
|
+
try:
|
|
54
|
+
tags = api_json(config["ollama_url"], "/api/tags")
|
|
55
|
+
report["reachable"] = True
|
|
56
|
+
report["version"] = api_json(config["ollama_url"], "/api/version").get("version")
|
|
57
|
+
for key in ("ovis_model", "minicpm_model"):
|
|
58
|
+
try:
|
|
59
|
+
report["models"][key] = {**model_identity(tags, config[key]), "available": True}
|
|
60
|
+
except OllamaError:
|
|
61
|
+
report["models"][key] = {"name": config[key], "available": False, "digest": None}
|
|
62
|
+
report["ready"] = all(m["available"] for m in report["models"].values())
|
|
63
|
+
if not report["ready"]:
|
|
64
|
+
report["error_code"] = "ollama_model_missing"
|
|
65
|
+
except (OSError, ValueError, OllamaError) as exc:
|
|
66
|
+
report["error_code"] = getattr(exc, "code", "ollama_not_reachable")
|
|
67
|
+
return report
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def running_models(payload: dict) -> list[str]:
|
|
71
|
+
models = payload.get("models")
|
|
72
|
+
if not isinstance(models, list):
|
|
73
|
+
raise OllamaError("ollama_ps_invalid")
|
|
74
|
+
names = []
|
|
75
|
+
for model in models:
|
|
76
|
+
if not isinstance(model, dict):
|
|
77
|
+
raise OllamaError("ollama_ps_invalid")
|
|
78
|
+
name = model.get("name", model.get("model"))
|
|
79
|
+
if not isinstance(name, str) or not name.strip():
|
|
80
|
+
raise OllamaError("ollama_ps_invalid")
|
|
81
|
+
names.append(name)
|
|
82
|
+
return names
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def unload_model(config: dict, name: str) -> None:
|
|
86
|
+
"""Wait for unload before another GPU engine can start."""
|
|
87
|
+
api_json(config["ollama_url"], "/api/generate", {"model": name, "keep_alive": 0, "stream": False}, timeout=15)
|
|
88
|
+
deadline = time.monotonic() + 15
|
|
89
|
+
while True:
|
|
90
|
+
running = running_models(api_json(config["ollama_url"], "/api/ps"))
|
|
91
|
+
if not any(m.removesuffix(":latest") == name.removesuffix(":latest") for m in running):
|
|
92
|
+
return
|
|
93
|
+
if time.monotonic() >= deadline:
|
|
94
|
+
raise OllamaError("ollama_unload_unconfirmed")
|
|
95
|
+
time.sleep(0.1)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def stream_recognize(image, *, model: str, prompt: str, config: dict, deadline: float) -> dict:
|
|
99
|
+
"""Read the stream on a disposable I/O thread; poll on the main thread.
|
|
100
|
+
|
|
101
|
+
Shutdown the socket on timeout/Ctrl+C/breaker before issuing unload. This
|
|
102
|
+
avoids Windows blocking socket reads masking Ctrl+C for a whole page budget.
|
|
103
|
+
"""
|
|
104
|
+
config = {**config, "ollama_url": validate_ollama_url(config["ollama_url"])}
|
|
105
|
+
from PIL import Image
|
|
106
|
+
|
|
107
|
+
budget = estimate_expected_chars(image, config)
|
|
108
|
+
prepared = image.copy()
|
|
109
|
+
if prepared.width * prepared.height > config["max_pixels"]:
|
|
110
|
+
factor = math.sqrt(config["max_pixels"] / (prepared.width * prepared.height))
|
|
111
|
+
prepared = prepared.resize((max(1, int(prepared.width * factor)), max(1, int(prepared.height * factor))), Image.Resampling.LANCZOS)
|
|
112
|
+
buffer = io.BytesIO()
|
|
113
|
+
prepared.save(buffer, format="PNG")
|
|
114
|
+
payload = {"model": model, "messages": [{"role": "user", "content": prompt, "images": [base64.b64encode(buffer.getvalue()).decode("ascii")]}], "stream": True, "keep_alive": "10m", "think": False, "options": {"temperature": 0, "top_p": 0.001, "top_k": 1, "repeat_penalty": 1, "num_ctx": config["num_ctx"], "num_predict": config["num_predict"]}}
|
|
115
|
+
address = urlsplit(config["ollama_url"])
|
|
116
|
+
events: queue.Queue = queue.Queue()
|
|
117
|
+
sockets: list = []
|
|
118
|
+
canceled = threading.Event()
|
|
119
|
+
|
|
120
|
+
def read():
|
|
121
|
+
connection = None
|
|
122
|
+
try:
|
|
123
|
+
remaining = max(0.1, deadline - time.monotonic())
|
|
124
|
+
cls = http.client.HTTPSConnection if address.scheme == "https" else http.client.HTTPConnection
|
|
125
|
+
connection = cls(address.hostname, address.port, timeout=remaining)
|
|
126
|
+
connection.connect()
|
|
127
|
+
sockets.append(connection.sock)
|
|
128
|
+
if canceled.is_set():
|
|
129
|
+
return
|
|
130
|
+
connection.request("POST", address.path.rstrip("/") + "/api/chat", body=json.dumps(payload).encode(), headers={"Content-Type": "application/json"})
|
|
131
|
+
response = connection.getresponse()
|
|
132
|
+
if response.status != 200:
|
|
133
|
+
raise OllamaError(f"ollama_http_{response.status}")
|
|
134
|
+
while not canceled.is_set():
|
|
135
|
+
line = response.readline()
|
|
136
|
+
if not line:
|
|
137
|
+
break
|
|
138
|
+
if not line.strip():
|
|
139
|
+
continue
|
|
140
|
+
chunk = json.loads(line)
|
|
141
|
+
if chunk.get("error"):
|
|
142
|
+
raise OllamaError("ollama_inference_error")
|
|
143
|
+
events.put(("chunk", chunk))
|
|
144
|
+
if chunk.get("done"):
|
|
145
|
+
break
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
events.put(("error", exc))
|
|
148
|
+
finally:
|
|
149
|
+
if connection is not None:
|
|
150
|
+
connection.close()
|
|
151
|
+
events.put(("end", None))
|
|
152
|
+
|
|
153
|
+
start = time.monotonic()
|
|
154
|
+
thread = threading.Thread(target=read, name="openmd-ollama-stream", daemon=True)
|
|
155
|
+
thread.start()
|
|
156
|
+
parts, final = [], {}
|
|
157
|
+
reason = None
|
|
158
|
+
try:
|
|
159
|
+
while True:
|
|
160
|
+
remaining = deadline - time.monotonic()
|
|
161
|
+
if remaining <= 0:
|
|
162
|
+
reason = {"rule": "page_deadline"}
|
|
163
|
+
break
|
|
164
|
+
try:
|
|
165
|
+
kind, value = events.get(timeout=min(0.1, remaining))
|
|
166
|
+
except queue.Empty:
|
|
167
|
+
continue
|
|
168
|
+
if kind == "error":
|
|
169
|
+
if time.monotonic() >= deadline or isinstance(value, TimeoutError):
|
|
170
|
+
reason = {"rule": "page_deadline"}
|
|
171
|
+
break
|
|
172
|
+
raise OllamaError("ollama_stream_failed") from value
|
|
173
|
+
if kind == "end":
|
|
174
|
+
if not final.get("done"):
|
|
175
|
+
reason = {"rule": "stream_incomplete"}
|
|
176
|
+
break
|
|
177
|
+
content = value.get("message", {}).get("content", "")
|
|
178
|
+
if not isinstance(content, str):
|
|
179
|
+
raise OllamaError("ollama_invalid_content")
|
|
180
|
+
parts.append(content)
|
|
181
|
+
reason = breaker_reason("".join(parts), budget, config)
|
|
182
|
+
if reason:
|
|
183
|
+
break
|
|
184
|
+
if value.get("done"):
|
|
185
|
+
final = value
|
|
186
|
+
if value.get("done_reason") == "length" or value.get("eval_count", 0) >= config["num_predict"]:
|
|
187
|
+
reason = {"rule": "token_limit", "num_predict": config["num_predict"]}
|
|
188
|
+
break
|
|
189
|
+
finally:
|
|
190
|
+
canceled.set()
|
|
191
|
+
for sock in sockets:
|
|
192
|
+
if sock:
|
|
193
|
+
try:
|
|
194
|
+
sock.shutdown(socket.SHUT_RDWR)
|
|
195
|
+
except OSError:
|
|
196
|
+
pass
|
|
197
|
+
thread.join(timeout=2)
|
|
198
|
+
if thread.is_alive():
|
|
199
|
+
raise OllamaError("ollama_stream_cancel_unconfirmed")
|
|
200
|
+
return {"text": "".join(parts), "seconds": round(time.monotonic() - start, 3), "breaker": reason, "tokens_generated": final.get("eval_count"), "input_size": list(prepared.size), "capacity": budget}
|
openmd/ocr/otsl.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Convert MiniCPM OTSL cell markers to raw HTML without adding dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def table_html(text: str) -> str:
|
|
10
|
+
value = re.sub(r"^\s*<otsl>|</otsl>\s*$", "", text.strip())
|
|
11
|
+
if not value.startswith(("<fcel>", "<ecel>", "<lcel>", "<ucel>", "<xcel>")):
|
|
12
|
+
value = "<fcel>" + value
|
|
13
|
+
tokens = re.split(r"(<(?:fcel|ecel|lcel|ucel|xcel|nl)>)", value)
|
|
14
|
+
cells, grid = [], {}
|
|
15
|
+
row = col = 0
|
|
16
|
+
current = None
|
|
17
|
+
for token in tokens:
|
|
18
|
+
if token == "<nl>":
|
|
19
|
+
row, col, current = row + 1, 0, None
|
|
20
|
+
elif token in {"<fcel>", "<ecel>"}:
|
|
21
|
+
current = {"row": row, "col": col, "row_end": row, "col_end": col, "text": ""}
|
|
22
|
+
cells.append(current)
|
|
23
|
+
grid[row, col] = current
|
|
24
|
+
col += 1
|
|
25
|
+
elif token in {"<lcel>", "<ucel>", "<xcel>"}:
|
|
26
|
+
owner = grid.get((row, col - 1)) if token == "<lcel>" else grid.get((row - 1, col))
|
|
27
|
+
if owner is None:
|
|
28
|
+
raise ValueError("invalid OTSL span")
|
|
29
|
+
owner["row_end"] = max(owner["row_end"], row)
|
|
30
|
+
owner["col_end"] = max(owner["col_end"], col)
|
|
31
|
+
grid[row, col] = owner
|
|
32
|
+
current = None
|
|
33
|
+
col += 1
|
|
34
|
+
elif token.strip():
|
|
35
|
+
if current is None:
|
|
36
|
+
raise ValueError("invalid OTSL content")
|
|
37
|
+
current["text"] += token
|
|
38
|
+
if not cells:
|
|
39
|
+
raise ValueError("empty OTSL table")
|
|
40
|
+
lines = ["<table>"]
|
|
41
|
+
for index in range(max(c["row"] for c in cells) + 1):
|
|
42
|
+
lines.append("<tr>")
|
|
43
|
+
for cell in (c for c in cells if c["row"] == index):
|
|
44
|
+
attrs = ""
|
|
45
|
+
for attr, start, end in (("rowspan", "row", "row_end"), ("colspan", "col", "col_end")):
|
|
46
|
+
span = cell[end] - cell[start] + 1
|
|
47
|
+
if span > 1:
|
|
48
|
+
attrs += f' {attr}="{span}"'
|
|
49
|
+
lines.append(f"<td{attrs}>{html.escape(cell['text'].strip())}</td>")
|
|
50
|
+
lines.append("</tr>")
|
|
51
|
+
return "\n".join([*lines, "</table>"])
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Extract all readable content from the image in natural human reading order and output the result as a single Markdown document. For charts or images, represent them using an HTML image tag: <img src="images/bbox_{left}_{top}_{right}_{bottom}.jpg" />, where left, top, right, bottom are bounding box coordinates scaled to [0, 1000). Format formulas as LaTeX. Format tables as HTML: <table>...</table>. Transcribe all other text as standard Markdown. Preserve the original text without translation or paraphrasing.
|