fontisan 0.4.33 → 0.4.35

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.
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Ufo
7
+ module Compile
8
+ # Builds the OpenType `GSUB` (Glyph Substitution) table from
9
+ # ligature substitution rules extracted from features.fea via
10
+ # {FeatureCompiler}.
11
+ #
12
+ # Emits a minimal but valid GSUB with:
13
+ #
14
+ # - ScriptList: DFLT script, default language system
15
+ # - FeatureList: one feature record per ligature feature tag
16
+ # (e.g. "liga", "dlig", "clig")
17
+ # - LookupList: one LigatureSubst lookup per feature tag
18
+ #
19
+ # Each `sub A B C by ABC;` rule becomes a LigatureSet on the
20
+ # first glyph (A), with a Ligature record pointing at ABC and
21
+ # component count 2 (B, C).
22
+ #
23
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/gsub
24
+ module Gsub
25
+ SCRIPT_DFLT = "DFLT"
26
+ LANGSYS_DEFAULT = 0
27
+
28
+ # Build GSUB table bytes from parsed feature rules.
29
+ #
30
+ # @param parsed [FeatureCompiler::ParsedFeatures]
31
+ # @param name_to_gid [Hash{String=>Integer}]
32
+ # @return [String, nil] GSUB bytes, or nil if no ligature rules
33
+ def self.build(parsed:, name_to_gid:)
34
+ features = ligature_features(parsed, name_to_gid)
35
+ return nil if features.empty?
36
+
37
+ lookups = features.values
38
+ build_gsub_table(features.keys, lookups)
39
+ end
40
+
41
+ # Resolve ligature rules to per-feature lookup data.
42
+ #
43
+ # @param parsed [FeatureCompiler::ParsedFeatures]
44
+ # @param name_to_gid [Hash{String=>Integer}]
45
+ # @return [Hash{String=>Array}] feature_tag → lookup data
46
+ def self.ligature_features(parsed, name_to_gid)
47
+ parsed.ligatures.keys.each_with_object({}) do |tag, h|
48
+ rules = parsed.ligatures_for(tag)
49
+ sets = build_ligature_sets(rules, name_to_gid)
50
+ h[tag] = sets unless sets.empty?
51
+ end
52
+ end
53
+
54
+ # Build ligature sets keyed by the first glyph in each sequence.
55
+ #
56
+ # @param rules [Array<Hash>] {sequence:, result:}
57
+ # @param name_to_gid [Hash]
58
+ # @return [Hash{Integer=>Array}] gid → [{result_gid, components: [gids]}]
59
+ def self.build_ligature_sets(rules, name_to_gid)
60
+ sets = Hash.new { |h, k| h[k] = [] }
61
+ rules.each do |rule|
62
+ seq_gids = rule[:sequence].map { |n| name_to_gid[n] }
63
+ result_gid = name_to_gid[rule[:result]]
64
+ next unless seq_gids.all? && result_gid
65
+
66
+ first = seq_gids.first
67
+ components = seq_gids[1..]
68
+ sets[first] << { result_gid: result_gid, components: components }
69
+ end
70
+ sets
71
+ end
72
+
73
+ # ---------- GSUB binary assembly ----------
74
+
75
+ # Build the complete GSUB table.
76
+ #
77
+ # @param tags [Array<String>] feature tags in lookup order
78
+ # @param lookups [Array<Hash>] one lookup per tag
79
+ # @return [String]
80
+ def self.build_gsub_table(tags, lookups)
81
+ io = StringIO.new(+"")
82
+ # Header: version (uint32 0x00010000), scriptListOffset,
83
+ # featureListOffset, lookupListOffset (each uint16)
84
+ header_size = 2 + 2 + 2 + 4 # version(4) + 3×uint16
85
+
86
+ # Serialize each list independently, then compute offsets.
87
+ script_list = build_script_list(tags.size)
88
+ feature_list = build_feature_list(tags)
89
+ lookup_list = build_lookup_list(lookups)
90
+
91
+ script_list_offset = header_size
92
+ feature_list_offset = script_list_offset + script_list.bytesize
93
+ lookup_list_offset = feature_list_offset + feature_list.bytesize
94
+
95
+ io << [0x00010000].pack("N")
96
+ io << [script_list_offset].pack("n")
97
+ io << [feature_list_offset].pack("n")
98
+ io << [lookup_list_offset].pack("n")
99
+ io << script_list
100
+ io << feature_list
101
+ io << lookup_list
102
+ io.string
103
+ end
104
+
105
+ # ScriptList: one script (DFLT) with a default LangSys that
106
+ # references all features (featureIndex 0..count-1).
107
+ def self.build_script_list(feature_count)
108
+ io = +""
109
+ io << [1].pack("n") # scriptCount = 1
110
+
111
+ # ScriptRecord: tag(4) + scriptOffset(2)
112
+ # Script table starts right after the ScriptList header:
113
+ # scriptCount(2) + ScriptRecord(6) = 8
114
+ script_offset = 8
115
+ io << SCRIPT_DFLT
116
+ io << [script_offset].pack("n")
117
+
118
+ # Script table: defaultLangSysOffset(2) + langSysCount(2) = 4
119
+ # LangSys follows immediately
120
+ default_langsys_offset = script_offset + 4
121
+ io << [default_langsys_offset].pack("n")
122
+ io << [0].pack("n") # langSysCount
123
+
124
+ # LangSys: lookupOrder(2)=0, requiredFeatureIndex(2)=0xFFFF,
125
+ # featureIndexCount(2), featureIndices[]
126
+ io << [0].pack("n")
127
+ io << [0xFFFF].pack("n")
128
+ io << [feature_count].pack("n")
129
+ feature_count.times { |i| io << [i].pack("n") }
130
+
131
+ io
132
+ end
133
+
134
+ # FeatureList: one FeatureRecord per tag. Each points to a
135
+ # Feature table with one lookup index (the lookup for that tag).
136
+ def self.build_feature_list(tags)
137
+ io = +""
138
+ io << [tags.size].pack("n") # featureCount
139
+
140
+ # FeatureRecords: tag(4) + featureOffset(2)
141
+ records_size = tags.size * 6
142
+ header_size = 2 + records_size
143
+
144
+ feature_records = +""
145
+ feature_tables = +""
146
+ tags.each_with_index do |tag, idx|
147
+ feature_records << tag.ljust(4, " ")[0, 4]
148
+ feature_records << [header_size + (idx * 4)].pack("n")
149
+
150
+ feature_tables << [0].pack("n") # featureParams (null)
151
+ feature_tables << [1].pack("n") # lookupIndexCount
152
+ feature_tables << [idx].pack("n") # lookupIndex (1:1 feature→lookup)
153
+ end
154
+
155
+ io << feature_records
156
+ io << feature_tables
157
+ io
158
+ end
159
+
160
+ # LookupList: one lookup per feature tag. Each lookup is a
161
+ # LigatureSubst (type 4) lookup.
162
+ def self.build_lookup_list(lookups)
163
+ io = +""
164
+ count = lookups.size
165
+ io << [count].pack("n") # lookupCount
166
+
167
+ # Serialize each lookup first, then compute offsets relative
168
+ # to the start of the LookupList.
169
+ serialized = lookups.map { |sets| build_ligature_lookup(sets) }
170
+ offset_table_size = 2 + (count * 2)
171
+ pos = offset_table_size
172
+ offsets = serialized.map do |s|
173
+ o = pos
174
+ pos += s.bytesize
175
+ o
176
+ end
177
+
178
+ offsets.each { |o| io << [o].pack("n") }
179
+ serialized.each { |s| io << s }
180
+
181
+ io
182
+ end
183
+
184
+ # Build a single LigatureSubst lookup (type 4, format 1).
185
+ def self.build_ligature_lookup(sets)
186
+ io = +""
187
+ # Lookup table: lookupType(2)=4, lookupFlag(2)=0, subTableCount(2)=1
188
+ io << [4].pack("n")
189
+ io << [0].pack("n")
190
+ io << [1].pack("n")
191
+
192
+ # Subtable offset (relative to start of Lookup table)
193
+ lookup_header_size = 6
194
+ io << [lookup_header_size].pack("n")
195
+
196
+ # LigatureSubstFormat1: format(2)=1, coverageOffset(2),
197
+ # ligatureSetCount(2), ligatureSetOffsets[]
198
+ first_glyphs = sets.keys.sort
199
+ set_count = first_glyphs.size
200
+ subst_header_size = 2 + 2 + 2 + (set_count * 2)
201
+
202
+ # Serialize each LigatureSet to compute offsets
203
+ serialized_sets = first_glyphs.map { |gid| sets[gid] }.map { |s| build_ligature_set(s) }
204
+ coverage = build_coverage(first_glyphs)
205
+
206
+ pos = subst_header_size
207
+ set_offsets = serialized_sets.map do |s|
208
+ o = pos
209
+ pos += s.bytesize
210
+ o
211
+ end
212
+ coverage_offset = pos
213
+
214
+ io << [1].pack("n") # substFormat = 1
215
+ io << [coverage_offset].pack("n")
216
+ io << [set_count].pack("n")
217
+ set_offsets.each { |o| io << [o].pack("n") }
218
+ serialized_sets.each { |s| io << s }
219
+ io << coverage
220
+
221
+ io
222
+ end
223
+
224
+ # Build a LigatureSet for one first glyph.
225
+ # @param ligatures [Array<Hash>] {result_gid:, components:}
226
+ def self.build_ligature_set(ligatures)
227
+ io = +""
228
+ count = ligatures.size
229
+ io << [count].pack("n")
230
+
231
+ # Serialize each Ligature record
232
+ serialized = ligatures.map { |lig| build_ligature(lig) }
233
+ pos = 2 + (count * 2)
234
+ offsets = serialized.map do |s|
235
+ o = pos
236
+ pos += s.bytesize
237
+ o
238
+ end
239
+
240
+ offsets.each { |o| io << [o].pack("n") }
241
+ serialized.each { |s| io << s }
242
+ io
243
+ end
244
+
245
+ # Build a single Ligature record.
246
+ # @param lig [Hash] {result_gid:, components: [gids]}
247
+ def self.build_ligature(lig)
248
+ io = +""
249
+ io << [lig[:result_gid]].pack("n")
250
+ io << [lig[:components].size + 1].pack("n") # componentCount includes first
251
+ lig[:components].each { |gid| io << [gid].pack("n") }
252
+ io
253
+ end
254
+
255
+ # Build a Coverage table (format 1 = glyph list).
256
+ # @param glyphs [Array<Integer>] sorted glyph IDs
257
+ def self.build_coverage(glyphs)
258
+ io = +""
259
+ io << [1].pack("n") # coverageFormat = 1
260
+ io << [glyphs.size].pack("n")
261
+ glyphs.each { |gid| io << [gid].pack("n") }
262
+ io
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end
@@ -34,8 +34,10 @@ module Fontisan
34
34
  autoload :OtfCompiler, "fontisan/ufo/compile/otf_compiler"
