-
Notifications
You must be signed in to change notification settings - Fork 688
/
box.rb
725 lines (635 loc) · 24.8 KB
/
box.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
# frozen_string_literal: true
# text/formatted/rectangle.rb : Implements text boxes with formatted text
#
# Copyright February 2010, Daniel Nelson. All Rights Reserved.
#
# This is free software. Please see the LICENSE and COPYING files for details.
#
module Prawn
module Text
module Formatted
# Formatted text box.
#
# Generally, one would use the {Prawn::Text::Formatted#formatted_text_box}
# convenience method. However, using `Text::Formatted::Box.new` in
# conjunction with `#render(dry_run: true)` enables one to do calculations
# prior to placing text on the page, or to determine how much vertical
# space was consumed by the printed text
class Box
include Prawn::Text::Formatted::Wrap
# @group Experimental API
# The text that was successfully printed (or, if `:dry_run` was
# used, the text that would have been successfully printed).
# @return [Array<Hash>]
attr_reader :text
# True if nothing printed (or, if `:dry_run` was used, nothing would
# have been successfully printed).
#
# @return [Boolean]
def nothing_printed?
@nothing_printed
end
# True if everything printed (or, if `:dry_run` was used, everything
# would have been successfully printed).
#
# @return [Boolean]
def everything_printed?
@everything_printed
end
# The upper left corner of the text box.
# @return [Array(Number, Number)]
attr_reader :at
# The line height of the last line printed.
# @return [Number]
attr_reader :line_height
# The height of the ascender of the last line printed.
# @return [Number]
attr_reader :ascender
# The height of the descender of the last line printed.
# @return [Number]
attr_reader :descender
# The leading used during printing.
# @return [Number]
attr_reader :leading
# Gap between adjacent lines of text.
#
# @return [Number]
def line_gap
line_height - (ascender + descender)
end
# See Prawn::Text#text_box for valid options
#
# @param formatted_text [Array<Hash{Symbol => any}>]
# Formatted text is an array of hashes, where each hash defines text
# and format information. The following hash options are supported:
#
# - `:text` --- the text to format according to the other hash
# options.
# - `:styles` --- an array of styles to apply to this text. Available
# styles include `:bold`, `:italic`, `:underline`, `:strikethrough`,
# `:subscript`, and `:superscript`.
# - `:size` ---a number denoting the font size to apply to this text.
# - `:character_spacing` --- a number denoting how much to increase or
# decrease the default spacing between characters.
# - `:font` --- the name of a font. The name must be an AFM font with
# the desired faces or must be a font that is already registered
# using {Prawn::Document#font_families}.
# - `:color` --- anything compatible with
# {Prawn::Graphics::Color#fill_color} and
# {Prawn::Graphics::Color#stroke_color}.
# - :link` --- a URL to which to create a link. A clickable link will
# be created to that URL. Note that you must explicitly underline
# and color using the appropriate tags if you which to draw
# attention to the link.
# - `:anchor` --- a destination that has already been or will be
# registered using `PDF::Core::Destinations#add_dest`. A clickable
# link will be created to that destination. Note that you must
# explicitly underline and color using the appropriate tags if you
# which to draw attention to the link.
# - `:local` --- a file or application to be opened locally.
# A clickable link will be created to the provided local file or
# application. If the file is another PDF, it will be opened in
# a new window. Note that you must explicitly underline and color
# using the appropriate options if you which to draw attention to
# the link.
# - `:draw_text_callback` --- if provided, this Proc will be called
# instead of {Prawn::Document#draw_text!} once per fragment for
# every low-level addition of text to the page.
# - `:callback` --- an object (or array of such objects) with two
# methods: `render_behind` and `render_in_front`, which are called
# immediately prior to and immediately after rendering the text
# fragment and which are passed the fragment as an argument.
# @param options [Hash{Symbol => any}]
# @option options :document [Prawn::Document] Owning document.
# @option options :kerning [Boolean]
# (value of document.default_kerning?)
# Whether or not to use kerning (if it is available with the current
# font).
# @option options :size [Number] (current font size)
# The font size to use.
# @option options :character_spacing [Number] (0)
# The amount of space to add to or remove from the default character
# spacing.
# @option options :disable_wrap_by_char [Boolean] (false)
# Whether or not to prevent mid-word breaks when text does not fit in
# box.
# @option options :mode [Symbol] (:fill)
# The text rendering mode. See documentation for
# {Prawn::Document#text_rendering_mode} for a list of valid options.
# @option option :style [Symbol] (current style)
# The style to use. The requested style must be part of the current
# font family.
# @option option :at [Array(Number, Number)] (bounds top left corner)
# The upper left corner of the box.
# @option options :width [Number] (bounds.right - at[0])
# The width of the box.
# @option options :height [Number] (default_height())
# The height of the box.
# @option options :direction [:ltr, :rtl]
# (value of document.text_direction)
# Direction of the text (left-to-right or right-to-left).
# @option options :fallback_fonts [Array<String>]
# An array of font names. Each name must be the name of an AFM font or
# the name that was used to register a family of external fonts (see
# {Prawn::Document#font_families}). If present, then each glyph will
# be rendered using the first font that includes the glyph, starting
# with the current font and then moving through `:fallback_fonts`.
# @option options :align [:left, :center, :right, :justify]
# (:left if direction is :ltr, :right if direction is :rtl)
# Alignment within the bounding box.
# @option options :valign [:top, :center, :bottom] (:top)
# Vertical alignment within the bounding box.
# @option options :rotate [Number]
# The angle to rotate the text.
# @option options :rotate_around
# [:center, :upper_left, :upper_right, :lower_right, :lower_left]
# (:upper_left)
# The point around which to rotate the text.
# @option options :leading [Number] (value of document.default_leading)
# Additional space between lines.
# @option options :single_line [Boolean] (false)
# If true, then only the first line will be drawn.
# @option options :overflow [:truncate, :shrink_to_fit, :expand]
# (:truncate)
# This controls the behavior when the amount of text exceeds the
# available space.
# @option options :min_font_size [Number] (5)
# The minimum font size to use when `:overflow` is set to
# `:shrink_to_fit` (that is the font size will not be reduced to less
# than this value, even if it means that some text will be cut off).
def initialize(formatted_text, options = {})
@inked = false
Prawn.verify_options(valid_options, options)
options = options.dup
self.class.extensions.reverse_each { |e| extend(e) }
@overflow = options[:overflow] || :truncate
@disable_wrap_by_char = options[:disable_wrap_by_char]
self.original_text = formatted_text
@text = nil
@document = options[:document]
@direction = options[:direction] || @document.text_direction
@fallback_fonts = options[:fallback_fonts] ||
@document.fallback_fonts
@at = (
options[:at] || [@document.bounds.left, @document.bounds.top]
).dup
@width = options[:width] ||
(@document.bounds.right - @at[0])
@height = options[:height] || default_height
@align = options[:align] ||
(@direction == :rtl ? :right : :left)
@vertical_align = options[:valign] || :top
@leading = options[:leading] || @document.default_leading
@character_spacing = options[:character_spacing] ||
@document.character_spacing
@mode = options[:mode] || @document.text_rendering_mode
@rotate = options[:rotate] || 0
@rotate_around = options[:rotate_around] || :upper_left
@single_line = options[:single_line]
@draw_text_callback = options[:draw_text_callback]
# if the text rendering mode is :unknown, force it back to :fill
if @mode == :unknown
@mode = :fill
end
if @overflow == :expand
# if set to expand, then we simply set the bottom
# as the bottom of the document bounds, since that
# is the maximum we should expand to
@height = default_height
@overflow = :truncate
end
@min_font_size = options[:min_font_size] || 5
if options[:kerning].nil?
options[:kerning] = @document.default_kerning?
end
@options = {
kerning: options[:kerning],
size: options[:size],
style: options[:style],
}
super(formatted_text, options)
end
# Render text to the document based on the settings defined in
# constructor.
#
# In order to facilitate look-ahead calculations, this method accepts
# a `dry_run: true` option. If provided, then everything is executed as
# if rendering, with the exception that nothing is drawn on the page.
# Useful for look-ahead computations of height, unprinted text, etc.
#
# @param flags [Hash{Symbol => any}]
# @option flags :dry_run [Boolean] (false)
# Do not draw the text. Everything else is done.
# @return [Array<Hash>]
# A formatted text array representing any text that did not print
# under the current settings.
# @raise [Prawn::Text::Formatted::Arranger::BadFontFamily]
# If no font family is defined for the current font.
# @raise [Prawn::Errors::CannotFit]
# If not wide enough to print any text.
def render(flags = {})
unprinted_text = []
@document.save_font do
@document.character_spacing(@character_spacing) do
@document.text_rendering_mode(@mode) do
process_options
text = normalized_text(flags)
@document.font_size(@font_size) do
shrink_to_fit(text) if @overflow == :shrink_to_fit
process_vertical_alignment(text)
@inked = true unless flags[:dry_run]
unprinted_text =
if @rotate != 0 && @inked
render_rotated(text)
else
wrap(text)
end
@inked = false
end
end
end
end
unprinted_text.map do |e|
e.merge(text: @document.font.to_utf8(e[:text]))
end
end
# The width available at this point in the box.
#
# @return [Number]
def available_width
@width
end
# The height actually used during the previous {render}.
#
# @return [Number]
def height
return 0 if @baseline_y.nil? || @descender.nil?
(@baseline_y - @descender).abs
end
# @private
# @param fragment [Prawn::Text::Formatted::Fragment]
# @param accumulated_width [Number]
# @param line_width [Number]
# @param word_spacing [Number]
# @return [void]
def draw_fragment(
fragment, accumulated_width = 0, line_width = 0, word_spacing = 0
)
case @align
when :left
x = @at[0]
when :center
x = @at[0] + (@width * 0.5) - (line_width * 0.5)
when :right
x = @at[0] + @width - line_width
when :justify
x =
if @direction == :ltr
@at[0]
else
@at[0] + @width - line_width
end
else
raise ArgumentError,
'align must be one of :left, :right, :center or :justify symbols'
end
x += accumulated_width
y = @at[1] + @baseline_y
y += fragment.y_offset
fragment.left = x
fragment.baseline = y
if @inked
draw_fragment_underlays(fragment)
@document.word_spacing(word_spacing) do
if @draw_text_callback
@draw_text_callback.call(
fragment.text,
at: [x, y],
kerning: @kerning,
)
else
@document.draw_text!(
fragment.text,
at: [x, y],
kerning: @kerning,
)
end
end
draw_fragment_overlays(fragment)
end
end
# @group Extension API
# Text box extensions.
#
# Example:
#
# ```ruby
# module MyWrap
# def wrap(array)
# initialize_wrap([{ text: 'all your base are belong to us' }])
# @line_wrap.wrap_line(
# document: @document,
# kerning: @kerning,
# width: 10000,
# arranger: @arranger
# )
# fragment = @arranger.retrieve_fragment
# format_and_draw_fragment(fragment, 0, @line_wrap.width, 0)
# []
# end
# end
#
# Prawn::Text::Formatted::Box.extensions << MyWrap
#
# box = Prawn::Text::Formatted::Box.new('hello world')
# box.render("why can't I print anything other than" +
# '"all your base are belong to us"?')
# ```
#
# See {Prawn::Text::Formatted::Wrap} for what is required of the
# wrap method if you want to override the default wrapping algorithm.
#
# @return [Array<Module>]
def self.extensions
@extensions ||= []
end
# @private
def self.inherited(base)
super
extensions.each { |e| base.extensions << e }
end
# @private
def valid_options
PDF::Core::Text::VALID_OPTIONS + %i[
at
height width
align valign
rotate rotate_around
overflow min_font_size
disable_wrap_by_char
leading character_spacing
mode single_line
document
direction
fallback_fonts
draw_text_callback
]
end
private
def normalized_text(flags)
text = normalize_encoding
text.each { |t| t.delete(:color) } if flags[:dry_run]
text
end
def original_text
@original_array.map(&:dup)
end
def original_text=(formatted_text)
@original_array = formatted_text
end
def normalize_encoding
formatted_text = original_text
unless @fallback_fonts.empty?
formatted_text = process_fallback_fonts(formatted_text)
end
formatted_text.each do |hash|
if hash[:font]
@document.font(hash[:font]) do
hash[:text] = @document.font.normalize_encoding(hash[:text])
end
else
hash[:text] = @document.font.normalize_encoding(hash[:text])
end
end
formatted_text
end
def process_fallback_fonts(formatted_text)
modified_formatted_text = []
formatted_text.each do |hash|
fragments = analyze_glyphs_for_fallback_font_support(hash)
modified_formatted_text.concat(fragments)
end
modified_formatted_text
end
def analyze_glyphs_for_fallback_font_support(hash)
font_glyph_pairs = []
original_font = @document.font.family
fragment_font = hash[:font] || original_font
fragment_font_options =
(fragment_font_style = font_style(hash[:styles])) == :normal ? {} : { style: fragment_font_style }
fallback_fonts = @fallback_fonts.dup
# always default back to the current font if the glyph is missing from
# all fonts
fallback_fonts << fragment_font
@document.save_font do
hash[:text].each_char do |char|
font_glyph_pairs << [
find_font_for_this_glyph(
char,
fragment_font,
fallback_fonts.dup,
fragment_font_options,
),
char,
]
end
end
# Don't add a :font to fragments if it wasn't there originally
if hash[:font].nil?
font_glyph_pairs.each do |pair|
pair[0] = nil if pair[0] == original_font
end
end
form_fragments_from_like_font_glyph_pairs(font_glyph_pairs, hash)
end
def font_style(styles)
if styles
if styles.include?(:bold)
styles.include?(:italic) ? :bold_italic : :bold
elsif styles.include?(:italic)
:italic
else
:normal
end
else
:normal
end
end
def find_font_for_this_glyph(char, current_font, fallback_fonts, current_font_options = {})
@document.font(current_font, current_font_options)
if fallback_fonts.empty? || @document.font.glyph_present?(char)
current_font
else
find_font_for_this_glyph(char, fallback_fonts.shift, fallback_fonts, current_font_options)
end
end
def form_fragments_from_like_font_glyph_pairs(font_glyph_pairs, hash)
fragments = []
fragment = nil
current_font = nil
font_glyph_pairs.each do |font, char|
if font != current_font || fragments.count.zero?
current_font = font
fragment = hash.dup
fragment[:text] = char
fragment[:font] = font unless font.nil?
fragments << fragment
else
fragment[:text] += char
end
end
fragments
end
def move_baseline_down
if @baseline_y.zero?
@baseline_y = -@ascender
else
@baseline_y -= (@line_height + @leading)
end
end
# Returns the default height to be used if none is provided or if the
# overflow option is set to :expand. If we are in a stretchy bounding
# box, assume we can stretch to the bottom of the innermost non-stretchy
# box.
#
def default_height
# Find the "frame", the innermost non-stretchy bbox.
frame = @document.bounds
frame = frame.parent while frame.stretchy? && frame.parent
@at[1] + @document.bounds.absolute_bottom - frame.absolute_bottom
end
def process_vertical_alignment(text)
# The vertical alignment must only be done once per text box, but
# we need to wait until render() is called so that the fonts are set
# up properly for wrapping. So guard with a boolean to ensure this is
# only run once.
if defined?(@vertical_alignment_processed) &&
@vertical_alignment_processed
return
end
@vertical_alignment_processed = true
return if @vertical_align == :top
wrap(text)
case @vertical_align
when :center
@at[1] -= (@height - height + @descender) * 0.5
when :bottom
@at[1] -= (@height - height)
else
raise ArgumentError,
'valign must be one of :left, :right or :center symbols'
end
@height = height
end
# Decrease the font size until the text fits or the min font
# size is reached
def shrink_to_fit(text)
loop do
if @disable_wrap_by_char && @font_size > @min_font_size
begin
wrap(text)
rescue Errors::CannotFit
# Ignore errors while we can still attempt smaller
# font sizes.
end
else
wrap(text)
end
break if @everything_printed || @font_size <= @min_font_size
@font_size = [@font_size - 0.5, @min_font_size].max
@document.font_size = @font_size
end
end
def process_options
# must be performed within a save_font block because
# document.process_text_options sets the font
@document.process_text_options(@options)
@font_size = @options[:size]
@kerning = @options[:kerning]
end
def render_rotated(text)
unprinted_text = ''
case @rotate_around
when :center
x = @at[0] + (@width * 0.5)
y = @at[1] - (@height * 0.5)
when :upper_right
x = @at[0] + @width
y = @at[1]
when :lower_right
x = @at[0] + @width
y = @at[1] - @height
when :lower_left
x = @at[0]
y = @at[1] - @height
else
x = @at[0]
y = @at[1]
end
@document.rotate(@rotate, origin: [x, y]) do
unprinted_text = wrap(text)
end
unprinted_text
end
def draw_fragment_underlays(fragment)
fragment.callback_objects.each do |obj|
obj.render_behind(fragment) if obj.respond_to?(:render_behind)
end
end
def draw_fragment_overlays(fragment)
draw_fragment_overlay_styles(fragment)
draw_fragment_overlay_link(fragment)
draw_fragment_overlay_anchor(fragment)
draw_fragment_overlay_local(fragment)
fragment.callback_objects.each do |obj|
obj.render_in_front(fragment) if obj.respond_to?(:render_in_front)
end
end
def draw_fragment_overlay_link(fragment)
return unless fragment.link
box = fragment.absolute_bounding_box
@document.link_annotation(
box,
Border: [0, 0, 0],
A: {
Type: :Action,
S: :URI,
URI: PDF::Core::LiteralString.new(fragment.link),
},
)
end
def draw_fragment_overlay_anchor(fragment)
return unless fragment.anchor
box = fragment.absolute_bounding_box
@document.link_annotation(
box,
Border: [0, 0, 0],
Dest: fragment.anchor,
)
end
def draw_fragment_overlay_local(fragment)
return unless fragment.local
box = fragment.absolute_bounding_box
@document.link_annotation(
box,
Border: [0, 0, 0],
A: {
Type: :Action,
S: :Launch,
F: PDF::Core::LiteralString.new(fragment.local),
NewWindow: true,
},
)
end
def draw_fragment_overlay_styles(fragment)
if fragment.styles.include?(:underline)
@document.stroke_line(fragment.underline_points)
end
if fragment.styles.include?(:strikethrough)
@document.stroke_line(fragment.strikethrough_points)
end
end
end
end
end
end