Files
plotzilla/internal/pdf/pdf_test.go
T
Kharec e80ad634ef feat: warn when a PDF export replaces characters it cannot draw
The hand-written PDF writer uses the standard Courier fonts
(WinAnsiEncoding), so any character outside CP1252 - a Polish, Greek,
Cyrillic or CJK script - was silently written as '?'. Render now
collects the characters it had to replace and returns them, and the
export dialog lists them in a warning once the file is written.

Embedding a TrueType font (e.g. Courier Prime) with a CID/ToUnicode
map would fix the root cause; the warning keeps the zero-dependency
trade-off honest in the meantime.
2026-10-15 17:49:53 +02:00

132 lines
3.9 KiB
Go

package pdf
import (
"bytes"
"compress/zlib"
"io"
"os"
"slices"
"strings"
"testing"
"plotzilla/internal/fountain"
"plotzilla/internal/layout"
"plotzilla/internal/screenplay"
)
const sample = `Title: Le Dernier Rouleau
Credit: Written by
Author: Jane Doe
Contact: jane@example.com
Draft date: 28/09/2026
INT. CABINE DE PROJECTION - NUIT
La poussière danse dans le **faisceau** du projecteur. Élodie _entre_.
ÉLODIE (V.O.)
(à voix basse)
Ça finit toujours *ici*.
MARC ^
(criant)
Non ! Pas encore, pas ce soir.
CUT TO:
`
func TestRender(t *testing.T) {
doc := fountain.Parse(sample)
pages := []layout.Page{layout.TitlePage(doc.TitlePage, doc.Settings.Paper)}
pages = append(pages, layout.Paginate(doc, layout.Options{SceneNumbers: true}).Pages...)
var buf bytes.Buffer
replaced, err := Render(&buf, pages, Options{Title: doc.TitlePage.Title, Author: doc.TitlePage.Author})
if err != nil {
t.Fatal(err)
}
if len(replaced) != 0 {
t.Errorf("the French sample is fully representable, replaced = %q", string(replaced))
}
out := buf.String()
if !strings.HasPrefix(out, "%PDF-1.4") || !strings.HasSuffix(out, "%%EOF\n") || !strings.Contains(out, "/Count 2") {
t.Fatalf("malformed PDF:\n%.300s", out)
}
// PLOTZILLA_PDF=/tmp/x.pdf go test ./internal/pdf keeps the file for inspection.
if path := os.Getenv("PLOTZILLA_PDF"); path != "" {
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
}
}
func TestRevisionsAndA4(t *testing.T) {
doc := fountain.Parse(sample)
doc.Settings.Paper = screenplay.A4
doc.Settings.Revisions = []screenplay.Revision{{Name: "Blue", Color: "blue", Date: "09/28/26"}}
doc.Elements[1].Runs[0].Rev = 1
var buf bytes.Buffer
pages := layout.Paginate(doc, layout.Options{}).Pages
if _, err := Render(&buf, pages, Options{Paper: doc.Settings.Paper, Revisions: doc.Settings.Revisions}); err != nil {
t.Fatal(err)
}
stream := uncompressed(t, buf.Bytes())
if !strings.Contains(buf.String(), "/MediaBox [0 0 595.44 841.68]") {
t.Errorf("not an A4 page")
}
if !strings.Contains(stream, "(Blue Revision - 09/28/26) Tj") || strings.Count(stream, "(*) Tj") != 1 {
t.Errorf("revision header or mark missing:\n%s", stream)
}
// Without revisions in the options, nothing is marked.
buf.Reset()
if _, err := Render(&buf, pages, Options{Paper: doc.Settings.Paper}); err != nil {
t.Fatal(err)
}
if stream := uncompressed(t, buf.Bytes()); strings.Contains(stream, "Revision") || strings.Contains(stream, "(*)") {
t.Errorf("unexpected revision marks:\n%s", stream)
}
}
// TestReplacedChars: characters outside WinAnsi are written as '?' and
// reported, so an export can warn instead of corrupting the text silently.
func TestReplacedChars(t *testing.T) {
src := "INT. ΚΑΦΕΝΕΙΟ - ΜΕΡΑ\n\nΚαλημέρα… Plain ASCII.\n"
doc := fountain.Parse(src)
pages := layout.Paginate(doc, layout.Options{}).Pages
var buf bytes.Buffer
replaced, err := Render(&buf, pages, Options{})
if err != nil {
t.Fatal(err)
}
got := string(replaced)
for _, r := range src { // every Greek letter is reported
if r > 0x7f && r != '…' && !strings.ContainsRune(got, r) {
t.Errorf("%q not reported in %q", r, got)
}
}
if strings.ContainsRune(got, '…') { // WinAnsi has "…"
t.Errorf("representable … reported in %q", got)
}
if !slices.IsSorted(replaced) {
t.Errorf("replaced not sorted: %q", got)
}
if stream := uncompressed(t, buf.Bytes()); !strings.Contains(stream, "????????") {
t.Errorf("Greek not written as '?':\n%s", stream)
}
}
// uncompressed returns the content streams of a PDF written by Render.
func uncompressed(t *testing.T, pdf []byte) string {
var out strings.Builder
for _, part := range bytes.Split(pdf, []byte(">>\nstream\n"))[1:] {
end := bytes.Index(part, []byte("\nendstream"))
r, err := zlib.NewReader(bytes.NewReader(part[:end]))
if err != nil {
t.Fatal(err)
}
data, _ := io.ReadAll(r)
out.Write(data)
}
return out.String()
}