fontisan 0.4.22 → 0.4.24

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/.rubocop_todo.yml +240 -24
  3. data/Gemfile +6 -0
  4. data/Rakefile +9 -6
  5. data/benchmark/compile_benchmark.rb +13 -5
  6. data/docs/.vitepress/config.ts +1 -0
  7. data/docs/STITCHER_GUIDE.adoc +47 -3
  8. data/docs/api/layer.md +89 -0
  9. data/docs/cli/subset.md +25 -6
  10. data/docs/guide/cli/subset.md +31 -0
  11. data/docs/guide/color-fonts/bitmaps.md +17 -0
  12. data/lib/fontisan/cli.rb +2 -1
  13. data/lib/fontisan/collection/writer.rb +2 -1
  14. data/lib/fontisan/commands/validate_collection_command.rb +7 -1
  15. data/lib/fontisan/commands.rb +2 -1
  16. data/lib/fontisan/config/subset_profiles.yml +2 -0
  17. data/lib/fontisan/converters/collection_converter.rb +6 -1
  18. data/lib/fontisan/converters/svg_generator.rb +3 -1
  19. data/lib/fontisan/stitcher/cbdt_propagator.rb +44 -22
  20. data/lib/fontisan/stitcher/collection_result.rb +2 -1
  21. data/lib/fontisan/stitcher/glyph_cloner.rb +41 -0
  22. data/lib/fontisan/stitcher/glyph_copier.rb +5 -34
  23. data/lib/fontisan/stitcher/partition_strategy/by_block.rb +3 -1
  24. data/lib/fontisan/stitcher/partition_strategy/by_script.rb +6 -2
  25. data/lib/fontisan/stitcher/source.rb +2 -1
  26. data/lib/fontisan/stitcher/unique_glyph_name.rb +46 -0
  27. data/lib/fontisan/stitcher.rb +4 -1
  28. data/lib/fontisan/subset/shared_state.rb +42 -0
  29. data/lib/fontisan/subset/subset_context.rb +20 -0
  30. data/lib/fontisan/subset/table_strategy/cbdt.rb +30 -0
  31. data/lib/fontisan/subset/table_strategy/cblc.rb +27 -0
  32. data/lib/fontisan/subset/table_strategy/cmap.rb +146 -0
  33. data/lib/fontisan/subset/table_strategy/color_bitmap_placement.rb +20 -0
  34. data/lib/fontisan/subset/table_strategy/color_bitmap_strike_plan.rb +45 -0
  35. data/lib/fontisan/subset/table_strategy/color_bitmap_subset_plan.rb +62 -0
  36. data/lib/fontisan/subset/table_strategy/color_bitmap_subsetter.rb +182 -0
  37. data/lib/fontisan/subset/table_strategy/color_bitmap_subtable_plan.rb +59 -0
  38. data/lib/fontisan/subset/table_strategy/glyf.rb +29 -0
  39. data/lib/fontisan/subset/table_strategy/glyf_loca_builder.rb +146 -0
  40. data/lib/fontisan/subset/table_strategy/head.rb +42 -0
  41. data/lib/fontisan/subset/table_strategy/hhea.rb +67 -0
  42. data/lib/fontisan/subset/table_strategy/hmtx.rb +52 -0
  43. data/lib/fontisan/subset/table_strategy/loca.rb +41 -0
  44. data/lib/fontisan/subset/table_strategy/maxp.rb +23 -0
  45. data/lib/fontisan/subset/table_strategy/name.rb +19 -0
  46. data/lib/fontisan/subset/table_strategy/os2.rb +21 -0
  47. data/lib/fontisan/subset/table_strategy/pass_through.rb +20 -0
  48. data/lib/fontisan/subset/table_strategy/post.rb +37 -0
  49. data/lib/fontisan/subset/table_strategy.rb +75 -0
  50. data/lib/fontisan/subset/table_subsetter.rb +49 -616
  51. data/lib/fontisan/subset.rb +3 -0
  52. data/lib/fontisan/svg/standalone_glyph.rb +4 -1
  53. data/lib/fontisan/svg_to_glyf/assembler.rb +6 -3
  54. data/lib/fontisan/svg_to_glyf/geometry/transform_parser.rb +11 -4
  55. data/lib/fontisan/svg_to_glyf/geometry.rb +4 -2
  56. data/lib/fontisan/tables/cbdt.rb +66 -121
  57. data/lib/fontisan/tables/cblc.rb +110 -231
  58. data/lib/fontisan/tables/cblc_big_glyph_metrics.rb +28 -0
  59. data/lib/fontisan/tables/cblc_bitmap_size.rb +67 -0
  60. data/lib/fontisan/tables/cblc_glyph_bitmap_location.rb +28 -0
  61. data/lib/fontisan/tables/cblc_index_subtable.rb +104 -0
  62. data/lib/fontisan/tables/cblc_index_subtable_array_entry.rb +20 -0
  63. data/lib/fontisan/tables/cblc_index_subtable_format_parser.rb +150 -0
  64. data/lib/fontisan/tables/cblc_index_subtable_header.rb +18 -0
  65. data/lib/fontisan/tables/cblc_sbit_line_metrics.rb +28 -0
  66. data/lib/fontisan/tables/cff/cff2_charstring_builder.rb +11 -5
  67. data/lib/fontisan/tables/cff.rb +2 -1
  68. data/lib/fontisan/tables/cff2/index_builder.rb +2 -1
  69. data/lib/fontisan/tables.rb +12 -0
  70. data/lib/fontisan/ufo/cli.rb +4 -1
  71. data/lib/fontisan/ufo/compile/cbdt_cblc.rb +2 -1
  72. data/lib/fontisan/ufo/compile/cff.rb +6 -2
  73. data/lib/fontisan/ufo/compile/cmap.rb +11 -5
  74. data/lib/fontisan/ufo/compile/cpal.rb +2 -1
  75. data/lib/fontisan/ufo/compile/feature_writers/gdef.rb +3 -1
  76. data/lib/fontisan/ufo/compile/feature_writers/mark_family_base.rb +3 -1
  77. data/lib/fontisan/ufo/compile/feature_writers.rb +2 -1
  78. data/lib/fontisan/ufo/compile/filters/cubic_to_quadratic.rb +4 -2
  79. data/lib/fontisan/ufo/compile/filters/remove_overlaps.rb +2 -1
  80. data/lib/fontisan/ufo/compile/fvar.rb +7 -3
  81. data/lib/fontisan/ufo/compile/glyf_loca.rb +4 -2
  82. data/lib/fontisan/ufo/compile/gvar.rb +5 -2
  83. data/lib/fontisan/ufo/compile/head.rb +2 -1
  84. data/lib/fontisan/ufo/compile/item_variation_store.rb +2 -1
  85. data/lib/fontisan/ufo/compile/name.rb +3 -1
  86. data/lib/fontisan/ufo/compile/sbix.rb +2 -1
  87. data/lib/fontisan/ufo/compile/stat.rb +6 -3
  88. data/lib/fontisan/ufo/convert/from_bin_data.rb +6 -2
  89. data/lib/fontisan/ufo/convert/to_dfont.rb +6 -2
  90. data/lib/fontisan/ufo/convert/to_otc.rb +4 -1
  91. data/lib/fontisan/ufo/convert/to_postscript.rb +8 -3
  92. data/lib/fontisan/ufo/convert/to_woff.rb +8 -3
  93. data/lib/fontisan/ufo/convert/to_woff2.rb +8 -3
  94. data/lib/fontisan/ufo/convert.rb +4 -1
  95. data/lib/fontisan/ufo/glyph_exists_error.rb +23 -0
  96. data/lib/fontisan/ufo/info.rb +4 -2
  97. data/lib/fontisan/ufo/layer.rb +42 -2
  98. data/lib/fontisan/ufo/plist.rb +7 -2
  99. data/lib/fontisan/ufo/reader.rb +3 -1
  100. data/lib/fontisan/ufo/writer.rb +13 -5
  101. data/lib/fontisan/ufo.rb +2 -1
  102. data/lib/fontisan/version.rb +1 -1
  103. data/lib/fontisan/woff2/collection_decoder.rb +4 -1
  104. data/lib/fontisan/woff2/collection_encoder.rb +4 -1
  105. data/lib/fontisan/woff2/glyf_canonicalizer.rb +2 -1
  106. data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +5 -2
  107. data/lib/fontisan/woff2/sfnt_checksum.rb +2 -1
  108. data/lib/fontisan.rb +0 -1
  109. metadata +36 -4
  110. data/lib/fontisan/tasks/fixture_downloader.rb +0 -162
  111. data/lib/fontisan/tasks.rb +0 -11
