431 lines
12 KiB
Go
431 lines
12 KiB
Go
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
|
|
}
|