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/tiling.py
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
"""Tiling/merge ported from the approved local OCR selection synth run."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from html.parser import HTMLParser
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import unicodedata
|
|
9
|
+
from difflib import SequenceMatcher
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
def row_ink(img):
|
|
13
|
+
"""Fraction of 'ink' pixels per row (deviation from the row's median background)."""
|
|
14
|
+
import numpy as np
|
|
15
|
+
gray = np.asarray(img.convert("L"), dtype=np.int16)
|
|
16
|
+
bg = np.median(gray, axis=1).astype(np.int16)
|
|
17
|
+
return (np.abs(gray - bg[:, None]) > 40).mean(axis=1)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def choose_cut(ink, target, lo, hi, blank_thr=0.003):
|
|
21
|
+
"""Pick a row in [lo, hi]: center of the blank run nearest target, else min-ink row."""
|
|
22
|
+
lo, hi = max(0, int(lo)), min(len(ink) - 1, int(hi))
|
|
23
|
+
if hi < lo:
|
|
24
|
+
return int(target), "forced"
|
|
25
|
+
best, best_dist = None, None
|
|
26
|
+
y = lo
|
|
27
|
+
while y <= hi:
|
|
28
|
+
if ink[y] < blank_thr:
|
|
29
|
+
s = y
|
|
30
|
+
while y + 1 <= hi and ink[y + 1] < blank_thr:
|
|
31
|
+
y += 1
|
|
32
|
+
c = (s + y) // 2
|
|
33
|
+
dist = abs(c - target)
|
|
34
|
+
if best is None or dist < best_dist:
|
|
35
|
+
best, best_dist = c, dist
|
|
36
|
+
y += 1
|
|
37
|
+
if best is not None:
|
|
38
|
+
return int(best), "blank"
|
|
39
|
+
seg = ink[lo:hi + 1]
|
|
40
|
+
return int(lo + int(seg.argmin())), "forced"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def plan_tiles(ink, height, tile_height=1440, overlap=0.18, search=0.15):
|
|
44
|
+
ov = int(tile_height * overlap)
|
|
45
|
+
tiles, start, start_q = [], 0, "top"
|
|
46
|
+
while True:
|
|
47
|
+
if height - start <= int(tile_height * 1.1):
|
|
48
|
+
tiles.append((start, height, start_q, "bottom"))
|
|
49
|
+
break
|
|
50
|
+
target = start + tile_height
|
|
51
|
+
end, q = choose_cut(ink, target, target - int(tile_height * search), target)
|
|
52
|
+
if end <= start + ov: # pathological: keep progress
|
|
53
|
+
end, q = target, "forced"
|
|
54
|
+
tiles.append((start, end, start_q, q))
|
|
55
|
+
ns, start_q = choose_cut(ink, end - ov, end - int(ov * 1.3), end - int(ov * 0.7))
|
|
56
|
+
start = max(ns, start + 1)
|
|
57
|
+
return tiles
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def split_image(path, out_dir, tile_height=1440, overlap=0.18, search=0.15, fmt="png"):
|
|
61
|
+
from PIL import Image
|
|
62
|
+
path, out_dir = Path(path), Path(out_dir)
|
|
63
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
with Image.open(path) as source:
|
|
65
|
+
img = source.convert("RGB")
|
|
66
|
+
w, h = img.size
|
|
67
|
+
ink = row_ink(img)
|
|
68
|
+
plan = plan_tiles(ink, h, tile_height, overlap, search)
|
|
69
|
+
tiles = []
|
|
70
|
+
prev_end = None
|
|
71
|
+
for i, (y0, y1, sq, eq) in enumerate(plan, 1):
|
|
72
|
+
name = f"tile-{i:03d}"
|
|
73
|
+
img.crop((0, y0, w, y1)).save(out_dir / f"{name}.{fmt}")
|
|
74
|
+
tiles.append({"index": i, "name": name, "file": f"{name}.{fmt}", "y0": y0, "y1": y1,
|
|
75
|
+
"height": y1 - y0, "overlap_prev": (prev_end - y0) if prev_end is not None else 0,
|
|
76
|
+
"start_cut": sq, "end_cut": eq,
|
|
77
|
+
"ink_at_start": round(float(ink[y0]), 5), "ink_at_end": round(float(ink[y1 - 1]), 5)})
|
|
78
|
+
prev_end = y1
|
|
79
|
+
h_sha = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
80
|
+
meta = {"source": path.name, "source_sha256": h_sha, "width": w, "height": h,
|
|
81
|
+
"params": {"tile_height": tile_height, "overlap": overlap, "search": search, "format": fmt,
|
|
82
|
+
"blank_threshold": 0.003, "ink_delta": 40},
|
|
83
|
+
"tiles": tiles}
|
|
84
|
+
(out_dir / "tiles.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
85
|
+
return meta
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# merge
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
_SKIP = set("#*`|>_")
|
|
92
|
+
|
|
93
|
+
# This is the entire deletion-equivalence table. In particular, NFKC letter,
|
|
94
|
+
# digit, ligature and combining-character transformations are NOT permission
|
|
95
|
+
# to delete source text. Candidate search below may still use NFKC.
|
|
96
|
+
PUNCTUATION_EQUIVALENTS = dict(zip(
|
|
97
|
+
"!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~",
|
|
98
|
+
"!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~",
|
|
99
|
+
))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def literal_view(text: str):
|
|
103
|
+
"""Deletion proof: one code point per non-whitespace source character."""
|
|
104
|
+
chars, indexes = [], []
|
|
105
|
+
for index, char in enumerate(text):
|
|
106
|
+
if not char.isspace():
|
|
107
|
+
chars.append(PUNCTUATION_EQUIVALENTS.get(char, char))
|
|
108
|
+
indexes.append(index)
|
|
109
|
+
return "".join(chars), indexes
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def deletion_proof(a: str, b: str):
|
|
113
|
+
"""Map every deleted B character to an equal raw A character.
|
|
114
|
+
|
|
115
|
+
The returned positions contain no recognized text and are safe for receipts.
|
|
116
|
+
Whitespace is the sole unrecorded source character category.
|
|
117
|
+
"""
|
|
118
|
+
av, ai = literal_view(a)
|
|
119
|
+
bv, bi = literal_view(b)
|
|
120
|
+
if av != bv:
|
|
121
|
+
return None
|
|
122
|
+
return {"deleted_nonspace_chars": len(bi), "authorized_nonspace_chars": len(bi),
|
|
123
|
+
"deletion_mapping_coverage": 1.0,
|
|
124
|
+
"mapping": [[x, y] for x, y in zip(ai, bi)]}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def norm_view(s: str):
|
|
128
|
+
chars, idx = [], []
|
|
129
|
+
for i, ch in enumerate(s):
|
|
130
|
+
if ch.isspace() or ch in _SKIP:
|
|
131
|
+
continue
|
|
132
|
+
for c in unicodedata.normalize("NFKC", ch):
|
|
133
|
+
if c.isspace() or c in _SKIP:
|
|
134
|
+
continue
|
|
135
|
+
chars.append(c)
|
|
136
|
+
idx.append(i)
|
|
137
|
+
return "".join(chars), idx
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _suffix_prefix_matches(tail: str, head: str, minimum: int):
|
|
141
|
+
"""Find exact seam candidates in linear time, including repeated borders."""
|
|
142
|
+
# A unique sentinel keeps a match from crossing the head/tail boundary.
|
|
143
|
+
values = list(head) + [None] + list(tail)
|
|
144
|
+
borders = [0] * len(values)
|
|
145
|
+
for i in range(1, len(values)):
|
|
146
|
+
j = borders[i - 1]
|
|
147
|
+
while j and values[i] != values[j]:
|
|
148
|
+
j = borders[j - 1]
|
|
149
|
+
if values[i] == values[j]:
|
|
150
|
+
j += 1
|
|
151
|
+
borders[i] = j
|
|
152
|
+
size = borders[-1] if borders else 0
|
|
153
|
+
matches = []
|
|
154
|
+
while size >= minimum:
|
|
155
|
+
matches.append(size)
|
|
156
|
+
size = borders[size - 1]
|
|
157
|
+
return matches
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _occurs_once(text: str, match: str):
|
|
161
|
+
first = text.find(match)
|
|
162
|
+
return first >= 0 and text.find(match, first + 1) < 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _authorize_candidate(a: str, b: str, size: int, bi: list[int]):
|
|
166
|
+
"""Candidate offsets cannot grant deletion; prove a literal suffix/prefix."""
|
|
167
|
+
av, ai = literal_view(a)
|
|
168
|
+
# Include ignored candidate punctuation at the boundary only when it has a
|
|
169
|
+
# literal counterpart in A. A unique trailing '>' is left in B, never eaten.
|
|
170
|
+
lower = bi[size - 1] + 1
|
|
171
|
+
upper = bi[size] if size < len(bi) else len(b)
|
|
172
|
+
proven = None
|
|
173
|
+
for end in range(lower, upper + 1):
|
|
174
|
+
if not a[-1:].isspace():
|
|
175
|
+
end = len(b[:end].rstrip())
|
|
176
|
+
bv, _ = literal_view(b[:end])
|
|
177
|
+
if bv and len(bv) <= len(av) and av.endswith(bv):
|
|
178
|
+
start = ai[len(av) - len(bv)]
|
|
179
|
+
if len(norm_view(a[start:])[0]) == size:
|
|
180
|
+
proof = deletion_proof(a[start:], b[:end])
|
|
181
|
+
if proof is not None:
|
|
182
|
+
proven = start, end, proof
|
|
183
|
+
return proven
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _local_candidate(a: str, b: str, wa: int, wb: int):
|
|
187
|
+
"""Find an anchored, monotonic alignment; it grants no deletion rights.
|
|
188
|
+
|
|
189
|
+
Stable literal anchors bound the seam. A small unmatched edge remains a
|
|
190
|
+
difference, not a discarded prefix/suffix. Large offsets stay unaligned.
|
|
191
|
+
"""
|
|
192
|
+
av, ai = literal_view(a)
|
|
193
|
+
bv, bi = literal_view(b)
|
|
194
|
+
offset = max(0, len(av) - wa)
|
|
195
|
+
tail, head = av[offset:], bv[:wb]
|
|
196
|
+
blocks = [block for block in SequenceMatcher(None, tail, head, autojunk=False).get_matching_blocks() if block.size >= 3]
|
|
197
|
+
candidates = []
|
|
198
|
+
for first in blocks:
|
|
199
|
+
if first.b > 2:
|
|
200
|
+
continue
|
|
201
|
+
start = max(0, first.a - first.b)
|
|
202
|
+
for last in blocks:
|
|
203
|
+
if last.a < first.a or last.b < first.b or len(tail) - last.a - last.size > 2:
|
|
204
|
+
continue
|
|
205
|
+
end = min(len(head), last.b + last.size + len(tail) - last.a - last.size)
|
|
206
|
+
left, right = tail[start:], head[:end]
|
|
207
|
+
alignment = SequenceMatcher(None, left, right, autojunk=False)
|
|
208
|
+
# Two short stable edges may surround one symbol in a short
|
|
209
|
+
# physical seam. Still require twelve literally equal characters
|
|
210
|
+
# overall; no approximate character is ever granted deletion.
|
|
211
|
+
if alignment.ratio() < 0.75 or sum(block.size for block in alignment.get_matching_blocks()) < 12:
|
|
212
|
+
continue
|
|
213
|
+
if min(first.size, last.size) < 12 and sum(max(j - i, y - x) for tag, i, j, x, y in alignment.get_opcodes() if tag != "equal") > 2:
|
|
214
|
+
# A three-character coincidental edge cannot localize a long
|
|
215
|
+
# unique suffix. Short edges only support tiny local edits.
|
|
216
|
+
continue
|
|
217
|
+
a_start = ai[offset + start]
|
|
218
|
+
b_end = bi[end] if end < len(bi) else len(b)
|
|
219
|
+
candidates.append((alignment.ratio(), a_start, b_end))
|
|
220
|
+
if not candidates:
|
|
221
|
+
return None
|
|
222
|
+
candidates = sorted(set(candidates), reverse=True)
|
|
223
|
+
best = candidates[0]
|
|
224
|
+
# Identical source ranges are one candidate. Comparable alternate ranges
|
|
225
|
+
# are ambiguous; do not rearrange source lines to manufacture an overlap.
|
|
226
|
+
if any(abs(best[0] - other[0]) < 0.10 and best[1:] != other[1:] for other in candidates[1:]):
|
|
227
|
+
return None
|
|
228
|
+
return best[1], best[2]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
_MARKDOWN_FENCE = re.compile(r"^(?:[ \t]*>[ \t]*)*[ \t]*(?:(?:[-+*]|\d+[.)])[ \t]+)?(`{3,}|~{3,})(.*)$")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _fenced_blocks(text: str):
|
|
235
|
+
"""Return complete/truncated fence ranges and any unclosed opener."""
|
|
236
|
+
ranges, opened, offset = [], None, 0
|
|
237
|
+
for line in text.splitlines(keepends=True):
|
|
238
|
+
match = _MARKDOWN_FENCE.match(line.rstrip("\r\n"))
|
|
239
|
+
if match:
|
|
240
|
+
marker, tail = match.groups()
|
|
241
|
+
if opened is None:
|
|
242
|
+
opened = (offset, marker[0], len(marker))
|
|
243
|
+
elif marker[0] == opened[1] and len(marker) >= opened[2] and not tail.strip():
|
|
244
|
+
ranges.append((opened[0], offset + len(line)))
|
|
245
|
+
opened = None
|
|
246
|
+
offset += len(line)
|
|
247
|
+
if opened is not None:
|
|
248
|
+
ranges.append((opened[0], len(text)))
|
|
249
|
+
return ranges, opened
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _html_blocks(text: str):
|
|
253
|
+
"""Conservative HTML container ranges, independent of blank-line syntax.
|
|
254
|
+
|
|
255
|
+
Only balanced known elements/comments are rendered as HTML. Unknown,
|
|
256
|
+
mismatched or truncated input is displayed literally as one atomic source.
|
|
257
|
+
Fenced Markdown is already literal and must not be parsed as HTML.
|
|
258
|
+
"""
|
|
259
|
+
offsets = [0]
|
|
260
|
+
for line in text.splitlines(keepends=True):
|
|
261
|
+
offsets.append(offsets[-1] + len(line))
|
|
262
|
+
fences, _ = _fenced_blocks(text)
|
|
263
|
+
paired = set("a abbr address article aside audio b bdi bdo blockquote body button caption cite code colgroup data datalist dd del details dfn dialog div dl dt em fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 head header hgroup html i iframe ins kbd label legend li main map mark menu meter nav noscript object ol optgroup option output p picture pre progress q rp rt ruby s samp script search section select small span strong style sub summary sup table tbody td template textarea tfoot th thead time title tr u ul var video".split())
|
|
264
|
+
void = set("area base br col embed hr img input link meta param source track wbr".split())
|
|
265
|
+
|
|
266
|
+
class Containers(HTMLParser):
|
|
267
|
+
def __init__(self):
|
|
268
|
+
super().__init__(convert_charrefs=False)
|
|
269
|
+
self.stack, self.ranges, self.reasons = [], [], set()
|
|
270
|
+
|
|
271
|
+
def position(self):
|
|
272
|
+
row, col = self.getpos()
|
|
273
|
+
return offsets[row - 1] + col
|
|
274
|
+
|
|
275
|
+
def skipped(self, start):
|
|
276
|
+
return any(lo <= start < hi for lo, hi in fences)
|
|
277
|
+
|
|
278
|
+
def handle_starttag(self, tag, attrs):
|
|
279
|
+
start = self.position()
|
|
280
|
+
if self.skipped(start):
|
|
281
|
+
return
|
|
282
|
+
end = start + len(self.get_starttag_text())
|
|
283
|
+
if tag not in paired | void:
|
|
284
|
+
self.reasons.add("unknown_html_container")
|
|
285
|
+
if tag not in void:
|
|
286
|
+
self.stack.append((tag, start))
|
|
287
|
+
else:
|
|
288
|
+
self.ranges.append((start, end))
|
|
289
|
+
|
|
290
|
+
def handle_startendtag(self, tag, attrs):
|
|
291
|
+
start = self.position()
|
|
292
|
+
if self.skipped(start):
|
|
293
|
+
return
|
|
294
|
+
self.ranges.append((start, start + len(self.get_starttag_text())))
|
|
295
|
+
# HTML self-closing syntax only closes void elements. Guessing XML
|
|
296
|
+
# semantics for <table/> could put the labels inside a real table.
|
|
297
|
+
if tag not in void:
|
|
298
|
+
self.reasons.add("unknown_html_container" if tag not in paired else "unclosed_html_container")
|
|
299
|
+
|
|
300
|
+
def handle_endtag(self, tag):
|
|
301
|
+
start = self.position()
|
|
302
|
+
if self.skipped(start):
|
|
303
|
+
return
|
|
304
|
+
if not self.stack or self.stack[-1][0] != tag:
|
|
305
|
+
self.reasons.add("mismatched_html_container")
|
|
306
|
+
return
|
|
307
|
+
_, lo = self.stack.pop()
|
|
308
|
+
self.ranges.append((lo, text.find(">", start) + 1))
|
|
309
|
+
|
|
310
|
+
def handle_comment(self, data):
|
|
311
|
+
start = self.position()
|
|
312
|
+
if not self.skipped(start):
|
|
313
|
+
end = text.find("-->", start)
|
|
314
|
+
if end < 0:
|
|
315
|
+
self.reasons.add("unclosed_html_container")
|
|
316
|
+
else:
|
|
317
|
+
self.ranges.append((start, end + 3))
|
|
318
|
+
|
|
319
|
+
def handle_data(self, data):
|
|
320
|
+
start = self.position()
|
|
321
|
+
if not self.skipped(start) and (re.match(r"<(?:[A-Za-z!/?]|$)", text[start:]) or "-->" in data):
|
|
322
|
+
self.reasons.add("malformed_html_container")
|
|
323
|
+
|
|
324
|
+
def handle_decl(self, decl):
|
|
325
|
+
if not self.skipped(self.position()):
|
|
326
|
+
self.reasons.add("unknown_html_container")
|
|
327
|
+
|
|
328
|
+
def unknown_decl(self, data):
|
|
329
|
+
self.handle_decl(data)
|
|
330
|
+
|
|
331
|
+
def handle_pi(self, data):
|
|
332
|
+
self.handle_decl(data)
|
|
333
|
+
|
|
334
|
+
parser = Containers()
|
|
335
|
+
try:
|
|
336
|
+
# Mask literal fences before parsing: an unclosed '<!--' inside code
|
|
337
|
+
# must not swallow a real HTML container after the closing fence.
|
|
338
|
+
masked = list(text)
|
|
339
|
+
for lo, hi in fences:
|
|
340
|
+
for index in range(lo, hi):
|
|
341
|
+
if masked[index] not in "\r\n":
|
|
342
|
+
masked[index] = " "
|
|
343
|
+
masked_text = "".join(masked)
|
|
344
|
+
parser.feed(masked_text)
|
|
345
|
+
parser.close()
|
|
346
|
+
# HTMLParser.close() reports a truncated "<tag ..." as data on some
|
|
347
|
+
# Python releases (3.12.2) and silently drops it on others (3.10,
|
|
348
|
+
# 3.14). Check explicitly so the verdict does not depend on Python.
|
|
349
|
+
if re.search(r"<[A-Za-z!/?][^>]*\Z", masked_text):
|
|
350
|
+
parser.reasons.add("malformed_html_container")
|
|
351
|
+
except (ValueError, AssertionError):
|
|
352
|
+
parser.reasons.add("malformed_html_container")
|
|
353
|
+
if parser.stack:
|
|
354
|
+
parser.reasons.add("unclosed_html_container")
|
|
355
|
+
if parser.reasons:
|
|
356
|
+
return [(0, len(text))], sorted(parser.reasons)
|
|
357
|
+
return parser.ranges, []
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _literal_reasons(raw):
|
|
361
|
+
return (["unclosed_source_fence"] if _fenced_blocks(raw)[1] is not None else []) + _html_blocks(raw)[1]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _markdown_block_range(text: str, start: int, end: int):
|
|
365
|
+
"""Expand a presentation range within the *complete* source Markdown.
|
|
366
|
+
|
|
367
|
+
This deliberately overgroups adjacent nonblank lines: a table, wrapped
|
|
368
|
+
paragraph, inline expression or lazy list continuation must never be split
|
|
369
|
+
by a seam marker. Fences, indented code and lists can also span blank lines.
|
|
370
|
+
These ranges grant no authority to delete or normalize source characters.
|
|
371
|
+
"""
|
|
372
|
+
lines = text.splitlines(keepends=True)
|
|
373
|
+
offsets = [0]
|
|
374
|
+
for line in lines:
|
|
375
|
+
offsets.append(offsets[-1] + len(line))
|
|
376
|
+
ranges = []
|
|
377
|
+
|
|
378
|
+
# Treat any uninterrupted run as atomic. This also covers tables without
|
|
379
|
+
# leading/trailing pipes, multiline inline markup and unknown block syntax.
|
|
380
|
+
first = None
|
|
381
|
+
for i, line in enumerate(lines):
|
|
382
|
+
if line.strip():
|
|
383
|
+
if first is None:
|
|
384
|
+
first = i
|
|
385
|
+
elif first is not None:
|
|
386
|
+
ranges.append((offsets[first], offsets[i]))
|
|
387
|
+
first = None
|
|
388
|
+
if first is not None:
|
|
389
|
+
ranges.append((offsets[first], len(text)))
|
|
390
|
+
|
|
391
|
+
list_item = re.compile(r"^[ ]{0,3}(?:[-+*]|\d+[.)])[ \t]+")
|
|
392
|
+
fence_ranges, _ = _fenced_blocks(text)
|
|
393
|
+
ranges.extend(fence_ranges)
|
|
394
|
+
ranges.extend(_html_blocks(text)[0])
|
|
395
|
+
|
|
396
|
+
i = 0
|
|
397
|
+
while i < len(lines):
|
|
398
|
+
line = lines[i]
|
|
399
|
+
is_list = bool(list_item.match(line))
|
|
400
|
+
is_code = line.startswith((" ", "\t")) and bool(line.strip())
|
|
401
|
+
if not (is_list or is_code):
|
|
402
|
+
i += 1
|
|
403
|
+
continue
|
|
404
|
+
j = i + 1
|
|
405
|
+
while j < len(lines):
|
|
406
|
+
if not lines[j].strip():
|
|
407
|
+
k = j + 1
|
|
408
|
+
while k < len(lines) and not lines[k].strip():
|
|
409
|
+
k += 1
|
|
410
|
+
if k == len(lines) or not (lines[k].startswith((" ", "\t")) or (is_list and list_item.match(lines[k]))):
|
|
411
|
+
break
|
|
412
|
+
j = k
|
|
413
|
+
elif is_list or lines[j].startswith((" ", "\t")):
|
|
414
|
+
# Unindented text before a blank is a possible lazy list
|
|
415
|
+
# continuation. Keeping the whole list is conservative.
|
|
416
|
+
j += 1
|
|
417
|
+
else:
|
|
418
|
+
break
|
|
419
|
+
ranges.append((offsets[i], offsets[j]))
|
|
420
|
+
i = max(i + 1, j)
|
|
421
|
+
|
|
422
|
+
# Earlier seams can themselves contain blank lines and complete code blocks.
|
|
423
|
+
# Keep their wrappers atomic when merging a third or subsequent tile.
|
|
424
|
+
seam_start, depth = None, 0
|
|
425
|
+
for i, line in enumerate(lines):
|
|
426
|
+
if line.strip() == "<!-- openmd:seam-diff start -->":
|
|
427
|
+
if not depth:
|
|
428
|
+
seam_start = i
|
|
429
|
+
depth += 1
|
|
430
|
+
elif line.strip() == "<!-- openmd:seam-diff end -->" and depth:
|
|
431
|
+
depth -= 1
|
|
432
|
+
if not depth:
|
|
433
|
+
ranges.append((offsets[seam_start], offsets[i + 1]))
|
|
434
|
+
if depth:
|
|
435
|
+
ranges.append((offsets[seam_start], len(text)))
|
|
436
|
+
|
|
437
|
+
start, end = max(0, start), min(len(text), end)
|
|
438
|
+
# Union overlapping structural ranges before expanding. A fence inside a
|
|
439
|
+
# list, for example, must keep both the fence and the enclosing item intact.
|
|
440
|
+
blocks = []
|
|
441
|
+
for lo, hi in sorted(ranges):
|
|
442
|
+
if blocks and lo < blocks[-1][1]:
|
|
443
|
+
blocks[-1] = (blocks[-1][0], max(hi, blocks[-1][1]))
|
|
444
|
+
else:
|
|
445
|
+
blocks.append((lo, hi))
|
|
446
|
+
for lo, hi in blocks:
|
|
447
|
+
if lo < end and hi > start:
|
|
448
|
+
start, end = min(start, lo), max(end, hi)
|
|
449
|
+
return start, end
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _difference_block(raw_a: str, raw_b: str):
|
|
453
|
+
# Blank lines separate labels from source tables/lists and separate the
|
|
454
|
+
# wrapper from surrounding paragraphs. Both raw readings stay contiguous.
|
|
455
|
+
def reading(raw):
|
|
456
|
+
reasons = _literal_reasons(raw)
|
|
457
|
+
if not reasons:
|
|
458
|
+
return raw
|
|
459
|
+
# A truncated source fence cannot safely contain our labels. Present
|
|
460
|
+
# its *unchanged* raw Markdown literally inside a longer outer fence,
|
|
461
|
+
# and disclose this rendering choice instead of inventing a source
|
|
462
|
+
# closing fence. The real code points still form one contiguous copy.
|
|
463
|
+
fence = "`" * max(3, 1 + max((len(run) for run in re.findall(r"`+", raw)), default=0))
|
|
464
|
+
label = "unclosed source fence" if reasons == ["unclosed_source_fence"] else "unsafe source container"
|
|
465
|
+
return "[openmd: " + label + "; literal Markdown]\n\n" + fence + "markdown\n" + raw + "\n" + fence
|
|
466
|
+
|
|
467
|
+
return "\n\n<!-- openmd:seam-diff start -->\n\n[openmd variant A]\n\n" + reading(raw_a) + "\n\n[openmd variant B]\n\n" + reading(raw_b) + "\n\n<!-- openmd:seam-diff end -->\n\n"
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _localized_difference(a: str, b: str, a_start: int, b_end: int):
|
|
471
|
+
# A literal alignment can locate a conflict, but even an equal character
|
|
472
|
+
# prefix may belong to a number, table cell or code expression. Preserve
|
|
473
|
+
# both full affected blocks rather than splice their shared characters.
|
|
474
|
+
display_a, _ = _markdown_block_range(a, a_start, len(a))
|
|
475
|
+
_, display_b = _markdown_block_range(b, 0, b_end)
|
|
476
|
+
return a[:display_a] + _difference_block(a[display_a:], b[:display_b]) + b[display_b:], {
|
|
477
|
+
"difference_ranges": [{"a_range": [display_a, len(a)], "b_range": [0, display_b]}],
|
|
478
|
+
"difference_count": 1, "difference_boundary": "markdown_block",
|
|
479
|
+
"difference_literal_markdown": [label for label, raw in (("A", a[display_a:]), ("B", b[:display_b])) if _literal_reasons(raw)],
|
|
480
|
+
"difference_container_policy": "markdown_and_balanced_html_or_literal",
|
|
481
|
+
"difference_literal_reasons": {label: _literal_reasons(raw) for label, raw in (("A", a[display_a:]), ("B", b[:display_b])) if _literal_reasons(raw)},
|
|
482
|
+
"deleted_nonspace_chars": 0, "authorized_nonspace_chars": 0,
|
|
483
|
+
"deletion_mapping_coverage": 1.0, "mapping": [],
|
|
484
|
+
"alignment": "monotonic_literal", "a_overlap_range": [a_start, len(a)],
|
|
485
|
+
"b_overlap_range": [0, b_end],
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def merge_pair(a: str, b: str, prev_len_norm: int, overlap=0.18, min_match=6, min_window=120,
|
|
490
|
+
*, geometry: dict | None = None, arbitrator: str | None = None):
|
|
491
|
+
"""Candidate matching is permissive; deletion always needs raw proof.
|
|
492
|
+
|
|
493
|
+
A conflict retains its two raw readings in complete Markdown blocks. Only
|
|
494
|
+
an entirely proven overlap is deduplicated. Unaligned inputs remain intact,
|
|
495
|
+
so neither geometry estimates nor an arbiter can discard symbols.
|
|
496
|
+
"""
|
|
497
|
+
an, ai = norm_view(a)
|
|
498
|
+
bn, bi = norm_view(b)
|
|
499
|
+
frac = min(0.9, overlap * 2.5)
|
|
500
|
+
wa = min(len(an), max(min_window, int(prev_len_norm * frac)))
|
|
501
|
+
wb = min(len(bn), max(min_window, int(len(bn) * frac)))
|
|
502
|
+
if geometry:
|
|
503
|
+
wa = min(wa, max(1, geometry.get("a_window_chars", wa)))
|
|
504
|
+
wb = min(wb, max(1, geometry.get("b_window_chars", wb)))
|
|
505
|
+
tail, head = an[len(an) - wa:], bn[:wb]
|
|
506
|
+
candidates = _suffix_prefix_matches(tail, head, max(1, min_match))
|
|
507
|
+
size = candidates[0] if candidates else 0
|
|
508
|
+
seam = {"window_prev": wa, "window_next": wb, "match_len": size,
|
|
509
|
+
"candidate_count": len(candidates), "match_next_start": 0 if size else None,
|
|
510
|
+
"dropped_prev_tail_chars": 0, "dropped_next_head_chars": 0,
|
|
511
|
+
"deleted_nonspace_chars": 0, "authorized_nonspace_chars": 0,
|
|
512
|
+
"deletion_mapping_coverage": 1.0}
|
|
513
|
+
if geometry:
|
|
514
|
+
seam["geometry"] = geometry
|
|
515
|
+
if not candidates:
|
|
516
|
+
reason = "no_anchored_overlap"
|
|
517
|
+
elif len(candidates) != 1 or not _occurs_once(tail, head[:size]) or not _occurs_once(head, head[:size]):
|
|
518
|
+
reason = "ambiguous_overlap"
|
|
519
|
+
elif size < max(12, min_match):
|
|
520
|
+
reason = "weak_overlap"
|
|
521
|
+
elif size < len(bi) and bi[size - 1] == bi[size]:
|
|
522
|
+
# NFKC can expand one source glyph into several characters. Never cut
|
|
523
|
+
# that glyph when only part of its normalized value was duplicated.
|
|
524
|
+
reason = "normalization_boundary"
|
|
525
|
+
else:
|
|
526
|
+
authorization = _authorize_candidate(a, b, size, bi)
|
|
527
|
+
if authorization:
|
|
528
|
+
a_start, b_end, proof = authorization
|
|
529
|
+
proof["mapping"] = [[x + a_start, y] for x, y in proof["mapping"]]
|
|
530
|
+
seam.update(proof)
|
|
531
|
+
seam.update({"status": "merged", "dropped_next_head_chars": proof["deleted_nonspace_chars"],
|
|
532
|
+
"a_overlap_range": [a_start, len(a)], "b_overlap_range": [0, b_end]})
|
|
533
|
+
return a + b[b_end:], seam
|
|
534
|
+
reason = "raw_character_mismatch"
|
|
535
|
+
seam.update({"status": "merge_uncertain", "reason": reason})
|
|
536
|
+
# Ambiguous and weak candidates cannot be made trustworthy by fuzzy search.
|
|
537
|
+
local = None
|
|
538
|
+
if reason == "raw_character_mismatch":
|
|
539
|
+
# The unique normalized candidate is already seam-anchored. Its raw
|
|
540
|
+
# differences (including Markdown-looking symbols) can be localized
|
|
541
|
+
# without guessing a later internal match or authorizing their loss.
|
|
542
|
+
first = len(an) - size
|
|
543
|
+
a_start = ai[first - 1] + 1 if first else 0
|
|
544
|
+
b_end = bi[size] if size < len(bi) else len(b)
|
|
545
|
+
local = a_start, b_end
|
|
546
|
+
elif reason not in {"ambiguous_overlap", "weak_overlap", "normalization_boundary"}:
|
|
547
|
+
local = _local_candidate(a, b, wa, wb)
|
|
548
|
+
if local:
|
|
549
|
+
a_start, b_end = local
|
|
550
|
+
if not a[-1:].isspace():
|
|
551
|
+
b_end = len(b[:b_end].rstrip())
|
|
552
|
+
merged, proof = _localized_difference(a, b, a_start, b_end)
|
|
553
|
+
seam.update(proof)
|
|
554
|
+
seam["dropped_next_head_chars"] = proof["deleted_nonspace_chars"]
|
|
555
|
+
seam["difference_format"] = "openmd:seam-diff"
|
|
556
|
+
if arbitrator is not None:
|
|
557
|
+
# Agreement identifies a preferred reading, never permission to
|
|
558
|
+
# remove a differing B character. Preserve the conflict in output.
|
|
559
|
+
agreement = []
|
|
560
|
+
if deletion_proof(a[a_start:], arbitrator):
|
|
561
|
+
agreement.append("A")
|
|
562
|
+
if deletion_proof(b[:b_end], arbitrator):
|
|
563
|
+
agreement.append("B")
|
|
564
|
+
seam["arbitration_agreement"] = agreement
|
|
565
|
+
seam["arbitration_result"] = "arbitration_not_deletion_authority" if agreement else "inconsistent"
|
|
566
|
+
return merged, seam
|
|
567
|
+
if geometry and geometry.get("overlap_bbox"):
|
|
568
|
+
# We cannot prove a unique correspondence inside this physical band.
|
|
569
|
+
# Localize the uncertainty without deleting ANY source character. This
|
|
570
|
+
# is a presentation boundary only, never a guessed deduplication.
|
|
571
|
+
av, ax = literal_view(a)
|
|
572
|
+
bv, bx = literal_view(b)
|
|
573
|
+
a_start = ax[max(0, len(av) - wa)] if av and wa else len(a)
|
|
574
|
+
b_end = bx[wb] if wb < len(bx) else len(b)
|
|
575
|
+
left, right = a[a_start:], b[:b_end]
|
|
576
|
+
merged, proof = _localized_difference(a, b, a_start, b_end)
|
|
577
|
+
seam.update(proof)
|
|
578
|
+
seam.update({"difference_format": "openmd:seam-diff", "alignment": "unresolved_overlap_band"})
|
|
579
|
+
if arbitrator is not None:
|
|
580
|
+
agreement = [label for label, reading in (("A", left), ("B", right)) if deletion_proof(reading, arbitrator)]
|
|
581
|
+
seam["arbitration_agreement"] = agreement
|
|
582
|
+
seam["arbitration_result"] = "arbitration_not_deletion_authority" if agreement else "inconsistent"
|
|
583
|
+
return merged, seam
|
|
584
|
+
# No physical band or reliable candidate: preserve both *whole* inputs in
|
|
585
|
+
# the same safe presentation format. Plain concatenation can let an
|
|
586
|
+
# unclosed fence in A swallow B or silently combine unrelated structures.
|
|
587
|
+
merged, proof = _localized_difference(a, b, 0, len(b))
|
|
588
|
+
seam.update(proof)
|
|
589
|
+
seam.update({"difference_format": "openmd:seam-diff", "alignment": "unresolved_whole_inputs"})
|
|
590
|
+
if arbitrator is not None:
|
|
591
|
+
agreement = [label for label, reading in (("A", a), ("B", b)) if deletion_proof(reading, arbitrator)]
|
|
592
|
+
seam["arbitration_agreement"] = agreement
|
|
593
|
+
seam["arbitration_result"] = "arbitration_not_deletion_authority" if agreement else "inconsistent"
|
|
594
|
+
return merged, seam
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def repeated_ngrams(text: str, n=24):
|
|
598
|
+
s, _ = norm_view(text)
|
|
599
|
+
seen, reps = {}, []
|
|
600
|
+
i = 0
|
|
601
|
+
while i + n <= len(s):
|
|
602
|
+
g = s[i:i + n]
|
|
603
|
+
if g in seen and i - seen[g] >= n:
|
|
604
|
+
reps.append([seen[g], i])
|
|
605
|
+
i += n
|
|
606
|
+
continue
|
|
607
|
+
seen.setdefault(g, i)
|
|
608
|
+
i += 1
|
|
609
|
+
return reps
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def merge_texts(texts, overlap=0.18, min_match=6):
|
|
613
|
+
if not texts:
|
|
614
|
+
return "", {"seams": [], "warnings": ["no input"]}
|
|
615
|
+
merged = texts[0]
|
|
616
|
+
prev_len = len(norm_view(texts[0])[0])
|
|
617
|
+
seams = []
|
|
618
|
+
for k, t in enumerate(texts[1:], 2):
|
|
619
|
+
merged, seam = merge_pair(merged, t, prev_len, overlap, min_match)
|
|
620
|
+
seam["seam"] = f"{k - 1}->{k}"
|
|
621
|
+
seams.append(seam)
|
|
622
|
+
prev_len = len(norm_view(t)[0])
|
|
623
|
+
reps = repeated_ngrams(merged)
|
|
624
|
+
warnings = [f"seam {s['seam']}: {s['status']} (possible missing or duplicated lines)"
|
|
625
|
+
for s in seams if s["status"] != "merged"]
|
|
626
|
+
if reps:
|
|
627
|
+
warnings.append(f"{len(reps)} repeated 24-char spans in merged text (possible duplicate overlap)")
|
|
628
|
+
return merged, {"tiles": len(texts), "seams": seams, "repeated_spans": reps[:20], "warnings": warnings,
|
|
629
|
+
"merged_norm_chars": len(norm_view(merged)[0])}
|
|
630
|
+
|
|
631
|
+
|