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

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>