alhena 0.2.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce5de3849b4aa2df47ea56cdeb9973a32292f141bc112dee2ea982cb57a34458
4
- data.tar.gz: 5a8511d09bdd3ba79634eebe8ea808fd90e8381a146e213b600315a81551d4d7
3
+ metadata.gz: 387d1d13d4023f565425ad659f94aaf572e7110e07eb029fcb7e17a8e20a7aee
4
+ data.tar.gz: 7828025fb60b1a932e7d2517705ba8d5c5b98563aba4d6fc8ca958d976d7bd31
5
5
  SHA512:
6
- metadata.gz: 8bdac70ca3bb5b3f5e9b36478bc0abab8323efe5b5127788ceffb0ef4bc8d294a3ed14b63e807834618888d9e87e8ffbf4b5dc9d2f8a65ee8ecc833938bb7f54
7
- data.tar.gz: a8c8e4713dedce77ea623faaf562e1014a523738798bb96a823463aa94f5e81334286bf7bcd4f9f1609941585032a357a56df269d4c182ca4facc2fb9bcd55f7
6
+ metadata.gz: 3a50ec152fed8ae539ee365514ff7759f74675f45fb446412f7d2c9b268f50a4ee0bbf06fed77700b135b23447152438a489c126b94521da70e28af1ed51aed8
7
+ data.tar.gz: 345d93a28236e523e1860db99d779a557676b15ccca644a8794d0713ab447cc591b604d60ad9d2dab06fb489c470e9ab19fa5037ff685692290729cfbf44fac0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-09-23
4
+
5
+ - Expose font bounds, glyph IDs, advances, and Unicode mappings for font consumers.
6
+ - Build self-contained TrueType subsets with composite glyph dependencies and valid checksums.
7
+ - Preserve complete CFF fonts when building PDF-compatible font data.
8
+ - Convert supported static name-keyed CFF1 fonts to CID-keyed subsets for PDF embedding.
9
+
3
10
  ## 0.2.1 — 2026-09-21
4
11
 
5
12
  - Add a deterministic rasterized-glyph demo image and regeneration task.
data/README.md CHANGED
@@ -28,6 +28,7 @@ Alhena reads TrueType, OpenType, and TTC fonts, extracts their outlines, and ras
28
28
  ## Features
29
29
 
30
30
  - TrueType, OpenType CFF1/CFF2, and TTC font parsing
31
+ - TrueType and supported CFF1 subset construction and glyph-to-Unicode lookup for PDF embedding
31
32
  - Analytic grayscale and LCD rasterization with subpixel positioning
32
33
  - Variable font axes, outlines, and metrics
33
34
  - Fast text advance measurement without rasterization
@@ -164,6 +165,10 @@ rgba = color&.rgba
164
165
 
165
166
  Alhena does not provide TrueType hinting, shaping, GSUB/GPOS, kerning, system font discovery, COLR v1 paint graphs, avar v2, JPEG/TIFF decoding, or MVAR global metric variation. Use `embedded_bitmap` to retrieve unsupported sbix image formats for external decoding.
166
167
 
168
+ `Alhena::Subset.build(font, glyph_ids)` creates a compact TrueType font containing the requested glyphs and any composite-glyph dependencies, or a compact static name-keyed CFF1 font for the supported CFF subset described below. CFF2, CID-keyed CFF, predefined CFF charsets, custom CFF encodings, and variable CFF1 fonts are rejected with `Alhena::UnsupportedFont`; they are never silently returned unchanged. CFF subsets retain selected outlines, Unicode mappings, horizontal metrics, and the original subroutine indexes, but omit layout, shaping, color, and vertical-metric tables whose glyph references are not rewritten.
169
+
170
+ For PDF consumers, `Alhena::Subset.build_cid(font, glyph_ids)` converts a static, name-keyed CFF1 font to a compact CID-keyed CFF1 program. Array order assigns CIDs (the first glyph must be glyph 0), and repeated glyph IDs are allowed. It intentionally rejects CFF2, variable, and already CID-keyed source fonts.
171
+
167
172
  Unknown formats raise `Alhena::UnsupportedFont`; malformed bounds and structures raise `Alhena::InvalidFont`. Bitmap allocations are limited to 16,777,216 samples.
168
173
 
169
174
  ## Performance
data/lib/alhena/cff.rb CHANGED
@@ -17,9 +17,11 @@ module Alhena
17
17
  else
18
18
  names, at = read_index(header_size)
19
19
  tops, at = read_index(at)
20
- _strings, at = read_index(at)
20
+ @strings, at = read_index(at)
21
21
  @global_subrs, = read_index(at)
22
22
  raise InvalidFont, "OpenType CFF must contain one font" unless names.size == 1 && tops.size == 1
23
+ @name_objects, @top_dictionary = names, tops.first
24
+ @top_dictionary_entries = dictionary_entries(@top_dictionary)
23
25
  @top = parse_dictionary(tops.first)
24
26
  end
25
27
  raise UnsupportedFont, "only Type 2 charstrings are supported" unless @top.fetch(1206, [2]).first == 2