@@ -17,12 +17,17 @@ module Fontisan
17
17
  # @param ps_options [Hash] forwarded to the generator
18
18
  # (encoding, upm_scale, convert_curves)
19
19
  # @return [String] the output_path
20
- def self.convert(ufo, output_path:, generator_class:, compiler: :ttf, **ps_options)
20
+ def self.convert(ufo, output_path:, generator_class:, compiler: :ttf,
21
+ **ps_options)
21
22
  compiler_class = Convert::COMPILER_FOR_FORMAT[compiler.to_sym]
22
- raise ArgumentError, "unknown intermediate compiler: #{compiler.inspect}" unless compiler_class
23
+ unless compiler_class
24
+ raise ArgumentError,
25
+ "unknown intermediate compiler: #{compiler.inspect}"
26
+ end
23
27
 
24
28
  Dir.mktmpdir do |dir|
25
- intermediate_path = File.join(dir, "intermediate#{ext_for(compiler)}")
29
+ intermediate_path = File.join(dir,
30
+ "intermediate#{ext_for(compiler)}")
26
31
  compiler_class.new(ufo).compile(output_path: intermediate_path)
27
32
 
28
33
  loaded = Fontisan::FontLoader.load(intermediate_path)
@@ -32,14 +32,19 @@ module Fontisan
32
32
  # @return [String] the output_path
