fontisan 0.4.46 → 0.4.47

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,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Variation
5
+ module Instancing
6
+ # The design space of a variable font: fvar axes plus avar mapping.
7
+ # Validates user coordinates and normalizes them to -1..1.
8
+ class AxisSpace
9
+ Axis = Struct.new(:tag, :minimum, :default, :maximum, :name_id)
10
+
11
+ attr_reader :axes
12
+
13
+ # @param fvar [String] raw fvar table
14
+ # @param avar [String, nil] raw avar table
15
+ def initialize(fvar, avar = nil)
16
+ @axes = parse_fvar(fvar)
17
+ @segments = avar ? parse_avar(avar) : {}
18
+ end
19
+
20
+ def tags
21
+ @axes.map(&:tag)
22
+ end
23
+
24
+ # Named instances: [{ name_id:, coordinates: { tag => value } }]
25
+ def self.named_instances(fvar)
26
+ offset, axis_count, axis_size, inst_count, inst_size = header(fvar)
27
+ base = offset + (axis_count * axis_size)
28
+ Array.new(inst_count) do |i|
29
+ pos = base + (i * inst_size)
30
+ name_id = fvar[pos, 2].unpack1("n")
31
+ coords = Array.new(axis_count) do |a|
32
+ fixed(fvar, pos + 4 + (a * 4))
33
+ end
34
+ { name_id: name_id, values: coords }
35
+ end
36
+ end
37
+
38
+ def self.header(fvar)
39
+ offset, _res, axis_count, axis_size, inst_count, inst_size =
40
+ fvar[4, 12].unpack("nnnnnn")
41
+ [offset, axis_count, axis_size, inst_count, inst_size]
42
+ end
43
+
44
+ def self.fixed(data, pos)
45
+ data[pos, 4].unpack1("l>") / 65536.0
46
+ end
47
+
48
+ # @param requested [Hash{String=>Numeric}] user coordinates
49
+ # @return [Hash{String=>Float}] full user location (defaults filled)
50
+ # @raise [InvalidCoordinatesError, InvalidAxisError]
51
+ def validate(requested)
52
+ known = tags
53
+ requested.each_key do |tag|
54
+ next if known.include?(tag.to_s)
55
+
56
+ raise InvalidAxisError.new(
57
+ axis: tag.to_s,
58
+ reason: "font has axes #{known.join(', ')}",
59
+ )
60
+ end
61
+ @axes.to_h do |axis|
62
+ value = requested.transform_keys(&:to_s)[axis.tag]
63
+ value = axis.default if value.nil?
64
+ value = Float(value)
65
+ unless value.between?(axis.minimum, axis.maximum)
66
+ raise InvalidCoordinatesError.new(
67
+ axis: axis.tag, value: value, range: [axis.minimum, axis.maximum],
68
+ )
69
+ end
70
+ [axis.tag, value]
71
+ end
72
+ end
73
+
74
+ # @return [Array<Float>] normalized (avar-mapped) coordinates, fvar order
75
+ def normalize(location)
76
+ @axes.map do |axis|
77
+ value = location.fetch(axis.tag)
78
+ n = if value < axis.default
79
+ (value - axis.default) / (axis.default - axis.minimum)
80
+ elsif value > axis.default
81
+ (value - axis.default) / (axis.maximum - axis.default)
82
+ else
83
+ 0.0
84
+ end
85
+ map_segment(axis.tag, n.clamp(-1.0, 1.0))
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def parse_fvar(fvar)
92
+ offset, axis_count, axis_size, = self.class.header(fvar)
93
+ Array.new(axis_count) do |i|
94
+ pos = offset + (i * axis_size)
95
+ Axis.new(fvar[pos, 4], self.class.fixed(fvar, pos + 4),
96
+ self.class.fixed(fvar, pos + 8),
97
+ self.class.fixed(fvar, pos + 12),
98
+ fvar[pos + 18, 2].unpack1("n"))
99
+ end
100
+ end
101
+
102
+ # avar v1: per axis, array of [from, to] pairs (F2Dot14)
103
+ def parse_avar(avar)
104
+ axis_count = avar[6, 2].unpack1("n")
105
+ pos = 8
106
+ segments = {}
107
+ axis_count.times do |i|
108
+ count = avar[pos, 2].unpack1("n")
109
+ pos += 2
110
+ pairs = Array.new(count) do |j|
111
+ from, to = avar[pos + (j * 4), 4].unpack("s>s>")
112
+ [from / 16384.0, to / 16384.0]
113
+ end
114
+ pos += count * 4
115
+ segments[@axes[i].tag] = pairs if @axes[i]
116
+ end
117
+ segments
118
+ end
119
+
120
+ def map_segment(tag, value)
121
+ pairs = @segments[tag]
122
+ return value if pairs.nil? || pairs.empty?
123
+
124
+ exact = pairs.find { |from, _| from == value }
125
+ return exact[1] if exact
126
+
127
+ idx = pairs.index { |from, _| from > value }
128
+ return value if idx.nil? || idx.zero?
129
+
130
+ (f0, t0), (f1, t1) = pairs[idx - 1], pairs[idx]
131
+ t0 + ((t1 - t0) * (value - f0) / (f1 - f0))
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Variation
5
+ module Instancing
6
+ # A parsed glyf entry that can be moved by deltas and re-serialized.
7
+ # Points are the simple-glyph points, or for composites one point per
8
+ # component (its x/y offset), matching gvar's point numbering.
9
+ class GlyphOutline
10
+ ON_CURVE = 0x01
11
+ X_SHORT = 0x02
12
+ Y_SHORT = 0x04
13
+ REPEAT = 0x08
14
+ X_SAME = 0x10
15
+ Y_SAME = 0x20
16
+
17
+ ARGS_ARE_WORDS = 0x0001
18
+ ARGS_ARE_XY = 0x0002
19
+ HAVE_SCALE = 0x0008
20
+ MORE_COMPONENTS = 0x0020
21
+ HAVE_XY_SCALE = 0x0040
22
+ HAVE_TWO_BY_TWO = 0x0080
23
+
24
+ Component = Struct.new(:flags, :gid, :arg1, :arg2, :transform)
25
+
26
+ attr_reader :end_pts, :on_curve, :overlap, :instructions, :components,
27
+ :trailer, :header_contours
28
+
29
+ def self.parse(bytes)
30
+ new(bytes)
31
+ end
32
+
33
+ def initialize(bytes)
34
+ @header_contours = 0
35
+ @end_pts = []
36
+ @on_curve = []
37
+ @components = []
38
+ @points = []
39
+ return if bytes.nil? || bytes.empty?
40
+
41
+ @header_contours = bytes[0, 2].unpack1("s>")
42
+ if @header_contours >= 0
43
+ parse_simple(bytes)
44
+ else
45
+ parse_composite(bytes)
46
+ end
47
+ end
48
+
49
+ def empty?
50
+ @header_contours.zero? && @points.empty?
51
+ end
52
+
53
+ def composite?
54
+ @header_contours.negative?
55
+ end
56
+
57
+ # @return [Array<Array<Float>>] [[x, y], ...]
58
+ def points
59
+ @points.map(&:dup)
60
+ end
61
+
62
+ def point_count
63
+ @points.size
64
+ end
65
+
66
+ def bounds_of(points)
67
+ return nil if points.empty?
68
+
69
+ xs = points.map(&:first)
70
+ ys = points.map(&:last)
71
+ [xs.min, ys.min, xs.max, ys.max]
72
+ end
73
+
74
+ # Serialize with the given (already rounded) points. +bbox+ is the
75
+ # [xMin, yMin, xMax, yMax] to record in the header.
76
+ def serialize(new_points, bbox)
77
+ return "".b if empty?
78
+
79
+ composite? ? serialize_composite(new_points, bbox) : serialize_simple(new_points, bbox)
80
+ end
81
+
82
+ private
83
+
84
+ def parse_simple(bytes)
85
+ n = @header_contours
86
+ @end_pts = bytes[10, n * 2].unpack("n*")
87
+ pos = 10 + (n * 2)
88
+ ilen = bytes[pos, 2].unpack1("n")
89
+ @instructions = bytes[pos + 2, ilen]
90
+ pos += 2 + ilen
91
+ count = n.zero? ? 0 : @end_pts.last + 1
92
+ flags = []
93
+ while flags.size < count
94
+ f = bytes.getbyte(pos)
95
+ pos += 1
96
+ flags << f
97
+ if f.anybits?(REPEAT)
98
+ bytes.getbyte(pos).times { flags << f }
99
+ pos += 1
100
+ end
101
+ end
102
+ xs = read_coords(bytes, pos, flags, X_SHORT, X_SAME)
103
+ pos = xs.pop
104
+ ys = read_coords(bytes, pos, flags, Y_SHORT, Y_SAME)
105
+ ys.pop
106
+ @on_curve = flags.map { |f| f.anybits?(ON_CURVE) }
107
+ @overlap = flags.first&.anybits?(0x40) || false
108
+ @points = xs.zip(ys)
109
+ end
110
+
111
+ def read_coords(bytes, pos, flags, short, same)
112
+ val = 0
113
+ out = flags.map do |f|
114
+ if f.anybits?(short)
115
+ d = bytes.getbyte(pos)
116
+ pos += 1
117
+ val += f.anybits?(same) ? d : -d
118
+ elsif !f.anybits?(same)
119
+ val += bytes[pos, 2].unpack1("s>")
120
+ pos += 2
121
+ end
122
+ val
123
+ end
124
+ out << pos
125
+ end
126
+
127
+ def parse_composite(bytes)
128
+ pos = 10
129
+ loop do
130
+ flags, gid = bytes[pos, 4].unpack("nn")
131
+ pos += 4
132
+ if flags.anybits?(ARGS_ARE_WORDS)
133
+ fmt = flags.anybits?(ARGS_ARE_XY) ? "s>s>" : "nn"
134
+ a1, a2 = bytes[pos, 4].unpack(fmt)
135
+ pos += 4
136
+ else
137
+ fmt = flags.anybits?(ARGS_ARE_XY) ? "cc" : "CC"
138
+ a1, a2 = bytes[pos, 2].unpack(fmt)
139
+ pos += 2
140
+ end
141
+ tlen = if flags.anybits?(HAVE_SCALE) then 2
142
+ elsif flags.anybits?(HAVE_XY_SCALE) then 4
143
+ elsif flags.anybits?(HAVE_TWO_BY_TWO) then 8
144
+ else 0
145
+ end
146
+ @components << Component.new(flags, gid, a1, a2, bytes[pos, tlen])
147
+ pos += tlen
148
+ break unless flags.anybits?(MORE_COMPONENTS)
149
+ end
150
+ @trailer = bytes[pos..] || "".b
151
+ @points = @components.map do |c|
152
+ c.flags.anybits?(ARGS_ARE_XY) ? [c.arg1.to_f, c.arg2.to_f] : [0.0, 0.0]
153
+ end
154
+ end
155
+
156
+ def serialize_simple(pts, bbox)
157
+ out = [@header_contours, *bbox].pack("s>5")
158
+ out << @end_pts.pack("n*")
159
+ out << [@instructions.bytesize].pack("n") << @instructions
160
+ flags = +"".b
161
+ xs = +"".b
162
+ ys = +"".b
163
+ px = py = 0
164
+ pts.each_with_index do |(x, y), i|
165
+ f = @on_curve[i] ? ON_CURVE : 0
166
+ f |= 0x40 if i.zero? && @overlap
167
+ dx = x - px
168
+ dy = y - py
169
+ if dx.zero? then f |= X_SAME
170
+ else xs << [dx].pack("s>")
171
+ end
172
+ if dy.zero? then f |= Y_SAME
173
+ else ys << [dy].pack("s>")
174
+ end
175
+ flags << f.chr
176
+ px = x
177
+ py = y
178
+ end
179
+ out << flags << xs << ys
180
+ end
181
+
182
+ def serialize_composite(pts, bbox)
183
+ out = [@header_contours, *bbox].pack("s>5")
184
+ @components.each_with_index do |c, i|
185
+ flags = c.flags
186
+ a1 = c.arg1
187
+ a2 = c.arg2
188
+ if flags.anybits?(ARGS_ARE_XY)
189
+ a1, a2 = pts[i]
190
+ flags |= ARGS_ARE_WORDS
191
+ out << [flags, c.gid, a1, a2].pack("nns>s>")
192
+ elsif flags.anybits?(ARGS_ARE_WORDS)
193
+ out << [flags, c.gid, a1, a2].pack("nnnn")
194
+ else
195
+ out << [flags, c.gid, a1, a2].pack("nnCC")
196
+ end
197
+ out << c.transform
198
+ end
199
+ out << @trailer
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Variation
5
+ module Instancing
6
+ # Reads per-glyph tuple variations from a gvar table.
7
+ class GvarReader
8
+ SHARED_POINTS = 0x8000
9
+ EMBEDDED_PEAK = 0x8000
10
+ INTERMEDIATE = 0x4000
11
+ PRIVATE_POINTS = 0x2000
12
+ INDEX_MASK = 0x0FFF
13
+
14
+ # points == nil means "every point of the glyph, phantoms included"
15
+ Tuple = Struct.new(:peak, :start, :end, :points, :dx, :dy) do
16
+ def intermediate?
17
+ !start.nil?
18
+ end
19
+ end
20
+
21
+ def initialize(data)
22
+ @data = data
23
+ _major, _minor, @axis_count, shared_count, shared_offset,
24
+ @glyph_count, flags, @array_offset = data[0, 20].unpack("nnnnNnnN")
25
+ @long_offsets = flags.anybits?(1)
26
+ @shared = Array.new(shared_count) do |i|
27
+ read_tuple(shared_offset + (i * @axis_count * 2))
28
+ end
29
+ end
30
+
31
+ # @param gid [Integer]
32
+ # @param point_count [Integer] glyph points including 4 phantoms
33
+ # @return [Array<Tuple>]
34
+ def tuples_for(gid, point_count)
35
+ return [] if gid >= @glyph_count
36
+
37
+ first, last = glyph_range(gid)
38
+ return [] if last <= first
39
+
40
+ parse_glyph(@data[@array_offset + first, last - first], point_count)
41
+ end
42
+
43
+ private
44
+
45
+ def read_tuple(pos)
46
+ @data[pos, @axis_count * 2].unpack("s>*").map { |v| v / 16384.0 }
47
+ end
48
+
49
+ def glyph_range(gid)
50
+ if @long_offsets
51
+ @data[20 + (gid * 4), 8].unpack("NN")
52
+ else
53
+ @data[20 + (gid * 2), 4].unpack("nn").map { |v| v * 2 }
54
+ end
55
+ end
56
+
57
+ def parse_glyph(glyph_data, point_count)
58
+ count_field, data_offset = glyph_data[0, 4].unpack("nn")
59
+ count = count_field & INDEX_MASK
60
+ headers = []
61
+ pos = 4
62
+ count.times do
63
+ size, index = glyph_data[pos, 4].unpack("nn")
64
+ pos += 4
65
+ peak = start = finish = nil
66
+ if index.anybits?(EMBEDDED_PEAK)
67
+ peak = glyph_data[pos, @axis_count * 2].unpack("s>*").map { |v| v / 16384.0 }
68
+ pos += @axis_count * 2
69
+ else
70
+ peak = @shared.fetch(index & INDEX_MASK)
71
+ end
72
+ if index.anybits?(INTERMEDIATE)
73
+ start = glyph_data[pos, @axis_count * 2].unpack("s>*").map { |v| v / 16384.0 }
74
+ finish = glyph_data[pos + (@axis_count * 2), @axis_count * 2]
75
+ .unpack("s>*").map { |v| v / 16384.0 }
76
+ pos += @axis_count * 4
77
+ end
78
+ headers << [size, index, peak, start, finish]
79
+ end
80
+
81
+ dpos = data_offset
82
+ shared_points = nil
83
+ if count_field.anybits?(SHARED_POINTS)
84
+ shared_points, dpos = read_points(glyph_data, dpos)
85
+ end
86
+ headers.map do |size, index, peak, start, finish|
87
+ tuple_end = dpos + size
88
+ points = shared_points
89
+ points, dpos = read_points(glyph_data, dpos) if index.anybits?(PRIVATE_POINTS)
90
+ n = points ? points.size : point_count
91
+ dx, dpos = read_deltas(glyph_data, dpos, n)
92
+ dy, = read_deltas(glyph_data, dpos, n)
93
+ dpos = tuple_end
94
+ Tuple.new(peak, start, finish, points, dx, dy)
95
+ end
96
+ end
97
+
98
+ def read_points(data, pos)
99
+ count = data.getbyte(pos)
100
+ pos += 1
101
+ if count.anybits?(0x80)
102
+ count = ((count & 0x7F) << 8) | data.getbyte(pos)
103
+ pos += 1
104
+ end
105
+ return [nil, pos] if count.zero?
106
+
107
+ points = []
108
+ last = 0
109
+ while points.size < count
110
+ ctl = data.getbyte(pos)
111
+ pos += 1
112
+ run = (ctl & 0x7F) + 1
113
+ if ctl.anybits?(0x80)
114
+ vals = data[pos, run * 2].unpack("n*")
115
+ pos += run * 2
116
+ else
117
+ vals = data[pos, run].unpack("C*")
118
+ pos += run
119
+ end
120
+ vals.each { |v| points << (last += v) }
121
+ end
122
+ [points, pos]
123
+ end
124
+
125
+ def read_deltas(data, pos, count)
126
+ out = []
127
+ while out.size < count
128
+ ctl = data.getbyte(pos)
129
+ pos += 1
130
+ run = (ctl & 0x3F) + 1
131
+ if ctl.anybits?(0x80)
132
+ out.concat(Array.new(run, 0))
133
+ elsif ctl.anybits?(0x40)
134
+ out.concat(data[pos, run * 2].unpack("s>*"))
135
+ pos += run * 2
136
+ else
137
+ out.concat(data[pos, run].unpack("c*"))
138
+ pos += run
139
+ end
140
+ end
141
+ [out.first(count), pos]
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Variation
5
+ module Instancing
6
+ # Evaluates HVAR advance-width deltas (ItemVariationStore plus the
7
+ # optional DeltaSetIndexMap) at a normalized location.
8
+ class ItemVariationStore
9
+ # @param hvar [String] raw HVAR table
10
+ def initialize(hvar)
11
+ @hvar = hvar
12
+ store_offset, @advance_map_offset = hvar[4, 8].unpack("NN")
13
+ @store = hvar.byteslice(store_offset..)
14
+ region_list_offset, data_count = @store[2, 6].unpack("Nn")
15
+ @regions = parse_regions(region_list_offset)
16
+ @data_offsets = @store[8, data_count * 4].unpack("N*")
17
+ end
18
+
19
+ # @return [Float] advance-width delta for +gid+
20
+ def advance_delta(gid, coords)
21
+ outer, inner = delta_index(gid)
22
+ delta(outer, inner, coords)
23
+ end
24
+
25
+ private
26
+
27
+ def parse_regions(offset)
28
+ axis_count, region_count = @store[offset, 4].unpack("nn")
29
+ Array.new(region_count) do |r|
30
+ pos = offset + 4 + (r * axis_count * 6)
31
+ @store[pos, axis_count * 6].unpack("s>*").map { |v| v / 16384.0 }
32
+ .each_slice(3).to_a
33
+ end
34
+ end
35
+
36
+ def delta_index(gid)
37
+ return [0, gid] if @advance_map_offset.zero?
38
+
39
+ format, entry_format = @hvar[@advance_map_offset, 2].unpack("CC")
40
+ if format.zero?
41
+ count = @hvar[@advance_map_offset + 2, 2].unpack1("n")
42
+ pos = @advance_map_offset + 4
43
+ else
44
+ count = @hvar[@advance_map_offset + 2, 4].unpack1("N")
45
+ pos = @advance_map_offset + 6
46
+ end
47
+ inner_bits = (entry_format & 0x0F) + 1
48
+ size = ((entry_format & 0x30) >> 4) + 1
49
+ entry_gid = [gid, count - 1].min
50
+ entry = @hvar[pos + (entry_gid * size), size].bytes.inject(0) { |a, b| (a << 8) | b }
51
+ [entry >> inner_bits, entry & ((1 << inner_bits) - 1)]
52
+ end
53
+
54
+ def delta(outer, inner, coords)
55
+ offset = @data_offsets[outer]
56
+ return 0.0 unless offset
57
+
58
+ item_count, word_field, region_count = @store[offset, 6].unpack("nnn")
59
+ return 0.0 if inner >= item_count
60
+
61
+ long = word_field.anybits?(0x8000)
62
+ word_count = word_field & 0x7FFF
63
+ indexes = @store[offset + 6, region_count * 2].unpack("n*")
64
+ big = long ? 4 : 2
65
+ small = long ? 2 : 1
66
+ row_size = (word_count * big) + ((region_count - word_count) * small)
67
+ pos = offset + 6 + (region_count * 2) + (inner * row_size)
68
+ words = @store[pos, word_count * big].unpack(long ? "l>*" : "s>*")
69
+ rest = @store[pos + (word_count * big), (region_count - word_count) * small]
70
+ .unpack(long ? "s>*" : "c*")
71
+ (words + rest).each_with_index.sum do |d, i|
72
+ d * region_scalar(@regions[indexes[i]], coords)
73
+ end
74
+ end
75
+
76
+ def region_scalar(region, coords)
77
+ scalar = 1.0
78
+ region.each_with_index do |(lower, peak, upper), i|
79
+ v = coords[i]
80
+ next if peak.zero? || lower > peak || peak > upper
81
+ next if lower.negative? && upper.positive?
82
+ next if v == peak
83
+ return 0.0 if v <= lower || upper <= v
84
+
85
+ scalar *= v < peak ? (v - lower) / (peak - lower) : (upper - v) / (upper - peak)
86
+ end
87
+ scalar
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Variation
5
+ module Instancing
6
+ # Rewrites a name table so it describes one instance of a family.
7
+ class NameRewriter
8
+ Record = Struct.new(:platform, :encoding, :language, :name_id, :text)
9
+
10
+ # @param name_data [String] raw name table
11
+ def initialize(name_data)
12
+ @records = parse(name_data)
13
+ end
14
+
15
+ def lookup(name_id)
16
+ same = @records.select { |r| r.name_id == name_id }
17
+ rec = same.find { |r| r.platform == 3 && r.language == 0x409 } ||
18
+ same.find { |r| r.platform == 3 } || same.first
19
+ rec&.text
20
+ end
21
+
22
+ # Names the instance +label+ (e.g. "Light") of the family.
23
+ def rename(label)
24
+ family = lookup(16) || lookup(1) || "Font"
25
+ legacy = ["Regular", "Bold", "Italic", "Bold Italic"].include?(label)
26
+ full = "#{family} #{label}".strip
27
+ ps = "#{family}-#{label}".delete(" ")
28
+ set(1, legacy ? family : full)
29
+ set(2, legacy ? label : "Regular")
30
+ set(3, "#{lookup(5) || ''};#{ps}".delete_prefix(";"))
31
+ set(4, full)
32
+ set(6, ps)
33
+ set(16, family)
34
+ set(17, label)
35
+ @records.reject! { |r| r.name_id == 25 }
36
+ self
37
+ end
38
+
39
+ def to_binary
40
+ recs = @records.sort_by { |r| [r.platform, r.encoding, r.language, r.name_id] }
41
+ strings = +"".b
42
+ entries = recs.map do |r|
43
+ bin = encode(r)
44
+ off = strings.bytesize
45
+ strings << bin
46
+ [r.platform, r.encoding, r.language, r.name_id, bin.bytesize, off]
47
+ end
48
+ header = [0, recs.size, 6 + (recs.size * 12)].pack("nnn")
49
+ header + entries.map { |e| e.pack("n6") }.join + strings
50
+ end
51
+
52
+ private
53
+
54
+ def set(name_id, text)
55
+ same = @records.select { |r| r.name_id == name_id }
56
+ same.each { |r| r.text = text }
57
+ return if same.any? { |r| r.platform == 3 }
58
+
59
+ @records << Record.new(3, 1, 0x409, name_id, text)
60
+ end
61
+
62
+ def parse(data)
63
+ count, str_off = data[2, 4].unpack("nn")
64
+ recs = Array.new(count) do |i|
65
+ plat, enc, lang, nid, len, off = data[6 + (i * 12), 12].unpack("n6")
66
+ raw = data[str_off + off, len]
67
+ Record.new(plat, enc, lang, nid, decode(plat, raw))
68
+ end
69
+ recs.reject { |r| r.language >= 0x8000 }
70
+ end
71
+
72
+ def decode(platform, raw)
73
+ if [0, 3].include?(platform)
74
+ raw.dup.force_encoding("UTF-16BE").encode("UTF-8", invalid: :replace)
75
+ else
76
+ raw.dup.force_encoding("ISO-8859-1").encode("UTF-8")
77
+ end
78
+ end
79
+
80
+ def encode(rec)
81
+ if [0, 3].include?(rec.platform)
82
+ rec.text.encode("UTF-16BE").b
83
+ else
84
+ rec.text.encode("ISO-8859-1", invalid: :replace, undef: :replace).b
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end