@@ -28,9 +30,11 @@ module Alhena
28
30
  raise InvalidFont, "invalid CFF FontMatrix" unless @matrix.size == 6
29
31
  if @top.key?(1230) || @version == 2
30
32
  dictionaries, = read_index(@top.fetch(1236).first)
33
+ @private_records = []
31
34
  @privates = dictionaries.map { |entry| read_private_dict(parse_dictionary(entry)) }
32
35
  @fd_select = @top[1237]&.first
33
36
  else
37
+ @private_records = []
34
38
  @privates = [read_private_dict(@top)]
35
39
  @privates.first[3] = nil
36
40
  end
@@ -49,6 +53,22 @@ module Alhena
49
53
  path.transform(@matrix.map { |n| n * @units_per_em })
50
54
  end
51
55
 
56
+ # Rebuild a name-keyed CFF1 table with only the requested charstrings.
57
+ def subset(glyphs)
58
+ Subsetter.new(glyphs, data: @data, version: @version, top: @top,
59
+ top_dictionary_entries: @top_dictionary_entries, name_objects: @name_objects,
60
+ strings: @strings, global_subrs: @global_subrs, charstrings: @charstrings,
61
+ private_records: @private_records).build
62
+ end
63
+
64
+ # Rebuild a single-FD CID-keyed CFF1 program in the requested CID order.
65
+ def subset_cid(glyphs)
66
+ Subsetter.new(glyphs, data: @data, version: @version, top: @top,
67
+ top_dictionary_entries: @top_dictionary_entries, name_objects: @name_objects,
68
+ strings: @strings, global_subrs: @global_subrs, charstrings: @charstrings,
69
+ private_records: @private_records).build_cid
70
+ end
71
+
52
72
  private
53
73
 
54
74
  def read_index(at)
@@ -73,7 +93,7 @@ module Alhena
73
93
  data, stack, result = Binary.new(bytes), [], {}
74
94
  while data.position < data.size
75
95
  byte = data.u8
76
- if byte >= 32 || [28, 29, 30].include?(byte)
96
+ if byte >= 32 || [28, 29, 30, 255].include?(byte)
77
97
  stack << read_number(data, byte)
78
98
  raise InvalidFont, "CFF DICT operand overflow" if stack.length > (@version == 2 ? 513 : 48)
79
99
  else
@@ -105,6 +125,7 @@ module Alhena
105
125
  raise InvalidFont, "oversized CFF real" if value.size > 64
106
126
  end
107
127
  end
128
+ when 255 then data.fixed
108
129
  when 32..246 then byte - 139
109
130
  when 247..250 then (byte - 247) * 256 + data.u8 + 108
110
131
  when 251..254 then -(byte - 251) * 256 - data.u8 - 108
@@ -116,11 +137,32 @@ module Alhena
116
137
 
117
138
  def read_private_dict(dict)
118
139
  length, offset = dict.fetch(18, [0, 0])
119
- private_data = parse_dictionary(@data.bytes(offset, length))
140
+ raw = @data.bytes(offset, length)
141
+ private_data = parse_dictionary(raw)
120
142
  local = private_data.key?(19) ? read_index(offset + private_data[19].first).first : []
143
+ @private_records << { entries: dictionary_entries(raw), subrs: local } if @version == 1
121
144
  [local, private_data.fetch(20, [0]).first, private_data.fetch(21, [0]).first, dict[1207], private_data.fetch(22, [0]).first]
122
145
  end
123
146
 
147
+ def dictionary_entries(bytes)
148
+ data, operands, entries = Binary.new(bytes), [], []
149
+ while data.position < data.size
150
+ start = data.position
151
+ byte = data.u8
152
+ if byte >= 32 || [28, 29, 30, 255].include?(byte)
153
+ value = read_number(data, byte)
154
+ operands << [bytes.byteslice(start...data.position), value]
155
+ else
156
+ operator = byte == 12 ? 1200 + data.u8 : byte
157
+ entries << [operator, operands]
158
+ operands = []
159
+ end
160
+ end
161
+ raise InvalidFont, "unterminated CFF DICT operands" unless operands.empty?
162
+
163
+ entries
164
+ end
165
+
124
166
  def font_dict_index(glyph)
125
167
  return 0 unless @fd_select
