Files
petal/internal/docs/export.go
prosolis 6e6e4edce7 Editor: font-size presets + image insert/export support
Two editor features that were in flight alongside the sound work:
- FontSize TipTap extension (rides on textStyle) with Small/Normal/Large/
  Title presets in the toolbar; StatusBar + CSS support
- Image handling: internal/images handler, upload route + config, client
  API, EditorCore wiring, and md/html/docx export support for images

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 09:05:40 -07:00

815 lines
23 KiB
Go

package docs
import (
"archive/zip"
"bytes"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// exportRoutes registers the download endpoint: GET /api/docs/{id}/export?format=md|html|txt|docx
func (h *Handler) exportRoutes(r chi.Router) {
r.Get("/{id}/export", h.export)
}
// exportFormat describes one downloadable format: how to render it and how to
// label the resulting file.
type exportFormat struct {
ext string
contentType string
render func(doc db.Document) ([]byte, error)
}
// exportFormats is the supported set. PDF is intentionally absent: a faithful,
// CJK-safe PDF needs an embedded Unicode font (~10MB into the single binary) or
// a headless browser (breaks the no-cgo, single-binary story). The frontend
// offers "Print / Save as PDF" via the browser instead, which uses the reader's
// own fonts and renders CJK correctly for free.
var exportFormats = map[string]exportFormat{
"md": {ext: "md", contentType: "text/markdown; charset=utf-8", render: renderMarkdown},
"html": {ext: "html", contentType: "text/html; charset=utf-8", render: renderHTMLFile},
"txt": {ext: "txt", contentType: "text/plain; charset=utf-8", render: renderPlainText},
"docx": {
ext: "docx",
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
render: renderDocx,
},
}
// export streams the document in the requested format as a download.
func (h *Handler) export(w http.ResponseWriter, r *http.Request) {
formatKey := r.URL.Query().Get("format")
if formatKey == "" {
formatKey = "md"
}
format, ok := exportFormats[formatKey]
if !ok {
badRequest(w, "unsupported export format")
return
}
doc, err := h.fetch(chi.URLParam(r, "id"))
if errors.Is(err, sql.ErrNoRows) {
notFound(w)
return
}
if err != nil {
serverError(w, err)
return
}
body, err := format.render(doc)
if err != nil {
serverError(w, err)
return
}
filename := sanitizeFilename(doc.Title) + "." + format.ext
w.Header().Set("Content-Type", format.contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename)))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
_, _ = w.Write(body)
}
// --- Tiptap document model --------------------------------------------------
// pmNode is one ProseMirror/Tiptap node. The tree is what the editor stores in
// Document.Content; we walk it to render every export format from one source.
type pmNode struct {
Type string `json:"type"`
Attrs map[string]any `json:"attrs"`
Marks []pmMark `json:"marks"`
Text string `json:"text"`
Content []pmNode `json:"content"`
}
type pmMark struct {
Type string `json:"type"`
Attrs map[string]any `json:"attrs"`
}
// parseDoc decodes Document.Content into a node tree. On empty or malformed JSON
// it falls back to wrapping the plain-text mirror in paragraphs, so export never
// fails just because the editor state is unusual.
func parseDoc(doc db.Document) pmNode {
var root pmNode
if err := json.Unmarshal([]byte(doc.Content), &root); err != nil || root.Type == "" {
return fallbackDoc(doc.ContentText)
}
if len(root.Content) == 0 && strings.TrimSpace(doc.ContentText) != "" {
return fallbackDoc(doc.ContentText)
}
return root
}
// fallbackDoc builds a minimal doc node from plain text, one paragraph per line.
func fallbackDoc(text string) pmNode {
root := pmNode{Type: "doc"}
for _, line := range strings.Split(text, "\n") {
p := pmNode{Type: "paragraph"}
if line != "" {
p.Content = []pmNode{{Type: "text", Text: line}}
}
root.Content = append(root.Content, p)
}
return root
}
func (n pmNode) hasMark(t string) bool {
for _, m := range n.Marks {
if m.Type == t {
return true
}
}
return false
}
// attrStr returns a string-valued attribute (e.g. an image src), or "".
func (n pmNode) attrStr(key string) string {
if n.Attrs == nil {
return ""
}
if s, ok := n.Attrs[key].(string); ok {
return s
}
return ""
}
// markAttr returns a string attribute from the named mark (e.g. a link href).
func (n pmNode) markAttr(markType, key string) string {
for _, m := range n.Marks {
if m.Type == markType && m.Attrs != nil {
if s, ok := m.Attrs[key].(string); ok {
return s
}
}
}
return ""
}
func (n pmNode) level() int {
if n.Attrs == nil {
return 1
}
if l, ok := n.Attrs["level"].(float64); ok && l >= 1 {
return int(l)
}
return 1
}
// --- Markdown ---------------------------------------------------------------
func renderMarkdown(doc db.Document) ([]byte, error) {
root := parseDoc(doc)
var blocks []string
for _, child := range root.Content {
if s := mdBlock(child, 0); s != "" {
blocks = append(blocks, s)
}
}
out := "# " + doc.Title + "\n\n" + strings.Join(blocks, "\n\n") + "\n"
return []byte(out), nil
}
func mdBlock(n pmNode, depth int) string {
switch n.Type {
case "heading":
return strings.Repeat("#", n.level()) + " " + mdInline(n.Content)
case "paragraph":
return mdInline(n.Content)
case "blockquote":
var lines []string
for _, c := range n.Content {
for _, l := range strings.Split(mdBlock(c, depth), "\n") {
lines = append(lines, "> "+l)
}
}
return strings.Join(lines, "\n")
case "codeBlock":
return "```\n" + textContent(n) + "\n```"
case "horizontalRule":
return "---"
case "image":
alt := n.attrStr("alt")
return fmt.Sprintf("![%s](%s)", alt, n.attrStr("src"))
case "table":
return mdTable(n)
case "bulletList", "orderedList":
var items []string
for i, item := range n.Content {
marker := "- "
if n.Type == "orderedList" {
marker = fmt.Sprintf("%d. ", i+1)
}
indent := strings.Repeat(" ", depth)
// A listItem holds block children (usually one paragraph).
var parts []string
for _, c := range item.Content {
parts = append(parts, mdBlock(c, depth+1))
}
items = append(items, indent+marker+strings.TrimSpace(strings.Join(parts, "\n")))
}
return strings.Join(items, "\n")
default:
// Unknown block: render any inline text it carries.
if len(n.Content) > 0 {
return mdInline(n.Content)
}
return ""
}
}
func mdInline(nodes []pmNode) string {
var b strings.Builder
for _, n := range nodes {
switch n.Type {
case "text":
b.WriteString(applyMdMarks(n))
case "hardBreak":
b.WriteString(" \n")
default:
b.WriteString(mdInline(n.Content))
}
}
return b.String()
}
// mdTable renders a table node as a GitHub-flavored Markdown table. The first
// row is used as the header (Markdown tables require one); a separator row is
// inserted after it. Cell text is flattened to inline Markdown.
func mdTable(n pmNode) string {
var rows [][]string
for _, row := range n.Content {
if row.Type != "tableRow" {
continue
}
var cells []string
for _, cell := range row.Content {
// A cell holds block children (usually one paragraph); flatten them.
var parts []string
for _, c := range cell.Content {
parts = append(parts, mdInline(c.Content))
}
// Escape pipes so cell content doesn't break the column layout.
cells = append(cells, strings.ReplaceAll(strings.TrimSpace(strings.Join(parts, " ")), "|", "\\|"))
}
rows = append(rows, cells)
}
if len(rows) == 0 {
return ""
}
cols := 0
for _, r := range rows {
if len(r) > cols {
cols = len(r)
}
}
pad := func(r []string) string {
for len(r) < cols {
r = append(r, "")
}
return "| " + strings.Join(r, " | ") + " |"
}
var lines []string
lines = append(lines, pad(rows[0]))
sep := make([]string, cols)
for i := range sep {
sep[i] = "---"
}
lines = append(lines, "| "+strings.Join(sep, " | ")+" |")
for _, r := range rows[1:] {
lines = append(lines, pad(r))
}
return strings.Join(lines, "\n")
}
func applyMdMarks(n pmNode) string {
t := n.Text
if n.hasMark("code") {
return "`" + t + "`" // code spans don't combine with other emphasis
}
if n.hasMark("bold") {
t = "**" + t + "**"
}
if n.hasMark("italic") {
t = "*" + t + "*"
}
if n.hasMark("strike") {
t = "~~" + t + "~~"
}
if n.hasMark("underline") {
t = "<u>" + t + "</u>"
}
if href := n.markAttr("link", "href"); href != "" {
t = "[" + t + "](" + href + ")"
}
return t
}
// --- Plain text -------------------------------------------------------------
func renderPlainText(doc db.Document) ([]byte, error) {
root := parseDoc(doc)
var blocks []string
for _, child := range root.Content {
if s := txtBlock(child); s != "" {
blocks = append(blocks, s)
}
}
out := doc.Title + "\n\n" + strings.Join(blocks, "\n\n") + "\n"
return []byte(out), nil
}
func txtBlock(n pmNode) string {
switch n.Type {
case "bulletList", "orderedList":
var items []string
for i, item := range n.Content {
marker := "• "
if n.Type == "orderedList" {
marker = fmt.Sprintf("%d. ", i+1)
}
items = append(items, marker+strings.TrimSpace(textContent(item)))
}
return strings.Join(items, "\n")
case "horizontalRule":
return "----------"
default:
return textContent(n)
}
}
// textContent flattens all descendant text, joining hardBreaks as newlines.
func textContent(n pmNode) string {
if n.Type == "text" {
return n.Text
}
if n.Type == "hardBreak" {
return "\n"
}
var b strings.Builder
for _, c := range n.Content {
b.WriteString(textContent(c))
}
return b.String()
}
// --- HTML -------------------------------------------------------------------
func renderHTMLFile(doc db.Document) ([]byte, error) {
root := parseDoc(doc)
var body strings.Builder
for _, child := range root.Content {
body.WriteString(htmlBlock(child))
}
page := fmt.Sprintf(htmlTemplate, htmlEscape(doc.Title), htmlEscape(doc.Title), body.String())
return []byte(page), nil
}
// htmlTemplate is a standalone, self-contained page with a warm, readable
// stylesheet and a CJK-first font stack so exported writing looks like Petal,
// not a raw dump. No external assets — opens offline anywhere.
const htmlTemplate = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>%s</title>
<style>
:root { color-scheme: light; }
body {
font-family: "Georgia", "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", serif;
line-height: 1.75; color: #463a3f; background: #fffafb;
max-width: 42rem; margin: 3rem auto; padding: 0 1.5rem;
}
h1, h2, h3 { font-family: "Georgia", "Songti SC", serif; color: #b04a6a; line-height: 1.3; }
h1 { font-size: 1.9rem; border-bottom: 2px solid #f6d6e0; padding-bottom: .4rem; }
blockquote { border-left: 3px solid #f3b6c8; margin: 1rem 0; padding: .2rem 1rem; color: #6b5860; background: #fff2f6; }
code { background: #fdeef3; padding: .1rem .35rem; border-radius: .3rem; font-size: .9em; }
pre { background: #fdeef3; padding: 1rem; border-radius: .6rem; overflow-x: auto; }
pre code { background: none; padding: 0; }
hr { border: none; border-top: 1px solid #f3cdd9; margin: 2rem 0; }
a { color: #b04a6a; }
ul, ol { padding-left: 1.4rem; }
</style>
</head>
<body>
<h1>%s</h1>
%s</body>
</html>
`
func htmlBlock(n pmNode) string {
switch n.Type {
case "heading":
tag := fmt.Sprintf("h%d", clampHeading(n.level()))
return fmt.Sprintf("<%s>%s</%s>\n", tag, htmlInline(n.Content), tag)
case "paragraph":
inner := htmlInline(n.Content)
if inner == "" {
return "<p><br></p>\n"
}
return "<p>" + inner + "</p>\n"
case "blockquote":
var b strings.Builder
for _, c := range n.Content {
b.WriteString(htmlBlock(c))
}
return "<blockquote>" + b.String() + "</blockquote>\n"
case "codeBlock":
return "<pre><code>" + htmlEscape(textContent(n)) + "</code></pre>\n"
case "horizontalRule":
return "<hr>\n"
case "image":
alt := htmlEscape(n.attrStr("alt"))
return fmt.Sprintf("<p><img src=\"%s\" alt=\"%s\"></p>\n", htmlEscape(n.attrStr("src")), alt)
case "table":
return htmlTable(n)
case "bulletList", "orderedList":
tag := "ul"
if n.Type == "orderedList" {
tag = "ol"
}
var b strings.Builder
b.WriteString("<" + tag + ">\n")
for _, item := range n.Content {
var inner strings.Builder
for _, c := range item.Content {
// Unwrap a lone paragraph so list items aren't double-spaced.
if c.Type == "paragraph" {
inner.WriteString(htmlInline(c.Content))
} else {
inner.WriteString(htmlBlock(c))
}
}
b.WriteString("<li>" + inner.String() + "</li>\n")
}
b.WriteString("</" + tag + ">\n")
return b.String()
default:
if len(n.Content) > 0 {
return "<p>" + htmlInline(n.Content) + "</p>\n"
}
return ""
}
}
// htmlTable renders a table node as an HTML <table>. tableHeader cells become
// <th>, tableCell cells become <td>; each cell's block children are flattened
// to inline HTML.
func htmlTable(n pmNode) string {
var b strings.Builder
b.WriteString("<table>\n")
for _, row := range n.Content {
if row.Type != "tableRow" {
continue
}
b.WriteString("<tr>")
for _, cell := range row.Content {
tag := "td"
if cell.Type == "tableHeader" {
tag = "th"
}
var inner strings.Builder
for _, c := range cell.Content {
if c.Type == "paragraph" {
inner.WriteString(htmlInline(c.Content))
} else {
inner.WriteString(htmlBlock(c))
}
}
b.WriteString("<" + tag + ">" + inner.String() + "</" + tag + ">")
}
b.WriteString("</tr>\n")
}
b.WriteString("</table>\n")
return b.String()
}
func htmlInline(nodes []pmNode) string {
var b strings.Builder
for _, n := range nodes {
switch n.Type {
case "text":
b.WriteString(applyHTMLMarks(n))
case "hardBreak":
b.WriteString("<br>")
default:
b.WriteString(htmlInline(n.Content))
}
}
return b.String()
}
func applyHTMLMarks(n pmNode) string {
t := htmlEscape(n.Text)
if n.hasMark("code") {
return "<code>" + t + "</code>"
}
if n.hasMark("bold") {
t = "<strong>" + t + "</strong>"
}
if n.hasMark("italic") {
t = "<em>" + t + "</em>"
}
if n.hasMark("underline") {
t = "<u>" + t + "</u>"
}
if n.hasMark("strike") {
t = "<s>" + t + "</s>"
}
if n.hasMark("highlight") {
t = "<mark>" + t + "</mark>"
}
if href := n.markAttr("link", "href"); href != "" {
t = fmt.Sprintf("<a href=\"%s\">%s</a>", htmlEscape(href), t)
}
return t
}
func clampHeading(level int) int {
if level < 1 {
return 1
}
if level > 6 {
return 6
}
return level
}
// --- DOCX -------------------------------------------------------------------
// renderDocx builds a minimal but valid .docx (Office Open XML) in pure Go: a
// zip of the few XML parts Word needs. Bold/italic/underline/strike map to run
// properties; headings use Word's built-in styles; lists are rendered with a
// bullet/number prefix (no numbering.xml dependency). CJK renders with the
// reader's own fonts, so no font embedding is required.
func renderDocx(doc db.Document) ([]byte, error) {
root := parseDoc(doc)
var body strings.Builder
body.WriteString(docxHeading(doc.Title, 1))
for _, child := range root.Content {
body.WriteString(docxBlock(child))
}
document := xmlHeader + `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
`<w:body>` + body.String() +
`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>` +
`</w:body></w:document>`
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
parts := []struct{ name, content string }{
{"[Content_Types].xml", docxContentTypes},
{"_rels/.rels", docxRootRels},
{"word/_rels/document.xml.rels", docxDocRels},
{"word/document.xml", document},
}
for _, p := range parts {
fw, err := zw.Create(p.name)
if err != nil {
return nil, err
}
if _, err := fw.Write([]byte(p.content)); err != nil {
return nil, err
}
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
const (
xmlHeader = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n"
docxContentTypes = xmlHeader + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
`<Default Extension="xml" ContentType="application/xml"/>` +
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>` +
`</Types>`
docxRootRels = xmlHeader + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>` +
`</Relationships>`
docxDocRels = xmlHeader + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
)
func docxBlock(n pmNode) string {
switch n.Type {
case "heading":
return docxHeading(textContent(n), n.level())
case "paragraph":
return docxPara(n, "")
case "blockquote":
var b strings.Builder
for _, c := range n.Content {
b.WriteString(docxPara(c, "Quote"))
}
return b.String()
case "codeBlock":
// One paragraph per line preserves layout without a code style.
var b strings.Builder
for _, line := range strings.Split(textContent(n), "\n") {
b.WriteString(`<w:p><w:r><w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/></w:rPr>` +
`<w:t xml:space="preserve">` + xmlEscape(line) + `</w:t></w:r></w:p>`)
}
return b.String()
case "horizontalRule":
return `<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="auto"/></w:pBdr></w:pPr></w:p>`
case "image":
// Image bytes aren't embedded (that needs media parts); leave a labeled
// placeholder so the reader knows an image belonged here.
label := n.attrStr("alt")
if label == "" {
label = "image"
}
return docxPara(pmNode{Content: []pmNode{{Type: "text", Text: "[" + label + "]", Marks: []pmMark{{Type: "italic"}}}}}, "")
case "table":
return docxTable(n)
case "bulletList", "orderedList":
var b strings.Builder
for i, item := range n.Content {
prefix := "• "
if n.Type == "orderedList" {
prefix = fmt.Sprintf("%d. ", i+1)
}
for _, c := range item.Content {
b.WriteString(docxPara(c, "", prefix))
}
}
return b.String()
default:
if len(n.Content) > 0 {
return docxPara(n, "")
}
return ""
}
}
// docxTable renders a table node as a Word table (w:tbl) with single-line
// borders. Header cells get a shaded background; every cell's block children are
// rendered as paragraphs inside the cell.
func docxTable(n pmNode) string {
var b strings.Builder
b.WriteString(`<w:tbl><w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="0" w:type="auto"/>` +
`<w:tblBorders>` +
`<w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
`<w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
`<w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
`<w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
`<w:insideH w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
`<w:insideV w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
`</w:tblBorders></w:tblPr>`)
for _, row := range n.Content {
if row.Type != "tableRow" {
continue
}
b.WriteString("<w:tr>")
for _, cell := range row.Content {
b.WriteString("<w:tc><w:tcPr>")
if cell.Type == "tableHeader" {
b.WriteString(`<w:shd w:val="clear" w:color="auto" w:fill="FFF0F5"/>`)
}
b.WriteString("</w:tcPr>")
// A cell must contain at least one paragraph to be valid.
if len(cell.Content) == 0 {
b.WriteString("<w:p/>")
}
for _, c := range cell.Content {
b.WriteString(docxBlock(c))
}
b.WriteString("</w:tc>")
}
b.WriteString("</w:tr>")
}
b.WriteString("</w:tbl>")
// Word needs a paragraph after a table; otherwise consecutive tables merge.
b.WriteString("<w:p/>")
return b.String()
}
// docxPara renders a block's inline children as a Word paragraph. An optional
// style name (e.g. "Quote") and an optional literal text prefix (for list
// markers) may be supplied.
func docxPara(n pmNode, style string, prefix ...string) string {
var b strings.Builder
b.WriteString("<w:p>")
if style != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="` + style + `"/></w:pPr>`)
}
if len(prefix) > 0 && prefix[0] != "" {
b.WriteString(docxRun(pmNode{Type: "text", Text: prefix[0]}))
}
b.WriteString(docxInline(n.Content))
b.WriteString("</w:p>")
return b.String()
}
func docxHeading(text string, level int) string {
return `<w:p><w:pPr><w:pStyle w:val="Heading` + fmt.Sprintf("%d", clampHeading(level)) + `"/></w:pPr>` +
docxRun(pmNode{Type: "text", Text: text}) + `</w:p>`
}
func docxInline(nodes []pmNode) string {
var b strings.Builder
for _, n := range nodes {
switch n.Type {
case "text":
b.WriteString(docxRun(n))
case "hardBreak":
b.WriteString(`<w:r><w:br/></w:r>`)
default:
b.WriteString(docxInline(n.Content))
}
}
return b.String()
}
func docxRun(n pmNode) string {
var props strings.Builder
if n.hasMark("bold") {
props.WriteString("<w:b/>")
}
if n.hasMark("italic") {
props.WriteString("<w:i/>")
}
if n.hasMark("underline") {
props.WriteString(`<w:u w:val="single"/>`)
}
if n.hasMark("strike") {
props.WriteString("<w:strike/>")
}
if n.hasMark("highlight") {
// Word's text highlight only supports a fixed palette of named colors.
props.WriteString(`<w:highlight w:val="yellow"/>`)
}
if n.markAttr("link", "href") != "" {
// Without hyperlink relationships, style links as blue underlined text so
// they at least read as links (the URL itself is preserved in md/html).
props.WriteString(`<w:color w:val="2563EB"/><w:u w:val="single"/>`)
}
rpr := ""
if props.Len() > 0 {
rpr = "<w:rPr>" + props.String() + "</w:rPr>"
}
return `<w:r>` + rpr + `<w:t xml:space="preserve">` + xmlEscape(n.Text) + `</w:t></w:r>`
}
// --- small helpers ----------------------------------------------------------
func htmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&#39;")
return r.Replace(s)
}
func xmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
return r.Replace(s)
}
// sanitizeFilename makes a title safe as a download filename, preserving CJK and
// most letters while dropping path separators and control characters.
func sanitizeFilename(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "untitled"
}
var b strings.Builder
for _, r := range title {
switch {
case r < 0x20, r == '/', r == '\\', r == ':', r == '*', r == '?', r == '"', r == '<', r == '>', r == '|':
b.WriteRune('-')
default:
b.WriteRune(r)
}
}
out := strings.TrimSpace(b.String())
if out == "" {
return "untitled"
}
return out
}
// urlEscapeFilename percent-encodes a filename for the RFC 5987 filename*=
// Content-Disposition form, which carries UTF-8 (CJK titles) safely.
func urlEscapeFilename(s string) string {
var b strings.Builder
for _, c := range []byte(s) {
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
c == '-' || c == '_' || c == '.' || c == '~' {
b.WriteByte(c)
} else {
fmt.Fprintf(&b, "%%%02X", c)
}
}
return b.String()
}