eleanor 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,511 @@
1
+ # Copyright (c) 2008 chiisaitsu <chiisaitsu@gmail.com>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ require 'eleanor/length'
22
+ require 'eleanor/parser'
23
+ require 'yaml'
24
+
25
+ module Eleanor
26
+
27
+ # Program name
28
+ NAME= 'Eleanor'
29
+
30
+ # Program version
31
+ VERSION= '1.0.0'
32
+
33
+ # Loads a YAML config file from +filename+. Must be called before anything
34
+ # else is done. This method creates class and instance methods corresponding
35
+ # to the options defined in the YAML. See "Configuration" in the README.
36
+ def self.load_config filename
37
+ config= YAML.load_file(filename)
38
+ Length.line_height= config['Screenplay']['line_height_points']
39
+ config.each_pair do |class_name, hash|
40
+ klass= const_get(class_name)
41
+ hash.each_pair do |att_name, att_val|
42
+ # define a class method that just returns the value of the trait
43
+ (class << klass; self; end).instance_eval do
44
+ define_method(att_name) { att_val }
45
+ end
46
+ # define an instance method that attempts to eval the trait if it's a
47
+ # string and just returns its value otherwise. the method may be passed
48
+ # a hash; each key in the hash is made avaiable to the eval'ed code as a
49
+ # function that returns the key's value.
50
+ klass.class_eval do
51
+ define_method(att_name) do |*args|
52
+ args= args[0] || {}
53
+ if att_val.is_a? String
54
+ closure= self.dup
55
+ (class << closure; self; end).instance_eval do
56
+ args.each_pair do |arg_name, arg_val|
57
+ define_method(arg_name) { arg_val }
58
+ end
59
+ end
60
+ closure.instance_eval do
61
+ begin
62
+ eval(att_val)
63
+ rescue StandardError, SyntaxError
64
+ att_val
65
+ end
66
+ end
67
+ else
68
+ att_val
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ # Returns a new Screenplay created by parsing the plain text screenplay
77
+ # at +filename+. If parsing fails, returns +nil+.
78
+ def self.parse filename
79
+ screenplay= Screenplay.new
80
+ screenplay.parse!(filename) and screenplay or nil
81
+ end
82
+
83
+
84
+
85
+ # Base Paragraph class from which all paragraphs inherit.
86
+ #
87
+ # Note that Eleanor.load_config dynamically adds both class and instance
88
+ # methods to Paragraph and its subclasses depending on the options present in
89
+ # the user's configuration file. See "Configuration" in the README.
90
+ class Paragraph
91
+
92
+ # Set by pagination
93
+ attr_accessor :is_first_on_page
94
+
95
+ # See initialize
96
+ attr_reader :input_line, :last_character_cue, :screenplay, :split_state
97
+
98
+ # Array of lines in the paragraph
99
+ attr_reader :lines
100
+
101
+ # The height (a Length) of the paragraph given its screenplay's line height
102
+ # and the number of lines in the paragraph.
103
+ def height
104
+ @screenplay.line_height * @lines.size
105
+ end
106
+
107
+ # A paragraph must be attached to a +screenplay+. +input_line+ is the line
108
+ # of raw text used to build the paragraph. +last_character_cue+ should be
109
+ # a reference to the last CharacterCue paragraph seen in the input and is
110
+ # used to keep track of Dialog continuations. +split_state+ should be one
111
+ # of :whole, :orphan, or :widow and indicates if the paragraph is intact on
112
+ # one page, split across pages and at the bottom of the first page, or split
113
+ # across pages and at the top of the second page.
114
+ def initialize screenplay, input_line, last_character_cue, split_state=:whole
115
+ @screenplay= screenplay
116
+ @last_character_cue= last_character_cue
117
+ @split_state= split_state
118
+ @is_first_on_page= false
119
+ @input_line= (self.respond_to?(:text) ? self.text : input_line)
120
+ @fixed_line= fix_input_line
121
+ @sentences= @fixed_line.split(/ {2,}/)
122
+ break_lines!
123
+ end
124
+
125
+ # Returns true if the paragraph should be kept on the same page as +para+.
126
+ def keep_with_next? para
127
+ if self.class.keep_with_nexts.is_a? Array
128
+ self.class.keep_with_nexts.include? para.class.name[/[^:]+$/]
129
+ else
130
+ self.keep_with_nexts(:next_para => para)
131
+ end
132
+ end
133
+
134
+ # Returns the Length between the paragraph and +para+.
135
+ def margin_between para
136
+ [self.margin_bottom(:next_para => para),
137
+ para.margin_top(:prev_para => self)].max
138
+ end
139
+
140
+ # Splits the paragraph across a page break, respecting orphan and widow
141
+ # sentences and lines. Returns [orphan, widow], where orphan is a new
142
+ # paragraph suitable for the bottom of the existing page, and widow is a
143
+ # new paragraph suitable for the top of a new page. orphan's height will
144
+ # be no more than +max_orphan_height+ (a Length). If such constraints cannot
145
+ # be satisfied, the paragraph cannot be broken, and [+nil+, +self+] is
146
+ # returned.
147
+ def split max_orphan_height
148
+ if !self.can_break_across_pages ||
149
+ @sentences.size < (self.min_orphan_sentences_allowed +
150
+ self.min_widow_sentences_allowed)
151
+ return [nil, self]
152
+ end
153
+ # [[orphan_para, widow_para] | orphan_para has
154
+ # self.class.min_orphan_sentences_allowed sentences, ... ]
155
+ # assume self.class.min_orphan_sentences_allowed >= 1
156
+ # assume self.class.min_widow_sentences_allowed >= 1
157
+ lo= self.min_orphan_sentences_allowed - 1
158
+ hi= @sentences.size - self.min_widow_sentences_allowed - 1
159
+ (lo..hi).inject([]) do |paras, i|
160
+ orphan= slice(0..i, :orphan)
161
+ break paras if orphan && orphan.height > max_orphan_height
162
+ widow= slice((i + 1)..-1, :widow)
163
+ paras << [orphan, widow]
164
+ end.reverse_each do |paras|
165
+ if paras[0].lines.size >= self.min_orphan_lines_allowed &&
166
+ paras[1].lines.size >= self.min_widow_lines_allowed
167
+ return paras
168
+ end
169
+ end
170
+ [nil, self]
171
+ end
172
+
173
+ def to_s
174
+ "[#{self.class.name[/[^:]+$/]}] #{@input_line}"
175
+ end
176
+
177
+ private
178
+
179
+ # Breaks the fixed input line into an array of lines according to the width
180
+ # of the paragraph.
181
+ def break_lines! hanging_indent=0
182
+ @lines= ['']
183
+ @sentences.each do |sentence|
184
+ sentence.split(/ /).each do |term|
185
+ # term is a single word that either contains hyphens or doesn't
186
+ term.scan(/[^-]+-+|[^-]+|-+/) do |word|
187
+ # word either contains no hyphens, ends in hyphens, or is only
188
+ # hyphens
189
+ width= @screenplay.text_width(@lines.last + word)
190
+ # new line needed
191
+ if width > self.width
192
+ @lines.last.rstrip!
193
+ indent_str= (' ' * hanging_indent)
194
+ # if word is just hyphens, it shouldn't start a line. chop the
195
+ # tail of the last line, move it to the front of the new line.
196
+ if /^-+$/ =~ word
197
+ tail= nil
198
+ @lines.last.sub!(/\s*[^\s]+$/) do |match|
199
+ tail= "#{match} "
200
+ ''
201
+ end
202
+ @lines.pop if @lines.last.empty?
203
+ @lines << indent_str + tail.lstrip
204
+ else
205
+ @lines << indent_str
206
+ end
207
+ end
208
+ @lines.last << word
209
+ end
210
+ @lines.last << ' '
211
+ end
212
+ @lines.last << ' '
213
+ end
214
+ @lines.last.rstrip!
215
+ if self.limit_to_one_line && @lines.size > 1
216
+ raise "pagination error: line too long: attempted to break #{self}"
217
+ end
218
+ @lines
219
+ end
220
+
221
+ # Some subclasses (e.g., a continued CharacterCue) need to massage the
222
+ # input line in some way.
223
+ def fix_input_line
224
+ @input_line
225
+ end
226
+
227
+ # Returns a new paragraph whose sentences are those of this paragraph in
228
+ # the range of +sentence_range+. The new paragraph will have the given
229
+ # +split_state+.
230
+ def slice sentence_range, split_state
231
+ self.class.new(@screenplay,
232
+ @sentences[sentence_range].join(' '),
233
+ @last_character_cue,
234
+ @split_state)
235
+ end
236
+
237
+ end
238
+
239
+ class Action < Paragraph; end
240
+
241
+ class CharacterCue < Paragraph
242
+
243
+ private
244
+
245
+ # Tacks on widow and continuation modifiers as necessary.
246
+ def fix_input_line
247
+ line= @input_line.upcase
248
+ contd_str=
249
+ if @split_state == :widow
250
+ widow_modifier
251
+ elsif @last_character_cue &&
252
+ @last_character_cue.input_line == @input_line
253
+ continuation_modifier
254
+ else
255
+ nil
256
+ end
257
+ if contd_str.nil? || contd_str.empty?
258
+ line
259
+ else
260
+ regex= /\)\s*$/
261
+ if regex =~ line
262
+ line.sub(regex, ", #{contd_str})")
263
+ else
264
+ "#{line} (#{contd_str})"
265
+ end
266
+ end
267
+ end
268
+
269
+ end
270
+
271
+ class Dialog < Paragraph; end
272
+
273
+ class Insert < Paragraph; end
274
+
275
+ class MontageHeading < Paragraph; end
276
+
277
+ class MontageItem < Paragraph
278
+ private
279
+ def break_lines!
280
+ super(3)
281
+ end
282
+ end
283
+
284
+ # When a Dialog is split across two pages, a More paragraph will be added as
285
+ # the last paragraph on the first page.
286
+ class More < Paragraph
287
+ def initialize screenplay
288
+ super(screenplay, nil, nil, :whole)
289
+ end
290
+ end
291
+
292
+ class Parenthetical < Paragraph
293
+ private
294
+ def break_lines!
295
+ super(1)
296
+ end
297
+ end
298
+
299
+ class SceneHeading < Paragraph
300
+ private
301
+ def fix_input_line
302
+ @input_line.upcase
303
+ end
304
+ end
305
+
306
+ class SlugLine < Paragraph; end
307
+
308
+ class Transition < Paragraph; end
309
+
310
+
311
+
312
+ # Encapsulates paragraphs.
313
+ #
314
+ # Note that Eleanor.load_config dynamically adds both class and instance
315
+ # methods to Page and its subclasses depending on the options present in the
316
+ # user's configuration file. See "Configuration" in the README.
317
+ class Page
318
+
319
+ # See initialize
320
+ attr_reader :page_no, :screenplay
321
+
322
+ # Array of Paragraphs
323
+ attr_reader :paras
324
+
325
+ # The page's body is all the paragraphs (and the margins between them)
326
+ # between the page's top and bottom margins. Returns a Length.
327
+ def body_height
328
+ prev_para= nil
329
+ @paras.inject(0.points) do |total, para|
330
+ prev, prev_para= [prev_para, para]
331
+ total + para.height + (prev.nil?? 0.points : prev.margin_between(para))
332
+ end
333
+ end
334
+
335
+ # Pages must be attached to a +screenplay+. +page_no+ is the page number.
336
+ def initialize screenplay, page_no
337
+ @screenplay= screenplay
338
+ @page_no= page_no
339
+ @paras= []
340
+ end
341
+
342
+ # The page's top margin plus the top margin of the first paragraph on the
343
+ # page. A Length.
344
+ def margin_top_actual
345
+ self.margin_top + (@paras.empty?? 0.points : @paras.first.margin_top)
346
+ end
347
+
348
+ # The page's maximum body height (see body_height) given its top and bottom
349
+ # margins.
350
+ def max_body_height
351
+ self.height - self.margin_top_actual - self.margin_bottom
352
+ end
353
+
354
+ # Attempts to add +para+ to the end of the page. If +para+ is split by the
355
+ # page break as a result or cannot be added at all, returns a new Paragraph
356
+ # which should be added to the top of a new page. Otherwise, if +para+ fits
357
+ # on the page, +nil+ is returned. If +force+ is true +para+ is added to the
358
+ # page regardless of constraints.
359
+ def push_para para, force=false
360
+ prev_para= @paras.last
361
+ if prev_para.nil? && (para.is_a?(Dialog) || para.is_a?(Parenthetical))
362
+ prev_para= CharacterCue.new(para.screenplay,
363
+ para.last_character_cue.input_line,
364
+ nil,
365
+ :widow)
366
+ self.push_para(prev_para)
367
+ end
368
+ margin_between= (prev_para.nil?? 0.points : prev_para.margin_between(para))
369
+ height_before_para= self.body_height + margin_between
370
+ # para overruns the current page. need to start a new page.
371
+ curr_page_para, new_page_para=
372
+ if (height_before_para + para.height > self.max_body_height) && !force
373
+ if height_before_para >= self.max_body_height
374
+ [nil, para]
375
+ else
376
+ orphan, widow= para.split(self.max_body_height - height_before_para)
377
+ if orphan.nil?
378
+ [nil, para]
379
+ else
380
+ [orphan, widow]
381
+ end
382
+ end
383
+ else
384
+ [para, nil]
385
+ end
386
+ unless curr_page_para.nil?
387
+ curr_page_para.is_first_on_page= true if @paras.empty?
388
+ @paras << curr_page_para
389
+ end
390
+ new_page_para
391
+ end
392
+
393
+ def to_s
394
+ str= '- ' + @page_no.to_s + ' ' + ('-' * 72) + "\n"
395
+ @paras.inject(str) { |s, para| "#{s}#{para}\n"}
396
+ end
397
+
398
+ end
399
+
400
+
401
+
402
+ # Encapsulates paragraphs and pages.
403
+ #
404
+ # Note that Eleanor.load_config dynamically adds both class and instance
405
+ # methods to Screenplay depending on the options present in the user's
406
+ # configuration file. See "Configuration" in the README.
407
+ class Screenplay
408
+
409
+ # Array of Page objects. Filled in during pagination.
410
+ attr_reader :pages
411
+
412
+ # Array of Paragraph objects. Filled in during parsing, used during
413
+ # pagination.
414
+ attr_reader :paras
415
+
416
+ # Array of TitlePage objects. Filled in during parsing.
417
+ attr_reader :title_pages
418
+
419
+ # The screenplay's author, nil if none.
420
+ def author
421
+ @title_pages.each { |tp| tp.author and return tp.author }
422
+ nil
423
+ end
424
+
425
+ def initialize
426
+ @paras= []
427
+ @title_pages= []
428
+ @pages= [Page.new(self, 1)]
429
+ initialize_paper!
430
+ end
431
+
432
+ # Paginates the screenplay according to its and all the constraints of its
433
+ # paragraphs.
434
+ def paginate!
435
+ @paras.each do |para|
436
+ new_para= @pages.last.push_para(para)
437
+ unless new_para.nil?
438
+ new_paras= [new_para]
439
+ if new_para.split_state == :whole
440
+ while @pages.last.paras.last.keep_with_next? new_paras.first
441
+ new_paras.unshift(@pages.last.paras.pop)
442
+ if @pages.last.paras.empty?
443
+ raise "pagination error: string of keep-with-nexts larger " \
444
+ "than one page; occured at:\n #{para}"
445
+ end
446
+ end
447
+ end
448
+ new_page= Page.new(self, @pages.size + 1)
449
+ new_paras.each do |p|
450
+ new_page_para= new_page.push_para(p)
451
+ if new_page_para
452
+ raise "pagination error: string of keep-with-nexts larger than " \
453
+ "one page:\n #{new_page_para}"
454
+ end
455
+ end
456
+ first_para= new_page.paras.first
457
+ if first_para.is_a?(CharacterCue) && first_para.split_state == :widow
458
+ @pages.last.push_para(More.new(self), true)
459
+ end
460
+ @pages << new_page
461
+ end
462
+ end
463
+ @pages
464
+ end
465
+
466
+ # Parses the plain text screenplay at +filename+. Returns +nil+ if parsing
467
+ # failed and an array of paragraphs if it succeeded. The paras attribute
468
+ # will be valid if parsing is successful.
469
+ def parse! filename
470
+ parse_! filename
471
+ end
472
+
473
+ # The screenplay's title, nil if none.
474
+ def title
475
+ @title_pages.each { |tp| tp.title and return tp.title }
476
+ nil
477
+ end
478
+
479
+ def to_s paginated=true
480
+ if paginated
481
+ @pages.inject('') { |str, page| "#{str}#{page}" }
482
+ else
483
+ @paras.inject('') { |str, para| "#{str}#{para}\n" }
484
+ end
485
+ end
486
+
487
+ private :parse_!
488
+
489
+ end
490
+
491
+
492
+
493
+ # A screenplay title page.
494
+ class TitlePage < Page
495
+
496
+ # See initialize
497
+ attr_reader :author, :contact, :title
498
+
499
+ # +options+ can include any title page elements recognized by the parser
500
+ # and backend. With the default parser and backend, options include
501
+ # :author, :contact, and :title. :author and :title are strings. :contact
502
+ # is an array of strings. Each option is turned into an instance variable.
503
+ def initialize options={}
504
+ options.each_pair do |att_name, att_val|
505
+ instance_variable_set("@#{att_name}", att_val)
506
+ end
507
+ end
508
+
509
+ end
510
+
511
+ end