126
168
  case @data.u8(@fd_select)
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alhena
4
+ class CFF
5
+ class Subsetter
6
+ def initialize(glyphs, data:, version:, top:, top_dictionary_entries:, name_objects:, strings:,
7
+ global_subrs:, charstrings:, private_records:)
8
+ @glyphs, @data, @version, @top = glyphs, data, version, top
9
+ @top_dictionary_entries, @name_objects, @strings = top_dictionary_entries, name_objects, strings
10
+ @global_subrs, @charstrings, @private_records = global_subrs, charstrings, private_records
11
+ end
12
+
13
+ def build
14
+ raise UnsupportedFont, "CFF subsetting requires CFF1" unless @version == 1
15
+ raise UnsupportedFont, "CID-keyed CFF subsetting is unsupported" if @top.key?(1230) || @top.key?(1236) || @top.key?(1237)
16
+ raise UnsupportedFont, "CFF predefined charsets cannot be subset" if @top.fetch(15, [0]).first < 3
17
+ raise UnsupportedFont, "custom CFF encodings cannot be subset" if @top.fetch(16, [0]).first > 1
18
+ raise ArgumentError, "glyphs must be a nonempty Array" unless @glyphs.is_a?(Array) && !@glyphs.empty?
19
+ raise ArgumentError, "CFF glyph out of range" unless @glyphs.all? { |glyph| glyph.is_a?(Integer) && glyph.between?(0, @charstrings.length - 1) }
20
+
21
+ glyphs = @glyphs.uniq.sort
22
+ raise ArgumentError, "CFF subset must include glyph 0" unless glyphs.first.zero?
23
+ private_record = @private_records.first
24
+ name_index, string_index = encode_index(@name_objects), encode_index(@strings)
25
+ global_subrs_index = encode_index(@global_subrs)
26
+ charstrings_index = encode_index(glyphs.map { |glyph| @charstrings.fetch(glyph) })
27
+ charset = subset_charset(glyphs)
28
+ local_subrs = private_record ? private_record[:subrs] : []
29
+ has_local_subrs = private_record && private_record[:entries].any? { |operator, _| operator == 19 }
30
+ local_subrs_index = has_local_subrs ? encode_index(local_subrs) : "".b
31
+ private_dictionary = encode_private_dictionary(private_record, has_local_subrs)
32
+ header = @data.bytes(0, @data.u8(2))
33
+ base_offset = header.bytesize + name_index.bytesize + string_index.bytesize + global_subrs_index.bytesize
34
+ charset_offset = charstrings_offset = private_offset = 0
35
+
36
+ 12.times do
37
+ top_index = encode_index([encode_top_dictionary(charset_offset, charstrings_offset, private_offset, private_dictionary.bytesize)])
38
+ offsets = [base_offset + top_index.bytesize]
39
+ offsets << offsets[0] + charset.bytesize
40
+ offsets << offsets[1] + charstrings_index.bytesize
41
+ break if offsets == [charset_offset, charstrings_offset, private_offset]
42
+
43
+ charset_offset, charstrings_offset, private_offset = offsets
44
+ end
45
+
46
+ top_index = encode_index([encode_top_dictionary(charset_offset, charstrings_offset, private_offset, private_dictionary.bytesize)])
47
+ offsets = [base_offset + top_index.bytesize]
48
+ offsets << offsets[0] + charset.bytesize
49
+ offsets << offsets[1] + charstrings_index.bytesize
50
+ raise InvalidFont, "CFF subset offsets did not converge" unless offsets == [charset_offset, charstrings_offset, private_offset]
51
+
52
+ header + name_index + top_index + string_index + global_subrs_index + charset +
53
+ charstrings_index + private_dictionary + local_subrs_index
54
+ end
55
+
56
+ def build_cid
57
+ raise UnsupportedFont, "CFF CID conversion requires name-keyed CFF1" unless @version == 1 && !@top.key?(1230) && !@top.key?(1236) && !@top.key?(1237)
58
+ raise ArgumentError, "glyphs must be a nonempty Array" unless @glyphs.is_a?(Array) && !@glyphs.empty?
59
+ raise ArgumentError, "CFF glyph out of range" unless @glyphs.all? { |glyph| glyph.is_a?(Integer) && glyph.between?(0, @charstrings.length - 1) }
60
+ raise ArgumentError, "CID 0 must map to glyph 0" unless @glyphs.first.zero?
61
+ raise ArgumentError, "CID font cannot exceed 65,536 glyphs" if @glyphs.length > 65_536
62
+
63
+ private_record = @private_records.first
64
+ has_local_subrs = private_record && private_record[:entries].any? { |operator, _| operator == 19 }
65
+ private_dictionary = private_record ? encode_private_dictionary(private_record, has_local_subrs) : "".b
66
+ local_subrs_index = has_local_subrs ? encode_index(private_record[:subrs]) : "".b
67
+ charset = [0].pack("C") + (1...@glyphs.length).to_a.pack("n*")
68
+ charstrings_index = encode_index(@glyphs.map { |glyph| @charstrings.fetch(glyph) })
69
+ fd_select = "\0".b * (@glyphs.length + 1)
70
+ header = @data.bytes(0, @data.u8(2))
71
+ name_index = encode_index(@name_objects)
72
+ string_index = encode_index(@strings + ["Adobe", "Identity"])
73
+ global_subrs_index = encode_index(@global_subrs)
74
+ base_offset = header.bytesize + name_index.bytesize + string_index.bytesize + global_subrs_index.bytesize
75
+ offsets = Array.new(5, 0)
76
+
77
+ 16.times do
78
+ top_index = encode_index([encode_cid_top_dictionary(offsets)])
79
+ cursor = base_offset + top_index.bytesize
80
+ charset_offset = cursor
81
+ cursor += charset.bytesize
82
+ charstrings_offset = cursor
83
+ cursor += charstrings_index.bytesize
84
+ fd_select_offset = cursor
85
+ cursor += fd_select.bytesize
86
+ fd_array_offset = cursor
87
+ fd_dictionary = encode_dictionary([], private_record ? {18 => [private_dictionary.bytesize, offsets[4]]} : {})
88
+ fd_array = encode_index([fd_dictionary])
89
+ private_offset = fd_array_offset + fd_array.bytesize
90
+ current = [charset_offset, charstrings_offset, fd_select_offset, fd_array_offset, private_offset]
91
+ break if current == offsets
92
+
93
+ offsets = current
94
+ end
95
+
96
+ top_index = encode_index([encode_cid_top_dictionary(offsets)])
97
+ fd_dictionary = encode_dictionary([], private_record ? {18 => [private_dictionary.bytesize, offsets[4]]} : {})
98
+ fd_array = encode_index([fd_dictionary])
99
+ charset_offset, charstrings_offset, fd_select_offset, fd_array_offset, private_offset = offsets
100
+ cursor = base_offset + top_index.bytesize
101
+ expected = [cursor, cursor + charset.bytesize,
102
+ cursor + charset.bytesize + charstrings_index.bytesize,
103
+ cursor + charset.bytesize + charstrings_index.bytesize + fd_select.bytesize]
104
+ raise InvalidFont, "CFF CID subset offsets did not converge" unless expected == offsets.take(4) && fd_array_offset + fd_array.bytesize == private_offset
105
+
106
+ header + name_index + top_index + string_index + global_subrs_index + charset +
107
+ charstrings_index + fd_select + fd_array + private_dictionary + local_subrs_index
108
+ end
109
+
110
+ private
111
+
112
+ def encode_private_dictionary(record, has_subrs)
113
+ return "".b unless record
114
+ return encode_dictionary(record[:entries], {}) unless has_subrs
115
+
116
+ relative_offset = 0
117
+ 8.times do
118
+ bytes = encode_dictionary(record[:entries], {19 => [relative_offset]})
119
+ return bytes if bytes.bytesize == relative_offset
120
+
121
+ relative_offset = bytes.bytesize
122
+ end
123
+ raise InvalidFont, "CFF private subroutine offset did not converge"
124
+ end
125
+
126
+ def encode_dictionary(entries, replacements)
127
+ present = {}
128
+ output = +"".b
129
+ entries.each do |operator, operands|
130
+ if replacements.key?(operator)
131
+ output << replacements.fetch(operator).map { |value| encode_number(value) }.join.b
132
+ present[operator] = true
133
+ else
134
+ operands.each { |raw, _value| output << raw }
135
+ end
136
+ output << encode_operator(operator)
137
+ end
138
+ replacements.each do |operator, values|
139
+ next if present[operator]
140
+
141
+ output << values.map { |value| encode_number(value) }.join.b
142
+ output << encode_operator(operator)
143
+ end
144
+ output
145
+ end
146
+
147
+ def encode_operator(operator)
148
+ operator >= 1200 ? [12, operator - 1200].pack("C2") : [operator].pack("C")
149
+ end
150
+
151
+ def encode_number(value)
152
+ raise InvalidFont, "invalid CFF offset" unless value.is_a?(Integer) && value >= 0
153
+ case value
154
+ when 0..107 then [value + 139].pack("C")
155
+ when 108..1131 then [247 + (value - 108) / 256, (value - 108) % 256].pack("C2")
156
+ when 1132..32767 then [28, value].pack("Cn")
157
+ when 32768..0x7fff_ffff then [29, value].pack("CN")
158
+ else raise InvalidFont, "CFF offset exceeds DICT integer range"
159
+ end
160
+ end
161
+
162
+ def encode_index(objects)
163
+ raise UnsupportedFont, "too many CFF INDEX entries" if objects.length > 0xffff
164
+ return [0].pack("n") if objects.empty?
165
+
166
+ offsets = [1]
167
+ objects.each { |object| offsets << offsets.last + object.bytesize }
168
+ raise UnsupportedFont, "CFF INDEX exceeds 32-bit offsets" if offsets.last > 0xffff_ffff
169
+ width = offsets.last <= 0xff ? 1 : offsets.last <= 0xffff ? 2 : offsets.last <= 0xff_ffff ? 3 : 4
170
+ encoded_offsets = offsets.map do |offset|
171
+ width.times.map { |index| (offset >> ((width - index - 1) * 8)) & 0xff }.pack("C*")
172
+ end.join.b
173
+ [objects.length].pack("n") + [width].pack("C") + encoded_offsets + objects.join.b
174
+ end
175
+
176
+ def subset_charset(glyphs)
177
+ offset = @top.fetch(15).first
178
+ format = @data.u8(offset)
179
+ source_sids = [0]
180
+ glyph = 1
181
+ case format
182
+ when 0
183
+ @data.validate_bounds(offset + 1, (@charstrings.length - 1) * 2)
184
+ source_sids.concat((@charstrings.length - 1).times.map { |index| @data.u16(offset + 1 + index * 2) })
185
+ when 1, 2
186
+ cursor = offset + 1
187
+ while glyph < @charstrings.length
188
+ first = @data.u16(cursor)
189
+ cursor += 2
190
+ left = format == 1 ? @data.u8(cursor) : @data.u16(cursor)
191
+ cursor += format == 1 ? 1 : 2
192
+ (left + 1).times { |index| source_sids[glyph] = first + index; glyph += 1 }
193
+ raise InvalidFont, "CFF charset range exceeds glyph count" if glyph > @charstrings.length
194
+ end
195
+ else
196
+ raise UnsupportedFont, "unsupported CFF charset format"
197
+ end
198
+ raise InvalidFont, "CFF charset does not cover glyphs" unless source_sids.length == @charstrings.length
199
+
200
+ selected_sids = glyphs.drop(1).map { |glyph_id| source_sids.fetch(glyph_id) }
201
+ unless selected_sids.all? { |sid| sid.between?(0, 390) || (sid >= 391 && sid - 391 < @strings.length) }
202
+ raise InvalidFont, "CFF charset references an invalid SID"
203
+ end
204
+ [0].pack("C") + selected_sids.pack("n*")
205
+ end
206
+
207
+ def encode_top_dictionary(charset_offset, charstrings_offset, private_offset, private_size)
208
+ replacements = {15 => [charset_offset], 17 => [charstrings_offset]}
209
+ replacements[18] = [private_size, private_offset] if @top.key?(18)
210
+ replacements[16] = [@top.fetch(16, [0]).first] if @top.key?(16)
211
+ encode_dictionary(@top_dictionary_entries, replacements)
212
+ end
213
+
214
+ def encode_cid_top_dictionary(offsets)
215
+ charset_offset, charstrings_offset, fd_select_offset, fd_array_offset = offsets
216
+ entries = @top_dictionary_entries.reject { |operator, _| [15, 16, 17, 18, 1230, 1236, 1237].include?(operator) }
217
+ ros_registry = 391 + @strings.length
218
+ replacements = {15 => [charset_offset], 17 => [charstrings_offset],
219
+ 1230 => [ros_registry, ros_registry + 1, 0], 1236 => [fd_array_offset], 1237 => [fd_select_offset]}
220
+ encode_dictionary(entries, replacements)
221
+ end
222
+ end
223
+ end
224
+ end
data/lib/alhena/font.rb CHANGED
@@ -5,7 +5,7 @@ module Alhena
5
5
  class Font
