package convert import ( "bytes" "fmt" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" extast "github.com/yuin/goldmark/extension/ast" "github.com/yuin/goldmark/renderer" "github.com/yuin/goldmark/util" ) // RTFRenderer converts a Goldmark AST to RTF. type RTFRenderer struct { orderedIndex int // tracks current ordered list item number } func NewRTFRenderer() *RTFRenderer { return &RTFRenderer{} } func (r *RTFRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { // Block nodes reg.Register(ast.KindDocument, r.renderDocument) reg.Register(ast.KindHeading, r.renderHeading) reg.Register(ast.KindParagraph, r.renderParagraph) reg.Register(ast.KindCodeBlock, r.renderCodeBlock) reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock) reg.Register(ast.KindBlockquote, r.renderBlockquote) reg.Register(ast.KindList, r.renderList) reg.Register(ast.KindListItem, r.renderListItem) reg.Register(ast.KindThematicBreak, r.renderThematicBreak) reg.Register(ast.KindHTMLBlock, r.renderRaw) reg.Register(ast.KindTextBlock, r.renderTextBlock) // Inline nodes reg.Register(ast.KindText, r.renderText) reg.Register(ast.KindString, r.renderString) reg.Register(ast.KindEmphasis, r.renderEmphasis) reg.Register(ast.KindCodeSpan, r.renderCodeSpan) reg.Register(ast.KindLink, r.renderLink) reg.Register(ast.KindAutoLink, r.renderAutoLink) reg.Register(ast.KindImage, r.renderImage) reg.Register(ast.KindRawHTML, r.renderRaw) // GFM extensions reg.Register(extast.KindStrikethrough, r.renderStrikethrough) reg.Register(extast.KindTable, r.renderTable) reg.Register(extast.KindTableHeader, r.renderTableHeader) reg.Register(extast.KindTableRow, r.renderTableRow) reg.Register(extast.KindTableCell, r.renderTableCell) reg.Register(extast.KindTaskCheckBox, r.renderTaskCheckBox) } // --- Block nodes --- func (r *RTFRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { _, _ = w.WriteString(`{\rtf1\ansi\deff0 {\fonttbl{\f0 Helvetica;}{\f1\fmodern Courier;}}`) _, _ = w.WriteString("\n") } else { _, _ = w.WriteString("}") } return ast.WalkContinue, nil } func (r *RTFRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { h := node.(*ast.Heading) if entering { // Font sizes: H1=36, H2=32, H3=28, H4=24, H5=22, H6=20 sizes := [7]int{0, 36, 32, 28, 24, 22, 20} level := h.Level if level < 1 || level > 6 { level = 6 } fs := sizes[level] * 2 // RTF font size is in half-points fmt.Fprintf(w, `{\pard\sb240\sa120\b\fs%d `, fs) } else { _, _ = w.WriteString(`\b0\par}`) _, _ = w.WriteString("\n") } return ast.WalkContinue, nil } func (r *RTFRenderer) renderParagraph(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { _, _ = w.WriteString(`{\pard\sa120 `) } else { _, _ = w.WriteString(`\par}`) _, _ = w.WriteString("\n") } return ast.WalkContinue, nil } func (r *RTFRenderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { _, _ = w.WriteString(`{\pard\f1\fs20 `) r.writeLines(w, source, node) _, _ = w.WriteString(`\par}`) _, _ = w.WriteString("\n") return ast.WalkSkipChildren, nil } return ast.WalkContinue, nil } func (r *RTFRenderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { _, _ = w.WriteString(`{\pard\f1\fs20 `) r.writeLines(w, source, node) _, _ = w.WriteString(`\par}`) _, _ = w.WriteString("\n") return ast.WalkSkipChildren, nil } return ast.WalkContinue, nil } func (r *RTFRenderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { _, _ = w.WriteString(`{\pard\li720\ri720\i `) } else { _, _ = w.WriteString(`\i0\par}`) _, _ = w.WriteString("\n") } return ast.WalkContinue, nil } func (r *RTFRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { list := node.(*ast.List) if list.IsOrdered() { r.orderedIndex = int(list.Start) } else { r.orderedIndex = 0 } } return ast.WalkContinue, nil } func (r *RTFRenderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { _, _ = w.WriteString(`{\pard\li360\fi-360 `) parent := node.Parent() if list, ok := parent.(*ast.List); ok && list.IsOrdered() { fmt.Fprintf(w, `%d.\tab `, r.orderedIndex) r.orderedIndex++ } else { _, _ = w.WriteString(`\bullet\tab `) } } else { _, _ = w.WriteString(`\par}`) _, _ = w.WriteString("\n") } return ast.WalkContinue, nil } func (r *RTFRenderer) renderThematicBreak(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { _, _ = w.WriteString(`{\pard\brdrb\brdrs\brdrw10\brsp20\par}`) _, _ = w.WriteString("\n") } return ast.WalkContinue, nil } func (r *RTFRenderer) renderTextBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { // TextBlock is used inside blockquotes; just pass through. if !entering { _, _ = w.WriteString(`\line `) } return ast.WalkContinue, nil } func (r *RTFRenderer) renderRaw(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { // Skip raw HTML in RTF output if entering { return ast.WalkSkipChildren, nil } return ast.WalkContinue, nil } // --- Inline nodes --- func (r *RTFRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { t := node.(*ast.Text) writeRTFEscaped(w, t.Segment.Value(source)) if t.SoftLineBreak() { _, _ = w.WriteString(" ") } if t.HardLineBreak() { _, _ = w.WriteString(`\line `) } } return ast.WalkContinue, nil } func (r *RTFRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { s := node.(*ast.String) writeRTFEscaped(w, s.Value) } return ast.WalkContinue, nil } func (r *RTFRenderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { e := node.(*ast.Emphasis) if e.Level == 2 { // Bold if entering { _, _ = w.WriteString(`{\b `) } else { _, _ = w.WriteString(`}`) } } else { // Italic if entering { _, _ = w.WriteString(`{\i `) } else { _, _ = w.WriteString(`}`) } } return ast.WalkContinue, nil } func (r *RTFRenderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { _, _ = w.WriteString(`{\f1 `) for c := node.FirstChild(); c != nil; c = c.NextSibling() { if t, ok := c.(*ast.Text); ok { writeRTFEscaped(w, t.Segment.Value(source)) } } _, _ = w.WriteString(`}`) return ast.WalkSkipChildren, nil } return ast.WalkContinue, nil } func (r *RTFRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { link := node.(*ast.Link) if entering { _, _ = w.WriteString(`{\field{\*\fldinst HYPERLINK "`) writeRTFEscaped(w, link.Destination) _, _ = w.WriteString(`"}{\fldrslt\ul `) } else { _, _ = w.WriteString(`}}`) } return ast.WalkContinue, nil } func (r *RTFRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { link := node.(*ast.AutoLink) if entering { url := link.URL(source) _, _ = w.WriteString(`{\field{\*\fldinst HYPERLINK "`) writeRTFEscaped(w, url) _, _ = w.WriteString(`"}{\fldrslt\ul `) writeRTFEscaped(w, url) _, _ = w.WriteString(`}}`) return ast.WalkSkipChildren, nil } return ast.WalkContinue, nil } func (r *RTFRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { _, _ = w.WriteString(`[image: `) // Alt text is stored in child text nodes for c := node.FirstChild(); c != nil; c = c.NextSibling() { if t, ok := c.(*ast.Text); ok { writeRTFEscaped(w, t.Segment.Value(source)) } } _, _ = w.WriteString(`]`) return ast.WalkSkipChildren, nil } return ast.WalkContinue, nil } // --- GFM extension nodes --- func (r *RTFRenderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { _, _ = w.WriteString(`{\strike `) } else { _, _ = w.WriteString(`}`) } return ast.WalkContinue, nil } func (r *RTFRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { _, _ = w.WriteString("\n") } return ast.WalkContinue, nil } func (r *RTFRenderer) renderTableHeader(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { // TableHeader acts as a row: Table > TableHeader > TableCell if entering { _, _ = w.WriteString(`{\trowd\trgaph108 `) cellCount := 0 for c := node.FirstChild(); c != nil; c = c.NextSibling() { cellCount++ } for i := 1; i <= cellCount; i++ { fmt.Fprintf(w, `\cellx%d `, i*2880) } } else { _, _ = w.WriteString(`\row}`) _, _ = w.WriteString("\n") } return ast.WalkContinue, nil } func (r *RTFRenderer) renderTableRow(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { _, _ = w.WriteString(`{\trowd\trgaph108 `) // Calculate cell boundaries cellCount := 0 for c := node.FirstChild(); c != nil; c = c.NextSibling() { cellCount++ } for i := 1; i <= cellCount; i++ { fmt.Fprintf(w, `\cellx%d `, i*2880) } } else { _, _ = w.WriteString(`\row}`) _, _ = w.WriteString("\n") } return ast.WalkContinue, nil } func (r *RTFRenderer) renderTableCell(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { isHeader := node.Parent().Kind() == extast.KindTableHeader if entering { _, _ = w.WriteString(`{\pard\intbl `) if isHeader { _, _ = w.WriteString(`\b `) } } else { if isHeader { _, _ = w.WriteString(`\b0`) } _, _ = w.WriteString(`\cell}`) } return ast.WalkContinue, nil } func (r *RTFRenderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { cb := node.(*extast.TaskCheckBox) if cb.IsChecked { _, _ = w.WriteString(`[X] `) } else { _, _ = w.WriteString(`[ ] `) } } return ast.WalkContinue, nil } // --- Helpers --- func (r *RTFRenderer) writeLines(w util.BufWriter, source []byte, node ast.Node) { lines := node.Lines() for i := 0; i < lines.Len(); i++ { line := lines.At(i) value := line.Value(source) // Replace newlines with RTF line breaks for _, b := range value { if b == '\n' { _, _ = w.WriteString(`\line `) } else { writeRTFEscapedByte(w, b) } } } } func writeRTFEscaped(w util.BufWriter, data []byte) { for _, b := range data { writeRTFEscapedByte(w, b) } } func writeRTFEscapedByte(w util.BufWriter, b byte) { switch b { case '\\': _, _ = w.WriteString(`\\`) case '{': _, _ = w.WriteString(`\{`) case '}': _, _ = w.WriteString(`\}`) default: _ = w.WriteByte(b) } } // Convert takes markdown bytes and returns RTF bytes. func Convert(source []byte) ([]byte, error) { md := goldmark.New( goldmark.WithExtensions(extension.GFM), goldmark.WithRenderer( renderer.NewRenderer( renderer.WithNodeRenderers( util.Prioritized(NewRTFRenderer(), 100), ), ), ), ) var buf bytes.Buffer if err := md.Convert(source, &buf); err != nil { return nil, fmt.Errorf("conversion failed: %w", err) } return buf.Bytes(), nil } // ConvertToHTML takes markdown bytes and returns HTML bytes. // Used for clipboard on platforms where apps expect text/html (web apps like Slack, Gmail). func ConvertToHTML(source []byte) ([]byte, error) { md := goldmark.New( goldmark.WithExtensions(extension.GFM), ) var buf bytes.Buffer if err := md.Convert(source, &buf); err != nil { return nil, fmt.Errorf("html conversion failed: %w", err) } return buf.Bytes(), nil }