prawnx 0.1.1

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.
Files changed (160) hide show
  1. checksums.yaml +7 -0
  2. data/COPYING +2 -0
  3. data/GPLv2 +339 -0
  4. data/GPLv3 +674 -0
  5. data/LICENSE +65 -0
  6. data/LICENSE-prawn-svg +21 -0
  7. data/README.md +107 -0
  8. data/data/fonts/Courier-Bold.afm +342 -0
  9. data/data/fonts/Courier-BoldOblique.afm +342 -0
  10. data/data/fonts/Courier-Oblique.afm +342 -0
  11. data/data/fonts/Courier.afm +342 -0
  12. data/data/fonts/Helvetica-Bold.afm +2827 -0
  13. data/data/fonts/Helvetica-BoldOblique.afm +2827 -0
  14. data/data/fonts/Helvetica-Oblique.afm +3051 -0
  15. data/data/fonts/Helvetica.afm +3051 -0
  16. data/data/fonts/MustRead.html +19 -0
  17. data/data/fonts/Symbol.afm +213 -0
  18. data/data/fonts/Times-Bold.afm +2588 -0
  19. data/data/fonts/Times-BoldItalic.afm +2384 -0
  20. data/data/fonts/Times-Italic.afm +2667 -0
  21. data/data/fonts/Times-Roman.afm +2419 -0
  22. data/data/fonts/ZapfDingbats.afm +225 -0
  23. data/lib/pdf/core.rb +4 -0
  24. data/lib/prawn/measurement_extensions.rb +4 -0
  25. data/lib/prawn/svg/attributes/clip_path.rb +46 -0
  26. data/lib/prawn/svg/attributes/mask.rb +16 -0
  27. data/lib/prawn/svg/attributes/opacity.rb +30 -0
  28. data/lib/prawn/svg/attributes/space.rb +10 -0
  29. data/lib/prawn/svg/attributes/stroke.rb +50 -0
  30. data/lib/prawn/svg/attributes/transform.rb +10 -0
  31. data/lib/prawn/svg/attributes.rb +6 -0
  32. data/lib/prawn/svg/calculators/arc_to_bezier_curve.rb +57 -0
  33. data/lib/prawn/svg/calculators/aspect_ratio.rb +60 -0
  34. data/lib/prawn/svg/calculators/document_sizing.rb +103 -0
  35. data/lib/prawn/svg/calculators/path_length.rb +168 -0
  36. data/lib/prawn/svg/calculators/pixels.rb +57 -0
  37. data/lib/prawn/svg/color.rb +240 -0
  38. data/lib/prawn/svg/css/font_face_parser.rb +72 -0
  39. data/lib/prawn/svg/css/font_family_parser.rb +39 -0
  40. data/lib/prawn/svg/css/font_parser.rb +46 -0
  41. data/lib/prawn/svg/css/selector_parser.rb +178 -0
  42. data/lib/prawn/svg/css/stylesheets.rb +233 -0
  43. data/lib/prawn/svg/css/values_parser.rb +68 -0
  44. data/lib/prawn/svg/document.rb +170 -0
  45. data/lib/prawn/svg/elements/anchor.rb +10 -0
  46. data/lib/prawn/svg/elements/base.rb +312 -0
  47. data/lib/prawn/svg/elements/bbox_scaling.rb +78 -0
  48. data/lib/prawn/svg/elements/call_duplicator.rb +37 -0
  49. data/lib/prawn/svg/elements/circle.rb +25 -0
  50. data/lib/prawn/svg/elements/clip_path.rb +26 -0
  51. data/lib/prawn/svg/elements/container.rb +10 -0
  52. data/lib/prawn/svg/elements/direct_render_base.rb +27 -0
  53. data/lib/prawn/svg/elements/ellipse.rb +22 -0
  54. data/lib/prawn/svg/elements/gradient.rb +184 -0
  55. data/lib/prawn/svg/elements/ignored.rb +5 -0
  56. data/lib/prawn/svg/elements/image.rb +177 -0
  57. data/lib/prawn/svg/elements/line.rb +27 -0
  58. data/lib/prawn/svg/elements/marker.rb +72 -0
  59. data/lib/prawn/svg/elements/mask.rb +65 -0
  60. data/lib/prawn/svg/elements/path.rb +294 -0
  61. data/lib/prawn/svg/elements/pattern.rb +150 -0
  62. data/lib/prawn/svg/elements/polygon.rb +25 -0
  63. data/lib/prawn/svg/elements/polyline.rb +23 -0
  64. data/lib/prawn/svg/elements/rect.rb +64 -0
  65. data/lib/prawn/svg/elements/root.rb +22 -0
  66. data/lib/prawn/svg/elements/switch.rb +92 -0
  67. data/lib/prawn/svg/elements/text.rb +99 -0
  68. data/lib/prawn/svg/elements/text_component.rb +346 -0
  69. data/lib/prawn/svg/elements/text_node.rb +308 -0
  70. data/lib/prawn/svg/elements/text_path.rb +153 -0
  71. data/lib/prawn/svg/elements/use.rb +142 -0
  72. data/lib/prawn/svg/elements/viewport.rb +29 -0
  73. data/lib/prawn/svg/elements.rb +46 -0
  74. data/lib/prawn/svg/extension.rb +25 -0
  75. data/lib/prawn/svg/font.rb +20 -0
  76. data/lib/prawn/svg/font_metrics.rb +144 -0
  77. data/lib/prawn/svg/font_registry.rb +315 -0
  78. data/lib/prawn/svg/funciri.rb +14 -0
  79. data/lib/prawn/svg/gradient_renderer.rb +332 -0
  80. data/lib/prawn/svg/gradients.rb +53 -0
  81. data/lib/prawn/svg/interface.rb +72 -0
  82. data/lib/prawn/svg/length.rb +43 -0
  83. data/lib/prawn/svg/link_renderer.rb +40 -0
  84. data/lib/prawn/svg/loaders/data.rb +17 -0
  85. data/lib/prawn/svg/loaders/file.rb +111 -0
  86. data/lib/prawn/svg/loaders/web.rb +24 -0
  87. data/lib/prawn/svg/paint.rb +67 -0
  88. data/lib/prawn/svg/pathable.rb +153 -0
  89. data/lib/prawn/svg/pattern_renderer.rb +112 -0
  90. data/lib/prawn/svg/pdfmatrix.rb +37 -0
  91. data/lib/prawn/svg/percentage.rb +24 -0
  92. data/lib/prawn/svg/properties.rb +329 -0
  93. data/lib/prawn/svg/renderer.rb +249 -0
  94. data/lib/prawn/svg/state.rb +24 -0
  95. data/lib/prawn/svg/transform_parser.rb +78 -0
  96. data/lib/prawn/svg/ttc.rb +35 -0
  97. data/lib/prawn/svg/ttf.rb +95 -0
  98. data/lib/prawn/svg/url_loader.rb +46 -0
  99. data/lib/prawn/svg/version.rb +5 -0
  100. data/lib/prawn/table.rb +4 -0
  101. data/lib/prawn-svg.rb +53 -0
  102. data/lib/prawn.rb +10 -0
  103. data/lib/prawnx/annotations.rb +21 -0
  104. data/lib/prawnx/document/bounding_box.rb +193 -0
  105. data/lib/prawnx/document/column_box.rb +91 -0
  106. data/lib/prawnx/document/internals.rb +144 -0
  107. data/lib/prawnx/document/span.rb +25 -0
  108. data/lib/prawnx/document.rb +253 -0
  109. data/lib/prawnx/encoding.rb +29 -0
  110. data/lib/prawnx/errors.rb +37 -0
  111. data/lib/prawnx/font.rb +214 -0
  112. data/lib/prawnx/fonts/afm.rb +171 -0
  113. data/lib/prawnx/fonts/otf.rb +50 -0
  114. data/lib/prawnx/fonts/ttf.rb +410 -0
  115. data/lib/prawnx/graphic_state.rb +41 -0
  116. data/lib/prawnx/graphics/color.rb +148 -0
  117. data/lib/prawnx/graphics/patterns.rb +100 -0
  118. data/lib/prawnx/graphics/style.rb +171 -0
  119. data/lib/prawnx/graphics.rb +208 -0
  120. data/lib/prawnx/grid.rb +216 -0
  121. data/lib/prawnx/images/image.rb +244 -0
  122. data/lib/prawnx/images/png_decoder.rb +65 -0
  123. data/lib/prawnx/images.rb +115 -0
  124. data/lib/prawnx/measurement_extensions.rb +40 -0
  125. data/lib/prawnx/measurements.rb +62 -0
  126. data/lib/prawnx/outline.rb +146 -0
  127. data/lib/prawnx/page.rb +125 -0
  128. data/lib/prawnx/page_geometry.rb +27 -0
  129. data/lib/prawnx/pdf/outline_node.rb +53 -0
  130. data/lib/prawnx/pdf/reference.rb +31 -0
  131. data/lib/prawnx/pdf/writer.rb +105 -0
  132. data/lib/prawnx/pdf.rb +66 -0
  133. data/lib/prawnx/pdf_a/srgb_profile.rb +61 -0
  134. data/lib/prawnx/pdf_a.rb +130 -0
  135. data/lib/prawnx/pdf_core.rb +25 -0
  136. data/lib/prawnx/repeater.rb +79 -0
  137. data/lib/prawnx/security.rb +108 -0
  138. data/lib/prawnx/stamp.rb +74 -0
  139. data/lib/prawnx/table/cell/kinds.rb +246 -0
  140. data/lib/prawnx/table/cell.rb +331 -0
  141. data/lib/prawnx/table/cells.rb +208 -0
  142. data/lib/prawnx/table.rb +296 -0
  143. data/lib/prawnx/text/box.rb +16 -0
  144. data/lib/prawnx/text/formatted/arranger.rb +217 -0
  145. data/lib/prawnx/text/formatted/box.rb +312 -0
  146. data/lib/prawnx/text/formatted/fragment.rb +205 -0
  147. data/lib/prawnx/text/formatted/line_wrap.rb +204 -0
  148. data/lib/prawnx/text/formatted/parser.rb +149 -0
  149. data/lib/prawnx/text/formatted/wrap.rb +103 -0
  150. data/lib/prawnx/text.rb +281 -0
  151. data/lib/prawnx/transformation_stack.rb +43 -0
  152. data/lib/prawnx/true_type/cff_subset.rb +157 -0
  153. data/lib/prawnx/true_type/file.rb +371 -0
  154. data/lib/prawnx/true_type/open_type_layout.rb +189 -0
  155. data/lib/prawnx/true_type/simple_subsets.rb +94 -0
  156. data/lib/prawnx/true_type/subset.rb +187 -0
  157. data/lib/prawnx/version.rb +7 -0
  158. data/lib/prawnx/view.rb +28 -0
  159. data/lib/prawnx.rb +102 -0
  160. metadata +251 -0
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module PrawnX
6
+ module Text
7
+ module Formatted
8
+ # Fills one line with as many words of the arranger's fragments as fit, breaking after spaces, hyphens and
9
+ # soft hyphens, and inside a word only when the line holds a single one.
10
+ class LineWrap
11
+ # Characters and patterns of one encoding, computed once: Prawn rebuilt them for every line.
12
+ Breaks = Data.define(:soft_hyphen, :zero_width_space, :scan_pattern, :word_division_pattern, :ends_with_break_pattern,
13
+ :last_word_pattern, :begins_with_break_pattern)
14
+
15
+ BREAKS_BY_ENCODING = Hash.new do |cache, encoding|
16
+ cache[encoding] = breaks_for(encoding)
17
+ end
18
+
19
+ def self.encode_or_nil(character, encoding)
20
+ character.encode(encoding)
21
+ rescue ::Encoding::InvalidByteSequenceError, ::Encoding::UndefinedConversionError
22
+ nil
23
+ end
24
+
25
+ def self.breaks_for(encoding)
26
+ soft_hyphen = encode_or_nil(SHY, encoding)
27
+ zero_width_space = encode_or_nil(ZWSP, encoding)
28
+ whitespace = " \t#{zero_width_space}".encode(encoding)
29
+ break_characters = [whitespace, soft_hyphen, "-"].join
30
+ patterns = ["[^#{break_characters}]+#{soft_hyphen}", "[^#{break_characters}]+-+", "[^#{break_characters}]+", "[#{whitespace}]+",
31
+ "-+[^#{break_characters}]*", soft_hyphen.to_s]
32
+ Breaks.new(
33
+ soft_hyphen: soft_hyphen&.freeze,
34
+ zero_width_space: zero_width_space&.freeze,
35
+ scan_pattern: Regexp.new(patterns.map { it.encode(encoding) }.join("|")),
36
+ word_division_pattern: Regexp.union([*["\t", "\n", "\v", "\r", " "].map { it.encode(encoding) }, zero_width_space, soft_hyphen, "-"].compact),
37
+ ends_with_break_pattern: Regexp.new("[#{break_characters}]$"),
38
+ last_word_pattern: Regexp.new("[^#{break_characters}]*$"),
39
+ begins_with_break_pattern: Regexp.new("^[#{break_characters}]"),
40
+ )
41
+ end
42
+
43
+ attr_reader :space_count
44
+
45
+ def width
46
+ @accumulated_width || 0
47
+ end
48
+
49
+ def paragraph_finished?
50
+ @newline_encountered || next_string_newline? || @arranger.finished?
51
+ end
52
+
53
+ def tokenize(fragment)
54
+ fragment.scan(BREAKS_BY_ENCODING[fragment.encoding].scan_pattern)
55
+ end
56
+
57
+ def wrap_line(options)
58
+ initialize_line(options)
59
+ while (fragment = @arranger.next_string)
60
+ @fragment_output = +""
61
+ fragment.lstrip! if first_fragment_on_this_line?(fragment)
62
+ next if empty_line?(fragment)
63
+ break unless apply_font_settings_and_add_fragment_to_line(fragment)
64
+ end
65
+ @arranger.finalize_line
66
+ @accumulated_width = @arranger.line_width
67
+ @space_count = @arranger.space_count
68
+ @arranger.line
69
+ end
70
+
71
+ private
72
+
73
+ def first_fragment_on_this_line?(fragment)
74
+ line_empty? && fragment != "\n"
75
+ end
76
+
77
+ def empty_line?(fragment)
78
+ empty = line_empty? && fragment.empty? && next_string_newline?
79
+ @arranger.update_last_string("", "", BREAKS_BY_ENCODING[fragment.encoding].soft_hyphen) if empty
80
+ empty
81
+ end
82
+
83
+ def next_string_newline?
84
+ @arranger.preview_next_string == "\n"
85
+ end
86
+
87
+ def apply_font_settings_and_add_fragment_to_line(fragment)
88
+ added = nil
89
+ @arranger.apply_font_settings { added = add_fragment_to_line(fragment) }
90
+ added
91
+ end
92
+
93
+ def add_fragment_to_line(fragment)
94
+ case fragment
95
+ when "" then return true
96
+ when "\n"
97
+ @newline_encountered = true
98
+ return false
99
+ end
100
+
101
+ breaks = BREAKS_BY_ENCODING[fragment.encoding]
102
+ # Words are read one at a time: a line stops long before the end of its paragraph.
103
+ scanner = StringScanner.new(fragment)
104
+ while (segment = scanner.scan(breaks.scan_pattern))
105
+ segment_width = segment == breaks.zero_width_space ? 0 : @document.width_of(segment, kerning: @kerning)
106
+ if @accumulated_width + segment_width <= @width
107
+ @accumulated_width += segment_width
108
+ @accumulated_width -= @document.width_of(breaks.soft_hyphen, kerning: @kerning) if segment[-1] == breaks.soft_hyphen
109
+ @fragment_output << segment
110
+ else
111
+ @line_contains_more_than_one_word = false if @accumulated_width.zero? && @line_contains_more_than_one_word
112
+ end_of_the_line_reached(segment)
113
+ fragment_finished(fragment)
114
+ return false
115
+ end
116
+ end
117
+ fragment_finished(fragment)
118
+ true
119
+ end
120
+
121
+ def line_empty?
122
+ @line_empty && @accumulated_width.zero?
123
+ end
124
+
125
+ def initialize_line(options)
126
+ @document = options[:document]
127
+ @kerning = options[:kerning]
128
+ @width = options[:width]
129
+ @disable_wrap_by_char = options[:disable_wrap_by_char]
130
+ @accumulated_width = 0
131
+ @line_empty = true
132
+ @line_contains_more_than_one_word = false
133
+ @arranger = options[:arranger]
134
+ @arranger.initialize_line
135
+ @newline_encountered = false
136
+ @line_full = false
137
+ end
138
+
139
+ def fragment_finished(fragment)
140
+ if fragment == "\n"
141
+ @newline_encountered = true
142
+ @line_empty = false
143
+ else
144
+ update_output_based_on_last_fragment(fragment, BREAKS_BY_ENCODING[fragment.encoding].soft_hyphen)
145
+ update_line_status_based_on_last_output
146
+ pull_preceding_fragment_to_join_this_one?(fragment)
147
+ end
148
+ remember_this_fragment_for_backward_looking_ops
149
+ end
150
+
151
+ def update_output_based_on_last_fragment(fragment, normalized_soft_hyphen = nil)
152
+ remaining_text = fragment.slice(@fragment_output.length..fragment.length)
153
+ raise Errors::CannotFit if line_finished? && line_empty? && @fragment_output.empty? && !fragment.strip.empty?
154
+
155
+ @arranger.update_last_string(@fragment_output, remaining_text, normalized_soft_hyphen)
156
+ end
157
+
158
+ def pull_preceding_fragment_to_join_this_one?(current_fragment)
159
+ return unless @fragment_output.empty? && !current_fragment.empty? && @line_contains_more_than_one_word
160
+ return if @previous_fragment_ended_with_breakable || fragment_begins_with_breakable?(current_fragment)
161
+
162
+ @fragment_output = @previous_fragment_output_without_last_word
163
+ update_output_based_on_last_fragment(@previous_fragment)
164
+ end
165
+
166
+ def remember_this_fragment_for_backward_looking_ops
167
+ @previous_fragment = @fragment_output.dup
168
+ breaks = BREAKS_BY_ENCODING[@previous_fragment.encoding]
169
+ @previous_fragment_ended_with_breakable = @previous_fragment.match?(breaks.ends_with_break_pattern)
170
+ last_word = @previous_fragment.slice(breaks.last_word_pattern)
171
+ @previous_fragment_output_without_last_word = @previous_fragment.slice(0, @previous_fragment.length - (last_word&.length || 0))
172
+ end
173
+
174
+ def fragment_begins_with_breakable?(fragment)
175
+ fragment.match?(BREAKS_BY_ENCODING[fragment.encoding].begins_with_break_pattern)
176
+ end
177
+
178
+ def line_finished?
179
+ @line_full || paragraph_finished?
180
+ end
181
+
182
+ def update_line_status_based_on_last_output
183
+ @line_contains_more_than_one_word = true if @fragment_output&.match?(BREAKS_BY_ENCODING[@fragment_output.encoding].word_division_pattern)
184
+ end
185
+
186
+ def end_of_the_line_reached(segment)
187
+ update_line_status_based_on_last_output
188
+ wrap_by_char(segment) unless @disable_wrap_by_char || @line_contains_more_than_one_word
189
+ @line_full = true
190
+ end
191
+
192
+ def wrap_by_char(segment)
193
+ segment.each_char do |character|
194
+ character_width = @document.width_of(character)
195
+ break unless @accumulated_width + character_width <= @width
196
+
197
+ @accumulated_width += character_width
198
+ @fragment_output << character
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
4
+ module Text
5
+ module Formatted
6
+ # Turns inline markup (<b>, <i>, <u>, <strikethrough>, <sub>, <sup>, <link>, <a>, <color>, <font>, <br>)
7
+ # into formatted text arrays, and back.
8
+ class Parser
9
+ PARSER_REGEX = Regexp.new(
10
+ "\n|<b>|</b>|<i>|</i>|<u>|</u>|<strikethrough>|</strikethrough>|<sub>|</sub>|<sup>|</sup>|<link[^>]*>|</link>|" \
11
+ "<color[^>]*>|</color>|<font[^>]*>|</font>|<strong>|</strong>|<em>|</em>|<a[^>]*>|</a>|[^<\n]+",
12
+ Regexp::MULTILINE,
13
+ )
14
+ ESCAPE_CHARS = {"&" => "&amp;", ">" => "&gt;", "<" => "&lt;"}.freeze
15
+ UNESCAPE_CHARS = ESCAPE_CHARS.invert.freeze
16
+ ESCAPE_PATTERN = Regexp.union(ESCAPE_CHARS.keys)
17
+ UNESCAPE_PATTERN = Regexp.union(UNESCAPE_CHARS.keys)
18
+ STYLE_BY_OPENING_TAG = {
19
+ "<b>" => :bold, "<strong>" => :bold, "<i>" => :italic, "<em>" => :italic, "<u>" => :underline,
20
+ "<strikethrough>" => :strikethrough, "<sub>" => :subscript, "<sup>" => :superscript,
21
+ }.freeze
22
+ STYLE_BY_CLOSING_TAG = STYLE_BY_OPENING_TAG.transform_keys { it.sub("<", "</") }.freeze
23
+ PREFIX_BY_STYLE = {bold: "<b>", italic: "<i>", underline: "<u>", strikethrough: "<strikethrough>", subscript: "<sub>", superscript: "<sup>"}.freeze
24
+ SUFFIX_BY_STYLE = PREFIX_BY_STYLE.transform_values { it.sub("<", "</") }.freeze
25
+
26
+ def self.format(string, *_arguments)
27
+ array_from_tokens(string.gsub(%r{<br\s*/?>}, "\n").scan(PARSER_REGEX))
28
+ end
29
+
30
+ def self.to_string(array)
31
+ array.map do |hash|
32
+ prefix = +""
33
+ suffix = +""
34
+ hash[:styles]&.each do |style|
35
+ prefix << PREFIX_BY_STYLE[style]
36
+ suffix = SUFFIX_BY_STYLE[style] + suffix
37
+ end
38
+ font = (" name='#{hash[:font]}'" if hash[:font])
39
+ size = (" size='#{hash[:size]}'" if hash[:size])
40
+ character_spacing = (" character_spacing='#{hash[:character_spacing]}'" if hash[:character_spacing])
41
+ if font || size || character_spacing
42
+ prefix << "<font#{font}#{size}#{character_spacing}>"
43
+ suffix = "</font>"
44
+ end
45
+ link = (" href='#{hash[:link]}'" if hash[:link])
46
+ anchor = (" anchor='#{hash[:anchor]}'" if hash[:anchor])
47
+ if link || anchor
48
+ prefix << "<link#{link}#{anchor}>"
49
+ suffix = "</link>"
50
+ end
51
+ if hash[:color]
52
+ prefix <<
53
+ if hash[:color].is_a?(Array)
54
+ c, m, y, k = hash[:color]
55
+ "<color c='#{c}' m='#{m}' y='#{y}' k='#{k}'>"
56
+ else
57
+ "<color rgb='#{hash[:color]}'>"
58
+ end
59
+ suffix = "</color>"
60
+ end
61
+ prefix + escape(hash[:text]) + suffix
62
+ end.join
63
+ end
64
+
65
+ def self.array_paragraphs(array)
66
+ paragraphs = []
67
+ paragraph = []
68
+ previous_string = "\n"
69
+ array.each do |hash|
70
+ hash[:text].scan(/[^\n]+|\n/).each do |string|
71
+ if string == "\n"
72
+ paragraph << hash.merge(text: "\n") if previous_string == "\n"
73
+ paragraphs << paragraph unless paragraph.empty?
74
+ paragraph = []
75
+ else
76
+ paragraph << hash.merge(text: string)
77
+ end
78
+ previous_string = string
79
+ end
80
+ end
81
+ paragraphs << paragraph unless paragraph.empty?
82
+ paragraphs
83
+ end
84
+
85
+ def self.array_from_tokens(tokens)
86
+ array = []
87
+ styles = []
88
+ colors = []
89
+ link = nil
90
+ anchor = nil
91
+ local = nil
92
+ fonts = []
93
+ sizes = []
94
+ character_spacings = []
95
+ tokens.each do |token|
96
+ if (style = STYLE_BY_OPENING_TAG[token])
97
+ styles << style
98
+ elsif (style = STYLE_BY_CLOSING_TAG[token])
99
+ styles.delete(style)
100
+ else
101
+ case token
102
+ when "</link>", "</a>"
103
+ link = anchor = local = nil
104
+ when "</color>"
105
+ colors.pop
106
+ when "</font>"
107
+ fonts.pop
108
+ sizes.pop
109
+ character_spacings.pop
110
+ when /^<link[^>]*>$/, /^<a[^>]*>$/
111
+ link = attribute(token, "href") || link
112
+ anchor = attribute(token, "anchor") || anchor
113
+ local = attribute(token, "local") || local
114
+ when /^<color[^>]*>$/
115
+ rgb = attribute(token, "rgb", hash_prefixed: true)
116
+ colors << rgb if rgb
117
+ cmyk = %w[c m y k].map { attribute(token, it, hash_prefixed: true)&.then { |value| Integer(value, 10) } }
118
+ colors << cmyk if cmyk.all?
119
+ when /^<font[^>]*>$/
120
+ attribute(token, "name")&.then { fonts << it }
121
+ attribute(token, "size")&.then { sizes << Float(it) }
122
+ attribute(token, "character_spacing")&.then { character_spacings << Float(it) }
123
+ else
124
+ array << {
125
+ text: unescape(token), styles: styles.dup, color: colors.last, local:, link:, anchor:,
126
+ font: fonts.last, size: sizes.last, character_spacing: character_spacings.last,
127
+ }
128
+ end
129
+ end
130
+ end
131
+ array
132
+ end
133
+
134
+ def self.attribute(token, name, hash_prefixed: false)
135
+ prefix = hash_prefixed ? "#?" : ""
136
+ token[/\b#{name}="#{prefix}([^"]*)"/, 1] || token[/\b#{name}='#{prefix}([^']*)'/, 1]
137
+ end
138
+
139
+ def self.escape(text)
140
+ text.gsub(ESCAPE_PATTERN, ESCAPE_CHARS)
141
+ end
142
+
143
+ def self.unescape(text)
144
+ text.gsub(UNESCAPE_PATTERN, UNESCAPE_CHARS)
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
4
+ module Text
5
+ module Formatted
6
+ # Lays formatted text out line by line inside a box's width and height.
7
+ module Wrap
8
+ def initialize(_array, options)
9
+ @line_wrap = LineWrap.new
10
+ @arranger = Arranger.new(@document, kerning: options[:kerning])
11
+ @disable_wrap_by_char = options[:disable_wrap_by_char]
12
+ end
13
+
14
+ def wrap(array)
15
+ initialize_wrap(array)
16
+ loop do
17
+ @line_wrap.wrap_line(document: @document, kerning: @kerning, width: available_width, arranger: @arranger,
18
+ disable_wrap_by_char: @disable_wrap_by_char)
19
+ break unless enough_height_for_this_line?
20
+
21
+ move_baseline_down
22
+ print_line
23
+ break if @single_line || @arranger.finished?
24
+ end
25
+ @text = @printed_lines.join("\n")
26
+ @everything_printed = @arranger.finished?
27
+ @arranger.unconsumed
28
+ end
29
+
30
+ private
31
+
32
+ def print_line
33
+ @nothing_printed = false
34
+ printed_fragments = []
35
+ fragments_this_line = []
36
+ word_spacing = word_spacing_for_this_line
37
+ @arranger.fragments.each do |fragment|
38
+ fragment.word_spacing = word_spacing
39
+ if fragment.text == "\n"
40
+ printed_fragments << "\n" if @printed_lines.last == ""
41
+ break
42
+ end
43
+ printed_fragments << fragment.text
44
+ fragments_this_line << fragment
45
+ end
46
+ @arranger.fragments.replace([])
47
+
48
+ accumulated_width = 0
49
+ drawn_fragments = []
50
+ fragments_this_line.reverse! if @direction == :rtl
51
+ fragments_this_line.each do |fragment|
52
+ fragment.default_direction = @direction
53
+ drawn_fragment = [fragment, accumulated_width, @line_wrap.width, word_spacing]
54
+ format_and_draw_fragment(*drawn_fragment)
55
+ drawn_fragments << drawn_fragment if @laid_out_lines
56
+ accumulated_width += fragment.width
57
+ end
58
+ @laid_out_lines << [@baseline_y, drawn_fragments] if @laid_out_lines
59
+ @printed_lines << printed_fragments.map { it.dup.force_encoding(::Encoding::UTF_8) }.join
60
+ end
61
+
62
+ def word_spacing_for_this_line
63
+ return 0 unless @align == :justify && @line_wrap.space_count.positive? && !@line_wrap.paragraph_finished?
64
+
65
+ (available_width - @line_wrap.width) / @line_wrap.space_count
66
+ end
67
+
68
+ # Slack in points, so floating point sums do not drop a line that fits exactly.
69
+ HEIGHT_TOLERANCE = 0.0001
70
+
71
+ def enough_height_for_this_line?
72
+ @line_height = @arranger.max_line_height
73
+ @descender = @arranger.max_descender
74
+ @ascender = @arranger.max_ascender
75
+ line_extent = @baseline_y.zero? ? @ascender + @descender : @descender + @line_height + @leading
76
+ return true if @baseline_y.abs + line_extent <= @height + HEIGHT_TOLERANCE
77
+
78
+ @arranger.repack_unretrieved
79
+ false
80
+ end
81
+
82
+ def initialize_wrap(array)
83
+ @text = nil
84
+ @arranger.format_array = array
85
+ @line_height = 0
86
+ @descender = 0
87
+ @ascender = 0
88
+ @baseline_y = 0
89
+ @printed_lines = []
90
+ @laid_out_lines&.clear
91
+ @nothing_printed = true
92
+ @everything_printed = false
93
+ end
94
+
95
+ def format_and_draw_fragment(fragment, accumulated_width, line_width, word_spacing)
96
+ @arranger.apply_color_and_font_settings(fragment) do
97
+ draw_fragment(fragment, accumulated_width, line_width, word_spacing)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end