stationery 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +145 -0
  5. data/lib/stationery/builder.rb +61 -0
  6. data/lib/stationery/canvas/path.rb +82 -0
  7. data/lib/stationery/canvas/text.rb +55 -0
  8. data/lib/stationery/canvas.rb +121 -0
  9. data/lib/stationery/color.rb +62 -0
  10. data/lib/stationery/component.rb +54 -0
  11. data/lib/stationery/document.rb +95 -0
  12. data/lib/stationery/elements.rb +90 -0
  13. data/lib/stationery/errors.rb +11 -0
  14. data/lib/stationery/fonts/cmap.rb +75 -0
  15. data/lib/stationery/fonts/family.rb +51 -0
  16. data/lib/stationery/fonts/font.rb +149 -0
  17. data/lib/stationery/fonts/font_book.rb +36 -0
  18. data/lib/stationery/fonts/name_table.rb +39 -0
  19. data/lib/stationery/fonts/registry.rb +38 -0
  20. data/lib/stationery/fonts/subset.rb +139 -0
  21. data/lib/stationery/fonts/true_type.rb +174 -0
  22. data/lib/stationery/geometry.rb +56 -0
  23. data/lib/stationery/images/cache.rb +32 -0
  24. data/lib/stationery/images/images.rb +45 -0
  25. data/lib/stationery/images/jpeg.rb +53 -0
  26. data/lib/stationery/images/png.rb +119 -0
  27. data/lib/stationery/images/scanlines.rb +56 -0
  28. data/lib/stationery/layout/box.rb +134 -0
  29. data/lib/stationery/layout/flow.rb +121 -0
  30. data/lib/stationery/layout/image.rb +53 -0
  31. data/lib/stationery/layout/leaves.rb +58 -0
  32. data/lib/stationery/layout/node.rb +46 -0
  33. data/lib/stationery/layout/paginator.rb +41 -0
  34. data/lib/stationery/layout/positioned.rb +24 -0
  35. data/lib/stationery/layout/row.rb +71 -0
  36. data/lib/stationery/layout/table/cell.rb +93 -0
  37. data/lib/stationery/layout/table/selection.rb +52 -0
  38. data/lib/stationery/layout/table/widths.rb +53 -0
  39. data/lib/stationery/layout/table.rb +118 -0
  40. data/lib/stationery/layout/text.rb +62 -0
  41. data/lib/stationery/page.rb +47 -0
  42. data/lib/stationery/page_templates.rb +36 -0
  43. data/lib/stationery/pdf/assembler.rb +53 -0
  44. data/lib/stationery/pdf/serializer.rb +56 -0
  45. data/lib/stationery/pdf/stream.rb +27 -0
  46. data/lib/stationery/pdf/types.rb +15 -0
  47. data/lib/stationery/pdf/writer.rb +59 -0
  48. data/lib/stationery/rails.rb +20 -0
  49. data/lib/stationery/resources.rb +34 -0
  50. data/lib/stationery/text/entities.rb +23 -0
  51. data/lib/stationery/text/line.rb +26 -0
  52. data/lib/stationery/text/markup.rb +103 -0
  53. data/lib/stationery/text/paragraph.rb +98 -0
  54. data/lib/stationery/text/runs_builder.rb +52 -0
  55. data/lib/stationery/text/style.rb +46 -0
  56. data/lib/stationery/text/wrapper.rb +131 -0
  57. data/lib/stationery/version.rb +5 -0
  58. data/lib/stationery.rb +58 -0
  59. metadata +108 -0
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Fonts
5
+ # Builds a TrueType font holding only the given glyphs (plus the parts of
6
+ # composite glyphs and .notdef), renumbered compactly from zero.
7
+ module Subset
8
+ HINTING_TABLES = ["cvt ", "fpgm", "prep"].freeze
9
+ CHECKSUM_MAGIC = 0xB1B0AFBA
10
+
11
+ module_function
12
+
13
+ # Returns [font_bytes, { original_gid => subset_gid }].
14
+ def build(ttf, gids)
15
+ glyphs = closure(ttf, Set.new(gids) << 0).sort
16
+ mapping = glyphs.each_with_index.to_h
17
+ glyf, loca = glyph_tables(ttf, glyphs, mapping)
18
+
19
+ tables = {
20
+ "head" => head(ttf), "hhea" => patch(ttf, "hhea", 34, glyphs.size),
21
+ "maxp" => patch(ttf, "maxp", 4, glyphs.size), "hmtx" => hmtx(ttf, glyphs),
22
+ "loca" => loca.pack("N*"), "glyf" => glyf
23
+ }
24
+ HINTING_TABLES.each { |tag| tables[tag] = ttf.table_data(tag) if ttf.table?(tag) }
25
+ tables["post"] = post(ttf) if ttf.table?("post")
26
+
27
+ [assemble(tables), mapping]
28
+ end
29
+
30
+ def checksum(data)
31
+ (data + padding(data)).unpack("N*").sum & 0xFFFFFFFF
32
+ end
33
+
34
+ def closure(ttf, gids)
35
+ glyphs = Set.new
36
+ queue = gids.to_a
37
+ until queue.empty?
38
+ gid = queue.pop
39
+ next if gid >= ttf.num_glyphs || !glyphs.add?(gid)
40
+
41
+ each_component(ttf.glyph_data(gid)) { |_, component| queue << component }
42
+ end
43
+ glyphs
44
+ end
45
+
46
+ def glyph_tables(ttf, glyphs, mapping)
47
+ glyf = String.new(encoding: Encoding::BINARY)
48
+ loca = glyphs.map do |gid|
49
+ offset = glyf.bytesize
50
+ data = remap_components(ttf.glyph_data(gid), mapping)
51
+ glyf << data << padding(data)
52
+ offset
53
+ end
54
+ [glyf, loca << glyf.bytesize]
55
+ end
56
+
57
+ def head(ttf)
58
+ head = ttf.table_data("head").dup
59
+ head[8, 4] = [0].pack("N") # checkSumAdjustment, filled in by assemble
60
+ head[50, 2] = [1].pack("n") # indexToLocFormat: long offsets
61
+ head
62
+ end
63
+
64
+ def patch(ttf, tag, offset, count)
65
+ data = ttf.table_data(tag).dup
66
+ data[offset, 2] = [count].pack("n")
67
+ data
68
+ end
69
+
70
+ def hmtx(ttf, glyphs)
71
+ glyphs.flat_map { |gid| [ttf.advance(gid), ttf.left_side_bearing(gid)] }.pack("ns>" * glyphs.size)
72
+ end
73
+
74
+ # Version 3.0: metrics only, no glyph names.
75
+ def post(ttf)
76
+ post = ttf.table_data("post").byteslice(0, 32).dup
77
+ post[0, 4] = [0x00030000].pack("N")
78
+ post
79
+ end
80
+
81
+ def remap_components(data, mapping)
82
+ copy = nil
83
+ each_component(data) do |pos, component|
84
+ copy ||= data.dup
85
+ copy[pos, 2] = [mapping.fetch(component, 0)].pack("n")
86
+ end
87
+ copy || data
88
+ end
89
+
90
+ # Yields the byte offset of each component glyph id in a composite glyph.
91
+ def each_component(data)
92
+ return if data.bytesize < 10 || data.unpack1("s>") >= 0
93
+
94
+ pos = 10
95
+ loop do
96
+ flags, component = data.byteslice(pos, 4).unpack("nn")
97
+ yield pos + 2, component
98
+ pos += 4 + component_arguments_size(flags)
99
+ break if flags.nobits?(0x0020) # MORE_COMPONENTS
100
+ end
101
+ end
102
+
103
+ def component_arguments_size(flags)
104
+ size = flags.allbits?(0x0001) ? 4 : 2 # ARG_1_AND_2_ARE_WORDS
105
+ if flags.allbits?(0x0008) then size + 2 # WE_HAVE_A_SCALE
106
+ elsif flags.allbits?(0x0040) then size + 4 # WE_HAVE_AN_X_AND_Y_SCALE
107
+ elsif flags.allbits?(0x0080) then size + 8 # WE_HAVE_A_TWO_BY_TWO
108
+ else size
109
+ end
110
+ end
111
+
112
+ def assemble(tables)
113
+ tags = tables.keys.sort
114
+ entry_selector = Math.log2(tags.size).floor
115
+ search_range = (2**entry_selector) * 16
116
+ directory = [0x00010000, tags.size, search_range, entry_selector, (tags.size * 16) - search_range]
117
+ .pack("Nnnnn")
118
+ body = String.new(encoding: Encoding::BINARY)
119
+ body_offset = 12 + (tags.size * 16)
120
+ head_offset = nil
121
+
122
+ tags.each do |tag|
123
+ data = tables[tag]
124
+ head_offset = body_offset + body.bytesize if tag == "head"
125
+ directory << [tag, checksum(data), body_offset + body.bytesize, data.bytesize].pack("a4NNN")
126
+ body << data << padding(data)
127
+ end
128
+
129
+ font = directory << body
130
+ font[head_offset + 8, 4] = [(CHECKSUM_MAGIC - checksum(font)) & 0xFFFFFFFF].pack("N")
131
+ font
132
+ end
133
+
134
+ def padding(data)
135
+ "\0".b * (-data.bytesize % 4)
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Fonts
5
+ # Reads the tables of a TrueType (.ttf) font needed to measure text and to
6
+ # embed a subset of it. Static TrueType outlines only: CFF (.otf), font
7
+ # collections and web font wrappers are rejected with a named reason.
8
+ class TrueType
9
+ REQUIRED_TABLES = %w[head hhea maxp hmtx loca glyf].freeze
10
+ SIGNATURES = {
11
+ "OTTO" => "OpenType fonts with CFF outlines (.otf) are not supported, use a TrueType (.ttf) font",
12
+ "ttcf" => "TrueType collections (.ttc) are not supported, use a single TrueType (.ttf) font",
13
+ "wOFF" => "WOFF web fonts are not supported, use the TrueType (.ttf) file",
14
+ "wOF2" => "WOFF2 web fonts are not supported, use the TrueType (.ttf) file"
15
+ }.freeze
16
+
17
+ attr_reader :data, :tables, :units_per_em, :bbox, :ascender, :descender, :line_gap, :num_glyphs,
18
+ :number_of_hmetrics, :italic_angle, :underline_position, :underline_thickness,
19
+ :strikeout_position, :strikeout_size, :cap_height, :x_height, :weight, :postscript_name
20
+
21
+ # A subset embedded in a PDF carries no cmap (the PDF maps glyphs itself),
22
+ # so reading one back passes `cmap: false`.
23
+ def initialize(data, cmap: true)
24
+ @data = data.b
25
+ check_signature
26
+ read_table_directory(cmap)
27
+ parse_head
28
+ parse_hhea
29
+ parse_maxp
30
+ parse_hmtx
31
+ parse_loca
32
+ @cmap = cmap ? Cmap.parse(self) : {}
33
+ parse_post
34
+ parse_os2
35
+ @postscript_name = NameTable.postscript_name(self)
36
+ end
37
+
38
+ def inspect = "#<#{self.class} #{@postscript_name} glyphs=#{@num_glyphs}>"
39
+
40
+ def glyph_id(codepoint)
41
+ @cmap.fetch(codepoint, 0)
42
+ end
43
+
44
+ def glyph?(char)
45
+ char.each_codepoint.all? { |codepoint| glyph_id(codepoint).positive? }
46
+ end
47
+
48
+ def advance(gid)
49
+ @advances[gid] || @advances.last
50
+ end
51
+
52
+ def left_side_bearing(gid)
53
+ if gid < @number_of_hmetrics
54
+ i16(@hmtx_offset + (gid * 4) + 2)
55
+ else
56
+ i16(@hmtx_offset + (@number_of_hmetrics * 4) + ((gid - @number_of_hmetrics) * 2))
57
+ end
58
+ end
59
+
60
+ def fixed_pitch?
61
+ @fixed_pitch
62
+ end
63
+
64
+ def table?(tag)
65
+ @tables.key?(tag)
66
+ end
67
+
68
+ def table_offset(tag)
69
+ @tables[tag]&.first
70
+ end
71
+
72
+ def table_data(tag)
73
+ offset, length = @tables.fetch(tag)
74
+ @data.byteslice(offset, length)
75
+ end
76
+
77
+ def glyph_data(gid)
78
+ return "".b if gid >= @num_glyphs
79
+
80
+ @data.byteslice(@glyf_offset + @loca[gid], @loca[gid + 1] - @loca[gid])
81
+ end
82
+
83
+ def u16(offset) = @data.byteslice(offset, 2).unpack1("n")
84
+ def i16(offset) = @data.byteslice(offset, 2).unpack1("s>")
85
+ def u32(offset) = @data.byteslice(offset, 4).unpack1("N")
86
+
87
+ private
88
+
89
+ def check_signature
90
+ signature = @data.byteslice(0, 4)
91
+ return if ["\x00\x01\x00\x00".b, "true"].include?(signature)
92
+
93
+ raise UnsupportedFont, SIGNATURES.fetch(signature, "not a TrueType font")
94
+ end
95
+
96
+ def read_table_directory(cmap)
97
+ @tables = {}
98
+ u16(4).times do |i|
99
+ record = 12 + (i * 16)
100
+ @tables[@data.byteslice(record, 4)] = [u32(record + 8), u32(record + 12)]
101
+ end
102
+ required = cmap ? REQUIRED_TABLES + ["cmap"] : REQUIRED_TABLES
103
+ missing = required.reject { |tag| @tables.key?(tag) }
104
+ raise UnsupportedFont, "font is missing the #{missing.join(", ")} table" if missing.any?
105
+ end
106
+
107
+ def parse_head
108
+ t = table_offset("head")
109
+ @units_per_em = u16(t + 18)
110
+ @bbox = [i16(t + 36), i16(t + 38), i16(t + 40), i16(t + 42)]
111
+ @index_to_loc_format = i16(t + 50)
112
+ end
113
+
114
+ def parse_hhea
115
+ t = table_offset("hhea")
116
+ @ascender = i16(t + 4)
117
+ @descender = i16(t + 6)
118
+ @line_gap = i16(t + 8)
119
+ @number_of_hmetrics = u16(t + 34)
120
+ end
121
+
122
+ def parse_maxp
123
+ @num_glyphs = u16(table_offset("maxp") + 4)
124
+ end
125
+
126
+ def parse_hmtx
127
+ @hmtx_offset = table_offset("hmtx")
128
+ @advances = @data.byteslice(@hmtx_offset, @number_of_hmetrics * 4).unpack("n*").each_slice(2).map(&:first)
129
+ end
130
+
131
+ def parse_loca
132
+ t = table_offset("loca")
133
+ @loca = if @index_to_loc_format.zero?
134
+ @data.byteslice(t, (@num_glyphs + 1) * 2).unpack("n*").map { |offset| offset * 2 }
135
+ else
136
+ @data.byteslice(t, (@num_glyphs + 1) * 4).unpack("N*")
137
+ end
138
+ @glyf_offset = table_offset("glyf")
139
+ end
140
+
141
+ def parse_post
142
+ if (t = table_offset("post"))
143
+ @italic_angle = i16(t + 4) + (u16(t + 6) / 65_536.0)
144
+ @underline_position = i16(t + 8)
145
+ @underline_thickness = i16(t + 10)
146
+ @fixed_pitch = u32(t + 12) != 0
147
+ else
148
+ @italic_angle = 0
149
+ @underline_position = -@units_per_em / 10
150
+ @underline_thickness = @units_per_em / 20
151
+ @fixed_pitch = false
152
+ end
153
+ end
154
+
155
+ def parse_os2
156
+ if (t = table_offset("OS/2"))
157
+ @weight = u16(t + 4)
158
+ @strikeout_size = i16(t + 26)
159
+ @strikeout_position = i16(t + 28)
160
+ if u16(t) >= 2
161
+ @x_height = i16(t + 86)
162
+ @cap_height = i16(t + 88)
163
+ end
164
+ end
165
+
166
+ @weight ||= 400
167
+ @strikeout_size ||= @underline_thickness
168
+ @strikeout_position ||= (@ascender * 0.25).round
169
+ @x_height ||= (@ascender * 0.5).round
170
+ @parse_os2 ||= (@ascender * 0.7).round
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # A rectangle in top-left page coordinates, in points.
5
+ Rect = Data.define(:x, :y, :width, :height) do
6
+ def right = x + width
7
+ def bottom = y + height
8
+
9
+ def inset(top, right, bottom, left)
10
+ Rect.new(x + left, y + top, [width - left - right, 0].max, [height - top - bottom, 0].max)
11
+ end
12
+ end
13
+
14
+ module Geometry
15
+ SIDES = { top: 0, right: 1, bottom: 2, left: 3 }.freeze
16
+
17
+ module_function
18
+
19
+ # CSS-style shorthand to [top, right, bottom, left].
20
+ def box(value)
21
+ case value
22
+ when nil then [0, 0, 0, 0]
23
+ when Numeric then [value] * 4
24
+ when Hash then from_hash(value)
25
+ when Array then from_array(value)
26
+ else raise ArgumentError, "not a box: #{value.inspect}"
27
+ end
28
+ end
29
+
30
+ def from_array(value)
31
+ case value.size
32
+ when 1 then value * 4
33
+ when 2 then value * 2
34
+ when 3 then [value[0], value[1], value[2], value[1]]
35
+ when 4 then value.dup
36
+ else raise ArgumentError, "a box takes 1 to 4 values, got #{value.size}"
37
+ end
38
+ end
39
+
40
+ def from_hash(value)
41
+ sides = [0, 0, 0, 0]
42
+ sides[0] = sides[2] = value[:y] if value.key?(:y)
43
+ sides[1] = sides[3] = value[:x] if value.key?(:x)
44
+ SIDES.each { |name, index| sides[index] = value[name] if value.key?(name) }
45
+ sides
46
+ end
47
+
48
+ def align_offset(align, available, used)
49
+ case align
50
+ when :center then (available - used) / 2.0
51
+ when :right then available - used
52
+ else 0
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/md5"
4
+
5
+ module Stationery
6
+ module Images
7
+ # Process-wide cache of decoded images keyed by content digest, so a logo
8
+ # drawn on every invoice is parsed once. Decoded images are immutable after
9
+ # construction apart from memoized derived data, which is deterministic.
10
+ module Cache
11
+ SIZE = 16
12
+ @cache = {}
13
+ @mutex = Mutex.new
14
+
15
+ class << self
16
+ def fetch(data)
17
+ key = Digest::MD5.digest(data)
18
+ @mutex.synchronize do
19
+ image = @cache.delete(key) || yield
20
+ @cache[key] = image
21
+ @cache.shift while @cache.size > SIZE
22
+ image
23
+ end
24
+ end
25
+
26
+ def clear
27
+ @mutex.synchronize { @cache.clear }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # JPEG and PNG images, embedded as image XObjects.
5
+ module Images
6
+ PNG_SIGNATURE = "\x89PNG\r\n\x1A\n".b
7
+ JPEG_SIGNATURE = "\xFF\xD8".b
8
+ UNSUPPORTED = {
9
+ "GIF8" => "GIF", "RIFF" => "WebP", "II*\0" => "TIFF", "MM\0*" => "TIFF", "BM" => "BMP"
10
+ }.freeze
11
+
12
+ module_function
13
+
14
+ # Accepts a path, a Pathname or an IO-like object (read from its start).
15
+ def load(source)
16
+ data = read(source)
17
+ Cache.fetch(data) { parse(data) }
18
+ end
19
+
20
+ def read(source)
21
+ data = if source.respond_to?(:read)
22
+ source.binmode if source.respond_to?(:binmode)
23
+ source.rewind if source.respond_to?(:rewind)
24
+ source.read
25
+ else
26
+ File.binread(source.to_s)
27
+ end
28
+ data.b
29
+ end
30
+
31
+ def parse(data)
32
+ return JPEG.new(data) if data.start_with?(JPEG_SIGNATURE)
33
+ return PNG.new(data) if data.start_with?(PNG_SIGNATURE)
34
+
35
+ format = UNSUPPORTED.find { |magic, _| data.start_with?(magic.b) }&.last
36
+ raise UnsupportedImage, "#{format} images are not supported, convert to PNG or JPEG" if format
37
+
38
+ raise UnsupportedImage, "only PNG and JPEG images are supported"
39
+ end
40
+
41
+ def xobject(width, height, color_space, bits)
42
+ { Type: :XObject, Subtype: :Image, Width: width, Height: height, ColorSpace: color_space, BitsPerComponent: bits }
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Images
5
+ # A JPEG passed through to the viewer as DCTDecode; only the frame header
6
+ # is read, for dimensions and colour components.
7
+ class JPEG
8
+ START_OF_FRAME = [0xC0..0xC3, 0xC5..0xC7, 0xC9..0xCB, 0xCD..0xCF].freeze
9
+ COLOR_SPACES = { 1 => :DeviceGray, 3 => :DeviceRGB, 4 => :DeviceCMYK }.freeze
10
+
11
+ attr_reader :width, :height
12
+
13
+ def initialize(data)
14
+ @data = data
15
+ read_frame_header
16
+ raise UnsupportedImage, "invalid JPEG image" unless @width
17
+ return if COLOR_SPACES.key?(@components)
18
+
19
+ raise UnsupportedImage,
20
+ "unsupported JPEG colour components: #{@components}"
21
+ end
22
+
23
+ def inspect = "#<#{self.class} #{@width}x#{@height}>"
24
+
25
+ def build(writer)
26
+ dictionary = Images.xobject(@width, @height, COLOR_SPACES.fetch(@components), @bits).merge(Filter: :DCTDecode)
27
+ # Adobe CMYK JPEGs store inverted values.
28
+ dictionary[:Decode] = [1, 0] * 4 if @components == 4 && @adobe
29
+ writer.add(PDF::Stream.new(@data, dictionary))
30
+ end
31
+
32
+ private
33
+
34
+ def read_frame_header
35
+ pos = 2
36
+ while pos + 4 <= @data.bytesize
37
+ raise UnsupportedImage, "invalid JPEG image" unless @data.getbyte(pos) == 0xFF
38
+
39
+ marker = @data.getbyte(pos + 1)
40
+ next pos += 1 if marker == 0xFF # fill byte
41
+
42
+ @adobe = true if marker == 0xEE && @data.byteslice(pos + 4, 5) == "Adobe"
43
+ if START_OF_FRAME.any? { |range| range.cover?(marker) }
44
+ @bits, @height, @width, @components = @data.byteslice(pos + 4, 6).unpack("CnnC")
45
+ return
46
+ end
47
+
48
+ pos += 2 + @data.byteslice(pos + 2, 2).unpack1("n")
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Stationery
6
+ module Images
7
+ # A PNG. Opaque images pass their compressed data straight through with a
8
+ # PNG predictor; transparency is split into a soft mask (or a colour-key
9
+ # mask for grey/RGB tRNS).
10
+ class PNG
11
+ CHANNELS = { 0 => 1, 2 => 3, 3 => 1, 4 => 2, 6 => 4 }.freeze
12
+
13
+ attr_reader :width, :height
14
+
15
+ def initialize(data)
16
+ @idat = String.new(encoding: Encoding::BINARY)
17
+ read_chunks(data)
18
+ raise UnsupportedImage, "invalid PNG image" unless @width && CHANNELS.key?(@color_type)
19
+ raise UnsupportedImage, "interlaced PNG images are not supported, save it non-interlaced" if @interlace == 1
20
+ end
21
+
22
+ def inspect = "#<#{self.class} #{@width}x#{@height}>"
23
+
24
+ def build(writer)
25
+ return build_with_alpha(writer) if alpha_channel?
26
+
27
+ dictionary = Images.xobject(@width, @height, color_space, @bit_depth).merge(
28
+ Filter: :FlateDecode,
29
+ DecodeParms: { Predictor: 15, Colors: CHANNELS[@color_type], BitsPerComponent: @bit_depth, Columns: @width }
30
+ )
31
+ if @transparency && @color_type == 3
32
+ dictionary[:SMask] = soft_mask(writer, palette_alpha)
33
+ elsif @transparency
34
+ dictionary[:Mask] = @transparency.unpack("n*").first(CHANNELS[@color_type]).flat_map { |v| [v, v] }
35
+ end
36
+ writer.add(PDF::Stream.new(@idat, dictionary))
37
+ end
38
+
39
+ private
40
+
41
+ def read_chunks(data)
42
+ pos = PNG_SIGNATURE.bytesize
43
+ while pos + 8 <= data.bytesize
44
+ length, type = data.byteslice(pos, 8).unpack("Na4")
45
+ chunk = data.byteslice(pos + 8, length)
46
+ break if type == "IEND"
47
+
48
+ read_chunk(type, chunk)
49
+ pos += 12 + length
50
+ end
51
+ end
52
+
53
+ def read_chunk(type, chunk)
54
+ case type
55
+ when "IHDR" then @width, @height, @bit_depth, @color_type, _, _, @interlace = chunk.unpack("NNCCCCC")
56
+ when "PLTE" then @palette = chunk
57
+ when "tRNS" then @transparency = chunk
58
+ when "IDAT" then @idat << chunk
59
+ end
60
+ end
61
+
62
+ def alpha_channel?
63
+ [4, 6].include?(@color_type)
64
+ end
65
+
66
+ def build_with_alpha(writer)
67
+ color, alpha = split_alpha
68
+ dictionary = Images.xobject(@width, @height, color_space, 8)
69
+ .merge(Filter: :FlateDecode, SMask: soft_mask(writer, alpha))
70
+ writer.add(PDF::Stream.new(Zlib::Deflate.deflate(color), dictionary))
71
+ end
72
+
73
+ def color_space
74
+ case @color_type
75
+ when 0, 4 then :DeviceGray
76
+ when 2, 6 then :DeviceRGB
77
+ else [:Indexed, :DeviceRGB, (@palette.bytesize / 3) - 1, PDF::HexString.new(@palette)]
78
+ end
79
+ end
80
+
81
+ def soft_mask(writer, alpha)
82
+ dictionary = Images.xobject(@width, @height, :DeviceGray, 8).merge(Filter: :FlateDecode)
83
+ writer.add(PDF::Stream.new(Zlib::Deflate.deflate(alpha), dictionary))
84
+ end
85
+
86
+ def palette_alpha
87
+ alphas = @transparency.bytes
88
+ alpha = String.new(capacity: @width * @height, encoding: Encoding::BINARY)
89
+ Scanlines.each(@idat, @height, 1, ((@width * @bit_depth) + 7) / 8) do |row|
90
+ alpha << palette_indexes(row).map { |index| alphas[index] || 255 }.pack("C*")
91
+ end
92
+ alpha
93
+ end
94
+
95
+ def palette_indexes(row)
96
+ return row if @bit_depth == 8
97
+
98
+ row.pack("C*").unpack1("B*").scan(/.{#{@bit_depth}}/o).first(@width).map { |bits| bits.to_i(2) }
99
+ end
100
+
101
+ # Grey+alpha or RGBA pixels split into colour and alpha, 8 bits per sample.
102
+ def split_alpha
103
+ channels = CHANNELS[@color_type]
104
+ sample = @bit_depth / 8
105
+ step = channels * sample
106
+ color_bytes = Array.new(@width) { |x| Array.new(channels - 1) { |c| (x * step) + (c * sample) } }.flatten
107
+ alpha_bytes = Array.new(@width) { |x| (x * step) + ((channels - 1) * sample) }
108
+
109
+ color = String.new(encoding: Encoding::BINARY)
110
+ alpha = String.new(encoding: Encoding::BINARY)
111
+ Scanlines.each(@idat, @height, step, @width * step) do |row|
112
+ color << row.values_at(*color_bytes).pack("C*")
113
+ alpha << row.values_at(*alpha_bytes).pack("C*")
114
+ end
115
+ [color, alpha]
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Stationery
6
+ module Images
7
+ # Inflates PNG image data and reverses the per-row filters, yielding each
8
+ # row as an Array of bytes.
9
+ module Scanlines
10
+ module_function
11
+
12
+ def each(idat, height, bpp, row_bytes)
13
+ data = Zlib::Inflate.inflate(idat)
14
+ previous = Array.new(row_bytes, 0)
15
+
16
+ height.times do |y|
17
+ pos = y * (row_bytes + 1)
18
+ row = unfilter(data.getbyte(pos), data.byteslice(pos + 1, row_bytes).bytes, previous, bpp)
19
+ yield row
20
+ previous = row
21
+ end
22
+ end
23
+
24
+ def unfilter(filter, row, previous, bpp)
25
+ case filter
26
+ when 0 then row
27
+ when 1 then row.each_index { |i| row[i] = (row[i] + (i >= bpp ? row[i - bpp] : 0)) & 0xFF }
28
+ when 2 then row.each_index { |i| row[i] = (row[i] + previous[i]) & 0xFF }
29
+ when 3 then row.each_index do |i|
30
+ row[i] = (row[i] + (((i >= bpp ? row[i - bpp] : 0) + previous[i]) >> 1)) & 0xFF
31
+ end
32
+ when 4 then paeth(row, previous, bpp)
33
+ else raise UnsupportedImage, "invalid PNG filter type: #{filter}"
34
+ end
35
+ end
36
+
37
+ def paeth(row, previous, bpp)
38
+ row.each_index do |i|
39
+ a = i >= bpp ? row[i - bpp] : 0
40
+ c = i >= bpp ? previous[i - bpp] : 0
41
+ row[i] = (row[i] + predictor(a, previous[i], c)) & 0xFF
42
+ end
43
+ end
44
+
45
+ def predictor(a, b, c)
46
+ p = a + b - c
47
+ pa = (p - a).abs
48
+ pb = (p - b).abs
49
+ pc = (p - c).abs
50
+ return a if pa <= pb && pa <= pc
51
+
52
+ pb <= pc ? b : c
53
+ end
54
+ end
55
+ end
56
+ end