diff --git a/internal/api/export_test.go b/internal/api/export_test.go index 967da01..8d52bef 100644 --- a/internal/api/export_test.go +++ b/internal/api/export_test.go @@ -85,3 +85,86 @@ func TestReadTokenMayExportButCannotWrite(t *testing.T) { t.Fatalf("只读令牌获得写权限:%d", code) } } + +func TestExportDoesNotTreatTextAndFootnotesAsAssets(t *testing.T) { + for _, content := range []string{ + "普通文字 ](private.png)\n", + "正文[^1]\n\n[^1]: private.png\n", + "\\[转义文字](private.png)\n", + "[未闭合链接](private.png\n", + "`[代码](private.png)`\n", + "正文[^1]\n\n[^1]: 这是脚注内容。\n", + } { + t.Run(content, func(t *testing.T) { + f := newFixture(t) + f.write("文章.md", content) + if err := os.WriteFile(filepath.Join(f.root, "private.png"), []byte("未选中的私有文件"), 0644); err != nil { + t.Fatal(err) + } + response := f.do(f.admin, "POST", "/api/v1/export", `{"paths":["文章.md"]}`, nil) + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != 200 || !strings.HasPrefix(response.Header.Get("Content-Type"), "text/markdown") || string(body) != content { + t.Fatalf("应只导出原文,不夹带无关文件:%d %s", response.StatusCode, body) + } + }) + } +} + +func TestExportIncludesAssetsInMultilineLinksAndContainers(t *testing.T) { + for _, content := range []string{ + "![图片](\nassets/image.png\n)\n", + "> ![图片](\n> assets/image.png\n> )\n", + "> ![图片][pic]\n>\n> [pic]: assets/image.png\n", + "> > ![图片][pic]\n> >\n> > [pic]: assets/image.png\n", + "- ![图片][pic]\n\n [pic]: assets/image.png\n", + "![图片][pic]\n\n[pic]:\n assets/image.png \"说明\"\n", + "正文[^1]\n\n[^1]: ![图片](assets/image.png)\n", + "![图片][pic]\n\n[pic]: assets/image.png \"[不是链接](private.png)\"\n", + "![图片][pic]\n\n[pic]: assets/image.png\n \"[不是链接](private.png)\"\n", + "![图片][pic]\n\n[pic]: assets/image.png \"说明\n[fake]: private.png\n\"\n", + } { + t.Run(content, func(t *testing.T) { + f := newFixture(t) + f.write("文章.md", content) + if err := os.MkdirAll(filepath.Join(f.root, "assets"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(f.root, "assets/image.png"), []byte("图片内容"), 0644); err != nil { + t.Fatal(err) + } + response := f.do(f.admin, "POST", "/api/v1/export", `{"paths":["文章.md"]}`, nil) + defer response.Body.Close() + data, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != 200 { + t.Fatalf("导出失败:%d %s", response.StatusCode, data) + } + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + t.Fatal("附件不应被漏收:", err) + } + files := map[string]string{} + for _, file := range reader.File { + r, err := file.Open() + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(r) + r.Close() + if err != nil { + t.Fatal(err) + } + files[file.Name] = string(body) + } + if len(files) != 2 || files["文章.md"] != content || files["assets/image.png"] != "图片内容" { + t.Fatalf("原文或附件不符:%v", files) + } + }) + } +} diff --git a/internal/vault/export.go b/internal/vault/export.go index a9c40bd..9ef335a 100644 --- a/internal/vault/export.go +++ b/internal/vault/export.go @@ -61,49 +61,34 @@ func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, na if e != nil { return nil, "", e } - var linkError error - transformMarkdownLines(content, importRef, func(line []byte) []byte { - if linkError != nil { - return line + for _, span := range importLinkSpans(content) { + target := string(content[span.start:span.end]) + if span.html { + target = html.UnescapeString(target) + } else { + target = markdownEscape.ReplaceAllString(target, "$1") + } + u, e := url.Parse(target) + if e != nil { + return nil, "", fmt.Errorf("%w:附件地址无效 %s", ErrInvalidPath, target) + } + if u.IsAbs() || u.Host != "" || strings.HasPrefix(target, "/") || u.Path == "" { + continue + } + rel := path.Clean(path.Join(path.Dir(note), u.Path)) + if IsNote(rel) { + continue + } + if e = v.exportPath(rel); e != nil { + return nil, "", fmt.Errorf("附件 %s:%w", rel, e) + } + info, e := v.Stat(rel) + if e != nil { + return nil, "", fmt.Errorf("附件 %s:%w", rel, e) + } + if !info.IsDir() { + files[rel] = false } - return transformOutsideCode(line, func(text []byte) []byte { - for _, span := range importLinkSpans(text) { - target := string(text[span.start:span.end]) - if span.html { - target = html.UnescapeString(target) - } else { - target = markdownEscape.ReplaceAllString(target, "$1") - } - u, e := url.Parse(target) - if e != nil { - linkError = fmt.Errorf("%w:附件地址无效 %s", ErrInvalidPath, target) - break - } - if u.IsAbs() || u.Host != "" || strings.HasPrefix(target, "/") || u.Path == "" { - continue - } - rel := path.Clean(path.Join(path.Dir(note), u.Path)) - if IsNote(rel) { - continue - } - if e = v.exportPath(rel); e != nil { - linkError = fmt.Errorf("附件 %s:%w", rel, e) - break - } - info, e := v.Stat(rel) - if e != nil { - linkError = fmt.Errorf("附件 %s:%w", rel, e) - break - } - if !info.IsDir() { - files[rel] = false - } - } - return text - }) - }) - if linkError != nil { - return nil, "", linkError } } tmp, e := os.CreateTemp("", "rawmark-export-*") diff --git a/internal/vault/images.go b/internal/vault/images.go index f3f1c09..10b35bc 100644 --- a/internal/vault/images.go +++ b/internal/vault/images.go @@ -11,7 +11,6 @@ import ( "os" "path" "path/filepath" - "sort" "strings" ) @@ -86,40 +85,36 @@ func rewriteImageLinks(content []byte, note, from, to string) ([]byte, error) { if err != nil { return nil, err } - transform := func(text []byte) []byte { - spans := importLinkSpans(text) - sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start }) - var out []byte - last := 0 - for _, span := range spans { - if span.start < last { - continue - } - target := string(text[span.start:span.end]) - if span.html { - target = html.UnescapeString(target) - } else { - target = markdownEscape.ReplaceAllString(target, "$1") - } - u, err := url.Parse(target) - if err != nil || u.IsAbs() || u.Host != "" || strings.HasPrefix(target, "/") || u.Path == "" || path.Clean(path.Join(path.Dir(note), u.Path)) != from { - continue - } - replacement := EscapeLinkPath(filepath.ToSlash(rel)) - if u.RawQuery != "" { - replacement += "?" + u.RawQuery - } - if u.Fragment != "" { - replacement += "#" + u.EscapedFragment() - } - if span.html { - replacement = html.EscapeString(replacement) - } - out = append(out, text[last:span.start]...) - out = append(out, replacement...) - last = span.end + spans := importLinkSpans(content) + var out []byte + last := 0 + for _, span := range spans { + if span.start < last { + continue } - return append(out, text[last:]...) + target := string(content[span.start:span.end]) + if span.html { + target = html.UnescapeString(target) + } else { + target = markdownEscape.ReplaceAllString(target, "$1") + } + u, err := url.Parse(target) + if err != nil || u.IsAbs() || u.Host != "" || strings.HasPrefix(target, "/") || u.Path == "" || path.Clean(path.Join(path.Dir(note), u.Path)) != from { + continue + } + replacement := EscapeLinkPath(filepath.ToSlash(rel)) + if u.RawQuery != "" { + replacement += "?" + u.RawQuery + } + if u.Fragment != "" { + replacement += "#" + u.EscapedFragment() + } + if span.html { + replacement = html.EscapeString(replacement) + } + out = append(out, content[last:span.start]...) + out = append(out, replacement...) + last = span.end } - return transformMarkdownLines(content, importRef, func(line []byte) []byte { return transformOutsideCode(line, transform) }), nil + return append(out, content[last:]...), nil } diff --git a/internal/vault/images_test.go b/internal/vault/images_test.go index d51ac8f..fb01c8f 100644 --- a/internal/vault/images_test.go +++ b/internal/vault/images_test.go @@ -53,6 +53,29 @@ func TestImageMoveUpdatesLocalReferences(t *testing.T) { f.Close() } +func TestImageMovePreservesMultilineMarkdownAndIgnoresLiteralPaths(t *testing.T) { + v := newVault(t) + if err := os.WriteFile(filepath.Join(v.Root(), "old.png"), []byte("image"), 0o644); err != nil { + t.Fatal(err) + } + content := "> ![图片][pic]\r\n>\r\n> [pic]: old.png \"[示例](old.png)\"\r\n\n![跨行](\nold.png\n)\n\n普通文字 ](old.png)\n\n[^1]: old.png\n\n`![代码](old.png)`\n\n> ```md\n> ![代码块](old.png)\n> ```\n" + if _, err := v.Write("文章.md", []byte(content), Precondition{}); err != nil { + t.Fatal(err) + } + if _, err := v.Move("old.png", "new.png", nil); err != nil { + t.Fatal(err) + } + body, _, err := v.Read("文章.md") + if err != nil { + t.Fatal(err) + } + want := strings.Replace(content, "[pic]: old.png", "[pic]: new.png", 1) + want = strings.Replace(want, "![跨行](\nold.png", "![跨行](\nnew.png", 1) + if string(body) != want { + t.Fatalf("应只改写真正的图片引用:\n%s\n期望:\n%s", body, want) + } +} + func TestReplaceImagePreservesPathAndUploadContents(t *testing.T) { v := newVault(t) link, err := v.SaveAsset("a.md", "old.png", strings.NewReader("original"), 100) diff --git a/internal/vault/import_links.go b/internal/vault/import_links.go index 98e1366..0bc8058 100644 --- a/internal/vault/import_links.go +++ b/internal/vault/import_links.go @@ -15,7 +15,7 @@ import ( "strings" ) -var importRef = regexp.MustCompile(`^ {0,3}\[[^\]\r\n]+\]:[ \t]*`) +var importRef = regexp.MustCompile(`(?m)^ {0,3}\[[^\[\]\r\n]+\]:[ \t]*`) var importSrc = regexp.MustCompile(`(?i)<(?:img|source|video|audio)\b[^>]*?\bsrc\s*=\s*["']([^"']*)["']`) var markdownEscape = regexp.MustCompile("\\\\([!\"#$%&'()*+,\\-./:;<=>?@\\[\\]\\\\^_`{|}~])") @@ -24,186 +24,298 @@ type importLink struct { html bool } -// linkDestination 返回目的地址本身的区间,不含尖括号、标题或闭合括号。 -func linkDestination(text []byte, start int) (int, int) { - for start < len(text) && (text[start] == ' ' || text[start] == '\t') { - start++ +// 链接中的空白允许一次换行,但不能跨过段落。 +func linkSpace(text []byte, pos int) int { + newline := false + for pos < len(text) { + switch text[pos] { + case ' ', '\t', '\r': + case '\n': + if newline { + return pos + } + newline = true + default: + return pos + } + pos++ } + return pos +} + +// linkDestination 返回地址区间和地址结束后的下标;地址本身不含尖括号。 +func linkDestination(text []byte, pos int) (start, end, next int) { + start = linkSpace(text, pos) if start == len(text) { - return start, start + return start, start, start } if text[start] == '<' { - end := start + 1 - for end < len(text) && text[end] != '>' && text[end] != '\n' && text[end] != '\r' { - if text[end] == '\\' && end+1 < len(text) { - end++ + for end = start + 1; end < len(text); end++ { + switch text[end] { + case '\\': + if end+1 < len(text) { + end++ + } + case '>': + return start + 1, end, end + 1 + case '<', '\n', '\r', 0: + return start, start, start } - end++ } - if end < len(text) && text[end] == '>' { - return start + 1, end - } - return start, start + return start, start, start } depth := 0 - end := start - for end < len(text) { + for end = start; end < len(text); end++ { c := text[end] if c == '\\' && end+1 < len(text) { - end += 2 + end++ continue } if c == '(' { depth++ - } - if c == ')' { + } else if c == ')' { if depth == 0 { break } depth-- - } - if c == ' ' || c == '\t' || c == '\n' || c == '\r' { + } else if c <= ' ' || c == '<' || c == 0x7f { break } - end++ } if depth != 0 { - return start, start + return start, start, start } - return start, end + return start, end, end } -func importLinkSpans(text []byte) []importLink { - var spans []importLink - if m := importRef.FindIndex(text); m != nil { - start, end := linkDestination(text, m[1]) - if end > start { - spans = append(spans, importLink{start, end, false}) - } +func linkTitleEnd(text []byte, pos int) int { + quote := text[pos] + close := quote + if quote == '(' { + close = ')' } - for pos := 0; pos < len(text); { - i := bytes.Index(text[pos:], []byte("](")) - if i < 0 { + for i := pos + 1; i < len(text); i++ { + if text[i] == '\\' && i+1 < len(text) { + i++ + } else if text[i] == close { + return i + 1 + } else if (quote == '(' && text[i] == '(') || (text[i] == '\n' && linkSpace(text, i) < len(text) && text[linkSpace(text, i)] == '\n') { break } - i += pos - // 转义的闭括号不是链接语法。 - escapes := 0 - for j := i - 1; j >= 0 && text[j] == '\\'; j-- { - escapes++ + } + return -1 +} + +// importLinkSpans 共用于导入、导出和图片改名;返回原文字节位置,不重新序列化正文。 +func importLinkSpans(content []byte) []importLink { + var text []byte + var source []int + lastEnd := 0 + transformMarkdownLines(content, importRef, func(line, body []byte, offset int) []byte { + if len(text) > 0 && offset != lastEnd { + text = append(text, '\n', '\n') + source = append(source, -1, -1) } - start, end := linkDestination(text, i+2) - if escapes%2 == 0 && end > start { - spans = append(spans, importLink{start, end, false}) + start := offset + len(line) - len(body) + text = append(text, body...) + for i := range body { + source = append(source, start+i) + } + lastEnd = offset + len(line) + return line + }) + // 行内代码可以跨行。遮住其中的标记,同时保留链接标签两侧的括号结构。 + masked := bytes.Repeat([]byte(" "), len(text)) + pos := 0 + for _, paragraph := range bytes.SplitAfter(text, []byte("\n\n")) { + transformOutsideCode(paragraph, func(part []byte, offset int) []byte { + copy(masked[pos+offset:], part) + return part + }) + pos += len(paragraph) + } + text = masked + var spans []importLink + add := func(start, end int, html bool) { + spans = append(spans, importLink{source[start], source[end-1] + 1, html}) + } + definitionEnd := 0 + for _, m := range importRef.FindAllIndex(text, -1) { + if m[0] < definitionEnd { + continue + } + label := bytes.TrimSpace(text[m[0]:m[1]]) + if bytes.HasPrefix(label, []byte("[^")) || len(bytes.TrimSpace(label[1:bytes.IndexByte(label, ']')])) == 0 { + continue + } + start, end, next := linkDestination(text, m[1]) + if end == start { + continue + } + titleStart := linkSpace(text, next) + if titleStart > next && titleStart < len(text) && (text[titleStart] == '"' || text[titleStart] == '\'' || text[titleStart] == '(') { + titleEnd := linkTitleEnd(text, titleStart) + if titleEnd >= 0 { + for titleEnd < len(text) && (text[titleEnd] == ' ' || text[titleEnd] == '\t' || text[titleEnd] == '\r') { + titleEnd++ + } + if titleEnd == len(text) || text[titleEnd] == '\n' { + next = titleEnd + } + } + } + for next < len(text) && (text[next] == ' ' || text[next] == '\t' || text[next] == '\r') { + next++ + } + if next < len(text) && text[next] != '\n' { + continue + } + add(start, end, false) + definitionEnd = next + // 引用定义及其标题不是行内正文,避免再次扫描标题中的示例标记。 + for i := m[0]; i < next; i++ { + if text[i] != '\n' && text[i] != '\r' { + text[i] = ' ' + } + } + } + var brackets []int + for i := 0; i < len(text); i++ { + switch text[i] { + case '\\': + i++ + case '\n': + if next := linkSpace(text, i); next < len(text) && text[next] == '\n' { + brackets = nil + } + case '[': + brackets = append(brackets, i) + case ']': + if len(brackets) == 0 { + continue + } + open := brackets[len(brackets)-1] + brackets = brackets[:len(brackets)-1] + if i+1 == len(text) || text[i+1] != '(' { + continue + } + start, end, next := linkDestination(text, i+2) + if end == start { + continue + } + close := linkSpace(text, next) + if close > next && close < len(text) && (text[close] == '"' || text[close] == '\'' || text[close] == '(') { + close = linkTitleEnd(text, close) + if close < 0 { + continue + } + close = linkSpace(text, close) + } + if close < len(text) && text[close] == ')' { + add(start, end, false) + i = close + if open == 0 || text[open-1] != '!' { + brackets = nil // 链接不能嵌套,图片可以位于链接标签内。 + } + } } - pos = max(i+2, end) } for _, m := range importSrc.FindAllSubmatchIndex(text, -1) { - spans = append(spans, importLink{m[2], m[3], true}) + if m[3] > m[2] { + add(m[2], m[3], true) + } } + sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start }) return spans } func (v *Vault) importLinks(ctx context.Context, note string, content []byte, total *int64) ([]byte, []string, error) { - var failure error missing := map[string]bool{} replacements := map[string]string{} - transform := func(text []byte) []byte { - spans := importLinkSpans(text) - // 区间可能来自 HTML 或 Markdown,按原始顺序替换,保持其他字节不变。 - sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start }) - var out []byte - last := 0 - for _, span := range spans { - if span.start < last || failure != nil { - continue - } - raw := string(text[span.start:span.end]) - target := raw - if span.html { - target = html.UnescapeString(target) - } else { - target = markdownEscape.ReplaceAllString(target, "$1") - } - u, err := url.Parse(target) - if err != nil || u.IsAbs() || u.Host != "" || strings.HasPrefix(target, "/") || u.Path == "" { - continue - } - rel := path.Clean(path.Join(path.Dir(note), u.Path)) - if _, err := CleanPath(rel); err != nil { - missing[rel] = true - continue - } - info, err := os.Stat(filepath.Join(v.root, filepath.FromSlash(rel))) - if os.IsNotExist(err) { - missing[rel] = true - continue - } - if err != nil { - failure = err - continue - } - if !info.Mode().IsRegular() { - continue - } - if !IsImage(rel) { - continue - } - replacement, found := replacements[rel] - if !found { - if err := ctx.Err(); err != nil { - failure = err - continue - } - data, err := os.ReadFile(filepath.Join(v.root, filepath.FromSlash(rel))) - if err != nil { - failure = err - continue - } - // 只统计新生成的副本;已有同名附件必须与哈希对应的内容一致。 - dest := filepath.Join(v.root, filepath.FromSlash(AssetsDir(note)), assetName(data, path.Base(rel))) - existing, readErr := os.ReadFile(dest) - if readErr != nil && !os.IsNotExist(readErr) { - failure = readErr - continue - } - if readErr == nil && !bytes.Equal(data, existing) { - failure = fmt.Errorf("%w:附件名称与内容冲突", ErrInvalidImport) - continue - } - if os.IsNotExist(readErr) { - if int64(len(data)) > maxImportExpanded-*total { - failure = ErrTooLarge - continue - } - *total += int64(len(data)) - } - replacement, err = v.SaveAsset(note, path.Base(rel), bytes.NewReader(data), importAssetLimit) - if err != nil { - failure = err - continue - } - replacements[rel] = replacement - } - if u.RawQuery != "" { - replacement += "?" + u.RawQuery - } - if u.Fragment != "" { - replacement += "#" + u.EscapedFragment() - } - if span.html { - replacement = html.EscapeString(replacement) - } - out = append(out, text[last:span.start]...) - out = append(out, replacement...) - last = span.end + spans := importLinkSpans(content) + var out []byte + last := 0 + for _, span := range spans { + if span.start < last { + continue } - return append(out, text[last:]...) + raw := string(content[span.start:span.end]) + target := raw + if span.html { + target = html.UnescapeString(target) + } else { + target = markdownEscape.ReplaceAllString(target, "$1") + } + u, err := url.Parse(target) + if err != nil || u.IsAbs() || u.Host != "" || strings.HasPrefix(target, "/") || u.Path == "" { + continue + } + rel := path.Clean(path.Join(path.Dir(note), u.Path)) + if _, err := CleanPath(rel); err != nil { + missing[rel] = true + continue + } + info, err := os.Stat(filepath.Join(v.root, filepath.FromSlash(rel))) + if os.IsNotExist(err) { + missing[rel] = true + continue + } + if err != nil { + return nil, nil, err + } + if !info.Mode().IsRegular() { + continue + } + if !IsImage(rel) { + continue + } + replacement, found := replacements[rel] + if !found { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + data, err := os.ReadFile(filepath.Join(v.root, filepath.FromSlash(rel))) + if err != nil { + return nil, nil, err + } + // 只统计新生成的副本;已有同名附件必须与哈希对应的内容一致。 + dest := filepath.Join(v.root, filepath.FromSlash(AssetsDir(note)), assetName(data, path.Base(rel))) + existing, readErr := os.ReadFile(dest) + if readErr != nil && !os.IsNotExist(readErr) { + return nil, nil, readErr + } + if readErr == nil && !bytes.Equal(data, existing) { + return nil, nil, fmt.Errorf("%w:附件名称与内容冲突", ErrInvalidImport) + } + if os.IsNotExist(readErr) { + if int64(len(data)) > maxImportExpanded-*total { + return nil, nil, ErrTooLarge + } + *total += int64(len(data)) + } + replacement, err = v.SaveAsset(note, path.Base(rel), bytes.NewReader(data), importAssetLimit) + if err != nil { + return nil, nil, err + } + replacements[rel] = replacement + } + if u.RawQuery != "" { + replacement += "?" + u.RawQuery + } + if u.Fragment != "" { + replacement += "#" + u.EscapedFragment() + } + if span.html { + replacement = html.EscapeString(replacement) + } + out = append(out, content[last:span.start]...) + out = append(out, replacement...) + last = span.end } - updated := transformMarkdownLines(content, importRef, func(line []byte) []byte { return transformOutsideCode(line, transform) }) + updated := append(out, content[last:]...) warnings := []string{} if len(missing) > 0 { warnings = append(warnings, note+":"+strconv.Itoa(len(missing))+" 个本地链接的目标不在导入内容中,已保留原链接") } - return updated, warnings, failure + return updated, warnings, nil } diff --git a/internal/vault/import_test.go b/internal/vault/import_test.go index e57c940..994f7d2 100644 --- a/internal/vault/import_test.go +++ b/internal/vault/import_test.go @@ -89,6 +89,33 @@ func TestImportZipAssetsAndMove(t *testing.T) { } } +func TestImportPreservesContainersAndOnlyRewritesActualLinks(t *testing.T) { + v := newVault(t) + content := "> ![图片][pic]\r\n>\r\n> [pic]: assets/image.png \"[示例](private.png)\"\r\n\n![跨行](\nassets/image.png\n)\n\n普通文字 ](private.png)\n\n[^1]: missing.png\n\n`![代码](assets/image.png)`\n" + source := importZip(t, + zipEntry{name: "文章.md", content: content}, + zipEntry{name: "assets/image.png", content: "image"}, + zipEntry{name: "private.png", content: "private"}, + ) + result, err := v.Import(context.Background(), "", "导入", []ImportFile{source}) + if err != nil { + t.Fatal(err) + } + if result.Notes != 1 || result.Assets != 3 || len(result.Warnings) != 0 { + t.Fatalf("仅真实图片应生成附件副本,脚注正文不应产生缺失警告:%+v", result) + } + body, _, err := v.Read("导入/文章.md") + if err != nil { + t.Fatal(err) + } + link := EscapeLinkPath("文章.assets") + "/" + assetName([]byte("image"), "x.png") + want := strings.Replace(content, "[pic]: assets/image.png", "[pic]: "+link, 1) + want = strings.Replace(want, "![跨行](\nassets/image.png", "![跨行](\n"+link, 1) + if string(body) != want { + t.Fatalf("应只改写真实链接并保留原文格式:\n%s\n期望:\n%s", body, want) + } +} + func TestImportRejectsInvalidWithoutPublishing(t *testing.T) { for _, tc := range []struct { name string diff --git a/internal/vault/links.go b/internal/vault/links.go index beeea7e..cd22aeb 100644 --- a/internal/vault/links.go +++ b/internal/vault/links.go @@ -16,21 +16,25 @@ func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte { refDef := regexp.MustCompile(`^( {0,3}\[[^\]]+\]:\s* 0 { start := bytes.IndexByte(line, '`') if start < 0 { @@ -185,9 +200,10 @@ func transformOutsideCode(line []byte, transform func([]byte) []byte) []byte { break } codeEnd := start + n + end + n - out = append(out, transform(line[:start])...) + out = append(out, transform(line[:start], offset)...) out = append(out, line[start:codeEnd]...) line = line[codeEnd:] + offset += codeEnd } - return append(out, transform(line)...) + return append(out, transform(line, offset)...) }