fontisan 0.4.46 → 0.4.48

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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/BENCHMARKS.md +56 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Cargo.lock +550 -0
  5. data/Cargo.toml +12 -0
  6. data/Gemfile +10 -0
  7. data/README.adoc +52 -0
  8. data/Rakefile +5 -1
  9. data/benchmark/baseline.json +5 -0
  10. data/benchmark/ci_gate.rb +217 -0
  11. data/benchmark/corpus/harness.rb +175 -0
  12. data/benchmark/corpus/ops.rb +150 -0
  13. data/benchmark/corpus/run.rb +182 -0
  14. data/benchmark/corpus/scan_child.rb +8 -0
  15. data/benchmark/corpus/tiers.rb +176 -0
  16. data/benchmark/tier_baseline.json +12 -0
  17. data/docs/.vitepress/config.ts +2 -1
  18. data/docs/guide/engines.md +117 -0
  19. data/docs/reference-facade.md +130 -0
  20. data/ext/fontisan_cdylib/Cargo.lock +313 -0
  21. data/ext/fontisan_cdylib/Cargo.toml +18 -0
  22. data/ext/fontisan_cdylib/build.rs +22 -0
  23. data/ext/fontisan_cdylib/extconf.rb +36 -0
  24. data/ext/fontisan_cdylib/src/lib.rs +13 -0
  25. data/ext/fontisan_native/Cargo.toml +21 -0
  26. data/ext/fontisan_native/build.rs +22 -0
  27. data/ext/fontisan_native/clippy.toml +10 -0
  28. data/ext/fontisan_native/extconf.rb +59 -0
  29. data/ext/fontisan_native/src/lib.rs +463 -0
  30. data/lib/fontisan/audit/checks/cmap_check.rb +7 -0
  31. data/lib/fontisan/audit/checks/hinting_check.rb +1 -1
  32. data/lib/fontisan/audit/checks/maxp_check.rb +1 -1
  33. data/lib/fontisan/audit/checks/opentype_conformance_check.rb +5 -2
  34. data/lib/fontisan/base_collection.rb +30 -0
  35. data/lib/fontisan/byte_ops.rb +219 -0
  36. data/lib/fontisan/cli.rb +5 -5
  37. data/lib/fontisan/commands/audit_command.rb +38 -43
  38. data/lib/fontisan/commands/convert_command.rb +53 -0
  39. data/lib/fontisan/commands/instance_command.rb +75 -0
  40. data/lib/fontisan/commands/subset_command.rb +43 -0
  41. data/lib/fontisan/commands/unpack_command.rb +56 -0
  42. data/lib/fontisan/converters/cff_table_builder.rb +1 -1
  43. data/lib/fontisan/converters/collection_converter.rb +13 -5
  44. data/lib/fontisan/converters/format_converter.rb +1 -1
  45. data/lib/fontisan/converters/woff2_encoder.rb +8 -6
  46. data/lib/fontisan/converters/woff_writer.rb +6 -5
  47. data/lib/fontisan/engine.rb +350 -0
  48. data/lib/fontisan/ffi/client.rb +121 -0
  49. data/lib/fontisan/ffi.rb +343 -0
  50. data/lib/fontisan/ffi_platform.rb +24 -0
  51. data/lib/fontisan/font_loader.rb +5 -1
  52. data/lib/fontisan/font_writer.rb +13 -1
  53. data/lib/fontisan/native.rb +231 -0
  54. data/lib/fontisan/pipeline/transformation_pipeline.rb +10 -8
  55. data/lib/fontisan/pipeline.rb +1 -1
  56. data/lib/fontisan/reference/table_digests.rb +251 -0
  57. data/lib/fontisan/reference.rb +619 -0
  58. data/lib/fontisan/scan.rb +372 -0
  59. data/lib/fontisan/sfnt_directory.rb +41 -0
  60. data/lib/fontisan/sfnt_font.rb +16 -6
  61. data/lib/fontisan/subset/builder.rb +2 -1
  62. data/lib/fontisan/tables/cff/charstring.rb +16 -0
  63. data/lib/fontisan/tables/cff.rb +15 -5
  64. data/lib/fontisan/tables/cmap.rb +11 -1
  65. data/lib/fontisan/tables/glyf.rb +4 -6
  66. data/lib/fontisan/tables/gpos.rb +7 -0
  67. data/lib/fontisan/tables/gsub.rb +7 -0
  68. data/lib/fontisan/tables/layout_common.rb +172 -0
  69. data/lib/fontisan/tables/name.rb +10 -4
  70. data/lib/fontisan/utilities/brotli_wrapper.rb +39 -4
  71. data/lib/fontisan/validators/opentype_validator.rb +15 -3
  72. data/lib/fontisan/variable/instancer.rb +23 -192
  73. data/lib/fontisan/variable/static_font_builder.rb +4 -1
  74. data/lib/fontisan/variable/table_updater.rb +4 -2
  75. data/lib/fontisan/variation/instance_generator.rb +3 -149
  76. data/lib/fontisan/variation/instancing/axis_space.rb +136 -0
  77. data/lib/fontisan/variation/instancing/glyph_outline.rb +204 -0
  78. data/lib/fontisan/variation/instancing/gvar_reader.rb +146 -0
  79. data/lib/fontisan/variation/instancing/item_variation_store.rb +92 -0
  80. data/lib/fontisan/variation/instancing/name_rewriter.rb +90 -0
  81. data/lib/fontisan/variation/instancing/true_type_instancer.rb +326 -0
  82. data/lib/fontisan/variation/instancing.rb +20 -0
  83. data/lib/fontisan/variation/variable_svg_generator.rb +19 -1
  84. data/lib/fontisan/variation.rb +1 -0
  85. data/lib/fontisan/version.rb +1 -1
  86. data/lib/fontisan/woff2/collection_decoder.rb +15 -1
  87. data/lib/fontisan/woff2/collection_encoder.rb +29 -6
  88. data/lib/fontisan/woff2/encoder_rules.rb +12 -4
  89. data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +1 -1
  90. data/lib/fontisan/woff2_collection.rb +7 -5
  91. data/lib/fontisan/woff2_font.rb +12 -3
  92. data/lib/fontisan/woff_container.rb +83 -0
  93. data/lib/fontisan/woff_font.rb +24 -20
  94. data/lib/fontisan.rb +24 -1
  95. data/rakelib/bench.rake +9 -0
  96. data/rakelib/cdylib.rake +39 -0
  97. data/rakelib/corpus.rake +272 -0
  98. data/rakelib/corpus_convert.rake +43 -0
  99. data/rakelib/corpus_goldens.rake +22 -0
  100. data/rakelib/corpus_malformed.rake +20 -0
  101. data/rakelib/corpus_scan.rake +24 -0
  102. data/rakelib/java.rake +61 -0
  103. data/rakelib/native.rake +43 -0
  104. data/scripts/build-woff2.sh +57 -0
  105. data/scripts/corpus/generate_convert_goldens.rb +478 -0
  106. data/scripts/corpus/generate_goldens.rb +282 -0
  107. data/scripts/corpus/generate_malformed_goldens.rb +254 -0
  108. data/scripts/corpus/generate_scan_goldens.rb +98 -0
  109. data/scripts/tier_fallback.rb +219 -0
  110. data/scripts/tier_smoke.rb +64 -0
  111. metadata +75 -3