6
6
  LOOKUP_CACHE_SIZE = 4096
7
7
  private_constant :LOOKUP_CACHE_SIZE
8
- attr_reader :index, :tables, :axis_values
8
+ attr_reader :index, :tables, :axis_values, :sfnt_signature
9
9
 
10
10
  # @param path [String] sfnt, OpenType or TTC filename
11
11
  # @param index [Integer] zero-based collection face
@@ -29,6 +29,7 @@ module Alhena
29
29
  end
30
30
  signature = @binary.bytes(offset, 4)
31
31
  raise UnsupportedFont, "expected sfnt, OpenType or TTC" unless ["\x00\x01\x00\x00".b, "OTTO", "true"].include?(signature)
32
+ @sfnt_signature = signature.freeze
32
33
  count = @binary.u16(offset + 4)
33
34
  @binary.validate_bounds(offset + 12, count * 16)
34
35
  @tables = {}
@@ -64,6 +65,36 @@ module Alhena
64
65
  def ascent = table("hhea").i16(4)
65
66
  def descent = table("hhea").i16(6)
66
67
  def line_gap = table("hhea").i16(8)
68
+ def bbox = 4.times.map { |index| table("head").i16(36 + index * 2) }.freeze
69
+ def cff? = @tables.key?("CFF ") || @tables.key?("CFF2")
70
+
71
+ def glyph_ids(text)
72
+ raise ArgumentError, "text must be a String" unless text.is_a?(String)
73
+
74
+ codepoints = text.codepoints
75
+ codepoints.each_with_index.filter_map do |codepoint, index|
76
+ next if variation_selector?(codepoint)
77
+
78
+ selector = codepoints[index + 1]
79
+ glyph_id(codepoint, variation_selector: variation_selector?(selector) ? selector : nil)
80
+ end
81
+ end
82
+
83
+ # Unicode codepoint => glyph ID. `glyph_id` remains the bounded lookup API;
84
+ # this map is useful when a consumer needs a stable reverse mapping.
85
+ def cmap
86
+ @cmap ||= begin
87
+ result = {}
88
+ cmaps.each { |data| add_cmap_entries(result, data) }
89
+ result.freeze
90
+ end
91
+ end
92
+
93
+ def unicode_for_glyph(glyph)
94
+ validate_glyph(glyph)
95
+ @unicode_cmap ||= cmap.each_with_object({}) { |(codepoint, id), reverse| reverse[id] ||= codepoint }.freeze
96
+ @unicode_cmap[glyph]
97
+ end
67
98
 
