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,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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FountainRB
4
+ # Base class for every error raised by fountainrb.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when source text cannot be read or parsed.
8
+ class ParseError < Error; end
9
+ 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