35
35
  autoload :Filters, "fontisan/ufo/compile/filters"
36
36
  autoload :FeatureWriters, "fontisan/ufo/compile/feature_writers"
37
- autoload :Fvar, "fontisan/ufo/compile/fvar"
37
+ autoload :Fvar, "fontisan/ufo/compile/fvar"
38
+ autoload :FeatureCompiler, "fontisan/ufo/compile/feature_compiler"
38
39
  autoload :Gpos, "fontisan/ufo/compile/gpos"
40
+ autoload :Gsub, "fontisan/ufo/compile/gsub"
39
41
  autoload :Gvar, "fontisan/ufo/compile/gvar"
40
42
  autoload :Head, "fontisan/ufo/compile/head"
41
43
  autoload :Hhea, "fontisan/ufo/compile/hhea"
@@ -11,8 +11,9 @@ module Fontisan
11
11
  # The class is the read/write API; serialization is handled by
12
12
  # Fontisan::Ufo::Reader and Fontisan::Ufo::Writer.
13
13
  class Font
14
- attr_accessor :path, :info, :features, :kerning, :groups, :lib, :ufo_version
15
- attr_reader :layers, :data, :images
14
+ attr_accessor :path, :info, :features, :kerning, :groups, :lib,
15
+ :ufo_version, :images
16
+ attr_reader :layers, :data
16
17
 
