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
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.