krill 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,146 @@
1
+ module Krill
2
+ class Fragment
3
+ attr_reader :format_state, :text
4
+ attr_writer :width
5
+ attr_accessor :line_height, :descender, :ascender
6
+ attr_accessor :word_spacing, :left, :baseline
7
+
8
+ attr_reader :font
9
+ alias formatter font
10
+
11
+ def initialize(text, format_state)
12
+ @format_state = format_state
13
+ @font = format_state.fetch(:font)
14
+ @word_spacing = 0
15
+
16
+ # keep the original value of "text", so we can reinitialize @text if formatting parameters
17
+ # like text direction are changed
18
+ @original_text = text
19
+ @text = process_text(@original_text)
20
+ end
21
+
22
+ def width
23
+ return @width if @word_spacing.zero?
24
+ @width + @word_spacing * space_count
25
+ end
26
+
27
+ def height
28
+ top - bottom
29
+ end
30
+
31
+ def superscript?
32
+ formatter.superscript?
33
+ end
34
+
35
+ def subscript?
36
+ formatter.subscript?
37
+ end
38
+
39
+ def character_spacing
40
+ formatter.character_spacing
41
+ end
42
+
43
+ def y_offset
44
+ if subscript? then -descender
45
+ elsif superscript? then 0.85 * ascender
46
+ else 0
47
+ end
48
+ end
49
+
50
+ def underline_points
51
+ y = baseline - 1.25
52
+ [[left, y], [right, y]]
53
+ end
54
+
55
+ def strikethrough_points
56
+ y = baseline + ascender * 0.3
57
+ [[left, y], [right, y]]
58
+ end
59
+
60
+ def direction
61
+ @format_state[:direction]
62
+ end
63
+
64
+ def default_direction=(direction)
65
+ unless @format_state[:direction]
66
+ @format_state[:direction] = direction
67
+ @text = process_text(@original_text)
68
+ end
69
+ end
70
+
71
+ def include_trailing_white_space!
72
+ @format_state.delete(:exclude_trailing_white_space)
73
+ @text = process_text(@original_text)
74
+ end
75
+
76
+ def space_count
77
+ @text.count(" ")
78
+ end
79
+
80
+ def right
81
+ left + width
82
+ end
83
+
84
+ def top
85
+ baseline + ascender
86
+ end
87
+
88
+ def bottom
89
+ baseline - descender
90
+ end
91
+
92
+ private
93
+
94
+ def process_text(text)
95
+ string = strip_zero_width_spaces(text)
96
+
97
+ if exclude_trailing_white_space?
98
+ string = string.rstrip
99
+
100
+ if soft_hyphens_need_processing?(string)
101
+ string = process_soft_hyphens(string[0..-2]) + string[-1..-1]
102
+ end
103
+ else
104
+ if soft_hyphens_need_processing?(string)
105
+ string = process_soft_hyphens(string)
106
+ end
107
+ end
108
+
109
+ case direction
110
+ when :rtl
111
+ string.reverse
112
+ else
113
+ string
114
+ end
115
+ end
116
+
117
+ def exclude_trailing_white_space?
118
+ @format_state[:exclude_trailing_white_space]
119
+ end
120
+
121
+ def soft_hyphens_need_processing?(string)
122
+ string.length > 0 && normalized_soft_hyphen
123
+ end
124
+
125
+ def normalized_soft_hyphen
126
+ @format_state[:normalized_soft_hyphen]
127
+ end
128
+
129
+ def process_soft_hyphens(string)
130
+ if string.encoding != normalized_soft_hyphen.encoding
131
+ string.force_encoding(normalized_soft_hyphen.encoding)
132
+ end
133
+
134
+ string.gsub(normalized_soft_hyphen, "")
135
+ end
136
+
137
+ def strip_zero_width_spaces(string)
138
+ if string.encoding == ::Encoding::UTF_8
139
+ string.gsub(ZWSP, "")
140
+ else
141
+ string
142
+ end
143
+ end
144
+
145
+ end
146
+ end
@@ -0,0 +1,287 @@
1
+ module Krill
2
+ class LineWrap
3
+
4
+ # The width of the last wrapped line
5
+ #
6
+ def width
7
+ @accumulated_width || 0
8
+ end
9
+
10
+ # The number of spaces in the last wrapped line
11
+ attr_reader :space_count
12
+ attr_reader :soft_hyphen
13
+ attr_reader :zero_width_space
14
+
15
+ # Whether this line is the last line in the paragraph
16
+ def paragraph_finished?
17
+ @newline_encountered || is_next_string_newline? || @arranger.finished?
18
+ end
19
+
20
+ def tokenize(fragment)
21
+ fragment.scan(scan_pattern)
22
+ end
23
+
24
+ # Work in conjunction with the PDF::Formatted::Arranger
25
+ # defined in the :arranger option to determine what formatted text
26
+ # will fit within the width defined by the :width option
27
+ #
28
+ def wrap_line(width:, arranger:, kerning: nil, disable_wrap_by_char: nil)
29
+ initialize_line(
30
+ kerning: kerning,
31
+ width: width,
32
+ arranger: arranger,
33
+ disable_wrap_by_char: disable_wrap_by_char)
34
+
35
+ while fragment = @arranger.next_string
36
+ @fragment_output = ""
37
+
38
+ fragment.lstrip! if first_fragment_on_this_line?(fragment)
39
+ next if empty_line?(fragment)
40
+
41
+ break unless apply_font_settings_and_add_fragment_to_line(fragment)
42
+ end
43
+
44
+ @arranger.finalize_line
45
+ @accumulated_width = @arranger.line_width
46
+ @space_count = @arranger.space_count
47
+ @arranger.line
48
+ end
49
+
50
+ private
51
+
52
+ def first_fragment_on_this_line?(fragment)
53
+ line_empty? && fragment != "\n"
54
+ end
55
+
56
+ def empty_line?(fragment)
57
+ empty = line_empty? && fragment.empty? && is_next_string_newline?
58
+ @arranger.update_last_string("", "", soft_hyphen) if empty
59
+ empty
60
+ end
61
+
62
+ def is_next_string_newline?
63
+ @arranger.preview_next_string == "\n"
64
+ end
65
+
66
+ def apply_font_settings_and_add_fragment_to_line(fragment)
67
+ # if font has changed from Unicode to non-Unicode, or vice versa,
68
+ # the characters used for soft hyphens and zero-width spaces will
69
+ # be different.
70
+ set_soft_hyphen_and_zero_width_space(@arranger.current_formatter)
71
+ add_fragment_to_line(fragment, @arranger.current_formatter)
72
+ end
73
+
74
+ # returns true if all text was printed without running into the end of
75
+ # the line
76
+ #
77
+ def add_fragment_to_line(fragment, formatter)
78
+ if fragment == ""
79
+ true
80
+ elsif fragment == "\n"
81
+ @newline_encountered = true
82
+ false
83
+ else
84
+ tokenize(fragment).each do |segment|
85
+ if segment == zero_width_space
86
+ segment_width = 0
87
+ else
88
+ segment_width = formatter.width_of(segment, kerning: @kerning)
89
+ end
90
+
91
+ if @accumulated_width + segment_width <= @width
92
+ @accumulated_width += segment_width
93
+ if segment[-1] == soft_hyphen
94
+ sh_width = formatter.width_of("#{soft_hyphen}", kerning: @kerning)
95
+ @accumulated_width -= sh_width
96
+ end
97
+ @fragment_output += segment
98
+ else
99
+ end_of_the_line_reached(formatter, segment)
100
+ fragment_finished(fragment)
101
+ return false
102
+ end
103
+ end
104
+
105
+ fragment_finished(fragment)
106
+ true
107
+ end
108
+ end
109
+
110
+
111
+
112
+ # The pattern used to determine chunks of text to place on a given line
113
+ #
114
+ def scan_pattern(encoding = ::Encoding::UTF_8)
115
+ ebc = break_chars(encoding)
116
+ eshy = soft_hyphen(encoding)
117
+ ehy = hyphen(encoding)
118
+ ews = whitespace(encoding)
119
+
120
+ patterns = [
121
+ "[^#{ebc}]+#{eshy}",
122
+ "[^#{ebc}]+#{ehy}+",
123
+ "[^#{ebc}]+",
124
+ "[#{ews}]+",
125
+ "#{ehy}+[^#{ebc}]*",
126
+ eshy.to_s
127
+ ]
128
+
129
+ pattern = patterns
130
+ .map { |p| p.encode(encoding) }
131
+ .join('|')
132
+
133
+ Regexp.new(pattern)
134
+ end
135
+
136
+ # The pattern used to determine whether any word breaks exist on a
137
+ # current line, which in turn determines whether character level
138
+ # word breaking is needed
139
+ #
140
+ def word_division_scan_pattern(encoding = ::Encoding::UTF_8)
141
+ common_whitespaces = ["\t", "\n", "\v", "\r", ' '].map do |c|
142
+ c.encode(encoding)
143
+ end
144
+
145
+ Regexp.union(
146
+ common_whitespaces +
147
+ [
148
+ zero_width_space(encoding),
149
+ soft_hyphen(encoding),
150
+ hyphen(encoding)
151
+ ].compact
152
+ )
153
+ end
154
+
155
+ def soft_hyphen(encoding = ::Encoding::UTF_8)
156
+ Krill::SHY.encode(encoding)
157
+ end
158
+
159
+ def break_chars(encoding = ::Encoding::UTF_8)
160
+ [
161
+ whitespace(encoding),
162
+ soft_hyphen(encoding),
163
+ hyphen(encoding)
164
+ ].join('')
165
+ end
166
+
167
+ def zero_width_space(encoding = ::Encoding::UTF_8)
168
+ Krill::ZWSP.encode(encoding)
169
+ end
170
+
171
+ def whitespace(encoding = ::Encoding::UTF_8)
172
+ "\s\t#{zero_width_space(encoding)}".encode(encoding)
173
+ end
174
+
175
+ def hyphen(_encoding = ::Encoding::UTF_8)
176
+ '-'
177
+ end
178
+
179
+
180
+
181
+ def line_empty?
182
+ @line_empty && @accumulated_width == 0
183
+ end
184
+
185
+ def initialize_line(kerning:, width:, arranger:, disable_wrap_by_char:)
186
+ @kerning = kerning
187
+ @width = width
188
+
189
+ @disable_wrap_by_char = disable_wrap_by_char
190
+
191
+ @accumulated_width = 0
192
+ @line_empty = true
193
+ @line_contains_more_than_one_word = false
194
+
195
+ @arranger = arranger
196
+ @arranger.initialize_line
197
+
198
+ @newline_encountered = false
199
+ @line_full = false
200
+ end
201
+
202
+ def set_soft_hyphen_and_zero_width_space(formatter)
203
+ # this is done once per fragment, after the font settings for the fragment are applied --
204
+ # it could actually be skipped if the font hasn't changed
205
+ @soft_hyphen = formatter.normalize_encoding(SHY)
206
+ @zero_width_space = formatter.unicode? ? ZWSP : ""
207
+ end
208
+
209
+ def fragment_finished(fragment)
210
+ if fragment == "\n"
211
+ @newline_encountered = true
212
+ @line_empty = false
213
+ else
214
+ update_output_based_on_last_fragment(fragment, soft_hyphen)
215
+ update_line_status_based_on_last_output
216
+ determine_whether_to_pull_preceding_fragment_to_join_this_one(fragment)
217
+ end
218
+ remember_this_fragment_for_backward_looking_ops
219
+ end
220
+
221
+ def update_output_based_on_last_fragment(fragment, normalized_soft_hyphen = nil)
222
+ remaining_text = fragment.slice(@fragment_output.length..fragment.length)
223
+ fail CannotFit if line_finished? && line_empty? && @fragment_output.empty? && !fragment.strip.empty?
224
+ @arranger.update_last_string(@fragment_output, remaining_text, normalized_soft_hyphen)
225
+ end
226
+
227
+ def determine_whether_to_pull_preceding_fragment_to_join_this_one(current_fragment)
228
+ if @fragment_output.empty? && !current_fragment.empty? && @line_contains_more_than_one_word
229
+ unless previous_fragment_ended_with_breakable? || fragment_begins_with_breakable?(current_fragment)
230
+ @fragment_output = @previous_fragment_output_without_last_word
231
+ update_output_based_on_last_fragment(@previous_fragment)
232
+ end
233
+ end
234
+ end
235
+
236
+ def remember_this_fragment_for_backward_looking_ops
237
+ @previous_fragment = @fragment_output.dup
238
+ pf = @previous_fragment
239
+ @previous_fragment_ended_with_breakable = pf =~ /[#{break_chars}]$/
240
+ last_word = pf.slice(/[^#{break_chars}]*$/)
241
+ last_word_length = last_word.nil? ? 0 : last_word.length
242
+ @previous_fragment_output_without_last_word = pf.slice(0, pf.length - last_word_length)
243
+ end
244
+
245
+ def previous_fragment_ended_with_breakable?
246
+ @previous_fragment_ended_with_breakable
247
+ end
248
+
249
+ def fragment_begins_with_breakable?(fragment)
250
+ fragment =~ /^[#{break_chars}]/
251
+ end
252
+
253
+ def line_finished?
254
+ @line_full || paragraph_finished?
255
+ end
256
+
257
+ def update_line_status_based_on_last_output
258
+ @line_contains_more_than_one_word = true if @fragment_output =~ word_division_scan_pattern
259
+ end
260
+
261
+ def end_of_the_line_reached(formatter, segment)
262
+ update_line_status_based_on_last_output
263
+ wrap_by_char(formatter, segment) unless @disable_wrap_by_char || @line_contains_more_than_one_word
264
+ @line_full = true
265
+ end
266
+
267
+ def wrap_by_char(formatter, segment)
268
+ segment.each_char do |char|
269
+ break unless append_char(char, formatter)
270
+ end
271
+ end
272
+
273
+ def append_char(char, formatter)
274
+ # kerning doesn't make sense in the context of a single character
275
+ char_width = formatter.compute_width_of(char, kerning: false)
276
+
277
+ if @accumulated_width + char_width <= @width
278
+ @accumulated_width += char_width
279
+ @fragment_output << char
280
+ true
281
+ else
282
+ false
283
+ end
284
+ end
285
+
286
+ end
287
+ end
@@ -0,0 +1,244 @@
1
+ require "krill/line_wrap"
2
+ require "krill/arranger"
3
+
4
+ module Krill
5
+ class TextBox
6
+
7
+ def initialize(formatted_text, options={})
8
+ @original_array = formatted_text
9
+ @text = nil
10
+ @at = [0, 720.0] # was [ @document.bounds.left, @document.bounds.top ]
11
+ @width = options.fetch(:width)
12
+ @height = options.fetch(:height, @at[1])
13
+ @direction = options.fetch(:direction, :ltr)
14
+ @align = options.fetch(:align, @direction == :rtl ? :right : :left)
15
+ @leading = options.fetch(:leading, 0)
16
+ @kerning = options.fetch(:kerning, true)
17
+ @disable_wrap_by_char = options[:disable_wrap_by_char]
18
+ @line_wrap = Krill::LineWrap.new
19
+ @arranger = Krill::Arranger.new(kerning: @kerning)
20
+ end
21
+
22
+ def render
23
+ wrap(normalize_encoding(original_text))
24
+ end
25
+
26
+ # The text that was successfully printed (or, if <tt>dry_run</tt> was
27
+ # used, the text that would have been successfully printed)
28
+ attr_reader :text
29
+
30
+ # True if nothing printed (or, if <tt>dry_run</tt> was
31
+ # used, nothing would have been successfully printed)
32
+ def nothing_printed?
33
+ @nothing_printed
34
+ end
35
+
36
+ # True if everything printed (or, if <tt>dry_run</tt> was
37
+ # used, everything would have been successfully printed)
38
+ def everything_printed?
39
+ @everything_printed
40
+ end
41
+
42
+ # The upper left corner of the text box
43
+ attr_reader :at
44
+
45
+ # The line height of the last line printed
46
+ attr_reader :line_height
47
+
48
+ # The height of the ascender of the last line printed
49
+ attr_reader :ascender
50
+
51
+ # The height of the descender of the last line printed
52
+ attr_reader :descender
53
+
54
+ # The leading used during printing
55
+ attr_reader :leading
56
+
57
+ def line_gap
58
+ line_height - (ascender + descender)
59
+ end
60
+
61
+ # The height actually used during the previous <tt>render</tt>
62
+ def height
63
+ return 0 if @baseline_y.nil? || @descender.nil?
64
+ (@baseline_y - @descender).abs
65
+ end
66
+
67
+ private
68
+
69
+ # The width available at this point in the box
70
+ def available_width
71
+ @width
72
+ end
73
+
74
+ # <tt>fragment</tt> is a Krill::Fragment object
75
+ def draw_fragment(fragment, accumulated_width=0, line_width=0, word_spacing=0)
76
+ case @align
77
+ when :left
78
+ x = @at[0]
79
+ when :center
80
+ x = @at[0] + @width * 0.5 - line_width * 0.5
81
+ when :right
82
+ x = @at[0] + @width - line_width
83
+ when :justify
84
+ x = if @direction == :ltr
85
+ @at[0]
86
+ else
87
+ @at[0] + @width - line_width
88
+ end
89
+ end
90
+
91
+ x += accumulated_width
92
+
93
+ y = @at[1] + @baseline_y
94
+
95
+ y += fragment.y_offset
96
+
97
+ fragment.left = x
98
+ fragment.baseline = y
99
+ end
100
+
101
+ def original_text
102
+ @original_array.collect(&:dup)
103
+ end
104
+
105
+ def normalize_encoding(text)
106
+ text.each do |hash|
107
+ hash[:text] = hash.fetch(:font).normalize_encoding(hash.fetch(:text))
108
+ end
109
+ end
110
+
111
+ def move_baseline_down
112
+ if @baseline_y.zero?
113
+ @baseline_y = -@ascender
114
+ else
115
+ @baseline_y -= (@line_height + @leading)
116
+ end
117
+ end
118
+
119
+ # See the developer documentation for PDF::Core::Text#wrap
120
+ #
121
+ # Formatted#wrap should set the following variables:
122
+ # <tt>@line_height</tt>::
123
+ # the height of the tallest fragment in the last printed line
124
+ # <tt>@descender</tt>::
125
+ # the descender height of the tallest fragment in the last
126
+ # printed line
127
+ # <tt>@ascender</tt>::
128
+ # the ascender heigth of the tallest fragment in the last
129
+ # printed line
130
+ # <tt>@baseline_y</tt>::
131
+ # the baseline of the current line
132
+ # <tt>@nothing_printed</tt>::
133
+ # set to true until something is printed, then false
134
+ # <tt>@everything_printed</tt>::
135
+ # set to false until everything printed, then true
136
+ #
137
+ # Returns any formatted text that was not printed
138
+ #
139
+ def wrap(array)
140
+ initialize_wrap(array)
141
+
142
+ stop = false
143
+ until stop
144
+ # wrap before testing if enough height for this line because the
145
+ # height of the highest fragment on this line will be used to
146
+ # determine the line height
147
+ @line_wrap.wrap_line(
148
+ kerning: @kerning,
149
+ width: available_width,
150
+ arranger: @arranger,
151
+ disable_wrap_by_char: @disable_wrap_by_char)
152
+
153
+ if enough_height_for_this_line?
154
+ move_baseline_down
155
+ print_line
156
+ else
157
+ stop = true
158
+ end
159
+
160
+ stop ||= @arranger.finished?
161
+ end
162
+ @text = @printed_lines.join("\n")
163
+ @everything_printed = @arranger.finished?
164
+ @arranger.unconsumed
165
+ end
166
+
167
+ def print_line
168
+ @nothing_printed = false
169
+ printed_fragments = []
170
+ fragments_this_line = []
171
+
172
+ word_spacing = word_spacing_for_this_line
173
+ @arranger.fragments.each do |fragment|
174
+ fragment.word_spacing = word_spacing
175
+ if fragment.text == "\n"
176
+ printed_fragments << "\n" if @printed_lines.last == ""
177
+ break
178
+ end
179
+ printed_fragments << fragment.text
180
+ fragments_this_line << fragment
181
+ end
182
+ @arranger.fragments.replace []
183
+
184
+ accumulated_width = 0
185
+ fragments_this_line.reverse! if @direction == :rtl
186
+ fragments_this_line.each do |fragment_this_line|
187
+ fragment_this_line.default_direction = @direction
188
+ format_and_draw_fragment(fragment_this_line, accumulated_width, @line_wrap.width, word_spacing)
189
+ accumulated_width += fragment_this_line.width
190
+ end
191
+
192
+ @printed_lines << printed_fragments.map do |s|
193
+ s.force_encoding(::Encoding::UTF_8)
194
+ end.join
195
+ end
196
+
197
+ def word_spacing_for_this_line
198
+ if @align == :justify && @line_wrap.space_count > 0 && !@line_wrap.paragraph_finished?
199
+ (available_width - @line_wrap.width) / @line_wrap.space_count
200
+ else
201
+ 0
202
+ end
203
+ end
204
+
205
+ def enough_height_for_this_line?
206
+ @line_height = @arranger.max_line_height
207
+ @descender = @arranger.max_descender
208
+ @ascender = @arranger.max_ascender
209
+ diff = if @baseline_y.zero?
210
+ @ascender + @descender
211
+ else
212
+ @descender + @line_height + @leading
213
+ end
214
+ require_relatived_total_height = @baseline_y.abs + diff
215
+ if require_relatived_total_height > @height + 0.0001
216
+ # no room for the full height of this line
217
+ @arranger.repack_unretrieved
218
+ false
219
+ else
220
+ true
221
+ end
222
+ end
223
+
224
+ def initialize_wrap(array)
225
+ @text = nil
226
+ @arranger.format_array = array
227
+
228
+ # these values will depend on the maximum value within a given line
229
+ @line_height = 0
230
+ @descender = 0
231
+ @ascender = 0
232
+ @baseline_y = 0
233
+
234
+ @printed_lines = []
235
+ @nothing_printed = true
236
+ @everything_printed = false
237
+ end
238
+
239
+ def format_and_draw_fragment(fragment, accumulated_width, line_width, word_spacing)
240
+ draw_fragment(fragment, accumulated_width, line_width, word_spacing)
241
+ end
242
+
243
+ end
244
+ end