修復含空格/括號檔名的圖片連結失效,自檢改驗連結可 render
來源檔名含空格或不平衡括號時,convert 產出的 Markdown 圖片連結會失效—— 圖檔明明存在於磁碟,整行卻退化成純文字(CommonMark 對未包住的空格與不平衡 括號視為分隔符)。用真 CommonMark parser 驗過:中文與成對括號正常,空格與 落單括號會壞。 - convert.py / from_docx.py / from_pdf.py / from_pptx.py:四處圖片連結目標 一律以 <> 包住(),同時涵蓋空格與不平衡括號。 - selfcheck.py:新增 assert_links_ok()——以 markdown_it 真 render,斷言圖片 連結數 == <img> 數且目標檔存在,取代原本只檢查 "
|
rank-bm25 # tools/search.py BM25(第一版不用向量庫)
|
||||||
fastmcp # mcp/server.py 薄殼
|
fastmcp # mcp/server.py 薄殼
|
||||||
|
markdown-it-py # 自檢用:以 CommonMark 解析驗證產出的圖片連結真的能 render
|
||||||
|
# (原為 fastmcp 的傳遞依賴,明確宣告以免上游調整後自檢失效)
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ def _vision_pdf(root, pdf_path, assets, conv_name):
|
|||||||
blocks = []
|
blocks = []
|
||||||
for i, (img, text) in enumerate(zip(images, texts), 1):
|
for i, (img, text) in enumerate(zip(images, texts), 1):
|
||||||
rel = img.relative_to(md_dir).as_posix()
|
rel = img.relative_to(md_dir).as_posix()
|
||||||
blocks.append(f"<!-- page {i} (vision: {model}) -->\n\n\n\n{text}")
|
# <> 包住:檔名含空格或不平衡括號時,未包住的連結會失效
|
||||||
|
blocks.append(f"<!-- page {i} (vision: {model}) -->\n\n\n\n{text}")
|
||||||
return "\n\n".join(blocks), "ok", "from_vision"
|
return "\n\n".join(blocks), "ok", "from_vision"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ def _par_md(par, doc, assets_dir, md_dir, counter):
|
|||||||
rel = (assets_dir / name).relative_to(md_dir).as_posix()
|
rel = (assets_dir / name).relative_to(md_dir).as_posix()
|
||||||
else:
|
else:
|
||||||
rel = name # dry-run:僅佔位
|
rel = name # dry-run:僅佔位
|
||||||
parts.append(f"")
|
# 連結目標以 <> 包住:來源檔名可能含空格或不平衡括號,兩者都會讓
|
||||||
|
# Markdown 連結失效(圖片存在卻整行退化成純文字)
|
||||||
|
parts.append(f"")
|
||||||
parts.append(run.text)
|
parts.append(run.text)
|
||||||
text = "".join(parts).strip()
|
text = "".join(parts).strip()
|
||||||
lvl = _heading_level(par)
|
lvl = _heading_level(par)
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ def convert(src, assets_dir=None, md_dir=None):
|
|||||||
assets_dir.mkdir(parents=True, exist_ok=True)
|
assets_dir.mkdir(parents=True, exist_ok=True)
|
||||||
pix.save(str(assets_dir / name))
|
pix.save(str(assets_dir / name))
|
||||||
rel = (assets_dir / name).relative_to(md_dir).as_posix()
|
rel = (assets_dir / name).relative_to(md_dir).as_posix()
|
||||||
blocks.append(f"")
|
# <> 包住:檔名含空格或不平衡括號時,未包住的連結會失效
|
||||||
|
blocks.append(f"")
|
||||||
doc.close()
|
doc.close()
|
||||||
return "\n\n".join(blocks), "ok"
|
return "\n\n".join(blocks), "ok"
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ def convert(src, assets_dir=None, md_dir=None):
|
|||||||
rel = (assets_dir / name).relative_to(md_dir).as_posix()
|
rel = (assets_dir / name).relative_to(md_dir).as_posix()
|
||||||
else:
|
else:
|
||||||
rel = name
|
rel = name
|
||||||
blocks.append(f"")
|
# <> 包住:檔名含空格或不平衡括號時,未包住的連結會失效
|
||||||
|
blocks.append(f"")
|
||||||
if slide.has_notes_slide:
|
if slide.has_notes_slide:
|
||||||
notes = slide.notes_slide.notes_text_frame.text.strip()
|
notes = slide.notes_slide.notes_text_frame.text.strip()
|
||||||
if notes:
|
if notes:
|
||||||
|
|||||||
@@ -26,13 +26,39 @@ import to_image
|
|||||||
|
|
||||||
# 測資檔名一律用「中文 + 空格」——真實來源檔就長這樣,而 git status --porcelain
|
# 測資檔名一律用「中文 + 空格」——真實來源檔就長這樣,而 git status --porcelain
|
||||||
# 對這兩者都會加引號轉義。ASCII 檔名的測資曾讓 ingest 的 preflight bug 溜過(§12)。
|
# 對這兩者都會加引號轉義。ASCII 檔名的測資曾讓 ingest 的 preflight bug 溜過(§12)。
|
||||||
DOCX, XLSX, PDF, SCAN = ("壓測 報告.docx", "測試案例 清單.xlsx",
|
DOCX, XLSX, PDF, SCAN = ("壓測 報告(1).docx", "測試案例 清單.xlsx",
|
||||||
"規格 說明.pdf", "掃描件 無文字層.pdf")
|
"規格 說明.pdf", "掃描件(2 期.pdf")
|
||||||
PPTX, HTML, TXT, DOC = ("結案 簡報.pptx", "指引 快照.html",
|
PPTX, HTML, TXT, DOC = ("結案 簡報.pptx", "指引 快照.html",
|
||||||
"不支援 筆記.txt", "舊版 報告.doc")
|
"不支援 筆記.txt", "舊版 報告.doc")
|
||||||
|
|
||||||
|
|
||||||
|
def assert_links_ok(md, base):
|
||||||
|
"""每個圖片連結都要真的 render 成 <img>、且目標檔存在。
|
||||||
|
|
||||||
|
只斷言 "![" 在不在會讓損毀連結矇混過關——含空格/不平衡括號的檔名會產出
|
||||||
|
語法有效但解析失敗的連結,圖檔明明在磁碟上卻整行退化成純文字(§13.4)。
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import urllib.parse
|
||||||
|
from markdown_it import MarkdownIt
|
||||||
|
|
||||||
|
want = md.count("![")
|
||||||
|
html = MarkdownIt("commonmark").render(md)
|
||||||
|
got = html.count("<img")
|
||||||
|
assert got == want, f"{want} 個圖片連結只有 {got} 個 render 成功:\n{md[:300]}"
|
||||||
|
for m in re.finditer(r'<img src="([^"]+)"', html):
|
||||||
|
p = base / urllib.parse.unquote(m.group(1))
|
||||||
|
assert p.is_file(), f"連結目標不存在:{p}"
|
||||||
|
return want
|
||||||
|
|
||||||
|
|
||||||
def make_fixtures(d):
|
def make_fixtures(d):
|
||||||
|
import fitz
|
||||||
|
png = d / "img.png" # 內嵌圖片用:docx/pdf/pptx 三條抽圖路徑都要走到
|
||||||
|
pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, 8, 8))
|
||||||
|
pix.clear_with(128)
|
||||||
|
pix.save(str(png))
|
||||||
|
|
||||||
import docx as docxlib
|
import docx as docxlib
|
||||||
doc = docxlib.Document()
|
doc = docxlib.Document()
|
||||||
doc.add_heading("支付閘道測試報告", level=1)
|
doc.add_heading("支付閘道測試報告", level=1)
|
||||||
@@ -42,6 +68,7 @@ def make_fixtures(d):
|
|||||||
t.rows[0].cells[1].text = "結果"
|
t.rows[0].cells[1].text = "結果"
|
||||||
t.rows[1].cells[0].text = "TC-001"
|
t.rows[1].cells[0].text = "TC-001"
|
||||||
t.rows[1].cells[1].text = "FAIL"
|
t.rows[1].cells[1].text = "FAIL"
|
||||||
|
doc.add_picture(str(png))
|
||||||
doc.save(d / DOCX)
|
doc.save(d / DOCX)
|
||||||
|
|
||||||
import openpyxl
|
import openpyxl
|
||||||
@@ -52,10 +79,10 @@ def make_fixtures(d):
|
|||||||
ws.append(["TC-001", "逾時 | 重試"])
|
ws.append(["TC-001", "逾時 | 重試"])
|
||||||
wb.save(d / XLSX)
|
wb.save(d / XLSX)
|
||||||
|
|
||||||
import fitz
|
|
||||||
pdf = fitz.open()
|
pdf = fitz.open()
|
||||||
page = pdf.new_page()
|
page = pdf.new_page()
|
||||||
page.insert_text((72, 72), "Payment gateway timeout defect reproduced under load test.")
|
page.insert_text((72, 72), "Payment gateway timeout defect reproduced under load test.")
|
||||||
|
page.insert_image(fitz.Rect(72, 100, 122, 150), filename=str(png))
|
||||||
pdf.save(d / PDF)
|
pdf.save(d / PDF)
|
||||||
pdf.close()
|
pdf.close()
|
||||||
scanned = fitz.open()
|
scanned = fitz.open()
|
||||||
@@ -64,9 +91,11 @@ def make_fixtures(d):
|
|||||||
scanned.close()
|
scanned.close()
|
||||||
|
|
||||||
from pptx import Presentation
|
from pptx import Presentation
|
||||||
|
from pptx.util import Inches
|
||||||
prs = Presentation()
|
prs = Presentation()
|
||||||
slide = prs.slides.add_slide(prs.slide_layouts[1])
|
slide = prs.slides.add_slide(prs.slide_layouts[1])
|
||||||
slide.shapes.title.text = "結案報告"
|
slide.shapes.title.text = "結案報告"
|
||||||
|
slide.shapes.add_picture(str(png), Inches(1), Inches(3))
|
||||||
slide.notes_slide.notes_text_frame.text = "備註:法遵項目全數通過"
|
slide.notes_slide.notes_text_frame.text = "備註:法遵項目全數通過"
|
||||||
prs.save(d / PPTX)
|
prs.save(d / PPTX)
|
||||||
|
|
||||||
@@ -103,12 +132,12 @@ def main():
|
|||||||
(root / sub).mkdir(parents=True)
|
(root / sub).mkdir(parents=True)
|
||||||
(root / "raw/manifest.json").write_text('{"version": 1, "files": {}}', encoding="utf-8")
|
(root / "raw/manifest.json").write_text('{"version": 1, "files": {}}', encoding="utf-8")
|
||||||
|
|
||||||
convert.main([str(fx / DOCX), str(fx / SCAN),
|
convert.main([str(fx / DOCX), str(fx / XLSX), str(fx / PDF), str(fx / SCAN),
|
||||||
str(fx / HTML), "--root", str(root)])
|
str(fx / PPTX), str(fx / HTML), "--root", str(root)])
|
||||||
m = json.loads((root / "raw/manifest.json").read_text(encoding="utf-8"))
|
m = json.loads((root / "raw/manifest.json").read_text(encoding="utf-8"))
|
||||||
origs = {k: v for k, v in m["files"].items() if v["kind"] == "original"}
|
origs = {k: v for k, v in m["files"].items() if v["kind"] == "original"}
|
||||||
convs = {k: v for k, v in m["files"].items() if v["kind"] == "converted"}
|
convs = {k: v for k, v in m["files"].items() if v["kind"] == "converted"}
|
||||||
assert len(origs) == 3 and len(convs) == 2, m
|
assert len(origs) == 6 and len(convs) == 5, m # SCAN 為 needs_ocr,無 converted
|
||||||
assert origs[f"raw/originals/{SCAN}"]["status"] == "needs_ocr"
|
assert origs[f"raw/originals/{SCAN}"]["status"] == "needs_ocr"
|
||||||
for k, v in convs.items():
|
for k, v in convs.items():
|
||||||
assert (root / k).is_file(), k
|
assert (root / k).is_file(), k
|
||||||
@@ -116,6 +145,13 @@ def main():
|
|||||||
assert (root / f"raw/originals/{DOCX}").is_file()
|
assert (root / f"raw/originals/{DOCX}").is_file()
|
||||||
print("PASS: convert.py 端到端 + manifest 對應")
|
print("PASS: convert.py 端到端 + manifest 對應")
|
||||||
|
|
||||||
|
# 圖片連結必須真的能 render——DOCX/PDF/PPTX 三條抽圖路徑都要驗到,
|
||||||
|
# 且測資檔名刻意含空格與不平衡括號(最容易讓連結失效的形狀)
|
||||||
|
linked = sum(assert_links_ok((root / k).read_text(encoding="utf-8"),
|
||||||
|
root / "raw/converted") for k in convs)
|
||||||
|
assert linked >= 3, f"應至少驗到 docx/pdf/pptx 各一個圖片連結,實際 {linked}"
|
||||||
|
print(f"PASS: 圖片連結可 render 且目標存在({linked} 個,檔名含空格/括號)")
|
||||||
|
|
||||||
# 冪等:同檔再跑一次 → skip,manifest 不變
|
# 冪等:同檔再跑一次 → skip,manifest 不變
|
||||||
before = (root / "raw/manifest.json").read_text(encoding="utf-8")
|
before = (root / "raw/manifest.json").read_text(encoding="utf-8")
|
||||||
convert.main([str(fx / DOCX), "--root", str(root)])
|
convert.main([str(fx / DOCX), "--root", str(root)])
|
||||||
@@ -228,6 +264,7 @@ def main():
|
|||||||
assert len(conv_rel) == 1 and mv["files"][conv_rel[0]]["converter"] == "from_vision", mv
|
assert len(conv_rel) == 1 and mv["files"][conv_rel[0]]["converter"] == "from_vision", mv
|
||||||
md_v = (root_v / conv_rel[0]).read_text(encoding="utf-8")
|
md_v = (root_v / conv_rel[0]).read_text(encoding="utf-8")
|
||||||
assert "可疑交易監控" in md_v and "(vision" in md_v and "![page 1]" in md_v, md_v
|
assert "可疑交易監控" in md_v and "(vision" in md_v and "![page 1]" in md_v, md_v
|
||||||
|
assert_links_ok(md_v, root_v / "raw/converted") # vision 頁的連結同樣要能 render
|
||||||
print("PASS: convert.py --vision 端到端(needs_ocr → 本地 vision 轉錄)")
|
print("PASS: convert.py --vision 端到端(needs_ocr → 本地 vision 轉錄)")
|
||||||
|
|
||||||
print("ALL PASS")
|
print("ALL PASS")
|
||||||
|
|||||||
Reference in New Issue
Block a user