diff --git a/CHANGELOG.md b/CHANGELOG.md index 89888c2..09a48d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ version is in the `VERSION` file. ## [Unreleased] +### Added + +- The PDF export warns when the script contains characters the standard PDF fonts + cannot draw: they are written as `?` in the file, and are now listed in a dialog + instead of being silently lost. + ### Fixed - The unsaved-changes marker is now updated atomically: an edit landing while a save diff --git a/app.go b/app.go index 1ce46f3..fe3e82b 100644 --- a/app.go +++ b/app.go @@ -151,11 +151,19 @@ func (a *App) SaveAs(doc screenplay.Document, current string) (string, error) { return a.Save(doc, path) } -func (a *App) ExportPDF(doc screenplay.Document, opts ExportOptions, current string) (string, error) { +// ExportResult describes a written PDF export. +type ExportResult struct { + Path string `json:"path"` // "" when the user cancelled the dialog + // Replaced are the characters the standard PDF fonts cannot draw: + // each one is written as "?" in the file. + Replaced []string `json:"replaced,omitempty"` +} + +func (a *App) ExportPDF(doc screenplay.Document, opts ExportOptions, current string) (ExportResult, error) { path, err := a.saveDialog("Export PDF", current, ".pdf", []runtime.FileFilter{{DisplayName: "PDF (*.pdf)", Pattern: "*.pdf"}}) if err != nil || path == "" { - return "", err + return ExportResult{}, err } var pages []layout.Page if opts.TitlePage && !doc.TitlePage.IsEmpty() { @@ -167,11 +175,18 @@ func (a *App) ExportPDF(doc screenplay.Document, opts ExportOptions, current str pdfOpts.Revisions = doc.Settings.Revisions } var buf bytes.Buffer - err = pdf.Render(&buf, pages, pdfOpts) + replaced, err := pdf.Render(&buf, pages, pdfOpts) if err != nil { - return "", err + return ExportResult{}, err } - return path, writeAtomic(path, buf.Bytes()) + if err := writeAtomic(path, buf.Bytes()); err != nil { + return ExportResult{}, err + } + res := ExportResult{Path: path} + for _, r := range replaced { + res.Replaced = append(res.Replaced, string(r)) + } + return res, nil } func (a *App) ExportFDX(doc screenplay.Document, current string) (string, error) { diff --git a/frontend/src/backend.ts b/frontend/src/backend.ts index 276cbea..85e85e0 100644 --- a/frontend/src/backend.ts +++ b/frontend/src/backend.ts @@ -144,13 +144,21 @@ export interface ExportOptions { revisions: boolean } +/** A written PDF export. */ +export interface ExportResult { + /** Empty when the user cancelled the dialog. */ + path: string + /** Characters the standard PDF fonts cannot draw, written as "?" in the file. */ + replaced?: string[] +} + interface GoApp { NewDocument(): Promise InitialFile(): Promise Open(): Promise Save(doc: ScriptDocument, path: string): Promise SaveAs(doc: ScriptDocument, current: string): Promise - ExportPDF(doc: ScriptDocument, opts: ExportOptions, current: string): Promise + ExportPDF(doc: ScriptDocument, opts: ExportOptions, current: string): Promise ExportFDX(doc: ScriptDocument, current: string): Promise Paginate(doc: ScriptDocument): Promise ParseFountain(text: string): Promise diff --git a/frontend/src/main.ts b/frontend/src/main.ts index b024827..6ee64a8 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -947,7 +947,15 @@ const actions: Record unknown> = { renderPaper() settingsChanged() } - await api.ExportPDF(script(), chosen, app.path).catch(reportError) + const res = await api.ExportPDF(script(), chosen, app.path).catch(reportError) + if (res?.replaced?.length) { + const list = res.replaced.map((c) => `"${c}"`).join(' ') + await messageBox({ + title: 'Characters replaced in the PDF', + text: `The standard PDF fonts cannot draw these characters: ${list}. Each one is written as "?" in the exported file.`, + ok: 'OK', + }) + } } dialog.showModal() }, diff --git a/internal/pdf/pdf.go b/internal/pdf/pdf.go index fd2e2d5..d9ab236 100644 --- a/internal/pdf/pdf.go +++ b/internal/pdf/pdf.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "math" + "slices" "strings" "unicode/utf16" @@ -34,8 +35,11 @@ const ( var fontNames = []string{"Courier", "Courier-Bold", "Courier-Oblique", "Courier-BoldOblique"} // Render writes pages to w. The title page, if any, must be the first page -// and have Number 0 so that it is not numbered. -func Render(w io.Writer, pages []layout.Page, opt Options) error { +// and have Number 0 so that it is not numbered. It returns, sorted, the +// characters the standard Courier fonts cannot draw: each one is written +// as "?" in the output, so the caller can warn instead of corrupting the +// script silently. +func Render(w io.Writer, pages []layout.Page, opt Options) ([]rune, error) { geo := layout.PaperGeometry(opt.Paper) var objs [][]byte // objs[i] is object number i+1 add := func(s string) int { @@ -50,11 +54,12 @@ func Render(w io.Writer, pages []layout.Page, opt Options) error { } add(fmt.Sprintf("<< /Title %s /Author %s /Creator (Plotzilla) /Producer (Plotzilla) >>", textString(opt.Title), textString(opt.Author))) + missing := map[rune]bool{} var kids []string for _, pg := range pages { - stream, err := deflate(pageContent(pg, geo, opt.Revisions)) + stream, err := deflate(pageContent(pg, geo, opt.Revisions, missing)) if err != nil { - return err + return nil, err } content := add(fmt.Sprintf("<< /Length %d /Filter /FlateDecode >>\nstream\n%s\nendstream", len(stream), stream)) page := add(fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %g %g] "+ @@ -80,7 +85,16 @@ func Render(w io.Writer, pages []layout.Page, opt Options) error { } fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R /Info 7 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objs)+1, xref) _, err := w.Write(buf.Bytes()) - return err + return sortedRunes(missing), err +} + +func sortedRunes(set map[rune]bool) []rune { + out := make([]rune, 0, len(set)) + for r := range set { + out = append(out, r) + } + slices.Sort(out) + return out } // points converts inches to PDF points, rounded to 1/100 pt. @@ -92,16 +106,16 @@ func baseline(geo layout.Geometry, row int) float64 { return top - float64(row)*fontSize - 9 } -func text(b *bytes.Buffer, x, y float64, s string) { - fmt.Fprintf(b, "BT /F1 12 Tf %.2f %.2f Td %s Tj ET\n", x, y, pdfString(s)) +func text(b *bytes.Buffer, x, y float64, s string, missing map[rune]bool) { + fmt.Fprintf(b, "BT /F1 12 Tf %.2f %.2f Td %s Tj ET\n", x, y, pdfString(s, missing)) } -func pageContent(pg layout.Page, geo layout.Geometry, revisions []screenplay.Revision) []byte { +func pageContent(pg layout.Page, geo layout.Geometry, revisions []screenplay.Revision, missing map[rune]bool) []byte { var b bytes.Buffer header := (geo.Height-0.5)*ptPerIn - 9 if pg.Number > 0 && pg.Label != "" && pg.Label != "1" { num := pg.Label + "." - text(&b, layout.RightEdge*ptPerIn-float64(len([]rune(num)))*fontSize*0.6, header, num) + text(&b, layout.RightEdge*ptPerIn-float64(len([]rune(num)))*fontSize*0.6, header, num, missing) } // A revised page names its revision; its latest changes are starred. rev := pg.Rev() @@ -114,17 +128,17 @@ func pageContent(pg layout.Page, geo layout.Geometry, revisions []screenplay.Rev if r.Date != "" { label += " - " + r.Date } - text(&b, 1.5*ptPerIn, header, label) + text(&b, 1.5*ptPerIn, header, label, missing) } for _, ln := range pg.Lines { y := baseline(geo, ln.Row) x := ln.X * ptPerIn if ln.SceneNumber != "" { - text(&b, 0.75*ptPerIn, y, ln.SceneNumber) - text(&b, geo.SceneNumber*ptPerIn, y, ln.SceneNumber) + text(&b, 0.75*ptPerIn, y, ln.SceneNumber, missing) + text(&b, geo.SceneNumber*ptPerIn, y, ln.SceneNumber, missing) } if rev > 0 && ln.Rev == rev { - text(&b, geo.RevisionMark*ptPerIn, y, "*") + text(&b, geo.RevisionMark*ptPerIn, y, "*", missing) } fmt.Fprintf(&b, "BT %.2f %.2f Td ", x, y) cursor := x @@ -142,7 +156,7 @@ func pageContent(pg layout.Page, geo layout.Geometry, revisions []screenplay.Rev underlines = append(underlines, [2]float64{cursor, cursor + width}) } cursor += width - fmt.Fprintf(&b, "/F%d 12 Tf %s Tj ", font, pdfString(s.Text)) + fmt.Fprintf(&b, "/F%d 12 Tf %s Tj ", font, pdfString(s.Text, missing)) } b.WriteString("ET\n") for _, u := range underlines { @@ -164,12 +178,16 @@ func deflate(data []byte) ([]byte, error) { return b.Bytes(), nil } -// pdfString encodes s as a WinAnsi literal string for the Courier fonts. -func pdfString(s string) string { +// pdfString encodes s as a WinAnsi literal string for the Courier fonts, +// recording in missing the characters they cannot draw. +func pdfString(s string, missing map[rune]bool) string { var b strings.Builder b.WriteByte('(') for _, r := range s { - c := winAnsi(r) + c, ok := winAnsi(r) + if !ok { + missing[r] = true + } if c == '(' || c == ')' || c == '\\' { b.WriteByte('\\') } @@ -189,7 +207,7 @@ func textString(s string) string { } } if ascii { - return pdfString(s) + return pdfString(s, map[rune]bool{}) // ASCII only: nothing to record } var b strings.Builder b.WriteString("= 0x20 && r < 0x7f: - return byte(r) + return byte(r), true case r >= 0xa0 && r <= 0xff: - return byte(r) + return byte(r), true } if c, ok := cp1252[r]; ok { - return c + return c, true } if r == ' ' || r == ' ' { - return ' ' // narrow spaces used before French punctuation + return ' ', true // narrow spaces used before French punctuation } - return '?' + return '?', false } diff --git a/internal/pdf/pdf_test.go b/internal/pdf/pdf_test.go index d84487f..db0da2d 100644 --- a/internal/pdf/pdf_test.go +++ b/internal/pdf/pdf_test.go @@ -5,6 +5,7 @@ import ( "compress/zlib" "io" "os" + "slices" "strings" "testing" @@ -39,9 +40,13 @@ func TestRender(t *testing.T) { 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 - if err := Render(&buf, pages, Options{Title: doc.TitlePage.Title, Author: doc.TitlePage.Author}); err != nil { + 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) @@ -61,7 +66,7 @@ func TestRevisionsAndA4(t *testing.T) { 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 { + if _, err := Render(&buf, pages, Options{Paper: doc.Settings.Paper, Revisions: doc.Settings.Revisions}); err != nil { t.Fatal(err) } stream := uncompressed(t, buf.Bytes()) @@ -74,7 +79,7 @@ func TestRevisionsAndA4(t *testing.T) { // Without revisions in the options, nothing is marked. buf.Reset() - if err := Render(&buf, pages, Options{Paper: doc.Settings.Paper}); err != nil { + 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, "(*)") { @@ -82,6 +87,34 @@ func TestRevisionsAndA4(t *testing.T) { } } +// 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 diff --git a/tools/screenshots/main.go b/tools/screenshots/main.go index 31ab122..98c2775 100644 --- a/tools/screenshots/main.go +++ b/tools/screenshots/main.go @@ -277,7 +277,7 @@ func pdfOutput(src, tmp, path string) error { pages := append([]layout.Page{layout.TitlePage(doc.TitlePage, doc.Settings.Paper)}, layout.Paginate(doc, layout.Options{SceneNumbers: true}).Pages...) var b bytes.Buffer - if err := pdf.Render(&b, pages, pdf.Options{Title: doc.TitlePage.Title, Author: doc.TitlePage.Author}); err != nil { + if _, err := pdf.Render(&b, pages, pdf.Options{Title: doc.TitlePage.Title, Author: doc.TitlePage.Author}); err != nil { return err } file := filepath.Join(tmp, "sample.pdf")