feat(dingtalk): normalize wide tables into grouped lists on final card

This commit is contained in:
姚章浩
2026-09-11 19:56:32 +08:00
parent 16aa981028
commit b49cb5eaf9
3 changed files with 292 additions and 2 deletions
+2 -2
View File
@@ -629,7 +629,7 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) string {
if h.outboundRewriter == nil {
return sanitizeDisplayAnswer(content)
return normalizeWideMarkdownTables(sanitizeDisplayAnswer(content))
}
timeout := h.finalDeliveryTimeout
if timeout <= 0 {
@@ -637,7 +637,7 @@ func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) st
}
rewriteCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer cancel()
return sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content))
return normalizeWideMarkdownTables(sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content)))
}
func (h *Handler) sanitizeOutboundPartial(content string) string {
@@ -0,0 +1,218 @@
package dingtalk
import (
"strings"
"unicode"
)
// maxTableCellVisualWidth is the DingTalk-safe cell budget. CJK counts as 2
// display columns; a cell wider than this tends to collapse the column layout
// (one character per line) in the streaming card renderer.
const maxTableCellVisualWidth = 32
// normalizeWideMarkdownTables makes final answers render reliably on DingTalk
// cards without changing the knowledge itself. It (1) replaces <br> inside
// table cells with a full-width separator, and (2) rewrites any table that
// still contains an over-wide cell into a grouped list — a bold row heading
// followed by one "- header:value" line per column. Compact tables pass
// through unchanged. Tables inside code fences are never touched.
func normalizeWideMarkdownTables(content string) string {
if !strings.Contains(content, "|") {
return content
}
lines := splitMarkdownStreamLines(content)
out := make([]string, 0, len(lines))
for i := 0; i < len(lines); {
line := lines[i]
if line.inFence {
out = append(out, line.text)
i++
continue
}
if isTableHeaderCandidate(line.text) && i+1 < len(lines) &&
isCompleteTableDelimiter(lines[i+1].text) {
end := i + 1
for end+1 < len(lines) && !lines[end+1].inFence &&
strings.HasPrefix(strings.TrimSpace(lines[end+1].text), "|") {
end++
}
out = append(out, normalizeTableBlock(lines[i:end+1])...)
i = end + 1
continue
}
out = append(out, line.text)
i++
}
return strings.Join(out, "\n")
}
func normalizeTableBlock(rows []markdownStreamLine) []string {
cells := make([][]string, 0, len(rows))
for idx, row := range rows {
if idx == 1 { // delimiter row
continue
}
cells = append(cells, splitTableRow(row.text))
}
maxWidth := 0
for _, row := range cells {
for _, cell := range row {
if w := visualWidth(cell); w > maxWidth {
maxWidth = w
}
}
}
header := cells[0]
if maxWidth <= maxTableCellVisualWidth {
// Compact tables keep their exact original bytes unless a cell carries
// <br>, which the card renderer mishandles inside cells.
if !blockHasBreak(rows) {
rebuilt := make([]string, len(rows))
for i, row := range rows {
rebuilt[i] = row.text
}
return rebuilt
}
var rebuilt []string
for idx, row := range cells {
joined := "| " + strings.Join(row, " | ") + " |"
if idx == 0 {
rebuilt = append(rebuilt, joined)
delims := make([]string, len(row))
for c := range row {
delims[c] = "---"
}
rebuilt = append(rebuilt, "| "+strings.Join(delims, " | ")+" |")
} else {
rebuilt = append(rebuilt, joined)
}
}
return rebuilt
}
var out []string
for gi, row := range cells[1:] {
if gi > 0 {
out = append(out, "")
}
var title string
var rest []string
for c, cell := range row {
if c == 0 {
title = cell
continue
}
label := strings.TrimSpace(header[min(c, len(header)-1)])
if label == "" {
label = "第" + itoa(c+1) + "项"
}
if cell == "" {
continue
}
rest = append(rest, "- "+label+":"+cell)
}
if title == "" {
title = label2(row[0])
}
out = append(out, "**"+strings.Trim(title, "* ")+"**")
out = append(out, rest...)
}
return out
}
func blockHasBreak(rows []markdownStreamLine) bool {
for _, row := range rows {
lower := strings.ToLower(row.text)
if strings.Contains(lower, "<br") {
return true
}
}
return false
}
func splitTableRow(line string) []string {
trimmed := strings.TrimSpace(line)
trimmed = strings.TrimPrefix(trimmed, "|")
trimmed = strings.TrimSuffix(trimmed, "|")
parts := splitEscaped(trimmed)
for i := range parts {
cell := strings.TrimSpace(parts[i])
cell = strings.ReplaceAll(cell, "<br>", ";")
cell = strings.ReplaceAll(cell, "<br/>", ";")
cell = strings.ReplaceAll(cell, "<br />", ";")
cell = strings.ReplaceAll(cell, "\\|", "|")
parts[i] = strings.TrimSpace(cell)
}
return parts
}
// splitEscaped splits on unescaped pipe separators.
func splitEscaped(s string) []string {
var parts []string
var b strings.Builder
for i := 0; i < len(s); i++ {
switch s[i] {
case '\\':
if i+1 < len(s) && s[i+1] == '|' {
b.WriteString("\\|")
i++
continue
}
b.WriteByte(s[i])
case '|':
parts = append(parts, b.String())
b.Reset()
default:
b.WriteByte(s[i])
}
}
parts = append(parts, b.String())
return parts
}
func label2(cell string) string { return strings.Trim(cell, "* ") }
func min(a, b int) int {
if a < b {
return a
}
return b
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var d []byte
for n > 0 {
d = append([]byte{byte('0' + n%10)}, d...)
n /= 10
}
return string(d)
}
func visualWidth(s string) int {
w := 0
for _, r := range s {
if r == '\n' {
if w > 0 {
w++ // separator counted conservatively
}
continue
}
if unicode.Is(unicode.Han, r) || unicode.Is(unicode.Katakana, r) ||
unicode.Is(unicode.Hiragana, r) || fullwidthPunct(r) {
w += 2
continue
}
w++
}
return w
}
func fullwidthPunct(r rune) bool {
switch r {
case ',', '。', ':', ';', '(', ')', '、', '【', '】', '《', '》', '“', '”', '‘', '’', ' ':
return true
}
return false
}
@@ -0,0 +1,72 @@
package dingtalk
import (
"strings"
"testing"
)
func TestNormalizeKeepsCompactTableUnchanged(t *testing.T) {
content := "重量如下:\n\n| 产品型号 | 净重 (本体重量) | 毛重 (含包装) |\n| :--- | :--- | :--- |\n| CR3A | 16.5 kg / 17.5 kg | 28.5 kg |\n| CR5A | 25 kg | 37 kg |\n\n结束"
got := normalizeWideMarkdownTables(content)
if got != content {
t.Fatalf("compact table must pass through untouched, got %q", got)
}
}
func TestNormalizeConvertsWideTableToGroupedList(t *testing.T) {
longCell := "J1: 180°/s (CR3A/5A/7A), 150°/s (CR10A/12A/16A)<br>J2: 180°/s (CR3A/5A/7A)"
content := "差异如下:\n\n| 对比项 | CR 系列 | CRA 系列 |\n| :--- | :--- | :--- |\n| **关节最大速度** | " + longCell + " | J1: 180°/s (CR3A/5A/7A), 150°/s (CR10A/12A/16A)<br>J2: 更多参数说明 |\n| 控制柜 | CC162 | CC262 |\n\n结论"
got := normalizeWideMarkdownTables(content)
if strings.Contains(got, "| 对比项") || strings.Contains(got, "<br>") {
t.Fatalf("wide table must be converted and <br> removed, got %q", got)
}
if !strings.HasPrefix(got, "差异如下:\n\n") || !strings.HasSuffix(got, "\n\n结论") {
t.Fatalf("surrounding text must be preserved, got %q", got)
}
// compact row (控制柜) from the same table is now a list entry too
if !strings.Contains(got, "**关节最大速度**\n- CR 系列:") || !strings.Contains(got, "- CRA 系列:") {
t.Fatalf("row key must become bold heading with header-labelled values, got %q", got)
}
if !strings.Contains(got, "**控制柜**\n- CR 系列:CC162\n- CRA 系列:CC262") {
t.Fatalf("every row must become a group, got %q", got)
}
if strings.Contains(got, "J1: 180°/s") == false {
t.Fatalf("cell content must be preserved, got %q", got)
}
}
func TestNormalizeReplacesBrKeepsTableWhenCellsShort(t *testing.T) {
content := "| A | B |\n| --- | --- |\n| 短 | 行一<br>行二 |"
got := normalizeWideMarkdownTables(content)
if strings.Contains(got, "<br>") {
t.Fatalf("<br> must be replaced, got %q", got)
}
if !strings.Contains(got, "| A | B |") || !strings.Contains(got, "行一;行二") {
t.Fatalf("short table must stay a table with ; separator, got %q", got)
}
}
func TestNormalizeSkipsFencedTables(t *testing.T) {
long := strings.Repeat("长", 30)
content := "```\n| 对比 | " + long + " |\n| --- | --- |\n| a | b |\n```\n正文"
got := normalizeWideMarkdownTables(content)
if got != content {
t.Fatalf("fenced content must never be rewritten, got %q", got)
}
}
func TestNormalizeHandlesEscapedPipesAndEmptyHeaders(t *testing.T) {
long := strings.Repeat("x", 40)
content := "| 项 | | \n| --- | --- |\n| a\\|b | " + long + " |"
got := normalizeWideMarkdownTables(content)
if strings.Contains(got, "| a\\|b") {
t.Fatalf("escaped pipe cell should be converted out of table form, got %q", got)
}
if !strings.Contains(got, "a|b") {
t.Fatalf("escaped pipe must be restored as literal, got %q", got)
}
if !strings.Contains(got, "第2项:") {
t.Fatalf("missing header should fall back to ordinal label, got %q", got)
}
}