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.
- checksums.yaml +7 -0
- data/COPYING +2 -0
- data/GPLv2 +339 -0
- data/GPLv3 +674 -0
- data/LICENSE +65 -0
- data/LICENSE-prawn-svg +21 -0
- data/README.md +107 -0
- data/data/fonts/Courier-Bold.afm +342 -0
- data/data/fonts/Courier-BoldOblique.afm +342 -0
- data/data/fonts/Courier-Oblique.afm +342 -0
- data/data/fonts/Courier.afm +342 -0
- data/data/fonts/Helvetica-Bold.afm +2827 -0
- data/data/fonts/Helvetica-BoldOblique.afm +2827 -0
- data/data/fonts/Helvetica-Oblique.afm +3051 -0
- data/data/fonts/Helvetica.afm +3051 -0
- data/data/fonts/MustRead.html +19 -0
- data/data/fonts/Symbol.afm +213 -0
- data/data/fonts/Times-Bold.afm +2588 -0
- data/data/fonts/Times-BoldItalic.afm +2384 -0
- data/data/fonts/Times-Italic.afm +2667 -0
- data/data/fonts/Times-Roman.afm +2419 -0
- data/data/fonts/ZapfDingbats.afm +225 -0
- data/lib/pdf/core.rb +4 -0
- data/lib/prawn/measurement_extensions.rb +4 -0
- data/lib/prawn/svg/attributes/clip_path.rb +46 -0
- data/lib/prawn/svg/attributes/mask.rb +16 -0
- data/lib/prawn/svg/attributes/opacity.rb +30 -0
- data/lib/prawn/svg/attributes/space.rb +10 -0
- data/lib/prawn/svg/attributes/stroke.rb +50 -0
- data/lib/prawn/svg/attributes/transform.rb +10 -0
- data/lib/prawn/svg/attributes.rb +6 -0
- data/lib/prawn/svg/calculators/arc_to_bezier_curve.rb +57 -0
- data/lib/prawn/svg/calculators/aspect_ratio.rb +60 -0
- data/lib/prawn/svg/calculators/document_sizing.rb +103 -0
- data/lib/prawn/svg/calculators/path_length.rb +168 -0
- data/lib/prawn/svg/calculators/pixels.rb +57 -0
- data/lib/prawn/svg/color.rb +240 -0
- data/lib/prawn/svg/css/font_face_parser.rb +72 -0
- data/lib/prawn/svg/css/font_family_parser.rb +39 -0
- data/lib/prawn/svg/css/font_parser.rb +46 -0
- data/lib/prawn/svg/css/selector_parser.rb +178 -0
- data/lib/prawn/svg/css/stylesheets.rb +233 -0
- data/lib/prawn/svg/css/values_parser.rb +68 -0
- data/lib/prawn/svg/document.rb +170 -0
- data/lib/prawn/svg/elements/anchor.rb +10 -0
- data/lib/prawn/svg/elements/base.rb +312 -0
- data/lib/prawn/svg/elements/bbox_scaling.rb +78 -0
- data/lib/prawn/svg/elements/call_duplicator.rb +37 -0
- data/lib/prawn/svg/elements/circle.rb +25 -0
- data/lib/prawn/svg/elements/clip_path.rb +26 -0
- data/lib/prawn/svg/elements/container.rb +10 -0
- data/lib/prawn/svg/elements/direct_render_base.rb +27 -0
- data/lib/prawn/svg/elements/ellipse.rb +22 -0
- data/lib/prawn/svg/elements/gradient.rb +184 -0
- data/lib/prawn/svg/elements/ignored.rb +5 -0
- data/lib/prawn/svg/elements/image.rb +177 -0
- data/lib/prawn/svg/elements/line.rb +27 -0
- data/lib/prawn/svg/elements/marker.rb +72 -0
- data/lib/prawn/svg/elements/mask.rb +65 -0
- data/lib/prawn/svg/elements/path.rb +294 -0
- data/lib/prawn/svg/elements/pattern.rb +150 -0
- data/lib/prawn/svg/elements/polygon.rb +25 -0
- data/lib/prawn/svg/elements/polyline.rb +23 -0
- data/lib/prawn/svg/elements/rect.rb +64 -0
- data/lib/prawn/svg/elements/root.rb +22 -0
- data/lib/prawn/svg/elements/switch.rb +92 -0
- data/lib/prawn/svg/elements/text.rb +99 -0
- data/lib/prawn/svg/elements/text_component.rb +346 -0
- data/lib/prawn/svg/elements/text_node.rb +308 -0
- data/lib/prawn/svg/elements/text_path.rb +153 -0
- data/lib/prawn/svg/elements/use.rb +142 -0
- data/lib/prawn/svg/elements/viewport.rb +29 -0
- data/lib/prawn/svg/elements.rb +46 -0
- data/lib/prawn/svg/extension.rb +25 -0
- data/lib/prawn/svg/font.rb +20 -0
- data/lib/prawn/svg/font_metrics.rb +144 -0
- data/lib/prawn/svg/font_registry.rb +315 -0
- data/lib/prawn/svg/funciri.rb +14 -0
- data/lib/prawn/svg/gradient_renderer.rb +332 -0
- data/lib/prawn/svg/gradients.rb +53 -0
- data/lib/prawn/svg/interface.rb +72 -0
- data/lib/prawn/svg/length.rb +43 -0
- data/lib/prawn/svg/link_renderer.rb +40 -0
- data/lib/prawn/svg/loaders/data.rb +17 -0
- data/lib/prawn/svg/loaders/file.rb +111 -0
- data/lib/prawn/svg/loaders/web.rb +24 -0
- data/lib/prawn/svg/paint.rb +67 -0
- data/lib/prawn/svg/pathable.rb +153 -0
- data/lib/prawn/svg/pattern_renderer.rb +112 -0
- data/lib/prawn/svg/pdfmatrix.rb +37 -0
- data/lib/prawn/svg/percentage.rb +24 -0
- data/lib/prawn/svg/properties.rb +329 -0
- data/lib/prawn/svg/renderer.rb +249 -0
- data/lib/prawn/svg/state.rb +24 -0
- data/lib/prawn/svg/transform_parser.rb +78 -0
- data/lib/prawn/svg/ttc.rb +35 -0
- data/lib/prawn/svg/ttf.rb +95 -0
- data/lib/prawn/svg/url_loader.rb +46 -0
- data/lib/prawn/svg/version.rb +5 -0
- data/lib/prawn/table.rb +4 -0
- data/lib/prawn-svg.rb +53 -0
- data/lib/prawn.rb +10 -0
- data/lib/prawnx/annotations.rb +21 -0
- data/lib/prawnx/document/bounding_box.rb +193 -0
- data/lib/prawnx/document/column_box.rb +91 -0
- data/lib/prawnx/document/internals.rb +144 -0
- data/lib/prawnx/document/span.rb +25 -0
- data/lib/prawnx/document.rb +253 -0
- data/lib/prawnx/encoding.rb +29 -0
- data/lib/prawnx/errors.rb +37 -0
- data/lib/prawnx/font.rb +214 -0
- data/lib/prawnx/fonts/afm.rb +171 -0
- data/lib/prawnx/fonts/otf.rb +50 -0
- data/lib/prawnx/fonts/ttf.rb +410 -0
- data/lib/prawnx/graphic_state.rb +41 -0
- data/lib/prawnx/graphics/color.rb +148 -0
- data/lib/prawnx/graphics/patterns.rb +100 -0
- data/lib/prawnx/graphics/style.rb +171 -0
- data/lib/prawnx/graphics.rb +208 -0
- data/lib/prawnx/grid.rb +216 -0
- data/lib/prawnx/images/image.rb +244 -0
- data/lib/prawnx/images/png_decoder.rb +65 -0
- data/lib/prawnx/images.rb +115 -0
- data/lib/prawnx/measurement_extensions.rb +40 -0
- data/lib/prawnx/measurements.rb +62 -0
- data/lib/prawnx/outline.rb +146 -0
- data/lib/prawnx/page.rb +125 -0
- data/lib/prawnx/page_geometry.rb +27 -0
- data/lib/prawnx/pdf/outline_node.rb +53 -0
- data/lib/prawnx/pdf/reference.rb +31 -0
- data/lib/prawnx/pdf/writer.rb +105 -0
- data/lib/prawnx/pdf.rb +66 -0
- data/lib/prawnx/pdf_a/srgb_profile.rb +61 -0
- data/lib/prawnx/pdf_a.rb +130 -0
- data/lib/prawnx/pdf_core.rb +25 -0
- data/lib/prawnx/repeater.rb +79 -0
- data/lib/prawnx/security.rb +108 -0
- data/lib/prawnx/stamp.rb +74 -0
- data/lib/prawnx/table/cell/kinds.rb +246 -0
- data/lib/prawnx/table/cell.rb +331 -0
- data/lib/prawnx/table/cells.rb +208 -0
- data/lib/prawnx/table.rb +296 -0
- data/lib/prawnx/text/box.rb +16 -0
- data/lib/prawnx/text/formatted/arranger.rb +217 -0
- data/lib/prawnx/text/formatted/box.rb +312 -0
- data/lib/prawnx/text/formatted/fragment.rb +205 -0
- data/lib/prawnx/text/formatted/line_wrap.rb +204 -0
- data/lib/prawnx/text/formatted/parser.rb +149 -0
- data/lib/prawnx/text/formatted/wrap.rb +103 -0
- data/lib/prawnx/text.rb +281 -0
- data/lib/prawnx/transformation_stack.rb +43 -0
- data/lib/prawnx/true_type/cff_subset.rb +157 -0
- data/lib/prawnx/true_type/file.rb +371 -0
- data/lib/prawnx/true_type/open_type_layout.rb +189 -0
- data/lib/prawnx/true_type/simple_subsets.rb +94 -0
- data/lib/prawnx/true_type/subset.rb +187 -0
- data/lib/prawnx/version.rb +7 -0
- data/lib/prawnx/view.rb +28 -0
- data/lib/prawnx.rb +102 -0
- metadata +251 -0
data/lib/prawnx/grid.rb
ADDED
|
@@ -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
|