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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +28 -0
- data/README.md +145 -0
- data/lib/stationery/builder.rb +61 -0
- data/lib/stationery/canvas/path.rb +82 -0
- data/lib/stationery/canvas/text.rb +55 -0
- data/lib/stationery/canvas.rb +121 -0
- data/lib/stationery/color.rb +62 -0
- data/lib/stationery/component.rb +54 -0
- data/lib/stationery/document.rb +95 -0
- data/lib/stationery/elements.rb +90 -0
- data/lib/stationery/errors.rb +11 -0
- data/lib/stationery/fonts/cmap.rb +75 -0
- data/lib/stationery/fonts/family.rb +51 -0
- data/lib/stationery/fonts/font.rb +149 -0
- data/lib/stationery/fonts/font_book.rb +36 -0
- data/lib/stationery/fonts/name_table.rb +39 -0
- data/lib/stationery/fonts/registry.rb +38 -0
- data/lib/stationery/fonts/subset.rb +139 -0
- data/lib/stationery/fonts/true_type.rb +174 -0
- data/lib/stationery/geometry.rb +56 -0
- data/lib/stationery/images/cache.rb +32 -0
- data/lib/stationery/images/images.rb +45 -0
- data/lib/stationery/images/jpeg.rb +53 -0
- data/lib/stationery/images/png.rb +119 -0
- data/lib/stationery/images/scanlines.rb +56 -0
- data/lib/stationery/layout/box.rb +134 -0
- data/lib/stationery/layout/flow.rb +121 -0
- data/lib/stationery/layout/image.rb +53 -0
- data/lib/stationery/layout/leaves.rb +58 -0
- data/lib/stationery/layout/node.rb +46 -0
- data/lib/stationery/layout/paginator.rb +41 -0
- data/lib/stationery/layout/positioned.rb +24 -0
- data/lib/stationery/layout/row.rb +71 -0
- data/lib/stationery/layout/table/cell.rb +93 -0
- data/lib/stationery/layout/table/selection.rb +52 -0
- data/lib/stationery/layout/table/widths.rb +53 -0
- data/lib/stationery/layout/table.rb +118 -0
- data/lib/stationery/layout/text.rb +62 -0
- data/lib/stationery/page.rb +47 -0
- data/lib/stationery/page_templates.rb +36 -0
- data/lib/stationery/pdf/assembler.rb +53 -0
- data/lib/stationery/pdf/serializer.rb +56 -0
- data/lib/stationery/pdf/stream.rb +27 -0
- data/lib/stationery/pdf/types.rb +15 -0
- data/lib/stationery/pdf/writer.rb +59 -0
- data/lib/stationery/rails.rb +20 -0
- data/lib/stationery/resources.rb +34 -0
- data/lib/stationery/text/entities.rb +23 -0
- data/lib/stationery/text/line.rb +26 -0
- data/lib/stationery/text/markup.rb +103 -0
- data/lib/stationery/text/paragraph.rb +98 -0
- data/lib/stationery/text/runs_builder.rb +52 -0
- data/lib/stationery/text/style.rb +46 -0
- data/lib/stationery/text/wrapper.rb +131 -0
- data/lib/stationery/version.rb +5 -0
- data/lib/stationery.rb +58 -0
- 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
|