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)
+ }
+}