17
18
  def initialize
18
19
  @path = nil
@@ -3,13 +3,26 @@
3
3
  module Fontisan
4
4
  module Ufo
5
5
  # A background image anchored to a glyph (common in color fonts).
6
+ #
7
+ # In UFO 3, images live in `UFO/images/` as binary blobs (PNG in
8
+ # practice). Each image is referenced from a glyph's GLIF via
9
+ # `<image fileName="..."/>`. The image set stores the actual bytes
10
+ # and deduplicates by sha256 content hash.
6
11
  class Image
7
- attr_reader :file_name, :transformation, :color
12
+ attr_reader :file_name, :transformation, :color, :sha, :bytes
8
13
 
9
- def initialize(file_name:, transformation: nil, color: nil)
14
+ # @param file_name [String] filename within the UFO images/ directory
15
+ # @param transformation [Hash, nil] UFO image transformation matrix
16
+ # @param color [String, nil] optional color hint
17
+ # @param sha [String, nil] content sha256 hex digest
18
+ # @param bytes [String, nil] raw image bytes (set by ImageSet)
19
+ def initialize(file_name:, transformation: nil, color: nil, sha: nil,
20
+ bytes: nil)
10
21
  @file_name = file_name.to_s
11
22
  @transformation = transformation
12
23
  @color = color
