feat: first release !

This commit is contained in:
Kharec committed 2026-09-28 14:21:06 +02:00
1 parent 77a333e8d9
commit 664527182d
66 files changed
+13812 -16

No files matched your search

+555
View File
@@ -0,0 +1,555 @@
// Package fdx reads and writes Final Draft XML (.fdx) files.
//
// Final Draft has no Fountain sections or synopses: they are written as
// script notes whose text starts with "#" or "=", attached to the start of
// the next paragraph, and read back as such. Notes inside the text are
// script notes at their position; revised text carries the RevisionID of
// its revision set; a forced page break is StartsNewPage.
package fdx
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"regexp"
"slices"
"strconv"
"strings"
"unicode"
"plotzilla/internal/screenplay"
)
type file struct {
XMLName xml.Name `xml:"FinalDraft"`
DocumentType string `xml:"DocumentType,attr"`
Template string `xml:"Template,attr"`
Version string `xml:"Version,attr"`
Content content `xml:"Content"`
TitlePage *titlePage `xml:"TitlePage,omitempty"`
Revisions *revisions `xml:"Revisions,omitempty"`
}
type revisions struct {
ActiveSet int `xml:"ActiveSet,attr"`
RevisionMode string `xml:"RevisionMode,attr"`
Items []revision `xml:"Revision"`
}
type revision struct {
ID int `xml:"ID,attr"`
Name string `xml:"Name,attr"`
Color string `xml:"Color,attr,omitempty"`
PageColor string `xml:"PageColor,attr,omitempty"`
Mark string `xml:"Mark,attr,omitempty"`
FullRevision string `xml:"FullRevision,attr,omitempty"`
}
type scriptNote struct {
ID int `xml:"ID,attr"`
Range string `xml:"Range,attr"` // "start,length" in the paragraph's text
Paragraphs []paragraph `xml:"Paragraph"`
}
type content struct {
Paragraphs []paragraph `xml:"Paragraph"`
}
type titlePage struct {
Content content `xml:"Content"`
}
type paragraph struct {
Type string `xml:"Type,attr,omitempty"`
Number string `xml:"Number,attr,omitempty"`
Alignment string `xml:"Alignment,attr,omitempty"`
StartsNewPage string `xml:"StartsNewPage,attr,omitempty"`
ScriptNotes []scriptNote `xml:"ScriptNote"`
Texts []text `xml:"Text"`
DualDialogue *content `xml:"DualDialogue,omitempty"`
}
type text struct {
Style string `xml:"Style,attr,omitempty"`
RevisionID int `xml:"RevisionID,attr,omitempty"`
Value string `xml:",chardata"`
}
// revisionColors are Final Draft's 48-bit page colours for each revision
// colour, in the order they are searched for in a revision set's name.
var revisionColors = []struct{ name, hex string }{
{"goldenrod", "#DADAA5A52020"},
{"white", "#FFFFFFFFFFFF"},
{"blue", "#ADADD8D8E6E6"},
{"pink", "#FFFFC0C0CBCB"},
{"yellow", "#FFFFFFFF9999"},
{"green", "#9999FFFF9999"},
{"buff", "#F0F0DCDC8282"},
{"salmon", "#FAFA80807272"},
{"cherry", "#DEDE31316363"},
}
// revisionName is how Plotzilla names a set in Final Draft: "Blue
// Revision - 09/28/26".
var revisionName = regexp.MustCompile(`^(.*?) Revision(?: - (.+))?$`)
var typeNames = map[screenplay.ElementType]string{
screenplay.Scene: "Scene Heading",
screenplay.Action: "Action",
screenplay.Character: "Character",
screenplay.Parenthetical: "Parenthetical",
screenplay.Dialogue: "Dialogue",
screenplay.Transition: "Transition",
screenplay.Centered: "Action",
screenplay.Shot: "Shot",
screenplay.Intertitle: "Action",
}
// Read parses an FDX document.
func Read(r io.Reader) (*screenplay.Document, error) {
var f file
dec := xml.NewDecoder(r)
dec.CharsetReader = func(_ string, in io.Reader) (io.Reader, error) { return in, nil }
if err := dec.Decode(&f); err != nil {
return nil, fmt.Errorf("reading FDX: %w", err)
}
doc := &screenplay.Document{}
rd := &reader{levels: map[int]int{}}
if f.Revisions != nil {
for i, rv := range f.Revisions.Items {
rd.levels[rv.ID] = i + 1
doc.Settings.Revisions = append(doc.Settings.Revisions, readRevision(rv, i+1))
}
doc.Settings.Revising = f.Revisions.RevisionMode == "Yes" && len(f.Revisions.Items) > 0
}
for _, p := range f.Content.Paragraphs {
rd.paragraph(p)
}
doc.Elements = rd.out
if len(doc.Elements) == 0 {
doc.Elements = []screenplay.Element{screenplay.NewElement(screenplay.Scene, "")}
}
if f.TitlePage != nil {
doc.TitlePage = readTitlePage(f.TitlePage.Content.Paragraphs)
}
return doc, nil
}
func readRevision(rv revision, level int) screenplay.Revision {
def := screenplay.RevisionSet(level)
r := screenplay.Revision{Name: strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(rv.Name), "Rev."))}
if m := revisionName.FindStringSubmatch(rv.Name); m != nil {
r.Name, r.Date = m[1], m[2]
}
lower := strings.ToLower(r.Name)
for _, c := range revisionColors {
if strings.Contains(lower, c.name) {
r.Color = c.name
break
}
}
if r.Color == "" {
r.Color = def.Color
}
if r.Name == "" {
r.Name = def.Name
}
return r
}
type reader struct {
out []screenplay.Element
levels map[int]int // Final Draft RevisionID → revision set
newPage bool // a page break on a paragraph that was left out
}
type inlineNote struct {
offset int
text string
}
// notes adds the sections and synopses stored in a paragraph's script
// notes and returns its other notes.
func (rd *reader) notes(sns []scriptNote) []inlineNote {
var inline []inlineNote
for _, sn := range sns {
var lines []string
for _, p := range sn.Paragraphs {
var b strings.Builder
for _, t := range p.Texts {
b.WriteString(t.Value)
}
lines = append(lines, b.String())
}
body := strings.Join(lines, "\n")
switch {
case strings.HasPrefix(body, "#"):
rest := strings.TrimLeft(body, "#")
rd.out = append(rd.out, screenplay.Element{Type: screenplay.Section, Level: len(body) - len(rest),
Runs: []screenplay.Run{{Text: strings.TrimSpace(rest)}}})
case strings.HasPrefix(body, "="):
rd.out = append(rd.out, screenplay.Element{Type: screenplay.Synopsis,
Runs: []screenplay.Run{{Text: strings.TrimSpace(strings.TrimPrefix(body, "="))}}})
default:
start, _, _ := strings.Cut(sn.Range, ",")
offset, _ := strconv.Atoi(strings.TrimSpace(start))
inline = append(inline, inlineNote{offset: max(offset, 0), text: strings.TrimPrefix(body, `\`)})
}
}
return inline
}
func (rd *reader) paragraph(p paragraph) {
notes := rd.notes(p.ScriptNotes)
newPage := p.StartsNewPage == "Yes" || rd.newPage
rd.newPage = false
if p.DualDialogue != nil {
start := len(rd.out)
cues := 0
for _, inner := range p.DualDialogue.Paragraphs {
n := len(rd.out)
rd.paragraph(inner)
if inner.Type == "Character" && len(rd.out) > n {
if cues++; cues == 2 {
rd.out[len(rd.out)-1].Dual = true
}
}
}
if newPage && len(rd.out) > start {
rd.out[start].NewPage = true
} else {
rd.newPage = newPage
}
return
}
el := screenplay.Element{SceneNumber: p.Number, NewPage: newPage}
switch p.Type {
case "Scene Heading":
el.Type = screenplay.Scene
case "Character":
el.Type = screenplay.Character
case "Parenthetical":
el.Type = screenplay.Parenthetical
case "Dialogue":
el.Type = screenplay.Dialogue
case "Transition":
el.Type = screenplay.Transition
case "Shot":
el.Type = screenplay.Shot
default:
el.Type = screenplay.Action
if p.Alignment == "Center" {
el.Type = screenplay.Centered
}
}
if el.Type != screenplay.Scene {
el.SceneNumber = ""
}
for _, t := range p.Texts {
el.Runs = append(el.Runs, screenplay.Run{
Text: t.Value,
Bold: strings.Contains(t.Style, "Bold"),
Italic: strings.Contains(t.Style, "Italic"),
Underline: strings.Contains(t.Style, "Underline"),
Rev: rd.levels[t.RevisionID],
})
}
el.Runs = insertNotes(screenplay.NormalizeRuns(el.Runs), notes)
if el.IsEmpty() {
rd.newPage = newPage
return
}
// Final Draft has no intertitle: they are centred, all-caps action.
if el.Type == screenplay.Centered && isAllCaps(el.PrintedText()) {
el.Type = screenplay.Intertitle
}
rd.out = append(rd.out, el)
}
// insertNotes puts note runs at their rune offsets in the text.
func insertNotes(runs []screenplay.Run, notes []inlineNote) []screenplay.Run {
if len(notes) == 0 {
return runs
}
slices.SortStableFunc(notes, func(a, b inlineNote) int { return a.offset - b.offset })
var out []screenplay.Run
pos, next := 0, 0
for _, r := range runs {
rs := []rune(r.Text)
start := 0
for ; next < len(notes) && notes[next].offset-pos < len(rs); next++ {
if at := notes[next].offset - pos; at > start {
part := r
part.Text = string(rs[start:at])
out = append(out, part)
start = at
}
out = append(out, screenplay.Run{Text: notes[next].text, Note: true})
}
if start < len(rs) {
r.Text = string(rs[start:])
out = append(out, r)
}
pos += len(rs)
}
for ; next < len(notes); next++ {
out = append(out, screenplay.Run{Text: notes[next].text, Note: true})
}
return screenplay.NormalizeRuns(out)
}
// readTitlePage maps Final Draft's free-form title page onto our fields:
// the first centred line is the title, a line containing "by" is the credit
// and the following one the author; left-aligned text goes to contact.
func readTitlePage(ps []paragraph) screenplay.TitlePage {
var tp screenplay.TitlePage
var centered, left, right []string
for _, p := range ps {
var b strings.Builder
for _, t := range p.Texts {
b.WriteString(t.Value)
}
s := strings.TrimSpace(b.String())
if s == "" {
continue
}
switch p.Alignment {
case "Center":
centered = append(centered, s)
case "Right":
right = append(right, s)
default:
left = append(left, s)
}
}
loop:
for i, s := range centered {
switch {
case tp.Title == "":
tp.Title = s
case tp.Credit == "" && strings.Contains(strings.ToLower(s), "by"):
tp.Credit = s
case tp.Credit != "" && tp.Author == "":
tp.Author = s
default:
tp.Source = strings.TrimSpace(strings.Join(centered[i:], "\n"))
break loop
}
}
tp.Contact = strings.Join(left, "\n")
tp.DraftDate = strings.Join(right, "\n")
return tp
}
// Write serialises a Document to FDX. Locked pages and the paper size are
// not written.
func Write(w io.Writer, doc *screenplay.Document) error {
wr := &writer{}
wr.f = file{DocumentType: "Script", Template: "No", Version: "5"}
els := doc.Elements
for i := 0; i < len(els); {
el := els[i]
if !el.Type.Printed() {
if !el.IsEmpty() {
wr.pending = append(wr.pending, wr.note(outlineText(el), 0))
}
wr.newPage = wr.newPage || el.NewPage
i++
continue
}
if el.Type == screenplay.Character {
j := blockEnd(els, i)
if j < len(els) && els[j].Type == screenplay.Character && els[j].Dual {
k := blockEnd(els, j)
dual := &content{}
for _, inner := range els[i:k] {
if !inner.IsEmpty() {
dual.Paragraphs = append(dual.Paragraphs, wr.paragraph(inner))
}
}
wr.add(paragraph{DualDialogue: dual}, el.NewPage)
i = k
continue
}
}
if el.IsEmpty() {
wr.newPage = wr.newPage || el.NewPage
} else {
wr.add(wr.paragraph(el), el.NewPage)
}
i++
}
if len(wr.pending) > 0 {
// Outline at the very end: an empty paragraph carries it.
wr.f.Content.Paragraphs = append(wr.f.Content.Paragraphs, paragraph{Type: "Action", ScriptNotes: wr.pending})
}
if !doc.TitlePage.IsEmpty() {
wr.f.TitlePage = &titlePage{Content: content{Paragraphs: writeTitlePage(doc.TitlePage)}}
}
if sets := doc.Settings.Revisions; len(sets) > 0 {
rs := &revisions{ActiveSet: len(sets), RevisionMode: "No"}
if doc.Settings.Revising {
rs.RevisionMode = "Yes"
}
for i, set := range sets {
name := set.Name + " Revision"
if set.Date != "" {
name += " - " + set.Date
}
hex := ""
for _, c := range revisionColors {
if c.name == set.Color {
hex = c.hex
}
}
rs.Items = append(rs.Items, revision{ID: i + 1, Name: name, Color: hex, PageColor: hex, Mark: "*", FullRevision: "No"})
}
wr.f.Revisions = rs
}
var buf bytes.Buffer
buf.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="no" ?>` + "\n")
enc := xml.NewEncoder(&buf)
enc.Indent("", " ")
if err := enc.Encode(wr.f); err != nil {
return err
}
buf.WriteString("\n")
_, err := w.Write(buf.Bytes())
return err
}
type writer struct {
f file
notes int // script notes written so far, for their IDs
pending []scriptNote // sections and synopses waiting for the next paragraph
newPage bool // page break of an element that was left out
}
func (wr *writer) note(body string, offset int) scriptNote {
wr.notes++
sn := scriptNote{ID: wr.notes, Range: strconv.Itoa(offset) + ",0"}
for _, line := range strings.Split(body, "\n") {
sn.Paragraphs = append(sn.Paragraphs, paragraph{Texts: []text{{Value: line}}})
}
return sn
}
// add appends a top-level paragraph with the outline and page break that
// precede it.
func (wr *writer) add(p paragraph, newPage bool) {
if newPage || wr.newPage {
p.StartsNewPage = "Yes"
}
p.ScriptNotes = append(wr.pending, p.ScriptNotes...)
wr.pending, wr.newPage = nil, false
wr.f.Content.Paragraphs = append(wr.f.Content.Paragraphs, p)
}
func outlineText(el screenplay.Element) string {
text := strings.ReplaceAll(el.Text(), "\n", " ")
if el.Type == screenplay.Section {
return strings.Repeat("#", max(el.Level, 1)) + " " + text
}
return "= " + text
}
// blockEnd returns the index just past the dialogue block starting at the
// character cue i.
func blockEnd(els []screenplay.Element, i int) int {
j := i + 1
for j < len(els) && els[j].Type.InDialogue() {
j++
}
return j
}
func (wr *writer) paragraph(el screenplay.Element) paragraph {
p := paragraph{Type: typeNames[el.Type]}
if p.Type == "" {
p.Type = "Action"
}
if el.Type == screenplay.Centered || el.Type == screenplay.Intertitle {
p.Alignment = "Center"
}
if el.Type == screenplay.Scene {
p.Number = el.SceneNumber
}
var runs []screenplay.Run
offset := 0 // runes of printed text so far
for _, r := range el.Runs {
if r.Note {
body := r.Text
// A note starting like an outline element would be read as one.
if strings.HasPrefix(body, "#") || strings.HasPrefix(body, "=") || strings.HasPrefix(body, `\`) {
body = `\` + body
}
p.ScriptNotes = append(p.ScriptNotes, wr.note(body, offset))
continue
}
offset += len([]rune(r.Text))
runs = append(runs, r)
}
for _, r := range screenplay.NormalizeRuns(runs) {
t := r.Text
if el.Type.Uppercase() {
t = screenplay.UpperRunes(t)
}
p.Texts = append(p.Texts, text{Style: style(r), RevisionID: r.Rev, Value: t})
}
return p
}
func writeTitlePage(tp screenplay.TitlePage) []paragraph {
var ps []paragraph
add := func(align, s string) {
for _, line := range strings.Split(s, "\n") {
ps = append(ps, paragraph{Type: "Action", Alignment: align, Texts: []text{{Value: line}}})
}
}
blank := func(n int) {
for i := 0; i < n; i++ {
add("Center", "")
}
}
blank(16)
add("Center", tp.Title)
blank(2)
add("Center", tp.Credit)
blank(1)
add("Center", tp.Author)
if tp.Source != "" {
blank(2)
add("Center", tp.Source)
}
blank(16)
if tp.Contact != "" {
add("Left", tp.Contact)
}
if tp.DraftDate != "" {
add("Right", tp.DraftDate)
}
if tp.Notes != "" {
add("Right", tp.Notes)
}
return ps
}
func isAllCaps(s string) bool {
return strings.ContainsFunc(s, unicode.IsLetter) && !strings.ContainsFunc(s, unicode.IsLower)
}
func style(r screenplay.Run) string {
var parts []string
if r.Bold {
parts = append(parts, "Bold")
}
if r.Italic {
parts = append(parts, "Italic")
}
if r.Underline {
parts = append(parts, "Underline")
}
return strings.Join(parts, "+")
}
+103
View File
@@ -0,0 +1,103 @@
package fdx
import (
"bytes"
"reflect"
"strings"
"testing"
"plotzilla/internal/screenplay"
)
func TestRoundTrip(t *testing.T) {
doc := &screenplay.Document{
TitlePage: screenplay.TitlePage{Title: "Le Dernier Rouleau", Credit: "Written by", Author: "Jane Doe"},
Elements: []screenplay.Element{
{Type: screenplay.Scene, Runs: []screenplay.Run{{Text: "INT. CABINE - NUIT"}}, SceneNumber: "1"},
{Type: screenplay.Action, Runs: []screenplay.Run{{Text: "Elle "}, {Text: "entre", Bold: true, Underline: true}, {Text: "."}}},
{Type: screenplay.Character, Runs: []screenplay.Run{{Text: "ÉLODIE"}}},
{Type: screenplay.Parenthetical, Runs: []screenplay.Run{{Text: "(bas)"}}},
{Type: screenplay.Dialogue, Runs: []screenplay.Run{{Text: "Ça finit ici."}}},
{Type: screenplay.Character, Runs: []screenplay.Run{{Text: "BOB"}}, Dual: true},
{Type: screenplay.Dialogue, Runs: []screenplay.Run{{Text: "Non."}}},
{Type: screenplay.Shot, Runs: []screenplay.Run{{Text: "INSERT - LA LETTRE"}}},
{Type: screenplay.Centered, Runs: []screenplay.Run{{Text: "Fin"}}},
{Type: screenplay.Intertitle, Runs: []screenplay.Run{{Text: "PARIS, 1942"}}},
},
}
var buf bytes.Buffer
if err := Write(&buf, doc); err != nil {
t.Fatal(err)
}
got, err := Read(&buf)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got.Elements, doc.Elements) {
t.Errorf("elements:\n got %+v\nwant %+v", got.Elements, doc.Elements)
}
if got.TitlePage.Title != doc.TitlePage.Title || got.TitlePage.Author != doc.TitlePage.Author {
t.Errorf("title page: %+v", got.TitlePage)
}
}
func TestOutlineNotesRevisions(t *testing.T) {
bob := screenplay.Element{Type: screenplay.Character, Runs: []screenplay.Run{{Text: "BOB"}}, Dual: true}
doc := &screenplay.Document{
Settings: screenplay.Settings{
Revisions: []screenplay.Revision{{Name: "Blue", Color: "blue", Date: "09/14/26"}, {Name: "Pink", Color: "pink", Date: "09/28/26"}},
Revising: true,
},
Elements: []screenplay.Element{
{Type: screenplay.Section, Level: 2, Runs: []screenplay.Run{{Text: "Act One"}}},
{Type: screenplay.Synopsis, Runs: []screenplay.Run{{Text: "It starts."}}},
{Type: screenplay.Scene, Runs: []screenplay.Run{{Text: "INT. BOOTH - NIGHT"}}, NewPage: true},
{Type: screenplay.Action, Runs: []screenplay.Run{
{Text: "He "}, {Text: "waits", Rev: 2, Bold: true}, {Text: ".", Rev: 1},
{Text: "# not a section", Note: true}, {Text: " Then", Rev: 1}, {Text: "fix", Note: true},
}},
{Type: screenplay.Action, Runs: []screenplay.Run{{Text: "only a note", Note: true}}},
{Type: screenplay.Character, Runs: []screenplay.Run{{Text: "ALICE"}}, NewPage: true},
{Type: screenplay.Dialogue, Runs: []screenplay.Run{{Text: "Hi."}}},
bob,
{Type: screenplay.Dialogue, Runs: []screenplay.Run{{Text: "Hey."}}},
{Type: screenplay.Section, Level: 1, Runs: []screenplay.Run{{Text: "The End"}}},
},
}
var buf bytes.Buffer
if err := Write(&buf, doc); err != nil {
t.Fatal(err)
}
out := buf.String()
for _, want := range []string{`StartsNewPage="Yes"`, `RevisionID="2"`, `Name="Pink Revision - 09/28/26"`, `RevisionMode="Yes"`, `\# not a section`} {
if !strings.Contains(out, want) {
t.Errorf("missing %s in:\n%s", want, out)
}
}
got, err := Read(&buf)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got.Settings, doc.Settings) {
t.Errorf("settings: %+v", got.Settings)
}
if !reflect.DeepEqual(got.Elements, doc.Elements) {
for i := range got.Elements {
if i >= len(doc.Elements) || !reflect.DeepEqual(got.Elements[i], doc.Elements[i]) {
t.Errorf("element %d: %+v", i, got.Elements[i])
}
}
t.Fatalf("round trip:\n%s", out)
}
}
func TestFinalDraftRevisionNames(t *testing.T) {
// A set named by Final Draft keeps its colour; an unknown name falls
// back to the colour of its position.
if r := readRevision(revision{Name: "Goldenrod Rev."}, 5); r.Name != "Goldenrod" || r.Color != "goldenrod" {
t.Errorf("got %+v", r)
}
if r := readRevision(revision{Name: "Second draft"}, 2); r.Name != "Second draft" || r.Color != "pink" {
t.Errorf("got %+v", r)
}
}
+230
View File
@@ -0,0 +1,230 @@
package fountain
import (
"reflect"
"strings"
"testing"
"plotzilla/internal/screenplay"
)
const sample = `Title: The Last Reel
Credit: Written by
Author: Jane Doe
Draft date: 1 Sept 2026
INT. PROJECTION BOOTH - NIGHT #1#
Dust hangs in the **beam** of the projector.
INSERT - THE TICKET STUB
> Paris, 1942 <
MARGOT (V.O.)
(quietly)
It always ends *here*.
Right here.
CUT TO:
.FLASHBACK
@McCLANE
Yippee.
HANS ^
Nein.
!SHOUTING IS ACTION
and continues.
> THE END <
`
func types(doc *screenplay.Document) []screenplay.ElementType {
var out []screenplay.ElementType
for _, e := range doc.Elements {
out = append(out, e.Type)
}
return out
}
func TestParse(t *testing.T) {
doc := Parse(sample)
if doc.TitlePage.Title != "The Last Reel" || doc.TitlePage.Author != "Jane Doe" {
t.Fatalf("title page: %+v", doc.TitlePage)
}
want := []screenplay.ElementType{
screenplay.Scene, screenplay.Action, screenplay.Shot, screenplay.Centered,
screenplay.Character, screenplay.Parenthetical, screenplay.Dialogue,
screenplay.Transition, screenplay.Scene,
screenplay.Character, screenplay.Dialogue,
screenplay.Character, screenplay.Dialogue,
screenplay.Action, screenplay.Intertitle,
}
if got := types(doc); !reflect.DeepEqual(got, want) {
t.Fatalf("types:\n got %v\nwant %v", got, want)
}
if doc.Elements[0].SceneNumber != "1" || doc.Elements[0].Text() != "INT. PROJECTION BOOTH - NIGHT" {
t.Errorf("scene: %+v", doc.Elements[0])
}
if r := doc.Elements[1].Runs; len(r) != 3 || !r[1].Bold || r[1].Text != "beam" {
t.Errorf("bold runs: %+v", r)
}
if got := doc.Elements[2].Text(); got != "INSERT - THE TICKET STUB" {
t.Errorf("shot: %q", got)
}
if got := doc.Elements[6].Text(); got != "It always ends here.\nRight here." {
t.Errorf("dialogue: %q", got)
}
if got := doc.Elements[9].Text(); got != "McCLANE" {
t.Errorf("forced character: %q", got)
}
if hans := doc.Elements[11]; hans.Text() != "HANS" || !hans.Dual || doc.Elements[9].Dual {
t.Errorf("dual cue: %+v", hans)
}
}
func TestInlineLiteralAsterisk(t *testing.T) {
runs := parseInline("2 * 3 = 6")
if len(runs) != 1 || runs[0].Text != "2 * 3 = 6" || runs[0].Italic {
t.Errorf("got %+v", runs)
}
}
func TestRoundTrip(t *testing.T) {
first := Parse(sample)
second := Parse(Write(first))
if !reflect.DeepEqual(first, second) {
t.Fatalf("round trip changed the document:\n%s", Write(first))
}
}
const outline = `# Act One
## The Booth
= Margot finds the reel.
INT. PROJECTION BOOTH - NIGHT
Dust hangs in the beam. [[Too dark? Try dawn.]]
[[Cut the cat
or keep it for the ending.]]
MARGOT [[she whispers]]
(quietly)
It always ends here.
`
func TestOutlineAndNotes(t *testing.T) {
doc := Parse(outline)
want := []screenplay.ElementType{
screenplay.Section, screenplay.Section, screenplay.Synopsis, screenplay.Scene,
screenplay.Action, screenplay.Action, screenplay.Character, screenplay.Parenthetical, screenplay.Dialogue,
}
if got := types(doc); !reflect.DeepEqual(got, want) {
t.Fatalf("types:\n got %v\nwant %v", got, want)
}
els := doc.Elements
if els[0].Level != 1 || els[0].Text() != "Act One" || els[1].Level != 2 || els[1].Text() != "The Booth" {
t.Errorf("sections: %+v %+v", els[0], els[1])
}
if els[2].Text() != "Margot finds the reel." {
t.Errorf("synopsis: %q", els[2].Text())
}
if r := els[4].Runs; len(r) != 2 || r[1].Text != "Too dark? Try dawn." || !r[1].Note || r[0].Note {
t.Errorf("inline note: %+v", r)
}
if r := els[5].Runs; len(r) != 1 || !r[0].Note || r[0].Text != "Cut the cat\n\nor keep it for the ending." {
t.Errorf("note paragraph: %+v", r)
}
if els[6].PrintedText() != "MARGOT " || !els[6].Runs[1].Note {
t.Errorf("note after a cue: %+v", els[6].Runs)
}
if second := Parse(Write(doc)); !reflect.DeepEqual(doc, second) {
t.Fatalf("round trip changed the document:\n%s", Write(doc))
}
}
func TestRevisionsAndLocks(t *testing.T) {
doc := &screenplay.Document{
TitlePage: screenplay.TitlePage{Title: "Reel"},
Settings: screenplay.Settings{
Paper: screenplay.A4,
Revisions: []screenplay.Revision{{Name: "Blue", Color: "blue", Date: "09/28/26"}, {Name: "Pink", Color: "pink", Date: "*/"}},
Revising: true,
Locked: true,
},
Elements: []screenplay.Element{
{Type: screenplay.Scene, Runs: []screenplay.Run{{Text: "INT. BOOTH - NIGHT"}}, Locked: []screenplay.PageLock{{Offset: 0, Page: "2"}}},
{Type: screenplay.Action, Runs: []screenplay.Run{
{Text: "Dust hangs in the "}, {Text: "golden", Rev: 2, Bold: true}, {Text: " beam.", Rev: 1},
{Text: " She waits.", Rev: 1, Note: true}, {Text: " Then leaves."},
}, Locked: []screenplay.PageLock{{Offset: 25, Page: "3"}, {Offset: 25, Page: "4"}}},
{Type: screenplay.Character, Runs: []screenplay.Run{{Text: "MARGOT"}}},
{Type: screenplay.Parenthetical, Runs: []screenplay.Run{{Text: "(quietly)", Rev: 1}}, Locked: []screenplay.PageLock{{Offset: 0, Page: "4A"}}},
{Type: screenplay.Dialogue, Runs: []screenplay.Run{{Text: "Here."}}},
{Type: screenplay.Shot, Runs: []screenplay.Run{{Text: "INSERT - ", Rev: 1}, {Text: "THE TICKET"}}},
},
}
out := Write(doc)
if !strings.Contains(out, "/*page 2*/INT. BOOTH") || !strings.Contains(out, "/*rev 2*/**golden**/*/rev*//*rev 1*/ /*page 3*//*page 4*/beam.") {
t.Errorf("markers:\n%s", out)
}
if got := Parse(out); !reflect.DeepEqual(doc, got) {
for i := range got.Elements {
if !reflect.DeepEqual(got.Elements[i], doc.Elements[i]) {
t.Errorf("element %d:\n got %#v\nwant %#v", i, got.Elements[i], doc.Elements[i])
}
}
t.Fatalf("round trip changed the document:\n%s", out)
}
}
func TestPlainScriptUnchanged(t *testing.T) {
// No Plotzilla data in a script that uses none of it.
if out := Write(Parse(sample)); strings.Contains(out, "/*") || strings.Contains(out, "[[") {
t.Errorf("unexpected markers:\n%s", out)
}
}
func TestPastedText(t *testing.T) {
els := ParseText("# Act Two\r\n\r\nJOHN\r\nHi. [[louder]]\r\n")
if len(els) != 3 || els[0].Type != screenplay.Section || els[2].PrintedText() != "Hi. " {
t.Errorf("got %+v", els)
}
}
func TestInvalidNoteStaysText(t *testing.T) {
// An empty line ends a note: without the two spaces this is not one.
doc := Parse("Wait [[for it\n\nnow]] here.\n")
if len(doc.Elements) != 2 || doc.Elements[0].Text() != "Wait [[for it" || doc.Elements[0].Runs[0].Note {
t.Errorf("got %+v", doc.Elements)
}
}
func TestForcedPageBreak(t *testing.T) {
doc := Parse("INT. HOUSE - DAY\n\nHe waits.\n\n===\n\nEXT. ROAD - NIGHT\n\nJOHN\nHi.\n\n===\nLater.\n")
want := []bool{false, false, true, false, false, true}
for i, el := range doc.Elements {
if el.NewPage != want[i] {
t.Errorf("element %d %q: NewPage = %v", i, el.Text(), el.NewPage)
}
}
out := Write(doc)
if !strings.Contains(out, "He waits.\n\n===\n\nEXT. ROAD") {
t.Errorf("page break not written:\n%s", out)
}
if second := Parse(out); !reflect.DeepEqual(doc, second) {
t.Fatalf("round trip changed the document:\n%s", out)
}
// The page break of an empty element moves to the next one written.
doc.Elements[1] = screenplay.Element{Type: screenplay.Action, NewPage: true}
if again := Parse(Write(doc)); !again.Elements[1].NewPage || again.Elements[1].Type != screenplay.Scene {
t.Errorf("carried page break: %+v", again.Elements)
}
}
+418
View File
@@ -0,0 +1,418 @@
// Package fountain reads and writes the Fountain plain-text screenplay
// format (https://fountain.io/syntax).
package fountain
import (
"encoding/json"
"regexp"
"strconv"
"strings"
"unicode"
"plotzilla/internal/screenplay"
)
var (
sceneRe = regexp.MustCompile(`(?i)^(INT|EXT|EST|INT\.?/EXT|I/E)[. ]`)
sceneNumberRe = regexp.MustCompile(`\s*#([\w.\-]+)#\s*$`)
transitionRe = regexp.MustCompile(`^[^a-z]*TO:$`)
titleKeyRe = regexp.MustCompile(`^([A-Za-z][A-Za-z ]*):\s*(.*)$`)
boneyardRe = regexp.MustCompile(`(?s)/\*.*?\*/`)
pageBreakRe = regexp.MustCompile(`^={3,}$`)
// Plotzilla's own data lives in boneyard comments, which other Fountain
// apps ignore: the document settings in one block at the end, and
// revision and locked-page markers inline, where they travel with the
// text they mark.
settingsRe = regexp.MustCompile(`(?s)/\*\s*Plotzilla (\{.*?\})\s*\*/`)
markerRe = regexp.MustCompile(`/\*(rev \d+|/rev|page [^*\s]+)\*/`)
noteRe = regexp.MustCompile(`(?s)\[\[.*?\]\]`)
tokenRe = regexp.MustCompile("\uE000[^\uE001]*\uE001")
// Fountain has no shot element: shots are all-caps action lines.
shotRe = regexp.MustCompile(`^(INSERT|CLOSE ON|CLOSE UP|EXTREME CLOSE|ECU|CU|ANGLE ON|NEW ANGLE|REVERSE ANGLE|POV|WIDE ON|WIDE SHOT|OVERHEAD|BACK TO SCENE|TRACKING|AERIAL)\b`)
)
// Parse converts Fountain source into a Document. Boneyard that is not
// Plotzilla's own data is dropped.
func Parse(src string) *screenplay.Document {
src = strings.TrimPrefix(src, "\ufeff")
src = strings.ReplaceAll(src, "\r\n", "\n")
src = strings.ReplaceAll(src, "\r", "\n")
doc := &screenplay.Document{}
if m := settingsRe.FindAllStringSubmatchIndex(src, -1); len(m) > 0 {
last := m[len(m)-1]
doc.Settings = parseSettings(src[last[2]:last[3]])
src = src[:last[0]] + src[last[1]:]
}
p := tokenize(src)
lines := strings.Split(p.src, "\n")
start := parseTitlePage(lines, &doc.TitlePage)
doc.Elements = p.elements(lines[start:])
if len(doc.Elements) == 0 {
doc.Elements = []screenplay.Element{screenplay.NewElement(screenplay.Scene, "")}
}
return doc
}
func parseSettings(data string) screenplay.Settings {
var s screenplay.Settings
if json.Unmarshal([]byte(data), &s) != nil {
return screenplay.Settings{}
}
if s.Paper != screenplay.A4 {
s.Paper = screenplay.Letter
}
return s
}
// ParseText parses body text (no title page), such as pasted plain text.
func ParseText(text string) []screenplay.Element {
text = strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n")
p := tokenize(text)
return p.elements(strings.Split(p.src, "\n"))
}
// ParseElements parses body lines (no title page).
func ParseElements(lines []string) []screenplay.Element {
return ParseText(strings.Join(lines, "\n"))
}
// parser holds the source once notes and markers have been replaced by
// tokens: U+E000, a payload, U+E001. Tokens contain no line break, so a
// note spanning lines no longer splits its paragraph, and line rules look
// at the text without them (bare).
type parser struct {
src string
notes []string
}
func token(payload string) string { return "\uE000" + payload + "\uE001" }
// bare removes the tokens: what line classification looks at.
func bare(s string) string { return strings.TrimSpace(tokenRe.ReplaceAllString(s, "")) }
func tokenize(src string) *parser {
src = markerRe.ReplaceAllStringFunc(src, func(m string) string {
body := m[2 : len(m)-2]
switch {
case body == "/rev":
return token("r")
case strings.HasPrefix(body, "rev "):
return token("r" + body[4:])
}
return token("p" + strings.TrimPrefix(body, "page "))
})
src = boneyardRe.ReplaceAllString(src, "")
p := &parser{}
src = noteRe.ReplaceAllStringFunc(src, func(m string) string {
note, ok := noteText(m[2 : len(m)-2])
if !ok {
return m
}
p.notes = append(p.notes, note)
return token("n" + strconv.Itoa(len(p.notes)-1))
})
p.src = src
return p
}
// noteText validates a note's content: it may span lines, but an empty
// line inside it must hold two spaces, as in dialogue.
func noteText(s string) (string, bool) {
lines := strings.Split(s, "\n")
for i := 1; i < len(lines)-1; i++ {
if strings.TrimSpace(lines[i]) == "" {
if len(lines[i]) < 2 {
return "", false
}
lines[i] = ""
}
}
return strings.Join(lines, "\n"), true
}
func (p *parser) elements(lines []string) []screenplay.Element {
var out []screenplay.Element
add := func(t screenplay.ElementType, text string) {
runs, locks := p.inline(text)
out = append(out, screenplay.Element{Type: t, Runs: runs, Locked: locks})
}
blank := func(i int) bool { return i < 0 || i >= len(lines) || strings.TrimSpace(lines[i]) == "" }
prevBlank := true
newPage := false // a "===" line: the next element starts a page
for i := 0; i < len(lines); i++ {
trimmed := strings.TrimSpace(lines[i])
if trimmed == "" {
prevBlank = true
continue
}
nextBlank := blank(i + 1)
b := bare(trimmed)
if pageBreakRe.MatchString(b) {
newPage = true
prevBlank = true
continue
}
first := len(out)
switch {
case strings.HasPrefix(b, "#"):
text := strings.TrimLeft(trimmed, "#")
add(screenplay.Section, strings.TrimSpace(text))
out[len(out)-1].Level = max(len(b)-len(strings.TrimLeft(b, "#")), 1)
case strings.HasPrefix(b, "="):
add(screenplay.Synopsis, strings.TrimSpace(strings.TrimPrefix(trimmed, "=")))
case strings.HasPrefix(b, ">") && strings.HasSuffix(b, "<") && len(b) > 1:
// Fountain only knows centred text; an all-caps one is read as
// an intertitle (title card), which is how Plotzilla writes them.
text := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(trimmed, ">"), "<"))
if isCharacterCue(bare(text)) {
add(screenplay.Intertitle, text)
} else {
add(screenplay.Centered, text)
}
case prevBlank && nextBlank && shotRe.MatchString(b) && isCharacterCue(b):
add(screenplay.Shot, trimmed)
case strings.HasPrefix(b, ">"):
add(screenplay.Transition, strings.TrimSpace(strings.TrimPrefix(trimmed, ">")))
case strings.HasPrefix(b, ".") && !strings.HasPrefix(b, ".."):
p.addScene(&out, strings.TrimPrefix(trimmed, "."))
case prevBlank && sceneRe.MatchString(b):
p.addScene(&out, trimmed)
case prevBlank && nextBlank && transitionRe.MatchString(b):
add(screenplay.Transition, trimmed)
case prevBlank && !nextBlank && !strings.HasPrefix(b, "!") &&
(strings.HasPrefix(b, "@") || isCharacterCue(b)):
cue := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(trimmed, "@"), "^"))
add(screenplay.Character, cue)
out[len(out)-1].Dual = strings.HasSuffix(b, "^")
i = p.dialogue(lines, i+1, &out)
default:
// Action: consecutive non-blank lines form one paragraph.
para := []string{strings.TrimPrefix(strings.TrimPrefix(trimmed, "!"), "~")}
for !blank(i + 1) {
i++
para = append(para, strings.TrimSpace(strings.TrimPrefix(lines[i], "~")))
}
add(screenplay.Action, strings.Join(para, "\n"))
}
if newPage && len(out) > first {
out[first].NewPage = true
newPage = false
}
prevBlank = false
}
return out
}
func (p *parser) addScene(out *[]screenplay.Element, text string) {
el := screenplay.Element{Type: screenplay.Scene}
if m := sceneNumberRe.FindStringSubmatch(text); m != nil {
el.SceneNumber = m[1]
text = text[:len(text)-len(m[0])]
}
el.Runs, el.Locked = p.inline(strings.TrimSpace(text))
*out = append(*out, el)
}
// dialogue consumes the lines of a dialogue block starting at i and
// returns the index of the last consumed line.
func (p *parser) dialogue(lines []string, i int, out *[]screenplay.Element) int {
var cur []string
add := func(t screenplay.ElementType, text string) {
runs, locks := p.inline(text)
*out = append(*out, screenplay.Element{Type: t, Runs: runs, Locked: locks})
}
flush := func() {
if len(cur) > 0 {
add(screenplay.Dialogue, strings.Join(cur, "\n"))
cur = nil
}
}
for ; i < len(lines); i++ {
line := lines[i]
trimmed := strings.TrimSpace(line)
if trimmed == "" {
// Two spaces keep an intentional empty line inside dialogue.
if len(line) >= 2 && len(cur) > 0 {
cur = append(cur, "")
continue
}
break
}
if b := bare(trimmed); strings.HasPrefix(b, "(") && strings.HasSuffix(b, ")") {
flush()
add(screenplay.Parenthetical, trimmed)
continue
}
cur = append(cur, trimmed)
}
flush()
return i - 1
}
func isCharacterCue(s string) bool {
name := strings.TrimSuffix(s, "^")
if j := strings.Index(name, "("); j >= 0 {
name = name[:j]
}
hasLetter := false
for _, r := range name {
if unicode.IsLetter(r) {
hasLetter = true
if unicode.IsLower(r) {
return false
}
}
}
return hasLetter
}
// parseTitlePage reads "Key: value" pairs at the top of the file and returns
// the index of the first body line.
func parseTitlePage(lines []string, tp *screenplay.TitlePage) int {
if len(lines) == 0 || !titleKeyRe.MatchString(lines[0]) {
return 0
}
var key string
values := map[string][]string{}
i := 0
for ; i < len(lines); i++ {
line := lines[i]
if strings.TrimSpace(line) == "" {
break
}
if m := titleKeyRe.FindStringSubmatch(line); m != nil && !startsIndented(line) {
key = strings.ToLower(strings.TrimSpace(m[1]))
if v := strings.TrimSpace(m[2]); v != "" {
values[key] = append(values[key], v)
}
continue
}
if key != "" {
values[key] = append(values[key], strings.TrimSpace(line))
}
}
get := func(keys ...string) string {
var parts []string
for _, k := range keys {
for _, v := range values[k] {
parts = append(parts, plain(v))
}
}
return strings.Join(parts, "\n")
}
tp.Title = get("title")
tp.Credit = get("credit")
tp.Author = get("author", "authors")
tp.Source = get("source")
tp.DraftDate = get("draft date", "date")
tp.Contact = get("contact")
tp.Notes = get("notes", "copyright")
return i
}
func startsIndented(line string) bool {
return strings.HasPrefix(line, "\t") || strings.HasPrefix(line, " ")
}
// plain drops emphasis, notes and markers from a title page value.
func plain(s string) string {
var b strings.Builder
for _, r := range parseInline(s) {
b.WriteString(r.Text)
}
return b.String()
}
// parseInline parses emphasis; notes are dropped.
func parseInline(s string) []screenplay.Run {
runs, _ := (&parser{}).inline(s)
return runs
}
// inline turns Fountain emphasis (**bold**, *italic*, _underline_, with
// backslash escapes) into runs, and tokens into notes, revision levels and
// page locks. A marker only opens a span when a matching marker follows on
// the same line, so stray asterisks stay literal.
func (p *parser) inline(s string) ([]screenplay.Run, []screenplay.PageLock) {
var runs []screenplay.Run
var locks []screenplay.PageLock
var cur screenplay.Run
var buf strings.Builder
offset := 0 // runes of element text so far
flush := func() {
if buf.Len() > 0 {
cur.Text = buf.String()
runs = append(runs, cur)
buf.Reset()
}
}
rs := []rune(s)
closes := func(from int, marker string) bool {
rest := string(rs[from:])
if nl := strings.IndexByte(rest, '\n'); nl >= 0 {
rest = rest[:nl]
}
return strings.Contains(rest, marker)
}
for i := 0; i < len(rs); i++ {
c := rs[i]
switch {
case c == '\uE000':
end := i + 1
for end < len(rs) && rs[end] != '\uE001' {
end++
}
payload := string(rs[i+1 : min(end, len(rs))])
i = end
if payload == "" {
continue
}
arg := payload[1:]
switch payload[0] {
case 'n':
if n, err := strconv.Atoi(arg); err == nil && n < len(p.notes) {
flush()
runs = append(runs, screenplay.Run{Text: p.notes[n], Note: true, Rev: cur.Rev})
offset += len([]rune(p.notes[n]))
}
case 'r':
flush()
cur.Rev, _ = strconv.Atoi(arg)
case 'p':
locks = append(locks, screenplay.PageLock{Offset: offset, Page: arg})
}
continue
case c == '\\' && i+1 < len(rs) && strings.ContainsRune(`*_\`, rs[i+1]):
buf.WriteRune(rs[i+1])
offset++
i++
case c == '*' && i+1 < len(rs) && rs[i+1] == '*' && (cur.Bold || closes(i+2, "**")):
flush()
cur.Bold = !cur.Bold
i++
case c == '*' && (cur.Italic || closes(i+1, "*")):
flush()
cur.Italic = !cur.Italic
case c == '_' && (cur.Underline || closes(i+1, "_")):
flush()
cur.Underline = !cur.Underline
default:
buf.WriteRune(c)
offset++
}
}
flush()
return screenplay.NormalizeRuns(runs), locks
}
+290
View File
@@ -0,0 +1,290 @@
package fountain
import (
"encoding/json"
"strconv"
"strings"
"unicode"
"plotzilla/internal/screenplay"
)
// Write serialises a Document to Fountain. Elements that Fountain would
// otherwise misread are written with the explicit "force" prefixes.
func Write(doc *screenplay.Document) string {
var b strings.Builder
writeTitlePage(&b, doc.TitlePage)
var prev screenplay.ElementType
var carried []screenplay.PageLock // locks of skipped empty elements
carriedPage := false // and their forced page break
first := true
for _, el := range doc.Elements {
if el.IsEmpty() {
for _, l := range el.Locked {
carried = append(carried, screenplay.PageLock{Page: l.Page})
}
carriedPage = carriedPage || el.NewPage
continue
}
locks := append(carried, el.Locked...)
newPage := carriedPage || el.NewPage
carried, carriedPage = nil, false
inBlock := el.Type.InDialogue() && (prev == screenplay.Character || prev.InDialogue())
if !first && !inBlock {
b.WriteString("\n")
}
// "===" cannot sit inside a dialogue block: layout breaks before the
// block anyway.
if newPage && !inBlock {
b.WriteString("===\n\n")
}
first = false
prev = el.Type
if el.Type == screenplay.Intertitle || el.Type == screenplay.Shot {
el = upper(el)
}
// Notes keep their line breaks where Fountain keeps the element's.
keepLines := el.Type == screenplay.Action || el.Type == screenplay.Dialogue
text := markup(el, locks, keepLines)
plainText := el.PrintedText()
switch el.Type {
// Case is kept as typed ("McCLANE"); layout upper-cases when printing.
case screenplay.Scene:
if !sceneRe.MatchString(plainText) {
text = "." + text
}
if el.SceneNumber != "" {
text += " #" + el.SceneNumber + "#"
}
case screenplay.Character:
if sceneRe.MatchString(plainText) || !isCharacterCue(plainText) {
text = "@" + text
}
if el.Dual {
text += " ^"
}
case screenplay.Parenthetical:
text = strings.ReplaceAll(text, "\n", " ")
if !strings.HasPrefix(plainText, "(") {
text = "(" + text
}
if !strings.HasSuffix(plainText, ")") {
text += ")"
}
case screenplay.Dialogue:
text = strings.ReplaceAll(text, "\n\n", "\n \n")
case screenplay.Transition:
if !transitionRe.MatchString(plainText) {
text = "> " + text
}
case screenplay.Centered, screenplay.Intertitle:
text = "> " + strings.ReplaceAll(text, "\n", " <\n> ") + " <"
case screenplay.Shot:
// Written as an all-caps line; read back as a shot when it
// starts like one (INSERT, CLOSE ON, POV, ...).
text = strings.ReplaceAll(text, "\n", " ")
if check := strings.ReplaceAll(plainText, "\n", " "); needsForcedAction(check) && !shotRe.MatchString(check) {
text = "!" + text
}
case screenplay.Section:
text = strings.Repeat("#", max(el.Level, 1)) + " " + strings.ReplaceAll(text, "\n", " ")
case screenplay.Synopsis:
text = "= " + strings.ReplaceAll(text, "\n", " ")
default:
if needsForcedAction(plainText) {
text = "!" + text
}
}
b.WriteString(text)
b.WriteString("\n")
}
writeSettings(&b, doc.Settings)
return b.String()
}
// writeSettings stores the production settings in a boneyard block that
// other Fountain apps ignore.
func writeSettings(b *strings.Builder, s screenplay.Settings) {
if s.IsZero() {
return
}
data, err := json.Marshal(s)
if err != nil {
return
}
// "*/" can only occur inside a JSON string, where "\/" means "/".
b.WriteString("\n/* Plotzilla " + strings.ReplaceAll(string(data), "*/", `*\/`) + " */\n")
}
// upper capitalises the text of an element that is always written in
// capitals, leaving its notes alone.
func upper(el screenplay.Element) screenplay.Element {
runs := make([]screenplay.Run, len(el.Runs))
for i, r := range el.Runs {
if !r.Note {
r.Text = screenplay.UpperRunes(r.Text)
}
runs[i] = r
}
el.Runs = runs
return el
}
func needsForcedAction(s string) bool {
first := s
if i := strings.IndexByte(s, '\n'); i >= 0 {
first = s[:i]
}
first = strings.TrimSpace(first)
if first == "" {
return false
}
if strings.ContainsRune(".>@#=~!", rune(first[0])) {
return true
}
if sceneRe.MatchString(first) || transitionRe.MatchString(first) {
return true
}
// An all-caps first line followed by more lines would read as a character.
return strings.Contains(s, "\n") && isCharacterCue(first)
}
func writeTitlePage(b *strings.Builder, tp screenplay.TitlePage) {
if tp.IsEmpty() {
return
}
field := func(key, value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
lines := strings.Split(value, "\n")
if len(lines) == 1 {
b.WriteString(key + ": " + value + "\n")
return
}
b.WriteString(key + ":\n")
for _, l := range lines {
b.WriteString(" " + strings.TrimSpace(l) + "\n")
}
}
field("Title", tp.Title)
field("Credit", tp.Credit)
field("Author", tp.Author)
field("Source", tp.Source)
field("Draft date", tp.DraftDate)
field("Contact", tp.Contact)
field("Notes", tp.Notes)
b.WriteString("\n")
}
// markup renders runs with Fountain emphasis markers, notes ("[[…]]"),
// revision markers ("/*rev 2*/…/*/rev*/") and locked page starts
// ("/*page 12*/"). Emphasis cannot span lines in Fountain, so markers are
// closed and reopened around "\n".
func markup(el screenplay.Element, locks []screenplay.PageLock, keepNoteLines bool) string {
var b strings.Builder
pos, next, rev := 0, 0, 0
emitLocks := func() {
for ; next < len(locks) && locks[next].Offset <= pos; next++ {
b.WriteString("/*page " + locks[next].Page + "*/")
}
}
for _, r := range splitRuns(el.Runs, locks) {
emitLocks()
if r.Rev != rev {
if rev > 0 {
b.WriteString("/*/rev*/")
}
if r.Rev > 0 {
b.WriteString("/*rev " + strconv.Itoa(r.Rev) + "*/")
}
rev = r.Rev
}
pos += len([]rune(r.Text))
if r.Note {
b.WriteString("[[" + noteMarkup(r.Text, keepNoteLines) + "]]")
continue
}
open, close := "", ""
if r.Underline {
open += "_"
}
if r.Bold {
open += "**"
}
if r.Italic {
open += "*"
}
for i := len(open) - 1; i >= 0; i-- {
close += string(open[i])
}
for i, line := range strings.Split(r.Text, "\n") {
if i > 0 {
b.WriteString("\n")
}
if strings.TrimSpace(line) == "" || open == "" {
b.WriteString(escape(line))
continue
}
// Keep surrounding spaces outside the markers.
lead := len(line) - len(strings.TrimLeftFunc(line, unicode.IsSpace))
trail := len(strings.TrimRightFunc(line, unicode.IsSpace))
b.WriteString(line[:lead] + open + escape(line[lead:trail]) + close + line[trail:])
}
}
if rev > 0 {
b.WriteString("/*/rev*/")
}
emitLocks()
return b.String()
}
// splitRuns cuts runs where a page lock falls, so that its marker can be
// written between two runs.
func splitRuns(runs []screenplay.Run, locks []screenplay.PageLock) []screenplay.Run {
if len(locks) == 0 {
return runs
}
var out []screenplay.Run
pos := 0
for _, r := range runs {
rs := []rune(r.Text)
start := 0
for _, l := range locks {
if at := l.Offset - pos; at > start && at < len(rs) {
part := r
part.Text = string(rs[start:at])
out = append(out, part)
start = at
}
}
r.Text = string(rs[start:])
out = append(out, r)
pos += len(rs)
}
return out
}
func noteMarkup(s string, keepLines bool) string {
s = strings.ReplaceAll(strings.ReplaceAll(s, "[[", "[ ["), "]]", "] ]")
if !keepLines {
return strings.ReplaceAll(s, "\n", " ")
}
// An empty line inside a note holds two spaces, as in dialogue.
lines := strings.Split(s, "\n")
for i := 1; i < len(lines)-1; i++ {
if strings.TrimSpace(lines[i]) == "" {
lines[i] = " "
}
}
return strings.Join(lines, "\n")
}
func escape(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, "*", `\*`)
return strings.ReplaceAll(s, "_", `\_`)
}
+908
View File
@@ -0,0 +1,908 @@
// Package layout turns a Document into printed pages following the
// Hollywood screenplay standard: US Letter (or A4), Courier 12pt (10
// characters per inch, 6 lines per inch), 1.5" left margin, ~55 lines per
// page so that one page reads as roughly one minute of screen time.
//
// It applies the usual pagination rules: a scene heading is never the last
// thing on a page, action and dialogue only break at the end of a sentence
// with at least two lines on each side, a page never ends on a
// parenthetical, and split dialogue gets "(MORE)" / "NAME (CONT'D)".
// Sections, synopses and notes are not printed. An element marked NewPage
// (Fountain "===") starts a page.
//
// Once pages are locked, every locked page starts where it did when it was
// locked: material added to a page flows onto A pages (12A, 12B), and a
// page whose material was all removed is merged with the previous one
// (12-13).
package layout
import (
"strconv"
"strings"
"unicode"
"plotzilla/internal/screenplay"
)
const (
TopMargin = 1.0 // inches
CharWidth = 0.1 // Courier 12pt
LineHeight = 1.0 / 6
RightEdge = 7.5
)
// Geometry is what changes with the paper size. The text keeps the same
// margins and widths on A4; the page is longer, so it holds more lines.
type Geometry struct {
Width, Height float64 // inches
Lines int // body lines per page, between 1" margins
// SceneNumber is where the right-hand scene number starts, and
// RevisionMark where the revision asterisk goes.
SceneNumber float64
RevisionMark float64
}
// PaperGeometry returns the geometry of a paper size.
func PaperGeometry(paper screenplay.Paper) Geometry {
if paper == screenplay.A4 {
return Geometry{Width: 8.27, Height: 11.69, Lines: 58, SceneNumber: 7.6, RevisionMark: 7.95}
}
return Geometry{Width: 8.5, Height: 11, Lines: 54, SceneNumber: 7.75, RevisionMark: 8.1}
}
type spec struct {
left float64 // inches from the left edge of the page
width int // characters per line
before int // blank lines before the element
}
var specs = map[screenplay.ElementType]spec{
screenplay.Scene: {1.5, 60, 2},
screenplay.Action: {1.5, 60, 1},
screenplay.Character: {3.7, 38, 1},
screenplay.Parenthetical: {3.1, 25, 0},
screenplay.Dialogue: {2.5, 35, 0},
screenplay.Transition: {1.5, 60, 1},
screenplay.Centered: {1.5, 60, 1},
screenplay.Shot: {1.5, 60, 1},
screenplay.Intertitle: {1.5, 60, 1},
}
type Segment struct {
Text string
Bold bool
Italic bool
Underline bool
}
type Line struct {
Row int // 0-based line within the page body
X float64 // inches from the left edge of the page
Segments []Segment
SceneNumber string // printed in both margins when set
Rev int // latest revision set of the text on the line
}
type Page struct {
Number int // 1, 2, 3… in print order (0 for a title page)
Label string // the printed page number: "12", "12A", "12-13"
Lines []Line
}
// Rev is the latest revision set of the text on the page: the page is
// issued on that colour.
func (p Page) Rev() int {
rev := 0
for _, l := range p.Lines {
rev = max(rev, l.Rev)
}
return rev
}
// Break marks where a page starts in the source: the element index and the
// rune offset inside that element's text.
type Break struct {
Element int `json:"element"`
Offset int `json:"offset"`
Page int `json:"page"`
Label string `json:"label"`
}
type ScenePage struct {
Element int `json:"element"`
Number string `json:"number"`
Page int `json:"page"`
Label string `json:"label"`
}
type Result struct {
Pages []Page
Breaks []Break
Scenes []ScenePage
}
type Options struct {
SceneNumbers bool
}
// dualSpecs place the two columns of a dual dialogue side by side:
// left column from 1.5", right column from 4.5".
var dualSpecs = [2]map[screenplay.ElementType]spec{
{
screenplay.Character: {2.5, 20, 1},
screenplay.Parenthetical: {1.8, 22, 0},
screenplay.Dialogue: {1.5, 27, 0},
},
{
screenplay.Character: {5.5, 20, 1},
screenplay.Parenthetical: {4.8, 22, 0},
screenplay.Dialogue: {4.5, 28, 0},
},
}
type style uint8
const (
sBold style = 1 << iota
sItalic
sUnderline
)
type item struct {
elem int
typ screenplay.ElementType
runes []rune
styles []style
revs []int // revision set of each rune
src []int // offset of each rune in the element's text
ranges [][2]int
before int
sceneNumber string
dual bool
sp spec
pages []string // locked pages starting with this item
newPage bool // forced page break before the item
}
// newItem prepares the printed text of an element: notes are removed, and
// so are the lines that held nothing but a note.
func newItem(idx int, el screenplay.Element) *item {
it := &item{elem: idx, typ: el.Type, dual: el.Dual, newPage: el.NewPage}
type char struct {
r rune
st style
rev int
note bool
}
var all []char
for _, r := range el.Runs {
var st style
if r.Bold {
st |= sBold
}
if r.Italic {
st |= sItalic
}
if r.Underline {
st |= sUnderline
}
for _, c := range r.Text {
all = append(all, char{c, st, r.Rev, r.Note})
}
}
started := false
for start, i := 0, 0; i <= len(all); i++ {
if i < len(all) && all[i].r != '\n' {
continue
}
line := all[start:i]
note, text := false, false
for _, c := range line {
note = note || c.note
text = text || (!c.note && !unicode.IsSpace(c.r))
}
if !note || text {
if started {
it.add('\n', 0, 0, start-1)
}
started = true
for k, c := range line {
if !c.note {
it.add(c.r, c.st, c.rev, start+k)
}
}
}
start = i + 1
}
it.relayout(specs[el.Type])
return it
}
func (it *item) add(c rune, st style, rev, src int) {
if c == '\t' {
c = ' '
}
if it.typ.Uppercase() {
c = unicode.ToUpper(c)
}
it.runes = append(it.runes, c)
it.styles = append(it.styles, st)
it.revs = append(it.revs, rev)
it.src = append(it.src, src)
}
func (it *item) blank() bool { return strings.TrimSpace(string(it.runes)) == "" }
// offset converts a printed rune index into an offset in the element text.
func (it *item) offset(i int) int {
switch {
case i < len(it.src):
return it.src[i]
case len(it.src) > 0:
return it.src[len(it.src)-1] + 1
}
return 0
}
// index is the first printed rune at or after an element text offset.
func (it *item) index(offset int) int {
i := 0
for i < len(it.src) && it.src[i] < offset {
i++
}
return i
}
// cut splits the item before printed rune k, for a locked page starting
// there. Spaces and line breaks around the cut go.
func (it *item) cut(k int) (*item, *item) {
tail := *it
head := *it
end := k
for end > 0 && unicode.IsSpace(it.runes[end-1]) {
end--
}
for k < len(it.runes) && unicode.IsSpace(it.runes[k]) {
k++
}
head.runes, head.styles, head.revs, head.src = it.runes[:end], it.styles[:end], it.revs[:end], it.src[:end]
tail.runes, tail.styles, tail.revs, tail.src = it.runes[k:], it.styles[k:], it.revs[k:], it.src[k:]
tail.pages = nil
tail.newPage = false
head.relayout(it.sp)
tail.relayout(it.sp)
return &head, &tail
}
// splittable reports whether a locked page may start inside the element.
func (it *item) splittable() bool {
switch it.typ {
case screenplay.Action, screenplay.Centered, screenplay.Dialogue, screenplay.Parenthetical:
return true
}
return false
}
func (it *item) relayout(sp spec) {
it.sp = sp
it.before = sp.before
cont := sp.width
if it.typ == screenplay.Parenthetical {
cont-- // hanging indent under the opening parenthesis
}
it.ranges = Wrap(it.runes, sp.width, cont)
}
func (it *item) x(li int) float64 {
sp := it.sp
n := float64(it.ranges[li][1] - it.ranges[li][0])
switch it.typ {
case screenplay.Transition:
return RightEdge - n*CharWidth
case screenplay.Centered, screenplay.Intertitle:
return sp.left + (float64(sp.width)-n)*CharWidth/2
case screenplay.Parenthetical:
if li > 0 {
return sp.left + CharWidth
}
}
return sp.left
}
func (it *item) rev(li int) int {
rev := 0
for _, r := range it.revs[it.ranges[li][0]:it.ranges[li][1]] {
rev = max(rev, r)
}
return rev
}
func (it *item) segments(li int) []Segment {
var segs []Segment
var cur style
rg := it.ranges[li]
for i := rg[0]; i < rg[1]; i++ {
st := it.styles[i]
if len(segs) == 0 || st != cur {
segs = append(segs, Segment{Bold: st&sBold != 0, Italic: st&sItalic != 0, Underline: st&sUnderline != 0})
cur = st
}
segs[len(segs)-1].Text += string(it.runes[i])
}
return segs
}
// Wrap splits text into lines of at most width characters (contWidth for the
// following lines), breaking on spaces and on "\n". It returns rune ranges.
func Wrap(r []rune, width, contWidth int) [][2]int {
width, contWidth = max(width, 1), max(contWidth, 1)
n := len(r)
var out [][2]int
start := 0
for {
w := width
if len(out) > 0 {
w = contWidth
}
end, lastSpace := start, -1
for end < n && r[end] != '\n' && end-start < w {
if r[end] == ' ' {
lastSpace = end
}
end++
}
switch {
case end >= n:
return append(out, trimRange(r, start, n))
case r[end] == '\n':
out = append(out, trimRange(r, start, end))
start = end + 1
case r[end] == ' ':
out = append(out, trimRange(r, start, end))
if start = skipSpaces(r, end); start >= n {
return out
}
case lastSpace > start:
out = append(out, trimRange(r, start, lastSpace))
start = skipSpaces(r, lastSpace)
default: // a single word longer than the line
out = append(out, [2]int{start, end})
start = end
}
}
}
func trimRange(r []rune, s, e int) [2]int {
for e > s && r[e-1] == ' ' {
e--
}
return [2]int{s, e}
}
func skipSpaces(r []rune, i int) int {
for i < len(r) && r[i] == ' ' {
i++
}
return i
}
type unit struct {
head *item // the element, or the character cue of a dialogue block
parts []*item // parentheticals and dialogue following a cue
block bool
right *unit // second column of a dual dialogue
// A dialogue block cut by a locked page: the first part ends with
// (MORE), the next one starts with NAME (CONT'D).
more, contd bool
pages []string // locked pages starting with this unit
newPage bool // forced page break before the unit
}
func (u *unit) items() []*item {
return append([]*item{u.head}, u.parts...)
}
func (u *unit) height() int {
n := 0
for _, it := range u.items() {
n += len(it.ranges)
}
return n
}
// minLines is how much of the unit must fit under a scene heading.
func (u *unit) minLines() int {
if !u.block {
return min(2, len(u.head.ranges))
}
n := 0
for _, p := range u.parts {
n += len(p.ranges)
}
return len(u.head.ranges) + min(2, n)
}
// Paginate lays the document out on pages.
func Paginate(doc *screenplay.Document, opt Options) *Result {
locked := doc.Settings.Locked
var items []*item
var carried []string // locked pages of elements that print nothing
carriedPage := false // and their forced page break
scene := 0
for i, el := range doc.Elements {
var locks []screenplay.PageLock
if locked {
for _, l := range carried {
locks = append(locks, screenplay.PageLock{Page: l})
}
locks = append(locks, el.Locked...)
}
var it *item
if !el.IsEmpty() && el.Type.Printed() {
it = newItem(i, el)
}
if it == nil || it.blank() {
carried = nil // now in locks
for _, l := range locks {
carried = append(carried, l.Page)
}
carriedPage = carriedPage || el.NewPage
continue
}
carried = nil
it.newPage = it.newPage || carriedPage
carriedPage = false
if el.Type == screenplay.Scene {
scene++
it.sceneNumber = el.SceneNumber
if it.sceneNumber == "" {
it.sceneNumber = strconv.Itoa(scene)
}
}
items = append(items, lockItem(it, locks, &carried)...)
}
var units []unit
for i := 0; i < len(items); i++ {
it := items[i]
if it.typ == screenplay.Character {
u := unit{head: it, block: true, pages: it.pages, newPage: it.newPage}
for i+1 < len(items) && items[i+1].typ.InDialogue() {
i++
part := items[i]
if len(part.pages) > 0 && !it.dual && !nextIsDual(items, i) {
u.more = true
units = append(units, u)
u = unit{head: it, block: true, contd: true, pages: part.pages}
} else {
u.pages = append(u.pages, part.pages...)
}
// A page cannot be forced inside a dialogue block: it breaks
// before the block.
u.newPage = u.newPage || part.newPage
u.parts = append(u.parts, part)
}
if prev := len(units) - 1; it.dual && prev >= 0 && units[prev].block && units[prev].right == nil {
for side, col := range []*unit{&units[prev], &u} {
for _, ci := range col.items() {
ci.relayout(dualSpecs[side][ci.typ])
}
}
// A dual dialogue is never split: its locked pages start above it.
units[prev].pages = append(units[prev].pages, u.pages...)
units[prev].newPage = units[prev].newPage || u.newPage
units[prev].right = &u
continue
}
units = append(units, u)
continue
}
if it.typ.InDialogue() {
it.before = 1 // dialogue without a cue: keep it off the previous element
}
units = append(units, unit{head: it, pages: it.pages, newPage: it.newPage})
}
p := &paginator{res: &Result{}, opt: opt, geo: PaperGeometry(doc.Settings.Paper), locked: locked}
p.newPage()
for i, u := range units {
var next *unit
if i+1 < len(units) {
next = &units[i+1]
if (locked && len(next.pages) > 0) || next.newPage {
next = nil // the next unit starts its own page anyway
}
}
if locked {
p.startLocked(u.pages)
}
if u.newPage && len(p.page.Lines) > 0 {
p.newPage()
}
switch {
case u.right != nil:
p.placeDual(u)
case u.block:
p.placeBlock(u)
default:
p.placeItem(u.head, next)
}
}
if locked {
// Locked pages whose material is all gone, at the end.
p.startLocked(carried)
if len(p.res.Pages) > 1 && len(p.page.Lines) == 0 {
p.dropEmptyPage()
}
}
// A page merged with an emptied one after it was renamed ("6-7").
for i, b := range p.res.Breaks {
p.res.Breaks[i].Label = p.res.Pages[b.Page-1].Label
}
for i, s := range p.res.Scenes {
p.res.Scenes[i].Label = p.res.Pages[s.Page-1].Label
}
return p.res
}
// nextIsDual reports whether the block holding items[i] is the left column
// of a dual dialogue.
func nextIsDual(items []*item, i int) bool {
for j := i + 1; j < len(items); j++ {
if !items[j].typ.InDialogue() {
return items[j].typ == screenplay.Character && items[j].dual
}
}
return false
}
// lockItem attaches the locked pages that start in an item, cutting it
// where a page starts inside it. A page starting at its very end starts
// with the next item.
func lockItem(it *item, locks []screenplay.PageLock, carried *[]string) []*item {
out := []*item{it}
cur := it
base := 0 // printed runes of it before cur
for _, l := range locks {
k := it.index(l.Offset) - base
switch {
case k >= len(cur.runes) || strings.TrimSpace(string(cur.runes[k:])) == "":
*carried = append(*carried, l.Page)
case k <= 0 || !cur.splittable() || strings.TrimSpace(string(cur.runes[:k])) == "":
cur.pages = append(cur.pages, l.Page)
default:
head, tail := cur.cut(k)
base += len(cur.runes) - len(tail.runes)
tail.pages = []string{l.Page}
out[len(out)-1] = head
out = append(out, tail)
cur = tail
}
}
return out
}
type paginator struct {
res *Result
page *Page
row int
pending bool // the next emitted source line starts a new page
opt Options
geo Geometry
locked bool
}
// newPage starts the next page. Once pages are locked, a page that does
// not start a locked page is an A page of the previous one.
func (p *paginator) newPage() {
label := strconv.Itoa(len(p.res.Pages) + 1)
if p.locked && p.page != nil {
label = nextLabel(p.page.Label)
}
p.addPage(label)
}
func (p *paginator) addPage(label string) {
p.res.Pages = append(p.res.Pages, Page{Number: len(p.res.Pages) + 1, Label: label})
p.page = &p.res.Pages[len(p.res.Pages)-1]
p.row = 0
p.pending = len(p.res.Pages) > 1
}
// startLocked starts the locked pages that begin with the next unit. A
// page left empty in between lost all its material: it is merged into the
// page before it ("12-13").
func (p *paginator) startLocked(labels []string) {
for _, l := range labels {
switch {
case len(p.page.Lines) > 0:
p.addPage(l)
case len(p.res.Pages) > 1:
p.dropEmptyPage()
p.addPage(l)
default: // nothing before the first locked page
p.page.Label = firstLabel(p.page.Label) + "-" + l
}
}
}
func (p *paginator) dropEmptyPage() {
n := len(p.res.Pages)
empty := p.res.Pages[n-1].Label
p.res.Pages = p.res.Pages[:n-1]
p.page = &p.res.Pages[n-2]
p.page.Label = firstLabel(p.page.Label) + "-" + lastLabel(empty)
p.row = p.geo.Lines
}
func firstLabel(l string) string { return strings.Split(l, "-")[0] }
func lastLabel(l string) string {
parts := strings.Split(l, "-")
return parts[len(parts)-1]
}
// nextLabel names the page added after a locked page: 12 → 12A → 12B.
func nextLabel(label string) string {
l := lastLabel(label)
if n := len(l); n > 0 && l[n-1] >= 'A' && l[n-1] < 'Z' && strings.ContainsAny(l, "0123456789") {
return l[:n-1] + string(l[n-1]+1)
}
return l + "A"
}
func (p *paginator) emit(it *item, li int) {
if p.pending {
p.res.Breaks = append(p.res.Breaks, Break{Element: it.elem, Offset: it.offset(it.ranges[li][0]), Page: p.page.Number, Label: p.page.Label})
p.pending = false
}
line := Line{Row: p.row, X: it.x(li), Segments: it.segments(li), Rev: it.rev(li)}
if it.typ == screenplay.Scene && li == 0 {
p.res.Scenes = append(p.res.Scenes, ScenePage{Element: it.elem, Number: it.sceneNumber, Page: p.page.Number, Label: p.page.Label})
if p.opt.SceneNumbers {
line.SceneNumber = it.sceneNumber
}
}
p.page.Lines = append(p.page.Lines, line)
p.row++
}
// emitText adds a line that does not come from the source ((MORE), CONT'D).
func (p *paginator) emitText(x float64, s string) {
p.page.Lines = append(p.page.Lines, Line{Row: p.row, X: x, Segments: []Segment{{Text: s}}})
p.row++
}
func (p *paginator) spaceBefore(it *item) int {
if p.row == 0 {
return 0
}
return it.before
}
func (p *paginator) placeItem(it *item, next *unit) {
n := len(it.ranges)
// Scene headings and inserts never end a page: keep them with what follows.
if (it.typ == screenplay.Scene || it.typ == screenplay.Shot) && p.row > 0 && next != nil {
need := it.before + n + next.head.before + next.minLines()
if p.row+need > p.geo.Lines {
p.newPage()
}
}
splittable := it.typ == screenplay.Action || it.typ == screenplay.Centered
start := 0
for {
before := p.spaceBefore(it)
if p.row+before+n-start <= p.geo.Lines {
p.row += before
for li := start; li < n; li++ {
p.emit(it, li)
}
return
}
avail := p.geo.Lines - p.row - before
k := 0
if splittable {
k = sentenceSplit(it, start, avail)
}
if k == 0 && p.row == 0 {
k = avail // longer than a page with no clean break
}
if k > 0 {
p.row += before
for li := start; li < start+k; li++ {
p.emit(it, li)
}
start += k
}
p.newPage()
}
}
type dline struct {
it *item
li int
}
func (p *paginator) placeBlock(u unit) {
var flat []dline
for _, part := range u.parts {
for li := range part.ranges {
flat = append(flat, dline{part, li})
}
}
cue := u.head
contd := u.contd
more := 0
if u.more {
more = 1
}
pos := 0
for {
before := p.spaceBefore(cue)
head := len(cue.ranges)
if contd {
head = 1
}
rem := len(flat) - pos
if p.row+before+head+rem+more <= p.geo.Lines {
p.row += before
p.emitCue(cue, contd)
for _, d := range flat[pos:] {
p.emit(d.it, d.li)
}
if u.more {
p.emitText(specs[screenplay.Character].left, "(MORE)")
}
return
}
avail := p.geo.Lines - p.row - before - head - 1 // one line for (MORE)
k := dialogueSplit(flat, pos, avail)
if k == 0 && p.row == 0 {
k = min(max(avail, 1), rem-1)
}
if k > 0 {
p.row += before
p.emitCue(cue, contd)
for _, d := range flat[pos : pos+k] {
p.emit(d.it, d.li)
}
p.emitText(specs[screenplay.Character].left, "(MORE)")
pos += k
contd = true
}
p.newPage()
}
}
// placeDual lays both columns side by side. A dual dialogue is never split.
func (p *paginator) placeDual(u unit) {
h := max(u.height(), u.right.height())
before := p.spaceBefore(u.head)
if p.row > 0 && p.row+before+h > p.geo.Lines {
p.newPage()
before = 0
}
p.row += before
base := p.row
for _, col := range []*unit{&u, u.right} {
p.row = base
for _, it := range col.items() {
for li := range it.ranges {
p.emit(it, li)
}
}
}
p.row = base + h
}
func (p *paginator) emitCue(cue *item, contd bool) {
if !contd {
for li := range cue.ranges {
p.emit(cue, li)
}
return
}
name := strings.TrimSpace(string(cue.runes))
if !strings.Contains(name, "(CONT'D)") {
name += " (CONT'D)"
}
p.emitText(specs[screenplay.Character].left, name)
}
// sentenceSplit returns how many lines (from start) to keep on the current
// page, or 0 if the element cannot be split cleanly within avail lines.
func sentenceSplit(it *item, start, avail int) int {
rem := len(it.ranges) - start
for k := min(avail, rem-2); k >= 2; k-- {
if endsSentence(it.runes, it.ranges[start+k-1]) {
return k
}
}
return 0
}
func dialogueSplit(flat []dline, pos, avail int) int {
rem := len(flat) - pos
for k := min(avail, rem-2); k >= 2; k-- {
last := flat[pos+k-1]
if last.it.typ == screenplay.Parenthetical {
continue
}
if last.li == len(last.it.ranges)-1 || endsSentence(last.it.runes, last.it.ranges[last.li]) {
return k
}
}
return 0
}
func endsSentence(r []rune, rg [2]int) bool {
if rg[1] < len(r) && r[rg[1]] == '\n' {
return true
}
s := strings.TrimRight(string(r[rg[0]:rg[1]]), " \"'”’»)")
for _, end := range []string{".", "!", "?", "…", ":", "--"} {
if strings.HasSuffix(s, end) {
return true
}
}
return false
}
// TitlePage lays out the cover page: title block centred in the upper half,
// contact bottom-left, draft date and notes bottom-right.
func TitlePage(tp screenplay.TitlePage, paper screenplay.Paper) Page {
lastRow := PaperGeometry(paper).Lines
var lines []Line
centered := func(row int, s string) int {
for _, para := range strings.Split(s, "\n") {
r := []rune(para)
for _, rg := range Wrap(r, 60, 60) {
n := float64(rg[1] - rg[0])
lines = append(lines, Line{Row: row, X: 1.5 + (60-n)*CharWidth/2, Segments: []Segment{{Text: string(r[rg[0]:rg[1]])}}})
row++
}
}
return row
}
bottom := func(s string, width int, right bool) {
var texts []string
for _, para := range strings.Split(strings.TrimSpace(s), "\n") {
r := []rune(para)
for _, rg := range Wrap(r, width, width) {
texts = append(texts, string(r[rg[0]:rg[1]]))
}
}
row := lastRow - len(texts)
for _, t := range texts {
x := 1.5
if right {
x = RightEdge - float64(len([]rune(t)))*CharWidth
}
lines = append(lines, Line{Row: row, X: x, Segments: []Segment{{Text: t}}})
row++
}
}
row := 18
if tp.Title != "" {
row = centered(row, tp.Title) + 3
}
if tp.Credit != "" {
row = centered(row, tp.Credit) + 1
}
if tp.Author != "" {
row = centered(row, tp.Author) + 3
}
if tp.Source != "" {
centered(row, tp.Source)
}
if strings.TrimSpace(tp.Contact) != "" {
bottom(tp.Contact, 35, false)
}
right := strings.TrimSpace(strings.TrimSpace(tp.Notes) + "\n" + strings.TrimSpace(tp.DraftDate))
if right != "" {
bottom(right, 25, true)
}
return Page{Lines: lines}
}
+307
View File
@@ -0,0 +1,307 @@
package layout
import (
"slices"
"strings"
"testing"
"plotzilla/internal/screenplay"
)
func TestWrap(t *testing.T) {
r := []rune("the quick brown fox jumps")
got := Wrap(r, 10, 10)
var lines []string
for _, rg := range got {
lines = append(lines, string(r[rg[0]:rg[1]]))
}
want := []string{"the quick", "brown fox", "jumps"}
if strings.Join(lines, "|") != strings.Join(want, "|") {
t.Errorf("got %q", lines)
}
}
func el(t screenplay.ElementType, s string) screenplay.Element {
return screenplay.NewElement(t, s)
}
func TestSceneHeadingNotOrphaned(t *testing.T) {
doc := &screenplay.Document{}
// 51 one-line actions (each with a blank line before) fill most of page 1.
for i := 0; i < 26; i++ {
doc.Elements = append(doc.Elements, el(screenplay.Action, "Something happens."))
}
doc.Elements = append(doc.Elements, el(screenplay.Scene, "INT. HOUSE - DAY"), el(screenplay.Action, "A\nB\nC"))
res := Paginate(doc, Options{})
if len(res.Pages) != 2 {
t.Fatalf("pages = %d", len(res.Pages))
}
if b := res.Breaks[0]; b.Element != 26 || b.Offset != 0 {
t.Errorf("scene heading should start page 2, break = %+v", b)
}
}
func TestDialogueMoreContd(t *testing.T) {
doc := &screenplay.Document{}
for i := 0; i < 24; i++ {
doc.Elements = append(doc.Elements, el(screenplay.Action, "Something happens."))
}
long := strings.Repeat("This is a sentence that goes on. ", 12)
doc.Elements = append(doc.Elements, el(screenplay.Character, "Margot"), el(screenplay.Dialogue, long))
res := Paginate(doc, Options{})
if len(res.Pages) != 2 {
t.Fatalf("pages = %d", len(res.Pages))
}
text := func(p Page, i int) string {
var b strings.Builder
for _, s := range p.Lines[i].Segments {
b.WriteString(s.Text)
}
return b.String()
}
p1, p2 := res.Pages[0], res.Pages[1]
if got := text(p1, len(p1.Lines)-1); got != "(MORE)" {
t.Errorf("last line of page 1 = %q", got)
}
if got := text(p2, 0); got != "MARGOT (CONT'D)" {
t.Errorf("first line of page 2 = %q", got)
}
if res.Breaks[0].Element != 25 || res.Breaks[0].Offset == 0 {
t.Errorf("break should be inside the dialogue: %+v", res.Breaks[0])
}
}
func TestDualDialogue(t *testing.T) {
right := el(screenplay.Character, "Bob")
right.Dual = true
doc := &screenplay.Document{Elements: []screenplay.Element{
el(screenplay.Character, "Alice"), el(screenplay.Dialogue, "Hello there, how are you doing today?"),
right, el(screenplay.Dialogue, "Fine."),
el(screenplay.Action, "They stop."),
}}
lines := Paginate(doc, Options{}).Pages[0].Lines
byRow := map[int][]float64{}
for _, l := range lines {
byRow[l.Row] = append(byRow[l.Row], l.X)
}
if len(byRow[0]) != 2 || byRow[0][0] != 2.5 || byRow[0][1] != 5.5 {
t.Errorf("cues should share row 0 at 2.5in and 5.5in: %v", byRow[0])
}
// Left dialogue wraps to 2 lines at 27 chars; action follows after a blank line.
if last := lines[len(lines)-1]; last.Row != 4 || last.X != 1.5 {
t.Errorf("action line = %+v", last)
}
}
func TestShotKeptWithNext(t *testing.T) {
doc := &screenplay.Document{}
for i := 0; i < 26; i++ {
doc.Elements = append(doc.Elements, el(screenplay.Action, "Something happens."))
}
doc.Elements = append(doc.Elements, el(screenplay.Shot, "insert - the letter"), el(screenplay.Action, "A\nB\nC"))
res := Paginate(doc, Options{})
if b := res.Breaks[0]; b.Element != 26 || b.Offset != 0 {
t.Errorf("insert should move to page 2 with its action, break = %+v", b)
}
if len(res.Scenes) != 0 {
t.Errorf("an insert is not a scene: %+v", res.Scenes)
}
line := res.Pages[1].Lines[0]
if line.X != 1.5 || line.Segments[0].Text != "INSERT - THE LETTER" {
t.Errorf("insert line = %+v", line)
}
}
func TestIntertitleCentered(t *testing.T) {
doc := &screenplay.Document{Elements: []screenplay.Element{el(screenplay.Intertitle, "Paris, 1942")}}
line := Paginate(doc, Options{}).Pages[0].Lines[0]
if line.Segments[0].Text != "PARIS, 1942" || line.X != 1.5+(60-11)*CharWidth/2 {
t.Errorf("intertitle line = %+v", line)
}
}
func lineText(l Line) string {
var b strings.Builder
for _, s := range l.Segments {
b.WriteString(s.Text)
}
return b.String()
}
func TestNotPrinted(t *testing.T) {
section := el(screenplay.Section, "Act One")
section.Level = 1
action := screenplay.Element{Type: screenplay.Action, Runs: []screenplay.Run{
{Text: "He waits."}, {Text: " (fix this)", Note: true}, {Text: "\n"}, {Text: "a whole line of notes", Note: true}, {Text: "\nThen he goes."},
}}
doc := &screenplay.Document{Elements: []screenplay.Element{
section, el(screenplay.Synopsis, "Where it starts."), el(screenplay.Scene, "INT. HOUSE - DAY"), action,
{Type: screenplay.Action, Runs: []screenplay.Run{{Text: "only a note", Note: true}}},
el(screenplay.Scene, "EXT. ROAD - NIGHT"),
}}
res := Paginate(doc, Options{})
var got []string
for _, l := range res.Pages[0].Lines {
got = append(got, lineText(l))
}
want := []string{"INT. HOUSE - DAY", "He waits.", "Then he goes.", "EXT. ROAD - NIGHT"}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Errorf("printed %q", got)
}
if res.Pages[0].Lines[3].Row != 6 {
t.Errorf("the note-only action should leave no gap: %+v", res.Pages[0].Lines)
}
if res.Scenes[0].Number != "1" || res.Scenes[1].Number != "2" {
t.Errorf("scenes: %+v", res.Scenes)
}
}
func TestBreakOffsetSkipsNotes(t *testing.T) {
doc := &screenplay.Document{}
for i := 0; i < 26; i++ {
doc.Elements = append(doc.Elements, el(screenplay.Action, "Line."))
}
long := screenplay.Element{Type: screenplay.Action, Runs: []screenplay.Run{
{Text: "N", Note: true}, {Text: strings.Repeat("One sentence here. ", 20)},
}}
doc.Elements = append(doc.Elements, long)
res := Paginate(doc, Options{})
b := res.Breaks[0]
text := []rune(doc.Elements[26].Text())
if b.Element != 26 || b.Offset != 1 || text[b.Offset] != 'O' {
t.Errorf("the page should start after the note: %+v", b)
}
}
func TestA4(t *testing.T) {
doc := &screenplay.Document{Settings: screenplay.Settings{Paper: screenplay.A4}}
for i := 0; i < 120; i++ {
doc.Elements = append(doc.Elements, el(screenplay.Action, "Line."))
}
// 29 actions (57 rows) fit on an A4 page, 27 (53 rows) on US Letter.
a4 := Paginate(doc, Options{})
doc.Settings.Paper = screenplay.Letter
letter := Paginate(doc, Options{})
if a4.Breaks[0].Element != 29 || letter.Breaks[0].Element != 27 {
t.Errorf("first break: A4 %+v, Letter %+v", a4.Breaks[0], letter.Breaks[0])
}
if g := PaperGeometry(screenplay.A4); g.Width != 8.27 || g.Lines != 58 {
t.Errorf("A4 geometry %+v", g)
}
}
func labels(res *Result) []string {
var out []string
for _, p := range res.Pages {
out = append(out, p.Label)
}
return out
}
func TestLockedPages(t *testing.T) {
doc := &screenplay.Document{}
for i := 0; i < 81; i++ {
doc.Elements = append(doc.Elements, el(screenplay.Action, "Line."))
}
// Lock the current pagination: 27 actions per page.
for _, b := range Paginate(doc, Options{}).Breaks {
doc.Elements[b.Element].Locked = []screenplay.PageLock{{Offset: b.Offset, Page: b.Label}}
}
doc.Settings.Locked = true
if got := labels(Paginate(doc, Options{})); strings.Join(got, " ") != "1 2 3" {
t.Fatalf("locked, unchanged: %v", got)
}
// Material added to page 1 goes to page 1A; page 2 does not move.
grown := *doc
grown.Elements = append(append(append([]screenplay.Element{}, doc.Elements[:5]...),
el(screenplay.Action, "New."), el(screenplay.Action, "New.")), doc.Elements[5:]...)
res := Paginate(&grown, Options{})
if got := labels(res); strings.Join(got, " ") != "1 1A 2 3" {
t.Fatalf("added material: %v", got)
}
if b := res.Breaks[1]; b.Element != 29 || b.Label != "2" {
t.Errorf("page 2 should still start with its first line: %+v", b)
}
// Page 2 emptied: merged into page 1.
shrunk := *doc
shrunk.Elements = append([]screenplay.Element{}, doc.Elements...)
for i := 27; i < 54; i++ {
shrunk.Elements[i] = screenplay.Element{Type: screenplay.Action, Locked: doc.Elements[i].Locked}
}
res = Paginate(&shrunk, Options{})
if got := labels(res); strings.Join(got, " ") != "1-2 3" {
t.Errorf("removed page: %v", got)
}
if b := res.Breaks[0]; b.Label != "3" || b.Page != 2 {
t.Errorf("break after the merged page: %+v", b)
}
// Page 3 emptied: page 2 becomes 2-3, and its break says so.
shrunk.Elements = append([]screenplay.Element{}, doc.Elements...)
for i := 54; i < 81; i++ {
shrunk.Elements[i] = screenplay.Element{Type: screenplay.Action, Locked: doc.Elements[i].Locked}
}
res = Paginate(&shrunk, Options{})
if got := labels(res); strings.Join(got, " ") != "1 2-3" || res.Breaks[0].Label != "2-3" {
t.Errorf("removed last page: %v %+v", got, res.Breaks)
}
}
func TestLockedPageInsideDialogue(t *testing.T) {
doc := &screenplay.Document{Settings: screenplay.Settings{Locked: true}}
doc.Elements = append(doc.Elements, el(screenplay.Character, "Margot"),
screenplay.Element{Type: screenplay.Dialogue, Runs: []screenplay.Run{{Text: "First part. Second part."}},
Locked: []screenplay.PageLock{{Offset: 12, Page: "2"}}})
res := Paginate(doc, Options{})
if len(res.Pages) != 2 {
t.Fatalf("pages = %d", len(res.Pages))
}
p1, p2 := res.Pages[0].Lines, res.Pages[1].Lines
if lineText(p1[1]) != "First part." || lineText(p1[2]) != "(MORE)" {
t.Errorf("page 1: %+v", p1)
}
if lineText(p2[0]) != "MARGOT (CONT'D)" || lineText(p2[1]) != "Second part." {
t.Errorf("page 2: %+v", p2)
}
if b := res.Breaks[0]; b.Element != 1 || b.Offset != 12 || b.Label != "2" {
t.Errorf("break %+v", b)
}
}
func TestRevisionLevels(t *testing.T) {
doc := &screenplay.Document{Elements: []screenplay.Element{
{Type: screenplay.Action, Runs: []screenplay.Run{{Text: "Old text. "}, {Text: "New text.", Rev: 2}}},
{Type: screenplay.Action, Runs: []screenplay.Run{{Text: "Blue text.", Rev: 1}}},
}}
page := Paginate(doc, Options{}).Pages[0]
if page.Lines[0].Rev != 2 || page.Lines[1].Rev != 1 || page.Rev() != 2 {
t.Errorf("revisions: %+v", page.Lines)
}
}
func TestForcedPageBreak(t *testing.T) {
scene := el(screenplay.Scene, "EXT. ROAD - NIGHT")
scene.NewPage = true
right := el(screenplay.Character, "Bob")
right.Dual, right.NewPage = true, true
cue := el(screenplay.Character, "Alice")
doc := &screenplay.Document{Elements: []screenplay.Element{
el(screenplay.Action, "He waits."), scene, el(screenplay.Action, "A car."),
cue, el(screenplay.Dialogue, "Hi."), right, el(screenplay.Dialogue, "Hey."),
{Type: screenplay.Section, Level: 1, Runs: []screenplay.Run{{Text: "Act Two"}}, NewPage: true},
el(screenplay.Action, "Later."),
}}
doc.Elements[0].NewPage = true // at the top of page 1: no effect
res := Paginate(doc, Options{})
var starts []int
for _, b := range res.Breaks {
starts = append(starts, b.Element)
}
// The right column's page break starts the page above the dual dialogue;
// the section's goes to the next printed element.
if len(res.Pages) != 4 || !slices.Equal(starts, []int{1, 3, 8}) {
t.Errorf("pages %d, breaks at %v", len(res.Pages), starts)
}
}
+224
View File
@@ -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 '?'
}
+98
View File
@@ -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()
}
+258
View File
@@ -0,0 +1,258 @@
// Package screenplay holds the in-memory model of a screenplay: an ordered
// list of typed elements (scene heading, action, character, ...) plus a
// title page. Every file format and renderer converts to and from this model.
package screenplay
import (
"strconv"
"strings"
"unicode"
)
type ElementType string
const (
Scene ElementType = "scene"
Action ElementType = "action"
Character ElementType = "character"
Parenthetical ElementType = "parenthetical"
Dialogue ElementType = "dialogue"
Transition ElementType = "transition"
Centered ElementType = "centered"
// Shot is an insert or camera direction ("INSERT - THE LETTER",
// "CLOSE ON", "POV"): set like a scene heading but not a scene.
Shot ElementType = "shot"
// Intertitle is on-screen text: a title card, centred in capitals.
Intertitle ElementType = "intertitle"
// Section is an outline heading (Fountain "#", "##"…) and Synopsis a
// summary line (Fountain "="). Neither is printed.
Section ElementType = "section"
Synopsis ElementType = "synopsis"
)
// Printed reports whether the element appears on the printed page.
func (t ElementType) Printed() bool {
return t != Section && t != Synopsis
}
// Uppercase reports whether the element is always printed in capitals.
func (t ElementType) Uppercase() bool {
return t == Scene || t == Character || t == Transition || t == Shot || t == Intertitle
}
// InDialogue reports whether the element belongs to a dialogue block
// (the lines that follow a character cue).
func (t ElementType) InDialogue() bool {
return t == Parenthetical || t == Dialogue
}
// Run is a span of text sharing the same emphasis. A "\n" inside Text is a
// hard line break within the element.
type Run struct {
Text string `json:"text"`
Bold bool `json:"b,omitempty"`
Italic bool `json:"i,omitempty"`
Underline bool `json:"u,omitempty"`
// Note marks a writer's note (Fountain "[[…]]"): kept and shown in the
// editor, never printed.
Note bool `json:"n,omitempty"`
// Rev is the revision set (1 = the first, see Settings.Revisions) in
// which the text was added or changed; 0 is original text.
Rev int `json:"rev,omitempty"`
}
func (r Run) SameStyle(o Run) bool {
return r.Bold == o.Bold && r.Italic == o.Italic && r.Underline == o.Underline && r.Note == o.Note && r.Rev == o.Rev
}
type Element struct {
Type ElementType `json:"type"`
Runs []Run `json:"runs"`
// SceneNumber is a locked scene number ("12A"). Empty means automatic.
SceneNumber string `json:"sceneNumber,omitempty"`
// Dual, on a character cue, puts its dialogue block beside the
// previous one (dual dialogue, Fountain's "^").
Dual bool `json:"dual,omitempty"`
// Level is the depth of a section, from 1 ("#").
Level int `json:"level,omitempty"`
// NewPage forces a page break before the element (Fountain "===").
// On a dialogue part it applies to the whole dialogue block.
NewPage bool `json:"newPage,omitempty"`
// Locked lists the locked pages that start inside this element, in
// offset order (see Settings.Locked).
Locked []PageLock `json:"locked,omitempty"`
}
// PageLock fixes where a page starts once pages are locked: the page
// labelled Page starts at rune Offset of the element's text.
type PageLock struct {
Offset int `json:"offset"`
Page string `json:"page"`
}
func NewElement(t ElementType, text string) Element {
e := Element{Type: t, Runs: []Run{}}
if text != "" {
e.Runs = append(e.Runs, Run{Text: text})
}
return e
}
// Text returns the element's plain text, without emphasis.
func (e Element) Text() string {
var b strings.Builder
for _, r := range e.Runs {
b.WriteString(r.Text)
}
return b.String()
}
func (e Element) IsEmpty() bool {
return strings.TrimSpace(e.Text()) == ""
}
// PrintedText returns the text that is printed: without notes.
func (e Element) PrintedText() string {
var b strings.Builder
for _, r := range e.Runs {
if !r.Note {
b.WriteString(r.Text)
}
}
return b.String()
}
type TitlePage struct {
Title string `json:"title"`
Credit string `json:"credit"`
Author string `json:"author"`
Source string `json:"source"`
DraftDate string `json:"draftDate"`
Contact string `json:"contact"`
Notes string `json:"notes"`
}
func (t TitlePage) IsEmpty() bool {
return strings.TrimSpace(t.Title+t.Author+t.Source+t.DraftDate+t.Contact+t.Notes) == ""
}
type Document struct {
TitlePage TitlePage `json:"titlePage"`
Elements []Element `json:"elements"`
Settings Settings `json:"settings"`
}
// Paper is the page size: "" for US Letter, or A4.
type Paper string
const (
Letter Paper = ""
A4 Paper = "a4"
)
// Settings are the document's production settings. The zero value is a
// US Letter script without revisions.
type Settings struct {
Paper Paper `json:"paper,omitempty"`
// Revisions are the revision sets issued so far, in order: the text
// of set n carries Run.Rev == n.
Revisions []Revision `json:"revisions,omitempty"`
// Revising marks new text with the latest revision set.
Revising bool `json:"revising,omitempty"`
// Locked keeps the page numbers of Element.Locked: added material goes
// to A pages (12A), removed material leaves shorter or merged pages.
Locked bool `json:"locked,omitempty"`
}
func (s Settings) IsZero() bool {
return s.Paper == Letter && len(s.Revisions) == 0 && !s.Revising && !s.Locked
}
// Revision is a revision set, named after the colour of its pages.
type Revision struct {
Name string `json:"name"` // "Blue", "Double Pink"
Color string `json:"color"` // "blue", "pink"…: the paper colour
Date string `json:"date"`
}
// revisionColors is the industry order of revision pages. The first cycle
// starts at Blue (the original draft is White); later cycles start again
// at White, as "Double White", "Double Blue"…
var revisionColors = []string{"White", "Blue", "Pink", "Yellow", "Green", "Goldenrod", "Buff", "Salmon", "Cherry"}
// RevisionSet returns the name and colour of revision set n (from 1).
func RevisionSet(n int) Revision {
n = max(n, 1)
cycle, i := n/len(revisionColors), n%len(revisionColors)
name := revisionColors[i]
r := Revision{Name: name, Color: strings.ToLower(name)}
switch cycle {
case 0:
case 1:
r.Name = "Double " + name
case 2:
r.Name = "Triple " + name
default:
r.Name = ordinal(cycle+1) + " " + name
}
return r
}
func ordinal(n int) string {
suffix := "th"
if n%100 < 11 || n%100 > 13 {
switch n % 10 {
case 1:
suffix = "st"
case 2:
suffix = "nd"
case 3:
suffix = "rd"
}
}
return strconv.Itoa(n) + suffix
}
// New returns an empty screenplay, ready to receive its first scene heading.
func New() *Document {
return &Document{
TitlePage: TitlePage{Credit: "Written by"},
Elements: []Element{NewElement(Scene, "")},
}
}
// NormalizeRuns drops empty runs and merges neighbours with the same style.
func NormalizeRuns(runs []Run) []Run {
out := make([]Run, 0, len(runs))
for _, r := range runs {
if r.Text == "" {
continue
}
if n := len(out); n > 0 && out[n-1].SameStyle(r) {
out[n-1].Text += r.Text
continue
}
out = append(out, r)
}
return out
}
// CharacterName extracts the bare name from a character cue:
// "John (V.O.)" -> "JOHN".
func CharacterName(cue string) string {
cue = strings.TrimSuffix(strings.TrimSpace(cue), "^")
if i := strings.Index(cue, "("); i >= 0 {
cue = cue[:i]
}
return strings.ToUpper(strings.TrimSpace(cue))
}
// UpperRunes upper-cases rune by rune so that offsets are preserved
// (strings.ToUpper may change the length, e.g. "ß" -> "SS").
func UpperRunes(s string) string {
r := []rune(s)
for i, c := range r {
r[i] = unicode.ToUpper(c)
}
return string(r)
}
+420
View File
@@ -0,0 +1,420 @@
package spell
import (
"strings"
"unicode"
"unicode/utf8"
)
type letterCase int
const (
lowerCase letterCase = iota
titleCase // "Paris"
upperCase // "PARIS"
mixedCase // "iPhone", "McDonald"
)
func caseOf(w string) letterCase {
upper, lower, firstUpper := 0, 0, false
for i, r := range w {
switch {
case unicode.IsUpper(r):
upper++
if i == 0 {
firstUpper = true
}
case unicode.IsLower(r):
lower++
}
}
switch {
case upper == 0:
return lowerCase
case lower == 0:
return upperCase
case upper == 1 && firstUpper:
return titleCase
}
return mixedCase
}
func capitalize(w string) string {
r, size := utf8.DecodeRuneInString(w)
return string(unicode.ToTitle(r)) + w[size:]
}
// Check reports whether word is spelled correctly. As in Hunspell, a
// capitalised or all-caps word is also accepted in its lower-case form
// ("Door", "DOOR"), but a lower-case proper noun is not ("paris").
func (d *Dictionary) Check(word string) bool {
w := d.iconv.Replace(word)
if d.accepts(w, false) {
return true
}
switch caseOf(w) {
case titleCase:
return d.accepts(strings.ToLower(w), true)
case upperCase:
lower := strings.ToLower(w)
return d.accepts(capitalize(lower), true) || d.accepts(lower, true)
}
return false
}
// accepts checks one exact spelling, as a word or as a compound word.
func (d *Dictionary) accepts(w string, caseChanged bool) bool {
if d.valid(w, caseChanged) || (d.compound.enabled() && d.compoundOf(w, 0, "", caseChanged)) {
return true
}
for _, r := range d.compound.rules {
if d.matchesRule(w, r, 0, 0, caseChanged) {
return true
}
}
return false
}
// matchesRule reports whether w splits into dictionary words (without
// affixes, as in Hunspell) whose flags follow rule from step i; parts
// counts the words already matched.
func (d *Dictionary) matchesRule(w string, rule compoundRule, i, parts int, cc bool) bool {
if i < len(rule) && rule[i].rep != 0 && d.matchesRule(w, rule, i+1, parts, cc) {
return true // an optional step left out
}
if w == "" {
return i == len(rule) && parts >= 2
}
if i == len(rule) {
return false
}
step := rule[i]
next := i + 1
if step.rep == '*' {
next = i
}
runes := []rune(w)
for n := d.compound.min; n <= len(runes); n++ {
part := string(runes[:n])
if d.isForbidden(part) || !d.stemHas(part, cc, func(f flagSet) bool { return f.has(step.flag) }) {
continue
}
if d.matchesRule(string(runes[n:]), rule, next, parts+1, cc) {
return true
}
}
return false
}
// Where a part sits in a compound word.
const (
partBegin = iota
partMiddle
partLast
)
// compoundOf reports whether w splits into compound parts, the first of
// which follows parts earlier ones (prev is the last of them).
func (d *Dictionary) compoundOf(w string, parts int, prev string, cc bool) bool {
c := d.compound
runes := []rune(w)
fits := func(n int) bool { return c.maxWords == 0 || n <= c.maxWords }
for i := c.min; i <= len(runes)-c.min; i++ {
head, rest := string(runes[:i]), string(runes[i:])
if c.checkDup && head == prev {
continue
}
if c.checkCase && (unicode.IsUpper(runes[i]) || unicode.IsUpper(runes[i-1])) {
continue
}
pos := partBegin
if parts > 0 {
pos = partMiddle
}
if !d.compoundPart(head, pos, cc) {
continue
}
if fits(parts+2) && !(c.checkDup && rest == head) && d.compoundPart(rest, partLast, cc) {
return true
}
if fits(parts+3) && d.compoundOf(rest, parts+1, head, cc) {
return true
}
}
return false
}
// compoundPart checks one part of a compound word. The first part may take
// a prefix and the last one a suffix; other affixes need COMPOUNDPERMITFLAG.
func (d *Dictionary) compoundPart(w string, pos int, cc bool) bool {
c := d.compound
allowed := func(f flagSet) bool {
return f.has(c.any) || (pos == partBegin && f.has(c.begin)) ||
(pos == partMiddle && f.has(c.middle)) || (pos == partLast && f.has(c.last))
}
if d.isForbidden(w) {
return false
}
if d.stemHas(w, cc, func(f flagSet) bool { return !f.has(d.needAffix) && allowed(f) }) {
return true
}
affixOK := func(a *affix, at int) bool {
return !a.cont.has(c.forbid) && (pos == at || a.cont.has(c.permit))
}
if d.matchingSuffixes(w, func(s *affix, stem string) bool {
return affixOK(s, partLast) && d.stemHas(stem, cc, func(f flagSet) bool { return f.has(s.flag) && allowed(f) })
}) {
return true
}
return d.matchingPrefixes(w, func(p *affix, rest string) bool {
if !affixOK(p, partBegin) {
return false
}
if d.stemHas(rest, cc, func(f flagSet) bool { return f.has(p.flag) && allowed(f) }) {
return true
}
// Both: a prefix and a suffix that combine (cross product).
return p.cross && d.matchingSuffixes(rest, func(s *affix, stem string) bool {
return s.cross && affixOK(s, partLast) &&
d.stemHas(stem, cc, func(f flagSet) bool { return f.has(p.flag) && f.has(s.flag) && allowed(f) })
})
})
}
// valid checks one exact spelling. caseChanged is set when the word was
// lower-cased to be looked up, which KEEPCASE words forbid.
func (d *Dictionary) valid(w string, caseChanged bool) bool {
if w == "" || d.isForbidden(w) {
return false
}
if d.stemHas(w, caseChanged, func(f flagSet) bool { return !f.has(d.needAffix) && !f.has(d.onlyInComp) }) {
return true
}
return d.suffixed(w, caseChanged) || d.prefixed(w, caseChanged)
}
func (d *Dictionary) isForbidden(w string) bool {
if d.forbidden == 0 {
return false
}
for _, f := range d.words[w] {
if f.has(d.forbidden) {
return true
}
}
return false
}
// stemHas reports whether one of the homonyms of stem satisfies ok.
func (d *Dictionary) stemHas(stem string, caseChanged bool, ok func(flagSet) bool) bool {
for _, f := range d.words[stem] {
if f.has(d.forbidden) || (caseChanged && f.has(d.keepCase)) {
continue
}
if ok(f) {
return true
}
}
return false
}
// matchingSuffixes calls fn for every suffix that w can end with, with the
// stem it leaves once the suffix is removed and the stripped text restored.
func (d *Dictionary) matchingSuffixes(w string, fn func(a *affix, stem string) bool) bool {
for n := 0; n <= min(d.maxSuffix, len(w)); n++ {
for _, a := range d.suffixes[w[len(w)-n:]] {
base := w[:len(w)-n]
if base == "" && !d.fullStrip {
continue
}
stem := base + a.strip
if stem == "" || !a.cond.matchEnd(stem) {
continue
}
if fn(a, stem) {
return true
}
}
}
return false
}
func (d *Dictionary) matchingPrefixes(w string, fn func(a *affix, stem string) bool) bool {
for n := 0; n <= min(d.maxPrefix, len(w)); n++ {
for _, a := range d.prefixes[w[:n]] {
rest := w[n:]
if rest == "" && !d.fullStrip {
continue
}
stem := a.strip + rest
if stem == "" || !a.cond.matchStart(stem) {
continue
}
if fn(a, stem) {
return true
}
}
}
return false
}
// suffixed accepts stem+suffix, and stem+suffix+suffix when the inner
// suffix lists the outer one as a continuation class.
func (d *Dictionary) suffixed(w string, cc bool) bool {
return d.matchingSuffixes(w, func(s *affix, stem string) bool {
if !s.cont.has(d.needAffix) && !s.cont.has(d.circumfix) &&
d.stemHas(stem, cc, func(f flagSet) bool { return f.has(s.flag) }) {
return true
}
if !d.contFlags[s.flag] {
return false
}
return d.matchingSuffixes(stem, func(inner *affix, root string) bool {
return inner.cont.has(s.flag) &&
d.stemHas(root, cc, func(f flagSet) bool { return f.has(inner.flag) })
})
})
}
// prefixed accepts prefix+stem and prefix+stem+suffix.
func (d *Dictionary) prefixed(w string, cc bool) bool {
return d.matchingPrefixes(w, func(p *affix, rest string) bool {
if !p.cont.has(d.needAffix) && !p.cont.has(d.circumfix) &&
d.stemHas(rest, cc, func(f flagSet) bool { return f.has(p.flag) }) {
return true
}
return d.matchingSuffixes(rest, func(s *affix, stem string) bool {
if s.cont.has(d.circumfix) != p.cont.has(d.circumfix) {
return false
}
return d.stemHas(stem, cc, func(f flagSet) bool {
switch {
case f.has(s.flag) && f.has(p.flag):
return p.cross && s.cross
case f.has(s.flag):
return s.cont.has(p.flag) // the suffix allows the prefix
case f.has(p.flag):
return p.cont.has(s.flag)
}
return false
})
})
})
}
// maxSuggestions is how many corrections Suggest returns at most.
const maxSuggestions = 8
// Suggest returns likely corrections for a misspelled word, best first,
// in the word's case ("TEH" gives "THE"). It tries the word with a capital
// (proper nouns), the dictionary's table of common misspellings (REP), then
// every spelling one edit away, the most frequent kinds of typo first:
// swapped letters, an extra letter, a wrong letter, a missing letter, and
// a missing space between two words of three letters or more.
func (d *Dictionary) Suggest(word string) []string {
w := d.iconv.Replace(word)
wc := caseOf(w)
base := w
if wc == titleCase || wc == upperCase {
base = strings.ToLower(w)
}
var out, proper []string
seen := map[string]bool{w: true}
try := func(cand string) {
if len(out) >= maxSuggestions || seen[cand] {
return
}
seen[cand] = true
if d.suggestable(cand) {
out = append(out, cand)
return
}
// A lower-case candidate may be a proper noun ("pariss" → "Paris"),
// offered after the common words.
if caseOf(cand) == lowerCase {
if cand = capitalize(cand); !seen[cand] && d.suggestable(cand) {
seen[cand] = true
proper = append(proper, cand)
}
}
}
if wc == lowerCase && d.suggestable(capitalize(base)) {
out = append(out, capitalize(base))
}
for _, r := range d.rep {
for i := 0; ; {
j := strings.Index(base[i:], r.from)
if j < 0 {
break
}
at := i + j
if (!r.start || at == 0) && (!r.end || at+len(r.from) == len(base)) {
try(base[:at] + r.to + base[at+len(r.from):])
}
i = at + 1
}
}
runes := []rune(base)
edit := func(i, del int, ins ...rune) string {
return string(runes[:i]) + string(ins) + string(runes[i+del:])
}
for i := 0; i+1 < len(runes); i++ {
if runes[i] != runes[i+1] {
try(edit(i, 2, runes[i+1], runes[i]))
}
}
for i := range runes {
try(edit(i, 1))
}
for i := range runes {
for _, c := range d.try {
if c != runes[i] && !unicode.IsUpper(c) {
try(edit(i, 1, c))
}
}
}
for i := 0; i <= len(runes); i++ {
for _, c := range d.try {
if !unicode.IsUpper(c) {
try(edit(i, 0, c))
}
}
}
// Short words ("a", "re", "er") make most splits look valid but are
// rarely what was meant; REP tables cover the common cases ("a lot").
for i := 3; i+3 <= len(runes); i++ {
try(edit(i, 0, ' '))
}
out = append(out, proper...)
out = out[:min(len(out), maxSuggestions)]
for i, s := range out {
switch wc {
case upperCase:
s = strings.ToUpper(s)
case titleCase:
s = capitalize(s)
}
out[i] = d.oconv.Replace(s)
}
return out
}
// suggestable checks a candidate, which may be two words separated by a
// space, and leaves out words marked NOSUGGEST (offensive words, mostly).
func (d *Dictionary) suggestable(cand string) bool {
for _, w := range strings.Split(cand, " ") {
if w == "" || strings.HasPrefix(w, "-") || strings.HasSuffix(w, "-") || !d.valid(w, false) {
return false
}
for _, f := range d.words[w] {
if f.has(d.noSuggest) {
return false
}
}
}
return true
}
+210
View File
@@ -0,0 +1,210 @@
package spell
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"unicode"
"plotzilla/internal/screenplay"
)
// Word is a word found in a text. Offset and Length count runes, like
// every offset exchanged with the editor.
type Word struct {
Text string
Offset int
Length int
}
func isApostrophe(r rune) bool { return r == '\'' || r == '’' }
func isWordRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsMark(r) || unicode.IsDigit(r)
}
// Words splits text into words: runs of letters and digits, with the
// apostrophes inside them ("don't", "l'homme"). Hyphens, slashes and
// dots separate words, so each half of "well-known" is checked alone.
func Words(text string) []Word {
var words []Word
runes := []rune(text)
for i := 0; i < len(runes); {
if !isWordRune(runes[i]) {
i++
continue
}
start := i
for i < len(runes) && (isWordRune(runes[i]) ||
(isApostrophe(runes[i]) && i+1 < len(runes) && isWordRune(runes[i+1]))) {
i++
}
words = append(words, Word{Text: string(runes[start:i]), Offset: start, Length: i - start})
}
return words
}
// Misspelling locates a misspelled word: the index of its element in
// document order (as layout.Break) and its rune offset and length.
type Misspelling struct {
Element int `json:"element"`
Offset int `json:"offset"`
Length int `json:"length"`
Word string `json:"word"`
}
// jargon lists screenplay abbreviations that no dictionary knows.
var jargon = map[string]bool{
"int": true, "ext": true, "est": true, "cont'd": true, "contd": true,
"pov": true, "vo": true, "os": true, "oc": true, "ecu": true, "mos": true,
}
var sceneOpener = regexp.MustCompile(`(?i)^\s*(int\.?/ext|ext\.?/int|int|ext|est|i/e)\b`)
// Checker checks screenplays against one dictionary plus the user's own
// words, and remembers the answers: a script repeats the same words.
type Checker struct {
dict *Dictionary
mu sync.Mutex
cache map[string]bool
personal map[string]bool // lower-case
}
func NewChecker(d *Dictionary, personal []string) *Checker {
c := &Checker{dict: d, cache: map[string]bool{}, personal: map[string]bool{}}
for _, w := range personal {
c.personal[strings.ToLower(w)] = true
}
return c
}
// Add accepts word from now on, in any letter case.
func (c *Checker) Add(word string) {
c.mu.Lock()
defer c.mu.Unlock()
c.personal[strings.ToLower(normalize(word))] = true
}
func normalize(w string) string { return strings.ReplaceAll(w, "’", "'") }
// Check reports whether word is correct. Words with digits ("3rd", "R2D2")
// and single letters are always accepted.
func (c *Checker) Check(word string) bool {
if len([]rune(word)) < 2 || strings.IndexFunc(word, unicode.IsDigit) >= 0 {
return true
}
lower := strings.ToLower(normalize(word))
c.mu.Lock()
defer c.mu.Unlock()
if jargon[lower] || c.personal[lower] {
return true
}
ok, found := c.cache[word]
if !found {
ok = c.dict.Check(word)
c.cache[word] = ok
}
return ok
}
func (c *Checker) Suggest(word string) []string { return c.dict.Suggest(word) }
// Misspellings returns the misspelled words of a script. Character cues
// hold names, so they are not checked, and the names they introduce are
// accepted everywhere else (an action line often spells them in capitals).
func (c *Checker) Misspellings(doc *screenplay.Document) []Misspelling {
names := map[string]bool{}
for _, el := range doc.Elements {
if el.Type == screenplay.Character {
for _, w := range Words(screenplay.CharacterName(el.PrintedText())) {
names[strings.ToLower(normalize(w.Text))] = true
}
}
}
out := []Misspelling{}
for i, el := range doc.Elements {
if el.Type == screenplay.Character {
continue
}
text := el.Text()
skip := 0 // runes of a scene heading's "INT./EXT."
if el.Type == screenplay.Scene {
if m := sceneOpener.FindString(text); m != "" {
skip = len([]rune(m))
}
}
for _, w := range Words(text) {
if w.Offset < skip || isName(names, w.Text) || c.Check(w.Text) {
continue
}
out = append(out, Misspelling{Element: i, Offset: w.Offset, Length: w.Length, Word: w.Text})
}
}
return out
}
// isName reports whether w is one of the names, possibly possessive
// ("ELODIE's").
func isName(names map[string]bool, w string) bool {
lower := strings.ToLower(normalize(w))
return names[lower] || names[strings.TrimSuffix(lower, "'s")]
}
// Language is an installed dictionary, named after its files ("en_US").
type Language struct {
Code string
Aff string
Dic string
}
// Available lists the dictionaries found in dirs; when the same language
// is in several directories, the first one wins.
func Available(dirs []string) []Language {
seen := map[string]bool{}
var out []Language
for _, dir := range dirs {
matches, _ := filepath.Glob(filepath.Join(dir, "*.dic"))
sort.Strings(matches)
for _, dic := range matches {
code := strings.TrimSuffix(filepath.Base(dic), ".dic")
aff := strings.TrimSuffix(dic, ".dic") + ".aff"
if seen[code] || strings.HasPrefix(code, "hyph_") || strings.HasPrefix(code, "th_") {
continue
}
if _, err := os.Stat(aff); err != nil {
continue
}
seen[code] = true
out = append(out, Language{Code: code, Aff: aff, Dic: dic})
}
}
return out
}
// Preferred picks the dictionary that suits a locale such as
// "fr_FR.UTF-8": the same language and region, then the same language,
// then American English, then the first one.
func Preferred(codes []string, locale string) string {
if len(codes) == 0 {
return ""
}
locale, _, _ = strings.Cut(locale, ".")
locale, _, _ = strings.Cut(locale, "@")
lang, _, _ := strings.Cut(locale, "_")
for _, want := range []func(string) bool{
func(c string) bool { return locale != "" && c == locale },
func(c string) bool { return lang != "" && (c == lang || strings.HasPrefix(c, lang+"_")) },
func(c string) bool { return c == "en_US" },
func(c string) bool { return strings.HasPrefix(c, "en_") || c == "en" },
} {
for _, c := range codes {
if want(c) {
return c
}
}
}
return codes[0]
}
+555
View File
@@ -0,0 +1,555 @@
// Package spell checks spelling with Hunspell dictionaries (the .aff and
// .dic files shipped by Linux distributions and LibreOffice). It implements
// the part of the Hunspell format that ordinary words need: prefixes and
// suffixes with their conditions, cross products, two-level suffixes,
// flag aliases and the usual word flags, plus compound words built with
// COMPOUNDFLAG or COMPOUNDBEGIN/MIDDLE/LAST (German, Dutch, Scandinavian
// dictionaries) or described by COMPOUNDRULE patterns.
package spell
import (
"bufio"
"bytes"
"fmt"
"os"
"sort"
"strconv"
"strings"
"unicode/utf8"
)
type flag uint16
// flagSet is a sorted list of flags; words and affixes carry only a few.
type flagSet []flag
func (s flagSet) has(f flag) bool {
if f == 0 {
return false
}
i := sort.Search(len(s), func(i int) bool { return s[i] >= f })
return i < len(s) && s[i] == f
}
// condition is a Hunspell affix condition: a sequence of characters, "."
// or bracket classes ("[^aeiou]y"), matched against one end of the stem.
type condition []condUnit
type condUnit struct {
any bool
neg bool
chars string
}
func (u condUnit) match(r rune) bool {
if u.any {
return true
}
return strings.ContainsRune(u.chars, r) != u.neg
}
func parseCondition(s string) condition {
if s == "" || s == "." {
return nil
}
var c condition
runes := []rune(s)
for i := 0; i < len(runes); i++ {
switch runes[i] {
case '.':
c = append(c, condUnit{any: true})
case '[':
u := condUnit{}
i++
if i < len(runes) && runes[i] == '^' {
u.neg = true
i++
}
start := i
for i < len(runes) && runes[i] != ']' {
i++
}
u.chars = string(runes[start:i])
c = append(c, u)
default:
c = append(c, condUnit{chars: string(runes[i])})
}
}
return c
}
func (c condition) matchStart(s string) bool {
for _, u := range c {
r, size := utf8.DecodeRuneInString(s)
if size == 0 || !u.match(r) {
return false
}
s = s[size:]
}
return true
}
func (c condition) matchEnd(s string) bool {
for i := len(c) - 1; i >= 0; i-- {
r, size := utf8.DecodeLastRuneInString(s)
if size == 0 || !c[i].match(r) {
return false
}
s = s[:len(s)-size]
}
return true
}
type affix struct {
flag flag
cross bool
strip string
add string
cond condition
cont flagSet // continuation classes: affixes allowed on top of this one
}
// compounding holds the rules for words made of several dictionary words
// ("Haustür" = "Haus" + "Tür").
type compounding struct {
any, begin, middle, last flag // COMPOUNDFLAG, COMPOUNDBEGIN, COMPOUNDMIDDLE, COMPOUNDLAST
permit, forbid flag // affixes allowed or forbidden inside a compound
min int // shortest part, in characters (COMPOUNDMIN, 3 by default)
maxWords int // most parts (COMPOUNDWORDMAX, 0 = no limit)
checkDup bool // CHECKCOMPOUNDDUP: no part repeated twice in a row
checkCase bool // CHECKCOMPOUNDCASE: no capital letter at a boundary
rules []compoundRule
}
func (c compounding) enabled() bool { return c.any != 0 || c.begin != 0 || c.last != 0 }
// compoundRule is a COMPOUNDRULE pattern: a sequence of flags, each
// optionally followed by "*" (any number of parts) or "?" (at most one).
// A compound matches when its parts, in order, carry those flags.
type compoundRule []ruleStep
type ruleStep struct {
flag flag
rep byte // 0, '*' or '?'
}
// parseRule reads a pattern such as "n*1t" or, with long or numeric flags,
// "(aa)(bb)*".
func (d *Dictionary) parseRule(s string) compoundRule {
var r compoundRule
runes := []rune(s)
for i := 0; i < len(runes); i++ {
name := string(runes[i])
if runes[i] == '(' {
end := i + 1
for end < len(runes) && runes[end] != ')' {
end++
}
name = string(runes[i+1 : min(end, len(runes))])
i = end
}
if name == "*" || name == "?" || name == "" {
continue
}
step := ruleStep{flag: d.flagID(name)}
if i+1 < len(runes) && (runes[i+1] == '*' || runes[i+1] == '?') {
step.rep = byte(runes[i+1])
i++
}
r = append(r, step)
}
return r
}
type replacement struct {
from, to string
start bool // "^" anchor
end bool // "$" anchor
}
// Dictionary is a loaded Hunspell dictionary. It is safe for concurrent
// use once loaded.
type Dictionary struct {
words map[string][]flagSet // homonyms keep separate flag sets
// Affixes are indexed by the text they add, so a word is only tested
// against the affixes that can have produced it.
prefixes map[string][]*affix
suffixes map[string][]*affix
maxPrefix int // longest added text, in bytes
maxSuffix int
contFlags map[flag]bool // suffix flags used as a continuation class
fullStrip bool
needAffix flag
forbidden flag
keepCase flag
noSuggest flag
onlyInComp flag
circumfix flag
compound compounding
try []rune
rep []replacement
iconv *strings.Replacer
oconv *strings.Replacer
flagMode string
flagIDs map[string]flag
flagAliases []flagSet
}
// Load reads a dictionary from its .aff and .dic files.
func Load(affPath, dicPath string) (*Dictionary, error) {
aff, err := os.ReadFile(affPath)
if err != nil {
return nil, fmt.Errorf("reading affix file: %w", err)
}
dic, err := os.ReadFile(dicPath)
if err != nil {
return nil, fmt.Errorf("reading dictionary: %w", err)
}
return Parse(aff, dic)
}
// Parse builds a dictionary from the contents of .aff and .dic files, in
// the encoding named by the affix file's SET directive.
func Parse(aff, dic []byte) (*Dictionary, error) {
enc := encodingOf(aff)
affText, err := decode(aff, enc)
if err != nil {
return nil, err
}
dicText, err := decode(dic, enc)
if err != nil {
return nil, err
}
d := &Dictionary{
compound: compounding{min: 3},
words: map[string][]flagSet{},
prefixes: map[string][]*affix{},
suffixes: map[string][]*affix{},
contFlags: map[flag]bool{},
flagIDs: map[string]flag{},
}
if err := d.parseAff(affText); err != nil {
return nil, err
}
d.parseDic(dicText)
return d, nil
}
func encodingOf(aff []byte) string {
for _, line := range bytes.Split(aff, []byte("\n")) {
if f := bytes.Fields(line); len(f) >= 2 && string(f[0]) == "SET" {
return strings.ToUpper(string(f[1]))
}
}
return "ISO8859-1" // Hunspell's default
}
// ISO 8859-15 differs from Latin-1 in eight positions (€, Š, Ž, Œ…).
var latin9 = map[byte]rune{
0xA4: '€', 0xA6: 'Š', 0xA8: 'š', 0xB4: 'Ž', 0xB8: 'ž', 0xBC: 'Œ', 0xBD: 'œ', 0xBE: 'Ÿ',
}
func decode(b []byte, enc string) (string, error) {
switch strings.ReplaceAll(enc, "-", "") {
case "UTF8":
return strings.TrimPrefix(string(b), "\uFEFF"), nil
case "ISO88591", "ISO885915":
isLatin9 := strings.HasSuffix(enc, "15")
var sb strings.Builder
sb.Grow(len(b))
for _, c := range b {
r, ok := latin9[c]
if !ok || !isLatin9 {
r = rune(c) // Latin-1 bytes are the first 256 code points
}
sb.WriteRune(r)
}
return sb.String(), nil
}
return "", fmt.Errorf("unsupported dictionary encoding %s", enc)
}
// flagID interns a flag name ("A", "L'", "123") as a small integer.
func (d *Dictionary) flagID(name string) flag {
if id, ok := d.flagIDs[name]; ok {
return id
}
id := flag(len(d.flagIDs) + 1)
d.flagIDs[name] = id
return id
}
// parseFlags reads a flag field, which is either an alias number (AF) or
// flags written in the dictionary's FLAG format.
func (d *Dictionary) parseFlags(s string) flagSet {
if len(d.flagAliases) > 0 {
if n, err := strconv.Atoi(s); err == nil {
if n >= 1 && n <= len(d.flagAliases) {
return d.flagAliases[n-1]
}
return nil
}
}
return d.plainFlags(s)
}
// plainFlags reads flags written in the dictionary's FLAG format.
func (d *Dictionary) plainFlags(s string) flagSet {
var names []string
switch d.flagMode {
case "long":
r := []rune(s)
for i := 0; i+1 < len(r); i += 2 {
names = append(names, string(r[i:i+2]))
}
case "num":
names = strings.Split(s, ",")
default: // single characters, "UTF-8" included
for _, r := range s {
names = append(names, string(r))
}
}
set := make(flagSet, 0, len(names))
for _, n := range names {
if n = strings.TrimSpace(n); n != "" {
set = append(set, d.flagID(n))
}
}
sort.Slice(set, func(i, j int) bool { return set[i] < set[j] })
return set
}
func (d *Dictionary) oneFlag(s string) flag {
if f := d.parseFlags(s); len(f) > 0 {
return f[0]
}
return 0
}
// pairs collects the pairs of a table directive such as ICONV or REP.
func pairs(lines [][]string, key string) [][2]string {
var out [][2]string
for _, f := range lines {
if len(f) >= 3 && f[0] == key {
out = append(out, [2]string{f[1], f[2]})
}
}
return out
}
func (d *Dictionary) parseAff(text string) error {
var lines [][]string
sc := bufio.NewScanner(strings.NewReader(text))
sc.Buffer(make([]byte, 64*1024), 1024*1024)
for sc.Scan() {
f := strings.Fields(sc.Text())
if len(f) == 0 || strings.HasPrefix(f[0], "#") {
continue
}
lines = append(lines, f)
}
if err := sc.Err(); err != nil {
return fmt.Errorf("reading affix file: %w", err)
}
// FLAG and AF change how every other flag is read: take them first.
for _, f := range lines {
if f[0] == "FLAG" && len(f) >= 2 {
d.flagMode = f[1]
}
}
var aliases []flagSet
counted := false
for _, f := range lines {
if f[0] == "AF" && len(f) >= 2 {
if !counted {
counted = true // the first line holds the number of aliases
continue
}
aliases = append(aliases, d.plainFlags(f[1]))
}
}
d.flagAliases = aliases
// COMPOUNDRULE: the first line holds the number of rules.
counted = false
for _, f := range lines {
if f[0] == "COMPOUNDRULE" && len(f) >= 2 {
if !counted {
counted = true
continue
}
if r := d.parseRule(f[1]); len(r) > 0 {
d.compound.rules = append(d.compound.rules, r)
}
}
}
pending := map[string]int{} // affix entries still expected per "PFX A"
cross := map[string]bool{}
for _, f := range lines {
switch f[0] {
case "TRY":
if len(f) >= 2 {
d.try = []rune(f[1])
}
case "FULLSTRIP":
d.fullStrip = true
case "NEEDAFFIX", "PSEUDOROOT":
d.needAffix = d.flagArg(f)
case "FORBIDDENWORD":
d.forbidden = d.flagArg(f)
case "KEEPCASE":
d.keepCase = d.flagArg(f)
case "NOSUGGEST":
d.noSuggest = d.flagArg(f)
case "ONLYINCOMPOUND":
d.onlyInComp = d.flagArg(f)
case "CIRCUMFIX":
d.circumfix = d.flagArg(f)
case "COMPOUNDFLAG":
d.compound.any = d.flagArg(f)
case "COMPOUNDBEGIN":
d.compound.begin = d.flagArg(f)
case "COMPOUNDMIDDLE":
d.compound.middle = d.flagArg(f)
case "COMPOUNDLAST", "COMPOUNDEND":
d.compound.last = d.flagArg(f)
case "COMPOUNDPERMITFLAG":
d.compound.permit = d.flagArg(f)
case "COMPOUNDFORBIDFLAG":
d.compound.forbid = d.flagArg(f)
case "COMPOUNDMIN":
if len(f) >= 2 {
if n, err := strconv.Atoi(f[1]); err == nil {
d.compound.min = max(n, 1)
}
}
case "COMPOUNDWORDMAX":
if len(f) >= 2 {
d.compound.maxWords, _ = strconv.Atoi(f[1])
}
case "CHECKCOMPOUNDDUP":
d.compound.checkDup = true
case "CHECKCOMPOUNDCASE":
d.compound.checkCase = true
case "PFX", "SFX":
if len(f) < 4 {
continue
}
key := f[0] + " " + f[1]
if pending[key] == 0 {
n, err := strconv.Atoi(f[3])
if err != nil {
continue
}
pending[key] = n
cross[key] = f[2] == "Y"
continue
}
pending[key]--
d.addAffix(f, cross[key])
}
}
for _, p := range pairs(lines, "REP") {
r := replacement{from: p[0], to: strings.ReplaceAll(p[1], "_", " ")}
r.start = strings.HasPrefix(r.from, "^")
r.end = strings.HasSuffix(r.from, "$")
r.from = strings.TrimSuffix(strings.TrimPrefix(r.from, "^"), "$")
r.from = strings.ReplaceAll(r.from, "_", " ")
if r.from != "" {
d.rep = append(d.rep, r)
}
}
d.iconv = replacer(pairs(lines, "ICONV"))
d.oconv = replacer(pairs(lines, "OCONV"))
return nil
}
func (d *Dictionary) flagArg(f []string) flag {
if len(f) < 2 {
return 0
}
return d.oneFlag(f[1])
}
func replacer(table [][2]string) *strings.Replacer {
var args []string
for _, p := range table {
args = append(args, p[0], p[1])
}
return strings.NewReplacer(args...)
}
// addAffix reads one entry line: "SFX flag strip add[/cont] [condition]".
func (d *Dictionary) addAffix(f []string, cross bool) {
a := &affix{flag: d.oneFlag(f[1]), cross: cross}
if f[2] != "0" {
a.strip = f[2]
}
add := f[3]
if i := strings.IndexByte(add, '/'); i >= 0 {
a.cont = d.parseFlags(add[i+1:])
add = add[:i]
}
if add != "0" {
a.add = add
}
if len(f) >= 5 {
a.cond = parseCondition(f[4])
}
if f[0] == "PFX" {
d.prefixes[a.add] = append(d.prefixes[a.add], a)
d.maxPrefix = max(d.maxPrefix, len(a.add))
return
}
d.suffixes[a.add] = append(d.suffixes[a.add], a)
d.maxSuffix = max(d.maxSuffix, len(a.add))
for _, c := range a.cont {
d.contFlags[c] = true
}
}
func (d *Dictionary) parseDic(text string) {
first := true
for _, line := range strings.Split(text, "\n") {
line = strings.TrimRight(line, "\r")
if first {
first = false
if _, err := strconv.Atoi(strings.TrimSpace(line)); err == nil {
continue // approximate word count
}
}
if line == "" || line[0] == '\t' || strings.HasPrefix(line, "#") {
continue
}
// Morphological fields follow a tab or a space.
if i := strings.IndexAny(line, "\t "); i >= 0 {
line = line[:i]
}
word, flags := splitEntry(line)
if word == "" {
continue
}
var set flagSet
if flags != "" {
set = d.parseFlags(flags)
}
d.words[word] = append(d.words[word], set)
}
}
// splitEntry separates "word/flags", where "\/" is a literal slash.
func splitEntry(s string) (word, flags string) {
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+1 < len(s) && s[i+1] == '/' {
i++
continue
}
if s[i] == '/' {
return strings.ReplaceAll(s[:i], `\/`, "/"), s[i+1:]
}
}
return strings.ReplaceAll(s, `\/`, "/"), ""
}
+360
View File
@@ -0,0 +1,360 @@
package spell
import (
"os"
"path/filepath"
"reflect"
"slices"
"testing"
"plotzilla/internal/screenplay"
)
const enAff = `SET UTF-8
TRY esianrtolcdugmphbyfvkwz'
ICONV 1
ICONV ’ '
NOSUGGEST !
KEEPCASE K
FORBIDDENWORD F
NEEDAFFIX N
REP 1
REP alot a_lot
PFX U Y 1
PFX U 0 un .
SFX S Y 4
SFX S y ies [^aeiou]y
SFX S 0 s [aeiou]y
SFX S 0 s [^sxzy]
SFX S 0 es [sxz]
SFX D Y 2
SFX D 0 ed [^e]
SFX D 0 d e
SFX M Y 1
SFX M 0 's .
# "ness" allows a plural on top of it: kindnesses.
SFX Q Y 1
SFX Q 0 ness/S .
`
const enDic = `16
kind/QU
open/SD
for
door/S
city/SM
play/SD
love/DS
the
a
lot
Paris/M
iPhone
OpenGL/K
walkd/F
realis/N
rude/!
`
func testDict(t *testing.T) *Dictionary {
t.Helper()
d, err := Parse([]byte(enAff), []byte(enDic))
if err != nil {
t.Fatal(err)
}
return d
}
func TestCheck(t *testing.T) {
d := testDict(t)
for word, want := range map[string]bool{
"door": true,
"doors": true,
"cities": true,
"citys": false, // condition [^y] rejects it
"plays": true,
"played": true,
"loved": true,
"loveed": false,
"unkind": true,
"kindness": true,
"kindnesses": true, // two suffixes
"unkindness": true, // cross product
"undoor": false,
"city's": true,
"city’s": true, // typographic apostrophe
"Door": true,
"DOORS": true,
"Paris": true,
"PARIS": true,
"Paris's": true,
"paris": false, // proper nouns keep their capital
"iPhone": true,
"OpenGL": true,
"OPENGL": false, // KEEPCASE
"walkd": false, // FORBIDDENWORD
"realis": false, // NEEDAFFIX
"cty": false,
"": false,
"rude": true,
"UnKind": false,
"the": true,
"supercalifrag": false,
} {
if got := d.Check(word); got != want {
t.Errorf("Check(%q) = %v, want %v", word, got, want)
}
}
}
func TestSuggest(t *testing.T) {
d := testDict(t)
for word, want := range map[string][]string{
"teh": {"the"},
"dor": {"for", "door"},
"doorz": {"door", "doors"},
"DOOR'S": {"DOORS"},
"Citys": {"City", "City's"},
"paris": {"Paris"},
"alot": {"a lot", "lot"},
"thedoor": {"the door"},
"rudee": nil, // NOSUGGEST
"luve": {"love"},
} {
if got := d.Suggest(word); !reflect.DeepEqual(got, want) {
t.Errorf("Suggest(%q) = %q, want %q", word, got, want)
}
}
}
// Long flags, flag aliases and prefixes allowed by a suffix's
// continuation class, as in the French dictionary ("l'homme", "d'hommes").
func TestAliasesAndLongFlags(t *testing.T) {
aff := `SET UTF-8
FLAG long
AF 2
AF L'S.
AF S.
PFX L' Y 1
PFX L' 0 l' [aeiouh]
SFX S. Y 1
SFX S. 0 s/L' [^sxz]
`
dic := "2\nhomme/1\nami/2\n"
d, err := Parse([]byte(aff), []byte(dic))
if err != nil {
t.Fatal(err)
}
for word, want := range map[string]bool{
"homme": true, "hommes": true, "l'homme": true, "l'hommes": true,
"L'homme": true, "amis": true, "l'ami": false, "l'amis": true,
} {
if got := d.Check(word); got != want {
t.Errorf("Check(%q) = %v, want %v", word, got, want)
}
}
}
func TestLatin1(t *testing.T) {
aff := []byte("SET ISO8859-1\nTRY ae\xe4\n")
dic := []byte("1\nH\xe4user\n")
d, err := Parse(aff, dic)
if err != nil {
t.Fatal(err)
}
if !d.Check("Häuser") || d.Check("Hauser") {
t.Error("Latin-1 dictionary not decoded")
}
if got := d.Suggest("Hauser"); !slices.Contains(got, "Häuser") {
t.Errorf("Suggest(Hauser) = %q", got)
}
if _, err := Parse([]byte("SET KOI8-R\n"), nil); err == nil {
t.Error("unsupported encoding accepted")
}
}
func TestWords(t *testing.T) {
got := Words("Élise's well-known R2D2, l’homme… 'quoted' x")
want := []Word{
{"Élise's", 0, 7}, {"well", 8, 4}, {"known", 13, 5}, {"R2D2", 19, 4},
{"l’homme", 25, 7}, {"quoted", 35, 6}, {"x", 43, 1},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("Words = %+v\nwant %+v", got, want)
}
}
func TestMisspellings(t *testing.T) {
c := NewChecker(testDict(t), []string{"Zorblax"})
doc := &screenplay.Document{Elements: []screenplay.Element{
screenplay.NewElement(screenplay.Scene, "INT. THE CTY - DAY"),
screenplay.NewElement(screenplay.Action, "GRUNDLE opens the dor for Zorblax."),
screenplay.NewElement(screenplay.Character, "Grundle (CONT'D)"),
screenplay.NewElement(screenplay.Dialogue, "Élan loves 3rd doors"),
screenplay.NewElement(screenplay.Action, "GRUNDLE's door."),
}}
got := c.Misspellings(doc)
want := []Misspelling{
{Element: 0, Offset: 9, Length: 3, Word: "CTY"},
{Element: 0, Offset: 15, Length: 3, Word: "DAY"},
{Element: 1, Offset: 18, Length: 3, Word: "dor"},
{Element: 3, Offset: 0, Length: 4, Word: "Élan"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("Misspellings = %+v\nwant %+v", got, want)
}
c.Add("élan")
c.Add("day")
if got := c.Misspellings(doc); len(got) != 2 {
t.Errorf("after Add, Misspellings = %+v", got)
}
}
func TestAvailable(t *testing.T) {
a, b := t.TempDir(), t.TempDir()
for _, f := range []string{
filepath.Join(a, "fr_FR.aff"), filepath.Join(a, "fr_FR.dic"),
filepath.Join(a, "de.dic"), // no .aff: skipped
filepath.Join(a, "hyph_fr.dic"), filepath.Join(a, "hyph_fr.aff"),
filepath.Join(b, "fr_FR.aff"), filepath.Join(b, "fr_FR.dic"),
filepath.Join(b, "en_US.aff"), filepath.Join(b, "en_US.dic"),
} {
if err := os.WriteFile(f, nil, 0o644); err != nil {
t.Fatal(err)
}
}
got := Available([]string{a, b, filepath.Join(a, "missing")})
want := []Language{
{"fr_FR", filepath.Join(a, "fr_FR.aff"), filepath.Join(a, "fr_FR.dic")},
{"en_US", filepath.Join(b, "en_US.aff"), filepath.Join(b, "en_US.dic")},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("Available = %+v", got)
}
}
func TestPreferred(t *testing.T) {
codes := []string{"de_DE_frami", "en_GB", "en_US", "fr"}
for locale, want := range map[string]string{
"fr_FR.UTF-8": "fr",
"en_GB.UTF-8": "en_GB",
"de_AT.UTF-8": "de_DE_frami",
"C": "en_US",
"": "en_US",
} {
if got := Preferred(codes, locale); got != want {
t.Errorf("Preferred(%q) = %q, want %q", locale, got, want)
}
}
if got := Preferred(nil, "fr_FR"); got != "" {
t.Errorf("Preferred(nil) = %q", got)
}
}
func TestCompounds(t *testing.T) {
// German-style compounds: parts marked for the start, middle or end,
// a linking "s" that only exists inside compounds, and an affix that
// lower-cases a noun inside a compound (COMPOUNDPERMITFLAG).
aff := `SET UTF-8
COMPOUNDBEGIN B
COMPOUNDMIDDLE M
COMPOUNDLAST E
ONLYINCOMPOUND O
COMPOUNDPERMITFLAG P
COMPOUNDMIN 1
COMPOUNDWORDMAX 3
CHECKCOMPOUNDDUP
PFX L Y 3
PFX L T t/P T
PFX L S s/P S
PFX L Z z/P Z
SFX N Y 1
SFX N 0 en .
`
dic := `6
Haus/BM
Tür/LMEN
Schlüssel/BMLE
s/MO
Arbeit/BM
Zeit/LE
`
d, err := Parse([]byte(aff), []byte(dic))
if err != nil {
t.Fatal(err)
}
for _, w := range []string{"Haus", "Haustür", "Haustüren", "Haustürschlüssel", "Arbeitszeit", "HAUSTÜR"} {
if !d.Check(w) {
t.Errorf("%q should be correct", w)
}
}
for _, w := range []string{"s", "Türhaus", "Hausshaus", "Haushaus", "Arbeitsarbeitszeit", "Haustürx"} {
if d.Check(w) {
t.Errorf("%q should be wrong", w)
}
}
// COMPOUNDFLAG: any marked word can go anywhere.
d, err = Parse([]byte("SET UTF-8\nCOMPOUNDFLAG X\n"), []byte("2\nfoot/X\nball/X\n"))
if err != nil {
t.Fatal(err)
}
if !d.Check("football") || !d.Check("ballfoot") || d.Check("footbal") {
t.Errorf("COMPOUNDFLAG compounds")
}
}
func TestCompoundRule(t *testing.T) {
// en_US style: numbers built from digits, ordinal endings that only
// exist in compounds, and a word pattern with long flags.
aff := `SET UTF-8
ONLYINCOMPOUND c
COMPOUNDMIN 1
COMPOUNDRULE 2
COMPOUNDRULE n*1t
COMPOUNDRULE n*mp
`
dic := `8
0/nm
1/n1
2/nm
3/nm
st/tc
nd/pc
th/pc
rd/pc
`
d, err := Parse([]byte(aff), []byte(dic))
if err != nil {
t.Fatal(err)
}
for _, w := range []string{"1st", "21st", "2nd", "33rd", "20th"} {
if !d.Check(w) {
t.Errorf("%q should be correct", w)
}
}
for _, w := range []string{"st", "1nd", "2st", "st1"} {
if d.Check(w) {
t.Errorf("%q should be wrong", w)
}
}
d, err = Parse([]byte("SET UTF-8\nFLAG long\nCOMPOUNDRULE 1\nCOMPOUNDRULE (aa)(bb)?(cc)\n"),
[]byte("3\nsun/aa\nflower/cc\nlit/bb\n"))
if err != nil {
t.Fatal(err)
}
if !d.Check("sunflower") || !d.Check("sunlitflower") || d.Check("flowersun") || d.Check("sunlit") {
t.Errorf("long-flag COMPOUNDRULE")
}
}