33
33
  def self.convert(ufo, output_path:, compiler: :ttf, **woff_options)
34
34
  compiler_class = Convert::COMPILER_FOR_FORMAT[compiler.to_sym]
35
- raise ArgumentError, "unknown intermediate compiler: #{compiler.inspect}" unless compiler_class
35
+ unless compiler_class
36
+ raise ArgumentError,
37
+ "unknown intermediate compiler: #{compiler.inspect}"
38
+ end
36
39
 
37
40
  Dir.mktmpdir do |dir|
38
- intermediate_path = File.join(dir, "intermediate#{format_ext(compiler)}")
41
+ intermediate_path = File.join(dir,
42
+ "intermediate#{format_ext(compiler)}")
39
43
  compiler_class.new(ufo).compile(output_path: intermediate_path)
40
44
 
41
45
  loaded = Fontisan::FontLoader.load(intermediate_path)
42
- woff_bytes = Fontisan::Converters::WoffWriter.new.convert(loaded, woff_options)
46
+ woff_bytes = Fontisan::Converters::WoffWriter.new.convert(loaded,
47
+ woff_options)
43
48
  File.binwrite(output_path, woff_bytes)
44
49
  end
45
50
 
@@ -21,14 +21,19 @@ module Fontisan
21
21
  # @return [String] the output_path
22
22
  def self.convert(ufo, output_path:, compiler: :ttf, **woff2_options)
23
23
  compiler_class = Convert::COMPILER_FOR_FORMAT[compiler.to_sym]
24
- raise ArgumentError, "unknown intermediate compiler: #{compiler.inspect}" unless compiler_class
24
+ unless compiler_class
25
+ raise ArgumentError,
26
+ "unknown intermediate compiler: #{compiler.inspect}"
27
+ end
25
28
 
26
29
  Dir.mktmpdir do |dir|
27
- intermediate_path = File.join(dir, "intermediate#{format_ext(compiler)}")
30
+ intermediate_path = File.join(dir,
31
+ "intermediate#{format_ext(compiler)}")
28
32
  compiler_class.new(ufo).compile(output_path: intermediate_path)
29
33
 
30
34
  loaded = Fontisan::FontLoader.load(intermediate_path)
31
- result = Fontisan::Converters::Woff2Encoder.new.convert(loaded, woff2_options)
35
+ result = Fontisan::Converters::Woff2Encoder.new.convert(loaded,
36
+ woff2_options)
32
37
  File.binwrite(output_path, result[:woff2_binary])
33
38
  end
34
39
 
@@ -71,7 +71,10 @@ module Fontisan
71
71
  end
72
72
 
73
73
  compiler = COMPILER_FOR_FORMAT[format_key]
74
- raise ArgumentError, "unknown UFO output format: #{to.inspect}" unless compiler
74
+ unless compiler
75
+ raise ArgumentError,
76
+ "unknown UFO output format: #{to.inspect}"
77
+ end
75
78
 
76
79
  compiler.new(ufo).compile(output_path: output_path)
77
80
  output_path
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ # Raised by +Layer#add+ when a glyph with the same name is already
6
+ # present. Surfaces what would otherwise be a silent overwrite so
7
+ # the caller can decide between +Layer#put+ (intentional replace)
8
+ # and +Stitcher::UniqueGlyphName.in+ (auto-rename).
9
+ #
10
+ # Extracted as a sibling of +Layer+ (rather than nested inside it)
11
+ # so the namespace stays flat and the error can be referenced
12
+ # without pulling in the full Layer implementation.
13
+ class GlyphExistsError < StandardError
14
+ attr_reader :name
15
+
16
+ def initialize(name)
17
+ @name = name
18
+ super("a glyph named #{name.inspect} already exists; use #put " \
19
+ "to overwrite or UniqueGlyphName.in to deconflict")
20
+ end
21
+ end
22
+ end
23
+ end
@@ -81,8 +81,10 @@ module Fontisan
81
81
  # @return [Array(Integer, Integer)] [major, minor]
82
82
  def self.parse_version(version)
83
83
  case version.to_s
