From 1bbaf2f07a1c8f6f41e58c9f2948dc0e273fb3db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Fri, 18 Sep 2026 17:14:45 +0800 Subject: [PATCH] fix(outboundtext): keep resource handles intact when normalizing formulas --- .../channels/dingtalk/download_codes_test.go | 2 +- .../channels/dingtalk/image_publish_test.go | 2 +- .../dingtalk/outbound_content_test.go | 40 +++++++++++++++++++ internal/outboundtext/math.go | 26 ++++++------ internal/outboundtext/normalize.go | 33 ++++++++++++--- internal/outboundtext/normalize_test.go | 2 + 6 files changed, 84 insertions(+), 21 deletions(-) create mode 100644 internal/channels/dingtalk/outbound_content_test.go diff --git a/internal/channels/dingtalk/download_codes_test.go b/internal/channels/dingtalk/download_codes_test.go index fbe47ca..58c7225 100644 --- a/internal/channels/dingtalk/download_codes_test.go +++ b/internal/channels/dingtalk/download_codes_test.go @@ -30,4 +30,4 @@ func TestEffectiveDownloadCodesEmpty(t *testing.T) { if codes := effectiveDownloadCodes(BotCallback{}); len(codes) != 0 { t.Fatalf("codes=%v, want empty", codes) } -} \ No newline at end of file +} diff --git a/internal/channels/dingtalk/image_publish_test.go b/internal/channels/dingtalk/image_publish_test.go index 7f158a3..483335a 100644 --- a/internal/channels/dingtalk/image_publish_test.go +++ b/internal/channels/dingtalk/image_publish_test.go @@ -77,4 +77,4 @@ func TestPublishQuestionImageFailsClosedWithoutURL(t *testing.T) { if got := h2.publishQuestionImage(context.Background(), image); got != "" { t.Fatalf("expected empty URL on publish error, got %q", got) } -} \ No newline at end of file +} diff --git a/internal/channels/dingtalk/outbound_content_test.go b/internal/channels/dingtalk/outbound_content_test.go new file mode 100644 index 0000000..cfe667e --- /dev/null +++ b/internal/channels/dingtalk/outbound_content_test.go @@ -0,0 +1,40 @@ +package dingtalk + +import ( + "context" + "strings" + "testing" + + "gitea.zheromiku.top/Weknora/dobotai-platform/internal/outboundimage" +) + +type diagFetcher struct{} + +func (diagFetcher) Fetch(ctx context.Context, ref string) ([]byte, string, error) { + return []byte("PNGDATA"), "image/png", nil +} + +type diagPublisher struct{} + +func (diagPublisher) Publish(ctx context.Context, data []byte, contentType string) (string, error) { + return "https://weknora-api-test.dobot.cn/dingtalk/i/a87f73b8-15cb-546e-ba53-bf93dade916f", nil +} + +// The outbound rewrite must publish a resource image and keep it in the card +// body. A reference whose handle contains "_x" (…ZM_pltQ) used to be mangled by +// the math normalizer, which made the image unpublishable and the sanitizer +// drop the whole line. +func TestRewriteOutboundContentKeepsPublishedImage(t *testing.T) { + content := "![CR10A 末端负载曲线](resource://efVTXx0gY9Lk2vuZM_pltQ)\n\n公式:$r = \\sqrt{X^2 + Y^2}$\n" + h := &Handler{ + outboundRewriter: outboundimage.NewRewriter(diagFetcher{}, diagPublisher{}, 50), + groupTablesFinal: true, + } + out := h.rewriteOutboundContent(context.Background(), content) + if !strings.Contains(out, "![CR10A 末端负载曲线](https://weknora-api-test.dobot.cn/dingtalk/i/") { + t.Fatalf("published image missing from card content: %q", out) + } + if !strings.Contains(out, "r = √(X² + Y²)") { + t.Fatalf("formula not converted: %q", out) + } +} diff --git a/internal/outboundtext/math.go b/internal/outboundtext/math.go index f14f844..fc7879e 100644 --- a/internal/outboundtext/math.go +++ b/internal/outboundtext/math.go @@ -11,19 +11,19 @@ import ( // well-known math commands, so ordinary prose is left byte-identical. var ( - mathBlockDelimRE = regexp.MustCompile(`\$\$([\s\S]+?)\$\$`) - mathInlineDelimRE = regexp.MustCompile(`\$([^$\n]{1,400})\$`) - parenDelimRE = regexp.MustCompile(`\\\(([\s\S]+?)\\\)`) - bracketDelimRE = regexp.MustCompile(`\\\[([\s\S]+?)\\\]`) - sqrtRE = regexp.MustCompile(`\\sqrt\s*(?:\[([^\]]{1,20})\])?\s*\{([^{}]{1,200})\}`) - fracRE = regexp.MustCompile(`\\frac\s*\{([^{}]{1,200})\}\s*\{([^{}]{1,200})\}`) - supBracedRE = regexp.MustCompile(`\^\s*\{([^{}]{1,40})\}`) - subBracedRE = regexp.MustCompile(`_\s*\{([^{}]{1,40})\}`) - supCharRE = regexp.MustCompile(`\^([0-9A-Za-z+\-=()])`) - subCharRE = regexp.MustCompile(`_([0-9A-Za-z+\-=()])`) - commandRE = regexp.MustCompile(`\\([A-Za-z]+)`) - braceDelimRE = regexp.MustCompile(`\{([^{}]{0,200})\}`) - blankRunRE = regexp.MustCompile(`[ \t]{2,}`) + mathBlockDelimRE = regexp.MustCompile(`\$\$([\s\S]+?)\$\$`) + mathSpanRE = regexp.MustCompile(`(?s)\$\$.+?\$\$|\\\[.+?\\\]|\\\(.+?\\\)|\$[^$\n]{1,400}\$`) + parenDelimRE = regexp.MustCompile(`\\\(([\s\S]+?)\\\)`) + bracketDelimRE = regexp.MustCompile(`\\\[([\s\S]+?)\\\]`) + sqrtRE = regexp.MustCompile(`\\sqrt\s*(?:\[([^\]]{1,20})\])?\s*\{([^{}]{1,200})\}`) + fracRE = regexp.MustCompile(`\\frac\s*\{([^{}]{1,200})\}\s*\{([^{}]{1,200})\}`) + supBracedRE = regexp.MustCompile(`\^\s*\{([^{}]{1,40})\}`) + subBracedRE = regexp.MustCompile(`_\s*\{([^{}]{1,40})\}`) + supCharRE = regexp.MustCompile(`\^([0-9A-Za-z+\-=()])`) + subCharRE = regexp.MustCompile(`_([0-9A-Za-z+\-=()])`) + commandRE = regexp.MustCompile(`\\([A-Za-z]+)`) + braceDelimRE = regexp.MustCompile(`\{([^{}]{0,200})\}`) + blankRunRE = regexp.MustCompile(`[ \t]{2,}`) ) var mathCommands = map[string]string{ diff --git a/internal/outboundtext/normalize.go b/internal/outboundtext/normalize.go index 2f311ae..5b56c9b 100644 --- a/internal/outboundtext/normalize.go +++ b/internal/outboundtext/normalize.go @@ -6,14 +6,36 @@ func NormalizeMath(markdown string) string { if !carriesMath(markdown) { return markdown } - content := markdown + content := mathSpanRE.ReplaceAllStringFunc(markdown, convertMathSpan) + // Outside math spans only LaTeX commands are converted. Identifiers and URLs + // (resource://…_pltQ) must never be rewritten: a mangled reference can no + // longer be published, and the outbound sanitizer then drops the whole image. + content = commandRE.ReplaceAllStringFunc(content, replaceCommand) + content = blankRunRE.ReplaceAllString(content, " ") + return content +} + +func convertMathSpan(span string) string { + inner := span + switch { + case strings.HasPrefix(inner, "$$"): + inner = strings.TrimSuffix(strings.TrimPrefix(inner, "$$"), "$$") + return "\n" + convertMath(inner) + "\n" + case strings.HasPrefix(inner, `\[`): + inner = strings.TrimSuffix(strings.TrimPrefix(inner, `\[`), `\]`) + case strings.HasPrefix(inner, `\(`): + inner = strings.TrimSuffix(strings.TrimPrefix(inner, `\(`), `\)`) + default: + inner = strings.TrimSuffix(strings.TrimPrefix(inner, "$"), "$") + } + return convertMath(inner) +} + +func convertMath(inner string) string { + content := inner for from, to := range escapedPunctuation { content = strings.ReplaceAll(content, from, to) } - content = mathBlockDelimRE.ReplaceAllString(content, "\n$1\n") - content = parenDelimRE.ReplaceAllString(content, "$1") - content = bracketDelimRE.ReplaceAllString(content, "$1") - content = mathInlineDelimRE.ReplaceAllString(content, "$1") for i := 0; i < 3; i++ { next := sqrtRE.ReplaceAllStringFunc(content, expandRoot) next = fracRE.ReplaceAllString(next, "($1)/($2)") @@ -28,7 +50,6 @@ func NormalizeMath(markdown string) string { } content = commandRE.ReplaceAllStringFunc(content, replaceCommand) content = braceDelimRE.ReplaceAllString(content, "$1") - content = blankRunRE.ReplaceAllString(content, " ") return content } diff --git a/internal/outboundtext/normalize_test.go b/internal/outboundtext/normalize_test.go index 61964ba..5951883 100644 --- a/internal/outboundtext/normalize_test.go +++ b/internal/outboundtext/normalize_test.go @@ -12,6 +12,8 @@ func TestNormalizeMath(t *testing.T) { {"nth root", `$\sqrt[3]{V}$`, `√[3](V)`}, {"no math untouched", `C:\new\folder 与 100% 覆盖`, `C:\new\folder 与 100% 覆盖`}, {"dollar free prose", `价格是 5 元, 无公式`, `价格是 5 元, 无公式`}, + {"resource url untouched", `![图](resource://efVTXx0gY9Lk2vuZM_pltQ) 公式 $r = \sqrt{X^2 + Y^2}$`, `![图](resource://efVTXx0gY9Lk2vuZM_pltQ) 公式 r = √(X² + Y²)`}, + {"code span untouched", "`x_1` 与 $x_1$", "`x_1` 与 x₁"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) {