fontisan 0.4.32 → 0.4.33

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: 9e6bea227da5d5c91a615e71d69d65cd7f8c98b7f374719e0f81da4cccc65c4c
4
- data.tar.gz: f38990f5900782d7881a2a4b676d09c1f656599f1281bb63086ed0f4be975d9b
3
+ metadata.gz: b16e0b97aeb7431384ce5f50ff3e4dfd8977b863ec76655ef09e6d53fefddc3c
4
+ data.tar.gz: b60547f43a45309f700f4a11bb67d36788cfde286de179b697a7fbfc3d43a897
5
5
  SHA512:
6
- metadata.gz: 2094827e33d51d8352391ff13ca61b450b17e51a6b588ae3ea4844b4ffd05d0e2845c1c031616af0ae140488367ddcc29cbedc2d808053d006063d9d53f803ba
7
- data.tar.gz: cbb253b31ba6f06442c3b08eda2acb046f26546652b80fa5b28140144ffeba415bdb92b9a9f4ef6db7398acd15364df1f800dfcf6ba5d19c899ea3b509f69317
6
+ metadata.gz: c5b05277f8c003e64537cfeb57eb933b8e34f26449acc86de639b1ed90e8b344e502f0a8181417c92de1b8d4c51ffbd98ed9ccacf04ae38b4ff5e0deeb6e80b4
7
+ data.tar.gz: ee1631c92ebf6a5fd7ba66c4f40bef089a8725560f08d013fd16dfafed46e6c8c3890d35128ded48d03c996f6a547bac1f2d8c17939a12bf5a429bd6ac9a1f58
@@ -8,7 +8,7 @@ Each file is `NN-short-name.md` where `NN` is the priority order.
8
8
  ### P0 — Correctness gaps (open bugs / silent failures)