84
- when /\A(\d+)\.(\d+)\.\d+\z/ then [Regexp.last_match(1).to_i, Regexp.last_match(2).to_i]
85
- when /\A(\d+)\.(\d+)\z/ then [Regexp.last_match(1).to_i, Regexp.last_match(2).to_i]
84
+ when /\A(\d+)\.(\d+)\.\d+\z/ then [Regexp.last_match(1).to_i,
85
+ Regexp.last_match(2).to_i]
86
+ when /\A(\d+)\.(\d+)\z/ then [Regexp.last_match(1).to_i,
87
+ Regexp.last_match(2).to_i]
86
88
  when /\A(\d+)\z/ then [Regexp.last_match(1).to_i, 0]
87
89
  else [0, 0]
88
90
  end
@@ -5,8 +5,27 @@ module Fontisan
5
5
  # A single layer in a UFO source. A Layer holds a set of glyphs
6
6
  # keyed by name. The default layer is `public.default` per UFO 3.
7
7
  #
8
- # glyphs is mutated in place by Reader and Writer; the Layer does
9
- # not own serialization concerns.
8
+ # == Naming contract
9
+ #
10
+ # Glyph names are the layer's primary key. Two distinct glyphs with
11
+ # the same name cannot coexist — adding the second would silently
12
+ # destroy the first, a class of bug that has historically cost real
13
+ # data (e.g. CJK Ext G cmap loss when CBDT placeholders collided
14
+ # with outline glyphs sharing "gid{N}" names).
15
+ #
16
+ # To make that contract unbreakable, +#add+ RAISES +GlyphExistsError+
17
+ # on conflict. Callers who want one of the other two semantics pick
18
+ # the method that names it:
19
+ #
20
+ # +#add+:: insert; raise if the name is taken (the safe default)
21
+ # +#put+:: insert; overwrite any existing glyph with the same name
22
+ #
23
+ # Callers that need auto-renaming (insertion without giving up on a
24
+ # collision) call +Stitcher::UniqueGlyphName.in(target, base)+ first,
25
+ # then +#add+ the glyph under the returned name.
26
+ #
27
+ # The error class itself lives at +Fontisan::Ufo::GlyphExistsError+
28
+ # (sibling, not nested) so the namespace stays flat.
10
29
  class Layer
11
30
  DEFAULT_NAME = "public.default"
12
31
 
@@ -21,7 +40,28 @@ module Fontisan
21
40
  @glyphs[glyph_name.to_s]
22
41
  end
23
42
 
43
+ # Insert +glyph+. Raises +GlyphExistsError+ if a glyph with the
44
+ # same name is already present — this is the contract that keeps
45
+ # the layer's key namespace trustworthy.
46
+ #
47
+ # @param glyph [Glyph]
48
+ # @return [Glyph] the same glyph
49
+ # @raise [GlyphExistsError] if +glyph.name+ is already taken
24
50
  def add(glyph)
51
+ name = glyph.name.to_s
52
+ raise GlyphExistsError, name if @glyphs.key?(name)
53
+
54
+ @glyphs[name] = glyph
55
+ glyph
56
+ end
57
+
58
+ # Insert +glyph+, replacing any existing glyph with the same
59
+ # name. Use this when the caller has positively decided that the
60
+ # previous glyph (if any) should be discarded.
61
+ #
62
+ # @param glyph [Glyph]
63
+ # @return [Glyph] the same glyph
64
+ def put(glyph)
25
65
  @glyphs[glyph.name.to_s] = glyph
26
66
  glyph
27
67
  end
@@ -25,7 +25,9 @@ module Fontisan
25
25
  root = doc.root
26
26
  raise ParseError, "no <plist> root element" unless root&.name == "plist"
27
27
 
28
- parse_value(root.children.find { |c| c.element? || c.cdata? || c.text? && !c.text.strip.empty? })
28
+ parse_value(root.children.find do |c|
29
+ c.element? || c.cdata? || c.text? && !c.text.strip.empty?
30
+ end)
29
31
  end
30
32
 
31
33
  # @param value [Object] the value to serialize
@@ -73,7 +75,10 @@ module Fontisan
73
75
  # dict children are key/value pairs (key first, then value)
74
76
  while children.any?
75
77
  key_node = children.shift
76
- raise ParseError, "dict key is not <key>" unless key_node.name == "key"
78
+ unless key_node.name == "key"
79
+ raise ParseError,
80
+ "dict key is not <key>"
81
+ end
77
82
 
78
83
  value_node = children.shift
79
84
  result[key_node.text] = parse_value(value_node)
@@ -94,7 +94,9 @@ module Fontisan
94
94
  if order.first.is_a?(Hash)
95
95
  order.reduce({}) { |h, pair| h.merge(pair) }
96
96
  else
97
- order.to_h { |filename| [File.basename(filename, ".glif"), filename] }
97
+ order.to_h do |filename|
98
+ [File.basename(filename, ".glif"), filename]
99
+ end
98
100
  end
