909 lines
23 KiB
Go
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}
|
|
}
|