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,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FountainRB
|
|
4
|
+
# A character cue together with the parentheticals, dialogue and notes that
|
|
5
|
+
# belong to it.
|
|
6
|
+
class DialogueBlock
|
|
7
|
+
attr_reader :character, :parts
|
|
8
|
+
|
|
9
|
+
def initialize(character, parts = [])
|
|
10
|
+
@character = character
|
|
11
|
+
@parts = parts
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def dual?
|
|
15
|
+
character.dual?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def dialogue
|
|
19
|
+
parts.select { |part| part.is_a?(Dialogue) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def parentheticals
|
|
23
|
+
parts.select { |part| part.is_a?(Parenthetical) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def notes
|
|
27
|
+
character.notes + parts.flat_map(&:notes)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# All spoken text in the block, separated by newlines.
|
|
31
|
+
def text
|
|
32
|
+
dialogue.map(&:text).join("\n")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_h
|
|
36
|
+
{
|
|
37
|
+
"type" => "dialogue_block",
|
|
38
|
+
"character" => character.to_h,
|
|
39
|
+
"parts" => parts.map(&:to_h)
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Two dialogue blocks that are spoken simultaneously.
|
|
45
|
+
class DualDialogue
|
|
46
|
+
attr_reader :left, :right
|
|
47
|
+
|
|
48
|
+
def initialize(left, right)
|
|
49
|
+
@left = left
|
|
50
|
+
@right = right
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def blocks
|
|
54
|
+
[left, right]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def to_h
|
|
58
|
+
{
|
|
59
|
+
"type" => "dual_dialogue",
|
|
60
|
+
"left" => left.to_h,
|
|
61
|
+
"right" => right.to_h
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The result of parsing a Fountain document.
|
|
67
|
+
#
|
|
68
|
+
# A screenplay enumerates its elements (in source order) and provides
|
|
69
|
+
# convenience accessors plus renderers.
|
|
70
|
+
#
|
|
71
|
+
# screenplay = FountainRB.parse(File.read("brick_and_steel.fountain"))
|
|
72
|
+
# screenplay.title_page.title #=> ["BRICK & STEEL", "FULL RETIRED"]
|
|
73
|
+
# screenplay.scenes.first.text #=> "EXT. BRICK'S PATIO - DAY"
|
|
74
|
+
# screenplay.character_names #=> ["STEEL", "BRICK", ...]
|
|
75
|
+
# puts screenplay.to_html
|
|
76
|
+
class Screenplay
|
|
77
|
+
include Enumerable
|
|
78
|
+
|
|
79
|
+
attr_reader :source, :title_page, :elements, :boneyards
|
|
80
|
+
|
|
81
|
+
def initialize(source: "", title_page: nil, elements: [], boneyards: [])
|
|
82
|
+
@source = source
|
|
83
|
+
@title_page = title_page
|
|
84
|
+
@elements = elements
|
|
85
|
+
@boneyards = boneyards
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def each(&block)
|
|
89
|
+
elements.each(&block)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def [](index)
|
|
93
|
+
elements[index]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def size
|
|
97
|
+
elements.size
|
|
98
|
+
end
|
|
99
|
+
alias_method :length, :size
|
|
100
|
+
|
|
101
|
+
def empty?
|
|
102
|
+
elements.empty?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def first
|
|
106
|
+
elements.first
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def last
|
|
110
|
+
elements.last
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Elements of a given type symbol, e.g. +:dialogue+.
|
|
114
|
+
def of_type(type)
|
|
115
|
+
elements.select { |element| element.type == type }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def scenes
|
|
119
|
+
elements.select { |element| element.is_a?(SceneHeading) }
|
|
120
|
+
end
|
|
121
|
+
alias_method :scene_headings, :scenes
|
|
122
|
+
|
|
123
|
+
def action
|
|
124
|
+
elements.select { |element| element.is_a?(Action) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def characters
|
|
128
|
+
elements.select { |element| element.is_a?(Character) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def dialogues
|
|
132
|
+
elements.select { |element| element.is_a?(Dialogue) }
|
|
133
|
+
end
|
|
134
|
+
alias_method :dialogue, :dialogues
|
|
135
|
+
|
|
136
|
+
def parentheticals
|
|
137
|
+
elements.select { |element| element.is_a?(Parenthetical) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def lyrics
|
|
141
|
+
elements.select { |element| element.is_a?(Lyrics) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def transitions
|
|
145
|
+
elements.select { |element| element.is_a?(Transition) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def centered
|
|
149
|
+
elements.select { |element| element.is_a?(Centered) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def page_breaks
|
|
153
|
+
elements.select { |element| element.is_a?(PageBreak) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def sections
|
|
157
|
+
elements.select { |element| element.is_a?(Section) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def synopses
|
|
161
|
+
elements.select { |element| element.is_a?(Synopsis) }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Every note, both standalone and inline.
|
|
165
|
+
#
|
|
166
|
+
# @return [Array<String>]
|
|
167
|
+
def notes
|
|
168
|
+
elements.flat_map(&:notes)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Unique character names in order of first appearance.
|
|
172
|
+
def character_names
|
|
173
|
+
characters.map(&:name).uniq
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Dialogue blocks in source order. Dual-dialogue blocks are returned
|
|
177
|
+
# individually (use {#units} to get the paired form).
|
|
178
|
+
def dialogue_blocks
|
|
179
|
+
units.flat_map do |unit|
|
|
180
|
+
case unit
|
|
181
|
+
when DualDialogue then unit.blocks
|
|
182
|
+
when DialogueBlock then [unit]
|
|
183
|
+
else []
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Elements grouped for rendering: plain elements, dialogue blocks and
|
|
189
|
+
# dual-dialogue pairs.
|
|
190
|
+
def units
|
|
191
|
+
@units ||= build_units
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# @return [Hash] a JSON-friendly representation of the whole screenplay
|
|
195
|
+
def to_h
|
|
196
|
+
hash = {
|
|
197
|
+
"title_page" => title_page&.to_h,
|
|
198
|
+
"elements" => elements.map(&:to_h)
|
|
199
|
+
}
|
|
200
|
+
hash["boneyards"] = boneyards.dup unless boneyards.empty?
|
|
201
|
+
hash
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def to_json(*args)
|
|
205
|
+
require "json"
|
|
206
|
+
to_h.to_json(*args)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Validation issues for the screenplay's source.
|
|
210
|
+
#
|
|
211
|
+
# @see Validator#validate
|
|
212
|
+
# @return [Array<Issue>]
|
|
213
|
+
def issues
|
|
214
|
+
Validator.new(source, screenplay: self).validate
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# True when the screenplay's source has no validation issues.
|
|
218
|
+
#
|
|
219
|
+
# @return [Boolean]
|
|
220
|
+
def valid?
|
|
221
|
+
issues.empty?
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Render the screenplay as HTML.
|
|
225
|
+
#
|
|
226
|
+
# @see HTMLRenderer#initialize
|
|
227
|
+
def to_html(**options)
|
|
228
|
+
HTMLRenderer.new(**options).render(self)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Render the screenplay as plain text.
|
|
232
|
+
#
|
|
233
|
+
# @see PlainTextRenderer#initialize
|
|
234
|
+
def to_plain_text(**options)
|
|
235
|
+
PlainTextRenderer.new(**options).render(self)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def ==(other)
|
|
239
|
+
other.is_a?(Screenplay) &&
|
|
240
|
+
other.title_page == title_page &&
|
|
241
|
+
other.elements == elements
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def inspect
|
|
245
|
+
"#<FountainRB::Screenplay elements=#{elements.size} title_page=#{!title_page.nil?}>"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private
|
|
249
|
+
|
|
250
|
+
def build_units
|
|
251
|
+
result = []
|
|
252
|
+
index = 0
|
|
253
|
+
|
|
254
|
+
while index < elements.size
|
|
255
|
+
element = elements[index]
|
|
256
|
+
|
|
257
|
+
unless element.is_a?(Character)
|
|
258
|
+
result << element
|
|
259
|
+
index += 1
|
|
260
|
+
next
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
parts = []
|
|
264
|
+
index += 1
|
|
265
|
+
while index < elements.size && dialogue_part?(elements[index])
|
|
266
|
+
parts << elements[index]
|
|
267
|
+
index += 1
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
block = DialogueBlock.new(element, parts)
|
|
271
|
+
result << if element.dual? && result.last.is_a?(DialogueBlock)
|
|
272
|
+
DualDialogue.new(result.pop, block)
|
|
273
|
+
else
|
|
274
|
+
block
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
result
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def dialogue_part?(element)
|
|
282
|
+
element.is_a?(Parenthetical) || element.is_a?(Dialogue) || element.is_a?(Note)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FountainRB
|
|
4
|
+
# The optional title page at the start of a Fountain document.
|
|
5
|
+
#
|
|
6
|
+
# Title page data is written as +key: value+ pairs. Values may be inline or
|
|
7
|
+
# indented on following lines (three or more spaces, or a tab). A single key
|
|
8
|
+
# may have several values (for example a two-line title or a mailing
|
|
9
|
+
# address).
|
|
10
|
+
#
|
|
11
|
+
# Title:
|
|
12
|
+
# BRICK & STEEL
|
|
13
|
+
# FULL RETIRED
|
|
14
|
+
# Credit: Written by
|
|
15
|
+
# Author: Stu Maschwitz
|
|
16
|
+
# Contact:
|
|
17
|
+
# Next Level Productions
|
|
18
|
+
# Solvang, CA 93463
|
|
19
|
+
class TitlePage
|
|
20
|
+
include Enumerable
|
|
21
|
+
|
|
22
|
+
# Keys the Fountain spec recommends. Unknown keys are still kept.
|
|
23
|
+
KNOWN_KEYS = [
|
|
24
|
+
"title",
|
|
25
|
+
"credit",
|
|
26
|
+
"author",
|
|
27
|
+
"authors",
|
|
28
|
+
"source",
|
|
29
|
+
"draft date",
|
|
30
|
+
"contact",
|
|
31
|
+
"copyright",
|
|
32
|
+
"notes",
|
|
33
|
+
"date",
|
|
34
|
+
"revision"
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
# A key starts with a letter, may contain spaces, and ends with a colon.
|
|
38
|
+
KEY_PATTERN = /\A([A-Za-z][A-Za-z0-9 ._\/'&()\-]*?):[ \t]*(.*)\z/
|
|
39
|
+
|
|
40
|
+
# Continuation values are indented by three or more spaces or a tab.
|
|
41
|
+
INDENTED_PATTERN = /\A(?:[ \t]{3,}|\t)/
|
|
42
|
+
|
|
43
|
+
# @return [Hash{String => Array<String>}] normalised key => values
|
|
44
|
+
attr_reader :entries
|
|
45
|
+
|
|
46
|
+
def initialize
|
|
47
|
+
@entries = {}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Detect a title page starting at +start+ in +lines+.
|
|
51
|
+
#
|
|
52
|
+
# A title page is only recognised when the first line looks like a key and
|
|
53
|
+
# either the key is a known title-page key or a second key line follows
|
|
54
|
+
# immediately. This keeps scripts that happen to begin with "CUT TO:"
|
|
55
|
+
# from being mistaken for a title page.
|
|
56
|
+
def self.detect?(lines, start = 0)
|
|
57
|
+
line = lines[start]
|
|
58
|
+
return false unless line
|
|
59
|
+
|
|
60
|
+
match = KEY_PATTERN.match(line.lstrip)
|
|
61
|
+
return false unless match
|
|
62
|
+
|
|
63
|
+
key = normalise_key(match[1])
|
|
64
|
+
following = lines[start + 1]
|
|
65
|
+
|
|
66
|
+
KNOWN_KEYS.include?(key) || (following && KEY_PATTERN.match?(following.lstrip))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Parse the title page starting at +start+.
|
|
70
|
+
#
|
|
71
|
+
# @return [Array(TitlePage, Integer), nil] the page and the index of the
|
|
72
|
+
# first line after it, or nil when no title page is present.
|
|
73
|
+
def self.parse(lines, start = 0)
|
|
74
|
+
return nil unless detect?(lines, start)
|
|
75
|
+
|
|
76
|
+
page = new
|
|
77
|
+
key = nil
|
|
78
|
+
index = start
|
|
79
|
+
|
|
80
|
+
while index < lines.size
|
|
81
|
+
line = lines[index]
|
|
82
|
+
break if line.strip.empty?
|
|
83
|
+
|
|
84
|
+
if key && line.match?(INDENTED_PATTERN)
|
|
85
|
+
page.add(key, line.strip)
|
|
86
|
+
elsif (match = KEY_PATTERN.match(line.lstrip))
|
|
87
|
+
key = normalise_key(match[1])
|
|
88
|
+
value = match[2].to_s.strip
|
|
89
|
+
page.add(key, value) unless value.empty?
|
|
90
|
+
else
|
|
91
|
+
break
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
index += 1
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
[page, index]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.normalise_key(key)
|
|
101
|
+
key.to_s.strip.downcase.gsub(/\s+/, " ")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Add +value+ under +key+.
|
|
105
|
+
def add(key, value)
|
|
106
|
+
key = self.class.normalise_key(key)
|
|
107
|
+
(@entries[key] ||= []) << value.to_s
|
|
108
|
+
self
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Values for a key (case-insensitive).
|
|
112
|
+
#
|
|
113
|
+
# @return [Array<String>]
|
|
114
|
+
def [](key)
|
|
115
|
+
(@entries[self.class.normalise_key(key)] || []).dup
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# First value for a key, or nil.
|
|
119
|
+
def fetch(key)
|
|
120
|
+
self[key].first
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def key?(key)
|
|
124
|
+
@entries.key?(self.class.normalise_key(key))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def keys
|
|
128
|
+
@entries.keys
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def each(&block)
|
|
132
|
+
@entries.each(&block)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def empty?
|
|
136
|
+
@entries.empty?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Convenience readers. Each returns an array of values (a title may span
|
|
140
|
+
# several lines).
|
|
141
|
+
def title
|
|
142
|
+
self["title"]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def credit
|
|
146
|
+
self["credit"]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Values for "Author" and "Authors", in that order.
|
|
150
|
+
def authors
|
|
151
|
+
self["author"] + self["authors"]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def source
|
|
155
|
+
self["source"]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def draft_date
|
|
159
|
+
self["draft date"] + self["date"]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def contact
|
|
163
|
+
self["contact"]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def copyright
|
|
167
|
+
self["copyright"]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def notes
|
|
171
|
+
self["notes"]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def to_h
|
|
175
|
+
@entries.each_with_object({}) { |(key, values), hash| hash[key] = values.dup }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def ==(other)
|
|
179
|
+
other.is_a?(TitlePage) && other.to_h == to_h
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def inspect
|
|
183
|
+
"#<FountainRB::TitlePage #{to_h.inspect}>"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|