feat: first release !
This commit is contained in:
1 parent
77a333e8d9
commit
664527182d
66 files changed
+13812
-16
No files matched your search
@@ -0,0 +1,908 @@
|
||||
// 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}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"plotzilla/internal/screenplay"
|
||||
)
|
||||
|
||||
func TestWrap(t *testing.T) {
|
||||
r := []rune("the quick brown fox jumps")
|
||||
got := Wrap(r, 10, 10)
|
||||
var lines []string
|
||||
for _, rg := range got {
|
||||
lines = append(lines, string(r[rg[0]:rg[1]]))
|
||||
}
|
||||
want := []string{"the quick", "brown fox", "jumps"}
|
||||
if strings.Join(lines, "|") != strings.Join(want, "|") {
|
||||
t.Errorf("got %q", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func el(t screenplay.ElementType, s string) screenplay.Element {
|
||||
return screenplay.NewElement(t, s)
|
||||
}
|
||||
|
||||
func TestSceneHeadingNotOrphaned(t *testing.T) {
|
||||
doc := &screenplay.Document{}
|
||||
// 51 one-line actions (each with a blank line before) fill most of page 1.
|
||||
for i := 0; i < 26; i++ {
|
||||
doc.Elements = append(doc.Elements, el(screenplay.Action, "Something happens."))
|
||||
}
|
||||
doc.Elements = append(doc.Elements, el(screenplay.Scene, "INT. HOUSE - DAY"), el(screenplay.Action, "A\nB\nC"))
|
||||
res := Paginate(doc, Options{})
|
||||
if len(res.Pages) != 2 {
|
||||
t.Fatalf("pages = %d", len(res.Pages))
|
||||
}
|
||||
if b := res.Breaks[0]; b.Element != 26 || b.Offset != 0 {
|
||||
t.Errorf("scene heading should start page 2, break = %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogueMoreContd(t *testing.T) {
|
||||
doc := &screenplay.Document{}
|
||||
for i := 0; i < 24; i++ {
|
||||
doc.Elements = append(doc.Elements, el(screenplay.Action, "Something happens."))
|
||||
}
|
||||
long := strings.Repeat("This is a sentence that goes on. ", 12)
|
||||
doc.Elements = append(doc.Elements, el(screenplay.Character, "Margot"), el(screenplay.Dialogue, long))
|
||||
res := Paginate(doc, Options{})
|
||||
if len(res.Pages) != 2 {
|
||||
t.Fatalf("pages = %d", len(res.Pages))
|
||||
}
|
||||
text := func(p Page, i int) string {
|
||||
var b strings.Builder
|
||||
for _, s := range p.Lines[i].Segments {
|
||||
b.WriteString(s.Text)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
p1, p2 := res.Pages[0], res.Pages[1]
|
||||
if got := text(p1, len(p1.Lines)-1); got != "(MORE)" {
|
||||
t.Errorf("last line of page 1 = %q", got)
|
||||
}
|
||||
if got := text(p2, 0); got != "MARGOT (CONT'D)" {
|
||||
t.Errorf("first line of page 2 = %q", got)
|
||||
}
|
||||
if res.Breaks[0].Element != 25 || res.Breaks[0].Offset == 0 {
|
||||
t.Errorf("break should be inside the dialogue: %+v", res.Breaks[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDualDialogue(t *testing.T) {
|
||||
right := el(screenplay.Character, "Bob")
|
||||
right.Dual = true
|
||||
doc := &screenplay.Document{Elements: []screenplay.Element{
|
||||
el(screenplay.Character, "Alice"), el(screenplay.Dialogue, "Hello there, how are you doing today?"),
|
||||
right, el(screenplay.Dialogue, "Fine."),
|
||||
el(screenplay.Action, "They stop."),
|
||||
}}
|
||||
lines := Paginate(doc, Options{}).Pages[0].Lines
|
||||
byRow := map[int][]float64{}
|
||||
for _, l := range lines {
|
||||
byRow[l.Row] = append(byRow[l.Row], l.X)
|
||||
}
|
||||
if len(byRow[0]) != 2 || byRow[0][0] != 2.5 || byRow[0][1] != 5.5 {
|
||||
t.Errorf("cues should share row 0 at 2.5in and 5.5in: %v", byRow[0])
|
||||
}
|
||||
// Left dialogue wraps to 2 lines at 27 chars; action follows after a blank line.
|
||||
if last := lines[len(lines)-1]; last.Row != 4 || last.X != 1.5 {
|
||||
t.Errorf("action line = %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShotKeptWithNext(t *testing.T) {
|
||||
doc := &screenplay.Document{}
|
||||
for i := 0; i < 26; i++ {
|
||||
doc.Elements = append(doc.Elements, el(screenplay.Action, "Something happens."))
|
||||
}
|
||||
doc.Elements = append(doc.Elements, el(screenplay.Shot, "insert - the letter"), el(screenplay.Action, "A\nB\nC"))
|
||||
res := Paginate(doc, Options{})
|
||||
if b := res.Breaks[0]; b.Element != 26 || b.Offset != 0 {
|
||||
t.Errorf("insert should move to page 2 with its action, break = %+v", b)
|
||||
}
|
||||
if len(res.Scenes) != 0 {
|
||||
t.Errorf("an insert is not a scene: %+v", res.Scenes)
|
||||
}
|
||||
line := res.Pages[1].Lines[0]
|
||||
if line.X != 1.5 || line.Segments[0].Text != "INSERT - THE LETTER" {
|
||||
t.Errorf("insert line = %+v", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntertitleCentered(t *testing.T) {
|
||||
doc := &screenplay.Document{Elements: []screenplay.Element{el(screenplay.Intertitle, "Paris, 1942")}}
|
||||
line := Paginate(doc, Options{}).Pages[0].Lines[0]
|
||||
if line.Segments[0].Text != "PARIS, 1942" || line.X != 1.5+(60-11)*CharWidth/2 {
|
||||
t.Errorf("intertitle line = %+v", line)
|
||||
}
|
||||
}
|
||||
|
||||
func lineText(l Line) string {
|
||||
var b strings.Builder
|
||||
for _, s := range l.Segments {
|
||||
b.WriteString(s.Text)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestNotPrinted(t *testing.T) {
|
||||
section := el(screenplay.Section, "Act One")
|
||||
section.Level = 1
|
||||
action := screenplay.Element{Type: screenplay.Action, Runs: []screenplay.Run{
|
||||
{Text: "He waits."}, {Text: " (fix this)", Note: true}, {Text: "\n"}, {Text: "a whole line of notes", Note: true}, {Text: "\nThen he goes."},
|
||||
}}
|
||||
doc := &screenplay.Document{Elements: []screenplay.Element{
|
||||
section, el(screenplay.Synopsis, "Where it starts."), el(screenplay.Scene, "INT. HOUSE - DAY"), action,
|
||||
{Type: screenplay.Action, Runs: []screenplay.Run{{Text: "only a note", Note: true}}},
|
||||
el(screenplay.Scene, "EXT. ROAD - NIGHT"),
|
||||
}}
|
||||
res := Paginate(doc, Options{})
|
||||
var got []string
|
||||
for _, l := range res.Pages[0].Lines {
|
||||
got = append(got, lineText(l))
|
||||
}
|
||||
want := []string{"INT. HOUSE - DAY", "He waits.", "Then he goes.", "EXT. ROAD - NIGHT"}
|
||||
if strings.Join(got, "|") != strings.Join(want, "|") {
|
||||
t.Errorf("printed %q", got)
|
||||
}
|
||||
if res.Pages[0].Lines[3].Row != 6 {
|
||||
t.Errorf("the note-only action should leave no gap: %+v", res.Pages[0].Lines)
|
||||
}
|
||||
if res.Scenes[0].Number != "1" || res.Scenes[1].Number != "2" {
|
||||
t.Errorf("scenes: %+v", res.Scenes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakOffsetSkipsNotes(t *testing.T) {
|
||||
doc := &screenplay.Document{}
|
||||
for i := 0; i < 26; i++ {
|
||||
doc.Elements = append(doc.Elements, el(screenplay.Action, "Line."))
|
||||
}
|
||||
long := screenplay.Element{Type: screenplay.Action, Runs: []screenplay.Run{
|
||||
{Text: "N", Note: true}, {Text: strings.Repeat("One sentence here. ", 20)},
|
||||
}}
|
||||
doc.Elements = append(doc.Elements, long)
|
||||
res := Paginate(doc, Options{})
|
||||
b := res.Breaks[0]
|
||||
text := []rune(doc.Elements[26].Text())
|
||||
if b.Element != 26 || b.Offset != 1 || text[b.Offset] != 'O' {
|
||||
t.Errorf("the page should start after the note: %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestA4(t *testing.T) {
|
||||
doc := &screenplay.Document{Settings: screenplay.Settings{Paper: screenplay.A4}}
|
||||
for i := 0; i < 120; i++ {
|
||||
doc.Elements = append(doc.Elements, el(screenplay.Action, "Line."))
|
||||
}
|
||||
// 29 actions (57 rows) fit on an A4 page, 27 (53 rows) on US Letter.
|
||||
a4 := Paginate(doc, Options{})
|
||||
doc.Settings.Paper = screenplay.Letter
|
||||
letter := Paginate(doc, Options{})
|
||||
if a4.Breaks[0].Element != 29 || letter.Breaks[0].Element != 27 {
|
||||
t.Errorf("first break: A4 %+v, Letter %+v", a4.Breaks[0], letter.Breaks[0])
|
||||
}
|
||||
if g := PaperGeometry(screenplay.A4); g.Width != 8.27 || g.Lines != 58 {
|
||||
t.Errorf("A4 geometry %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func labels(res *Result) []string {
|
||||
var out []string
|
||||
for _, p := range res.Pages {
|
||||
out = append(out, p.Label)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestLockedPages(t *testing.T) {
|
||||
doc := &screenplay.Document{}
|
||||
for i := 0; i < 81; i++ {
|
||||
doc.Elements = append(doc.Elements, el(screenplay.Action, "Line."))
|
||||
}
|
||||
// Lock the current pagination: 27 actions per page.
|
||||
for _, b := range Paginate(doc, Options{}).Breaks {
|
||||
doc.Elements[b.Element].Locked = []screenplay.PageLock{{Offset: b.Offset, Page: b.Label}}
|
||||
}
|
||||
doc.Settings.Locked = true
|
||||
if got := labels(Paginate(doc, Options{})); strings.Join(got, " ") != "1 2 3" {
|
||||
t.Fatalf("locked, unchanged: %v", got)
|
||||
}
|
||||
|
||||
// Material added to page 1 goes to page 1A; page 2 does not move.
|
||||
grown := *doc
|
||||
grown.Elements = append(append(append([]screenplay.Element{}, doc.Elements[:5]...),
|
||||
el(screenplay.Action, "New."), el(screenplay.Action, "New.")), doc.Elements[5:]...)
|
||||
res := Paginate(&grown, Options{})
|
||||
if got := labels(res); strings.Join(got, " ") != "1 1A 2 3" {
|
||||
t.Fatalf("added material: %v", got)
|
||||
}
|
||||
if b := res.Breaks[1]; b.Element != 29 || b.Label != "2" {
|
||||
t.Errorf("page 2 should still start with its first line: %+v", b)
|
||||
}
|
||||
|
||||
// Page 2 emptied: merged into page 1.
|
||||
shrunk := *doc
|
||||
shrunk.Elements = append([]screenplay.Element{}, doc.Elements...)
|
||||
for i := 27; i < 54; i++ {
|
||||
shrunk.Elements[i] = screenplay.Element{Type: screenplay.Action, Locked: doc.Elements[i].Locked}
|
||||
}
|
||||
res = Paginate(&shrunk, Options{})
|
||||
if got := labels(res); strings.Join(got, " ") != "1-2 3" {
|
||||
t.Errorf("removed page: %v", got)
|
||||
}
|
||||
if b := res.Breaks[0]; b.Label != "3" || b.Page != 2 {
|
||||
t.Errorf("break after the merged page: %+v", b)
|
||||
}
|
||||
|
||||
// Page 3 emptied: page 2 becomes 2-3, and its break says so.
|
||||
shrunk.Elements = append([]screenplay.Element{}, doc.Elements...)
|
||||
for i := 54; i < 81; i++ {
|
||||
shrunk.Elements[i] = screenplay.Element{Type: screenplay.Action, Locked: doc.Elements[i].Locked}
|
||||
}
|
||||
res = Paginate(&shrunk, Options{})
|
||||
if got := labels(res); strings.Join(got, " ") != "1 2-3" || res.Breaks[0].Label != "2-3" {
|
||||
t.Errorf("removed last page: %v %+v", got, res.Breaks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockedPageInsideDialogue(t *testing.T) {
|
||||
doc := &screenplay.Document{Settings: screenplay.Settings{Locked: true}}
|
||||
doc.Elements = append(doc.Elements, el(screenplay.Character, "Margot"),
|
||||
screenplay.Element{Type: screenplay.Dialogue, Runs: []screenplay.Run{{Text: "First part. Second part."}},
|
||||
Locked: []screenplay.PageLock{{Offset: 12, Page: "2"}}})
|
||||
res := Paginate(doc, Options{})
|
||||
if len(res.Pages) != 2 {
|
||||
t.Fatalf("pages = %d", len(res.Pages))
|
||||
}
|
||||
p1, p2 := res.Pages[0].Lines, res.Pages[1].Lines
|
||||
if lineText(p1[1]) != "First part." || lineText(p1[2]) != "(MORE)" {
|
||||
t.Errorf("page 1: %+v", p1)
|
||||
}
|
||||
if lineText(p2[0]) != "MARGOT (CONT'D)" || lineText(p2[1]) != "Second part." {
|
||||
t.Errorf("page 2: %+v", p2)
|
||||
}
|
||||
if b := res.Breaks[0]; b.Element != 1 || b.Offset != 12 || b.Label != "2" {
|
||||
t.Errorf("break %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionLevels(t *testing.T) {
|
||||
doc := &screenplay.Document{Elements: []screenplay.Element{
|
||||
{Type: screenplay.Action, Runs: []screenplay.Run{{Text: "Old text. "}, {Text: "New text.", Rev: 2}}},
|
||||
{Type: screenplay.Action, Runs: []screenplay.Run{{Text: "Blue text.", Rev: 1}}},
|
||||
}}
|
||||
page := Paginate(doc, Options{}).Pages[0]
|
||||
if page.Lines[0].Rev != 2 || page.Lines[1].Rev != 1 || page.Rev() != 2 {
|
||||
t.Errorf("revisions: %+v", page.Lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForcedPageBreak(t *testing.T) {
|
||||
scene := el(screenplay.Scene, "EXT. ROAD - NIGHT")
|
||||
scene.NewPage = true
|
||||
right := el(screenplay.Character, "Bob")
|
||||
right.Dual, right.NewPage = true, true
|
||||
cue := el(screenplay.Character, "Alice")
|
||||
doc := &screenplay.Document{Elements: []screenplay.Element{
|
||||
el(screenplay.Action, "He waits."), scene, el(screenplay.Action, "A car."),
|
||||
cue, el(screenplay.Dialogue, "Hi."), right, el(screenplay.Dialogue, "Hey."),
|
||||
{Type: screenplay.Section, Level: 1, Runs: []screenplay.Run{{Text: "Act Two"}}, NewPage: true},
|
||||
el(screenplay.Action, "Later."),
|
||||
}}
|
||||
doc.Elements[0].NewPage = true // at the top of page 1: no effect
|
||||
res := Paginate(doc, Options{})
|
||||
var starts []int
|
||||
for _, b := range res.Breaks {
|
||||
starts = append(starts, b.Element)
|
||||
}
|
||||
// The right column's page break starts the page above the dual dialogue;
|
||||
// the section's goes to the next printed element.
|
||||
if len(res.Pages) != 4 || !slices.Equal(starts, []int{1, 3, 8}) {
|
||||
t.Errorf("pages %d, breaks at %v", len(res.Pages), starts)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user