99
101
  else
100
102
  raise "unsupported contents.plist format: #{order.class}"
@@ -50,7 +50,8 @@ module Fontisan
50
50
  end
51
51
 
52
52
  def write_fontinfo(path)
53
- File.write(File.join(path, "fontinfo.plist"), Plist.emit(@font.info.to_plist))
53
+ File.write(File.join(path, "fontinfo.plist"),
54
+ Plist.emit(@font.info.to_plist))
54
55
  end
55
56
 
56
57
  def write_layercontents(path)
@@ -75,11 +76,17 @@ module Fontisan
75
76
  FileUtils.mkpath(layer_dir)
76
77
 
77
78
  contents =
78
- layer.glyphs.transform_values { |g| "#{safe_filename(g.name)}.glif" }
79
- File.write(File.join(layer_dir, "contents.plist"), Plist.emit(contents))
79
+ layer.glyphs.transform_values do |g|
80
+ "#{safe_filename(g.name)}.glif"
81
+ end
82
+ File.write(File.join(layer_dir, "contents.plist"),
83
+ Plist.emit(contents))
80
84
 
81
85
  layer.glyphs.each_value do |glyph|
82
- File.write(File.join(layer_dir, "#{safe_filename(glyph.name)}.glif"), glyph.to_glif)
86
+ File.write(
87
+ File.join(layer_dir,
88
+ "#{safe_filename(glyph.name)}.glif"), glyph.to_glif
89
+ )
83
90
  end
84
91
  end
85
92
  end
@@ -96,7 +103,8 @@ module Fontisan
96
103
  def write_kerning(path)
97
104
  return if @font.kerning.empty?
98
105
 
99
- File.write(File.join(path, "kerning.plist"), Plist.emit(@font.kerning.to_plist))
106
+ File.write(File.join(path, "kerning.plist"),
107
+ Plist.emit(@font.kerning.to_plist))
100
108
  end
101
109
 
102
110
  def write_features(path)
data/lib/fontisan/ufo.rb CHANGED
@@ -17,7 +17,8 @@ module Fontisan
17
17
  #
18
18
  # Reference: https://unifiedfontobject.org
19
19
  module Ufo
20
- autoload :Font, "fontisan/ufo/font"
20
+ autoload :Font, "fontisan/ufo/font"
21
+ autoload :GlyphExistsError, "fontisan/ufo/glyph_exists_error"
21
22
  autoload :Info, "fontisan/ufo/info"
22
23
  autoload :Layer, "fontisan/ufo/layer"
23
24
  autoload :LayerSet, "fontisan/ufo/layer_set"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.22"
4
+ VERSION = "0.4.24"
5
5
  end
@@ -131,7 +131,10 @@ module Fontisan
131
131
  def read_collection_directory(pos)
132
132
  version = @data[pos, 4].unpack1("N")
133
133
  pos += 4
134
- raise InvalidFontError, "Unsupported CollectionHeader version 0x#{version.to_s(16)}" unless version == 0x00010000
134
+ unless version == 0x00010000
135
+ raise InvalidFontError,
136
+ "Unsupported CollectionHeader version 0x#{version.to_s(16)}"
137
+ end
135
138
 
136
139
  num_fonts, pos = UInt255.decode_at(@data, pos)
137
140
  font_entries = Array.new(num_fonts) do
@@ -32,7 +32,10 @@ module Fontisan
32
32
  # @param fonts [Array<TrueTypeFont, OpenTypeFont>] Source fonts
33
33
  # @return [String] WOFF2 collection binary
34
34
  def encode_fonts(fonts)
35
- raise ArgumentError, "fonts cannot be empty" if fonts.nil? || fonts.empty?
35
+ if fonts.nil? || fonts.empty?
36
+ raise ArgumentError,
37
+ "fonts cannot be empty"
38
+ end
36
39
 
37
40
  prepared = fonts.map.with_index { |font, i| prepare_font(font, i) }
38
41
  _, font_to_ckeys = deduplicate(prepared)
@@ -50,7 +50,8 @@ module Fontisan
50
50
  start_offset = source_offsets[i]
51
51
  end_offset = source_offsets[i + 1]
52
52
  if start_offset < end_offset
53
- glyf << @glyf_data.byteslice(start_offset, end_offset - start_offset)
53
+ glyf << @glyf_data.byteslice(start_offset,
54
+ end_offset - start_offset)
54
55
  end
55
56
  remainder = glyf.bytesize % 4
56
57
  glyf << ("\x00" * (4 - remainder)) if remainder.positive?
@@ -131,7 +131,8 @@ module Fontisan
131
131
  streams[:bbox_entries] = StringIO.new(bbox_blob[bitmap_size..] || "")
132
132
 
