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 `
` + body + ``, 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 + `'` }