Phase 8: version history + export (trust foundation)
Version history: new document_versions table (migration 0003) holding full-body snapshots that cascade with the doc. Throttled auto-snapshots on save (>=3min apart, max 40/doc, pruned), explicit manual restore points, and a pre_restore safety copy taken before each restore so restoring is itself undoable. Endpoints under /api/docs/:id/versions, all owner-scoped. Empties and bare renames never snapshot. Export: pure-Go Tiptap-JSON -> Markdown / HTML / plain-text / docx (no cgo/pandoc, single-binary intact), CJK-safe with RFC 5987 filenames. docx is a hand-built OOXML zip. PDF is handled client-side via the browser print dialog + an @media print stylesheet so CJK renders with the reader's own fonts. Frontend: ExportMenu (downloads + Print/PDF) and HistoryPanel (snapshot list, preview, restore) wired into the title row; bilingual zh-first to match chrome. Restore remounts the editor via editorEpoch. Stop saving empty docs: blank Untitled drafts now reuse-on-create and self-discard when navigated away from (refs avoid stale closures). Tests: versions_test.go, export_test.go (incl. valid-zip docx). go build/vet/test, tsc, vite build all clean; live end-to-end smoke verified snapshot/throttle/restore/export. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
629
internal/docs/export.go
Normal file
629
internal/docs/export.go
Normal file
@@ -0,0 +1,629 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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 "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()
|
||||
}
|
||||
|
||||
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>"
|
||||
}
|
||||
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 "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 ""
|
||||
}
|
||||
}
|
||||
|
||||
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>"
|
||||
}
|
||||
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 "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 ""
|
||||
}
|
||||
}
|
||||
|
||||
// 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/>")
|
||||
}
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user