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,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FountainRB
|
|
4
|
+
# Base class for every screenplay element.
|
|
5
|
+
#
|
|
6
|
+
# Every element knows:
|
|
7
|
+
#
|
|
8
|
+
# * +text+ -- the element's content with markup removed from the
|
|
9
|
+
# element prefix (a leading ".", "!", "@", ">", "~",
|
|
10
|
+
# "#" or "=") but with inline emphasis left intact.
|
|
11
|
+
# * +line+ -- the 1-based line number in the source where the
|
|
12
|
+
# element begins.
|
|
13
|
+
# * +blank_before+ -- the number of blank source lines immediately before
|
|
14
|
+
# the element.
|
|
15
|
+
# * +forced?+ -- whether the writer used an explicit prefix to force
|
|
16
|
+
# the element type.
|
|
17
|
+
class Element
|
|
18
|
+
attr_reader :text, :line, :blank_before
|
|
19
|
+
|
|
20
|
+
def initialize(text: "", line: nil, blank_before: 0, forced: false)
|
|
21
|
+
@text = text.to_s
|
|
22
|
+
@line = line
|
|
23
|
+
@blank_before = blank_before
|
|
24
|
+
@forced = forced
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Symbol identifying the element type. Subclasses override.
|
|
28
|
+
def type
|
|
29
|
+
:element
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def forced?
|
|
33
|
+
@forced
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# True for elements that may contain several source lines (and therefore
|
|
37
|
+
# may be merged or extended by the parser).
|
|
38
|
+
def multiline?
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Append a line of text. Used for line breaks inside multi-line elements.
|
|
43
|
+
def append_line(line)
|
|
44
|
+
@text = "#{@text}\n#{line}"
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Merge another element of the same type into this one.
|
|
49
|
+
def merge!(other)
|
|
50
|
+
append_line(other.text)
|
|
51
|
+
@forced ||= other.forced?
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Inline notes contained in this element's text.
|
|
56
|
+
#
|
|
57
|
+
# @return [Array<String>]
|
|
58
|
+
def notes
|
|
59
|
+
Inline.parse(text, connected_blanks: true)
|
|
60
|
+
.select { |node| node.is_a?(Inline::Note) }
|
|
61
|
+
.map(&:text)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Hash] a JSON-friendly representation
|
|
65
|
+
def to_h
|
|
66
|
+
hash = {"type" => type.to_s, "text" => text}
|
|
67
|
+
hash["line"] = line if line
|
|
68
|
+
hash["blank_before"] = blank_before if blank_before.to_i.positive?
|
|
69
|
+
hash["forced"] = true if forced?
|
|
70
|
+
hash
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def ==(other)
|
|
74
|
+
other.class == self.class && other.to_h == to_h
|
|
75
|
+
end
|
|
76
|
+
alias_method :eql?, :==
|
|
77
|
+
|
|
78
|
+
def hash
|
|
79
|
+
to_h.hash
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def inspect
|
|
83
|
+
attributes = to_h.slice(*(to_h.keys - ["type"]))
|
|
84
|
+
"#<#{self.class.name.split("::").last} #{attributes.inspect}>"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A scene heading, e.g. "INT. BRICK'S PATIO - DAY" or ".OPENING TITLES".
|
|
89
|
+
class SceneHeading < Element
|
|
90
|
+
attr_reader :scene_number
|
|
91
|
+
|
|
92
|
+
def initialize(scene_number: nil, **kwargs)
|
|
93
|
+
super(**kwargs)
|
|
94
|
+
@scene_number = scene_number
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def type
|
|
98
|
+
:scene_heading
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def to_h
|
|
102
|
+
hash = super
|
|
103
|
+
hash["scene_number"] = scene_number if scene_number
|
|
104
|
+
hash
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Scene description. May contain several lines and intentional whitespace.
|
|
109
|
+
class Action < Element
|
|
110
|
+
def type
|
|
111
|
+
:action
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def multiline?
|
|
115
|
+
true
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The action broken into its individual lines.
|
|
119
|
+
def lines
|
|
120
|
+
text.split("\n", -1)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A character cue, e.g. "STEEL", "MOM (O. S.)" or "@McCLANE".
|
|
125
|
+
class Character < Element
|
|
126
|
+
attr_reader :name, :extensions
|
|
127
|
+
|
|
128
|
+
def initialize(name:, extensions: [], dual: false, **kwargs)
|
|
129
|
+
@name = name.to_s
|
|
130
|
+
@extensions = Array(extensions).map(&:to_s)
|
|
131
|
+
@dual = dual
|
|
132
|
+
super(text: build_text, **kwargs)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def type
|
|
136
|
+
:character
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# True when the cue ends with a caret, meaning this block is simultaneous
|
|
140
|
+
# with the preceding dialogue block (dual dialogue).
|
|
141
|
+
def dual?
|
|
142
|
+
@dual
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def extension
|
|
146
|
+
extensions.join(" ")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def to_h
|
|
150
|
+
hash = super
|
|
151
|
+
hash["name"] = name
|
|
152
|
+
hash["extensions"] = extensions unless extensions.empty?
|
|
153
|
+
hash["dual"] = true if dual?
|
|
154
|
+
hash
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def build_text
|
|
160
|
+
[name, *extensions.map { |extension| "(#{extension})" }].join(" ")
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# A line of dialogue.
|
|
165
|
+
class Dialogue < Element
|
|
166
|
+
def type
|
|
167
|
+
:dialogue
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def multiline?
|
|
171
|
+
true
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def lines
|
|
175
|
+
text.split("\n", -1)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# A parenthetical direction, e.g. "(starting the engine)".
|
|
180
|
+
class Parenthetical < Element
|
|
181
|
+
def type
|
|
182
|
+
:parenthetical
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The text without the surrounding parentheses.
|
|
186
|
+
def content
|
|
187
|
+
if text.start_with?("(") && text.end_with?(")") && text.length >= 2
|
|
188
|
+
text[1...-1]
|
|
189
|
+
else
|
|
190
|
+
text
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def to_h
|
|
195
|
+
hash = super
|
|
196
|
+
hash["content"] = content
|
|
197
|
+
hash
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# A lyric line, introduced by "~".
|
|
202
|
+
class Lyrics < Element
|
|
203
|
+
def type
|
|
204
|
+
:lyrics
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def multiline?
|
|
208
|
+
true
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# A transition, e.g. "CUT TO:" or ">Burn to White.".
|
|
213
|
+
class Transition < Element
|
|
214
|
+
def type
|
|
215
|
+
:transition
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Centered text, e.g. "> THE END <". Fountain treats this as a special
|
|
220
|
+
# form of Action.
|
|
221
|
+
class Centered < Element
|
|
222
|
+
def type
|
|
223
|
+
:centered
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# A page break: a line of three or more equals signs.
|
|
228
|
+
class PageBreak < Element
|
|
229
|
+
def type
|
|
230
|
+
:page_break
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# An outline section, e.g. "# Act One" or "## Sequence".
|
|
235
|
+
class Section < Element
|
|
236
|
+
attr_reader :level
|
|
237
|
+
|
|
238
|
+
def initialize(level:, **kwargs)
|
|
239
|
+
super(**kwargs)
|
|
240
|
+
@level = level
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def type
|
|
244
|
+
:section
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def to_h
|
|
248
|
+
hash = super
|
|
249
|
+
hash["level"] = level
|
|
250
|
+
hash
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# A single-line synopsis, e.g. "= Set up the characters.".
|
|
255
|
+
class Synopsis < Element
|
|
256
|
+
def type
|
|
257
|
+
:synopsis
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# A note on its own line.
|
|
262
|
+
class Note < Element
|
|
263
|
+
def type
|
|
264
|
+
:note
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def notes
|
|
268
|
+
[text]
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FountainRB
|
|
4
|
+
# Renders a {Screenplay} as HTML.
|
|
5
|
+
#
|
|
6
|
+
# The output uses semantic class names rather than inline styles so that the
|
|
7
|
+
# caller can supply whatever screenplay stylesheet they like. Sections,
|
|
8
|
+
# synopses and notes are omitted by default because Fountain ignores them in
|
|
9
|
+
# formatted output, but they can be enabled for editing/debug views.
|
|
10
|
+
#
|
|
11
|
+
# puts screenplay.to_html(notes: true, sections: true)
|
|
12
|
+
class HTMLRenderer
|
|
13
|
+
# @param notes [Boolean] render notes (default: false)
|
|
14
|
+
# @param sections [Boolean] render sections (default: false)
|
|
15
|
+
# @param synopses [Boolean] render synopses (default: false)
|
|
16
|
+
# @param title_page [Boolean] render the title page (default: true)
|
|
17
|
+
# @param page_breaks [Boolean] render page breaks (default: true)
|
|
18
|
+
def initialize(notes: false, sections: false, synopses: false,
|
|
19
|
+
title_page: true, page_breaks: true)
|
|
20
|
+
@notes = notes
|
|
21
|
+
@sections = sections
|
|
22
|
+
@synopses = synopses
|
|
23
|
+
@title_page = title_page
|
|
24
|
+
@page_breaks = page_breaks
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param screenplay [Screenplay]
|
|
28
|
+
# @return [String]
|
|
29
|
+
def render(screenplay)
|
|
30
|
+
parts = []
|
|
31
|
+
|
|
32
|
+
if @title_page && screenplay.title_page
|
|
33
|
+
parts << render_title_page(screenplay.title_page)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
screenplay.units.each do |unit|
|
|
37
|
+
html = render_unit(unit)
|
|
38
|
+
parts << html unless html.nil? || html.empty?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
%(<div class="fountain">\n#{parts.join("\n")}\n</div>)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def html(text)
|
|
47
|
+
Inline.to_html(text, notes: @notes, connected_blanks: true)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def escape(text)
|
|
51
|
+
Inline.escape_html(text)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def render_unit(unit)
|
|
55
|
+
case unit
|
|
56
|
+
when DialogueBlock then render_dialogue_block(unit)
|
|
57
|
+
when DualDialogue then render_dual_dialogue(unit)
|
|
58
|
+
when SceneHeading then render_scene_heading(unit)
|
|
59
|
+
when Action then render_action(unit)
|
|
60
|
+
when Lyrics then render_lyrics(unit)
|
|
61
|
+
when Transition then render_transition(unit)
|
|
62
|
+
when Centered then render_centered(unit)
|
|
63
|
+
when PageBreak then @page_breaks ? render_page_break : nil
|
|
64
|
+
when Section then @sections ? render_section(unit) : nil
|
|
65
|
+
when Synopsis then @synopses ? render_synopsis(unit) : nil
|
|
66
|
+
when Note then @notes ? render_note(unit) : nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render_title_page(page)
|
|
71
|
+
lines = ['<section class="title-page">']
|
|
72
|
+
|
|
73
|
+
page.entries.each do |key, values|
|
|
74
|
+
class_name = key.gsub(/[^a-z0-9]+/, "-")
|
|
75
|
+
lines << %( <div class="#{class_name}">)
|
|
76
|
+
values.each { |value| lines << %( <p>#{html(value)}</p>) }
|
|
77
|
+
lines << " </div>"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
lines << "</section>"
|
|
81
|
+
lines.join("\n")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def render_scene_heading(element)
|
|
85
|
+
attributes = +%(class="scene-heading")
|
|
86
|
+
if element.scene_number
|
|
87
|
+
attributes << %( data-scene-number="#{escape(element.scene_number)}")
|
|
88
|
+
end
|
|
89
|
+
%(<h2 #{attributes}>#{html(element.text)}</h2>)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def render_action(element)
|
|
93
|
+
%(<p class="action">#{html(element.text)}</p>)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def render_lyrics(element)
|
|
97
|
+
%(<p class="lyrics">#{html(element.text)}</p>)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def render_transition(element)
|
|
101
|
+
%(<p class="transition">#{html(element.text)}</p>)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def render_centered(element)
|
|
105
|
+
%(<p class="centered">#{html(element.text)}</p>)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def render_page_break
|
|
109
|
+
%(<div class="page-break"></div>)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def render_section(element)
|
|
113
|
+
%(<div class="section" data-level="#{element.level}">#{html(element.text)}</div>)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def render_synopsis(element)
|
|
117
|
+
%(<div class="synopsis">#{html(element.text)}</div>)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def render_note(element)
|
|
121
|
+
%(<div class="note">#{html(element.text)}</div>)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def render_dialogue_block(block)
|
|
125
|
+
lines = ['<div class="dialogue-block">']
|
|
126
|
+
lines << %( <p class="character">#{render_character(block.character)}</p>)
|
|
127
|
+
|
|
128
|
+
block.parts.each do |part|
|
|
129
|
+
case part
|
|
130
|
+
when Parenthetical
|
|
131
|
+
lines << %( <p class="parenthetical">#{html(part.text)}</p>)
|
|
132
|
+
when Dialogue
|
|
133
|
+
lines << %( <p class="dialogue">#{html(part.text)}</p>)
|
|
134
|
+
when Note
|
|
135
|
+
lines << %( <p class="note">#{html(part.text)}</p>) if @notes
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
lines << "</div>"
|
|
140
|
+
lines.join("\n")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def render_dual_dialogue(dual)
|
|
144
|
+
lines = ['<div class="dual-dialogue">']
|
|
145
|
+
lines << render_dialogue_block(dual.left)
|
|
146
|
+
lines << render_dialogue_block(dual.right)
|
|
147
|
+
lines << "</div>"
|
|
148
|
+
lines.join("\n")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def render_character(character)
|
|
152
|
+
parts = [%(<span class="character-name">#{html(character.name)}</span>)]
|
|
153
|
+
character.extensions.each do |extension|
|
|
154
|
+
parts << %( <span class="extension">(#{html(extension)})</span>)
|
|
155
|
+
end
|
|
156
|
+
parts.join
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|