9
9
  - [x] ~~[01 — CBDT/CBLC GID-stable propagation](01-cbdt-cblc-gid-stable-propagation.md)~~ ✓ Done (PR #115)
10
10
  - [x] ~~[02 — Collection-mode outline-priority regression](02-collection-outline-priority.md)~~ ✓ Already fixed
11
- - [15 — CFF/CFF2 subsetter strategy](15-cff-cff2-subsetter-strategy.md) Head crash fixed, profiles updated; CFF→UFO extraction done (TODO #10b); blocked on TODO #05 for full round-trip (PR #108 unskipped the test; now passing with TODO 01)
11
+ - [x] ~~[15 — CFF/CFF2 subsetter strategy](15-cff-cff2-subsetter-strategy.md)~~ Done CFF via UFO round-trip, CFF2 via standalone INDEX filter with VStore preservation
12
12
 
13
13
  ### P1 — High-value features
14
14
  - [03 — `fontisan audit` command (identity+style+features lens)](03-fontisan-audit-command.md)
@@ -18,7 +18,7 @@ Each file is `NN-short-name.md` where `NN` is the priority order.
18
18
  ### P2 — Specialist feature parity
19
19
  - [x] ~~[07 — CPAL v1 header fields](07-cpal-v1-header-fields.md)~~ ✓ Done (v0.4.25)
20
20
  - [x] ~~[08 — CFF standard string table](08-cff-standard-string-table.md)~~ ✓ Done (full 391 SIDs per Adobe TN 5176)
21
- - [06 — CFF2 blend/vsindex operators](06-cff2-blend-vsindex-operators.md)
21
+ - [x] ~~[06 — CFF2 blend/vsindex operators](06-cff2-blend-vsindex-operators.md)~~ ✓ Done — `Cff2.build_variable` emits blend operators in charstrings; `VariableOtf` passes masters through; VStore embedded with regions
22
22
  - [09 — Type 1 seac expansion](09-type1-seac-expansion.md)
23
23
  - [10 — UFO image set + feature writers](10-ufo-image-set-feature-writers.md)
24
24
  - [x] ~~[11 — kern groups.plist support](11-kern-groups-plist.md)~~ ✓ Done (Groups model in PR #116 + GPOS group resolution in this PR)
@@ -0,0 +1,392 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Subset
7
+ module TableStrategy
8
+ # CFF2 (Compact Font Format 2) subsetter strategy.
9
+ #
10
+ # Subsets CFF2 tables by filtering the CharStrings INDEX and FDSelect
11
+ # to the subset glyphs, then rebuilding the table with patched offsets.
12
+ # The Variable Store (ItemVariationStore) is preserved unchanged —
13
+ # its `vsindex` references in charstrings remain valid because
14
+ # charstrings are carried as opaque bytes.
15
+ #
16
+ # Unlike the CFF1 subsetter (which routes through UFO), CFF2 uses a
17
+ # standalone binary filter because CFF2 charstrings carry blend/vsindex
18
+ # operators for variation that the UFO round-trip would strip.
19
+ #
20
+ # Layout of the rebuilt table:
21
+ #
22
+ # Header (5 bytes)
23
+ # Top DICT (offsets patched to new positions)
24
+ # Global Subr INDEX (preserved as-is)
25
+ # [Variable Store] (preserved as-is, if present)
26
+ # [FDArray INDEX] (Private DICT offsets patched)
27
+ # [Private DICTs] (preserved as-is, relocated after FDArray)
28
+ # [FDSelect] (filtered to subset glyphs, if present)
29
+ # CharStrings INDEX (filtered to subset glyphs)
30
+ #
31
+ # Private DICT offsets in Font DICTs always use the 5-byte integer
32
+ # encoding (operator 29 + int32). This makes the FDArray byte size
33
+ # independent of the offset values, breaking the layout chicken-and-egg.
34
+ #
35
+ # See TODO #15 for the full design rationale.
36
+ class Cff2
37
+ # CFF2 Top DICT operator codes (per Adobe TN 5177 §8.1).
38
+ OP_CHARSTRINGS = 17
39
+ OP_VSTORE = 24
40
+ OP_FDARRAY = [12, 36].freeze
41
+ OP_FDSELECT = [12, 37].freeze
42
+ OP_PRIVATE = 18
43
+
44
+ MAX_TOPDICT_ITERATIONS = 16
45
+
46
+ # @param context [SubsetContext]
47
+ # @param tag [String] "CFF2"
48
+ # @param table [Object] parsed CFF2 table (unused — we read raw bytes)
49
+ # @return [String] subset CFF2 table bytes
50
+ def self.call(context:, tag:, table:)
51
+ raw = raw_bytes_from_font(context.font) || raw_bytes_from_table(table)
52
+ return raw unless raw && !raw.empty?
53
+
54
+ new(raw, context.mapping).subset
55
+ end
56
+
57
+ def self.raw_bytes_from_font(font)
58
+ t = font.table("CFF2") if font.respond_to?(:table)
59
+ t&.raw_data
60
+ end
61
+ private_class_method :raw_bytes_from_font
62
+
63
+ def self.raw_bytes_from_table(table)
64
+ table&.raw_data if table.respond_to?(:raw_data)
65
+ end
66
+ private_class_method :raw_bytes_from_table
67
+
68
+ # ------------------------------------------------------------------
69
+
70
+ def initialize(raw_data, mapping)
71
+ @raw = raw_data
72
+ @mapping = mapping
73
+ @reader = Tables::Cff2::TableReader.new(raw_data)
74
+ @reader.read_top_dict
75
+ end
76
+
77
+ # Build the subset CFF2 table.
78
+ #
79
+ # @return [String]
80
+ def subset
81
+ sections = gather_sections
82
+ retained_gids = @mapping.old_ids.sort
83
+
84
+ filtered = build_filtered_sections(sections, retained_gids)
85
+ layout = converge_layout(sections, filtered)
86
+ write_table(layout, filtered)
87
+ end
88
+
89
+ # ------------------------------------------------------------------
90
+ # Source section gathering
91
+ # ------------------------------------------------------------------
92
+
93
+ def gather_sections
94
+ td = @reader.top_dict
95
+ hdr = @reader.header
96
+ gsubr_off = hdr[:header_size] + hdr[:top_dict_length]
97
+
98
+ {
99
+ header_size: hdr[:header_size],
100
+ charstrings: read_index(td[OP_CHARSTRINGS]),
101
+ gsubr: read_index(gsubr_off),
102
+ fdarray: td[OP_FDARRAY] ? read_index(td[OP_FDARRAY]) : nil,
103
+ fdselect_offset: td[OP_FDSELECT],
104
+ vstore_offset: td[OP_VSTORE],
105
+ }
106
+ end
107
+
108
+ def read_index(offset)
109
+ Tables::Cff2::Index.read(@raw, offset)
110
+ end
111
+
112
+ # ------------------------------------------------------------------
113
+ # Section filtering
114
+ # ------------------------------------------------------------------
115
+
116
+ # Build all filtered/rebuilt section bytes.
117
+ def build_filtered_sections(sections, retained_gids)
118
+ {
119
+ charstrings_bytes: filter_charstrings(sections[:charstrings], retained_gids),
120
+ fdselect_bytes: filter_fdselect(sections, retained_gids),
121
+ fdarray_entries: sections[:fdarray] ? parse_fdarray_entries(sections[:fdarray]) : [],
122
+ }
123
+ end
124
+
125
+ # Build a new CharStrings INDEX containing only the retained glyphs.
126
+ def filter_charstrings(source_cs, retained_gids)
127
+ items = retained_gids.map do |gid|
128
+ item = source_cs[gid]
129
+ item.nil? || item.empty? ? "\x0e".b : item
130
+ end
131
+ Tables::Cff2::IndexBuilder.build(items)
132
+ end
133
+
134
+ # Rebuild FDSelect to cover only retained glyphs in their new order.
135
+ def filter_fdselect(sections, retained_gids)
136
+ return nil unless sections[:fdselect_offset]
137
+
138
+ assignments = Tables::Cff2::FdSelect.read(
139
+ @raw, sections[:fdselect_offset], sections[:charstrings].count
140
+ )
141
+ new_assigns = retained_gids.map { |gid| assignments[gid] || 0 }
142
+ Tables::Cff2::FdSelect.build(new_assigns)
143
+ end
144
+
145
+ # Parse each Font DICT in the FDArray, extract Private DICT bytes,
146
+ # and prepare entries for relocation.
147
+ def parse_fdarray_entries(source_fdarray)
148
+ source_fdarray.to_a.map do |fd_bytes|
149
+ parsed = @reader.parse_dict(fd_bytes)
150
+ priv = parsed[OP_PRIVATE]
151
+ entry = { parsed: parsed, priv_bytes: nil, priv_size: nil }
152
+ next entry unless priv.is_a?(Array) && priv.size == 2
153
+
154
+ size, offset = priv
155
+ entry[:priv_bytes] = @raw.byteslice(offset, size)
156
+ entry[:priv_size] = size
157
+ entry
158
+ end
159
+ end
160
+
161
+ # Build the FDArray INDEX bytes with the given Private DICT offsets.
162
+ # Private offsets always use 5-byte int32 encoding for deterministic
163
+ # sizing across layout iterations.
164
+ def build_fdarray_bytes(entries, priv_offsets)
165
+ rebuilt = entries.each_with_index.map do |entry, idx|
166
+ parsed = entry[:parsed].dup
167
+ if entry[:priv_size]
168
+ parsed[OP_PRIVATE] = [entry[:priv_size], priv_offsets[idx] || 0]
169
+ end
170
+ encode_font_dict(parsed)
171
+ end
172
+ Tables::Cff2::IndexBuilder.build(rebuilt)
173
+ end
174
+
175
+ # ------------------------------------------------------------------
176
+ # Layout convergence
177
+ # ------------------------------------------------------------------
178
+
179
+ # Iterate Top DICT encoding to stability, then produce the final
180
+ # layout with FDArray patched with concrete Private DICT offsets.
181
+ def converge_layout(sections, filtered)
182
+ top_dict_size = @reader.header[:top_dict_length]
183
+ placeholder_offsets = Array.new(filtered[:fdarray_entries].size, 0)
184
+ fdarray_placeholder = if filtered[:fdarray_entries].any?
185
+ build_fdarray_bytes(filtered[:fdarray_entries],
186
+ placeholder_offsets)
187
+ end
188
+ priv_bytes = filtered[:fdarray_entries].map { |e| e[:priv_bytes] }.compact
189
+
190
+ prev_size = nil
191
+ layout = nil
192
+ MAX_TOPDICT_ITERATIONS.times do
193
+ layout = layout_offsets(sections, top_dict_size, filtered,
194
+ fdarray_placeholder, priv_bytes)
195
+ new_td = build_top_dict_bytes(layout)
196
+ top_dict_size = new_td.bytesize
197
+ break if top_dict_size == prev_size
198
+
199
+ prev_size = top_dict_size
200
+ end
201
+
202
+ finalize_layout(layout, sections, top_dict_size, filtered,
203
+ fdarray_placeholder, priv_bytes)
204
+ end
205
+
206
+ # Assign byte offsets to every section.
207
+ def layout_offsets(sections, top_dict_size, filtered, fdarray_bytes, priv_bytes)
208
+ pos = sections[:header_size] + top_dict_size
209
+ layout = { top_dict_size: top_dict_size }
210
+
211
+ layout[:global_subrs_offset] = pos
212
+ layout[:global_subrs_bytes] = index_bytes(sections[:gsubr])
213
+ pos += layout[:global_subrs_bytes].bytesize
214
+
215
+ if sections[:vstore_offset]
216
+ layout[:vstore_offset] = pos
217
+ layout[:vstore_bytes] = vstore_bytes(sections[:vstore_offset])
218
+ pos += layout[:vstore_bytes].bytesize
219
+ end
220
+
221
+ if fdarray_bytes
222
+ layout[:fdarray_offset] = pos
223
+ layout[:fdarray_bytes] = fdarray_bytes
224
+ pos += fdarray_bytes.bytesize
225
+
226
+ priv_offsets = priv_bytes.map do |pb|
227
+ off = pos
228
+ pos += pb.bytesize
229
+ off
230
+ end
231
+ layout[:private_dict_offsets] = priv_offsets
232
+ layout[:private_dicts] = priv_bytes
233
+ end
234
+
235
+ if filtered[:fdselect_bytes]
236
+ layout[:fdselect_offset] = pos
237
+ layout[:fdselect_bytes] = filtered[:fdselect_bytes]
238
+ pos += layout[:fdselect_bytes].bytesize
239
+ end
240
+
241
+ layout[:charstrings_offset] = pos
242
+ layout[:charstrings_bytes] = filtered[:charstrings_bytes]
243
+ layout
244
+ end
245
+
246
+ # Build the Top DICT bytes with updated section offsets.
247
+ def build_top_dict_bytes(layout)
248
+ td = @reader.top_dict.dup
249
+ td[OP_CHARSTRINGS] = layout[:charstrings_offset]
250
+ td[OP_VSTORE] = layout[:vstore_offset] if layout[:vstore_offset]
251
+ td[OP_FDARRAY] = layout[:fdarray_offset] if layout[:fdarray_offset]
252
+ td[OP_FDSELECT] = layout[:fdselect_offset] if layout[:fdselect_offset]
253
+ encode_dict(td)
254
+ end
255
+
256
+ # Produce the final layout: stabilized Top DICT bytes, header,
257
+ # and FDArray rebuilt with concrete Private DICT offsets.
258
+ def finalize_layout(layout, sections, top_dict_size, filtered,
259
+ fdarray_placeholder, priv_bytes)
260
+ final = layout.dup
261
+ final[:top_dict_bytes] = build_top_dict_bytes(layout)
262
+ final[:header_bytes] = Tables::Cff2::Header.build(
263
+ top_dict_size: top_dict_size,
264
+ )
265
+ if filtered[:fdarray_entries].any?
266
+ final[:fdarray_bytes] = build_fdarray_bytes(
267
+ filtered[:fdarray_entries], final[:private_dict_offsets] || []
268
+ )
269
+ end
270
+ final
271
+ end
272
+
273
+ # ------------------------------------------------------------------
274
+ # Table serialization
275
+ # ------------------------------------------------------------------
276
+
277
+ def write_table(layout, filtered)
278
+ io = StringIO.new(+"")
279
+ io << layout[:header_bytes]
280
+ io << layout[:top_dict_bytes]
281
+ io << layout[:global_subrs_bytes]
282
+ io << layout[:vstore_bytes] if layout[:vstore_bytes]
283
+ io << layout[:fdarray_bytes] if layout[:fdarray_bytes]
284
+ (layout[:private_dicts] || []).each { |pd| io << pd }
285
+ io << layout[:fdselect_bytes] if layout[:fdselect_bytes]
286
+ io << layout[:charstrings_bytes]
287
+ io.string
288
+ end
289
+
290
+ # ------------------------------------------------------------------
291
+ # Binary helpers
292
+ # ------------------------------------------------------------------
293
+
294
+ # Extract the raw bytes of a parsed INDEX from the source table.
295
+ def index_bytes(index)
296
+ @raw.byteslice(index.start_offset, index.total_size)
297
+ end
298
+
299
+ # Extract VStore bytes by computing its byte extent.
300
+ def vstore_bytes(vstore_offset)
301
+ size = vstore_extent(vstore_offset)
302
+ @raw.byteslice(vstore_offset, size)
303
+ end
304
+
305
+ # Compute the byte size of an ItemVariationStore.
306
+ def vstore_extent(vs_off)
307
+ io = StringIO.new(@raw)
308
+ io.seek(vs_off)
309
+ _format = read_u16(io)
310
+ region_list_off = read_u32(io)
311
+ data_count = read_u16(io)
312
+ data_offsets = Array.new(data_count) { read_u32(io) }
313
+
314
+ extents = []
315
+ extents << region_list_end(vs_off + region_list_off) if region_list_off.positive?
316
+ data_offsets.each do |rel_off|
317
+ extents << item_variation_data_end(vs_off + rel_off) if rel_off.positive?
318
+ end
319
+ end_off = extents.max || (8 + (data_count * 4))
320
+ end_off - vs_off
321
+ end
322
+
323
+ def region_list_end(abs_off)
324
+ io = StringIO.new(@raw)
325
+ io.seek(abs_off)
326
+ axis_count = read_u16(io)
327
+ region_count = read_u16(io)
328
+ abs_off + 4 + (region_count * axis_count * 6)
329
+ end
330
+
331
+ def item_variation_data_end(abs_off)
332
+ io = StringIO.new(@raw)
333
+ io.seek(abs_off)
334
+ item_count = read_u16(io)
335
+ short_delta_count = read_u16(io)
336
+ region_index_count = read_u16(io)
337
+ long_count = region_index_count - short_delta_count
338
+ delta_set_size = (short_delta_count * 2) + long_count
339
+ abs_off + 6 + (region_index_count * 2) + (item_count * delta_set_size)
340
+ end
341
+
342
+ def read_u16(io)
343
+ io.read(2).unpack1("n")
344
+ end
345
+
346
+ def read_u32(io)
347
+ io.read(4).unpack1("N")
348
+ end
349
+
350
+ # ------------------------------------------------------------------
351
+ # DICT encoding
352
+ # ------------------------------------------------------------------
353
+
354
+ # Encode a DICT hash to binary using the CFF2 DictEncoder.
355
+ def encode_dict(dict)
356
+ io = +""
357
+ dict.each do |operator, operands|
358
+ vals = Array(operands)
359
+ vals.each do |v|
360
+ io << Tables::Cff2::DictEncoder.encode_integer(v.to_i)
361
+ end
362
+ io << Tables::Cff2::DictEncoder.encode_operator(operator)
363
+ end
364
+ io
365
+ end
366
+
367
+ # Encode a Font DICT. The Private operator (18) always uses 5-byte
368
+ # integer encoding for BOTH operands so the Font DICT byte size
369
+ # is deterministic across layout iterations.
370
+ def encode_font_dict(parsed)
371
+ io = +""
372
+ parsed.each do |operator, operands|
373
+ vals = Array(operands)
374
+ if operator == OP_PRIVATE && vals.size == 2
375
+ io << encode_int32(vals[0].to_i)
376
+ io << encode_int32(vals[1].to_i)
377
+ else
378
+ vals.each { |v| io << Tables::Cff2::DictEncoder.encode_integer(v.to_i) }
379
+ end
380
+ io << Tables::Cff2::DictEncoder.encode_operator(operator)
381
+ end
382
+ io
383
+ end
384
+
385
+ # 5-byte integer encoding: byte 29 (marker) + 4-byte big-endian.
386
+ def encode_int32(value)
387
+ [29, value].pack("CN")
388
+ end
389
+ end
390
+ end
391
+ end
392
+ end
@@ -17,6 +17,7 @@ module Fontisan
17
17
  autoload :Cblc, "fontisan/subset/table_strategy/cblc"
18
18
  autoload :Cbdt, "fontisan/subset/table_strategy/cbdt"
19
19
  autoload :Cff, "fontisan/subset/table_strategy/cff"
20
+ autoload :Cff2, "fontisan/subset/table_strategy/cff2"
20
21
  autoload :Cmap, "fontisan/subset/table_strategy/cmap"
21
22
  autoload :ColorBitmapPlacement,
22
23
  "fontisan/subset/table_strategy/color_bitmap_placement"
@@ -57,6 +58,7 @@ module Fontisan
57
58
  "head" => :Head,
58
59
  "OS/2" => :Os2,
59
60
  "CFF " => :Cff,
61
+ "CFF2" => :Cff2,
60
62
  "CBDT" => :Cbdt,
61
63
  "CBLC" => :Cblc,
62
64
  }.freeze
@@ -73,8 +73,11 @@ width: nil)
73
73
  @num_regions = num_regions
74
74
  @has_blend = false
75
75
 
76
- outline.commands.each_with_index do |cmd, i|
77
- master_cmds = master_outlines.map { |mo| mo.commands[i] }
76
+ commands = outline.to_cff_commands
77
+ master_commands = master_outlines.map(&:to_cff_commands)
78
+
79
+ commands.each_with_index do |cmd, i|
80
+ master_cmds = master_commands.map { |mc| mc[i] }
78
81
  encode_variable_command(cmd, master_cmds)
79
82
  end
80
83
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "stringio"
4
+
3
5
  module Fontisan
4
6
  module Tables
5
7
  class Cff2
@@ -17,10 +19,81 @@ module Fontisan
17
19
  #
18
20
  # @see https://learn.microsoft.com/en-us/typography/opentype/spec/cff2
19
21
  class FdSelect
22
+ # Read an FDSelect subtable and return the flat FD-index array
23
+ # (one entry per glyph).
24
+ #
25
+ # @param raw_data [String] full CFF2 table bytes
26
+ # @param offset [Integer] byte offset of the FDSelect
27
+ # @param num_glyphs [Integer] glyph count (from maxp)
28
+ # @return [Array<Integer>] FD index per glyph
29
+ def self.read(raw_data, offset, num_glyphs)
30
+ io = StringIO.new(raw_data)
31
+ io.seek(offset)
32
+ format = io.read(1).unpack1("C")
33
+
34
+ case format
35
+ when 0 then read_format0(io, num_glyphs)
36
+ when 3 then read_format3(io, num_glyphs)
37
+ when 4 then read_format4(io, num_glyphs)
38
+ else
39
+ raise CorruptedTableError, "unsupported FDSelect format: #{format}"
40
+ end
41
+ end
42
+
43
+ # Format 0: uint8 fd_index[numGlyphs]
44
+ def self.read_format0(io, num_glyphs)
45
+ io.read(num_glyphs).unpack("C*")
46
+ end
47
+ private_class_method :read_format0
48
+
49
+ # Format 3: uint16 numRanges, Range3[], uint16 sentinel
50
+ def self.read_format3(io, num_glyphs)
51
+ num_ranges = io.read(2).unpack1("n")
52
+ assignments = Array.new(num_glyphs, 0)
53
+ ranges = Array.new(num_ranges) do
54
+ first = io.read(2).unpack1("n")
55
+ fd = io.read(1).unpack1("C")
56
+ [first, fd]
57
+ end
58
+ sentinel = io.read(2).unpack1("n")
59
+
60
+ ranges.each_cons(2) do |(first, fd), (next_first, _)|
61
+ assignments.fill(fd, first, next_first - first)
62
+ end
63
+ if ranges.any?
64
+ last_first, last_fd = ranges.last
65
+ assignments.fill(last_fd, last_first, sentinel - last_first)
66
+ end
67
+ assignments
68
+ end
69
+ private_class_method :read_format3
70
+
71
+ # Format 4: uint32 numRanges, Range4[], uint32 sentinel
72
+ def self.read_format4(io, num_glyphs)
73
+ num_ranges = io.read(4).unpack1("N")
74
+ assignments = Array.new(num_glyphs, 0)
75
+ ranges = Array.new(num_ranges) do
76
+ first = io.read(4).unpack1("N")
77
+ fd = io.read(2).unpack1("n")
78
+ [first, fd]
79
+ end
80
+ sentinel = io.read(4).unpack1("N")
81
+
82
+ ranges.each_cons(2) do |(first, fd), (next_first, _)|
83
+ assignments.fill(fd, first, next_first - first)
84
+ end
85
+ if ranges.any?
86
+ last_first, last_fd = ranges.last
87
+ assignments.fill(last_fd, last_first, sentinel - last_first)
88
+ end
89
+ assignments
90
+ end
91
+ private_class_method :read_format4
92
+
20
93
  # @param assignments [Array<Integer>] FD index per glyph (length = numGlyphs)
21
94
  # @return [String] FDSelect bytes in the most compact format
22
95
  def self.build(assignments)
23
- return 0.to_s if assignments.empty?
96
+ return [0].pack("C").to_s + "".b if assignments.empty?
24
97
 
25
98
  format0_bytes(assignments)
26
99
  end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ class Cff2
8
+ # Reads a CFF2 INDEX structure.
9
+ #
10
+ # A CFF2 INDEX uses a 4-byte count (Card32) instead of the
11
+ # 2-byte count (Card16) used by CFF1. Otherwise the layout is
12
+ # the same: count, offSize, offset array (1-based), then data.
13
+ #
14
+ # An empty INDEX is just the 4-byte count field (= 0).
15
+ #
16
+ # @example
17
+ # idx = Cff2::Index.read(raw_cff2_bytes, charstrings_offset)
18
+ # idx[0] # first item
19
+ # idx.count
20
+ # idx.total_size
21
+ class Index
22
+ include Enumerable
23
+
24
+ attr_reader :count, :off_size, :offsets, :data, :start_offset
25
+
26
+ # Read a CFF2 INDEX at +offset+ within +raw_data+.
27
+ #
28
+ # @param raw_data [String] full CFF2 table bytes
29
+ # @param offset [Integer] byte offset of the INDEX
30
+ def self.read(raw_data, offset)
31
+ io = StringIO.new(raw_data)
32
+ io.seek(offset)
33
+ new(io, offset)
34
+ end
35
+
36
+ # Build an INDEX from a list of data items.
37
+ # Delegates to {IndexBuilder}.
38
+ #
39
+ # @param items [Array<String>]
40
+ # @return [String] binary INDEX bytes
41
+ def self.build(items)
42
+ IndexBuilder.build(items)
43
+ end
44
+
45
+ # @param io [IO, StringIO] positioned at the INDEX start
46
+ # @param start_offset [Integer] byte offset (for diagnostics)
47
+ def initialize(io, start_offset = 0)
48
+ @io = io
49
+ @start_offset = start_offset
50
+ parse!
51
+ end
52
+
53
+ # Fetch the item at +index+ (0-based).
54
+ #
55
+ # @return [String, nil] binary data, or nil if out of range
56
+ def [](index)
57
+ return nil if index.negative? || index >= count
58
+ return "".b if count.zero?
59
+
60
+ start_pos = offsets[index] - 1
61
+ end_pos = offsets[index + 1] - 1
62
+ data[start_pos, end_pos - start_pos]
63
+ end
64
+
65
+ # Iterate over all items.
66
+ def each
67
+ return enum_for(:each) unless block_given?
68
+
69
+ count.times { |i| yield self[i] }
70
+ end
71
+
72
+ # All items as an array.
73
+ def to_a
74
+ Array.new(count) { |i| self[i] }
75
+ end
76
+
77
+ def empty?
78
+ count.zero?
79
+ end
80
+
81
+ # Total size of the INDEX in bytes (count + offSize + offsets + data).
82
+ def total_size
83
+ return 4 if count.zero?
84
+
85
+ 4 + 1 + ((count + 1) * off_size) + data.bytesize
86
+ end
87
+
88
+ private
89
+
90
+ def parse!
91
+ @count = read_uint32
92
+
93
+ if @count.zero?
94
+ @off_size = 0
95
+ @offsets = []
96
+ @data = "".b
97
+ return
98
+ end
99
+
100
+ @off_size = read_uint8
101
+ raise CorruptedTableError, "invalid CFF2 offSize: #{@off_size}" unless (1..4).cover?(@off_size)
102
+
103
+ @offsets = Array.new(@count + 1) { read_offset(@off_size) }
104
+ validate_offsets!
105
+
106
+ data_size = @offsets.last - 1
107
+ @data = read_bytes(data_size)
108
+ end
109
+
110
+ def read_uint32
111
+ read_bytes(4).unpack1("N")
112
+ end
113
+
114
+ def read_uint8
115
+ read_bytes(1).unpack1("C")
116
+ end
117
+
118
+ def read_offset(size)
119
+ bytes = read_bytes(size)
120
+ case size
121
+ when 1 then bytes.unpack1("C")
122
+ when 2 then bytes.unpack1("n")
123
+ when 3 then bytes.unpack("C3").inject(0) { |s, b| (s << 8) | b }
124
+ when 4 then bytes.unpack1("N")
125
+ end
126
+ end
127
+
128
+ def read_bytes(n)
129
+ return "".b if n.zero?
130
+
131
+ bytes = @io.read(n)
132
+ if bytes.nil? || bytes.bytesize < n
133
+ raise CorruptedTableError,
134
+ "unexpected EOF in CFF2 INDEX at offset #{@start_offset}"
135
+ end
136
+
137
+ bytes
138
+ end
139
+
140
+ def validate_offsets!
141
+ unless @offsets.first == 1
142
+ raise CorruptedTableError,
143
+ "CFF2 INDEX first offset must be 1, got #{@offsets.first}"
144
+ end
145
+
146
+ @offsets.each_cons(2) do |prev, curr|
147
+ next unless curr < prev
148
+
149
+ raise CorruptedTableError, "CFF2 INDEX offsets not ascending"
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -235,25 +235,7 @@ module Fontisan
235
235
  Cff::Index.new(@io, start_offset: offset)
236
236
  end
237
237
 
238
- private
239
-
240
- # Read bytes safely with EOF checking
241
- #
242
- # @param bytes [Integer] Number of bytes to read
243
- # @param description [String] Description for error messages
244
- # @return [String] Binary data
245
- # @raise [EOFError] If not enough bytes available
246
- def read_safely(bytes, description)
247
- data = @io.read(bytes)
248
- if data.nil? || data.bytesize < bytes
249
- raise EOFError,
250
- "Unexpected EOF while reading #{description}"
251
- end
252
-
253
- data
254
- end
255
-
256
- # Parse DICT structure
238
+ # Parse a DICT structure (public: reused by the CFF2 subsetter).
257
239
  #
258
240
  # @param data [String] DICT binary data
259
241
  # @return [Hash] Parsed operators and values
@@ -281,20 +263,37 @@ module Fontisan
281
263
  dict
282
264
  end
283
265
 
266
+ private
267
+
268
+ # Read bytes safely with EOF checking
269
+ #
270
+ # @param bytes [Integer] Number of bytes to read
271
+ # @param description [String] Description for error messages
272
+ # @return [String] Binary data
273
+ # @raise [EOFError] If not enough bytes available
274
+ def read_safely(bytes, description)
275
+ data = @io.read(bytes)
276
+ if data.nil? || data.bytesize < bytes
277
+ raise EOFError,
278
+ "Unexpected EOF while reading #{description}"
279
+ end
280
+
281
+ data
282
+ end
283
+
284
284
  # Check if byte is an operator
285
285
  #
286
- # CFF2 extends the operator range to include operator 24 (vstore)
286
+ # CFF/CFF2 DICT operator bytes are 0-21 (with 12 as the two-byte
287
+ # escape prefix) and 24 (vstore, CFF2-specific). Number encoding
288
+ # prefixes (28, 29, 30) and ranges (32-254) are NOT operators.
287
289
  #
288
290
  # @param byte [Integer] Byte value
289
291
  # @return [Boolean] True if operator
290
292
  def operator_byte?(byte)
291
- # Standard DICT operators (0-21, excluding number markers)
292
- return true if byte <= 21 && ![12, 28, 29, 30, 31].include?(byte)
293
-
294
- # CFF2-specific operators
295
- return true if byte == VSTORE_OPERATOR
293
+ return false if (28..30).cover?(byte) # number prefixes
294
+ return false if (32..254).cover?(byte) # number ranges
296
295
 
297
- false
296
+ true # 0-27 (incl. 12 escape), 31, 255 — treat as operator
298
297
  end
299
298
 
300
299
  # Read DICT operator
@@ -26,6 +26,7 @@ module Fontisan
26
26
  autoload :CharstringParser, "fontisan/tables/cff2/charstring_parser"
27
27
  autoload :FdSelect, "fontisan/tables/cff2/fd_select"
28
28
  autoload :Header, "fontisan/tables/cff2/header"
29
+ autoload :Index, "fontisan/tables/cff2/index"
29
30
  autoload :IndexBuilder, "fontisan/tables/cff2/index_builder"
30
31
  autoload :DictEncoder, "fontisan/tables/cff2/dict_encoder"
31
32
  autoload :OperandStack, "fontisan/tables/cff2/operand_stack"
@@ -33,42 +33,143 @@ module Fontisan
33
33
 
34
34
  # @param font [Fontisan::Ufo::Font]
35
35
  # @param glyphs [Array<Fontisan::Ufo::Glyph>] in gid order
36
+ # @param masters [Array<Hash>, nil] variation masters for variable
37
+ # CFF2. Each hash: { font:, axes: }. When present, charstrings
38
+ # emit blend operators and a VariationStore is embedded.
39
+ # @param axis_count [Integer, nil] number of variation axes
36
40
  # @param variation_store [String, nil] ItemVariationStore bytes
37
41
  # for variable CFF2 fonts. When present, embedded between the
38
42
  # GlobalSubr INDEX and CharStrings INDEX, and referenced from
39
43
  # the Top DICT via operator 24.
40
44
  # @return [String] CFF2 table bytes
41
- def self.build(_font, glyphs:, variation_store: nil)
45
+ def self.build(font, glyphs:, masters: nil, axis_count: nil,
46
+ variation_store: nil)
47
+ if masters&.any?
48
+ build_variable(font, glyphs, masters, axis_count)
49
+ else
50
+ build_static(font, glyphs, variation_store:)
51
+ end
52
+ end
53
+
54
+ # Build a static CFF2 table (no variation data).
55
+ def self.build_static(font, glyphs, variation_store:)
42
56
  charstrings = glyphs.map { |g| charstring_for(g) }
43
57
  global_subr_index = empty_global_subr_index
44
58
  font_dict = build_font_dict(private_size: 0, private_offset: 0)
45
59
  font_dict_index = Tables::Cff2::IndexBuilder.build([font_dict])
46
60
  vs_bytes = variation_store&.b
47
61
 
48
- # Fixed-point iteration: encode Top DICT, compute offsets, repeat.
49
- top_dict = encode_top_dict(
50
- charstrings_offset: 0, font_dict_index_offset: 0,
51
- variation_store_offset: vs_bytes ? 0 : nil
62
+ layout = converge_layout(
63
+ charstrings:, global_subr_index:, font_dict_index:,
64
+ variation_store: vs_bytes
52
65
  )
53
- layout = compute_layout(top_dict:, charstrings:, global_subr_index:,
54
- font_dict_index:, variation_store: vs_bytes)
66
+ assemble(layout:)
67
+ end
55
68
 
56
- 10.times do
57
- top_dict = encode_top_dict(
58
- charstrings_offset: layout[:charstrings_offset],
59
- font_dict_index_offset: layout[:font_dict_index_offset],
60
- variation_store_offset: layout[:variation_store_offset],
61
- )
62
- next_layout = compute_layout(top_dict:, charstrings:, global_subr_index:,
63
- font_dict_index:, variation_store: vs_bytes)
64
- break if same_offsets?(layout, next_layout)
69
+ # Build a variable CFF2 table with blend operators and a VStore.
70
+ #
71
+ # Each glyph gets a variable charstring with blend operators when
72
+ # master outlines are available for it. Glyphs without masters fall
73
+ # back to static charstrings. The VStore contains one region per
74
+ # master, each peaking at the master's axis location.
75
+ def self.build_variable(font, glyphs, masters, axis_count)
76
+ num_regions = masters.size
77
+ vstore = build_variation_store(masters, axis_count)
65
78
 
66
- layout = next_layout
79
+ master_glyph_sets = masters.map do |m|
80
+ { font: m[:font], by_name: m[:font].glyphs }
81
+ end
82
+
83
+ charstrings = glyphs.map.with_index do |glyph, _idx|
84
+ master_outlines = master_glyph_sets.filter_map do |mg|
85
+ mg[:by_name][glyph.name]&.to_outline
86
+ end
87
+
88
+ if master_outlines.size == num_regions && glyph.contours.any?
89
+ Tables::Cff::Cff2CharStringBuilder.build_variable(
90
+ glyph.to_outline,
91
+ master_outlines: master_outlines,
92
+ num_regions: num_regions,
93
+ width: glyph.width.to_i,
94
+ )
95
+ else
96
+ charstring_for(glyph)
97
+ end
67
98
  end
68
99
 
100
+ global_subr_index = empty_global_subr_index
101
+ font_dict = build_font_dict(private_size: 0, private_offset: 0)
102
+ font_dict_index = Tables::Cff2::IndexBuilder.build([font_dict])
103
+
104
+ layout = converge_layout(
105
+ charstrings:, global_subr_index:, font_dict_index:,
106
+ variation_store: vstore
107
+ )
69
108
  assemble(layout:)
70
109
  end
71
110
 
111
+ # Build the ItemVariationStore for CFF2: one region per master,
112
+ # each peaking at the master's axis location.
113
+ def self.build_variation_store(masters, axis_count)
114
+ ac = axis_count || masters.first&.dig(:axes)&.size || 1
115
+ regions = masters.each_with_index.map do |_master, midx|
116
+ Array.new(ac) do |aidx|
117
+ if aidx == midx
118
+ { start: -1.0, peak: 1.0, end: 1.0 }
119
+ else
120
+ { start: -1.0, peak: 0.0, end: 1.0 }
121
+ end
122
+ end
123
+ end
124
+
125
+ region_list = serialize_region_list(regions, ac)
126
+ # One ItemVariationData with all regions referenced but zero items.
127
+ # The actual deltas live in charstrings (pushed via blend operands).
128
+ var_data = serialize_empty_variation_data(regions.size)
129
+
130
+ header_size = 8
131
+ offsets_array_size = 4
132
+ region_list_offset = header_size + offsets_array_size
133
+ var_data_offset = region_list_offset + region_list.bytesize
134
+
135
+ store = +""
136
+ store << [1].pack("n")
137
+ store << [region_list_offset].pack("N")
138
+ store << [1].pack("n")
139
+ store << [var_data_offset].pack("N")
140
+ store << region_list
141
+ store << var_data
142
+ store
143
+ end
144
+
145
+ def self.serialize_region_list(regions, axis_count)
146
+ io = +""
147
+ io << [axis_count].pack("n")
148
+ io << [regions.size].pack("n")
149
+ regions.each do |region|
150
+ region.each do |coords|
151
+ io << [f2dot14(coords[:start]), f2dot14(coords[:peak]),
152
+ f2dot14(coords[:end])].pack("nnn")
153
+ end
154
+ end
155
+ io
156
+ end
157
+
158
+ # Serialize an ItemVariationData with all regions referenced but
159
+ # zero items. CFF2 charstrings push their own deltas via blend.
160
+ def self.serialize_empty_variation_data(region_count)
161
+ io = +""
162
+ io << [0].pack("n") # itemCount = 0
163
+ io << [region_count].pack("n") # shortDeltaCount
164
+ io << [region_count].pack("n") # regionIndexCount
165
+ region_count.times { |i| io << [i].pack("n") }
166
+ io
167
+ end
168
+
169
+ def self.f2dot14(value)
170
+ (value.to_f * 16384).to_i.clamp(-16384, 16384)
171
+ end
172
+
72
173
  # ---------- charstring per-glyph ----------
73
174
 
74
175
  def self.charstring_for(glyph)
@@ -148,6 +249,43 @@ module Fontisan
148
249
  }
149
250
  end
150
251
 
252
+ # Iteratively encode the Top DICT and recompute offsets until the
253
+ # layout stabilizes. After convergence, performs one final
254
+ # encode + compute pass so +layout[:top_dict]+ carries the
255
+ # converged offsets (not the stale initial placeholder).
256
+ def self.converge_layout(charstrings:, global_subr_index:,
257
+ font_dict_index:, variation_store: nil)
258
+ has_vs = !variation_store.nil?
259
+ top_dict = encode_top_dict(
260
+ charstrings_offset: 0, font_dict_index_offset: 0,
261
+ variation_store_offset: has_vs ? 0 : nil
262
+ )
263
+ layout = compute_layout(top_dict:, charstrings:, global_subr_index:,
264
+ font_dict_index:, variation_store:)
265
+
266
+ 10.times do
267
+ top_dict = encode_top_dict(
268
+ charstrings_offset: layout[:charstrings_offset],
269
+ font_dict_index_offset: layout[:font_dict_index_offset],
270
+ variation_store_offset: layout[:variation_store_offset],
271
+ )
272
+ next_layout = compute_layout(top_dict:, charstrings:, global_subr_index:,
273
+ font_dict_index:, variation_store:)
274
+ break if same_offsets?(layout, next_layout)
275
+
276
+ layout = next_layout
277
+ end
278
+
279
+ # Final encode ensures layout[:top_dict] matches converged offsets
280
+ final_td = encode_top_dict(
281
+ charstrings_offset: layout[:charstrings_offset],
282
+ font_dict_index_offset: layout[:font_dict_index_offset],
283
+ variation_store_offset: layout[:variation_store_offset],
284
+ )
285
+ compute_layout(top_dict: final_td, charstrings:, global_subr_index:,
286
+ font_dict_index:, variation_store:)
287
+ end
288
+
151
289
  def self.same_offsets?(a, b)
152
290
  a[:charstrings_offset] == b[:charstrings_offset] &&
153
291
  a[:font_dict_index_offset] == b[:font_dict_index_offset] &&
@@ -173,8 +311,12 @@ module Fontisan
173
311
 
174
312
  private_class_method :charstring_for, :empty_charstring,
175
313
  :encode_top_dict, :build_font_dict,
176
- :compute_layout, :same_offsets?,
177
- :empty_global_subr_index, :assemble
314
+ :compute_layout, :converge_layout,
315
+ :same_offsets?,
316
+ :empty_global_subr_index, :assemble,
317
+ :build_static, :build_variable,
318
+ :build_variation_store, :serialize_region_list,
319
+ :serialize_empty_variation_data, :f2dot14
178
320
  end
179
321
  end
180
322
  end
@@ -9,16 +9,13 @@ module Fontisan
9
9
  #
10
10
  # The orchestrator assembles a default-master UFO plus variation
11
11
  # masters into a variable OpenType font with:
12
- # - CFF2 outlines (static blend/vsindex operators are TODO 07/18)
12
+ # - CFF2 outlines with blend operators for each varying coordinate
13
+ # - ItemVariationStore embedded in the CFF2 table (regions only;
14
+ # deltas live in charstring blend operands)
13
15
  # - fvar (axes + instances)
14
16
  # - STAT (style attributes)
15
17
  # - avar (axis remapping, when maps are provided)
16
18
  #
17
- # NOTE: the CFF2 table currently contains static outlines (no
18
- # VariationStore, no blend operators). Full variable CFF2 requires
19
- # TODO 07 (blend/vsindex) and TODO 18 (blend integration) to be
20
- # wired into the charstring builder.
21
- #
22
19
  # @see https://learn.microsoft.com/en-us/typography/opentype/spec/cff2
23
20
  class VariableOtf
24
21
  # @param default_font [Fontisan::Ufo::Font] the default master
@@ -59,7 +56,7 @@ module Fontisan
59
56
  "post" => Post.build(@default),
60
57
  "hmtx" => Hmtx.build(@default, glyphs: glyphs),
61
58
  "cmap" => Cmap.build(@default, glyphs: glyphs),
62
- "CFF2" => Cff2.build(@default, glyphs: glyphs),
59
+ "CFF2" => build_cff2(glyphs),
63
60
  "fvar" => Fvar.build(@default, axes: @axes, instances: @instances),
64
61
  "STAT" => Stat.build(axes: @axes),
65
62
  }
