feat: first release !
This commit is contained in:
1 parent
77a333e8d9
commit
664527182d
66 files changed
+13812
-16
No files matched your search
@@ -0,0 +1,224 @@
|
||||
// Package pdf renders laid-out pages to PDF. It writes the file by hand
|
||||
// using the standard Courier fonts that every PDF reader ships with, so no
|
||||
// font is embedded and there is no external dependency.
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
|
||||
"plotzilla/internal/layout"
|
||||
"plotzilla/internal/screenplay"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Title string
|
||||
Author string
|
||||
Paper screenplay.Paper
|
||||
// Revisions are the document's revision sets. When set, a page whose
|
||||
// text was revised gets a header naming its revision, and its revised
|
||||
// lines an asterisk in the right margin.
|
||||
Revisions []screenplay.Revision
|
||||
}
|
||||
|
||||
const (
|
||||
fontSize = 12.0
|
||||
ptPerIn = 72.0
|
||||
)
|
||||
|
||||
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 {
|
||||
geo := layout.PaperGeometry(opt.Paper)
|
||||
var objs [][]byte // objs[i] is object number i+1
|
||||
add := func(s string) int {
|
||||
objs = append(objs, []byte(s))
|
||||
return len(objs)
|
||||
}
|
||||
// 1: catalog, 2: page tree, 3-6: fonts, 7: info. Pages follow.
|
||||
add("<< /Type /Catalog /Pages 2 0 R >>")
|
||||
add("") // page tree, filled once the kids are known
|
||||
for _, f := range fontNames {
|
||||
add("<< /Type /Font /Subtype /Type1 /BaseFont /" + f + " /Encoding /WinAnsiEncoding >>")
|
||||
}
|
||||
add(fmt.Sprintf("<< /Title %s /Author %s /Creator (Plotzilla) /Producer (Plotzilla) >>", textString(opt.Title), textString(opt.Author)))
|
||||
|
||||
var kids []string
|
||||
for _, pg := range pages {
|
||||
stream, err := deflate(pageContent(pg, geo, opt.Revisions))
|
||||
if err != nil {
|
||||
return 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] "+
|
||||
"/Resources << /Font << /F1 3 0 R /F2 4 0 R /F3 5 0 R /F4 6 0 R >> >> /Contents %d 0 R >>",
|
||||
points(geo.Width), points(geo.Height), content))
|
||||
kids = append(kids, fmt.Sprintf("%d 0 R", page))
|
||||
}
|
||||
objs[1] = []byte(fmt.Sprintf("<< /Type /Pages /Kids [%s] /Count %d >>", strings.Join(kids, " "), len(kids)))
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
|
||||
offsets := make([]int, len(objs))
|
||||
for i, o := range objs {
|
||||
offsets[i] = buf.Len()
|
||||
fmt.Fprintf(&buf, "%d 0 obj\n", i+1)
|
||||
buf.Write(o)
|
||||
buf.WriteString("\nendobj\n")
|
||||
}
|
||||
xref := buf.Len()
|
||||
fmt.Fprintf(&buf, "xref\n0 %d\n0000000000 65535 f \n", len(objs)+1)
|
||||
for _, off := range offsets {
|
||||
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// points converts inches to PDF points, rounded to 1/100 pt.
|
||||
func points(in float64) float64 { return math.Round(in*ptPerIn*100) / 100 }
|
||||
|
||||
// baseline returns the PDF y coordinate of the baseline of a body row.
|
||||
func baseline(geo layout.Geometry, row int) float64 {
|
||||
top := (geo.Height - layout.TopMargin) * ptPerIn
|
||||
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 pageContent(pg layout.Page, geo layout.Geometry, revisions []screenplay.Revision) []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)
|
||||
}
|
||||
// A revised page names its revision; its latest changes are starred.
|
||||
rev := pg.Rev()
|
||||
if rev > len(revisions) {
|
||||
rev = 0
|
||||
}
|
||||
if rev > 0 {
|
||||
r := revisions[rev-1]
|
||||
label := r.Name + " Revision"
|
||||
if r.Date != "" {
|
||||
label += " - " + r.Date
|
||||
}
|
||||
text(&b, 1.5*ptPerIn, header, label)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if rev > 0 && ln.Rev == rev {
|
||||
text(&b, geo.RevisionMark*ptPerIn, y, "*")
|
||||
}
|
||||
fmt.Fprintf(&b, "BT %.2f %.2f Td ", x, y)
|
||||
cursor := x
|
||||
var underlines [][2]float64
|
||||
for _, s := range ln.Segments {
|
||||
font := 1
|
||||
if s.Bold {
|
||||
font++
|
||||
}
|
||||
if s.Italic {
|
||||
font += 2
|
||||
}
|
||||
width := float64(len([]rune(s.Text))) * fontSize * 0.6
|
||||
if s.Underline {
|
||||
underlines = append(underlines, [2]float64{cursor, cursor + width})
|
||||
}
|
||||
cursor += width
|
||||
fmt.Fprintf(&b, "/F%d 12 Tf %s Tj ", font, pdfString(s.Text))
|
||||
}
|
||||
b.WriteString("ET\n")
|
||||
for _, u := range underlines {
|
||||
fmt.Fprintf(&b, "0.6 w %.2f %.2f m %.2f %.2f l S\n", u[0], y-1.8, u[1], y-1.8)
|
||||
}
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func deflate(data []byte) ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
zw := zlib.NewWriter(&b)
|
||||
if _, err := zw.Write(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
// pdfString encodes s as a WinAnsi literal string for the Courier fonts.
|
||||
func pdfString(s string) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('(')
|
||||
for _, r := range s {
|
||||
c := winAnsi(r)
|
||||
if c == '(' || c == ')' || c == '\\' {
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
b.WriteByte(c)
|
||||
}
|
||||
b.WriteByte(')')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// textString encodes s for the document info dictionary.
|
||||
func textString(s string) string {
|
||||
ascii := true
|
||||
for _, r := range s {
|
||||
if r > 0x7e || r < 0x20 {
|
||||
ascii = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ascii {
|
||||
return pdfString(s)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<FEFF")
|
||||
for _, u := range utf16.Encode([]rune(s)) {
|
||||
fmt.Fprintf(&b, "%04X", u)
|
||||
}
|
||||
b.WriteString(">")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var cp1252 = map[rune]byte{
|
||||
'€': 0x80, '‚': 0x82, 'ƒ': 0x83, '„': 0x84, '…': 0x85, '†': 0x86, '‡': 0x87,
|
||||
'ˆ': 0x88, '‰': 0x89, 'Š': 0x8a, '‹': 0x8b, 'Œ': 0x8c, 'Ž': 0x8e,
|
||||
'‘': 0x91, '’': 0x92, '“': 0x93, '”': 0x94, '•': 0x95, '–': 0x96, '—': 0x97,
|
||||
'˜': 0x98, '™': 0x99, 'š': 0x9a, '›': 0x9b, 'œ': 0x9c, 'ž': 0x9e, 'Ÿ': 0x9f,
|
||||
}
|
||||
|
||||
func winAnsi(r rune) byte {
|
||||
switch {
|
||||
case r >= 0x20 && r < 0x7f:
|
||||
return byte(r)
|
||||
case r >= 0xa0 && r <= 0xff:
|
||||
return byte(r)
|
||||
}
|
||||
if c, ok := cp1252[r]; ok {
|
||||
return c
|
||||
}
|
||||
if r == ' ' || r == ' ' {
|
||||
return ' ' // narrow spaces used before French punctuation
|
||||
}
|
||||
return '?'
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"io"
|
||||
"os"
|
||||
"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
|
||||
if err := Render(&buf, pages, Options{Title: doc.TitlePage.Title, Author: doc.TitlePage.Author}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
Reference in new issue
Block a user