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 +4 -4
- data/TODO.improvements/README.md +2 -2
- 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/ufo/compile/cff2.rb +161 -19
- data/lib/fontisan/ufo/compile/variable_otf.rb +16 -7
- data/lib/fontisan/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b16e0b97aeb7431384ce5f50ff3e4dfd8977b863ec76655ef09e6d53fefddc3c
|
|
4
|
+
data.tar.gz: b60547f43a45309f700f4a11bb67d36788cfde286de179b697a7fbfc3d43a897
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c5b05277f8c003e64537cfeb57eb933b8e34f26449acc86de639b1ed90e8b344e502f0a8181417c92de1b8d4c51ffbd98ed9ccacf04ae38b4ff5e0deeb6e80b4
|
|
7
|
+
data.tar.gz: ee1631c92ebf6a5fd7ba66c4f40bef089a8725560f08d013fd16dfafed46e6c8c3890d35128ded48d03c996f6a547bac1f2d8c17939a12bf5a429bd6ac9a1f58
|
data/TODO.improvements/README.md
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
|
@@ -235,25 +235,7 @@ module Fontisan
|
|
|
235
235
|
Cff::Index.new(@io, start_offset: offset)
|
|
236
236
|
end
|
|
237
237
|
|
|
238
|
-
|
|
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
|
|
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
|
-
|
|
292
|
-
return
|
|
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
|
-
|
|
296
|
+
true # 0-27 (incl. 12 escape), 31, 255 — treat as operator
|
|
298
297
|
end
|
|
299
298
|
|
|
300
299
|
# Read DICT operator
|
data/lib/fontisan/tables/cff2.rb
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
54
|
-
|
|
66
|
+
assemble(layout:)
|
|
67
|
+
end
|
|
55
68
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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, :
|
|
177
|
-
:
|
|
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
|
|
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" =>
|
|
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
|
data/lib/fontisan/version.rb
CHANGED
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.
|
|
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
|