fontisan 0.4.32 → 0.4.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +22 -213
- data/TODO.improvements/README.md +6 -6
- data/lib/fontisan/cli.rb +65 -0
- data/lib/fontisan/commands/audit_command.rb +257 -0
- data/lib/fontisan/commands.rb +1 -0
- data/lib/fontisan/models/audit_report.rb +143 -0
- data/lib/fontisan/models.rb +2 -0
- data/lib/fontisan/subset/table_strategy/cff2.rb +392 -0
- data/lib/fontisan/subset/table_strategy.rb +2 -0
- data/lib/fontisan/tables/cff/cff2_charstring_builder.rb +5 -2
- data/lib/fontisan/tables/cff2/fd_select.rb +74 -1
- data/lib/fontisan/tables/cff2/index.rb +155 -0
- data/lib/fontisan/tables/cff2/table_reader.rb +25 -26
- data/lib/fontisan/tables/cff2.rb +1 -0
- data/lib/fontisan/type1/charstrings.rb +34 -1
- data/lib/fontisan/type1/seac_expander.rb +68 -57
- data/lib/fontisan/ufo/compile/cff2.rb +161 -19
- data/lib/fontisan/ufo/compile/feature_compiler.rb +161 -0
- data/lib/fontisan/ufo/compile/gsub.rb +267 -0
- data/lib/fontisan/ufo/compile/variable_otf.rb +16 -7
- data/lib/fontisan/ufo/compile.rb +3 -1
- data/lib/fontisan/ufo/font.rb +3 -2
- data/lib/fontisan/ufo/image.rb +15 -2
- data/lib/fontisan/ufo/image_set.rb +82 -2
- data/lib/fontisan/ufo/reader.rb +8 -0
- data/lib/fontisan/version.rb +1 -1
- metadata +7 -1
|
@@ -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
|
-
|
|
77
|
-
|
|
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
|