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 <bushiyan@outlook.com>
This commit is contained in:
FFFFFFpy
2026-06-22 15:53:54 +08:00
committed by GitHub
parent 5f7ed2ffe9
commit a6a20175e5
8 changed files with 1112 additions and 16 deletions
+107 -6
View File
@@ -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."""
+147
View File
@@ -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(
"<html><body>"
"<table>"
"<tr><th>体验方向</th><th>代表内容</th></tr>"
"<tr><td>赛季制建立</td><td>BP、Rank</td></tr>"
"</table>"
'<img src="cid:test-image" alt="图片" title="图片">'
"<p>高机动性身法与独特枪械反馈</p>"
"</body></html>",
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"] = "<test-image>"
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(
"<ul><li>parent<ul><li>child</li></ul></li></ul>"
"<blockquote><p>quoted</p></blockquote>"
"<pre><code>line1\n\n indented\n</code></pre>"
)
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("<ul><li>parent<ul><li>child</li></ul></li></ul>")
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("<p>before</p><blockquote><p>quoted</p></blockquote><p>after</p>")
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("<pre><code>line1\n\n\nline2\n</code></pre>")
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("<p>alpha<br>beta</p>")
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)
@@ -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)
@@ -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: `![图片](<images/第 1 页 (测试).png> "阶段 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, `![图片](<local://test/`) || !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)
}
}
@@ -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
}
@@ -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](<images/a b.png> "title")`,
targets: []string{`<images/a b.png> "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: `<images/第 1 页 (测试).gif> "阶段 1) 图片"`,
wantPath: `images/第 1 页 (测试).gif`,
wantText: `<local://stored> "阶段 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)
}
})
}
}
+11
View File
@@ -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"
}
]
}
+48
View File
@@ -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
<html>
<body>
<table>
<tr><th>体验方向</th><th>代表内容</th></tr>
<tr><td>赛季制建立</td><td>BP、Rank</td></tr>
</table>
<p><img src="images/%E7%AC%AC%201%20%E9%A1%B5%20%28%E6%B5%8B%E8%AF%95%29.gif" alt="图片" title="阶段 1) 图片"></p>
<p>高机动性身法与独特枪械反馈</p>
<p>alpha<br>beta</p>
<p><br><br><br></p>
<ul>
<li>parent
<ul><li>child</li></ul>
</li>
</ul>
<blockquote><p>quoted</p></blockquote>
<pre><code>line1
line2
</code></pre>
</body>
</html>
------=_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--