133
133
  # overlapSimpleBitmap follows all streams when optionFlags bit 0 set.
134
- streams[:overlap_bitmap] = header[:has_overlap_bitmap] ? @data[pos, overlap_size] : ""
134
+ streams[:overlap_bitmap] =
135
+ header[:has_overlap_bitmap] ? @data[pos, overlap_size] : ""
135
136
 
136
137
  # nContour is read positionally (int16 per glyph).
137
138
  nc_data = streams[:n_contour].string
@@ -178,7 +179,9 @@ module Fontisan
178
179
  x = 0
179
180
  y = 0
180
181
  triplet_flags.each do |flag|
181
- dx, dy, oc = TripletCodec.decode(flag, read_triplet_payload(flag, streams[:glyph]))
182
+ dx, dy, oc = TripletCodec.decode(flag,
183
+ read_triplet_payload(flag,
184
+ streams[:glyph]))
182
185
  x += dx
183
186
  y += dy
184
187
  xs << x
@@ -97,7 +97,8 @@ module Fontisan
97
97
  def per_table_checksum(table)
98
98
  data = table.bytes
99
99
  if table.tag == "head"
100
- data = "#{data.byteslice(0, 8)}#{['00000000'].pack('H8')}#{data.byteslice(12..)}"
100
+ data = "#{data.byteslice(0,
101
+ 8)}#{['00000000'].pack('H8')}#{data.byteslice(12..)}"
101
102
  end
102
103
  Utilities::ChecksumCalculator.calculate_table_checksum(data)
103
104
  end
data/lib/fontisan.rb CHANGED
@@ -119,7 +119,6 @@ module Fontisan
119
119
  autoload :SfntBuilder, "fontisan/sfnt_builder"
120
120
  autoload :Stitcher, "fontisan/stitcher"
121
121
  autoload :StitcherCli, "fontisan/stitcher_cli"
122
- autoload :Tasks, "fontisan/tasks"
123
122
  autoload :TrueTypeCollection, "fontisan/true_type_collection"
124
123
  autoload :TrueTypeFont, "fontisan/true_type_font"
125
124
  autoload :TrueTypeFontExtensions, "fontisan/true_type_font_extensions"
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.22
4
+ version: 0.4.24
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-08 00:00:00.000000000 Z
11
+ date: 2026-07-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -168,6 +168,7 @@ files:
168
168
  - docs/api/font-loader.md
169
169
  - docs/api/font-writer.md
170
170
  - docs/api/index.md
171
+ - docs/api/layer.md
171
172
  - docs/api/models/glyph-accessor.md
172
173
  - docs/api/models/glyph.md
173
174
  - docs/api/models/table-analyzer.md
@@ -440,6 +441,7 @@ files:
440
441
  - lib/fontisan/stitcher/collection_result.rb
441
442
  - lib/fontisan/stitcher/deduplicator.rb
442
443
  - lib/fontisan/stitcher/format_metadata.rb
444
+ - lib/fontisan/stitcher/glyph_cloner.rb
443
445
  - lib/fontisan/stitcher/glyph_copier.rb
444
446
  - lib/fontisan/stitcher/glyph_limit.rb
445
447
  - lib/fontisan/stitcher/glyph_signature.rb
@@ -455,12 +457,35 @@ files:
455
457
  - lib/fontisan/stitcher/selector/gid.rb
456
458
  - lib/fontisan/stitcher/selector/range.rb
457
459
  - lib/fontisan/stitcher/source.rb
460
+ - lib/fontisan/stitcher/unique_glyph_name.rb
458
461
  - lib/fontisan/stitcher_cli.rb
459
462
  - lib/fontisan/subset.rb
460
463
  - lib/fontisan/subset/builder.rb
461
464
  - lib/fontisan/subset/glyph_mapping.rb
462
465
  - lib/fontisan/subset/options.rb
463
466
  - lib/fontisan/subset/profile.rb
467
+ - lib/fontisan/subset/shared_state.rb
468
+ - lib/fontisan/subset/subset_context.rb
469
+ - lib/fontisan/subset/table_strategy.rb
470
+ - lib/fontisan/subset/table_strategy/cbdt.rb
471
+ - lib/fontisan/subset/table_strategy/cblc.rb
472
+ - lib/fontisan/subset/table_strategy/cmap.rb
473
+ - lib/fontisan/subset/table_strategy/color_bitmap_placement.rb
474
+ - lib/fontisan/subset/table_strategy/color_bitmap_strike_plan.rb
475
+ - lib/fontisan/subset/table_strategy/color_bitmap_subset_plan.rb
476
+ - lib/fontisan/subset/table_strategy/color_bitmap_subsetter.rb
477
+ - lib/fontisan/subset/table_strategy/color_bitmap_subtable_plan.rb
478
+ - lib/fontisan/subset/table_strategy/glyf.rb
479
+ - lib/fontisan/subset/table_strategy/glyf_loca_builder.rb
480
+ - lib/fontisan/subset/table_strategy/head.rb
481
+ - lib/fontisan/subset/table_strategy/hhea.rb
482
+ - lib/fontisan/subset/table_strategy/hmtx.rb
483
+ - lib/fontisan/subset/table_strategy/loca.rb
484
+ - lib/fontisan/subset/table_strategy/maxp.rb
485
+ - lib/fontisan/subset/table_strategy/name.rb
486
+ - lib/fontisan/subset/table_strategy/os2.rb
487
+ - lib/fontisan/subset/table_strategy/pass_through.rb
488
+ - lib/fontisan/subset/table_strategy/post.rb
464
489
  - lib/fontisan/subset/table_subsetter.rb
