fontisan 0.4.19 → 0.4.21

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec589024241926918e56eb58fbe63cd2b14cc0d82fd92908337f7f2f7ef5c076
4
- data.tar.gz: f3ef0a193655de4790717559c7bce5722f5d9e7b5548bc5b313cf400a3b2121d
3
+ metadata.gz: 55632b625474e82c1cf9573ad6fce2de32f5636823701023abee87fc8817d8fc
4
+ data.tar.gz: 5a26b3428874b792c101484dbed0468c25eb9f8eb7644a2c0ffff61c8f2baf6a
5
5
  SHA512:
6
- metadata.gz: b83304cf80e5f7a55ee98717b0f3bec987ad81cef329d0f1d2ab9ab91ecd66db58f6e2ed5eada8c3b4049665e3a37240a26477dbd25405a586d1f9ca7fc7dd61
7
- data.tar.gz: ebc0f3e2c752e7752452280d83e4220aabb12c23a4766e431dbc3e13b480b85b0259a929656919da42e9993a8bd2354a9283fbef21a95ba49b666c54c8d0812b
6
+ metadata.gz: e7334f4be856d9a86712a0215c3671c7148d623649d4b81d232aa5f70b8aee71e1e55047a2780f48a7018721026e13dc99f0fff6dcbeda49801f88a832cbf99e
7
+ data.tar.gz: a6432a0a4db97bdfedd597f5f79f40be36b0fd5d450f21e43319023a85b3e331377b2ee7e002fceb9f90c2e9e9a9ee4c1489ebd25e295058e5605d5be0482f57
data/.rubocop_todo.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config`
3
- # on 2026-07-07 11:03:20 UTC using RuboCop version 1.86.1.
3
+ # on 2026-07-08 06:50:36 UTC using RuboCop version 1.86.1.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
@@ -12,7 +12,7 @@ Gemspec/RequiredRubyVersion:
12
12
  - 'docs/node_modules/speakingurl/speakingurl-rails.gemspec'
13
13
  - 'fontisan.gemspec'
14
14
 
15
- # Offense count: 2090
15
+ # Offense count: 2110
16
16
  # This cop supports safe autocorrection (--autocorrect).
17
17
  # Configuration parameters: Max, AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings.
18
18
  # URISchemes: http, https
@@ -95,7 +95,7 @@ Lint/UselessConstantScoping:
95
95
  - 'lib/fontisan/conversion_options.rb'
96
96
  - 'lib/fontisan/type1/charstrings.rb'
97
97
 
98
- # Offense count: 703
98
+ # Offense count: 704
99
99
  # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
100
100
  Metrics/AbcSize:
101
101
  Enabled: false
@@ -123,7 +123,7 @@ Metrics/CollectionLiteralLength:
123
123
  Metrics/CyclomaticComplexity:
124
124
  Enabled: false
125
125
 
126
- # Offense count: 977
126
+ # Offense count: 978
127
127
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
128
128
  Metrics/MethodLength:
129
129
  Max: 135
@@ -221,7 +221,7 @@ RSpec/DescribeMethod:
221
221
  - 'spec/fontisan/type1/seac_expander_spec.rb'
222
222
  - 'spec/fontisan/type1_real_fonts_spec.rb'
223
223
 
224
- # Offense count: 1660
224
+ # Offense count: 1666
225
225
  # Configuration parameters: CountAsOne.
226
226
  RSpec/ExampleLength:
227
227
  Max: 66
@@ -298,7 +298,7 @@ RSpec/MultipleDescribes:
298
298
  - 'spec/fontisan/utils/thread_pool_spec.rb'
299
299
  - 'spec/fontisan/variation/cache_spec.rb'
300
300
 
301
- # Offense count: 2097
301
+ # Offense count: 2103
302
302
  RSpec/MultipleExpectations:
303
303
  Max: 19
304
304
 
@@ -330,7 +330,7 @@ RSpec/RepeatedExample:
330
330
  - 'spec/fontisan/metrics_calculator_spec.rb'
331
331
  - 'spec/fontisan/tables/os2_spec.rb'
332
332
 
333
- # Offense count: 47
333
+ # Offense count: 48
334
334
  # Configuration parameters: CustomTransform, IgnoreMethods, IgnoreMetadata, InflectorPath, EnforcedInflector.
335
335
  # SupportedInflectors: default, active_support
336
336
  RSpec/SpecFilePathFormat:
@@ -139,6 +139,15 @@ module Fontisan
139
139
  cbdts.first
140
140
  end
141
141
 
142
+ # Safe variant of #cbdt_source: returns nil instead of raising when
143
+ # the project has multiple CBDT sources. Used inside the per-subfont
144
+ # glyph-copy pass where raising mid-compile would leave partial state.
145
+ def safe_cbdt_source
146
+ cbdt_source
147
+ rescue MultipleCbdtSourcesError
148
+ nil
149
+ end
150
+
142
151
  def build_target_for(subfont_name)
143
152
  bindings = @subfonts[subfont_name] || []
144
153
  target = Ufo::Font.new
@@ -171,15 +180,22 @@ module Fontisan
171
180
  def assign_gids_and_copy_glyphs(bindings, target, deduplicator)
172
181
  cbdt = safe_cbdt_source
173
182
 
174
- if cbdt
175
- add_all_cbdt_glyphs(cbdt, target)
176
- else
177
- add_notdef_from(bindings, target, deduplicator)
178
- end
183
+ # Glyph ordering matters: Cmap.build uses first-wins semantics
184
+ # (Cmap.build docstring), so outline donors must be stitched
185
+ # before CBDT placeholders. Otherwise the empty CBDT placeholder
186
+ # would land at a lower GID and win the cp→gid mapping for any
187
+ # codepoint covered by both donors, hiding the real outline glyph.
188
+ # CBDT bitmap data still propagates via propagate_cbdt_tables.
189
+ add_notdef_from(bindings, target, deduplicator)
190
+ add_outline_glyphs(bindings, target, deduplicator,
191
+ skip_sources: cbdt ? [cbdt] : [])
192
+ add_all_cbdt_glyphs(cbdt, target) if cbdt
193
+ end
179
194
 
195
+ def add_outline_glyphs(bindings, target, deduplicator, skip_sources:)
180
196
  sorted_bindings(bindings).each do |binding|
181
197
  next if binding[:donor_gid].zero?
182
- next if cbdt && binding[:source].equal?(cbdt)
198
+ next if skip_sources.any? { |s| s.equal?(binding[:source]) }
183
199
 
184
200
  glyph = binding[:source].glyph_for_gid(binding[:donor_gid])
185
201
  next unless glyph
@@ -197,13 +213,6 @@ module Fontisan
197
213
  end
198
214
  end
199
215
 
200
- def safe_cbdt_source
201
- cbdts = @sources.values.select { |s| s.bitmap_mode == :cbdt }
202
- cbdts.size == 1 ? cbdts.first : nil
203
- rescue MultipleCbdtSourcesError
204
- nil
205
- end
206
-
207
216
  def sorted_bindings(bindings)
208
217
  bindings.sort_by { |b| [b[:codepoint] || Float::INFINITY, b[:donor_gid]] }
209
218
  end
@@ -273,9 +282,14 @@ module Fontisan
273
282
 
274
283
  compiled = Fontisan::FontLoader.load(path)
275
284
 
285
+ # Read every table as raw bytes straight from the file's table
286
+ # directory. We deliberately bypass #table (which parses via
287
+ # BinData) because some tables — notably CFF2 — don't yet have
288
+ # round-trippable BinData models; calling #table on them returns
289
+ # nil and would silently drop them from the rewritten font.
276
290
  tables = {}
277
291
  compiled.table_names.each do |tag|
278
- raw = extract_raw_table(compiled, tag)
292
+ raw = compiled.table_data[tag]
279
293
  tables[tag] = raw if raw
280
294
  end
281
295
 
@@ -288,15 +302,6 @@ module Fontisan
288
302
  Fontisan::FontWriter.write_to_file(tables, path, sfnt_version: sfnt)
289
303
  end
290
304
 
291
- def extract_raw_table(font, tag)
292
- sfnt_table = font.table(tag)
293
- return nil unless sfnt_table
294
-
295
- sfnt_table.raw_data
296
- rescue StandardError
297
- nil
298
- end
299
-
300
305
  def add_all_cbdt_glyphs(source, target)
301
306
  ufo = source.font.is_a?(Ufo::Font) ? source.font : nil
302
307
  if ufo
@@ -66,7 +66,7 @@ module Fontisan
66
66
  case off_size
67
67
  when 1 then [value].pack("C")
68
68
  when 2 then [value].pack("n")
69
- when 3 then [value].pack("C3")
69
+ when 3 then [(value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF].pack("CCC")
70
70
  when 4 then [value].pack("N")
71
71
  else raise ArgumentError, "invalid off_size: #{off_size}"
72
72
  end
@@ -55,7 +55,11 @@ module Fontisan
55
55
  issues << "no glyphs in default layer" if font.glyphs.empty?
56
56
  issues << "no family name" unless font.info.family_name
57
57
  issues << "unitsPerEm not set" unless font.info.units_per_em
58
- issues << "missing .notdef glyph" unless font.glyph(".notdef")
58
+ # The compiler auto-injects an empty .notdef at GID 0 when the
59
+ # UFO source omits it (OpenType requires GID 0 to be .notdef).
60
+ # Surface this as a warning, not a failure — compilation will
61
+ # still succeed.
62
+ warn "NOTE missing .notdef glyph — compiler will inject one at GID 0" unless font.glyph(".notdef")
59
63
 
60
64
  if issues.empty?
61
65
  puts "OK #{ufo}"
@@ -43,7 +43,7 @@ module Fontisan
43
43
  # All tables every OTF/TTF must have, plus optional feature
44
44
  # tables (GPOS for kerning) when the UFO source has kerning data.
45
45
  def build_tables
46
- glyphs = font.glyphs.values
46
+ glyphs = glyphs_with_notdef
47
47
  tables = {
48
48
  "head" => Head.build(font, glyphs: glyphs),
49
49
  "hhea" => Hhea.build(font, glyphs: glyphs),
@@ -62,6 +62,31 @@ module Fontisan
62
62
  tables.merge(build_outline_tables)
63
63
  end
64
64
 
65
+ # OpenType requires GID 0 to be `.notdef`. Normalize the source
66
+ # glyph list so that:
67
+ # - if `.notdef` is already at GID 0, the list is unchanged;
68
+ # - if `.notdef` exists elsewhere, it's moved to GID 0;
69
+ # - if no `.notdef` exists, an empty one is prepended.
70
+ #
71
+ # Without this, the alphabetically-first source glyph lands at
72
+ # GID 0 and the cmap parser (which treats GID 0 as `.notdef` per
73
+ # spec) silently drops it from the cmap.
74
+ def glyphs_with_notdef
75
+ source = font.glyphs.values
76
+ return source if source.empty?
77
+
78
+ existing = source.find { |g| g.name == ".notdef" }
79
+ return source if existing == source.first
80
+
81
+ rest = source - [existing]
82
+ notdef = existing || Ufo::Glyph.notdef(units_per_em: default_units_per_em)
83
+ [notdef, *rest]
84
+ end
85
+
86
+ def default_units_per_em
87
+ font.info&.units_per_em&.to_i || Ufo::Glyph::DEFAULT_UNITS_PER_EM
88
+ end
89
+
65
90
  def write(tables_hash, output_path)
66
91
  dir = File.dirname(output_path)
67
92
  FileUtils.mkpath(dir) unless dir == "."
@@ -11,6 +11,14 @@ module Fontisan
11
11
  # Both subtables share the same segment list (capped to BMP for
12
12
  # format 4); format 4 is required by Windows even though format
13
13
  # 12 is more capable.
14
+ #
15
+ # Contract — first-wins: when multiple glyphs declare the same
16
+ # codepoint, the glyph at the LOWER GID wins the cp→gid mapping.
17
+ # Callers that want one donor to outrank another for a shared
18
+ # codepoint (e.g. outline donor vs. CBDT placeholder) MUST order
19
+ # the higher-priority glyphs earlier in the `glyphs:` array.
20
+ # Stitcher relies on this for outline-first stitching.
21
+ #
14
22
  # @see https://learn.microsoft.com/en-us/typography/opentype/spec/cmap
15
23
  module Cmap
16
24
  PLATFORM_WINDOWS = 3
@@ -195,7 +195,7 @@ module Fontisan
195
195
 
196
196
  def self.build_empty(glyph_count)
197
197
  # Minimal gvar with no variation data
198
- header = [VERSION_MAJOR, VERSION_MINOR, 0, 0, 0, glyph_count, 1, glyph_count * 1 + 16].pack("nnnnnnNN")
198
+ header = [VERSION_MAJOR, VERSION_MINOR, 0, 0, 0, glyph_count, 1, glyph_count + 16].pack("nnnnnnNN")
199
199
  offsets = Array.new(glyph_count + 1, 0).pack("C*")
200
200
  header + offsets
201
201
  end
@@ -27,7 +27,7 @@ module Fontisan
27
27
  checksum_adjustment: 0, # patched by FontWriter
28
28
  magic_number: MAGIC_NUMBER,
29
29
  flags: DEFAULT_FLAGS,
30
- units_per_em: units_per_em || font.info.units_per_em || 1000,
30
+ units_per_em: units_per_em || font.info.units_per_em || Glyph::DEFAULT_UNITS_PER_EM,
31
31
  created_raw: time_to_longdatetime(font.info.created),
32
32
  modified_raw: time_to_longdatetime(font.info.modified),
33
33
  x_min: bbox[:x_min].to_i,
@@ -16,14 +16,8 @@ module Fontisan
16
16
  class Otf2Compiler < BaseCompiler
17
17
  SFNT_VERSION = SFNT_VERSION_OPEN_TYPE
18
18
 
19
- def build_outline_tables
20
- {
21
- "CFF2" => Cff2.build(font, glyphs: font.glyphs.values),
22
- }
23
- end
24
-
25
19
  def compile(output_path:)
26
- glyphs = font.glyphs.values
20
+ glyphs = glyphs_with_notdef
27
21
 
28
22
  tables = {
29
23
  "head" => Head.build(font, glyphs: glyphs, loca_format: Head::LOCA_FORMAT_LONG),
@@ -13,14 +13,8 @@ module Fontisan
13
13
  class OtfCompiler < BaseCompiler
14
14
  SFNT_VERSION = SFNT_VERSION_OPEN_TYPE
15
15
 
16
- def build_outline_tables
17
- {
18
- "CFF " => Cff.build(font, glyphs: font.glyphs.values),
19
- }
20
- end
21
-
22
16
  def compile(output_path:)
23
- glyphs = font.glyphs.values
17
+ glyphs = glyphs_with_notdef
24
18
 
25
19
  tables = {
26
20
  "head" => Head.build(font, glyphs: glyphs, loca_format: Head::LOCA_FORMAT_LONG),
@@ -14,7 +14,7 @@ module Fontisan
14
14
 
15
15
  # @return [Hash<String, #to_binary_s>] all TTF tables, not yet written
16
16
  def build_tables
17
- glyphs = font.glyphs.values
17
+ glyphs = glyphs_with_notdef
18
18
 
19
19
  # Deep-clone glyphs so filters don't mutate the source UFO.
20
20
  filtered = clone_glyphs(glyphs)
@@ -11,6 +11,30 @@ module Fontisan
11
11
  # The `.glif` XML format has multiple revisions (1, 2, 3); the
12
12
  # parser accepts all of them.
13
13
  class Glyph
14
+ # OpenType requires GID 0 to be a glyph named `.notdef`. UFO
15
+ # sources that lack an explicit `.notdef` (synthetic SVG donors,
16
+ # programmatic sources) need one injected before compile. This
17
+ # constant is the default width used for that injected glyph, and
18
+ # doubles as the fallback unitsPerEm in Head.build when UFO info
19
+ # omits the field. Single source of truth for both call sites.
20
+ DEFAULT_UNITS_PER_EM = 1000
21
+
22
+ # Synthesize an empty `.notdef` glyph suitable for GID 0.
23
+ #
24
+ # OpenType mandates GID 0 be `.notdef`. UFO sources that lack an
25
+ # explicit `.notdef` (synthetic SVG donors, programmatic sources)
26
+ # need one injected before compile. The width defaults to the
27
+ # font's units_per_em so the .notdef consumes one em of advance.
28
+ #
29
+ # @param units_per_em [Integer, nil] Advance width for the
30
+ # synthesized glyph. Defaults to DEFAULT_UNITS_PER_EM when nil.
31
+ # @return [Glyph]
32
+ def self.notdef(units_per_em: nil)
33
+ new(name: ".notdef").tap do |g|
34
+ g.width = units_per_em&.to_i || DEFAULT_UNITS_PER_EM
35
+ end
36
+ end
37
+
14
38
  attr_accessor :width, :height, :note, :lib
15
39
  attr_reader :name, :unicodes, :contours, :components, :anchors,
16
40
  :guidelines, :images
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.19"
4
+ VERSION = "0.4.21"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontisan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.19
4
+ version: 0.4.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-07 00:00:00.000000000 Z
11
+ date: 2026-07-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64