init commit

This commit is contained in:
Yehoshua Adam Sandler
2026-02-05 15:33:07 -06:00
commit e70b629b4a
13 changed files with 1021 additions and 0 deletions

430
convert/rtf.go Normal file
View 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
}