24
+ @sha = sha
25
+ @bytes = bytes
13
26
  end
14
27
  end
15
28
  end
@@ -1,19 +1,99 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module Fontisan
4
6
  module Ufo
5
- # Background images from `images/<layer>/...`. MVP stores nothing;
6
- # a real implementation lands with TODO 02 (glyph model + images).
7
+ # UFO 3 background image set. Lives at `UFO/images/` in a UFO
8
+ # source directory. Each image is a PNG file referenced by glyph
9
+ # GLIF `<image fileName="..."/>` elements.
10
+ #
11
+ # Per the UFO 3 spec, image files are arbitrary binary blobs (in
12
+ # practice always PNG). The image set deduplicates by content hash
13
+ # (sha256) so the same image referenced from multiple glyphs takes
14
+ # one slot.
15
+ #
16
+ # @example Read images from a UFO directory
17
+ # imageset = ImageSet.load_from_dir("MyFont.ufo/images")
18
+ # imageset.each { |img| puts img.file_name }
7
19
  class ImageSet
20
+ include Enumerable
21
+
22
+ # @return [Hash{String=>Image}] filename → Image
8
23
  attr_reader :images
9
24
 
10
25
  def initialize
11
26
  @images = {}
12
27
  end
13
28
 
29
+ # Load all PNG files from a UFO `images/` directory.
30
+ #
31
+ # @param dir [String] path to the `images` directory
32
+ # @return [ImageSet]
33
+ def self.load_from_dir(dir)
34
+ is = new
35
+ return is unless File.directory?(dir)
36
+
37
+ Dir.each_child(dir).sort.each do |name|
38
+ path = File.join(dir, name)
39
+ next unless File.file?(path)
40
+
41
+ is.register_file(name, File.binread(path))
42
+ end
43
+ is
44
+ end
45
+
46
+ # Register an image from its filename and raw bytes. Computes the
47
+ # sha256 content hash automatically.
48
+ #
49
+ # @param file_name [String]
50
+ # @param bytes [String] binary image data
51
+ # @return [Image]
52
+ def register_file(file_name, bytes)
53
+ image = Image.new(
54
+ file_name: file_name,
55
+ sha: Digest::SHA256.hexdigest(bytes),
56
+ bytes: bytes,
57
+ )
58
+ @images[file_name.to_s] = image
59
+ image
60
+ end
61
+
62
+ # Look up an image by its filename.
63
+ #
64
+ # @param file_name [String]
65
+ # @return [Image, nil]
66
+ def find(file_name)
67
+ @images[file_name.to_s]
68
+ end
69
+ alias [] find
70
+
71
+ # Iterate over images in filename order.
72
+ def each(&)
73
+ return enum_for(:each) unless block_given?
74
+
75
+ @images.each_value(&)
76
+ end
77
+
78
+ # Number of registered images.
79
+ def count
80
+ @images.size
81
+ end
82
+
14
83
  def empty?
15
84
  @images.empty?
16
85
  end
86
+
87
+ # Write all images to a target `images/` directory. Useful for
88
+ # UFO round-trip serialization.
89
+ #
90
+ # @param dir [String] target `images` directory path
91
+ def write_to_dir(dir)
92
+ FileUtils.mkpath(dir) unless File.directory?(dir)
93
+ @images.each do |name, img|
94
+ File.binwrite(File.join(dir, name), img.bytes)
95
+ end
96
+ end
17
97
  end
