1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
|
package export
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
// CopyRichText renders markdown to a bare semantic HTML fragment (headings,
// bold/italic, lists, links, tables, code — no theme, no paper background, no
// fonts) and places it on the system clipboard as rich text, with the raw
// markdown as a plain-text fallback flavor — so pasting into apps that
// support rich text (Mail, Word, Pages, Slack) shows real formatting, while
// anything else falls back to plain text (TASK-035). macOS only: converting
// HTML to RTF and writing multiple pasteboard flavors at once has no
// cross-platform equivalent to atotto/clipboard's single plain-text write.
//
// Unlike Document (used by Ctrl+E/-e, which wants the full house style for
// print/PDF), this deliberately skips doc.css/theme/fonts: pasted rich text
// should adopt the target app's own background and font, not glint's paper
// color — an explicit background-color on the page carries into RTF as a
// highlight color behind every run of text, not real page styling, which
// Cocoa apps render as an unwanted highlight box.
func CopyRichText(markdown string) error {
if runtime.GOOS != "darwin" {
return fmt.Errorf("rich text copy is only supported on macOS")
}
html, err := richTextHTML(markdown)
if err != nil {
return err
}
rtf, err := htmlToRTF(html)
if err != nil {
return err
}
return setClipboardRichText(rtf, markdown)
}
// richTextHTML converts markdown to a minimal, theme-free HTML document —
// just the semantic body (via renderBody, the same goldmark conversion and
// task-list/heading-class postprocessing Document uses) with no doc.css, no
// background, no fonts, so the RTF conversion carries no page styling.
func richTextHTML(markdown string) (string, error) {
body, err := renderBody(markdown, Options{})
if err != nil {
return "", err
}
return `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>` + body + `</body></html>`, nil
}
// htmlToRTF shells out to macOS's textutil to convert a self-contained HTML
// document to RTF bytes.
func htmlToRTF(html string) ([]byte, error) {
cmd := exec.Command("textutil", "-convert", "rtf", "-format", "html", "-stdin", "-stdout")
cmd.Stdin = strings.NewReader(html)
var out, stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("textutil: %w: %s", err, strings.TrimSpace(stderr.String()))
}
return out.Bytes(), nil
}
// setClipboardRichText writes rtf and plainFallback to temp files and runs a
// JXA (JavaScript for Automation) script that sets both pasteboard flavors —
// public.rtf and public.utf8-plain-text — directly via NSPasteboard, so paste
// targets pick the richest flavor they understand instead of only ever
// getting plain text.
//
// AppleScript's classic `set the clipboard to {«class RTF»:x, string:y}`
// record form was tried first and looked right (clipboard info listed both
// flavors), but real readers (pbpaste -Prefer rtf, and NSPasteboard reads
// from a second process) only ever saw the plain-text flavor back — the
// record form doesn't reliably register distinct UTIs. Setting each type via
// NSPasteboard.setDataForType directly does, verified with both pbpaste and
// a raw NSPasteboard read-back.
func setClipboardRichText(rtf []byte, plainFallback string) error {
dir, err := os.MkdirTemp("", "glint-rtf")
if err != nil {
return err
}
defer os.RemoveAll(dir)
rtfPath := filepath.Join(dir, "clip.rtf")
txtPath := filepath.Join(dir, "clip.txt")
if err := os.WriteFile(rtfPath, rtf, 0o600); err != nil {
return err
}
if err := os.WriteFile(txtPath, []byte(plainFallback), 0o600); err != nil {
return err
}
scriptPath := filepath.Join(dir, "clip.js")
if err := os.WriteFile(scriptPath, []byte(clipboardScript(rtfPath, txtPath)), 0o600); err != nil {
return err
}
var stderr bytes.Buffer
cmd := exec.Command("osascript", "-l", "JavaScript", scriptPath)
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("osascript: %w: %s", err, strings.TrimSpace(stderr.String()))
}
return nil
}
// clipboardScript builds the JXA script that reads rtfPath/txtPath and sets
// both pasteboard flavors via NSPasteboard.setDataForType.
func clipboardScript(rtfPath, txtPath string) string {
return fmt.Sprintf(`ObjC.import('AppKit');
var pb = $.NSPasteboard.generalPasteboard;
pb.clearContents;
var rtfData = $.NSData.dataWithContentsOfFile(%s);
var txtData = $.NSData.dataWithContentsOfFile(%s);
pb.setDataForType(rtfData, 'public.rtf');
pb.setDataForType(txtData, 'public.utf8-plain-text');
`, jsQuote(rtfPath), jsQuote(txtPath))
}
// jsQuote wraps a path in a single-quoted JavaScript string literal,
// escaping any embedded backslashes or quotes.
func jsQuote(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `'`, `\'`)
return `'` + s + `'`
}
|