From e71e7f1a45a8318c0a290fe2fbcffeff7d96a499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Fri, 27 Mar 2026 16:17:15 +0400 Subject: [PATCH] Use prosemirror pkg to render document PDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- go.mod | 2 - go.sum | 4 - pkg/docgen/generator.go | 55 ++++----- pkg/docgen/generator_test.go | 152 +++++++++++++++++-------- pkg/docgen/template.html | 32 ++++-- pkg/prosemirror/node.go | 13 +++ pkg/prosemirror/testdata/document.html | 96 +--------------- 7 files changed, 170 insertions(+), 184 deletions(-) diff --git a/go.mod b/go.mod index 20007b870..733e7d778 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 0be1f264a..12cd050f3 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/docgen/generator.go b/pkg/docgen/generator.go index c2e5516c1..19ccddb7b 100644 --- a/pkg/docgen/generator.go +++ b/pkg/docgen/generator.go @@ -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("

%s

", html.EscapeString(content))) - } - return template.HTML(buf.String()) - }, "imgTag": func(src, alt, class string) template.HTML { return template.HTML(fmt.Sprintf(`%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("

%s

", html.EscapeString(s))) + } + htmlStr, err := prosemirror.RenderHTML(node) + if err != nil { + return template.HTML(fmt.Sprintf("

%s

", 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) } diff --git a/pkg/docgen/generator_test.go b/pkg/docgen/generator_test.go index c029829b9..95095f935 100644 --- a/pkg/docgen/generator_test.go +++ b/pkg/docgen/generator_test.go @@ -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{ "

Section 1

", "", "Bold text", "italic text", - "code block", + "
code block
", }, }, { @@ -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), "bold") - // 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), "

simple text

") - // 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), "

**not** json

") + + result = ProseMirrorJSONToHTML("") assert.Equal(t, template.HTML(""), result) }) } @@ -276,44 +288,62 @@ func TestHTMLEscaping(t *testing.T) { assert.Contains(t, resultStr, "'") } -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

", "

H2

", "

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

", "

H2

", "

H3

"}, }, { - name: "emphasis", - markdown: "**bold** and *italic*", - want: []string{"bold", "italic"}, + 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{"bold", "italic"}, }, { - name: "lists", - markdown: "- Item 1\n- Item 2", - want: []string{""}, + 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{""}, }, { - name: "paragraphs", - markdown: "Paragraph 1\n\nParagraph 2", - want: []string{"

Paragraph 1

", "

Paragraph 2

"}, + name: "paragraphs", + content: `{"type":"doc","content":[` + + `{"type":"paragraph","content":[{"type":"text","text":"Paragraph 1"}]},` + + `{"type":"paragraph","content":[{"type":"text","text":"Paragraph 2"}]}` + + `]}`, + want: []string{"

Paragraph 1

", "

Paragraph 2

"}, }, { - name: "code", - markdown: "`inline code` and\n```\ncode block\n```", - want: []string{"inline 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{"inline code", "
code block
"}, }, } 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"}, diff --git a/pkg/docgen/template.html b/pkg/docgen/template.html index 3aef8cfac..4d70965eb 100644 --- a/pkg/docgen/template.html +++ b/pkg/docgen/template.html @@ -1,5 +1,6 @@ + @@ -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 @@ }); +
@@ -394,9 +405,9 @@ {{index .Approvers 0}} {{- else}}
    - {{- range .Approvers}} + {{- range .Approvers}}
  • {{.}}
  • - {{- end}} + {{- end}}
{{- end}} @@ -417,7 +428,7 @@
- {{.Content | formatContent}} + {{.BodyHTML}}
{{- if .Signatures}} @@ -460,4 +471,5 @@
- + + \ No newline at end of file diff --git a/pkg/prosemirror/node.go b/pkg/prosemirror/node.go index 061ba4966..d47392816 100644 --- a/pkg/prosemirror/node.go +++ b/pkg/prosemirror/node.go @@ -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) diff --git a/pkg/prosemirror/testdata/document.html b/pkg/prosemirror/testdata/document.html index 9ebfd9241..f14dd5e10 100644 --- a/pkg/prosemirror/testdata/document.html +++ b/pkg/prosemirror/testdata/document.html @@ -1,95 +1 @@ -

Heading 1

-

This is a paragraph with some bold and some italic and some underlined text.
It - contains a line break, and some strikethrough.
There's some inline code. And a link

-

Heading 2

-

A simple paragraph.

-
code block
-

Heading 3

-
    -
  • -

    ul list item 1

    -
  • -
  • -

    ul list item 2

    -
  • -
  • -

    ul list item 3

    -
  • -
-
-
    -
  1. -

    ol list item 1

    -
  2. -
  3. -

    ol list item 2

    -
  4. -
  5. -

    ol list item 3

    -
  6. -
-
-
-

Blockquote
Line break

-
-
- - - - - - - - - - - - - - - - - - - -
-

th 1

-
-

th 2

-
-

th 3

-
-

th 4

-
-

td 11

-
-

td 12

-
-

td 13

-
-
    -
  • -

    1

    -
  • -
  • -

    2

    -
  • -
-
-

td 21

-
-

td 22

-
-

td 23

-
-
    -
  1. -

    A

    -
  2. -
  3. -

    B

    -
  4. -
-
-

\ No newline at end of file +

Heading 1

This is a paragraph with some bold and some italic and some underlined text.
It contains a line break, and some strikethrough.
There's some inline code. And a link

Heading 2

A simple paragraph.

code block

Heading 3

  • ul list item 1

  • ul list item 2

  • ul list item 3


  1. ol list item 1

  2. ol list item 2

  3. ol list item 3


Blockquote
Line break


th 1

th 2

th 3

th 4

td 11

td 12

td 13

  • 1

  • 2

td 21

td 22

td 23

  1. A

  2. B

\ No newline at end of file