From b49cb5eaf901d7228baeecdaf8a8e78c17fceeee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Fri, 11 Sep 2026 19:56:32 +0800 Subject: [PATCH] feat(dingtalk): normalize wide tables into grouped lists on final card --- internal/channels/dingtalk/handler.go | 4 +- internal/channels/dingtalk/table_normalize.go | 218 ++++++++++++++++++ .../channels/dingtalk/table_normalize_test.go | 72 ++++++ 3 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 internal/channels/dingtalk/table_normalize.go create mode 100644 internal/channels/dingtalk/table_normalize_test.go diff --git a/internal/channels/dingtalk/handler.go b/internal/channels/dingtalk/handler.go index 3323af5..6a5e6cb 100644 --- a/internal/channels/dingtalk/handler.go +++ b/internal/channels/dingtalk/handler.go @@ -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 { diff --git a/internal/channels/dingtalk/table_normalize.go b/internal/channels/dingtalk/table_normalize.go new file mode 100644 index 0000000..deb123e --- /dev/null +++ b/internal/channels/dingtalk/table_normalize.go @@ -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
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 + //
, 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, "", ";") + cell = strings.ReplaceAll(cell, "
", ";") + cell = strings.ReplaceAll(cell, "
", ";") + 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 +} diff --git a/internal/channels/dingtalk/table_normalize_test.go b/internal/channels/dingtalk/table_normalize_test.go new file mode 100644 index 0000000..9b4e640 --- /dev/null +++ b/internal/channels/dingtalk/table_normalize_test.go @@ -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)
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)
J2: 更多参数说明 |\n| 控制柜 | CC162 | CC262 |\n\n结论" + got := normalizeWideMarkdownTables(content) + + if strings.Contains(got, "| 对比项") || strings.Contains(got, "
") { + t.Fatalf("wide table must be converted and
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| 短 | 行一
行二 |" + got := normalizeWideMarkdownTables(content) + if strings.Contains(got, "
") { + t.Fatalf("
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) + } +}