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,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, "_", `\_`)
|
||||
}
|
||||
Reference in new issue
Block a user