@@ -0,0 +1,619 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Fontisan
7
+ # The reference facade: one path-based entry point per engine operation.
8
+ #
9
+ # Every golden is generated through this module and every engine tier
10
+ # (Ruby, native, FFI) serves the same operations, so the signatures, return
11
+ # schemas and error surfaces here are the contract. Operations take a file
12
+ # path, never a loaded font object. They wrap existing classes and add no
13
+ # behaviour except three deliberate additions:
14
+ #
15
+ # 1. An optional `timestamp:` (Unix seconds) reaches the places Ruby stamps
16
+ # `head.modified` from the clock (WOFF2 encode, and StaticFontBuilder).
17
+ # nil keeps today's behaviour; operations that do not stamp ignore it.
18
+ # 2. {.subset} copies fontist's `glyph_ids` step (cmap -> uniq -> sort and
19
+ # its "None of the requested characters exist in <name>" error) before
20
+ # calling `Subset::Builder` with the web profile.
21
+ # 3. {.convert} runs `Fontisan.convert` quietly (ConvertCommand prints to
22
+ # stdout otherwise).
23
+ #
24
+ # Read-type results ({.read}, {.audit}, {.audit_dir}, {.scan}) are plain
25
+ # JSON-ready data: Hash with String keys sorted bytewise, Array, String,
26
+ # Integer, true, false, nil. They never contain a Float (Fixed 16.16 values
27
+ # are raw integers) or a clock value. See docs/reference-facade.md or
28
+ # REFERENCE-FACADE.md at the repository root.
29
+ #
30
+ # Errors: operations let the wrapped class's exception propagate unchanged
31
+ # (Errno::ENOENT, Fontisan::InvalidFontError, Fontisan::Error
32
+ # "Conversion failed: ...", ArgumentError for bad options, ...). {.scan}
33
+ # and {.audit_dir} never raise for a bad font; they record the failure in
34
+ # the row.
35
+ module Reference
36
+ FONT_EXTENSIONS = %w[.ttf .otf .ttc .otc .woff .woff2 .dfont .pfa .pfb].freeze
37
+
38
+ # Tables whose parsed header fields are included in {.read}.
39
+ SNAPSHOT_TABLES = {
40
+ "head" => :head, "hhea" => :hhea, "maxp" => :maxp, "OS/2" => :os2
41
+ }.freeze
42
+
43
+ FIXED_ONE = 65_536
44
+
45
+ VARIATION_DIGESTS = {
46
+ "gvar" => :gvar, "HVAR" => :hvar, "VVAR" => :hvar, "MVAR" => :mvar, "STAT" => :stat
47
+ }.freeze
48
+
49
+ POST_FIELDS = %i[version_raw italic_angle_raw underline_position underline_thickness
50
+ is_fixed_pitch min_mem_type42 max_mem_type42 min_mem_type1
51
+ max_mem_type1].freeze
52
+
53
+ autoload :TableDigests, "fontisan/reference/table_digests"
54
+
55
+ class << self
56
+ # Read the structure of every face in a font file.
57
+ #
58
+ # @param path [String]
59
+ # @param mode [Symbol] :metadata or :full (FontLoader loading mode)
60
+ # @param lazy [Boolean] FontLoader lazy flag
61
+ # @return [Array<Hash>] one Hash per face (see docs for the schema)
62
+ def read(path, mode: :metadata, lazy: true)
63
+ path = path_arg(path)
64
+ sha = Digest::SHA256.file(path).hexdigest
65
+ format = FontLoader.detect_format(path)&.to_s
66
+ count = face_count(path)
67
+ faces = Array.new(count) do |index|
68
+ font = FontLoader.load(path, font_index: index, mode: mode, lazy: lazy)
69
+ begin
70
+ read_face(font, index, sha, format, mode, lazy)
71
+ ensure
72
+ font.close if font.respond_to?(:close)
73
+ end
74
+ end
75
+ normalize(faces)
76
+ end
77
+
78
+ # Per-glyph outline hashes of one face (full mode).
79
+ #
80
+ # glyf fonts: SHA-256 of the glyph's byte range in `glyf`, located by
81
+ # the parsed `loca`. CFF/CFF2 fonts: SHA-256 of the glyph's
82
+ # CharString bytes from the CharStrings INDEX. Empty glyphs hash the
83
+ # empty string.
84
+ #
85
+ # @return [Hash] `{"kind"=>"glyf"|"cff"|nil, "hashes"=>[hex, ...]}`
86
+ def outline_hashes(path, index: 0)
87
+ path = path_arg(path)
88
+ font = FontLoader.load(path, font_index: index, mode: :full,
89
+ lazy: true)
90
+ begin
91
+ normalize(outline_data(font))
92
+ ensure
93
+ font.close if font.respond_to?(:close)
94
+ end
95
+ end
96
+
97
+ # Structured audit of every face, without clock or version fields.
98
+ #
99
+ # @param path [String]
100
+ # @param validate [Boolean, Symbol] false (no checks), true (default
101
+ # profile) or a profile name
102
+ # @return [Array<Hash>] one canonical report per face
103
+ def audit(path, validate: false)
104
+ path = path_arg(path)
105
+ result = Commands::AuditCommand.new(
106
+ path, validate: validate || false, include_codepoints: false
107
+ ).run
108
+ normalize(Array(result).map { |report| audit_hash(report) })
109
+ end
110
+
111
+ # Audit every font file below a directory.
112
+ #
113
+ # @param dir [String]
114
+ # @param validate [Boolean, Symbol] as for {.audit}
115
+ # @return [Array<Hash>] one entry per file, sorted by relative path:
116
+ # `{"path"=>rel, "faces"=>[...]}` or `{"path"=>rel, "error"=>{...}}`
117
+ def audit_dir(dir, validate: false)
118
+ dir = path_arg(dir)
119
+ files = font_files(dir)
120
+ entries = files.map do |file|
121
+ rel = relative_row_path(dir, file)
122
+ begin
123
+ { path: rel, faces: audit(file, validate: validate) }
124
+ rescue StandardError => e
125
+ { path: rel, error: { class: e.class.name, message: e.message } }
126
+ end
127
+ end
128
+ normalize(entries)
129
+ end
130
+
131
+ # The path of an audit_dir row relative to +dir+, with "/" separators. An
132
+ # engine tier on Windows reports "D:/a/dir\\file.ttf" (the directory as
133
+ # given, then a native separator), so both sides are normalised before the
134
+ # prefix is removed; elsewhere a backslash is an ordinary file name byte.
135
+ #
136
+ # @param dir [String]
137
+ # @param path [String] the row's path, below +dir+
138
+ # @param alt_separator [String, nil] the platform's second separator
139
+ # @return [String]
140
+ def relative_row_path(dir, path, alt_separator: File::ALT_SEPARATOR)
141
+ if alt_separator
142
+ dir = dir.tr(alt_separator, "/")
143
+ path = path.tr(alt_separator, "/")
144
+ end
145
+ dir = dir.delete_suffix("/") while dir.end_with?("/")
146
+ prefix = "#{dir}/"
147
+ return path unless path.start_with?(prefix)
148
+
149
+ # A glob over "dir//" yields "dir//a.ttf": drop every separator.
150
+ path.delete_prefix(prefix).sub(%r{\A/+}, "")
151
+ end
152
+
153
+ # One row per font face for every path (see {Fontisan::Scan}).
154
+ #
155
+ # @param paths [Array<String>]
156
+ # @return [Array<Hash>] rows in input order; never raises for a bad font
157
+ def scan(paths)
158
+ paths = Array(paths).map { |p| path_arg(p, must_exist: false) }
159
+ normalize(Scan.call(paths))
160
+ end
161
+
162
+ # Convert a font file to another format.
163
+ #
164
+ # @param path [String]
165
+ # @param to [Symbol, String] target format (:ttf, :otf, :woff, :woff2,
166
+ # :ttc, ...); a single format
167
+ # @param output [String] output path
168
+ # @param timestamp [Integer, Time, nil] fixed head.modified for WOFF2
169
+ # output; ignored for other targets
170
+ # @return [Hash] the ConvertCommand result (success, input_path,
171
+ # output_path, source_format, target_format, input_size, output_size,
172
+ # variation_strategy | num_fonts)
173
+ # @raise [Fontisan::Error] "Conversion failed: ..."
174
+ def convert(path, output:, timestamp: nil, **options)
175
+ path = path_arg(path)
176
+ target = options.fetch(:to)
177
+ opts = { quiet: true, **options.except(:to) }
178
+ opts[:timestamp] = unix_seconds(timestamp) if stamps?(target, timestamp)
179
+ # The command itself, not Fontisan.convert: that one dispatches to the
180
+ # native tier and would loop back through the gate.
181
+ Commands::ConvertCommand.new(path, { to: target, output: output, **opts }).run
182
+ end
183
+
184
+ # Extract one member of a collection as a standalone font.
185
+ #
186
+ # @return [String] path of the extracted file (as UnpackCommand reports)
187
+ def extract(path, index:, output_dir:)
188
+ path = path_arg(path)
189
+ Commands::UnpackCommand.new(
190
+ path, output_dir: output_dir, font_index: index
191
+ ).run[:extracted_files].first
192
+ end
193
+
194
+ # Static instance of a variable font.
195
+ #
196
+ # @param coords [Hash{String=>Numeric}] axis tag => value
197
+ # @param timestamp [Integer, Time, nil] accepted for uniformity;
198
+ # Instancer does not stamp, so it is ignored
199
+ # @return [String] binary font bytes
200
+ def instance(path, coords:, timestamp: nil)
201
+ _ = timestamp
202
+ font = FontLoader.load(path_arg(path), mode: :full, lazy: false)
203
+ values = coords.to_h { |tag, value| [tag.to_s, Float(value)] }
204
+ Variable::Instancer.new(font).instance(values)
205
+ end
206
+
207
+ # Glyph ids kept by a subset request (fontist's `glyph_ids`).
208
+ #
209
+ # @param unicodes [Array<Integer>, nil] codepoints
210
+ # @param text [String, nil] characters to keep
211
+ # @return [Array<Integer>] sorted, unique glyph ids
212
+ # @raise [Fontisan::Error] "None of the requested characters exist in
213
+ # <basename>"
214
+ def glyph_ids(path, unicodes: nil, text: nil)
215
+ path = path_arg(path)
216
+ font = FontLoader.load(path, mode: :full, lazy: false)
217
+ codepoints = Array(unicodes).map(&:to_i) + text.to_s.codepoints
218
+ gids_for(font, codepoints, File.basename(path))
219
+ end
220
+
221
+ # Subset a font to the glyphs of the given characters.
222
+ #
223
+ # @param profile [String] subset profile (default "web")
224
+ # @param timestamp [Integer, Time, nil] ignored: Subset::Builder keeps
225
+ # the source head.modified
226
+ # @return [String] binary font bytes
227
+ def subset(path, unicodes: nil, text: nil, profile: "web", timestamp: nil)
228
+ _ = timestamp
229
+ path = path_arg(path)
230
+ font = FontLoader.load(path, mode: :full, lazy: false)
231
+ codepoints = Array(unicodes).map(&:to_i) + text.to_s.codepoints
232
+ gids = gids_for(font, codepoints, File.basename(path))
233
+ options = Subset::Options.new(profile: profile)
234
+ Subset::Builder.new(font, gids, options).build
235
+ end
236
+
237
+ # Canonical JSON text of a read-type result: sorted keys, two-space
238
+ # indent, trailing newline.
239
+ def canonical_json(data)
240
+ "#{JSON.pretty_generate(normalize(data))}\n"
241
+ end
242
+
243
+ # Recursively convert to JSON-ready data with sorted String keys.
244
+ # Floats are rejected: Fixed values must be stored as raw integers.
245
+ def normalize(value)
246
+ case value
247
+ when Hash
248
+ value.map { |k, v| [k.to_s, normalize(v)] }.sort_by(&:first).to_h
249
+ when Array then value.map { |v| normalize(v) }
250
+ when Symbol then value.to_s
251
+ when BinData::Base then normalize(value.snapshot)
252
+ when Float
253
+ raise ArgumentError, "Float #{value} in canonical output"
254
+ else value
255
+ end
256
+ end
257
+
258
+ private
259
+
260
+ def path_arg(path, must_exist: true)
261
+ unless path.is_a?(String) || path.respond_to?(:to_path)
262
+ raise TypeError,
263
+ "Reference operations take a file path, got #{path.class}"
264
+ end
265
+
266
+ path = File.path(path)
267
+ raise Errno::ENOENT, path if must_exist && !File.exist?(path)
268
+
269
+ path
270
+ end
271
+
272
+ def face_count(path)
273
+ FontLoader.collection?(path) ? FontLoader.load_collection(path).num_fonts : 1
274
+ end
275
+
276
+ def font_files(dir)
277
+ Dir.glob(File.join(dir, "**", "*"))
278
+ .select { |f| File.file?(f) && FONT_EXTENSIONS.include?(File.extname(f).downcase) }
279
+ .sort
280
+ end
281
+
282
+ def stamps?(target, timestamp)
283
+ !timestamp.nil? && target.to_s == "woff2"
284
+ end
285
+
286
+ def unix_seconds(timestamp)
287
+ timestamp.respond_to?(:to_time) ? timestamp.to_time.to_i : Integer(timestamp)
288
+ end
289
+
290
+ def gids_for(font, codepoints, name)
291
+ cmap = font.table("cmap") or
292
+ raise Fontisan::MissingTableError,
293
+ "Required table 'cmap' not found in font"
294
+ mappings = cmap.unicode_mappings
295
+ gids = codepoints.uniq.filter_map { |cp| mappings[cp] }.uniq.sort
296
+ return gids if gids.any?
297
+
298
+ raise Fontisan::Error, "None of the requested characters exist in #{name}"
299
+ end
300
+
301
+ # ---- audit ---------------------------------------------------------
302
+
303
+ def audit_hash(report)
304
+ data = JSON.parse(report.to_json)
305
+ data.delete("generated_at")
306
+ data.delete("fontisan_version")
307
+ data["font_revision_raw"] = fixed_raw(data.delete("font_revision"))
308
+ data["axes"] = Array(data["axes"]).map { |axis| axis_raw(axis) }
309
+ mask_addresses(data)
310
+ end
311
+
312
+ # Messages of checks that crash embed `#<Class:0x000...>`; the address
313
+ # differs per run, so it is dropped: `#<Class>`.
314
+ def mask_addresses(value)
315
+ case value
316
+ when String then value.gsub(/#<([\w:]+):0x\h+>/, '#<\\1>')
317
+ when Array then value.map { |v| mask_addresses(v) }
318
+ when Hash then value.transform_values { |v| mask_addresses(v) }
319
+ else value
320
+ end
321
+ end
322
+
323
+ def axis_raw(axis)
324
+ %w[min_value default_value max_value].each_with_object(axis) do |key, h|
325
+ h["#{key}_raw"] = fixed_raw(h.delete(key))
326
+ end
327
+ end
328
+
329
+ def fixed_raw(value)
330
+ value.nil? ? nil : (value * FIXED_ONE).round
331
+ end
332
+
333
+ # ---- read ----------------------------------------------------------
334
+
335
+ def read_face(font, index, sha, format, mode, lazy)
336
+ {
337
+ index: index,
338
+ format: format,
339
+ mode: mode.to_s,
340
+ lazy: lazy ? true : false,
341
+ file_sha256: sha,
342
+ tables: table_digests(font),
343
+ names: name_records(font),
344
+ header_fields: header_fields(font),
345
+ cmap: cmap_digest(font),
346
+ fvar: fvar_data(font),
347
+ outlines: outline_summary(font),
348
+ hmtx_metrics: hmtx_metrics(font),
349
+ post: post_data(font),
350
+ layout: layout_data(font),
351
+ variation: variation_data(font),
352
+ }
353
+ end
354
+
355
+ # Digest of the per-glyph horizontal metrics as Tables::Hmtx#metric_for
356
+ # decodes them (advance and lsb of every glyph). Raw-byte digests
357
+ # (`tables`) cannot see a decoder bug and no convert golden reads hmtx,
358
+ # so this is the only golden that does. (Parsed glyf coordinates are
359
+ # covered by the convert goldens, which rebuild outlines in ttf -> otf.)
360
+ # nil when hmtx is absent or gated out by the loading mode; `{error:
361
+ # class}` when parsing fails.
362
+ def hmtx_metrics(font)
363
+ return nil unless available_table(font, "hmtx") && available_table(font, "hhea") && available_table(font, "maxp")
364
+
365
+ hmtx = font.table("hmtx")
366
+ hhea = font.table("hhea")
367
+ num_glyphs = font.table("maxp").num_glyphs
368
+ hmtx.parse_with_context(hhea.number_of_h_metrics, num_glyphs) unless hmtx.parsed?
369
+ packed = Array.new(num_glyphs) do |gid|
370
+ metric = hmtx.metric_for(gid)
371
+ [metric[:advance_width], metric[:lsb]].pack("Nl>")
372
+ end
373
+ { count: num_glyphs, sha256: Digest::SHA256.hexdigest(packed.join) }
374
+ rescue StandardError => e
375
+ { error: e.class.name }
376
+ end
377
+
378
+ # post header fields plus a count and digest of the glyph names (names
379
+ # joined with "\n"); a failure to read the names is recorded, not raised.
380
+ def post_data(font)
381
+ table = available_table(font, "post")
382
+ return nil unless table
383
+
384
+ data = POST_FIELDS.to_h { |f| [f, table.public_send(f).to_i] }
385
+ begin
386
+ names = table.glyph_names
387
+ data[:glyph_name_count] = names.size
388
+ data[:glyph_names_sha256] = Digest::SHA256.hexdigest(names.join("\n"))
389
+ rescue StandardError => e
390
+ data[:glyph_names_error] = e.class.name
391
+ end
392
+ data
393
+ end
394
+
395
+ # GSUB/GPOS script tags and, per script, feature tags, as the table
396
+ # classes report them (they swallow parse errors and answer []).
397
+ def layout_data(font)
398
+ %w[GSUB GPOS].to_h do |tag|
399
+ table = available_table(font, tag)
400
+ next [tag, nil] unless table
401
+
402
+ scripts = table.scripts
403
+ by_script = table.script_features
404
+ [tag, {
405
+ scripts: scripts,
406
+ features: scripts.to_h { |s| [s, by_script.fetch(s, [])] },
407
+ }]
408
+ end
409
+ end
410
+
411
+ # Parsed digests of the variation tables, from the raw table bytes
412
+ # (see {TableDigests}). A table that cannot be read records the error.
413
+ def variation_data(font)
414
+ VARIATION_DIGESTS.to_h do |tag, digest|
415
+ [tag, digest_table(font, tag, digest)]
416
+ end
417
+ end
418
+
419
+ def digest_table(font, tag, digest)
420
+ return nil unless font.has_table?(tag)
421
+
422
+ font.table(tag)
423
+ raw = raw_table(font, tag)
424
+ raw ? TableDigests.public_send(digest, raw) : nil
425
+ rescue ArgumentError
426
+ nil
427
+ rescue StandardError => e
428
+ { error: e.class.name }
429
+ end
430
+
431
+ # Digest of every table the loading mode makes available. A table that
432
+ # the mode gates out has a nil digest; a table that fails to load or
433
+ # parse records the error class (Ruby fails lazily, on first access).
434
+ def table_digests(font)
435
+ font.table_names.map do |tag|
436
+ entry = { tag: tag, length: nil, sha256: nil, error: nil }
437
+ begin
438
+ font.table(tag)
439
+ raw = raw_table(font, tag)
440
+ if raw
441
+ entry[:length] = raw.bytesize
442
+ entry[:sha256] = Digest::SHA256.hexdigest(raw)
443
+ end
444
+ rescue ArgumentError
445
+ entry[:error] = nil # gated by the loading mode
446
+ rescue StandardError => e
447
+ entry[:error] = e.class.name
448
+ end
449
+ entry
450
+ end
451
+ end
452
+
453
+ def raw_table(font, tag)
454
+ if font.method(:table_data).arity.zero?
455
+ font.table_data[tag]
456
+ else
457
+ font.table_data(tag)
458
+ end
459
+ end
460
+
461
+ def name_records(font)
462
+ table = available_table(font, "name")
463
+ return [] unless table
464
+
465
+ table.name_records.map do |record|
466
+ {
467
+ platform_id: record.platform_id.to_i,
468
+ encoding_id: record.encoding_id.to_i,
469
+ language_id: record.language_id.to_i,
470
+ name_id: record.name_id.to_i,
471
+ string: decode_name(table, record),
472
+ }
473
+ end
474
+ end
475
+
476
+ # Same decoding rules as fontist's decode_name_record_manually.
477
+ def decode_name(table, record)
478
+ storage = table.string_storage.to_s.b
479
+ length = record.string_length
480
+ return nil if record.string_offset + length > storage.bytesize || length.zero?
481
+
482
+ data = storage.byteslice(record.string_offset, length)
483
+ case record.platform_id.to_i
484
+ when 0, 3
485
+ data.dup.force_encoding("UTF-16BE")
486
+ .encode("UTF-8", invalid: :replace, undef: :replace)
487
+ when 1
488
+ data.dup.force_encoding("ASCII-8BIT")
489
+ .encode("UTF-8", invalid: :replace, undef: :replace)
490
+ else
491
+ data.dup.force_encoding("UTF-8").scrub
492
+ end
493
+ end
494
+
495
+ def header_fields(font)
496
+ SNAPSHOT_TABLES.keys.each_with_object({}) do |tag, out|
497
+ table = available_table(font, tag)
498
+ out[tag] = table ? snapshot(table) : nil
499
+ end
500
+ end
501
+
502
+ def snapshot(table)
503
+ table.snapshot.transform_values { |v| snapshot_value(v) }
504
+ end
505
+
506
+ # Strings that are printable ASCII (tags, vendor ids) stay text; any
507
+ # other String is "0x" + hex.
508
+ def snapshot_value(value)
509
+ case value
510
+ when String
511
+ value.b.match?(/\A[\x20-\x7e]*\z/n) ? value : "0x#{value.b.unpack1('H*')}"
512
+ when Array then value.map { |v| snapshot_value(v) }
513
+ when Hash then value.transform_values { |v| snapshot_value(v) }
514
+ else value
515
+ end
516
+ end
517
+
518
+ # Count and digest of the sorted codepoints: SHA-256 over each
519
+ # codepoint as a big-endian uint32, ascending.
520
+ def cmap_digest(font)
521
+ table = available_table(font, "cmap")
522
+ return nil unless table
523
+
524
+ codepoints = (table.unicode_mappings || {}).keys.sort
525
+ {
526
+ count: codepoints.size,
527
+ sha256: Digest::SHA256.hexdigest(codepoints.pack("N*")),
528
+ subtable_formats: table.subtable_formats,
529
+ }
530
+ end
531
+
532
+ def fvar_data(font)
533
+ table = available_table(font, "fvar")
534
+ return nil unless table
535
+
536
+ {
537
+ axes: table.axes.map { |axis| snapshot(axis) },
538
+ instances: table.instances.compact.map { |inst| instance_data(inst) },
539
+ }
540
+ end
541
+
542
+ def instance_data(inst)
543
+ {
544
+ name_id: inst[:name_id],
545
+ flags: inst[:flags],
546
+ postscript_name_id: inst[:postscript_name_id],
547
+ coordinates_raw: Array(inst[:coordinates]).map { |c| (c * FIXED_ONE).round },
548
+ }
549
+ end
550
+
551
+ def outline_summary(font)
552
+ data = outline_data(font)
553
+ return { kind: nil, count: 0, digest: nil } if data[:kind].nil?
554
+
555
+ hashes = data[:hashes]
556
+ {
557
+ kind: data[:kind],
558
+ count: hashes.size,
559
+ digest: Digest::SHA256.hexdigest(hashes.map { |h| [h].pack("H*") }.join),
560
+ }
561
+ rescue StandardError => e
562
+ { kind: nil, count: 0, digest: nil, error: e.class.name }
563
+ end
564
+
565
+ def outline_data(font)
566
+ if available_table(font, "glyf") && available_table(font, "loca")
567
+ { kind: "glyf", hashes: glyf_hashes(font) }
568
+ elsif (cff = cff_table(font))
569
+ { kind: "cff", hashes: cff_hashes(cff) }
570
+ elsif font.has_table?("CFF2") && (raw = raw_table(font, "CFF2"))
571
+ { kind: "cff2", hashes: TableDigests.cff2_charstrings(raw).map { |c| Digest::SHA256.hexdigest(c) } }
572
+ else
573
+ { kind: nil, hashes: [] }
574
+ end
575
+ end
576
+
577
+ def glyf_hashes(font)
578
+ glyf = raw_table(font, "glyf")
579
+ loca = font.table("loca")
580
+ head = font.table("head")
581
+ maxp = font.table("maxp")
582
+ loca.parse_with_context(head.index_to_loc_format, maxp.num_glyphs) unless loca.parsed?
583
+ Array.new(maxp.num_glyphs) do |gid|
584
+ bytes = glyf.byteslice(loca.offset_for(gid), loca.size_of(gid)) || ""
585
+ Digest::SHA256.hexdigest(bytes)
586
+ end
587
+ end
588
+
589
+ # WoffFont#table returns nil for "CFF " (measured), so parse the raw
590
+ # table when the font class gives no parsed one.
591
+ def cff_table(font)
592
+ parsed = available_table(font, "CFF ")
593
+ return parsed if parsed
594
+
595
+ raw = font.has_table?("CFF ") && raw_table(font, "CFF ")
596
+ raw ? Tables::Cff.read(raw) : nil
597
+ end
598
+
599
+ def cff_hashes(cff)
600
+ index = cff.charstrings_index
601
+ return [] unless index
602
+
603
+ Array.new(index.count) { |gid| Digest::SHA256.hexdigest(index[gid]) }
604
+ end
605
+
606
+ # The parsed table, or nil when the font lacks it or the loading mode
607
+ # gates it out.
608
+ def available_table(font, tag)
609
+ return nil unless font.has_table?(tag)
610
+
611
+ font.table(tag)
612
+ rescue ArgumentError
613
+ nil
614
+ end
615
+ end
616
+ end
617
+ end
618
+
619
+ Fontisan::Engine.install(Fontisan::Reference)