init commit
This commit is contained in:
430
convert/rtf.go
Normal file
430
convert/rtf.go
Normal file
@@ -0,0 +1,430 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user