feat: first release !
This commit is contained in:
1 parent
77a333e8d9
commit
664527182d
66 files changed
+13812
-16
No files matched your search
@@ -0,0 +1,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, "+")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "_", `\_`)
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// Package pdf renders laid-out pages to PDF. It writes the file by hand
|
||||
// using the standard Courier fonts that every PDF reader ships with, so no
|
||||
// font is embedded and there is no external dependency.
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
|
||||
"plotzilla/internal/layout"
|
||||
"plotzilla/internal/screenplay"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Title string
|
||||
Author string
|
||||
Paper screenplay.Paper
|
||||
// Revisions are the document's revision sets. When set, a page whose
|
||||
// text was revised gets a header naming its revision, and its revised
|
||||
// lines an asterisk in the right margin.
|
||||
Revisions []screenplay.Revision
|
||||
}
|
||||
|
||||
const (
|
||||
fontSize = 12.0
|
||||
ptPerIn = 72.0
|
||||
)
|
||||
|
||||
var fontNames = []string{"Courier", "Courier-Bold", "Courier-Oblique", "Courier-BoldOblique"}
|
||||
|
||||
// Render writes pages to w. The title page, if any, must be the first page
|
||||
// and have Number 0 so that it is not numbered.
|
||||
func Render(w io.Writer, pages []layout.Page, opt Options) error {
|
||||
geo := layout.PaperGeometry(opt.Paper)
|
||||
var objs [][]byte // objs[i] is object number i+1
|
||||
add := func(s string) int {
|
||||
objs = append(objs, []byte(s))
|
||||
return len(objs)
|
||||
}
|
||||
// 1: catalog, 2: page tree, 3-6: fonts, 7: info. Pages follow.
|
||||
add("<< /Type /Catalog /Pages 2 0 R >>")
|
||||
add("") // page tree, filled once the kids are known
|
||||
for _, f := range fontNames {
|
||||
add("<< /Type /Font /Subtype /Type1 /BaseFont /" + f + " /Encoding /WinAnsiEncoding >>")
|
||||
}
|
||||
add(fmt.Sprintf("<< /Title %s /Author %s /Creator (Plotzilla) /Producer (Plotzilla) >>", textString(opt.Title), textString(opt.Author)))
|
||||
|
||||
var kids []string
|
||||
for _, pg := range pages {
|
||||
stream, err := deflate(pageContent(pg, geo, opt.Revisions))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content := add(fmt.Sprintf("<< /Length %d /Filter /FlateDecode >>\nstream\n%s\nendstream", len(stream), stream))
|
||||
page := add(fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %g %g] "+
|
||||
"/Resources << /Font << /F1 3 0 R /F2 4 0 R /F3 5 0 R /F4 6 0 R >> >> /Contents %d 0 R >>",
|
||||
points(geo.Width), points(geo.Height), content))
|
||||
kids = append(kids, fmt.Sprintf("%d 0 R", page))
|
||||
}
|
||||
objs[1] = []byte(fmt.Sprintf("<< /Type /Pages /Kids [%s] /Count %d >>", strings.Join(kids, " "), len(kids)))
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
|
||||
offsets := make([]int, len(objs))
|
||||
for i, o := range objs {
|
||||
offsets[i] = buf.Len()
|
||||
fmt.Fprintf(&buf, "%d 0 obj\n", i+1)
|
||||
buf.Write(o)
|
||||
buf.WriteString("\nendobj\n")
|
||||
}
|
||||
xref := buf.Len()
|
||||
fmt.Fprintf(&buf, "xref\n0 %d\n0000000000 65535 f \n", len(objs)+1)
|
||||
for _, off := range offsets {
|
||||
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
|
||||
}
|
||||
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R /Info 7 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objs)+1, xref)
|
||||
_, err := w.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
// points converts inches to PDF points, rounded to 1/100 pt.
|
||||
func points(in float64) float64 { return math.Round(in*ptPerIn*100) / 100 }
|
||||
|
||||
// baseline returns the PDF y coordinate of the baseline of a body row.
|
||||
func baseline(geo layout.Geometry, row int) float64 {
|
||||
top := (geo.Height - layout.TopMargin) * ptPerIn
|
||||
return top - float64(row)*fontSize - 9
|
||||
}
|
||||
|
||||
func text(b *bytes.Buffer, x, y float64, s string) {
|
||||
fmt.Fprintf(b, "BT /F1 12 Tf %.2f %.2f Td %s Tj ET\n", x, y, pdfString(s))
|
||||
}
|
||||
|
||||
func pageContent(pg layout.Page, geo layout.Geometry, revisions []screenplay.Revision) []byte {
|
||||
var b bytes.Buffer
|
||||
header := (geo.Height-0.5)*ptPerIn - 9
|
||||
if pg.Number > 0 && pg.Label != "" && pg.Label != "1" {
|
||||
num := pg.Label + "."
|
||||
text(&b, layout.RightEdge*ptPerIn-float64(len([]rune(num)))*fontSize*0.6, header, num)
|
||||
}
|
||||
// A revised page names its revision; its latest changes are starred.
|
||||
rev := pg.Rev()
|
||||
if rev > len(revisions) {
|
||||
rev = 0
|
||||
}
|
||||
if rev > 0 {
|
||||
r := revisions[rev-1]
|
||||
label := r.Name + " Revision"
|
||||
if r.Date != "" {
|
||||
label += " - " + r.Date
|
||||
}
|
||||
text(&b, 1.5*ptPerIn, header, label)
|
||||
}
|
||||
for _, ln := range pg.Lines {
|
||||
y := baseline(geo, ln.Row)
|
||||
x := ln.X * ptPerIn
|
||||
if ln.SceneNumber != "" {
|
||||
text(&b, 0.75*ptPerIn, y, ln.SceneNumber)
|
||||
text(&b, geo.SceneNumber*ptPerIn, y, ln.SceneNumber)
|
||||
}
|
||||
if rev > 0 && ln.Rev == rev {
|
||||
text(&b, geo.RevisionMark*ptPerIn, y, "*")
|
||||
}
|
||||
fmt.Fprintf(&b, "BT %.2f %.2f Td ", x, y)
|
||||
cursor := x
|
||||
var underlines [][2]float64
|
||||
for _, s := range ln.Segments {
|
||||
font := 1
|
||||
if s.Bold {
|
||||
font++
|
||||
}
|
||||
if s.Italic {
|
||||
font += 2
|
||||
}
|
||||
width := float64(len([]rune(s.Text))) * fontSize * 0.6
|
||||
if s.Underline {
|
||||
underlines = append(underlines, [2]float64{cursor, cursor + width})
|
||||
}
|
||||
cursor += width
|
||||
fmt.Fprintf(&b, "/F%d 12 Tf %s Tj ", font, pdfString(s.Text))
|
||||
}
|
||||
b.WriteString("ET\n")
|
||||
for _, u := range underlines {
|
||||
fmt.Fprintf(&b, "0.6 w %.2f %.2f m %.2f %.2f l S\n", u[0], y-1.8, u[1], y-1.8)
|
||||
}
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func deflate(data []byte) ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
zw := zlib.NewWriter(&b)
|
||||
if _, err := zw.Write(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
// pdfString encodes s as a WinAnsi literal string for the Courier fonts.
|
||||
func pdfString(s string) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('(')
|
||||
for _, r := range s {
|
||||
c := winAnsi(r)
|
||||
if c == '(' || c == ')' || c == '\\' {
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
b.WriteByte(c)
|
||||
}
|
||||
b.WriteByte(')')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// textString encodes s for the document info dictionary.
|
||||
func textString(s string) string {
|
||||
ascii := true
|
||||
for _, r := range s {
|
||||
if r > 0x7e || r < 0x20 {
|
||||
ascii = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ascii {
|
||||
return pdfString(s)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<FEFF")
|
||||
for _, u := range utf16.Encode([]rune(s)) {
|
||||
fmt.Fprintf(&b, "%04X", u)
|
||||
}
|
||||
b.WriteString(">")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var cp1252 = map[rune]byte{
|
||||
'€': 0x80, '‚': 0x82, 'ƒ': 0x83, '„': 0x84, '…': 0x85, '†': 0x86, '‡': 0x87,
|
||||
'ˆ': 0x88, '‰': 0x89, 'Š': 0x8a, '‹': 0x8b, 'Œ': 0x8c, 'Ž': 0x8e,
|
||||
'‘': 0x91, '’': 0x92, '“': 0x93, '”': 0x94, '•': 0x95, '–': 0x96, '—': 0x97,
|
||||
'˜': 0x98, '™': 0x99, 'š': 0x9a, '›': 0x9b, 'œ': 0x9c, 'ž': 0x9e, 'Ÿ': 0x9f,
|
||||
}
|
||||
|
||||
func winAnsi(r rune) byte {
|
||||
switch {
|
||||
case r >= 0x20 && r < 0x7f:
|
||||
return byte(r)
|
||||
case r >= 0xa0 && r <= 0xff:
|
||||
return byte(r)
|
||||
}
|
||||
if c, ok := cp1252[r]; ok {
|
||||
return c
|
||||
}
|
||||
if r == ' ' || r == ' ' {
|
||||
return ' ' // narrow spaces used before French punctuation
|
||||
}
|
||||
return '?'
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"plotzilla/internal/fountain"
|
||||
"plotzilla/internal/layout"
|
||||
"plotzilla/internal/screenplay"
|
||||
)
|
||||
|
||||
const sample = `Title: Le Dernier Rouleau
|
||||
Credit: Written by
|
||||
Author: Jane Doe
|
||||
Contact: jane@example.com
|
||||
Draft date: 28/09/2026
|
||||
|
||||
INT. CABINE DE PROJECTION - NUIT
|
||||
|
||||
La poussière danse dans le **faisceau** du projecteur. Élodie _entre_.
|
||||
|
||||
ÉLODIE (V.O.)
|
||||
(à voix basse)
|
||||
Ça finit toujours *ici*.
|
||||
|
||||
MARC ^
|
||||
(criant)
|
||||
Non ! Pas encore, pas ce soir.
|
||||
|
||||
CUT TO:
|
||||
`
|
||||
|
||||
func TestRender(t *testing.T) {
|
||||
doc := fountain.Parse(sample)
|
||||
pages := []layout.Page{layout.TitlePage(doc.TitlePage, doc.Settings.Paper)}
|
||||
pages = append(pages, layout.Paginate(doc, layout.Options{SceneNumbers: true}).Pages...)
|
||||
var buf bytes.Buffer
|
||||
if err := Render(&buf, pages, Options{Title: doc.TitlePage.Title, Author: doc.TitlePage.Author}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.HasPrefix(out, "%PDF-1.4") || !strings.HasSuffix(out, "%%EOF\n") || !strings.Contains(out, "/Count 2") {
|
||||
t.Fatalf("malformed PDF:\n%.300s", out)
|
||||
}
|
||||
// PLOTZILLA_PDF=/tmp/x.pdf go test ./internal/pdf keeps the file for inspection.
|
||||
if path := os.Getenv("PLOTZILLA_PDF"); path != "" {
|
||||
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionsAndA4(t *testing.T) {
|
||||
doc := fountain.Parse(sample)
|
||||
doc.Settings.Paper = screenplay.A4
|
||||
doc.Settings.Revisions = []screenplay.Revision{{Name: "Blue", Color: "blue", Date: "09/28/26"}}
|
||||
doc.Elements[1].Runs[0].Rev = 1
|
||||
var buf bytes.Buffer
|
||||
pages := layout.Paginate(doc, layout.Options{}).Pages
|
||||
if err := Render(&buf, pages, Options{Paper: doc.Settings.Paper, Revisions: doc.Settings.Revisions}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stream := uncompressed(t, buf.Bytes())
|
||||
if !strings.Contains(buf.String(), "/MediaBox [0 0 595.44 841.68]") {
|
||||
t.Errorf("not an A4 page")
|
||||
}
|
||||
if !strings.Contains(stream, "(Blue Revision - 09/28/26) Tj") || strings.Count(stream, "(*) Tj") != 1 {
|
||||
t.Errorf("revision header or mark missing:\n%s", stream)
|
||||
}
|
||||
|
||||
// Without revisions in the options, nothing is marked.
|
||||
buf.Reset()
|
||||
if err := Render(&buf, pages, Options{Paper: doc.Settings.Paper}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stream := uncompressed(t, buf.Bytes()); strings.Contains(stream, "Revision") || strings.Contains(stream, "(*)") {
|
||||
t.Errorf("unexpected revision marks:\n%s", stream)
|
||||
}
|
||||
}
|
||||
|
||||
// uncompressed returns the content streams of a PDF written by Render.
|
||||
func uncompressed(t *testing.T, pdf []byte) string {
|
||||
var out strings.Builder
|
||||
for _, part := range bytes.Split(pdf, []byte(">>\nstream\n"))[1:] {
|
||||
end := bytes.Index(part, []byte("\nendstream"))
|
||||
r, err := zlib.NewReader(bytes.NewReader(part[:end]))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := io.ReadAll(r)
|
||||
out.Write(data)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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, `\/`, "/"), ""
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user