fountainrb 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +34 -0
- data/LICENSE.txt +21 -0
- data/README.md +373 -0
- data/exe/fountainrb +98 -0
- data/lib/fountainrb/elements.rb +271 -0
- data/lib/fountainrb/errors.rb +9 -0
- data/lib/fountainrb/html_renderer.rb +159 -0
- data/lib/fountainrb/inline.rb +356 -0
- data/lib/fountainrb/parser.rb +137 -0
- data/lib/fountainrb/plain_text_renderer.rb +137 -0
- data/lib/fountainrb/screenplay.rb +285 -0
- data/lib/fountainrb/title_page.rb +186 -0
- data/lib/fountainrb/tokenizer.rb +390 -0
- data/lib/fountainrb/validator.rb +294 -0
- data/lib/fountainrb/version.rb +6 -0
- data/lib/fountainrb.rb +65 -0
- metadata +98 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FountainRB
|
|
4
|
+
# Parses and renders the inline markup that can appear inside Fountain
|
|
5
|
+
# elements: emphasis, notes and soft line breaks.
|
|
6
|
+
#
|
|
7
|
+
# Fountain follows Markdown's emphasis rules with one twist: underscores
|
|
8
|
+
# mean underline rather than italics.
|
|
9
|
+
#
|
|
10
|
+
# *italics* -> :italic
|
|
11
|
+
# **bold** -> :bold
|
|
12
|
+
# ***bold italics*** -> :bold_italic
|
|
13
|
+
# _underline_ -> :underline
|
|
14
|
+
# [[a note]] -> :note
|
|
15
|
+
#
|
|
16
|
+
# Delimiters that are not closed on the same line are left as literal text,
|
|
17
|
+
# and a backslash escapes the following character. Emphasis never spans a
|
|
18
|
+
# line break.
|
|
19
|
+
module Inline
|
|
20
|
+
# A node produced by {Inline.parse}.
|
|
21
|
+
class Node
|
|
22
|
+
def ==(other)
|
|
23
|
+
other.class == self.class && other.state == state
|
|
24
|
+
end
|
|
25
|
+
alias_method :eql?, :==
|
|
26
|
+
|
|
27
|
+
def hash
|
|
28
|
+
[self.class, state].hash
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_h
|
|
32
|
+
raise NotImplementedError, "#{self.class}#to_h"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
protected
|
|
36
|
+
|
|
37
|
+
def state
|
|
38
|
+
[]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Literal text.
|
|
43
|
+
class Text < Node
|
|
44
|
+
attr_reader :text
|
|
45
|
+
|
|
46
|
+
def initialize(text)
|
|
47
|
+
@text = text
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_h
|
|
51
|
+
{"type" => "text", "text" => text}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
protected
|
|
55
|
+
|
|
56
|
+
def state
|
|
57
|
+
[text]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Emphasised text. +style+ is one of +:italic+, +:bold+, +:bold_italic+
|
|
62
|
+
# or +:underline+; +children+ is a list of nodes.
|
|
63
|
+
class Emphasis < Node
|
|
64
|
+
STYLES = %i[italic bold bold_italic underline].freeze
|
|
65
|
+
|
|
66
|
+
attr_reader :style, :children
|
|
67
|
+
|
|
68
|
+
def initialize(style, children)
|
|
69
|
+
raise ArgumentError, "unknown emphasis style: #{style.inspect}" unless STYLES.include?(style)
|
|
70
|
+
|
|
71
|
+
@style = style
|
|
72
|
+
@children = children
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_h
|
|
76
|
+
{"type" => style.to_s, "children" => children.map(&:to_h)}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
protected
|
|
80
|
+
|
|
81
|
+
def state
|
|
82
|
+
[style, children]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A note -- text wrapped in double brackets. Notes are normally omitted
|
|
87
|
+
# from formatted output.
|
|
88
|
+
class Note < Node
|
|
89
|
+
attr_reader :text
|
|
90
|
+
|
|
91
|
+
def initialize(text)
|
|
92
|
+
@text = text
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def to_h
|
|
96
|
+
{"type" => "note", "text" => text}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
protected
|
|
100
|
+
|
|
101
|
+
def state
|
|
102
|
+
[text]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A line break inside an element.
|
|
107
|
+
class LineBreak < Node
|
|
108
|
+
def to_h
|
|
109
|
+
{"type" => "line_break"}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Marker => { run length => emphasis style }.
|
|
114
|
+
EMPHASIS_MARKERS = {
|
|
115
|
+
"*" => {3 => :bold_italic, 2 => :bold, 1 => :italic}.freeze,
|
|
116
|
+
"_" => {1 => :underline}.freeze
|
|
117
|
+
}.freeze
|
|
118
|
+
|
|
119
|
+
HTML_ESCAPES = {
|
|
120
|
+
"&" => "&",
|
|
121
|
+
"<" => "<",
|
|
122
|
+
">" => ">",
|
|
123
|
+
'"' => """,
|
|
124
|
+
"'" => "'"
|
|
125
|
+
}.freeze
|
|
126
|
+
|
|
127
|
+
# Characters that a backslash may escape. A backslash before anything
|
|
128
|
+
# else is kept as literal text (as in Markdown).
|
|
129
|
+
ESCAPABLE = "\\*_[]<>#!~^=.\""
|
|
130
|
+
|
|
131
|
+
class << self
|
|
132
|
+
# Parse +text+ into a flat list of {Inline} nodes.
|
|
133
|
+
#
|
|
134
|
+
# @param text [String, nil]
|
|
135
|
+
# @param connected_blanks [Boolean] allow a note to span a blank line
|
|
136
|
+
# (used for element text, where a blank line can only come from a
|
|
137
|
+
# deliberate two-space dialogue line)
|
|
138
|
+
# @return [Array<Node>]
|
|
139
|
+
def parse(text, connected_blanks: false)
|
|
140
|
+
text = text.to_s
|
|
141
|
+
return [] if text.empty?
|
|
142
|
+
|
|
143
|
+
nodes = []
|
|
144
|
+
position = 0
|
|
145
|
+
|
|
146
|
+
while position < text.length
|
|
147
|
+
open = find_note_open(text, position)
|
|
148
|
+
|
|
149
|
+
if open.nil?
|
|
150
|
+
nodes.concat(parse_emphasis(text[position..]))
|
|
151
|
+
break
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
close = find_note_close(text, open + 2, connected_blanks: connected_blanks)
|
|
155
|
+
|
|
156
|
+
if close.nil?
|
|
157
|
+
# An unterminated note (or one containing a blank line) is not a
|
|
158
|
+
# note at all; leave it as literal text.
|
|
159
|
+
nodes.concat(parse_emphasis(text[position..]))
|
|
160
|
+
break
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
nodes.concat(parse_emphasis(text[position...open])) if open > position
|
|
164
|
+
nodes << Note.new(text[(open + 2)...close])
|
|
165
|
+
position = close + 2
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
nodes
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Convert inline markup to HTML.
|
|
172
|
+
#
|
|
173
|
+
# @param text [String, nil]
|
|
174
|
+
# @param notes [Boolean] render notes as spans (default: false)
|
|
175
|
+
# @param connected_blanks [Boolean] see {#parse}
|
|
176
|
+
# @return [String]
|
|
177
|
+
def to_html(text, notes: false, connected_blanks: false)
|
|
178
|
+
render(parse(text, connected_blanks: connected_blanks), notes: notes)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Strip inline markup from +text+.
|
|
182
|
+
#
|
|
183
|
+
# @param text [String, nil]
|
|
184
|
+
# @param notes [Boolean] keep note contents (default: false)
|
|
185
|
+
# @param connected_blanks [Boolean] see {#parse}
|
|
186
|
+
# @return [String]
|
|
187
|
+
def to_plain_text(text, notes: false, connected_blanks: false)
|
|
188
|
+
parse(text, connected_blanks: connected_blanks).map { |node| plain_node(node, notes) }.join
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Render an already parsed list of nodes.
|
|
192
|
+
def render(nodes, notes: false)
|
|
193
|
+
nodes.map { |node| render_node(node, notes) }.join
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Escape HTML special characters.
|
|
197
|
+
def escape_html(text)
|
|
198
|
+
text.to_s.gsub(/[&<>"']/, HTML_ESCAPES)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private
|
|
202
|
+
|
|
203
|
+
def parse_emphasis(chunk)
|
|
204
|
+
return [] if chunk.empty?
|
|
205
|
+
|
|
206
|
+
nodes = []
|
|
207
|
+
chunk.split("\n", -1).each_with_index do |line, index|
|
|
208
|
+
nodes << LineBreak.new if index.positive?
|
|
209
|
+
nodes.concat(parse_line(line))
|
|
210
|
+
end
|
|
211
|
+
nodes
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def parse_line(line)
|
|
215
|
+
nodes = []
|
|
216
|
+
buffer = +""
|
|
217
|
+
index = 0
|
|
218
|
+
length = line.length
|
|
219
|
+
|
|
220
|
+
while index < length
|
|
221
|
+
char = line[index]
|
|
222
|
+
|
|
223
|
+
if char == "\\"
|
|
224
|
+
following = line[index + 1]
|
|
225
|
+
if following && ESCAPABLE.include?(following)
|
|
226
|
+
buffer << following
|
|
227
|
+
index += 2
|
|
228
|
+
else
|
|
229
|
+
buffer << char
|
|
230
|
+
index += 1
|
|
231
|
+
end
|
|
232
|
+
elsif char == "*" || char == "_"
|
|
233
|
+
run = char_run(line, index, char)
|
|
234
|
+
style = EMPHASIS_MARKERS[char][run]
|
|
235
|
+
close = style && find_emphasis_close(line, index, char, run)
|
|
236
|
+
|
|
237
|
+
if close
|
|
238
|
+
unless buffer.empty?
|
|
239
|
+
nodes << Text.new(buffer)
|
|
240
|
+
buffer = +""
|
|
241
|
+
end
|
|
242
|
+
inner = line[(index + run)...close]
|
|
243
|
+
nodes << Emphasis.new(style, parse_line(inner))
|
|
244
|
+
index = close + run
|
|
245
|
+
else
|
|
246
|
+
buffer << char
|
|
247
|
+
index += 1
|
|
248
|
+
end
|
|
249
|
+
else
|
|
250
|
+
buffer << char
|
|
251
|
+
index += 1
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
nodes << Text.new(buffer) unless buffer.empty?
|
|
256
|
+
nodes
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Length of the run of +char+ starting at +start+.
|
|
260
|
+
def char_run(line, start, char)
|
|
261
|
+
run = 0
|
|
262
|
+
run += 1 while line[start + run] == char
|
|
263
|
+
run
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Find the index of a closing delimiter of exactly +run+ characters.
|
|
267
|
+
# Escaped characters are skipped and a closing delimiter may not be
|
|
268
|
+
# preceded by whitespace.
|
|
269
|
+
def find_emphasis_close(line, start, char, run)
|
|
270
|
+
content_start = start + run
|
|
271
|
+
return nil if content_start >= line.length
|
|
272
|
+
return nil if line[content_start].match?(/\s/)
|
|
273
|
+
|
|
274
|
+
index = content_start
|
|
275
|
+
|
|
276
|
+
while index < line.length
|
|
277
|
+
if line[index] == "\\"
|
|
278
|
+
index += 2
|
|
279
|
+
elsif line[index] == char
|
|
280
|
+
length = char_run(line, index, char)
|
|
281
|
+
if length == run && index.positive? && !line[index - 1].match?(/\s/)
|
|
282
|
+
return index
|
|
283
|
+
end
|
|
284
|
+
index += length
|
|
285
|
+
else
|
|
286
|
+
index += 1
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
nil
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Find an unescaped note opener (a "[[" with an even number of
|
|
294
|
+
# preceding backslashes).
|
|
295
|
+
def find_note_open(text, from)
|
|
296
|
+
index = from
|
|
297
|
+
|
|
298
|
+
while (open = text.index("[[", index))
|
|
299
|
+
backslashes = 0
|
|
300
|
+
cursor = open - 1
|
|
301
|
+
while cursor >= 0 && text[cursor] == "\\"
|
|
302
|
+
backslashes += 1
|
|
303
|
+
cursor -= 1
|
|
304
|
+
end
|
|
305
|
+
return open if backslashes.even?
|
|
306
|
+
|
|
307
|
+
index = open + 1
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
nil
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# The end of a note may not cross a blank line unless the caller says
|
|
314
|
+
# the blank line is a deliberate connected break ("Fountain won't look
|
|
315
|
+
# past a double line break").
|
|
316
|
+
def find_note_close(text, from, connected_blanks: false)
|
|
317
|
+
close = text.index("]]", from)
|
|
318
|
+
return nil unless close
|
|
319
|
+
return nil if !connected_blanks && text[from...close].match?(/\n[ \t]*\n/)
|
|
320
|
+
|
|
321
|
+
close
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def render_node(node, notes)
|
|
325
|
+
case node
|
|
326
|
+
when Text
|
|
327
|
+
escape_html(node.text)
|
|
328
|
+
when LineBreak
|
|
329
|
+
"<br>"
|
|
330
|
+
when Note
|
|
331
|
+
notes ? %(<span class="note">#{escape_html(node.text)}</span>) : ""
|
|
332
|
+
when Emphasis
|
|
333
|
+
inner = render(node.children, notes: notes)
|
|
334
|
+
case node.style
|
|
335
|
+
when :italic then "<em>#{inner}</em>"
|
|
336
|
+
when :bold then "<strong>#{inner}</strong>"
|
|
337
|
+
when :bold_italic then "<strong><em>#{inner}</em></strong>"
|
|
338
|
+
when :underline then "<u>#{inner}</u>"
|
|
339
|
+
end
|
|
340
|
+
else
|
|
341
|
+
""
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def plain_node(node, notes)
|
|
346
|
+
case node
|
|
347
|
+
when Text then node.text
|
|
348
|
+
when LineBreak then "\n"
|
|
349
|
+
when Note then notes ? node.text : ""
|
|
350
|
+
when Emphasis then node.children.map { |child| plain_node(child, notes) }.join
|
|
351
|
+
else ""
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FountainRB
|
|
4
|
+
# Parses Fountain source into a {Screenplay}.
|
|
5
|
+
#
|
|
6
|
+
# screenplay = FountainRB::Parser.new(source).parse
|
|
7
|
+
# screenplay.elements.each { |element| puts element.type }
|
|
8
|
+
#
|
|
9
|
+
# Parsing never raises on malformed markup: when in doubt, Fountain returns
|
|
10
|
+
# the text as Action. Unclosed emphasis, notes and boneyards are left as
|
|
11
|
+
# literal text.
|
|
12
|
+
class Parser
|
|
13
|
+
# @return [String] the normalised source being parsed
|
|
14
|
+
attr_reader :source
|
|
15
|
+
|
|
16
|
+
def initialize(source)
|
|
17
|
+
@source = normalise(source)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Screenplay]
|
|
21
|
+
def parse
|
|
22
|
+
lines = source.split("\n", -1)
|
|
23
|
+
title_page, body_start = split_title_page(lines)
|
|
24
|
+
|
|
25
|
+
body = (body_start < lines.size) ? lines[body_start..].join("\n") : ""
|
|
26
|
+
tokenizer = Tokenizer.new(body, line_offset: body_start)
|
|
27
|
+
|
|
28
|
+
Screenplay.new(
|
|
29
|
+
source: source,
|
|
30
|
+
title_page: title_page,
|
|
31
|
+
elements: build_elements(tokenizer.tokens),
|
|
32
|
+
boneyards: tokenizer.boneyards
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def normalise(source)
|
|
39
|
+
source.to_s.sub(/\A\uFEFF/, "").gsub(/\r\n?/, "\n")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def split_title_page(lines)
|
|
43
|
+
start = 0
|
|
44
|
+
start += 1 while start < lines.size && lines[start].strip.empty?
|
|
45
|
+
return [nil, 0] if start >= lines.size
|
|
46
|
+
|
|
47
|
+
parsed = TitlePage.parse(lines, start)
|
|
48
|
+
return [nil, 0] unless parsed
|
|
49
|
+
|
|
50
|
+
[parsed[0], parsed[1]]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def build_elements(tokens)
|
|
54
|
+
elements = []
|
|
55
|
+
pending_blanks = 0
|
|
56
|
+
can_merge = false
|
|
57
|
+
|
|
58
|
+
tokens.each do |token|
|
|
59
|
+
case token.type
|
|
60
|
+
when :blank
|
|
61
|
+
pending_blanks += 1
|
|
62
|
+
can_merge = false
|
|
63
|
+
when :hard_break
|
|
64
|
+
last = elements.last
|
|
65
|
+
last&.append_line("") if last&.multiline?
|
|
66
|
+
else
|
|
67
|
+
element = build_element(token, pending_blanks)
|
|
68
|
+
pending_blanks = 0
|
|
69
|
+
|
|
70
|
+
if can_merge && mergeable?(elements.last, element)
|
|
71
|
+
elements.last.merge!(element)
|
|
72
|
+
else
|
|
73
|
+
elements << element
|
|
74
|
+
can_merge = true
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
elements
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def mergeable?(last, element)
|
|
83
|
+
return false if last.nil?
|
|
84
|
+
|
|
85
|
+
case element
|
|
86
|
+
when Action then last.is_a?(Action)
|
|
87
|
+
when Dialogue then last.is_a?(Dialogue)
|
|
88
|
+
when Lyrics then last.is_a?(Lyrics)
|
|
89
|
+
else false
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build_element(token, blank_before)
|
|
94
|
+
base = {line: token.line, blank_before: blank_before}
|
|
95
|
+
|
|
96
|
+
case token.type
|
|
97
|
+
when :scene_heading
|
|
98
|
+
SceneHeading.new(
|
|
99
|
+
text: token.text,
|
|
100
|
+
scene_number: token[:scene_number],
|
|
101
|
+
forced: token.forced?,
|
|
102
|
+
**base
|
|
103
|
+
)
|
|
104
|
+
when :action
|
|
105
|
+
Action.new(text: token.text, forced: token.forced?, **base)
|
|
106
|
+
when :character
|
|
107
|
+
Character.new(
|
|
108
|
+
name: token[:name],
|
|
109
|
+
extensions: token[:extensions],
|
|
110
|
+
dual: token[:dual],
|
|
111
|
+
forced: token.forced?,
|
|
112
|
+
**base
|
|
113
|
+
)
|
|
114
|
+
when :parenthetical
|
|
115
|
+
Parenthetical.new(text: token.text, **base)
|
|
116
|
+
when :dialogue
|
|
117
|
+
Dialogue.new(text: token.text, **base)
|
|
118
|
+
when :lyrics
|
|
119
|
+
Lyrics.new(text: token.text, **base)
|
|
120
|
+
when :transition
|
|
121
|
+
Transition.new(text: token.text, forced: token.forced?, **base)
|
|
122
|
+
when :centered
|
|
123
|
+
Centered.new(text: token.text, **base)
|
|
124
|
+
when :page_break
|
|
125
|
+
PageBreak.new(text: token.text, **base)
|
|
126
|
+
when :section
|
|
127
|
+
Section.new(text: token.text, level: token[:level], **base)
|
|
128
|
+
when :synopsis
|
|
129
|
+
Synopsis.new(text: token.text, **base)
|
|
130
|
+
when :note
|
|
131
|
+
Note.new(text: token.text, **base)
|
|
132
|
+
else
|
|
133
|
+
Action.new(text: token.text, **base)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FountainRB
|
|
4
|
+
# Renders a {Screenplay} as plain text with screenplay-ish indentation.
|
|
5
|
+
#
|
|
6
|
+
# This is intentionally simple -- it is useful for quick inspection and for
|
|
7
|
+
# tests, but it is not a typesetter. Lines are not wrapped.
|
|
8
|
+
class PlainTextRenderer
|
|
9
|
+
# Left indentation for each element type.
|
|
10
|
+
INDENTS = {
|
|
11
|
+
character: 20,
|
|
12
|
+
parenthetical: 16,
|
|
13
|
+
dialogue: 8,
|
|
14
|
+
lyrics: 8,
|
|
15
|
+
transition: 0,
|
|
16
|
+
centered: 20
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# @param notes [Boolean] include notes (default: false)
|
|
20
|
+
# @param sections [Boolean] include sections (default: false)
|
|
21
|
+
# @param synopses [Boolean] include synopses (default: false)
|
|
22
|
+
# @param title_page [Boolean] include the title page (default: true)
|
|
23
|
+
# @param page_breaks [Boolean] include a form feed for page breaks
|
|
24
|
+
def initialize(notes: false, sections: false, synopses: false,
|
|
25
|
+
title_page: true, page_breaks: false)
|
|
26
|
+
@notes = notes
|
|
27
|
+
@sections = sections
|
|
28
|
+
@synopses = synopses
|
|
29
|
+
@title_page = title_page
|
|
30
|
+
@page_breaks = page_breaks
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param screenplay [Screenplay]
|
|
34
|
+
# @return [String]
|
|
35
|
+
def render(screenplay)
|
|
36
|
+
lines = []
|
|
37
|
+
|
|
38
|
+
if @title_page && screenplay.title_page
|
|
39
|
+
title_lines = render_title_page(screenplay.title_page)
|
|
40
|
+
lines.concat(title_lines) unless title_lines.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
previous = nil
|
|
44
|
+
screenplay.units.each do |unit|
|
|
45
|
+
rendered = render_unit(unit)
|
|
46
|
+
next if rendered.nil?
|
|
47
|
+
|
|
48
|
+
lines << "" unless lines.empty? || tight?(previous, unit)
|
|
49
|
+
lines.concat(rendered)
|
|
50
|
+
previous = unit
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
result = lines.join("\n").rstrip
|
|
54
|
+
result.empty? ? "" : "#{result}\n"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def render_unit(unit)
|
|
60
|
+
case unit
|
|
61
|
+
when DialogueBlock then render_dialogue_block(unit)
|
|
62
|
+
when DualDialogue then render_dual_dialogue(unit)
|
|
63
|
+
when SceneHeading then text_lines(unit.text)
|
|
64
|
+
when Action then text_lines(unit.text)
|
|
65
|
+
when Lyrics then indented(unit.text, INDENTS[:lyrics])
|
|
66
|
+
when Transition then [unit.text]
|
|
67
|
+
when Centered then indented(unit.text, INDENTS[:centered])
|
|
68
|
+
when PageBreak then @page_breaks ? ["\f"] : nil
|
|
69
|
+
when Section then @sections ? ["#{"#" * unit.level} #{unit.text}".rstrip] : nil
|
|
70
|
+
when Synopsis then @synopses ? ["= #{unit.text}"] : nil
|
|
71
|
+
when Note then @notes ? note_lines(unit.text) : nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def render_title_page(page)
|
|
76
|
+
lines = []
|
|
77
|
+
|
|
78
|
+
page.entries.each do |_key, values|
|
|
79
|
+
values.each { |value| lines << Inline.to_plain_text(value, notes: @notes) }
|
|
80
|
+
lines << ""
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
lines.pop if lines.last == ""
|
|
84
|
+
lines
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def render_dialogue_block(block)
|
|
88
|
+
character = block.character
|
|
89
|
+
cue = character.name
|
|
90
|
+
character.extensions.each { |extension| cue += " (#{extension})" }
|
|
91
|
+
|
|
92
|
+
lines = [indent(cue, INDENTS[:character])]
|
|
93
|
+
|
|
94
|
+
block.parts.each do |part|
|
|
95
|
+
case part
|
|
96
|
+
when Parenthetical
|
|
97
|
+
lines << indent(part.text, INDENTS[:parenthetical])
|
|
98
|
+
when Dialogue
|
|
99
|
+
lines.concat(indented(part.text, INDENTS[:dialogue]))
|
|
100
|
+
when Note
|
|
101
|
+
lines.concat(note_lines(part.text)) if @notes
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
lines
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def render_dual_dialogue(dual)
|
|
109
|
+
left = render_dialogue_block(dual.left)
|
|
110
|
+
right = render_dialogue_block(dual.right)
|
|
111
|
+
width = [left.map(&:length).max, right.map(&:length).max].max
|
|
112
|
+
left.map { |line| line.ljust(width) }
|
|
113
|
+
.zip(right)
|
|
114
|
+
.map { |l, r| "#{l} #{r}".rstrip }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def note_lines(text)
|
|
118
|
+
text.split("\n", -1).map { |line| "[[#{line}]]" }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def text_lines(text)
|
|
122
|
+
text.split("\n", -1).map { |line| Inline.to_plain_text(line, notes: @notes) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def indented(text, count)
|
|
126
|
+
text.split("\n", -1).map { |line| indent(Inline.to_plain_text(line, notes: @notes), count) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def indent(line, count)
|
|
130
|
+
line.empty? ? "" : (" " * count) + line
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def tight?(previous, unit)
|
|
134
|
+
previous.is_a?(DialogueBlock) && unit.is_a?(DialogueBlock) && unit.dual?
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|