Use prosemirror pkg to render document PDF

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-03-27 16:17:15 +04:00
parent 1e55bccdf8
commit e71e7f1a45
7 changed files with 170 additions and 184 deletions

2
go.mod
View File

@@ -32,8 +32,6 @@ require (
github.com/stretchr/testify v1.11.1
github.com/vektah/gqlparser/v2 v2.5.32
github.com/vikstrous/dataloadgen v0.0.10
github.com/yuin/goldmark v1.7.16
go.abhg.dev/goldmark/mermaid v0.6.0
go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712
go.gearno.de/kit v0.1.1
go.gearno.de/x/ref v0.0.0-20260216110753-a700c951377c

4
go.sum
View File

@@ -322,10 +322,6 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.abhg.dev/goldmark/mermaid v0.6.0 h1:VvkYFWuOjD6cmSBVJpLAtzpVCGM1h0B7/DQ9IzERwzY=
go.abhg.dev/goldmark/mermaid v0.6.0/go.mod h1:uMc+PcnIH2NVL7zjH10Q1wr7hL3+4n4jUMifhyBYB9I=
go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712 h1:J5ccbcxFuwxe6Oa9fVi9FqQOo+n17ni4wbl9t4NuEzc=
go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712/go.mod h1:fnIIvKO9QnsyLO3ZJLJT3r8KZv/p0FOeT5eZKilYWXg=
go.gearno.de/kit v0.1.1 h1:QuBZCZ/h2Eyh6DjjR6CGjkdsab/ztHz6xUiIk0FeREE=

View File

@@ -23,11 +23,8 @@ import (
"strings"
"time"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
gmhtml "github.com/yuin/goldmark/renderer/html"
"go.abhg.dev/goldmark/mermaid"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/prosemirror"
)
var (
@@ -80,26 +77,6 @@ var (
}
return "No"
},
"formatContent": func(content string) template.HTML {
md := goldmark.New(
goldmark.WithExtensions(
extension.Table,
&mermaid.Extender{
RenderMode: mermaid.RenderModeClient,
NoScript: true,
},
),
goldmark.WithRendererOptions(
gmhtml.WithUnsafe(),
),
)
var buf bytes.Buffer
if err := md.Convert([]byte(content), &buf); err != nil {
return template.HTML(fmt.Sprintf("<p>%s</p>", html.EscapeString(content)))
}
return template.HTML(buf.String())
},
"imgTag": func(src, alt, class string) template.HTML {
return template.HTML(fmt.Sprintf(`<img src="%s" alt="%s" class="%s">`, html.EscapeString(src), html.EscapeString(alt), html.EscapeString(class)))
},
@@ -217,7 +194,7 @@ type (
DocumentData struct {
Title string
Content string
Content string // ProseMirror/Tiptap document JSON; use ProseMirrorJSONToHTML for HTML
Major int
Minor int
Classification Classification
@@ -340,11 +317,37 @@ const (
ClassificationSecret Classification = "SECRET"
)
// ProseMirrorJSONToHTML converts ProseMirror/Tiptap document JSON to an HTML fragment.
// On parse or render failure it returns a single escaped paragraph with the raw input.
func ProseMirrorJSONToHTML(content string) template.HTML {
s := strings.TrimSpace(content)
if s == "" {
return template.HTML("")
}
node, err := prosemirror.Parse(s)
if err != nil {
return template.HTML(fmt.Sprintf("<p>%s</p>", html.EscapeString(s)))
}
htmlStr, err := prosemirror.RenderHTML(node)
if err != nil {
return template.HTML(fmt.Sprintf("<p>%s</p>", html.EscapeString(s)))
}
return template.HTML(htmlStr)
}
func RenderHTML(data DocumentData) ([]byte, error) {
data.MermaidJS = template.JS(mermaidJSSource)
page := struct {
DocumentData
BodyHTML template.HTML
}{
DocumentData: data,
BodyHTML: ProseMirrorJSONToHTML(data.Content),
}
var buf bytes.Buffer
if err := documentTemplate.Execute(&buf, data); err != nil {
if err := documentTemplate.Execute(&buf, page); err != nil {
return nil, fmt.Errorf("cannot execute template: %w", err)
}

View File

@@ -38,7 +38,7 @@ func TestRenderHTML(t *testing.T) {
name: "basic document with all fields",
data: DocumentData{
Title: "Test Document",
Content: "# Main Title\n\nThis is **bold** text with *italic* formatting.",
Content: `{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"Main Title"}]},{"type":"paragraph","content":[{"type":"text","text":"This is "},{"type":"text","marks":[{"type":"bold"}],"text":"bold"},{"type":"text","text":" text with "},{"type":"text","marks":[{"type":"italic"}],"text":"italic"},{"type":"text","text":" formatting."}]}]}`,
Major: 1,
Classification: ClassificationPublic,
Approvers: []string{"John Doe"},
@@ -87,20 +87,32 @@ func TestRenderHTML(t *testing.T) {
},
},
{
name: "document with markdown content",
name: "document with prosemirror content",
data: DocumentData{
Title: "Markdown Test",
Content: "## Section 1\n\n- Item 1\n- Item 2\n\n**Bold text** and *italic text*\n\n```code block```",
Title: "ProseMirror Test",
Content: `{"type":"doc","content":[` +
`{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Section 1"}]},` +
`{"type":"bulletList","content":[` +
`{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Item 1"}]}]},` +
`{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Item 2"}]}]}` +
`]},` +
`{"type":"paragraph","content":[` +
`{"type":"text","marks":[{"type":"bold"}],"text":"Bold text"},` +
`{"type":"text","text":" and "},` +
`{"type":"text","marks":[{"type":"italic"}],"text":"italic text"}` +
`]},` +
`{"type":"codeBlock","content":[{"type":"text","text":"code block"}]}` +
`]}`,
},
wantContains: []string{
"<h2>Section 1</h2>",
"<ul>",
"<li>Item 1</li>",
"<li>Item 2</li>",
"<li><p>Item 1</p></li>",
"<li><p>Item 2</p></li>",
"</ul>",
"<strong>Bold text</strong>",
"<em>italic text</em>",
"<code>code block</code>",
"<pre><code>code block</code></pre>",
},
},
{
@@ -176,10 +188,9 @@ func TestRenderHTML(t *testing.T) {
}
func TestRenderHTML_ErrorHandling(t *testing.T) {
// Test with data that should not cause errors
data := DocumentData{
Title: "Valid Document",
Content: "Valid content",
Content: `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Valid content"}]}]}`,
}
result, err := RenderHTML(data)
@@ -214,20 +225,21 @@ func TestTemplateFunctions(t *testing.T) {
assert.Equal(t, "CONFIDENTIAL", classFunc(ClassificationConfidential))
})
t.Run("formatContent function", func(t *testing.T) {
formatFunc := templateFuncs["formatContent"].(func(string) template.HTML)
// Test markdown conversion
result := formatFunc("**bold** text")
t.Run("ProseMirrorJSONToHTML", func(t *testing.T) {
result := ProseMirrorJSONToHTML(
`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"plain "},{"type":"text","marks":[{"type":"bold"}],"text":"bold"}]}]}`,
)
assert.Contains(t, string(result), "<strong>bold</strong>")
// Test basic text
result = formatFunc("simple text")
result = ProseMirrorJSONToHTML(
`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"simple text"}]}]}`,
)
assert.Contains(t, string(result), "<p>simple text</p>")
// Test empty content - goldmark produces empty output for empty input
result = formatFunc("")
// Empty content should produce empty result from goldmark
result = ProseMirrorJSONToHTML("**not** json")
assert.Contains(t, string(result), "<p>**not** json</p>")
result = ProseMirrorJSONToHTML("")
assert.Equal(t, template.HTML(""), result)
})
}
@@ -276,44 +288,62 @@ func TestHTMLEscaping(t *testing.T) {
assert.Contains(t, resultStr, "&#39;")
}
func TestMarkdownRendering(t *testing.T) {
func TestProseMirrorContentRendering(t *testing.T) {
tests := []struct {
name string
markdown string
want []string
name string
content string
want []string
}{
{
name: "headers",
markdown: "# H1\n## H2\n### H3",
want: []string{"<h1>H1</h1>", "<h2>H2</h2>", "<h3>H3</h3>"},
name: "headers",
content: `{"type":"doc","content":[` +
`{"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"H1"}]},` +
`{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"H2"}]},` +
`{"type":"heading","attrs":{"level":3},"content":[{"type":"text","text":"H3"}]}` +
`]}`,
want: []string{"<h1>H1</h1>", "<h2>H2</h2>", "<h3>H3</h3>"},
},
{
name: "emphasis",
markdown: "**bold** and *italic*",
want: []string{"<strong>bold</strong>", "<em>italic</em>"},
name: "emphasis",
content: `{"type":"doc","content":[{"type":"paragraph","content":[` +
`{"type":"text","marks":[{"type":"bold"}],"text":"bold"},` +
`{"type":"text","text":" and "},` +
`{"type":"text","marks":[{"type":"italic"}],"text":"italic"}` +
`]}]}`,
want: []string{"<strong>bold</strong>", "<em>italic</em>"},
},
{
name: "lists",
markdown: "- Item 1\n- Item 2",
want: []string{"<ul>", "<li>Item 1</li>", "<li>Item 2</li>", "</ul>"},
name: "lists",
content: `{"type":"doc","content":[{"type":"bulletList","content":[` +
`{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Item 1"}]}]},` +
`{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Item 2"}]}]}` +
`]}]}`,
want: []string{"<ul>", "<li><p>Item 1</p></li>", "<li><p>Item 2</p></li>", "</ul>"},
},
{
name: "paragraphs",
markdown: "Paragraph 1\n\nParagraph 2",
want: []string{"<p>Paragraph 1</p>", "<p>Paragraph 2</p>"},
name: "paragraphs",
content: `{"type":"doc","content":[` +
`{"type":"paragraph","content":[{"type":"text","text":"Paragraph 1"}]},` +
`{"type":"paragraph","content":[{"type":"text","text":"Paragraph 2"}]}` +
`]}`,
want: []string{"<p>Paragraph 1</p>", "<p>Paragraph 2</p>"},
},
{
name: "code",
markdown: "`inline code` and\n```\ncode block\n```",
want: []string{"<code>inline code</code>", "<pre><code>code block"},
name: "code",
content: `{"type":"doc","content":[` +
`{"type":"paragraph","content":[{"type":"text","marks":[{"type":"code"}],"text":"inline code"}]},` +
`{"type":"paragraph","content":[{"type":"text","text":" and "}]},` +
`{"type":"codeBlock","content":[{"type":"text","text":"code block"}]}` +
`]}`,
want: []string{"<code>inline code</code>", "<pre><code>code block</code></pre>"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := DocumentData{
Title: "Markdown Test",
Content: tt.markdown,
Title: "ProseMirror Test",
Content: tt.content,
}
result, err := RenderHTML(data)
@@ -357,14 +387,29 @@ func TestDocumentVersionSignatureStates(t *testing.T) {
}
func TestLargeContent(t *testing.T) {
// Create a large markdown content
var largeContent strings.Builder
largeContent.WriteString(`{"type":"doc","content":[`)
for i := range 1000 {
largeContent.WriteString("# Section ")
largeContent.WriteString(string(rune('A' + i%26)))
largeContent.WriteString("\n\nThis is a paragraph with **bold** and *italic* text.\n\n")
largeContent.WriteString("- List item 1\n- List item 2\n- List item 3\n\n")
if i > 0 {
largeContent.WriteByte(',')
}
largeContent.WriteString(`{"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"Section `)
largeContent.WriteByte(byte('A' + i%26))
largeContent.WriteString(`"}]},`)
largeContent.WriteString(`{"type":"paragraph","content":[` +
`{"type":"text","text":"This is a paragraph with "},` +
`{"type":"text","marks":[{"type":"bold"}],"text":"bold"},` +
`{"type":"text","text":" and "},` +
`{"type":"text","marks":[{"type":"italic"}],"text":"italic"},` +
`{"type":"text","text":" text."}` +
`]},`)
largeContent.WriteString(`{"type":"bulletList","content":[` +
`{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"List item 1"}]}]},` +
`{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"List item 2"}]}]},` +
`{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"List item 3"}]}]}` +
`]}`)
}
largeContent.WriteString(`]}`)
data := DocumentData{
Title: "Large Document",
@@ -381,8 +426,21 @@ func BenchmarkGenerateHTML(b *testing.B) {
now := time.Now()
data := DocumentData{
Title: "Benchmark Document",
Content: "# Title\n\nThis is **bold** text with *italic* formatting.\n\n- Item 1\n- Item 2",
Title: "Benchmark Document",
Content: `{"type":"doc","content":[` +
`{"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"Title"}]},` +
`{"type":"paragraph","content":[` +
`{"type":"text","text":"This is "},` +
`{"type":"text","marks":[{"type":"bold"}],"text":"bold"},` +
`{"type":"text","text":" text with "},` +
`{"type":"text","marks":[{"type":"italic"}],"text":"italic"},` +
`{"type":"text","text":" formatting."}` +
`]},` +
`{"type":"bulletList","content":[` +
`{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Item 1"}]}]},` +
`{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Item 2"}]}]}` +
`]}` +
`]}`,
Major: 1,
Classification: ClassificationPublic,
Approvers: []string{"John Doe"},

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -9,6 +10,7 @@
@page {
size: A4;
margin: 2.5cm;
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-family: Arial, sans-serif;
@@ -32,14 +34,15 @@
width: 21cm;
margin: 0 auto;
background: white;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
/* Individual page sections */
.page-section {
padding: 2.5cm;
position: relative;
min-height: 24.7cm; /* A4 height minus padding */
min-height: 24.7cm;
/* A4 height minus padding */
border-bottom: 2px dashed #ddd;
page-break-after: always;
}
@@ -75,7 +78,7 @@
text-align: left;
}
.company-header + .document-title {
.company-header+.document-title {
margin-top: 30px;
}
@@ -248,16 +251,23 @@
}
/* Prevent bad page breaks */
h1, h2, h3, h4, h5, h6 {
h1,
h2,
h3,
h4,
h5,
h6 {
page-break-after: avoid;
page-break-inside: avoid;
}
p, li {
p,
li {
page-break-inside: avoid;
}
table, .signatures-section {
table,
.signatures-section {
page-break-inside: avoid;
}
@@ -367,6 +377,7 @@
});
</script>
</head>
<body>
<div class="document-container">
<div class="page-section">
@@ -394,9 +405,9 @@
{{index .Approvers 0}}
{{- else}}
<ul style="margin: 0; padding-left: 18px;">
{{- range .Approvers}}
{{- range .Approvers}}
<li>{{.}}</li>
{{- end}}
{{- end}}
</ul>
{{- end}}
</td>
@@ -417,7 +428,7 @@
</div>
<div class="document-content">
{{.Content | formatContent}}
{{.BodyHTML}}
</div>
{{- if .Signatures}}
@@ -460,4 +471,5 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -110,6 +110,16 @@ const (
MarkLink MarkType = "link"
)
// Parse unmarshals a ProseMirror/Tiptap JSON document string into a Node tree.
// Nested content and marks are unmarshaled into Go structs recursively.
func Parse(s string) (Node, error) {
var n Node
if err := json.Unmarshal([]byte(s), &n); err != nil {
return Node{}, fmt.Errorf("cannot parse prosemirror node: %w", err)
}
return n, nil
}
// HeadingAttrs parses and returns the heading attributes from a heading node.
func (n Node) HeadingAttrs() (HeadingAttrs, error) {
var a HeadingAttrs
@@ -121,6 +131,9 @@ func (n Node) HeadingAttrs() (HeadingAttrs, error) {
// CodeBlockAttrs parses and returns the code block attributes.
func (n Node) CodeBlockAttrs() (CodeBlockAttrs, error) {
if len(n.Attrs) == 0 {
return CodeBlockAttrs{}, nil
}
var a CodeBlockAttrs
if err := json.Unmarshal(n.Attrs, &a); err != nil {
return a, fmt.Errorf("cannot parse code block attrs: %w", err)

View File

@@ -1,95 +1 @@
<h1>Heading 1</h1>
<p>This is a paragraph <strong>with some bold</strong> and <em>some italic </em>and some <u>underlined text</u>.<br>It
contains a line break, and some <s>strikethrough.</s><br>There&#39;s some <code>inline code</code>. And a <a
href="https://getprobo.com" target="_blank" rel="noopener noreferrer nofollow">link</a></p>
<h2>Heading 2</h2>
<p>A simple paragraph.</p>
<pre><code>code block</code></pre>
<h3>Heading 3</h3>
<ul>
<li>
<p>ul <strong>list</strong> item 1</p>
</li>
<li>
<p>ul <em>list</em> item 2</p>
</li>
<li>
<p>ul <a href="https://google.com" target="_blank" rel="noopener noreferrer nofollow">list</a> item 3</p>
</li>
</ul>
<hr>
<ol>
<li>
<p>ol list <u>item</u> 1</p>
</li>
<li>
<p>ol list <code>item</code> 2</p>
</li>
<li>
<p>ol list <s>item</s> 3</p>
</li>
</ol>
<hr>
<blockquote>
<p>Blockquote<br>Line <strong>break</strong></p>
</blockquote>
<hr>
<table>
<tr>
<th>
<p>th 1</p>
</th>
<th>
<p>th 2</p>
</th>
<th>
<p>th 3</p>
</th>
<th style="min-width: 61px">
<p>th 4</p>
</th>
</tr>
<tr>
<td>
<p>td <em>11</em></p>
</td>
<td>
<p>td <strong>12</strong></p>
</td>
<td>
<p>td <s>13</s></p>
</td>
<td style="min-width: 61px">
<ul>
<li>
<p>1</p>
</li>
<li>
<p>2</p>
</li>
</ul>
</td>
</tr>
<tr>
<td>
<p>td <u>21</u></p>
</td>
<td>
<p>td <a href="https://example.org" target="_blank" rel="noopener noreferrer nofollow">22</a></p>
</td>
<td>
<p>td <code>23</code></p>
</td>
<td style="min-width: 61px">
<ol>
<li>
<p>A</p>
</li>
<li>
<p>B</p>
</li>
</ol>
</td>
</tr>
</table>
<p></p>
<h1>Heading 1</h1><p>This is a paragraph <strong>with some bold</strong> and <em>some italic </em>and some <u>underlined text</u>.<br>It contains a line break, and some <s>strikethrough.</s><br>There&#39;s some <code>inline code</code>. And a <a href="https://getprobo.com" target="_blank" rel="noopener noreferrer nofollow">link</a></p><h2>Heading 2</h2><p>A simple paragraph.</p><pre><code>code block</code></pre><h3>Heading 3</h3><ul><li><p>ul <strong>list</strong> item 1</p></li><li><p>ul <em>list</em> item 2</p></li><li><p>ul <a href="https://google.com" target="_blank" rel="noopener noreferrer nofollow">list</a> item 3</p></li></ul><hr><ol><li><p>ol list <u>item</u> 1</p></li><li><p>ol list <code>item</code> 2</p></li><li><p>ol list <s>item</s> 3</p></li></ol><hr><blockquote><p>Blockquote<br>Line <strong>break</strong></p></blockquote><hr><table><tr><th><p>th 1</p></th><th><p>th 2</p></th><th><p>th 3</p></th><th style="min-width: 61px"><p>th 4</p></th></tr><tr><td><p>td <em>11</em></p></td><td><p>td <strong>12</strong></p></td><td><p>td <s>13</s></p></td><td style="min-width: 61px"><ul><li><p>1</p></li><li><p>2</p></li></ul></td></tr><tr><td><p>td <u>21</u></p></td><td><p>td <a href="https://example.org" target="_blank" rel="noopener noreferrer nofollow">22</a></p></td><td><p>td <code>23</code></p></td><td style="min-width: 61px"><ol><li><p>A</p></li><li><p>B</p></li></ol></td></tr></table><p></p>