From a6a20175e51a04df9e0c0b24d133c8ff7811f757 Mon Sep 17 00:00:00 2001 From: FFFFFFpy <87560048+FFFFFFpy@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:53:54 +0800 Subject: [PATCH] fix: preserve MHTML image resolution and Markdown structure (#1743) * fix(docreader): preserve MHTML blocks and resolve titled images * fix(docreader): harden MHTML markdown image resolution * fix(docreader): preserve markdown hard break spaces --------- Co-authored-by: FFFFFFpy --- docreader/parser/mhtml_parser.py | 113 +++++- docreader/tests/test_mhtml_parser.py | 147 ++++++++ .../docparser/image_resolver.go | 20 +- .../docparser/image_resolver_test.go | 324 ++++++++++++++++++ .../docparser/markdown_image_scanner.go | 262 ++++++++++++++ .../docparser/markdown_image_scanner_test.go | 203 +++++++++++ testdata/mhtml/titled-image-contract.json | 11 + testdata/mhtml/titled-image.mhtml | 48 +++ 8 files changed, 1112 insertions(+), 16 deletions(-) create mode 100644 internal/infrastructure/docparser/markdown_image_scanner.go create mode 100644 internal/infrastructure/docparser/markdown_image_scanner_test.go create mode 100644 testdata/mhtml/titled-image-contract.json create mode 100644 testdata/mhtml/titled-image.mhtml diff --git a/docreader/parser/mhtml_parser.py b/docreader/parser/mhtml_parser.py index 4d1fd1d1..b5fded30 100644 --- a/docreader/parser/mhtml_parser.py +++ b/docreader/parser/mhtml_parser.py @@ -8,7 +8,7 @@ import email import html import logging import os -from urllib.parse import unquote, urljoin +from urllib.parse import unquote, urljoin, urlparse import uuid from typing import Dict @@ -70,8 +70,7 @@ class MHTMLParser(BaseParser): elif content_type.startswith("image/") and self.extract_images: image_data = part.get_payload(decode=True) if image_data: - ext = self._image_extension(content_type) - image_path = f"images/{uuid.uuid4().hex}{ext}" + image_path = self._image_path_for_part(part, content_type, images) images[image_path] = base64.b64encode(image_data).decode("utf-8") self._add_image_aliases(image_aliases, part, image_path) @@ -154,6 +153,45 @@ class MHTMLParser(BaseParser): "image/x-icon": ".ico", }.get(content_type, ".png") + @classmethod + def _image_path_for_part( + cls, part, content_type: str, images: Dict[str, str] + ) -> str: + """Choose a stable image path when the MHTML part exposes a filename.""" + ext = cls._image_extension(content_type) + location = (part.get("Content-Location", "") or "").strip() + filename = cls._filename_from_content_location(location) + if not filename: + return f"images/{uuid.uuid4().hex}{ext}" + + stem, location_ext = os.path.splitext(filename) + if not location_ext: + filename = f"{filename}{ext}" + image_path = f"images/{filename}" + if image_path not in images: + return image_path + + suffix = 2 + stem, location_ext = os.path.splitext(filename) + while True: + candidate = f"images/{stem}_{suffix}{location_ext}" + if candidate not in images: + return candidate + suffix += 1 + + @staticmethod + def _filename_from_content_location(location: str) -> str: + decoded = unquote(html.unescape(location.strip())) + if not decoded or decoded.lower().startswith("cid:"): + return "" + path = urlparse(decoded).path or decoded + filename = os.path.basename(path) + if not filename or filename in {".", ".."}: + return "" + if "/" in filename or "\\" in filename: + return "" + return filename + def _html_to_markdown( self, html_content: str, @@ -171,9 +209,7 @@ class MHTMLParser(BaseParser): self._rewrite_image_sources(soup, image_aliases, base_location) text_fallback = soup.get_text(separator="\n", strip=True) markdown_text = md(str(soup), heading_style="ATX") - result = "\n".join( - line.strip() for line in markdown_text.split("\n") if line.strip() - ) + result = self._normalize_markdown(markdown_text) if not result and text_fallback: logger.warning("Markdown empty, falling back to text extraction") return text_fallback @@ -187,6 +223,71 @@ class MHTMLParser(BaseParser): logger.error("HTML to Markdown conversion failed: %s", e) return f"```html\n{html_content}\n```" + @staticmethod + def _normalize_markdown(markdown_text: str) -> str: + text = markdown_text.replace("\r\n", "\n").replace("\r", "\n") + output: list[str] = [] + pending_blank = False + fence_char: str | None = None + fence_len = 0 + + for line in text.split("\n"): + if fence_char is not None: + output.append(line) + if MHTMLParser._is_closing_fence(line, fence_char, fence_len): + fence_char = None + fence_len = 0 + continue + + opening = MHTMLParser._opening_fence(line) + if opening is not None: + if pending_blank and output: + output.append("") + pending_blank = False + output.append(line) + fence_char, fence_len = opening + continue + + if not line.strip(" \t"): + pending_blank = True + continue + + if pending_blank and output: + output.append("") + pending_blank = False + + trailing_spaces = len(line) - len(line.rstrip(" ")) + if trailing_spaces >= 2: + line = line.rstrip(" \t") + " " + else: + line = line.rstrip(" \t") + output.append(line) + + return "\n".join(output).strip("\n") + + @staticmethod + def _opening_fence(line: str) -> tuple[str, int] | None: + stripped = line.lstrip(" ") + if len(line) - len(stripped) > 3 or not stripped: + return None + fence_char = stripped[0] + if fence_char not in {"`", "~"}: + return None + fence_len = len(stripped) - len(stripped.lstrip(fence_char)) + if fence_len < 3: + return None + return fence_char, fence_len + + @staticmethod + def _is_closing_fence(line: str, fence_char: str, fence_len: int) -> bool: + stripped = line.lstrip(" ") + if len(line) - len(stripped) > 3: + return False + closing_len = len(stripped) - len(stripped.lstrip(fence_char)) + if closing_len < fence_len: + return False + return not stripped[closing_len:].strip(" \t") + @staticmethod def _strip_internal_links(soup: BeautifulSoup) -> None: """Unwrap links that don't point to an external resource.""" diff --git a/docreader/tests/test_mhtml_parser.py b/docreader/tests/test_mhtml_parser.py index 531ba44d..afd70773 100644 --- a/docreader/tests/test_mhtml_parser.py +++ b/docreader/tests/test_mhtml_parser.py @@ -1,9 +1,14 @@ +import base64 +import json import unittest from email.message import EmailMessage +from pathlib import Path from docreader.parser.mhtml_parser import MHTMLParser from docreader.parser.registry import registry +REPO_ROOT = Path(__file__).resolve().parents[2] + def _minimal_mhtml_bytes() -> bytes: root = EmailMessage() @@ -46,6 +51,39 @@ def _minimal_mhtml_bytes() -> bytes: return root.as_bytes() +def _mhtml_with_table_image_and_caption() -> bytes: + root = EmailMessage() + root["Subject"] = "MHTML with table image" + root.make_related() + + main = EmailMessage() + main.set_content( + "" + "" + "" + "" + "
体验方向代表内容
赛季制建立BP、Rank
" + '图片' + "

高机动性身法与独特枪械反馈

" + "", + subtype="html", + ) + main["Content-Location"] = "https://example.com/article" + root.attach(main) + + image = EmailMessage() + image.set_content( + b"GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff,\x00\x00" + b"\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;", + maintype="image", + subtype="gif", + ) + image["Content-ID"] = "" + root.attach(image) + + return root.as_bytes() + + class MHTMLParserTest(unittest.TestCase): def test_parse_selects_main_html_and_filters_noise(self): document = MHTMLParser( @@ -84,6 +122,115 @@ class MHTMLParserTest(unittest.TestCase): self.assertNotIn("cid:tiny-image", with_images.content) self.assertEqual(without_images.images, {}) + def test_table_image_and_caption_keep_markdown_block_boundaries(self): + document = MHTMLParser( + file_name="article.mhtml", file_type="mhtml" + ).parse_into_text(_mhtml_with_table_image_and_caption()) + + self.assertEqual(len(document.images), 1) + image_ref = next(iter(document.images)) + self.assertIn(f'![图片]({image_ref} "图片")', document.content) + self.assertIn( + f"| 赛季制建立 | BP、Rank |\n\n![图片]({image_ref} \"图片\")", + document.content, + ) + self.assertIn( + f'![图片]({image_ref} "图片")\n\n高机动性身法与独特枪械反馈', + document.content, + ) + + def test_html_to_markdown_preserves_indentation_and_code_block_blanks(self): + markdown = MHTMLParser( + file_name="article.mhtml", file_type="mhtml" + )._html_to_markdown( + "" + "

quoted

" + "
line1\n\n  indented\n
" + ) + + self.assertIn("* parent\n + child", markdown) + self.assertIn("\n\n> quoted\n\n", markdown) + self.assertIn("```\nline1\n\n indented\n```", markdown) + + def test_html_to_markdown_preserves_nested_list_indentation(self): + markdown = MHTMLParser( + file_name="article.mhtml", file_type="mhtml" + )._html_to_markdown("") + + self.assertIn("* parent\n + child", markdown) + + def test_html_to_markdown_preserves_blockquote_boundaries(self): + markdown = MHTMLParser( + file_name="article.mhtml", file_type="mhtml" + )._html_to_markdown("

before

quoted

after

") + + self.assertIn("before\n\n> quoted\n\nafter", markdown) + + def test_html_to_markdown_preserves_fenced_code_blank_lines(self): + markdown = MHTMLParser( + file_name="article.mhtml", file_type="mhtml" + )._html_to_markdown("
line1\n\n\nline2\n
") + + self.assertIn("```\nline1\n\n\nline2\n```", markdown) + + def test_html_to_markdown_collapses_excess_blank_lines_outside_code(self): + markdown = MHTMLParser._normalize_markdown("alpha\n\n \n\t\n\nbeta") + + self.assertEqual(markdown, "alpha\n\nbeta") + self.assertNotIn("\n\n\n", markdown) + + def test_html_to_markdown_preserves_hard_break_spaces(self): + markdown = MHTMLParser( + file_name="article.mhtml", file_type="mhtml" + )._html_to_markdown("

alpha
beta

") + + self.assertEqual(markdown, "alpha \nbeta") + + def test_normalize_markdown_preserves_two_space_hard_break(self): + markdown = MHTMLParser._normalize_markdown("alpha \nbeta") + + self.assertEqual(markdown, "alpha \nbeta") + + def test_html_to_markdown_normalizes_crlf(self): + markdown = MHTMLParser._normalize_markdown("alpha\r\n\r\nbeta\rgamma") + + self.assertEqual(markdown, "alpha\n\nbeta\ngamma") + + def test_html_to_markdown_does_not_strip_leading_indentation_at_document_start(self): + markdown = MHTMLParser._normalize_markdown(" indented start\n") + + self.assertEqual(markdown, " indented start") + + def test_mhtml_shared_contract_fixture(self): + fixture = REPO_ROOT / "testdata" / "mhtml" / "titled-image.mhtml" + contract_path = REPO_ROOT / "testdata" / "mhtml" / "titled-image-contract.json" + contract = json.loads(contract_path.read_text(encoding="utf-8")) + + document = MHTMLParser( + file_name="titled-image.mhtml", file_type="mhtml" + ).parse_into_text(fixture.read_bytes()) + + self.assertEqual(document.content, contract["markdown_content"]) + self.assertEqual(len(document.images), 1) + image_contract = contract["images"][0] + self.assertIn(image_contract["original_ref"], document.images) + self.assertEqual( + base64.b64decode(document.images[image_contract["original_ref"]]), + base64.b64decode(image_contract["image_data_base64"]), + ) + self.assertIn( + '| 赛季制建立 | BP、Rank |\n\n![图片](images/第 1 页 (测试).gif "阶段 1) 图片")', + document.content, + ) + self.assertIn( + '![图片](images/第 1 页 (测试).gif "阶段 1) 图片")\n\n高机动性身法与独特枪械反馈', + document.content, + ) + self.assertNotIn("\n\n\n", document.content.split("```", 1)[0]) + self.assertIn("```\nline1\n\n\nline2\n```", document.content) + self.assertIn("* parent\n + child", document.content) + self.assertIn("alpha \nbeta", document.content) + def test_registry_resolves_mhtml(self): self.assertIs(registry.get_parser_class("", "mhtml"), MHTMLParser) diff --git a/internal/infrastructure/docparser/image_resolver.go b/internal/infrastructure/docparser/image_resolver.go index 16c7fb56..59ff6ae8 100644 --- a/internal/infrastructure/docparser/image_resolver.go +++ b/internal/infrastructure/docparser/image_resolver.go @@ -104,18 +104,16 @@ func (r *ImageResolver) ResolveAndStore( } savedRefs := make(map[string]StoredImage) - // Process each image reference found in the markdown. - // The URL group supports one level of balanced parentheses so that URLs - // like https://example.com/item_(abc)/123 are captured in full. - // Allow spaces in URLs (exclude only parens and newlines) to handle - // filenames with spaces, e.g. "images/第 1 页.jpg". - imgPattern := regexp.MustCompile(`!\[(.*?)\]\(([^()\n]*(?:\([^)]*\)[^()\n]*)*)\)`) - matches := imgPattern.FindAllStringSubmatchIndex(markdown, -1) + matches := scanMarkdownImageTargets(markdown) // Process in reverse order to preserve positions when replacing for i := len(matches) - 1; i >= 0; i-- { - m := matches[i] - refPath := markdown[m[4]:m[5]] // group 2: the URL/path + match := matches[i] + rawTarget := markdown[match.TargetStart:match.TargetEnd] + refPath, pathStart, pathEnd, ok := splitMarkdownImageTarget(rawTarget, refMap) + if !ok { + continue + } // Skip already-resolved URLs (http/https, unified /files/, or provider:// scheme) if strings.HasPrefix(refPath, "http://") || strings.HasPrefix(refPath, "https://") || @@ -131,7 +129,9 @@ func (r *ImageResolver) ResolveAndStore( images = appendStoredImage(images, stored) // Replace in markdown - markdown = markdown[:m[4]] + stored.ServingURL + markdown[m[5]:] + absolutePathStart := match.TargetStart + pathStart + absolutePathEnd := match.TargetStart + pathEnd + markdown = markdown[:absolutePathStart] + stored.ServingURL + markdown[absolutePathEnd:] } md5, imgRelativeHTML, _ := r.ResolveRelativeHTMLImages(ctx, markdown, fileSvc, tenantID, refMap, savedRefs) diff --git a/internal/infrastructure/docparser/image_resolver_test.go b/internal/infrastructure/docparser/image_resolver_test.go index d4a6a763..e299c8a5 100644 --- a/internal/infrastructure/docparser/image_resolver_test.go +++ b/internal/infrastructure/docparser/image_resolver_test.go @@ -4,12 +4,15 @@ import ( "bytes" "context" "encoding/base64" + "encoding/json" "fmt" "image" "image/color" "image/png" "io" "mime/multipart" + "os" + "path/filepath" "strings" "testing" @@ -274,3 +277,324 @@ func TestResolveAndStore_MultipleFormats(t *testing.T) { t.Fatalf("data URI still in output after ResolveAndStore") } } + +func TestResolveAndStoreMarkdownImageWithTitle(t *testing.T) { + png := createTestPNG(200, 150) + result := &types.ReadResult{ + MarkdownContent: `![图片](images/test.png "图片")`, + ImageRefs: []types.ImageRef{ + { + Filename: "test.png", + OriginalRef: "images/test.png", + MimeType: "image/png", + ImageData: png, + }, + }, + } + + svc := &captureSaveBytes{} + r := NewImageResolver() + out, imgs, err := r.ResolveAndStore(context.Background(), result, svc, 1) + if err != nil { + t.Fatal(err) + } + if len(imgs) != 1 { + t.Fatalf("expected 1 image but got %d", len(imgs)) + } + if len(svc.saved) != 1 { + t.Fatalf("expected SaveBytes to be called once but got %d", len(svc.saved)) + } + if !strings.Contains(out, `![图片](local://test/`) || !strings.Contains(out, ` "图片")`) { + t.Fatalf("markdown image title was not preserved around stored URL: %s", out) + } + if strings.Contains(out, "images/test.png") { + t.Fatalf("original image path was not replaced: %s", out) + } +} + +func TestResolveAndStoreMarkdownImageWithSingleQuotedTitle(t *testing.T) { + png := createTestPNG(200, 150) + result := &types.ReadResult{ + MarkdownContent: `![图片](images/test.png '图片')`, + ImageRefs: []types.ImageRef{ + { + Filename: "test.png", + OriginalRef: "images/test.png", + MimeType: "image/png", + ImageData: png, + }, + }, + } + + svc := &captureSaveBytes{} + r := NewImageResolver() + out, imgs, err := r.ResolveAndStore(context.Background(), result, svc, 1) + if err != nil { + t.Fatal(err) + } + if len(imgs) != 1 || len(svc.saved) != 1 { + t.Fatalf("expected one stored image, got imgs=%d saved=%d", len(imgs), len(svc.saved)) + } + if !strings.Contains(out, `![图片](local://test/`) || !strings.Contains(out, ` '图片')`) { + t.Fatalf("markdown image title was not preserved around stored URL: %s", out) + } +} + +func TestResolveAndStoreMarkdownImageWithSpacedFilename(t *testing.T) { + png := createTestPNG(200, 150) + result := &types.ReadResult{ + MarkdownContent: `![](images/第 1 页.png)`, + ImageRefs: []types.ImageRef{ + { + Filename: "第 1 页.png", + OriginalRef: "images/第 1 页.png", + MimeType: "image/png", + ImageData: png, + }, + }, + } + + svc := &captureSaveBytes{} + r := NewImageResolver() + out, imgs, err := r.ResolveAndStore(context.Background(), result, svc, 1) + if err != nil { + t.Fatal(err) + } + if len(imgs) != 1 || len(svc.saved) != 1 { + t.Fatalf("expected one stored image, got imgs=%d saved=%d", len(imgs), len(svc.saved)) + } + if !strings.Contains(out, `![](local://test/`) { + t.Fatalf("spaced filename path was not replaced: %s", out) + } +} + +func TestResolveAndStoreMarkdownImageTitleContainingRightParen(t *testing.T) { + png := createTestPNG(200, 150) + result := &types.ReadResult{ + MarkdownContent: `![图片](images/test.png "阶段 1) 图片")`, + ImageRefs: []types.ImageRef{ + { + Filename: "test.png", + OriginalRef: "images/test.png", + MimeType: "image/png", + ImageData: png, + }, + }, + } + + svc := &captureSaveBytes{} + r := NewImageResolver() + out, imgs, err := r.ResolveAndStore(context.Background(), result, svc, 1) + if err != nil { + t.Fatal(err) + } + if len(imgs) != 1 || len(svc.saved) != 1 { + t.Fatalf("expected one stored image, got imgs=%d saved=%d", len(imgs), len(svc.saved)) + } + if !strings.Contains(out, `![图片](local://test/`) || !strings.Contains(out, ` "阶段 1) 图片")`) { + t.Fatalf("right-paren title was not preserved around stored URL: %s", out) + } +} + +func TestResolveAndStoreMarkdownImageWithMultilineTitle(t *testing.T) { + png := createTestPNG(200, 150) + result := &types.ReadResult{ + MarkdownContent: "![图片](images/test.png\n \"图片说明\")", + ImageRefs: []types.ImageRef{ + { + Filename: "test.png", + OriginalRef: "images/test.png", + MimeType: "image/png", + ImageData: png, + }, + }, + } + + svc := &captureSaveBytes{} + r := NewImageResolver() + out, imgs, err := r.ResolveAndStore(context.Background(), result, svc, 1) + if err != nil { + t.Fatal(err) + } + if len(imgs) != 1 || len(svc.saved) != 1 { + t.Fatalf("expected one stored image, got imgs=%d saved=%d", len(imgs), len(svc.saved)) + } + if !strings.Contains(out, "![图片](local://test/") || !strings.Contains(out, "\n \"图片说明\")") { + t.Fatalf("multiline title was not preserved around stored URL: %s", out) + } +} + +func TestResolveAndStoreMarkdownImageWithAngleDestination(t *testing.T) { + png := createTestPNG(200, 150) + result := &types.ReadResult{ + MarkdownContent: `![图片]( "阶段 1) 图片")`, + ImageRefs: []types.ImageRef{ + { + Filename: "第 1 页 (测试).png", + OriginalRef: "images/第 1 页 (测试).png", + MimeType: "image/png", + ImageData: png, + }, + }, + } + + svc := &captureSaveBytes{} + r := NewImageResolver() + out, imgs, err := r.ResolveAndStore(context.Background(), result, svc, 1) + if err != nil { + t.Fatal(err) + } + if len(imgs) != 1 || len(svc.saved) != 1 { + t.Fatalf("expected one stored image, got imgs=%d saved=%d", len(imgs), len(svc.saved)) + } + if !strings.Contains(out, `![图片]( "阶段 1) 图片")`) { + t.Fatalf("angle destination wrapper or title was not preserved: %s", out) + } +} + +func TestResolveAndStoreDuplicateReferencesStoreOnce(t *testing.T) { + png := createTestPNG(200, 150) + result := &types.ReadResult{ + MarkdownContent: strings.Join([]string{ + `![图片](images/test.png "第一处")`, + `![图片](images/test.png "第二处")`, + }, "\n\n"), + ImageRefs: []types.ImageRef{ + { + Filename: "test.png", + OriginalRef: "images/test.png", + MimeType: "image/png", + ImageData: png, + }, + }, + } + + svc := &captureSaveBytes{} + r := NewImageResolver() + out, imgs, err := r.ResolveAndStore(context.Background(), result, svc, 1) + if err != nil { + t.Fatal(err) + } + if len(svc.saved) != 1 { + t.Fatalf("expected duplicate references to save once but got %d", len(svc.saved)) + } + if len(imgs) != 1 { + t.Fatalf("expected one stored image record but got %d", len(imgs)) + } + if strings.Count(out, "local://test/") != 2 { + t.Fatalf("expected both markdown references to be replaced: %s", out) + } + if !strings.Contains(out, `"第一处"`) || !strings.Contains(out, `"第二处"`) { + t.Fatalf("duplicate reference titles were not preserved: %s", out) + } +} + +func TestResolveAndStoreUnknownReferenceUnchanged(t *testing.T) { + png := createTestPNG(200, 150) + result := &types.ReadResult{ + MarkdownContent: `![图片](images/missing.png "图片")`, + ImageRefs: []types.ImageRef{ + { + Filename: "test.png", + OriginalRef: "images/test.png", + MimeType: "image/png", + ImageData: png, + }, + }, + } + + svc := &captureSaveBytes{} + r := NewImageResolver() + out, imgs, err := r.ResolveAndStore(context.Background(), result, svc, 1) + if err != nil { + t.Fatal(err) + } + if len(svc.saved) != 0 || len(imgs) != 0 { + t.Fatalf("unknown reference should not be saved, got imgs=%d saved=%d", len(imgs), len(svc.saved)) + } + if out != result.MarkdownContent { + t.Fatalf("unknown reference changed: %s", out) + } +} + +func TestResolveAndStoreSharedMHTMLContract(t *testing.T) { + type contractImage struct { + Filename string `json:"filename"` + OriginalRef string `json:"original_ref"` + MimeType string `json:"mime_type"` + ImageDataBase64 string `json:"image_data_base64"` + } + var contract struct { + MarkdownContent string `json:"markdown_content"` + Images []contractImage `json:"images"` + } + + contractPath := filepath.Join("..", "..", "..", "testdata", "mhtml", "titled-image-contract.json") + raw, err := os.ReadFile(contractPath) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &contract); err != nil { + t.Fatal(err) + } + if len(contract.Images) != 1 { + t.Fatalf("expected one contract image but got %d", len(contract.Images)) + } + + imageContract := contract.Images[0] + imageData, err := base64.StdEncoding.DecodeString(imageContract.ImageDataBase64) + if err != nil { + t.Fatal(err) + } + result := &types.ReadResult{ + MarkdownContent: contract.MarkdownContent, + ImageRefs: []types.ImageRef{ + { + Filename: imageContract.Filename, + OriginalRef: imageContract.OriginalRef, + MimeType: imageContract.MimeType, + ImageData: imageData, + }, + }, + } + + svc := &captureSaveBytes{} + r := NewImageResolver() + out, imgs, err := r.ResolveAndStore(context.Background(), result, svc, 1) + if err != nil { + t.Fatal(err) + } + if len(svc.saved) != 1 { + t.Fatalf("expected SaveBytes once but got %d", len(svc.saved)) + } + if !bytes.Equal(svc.saved[0], imageData) { + t.Fatal("stored image bytes do not match contract fixture") + } + if len(imgs) != 1 { + t.Fatalf("expected one stored image record but got %d", len(imgs)) + } + if imgs[0].OriginalRef != imageContract.OriginalRef || imgs[0].MimeType != imageContract.MimeType { + t.Fatalf("unexpected stored image metadata: %#v", imgs[0]) + } + if strings.Contains(out, imageContract.OriginalRef) { + t.Fatalf("original image path remained in output: %s", out) + } + if !strings.Contains(out, `![图片](local://test/`) { + t.Fatalf("stored URL missing from markdown: %s", out) + } + if !strings.Contains(out, `"阶段 1) 图片"`) { + t.Fatalf("image title was not preserved: %s", out) + } + if !strings.Contains(out, "| 赛季制建立 | BP、Rank |\n\n![图片](local://test/") { + t.Fatalf("table-to-image block boundary changed: %s", out) + } + if !strings.Contains(out, `"阶段 1) 图片")`+"\n\n高机动性身法与独特枪械反馈") { + t.Fatalf("image-to-body block boundary changed: %s", out) + } + if !strings.Contains(out, "alpha \nbeta") { + t.Fatalf("markdown hard break changed: %s", out) + } + if !strings.Contains(out, "```\nline1\n\n\nline2\n```") { + t.Fatalf("fenced code blank lines changed: %s", out) + } +} diff --git a/internal/infrastructure/docparser/markdown_image_scanner.go b/internal/infrastructure/docparser/markdown_image_scanner.go new file mode 100644 index 00000000..d36d6fcb --- /dev/null +++ b/internal/infrastructure/docparser/markdown_image_scanner.go @@ -0,0 +1,262 @@ +package docparser + +import ( + "strings" + + "github.com/Tencent/WeKnora/internal/types" +) + +type markdownImageTargetSpan struct { + TargetStart int + TargetEnd int +} + +func scanMarkdownImageTargets(markdown string) []markdownImageTargetSpan { + var spans []markdownImageTargetSpan + for i := 0; i+1 < len(markdown); i++ { + if markdown[i] != '!' || markdown[i+1] != '[' || isEscaped(markdown, i) { + continue + } + + altEnd := findMarkdownImageAltEnd(markdown, i+2) + if altEnd == -1 { + continue + } + + targetStart := altEnd + 2 + targetEnd, ok := findMarkdownImageTargetEnd(markdown, targetStart) + if !ok { + i = altEnd + continue + } + spans = append(spans, markdownImageTargetSpan{ + TargetStart: targetStart, + TargetEnd: targetEnd, + }) + i = targetEnd + } + return spans +} + +func findMarkdownImageAltEnd(markdown string, start int) int { + for i := start; i+1 < len(markdown); i++ { + if markdown[i] == ']' && markdown[i+1] == '(' && !isEscaped(markdown, i) { + return i + } + } + return -1 +} + +func findMarkdownImageTargetEnd(markdown string, start int) (int, bool) { + parenDepth := 1 + inAngleDestination := false + seenNonSpace := false + var inQuote byte + + for i := start; i < len(markdown); i++ { + ch := markdown[i] + if ch == '\\' { + i++ + continue + } + + if !seenNonSpace && !isMarkdownSpace(ch) { + seenNonSpace = true + if ch == '<' { + inAngleDestination = true + continue + } + } + + if inAngleDestination { + if ch == '>' { + inAngleDestination = false + } + continue + } + + if inQuote != 0 { + if ch == inQuote { + inQuote = 0 + } + continue + } + + if (ch == '"' || ch == '\'') && i > start && isMarkdownSpace(markdown[i-1]) { + inQuote = ch + continue + } + + switch ch { + case '(': + parenDepth++ + case ')': + parenDepth-- + if parenDepth == 0 { + return i, true + } + } + } + return 0, false +} + +func splitMarkdownImageTarget( + raw string, + refMap map[string]types.ImageRef, +) (path string, pathStart int, pathEnd int, ok bool) { + start, end := trimMarkdownSpaceBounds(raw, 0, len(raw)) + if start >= end { + return "", 0, 0, false + } + + trimmed := raw[start:end] + if _, found := refMap[trimmed]; found { + return trimmed, start, end, true + } + + if raw[start] == '<' { + return splitAngleMarkdownImageTarget(raw, start, end, refMap) + } + + titleStart, found := parseMarkdownImageTitleSuffix(trimmed) + if !found { + return "", 0, 0, false + } + + pathEnd = start + titleStart + for pathEnd > start && isMarkdownSpace(raw[pathEnd-1]) { + pathEnd-- + } + if pathEnd == start { + return "", 0, 0, false + } + + path = raw[start:pathEnd] + if _, found := refMap[path]; !found { + return "", 0, 0, false + } + return path, start, pathEnd, true +} + +func splitAngleMarkdownImageTarget( + raw string, + start int, + end int, + refMap map[string]types.ImageRef, +) (path string, pathStart int, pathEnd int, ok bool) { + close := -1 + for i := start + 1; i < end; i++ { + if raw[i] == '>' && !isEscaped(raw, i) { + close = i + break + } + } + if close == -1 { + return "", 0, 0, false + } + + path = raw[start+1 : close] + if _, found := refMap[path]; !found { + return "", 0, 0, false + } + if !isEmptyOrMarkdownImageTitleSuffix(raw[close+1 : end]) { + return "", 0, 0, false + } + return path, start + 1, close, true +} + +func parseMarkdownImageTitleSuffix(raw string) (titleStart int, ok bool) { + _, end := trimMarkdownSpaceBounds(raw, 0, len(raw)) + if end == 0 { + return 0, false + } + + switch raw[end-1] { + case '"', '\'': + quote := raw[end-1] + for i := end - 2; i >= 0; i-- { + if raw[i] != quote || isEscaped(raw, i) { + continue + } + if i == 0 || !isMarkdownSpace(raw[i-1]) { + return 0, false + } + if markdownTitleHasBlankLine(raw[i+1 : end-1]) { + return 0, false + } + return i, true + } + case ')': + depth := 0 + for i := end - 2; i >= 0; i-- { + if isEscaped(raw, i) { + continue + } + switch raw[i] { + case ')': + depth++ + case '(': + if depth == 0 { + if i == 0 || !isMarkdownSpace(raw[i-1]) { + return 0, false + } + if markdownTitleHasBlankLine(raw[i+1 : end-1]) { + return 0, false + } + return i, true + } + depth-- + } + } + } + return 0, false +} + +func isEmptyOrMarkdownImageTitleSuffix(raw string) bool { + start, end := trimMarkdownSpaceBounds(raw, 0, len(raw)) + if start == end { + return true + } + titleStart, ok := parseMarkdownImageTitleSuffix(raw[:end]) + return ok && isAllMarkdownSpace(raw[:titleStart]) +} + +func markdownTitleHasBlankLine(title string) bool { + for _, line := range strings.Split(title, "\n") { + if strings.Trim(line, " \t") == "" { + return true + } + } + return false +} + +func isAllMarkdownSpace(raw string) bool { + for i := 0; i < len(raw); i++ { + if !isMarkdownSpace(raw[i]) { + return false + } + } + return true +} + +func trimMarkdownSpaceBounds(raw string, start int, end int) (int, int) { + for start < end && isMarkdownSpace(raw[start]) { + start++ + } + for end > start && isMarkdownSpace(raw[end-1]) { + end-- + } + return start, end +} + +func isMarkdownSpace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} + +func isEscaped(s string, pos int) bool { + backslashes := 0 + for i := pos - 1; i >= 0 && s[i] == '\\'; i-- { + backslashes++ + } + return backslashes%2 == 1 +} diff --git a/internal/infrastructure/docparser/markdown_image_scanner_test.go b/internal/infrastructure/docparser/markdown_image_scanner_test.go new file mode 100644 index 00000000..6f3b83dc --- /dev/null +++ b/internal/infrastructure/docparser/markdown_image_scanner_test.go @@ -0,0 +1,203 @@ +package docparser + +import ( + "strings" + "testing" + + "github.com/Tencent/WeKnora/internal/types" +) + +func TestScanMarkdownImageTargets(t *testing.T) { + tests := []struct { + name string + input string + targets []string + }{ + { + name: "without title", + input: `before ![a](images/a.png) after`, + targets: []string{`images/a.png`}, + }, + { + name: "double quoted title", + input: `![a](images/a.png "图")`, + targets: []string{`images/a.png "图"`}, + }, + { + name: "single quoted title", + input: `![a](images/a.png '图')`, + targets: []string{`images/a.png '图'`}, + }, + { + name: "parenthesized title", + input: `![a](images/a.png (图))`, + targets: []string{`images/a.png (图)`}, + }, + { + name: "title containing right paren", + input: `![a](images/a.png "阶段 1) 结果")`, + targets: []string{`images/a.png "阶段 1) 结果"`}, + }, + { + name: "title containing both parens", + input: `![a](images/a.png '阶段 (1) 结果')`, + targets: []string{`images/a.png '阶段 (1) 结果'`}, + }, + { + name: "escaped quote in title", + input: `![a](images/a.png "阶段 \"1\"")`, + targets: []string{`images/a.png "阶段 \"1\""`}, + }, + { + name: "multiline title", + input: "![a](images/a.png\n \"多行 title\")", + targets: []string{"images/a.png\n \"多行 title\""}, + }, + { + name: "spaced path", + input: `![a](images/第 1 页.png)`, + targets: []string{`images/第 1 页.png`}, + }, + { + name: "path containing balanced parens", + input: `![a](images/a_(1).png "title")`, + targets: []string{`images/a_(1).png "title"`}, + }, + { + name: "angle destination", + input: `![a]( "title")`, + targets: []string{` "title"`}, + }, + { + name: "malformed image is skipped", + input: `![a](images/a.png "unterminated title) after ![b](images/b.png)`, + targets: []string{`images/b.png`}, + }, + { + name: "escaped image marker is skipped", + input: `\![a](images/a.png) ![b](images/b.png)`, + targets: []string{`images/b.png`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spans := scanMarkdownImageTargets(tt.input) + if len(spans) != len(tt.targets) { + t.Fatalf("got %d spans, want %d: %#v", len(spans), len(tt.targets), spans) + } + for i, span := range spans { + got := tt.input[span.TargetStart:span.TargetEnd] + if got != tt.targets[i] { + t.Fatalf("target %d = %q, want %q", i, got, tt.targets[i]) + } + } + }) + } +} + +func TestSplitMarkdownImageTarget(t *testing.T) { + refMap := map[string]types.ImageRef{ + "images/a.png": {OriginalRef: "images/a.png"}, + "images/第 1 页.png": {OriginalRef: "images/第 1 页.png"}, + "images/a_(1).png": {OriginalRef: "images/a_(1).png"}, + "images/第 1 页 (测试).gif": {OriginalRef: "images/第 1 页 (测试).gif"}, + } + + tests := []struct { + name string + raw string + wantPath string + wantText string + wantOK bool + }{ + { + name: "without title", + raw: `images/a.png`, + wantPath: `images/a.png`, + wantText: `local://stored`, + wantOK: true, + }, + { + name: "double quoted title", + raw: `images/a.png "图"`, + wantPath: `images/a.png`, + wantText: `local://stored "图"`, + wantOK: true, + }, + { + name: "single quoted title", + raw: `images/a.png '图'`, + wantPath: `images/a.png`, + wantText: `local://stored '图'`, + wantOK: true, + }, + { + name: "parenthesized title", + raw: `images/a.png (阶段 (1) 结果)`, + wantPath: `images/a.png`, + wantText: `local://stored (阶段 (1) 结果)`, + wantOK: true, + }, + { + name: "multiline title", + raw: "images/a.png\n \"多行 title\"", + wantPath: `images/a.png`, + wantText: "local://stored\n \"多行 title\"", + wantOK: true, + }, + { + name: "path with spaces wins without title", + raw: `images/第 1 页.png`, + wantPath: `images/第 1 页.png`, + wantText: `local://stored`, + wantOK: true, + }, + { + name: "path with balanced parens", + raw: `images/a_(1).png "title"`, + wantPath: `images/a_(1).png`, + wantText: `local://stored "title"`, + wantOK: true, + }, + { + name: "angle destination replaces only inner path", + raw: ` "阶段 1) 图片"`, + wantPath: `images/第 1 页 (测试).gif`, + wantText: ` "阶段 1) 图片"`, + wantOK: true, + }, + { + name: "unknown reference", + raw: `images/missing.png "title"`, + wantOK: false, + }, + { + name: "blank line in title is invalid", + raw: "images/a.png \"line1\n\nline2\"", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path, pathStart, pathEnd, ok := splitMarkdownImageTarget(tt.raw, refMap) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + } + if !ok { + return + } + if path != tt.wantPath { + t.Fatalf("path = %q, want %q", path, tt.wantPath) + } + replaced := tt.raw[:pathStart] + "local://stored" + tt.raw[pathEnd:] + if replaced != tt.wantText { + t.Fatalf("replacement = %q, want %q", replaced, tt.wantText) + } + if strings.Contains(replaced, tt.wantPath) { + t.Fatalf("replacement still contains original path: %q", replaced) + } + }) + } +} diff --git a/testdata/mhtml/titled-image-contract.json b/testdata/mhtml/titled-image-contract.json new file mode 100644 index 00000000..96b5f357 --- /dev/null +++ b/testdata/mhtml/titled-image-contract.json @@ -0,0 +1,11 @@ +{ + "markdown_content": "| 体验方向 | 代表内容 |\n| --- | --- |\n| 赛季制建立 | BP、Rank |\n\n![图片](images/第 1 页 (测试).gif \"阶段 1) 图片\")\n\n高机动性身法与独特枪械反馈\n\nalpha \nbeta\n\n* parent\n + child\n\n> quoted\n\n```\nline1\n\n\nline2\n```", + "images": [ + { + "filename": "第 1 页 (测试).gif", + "original_ref": "images/第 1 页 (测试).gif", + "mime_type": "image/gif", + "image_data_base64": "R0lGODdhQABAAIEAAFCM3AAAAAAAAAAAACwAAAAAQABAAEAIaQABCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzatzIsaPHjyBDihxJsqTJkyhTqlzJsqXLlzBjypxJs6bNmzhz6tzJs6fPn0CDCh1KtKjRo0iTKl3KtKnTp1CjSp1KtarVq1izagUQEAA7" + } + ] +} diff --git a/testdata/mhtml/titled-image.mhtml b/testdata/mhtml/titled-image.mhtml new file mode 100644 index 00000000..1f66c14c --- /dev/null +++ b/testdata/mhtml/titled-image.mhtml @@ -0,0 +1,48 @@ +MIME-Version: 1.0 +Subject: Titled Image Contract +Content-Type: multipart/related; type="text/html"; boundary="----=_NextPart_Contract" + +------=_NextPart_Contract +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit +Content-Location: https://example.com/titled-image + + + + + + +
体验方向代表内容
赛季制建立BP、Rank
+ +

图片

+ +

高机动性身法与独特枪械反馈

+ +

alpha
beta

+ +




+ +
    +
  • parent +
    • child
    +
  • +
+ +

quoted

+ +
line1
+
+
+line2
+
+ + + +------=_NextPart_Contract +Content-Type: image/gif +Content-Transfer-Encoding: base64 +Content-Location: images/%E7%AC%AC%201%20%E9%A1%B5%20%28%E6%B5%8B%E8%AF%95%29.gif + +R0lGODdhQABAAIEAAFCM3AAAAAAAAAAAACwAAAAAQABAAEAIaQABCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzatzIsaPHjyBDihxJsqTJkyhTqlzJsqXLlzBjypxJs6bNmzhz6tzJs6fPn0CDCh1KtKjRo0iTKl3KtKnTp1CjSp1KtarVq1izagUQEAA7 + +------=_NextPart_Contract--