68
99
  def names
69
100
  @names ||= begin
@@ -126,7 +157,11 @@ module Alhena
126
157
  cached_metric(glyph, vertical: vertical)[0] * scale_factor(size)
127
158
  end
128
159
 
129
- def advance_width(codepoints, size:, features: [])
160
+ def advance_width(codepoints, size: nil, features: [])
161
+ if codepoints.is_a?(Integer)
162
+ return cached_metric(codepoints, vertical: false)[0] * scale_factor(size || units_per_em)
163
+ end
164
+ raise ArgumentError, "size is required" unless size
130
165
  validate_features(features)
131
166
  raise ArgumentError, "codepoints must be an Array" unless codepoints.is_a?(Array)
132
167
 
@@ -190,6 +225,45 @@ module Alhena
190
225
 
191
226
  private
192
227
 
228
+ def add_cmap_entries(result, data)
229
+ format = data.u16(0)
230
+ case format
231
+ when 0
232
+ 256.times { |codepoint| add_cmap_entry(result, codepoint, glyph_id_from_cmap(data, codepoint)) }
233
+ when 4
234
+ count = data.u16(6) / 2
235
+ count.times do |index|
236
+ first = data.u16(16 + count * 2 + index * 2)
237
+ last = data.u16(14 + index * 2)
238
+ next if first == 0xffff || last == 0xffff
239
+
240
+ first.upto(last) { |codepoint| add_cmap_entry(result, codepoint, glyph_id_from_cmap(data, codepoint)) }
241
+ end
242
+ when 6
243
+ first, count = data.u16(6), data.u16(8)
244
+ count.times { |index| add_cmap_entry(result, first + index, glyph_id_from_cmap(data, first + index)) }
245
+ when 12, 13
246
+ count = data.u32(12)
247
+ data.validate_bounds(16, count * 12)
248
+ count.times do |index|
249
+ at = 16 + index * 12
250
+ first, last, start_glyph = data.u32(at), data.u32(at + 4), data.u32(at + 8)
251
+ if format == 13
252
+ add_cmap_entry(result, first, start_glyph)
253
+ next
254
+ end
255
+ [last, first + glyph_count - start_glyph - 1, 0x10ffff].min.downto(first) do |codepoint|
256
+ glyph = start_glyph + codepoint - first
257
+ add_cmap_entry(result, codepoint, glyph)
258
+ end
259
+ end
260
+ end
261
+ end
262
+
263
+ def add_cmap_entry(result, codepoint, glyph)
264
+ result[codepoint] ||= glyph if glyph.positive? && glyph < glyph_count && !(0xd800..0xdfff).cover?(codepoint)
265
+ end
266
+
193
267
  def scale_factor(size)
