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,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
+ "&" => "&amp;",
121
+ "<" => "&lt;",
122
+ ">" => "&gt;",
123
+ '"' => "&quot;",
124
+ "'" => "&#39;"
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