receipts 2.4.0 → 3.0.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 +4 -4
- data/.standard.yml +1 -0
- data/CHANGELOG.md +13 -1
- data/Gemfile +0 -1
- data/README.md +29 -10
- data/examples/invoice.pdf +0 -0
- data/examples/receipt.pdf +0 -0
- data/examples/statement.pdf +0 -0
- data/lib/receipts/base.rb +3 -7
- data/lib/receipts/fonts/Inter-Bold.ttf +0 -0
- data/lib/receipts/fonts/Inter-Regular.ttf +0 -0
- data/lib/receipts/fonts/OFL.txt +92 -0
- data/lib/receipts/pdf/document.rb +383 -0
- data/lib/receipts/pdf/font.rb +168 -0
- data/lib/receipts/pdf/geometry.rb +29 -0
- data/lib/receipts/pdf/image.rb +242 -0
- data/lib/receipts/pdf/inline_format.rb +79 -0
- data/lib/receipts/pdf/table.rb +250 -0
- data/lib/receipts/pdf/text_layout.rb +175 -0
- data/lib/receipts/pdf/true_type.rb +387 -0
- data/lib/receipts/pdf/writer.rb +119 -0
- data/lib/receipts/version.rb +1 -1
- data/lib/receipts.rb +9 -2
- metadata +18 -43
- data/receipts.gemspec +0 -33
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
module Receipts
|
|
2
|
+
module PDF
|
|
3
|
+
# A TrueType font used in a document. Tracks which glyphs are used so only
|
|
4
|
+
# those are embedded when the document is rendered.
|
|
5
|
+
class Font
|
|
6
|
+
# Skew applied to fake an italic style when a font family has no italic font
|
|
7
|
+
OBLIQUE_SKEW = Math.tan(12 * Math::PI / 180)
|
|
8
|
+
|
|
9
|
+
attr_reader :ttf
|
|
10
|
+
|
|
11
|
+
def initialize(path)
|
|
12
|
+
@ttf = TrueType.load(path)
|
|
13
|
+
@used = {}
|
|
14
|
+
@widths = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def width_of(text, size, character_spacing: 0)
|
|
18
|
+
units = text.each_char.sum { |char| @widths[char] ||= @ttf.advance(@ttf.glyph_id(char.ord)) }
|
|
19
|
+
scale(units, size) + character_spacing * text.length
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def ascender(size)
|
|
23
|
+
scale(@ttf.ascender, size)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Distance below the baseline, as a positive number
|
|
27
|
+
def descender(size)
|
|
28
|
+
-scale(@ttf.descender, size)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def line_gap(size)
|
|
32
|
+
scale(@ttf.line_gap, size)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def underline_position(size)
|
|
36
|
+
scale(@ttf.underline_position, size)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def underline_thickness(size)
|
|
40
|
+
scale(@ttf.underline_thickness, size)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def strikeout_position(size)
|
|
44
|
+
scale(@ttf.strikeout_position, size)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def strikeout_size(size)
|
|
48
|
+
scale(@ttf.strikeout_size, size)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def bold?
|
|
52
|
+
@ttf.weight >= 600
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Encodes text as 2-byte original glyph IDs (Identity-H encoding)
|
|
56
|
+
def encode(text)
|
|
57
|
+
text.each_char.map do |char|
|
|
58
|
+
gid = @ttf.glyph_id(char.ord)
|
|
59
|
+
@used[gid] ||= char
|
|
60
|
+
gid
|
|
61
|
+
end.pack("n*")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build(writer)
|
|
65
|
+
gids = @used.keys.sort
|
|
66
|
+
subset, mapping = @ttf.subset(gids)
|
|
67
|
+
name = :"#{subset_tag(gids)}+#{@ttf.postscript_name}"
|
|
68
|
+
|
|
69
|
+
font_file = writer.add(Stream.new(subset, {Length1: subset.bytesize}))
|
|
70
|
+
descriptor = writer.add(
|
|
71
|
+
Type: :FontDescriptor,
|
|
72
|
+
FontName: name,
|
|
73
|
+
Flags: flags,
|
|
74
|
+
FontBBox: @ttf.bbox.map { |v| to_glyph_space(v) },
|
|
75
|
+
ItalicAngle: @ttf.italic_angle,
|
|
76
|
+
Ascent: to_glyph_space(@ttf.ascender),
|
|
77
|
+
Descent: to_glyph_space(@ttf.descender),
|
|
78
|
+
CapHeight: to_glyph_space(@ttf.cap_height),
|
|
79
|
+
XHeight: to_glyph_space(@ttf.x_height),
|
|
80
|
+
StemV: bold? ? 120 : 80,
|
|
81
|
+
FontFile2: font_file
|
|
82
|
+
)
|
|
83
|
+
cid_font = writer.add(
|
|
84
|
+
Type: :Font,
|
|
85
|
+
Subtype: :CIDFontType2,
|
|
86
|
+
BaseFont: name,
|
|
87
|
+
CIDSystemInfo: {Registry: "Adobe", Ordering: "Identity", Supplement: 0},
|
|
88
|
+
FontDescriptor: descriptor,
|
|
89
|
+
DW: to_glyph_space(@ttf.advance(0)),
|
|
90
|
+
W: glyph_widths(gids),
|
|
91
|
+
CIDToGIDMap: writer.add(Stream.new(cid_to_gid_map(gids, mapping)))
|
|
92
|
+
)
|
|
93
|
+
writer.add(
|
|
94
|
+
Type: :Font,
|
|
95
|
+
Subtype: :Type0,
|
|
96
|
+
BaseFont: name,
|
|
97
|
+
Encoding: :"Identity-H",
|
|
98
|
+
DescendantFonts: [cid_font],
|
|
99
|
+
ToUnicode: writer.add(Stream.new(to_unicode_cmap))
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def scale(units, size)
|
|
106
|
+
units * size / @ttf.units_per_em.to_f
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def to_glyph_space(units)
|
|
110
|
+
(units * 1000.0 / @ttf.units_per_em).round
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def flags
|
|
114
|
+
flags = 32 # Nonsymbolic
|
|
115
|
+
flags |= 1 if @ttf.fixed_pitch?
|
|
116
|
+
flags |= 64 unless @ttf.italic_angle.zero?
|
|
117
|
+
flags
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Subset fonts are named with a tag of 6 uppercase letters, like ABCDEF+Inter-Regular
|
|
121
|
+
def subset_tag(gids)
|
|
122
|
+
Digest::MD5.digest(gids.pack("n*") + @ttf.postscript_name).bytes.first(6).map { |b| (65 + b % 26).chr }.join
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Text is encoded with the original glyph IDs as CIDs. This maps them to the renumbered subset glyphs.
|
|
126
|
+
def cid_to_gid_map(gids, mapping)
|
|
127
|
+
map = Array.new((gids.max || 0) + 1, 0)
|
|
128
|
+
gids.each { |gid| map[gid] = mapping.fetch(gid) }
|
|
129
|
+
map.pack("n*")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Widths array grouping consecutive glyph IDs: [first [w1 w2 ...] first [w1 ...]]
|
|
133
|
+
def glyph_widths(gids)
|
|
134
|
+
gids.slice_when { |a, b| b != a + 1 }.flat_map do |run|
|
|
135
|
+
[run.first, run.map { |gid| to_glyph_space(@ttf.advance(gid)) }]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Maps glyph IDs back to Unicode so text can be copied and searched
|
|
140
|
+
def to_unicode_cmap
|
|
141
|
+
mappings = @used.sort.map do |gid, char|
|
|
142
|
+
format("<%04X> <%s>", gid, char.encode(Encoding::UTF_16BE).unpack1("H*").upcase)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
blocks = mappings.each_slice(100).map do |slice|
|
|
146
|
+
"#{slice.size} beginbfchar\n#{slice.join("\n")}\nendbfchar"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
<<~CMAP
|
|
150
|
+
/CIDInit /ProcSet findresource begin
|
|
151
|
+
12 dict begin
|
|
152
|
+
begincmap
|
|
153
|
+
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
|
|
154
|
+
/CMapName /Adobe-Identity-UCS def
|
|
155
|
+
/CMapType 2 def
|
|
156
|
+
1 begincodespacerange
|
|
157
|
+
<0000> <FFFF>
|
|
158
|
+
endcodespacerange
|
|
159
|
+
#{blocks.join("\n")}
|
|
160
|
+
endcmap
|
|
161
|
+
CMapName currentdict /CMap defineresource pop
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
CMAP
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Receipts
|
|
2
|
+
module PDF
|
|
3
|
+
module Geometry
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
# [top, right, bottom, left] from a number, [vertical, horizontal], [top, horizontal, bottom] or [top, right, bottom, left]
|
|
7
|
+
def expand_box(value)
|
|
8
|
+
values = Array(value)
|
|
9
|
+
case values.size
|
|
10
|
+
when 1 then values * 4
|
|
11
|
+
when 2 then [values[0], values[1], values[0], values[1]]
|
|
12
|
+
when 3 then [values[0], values[1], values[2], values[1]]
|
|
13
|
+
else values.first(4)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Offset that aligns content of the used width within the available width.
|
|
18
|
+
# Position is :left, :center, :right or an offset.
|
|
19
|
+
def align_offset(position, available, used)
|
|
20
|
+
case position
|
|
21
|
+
when :center then (available - used) / 2.0
|
|
22
|
+
when :right then available - used
|
|
23
|
+
when Numeric then position
|
|
24
|
+
else 0
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
module Receipts
|
|
2
|
+
module PDF
|
|
3
|
+
class UnsupportedImageError < StandardError; end
|
|
4
|
+
|
|
5
|
+
module Image
|
|
6
|
+
PNG_SIGNATURE = "\x89PNG\r\n\x1A\n".b
|
|
7
|
+
|
|
8
|
+
# Recently used images, keyed by content, so a logo used on every receipt is only decoded once
|
|
9
|
+
CACHE = {}
|
|
10
|
+
CACHE_MUTEX = Mutex.new
|
|
11
|
+
CACHE_SIZE = 16
|
|
12
|
+
|
|
13
|
+
# Accepts a path, Pathname, or IO-like object
|
|
14
|
+
def self.load(source)
|
|
15
|
+
data = if source.respond_to?(:read)
|
|
16
|
+
source.binmode if source.respond_to?(:binmode)
|
|
17
|
+
source.rewind if source.respond_to?(:rewind)
|
|
18
|
+
source.read
|
|
19
|
+
else
|
|
20
|
+
File.binread(source.to_s)
|
|
21
|
+
end.b
|
|
22
|
+
|
|
23
|
+
key = Digest::MD5.digest(data)
|
|
24
|
+
CACHE_MUTEX.synchronize do
|
|
25
|
+
CACHE[key] ||= parse(data)
|
|
26
|
+
CACHE.shift while CACHE.size > CACHE_SIZE
|
|
27
|
+
CACHE[key]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.parse(data)
|
|
32
|
+
if data.start_with?("\xFF\xD8".b)
|
|
33
|
+
JPEG.new(data)
|
|
34
|
+
elsif data.start_with?(PNG_SIGNATURE)
|
|
35
|
+
PNG.new(data)
|
|
36
|
+
else
|
|
37
|
+
raise UnsupportedImageError, "only PNG and JPEG images are supported"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.xobject(width, height, color_space, bits)
|
|
42
|
+
{Type: :XObject, Subtype: :Image, Width: width, Height: height, ColorSpace: color_space, BitsPerComponent: bits}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class JPEG
|
|
46
|
+
START_OF_FRAME = [0xC0..0xC3, 0xC5..0xC7, 0xC9..0xCB, 0xCD..0xCF].freeze
|
|
47
|
+
COLOR_SPACES = {1 => :DeviceGray, 3 => :DeviceRGB, 4 => :DeviceCMYK}.freeze
|
|
48
|
+
|
|
49
|
+
attr_reader :width, :height
|
|
50
|
+
|
|
51
|
+
def initialize(data)
|
|
52
|
+
@data = data
|
|
53
|
+
pos = 2
|
|
54
|
+
|
|
55
|
+
while pos + 4 <= data.bytesize
|
|
56
|
+
raise UnsupportedImageError, "invalid JPEG image" unless data.getbyte(pos) == 0xFF
|
|
57
|
+
marker = data.getbyte(pos + 1)
|
|
58
|
+
if marker == 0xFF # fill byte
|
|
59
|
+
pos += 1
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
length = data.byteslice(pos + 2, 2).unpack1("n")
|
|
64
|
+
@adobe = true if marker == 0xEE && data.byteslice(pos + 4, 5) == "Adobe"
|
|
65
|
+
|
|
66
|
+
if START_OF_FRAME.any? { |range| range.cover?(marker) }
|
|
67
|
+
@bits, @height, @width, @components = data.byteslice(pos + 4, 6).unpack("CnnC")
|
|
68
|
+
break
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
pos += 2 + length
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
raise UnsupportedImageError, "invalid JPEG image" unless @width
|
|
75
|
+
raise UnsupportedImageError, "unsupported JPEG color components: #{@components}" unless COLOR_SPACES.key?(@components)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build(writer)
|
|
79
|
+
dictionary = Image.xobject(@width, @height, COLOR_SPACES.fetch(@components), @bits).merge(Filter: :DCTDecode)
|
|
80
|
+
# Adobe CMYK JPEGs store inverted values
|
|
81
|
+
dictionary[:Decode] = [1, 0] * 4 if @components == 4 && @adobe
|
|
82
|
+
writer.add(Stream.new(@data, dictionary))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
class PNG
|
|
87
|
+
CHANNELS = {0 => 1, 2 => 3, 3 => 1, 4 => 2, 6 => 4}.freeze
|
|
88
|
+
|
|
89
|
+
attr_reader :width, :height
|
|
90
|
+
|
|
91
|
+
def initialize(data)
|
|
92
|
+
@idat = String.new(encoding: Encoding::BINARY)
|
|
93
|
+
pos = PNG_SIGNATURE.bytesize
|
|
94
|
+
|
|
95
|
+
while pos + 8 <= data.bytesize
|
|
96
|
+
length, type = data.byteslice(pos, 8).unpack("Na4")
|
|
97
|
+
chunk = data.byteslice(pos + 8, length)
|
|
98
|
+
|
|
99
|
+
case type
|
|
100
|
+
when "IHDR" then @width, @height, @bit_depth, @color_type, _, _, @interlace = chunk.unpack("NNCCCCC")
|
|
101
|
+
when "PLTE" then @palette = chunk
|
|
102
|
+
when "tRNS" then @transparency = chunk
|
|
103
|
+
when "IDAT" then @idat << chunk
|
|
104
|
+
when "IEND" then break
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
pos += 12 + length
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
raise UnsupportedImageError, "invalid PNG image" unless @width && CHANNELS.key?(@color_type)
|
|
111
|
+
raise UnsupportedImageError, "interlaced PNG images are not supported" if @interlace == 1
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def build(writer)
|
|
115
|
+
if @color_type == 4 || @color_type == 6
|
|
116
|
+
dictionary = Image.xobject(@width, @height, color_space, 8).merge(Filter: :FlateDecode, SMask: soft_mask(writer))
|
|
117
|
+
return writer.add(Stream.new(decoded[:color], dictionary))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Compressed PNG data can be embedded directly using the PNG predictor
|
|
121
|
+
dictionary = Image.xobject(@width, @height, color_space, @bit_depth).merge(
|
|
122
|
+
Filter: :FlateDecode,
|
|
123
|
+
DecodeParms: {Predictor: 15, Colors: CHANNELS[@color_type], BitsPerComponent: @bit_depth, Columns: @width}
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if @transparency && @color_type == 3
|
|
127
|
+
dictionary[:SMask] = soft_mask(writer)
|
|
128
|
+
elsif @transparency
|
|
129
|
+
# Color key masking: [min max] per channel
|
|
130
|
+
dictionary[:Mask] = @transparency.unpack("n*").first(CHANNELS[@color_type]).flat_map { |v| [v, v] }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
writer.add(Stream.new(@idat, dictionary))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def color_space
|
|
139
|
+
case @color_type
|
|
140
|
+
when 0, 4 then :DeviceGray
|
|
141
|
+
when 2, 6 then :DeviceRGB
|
|
142
|
+
when 3 then [:Indexed, :DeviceRGB, @palette.bytesize / 3 - 1, HexString.new(@palette)]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def soft_mask(writer)
|
|
147
|
+
dictionary = Image.xobject(@width, @height, :DeviceGray, 8).merge(Filter: :FlateDecode)
|
|
148
|
+
writer.add(Stream.new(decoded[:alpha], dictionary))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Compressed color and alpha data, memoized since images are cached across documents
|
|
152
|
+
def decoded
|
|
153
|
+
@decoded ||= begin
|
|
154
|
+
color, alpha = (@color_type == 3) ? [nil, palette_alpha] : split_alpha
|
|
155
|
+
{color: color && Zlib::Deflate.deflate(color), alpha: Zlib::Deflate.deflate(alpha)}
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Alpha channel for palette images from the per-palette-entry tRNS values
|
|
160
|
+
def palette_alpha
|
|
161
|
+
alphas = @transparency.bytes
|
|
162
|
+
alpha = String.new(capacity: @width * @height, encoding: Encoding::BINARY)
|
|
163
|
+
|
|
164
|
+
each_row(1, (@width * @bit_depth + 7) / 8) do |row|
|
|
165
|
+
indexes = if @bit_depth == 8
|
|
166
|
+
row
|
|
167
|
+
else
|
|
168
|
+
row.pack("C*").unpack1("B*").scan(/.{#{@bit_depth}}/).first(@width).map { |bits| bits.to_i(2) }
|
|
169
|
+
end
|
|
170
|
+
alpha << indexes.map { |index| alphas[index] || 255 }.pack("C*")
|
|
171
|
+
end
|
|
172
|
+
alpha
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Splits gray+alpha or RGBA pixels into color and alpha, reduced to 8 bits per sample
|
|
176
|
+
def split_alpha
|
|
177
|
+
channels = CHANNELS[@color_type]
|
|
178
|
+
sample = @bit_depth / 8
|
|
179
|
+
step = channels * sample
|
|
180
|
+
# Positions of each sample's high byte within a row
|
|
181
|
+
color_bytes = Array.new(@width) { |x| Array.new(channels - 1) { |c| x * step + c * sample } }.flatten
|
|
182
|
+
alpha_bytes = Array.new(@width) { |x| x * step + (channels - 1) * sample }
|
|
183
|
+
|
|
184
|
+
color = String.new(capacity: @width * @height * (channels - 1), encoding: Encoding::BINARY)
|
|
185
|
+
alpha = String.new(capacity: @width * @height, encoding: Encoding::BINARY)
|
|
186
|
+
each_row(step, @width * step) do |row|
|
|
187
|
+
color << row.values_at(*color_bytes).pack("C*")
|
|
188
|
+
alpha << row.values_at(*alpha_bytes).pack("C*")
|
|
189
|
+
end
|
|
190
|
+
[color, alpha]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Decompresses the image data and reverses the per-row PNG filters, yielding each row's bytes
|
|
194
|
+
def each_row(bpp, row_bytes)
|
|
195
|
+
data = Zlib::Inflate.inflate(@idat)
|
|
196
|
+
previous = Array.new(row_bytes, 0)
|
|
197
|
+
|
|
198
|
+
@height.times do |y|
|
|
199
|
+
pos = y * (row_bytes + 1)
|
|
200
|
+
filter = data.getbyte(pos)
|
|
201
|
+
row = data.byteslice(pos + 1, row_bytes).bytes
|
|
202
|
+
|
|
203
|
+
case filter
|
|
204
|
+
when 0
|
|
205
|
+
nil
|
|
206
|
+
when 1
|
|
207
|
+
(bpp...row_bytes).each { |i| row[i] = (row[i] + row[i - bpp]) & 0xFF }
|
|
208
|
+
when 2
|
|
209
|
+
row_bytes.times { |i| row[i] = (row[i] + previous[i]) & 0xFF }
|
|
210
|
+
when 3
|
|
211
|
+
row_bytes.times do |i|
|
|
212
|
+
left = (i >= bpp) ? row[i - bpp] : 0
|
|
213
|
+
row[i] = (row[i] + ((left + previous[i]) >> 1)) & 0xFF
|
|
214
|
+
end
|
|
215
|
+
when 4
|
|
216
|
+
row_bytes.times do |i|
|
|
217
|
+
a = (i >= bpp) ? row[i - bpp] : 0
|
|
218
|
+
b = previous[i]
|
|
219
|
+
c = (i >= bpp) ? previous[i - bpp] : 0
|
|
220
|
+
p = a + b - c
|
|
221
|
+
pa = (p - a).abs
|
|
222
|
+
pb = (p - b).abs
|
|
223
|
+
pc = (p - c).abs
|
|
224
|
+
predictor = if pa <= pb && pa <= pc
|
|
225
|
+
a
|
|
226
|
+
else
|
|
227
|
+
(pb <= pc) ? b : c
|
|
228
|
+
end
|
|
229
|
+
row[i] = (row[i] + predictor) & 0xFF
|
|
230
|
+
end
|
|
231
|
+
else
|
|
232
|
+
raise UnsupportedImageError, "invalid PNG filter type: #{filter}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
yield row
|
|
236
|
+
previous = row
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module Receipts
|
|
2
|
+
module PDF
|
|
3
|
+
# Parses HTML-like inline formatting into styled text fragments:
|
|
4
|
+
# <b> <strong> <i> <em> <u> <strikethrough> <sub> <sup> <br>
|
|
5
|
+
# <font name="..." size="..." character_spacing="...">
|
|
6
|
+
# <color rgb="#ff0000"> or <color c="0" m="100" y="100" k="0">
|
|
7
|
+
# <link href="..."> or <a href="...">
|
|
8
|
+
#
|
|
9
|
+
# Each fragment is a Hash with :text and any of :styles, :color, :link,
|
|
10
|
+
# :font, :size and :character_spacing.
|
|
11
|
+
module InlineFormat
|
|
12
|
+
TAG = %r{<(/?)(b|strong|i|em|u|strikethrough|sub|sup|font|color|link|a|br)(\s[^>]*?)?\s*/?>}i
|
|
13
|
+
ATTRIBUTE = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)')/
|
|
14
|
+
ENTITIES = {"<" => "<", ">" => ">", "&" => "&"}.freeze
|
|
15
|
+
STYLES = {
|
|
16
|
+
"b" => :bold, "strong" => :bold, "i" => :italic, "em" => :italic, "u" => :underline,
|
|
17
|
+
"strikethrough" => :strikethrough, "sub" => :subscript, "sup" => :superscript
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def parse(string)
|
|
23
|
+
fragments = []
|
|
24
|
+
stack = [[nil, {}]]
|
|
25
|
+
pos = 0
|
|
26
|
+
|
|
27
|
+
while (match = TAG.match(string, pos))
|
|
28
|
+
add(fragments, string[pos...match.begin(0)], stack.last.last)
|
|
29
|
+
name = match[2].downcase
|
|
30
|
+
|
|
31
|
+
if name == "br"
|
|
32
|
+
add(fragments, "\n", stack.last.last)
|
|
33
|
+
elsif match[1] == "/"
|
|
34
|
+
index = stack.rindex { |tag, _| tag == name }
|
|
35
|
+
stack = stack.first(index) if index
|
|
36
|
+
else
|
|
37
|
+
stack.push([name, apply(stack.last.last, name, attributes(match[3].to_s))])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
pos = match.end(0)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
add(fragments, string[pos..], stack.last.last)
|
|
44
|
+
fragments
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def add(fragments, text, style)
|
|
48
|
+
return if text.empty?
|
|
49
|
+
fragments << style.merge(text: text.gsub(/&(?:lt|gt|amp);/, ENTITIES))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def attributes(string)
|
|
53
|
+
string.scan(ATTRIBUTE).map { |key, double, single| [key.downcase, double || single] }.to_h
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def apply(style, name, attributes)
|
|
57
|
+
style = style.dup
|
|
58
|
+
|
|
59
|
+
if STYLES.key?(name)
|
|
60
|
+
style[:styles] = Array(style[:styles]) | [STYLES[name]]
|
|
61
|
+
elsif name == "font"
|
|
62
|
+
style[:font] = attributes["name"] if attributes["name"]
|
|
63
|
+
style[:size] = attributes["size"].to_f if attributes["size"]
|
|
64
|
+
style[:character_spacing] = attributes["character_spacing"].to_f if attributes["character_spacing"]
|
|
65
|
+
elsif name == "color"
|
|
66
|
+
if attributes["rgb"]
|
|
67
|
+
style[:color] = attributes["rgb"].delete("#")
|
|
68
|
+
elsif %w[c m y k].all? { |key| attributes.key?(key) }
|
|
69
|
+
style[:color] = attributes.values_at("c", "m", "y", "k").map(&:to_f)
|
|
70
|
+
end
|
|
71
|
+
elsif attributes["href"]
|
|
72
|
+
style[:link] = attributes["href"]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
style
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|