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,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
4
+ module Text
5
+ NBSP = " "
6
+ ZWSP = "​"
7
+ SHY = "­"
8
+
9
+ VALID_OPTIONS = %i[kerning size style].freeze
10
+ MODES = {fill: 0, stroke: 1, fill_stroke: 2, invisible: 3, fill_clip: 4, stroke_clip: 5, fill_stroke_clip: 6, clip: 7}.freeze
11
+
12
+ class BadFontFamily < StandardError
13
+ def initialize(message = "Bad font family")
14
+ super
15
+ end
16
+ end
17
+
18
+ def text(string, options = {})
19
+ return false if string.nil?
20
+
21
+ options = options.dup
22
+ inline_format = options.delete(:inline_format)
23
+ array =
24
+ if inline_format
25
+ text_formatter.format(string, *(inline_format.is_a?(Array) ? inline_format : []))
26
+ else
27
+ [{text: string}]
28
+ end
29
+ formatted_text(array, options)
30
+ end
31
+
32
+ def formatted_text(array, options = {})
33
+ options = inspect_options_for_text(options.dup)
34
+ color = options.delete(:color)
35
+ array = array.map { it[:color] ? it : it.merge(color:) } if color
36
+
37
+ if @indent_paragraphs
38
+ text_formatter.array_paragraphs(array).each do |paragraph|
39
+ remaining_text = draw_indented_formatted_line(paragraph, options)
40
+ if @no_text_printed && !@all_text_printed
41
+ @bounding_box.move_past_bottom
42
+ remaining_text = draw_indented_formatted_line(paragraph, options)
43
+ end
44
+ next if @all_text_printed
45
+
46
+ remaining_text = fill_formatted_text_box(remaining_text, options)
47
+ draw_remaining_formatted_text_on_new_pages(remaining_text, options)
48
+ end
49
+ else
50
+ remaining_text = fill_formatted_text_box(array, options)
51
+ draw_remaining_formatted_text_on_new_pages(remaining_text, options)
52
+ end
53
+ end
54
+
55
+ def text_box(string, options = {})
56
+ options = options.dup
57
+ options[:document] = self
58
+ inline_format = options.delete(:inline_format)
59
+ box =
60
+ if inline_format
61
+ Formatted::Box.new(text_formatter.format(string, *(inline_format.is_a?(Array) ? inline_format : [])), options)
62
+ else
63
+ Box.new(string, options)
64
+ end
65
+ box.render
66
+ end
67
+
68
+ def formatted_text_box(array, options = {})
69
+ Formatted::Box.new(array, options.merge(document: self)).render
70
+ end
71
+
72
+ def height_of(string, options = {})
73
+ height_of_formatted([{text: string}], options)
74
+ end
75
+
76
+ def height_of_formatted(array, options = {})
77
+ raise NotImplementedError, ":indent_paragraphs option not available with height_of" if options[:indent_paragraphs]
78
+
79
+ process_final_gap_option(options)
80
+ box = Formatted::Box.new(array, options.merge(height: 100_000_000, document: self))
81
+ box.render(dry_run: true)
82
+ @final_gap ? box.height + box.line_gap + box.leading : box.height
83
+ end
84
+
85
+ def draw_text(text, options)
86
+ options = inspect_options_for_draw_text(options.dup)
87
+ text = text.to_s.dup
88
+ save_font do
89
+ process_text_options(options)
90
+ text = font.normalize_encoding(text)
91
+ font_size(options[:size]) { draw_text!(text, options) }
92
+ end
93
+ end
94
+
95
+ def draw_text!(text, options)
96
+ unless font.unicode? || font.class.hide_m17n_warning || text.ascii_only?
97
+ warn "PDF's built-in fonts have very limited support for internationalized text.\n" \
98
+ "If you need full UTF-8 support, consider using an external font instead.\n\n" \
99
+ "To disable this warning, add the following line to your code:\nPrawn::Fonts::AFM.hide_m17n_warning = true\n"
100
+ font.class.hide_m17n_warning = true
101
+ end
102
+ x, y = map_to_absolute(options[:at])
103
+ add_text_content(text, x, y, options)
104
+ end
105
+
106
+ def process_text_options(options)
107
+ if options[:style]
108
+ raise BadFontFamily unless font.family
109
+
110
+ font(font.family, style: options[:style])
111
+ end
112
+ options[:kerning] = font.has_kerning_data? unless options[:kerning] == false
113
+ options[:size] ||= font_size
114
+ end
115
+
116
+ def default_kerning?
117
+ return true unless defined?(@default_kerning)
118
+
119
+ @default_kerning
120
+ end
121
+
122
+ def default_kerning(value)
123
+ @default_kerning = value
124
+ end
125
+ alias default_kerning= default_kerning
126
+
127
+ def default_leading(number = nil)
128
+ return @default_leading || 0 if number.nil?
129
+
130
+ @default_leading = number
131
+ end
132
+ alias default_leading= default_leading
133
+
134
+ def text_direction(direction = nil)
135
+ return @text_direction || :ltr if direction.nil?
136
+
137
+ @text_direction = direction
138
+ end
139
+ alias text_direction= text_direction
140
+
141
+ def fallback_fonts(fallback_fonts = nil)
142
+ return @fallback_fonts || [] if fallback_fonts.nil?
143
+
144
+ @fallback_fonts = fallback_fonts
145
+ end
146
+ alias fallback_fonts= fallback_fonts
147
+
148
+ def text_rendering_mode(mode = nil, &)
149
+ return @text_rendering_mode || :fill if mode.nil?
150
+ raise ArgumentError, "mode must be between one of #{MODES.keys.join(", ")} (#{mode})" unless MODES.key?(mode)
151
+ return yield if text_rendering_mode == mode
152
+
153
+ wrap_and_restore(:@text_rendering_mode, mode, -> { "\n#{MODES[text_rendering_mode]} Tr" }, &)
154
+ end
155
+
156
+ def forget_text_rendering_mode!
157
+ @text_rendering_mode = :unknown
158
+ end
159
+
160
+ def character_spacing(amount = nil, &)
161
+ return @character_spacing || 0 if amount.nil?
162
+ return yield if character_spacing == amount
163
+
164
+ wrap_and_restore(:@character_spacing, amount, -> { "\n#{PDF.real(character_spacing)} Tc" }, &)
165
+ end
166
+
167
+ def word_spacing(amount = nil, &)
168
+ return @word_spacing || 0 if amount.nil?
169
+ return yield if word_spacing == amount
170
+
171
+ wrap_and_restore(:@word_spacing, amount, -> { "\n#{PDF.real(word_spacing)} Tw" }, &)
172
+ end
173
+
174
+ def horizontal_text_scaling(amount = nil, &)
175
+ return @horizontal_text_scaling || 100 if amount.nil?
176
+ return yield if horizontal_text_scaling == amount
177
+
178
+ wrap_and_restore(:@horizontal_text_scaling, amount, -> { "\n#{PDF.real(horizontal_text_scaling)} Tz" }, &)
179
+ end
180
+
181
+ def rise(amount = nil, &)
182
+ return @rise || 0 if amount.nil?
183
+ return yield if rise == amount
184
+
185
+ wrap_and_restore(:@rise, amount, -> { "\n#{PDF.real(rise)} Ts" }, &)
186
+ end
187
+
188
+ def add_text_content(text, x, y, options)
189
+ chunks = font.encode_text(text, options.merge(word_spacing:, font_size:))
190
+ add_content("\nBT")
191
+ if options[:rotate]
192
+ radians = Float(options[:rotate]) * Math::PI / 180
193
+ add_content("#{PDF.real_params([Math.cos(radians), Math.sin(radians), -Math.sin(radians), Math.cos(radians), x, y])} Tm")
194
+ else
195
+ add_content("#{PDF.real(x)} #{PDF.real(y)} Td")
196
+ end
197
+ chunks.each do |subset, string|
198
+ font.add_to_current_page(subset)
199
+ add_content("#{PDF.serialize(font.identifier_for(subset), true)} #{PDF.serialize(font_size, true)} Tf")
200
+ operator = string.is_a?(Array) ? "TJ" : "Tj"
201
+ add_content("#{PDF.serialize(string, true)} #{operator}")
202
+ end
203
+ add_content("ET\n")
204
+ end
205
+
206
+ private
207
+
208
+ def draw_remaining_formatted_text_on_new_pages(remaining_text, options)
209
+ until remaining_text.empty?
210
+ @bounding_box.move_past_bottom
211
+ previous_remaining_text = remaining_text
212
+ remaining_text = fill_formatted_text_box(remaining_text, options)
213
+ break if remaining_text == previous_remaining_text
214
+ end
215
+ end
216
+
217
+ def draw_indented_formatted_line(string, options)
218
+ gap = options.fetch(:direction, text_direction) == :ltr ? [@indent_paragraphs, 0] : [0, @indent_paragraphs]
219
+ indent(*gap) { fill_formatted_text_box(string, options.merge(single_line: true)) }
220
+ end
221
+
222
+ def fill_formatted_text_box(text, options)
223
+ merge_text_box_positioning_options(options)
224
+ box = Formatted::Box.new(text, options)
225
+ remaining_text = box.render
226
+ @no_text_printed = box.nothing_printed?
227
+ @all_text_printed = box.everything_printed?
228
+ self.y -= box.height
229
+ self.y -= box.line_gap + box.leading if @final_gap || (options[:single_line] && !@all_text_printed)
230
+ remaining_text
231
+ end
232
+
233
+ def merge_text_box_positioning_options(options)
234
+ bottom = @bounding_box.stretchy? ? @margin_box.absolute_bottom : @bounding_box.absolute_bottom
235
+ options[:height] = y - bottom
236
+ options[:width] = bounds.width
237
+ options[:at] = [@bounding_box.left_side - @bounding_box.absolute_left, y - @bounding_box.absolute_bottom]
238
+ end
239
+
240
+ def inspect_options_for_text(options)
241
+ raise ArgumentError, ":at is no longer a valid option with text.use draw_text or text_box instead" if options[:at]
242
+
243
+ process_final_gap_option(options)
244
+ @indent_paragraphs = options.delete(:indent_paragraphs)
245
+ options[:document] = self
246
+ options
247
+ end
248
+
249
+ def process_final_gap_option(options)
250
+ @final_gap = options[:final_gap].nil? || options[:final_gap]
251
+ options.delete(:final_gap)
252
+ end
253
+
254
+ def move_text_position(amount)
255
+ bottom = @bounding_box.stretchy? ? @margin_box.absolute_bottom : @bounding_box.absolute_bottom
256
+ @bounding_box.move_past_bottom if (y - amount) < bottom
257
+ self.y -= amount
258
+ end
259
+
260
+ def wrap_and_restore(variable, value, operator)
261
+ original_value = instance_variable_get(variable)
262
+ instance_variable_set(variable, value)
263
+ add_content(operator.call)
264
+ begin
265
+ yield
266
+ ensure
267
+ instance_variable_set(variable, original_value)
268
+ add_content(operator.call)
269
+ end
270
+ end
271
+
272
+ def inspect_options_for_draw_text(options)
273
+ raise ArgumentError, "The :at option is required for draw_text" if options[:at].nil?
274
+ raise ArgumentError, "The :align option does not work with draw_text" if options[:align]
275
+
276
+ options[:kerning] = default_kerning? if options[:kerning].nil?
277
+ PrawnX.verify_options(VALID_OPTIONS + %i[at rotate], options)
278
+ options
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
4
+ # The current transformation matrix, tracked alongside the graphic state so gradients and soft masks can be
5
+ # placed in page space.
6
+ module TransformationStack
7
+ IDENTITY = [1, 0, 0, 1, 0, 0].freeze
8
+
9
+ def add_to_transformation_stack(a, b, c, d, e, f)
10
+ @transformation_stack ||= [IDENTITY.dup]
11
+ @transformation_stack.push(multiply(@transformation_stack.last, [a, b, c, d, e, f]))
12
+ end
13
+
14
+ def save_transformation_stack
15
+ @transformation_stack ||= [IDENTITY.dup]
16
+ @saved_transformation_stack_lengths ||= []
17
+ @saved_transformation_stack_lengths.push(@transformation_stack.length)
18
+ end
19
+
20
+ def restore_transformation_stack
21
+ return if @saved_transformation_stack_lengths.nil? || @saved_transformation_stack_lengths.empty?
22
+
23
+ @transformation_stack.pop(@transformation_stack.length - @saved_transformation_stack_lengths.pop)
24
+ end
25
+
26
+ def current_transformation_matrix_with_translation(x = 0, y = 0)
27
+ a, b, c, d, e, f = (@transformation_stack || [IDENTITY]).last
28
+ [a, b, c, d, (a * x) + (c * y) + e, (b * x) + (d * y) + f].map { Float(it) }
29
+ end
30
+
31
+ private
32
+
33
+ def multiply(first, second)
34
+ a1, b1, c1, d1, e1, f1 = first
35
+ a2, b2, c2, d2, e2, f2 = second
36
+ [
37
+ (a2 * a1) + (b2 * c1), (a2 * b1) + (b2 * d1),
38
+ (c2 * a1) + (d2 * c1), (c2 * b1) + (d2 * d1),
39
+ (e2 * a1) + (f2 * c1) + e1, (e2 * b1) + (f2 * d1) + f1,
40
+ ]
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
4
+ module TrueType
5
+ # A Compact Font Format program (Adobe Technical Note #5176) keeping only the outlines a document uses: other
6
+ # glyphs become an empty charstring, so glyph indices and subroutines stay where they were.
7
+ class CffSubset
8
+ ENDCHAR = "\x0E".b
9
+ CHARSTRINGS = [17].freeze
10
+ PRIVATE = [18].freeze
11
+ CHARSET = [15].freeze
12
+ ENCODING = [16].freeze
13
+ FD_ARRAY = [12, 36].freeze
14
+ FD_SELECT = [12, 37].freeze
15
+ # Offsets 0 to 2 of charset and Encoding name predefined tables, not places in the font.
16
+ PREDEFINED_TABLE_LIMIT = 2
17
+ FIVE_BYTE_INTEGER = 29
18
+
19
+ def initialize(cff)
20
+ @cff = cff.b
21
+ end
22
+
23
+ def bytes(glyphs_kept)
24
+ build(glyphs_kept)
25
+ rescue *File::MALFORMED_FONT_ERRORS => exception
26
+ raise Error, "Malformed CFF font program: #{exception.message}"
27
+ end
28
+
29
+ private
30
+
31
+ def build(glyphs_kept)
32
+ header_size = @cff.getbyte(2)
33
+ _names, names_end = read_index(header_size)
34
+ top_dicts, top_end = read_index(names_end)
35
+ top_operators = parse_dict(top_dicts.first)
36
+
37
+ old_charstrings_offset = operands(top_operators, CHARSTRINGS).first
38
+ charstrings, = read_index(old_charstrings_offset)
39
+ kept = glyphs_kept.to_h { [it, true] }
40
+ new_charstrings = charstrings.each_with_index.map { |charstring, glyph| kept[glyph] ? charstring : ENDCHAR }
41
+
42
+ font_dicts = nil
43
+ if (fd_array_offset = operands(top_operators, FD_ARRAY).first)
44
+ font_dicts = read_index(fd_array_offset).first.map { parse_dict(it) }
45
+ end
46
+
47
+ # Every offset operand is written as a five-byte integer, so the new Top DICT's size is known before its
48
+ # offsets are: everything after it then moves by the same delta.
49
+ placeholder_top = encode_dict(relocated(top_operators, 0, 0, 0))
50
+ delta = index([placeholder_top]).bytesize - (top_end - names_end)
51
+ tail = @cff.byteslice(top_end..)
52
+ charstrings_offset = top_end + delta + tail.bytesize
53
+ appended = index(new_charstrings)
54
+ fd_array_new_offset = nil
55
+ if font_dicts
56
+ fd_array_new_offset = charstrings_offset + appended.bytesize
57
+ appended += index(font_dicts.map { encode_dict(relocated(it, delta, nil, nil)) })
58
+ end
59
+ new_top = encode_dict(relocated(top_operators, delta, charstrings_offset, fd_array_new_offset))
60
+ @cff.byteslice(0, names_end) + index([new_top]) + tail + appended
61
+ end
62
+
63
+ def read_index(offset)
64
+ count = @cff.unpack1("@#{offset} n")
65
+ return [[], offset + 2] if count.zero?
66
+
67
+ offset_size = @cff.getbyte(offset + 2)
68
+ offsets = Array.new(count + 1) { read_offset(offset + 3 + (it * offset_size), offset_size) }
69
+ data_start = offset + 3 + ((count + 1) * offset_size) - 1
70
+ items = offsets.each_cons(2).map { |first, last| @cff.byteslice(data_start + first, last - first) }
71
+ [items, data_start + offsets.last]
72
+ end
73
+
74
+ def read_offset(position, size)
75
+ @cff.byteslice(position, size).bytes.inject(0) { (_1 << 8) | _2 }
76
+ end
77
+
78
+ def index(items)
79
+ return [0].pack("n") if items.empty?
80
+
81
+ offsets = items.each_with_object([1]) { |item, list| list << (list.last + item.bytesize) }
82
+ offset_size = [1, 2, 3, 4].find { offsets.last < (1 << (8 * it)) }
83
+ packed_offsets = offsets.map { [it].pack("N").byteslice(4 - offset_size, offset_size) }.join
84
+ [items.size].pack("n") + offset_size.chr + packed_offsets + items.join.b
85
+ end
86
+
87
+ # [operator bytes, operand values, raw operand bytes] in order.
88
+ def parse_dict(data)
89
+ entries = []
90
+ operands = []
91
+ raw_start = 0
92
+ position = 0
93
+ while position < data.bytesize
94
+ byte = data.getbyte(position)
95
+ if byte <= 21
96
+ operator = byte == 12 ? [12, data.getbyte(position + 1)] : [byte]
97
+ position += operator.size
98
+ entries << [operator, operands, data.byteslice(raw_start, position - operator.size - raw_start)]
99
+ operands = []
100
+ raw_start = position
101
+ else
102
+ value, size = read_operand(data, position)
103
+ operands << value
104
+ position += size
105
+ end
106
+ end
107
+ entries
108
+ end
109
+
110
+ def read_operand(data, position)
111
+ byte = data.getbyte(position)
112
+ case byte
113
+ when 28 then [data.unpack1("@#{position + 1} s>"), 3]
114
+ when 29 then [data.unpack1("@#{position + 1} l>"), 5]
115
+ when 30
116
+ size = 1
117
+ loop do
118
+ nibbles = data.getbyte(position + size)
119
+ size += 1
120
+ break if (nibbles >> 4) == 0x0F || (nibbles & 0x0F) == 0x0F
121
+ end
122
+ [:real, size]
123
+ when 32..246 then [byte - 139, 1]
124
+ when 247..250 then [((byte - 247) * 256) + data.getbyte(position + 1) + 108, 2]
125
+ when 251..254 then [(-(byte - 251) * 256) - data.getbyte(position + 1) - 108, 2]
126
+ else raise Error, "Unexpected byte #{byte} in a CFF dictionary"
127
+ end
128
+ end
129
+
130
+ def operands(entries, operator)
131
+ entries.find { it[0] == operator }&.dig(1) || []
132
+ end
133
+
134
+ # Offsets into the font move by delta; CharStrings and FDArray point at their new copies at the end.
135
+ def relocated(entries, delta, charstrings_offset, fd_array_offset)
136
+ entries.map do |operator, values, raw|
137
+ new_values =
138
+ case operator
139
+ when CHARSTRINGS then charstrings_offset && [charstrings_offset]
140
+ when FD_ARRAY then fd_array_offset && [fd_array_offset]
141
+ when PRIVATE then [values[0], values[1] + delta]
142
+ when CHARSET, ENCODING then [values[0] > PREDEFINED_TABLE_LIMIT ? values[0] + delta : values[0]]
143
+ when FD_SELECT then [values[0] + delta]
144
+ end
145
+ [operator, new_values, raw]
146
+ end
147
+ end
148
+
149
+ def encode_dict(entries)
150
+ entries.each_with_object(+"".b) do |(operator, new_values, raw), dict|
151
+ dict << (new_values ? new_values.map { [FIVE_BYTE_INTEGER, it].pack("Cl>") }.join : raw)
152
+ dict << operator.pack("C*")
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end