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/layout.py ADDED
@@ -0,0 +1,92 @@
1
+ """Bounded external layout worker; never imports torch in the controller."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import tempfile
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from openmd.runtime.interpreters import owned_popen, communicate_interruptibly, process_options, terminate_quietly, wait_interruptibly, worker_command, worker_environment
12
+ from .config import layout_runtime
13
+
14
+ from openmd.runtime.tempdirs import owned_temporary_directory
15
+
16
+
17
+ class LayoutUnavailable(RuntimeError):
18
+ pass
19
+
20
+
21
+ def _run(args: list[str], timeout: float, directory: Path):
22
+ python, _ = layout_runtime()
23
+ if python is None:
24
+ raise LayoutUnavailable("fallback_unavailable: configure ocr_python / OPENMD_OCR_PYTHON")
25
+ environment = worker_environment()
26
+ # Windows CUDA may treat an empty value as unset. -1 hides every device.
27
+ environment["CUDA_VISIBLE_DEVICES"] = "-1"
28
+ with (directory / "layout.log").open("wb") as log:
29
+ process = owned_popen(worker_command(python, *args, module="openmd.ocr.layout_worker"), cwd=directory, env=environment, stdin=subprocess.DEVNULL, stdout=log, stderr=log, **process_options())
30
+ try:
31
+ wait_interruptibly(process, timeout)
32
+ except BaseException:
33
+ terminate_quietly(process)
34
+ raise
35
+ if process.returncode:
36
+ raise LayoutUnavailable("fallback_unavailable: external layout worker failed; check local torch/transformers and PP-DocLayoutV3 weights")
37
+
38
+
39
+ def detect_regions(image, config: dict, deadline: float) -> tuple[list[dict], float]:
40
+ python, model = layout_runtime()
41
+ if python is None or model is None:
42
+ raise LayoutUnavailable("fallback_unavailable: configure ocr_python and a model_roots directory containing layout/PP-DocLayoutV3")
43
+ start = time.monotonic()
44
+ remaining = deadline - start
45
+ if remaining <= 0:
46
+ raise TimeoutError("page_deadline")
47
+ try:
48
+ with owned_temporary_directory(prefix="openmd-layout-") as tmp:
49
+ directory = Path(tmp)
50
+ source, result = directory / "input.png", directory / "result.json"
51
+ image.save(source)
52
+ _run(["--input", str(source), "--model", str(model), "--result", str(result), "--threshold", str(config["layout_threshold"])], min(config["layout_seconds"], remaining), directory)
53
+ if not result.is_file() or result.stat().st_size > 4 * 1024 * 1024:
54
+ raise LayoutUnavailable("fallback_unavailable: invalid layout worker result")
55
+ payload = json.loads(result.read_text(encoding="utf-8"))
56
+ regions = payload.get("regions")
57
+ if payload.get("schema") != "openmd.layout/v1" or not isinstance(regions, list) or not regions:
58
+ raise LayoutUnavailable("fallback_unavailable: no layout regions")
59
+ for region in regions:
60
+ box = region.get("bbox")
61
+ if not isinstance(region.get("category"), str) or not isinstance(box, list) or len(box) != 4 or not all(isinstance(v, int) for v in box) or not (0 <= box[0] < box[2] <= image.width and 0 <= box[1] < box[3] <= image.height):
62
+ raise LayoutUnavailable("fallback_unavailable: invalid layout region")
63
+ return regions, time.monotonic() - start
64
+ except (OSError, ValueError, subprocess.SubprocessError) as exc:
65
+ raise LayoutUnavailable("fallback_unavailable: external layout worker unavailable") from exc
66
+
67
+
68
+ def probe_layout() -> dict:
69
+ python, model = layout_runtime()
70
+ report = {"python_configured": python is not None, "model_available": model is not None, "ready": False}
71
+ if python is None or model is None:
72
+ return report
73
+ try:
74
+ with owned_temporary_directory(prefix="openmd-layout-probe-") as tmp:
75
+ directory = Path(tmp)
76
+ environment = worker_environment()
77
+ environment["CUDA_VISIBLE_DEVICES"] = "-1"
78
+ process = owned_popen(worker_command(python, "--probe", module="openmd.ocr.layout_worker"), cwd=directory, env=environment, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **process_options())
79
+ try:
80
+ stdout, _ = communicate_interruptibly(process, timeout=15)
81
+ except BaseException:
82
+ terminate_quietly(process)
83
+ raise
84
+ if process.returncode or len(stdout) > 1024 * 1024:
85
+ raise ValueError("layout probe failed")
86
+ payload = json.loads(stdout)
87
+ if payload.get("schema") != "openmd.layout-probe/v1":
88
+ raise ValueError("invalid layout probe")
89
+ report.update(payload)
90
+ except (OSError, ValueError, subprocess.SubprocessError):
91
+ report["error_code"] = "fallback_unavailable"
92
+ return report
@@ -0,0 +1,118 @@
1
+ # Copyright (c) 2024 PaddlePaddle Authors. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+
16
+ # Verbatim rect-layout functions; polygon branch not used.
17
+ from copy import deepcopy
18
+ from typing import Dict,List,Tuple
19
+ import numpy as np
20
+
21
+ def _compute_pairwise_overlap_small(
22
+ coords: np.ndarray,
23
+ ) -> Tuple[np.ndarray, np.ndarray]:
24
+ """
25
+ Compute pairwise overlap ratio (mode="small") and bbox areas.
26
+
27
+ This only replaces the numeric overlap/area calculation. The caller keeps
28
+ the original sequential dropping order because it affects inline formulas.
29
+ """
30
+ x1 = coords[:, 0]
31
+ y1 = coords[:, 1]
32
+ x2 = coords[:, 2]
33
+ y2 = coords[:, 3]
34
+
35
+ areas = np.abs((x2 - x1) * (y2 - y1))
36
+
37
+ inter_x1 = np.maximum(x1[:, None], x1[None, :])
38
+ inter_y1 = np.maximum(y1[:, None], y1[None, :])
39
+ inter_x2 = np.minimum(x2[:, None], x2[None, :])
40
+ inter_y2 = np.minimum(y2[:, None], y2[None, :])
41
+
42
+ inter_w = np.maximum(0, inter_x2 - inter_x1)
43
+ inter_h = np.maximum(0, inter_y2 - inter_y1)
44
+ inter_area = inter_w * inter_h
45
+
46
+ small_area = np.minimum(areas[:, None], areas[None, :])
47
+ with np.errstate(divide="ignore", invalid="ignore"):
48
+ overlap = np.where(small_area > 0, inter_area / small_area, 0.0)
49
+ return overlap, areas
50
+
51
+
52
+ def filter_overlap_boxes(
53
+ layout_det_res: Dict[str, List[Dict]], layout_shape_mode: str
54
+ ) -> Dict[str, List[Dict]]:
55
+ """
56
+ Remove overlapping boxes from layout detection results based on a given overlap ratio.
57
+
58
+ Args:
59
+ layout_det_res (Dict[str, List[Dict]]): Layout detection result dict containing a 'boxes' list.
60
+
61
+ Returns:
62
+ Dict[str, List[Dict]]: Filtered dict with overlapping boxes removed.
63
+ """
64
+ layout_det_res_filtered = deepcopy(layout_det_res)
65
+ boxes = [
66
+ box for box in layout_det_res_filtered["boxes"] if box["label"] != "reference"
67
+ ]
68
+ if not boxes:
69
+ layout_det_res_filtered["boxes"] = boxes
70
+ return layout_det_res_filtered
71
+
72
+ coords = np.array([box["coordinate"] for box in boxes], dtype=np.float64)
73
+ widths = coords[:, 2] - coords[:, 0]
74
+ heights = coords[:, 3] - coords[:, 1]
75
+ overlap_matrix, areas = _compute_pairwise_overlap_small(coords)
76
+ dropped_indexes = set()
77
+
78
+ for i in range(len(boxes)):
79
+ if widths[i] < 6 or heights[i] < 6:
80
+ dropped_indexes.add(i)
81
+ for j in range(i + 1, len(boxes)):
82
+ if i in dropped_indexes or j in dropped_indexes:
83
+ continue
84
+ overlap_ratio = overlap_matrix[i, j]
85
+ if (
86
+ boxes[i]["label"] == "inline_formula"
87
+ or boxes[j]["label"] == "inline_formula"
88
+ ):
89
+ if overlap_ratio > 0.5:
90
+ if boxes[i]["label"] == "inline_formula":
91
+ dropped_indexes.add(i)
92
+ if boxes[j]["label"] == "inline_formula":
93
+ dropped_indexes.add(j)
94
+ continue
95
+ if overlap_ratio > 0.7:
96
+ if layout_shape_mode != "rect" and "polygon_points" in boxes[i]:
97
+ poly_overlap_ratio = calculate_polygon_overlap_ratio(
98
+ boxes[i]["polygon_points"], boxes[j]["polygon_points"], "small"
99
+ )
100
+ if poly_overlap_ratio < 0.7:
101
+ continue
102
+ labels = {boxes[i]["label"], boxes[j]["label"]}
103
+ if labels & {"image", "table", "seal", "chart"} and len(labels) > 1:
104
+ if "table" not in labels or labels <= {
105
+ "table",
106
+ "image",
107
+ "seal",
108
+ "chart",
109
+ }:
110
+ continue
111
+ if areas[i] >= areas[j]:
112
+ dropped_indexes.add(j)
113
+ else:
114
+ dropped_indexes.add(i)
115
+ layout_det_res_filtered["boxes"] = [
116
+ box for idx, box in enumerate(boxes) if idx not in dropped_indexes
117
+ ]
118
+ return layout_det_res_filtered
@@ -0,0 +1,89 @@
1
+ """Offline PP-DocLayoutV3 CPU worker. No OCR model is loaded here."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib.metadata
7
+ import importlib.util
8
+ import json
9
+ import math
10
+ import os
11
+ from pathlib import Path
12
+
13
+
14
+ def detect(image, folder: Path, threshold: float) -> list[dict]:
15
+ import torch
16
+ import transformers
17
+ from .layout_filter import filter_overlap_boxes
18
+
19
+ assert_cpu_only(torch)
20
+ torch.set_num_threads(4)
21
+ processor_class = getattr(transformers, "PPDocLayoutV3ImageProcessorFast", transformers.AutoImageProcessor)
22
+ processor = processor_class.from_pretrained(folder, local_files_only=True, trust_remote_code=False)
23
+ model = transformers.AutoModelForObjectDetection.from_pretrained(folder, local_files_only=True, trust_remote_code=False, torch_dtype=torch.float32).to("cpu").eval()
24
+ inputs = processor(images=image, return_tensors="pt")
25
+ with torch.inference_mode():
26
+ output = model(**inputs)
27
+ result = processor.post_process_object_detection(output, target_sizes=[(image.height, image.width)], threshold=threshold)[0]
28
+ regions = []
29
+ for index, (box, label, score) in enumerate(zip(result["boxes"], result["labels"], result["scores"])):
30
+ left, top, right, bottom = [float(v) for v in box]
31
+ coords = [max(0, math.floor(left)), max(0, math.floor(top)), min(image.width, math.ceil(right)), min(image.height, math.ceil(bottom))]
32
+ if coords[2] <= coords[0] or coords[3] <= coords[1]:
33
+ continue
34
+ labels = model.config.id2label
35
+ category = labels.get(int(label), labels.get(str(int(label)), str(int(label))))
36
+ regions.append({"order": index, "category": category, "bbox": coords, "score": float(score), "label": category, "coordinate": coords})
37
+ # Preserve the learned upstream reading order, including inline formula filtering.
38
+ filtered = filter_overlap_boxes({"boxes": regions}, "rect")["boxes"]
39
+ return [{key: value for key, value in region.items() if key not in {"label", "coordinate"}} for region in filtered]
40
+
41
+
42
+ def assert_cpu_only(torch=None) -> None:
43
+ # Reject a mis-launched worker instead of silently hiding a visible GPU.
44
+ # The controller passes -1: an empty Windows environment value can leave
45
+ # CUDA visible. Even an explicit mask must pass the actual runtime check.
46
+ if os.environ.get("CUDA_VISIBLE_DEVICES") not in {"", "-1"}:
47
+ raise RuntimeError("layout_cpu_only: CUDA_VISIBLE_DEVICES must hide all devices")
48
+ if torch is not None and torch.cuda.is_available():
49
+ raise RuntimeError("layout_cpu_only: CUDA is visible to the layout worker")
50
+
51
+
52
+ def main() -> int:
53
+ parser = argparse.ArgumentParser(description=__doc__)
54
+ parser.add_argument("--probe", action="store_true")
55
+ parser.add_argument("--input", type=Path)
56
+ parser.add_argument("--model", type=Path)
57
+ parser.add_argument("--result", type=Path)
58
+ parser.add_argument("--threshold", type=float, default=0.5)
59
+ args = parser.parse_args()
60
+ assert_cpu_only()
61
+ os.environ.update({"HF_HUB_OFFLINE": "1", "TRANSFORMERS_OFFLINE": "1", "TOKENIZERS_PARALLELISM": "false"})
62
+ if args.probe:
63
+ modules = {name: importlib.util.find_spec(name) is not None for name in ("torch", "transformers", "PIL", "numpy", "torchvision")}
64
+ cuda_visible = None
65
+ if modules["torch"]:
66
+ import torch
67
+
68
+ assert_cpu_only(torch)
69
+ cuda_visible = torch.cuda.is_available()
70
+ versions = {}
71
+ for name in ("torch", "transformers", "torchvision"):
72
+ try:
73
+ versions[name] = importlib.metadata.version(name)
74
+ except importlib.metadata.PackageNotFoundError:
75
+ versions[name] = None
76
+ print(json.dumps({"schema": "openmd.layout-probe/v1", "ready": all(modules.values()), "modules": modules, "versions": versions, "device": "cpu", "cpu_only": True, "cuda_visible": cuda_visible}))
77
+ return 0
78
+ if args.input is None or args.model is None or args.result is None:
79
+ parser.error("--input, --model and --result required")
80
+ from PIL import Image
81
+
82
+ with Image.open(args.input) as source:
83
+ regions = detect(source.convert("RGB"), args.model, args.threshold)
84
+ args.result.write_text(json.dumps({"schema": "openmd.layout/v1", "regions": regions}), encoding="utf-8")
85
+ return 0
86
+
87
+
88
+ if __name__ == "__main__":
89
+ raise SystemExit(main())
@@ -0,0 +1,220 @@
1
+ """Restore supported DOCX formula structure only with unique native text proof."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from io import BytesIO
7
+ from pathlib import Path
8
+ from xml.etree import ElementTree as ET
9
+ from zipfile import BadZipFile, ZipFile
10
+
11
+
12
+ _MATH = "{http://schemas.openxmlformats.org/officeDocument/2006/math}"
13
+ _WORD_NAMESPACES = {"http://schemas.openxmlformats.org/wordprocessingml/2006/main",
14
+ "http://purl.oclc.org/ooxml/wordprocessingml/main"}
15
+ _COMPATIBILITY = "{http://schemas.openxmlformats.org/markup-compatibility/2006}"
16
+
17
+
18
+ def _xml_source(data):
19
+ """Retain namespace scopes needed to interpret mc:Ignorable accurately."""
20
+ scopes, pending, ignored = [], {}, {}
21
+ for event, value in ET.iterparse(BytesIO(data), events=("start", "end", "start-ns")):
22
+ if event == "start-ns":
23
+ pending[value[0]] = value[1]
24
+ elif event == "start":
25
+ names = dict(scopes[-1][0]) if scopes else {}
26
+ names.update(pending)
27
+ pending.clear()
28
+ namespaces = set(scopes[-1][1]) if scopes else set()
29
+ namespaces.update(names.get(prefix, "") for prefix in value.get(_COMPATIBILITY + "Ignorable", "").split())
30
+ ignored[value] = value.tag.partition("}")[0].removeprefix("{") in namespaces
31
+ scopes.append((names, namespaces))
32
+ else:
33
+ scopes.pop()
34
+ return value, ignored
35
+
36
+
37
+ def _permitted_math_sources(roots, ignored) -> list[str]:
38
+ """Prove the entire ancestry, not just absence of known unsafe wrappers.
39
+
40
+ This intentionally accepts a small rendering grammar. Properties, extension
41
+ wrappers and other parts cannot acquire identity from matching PDF digits.
42
+ Header/footer math is checked but only main-document math is restored.
43
+ """
44
+ reasons = set()
45
+ for part, root in roots.items():
46
+ if not any(n.tag.rsplit('}', 1)[-1] in {"oMath", "oMathPara"} for n in root.iter()):
47
+ continue
48
+ expected = "document" if part == "word/document.xml" else (
49
+ "hdr" if re.fullmatch(r"word/header[0-9]+\.xml", part) else
50
+ "ftr" if re.fullmatch(r"word/footer[0-9]+\.xml", part) else None)
51
+ namespace = next((ns for ns in _WORD_NAMESPACES if root.tag == "{" + ns + "}" + str(expected)), None)
52
+ if expected is None or namespace is None:
53
+ reasons.add("omml_unapproved_part_or_root")
54
+ continue
55
+ word = "{" + namespace + "}"
56
+ # Every edge on the path to an equation must be explicitly recognized.
57
+ edges = {
58
+ word + "document": {word + "body"},
59
+ **{word + n: {word + "p", word + "tbl"} for n in ("body", "hdr", "ftr", "tc")},
60
+ word + "tbl": {word + "tr"}, word + "tr": {word + "tc"},
61
+ word + "p": {word + "r", _MATH + "oMathPara", _MATH + "oMath"},
62
+ # Inline (run-level) oMath is a sibling of w:r inside w:p. CT_R
63
+ # itself is not an equation container.
64
+ _MATH + "oMathPara": {_MATH + "oMath"},
65
+ }
66
+ def visit(node, permitted):
67
+ if node.tag.rsplit('}', 1)[-1] in {"oMath", "oMathPara"}:
68
+ if not permitted or node.tag not in {_MATH + "oMath", _MATH + "oMathPara"}:
69
+ reasons.add("omml_unapproved_ancestor")
70
+ for child in node:
71
+ visit(child, permitted and child.tag in edges.get(node.tag, set()) and not ignored.get(child, False))
72
+ visit(root, not ignored.get(root, False))
73
+ return sorted(reasons)
74
+
75
+
76
+ def _ambiguous_rendering(roots) -> list[str]:
77
+ """A source-wide veto avoids guessing a converter's rendered branch."""
78
+ reasons = set()
79
+ for root in roots:
80
+ for node in root.iter():
81
+ if node.tag == _COMPATIBILITY + "AlternateContent":
82
+ reasons.add("alternate_content")
83
+ for namespace in _WORD_NAMESPACES:
84
+ prefix = "{" + namespace + "}"
85
+ if not node.tag.startswith(prefix):
86
+ continue
87
+ kind = node.tag.removeprefix(prefix)
88
+ if kind in {"ins", "del"} or kind.startswith(("moveFrom", "moveTo")) or kind.endswith("PrChange"):
89
+ reasons.add("revision_markup")
90
+ if kind in {"fldSimple", "fldChar", "instrText", "delInstrText"}:
91
+ reasons.add("field_code")
92
+ if kind in {"vanish", "webHidden"}:
93
+ reasons.add("hidden_content")
94
+ if kind in {"altChunk", "subDoc", "dataBinding"}:
95
+ reasons.add("dynamic_content")
96
+ return sorted(reasons)
97
+
98
+
99
+ def _formula(node) -> tuple[str, str]:
100
+ """Return extraction-order characters and explicit source math structure.
101
+
102
+ This deliberately supports a small OMML subset. Unknown structures cannot
103
+ be flattened speculatively. Formatting properties do not supply characters.
104
+ """
105
+ kind = node.tag.removeprefix(_MATH)
106
+
107
+ def child(name):
108
+ matches = node.findall(_MATH + name)
109
+ if len(matches) != 1:
110
+ raise ValueError("missing_or_duplicate_math_component")
111
+ return _formula(matches[0])
112
+
113
+ if node.tag == _MATH + "t":
114
+ value = node.text or ""
115
+ # Do not interpret literal TeX syntax from a text run as markup.
116
+ if any(c in value for c in "\\{}$&#_%^"):
117
+ raise ValueError("literal_math_markup")
118
+ return value, value
119
+ containers = {"oMath", "r", "e", "sub", "sup", "num", "den"}
120
+ components = {"sSub": {"e", "sub", "sSubPr"}, "sSup": {"e", "sup", "sSupPr"},
121
+ "sSubSup": {"e", "sub", "sup", "sSubSupPr"},
122
+ "f": {"num", "den", "fPr"}, "nary": {"sub", "sup", "e", "naryPr"}}
123
+ if kind not in containers | components.keys() or not node.tag.startswith(_MATH):
124
+ raise ValueError("unsupported_math_structure")
125
+ if kind in components and any(c.tag not in {_MATH + n for n in components[kind]} for c in node):
126
+ raise ValueError("unsupported_math_component")
127
+ if kind in {"sSub", "sSup", "sSubSup"}:
128
+ plain, markup = child("e")
129
+ for name, operator in (("sub", "_"), ("sup", "^")):
130
+ if name == "sub" and kind == "sSup" or name == "sup" and kind == "sSub":
131
+ continue
132
+ raw, rendered = child(name)
133
+ plain += raw
134
+ markup += operator + "{" + rendered + "}"
135
+ return plain, markup
136
+ if kind == "f":
137
+ fraction_type = node.find(_MATH + "fPr/" + _MATH + "type")
138
+ if fraction_type is not None and fraction_type.get(_MATH + "val") != "bar":
139
+ raise ValueError("unsupported_fraction_type")
140
+ numerator, denominator = child("num"), child("den")
141
+ return numerator[0] + denominator[0], "\\frac{" + numerator[1] + "}{" + denominator[1] + "}"
142
+ if kind == "nary":
143
+ symbol = node.find(_MATH + "naryPr/" + _MATH + "chr")
144
+ symbol = symbol.get(_MATH + "val", "") if symbol is not None else ""
145
+ if symbol not in {"∑", "∏", "∫", "∬", "∭", "⋃", "⋂"}:
146
+ raise ValueError("unsupported_nary_symbol")
147
+ lower, upper, expression = child("sub"), child("sup"), child("e")
148
+ return symbol + lower[0] + upper[0] + expression[0], symbol + "_{" + lower[1] + "}^{" + upper[1] + "}" + expression[1]
149
+ pairs = [_formula(c) for c in node if c.tag not in {
150
+ _MATH + "rPr", "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}rPr"}]
151
+ return "".join(p[0] for p in pairs), "".join(p[1] for p in pairs)
152
+
153
+
154
+ def restore_docx_math(path: Path, text: str) -> tuple[str, dict]:
155
+ """Never change characters without a unique, whitespace-only native match.
156
+
157
+ Caller must already have proved every output page's text layer reliable.
158
+ No OCR, URL resolution, normalization, numerical correction or guessing.
159
+ """
160
+ receipt = {"source": "docx_omml", "formulas": 0, "restored": 0, "unmatched": 0, "unsupported": 0,
161
+ "match_rule": "unique_raw_characters_ignoring_whitespace_within_paragraph"}
162
+ try:
163
+ with ZipFile(path) as archive:
164
+ roots, ignored = {}, {}
165
+ names = archive.namelist()
166
+ if len(names) != len(set(names)):
167
+ receipt.update(disabled=True, reason="ambiguous_source_parts", disabled_reasons=["duplicate_source_part"])
168
+ return text, receipt
169
+ for name in names:
170
+ data = archive.read(name)
171
+ # OPC XML parts need not use .xml names. Unrecognized XML can
172
+ # contain equations too; media bytes grant no source identity.
173
+ looks_xml = data.lstrip().startswith((b"<", b"\xef\xbb\xbf", b"\xff\xfe", b"\xfe\xff"))
174
+ if name.lower().endswith((".xml", ".vml")) or looks_xml:
175
+ roots[name], scope = _xml_source(data)
176
+ ignored.update(scope)
177
+ root = roots["word/document.xml"]
178
+ except (OSError, BadZipFile, KeyError, ET.ParseError):
179
+ receipt["source_unavailable"] = True
180
+ return text, receipt
181
+ reasons = _ambiguous_rendering(roots.values())
182
+ if reasons:
183
+ receipt.update({"disabled": True, "reason": "ambiguous_document_rendering", "disabled_reasons": reasons})
184
+ return text, receipt
185
+ reasons = _permitted_math_sources(roots, ignored)
186
+ if reasons:
187
+ receipt.update({"disabled": True, "reason": "unapproved_omml_rendering_path", "disabled_reasons": reasons})
188
+ return text, receipt
189
+ offsets = [i for i, c in enumerate(text) if not c.isspace()]
190
+ compact = "".join(text[i] for i in offsets)
191
+ replacements = []
192
+ for node in root.iter(_MATH + "oMath"):
193
+ receipt["formulas"] += 1
194
+ try:
195
+ plain, markup = _formula(node)
196
+ except ValueError:
197
+ receipt["unsupported"] += 1
198
+ continue
199
+ needle = "".join(c for c in plain if not c.isspace())
200
+ start = compact.find(needle)
201
+ if len(needle) < 4 or start < 0 or compact.find(needle, start + 1) >= 0:
202
+ receipt["unmatched"] += 1
203
+ continue
204
+ end = start + len(needle)
205
+ # Native PDF extraction uses blank lines for paragraph boundaries.
206
+ # Whitespace-insensitive matching must never bridge those boundaries
207
+ # (or a page break), even if the combined characters occur only once.
208
+ candidate = text[offsets[start]:offsets[end - 1] + 1]
209
+ if re.search(r"\n[ \t\r]*\n|\f", candidate):
210
+ receipt["unmatched"] += 1
211
+ receipt["paragraph_boundary_rejections"] = receipt.get("paragraph_boundary_rejections", 0) + 1
212
+ continue
213
+ replacements.append((offsets[start], offsets[end - 1] + 1, "$" + markup + "$"))
214
+ # Repeated source formulas or nested/overlapping matches are not unique proof.
215
+ unique = [r for r in replacements if sum(r[0] < s[1] and s[0] < r[1] for s in replacements) == 1]
216
+ receipt["unmatched"] += len(replacements) - len(unique)
217
+ for start, end, replacement in sorted(unique, reverse=True):
218
+ text = text[:start] + replacement + text[end:]
219
+ receipt["restored"] += 1
220
+ return text, receipt