419 lines
12 KiB
Go
419 lines
12 KiB
Go
// 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
|
|
}
|