feat: first release !

This commit is contained in:
Kharec committed 2026-09-28 14:21:06 +02:00
1 parent 77a333e8d9
commit 664527182d
66 files changed
+13812 -16

No files matched your search

+555
View File
@@ -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, "+")
}
+103
View File
@@ -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)
}
}