Backend: - Extract shared internal/httputil (WriteJSON/ErrorJSON/BadRequest/ ServerError); drop the triple-duplicated helpers in docs, suggestions, vocab. ServerError now logs the real error and returns a generic 500 so raw DB/internal errors never reach the client. - vocab capture: validate doc_id ownership (blank -> none, unknown -> 400 instead of a leaked FK 500); rune-safe clamp word/gloss/definition/ phonetic/example. - vocab review(): wrap the read-modify-write in a transaction (TOCTOU). - /api request-size cap via MaxBytesReader middleware (2 MiB), exempting /api/images (own 10 MiB limit). Frontend: - StatusBar: drive the checking/voicing/collocating indicators from one array; llmDown uses !anyBusy. - Slide-overs: new useFocusTrap hook (focus-in, Tab trap, focus-restore) on GardenPanel + HistoryPanel, both role=dialog/aria-modal/aria-label. - speech.ts: export stopSpeech(); GardenPanel cancels audio on unmount. Tests: add doc_id-validation and field-clamp coverage; full suite green. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
902 lines
26 KiB
Go
902 lines
26 KiB
Go
package docs
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
|
)
|
|
|
|
// exportRoutes registers the download endpoints: one document
|
|
// (GET /api/docs/{id}/export?format=…) and a whole-corpus backup zip
|
|
// (GET /api/docs/export-all?format=…). The static "export-all" segment takes
|
|
// priority over the {id} param in chi's router, so the two don't collide.
|
|
func (h *Handler) exportRoutes(r chi.Router) {
|
|
r.Get("/{id}/export", h.export)
|
|
r.Get("/export-all", h.exportAll)
|
|
}
|
|
|
|
// exportAll streams every document the user owns, each rendered in the requested
|
|
// format, bundled into a single zip — a one-click "download all my writing"
|
|
// backup. Per-doc version history guards against bad edits; this guards against
|
|
// a lost disk. Reuses the same renderers as the single-document export.
|
|
func (h *Handler) exportAll(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
|
|
}
|
|
|
|
rows, err := h.DB.Query(
|
|
`SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at
|
|
FROM documents
|
|
WHERE user_id = ?
|
|
ORDER BY updated_at DESC`,
|
|
db.LocalUserID,
|
|
)
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
seen := map[string]int{} // de-duplicate filenames from same-titled docs
|
|
for rows.Next() {
|
|
var doc db.Document
|
|
if err := rows.Scan(
|
|
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
|
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
|
); err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
body, err := format.render(doc)
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
base := sanitizeFilename(doc.Title)
|
|
if base == "" {
|
|
base = "untitled"
|
|
}
|
|
key := base + "." + format.ext
|
|
name := key
|
|
if c := seen[key]; c > 0 {
|
|
name = fmt.Sprintf("%s (%d).%s", base, c, format.ext)
|
|
}
|
|
seen[key]++
|
|
f, err := zw.Create(name)
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
if _, err := f.Write(body); err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
|
|
filename := "petal-backup-" + time.Now().Format("2006-01-02") + ".zip"
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename)))
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", buf.Len()))
|
|
_, _ = w.Write(buf.Bytes())
|
|
}
|
|
|
|
// 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 {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
|
|
body, err := format.render(doc)
|
|
if err != nil {
|
|
httputil.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("", 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("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
|
return r.Replace(s)
|
|
}
|
|
|
|
func xmlEscape(s string) string {
|
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
|
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()
|
|
}
|