bandoola 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.
@@ -0,0 +1,349 @@
1
+ module Bandoola
2
+ # A tiny SVG reader that turns a path-based SVG into PDF path-painting
3
+ # operators. It is deliberately small: it understands <path> elements with
4
+ # solid fills — which covers icon/logo SVGs like a BankID mark — and not
5
+ # gradients, strokes, text, or raster <image> data.
6
+ #
7
+ # It is a standalone value object that knows nothing about View or layout:
8
+ # give it SVG source and a target box, get back PDF operators.
9
+ #
10
+ # The SVG coordinate system has its origin at the top-left with y pointing
11
+ # down; PDF's points up from the bottom-left. #to_pdf emits a `cm` transform
12
+ # that maps the viewBox into a target box and flips y, so the path data can
13
+ # be written out essentially verbatim.
14
+ class Svg
15
+ VIEWBOX = /viewBox="\s*([-\d.]+)[\s,]+([-\d.]+)[\s,]+([-\d.]+)[\s,]+([-\d.]+)"/
16
+ DIMENSION = /\A(\d*\.?\d+)/
17
+ PATH_TAG = /<path\b([^>]*)>/m
18
+ ATTRIBUTE = ->(name) { /#{name}="([^"]*)"/ }
19
+
20
+ # <mask> (and <clipPath>) define stencils, not visible artwork; the paths
21
+ # inside them must not be painted, so we strip these blocks before scanning.
22
+ STENCIL = %r{<(mask|clipPath)\b[^>]*>.*?</\1>}m
23
+
24
+ # A <mask id="..."> definition and its body, captured to build a clip.
25
+ MASK = %r{<mask\b[^>]*\bid="([^"]*)"[^>]*>(.*?)</mask>}m
26
+ # A <rect> inside a mask, turned into a rectangular clip subpath.
27
+ RECT_TAG = %r{<rect\b([^>]*?)/?>}m
28
+ # Open <g>, close </g>, or <path> — walked in document order so a path
29
+ # inherits the mask of any enclosing group.
30
+ ELEMENT = %r{<g\b([^>]*)>|</g>|<path\b([^>]*)>}m
31
+ # The id inside a mask="url(#id)" reference.
32
+ MASK_REF = /#([\w-]+)/
33
+
34
+ # A path command letter or a number (incl. decimals and exponents).
35
+ TOKEN = /[MmLlHhVvCcSsZz]|-?\d*\.?\d+(?:[eE][-+]?\d+)?/
36
+
37
+ def self.load(src)
38
+ new(File.read(src))
39
+ end
40
+
41
+ def initialize(source)
42
+ @source = source
43
+ @view_width, @view_height = parse_viewbox
44
+ @paths = parse_paths
45
+ end
46
+
47
+ attr_reader :view_width, :view_height
48
+
49
+ # Intrinsic display size: the SVG's width/height attributes if present,
50
+ # otherwise the viewBox dimensions.
51
+ def intrinsic_width
52
+ dimension("width") || view_width
53
+ end
54
+
55
+ def intrinsic_height
56
+ dimension("height") || view_height
57
+ end
58
+
59
+ # Content aspect ratio (width / height) of the viewBox.
60
+ def aspect
61
+ view_width / view_height
62
+ end
63
+
64
+ # Paint the SVG into +box+ ([left, top, width, height]). SVG paths draw
65
+ # inline, so +resources+ is unused (only raster images register XObjects);
66
+ # the signature matches Jpeg so Img can treat both the same.
67
+ def paint_into(buffer, _resources, box)
68
+ buffer << to_pdf(*box)
69
+ end
70
+
71
+ # PDF operators that paint the SVG into the box whose top-left corner is
72
+ # (left, top) and whose size is width x height, in PDF points.
73
+ def to_pdf(left, top, width, height)
74
+ scale_x = width / view_width
75
+ scale_y = height / view_height
76
+
77
+ out = +"q\n"
78
+ out << "#{fmt(scale_x)} 0 0 #{fmt(-scale_y)} #{fmt(left)} #{fmt(top)} cm\n"
79
+ @paths.each { |path| paint_path(out, path) }
80
+ out << "Q\n"
81
+ end
82
+
83
+ private
84
+
85
+ # Paint one parsed path. If it carries mask geometry, the fill is wrapped
86
+ # in its own save-state and clipped to that geometry first. Masks use the
87
+ # even-odd rule (W*) so the white-rect-with-knockouts idiom resolves to the
88
+ # expected holes.
89
+ def paint_path(out, path)
90
+ clip = path[:clip]
91
+ clipped = clip && !clip.empty?
92
+ out << "q\n" << clip << "W* n\n" if clipped
93
+ red, green, blue = path[:color]
94
+ out << "#{fmt(red)} #{fmt(green)} #{fmt(blue)} rg\n"
95
+ out << path[:ops]
96
+ out << (path[:even_odd] ? "f*\n" : "f\n")
97
+ out << "Q\n" if clipped
98
+ end
99
+
100
+ def parse_viewbox
101
+ if (match = @source.match(VIEWBOX))
102
+ [match[3].to_f, match[4].to_f]
103
+ else
104
+ [dimension("width") || 1.0, dimension("height") || 1.0]
105
+ end
106
+ end
107
+
108
+ def dimension(name)
109
+ match = @source.match(ATTRIBUTE.call(name))
110
+ return nil unless match
111
+
112
+ number = match[1].match(DIMENSION)
113
+ number && number[1].to_f
114
+ end
115
+
116
+ def parse_paths
117
+ masks = parse_masks
118
+ groups = [] # stack of enclosing-group mask ids (nil when a group is unmasked)
119
+
120
+ @source.gsub(STENCIL, "").scan(ELEMENT).filter_map do |group_attrs, path_attrs|
121
+ if group_attrs
122
+ groups.push(mask_ref(group_attrs))
123
+ next
124
+ elsif path_attrs.nil? # a </g>
125
+ groups.pop
126
+ next
127
+ end
128
+
129
+ build_path(path_attrs, masks, groups.compact.last)
130
+ end
131
+ end
132
+
133
+ def build_path(attributes, masks, group_mask)
134
+ fill = attribute(attributes, "fill")
135
+ return if fill.nil? || fill == "none"
136
+
137
+ data = attribute(attributes, "d")
138
+ return if data.nil?
139
+
140
+ mask = mask_ref(attributes) || group_mask
141
+ {
142
+ ops: Pen.new(data.scan(TOKEN)).draw,
143
+ color: rgb(fill),
144
+ even_odd: attribute(attributes, "fill-rule") == "evenodd",
145
+ clip: mask && masks[mask]
146
+ }
147
+ end
148
+
149
+ # id => clip operators, for every <mask> in the document. A mask's <path>
150
+ # and <rect> children become the clip geometry; other shapes are ignored.
151
+ def parse_masks
152
+ @source.scan(MASK).to_h do |id, body|
153
+ [id, mask_clip(body)]
154
+ end
155
+ end
156
+
157
+ def mask_clip(body)
158
+ ops = +""
159
+ body.scan(PATH_TAG).each do |(attributes)|
160
+ data = attribute(attributes, "d")
161
+ ops << Pen.new(data.scan(TOKEN)).draw if data
162
+ end
163
+ body.scan(RECT_TAG).each { |(attributes)| ops << rect_path(attributes) }
164
+ ops
165
+ end
166
+
167
+ # A <rect> as an explicit closed subpath (rx/ry rounding is ignored).
168
+ def rect_path(attributes)
169
+ x = number_attribute(attributes, "x")
170
+ y = number_attribute(attributes, "y")
171
+ width = number_attribute(attributes, "width")
172
+ height = number_attribute(attributes, "height")
173
+
174
+ "#{fmt(x)} #{fmt(y)} m\n" \
175
+ "#{fmt(x + width)} #{fmt(y)} l\n" \
176
+ "#{fmt(x + width)} #{fmt(y + height)} l\n" \
177
+ "#{fmt(x)} #{fmt(y + height)} l\n" \
178
+ "h\n"
179
+ end
180
+
181
+ def number_attribute(attributes, name)
182
+ value = attribute(attributes, name)
183
+ value ? value.to_f : 0.0
184
+ end
185
+
186
+ # The id referenced by a mask="url(#id)" attribute, or nil if absent.
187
+ def mask_ref(attributes)
188
+ ref = attribute(attributes, "mask")
189
+ ref && ref[MASK_REF, 1]
190
+ end
191
+
192
+ def attribute(attributes, name)
193
+ match = attributes.match(ATTRIBUTE.call(name))
194
+ match && match[1]
195
+ end
196
+
197
+ # "#193E4F" => [0.098, 0.243, 0.310], each channel scaled to 0..1.
198
+ # Shorthand 3-digit hex ("#fff") expands to its 6-digit form first.
199
+ def rgb(hex)
200
+ digits = hex.delete("#")
201
+ digits = digits.chars.map { |digit| digit * 2 }.join if digits.length == 3
202
+ [0, 2, 4].map { |offset| digits[offset, 2].to_i(16) / 255.0 }
203
+ end
204
+
205
+ def fmt(value)
206
+ format("%.3f", value).sub(/\.?0+\z/, "")
207
+ end
208
+
209
+ # Walks the token stream of one path's `d` attribute, tracking the current
210
+ # point so relative commands and implicit repeats resolve, and emits PDF
211
+ # path operators (m/l/c/h) in the SVG's own coordinate space.
212
+ class Pen
213
+ # SVG path command letter (upper = absolute, lower = relative) to the
214
+ # method that emits it.
215
+ COMMANDS = {
216
+ "M" => :move, "m" => :move,
217
+ "L" => :line, "l" => :line,
218
+ "H" => :horizontal, "h" => :horizontal,
219
+ "V" => :vertical, "v" => :vertical,
220
+ "C" => :cubic, "c" => :cubic,
221
+ "S" => :smooth_cubic, "s" => :smooth_cubic,
222
+ "Z" => :close, "z" => :close
223
+ }.freeze
224
+
225
+ def initialize(tokens)
226
+ @tokens = tokens
227
+ @index = 0
228
+ @x = 0.0
229
+ @y = 0.0
230
+ @start_x = 0.0
231
+ @start_y = 0.0
232
+ @command = nil
233
+ @control_x = nil
234
+ @control_y = nil
235
+ @ops = +""
236
+ end
237
+
238
+ def draw
239
+ while @index < @tokens.length
240
+ token = @tokens[@index]
241
+ if token.match?(/[A-Za-z]/)
242
+ @command = token
243
+ @index += 1
244
+ end
245
+ step(@command)
246
+ end
247
+ @ops
248
+ end
249
+
250
+ private
251
+
252
+ def step(command)
253
+ action = COMMANDS[command]
254
+ return @index += 1 unless action # skip unsupported/leading token, ensuring progress
255
+
256
+ send(action)
257
+ end
258
+
259
+ def number
260
+ value = @tokens[@index].to_f
261
+ @index += 1
262
+ value
263
+ end
264
+
265
+ def relative?
266
+ @command == @command.downcase
267
+ end
268
+
269
+ def move
270
+ implicit = relative? ? "l" : "L"
271
+ target = point
272
+ @x = @start_x = target[0]
273
+ @y = @start_y = target[1]
274
+ @ops << "#{fmt(target[0])} #{fmt(target[1])} m\n"
275
+ # Subsequent coordinate pairs after a moveto are implicit linetos.
276
+ @command = implicit
277
+ end
278
+
279
+ def line
280
+ line_to(point)
281
+ end
282
+
283
+ def horizontal
284
+ along = number
285
+ along += @x if relative?
286
+ line_to([along, @y])
287
+ end
288
+
289
+ def vertical
290
+ along = number
291
+ along += @y if relative?
292
+ line_to([@x, along])
293
+ end
294
+
295
+ def cubic
296
+ control1 = point
297
+ control2 = point
298
+ endpoint = point
299
+ curve_to(control1, control2, endpoint)
300
+ end
301
+
302
+ def smooth_cubic
303
+ control2 = point
304
+ endpoint = point
305
+ curve_to(reflected_control, control2, endpoint)
306
+ end
307
+
308
+ # Read the next coordinate pair, resolving relative commands against the
309
+ # current point.
310
+ def point
311
+ coords = [number, number]
312
+ return coords unless relative?
313
+
314
+ [coords[0] + @x, coords[1] + @y]
315
+ end
316
+
317
+ def reflected_control
318
+ return [@x, @y] unless @control_x
319
+
320
+ [(2 * @x) - @control_x, (2 * @y) - @control_y]
321
+ end
322
+
323
+ def close
324
+ @ops << "h\n"
325
+ @x = @start_x
326
+ @y = @start_y
327
+ @command = nil # a fresh command (or EOF) must follow a closepath
328
+ end
329
+
330
+ def line_to(target)
331
+ @x, @y = target
332
+ @control_x = @control_y = nil
333
+ @ops << "#{fmt(@x)} #{fmt(@y)} l\n"
334
+ end
335
+
336
+ def curve_to(control1, control2, target)
337
+ @ops << "#{fmt(control1[0])} #{fmt(control1[1])} " \
338
+ "#{fmt(control2[0])} #{fmt(control2[1])} " \
339
+ "#{fmt(target[0])} #{fmt(target[1])} c\n"
340
+ @control_x, @control_y = control2
341
+ @x, @y = target
342
+ end
343
+
344
+ def fmt(value)
345
+ format("%.3f", value).sub(/\.?0+\z/, "")
346
+ end
347
+ end
348
+ end
349
+ end
@@ -0,0 +1,206 @@
1
+ module Bandoola
2
+ # A minimal TrueType font reader: enough to embed the font in a PDF and look
3
+ # up the metrics PDF needs. It parses the table directory and the head/hhea/
4
+ # maxp/hmtx/cmap/post/OS2/name tables — not the glyph outlines, which travel
5
+ # to the viewer untouched inside the embedded font file.
6
+ #
7
+ # Metrics are scaled to PDF glyph space (1000 units per em). Character codes
8
+ # are treated as Unicode (Latin-1 for 0..255), resolved to glyphs through a
9
+ # format-4 Unicode cmap subtable.
10
+ class TrueTypeFont
11
+ # A right-leaning slant for fonts flagged italic whose post table doesn't
12
+ # report an angle (common in variable fonts, where the slant is in the
13
+ # variation data). PDF measures the angle counter-clockwise from vertical.
14
+ DEFAULT_ITALIC_ANGLE = -12
15
+
16
+ def self.load(path)
17
+ new(File.binread(path))
18
+ end
19
+
20
+ def initialize(data)
21
+ @data = data
22
+ @tables = read_directory
23
+ read_head
24
+ read_hhea
25
+ @num_glyphs = u16(table_offset("maxp") + 4)
26
+ read_cmap
27
+ end
28
+
29
+ attr_reader :data, :ascent, :descent, :cap_height, :font_bbox, :italic_angle
30
+
31
+ # The glyph index for a Unicode code point (0 = .notdef / missing).
32
+ def glyph(codepoint)
33
+ glyph_for(codepoint)
34
+ end
35
+
36
+ # The advance width (in 1000-unit em) of a glyph index.
37
+ def glyph_width(glyph)
38
+ index = glyph < @num_hmetrics ? glyph : @num_hmetrics - 1
39
+ scale(u16(table_offset("hmtx") + (index * 4)))
40
+ end
41
+
42
+ # The advance width (in 1000-unit em) of a character code.
43
+ def width(codepoint)
44
+ glyph_width(glyph(codepoint))
45
+ end
46
+
47
+ def widths(first, last)
48
+ (first..last).map { |code| width(code) }
49
+ end
50
+
51
+ # A PDF/PostScript-safe BaseFont name, from the font's name table.
52
+ def postscript_name
53
+ @postscript_name ||= read_postscript_name
54
+ end
55
+
56
+ # FontDescriptor flags: 32 = Nonsymbolic (uses a standard Latin set), plus
57
+ # 64 if the font is italic.
58
+ def flags
59
+ @italic ? 96 : 32
60
+ end
61
+
62
+ private
63
+
64
+ def read_directory
65
+ tables = {}
66
+ u16(4).times do |i|
67
+ entry = 12 + (i * 16)
68
+ tables[@data[entry, 4]] = u32(entry + 8)
69
+ end
70
+ tables
71
+ end
72
+
73
+ def table_offset(tag)
74
+ @tables.fetch(tag) { raise Error, "TrueType font missing #{tag} table" }
75
+ end
76
+
77
+ def read_head
78
+ offset = table_offset("head")
79
+ @units_per_em = u16(offset + 18)
80
+ @font_bbox = [offset + 36, offset + 38, offset + 40, offset + 42].map { |pos| scale(i16(pos)) }
81
+ @italic = u16(offset + 44).anybits?(0x0002) || os2_italic? # head.macStyle bit 1
82
+ end
83
+
84
+ def os2_italic?
85
+ return false unless @tables["OS/2"]
86
+
87
+ u16(table_offset("OS/2") + 62).anybits?(0x0001) # fsSelection bit 0
88
+ end
89
+
90
+ def read_hhea
91
+ offset = table_offset("hhea")
92
+ @ascent = scale(i16(offset + 4))
93
+ @descent = scale(i16(offset + 6))
94
+ @num_hmetrics = u16(offset + 34)
95
+ @cap_height = cap_height_from_os2 || @ascent
96
+ @italic_angle = italic_angle_value
97
+ end
98
+
99
+ def cap_height_from_os2
100
+ return nil unless @tables["OS/2"]
101
+
102
+ offset = table_offset("OS/2")
103
+ return nil if u16(offset) < 2 # sCapHeight only exists from version 2
104
+
105
+ scale(i16(offset + 88))
106
+ end
107
+
108
+ def italic_angle_value
109
+ from_post = @tables["post"] ? (i32(table_offset("post") + 4) / 65_536.0).round : 0
110
+ return from_post unless from_post.zero?
111
+
112
+ @italic ? DEFAULT_ITALIC_ANGLE : 0
113
+ end
114
+
115
+ # Locate a format-4 Unicode cmap subtable (preferring Windows 3,1) and
116
+ # cache the offsets of its parallel segment arrays.
117
+ def read_cmap
118
+ base = table_offset("cmap")
119
+ subtable = find_format4_subtable(base)
120
+ raise Error, "TrueType font has no usable Unicode cmap" unless subtable
121
+
122
+ @seg_count = u16(subtable + 6) / 2
123
+ @cmap_end = subtable + 14
124
+ @cmap_start = @cmap_end + (@seg_count * 2) + 2
125
+ @cmap_delta = @cmap_start + (@seg_count * 2)
126
+ @cmap_range = @cmap_delta + (@seg_count * 2)
127
+ end
128
+
129
+ def find_format4_subtable(base)
130
+ fallback = nil
131
+ u16(base + 2).times do |i|
132
+ record = base + 4 + (i * 8)
133
+ platform = u16(record)
134
+ encoding = u16(record + 2)
135
+ subtable = base + u32(record + 4)
136
+ next unless u16(subtable) == 4
137
+
138
+ return subtable if platform == 3 && encoding == 1
139
+
140
+ fallback ||= subtable if platform.zero?
141
+ end
142
+ fallback
143
+ end
144
+
145
+ # cmap format 4 lookup: find the segment covering +code+ and map it to a
146
+ # glyph index via idRangeOffset/idDelta.
147
+ def glyph_for(code)
148
+ segment = 0
149
+ segment += 1 while segment < @seg_count && u16(@cmap_end + (2 * segment)) < code
150
+ return 0 if segment >= @seg_count
151
+ return 0 if u16(@cmap_start + (2 * segment)) > code
152
+
153
+ range = u16(@cmap_range + (2 * segment))
154
+ delta = i16(@cmap_delta + (2 * segment))
155
+ return (code + delta) % 65_536 if range.zero?
156
+
157
+ start = u16(@cmap_start + (2 * segment))
158
+ glyph = u16(@cmap_range + (2 * segment) + range + (2 * (code - start)))
159
+ glyph.zero? ? 0 : (glyph + delta) % 65_536
160
+ end
161
+
162
+ def read_postscript_name
163
+ return "Embedded" unless @tables["name"]
164
+
165
+ offset = table_offset("name")
166
+ strings = offset + u16(offset + 4)
167
+ u16(offset + 2).times do |i|
168
+ record = offset + 6 + (i * 12)
169
+ next unless u16(record + 6) == 6 # nameID 6 = PostScript name
170
+
171
+ raw = @data[strings + u16(record + 10), u16(record + 8)] || ""
172
+ # Keep printable ASCII, which also drops the 0x00s of UTF-16BE names.
173
+ ascii = raw.bytes.grep(0x21..0x7E).pack("C*")
174
+ return sanitize(ascii) unless ascii.empty?
175
+ end
176
+ "Embedded"
177
+ end
178
+
179
+ def sanitize(name)
180
+ cleaned = name.gsub(/[^A-Za-z0-9-]/, "")
181
+ cleaned.empty? ? "Embedded" : cleaned
182
+ end
183
+
184
+ def scale(value)
185
+ (value * 1000.0 / @units_per_em).round
186
+ end
187
+
188
+ def u16(offset)
189
+ (@data.getbyte(offset) << 8) | @data.getbyte(offset + 1)
190
+ end
191
+
192
+ def i16(offset)
193
+ value = u16(offset)
194
+ value >= 0x8000 ? value - 0x10000 : value
195
+ end
196
+
197
+ def u32(offset)
198
+ (u16(offset) << 16) | u16(offset + 2)
199
+ end
200
+
201
+ def i32(offset)
202
+ value = u32(offset)
203
+ value >= 0x8000_0000 ? value - 0x1_0000_0000 : value
204
+ end
205
+ end
206
+ end