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.
@@ -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