docforge UX slice S4 (PRD §2.3). Extends the S3 /api/submission-preview
endpoint with a truthful page-image render behind the SAME modal+endpoint
shape (the swappable body region). Resolves the S3 caveat: anchors-only
Composer/Gitea bases now render their REAL letterhead/Rubrum styling instead
of falling back to a base-agnostic structural view.
Pipeline (PRD §2.3): build the .docx via the EXISTING export pipeline
(byte-faithful) → Gotenberg sidecar (.docx→PDF over HTTP, keeps the Go image
lean) → poppler pdftoppm (PDF→PNG-per-page) → cache + single-flight.
Code only — NO Dokploy/compose change (head provisions the sidecar + fonts).
When GOTENBERG_URL is unset or poppler is absent, Available()=false and the
endpoint gracefully falls back to S3 structural HTML, so merging this is safe
before the infra lands.
Backend:
- internal/services/preview_render.go: PreviewImageRenderer interface +
GotenbergRenderer (.docx→PDF→PNG) + PreviewImageCache (bounded FIFO,
single-flight per key, serialised conversions) + NewPreviewImageCacheFromEnv
(GOTENBERG_URL / PREVIEW_DPI). The PNG cache is regenerable, not a retained
document (no disk/DB persistence; rebuildable from inputs).
- /api/submission-preview gains fidelity=truthful: builds the truthful .docx
(editor = clone draft + base override → full export pipeline incl. Composer;
catalog = context bag → render), caches by (draft id+updated_at | code+
project) × base × lang × data-mode, returns page data-URIs. Structural
responses now carry truthful:false.
- exportSubmissionDraft + Export gain a pluggable missing-marker (nil = default,
so every existing export path is byte-identical); enables sample-data truthful
render. New SubmissionDraftService.{ExportWithMarker,RenderContextPreviewDocx}.
- Unit tests: cache hit/miss, single-flight, eviction, availability, gotenberg
multipart request shape (httptest, no poppler needed).
Frontend:
- base-preview-modal.ts requests fidelity=truthful; renders <img> page(s) with
a ‹ n/N › prev/next pager (instant — pages held client-side); falls back to
the S3 structural sheet when truthful:false. CSS for .base-preview-img +
page-nav.
bun build (i18n scan clean) + go vet ./... + go test ./... (15 ok, 0 fail;
+6 new service tests).
150 lines
4.3 KiB
Go
150 lines
4.3 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
)
|
|
|
|
// fakeRenderer counts RenderPages calls so the cache + single-flight behaviour
|
|
// can be asserted without gotenberg/poppler.
|
|
type fakeRenderer struct {
|
|
calls int32
|
|
available bool
|
|
block chan struct{} // when non-nil, RenderPages blocks until closed
|
|
}
|
|
|
|
func (f *fakeRenderer) Available() bool { return f.available }
|
|
|
|
func (f *fakeRenderer) RenderPages(ctx context.Context, docx []byte) ([][]byte, error) {
|
|
atomic.AddInt32(&f.calls, 1)
|
|
if f.block != nil {
|
|
<-f.block
|
|
}
|
|
return [][]byte{[]byte("png-page-1")}, nil
|
|
}
|
|
|
|
func TestPreviewCache_HitMiss(t *testing.T) {
|
|
f := &fakeRenderer{available: true}
|
|
c := NewPreviewImageCache(f, 8)
|
|
|
|
build := func() ([]byte, error) { return []byte("docx"), nil }
|
|
|
|
if _, err := c.Pages(context.Background(), "k1", build); err != nil {
|
|
t.Fatalf("first miss: %v", err)
|
|
}
|
|
if _, err := c.Pages(context.Background(), "k1", build); err != nil {
|
|
t.Fatalf("second (cached): %v", err)
|
|
}
|
|
if got := atomic.LoadInt32(&f.calls); got != 1 {
|
|
t.Fatalf("expected 1 render for a cached key, got %d", got)
|
|
}
|
|
if _, err := c.Pages(context.Background(), "k2", build); err != nil {
|
|
t.Fatalf("new key: %v", err)
|
|
}
|
|
if got := atomic.LoadInt32(&f.calls); got != 2 {
|
|
t.Fatalf("expected 2 renders for two keys, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestPreviewCache_SingleFlight(t *testing.T) {
|
|
f := &fakeRenderer{available: true, block: make(chan struct{})}
|
|
c := NewPreviewImageCache(f, 8)
|
|
build := func() ([]byte, error) { return []byte("docx"), nil }
|
|
|
|
const n = 8
|
|
var wg sync.WaitGroup
|
|
wg.Add(n)
|
|
for range n {
|
|
go func() {
|
|
defer wg.Done()
|
|
_, _ = c.Pages(context.Background(), "same", build)
|
|
}()
|
|
}
|
|
// Let the goroutines collapse onto one in-flight render, then release it.
|
|
for atomic.LoadInt32(&f.calls) == 0 {
|
|
}
|
|
close(f.block)
|
|
wg.Wait()
|
|
|
|
if got := atomic.LoadInt32(&f.calls); got != 1 {
|
|
t.Fatalf("single-flight: expected 1 render for %d concurrent same-key calls, got %d", n, got)
|
|
}
|
|
}
|
|
|
|
func TestPreviewCache_Eviction(t *testing.T) {
|
|
f := &fakeRenderer{available: true}
|
|
c := NewPreviewImageCache(f, 2)
|
|
build := func() ([]byte, error) { return []byte("docx"), nil }
|
|
|
|
for _, k := range []string{"a", "b", "c"} { // "a" evicted by capacity 2
|
|
if _, err := c.Pages(context.Background(), k, build); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// "a" was evicted → re-rendering it is a fresh call.
|
|
if _, err := c.Pages(context.Background(), "a", build); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := atomic.LoadInt32(&f.calls); got != 4 {
|
|
t.Fatalf("expected 4 renders (a,b,c, then a again after eviction), got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestPreviewCache_Available(t *testing.T) {
|
|
if NewPreviewImageCache(&fakeRenderer{available: false}, 4).Available() {
|
|
t.Error("cache should be unavailable when the renderer is")
|
|
}
|
|
if !NewPreviewImageCache(&fakeRenderer{available: true}, 4).Available() {
|
|
t.Error("cache should be available when the renderer is")
|
|
}
|
|
}
|
|
|
|
// TestGotenbergRenderer_DocxToPDF asserts the converter POSTs a multipart .docx
|
|
// to the LibreOffice route and returns the body — no poppler needed.
|
|
func TestGotenbergRenderer_DocxToPDF(t *testing.T) {
|
|
var gotPath, gotFile string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
_, params, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
|
mr, err := r.MultipartReader()
|
|
if err == nil && params != nil {
|
|
if p, perr := mr.NextPart(); perr == nil {
|
|
gotFile = p.FileName()
|
|
_, _ = io.Copy(io.Discard, p)
|
|
}
|
|
}
|
|
_, _ = w.Write([]byte("%PDF-1.7 fake"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
g := NewGotenbergRenderer(srv.URL, 110)
|
|
g.HTTP = srv.Client()
|
|
pdf, err := g.docxToPDF(context.Background(), []byte("PK\x03\x04 fake docx"))
|
|
if err != nil {
|
|
t.Fatalf("docxToPDF: %v", err)
|
|
}
|
|
if !strings.HasPrefix(string(pdf), "%PDF") {
|
|
t.Errorf("expected PDF bytes, got %q", string(pdf))
|
|
}
|
|
if gotPath != "/forms/libreoffice/convert" {
|
|
t.Errorf("posted to %q, want /forms/libreoffice/convert", gotPath)
|
|
}
|
|
if !strings.HasSuffix(gotFile, ".docx") {
|
|
t.Errorf("uploaded file %q, want a .docx", gotFile)
|
|
}
|
|
}
|
|
|
|
func TestGotenbergRenderer_AvailableNeedsURL(t *testing.T) {
|
|
// No URL → unavailable regardless of poppler.
|
|
if NewGotenbergRenderer("", 110).Available() {
|
|
t.Error("renderer with empty URL must be unavailable")
|
|
}
|
|
}
|