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.
@@ -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
@@ -9,16 +9,13 @@ module Fontisan
9
9
  #
10
10
  # The orchestrator assembles a default-master UFO plus variation
11
11
  # masters into a variable OpenType font with:
12
- # - CFF2 outlines (static blend/vsindex operators are TODO 07/18)
12
+ # - CFF2 outlines with blend operators for each varying coordinate
13
+ # - ItemVariationStore embedded in the CFF2 table (regions only;
14
+ # deltas live in charstring blend operands)
13
15
  # - fvar (axes + instances)
14
16
  # - STAT (style attributes)
15
17
  # - avar (axis remapping, when maps are provided)
16
18
  #
17
- # NOTE: the CFF2 table currently contains static outlines (no
18
- # VariationStore, no blend operators). Full variable CFF2 requires
19
- # TODO 07 (blend/vsindex) and TODO 18 (blend integration) to be
20
- # wired into the charstring builder.
21
- #
22
19
  # @see https://learn.microsoft.com/en-us/typography/opentype/spec/cff2
23
20
  class VariableOtf
24
21
  # @param default_font [Fontisan::Ufo::Font] the default master
@@ -59,7 +56,7 @@ module Fontisan
59
56
  "post" => Post.build(@default),
60
57
  "hmtx" => Hmtx.build(@default, glyphs: glyphs),
61
58
  "cmap" => Cmap.build(@default, glyphs: glyphs),
62
- "CFF2" => Cff2.build(@default, glyphs: glyphs),
59
+ "CFF2" => build_cff2(glyphs),
63
60
  "fvar" => Fvar.build(@default, axes: @axes, instances: @instances),
64
61
  "STAT" => Stat.build(axes: @axes),
65
62
  }
@@ -69,6 +66,18 @@ module Fontisan
69
66
 
70
67
  tables
71
68
  end
69
+
70
+ private
71
+
72
+ # Build the CFF2 table. When masters are supplied, variable
73
+ # charstrings with blend operators are emitted; otherwise a static
74
+ # CFF2 table is produced.
75
+ def build_cff2(glyphs)
76
+ masters = @masters.map { |mf| { font: mf, axes: @axes } }
77
+ Cff2.build(@default, glyphs: glyphs,
78
+ masters: masters.any? ? masters : nil,
79
+ axis_count: @axes.size)
80
+ end
72
81
  end
73
82
  end
74
83
  end
@@ -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.32"
4
+ VERSION = "0.4.34"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontisan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.32
4
+ version: 0.4.34
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -309,6 +309,7 @@ files:
309
309
  - lib/fontisan/collection/table_deduplicator.rb
310
310
  - lib/fontisan/collection/writer.rb
311
311
  - lib/fontisan/commands.rb
312
+ - lib/fontisan/commands/audit_command.rb
312
313
  - lib/fontisan/commands/base_command.rb
313
314
  - lib/fontisan/commands/convert_command.rb
314
315
  - lib/fontisan/commands/dump_table_command.rb
@@ -387,6 +388,7 @@ files:
387
388
  - lib/fontisan/metrics_calculator.rb
388
389
  - lib/fontisan/models.rb
389
390
  - lib/fontisan/models/all_scripts_features_info.rb
391
+ - lib/fontisan/models/audit_report.rb
390
392
  - lib/fontisan/models/bitmap_glyph.rb
391
393
  - lib/fontisan/models/bitmap_strike.rb
392
394
  - lib/fontisan/models/collection_brief_info.rb
@@ -486,6 +488,7 @@ files:
486
488
  - lib/fontisan/subset/table_strategy/cbdt.rb
487
489
  - lib/fontisan/subset/table_strategy/cblc.rb
488
490
  - lib/fontisan/subset/table_strategy/cff.rb
491
+ - lib/fontisan/subset/table_strategy/cff2.rb
489
492
  - lib/fontisan/subset/table_strategy/cmap.rb
490
493
  - lib/fontisan/subset/table_strategy/color_bitmap_placement.rb
491
494
  - lib/fontisan/subset/table_strategy/color_bitmap_strike_plan.rb
@@ -561,6 +564,7 @@ files:
561
564
  - lib/fontisan/tables/cff2/dict_encoder.rb
562
565
  - lib/fontisan/tables/cff2/fd_select.rb
563
566
  - lib/fontisan/tables/cff2/header.rb
567
+ - lib/fontisan/tables/cff2/index.rb
564
568
  - lib/fontisan/tables/cff2/index_builder.rb
565
569
  - lib/fontisan/tables/cff2/operand_stack.rb
566
570
  - lib/fontisan/tables/cff2/private_dict_blend_handler.rb
@@ -647,6 +651,7 @@ files:
647
651
  - lib/fontisan/ufo/compile/cmap.rb
648
652
  - lib/fontisan/ufo/compile/colr.rb
649
653
  - lib/fontisan/ufo/compile/cpal.rb
654
+ - lib/fontisan/ufo/compile/feature_compiler.rb
650
655
  - lib/fontisan/ufo/compile/feature_writers.rb
651
656
  - lib/fontisan/ufo/compile/feature_writers/base.rb
652
657
  - lib/fontisan/ufo/compile/feature_writers/curs.rb
@@ -668,6 +673,7 @@ files:
668
673
  - lib/fontisan/ufo/compile/fvar.rb
669
674
  - lib/fontisan/ufo/compile/glyf_loca.rb
670
675
  - lib/fontisan/ufo/compile/gpos.rb
676
+ - lib/fontisan/ufo/compile/gsub.rb
671
677
  - lib/fontisan/ufo/compile/gvar.rb
672
678
  - lib/fontisan/ufo/compile/head.rb
673
679
  - lib/fontisan/ufo/compile/hhea.rb