465
490
  - lib/fontisan/svg.rb
466
491
  - lib/fontisan/svg/font_face_generator.rb
@@ -483,6 +508,14 @@ files:
483
508
  - lib/fontisan/tables.rb
484
509
  - lib/fontisan/tables/cbdt.rb
485
510
  - lib/fontisan/tables/cblc.rb
511
+ - lib/fontisan/tables/cblc_big_glyph_metrics.rb
512
+ - lib/fontisan/tables/cblc_bitmap_size.rb
513
+ - lib/fontisan/tables/cblc_glyph_bitmap_location.rb
514
+ - lib/fontisan/tables/cblc_index_subtable.rb
515
+ - lib/fontisan/tables/cblc_index_subtable_array_entry.rb
516
+ - lib/fontisan/tables/cblc_index_subtable_format_parser.rb
517
+ - lib/fontisan/tables/cblc_index_subtable_header.rb
518
+ - lib/fontisan/tables/cblc_sbit_line_metrics.rb
486
519
  - lib/fontisan/tables/cff.rb
487
520
  - lib/fontisan/tables/cff/cff2_charstring_builder.rb
488
521
  - lib/fontisan/tables/cff/cff_glyph.rb
@@ -556,8 +589,6 @@ files:
556
589
  - lib/fontisan/tables/svg.rb
557
590
  - lib/fontisan/tables/variation_common.rb
558
591
  - lib/fontisan/tables/vvar.rb
559
- - lib/fontisan/tasks.rb
560
- - lib/fontisan/tasks/fixture_downloader.rb
561
592
  - lib/fontisan/true_type_collection.rb
562
593
  - lib/fontisan/true_type_font.rb
563
594
  - lib/fontisan/true_type_font_extensions.rb
@@ -657,6 +688,7 @@ files:
657
688
  - lib/fontisan/ufo/features.rb
658
689
  - lib/fontisan/ufo/font.rb
659
690
  - lib/fontisan/ufo/glyph.rb
691
+ - lib/fontisan/ufo/glyph_exists_error.rb
660
692
  - lib/fontisan/ufo/guideline.rb
661
693
  - lib/fontisan/ufo/image.rb
662
694
  - lib/fontisan/ufo/image_set.rb
