treesearchlib 1.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.
- treesearch/__init__.py +53 -0
- treesearch/__main__.py +6 -0
- treesearch/_bin/pst-extract.exe +0 -0
- treesearch/cli.py +554 -0
- treesearch/config.py +206 -0
- treesearch/fts.py +2293 -0
- treesearch/heuristics.py +425 -0
- treesearch/indexer.py +2038 -0
- treesearch/parsers/__init__.py +62 -0
- treesearch/parsers/anydoc_parser.py +193 -0
- treesearch/parsers/ast_parser.py +136 -0
- treesearch/parsers/docx_parser.py +304 -0
- treesearch/parsers/email_html_md.py +60 -0
- treesearch/parsers/excel_parser.py +218 -0
- treesearch/parsers/html_parser.py +172 -0
- treesearch/parsers/image_metadata.py +345 -0
- treesearch/parsers/image_parser.py +59 -0
- treesearch/parsers/image_store.py +182 -0
- treesearch/parsers/markitdown_parser.py +258 -0
- treesearch/parsers/mhtml_parser.py +108 -0
- treesearch/parsers/pdf_parser.py +409 -0
- treesearch/parsers/pst_attachment_store.py +156 -0
- treesearch/parsers/pst_parser.py +733 -0
- treesearch/parsers/registry.py +405 -0
- treesearch/parsers/treesitter_parser.py +433 -0
- treesearch/pathutil.py +227 -0
- treesearch/py.typed +0 -0
- treesearch/ripgrep.py +159 -0
- treesearch/search.py +935 -0
- treesearch/tokenizer.py +176 -0
- treesearch/tree.py +393 -0
- treesearch/tree_searcher.py +1006 -0
- treesearch/treesearch.py +574 -0
- treesearch/watch.py +305 -0
- treesearchlib-1.1.0.dist-info/METADATA +124 -0
- treesearchlib-1.1.0.dist-info/RECORD +39 -0
- treesearchlib-1.1.0.dist-info/WHEEL +5 -0
- treesearchlib-1.1.0.dist-info/entry_points.txt +2 -0
- treesearchlib-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""图像元数据网关(deep module)—— vision 解读结果写回 / 读回 + 内容指纹。
|
|
3
|
+
|
|
4
|
+
载体(ADR-0009 + 工单 01 spike 实证):
|
|
5
|
+
- JPEG/JPG:EXIF ``XPComment`` (0x9c9c, UTF-16LE),经 piexif 无损写入;
|
|
6
|
+
Windows 资源管理器「备注」可读完整中文。
|
|
7
|
+
- PNG / WebP:工单 05/06 实现,本期 ``read_back`` 返回 None、其余抛 NotImplementedError。
|
|
8
|
+
|
|
9
|
+
``XPComment`` payload 为「markdown 主体 + 末尾 HTML 注释元数据」,人机共用——
|
|
10
|
+
Windows「备注」里人眼能看到描述主体,宿主解析末尾注释取版本::
|
|
11
|
+
|
|
12
|
+
{markdown}
|
|
13
|
+
|
|
14
|
+
<!--doclens model={model_tag} prompt={prompt_version}-->
|
|
15
|
+
|
|
16
|
+
设计目标:解读结果「跟文件走」;写回不改像素、不改内容指纹(避免增量死循环)。
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# 走 vision 解读 + 写回的图像格式(ADR-0009:仅这四种)
|
|
29
|
+
INTERPRETED_IMAGE_EXTS: frozenset[str] = frozenset({".jpg", ".jpeg", ".png", ".webp"})
|
|
30
|
+
|
|
31
|
+
# 全图像格式集合(含本期不解读的,供「其他图像格式跳过」判断,工单 07)
|
|
32
|
+
ALL_IMAGE_EXTS: frozenset[str] = frozenset({
|
|
33
|
+
".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff", ".tif",
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
_JPEG_EXTS: frozenset[str] = frozenset({".jpg", ".jpeg"})
|
|
37
|
+
|
|
38
|
+
# EXIF XPComment tag(Microsoft 扩展,UTF-16LE 字符串)
|
|
39
|
+
_XPCOMMENT_TAG = 0x9C9C
|
|
40
|
+
|
|
41
|
+
# payload 末尾元数据标记(HTML 注释,markdown 不渲染、Windows 备注可见但不喧宾夺主)
|
|
42
|
+
_META_MARKER = "<!--doclens"
|
|
43
|
+
_META_RE = re.compile(r"model=(?P<model>\S+)\s+prompt=(?P<prompt>\S+?)\s*-->")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ext(path: str) -> str:
|
|
47
|
+
return os.path.splitext(path)[1].lower()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _encode_xpcomment(text: str) -> bytes:
|
|
51
|
+
"""XPComment:UTF-16LE + 双字节 null 终止。"""
|
|
52
|
+
return text.encode("utf-16-le") + b"\x00\x00"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _decode_xpcomment(raw) -> str:
|
|
56
|
+
if not raw:
|
|
57
|
+
return ""
|
|
58
|
+
if isinstance(raw, (tuple, list)): # piexif.load 对 BYTE 类返回 tuple[int, ...]
|
|
59
|
+
raw = bytes(raw)
|
|
60
|
+
# 先在完整 bytes 上 decode(保 UTF-16LE 双字节对齐),再在 str 层去尾部 null 字符。
|
|
61
|
+
# 不能先 rstrip 字节——会误删高字节为 0x00 的字符(如 '>' = 0x003E),破坏对齐。
|
|
62
|
+
try:
|
|
63
|
+
text = raw.decode("utf-16-le")
|
|
64
|
+
except UnicodeDecodeError:
|
|
65
|
+
text = raw.decode("utf-16-le", errors="replace")
|
|
66
|
+
return text.rstrip("\x00")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _format_payload(markdown: str, model_tag: str, prompt_version: str) -> str:
|
|
70
|
+
return f"{markdown}\n\n{_META_MARKER} model={model_tag} prompt={prompt_version}-->"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parse_payload(text: str) -> dict:
|
|
74
|
+
"""从 XPComment 文本解析 payload;无元数据标记则视为纯描述(版本字段为空)。"""
|
|
75
|
+
idx = text.rfind(_META_MARKER)
|
|
76
|
+
if idx < 0:
|
|
77
|
+
return {"markdown": text, "model_tag": "", "prompt_version": ""}
|
|
78
|
+
markdown = text[:idx].rstrip("\n").rstrip()
|
|
79
|
+
m = _META_RE.search(text[idx:])
|
|
80
|
+
return {
|
|
81
|
+
"markdown": markdown,
|
|
82
|
+
"model_tag": m.group("model") if m else "",
|
|
83
|
+
"prompt_version": m.group("prompt") if m else "",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# 期望版本:宿主在 reindex 前经 set_expected_version 设置;read_back 默认据此校验,
|
|
88
|
+
# payload 版本不符则当无解读 → image_to_tree 占位入队重解读(工单 08)。
|
|
89
|
+
_expected_model_tag: Optional[str] = None
|
|
90
|
+
_expected_prompt_version: Optional[str] = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def set_expected_version(
|
|
94
|
+
model_tag: Optional[str] = None, prompt_version: Optional[str] = None
|
|
95
|
+
) -> None:
|
|
96
|
+
"""设置当前期望的解读版本(宿主在 reindex 前调用)。"""
|
|
97
|
+
global _expected_model_tag, _expected_prompt_version
|
|
98
|
+
_expected_model_tag = model_tag
|
|
99
|
+
_expected_prompt_version = prompt_version
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# write_back 失败计数(工单 09 可观测;经 writeback_failure_count 读、reset 清零)
|
|
103
|
+
_writeback_failures: int = 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def writeback_failure_count() -> int:
|
|
107
|
+
"""返回累计的 write_back 失败次数(供 status API 展示)。"""
|
|
108
|
+
return _writeback_failures
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def reset_writeback_failures() -> None:
|
|
112
|
+
global _writeback_failures
|
|
113
|
+
_writeback_failures = 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def read_back(
|
|
117
|
+
image_path: str,
|
|
118
|
+
*,
|
|
119
|
+
model_tag: Optional[str] = None,
|
|
120
|
+
prompt_version: Optional[str] = None,
|
|
121
|
+
) -> Optional[dict]:
|
|
122
|
+
"""读图像元数据里的解读 payload。
|
|
123
|
+
|
|
124
|
+
返回 ``{"markdown", "model_tag", "prompt_version"}``;无元数据返回 None。
|
|
125
|
+
传入 ``model_tag`` / ``prompt_version`` 且与 payload 不符时返回 None(版本不符)。
|
|
126
|
+
"""
|
|
127
|
+
ext = _ext(image_path)
|
|
128
|
+
if ext in _JPEG_EXTS:
|
|
129
|
+
text = _read_jpeg_xpcomment(image_path)
|
|
130
|
+
elif ext == ".png":
|
|
131
|
+
text = _read_png(image_path)
|
|
132
|
+
elif ext == ".webp":
|
|
133
|
+
text = _read_webp(image_path)
|
|
134
|
+
else:
|
|
135
|
+
# 其他格式不解读
|
|
136
|
+
return None
|
|
137
|
+
if not text:
|
|
138
|
+
return None
|
|
139
|
+
payload = _parse_payload(text)
|
|
140
|
+
# 手动备注(model_tag == "manual")是用户显式覆盖,不参与版本过期校验,
|
|
141
|
+
# 持久保留——否则切模型/重建会被 read_back 判过期 → 占位重解析覆盖。
|
|
142
|
+
if payload.get("model_tag") == "manual":
|
|
143
|
+
return payload
|
|
144
|
+
mt = model_tag if model_tag is not None else _expected_model_tag
|
|
145
|
+
pv = prompt_version if prompt_version is not None else _expected_prompt_version
|
|
146
|
+
if mt is not None and payload["model_tag"] != mt:
|
|
147
|
+
return None
|
|
148
|
+
if pv is not None and payload["prompt_version"] != pv:
|
|
149
|
+
return None
|
|
150
|
+
return payload
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _read_jpeg_xpcomment(image_path: str) -> Optional[str]:
|
|
154
|
+
try:
|
|
155
|
+
import piexif
|
|
156
|
+
except ImportError:
|
|
157
|
+
logger.warning("piexif 未安装,无法读 JPEG 元数据")
|
|
158
|
+
return None
|
|
159
|
+
try:
|
|
160
|
+
exif_dict = piexif.load(image_path)
|
|
161
|
+
except Exception as e: # 无 EXIF / 损坏 → piexif 抛异常
|
|
162
|
+
logger.debug("piexif.load 失败 %s: %s", image_path, e)
|
|
163
|
+
return None
|
|
164
|
+
raw = exif_dict.get("0th", {}).get(_XPCOMMENT_TAG)
|
|
165
|
+
if raw is None:
|
|
166
|
+
return None
|
|
167
|
+
return _decode_xpcomment(raw)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def write_back(
|
|
171
|
+
image_path: str,
|
|
172
|
+
markdown: str,
|
|
173
|
+
*,
|
|
174
|
+
model_tag: str,
|
|
175
|
+
prompt_version: str,
|
|
176
|
+
) -> bool:
|
|
177
|
+
"""无损写入解读 payload 到图像元数据。失败返回 False(不抛),并计入失败计数(工单 09)。
|
|
178
|
+
|
|
179
|
+
JPEG:合并已有 EXIF(只改 XPComment,不覆盖其他字段),piexif 无损 insert。
|
|
180
|
+
"""
|
|
181
|
+
ext = _ext(image_path)
|
|
182
|
+
if ext in _JPEG_EXTS:
|
|
183
|
+
ok = _write_jpeg(image_path, markdown, model_tag, prompt_version)
|
|
184
|
+
elif ext == ".png":
|
|
185
|
+
ok = _write_png(image_path, markdown, model_tag, prompt_version)
|
|
186
|
+
elif ext == ".webp":
|
|
187
|
+
ok = _write_webp(image_path, markdown, model_tag, prompt_version)
|
|
188
|
+
else:
|
|
189
|
+
logger.warning("write_back 暂不支持 %s", ext)
|
|
190
|
+
ok = False
|
|
191
|
+
if not ok:
|
|
192
|
+
global _writeback_failures
|
|
193
|
+
_writeback_failures += 1
|
|
194
|
+
return ok
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _write_jpeg(
|
|
198
|
+
image_path: str, markdown: str, model_tag: str, prompt_version: str
|
|
199
|
+
) -> bool:
|
|
200
|
+
try:
|
|
201
|
+
import piexif
|
|
202
|
+
except ImportError:
|
|
203
|
+
logger.error("piexif 未安装,无法写 JPEG 元数据")
|
|
204
|
+
return False
|
|
205
|
+
payload = _format_payload(markdown, model_tag, prompt_version)
|
|
206
|
+
try:
|
|
207
|
+
# 合并已有 EXIF:无 EXIF 时 piexif.load 抛异常 → 用空 0th
|
|
208
|
+
try:
|
|
209
|
+
exif_dict = piexif.load(image_path)
|
|
210
|
+
zeroth = dict(exif_dict.get("0th", {}))
|
|
211
|
+
except Exception:
|
|
212
|
+
zeroth = {}
|
|
213
|
+
zeroth[_XPCOMMENT_TAG] = _encode_xpcomment(payload)
|
|
214
|
+
exif_bytes = piexif.dump({"0th": zeroth})
|
|
215
|
+
piexif.insert(exif_bytes, image_path) # 无损:只动 APP1 EXIF,不碰像素
|
|
216
|
+
return True
|
|
217
|
+
except Exception as e:
|
|
218
|
+
logger.warning("write_back JPEG 失败 %s: %s", image_path, e)
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# PNG iTXt chunk 的 key(UTF-8,doclens 自读自写;Windows 资源管理器不读 PNG 元数据)
|
|
223
|
+
_PNG_KEY = "Description"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _read_png(image_path: str) -> Optional[str]:
|
|
227
|
+
try:
|
|
228
|
+
from PIL import Image
|
|
229
|
+
with Image.open(image_path) as img:
|
|
230
|
+
img.load()
|
|
231
|
+
text = img.info.get(_PNG_KEY)
|
|
232
|
+
return text if isinstance(text, str) else None
|
|
233
|
+
except Exception as e:
|
|
234
|
+
logger.debug("读 PNG 元数据失败 %s: %s", image_path, e)
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _write_png(
|
|
239
|
+
image_path: str, markdown: str, model_tag: str, prompt_version: str
|
|
240
|
+
) -> bool:
|
|
241
|
+
"""PNG:Pillow ``PngInfo`` iTXt(UTF-8)。
|
|
242
|
+
|
|
243
|
+
``save`` 会重编码,但 PNG 无损 → 像素不变 → ``content_fingerprint`` 不变(不死循环)。
|
|
244
|
+
仅写 ``_PNG_KEY``,不保留原图其他 tEXt(PNG 的 tEXt 多为软件元信息,可接受丢失)。
|
|
245
|
+
"""
|
|
246
|
+
try:
|
|
247
|
+
from PIL import Image
|
|
248
|
+
from PIL.PngImagePlugin import PngInfo
|
|
249
|
+
payload = _format_payload(markdown, model_tag, prompt_version)
|
|
250
|
+
with Image.open(image_path) as img:
|
|
251
|
+
img.load()
|
|
252
|
+
pi = PngInfo()
|
|
253
|
+
pi.add_itxt(_PNG_KEY, payload)
|
|
254
|
+
img.save(image_path, "PNG", pnginfo=pi)
|
|
255
|
+
return True
|
|
256
|
+
except Exception as e:
|
|
257
|
+
logger.warning("write_back PNG 失败 %s: %s", image_path, e)
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _webp_xmp_packet(payload: str) -> bytes:
|
|
262
|
+
"""把 payload 包成 XMP packet(dc:description)。XML-escape 防止 payload 里的
|
|
263
|
+
``<!--`` 破坏 XML 结构;``getxmp`` 读回时自动 unescape。"""
|
|
264
|
+
import xml.sax.saxutils as su
|
|
265
|
+
desc = su.escape(payload)
|
|
266
|
+
return (
|
|
267
|
+
b'<?xpacket begin="\xef\xbb\xbf" id="W5M0MpCehiHzreSzNTczkc9d"?>'
|
|
268
|
+
b'<x:xmpmeta xmlns:x="adobe:ns:meta/">'
|
|
269
|
+
b'<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">'
|
|
270
|
+
b'<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">'
|
|
271
|
+
b'<dc:description><rdf:Alt>'
|
|
272
|
+
b'<rdf:li xml:lang="x-default">' + desc.encode("utf-8") + b'</rdf:li>'
|
|
273
|
+
b'</rdf:Alt></dc:description>'
|
|
274
|
+
b'</rdf:Description></rdf:RDF></x:xmpmeta>'
|
|
275
|
+
b'<?xpacket end="w"?>'
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _read_webp(image_path: str) -> Optional[str]:
|
|
280
|
+
"""读 WebP XMP 的 dc:description 文本(getxmp 自动 unescape)。"""
|
|
281
|
+
try:
|
|
282
|
+
from PIL import Image
|
|
283
|
+
with Image.open(image_path) as img:
|
|
284
|
+
img.load()
|
|
285
|
+
try:
|
|
286
|
+
xmp = img.getxmp()
|
|
287
|
+
except Exception:
|
|
288
|
+
return None
|
|
289
|
+
li = (
|
|
290
|
+
(((xmp.get("xmpmeta") or {}).get("RDF") or {}).get("Description") or {})
|
|
291
|
+
.get("description", {}).get("Alt", {}).get("li")
|
|
292
|
+
)
|
|
293
|
+
if isinstance(li, dict):
|
|
294
|
+
t = li.get("text")
|
|
295
|
+
if isinstance(t, str):
|
|
296
|
+
return t
|
|
297
|
+
return None
|
|
298
|
+
except Exception as e:
|
|
299
|
+
logger.debug("读 WebP XMP 失败 %s: %s", image_path, e)
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _write_webp(
|
|
304
|
+
image_path: str, markdown: str, model_tag: str, prompt_version: str
|
|
305
|
+
) -> bool:
|
|
306
|
+
"""WebP:Pillow ``save(xmp=, lossless=True)``。lossless 保像素 → content_fingerprint 不变。"""
|
|
307
|
+
try:
|
|
308
|
+
from PIL import Image
|
|
309
|
+
payload = _format_payload(markdown, model_tag, prompt_version)
|
|
310
|
+
xmp = _webp_xmp_packet(payload)
|
|
311
|
+
with Image.open(image_path) as img:
|
|
312
|
+
img.load()
|
|
313
|
+
img.save(image_path, "WEBP", lossless=True, xmp=xmp)
|
|
314
|
+
return True
|
|
315
|
+
except Exception as e:
|
|
316
|
+
logger.warning("write_back WebP 失败 %s: %s", image_path, e)
|
|
317
|
+
return False
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def content_fingerprint(image_path: str) -> str:
|
|
321
|
+
"""对图像核心内容(解码像素)算指纹(md5)。
|
|
322
|
+
|
|
323
|
+
用解码像素而非文件字节,确保写回元数据(EXIF/XMP,含 piexif 写入时对段结构
|
|
324
|
+
的调整)不改变指纹 —— 这是避免「写回 → file_hash 变 → 增量重解析」死循环的关键
|
|
325
|
+
(工单 03 把它注入 file_hash_fn)。对所有图像格式通用(JPEG/PNG/WebP)。
|
|
326
|
+
|
|
327
|
+
注:spike 曾试「剥离 EXIF 段算字节 hash」,但 piexif.insert 会移除原图 APP0/JFIF
|
|
328
|
+
段(副作用、像素不变),导致字节 hash 漂移;像素 hash 完全不受段结构影响,故改用。
|
|
329
|
+
"""
|
|
330
|
+
ext = _ext(image_path)
|
|
331
|
+
if ext in ALL_IMAGE_EXTS:
|
|
332
|
+
return _pixel_hash(image_path)
|
|
333
|
+
raise NotImplementedError(f"content_fingerprint 不支持 {ext}")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _pixel_hash(image_path: str) -> str:
|
|
337
|
+
"""解码像素(RGB)的 md5。元数据/段结构不影响,对所有图像格式通用。"""
|
|
338
|
+
try:
|
|
339
|
+
from PIL import Image
|
|
340
|
+
with Image.open(image_path) as img:
|
|
341
|
+
return hashlib.md5(img.convert("RGB").tobytes()).hexdigest()
|
|
342
|
+
except Exception as e:
|
|
343
|
+
logger.warning("像素指纹失败 %s: %s,回退文件字节 hash", image_path, e)
|
|
344
|
+
with open(image_path, "rb") as f:
|
|
345
|
+
return hashlib.md5(f.read()).hexdigest()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""独立图像文件 parser —— 占位节点,真正的视觉解析由后台 Vision Worker 完成。
|
|
3
|
+
|
|
4
|
+
架构(见 docs/adr/0001-vision-image-indexing.md):
|
|
5
|
+
视觉识别耗时长,不能拖慢主索引。本 parser 不调任何视觉 API,只产出
|
|
6
|
+
「占位节点」树(标题=文件名,保证文件名立即可搜索),并通过
|
|
7
|
+
``result["vision_pending"]`` 标记通知 indexer 把文件写入 vision_queue 表;
|
|
8
|
+
宿主侧的 VisionWorker(如 doclens)后台串行消费队列,拿到视觉模型输出的
|
|
9
|
+
Markdown 后复用 ``md_to_tree`` 建树,原位替换占位节点。
|
|
10
|
+
"""
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from ..indexer import md_to_tree
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# 独立图像文件扩展名 —— 仅这四种走 vision 解读 + 元数据写回(ADR-0009 / 工单 07)。
|
|
19
|
+
# gif/bmp/tiff/tif 不再作为图像处理(不 image_to_tree、不占位、不入 vision 队列);
|
|
20
|
+
# svg 是文本走 code/xml,不在此列。
|
|
21
|
+
IMAGE_EXTENSIONS: frozenset[str] = frozenset({
|
|
22
|
+
".png", ".jpg", ".jpeg", ".webp",
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
# 占位节点正文(解析完成后被视觉模型输出的 Markdown 原位替换)
|
|
26
|
+
PLACEHOLDER_TEXT = "(图像文件已登记,等待后台视觉解析;当前仅文件名可搜索)"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def image_to_tree(image_path: str, **kwargs) -> dict:
|
|
30
|
+
"""为图像文件构建树:已有解读就读回、否则占位入队。
|
|
31
|
+
|
|
32
|
+
- **已有 vision 解读元数据**(``read_back`` 命中):用解读 Markdown 走 ``md_to_tree``
|
|
33
|
+
建树,**不设** ``vision_pending``(indexer 据此跳过入队)——这是 force 重建不重花
|
|
34
|
+
API 的闭环(ADR-0009 / 工单 04)。
|
|
35
|
+
- **无解读**:单个 ``# <文件名>`` 根节点 + 占位正文,设 ``vision_pending`` 入队
|
|
36
|
+
让 Vision Worker 后台解析。
|
|
37
|
+
|
|
38
|
+
走 ``md_to_tree`` 以获得与其他类型一致的 node_id / summary 后处理。
|
|
39
|
+
"""
|
|
40
|
+
from .image_metadata import read_back
|
|
41
|
+
|
|
42
|
+
abs_path = os.path.abspath(image_path)
|
|
43
|
+
doc_name = os.path.splitext(os.path.basename(image_path))[0]
|
|
44
|
+
|
|
45
|
+
payload = read_back(abs_path)
|
|
46
|
+
markdown = payload["markdown"] if payload and payload.get("markdown") else None
|
|
47
|
+
pending = markdown is None
|
|
48
|
+
if pending:
|
|
49
|
+
markdown = f"# {doc_name}\n\n{PLACEHOLDER_TEXT}\n"
|
|
50
|
+
|
|
51
|
+
result = await md_to_tree(md_content=markdown, **kwargs)
|
|
52
|
+
# md_to_tree(md_content=...) 的 doc_name 是 "untitled",且没有 source_path,
|
|
53
|
+
# 这里补上真实值,保证增量索引 / preview 反查正常工作。
|
|
54
|
+
result["doc_name"] = doc_name
|
|
55
|
+
result["source_path"] = abs_path
|
|
56
|
+
if pending:
|
|
57
|
+
# 通知 indexer._index_one 写入 vision_queue(worker 据此后台解析)
|
|
58
|
+
result["vision_pending"] = True
|
|
59
|
+
return result
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""图片落盘存储 —— 把富文档里提取出的图片写成磁盘文件并维护元信息。
|
|
3
|
+
|
|
4
|
+
供 docx / pptx parser 在解析阶段调用:落盘 + 文档内按 blob sha256 去重,
|
|
5
|
+
返回每个图片引用的 markdown 内联语法。预览端点用 ``resolve`` 反查文件。
|
|
6
|
+
"""
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# 扩展名 → MIME(公共 API:宿主按扩展名取 media type;缺省 application/octet-stream)
|
|
19
|
+
EXT_TO_MEDIA: dict[str, str] = {
|
|
20
|
+
"png": "image/png",
|
|
21
|
+
"jpg": "image/jpeg",
|
|
22
|
+
"jpeg": "image/jpeg",
|
|
23
|
+
"gif": "image/gif",
|
|
24
|
+
"bmp": "image/bmp",
|
|
25
|
+
"webp": "image/webp",
|
|
26
|
+
"tiff": "image/tiff",
|
|
27
|
+
"tif": "image/tiff",
|
|
28
|
+
"svg": "image/svg+xml",
|
|
29
|
+
"emf": "image/emf",
|
|
30
|
+
"wmf": "image/wmf",
|
|
31
|
+
"ico": "image/x-icon",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_META_FILENAME = "_meta.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ImagePart:
|
|
39
|
+
"""待落盘的一张图片。
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
blob: 图片二进制。
|
|
43
|
+
ext: 扩展名(不含点,如 "png")。
|
|
44
|
+
source_ref: 溯源键,docx 用 rId,pptx 用 "slide{idx}:{shape_id}"。
|
|
45
|
+
disp_w: 文档内显示宽(px),供前端 icon 判定;None=未知(前端退回 naturalWidth)。
|
|
46
|
+
"""
|
|
47
|
+
blob: bytes
|
|
48
|
+
ext: str
|
|
49
|
+
source_ref: str
|
|
50
|
+
disp_w: int | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ImageRef:
|
|
55
|
+
"""落盘后返回的图片引用。"""
|
|
56
|
+
seq: int
|
|
57
|
+
inline_md: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def doc_hash_for(rel_path: str) -> str:
|
|
61
|
+
"""rel_path → 12 位 sha256,用作图片子目录名。"""
|
|
62
|
+
return hashlib.sha256(rel_path.encode("utf-8")).hexdigest()[:12]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _normalize_ext(ext: str) -> str:
|
|
66
|
+
return (ext or "").lower().lstrip(".")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _inline_md(seq: int, rel_path: str, disp_w: int | None = None) -> str:
|
|
70
|
+
url = f"/api/preview/asset?path={quote(rel_path, safe='')}&id={seq}"
|
|
71
|
+
if disp_w is not None:
|
|
72
|
+
url += f"&dw={disp_w}"
|
|
73
|
+
return f""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ImageStore:
|
|
77
|
+
"""图片落盘存储(无运行时可变状态,仅持有 images_root)。
|
|
78
|
+
|
|
79
|
+
所有方法基于磁盘文件操作,可被索引阶段与预览端点各自独立实例化,
|
|
80
|
+
只要 ``images_root`` 指向同一目录即可。
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, images_root: Path):
|
|
84
|
+
self._root = Path(images_root)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def root(self) -> Path:
|
|
88
|
+
return self._root
|
|
89
|
+
|
|
90
|
+
def _doc_dir(self, doc_hash: str) -> Path:
|
|
91
|
+
return self._root / doc_hash
|
|
92
|
+
|
|
93
|
+
def extract_for_doc(
|
|
94
|
+
self,
|
|
95
|
+
rel_path: str,
|
|
96
|
+
parts: list[ImagePart],
|
|
97
|
+
) -> dict[str, ImageRef]:
|
|
98
|
+
"""把一个文档的图片落盘 + 去重。
|
|
99
|
+
|
|
100
|
+
幂等:每次调用先清空该文档的图目录,再重新提取(保证重索引时无旧图残留)。
|
|
101
|
+
文档内按 ``sha256(blob)`` 去重,同图只落一份,多个 source_ref 指向同一 seq。
|
|
102
|
+
"""
|
|
103
|
+
dh = doc_hash_for(rel_path)
|
|
104
|
+
doc_dir = self._doc_dir(dh)
|
|
105
|
+
if doc_dir.exists():
|
|
106
|
+
shutil.rmtree(doc_dir, ignore_errors=True)
|
|
107
|
+
if not parts:
|
|
108
|
+
return {}
|
|
109
|
+
doc_dir.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
|
|
111
|
+
meta: dict[str, dict] = {}
|
|
112
|
+
refs: dict[str, ImageRef] = {}
|
|
113
|
+
blob_to_seq: dict[str, int] = {}
|
|
114
|
+
seq = 0
|
|
115
|
+
for part in parts:
|
|
116
|
+
sha = hashlib.sha256(part.blob).hexdigest()
|
|
117
|
+
if sha in blob_to_seq:
|
|
118
|
+
s = blob_to_seq[sha]
|
|
119
|
+
else:
|
|
120
|
+
seq += 1
|
|
121
|
+
s = seq
|
|
122
|
+
ext = _normalize_ext(part.ext) or "png"
|
|
123
|
+
fname = f"{s}.{ext}"
|
|
124
|
+
try:
|
|
125
|
+
(doc_dir / fname).write_bytes(part.blob)
|
|
126
|
+
except OSError as e:
|
|
127
|
+
logger.warning("Failed to write image %s: %s", fname, e)
|
|
128
|
+
continue
|
|
129
|
+
entry = {
|
|
130
|
+
"sha256": sha,
|
|
131
|
+
"media_type": EXT_TO_MEDIA.get(ext, "application/octet-stream"),
|
|
132
|
+
"filename": fname,
|
|
133
|
+
}
|
|
134
|
+
if part.disp_w is not None:
|
|
135
|
+
entry["disp_w"] = part.disp_w
|
|
136
|
+
meta[str(s)] = entry
|
|
137
|
+
blob_to_seq[sha] = s
|
|
138
|
+
refs[part.source_ref] = ImageRef(seq=s, inline_md=_inline_md(s, rel_path, part.disp_w))
|
|
139
|
+
|
|
140
|
+
self._write_meta(doc_dir, meta)
|
|
141
|
+
return refs
|
|
142
|
+
|
|
143
|
+
def resolve(self, doc_hash: str, seq: int) -> tuple[Path, str] | None:
|
|
144
|
+
"""端点用:按 doc_hash + seq 返回 (文件路径, media_type),缺失返回 None。"""
|
|
145
|
+
doc_dir = self._doc_dir(doc_hash)
|
|
146
|
+
meta = self._load_meta(doc_dir)
|
|
147
|
+
entry = meta.get(str(seq))
|
|
148
|
+
if not entry:
|
|
149
|
+
return None
|
|
150
|
+
path = doc_dir / entry["filename"]
|
|
151
|
+
if not path.exists():
|
|
152
|
+
return None
|
|
153
|
+
return path, entry.get("media_type", "application/octet-stream")
|
|
154
|
+
|
|
155
|
+
def purge_doc(self, rel_path: str) -> None:
|
|
156
|
+
"""删除单个文档的图目录(用于源文件被删除的清理)。"""
|
|
157
|
+
doc_dir = self._doc_dir(doc_hash_for(rel_path))
|
|
158
|
+
if doc_dir.exists():
|
|
159
|
+
shutil.rmtree(doc_dir, ignore_errors=True)
|
|
160
|
+
|
|
161
|
+
def purge_all(self) -> None:
|
|
162
|
+
"""清空整个 images_root(用于 force 全量重建)。"""
|
|
163
|
+
if self._root.exists():
|
|
164
|
+
shutil.rmtree(self._root, ignore_errors=True)
|
|
165
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
|
|
167
|
+
def _load_meta(self, doc_dir: Path) -> dict:
|
|
168
|
+
mp = doc_dir / _META_FILENAME
|
|
169
|
+
if not mp.exists():
|
|
170
|
+
return {}
|
|
171
|
+
try:
|
|
172
|
+
return json.loads(mp.read_text(encoding="utf-8"))
|
|
173
|
+
except (OSError, json.JSONDecodeError):
|
|
174
|
+
logger.warning("image meta corrupted, ignoring: %s", mp)
|
|
175
|
+
return {}
|
|
176
|
+
|
|
177
|
+
def _write_meta(self, doc_dir: Path, meta: dict) -> None:
|
|
178
|
+
"""原子写 _meta.json:临时文件 + os.replace。"""
|
|
179
|
+
mp = doc_dir / _META_FILENAME
|
|
180
|
+
tmp = mp.with_suffix(".tmp")
|
|
181
|
+
tmp.write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8")
|
|
182
|
+
os.replace(tmp, mp)
|