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