Petal ran as a single hardcoded user, with db.LocalUserID named directly
at ~35 query sites. That made the caller's identity a compile-time
constant scattered across every package — nothing a real login could
replace without touching all of them.
New internal/auth moves it into the request context:
- Middleware(Resolver) resolves the caller once per API request
- handlers read auth.UserID(r.Context()) instead of naming a user
- Resolver is the seam an Authentik session check drops into
- StaticResolver(db.LocalUserID) keeps Petal single-user today
Behavior is unchanged. UserID returns "" rather than panicking when the
middleware is absent, so a mis-wired route fails closed: every query is
WHERE user_id = ?, which then matches nothing.
main.go splits /api into a public group (/health, /version) and an
authenticated group for everything else — a monitoring probe must not
need a session.
Two pre-existing access-control gaps fixed while threading, both
harmless with one user and not with two:
- setStatus (accept/dismiss) updated a suggestion by bare id with no
ownership check at all
- listForDoc/fetchPending read a document's suggestions by doc_id
alone; a suggestion quotes the sentence it corrects, so that leaked
the source prose
Both now scope through documents.user_id.
Tests: internal/auth covers the context round-trip, the absent-context
case, and both 401 paths. Two-user isolation suites in docs and
suggestions mount the same routers twice behind two resolvers over one
database and assert a stranger gets 404 on every id-taking path, sees
nothing in list/search, and leaves the owner's data untouched.
Those suites earned their keep immediately: docs.fetch gained a userID
parameter but kept binding db.LocalUserID in the query. Unused
parameters are legal Go, so it compiled clean, vet was silent, and every
existing test passed while the lookup stayed unscoped.
Still global, out of scope and flagged in BUILD_PLAN.md: the image store
has no per-user association, and frontend localStorage keys are
per-browser rather than per-account.
903 lines
26 KiB
Go
903 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/auth"
|
|
"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`,
|
|
auth.UserID(r.Context()),
|
|
)
|
|
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(auth.UserID(r.Context()), 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()
|
|
}
|