Files
2026-09-28 14:21:06 +02:00

291 lines
7.7 KiB
Go

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, "_", `\_`)
}