194
268
  valid = size.is_a?(Numeric) && size.real? && size.finite? && size > 0
195
269
  raise ArgumentError, "size must be positive and finite" unless valid
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alhena
4
+ module Subset
5
+ CHECKSUM = 0xb1b0afba
6
+ private_constant :CHECKSUM
7
+
8
+ module_function
9
+
10
+ def build(font, glyph_ids)
11
+ raise ArgumentError, "font must be an Alhena::Font" unless font.is_a?(Font)
12
+ raise ArgumentError, "glyph_ids must be an Array" unless glyph_ids.is_a?(Array)
13
+ glyph_ids.each do |glyph|
14
+ raise ArgumentError, "glyph ID out of range" unless glyph.is_a?(Integer) && glyph.between?(0, font.glyph_count - 1)
15
+ end
16
+
17
+ return cff(font, glyph_ids) if font.cff?
18
+
19
+ true_type(font, glyph_ids)
20
+ end
21
+
22
+ # Returns a CID-keyed CFF1 program with glyph order matching the requested CIDs.
23
+ # Unlike #build, glyph_ids may repeat; entry zero must be glyph 0 (.notdef).
24
+ def build_cid(font, glyph_ids)
25
+ raise ArgumentError, "font must be an Alhena::Font" unless font.is_a?(Font)
26
+ raise ArgumentError, "glyph_ids must be an Array" unless glyph_ids.is_a?(Array)
27
+ glyph_ids.each do |glyph|
28
+ raise ArgumentError, "glyph ID out of range" unless glyph.is_a?(Integer) && glyph.between?(0, font.glyph_count - 1)
29
+ end
30
+ raise UnsupportedFont, "CID conversion requires a non-variable CFF1 font" unless font.tables.key?("CFF ") && !font.tables.key?("CFF2") && !font.tables.key?("fvar") && !font.tables.key?("HVAR")
31
+
32
+ CFF.new(font.table("CFF "), units_per_em: font.units_per_em).subset_cid(glyph_ids)
33
+ end
34
+
35
+ def cff(font, glyph_ids)
36
+ raise UnsupportedFont, "CFF2 subsetting is unsupported" unless font.tables.key?("CFF ") && !font.tables.key?("CFF2")
37
+ raise UnsupportedFont, "variable CFF1 subsetting is unsupported" if font.tables.key?("fvar") || font.tables.key?("HVAR")
38
+
39
+ glyphs = [0, *glyph_ids].uniq.sort
40
+ remap = glyphs.each_with_index.to_h
41
+ header = font.table("hhea").data.dup
42
+ header[34, 2] = [glyphs.length].pack("n")
43
+ maximum = font.table("maxp").data.dup
44
+ maximum[4, 2] = [glyphs.length].pack("n")
45
+ tables = {
46
+ "CFF " => CFF.new(font.table("CFF "), units_per_em: font.units_per_em).subset(glyphs),
47
+ "head" => font.table("head").data,
48
+ "hhea" => header,
49
+ "hmtx" => metrics(font, glyphs),
50
+ "maxp" => maximum,
51
+ "cmap" => unicode_cmap(font, remap)
52
+ }
53
+ %w[OS/2 name].each { |tag| tables[tag] = font.table(tag).data if font.tables.key?(tag) }
54
+ if font.tables.key?("post")
55
+ post = font.table("post").bytes(0, 32).dup
56
+ post[0, 4] = [0x0003_0000].pack("N")
57
+ tables["post"] = post
58
+ end
59
+ sfnt(font, tables)
60
+ end
61
+
62
+ def true_type(font, glyph_ids)
63
+ glyphs = closure(font, [0, *glyph_ids].uniq)
64
+ remap = glyphs.each_with_index.to_h
65
+ glyf, locations = +"".b, [0]
66
+ glyphs.each do |glyph|
67
+ data = remap_components(glyph_bytes(font, glyph), remap)
68
+ glyf << data
69
+ glyf << "\0" if glyf.bytesize.odd?
70
+ locations << glyf.bytesize
71
+ end
72
+
73
+ head = font.table("head").data.dup
74
+ head[8, 4] = "\0" * 4
75
+ head[50, 2] = [1].pack("s>")
76
+ hhea = font.table("hhea").data.dup
77
+ hhea[34, 2] = [glyphs.length].pack("n")
78
+ maxp = font.table("maxp").data.dup
79
+ maxp[4, 2] = [glyphs.length].pack("n")
80
+ tables = {"head" => head, "hhea" => hhea, "maxp" => maxp, "glyf" => glyf,
81
+ "loca" => locations.pack("N*"), "hmtx" => metrics(font, glyphs),
82
+ "cmap" => unicode_cmap(font, remap)}
83
+ %w[OS/2 name post].each { |tag| tables[tag] = font.table(tag).data if font.tables.key?(tag) }
84
+ sfnt(font, tables)
85
+ end
86
+
87
+ def closure(font, roots)
88
+ found, pending = {}, roots.dup
89
+ until pending.empty?
90
+ glyph = pending.pop
91
+ next if found[glyph]
92
+ found[glyph] = true
93
+ composite_children(glyph_bytes(font, glyph)).each { |child| pending << child unless found[child] }
94
+ end
95
+ found.keys.sort
96
+ end
97
+
98
+ def glyph_bytes(font, glyph)
99
+ loca = font.table("loca")
100
+ format = font.table("head").i16(50)
101
+ first = format.zero? ? loca.u16(glyph * 2) * 2 : loca.u32(glyph * 4)
102
+ last = format.zero? ? loca.u16((glyph + 1) * 2) * 2 : loca.u32((glyph + 1) * 4)
103
+ raise InvalidFont, "invalid glyph location" if last < first
104
+
105
+ font.table("glyf").bytes(first, last - first)
106
+ end
107
+
108
+ def composite_children(data)
109
+ composite_offsets(data).map { |offset| data.unpack1("n", offset: offset) }
110
+ end
111
+
112
+ def remap_components(data, remap)
113
+ composite_offsets(data).each do |offset|
114
+ old = data.unpack1("n", offset: offset)
115
+ data[offset, 2] = [remap.fetch(old) { raise InvalidFont, "missing composite glyph #{old}" }].pack("n")
116
+ end
117
+ data
118
+ end
119
+
120
+ def composite_offsets(data)
121
+ return [] if data.empty? || data.unpack1("s>") >= 0
122
+
123
+ offsets = []
124
+ at = 10
125
+ loop do
126
+ raise InvalidFont, "truncated composite glyph" if at + 4 > data.bytesize
127
+ flags = data.unpack1("n", offset: at)
128
+ offsets << at + 2
129
+ at += 4 + ((flags & 1).zero? ? 2 : 4)
130
+ at += if (flags & 8) != 0 then 2 elsif (flags & 64) != 0 then 4 elsif (flags & 128) != 0 then 8 else 0 end
131
+ break if (flags & 32).zero?
132
+ end
133
+ offsets
134
+ end
135
+
136
+ def metrics(font, glyphs)
137
+ glyphs.map { |glyph| [font.advance(glyph).round, font.bearing(glyph).round].pack("ns>") }.join.b
138
+ end
139
+
140
+ def unicode_cmap(font, remap)
141
+ entries = font.cmap.filter_map { |codepoint, glyph| [codepoint, remap[glyph]] if remap.key?(glyph) }
142
+ groups = []
143
+ entries.sort.each do |codepoint, glyph|
144
+ previous = groups.last
145
+ if previous && codepoint == previous[1] + 1 && glyph == previous[2] + codepoint - previous[0]
146
+ previous[1] = codepoint
147
+ else
148
+ groups << [codepoint, codepoint, glyph]
149
+ end
150
+ end
151
+ subtable = [12, 0, 16 + groups.length * 12, 0, groups.length].pack("nnNNN") + groups.flatten.pack("N*")
152
+ [0, 1, 0, 4, 12].pack("n4N") + subtable
153
+ end
154
+
155
+ def sfnt(font, tables)
156
+ tables = tables.transform_values(&:b)
157
+ head = tables.fetch("head").dup
158
+ head[8, 4] = "\0" * 4
159
+ tables["head"] = head
160
+ count = tables.length
161
+ power = 1 << (Math.log2(count).floor)
162
+ search_range = power * 16
163
+ directory = [font.sfnt_signature, count, search_range, Math.log2(power).to_i, count * 16 - search_range].pack("a4n4")
164
+ payload, records, head_offset = +"".b, +"".b, nil
165
+ tables.sort.each do |tag, bytes|
166
+ offset = 12 + count * 16 + payload.bytesize
167
+ checksum = table_checksum(bytes)
168
+ records << tag << [checksum, offset, bytes.bytesize].pack("N3")
169
+ head_offset = offset if tag == "head"
170
+ payload << bytes
171
+ payload << "\0" until payload.bytesize % 4 == 0
172
+ end
173
+ output = directory + records + payload
174
+ adjustment = (CHECKSUM - table_checksum(output)) & 0xffffffff
175
+ output[head_offset + 8, 4] = [adjustment].pack("N")
176
+ output
177
+ end
178
+
179
+ def table_checksum(bytes)
180
+ padded = bytes.dup
181
+ padded << "\0" until padded.bytesize % 4 == 0
182
+ padded.unpack("N*").sum & 0xffffffff
183
+ end
184
+ end
185
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Alhena
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/alhena.rb CHANGED
@@ -15,7 +15,9 @@ require_relative "alhena/outline"
15
15
  require_relative "alhena/binary"