18
98
  end
19
99
  end
@@ -35,6 +35,7 @@ module Fontisan
35
35
  read_groups
36
36
  read_features
37
37
  read_lib
38
+ read_images
38
39
  @font
39
40
  end
40
41
 
@@ -147,6 +148,13 @@ module Fontisan
147
148
  @font.lib = Lib.new(data)
148
149
  end
149
150
 
151
+ def read_images
152
+ dir = join(@font.path, "images")
153
+ return unless File.directory?(dir)
154
+
155
+ @font.images = ImageSet.load_from_dir(dir)
156
+ end
157
+
150
158
  def join(*parts)
151
159
  File.join(*parts)
152
160
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.33"
4
+ VERSION = "0.4.35"
5
5
  end
data/lib/fontisan.rb CHANGED
@@ -77,6 +77,7 @@ module Fontisan
77
77
 
78
78
  # Namespace hubs (each hub declares its own child autoloads)
79
79
  autoload :Binary, "fontisan/binary"
80
+ autoload :Audit, "fontisan/audit"
80
81
  autoload :Collection, "fontisan/collection"
81
82
  autoload :Commands, "fontisan/commands"
82
83
  autoload :Converters, "fontisan/converters"
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.33
4
+ version: 0.4.35
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -293,6 +293,15 @@ files:
293
293
  - exe/fontisan
294
294
  - fontisan.gemspec
295
295
  - lib/fontisan.rb
296
+ - lib/fontisan/audit.rb
297
+ - lib/fontisan/audit/check.rb
298
+ - lib/fontisan/audit/check_registry.rb
299
+ - lib/fontisan/audit/checks.rb
300
+ - lib/fontisan/audit/checks/cmap_check.rb
301
+ - lib/fontisan/audit/checks/collection_integrity_check.rb
302
+ - lib/fontisan/audit/checks/glyph_name_check.rb
303
+ - lib/fontisan/audit/checks/ots_compatibility_check.rb
304
+ - lib/fontisan/audit/checks/table_directory_check.rb
296
305
  - lib/fontisan/base_collection.rb
297
306
  - lib/fontisan/binary.rb
298
307
  - lib/fontisan/binary/base_record.rb
@@ -309,6 +318,7 @@ files:
309
318
  - lib/fontisan/collection/table_deduplicator.rb
310
319
  - lib/fontisan/collection/writer.rb
311
320
  - lib/fontisan/commands.rb
321
+ - lib/fontisan/commands/audit_command.rb
312
322
  - lib/fontisan/commands/base_command.rb
313
323
  - lib/fontisan/commands/convert_command.rb
314
324
  - lib/fontisan/commands/dump_table_command.rb
@@ -387,6 +397,7 @@ files:
387
397
  - lib/fontisan/metrics_calculator.rb
388
398
  - lib/fontisan/models.rb
389
399
  - lib/fontisan/models/all_scripts_features_info.rb
400
+ - lib/fontisan/models/audit_report.rb
390
401
  - lib/fontisan/models/bitmap_glyph.rb
391
402
  - lib/fontisan/models/bitmap_strike.rb
392
403
  - lib/fontisan/models/collection_brief_info.rb
@@ -649,6 +660,7 @@ files:
649
660
  - lib/fontisan/ufo/compile/cmap.rb
650
661
  - lib/fontisan/ufo/compile/colr.rb
651
662
  - lib/fontisan/ufo/compile/cpal.rb
663
+ - lib/fontisan/ufo/compile/feature_compiler.rb
652
664
  - lib/fontisan/ufo/compile/feature_writers.rb
653
665
  - lib/fontisan/ufo/compile/feature_writers/base.rb
654
666
  - lib/fontisan/ufo/compile/feature_writers/curs.rb
@@ -670,6 +682,7 @@ files:
670
682
  - lib/fontisan/ufo/compile/fvar.rb
671
683
  - lib/fontisan/ufo/compile/glyf_loca.rb
672
684
  - lib/fontisan/ufo/compile/gpos.rb
685
+ - lib/fontisan/ufo/compile/gsub.rb
673
686
  - lib/fontisan/ufo/compile/gvar.rb
674
687
  - lib/fontisan/ufo/compile/head.rb
675
688
  - lib/fontisan/ufo/compile/hhea.rb