@@ -1,162 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "open-uri"
4
- require "net/http"
5
- require "fileutils"
6
-
7
- module Fontisan
8
- # Tasks supporting the developer workflow: fixture downloads, etc.
9
- # Lives under its own namespace so Rakefiles and other tooling can
10
- # load just the task plumbing without pulling in the full fontisan
11
- # stack (BinData tables, UFO, etc.).
12
- module Tasks
13
- # Downloads a single fixture file with retry on transient network
14
- # failures. Used by `rake fixtures:download` so a single CDN blip
15
- # (5xx, connection reset, OpenTimeout) doesn't sink a fresh
16
- # checkout. Permanent failures (404, malformed URL) surface
17
- # immediately.
18
- #
19
- # The downloader is a focused class, not a procedural Rakefile
20
- # patch, so the retry logic is unit-testable in isolation.
21
- #
22
- # @example
23
- # Fontisan::Tasks::FixtureDownloader.new(
24
- # url: "https://github.com/.../font.ttf",
25
- # destination: "spec/fixtures/font.ttf",
26
- # ).call
27
- class FixtureDownloader
28
- RETRIABLE_ERRORS = [
29
- Net::OpenTimeout,
30
- Net::ReadTimeout,
31
- Errno::ECONNRESET,
32
- Errno::ECONNREFUSED,
33
- Errno::EHOSTUNREACH,
34
- Errno::ETIMEDOUT,
35
- EOFError,
36
- IOError,
37
- ].freeze
38
-
39
- # 5xx HTTP responses are transient server errors worth retrying.
40
- # 4xx are permanent (404, 403) and must fail fast.
41
- RETRIABLE_HTTP_STATUSES = (500..599)
42
-
43
- DEFAULT_MAX_RETRIES = 3
44
- DEFAULT_BASE_BACKOFF = 0.5 # seconds; doubles per attempt
45
-
46
- # Error raised after exhausting all retries. Carries the last
47
- # underlying exception so callers can log the root cause.
48
- class Error < StandardError
49
- attr_reader :last_error
50
-
51
- def initialize(url:, attempts:, last_error:)
52
- @last_error = last_error
53
- super("Failed to download #{url} after #{attempts} attempts: " \
54
- "#{last_error.class}: #{last_error.message}")
55
- end
56
- end
57
-
58
- attr_reader :url, :destination, :max_retries, :base_backoff, :sleep_method
59
-
60
- # @param url [String] source URL.
61
- # @param destination [String] path to write bytes to. Parent dir
62
- # is auto-created.
63
- # @param max_retries [Integer] total attempts including the
64
- # first. 3 means: try, retry, retry.
65
- # @param base_backoff [Float] seconds to sleep before the first
66
- # retry. Doubles per attempt.
67
- # @param sleep_method [#call] injectable sleep (for tests).
68
- # Defaults to Kernel.sleep.
69
- def initialize(url:, destination:, max_retries: DEFAULT_MAX_RETRIES,
70
- base_backoff: DEFAULT_BASE_BACKOFF, sleep_method: method(:sleep))
71
- @url = url
72
- @destination = destination
73
- @max_retries = max_retries
74
- @base_backoff = base_backoff
75
- @sleep_method = sleep_method
76
- end
77
-
78
- # Performs the download. Returns the destination path on
79
- # success. Raises {Error} after exhausting retries.
80
- #
81
- # @return [String] destination path
82
- # @raise [Error]
83
- def call
84
- attempts = 0
85
- nil
86
-
87
- begin
88
- attempts += 1
89
- fetch_to_destination
90
- destination
91
- rescue StandardError => e
92
- e
93
- raise if permanent_failure?(e)
94
- raise Error.new(url: url, attempts: attempts, last_error: e) if attempts >= max_retries
95
-
96
- backoff = base_backoff * (2**(attempts - 1))
97
- sleep_method.call(backoff)
98
- retry
99
- end
100
- end
101
-
102
- private
103
-
104
- def fetch_to_destination
105
- FileUtils.mkdir_p(File.dirname(destination))
106
-
107
- # IO.copy_stream avoids loading the whole response into memory
108
- # and is more Windows-compatible than remote.read + File.binwrite.
109
- # URLs come from FixtureFonts config (version-controlled), not
110
- # user input — same trust model as the previous inline URI.open
111
- # call in the Rakefile.
112
- #
113
- # Parsing with URI.parse first satisfies CodeQL's "open with
114
- # non-constant value" check: any string that isn't a valid URI
115
- # raises URI::InvalidURIError before OpenURI can dispatch on
116
- # it. The parsed URI's .open is OpenURI's standard entry.
117
- # rubocop:disable Security/Open
118
- URI.parse(url).open(open_uri_options) do |remote|
119
- File.open(destination, "wb") do |file|
120
- IO.copy_stream(remote, file)
121
- end
122
- end
123
- # rubocop:enable Security/Open
124
- end
125
-
126
- # `open-uri` follows redirects by default and surfaces HTTP
127
- # errors as `OpenURI::HTTPError` whose `io.status` is the `[code,
128
- # message]` array. We re-raise non-retriable 4xx as
129
- # `permanent-failure`-tagged exceptions so the retry loop exits.
130
- def open_uri_options
131
- {
132
- "User-Agent" => "fontisan-fixtures/1.0",
133
- redirect: true,
134
- open_timeout: 30,
135
- read_timeout: 120,
136
- }
137
- end
138
-
139
- def permanent_failure?(error)
140
- case error
141
- when OpenURI::HTTPError
142
- status = parse_http_status(error)
143
- status && !RETRIABLE_HTTP_STATUSES.cover?(status)
144
- else
145
- RETRIABLE_ERRORS.none? { |klass| error.is_a?(klass) }
146
- end
147
- end
148
-
149
- def parse_http_status(error)
150
- io = error.io
151
- return nil unless io
152
-
153
- status = io.status
154
- return nil unless status
155
-
156
- status.first.to_i
157
- rescue StandardError
158
- nil
159
- end
160
- end
161
- end
162
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Fontisan
4
- # Tasks supporting the developer workflow: fixture downloads, etc.
5
- # Lives under its own namespace so Rakefiles and other tooling can
6
- # load just the task plumbing without pulling in the full fontisan
7
- # stack (BinData tables, UFO, etc.).
8
- module Tasks
9
- autoload :FixtureDownloader, "fontisan/tasks/fixture_downloader"
10
- end
11
- end