16
16
  require_relative "alhena/rasterizer"
17
17
  require_relative "alhena/cff"
18
+ require_relative "alhena/cff_subset"
18
19
  require_relative "alhena/font"
20
+ require_relative "alhena/subset"
19
21
  require_relative "alhena/variation"
20
22
  require_relative "alhena/png"
21
23
  require_relative "alhena/color"
data/sig/alhena.rbs CHANGED
@@ -62,12 +62,18 @@ module Alhena
62
62
  attr_reader index: Integer
63
63
  attr_reader tables: Hash[String, [Integer, Integer]]
64
64
  attr_reader axis_values: Hash[String, Numeric]
65
+ attr_reader sfnt_signature: String
65
66
  def data: () -> String
66
67
  def self.open: (String path, ?index: Integer, ?axes: Hash[String | Symbol, Numeric]) -> Font
67
68
  def initialize: (String data, ?index: Integer, ?axes: Hash[String | Symbol, Numeric]) -> void
68
69
  def table: (String tag) -> Binary
69
70
  def units_per_em: () -> Integer
70
71
  def glyph_count: () -> Integer
72
+ def bbox: () -> [Integer, Integer, Integer, Integer]
73
+ def cff?: () -> bool
74
+ def glyph_ids: (String) -> Array[Integer]
75
+ def cmap: () -> Hash[Integer, Integer]
76
+ def unicode_for_glyph: (Integer) -> Integer?
71
77
  def ascent: () -> Integer
