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,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
4
+ class Document
5
+ def define_grid(options = {})
6
+ @boxes = nil
7
+ @grid = Grid.new(self, options)
8
+ end
9
+
10
+ def grid(*arguments)
11
+ @boxes ||= {}
12
+ @boxes[arguments] ||=
13
+ if arguments.empty?
14
+ @grid
15
+ else
16
+ first, second = arguments
17
+ if first.is_a?(Array) && second.is_a?(Array) && first.length == 2 && second.length == 2
18
+ MultiBox.new(self, GridBox.new(self, *first), GridBox.new(self, *second))
19
+ else
20
+ GridBox.new(self, first, second)
21
+ end
22
+ end
23
+ end
24
+
25
+ # Rows and columns of equal size dividing the current bounds, separated by gutters.
26
+ class Grid
27
+ attr_reader :pdf, :columns, :rows, :gutter, :row_gutter, :column_gutter
28
+
29
+ def initialize(pdf, options = {})
30
+ PrawnX.verify_options(%i[columns rows gutter row_gutter column_gutter], options)
31
+ @pdf = pdf
32
+ @columns = options[:columns]
33
+ @rows = options[:rows]
34
+ if options.key?(:gutter)
35
+ @gutter = Float(options[:gutter])
36
+ @row_gutter = @gutter
37
+ @column_gutter = @gutter
38
+ else
39
+ @row_gutter = Float(options[:row_gutter])
40
+ @column_gutter = Float(options[:column_gutter])
41
+ @gutter = 0
42
+ end
43
+ end
44
+
45
+ def column_width
46
+ @column_width ||= subdivide(pdf.bounds.width, columns, column_gutter)
47
+ end
48
+
49
+ def row_height
50
+ @row_height ||= subdivide(pdf.bounds.height, rows, row_gutter)
51
+ end
52
+
53
+ def show_all(color = "CCCCCC")
54
+ rows.times { |row| columns.times { |column| pdf.grid(row, column).show(color) } }
55
+ end
56
+
57
+ private
58
+
59
+ def subdivide(total, count, gutter)
60
+ (Float(total) - (gutter * Float(count - 1))) / Float(count)
61
+ end
62
+ end
63
+
64
+ # What a grid cell and a span of cells share: corners, a bounding box, and a way to show themselves.
65
+ module GridArea
66
+ def top_left
67
+ [left, top]
68
+ end
69
+
70
+ def top_right
71
+ [right, top]
72
+ end
73
+
74
+ def bottom_left
75
+ [left, bottom]
76
+ end
77
+
78
+ def bottom_right
79
+ [right, bottom]
80
+ end
81
+
82
+ def bounding_box(&)
83
+ pdf.bounding_box(top_left, width:, height:, &)
84
+ end
85
+
86
+ def show(grid_color = "CCCCCC")
87
+ bounding_box do
88
+ original_stroke_color = pdf.stroke_color
89
+ pdf.stroke_color = grid_color
90
+ pdf.text(name)
91
+ pdf.stroke_bounds
92
+ pdf.stroke_color = original_stroke_color
93
+ end
94
+ end
95
+ end
96
+
97
+ class GridBox
98
+ include GridArea
99
+
100
+ attr_reader :pdf
101
+
102
+ def initialize(pdf, rows, columns)
103
+ @pdf = pdf
104
+ @rows = rows
105
+ @columns = columns
106
+ end
107
+
108
+ def name
109
+ "#{@rows},#{@columns}"
110
+ end
111
+
112
+ def total_height
113
+ Float(pdf.bounds.height)
114
+ end
115
+
116
+ def width
117
+ Float(grid.column_width)
118
+ end
119
+
120
+ def height
121
+ Float(grid.row_height)
122
+ end
123
+
124
+ def gutter
125
+ Float(grid.gutter)
126
+ end
127
+
128
+ def left
129
+ @left ||= (width + grid.column_gutter) * Float(@columns)
130
+ end
131
+
132
+ def right
133
+ @right ||= left + width
134
+ end
135
+
136
+ def top
137
+ @top ||= total_height - ((height + grid.row_gutter) * Float(@rows))
138
+ end
139
+
140
+ def bottom
141
+ @bottom ||= top - height
142
+ end
143
+
144
+ private
145
+
146
+ def grid
147
+ pdf.grid
148
+ end
149
+ end
150
+
151
+ class MultiBox
152
+ include GridArea
153
+
154
+ attr_reader :pdf
155
+
156
+ def initialize(pdf, box1, box2)
157
+ @pdf = pdf
158
+ @boxes = [box1, box2]
159
+ end
160
+
161
+ def name
162
+ @boxes.map(&:name).join(":")
163
+ end
164
+
165
+ def total_height
166
+ @boxes[0].total_height
167
+ end
168
+
169
+ def width
170
+ right_box.right - left_box.left
171
+ end
172
+
173
+ def height
174
+ top_box.top - bottom_box.bottom
175
+ end
176
+
177
+ def gutter
178
+ @boxes[0].gutter
179
+ end
180
+
181
+ def left
182
+ left_box.left
183
+ end
184
+
185
+ def right
186
+ right_box.right
187
+ end
188
+
189
+ def top
190
+ top_box.top
191
+ end
192
+
193
+ def bottom
194
+ bottom_box.bottom
195
+ end
196
+
197
+ private
198
+
199
+ def left_box
200
+ @left_box ||= @boxes.min_by(&:left)
201
+ end
202
+
203
+ def right_box
204
+ @right_box ||= @boxes.max_by(&:right)
205
+ end
206
+
207
+ def top_box
208
+ @top_box ||= @boxes.max_by(&:top)
209
+ end
210
+
211
+ def bottom_box
212
+ @bottom_box ||= @boxes.min_by(&:bottom)
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
4
+ module Images
5
+ class Image
6
+ attr_accessor :scaled_width, :scaled_height
7
+
8
+ def calc_image_dimensions(options)
9
+ w = options[:width] || width
10
+ h = options[:height] || height
11
+ if options[:width] && !options[:height]
12
+ ratio = w / Float(width)
13
+ w = width * ratio
14
+ h = height * ratio
15
+ elsif options[:height] && !options[:width]
16
+ ratio = h / Float(height)
17
+ w = width * ratio
18
+ h = height * ratio
19
+ elsif options[:scale]
20
+ w = width * options[:scale]
21
+ h = height * options[:scale]
22
+ elsif options[:fit]
23
+ box_width, box_height = options[:fit]
24
+ if width / Float(height) > box_width / Float(box_height)
25
+ w = box_width
26
+ h = box_width / (width / Float(height))
27
+ else
28
+ h = box_height
29
+ w = box_height * (width / Float(height))
30
+ end
31
+ end
32
+ self.scaled_width = w
33
+ self.scaled_height = h
34
+ [w, h]
35
+ end
36
+ end
37
+
38
+ class JPG < Image
39
+ class FormatError < StandardError
40
+ end
41
+
42
+ START_OF_FRAME_MARKERS = [0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF].freeze
43
+ MARKER_PREFIX = 0xFF
44
+ COLOR_SPACE_BY_CHANNELS = {1 => :DeviceGray, 3 => :DeviceRGB, 4 => :DeviceCMYK}.freeze
45
+ # Adobe's CMYK JPEGs store inverted values.
46
+ INVERTED_CMYK_DECODE = [1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0].freeze
47
+
48
+ attr_reader :width, :height, :bits, :channels
49
+
50
+ def self.can_render?(image_blob)
51
+ image_blob.byteslice(0, 3).bytes == [255, 216, 255]
52
+ end
53
+
54
+ def initialize(data)
55
+ super()
56
+ @data = data
57
+ position = 2
58
+ loop do
59
+ marker, code, length = data.byteslice(position, 4)&.unpack("CCn")
60
+ raise FormatError, "JPEG marker not found!" if marker != MARKER_PREFIX
61
+
62
+ if START_OF_FRAME_MARKERS.include?(code)
63
+ @bits, @height, @width, @channels = data.unpack("@#{position + 4} CnnC")
64
+ break
65
+ end
66
+ position += 2 + length
67
+ end
68
+ end
69
+
70
+ def build_pdf_object(document)
71
+ color_space = COLOR_SPACE_BY_CHANNELS.fetch(channels) { raise ArgumentError, "JPG uses an unsupported number of channels" }
72
+ dictionary = {Type: :XObject, Subtype: :Image, ColorSpace: color_space, BitsPerComponent: bits, Width: width, Height: height, Filter: :DCTDecode}
73
+ dictionary[:Decode] = INVERTED_CMYK_DECODE if color_space == :DeviceCMYK
74
+ document.ref!(dictionary, @data)
75
+ end
76
+ end
77
+
78
+ class PNG < Image
79
+ SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10].freeze
80
+ GRAYSCALE = 0
81
+ TRUE_COLOR = 2
82
+ INDEXED = 3
83
+ GRAYSCALE_ALPHA = 4
84
+ TRUE_COLOR_ALPHA = 6
85
+ PNG_PREDICTORS = 15
86
+ # Compressed input fed at a time: at zlib's highest ratio, 4 KB inflate to about 4 MB before the size check.
87
+ INFLATE_CHUNK_BYTES = 4096
88
+ MAXIMUM_IMAGE_BYTES = 1 << 30
89
+
90
+ attr_reader :palette, :img_data, :transparency, :width, :height, :bits, :color_type, :compression_method,
91
+ :filter_method, :interlace_method, :alpha_channel
92
+
93
+ def self.can_render?(image_blob)
94
+ image_blob.byteslice(0, 8).bytes == SIGNATURE
95
+ end
96
+
97
+ def initialize(data)
98
+ super()
99
+ @palette = +"".b
100
+ compressed = +"".b
101
+ @transparency = {}
102
+ position = 8
103
+ loop do
104
+ raise Errors::UnsupportedImageType, "Truncated PNG" if position + 8 > data.bytesize
105
+
106
+ length, section = data.unpack("@#{position} N a4")
107
+ raise Errors::UnsupportedImageType, "Truncated PNG #{section} chunk" if position + 8 + length > data.bytesize
108
+
109
+ chunk = data.byteslice(position + 8, length)
110
+ case section
111
+ when "IHDR"
112
+ @width, @height, @bits, @color_type, @compression_method, @filter_method, @interlace_method = chunk.unpack("NNCCCCC")
113
+ when "PLTE" then @palette << chunk
114
+ when "IDAT" then compressed << chunk
115
+ when "tRNS"
116
+ @transparency =
117
+ case @color_type
118
+ when INDEXED then {palette: chunk.unpack("C*")}
119
+ when GRAYSCALE then {grayscale: chunk.unpack1("n")}
120
+ when TRUE_COLOR then {rgb: chunk.unpack("nnn")}
121
+ else {}
122
+ end
123
+ when "IEND" then break
124
+ end
125
+ position += 12 + length
126
+ end
127
+ raise Errors::UnsupportedImageType, "PNG without a valid IHDR chunk" unless @width.to_i.positive? && @height.to_i.positive? && @bits.to_i.positive?
128
+
129
+ @compressed_data = compressed
130
+ end
131
+
132
+ # Inflated up to the size the header announces, never more: a few kilobytes of crafted data could otherwise
133
+ # inflate into gigabytes.
134
+ def img_data
135
+ @img_data ||= begin
136
+ samples = (colors || 1) + (alpha_channel? && color_type != INDEXED ? 1 : 0)
137
+ expected_bytes = height * (1 + (((width * bits * samples) + 7) / 8))
138
+ raise Errors::UnsupportedImageType, "PNG of #{width}x#{height} pixels is too large" if expected_bytes > MAXIMUM_IMAGE_BYTES
139
+
140
+ inflater = Zlib::Inflate.new
141
+ inflated = +"".b
142
+ (0...@compressed_data.bytesize).step(INFLATE_CHUNK_BYTES) do |offset|
143
+ inflated << inflater.inflate(@compressed_data.byteslice(offset, INFLATE_CHUNK_BYTES))
144
+ raise Errors::UnsupportedImageType, "PNG data inflates beyond its #{width}x#{height} size" if inflated.bytesize > expected_bytes
145
+ end
146
+ inflated << inflater.finish
147
+ inflated
148
+ rescue Zlib::Error => exception
149
+ raise Errors::UnsupportedImageType, "Corrupt PNG data: #{exception.message}"
150
+ ensure
151
+ inflater&.close
152
+ end
153
+ end
154
+
155
+ def colors
156
+ case color_type
157
+ when GRAYSCALE, INDEXED, GRAYSCALE_ALPHA then 1
158
+ when TRUE_COLOR, TRUE_COLOR_ALPHA then 3
159
+ end
160
+ end
161
+
162
+ def alpha_channel?
163
+ return true if color_type == GRAYSCALE_ALPHA || color_type == TRUE_COLOR_ALPHA
164
+ return @transparency.any? if color_type == INDEXED
165
+
166
+ false
167
+ end
168
+
169
+ def min_pdf_version
170
+ if bits > 8 then 1.5
171
+ elsif alpha_channel? then 1.4
172
+ else 1.0
173
+ end
174
+ end
175
+
176
+ def build_pdf_object(document)
177
+ raise Errors::UnsupportedImageType, "PNG uses an unsupported compression method" if compression_method != 0
178
+ raise Errors::UnsupportedImageType, "PNG uses an unsupported filter method" if filter_method != 0
179
+ raise Errors::UnsupportedImageType, "PNG uses unsupported interlace method" if interlace_method != 0
180
+
181
+ dictionary = {Type: :XObject, Subtype: :Image, Height: height, Width: width, BitsPerComponent: bits}
182
+ if color_type == INDEXED && alpha_channel?
183
+ index_samples, alpha_samples = split_palette_transparency
184
+ image = document.ref!(dictionary, index_samples)
185
+ image.data[:SMask] = document.ref!(alpha_dictionary(8), alpha_samples)
186
+ elsif alpha_channel?
187
+ color_rows, alpha_rows = split_alpha_channel
188
+ image = document.ref!(dictionary.merge(DecodeParms: predictors(colors)), color_rows)
189
+ image.data[:SMask] = document.ref!(alpha_dictionary(bits).merge(DecodeParms: predictors(1)), alpha_rows)
190
+ else
191
+ image = document.ref!(dictionary.merge(DecodeParms: predictors(colors)), img_data)
192
+ end
193
+ image.data[:ColorSpace] = palette.empty? ? (colors == 1 ? :DeviceGray : :DeviceRGB) : [:Indexed, :DeviceRGB, (palette.bytesize / 3) - 1, document.ref!({}, palette)]
194
+ if transparency[:grayscale]
195
+ image.data[:Mask] = [transparency[:grayscale]] * 2
196
+ elsif transparency[:rgb]
197
+ image.data[:Mask] = transparency[:rgb].flat_map { [it, it] }
198
+ end
199
+ image
200
+ end
201
+
202
+ private
203
+
204
+ # The reader undoes the PNG filters itself; the stream gets compressed again when the document is written.
205
+ def predictors(color_count)
206
+ {Predictor: PNG_PREDICTORS, Colors: color_count, BitsPerComponent: bits, Columns: width}
207
+ end
208
+
209
+ def alpha_dictionary(bits_per_component)
210
+ {Type: :XObject, Subtype: :Image, Height: height, Width: width, BitsPerComponent: bits_per_component, ColorSpace: :DeviceGray, Decode: [0, 1]}
211
+ end
212
+
213
+ # Color and alpha rows, each keeping its filter byte: PNG filters predict a byte from the same channel of
214
+ # the neighbouring pixels, which stays at the same relative place once the channels are apart.
215
+ def split_alpha_channel
216
+ sample_bytes = bits / 8
217
+ color_bytes = colors * sample_bytes
218
+ row_bytes = (width * (color_bytes + sample_bytes)) + 1
219
+ pixel_layout = "a#{color_bytes}a#{sample_bytes}" * width
220
+ color = +"".b
221
+ alpha = +"".b
222
+ (img_data.bytesize / row_bytes).times do |row_index|
223
+ start = row_index * row_bytes
224
+ filter = img_data.byteslice(start, 1)
225
+ samples = img_data.byteslice(start + 1, row_bytes - 1).unpack(pixel_layout)
226
+ color << filter << samples.each_slice(2).map(&:first).join
227
+ alpha << filter << samples.each_slice(2).map(&:last).join
228
+ end
229
+ [color, alpha]
230
+ end
231
+
232
+ # Palette indices and their opacity: indices only mean something once the rows are unfiltered.
233
+ def split_palette_transparency
234
+ rows = PngDecoder.unfilter(img_data, ((width * bits) + 7) / 8, 1)
235
+ alpha_by_index = Array.new(256) { transparency[:palette][it] || 0xFF }
236
+ alpha = rows.map do |row|
237
+ indices = bits == 8 ? row.bytes : row.unpack1("B*").scan(/.{#{bits}}/).first(width).map { it.to_i(2) }
238
+ indices.map { alpha_by_index[it] }.pack("C*")
239
+ end
240
+ [rows.join, alpha.join]
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
4
+ module Images
5
+ # Undoes PNG's per-row filters (PNG specification §9), giving back raw samples: needed to split an alpha
6
+ # channel or a palette's transparency into a separate soft mask.
7
+ module PngDecoder
8
+ NONE = 0
9
+ SUB = 1
10
+ UP = 2
11
+ AVERAGE = 3
12
+ PAETH = 4
13
+
14
+ module_function
15
+
16
+ # Rows of unfiltered bytes, from the inflated data of a non-interlaced image.
17
+ def unfilter(data, row_bytes, bytes_per_pixel)
18
+ previous = ("\x00" * row_bytes).b
19
+ row_count = data.bytesize / (row_bytes + 1)
20
+ Array.new(row_count) do |row_index|
21
+ start = row_index * (row_bytes + 1)
22
+ filter = data.getbyte(start)
23
+ row = data.byteslice(start + 1, row_bytes).b
24
+ unfilter_row!(row, previous, filter, bytes_per_pixel)
25
+ previous = row
26
+ end
27
+ end
28
+
29
+ def unfilter_row!(row, previous, filter, bytes_per_pixel)
30
+ case filter
31
+ when NONE then nil
32
+ when SUB
33
+ (bytes_per_pixel...row.bytesize).each { row.setbyte(it, (row.getbyte(it) + row.getbyte(it - bytes_per_pixel)) & 0xFF) }
34
+ when UP
35
+ row.bytesize.times { row.setbyte(it, (row.getbyte(it) + previous.getbyte(it)) & 0xFF) }
36
+ when AVERAGE
37
+ row.bytesize.times do |index|
38
+ left = index >= bytes_per_pixel ? row.getbyte(index - bytes_per_pixel) : 0
39
+ row.setbyte(index, (row.getbyte(index) + ((left + previous.getbyte(index)) >> 1)) & 0xFF)
40
+ end
41
+ when PAETH
42
+ row.bytesize.times do |index|
43
+ left = index >= bytes_per_pixel ? row.getbyte(index - bytes_per_pixel) : 0
44
+ up = previous.getbyte(index)
45
+ up_left = index >= bytes_per_pixel ? previous.getbyte(index - bytes_per_pixel) : 0
46
+ row.setbyte(index, (row.getbyte(index) + paeth(left, up, up_left)) & 0xFF)
47
+ end
48
+ else raise Errors::UnsupportedImageType, "PNG uses an unknown filter type #{filter}"
49
+ end
50
+ row
51
+ end
52
+
53
+ def paeth(left, up, up_left)
54
+ estimate = left + up - up_left
55
+ distance_left = (estimate - left).abs
56
+ distance_up = (estimate - up).abs
57
+ distance_up_left = (estimate - up_left).abs
58
+ if distance_left <= distance_up && distance_left <= distance_up_left then left
59
+ elsif distance_up <= distance_up_left then up
60
+ else up_left
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+ require "pathname"
5
+
6
+ module PrawnX
7
+ def self.image_handler
8
+ @image_handler ||= ImageHandler.new
9
+ end
10
+
11
+ # The image formats PrawnX knows, tried in order on an image's first bytes.
12
+ class ImageHandler
13
+ def initialize
14
+ @handlers = []
15
+ end
16
+
17
+ def register(handler)
18
+ @handlers.delete(handler)
19
+ @handlers.push(handler)
20
+ end
21
+
22
+ def register!(handler)
23
+ @handlers.delete(handler)
24
+ @handlers.unshift(handler)
25
+ end
26
+
27
+ def unregister(handler)
28
+ @handlers.delete(handler)
29
+ end
30
+
31
+ def find(image_blob)
32
+ @handlers.find { it.can_render?(image_blob) } || raise(Errors::UnsupportedImageType, "image file is an unrecognised format")
33
+ end
34
+ end
35
+
36
+ module Images
37
+ def image(file, options = {})
38
+ PrawnX.verify_options(%i[at position vposition height width scale fit], options)
39
+ pdf_object, info = build_image_object(file)
40
+ embed_image(pdf_object, info, options)
41
+ info
42
+ end
43
+
44
+ # The same image bytes are embedded once per document, however many times they are placed.
45
+ def build_image_object(file)
46
+ image_content = verify_and_read_image(file)
47
+ entry = image_registry[Digest::SHA1.hexdigest(image_content)] ||= begin
48
+ info = PrawnX.image_handler.find(image_content).new(image_content)
49
+ min_version(info.min_pdf_version) if info.respond_to?(:min_pdf_version)
50
+ {obj: info.build_pdf_object(self), info:}
51
+ end
52
+ [entry[:obj], entry[:info]]
53
+ end
54
+
55
+ def embed_image(pdf_object, info, options)
56
+ width, height = info.calc_image_dimensions(options)
57
+ if options[:at]
58
+ x, y = map_to_absolute(options[:at])
59
+ else
60
+ x, y = image_position(width, height, options)
61
+ move_text_position(height)
62
+ end
63
+ label = "I#{@image_counter = (@image_counter || 0) + 1}"
64
+ page.xobjects[label] = pdf_object
65
+ add_content("\nq\n#{PDF.real_params([width, 0, 0, height, x, y - height])} cm\n/#{label} Do\nQ")
66
+ end
67
+
68
+ private
69
+
70
+ def verify_and_read_image(io_or_path)
71
+ if io_or_path.respond_to?(:rewind)
72
+ io_or_path.rewind
73
+ io_or_path.binmode if io_or_path.respond_to?(:binmode)
74
+ return io_or_path.read
75
+ end
76
+ path = Pathname.new(io_or_path)
77
+ raise ArgumentError, "#{path} not found" unless path.file?
78
+
79
+ path.binread
80
+ end
81
+
82
+ def image_position(width, height, options)
83
+ options[:position] ||= :left
84
+ y =
85
+ case options[:vposition]
86
+ when :top then bounds.absolute_top
87
+ when :center then bounds.absolute_top - ((bounds.height - height) / 2.0)
88
+ when :bottom then bounds.absolute_bottom + height
89
+ when Numeric then bounds.absolute_top - options[:vposition]
90
+ else determine_y_with_page_flow(height)
91
+ end
92
+ x =
93
+ case options[:position]
94
+ when :left then bounds.left_side
95
+ when :center then bounds.left_side + ((bounds.width - width) / 2.0)
96
+ when :right then bounds.right_side - width
97
+ when Numeric then options[:position] + bounds.left_side
98
+ end
99
+ [x, y]
100
+ end
101
+
102
+ def determine_y_with_page_flow(height)
103
+ bounds.move_past_bottom if overruns_page?(height)
104
+ y
105
+ end
106
+
107
+ def overruns_page?(height)
108
+ (y - height) < reference_bounds.absolute_bottom
109
+ end
110
+
111
+ def image_registry
112
+ @image_registry ||= {}
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "measurements"
4
+
5
+ # 10.mm, 2.cm, 1.in…: lengths in PDF points.
6
+ class Numeric
7
+ include PrawnX::Measurements
8
+
9
+ def mm
10
+ mm2pt(self)
11
+ end
12
+
13
+ def cm
14
+ cm2pt(self)
15
+ end
16
+
17
+ def dm
18
+ dm2pt(self)
19
+ end
20
+
21
+ def m
22
+ m2pt(self)
23
+ end
24
+
25
+ def in
26
+ in2pt(self)
27
+ end
28
+
29
+ def yd
30
+ yd2pt(self)
31
+ end
32
+
33
+ def ft
34
+ ft2pt(self)
35
+ end
36
+
37
+ def pt
38
+ pt2pt(self)
39
+ end
40
+ end