package toc import ( "fmt" "html" "regexp" "strings" "unicode/utf8" ) var htmlTagRegexp = regexp.MustCompile(`<[^>]*>`) // htmlTextLength 计算剥离 HTML 标签后的字符数,匹配 Telegram 的计数方式 func htmlTextLength(s string) int { stripped := htmlTagRegexp.ReplaceAllString(s, "") return utf8.RuneCountInString(stripped) } // renderLines 将所有分类和条目渲染为独立的行 func (m *Manager) renderLines() (header string, lines []string, footer string, err error) { categories, err := m.storage.ListCategories() if err != nil { return "", nil, "", err } entriesByCategory, err := m.storage.GetEntriesByCategory() if err != nil { return "", nil, "", err } header = "📚 频道目录\n━━━━━━━━━━━━━━━\n\n" footer = "━━━━━━━━━━━━━━━\n" if len(categories) == 0 { lines = append(lines, "暂无分类\n") return header, lines, footer, nil } for _, cat := range categories { entries := entriesByCategory[cat.Name] catLine := fmt.Sprintf("📁 %s", html.EscapeString(cat.Name)) if len(entries) > 0 { catLine += fmt.Sprintf(" (%d)", len(entries)) } catLine += "\n" lines = append(lines, catLine) if len(entries) == 0 { lines = append(lines, " 暂无内容\n") } else { for _, entry := range entries { entryLine := fmt.Sprintf(" • %s", html.EscapeString(entry.MessageLink()), html.EscapeString(entry.Title)) if entry.HasDirectLink() { entryLine += fmt.Sprintf(" [直达]", html.EscapeString(entry.DirectLink)) } entryLine += "\n" lines = append(lines, entryLine) } } // 分类之间空行 lines = append(lines, "\n") } return header, lines, footer, nil } // RenderPage 渲染指定页的 TOC 内容,返回内容、总页数 func (m *Manager) RenderPage(page, charLimit int) (string, int, error) { header, lines, footer, err := m.renderLines() if err != nil { return "", 0, err } // 页码行的最大长度预估(如 "📄 99 / 99\n") pageIndicatorLen := 15 headerLen := htmlTextLength(header) footerLen := htmlTextLength(footer) fixedLen := headerLen + footerLen + pageIndicatorLen // 将行分配到各页 var pages [][]string var currentPage []string currentLen := fixedLen for _, line := range lines { lineLen := htmlTextLength(line) // 如果当前页加上这行会超限,且当前页已有内容,则开新页 if currentLen+lineLen > charLimit && len(currentPage) > 0 { pages = append(pages, currentPage) currentPage = nil currentLen = fixedLen } currentPage = append(currentPage, line) currentLen += lineLen } // 最后一页 if len(currentPage) > 0 { pages = append(pages, currentPage) } totalPages := len(pages) if totalPages == 0 { totalPages = 1 pages = [][]string{{}} } // clamp page if page < 1 { page = 1 } if page > totalPages { page = totalPages } // 组装当前页内容 var sb strings.Builder sb.WriteString(header) for _, line := range pages[page-1] { sb.WriteString(line) } sb.WriteString(footer) if totalPages > 1 { sb.WriteString(fmt.Sprintf("📄 %d / %d\n", page, totalPages)) } return sb.String(), totalPages, nil } // Render 渲染完整 TOC(兼容方法,返回第 1 页,使用 photo caption 限制) func (m *Manager) Render() (string, error) { content, _, err := m.RenderPage(1, captionCharLimit) return content, err }