72
78
  def descent: () -> Integer
73
79
  def line_gap: () -> Integer
@@ -77,7 +83,8 @@ module Alhena
77
83
  def post: () -> Hash[Symbol, Numeric | bool]
78
84
  def glyph_id: (String | Integer character, ?variation_selector: String | Integer | nil) -> Integer
79
85
  def advance: (Integer glyph, ?size: Numeric, ?vertical: bool) -> Float
80
- def advance_width: (Array[Integer] codepoints, size: Numeric, ?features: Array[String | Symbol]) -> Float
86
+ def advance_width: (Integer glyph_id, ?size: Numeric) -> Float
87
+ | (Array[Integer] codepoints, size: Numeric, ?features: Array[String | Symbol]) -> Float
81
88
  def measure: (String text, size: Numeric, ?features: Array[String | Symbol]) -> Metrics
82
89
  def bearing: (Integer glyph, ?size: Numeric, ?vertical: bool) -> Float
83
90
  def outline: (Integer glyph) -> Outline
@@ -124,4 +131,8 @@ module Alhena
124
131
  def prewarm: (Font font, String text, ?Numeric? size, **untyped options) -> self
125
132
  def clear: () -> self
126
133
  end
134
+ module Subset
135
+ def self.build: (Font, Array[Integer]) -> String
136
+ def self.build_cid: (Font, Array[Integer]) -> String
137
+ end
127
138
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: alhena
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
@@ -27,12 +27,14 @@ files:
27
27
  - lib/alhena/bitmap.rb
28
28
  - lib/alhena/cache.rb
29
29
  - lib/alhena/cff.rb
30
+ - lib/alhena/cff_subset.rb
30
31
  - lib/alhena/color.rb
31
32
  - lib/alhena/data_compat.rb
32
33
  - lib/alhena/font.rb
33
34
  - lib/alhena/outline.rb
34
35
  - lib/alhena/png.rb
35
36
  - lib/alhena/rasterizer.rb
37
+ - lib/alhena/subset.rb
36
38
  - lib/alhena/variation.rb
37
39
  - lib/alhena/version.rb
38
40
  - sig/alhena.rbs