krill 0.1.0 → 0.2.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,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