feat(dingtalk): normalize wide tables into grouped lists on final card
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user