@@ -69,6 +66,18 @@ module Fontisan
69
66
 
70
67
  tables
71
68
  end
69
+
70
+ private
71
+
72
+ # Build the CFF2 table. When masters are supplied, variable
73
+ # charstrings with blend operators are emitted; otherwise a static
74
+ # CFF2 table is produced.
75
+ def build_cff2(glyphs)
76
+ masters = @masters.map { |mf| { font: mf, axes: @axes } }
77
+ Cff2.build(@default, glyphs: glyphs,
78
+ masters: masters.any? ? masters : nil,
79
+ axis_count: @axes.size)
80
+ end
72
81
  end
73
82
  end
74
83
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.32"
4
+ VERSION = "0.4.33"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontisan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.32
4
+ version: 0.4.33
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -486,6 +486,7 @@ files:
486
486
  - lib/fontisan/subset/table_strategy/cbdt.rb
487
487
  - lib/fontisan/subset/table_strategy/cblc.rb
488
488
  - lib/fontisan/subset/table_strategy/cff.rb
489
+ - lib/fontisan/subset/table_strategy/cff2.rb
489
490
  - lib/fontisan/subset/table_strategy/cmap.rb
490
491
  - lib/fontisan/subset/table_strategy/color_bitmap_placement.rb
491
492
  - lib/fontisan/subset/table_strategy/color_bitmap_strike_plan.rb
@@ -561,6 +562,7 @@ files:
561
562
  - lib/fontisan/tables/cff2/dict_encoder.rb
562
563
  - lib/fontisan/tables/cff2/fd_select.rb
563
564
  - lib/fontisan/tables/cff2/header.rb
565
+ - lib/fontisan/tables/cff2/index.rb
564
566
  - lib/fontisan/tables/cff2/index_builder.rb
565
567
  - lib/fontisan/tables/cff2/operand_stack.rb
566
568
  - lib/fontisan/tables/cff2/private_dict_blend_handler.rb