Files
enkly-ai/pkg/officekit/markdown.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

129 lines
3.8 KiB
Go

// markdown.go adds Markdown → the same structured []Paragraph
// vocabulary create_document and BuildDocx already use, so an
// existing Markdown document (a README, a chat reply, something an
// upload already contains) can become a real .docx without the model
// ever hand-translating headings and bullets into paragraph arguments
// itself. It reuses the gomarkdown parser already vendored for
// chat-message rendering (pkg/renderkit) rather than hand-rolling a
// second Markdown parser — one less thing in this codebase that can
// disagree about what counts as a heading or a list item.
//
// Scope: block-level structure only — headings, paragraphs, and
// bullet/numbered lists (numbered lists render as bullets, since
// Paragraph has no numbered-list style of its own yet). Fenced or
// indented code blocks come through as plain paragraphs, since
// Paragraph has no monospace style either. Inline formatting
// (bold/italic spans within a paragraph, links, inline code) is
// flattened to plain text — Paragraph only supports whole-paragraph
// bold/italic/underline (see docx.go), the same limit
// create_document's tool description already states. Tables, raw HTML
// blocks, footnotes, and anything else without a Paragraph-vocabulary
// equivalent are silently dropped rather than guessed at.
package officekit
import (
"strings"
"github.com/gomarkdown/markdown/ast"
mdparser "github.com/gomarkdown/markdown/parser"
)
// MarkdownToParagraphs parses a Markdown document into the paragraph
// vocabulary BuildDocx/AppendDocxParagraph already understand, so a
// caller can go straight from Markdown source to a .docx via
// BuildDocx(MarkdownToParagraphs(src)).
func MarkdownToParagraphs(src string) []Paragraph {
p := mdparser.NewWithExtensions(mdparser.CommonExtensions)
doc := p.Parse([]byte(src))
var out []Paragraph
for _, block := range childNodes(doc) {
out = append(out, blockToParagraphs(block)...)
}
return out
}
func blockToParagraphs(n ast.Node) []Paragraph {
switch v := n.(type) {
case *ast.Heading:
text := leafText(v)
if text == "" {
return nil
}
style := StyleHeading3
switch {
case v.Level <= 1:
style = StyleHeading1
case v.Level == 2:
style = StyleHeading2
}
return []Paragraph{{Text: text, Style: style}}
case *ast.Paragraph:
text := leafText(v)
if text == "" {
return nil
}
return []Paragraph{{Text: text, Style: StyleNormal}}
case *ast.List:
var out []Paragraph
for _, item := range childNodes(v) {
text := leafText(item)
if text == "" {
continue
}
out = append(out, Paragraph{Text: text, Style: StyleBullet})
}
return out
case *ast.CodeBlock:
text := strings.TrimRight(string(v.Literal), "\n")
if text == "" {
return nil
}
return []Paragraph{{Text: text, Style: StyleNormal}}
case *ast.BlockQuote:
var out []Paragraph
for _, c := range childNodes(v) {
out = append(out, blockToParagraphs(c)...)
}
return out
default:
// HorizontalRule, Table, HTMLBlock, and anything else this
// package has no Paragraph-vocabulary equivalent for.
return nil
}
}
// childNodes returns n's direct block-level children via gomarkdown's
// Container/Leaf node split: a Leaf node (Text, Code, ...) has none.
func childNodes(n ast.Node) []ast.Node {
if c := n.AsContainer(); c != nil {
return c.Children
}
return nil
}
// leafText concatenates every Text/Code leaf found under n, depth
// first — the inline-formatting-flattening step described in this
// file's package doc.
func leafText(n ast.Node) string {
var sb strings.Builder
collectText(n, &sb)
return strings.TrimSpace(sb.String())
}
func collectText(n ast.Node, sb *strings.Builder) {
switch v := n.(type) {
case *ast.Text:
sb.Write(v.Literal)
return
case *ast.Code:
sb.Write(v.Literal)
return
case *ast.Hardbreak:
sb.WriteByte(' ')
return
}
for _, c := range childNodes(n) {
collectText(c, sb)
}
}