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

909 lines
23 KiB
Go

// Package layout turns a Document into printed pages following the
// Hollywood screenplay standard: US Letter (or A4), Courier 12pt (10
// characters per inch, 6 lines per inch), 1.5" left margin, ~55 lines per
// page so that one page reads as roughly one minute of screen time.
//
// It applies the usual pagination rules: a scene heading is never the last
// thing on a page, action and dialogue only break at the end of a sentence
// with at least two lines on each side, a page never ends on a
// parenthetical, and split dialogue gets "(MORE)" / "NAME (CONT'D)".
// Sections, synopses and notes are not printed. An element marked NewPage
// (Fountain "===") starts a page.
//
// Once pages are locked, every locked page starts where it did when it was
// locked: material added to a page flows onto A pages (12A, 12B), and a
// page whose material was all removed is merged with the previous one
// (12-13).
package layout
import (
"strconv"
"strings"
"unicode"
"plotzilla/internal/screenplay"
)
const (
TopMargin = 1.0 // inches
CharWidth = 0.1 // Courier 12pt
LineHeight = 1.0 / 6
RightEdge = 7.5
)
// Geometry is what changes with the paper size. The text keeps the same
// margins and widths on A4; the page is longer, so it holds more lines.
type Geometry struct {
Width, Height float64 // inches
Lines int // body lines per page, between 1" margins
// SceneNumber is where the right-hand scene number starts, and
// RevisionMark where the revision asterisk goes.
SceneNumber float64
RevisionMark float64
}
// PaperGeometry returns the geometry of a paper size.
func PaperGeometry(paper screenplay.Paper) Geometry {
if paper == screenplay.A4 {
return Geometry{Width: 8.27, Height: 11.69, Lines: 58, SceneNumber: 7.6, RevisionMark: 7.95}
}
return Geometry{Width: 8.5, Height: 11, Lines: 54, SceneNumber: 7.75, RevisionMark: 8.1}
}
type spec struct {
left float64 // inches from the left edge of the page
width int // characters per line
before int // blank lines before the element
}
var specs = map[screenplay.ElementType]spec{
screenplay.Scene: {1.5, 60, 2},
screenplay.Action: {1.5, 60, 1},
screenplay.Character: {3.7, 38, 1},
screenplay.Parenthetical: {3.1, 25, 0},
screenplay.Dialogue: {2.5, 35, 0},
screenplay.Transition: {1.5, 60, 1},
screenplay.Centered: {1.5, 60, 1},
screenplay.Shot: {1.5, 60, 1},
screenplay.Intertitle: {1.5, 60, 1},
}
type Segment struct {
Text string
Bold bool
Italic bool
Underline bool
}
type Line struct {
Row int // 0-based line within the page body
X float64 // inches from the left edge of the page
Segments []Segment
SceneNumber string // printed in both margins when set
Rev int // latest revision set of the text on the line
}
type Page struct {
Number int // 1, 2, 3… in print order (0 for a title page)
Label string // the printed page number: "12", "12A", "12-13"
Lines []Line
}
// Rev is the latest revision set of the text on the page: the page is
// issued on that colour.
func (p Page) Rev() int {
rev := 0
for _, l := range p.Lines {
rev = max(rev, l.Rev)
}
return rev
}
// Break marks where a page starts in the source: the element index and the
// rune offset inside that element's text.
type Break struct {
Element int `json:"element"`
Offset int `json:"offset"`
Page int `json:"page"`
Label string `json:"label"`
}
type ScenePage struct {
Element int `json:"element"`
Number string `json:"number"`
Page int `json:"page"`
Label string `json:"label"`
}
type Result struct {
Pages []Page
Breaks []Break
Scenes []ScenePage
}
type Options struct {
SceneNumbers bool
}
// dualSpecs place the two columns of a dual dialogue side by side:
// left column from 1.5", right column from 4.5".
var dualSpecs = [2]map[screenplay.ElementType]spec{
{
screenplay.Character: {2.5, 20, 1},
screenplay.Parenthetical: {1.8, 22, 0},
screenplay.Dialogue: {1.5, 27, 0},
},
{
screenplay.Character: {5.5, 20, 1},
screenplay.Parenthetical: {4.8, 22, 0},
screenplay.Dialogue: {4.5, 28, 0},
},
}
type style uint8
const (
sBold style = 1 << iota
sItalic
sUnderline
)
type item struct {
elem int
typ screenplay.ElementType
runes []rune
styles []style
revs []int // revision set of each rune
src []int // offset of each rune in the element's text
ranges [][2]int
before int
sceneNumber string
dual bool
sp spec
pages []string // locked pages starting with this item
newPage bool // forced page break before the item
}
// newItem prepares the printed text of an element: notes are removed, and
// so are the lines that held nothing but a note.
func newItem(idx int, el screenplay.Element) *item {
it := &item{elem: idx, typ: el.Type, dual: el.Dual, newPage: el.NewPage}
type char struct {
r rune
st style
rev int
note bool
}
var all []char
for _, r := range el.Runs {
var st style
if r.Bold {
st |= sBold
}
if r.Italic {
st |= sItalic
}
if r.Underline {
st |= sUnderline
}
for _, c := range r.Text {
all = append(all, char{c, st, r.Rev, r.Note})
}
}
started := false
for start, i := 0, 0; i <= len(all); i++ {
if i < len(all) && all[i].r != '\n' {
continue
}
line := all[start:i]
note, text := false, false
for _, c := range line {
note = note || c.note
text = text || (!c.note && !unicode.IsSpace(c.r))
}
if !note || text {
if started {
it.add('\n', 0, 0, start-1)
}
started = true
for k, c := range line {
if !c.note {
it.add(c.r, c.st, c.rev, start+k)
}
}
}
start = i + 1
}
it.relayout(specs[el.Type])
return it
}
func (it *item) add(c rune, st style, rev, src int) {
if c == '\t' {
c = ' '
}
if it.typ.Uppercase() {
c = unicode.ToUpper(c)
}
it.runes = append(it.runes, c)
it.styles = append(it.styles, st)
it.revs = append(it.revs, rev)
it.src = append(it.src, src)
}
func (it *item) blank() bool { return strings.TrimSpace(string(it.runes)) == "" }
// offset converts a printed rune index into an offset in the element text.
func (it *item) offset(i int) int {
switch {
case i < len(it.src):
return it.src[i]
case len(it.src) > 0:
return it.src[len(it.src)-1] + 1
}
return 0
}
// index is the first printed rune at or after an element text offset.
func (it *item) index(offset int) int {
i := 0
for i < len(it.src) && it.src[i] < offset {
i++
}
return i
}
// cut splits the item before printed rune k, for a locked page starting
// there. Spaces and line breaks around the cut go.
func (it *item) cut(k int) (*item, *item) {
tail := *it
head := *it
end := k
for end > 0 && unicode.IsSpace(it.runes[end-1]) {
end--
}
for k < len(it.runes) && unicode.IsSpace(it.runes[k]) {
k++
}
head.runes, head.styles, head.revs, head.src = it.runes[:end], it.styles[:end], it.revs[:end], it.src[:end]
tail.runes, tail.styles, tail.revs, tail.src = it.runes[k:], it.styles[k:], it.revs[k:], it.src[k:]
tail.pages = nil
tail.newPage = false
head.relayout(it.sp)
tail.relayout(it.sp)
return &head, &tail
}
// splittable reports whether a locked page may start inside the element.
func (it *item) splittable() bool {
switch it.typ {
case screenplay.Action, screenplay.Centered, screenplay.Dialogue, screenplay.Parenthetical:
return true
}
return false
}
func (it *item) relayout(sp spec) {
it.sp = sp
it.before = sp.before
cont := sp.width
if it.typ == screenplay.Parenthetical {
cont-- // hanging indent under the opening parenthesis
}
it.ranges = Wrap(it.runes, sp.width, cont)
}
func (it *item) x(li int) float64 {
sp := it.sp
n := float64(it.ranges[li][1] - it.ranges[li][0])
switch it.typ {
case screenplay.Transition:
return RightEdge - n*CharWidth
case screenplay.Centered, screenplay.Intertitle:
return sp.left + (float64(sp.width)-n)*CharWidth/2
case screenplay.Parenthetical:
if li > 0 {
return sp.left + CharWidth
}
}
return sp.left
}
func (it *item) rev(li int) int {
rev := 0
for _, r := range it.revs[it.ranges[li][0]:it.ranges[li][1]] {
rev = max(rev, r)
}
return rev
}
func (it *item) segments(li int) []Segment {
var segs []Segment
var cur style
rg := it.ranges[li]
for i := rg[0]; i < rg[1]; i++ {
st := it.styles[i]
if len(segs) == 0 || st != cur {
segs = append(segs, Segment{Bold: st&sBold != 0, Italic: st&sItalic != 0, Underline: st&sUnderline != 0})
cur = st
}
segs[len(segs)-1].Text += string(it.runes[i])
}
return segs
}
// Wrap splits text into lines of at most width characters (contWidth for the
// following lines), breaking on spaces and on "\n". It returns rune ranges.
func Wrap(r []rune, width, contWidth int) [][2]int {
width, contWidth = max(width, 1), max(contWidth, 1)
n := len(r)
var out [][2]int
start := 0
for {
w := width
if len(out) > 0 {
w = contWidth
}
end, lastSpace := start, -1
for end < n && r[end] != '\n' && end-start < w {
if r[end] == ' ' {
lastSpace = end
}
end++
}
switch {
case end >= n:
return append(out, trimRange(r, start, n))
case r[end] == '\n':
out = append(out, trimRange(r, start, end))
start = end + 1
case r[end] == ' ':
out = append(out, trimRange(r, start, end))
if start = skipSpaces(r, end); start >= n {
return out
}
case lastSpace > start:
out = append(out, trimRange(r, start, lastSpace))
start = skipSpaces(r, lastSpace)
default: // a single word longer than the line
out = append(out, [2]int{start, end})
start = end
}
}
}
func trimRange(r []rune, s, e int) [2]int {
for e > s && r[e-1] == ' ' {
e--
}
return [2]int{s, e}
}
func skipSpaces(r []rune, i int) int {
for i < len(r) && r[i] == ' ' {
i++
}
return i
}
type unit struct {
head *item // the element, or the character cue of a dialogue block
parts []*item // parentheticals and dialogue following a cue
block bool
right *unit // second column of a dual dialogue
// A dialogue block cut by a locked page: the first part ends with
// (MORE), the next one starts with NAME (CONT'D).
more, contd bool
pages []string // locked pages starting with this unit
newPage bool // forced page break before the unit
}
func (u *unit) items() []*item {
return append([]*item{u.head}, u.parts...)
}
func (u *unit) height() int {
n := 0
for _, it := range u.items() {
n += len(it.ranges)
}
return n
}
// minLines is how much of the unit must fit under a scene heading.
func (u *unit) minLines() int {
if !u.block {
return min(2, len(u.head.ranges))
}
n := 0
for _, p := range u.parts {
n += len(p.ranges)
}
return len(u.head.ranges) + min(2, n)
}
// Paginate lays the document out on pages.
func Paginate(doc *screenplay.Document, opt Options) *Result {
locked := doc.Settings.Locked
var items []*item
var carried []string // locked pages of elements that print nothing
carriedPage := false // and their forced page break
scene := 0
for i, el := range doc.Elements {
var locks []screenplay.PageLock
if locked {
for _, l := range carried {
locks = append(locks, screenplay.PageLock{Page: l})
}
locks = append(locks, el.Locked...)
}
var it *item
if !el.IsEmpty() && el.Type.Printed() {
it = newItem(i, el)
}
if it == nil || it.blank() {
carried = nil // now in locks
for _, l := range locks {
carried = append(carried, l.Page)
}
carriedPage = carriedPage || el.NewPage
continue
}
carried = nil
it.newPage = it.newPage || carriedPage
carriedPage = false
if el.Type == screenplay.Scene {
scene++
it.sceneNumber = el.SceneNumber
if it.sceneNumber == "" {
it.sceneNumber = strconv.Itoa(scene)
}
}
items = append(items, lockItem(it, locks, &carried)...)
}
var units []unit
for i := 0; i < len(items); i++ {
it := items[i]
if it.typ == screenplay.Character {
u := unit{head: it, block: true, pages: it.pages, newPage: it.newPage}
for i+1 < len(items) && items[i+1].typ.InDialogue() {
i++
part := items[i]
if len(part.pages) > 0 && !it.dual && !nextIsDual(items, i) {
u.more = true
units = append(units, u)
u = unit{head: it, block: true, contd: true, pages: part.pages}
} else {
u.pages = append(u.pages, part.pages...)
}
// A page cannot be forced inside a dialogue block: it breaks
// before the block.
u.newPage = u.newPage || part.newPage
u.parts = append(u.parts, part)
}
if prev := len(units) - 1; it.dual && prev >= 0 && units[prev].block && units[prev].right == nil {
for side, col := range []*unit{&units[prev], &u} {
for _, ci := range col.items() {
ci.relayout(dualSpecs[side][ci.typ])
}
}
// A dual dialogue is never split: its locked pages start above it.
units[prev].pages = append(units[prev].pages, u.pages...)
units[prev].newPage = units[prev].newPage || u.newPage
units[prev].right = &u
continue
}
units = append(units, u)
continue
}
if it.typ.InDialogue() {
it.before = 1 // dialogue without a cue: keep it off the previous element
}
units = append(units, unit{head: it, pages: it.pages, newPage: it.newPage})
}
p := &paginator{res: &Result{}, opt: opt, geo: PaperGeometry(doc.Settings.Paper), locked: locked}
p.newPage()
for i, u := range units {
var next *unit
if i+1 < len(units) {
next = &units[i+1]
if (locked && len(next.pages) > 0) || next.newPage {
next = nil // the next unit starts its own page anyway
}
}
if locked {
p.startLocked(u.pages)
}
if u.newPage && len(p.page.Lines) > 0 {
p.newPage()
}
switch {
case u.right != nil:
p.placeDual(u)
case u.block:
p.placeBlock(u)
default:
p.placeItem(u.head, next)
}
}
if locked {
// Locked pages whose material is all gone, at the end.
p.startLocked(carried)
if len(p.res.Pages) > 1 && len(p.page.Lines) == 0 {
p.dropEmptyPage()
}
}
// A page merged with an emptied one after it was renamed ("6-7").
for i, b := range p.res.Breaks {
p.res.Breaks[i].Label = p.res.Pages[b.Page-1].Label
}
for i, s := range p.res.Scenes {
p.res.Scenes[i].Label = p.res.Pages[s.Page-1].Label
}
return p.res
}
// nextIsDual reports whether the block holding items[i] is the left column
// of a dual dialogue.
func nextIsDual(items []*item, i int) bool {
for j := i + 1; j < len(items); j++ {
if !items[j].typ.InDialogue() {
return items[j].typ == screenplay.Character && items[j].dual
}
}
return false
}
// lockItem attaches the locked pages that start in an item, cutting it
// where a page starts inside it. A page starting at its very end starts
// with the next item.
func lockItem(it *item, locks []screenplay.PageLock, carried *[]string) []*item {
out := []*item{it}
cur := it
base := 0 // printed runes of it before cur
for _, l := range locks {
k := it.index(l.Offset) - base
switch {
case k >= len(cur.runes) || strings.TrimSpace(string(cur.runes[k:])) == "":
*carried = append(*carried, l.Page)
case k <= 0 || !cur.splittable() || strings.TrimSpace(string(cur.runes[:k])) == "":
cur.pages = append(cur.pages, l.Page)
default:
head, tail := cur.cut(k)
base += len(cur.runes) - len(tail.runes)
tail.pages = []string{l.Page}
out[len(out)-1] = head
out = append(out, tail)
cur = tail
}
}
return out
}
type paginator struct {
res *Result
page *Page
row int
pending bool // the next emitted source line starts a new page
opt Options
geo Geometry
locked bool
}
// newPage starts the next page. Once pages are locked, a page that does
// not start a locked page is an A page of the previous one.
func (p *paginator) newPage() {
label := strconv.Itoa(len(p.res.Pages) + 1)
if p.locked && p.page != nil {
label = nextLabel(p.page.Label)
}
p.addPage(label)
}
func (p *paginator) addPage(label string) {
p.res.Pages = append(p.res.Pages, Page{Number: len(p.res.Pages) + 1, Label: label})
p.page = &p.res.Pages[len(p.res.Pages)-1]
p.row = 0
p.pending = len(p.res.Pages) > 1
}
// startLocked starts the locked pages that begin with the next unit. A
// page left empty in between lost all its material: it is merged into the
// page before it ("12-13").
func (p *paginator) startLocked(labels []string) {
for _, l := range labels {
switch {
case len(p.page.Lines) > 0:
p.addPage(l)
case len(p.res.Pages) > 1:
p.dropEmptyPage()
p.addPage(l)
default: // nothing before the first locked page
p.page.Label = firstLabel(p.page.Label) + "-" + l
}
}
}
func (p *paginator) dropEmptyPage() {
n := len(p.res.Pages)
empty := p.res.Pages[n-1].Label
p.res.Pages = p.res.Pages[:n-1]
p.page = &p.res.Pages[n-2]
p.page.Label = firstLabel(p.page.Label) + "-" + lastLabel(empty)
p.row = p.geo.Lines
}
func firstLabel(l string) string { return strings.Split(l, "-")[0] }
func lastLabel(l string) string {
parts := strings.Split(l, "-")
return parts[len(parts)-1]
}
// nextLabel names the page added after a locked page: 12 → 12A → 12B.
func nextLabel(label string) string {
l := lastLabel(label)
if n := len(l); n > 0 && l[n-1] >= 'A' && l[n-1] < 'Z' && strings.ContainsAny(l, "0123456789") {
return l[:n-1] + string(l[n-1]+1)
}
return l + "A"
}
func (p *paginator) emit(it *item, li int) {
if p.pending {
p.res.Breaks = append(p.res.Breaks, Break{Element: it.elem, Offset: it.offset(it.ranges[li][0]), Page: p.page.Number, Label: p.page.Label})
p.pending = false
}
line := Line{Row: p.row, X: it.x(li), Segments: it.segments(li), Rev: it.rev(li)}
if it.typ == screenplay.Scene && li == 0 {
p.res.Scenes = append(p.res.Scenes, ScenePage{Element: it.elem, Number: it.sceneNumber, Page: p.page.Number, Label: p.page.Label})
if p.opt.SceneNumbers {
line.SceneNumber = it.sceneNumber
}
}
p.page.Lines = append(p.page.Lines, line)
p.row++
}
// emitText adds a line that does not come from the source ((MORE), CONT'D).
func (p *paginator) emitText(x float64, s string) {
p.page.Lines = append(p.page.Lines, Line{Row: p.row, X: x, Segments: []Segment{{Text: s}}})
p.row++
}
func (p *paginator) spaceBefore(it *item) int {
if p.row == 0 {
return 0
}
return it.before
}
func (p *paginator) placeItem(it *item, next *unit) {
n := len(it.ranges)
// Scene headings and inserts never end a page: keep them with what follows.
if (it.typ == screenplay.Scene || it.typ == screenplay.Shot) && p.row > 0 && next != nil {
need := it.before + n + next.head.before + next.minLines()
if p.row+need > p.geo.Lines {
p.newPage()
}
}
splittable := it.typ == screenplay.Action || it.typ == screenplay.Centered
start := 0
for {
before := p.spaceBefore(it)
if p.row+before+n-start <= p.geo.Lines {
p.row += before
for li := start; li < n; li++ {
p.emit(it, li)
}
return
}
avail := p.geo.Lines - p.row - before
k := 0
if splittable {
k = sentenceSplit(it, start, avail)
}
if k == 0 && p.row == 0 {
k = avail // longer than a page with no clean break
}
if k > 0 {
p.row += before
for li := start; li < start+k; li++ {
p.emit(it, li)
}
start += k
}
p.newPage()
}
}
type dline struct {
it *item
li int
}
func (p *paginator) placeBlock(u unit) {
var flat []dline
for _, part := range u.parts {
for li := range part.ranges {
flat = append(flat, dline{part, li})
}
}
cue := u.head
contd := u.contd
more := 0
if u.more {
more = 1
}
pos := 0
for {
before := p.spaceBefore(cue)
head := len(cue.ranges)
if contd {
head = 1
}
rem := len(flat) - pos
if p.row+before+head+rem+more <= p.geo.Lines {
p.row += before
p.emitCue(cue, contd)
for _, d := range flat[pos:] {
p.emit(d.it, d.li)
}
if u.more {
p.emitText(specs[screenplay.Character].left, "(MORE)")
}
return
}
avail := p.geo.Lines - p.row - before - head - 1 // one line for (MORE)
k := dialogueSplit(flat, pos, avail)
if k == 0 && p.row == 0 {
k = min(max(avail, 1), rem-1)
}
if k > 0 {
p.row += before
p.emitCue(cue, contd)
for _, d := range flat[pos : pos+k] {
p.emit(d.it, d.li)
}
p.emitText(specs[screenplay.Character].left, "(MORE)")
pos += k
contd = true
}
p.newPage()
}
}
// placeDual lays both columns side by side. A dual dialogue is never split.
func (p *paginator) placeDual(u unit) {
h := max(u.height(), u.right.height())
before := p.spaceBefore(u.head)
if p.row > 0 && p.row+before+h > p.geo.Lines {
p.newPage()
before = 0
}
p.row += before
base := p.row
for _, col := range []*unit{&u, u.right} {
p.row = base
for _, it := range col.items() {
for li := range it.ranges {
p.emit(it, li)
}
}
}
p.row = base + h
}
func (p *paginator) emitCue(cue *item, contd bool) {
if !contd {
for li := range cue.ranges {
p.emit(cue, li)
}
return
}
name := strings.TrimSpace(string(cue.runes))
if !strings.Contains(name, "(CONT'D)") {
name += " (CONT'D)"
}
p.emitText(specs[screenplay.Character].left, name)
}
// sentenceSplit returns how many lines (from start) to keep on the current
// page, or 0 if the element cannot be split cleanly within avail lines.
func sentenceSplit(it *item, start, avail int) int {
rem := len(it.ranges) - start
for k := min(avail, rem-2); k >= 2; k-- {
if endsSentence(it.runes, it.ranges[start+k-1]) {
return k
}
}
return 0
}
func dialogueSplit(flat []dline, pos, avail int) int {
rem := len(flat) - pos
for k := min(avail, rem-2); k >= 2; k-- {
last := flat[pos+k-1]
if last.it.typ == screenplay.Parenthetical {
continue
}
if last.li == len(last.it.ranges)-1 || endsSentence(last.it.runes, last.it.ranges[last.li]) {
return k
}
}
return 0
}
func endsSentence(r []rune, rg [2]int) bool {
if rg[1] < len(r) && r[rg[1]] == '\n' {
return true
}
s := strings.TrimRight(string(r[rg[0]:rg[1]]), " \"'”’»)")
for _, end := range []string{".", "!", "?", "…", ":", "--"} {
if strings.HasSuffix(s, end) {
return true
}
}
return false
}
// TitlePage lays out the cover page: title block centred in the upper half,
// contact bottom-left, draft date and notes bottom-right.
func TitlePage(tp screenplay.TitlePage, paper screenplay.Paper) Page {
lastRow := PaperGeometry(paper).Lines
var lines []Line
centered := func(row int, s string) int {
for _, para := range strings.Split(s, "\n") {
r := []rune(para)
for _, rg := range Wrap(r, 60, 60) {
n := float64(rg[1] - rg[0])
lines = append(lines, Line{Row: row, X: 1.5 + (60-n)*CharWidth/2, Segments: []Segment{{Text: string(r[rg[0]:rg[1]])}}})
row++
}
}
return row
}
bottom := func(s string, width int, right bool) {
var texts []string
for _, para := range strings.Split(strings.TrimSpace(s), "\n") {
r := []rune(para)
for _, rg := range Wrap(r, width, width) {
texts = append(texts, string(r[rg[0]:rg[1]]))
}
}
row := lastRow - len(texts)
for _, t := range texts {
x := 1.5
if right {
x = RightEdge - float64(len([]rune(t)))*CharWidth
}
lines = append(lines, Line{Row: row, X: x, Segments: []Segment{{Text: t}}})
row++
}
}
row := 18
if tp.Title != "" {
row = centered(row, tp.Title) + 3
}
if tp.Credit != "" {
row = centered(row, tp.Credit) + 1
}
if tp.Author != "" {
row = centered(row, tp.Author) + 3
}
if tp.Source != "" {
centered(row, tp.Source)
}
if strings.TrimSpace(tp.Contact) != "" {
bottom(tp.Contact, 35, false)
}
right := strings.TrimSpace(strings.TrimSpace(tp.Notes) + "\n" + strings.TrimSpace(tp.DraftDate))
if right != "" {
bottom(right, 25, true)
}
return Page{Lines: lines}
}