diff --git a/.env.example b/.env.example
index 527f21c..2024024 100644
--- a/.env.example
+++ b/.env.example
@@ -7,6 +7,9 @@ BASE_URL=http://localhost:8080
# Database (SQLite, pure-Go modernc — no cgo)
DATABASE_PATH=./data/petal.db
+# On-disk store for images pasted/dropped/inserted in the editor
+IMAGE_DIR=./data/images
+
# LLM
LLM_BACKEND=vllm # vllm | ollama
LLM_ENDPOINT=http://localhost:8000 # vLLM :8000, Ollama :11434
diff --git a/cmd/server/main.go b/cmd/server/main.go
index bea33cc..ff90ceb 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -15,6 +15,7 @@ import (
"gitea.parodia.dev/drwily/petal/internal/config"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/docs"
+ "gitea.parodia.dev/drwily/petal/internal/images"
"gitea.parodia.dev/drwily/petal/internal/lexicon"
"gitea.parodia.dev/drwily/petal/internal/llm"
"gitea.parodia.dev/drwily/petal/internal/suggestions"
@@ -80,6 +81,13 @@ func main() {
lex := lexicon.NewHandler()
api.Mount("/word", lex.Routes())
api.Mount("/gloss", lex.GlossRoutes())
+
+ // Editor image uploads, stored on disk and served back by content hash.
+ imgHandler, err := images.New(cfg.ImageDir)
+ if err != nil {
+ log.Fatalf("image store: %v", err)
+ }
+ api.Mount("/images", imgHandler.Routes())
})
// Everything else: serve the embedded SPA (with index.html fallback for client routing).
diff --git a/internal/config/config.go b/internal/config/config.go
index 9d46a8c..cecbd2b 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -11,6 +11,7 @@ type Config struct {
Port string
BaseURL string
DatabasePath string
+ ImageDir string // on-disk store for editor image uploads
// LLM
LLMBackend string // "vllm" | "ollama"
@@ -32,6 +33,7 @@ func Load() *Config {
Port: env("PORT", "8080"),
BaseURL: env("BASE_URL", "http://localhost:8080"),
DatabasePath: env("DATABASE_PATH", "./data/petal.db"),
+ ImageDir: env("IMAGE_DIR", "./data/images"),
LLMBackend: env("LLM_BACKEND", "vllm"),
LLMEndpoint: env("LLM_ENDPOINT", "http://localhost:8000"),
diff --git a/internal/docs/export.go b/internal/docs/export.go
index 37abd1b..fd4622e 100644
--- a/internal/docs/export.go
+++ b/internal/docs/export.go
@@ -92,7 +92,8 @@ type pmNode struct {
}
type pmMark struct {
- Type string `json:"type"`
+ Type string `json:"type"`
+ Attrs map[string]any `json:"attrs"`
}
// parseDoc decodes Document.Content into a node tree. On empty or malformed JSON
@@ -131,6 +132,29 @@ func (n pmNode) hasMark(t string) bool {
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
@@ -173,6 +197,11 @@ func mdBlock(n pmNode, depth int) string {
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 {
@@ -213,6 +242,55 @@ func mdInline(nodes []pmNode) string {
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") {
@@ -230,6 +308,9 @@ func applyMdMarks(n pmNode) string {
if n.hasMark("underline") {
t = "" + t + ""
}
+ if href := n.markAttr("link", "href"); href != "" {
+ t = "[" + t + "](" + href + ")"
+ }
return t
}
@@ -347,6 +428,11 @@ func htmlBlock(n pmNode) string {
return "
" + htmlEscape(textContent(n)) + "
\n"
case "horizontalRule":
return "\n"
+ case "image":
+ alt := htmlEscape(n.attrStr("alt"))
+ return fmt.Sprintf("
\n", htmlEscape(n.attrStr("src")), alt)
+ case "table":
+ return htmlTable(n)
case "bulletList", "orderedList":
tag := "ul"
if n.Type == "orderedList" {
@@ -376,6 +462,38 @@ func htmlBlock(n pmNode) string {
}
}
+// htmlTable renders a table node as an HTML
. tableHeader cells become
+//
, tableCell cells become
; each cell's block children are flattened
+// to inline HTML.
+func htmlTable(n pmNode) string {
+ var b strings.Builder
+ b.WriteString("
\n")
+ for _, row := range n.Content {
+ if row.Type != "tableRow" {
+ continue
+ }
+ b.WriteString("
")
+ 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("
\n")
+ }
+ b.WriteString("
\n")
+ return b.String()
+}
+
func htmlInline(nodes []pmNode) string {
var b strings.Builder
for _, n := range nodes {
@@ -408,6 +526,12 @@ func applyHTMLMarks(n pmNode) string {
if n.hasMark("strike") {
t = "" + t + ""
}
+ if n.hasMark("highlight") {
+ t = "" + t + ""
+ }
+ if href := n.markAttr("link", "href"); href != "" {
+ t = fmt.Sprintf("%s", htmlEscape(href), t)
+ }
return t
}
@@ -500,6 +624,16 @@ func docxBlock(n pmNode) string {
return b.String()
case "horizontalRule":
return ``
+ 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 {
@@ -520,6 +654,48 @@ func docxBlock(n pmNode) string {
}
}
+// 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(`` +
+ `` +
+ `` +
+ `` +
+ `` +
+ `` +
+ `` +
+ `` +
+ ``)
+ for _, row := range n.Content {
+ if row.Type != "tableRow" {
+ continue
+ }
+ b.WriteString("")
+ for _, cell := range row.Content {
+ b.WriteString("")
+ if cell.Type == "tableHeader" {
+ b.WriteString(``)
+ }
+ b.WriteString("")
+ // A cell must contain at least one paragraph to be valid.
+ if len(cell.Content) == 0 {
+ b.WriteString("")
+ }
+ for _, c := range cell.Content {
+ b.WriteString(docxBlock(c))
+ }
+ b.WriteString("")
+ }
+ b.WriteString("")
+ }
+ b.WriteString("")
+ // Word needs a paragraph after a table; otherwise consecutive tables merge.
+ b.WriteString("")
+ 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.
@@ -571,6 +747,15 @@ func docxRun(n pmNode) string {
if n.hasMark("strike") {
props.WriteString("")
}
+ if n.hasMark("highlight") {
+ // Word's text highlight only supports a fixed palette of named colors.
+ props.WriteString(``)
+ }
+ 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(``)
+ }
rpr := ""
if props.Len() > 0 {
rpr = "" + props.String() + ""
diff --git a/internal/docs/export_test.go b/internal/docs/export_test.go
index fbb607c..25fa459 100644
--- a/internal/docs/export_test.go
+++ b/internal/docs/export_test.go
@@ -126,6 +126,77 @@ func TestExportDocx(t *testing.T) {
}
}
+// richDocJSON2 exercises the newer node/mark types: links, highlight, an image,
+// and a table (with a header row). Each export format should carry them through.
+const richDocJSON2 = `{"type":"doc","content":[` +
+ `{"type":"paragraph","content":[` +
+ `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://petal.test"}}],"text":"site"},` +
+ `{"type":"text","text":" and "},` +
+ `{"type":"text","marks":[{"type":"highlight","attrs":{"color":"#FFF1A8"}}],"text":"lit"}` +
+ `]},` +
+ `{"type":"image","attrs":{"src":"/api/images/abc123.png","alt":"a cat"}},` +
+ `{"type":"table","content":[` +
+ `{"type":"tableRow","content":[` +
+ `{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"Name"}]}]},` +
+ `{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"年龄"}]}]}` +
+ `]},` +
+ `{"type":"tableRow","content":[` +
+ `{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"Mei"}]}]},` +
+ `{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"30"}]}]}` +
+ `]}` +
+ `]}` +
+ `]}`
+
+func seedRichDoc2(t *testing.T, srv http.Handler) string {
+ t.Helper()
+ id := newDoc(t, srv)
+ body := `{"title":"Rich2","content":` + jsonString(richDocJSON2) + `,"content_text":"site and lit\nName 年龄 Mei 30","word_count":6}`
+ if rec := do(t, srv, http.MethodPut, "/"+id, body); rec.Code != http.StatusOK {
+ t.Fatalf("seed rich doc 2: code=%d body=%s", rec.Code, rec.Body)
+ }
+ return id
+}
+
+func TestExportLinksImagesTables(t *testing.T) {
+ srv := newTestServer(t)
+ id := seedRichDoc2(t, srv)
+
+ md := do(t, srv, http.MethodGet, "/"+id+"/export?format=md", "").Body.String()
+ for _, want := range []string{"[site](https://petal.test)", "", "| Name | 年龄 |", "| --- | --- |", "| Mei | 30 |"} {
+ if !strings.Contains(md, want) {
+ t.Fatalf("markdown missing %q in:\n%s", want, md)
+ }
+ }
+
+ html := do(t, srv, http.MethodGet, "/"+id+"/export?format=html", "").Body.String()
+ for _, want := range []string{`site`, "lit", ``, "
Name
", "
Mei
"} {
+ if !strings.Contains(html, want) {
+ t.Fatalf("html missing %q in:\n%s", want, html)
+ }
+ }
+
+ docx := do(t, srv, http.MethodGet, "/"+id+"/export?format=docx", "")
+ raw := docx.Body.Bytes()
+ zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
+ if err != nil {
+ t.Fatalf("docx not a zip: %v", err)
+ }
+ var docXML string
+ for _, f := range zr.File {
+ if f.Name == "word/document.xml" {
+ rc, _ := f.Open()
+ b, _ := io.ReadAll(rc)
+ rc.Close()
+ docXML = string(b)
+ }
+ }
+ for _, want := range []string{"", "Name", "年龄", "[a cat]", " 512 {
+ head = head[:512]
+ }
+ return strings.Contains(strings.ToLower(string(head)), "