fontisan 0.4.9 → 0.4.11

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: 38b49343b74d1876d57ec13992fc49fdc2879a44d1f0fe3c6529bd3a2f8f7a75
4
- data.tar.gz: cc69156566e86294177d9046f39ebde8334f87807da38a7a5dd83ddd2eea8247
3
+ metadata.gz: 1ed3f50509fb7f223b2cdcfdf7e59345e83b5ca2d11b2a0806574651687c4582
4
+ data.tar.gz: ccb6cabe8c99dfcc060acc7f46d26efd9961b5384365ab404552dad539dc4555
5
5
  SHA512:
6
- metadata.gz: 2822ae02861c4183bbd42ded8005fa17d28d59175155767413f94b4400faaf28fb61dad38736f3fcd11235746e1b254151c8e8918480b5ccb146882742c3ec99
7
- data.tar.gz: eefb0dbf8e3360e7b8aeb89dfcfbf62425c255075ee1a3d2c030cd2551f90f08d980f7ea7b8c56c382b0a1cb69933654a57fc4e57d069ebe6809ace84e7d0b42
6
+ metadata.gz: b977d4f9ac972020514daca43a14917784c713258f73223597c8a48dd2b32fcf3ccb9adbe2bd343db7ac43e63eaba8943a883580ec3c7bb7dec23f6a0f1e1fe9
7
+ data.tar.gz: 5c17de9dc3c60f44e472e94c19a1ca33435805ed81f261cc357dfcfd6d197f3109531ae8ad4b3a842e52f4f04de19501c0e860793212b87e874b63f01d99d8f3
data/Rakefile CHANGED
@@ -12,24 +12,25 @@ RuboCop::RakeTask.new
12
12
  namespace :fixtures do
13
13
  # Load centralized fixture configuration
14
14
  require_relative "spec/support/fixture_fonts"
15
+ require "fontisan/tasks"
15
16
 
16
17
  # Helper method to download a single file
17
18
  def download_single_file(name, url, target_path)
18
- require "open-uri"
19
-
20
19
  puts "[fixtures:download] Downloading #{name}..."
21
- FileUtils.mkdir_p(File.dirname(target_path))
22
20
 
23
- URI.open(url) do |remote|
24
- File.binwrite(target_path, remote.read)
25
- end
21
+ Fontisan::Tasks::FixtureDownloader.new(
22
+ url: url,
23
+ destination: target_path,
24
+ ).call
26
25
 
27
26
  puts "[fixtures:download] #{name} downloaded successfully"
27
+ rescue Fontisan::Tasks::FixtureDownloader::Error => e
28
+ warn "[fixtures:download] #{name} failed after retries: #{e.message}"
29
+ raise
28
30
  end
29
31
 
30
32
  # Helper method to download and extract a font archive
31
33
  def download_font(name, url, target_dir)
32
- require "open-uri"
33
34
  require "zip"
34
35
 
35
36
  puts "[fixtures:download] Downloading #{name}..."
@@ -39,45 +40,58 @@ namespace :fixtures do
39
40
  temp_path = File.join(Dir.tmpdir,
40
41
  "fontisan_#{name}_#{Process.pid}_#{rand(10000)}.zip")
41
42
 
42
- # Download using IO.copy_stream for better Windows compatibility
43
- URI.open(url, "rb") do |remote|
44
- File.open(temp_path, "wb") do |file|
45
- IO.copy_stream(remote, file)
46
- end
47
- end
48
-
49
- puts "[fixtures:download] Extracting #{name}..."
50
-
51
- # Open zip file and ensure it's fully closed before we're done
52
- zip_file = Zip::File.open(temp_path)
53
43
  begin
54
- zip_file.each do |entry|
55
- # Skip macOS metadata files and directories
56
- next if entry.name.start_with?("__MACOSX/") || entry.name.include?("/._")
57
- next if entry.directory?
58
-
59
- # Ensure entry.name is relative by stripping leading slashes
60
- relative_name = entry.name.sub(%r{^/+}, "")
61
-
62
- dest_path = File.join(target_dir, relative_name)
63
- FileUtils.mkdir_p(File.dirname(dest_path))
64
-
65
- # Skip if file already exists
66
- next if File.exist?(dest_path)
67
-
68
- # Write the file content directly using binary mode
69
- File.open(dest_path, "wb") do |file|
70
- IO.copy_stream(entry.get_input_stream, file)
44
+ Fontisan::Tasks::FixtureDownloader.new(
45
+ url: url,
46
+ destination: temp_path,
47
+ ).call
48
+
49
+ puts "[fixtures:download] Extracting #{name}..."
50
+
51
+ # Open zip file and ensure it's fully closed before we're done
52
+ zip_file = Zip::File.open(temp_path)
53
+ begin
54
+ zip_file.each do |entry|
55
+ # Skip macOS metadata files and directories
56
+ next if entry.name.start_with?("__MACOSX/") || entry.name.include?("/._")
57
+ next if entry.directory?
58
+
59
+ # Ensure entry.name is relative by stripping leading slashes
60
+ relative_name = entry.name.sub(%r{^/+}, "")
61
+
62
+ dest_path = File.join(target_dir, relative_name)
63
+ FileUtils.mkdir_p(File.dirname(dest_path))
64
+
65
+ # Skip if file already exists
66
+ next if File.exist?(dest_path)
67
+
68
+ # Write the file content directly using binary mode
69
+ File.open(dest_path, "wb") do |file|
70
+ IO.copy_stream(entry.get_input_stream, file)
71
+ end
71
72
  end
73
+ ensure
74
+ # Explicitly close the zip file to release file handle on Windows
75
+ zip_file&.close
72
76
  end
73
77
  ensure
74
- # Explicitly close the zip file to release file handle on Windows
75
- zip_file&.close
78
+ # Clean up the temp zip explicitly so the temp dir doesn't fill
79
+ # up on repeated runs. On Windows the just-closed zip file
80
+ # handle can briefly hold a lock that surfaces as
81
+ # Errno::EACCES; swallow that one error so the rake task can
82
+ # complete (OS will sweep the temp file later).
83
+ begin
84
+ File.delete(temp_path) if File.exist?(temp_path)
85
+ rescue Errno::EACCES
86
+ warn "[fixtures:download] could not delete temp zip #{temp_path}; " \
87
+ "OS will clean it up"
88
+ end
76
89
  end
77
90
 
78
- # Temp file left in Dir.tmpdir - OS will clean it up automatically
79
-
80
91
  puts "[fixtures:download] #{name} downloaded successfully"
92
+ rescue Fontisan::Tasks::FixtureDownloader::Error => e
93
+ warn "[fixtures:download] #{name} failed after retries: #{e.message}"
94
+ raise
81
95
  rescue LoadError => e
82
96
  warn "[fixtures:download] Error: Required gem not installed. Please run: gem install rubyzip"
83
97
  raise e
@@ -165,8 +165,18 @@ must either raise the cap, drop codepoints, or switch to a format
165
165
  with a higher glyph limit.
166
166
 
167
167
  The Unicode plane metadata used by ByPlane lives in
168
- `Fontisan::Unicode::Plane` (`Plane.of(cp)`, `Plane.label(n)`,
169
- `Plane::LARGE_CJK_BLOCKS`).
168
+ `Fontisan::Unicode::Plane` (`Plane.of(cp)`, `Plane.label(n)`).
169
+
170
+ The sub-plane block boundaries used for sub-splitting live on ByPlane
171
+ itself:
172
+
173
+ * `ByPlane::ATOMIC_BLOCKS` — five SIP CJK extension blocks that cannot
174
+ be sub-split; raise if one alone exceeds `cap`.
175
+ * `ByPlane::CARVE_OUT_BLOCKS` — BMP-resident blocks (CJK Unified
176
+ Ideographs, Hangul Syllables) that get their own partition bucket
177
+ when BMP overflows `cap`; chunkable, never raised.
178
+ * `ByPlane::RECOGNIZED_BLOCKS` — union of the two; the lookup table
179
+ the bucketing pass walks.
170
180
 
171
181
  === Partition / Blueprint
172
182
 
@@ -145,7 +145,7 @@ palette_index: 0)
145
145
  #
146
146
  # @param font [TrueTypeFont, OpenTypeFont] Source font
147
147
  # @param options [Hash] Extraction options
148
- # @return [Hash] Glyph data map (glyph_id => {outline, unicode, name, advance})
148
+ # @return [Hash] Glyph data map (glyph_id => {outline, codepoints, name, advance})
149
149
  def extract_glyph_data(font, options = {})
150
150
  extractor = OutlineExtractor.new(font)
151
151
  cmap = font.table("cmap")
@@ -157,33 +157,20 @@ palette_index: 0)
157
157
  num_glyphs = maxp&.num_glyphs || 0
158
158
  max_glyphs = options[:max_glyphs] || num_glyphs
159
159
 
160
- # Get unicode mappings
161
- unicode_map = build_unicode_map(cmap)
160
+ gid_to_codepoints = build_glyph_to_codepoints(cmap)
161
+ glyph_names = post&.glyph_names || []
162
162
 
163
- # Extract specified or all glyphs
164
163
  glyph_ids = options[:glyph_ids] || (0...num_glyphs).to_a
165
164
  glyph_ids = glyph_ids.take(max_glyphs) if max_glyphs
166
165
 
167
166
  glyph_ids.each do |glyph_id|
168
167
  next if glyph_id >= num_glyphs
169
168
 
170
- # Extract outline
171
- outline = extractor.extract(glyph_id)
172
-
173
- # Get advance width
174
- advance = extract_advance_width(hmtx, glyph_id)
175
-
176
- # Get unicode character
177
- unicode = unicode_map[glyph_id]
178
-
179
- # Get glyph name
180
- glyph_name = extract_glyph_name(post, glyph_id)
181
-
182
169
  glyph_data[glyph_id] = {
183
- outline: outline,
184
- unicode: unicode,
185
- name: glyph_name,
186
- advance: advance,
170
+ outline: extractor.extract(glyph_id),
171
+ codepoints: gid_to_codepoints[glyph_id] || [],
172
+ name: glyph_name_for(glyph_names, glyph_id),
173
+ advance: extract_advance_width(hmtx, glyph_id),
187
174
  }
188
175
  rescue StandardError => e
189
176
  warn "Failed to extract glyph #{glyph_id}: #{e.message}"
@@ -193,61 +180,38 @@ palette_index: 0)
193
180
  glyph_data
194
181
  end
195
182
 
196
- # Build unicode to glyph ID map from cmap table
183
+ # Build reverse cmap: glyph_id => [codepoint, ...].
197
184
  #
198
- # @param cmap [Tables::Cmap, nil] Cmap table
199
- # @return [Hash<Integer, String>] Map of glyph_id to unicode character
200
- def build_unicode_map(cmap)
185
+ # A glyph can be referenced by multiple codepoints (e.g. space
186
+ # and non-breaking space might share a glyph). Uses
187
+ # +cmap.unicode_mappings+ directly — that is the canonical
188
+ # {codepoint => gid} hash.
189
+ #
190
+ # @param cmap [Tables::Cmap, nil]
191
+ # @return [Hash<Integer, Array<Integer>>]
192
+ def build_glyph_to_codepoints(cmap)
201
193
  return {} unless cmap
202
194
 
203
- unicode_map = {}
204
-
205
- # Get best cmap subtable (prefer Unicode BMP or full)
206
- subtable = find_best_cmap_subtable(cmap)
207
- return {} unless subtable
208
-
209
- # Build reverse map: glyph_id => unicode
210
- subtable.each do |code_point, glyph_id|
211
- # Store first unicode for each glyph
212
- next if unicode_map[glyph_id]
213
-
214
- unicode_map[glyph_id] = [code_point].pack("U")
215
- rescue StandardError
216
- # Skip invalid code points
217
- next
195
+ cmap.unicode_mappings.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(cp, gid), h|
196
+ h[gid] << cp
218
197
  end
219
-
220
- unicode_map
221
198
  rescue StandardError => e
222
- warn "Failed to build unicode map: #{e.message}"
199
+ warn "Failed to build glyph to codepoints map: #{e.message}"
223
200
  {}
224
201
  end
225
202
 
226
- # Find best cmap subtable for unicode mapping
203
+ # Look up glyph name from the post table's per-gid name array.
227
204
  #
228
- # @param cmap [Tables::Cmap] Cmap table
229
- # @return [Hash, nil] Subtable or nil
230
- def find_best_cmap_subtable(cmap)
231
- # Try Unicode BMP (platform 3, encoding 1) - Windows Unicode BMP
232
- subtable = cmap.subtable(3, 1)
233
- return subtable if subtable
234
-
235
- # Try Unicode full (platform 3, encoding 10) - Windows Unicode full
236
- subtable = cmap.subtable(3, 10)
237
- return subtable if subtable
238
-
239
- # Try Unicode (platform 0, encoding 3) - Unicode 2.0+ BMP
240
- subtable = cmap.subtable(0, 3)
241
- return subtable if subtable
242
-
243
- # Try Unicode (platform 0, encoding 4) - Unicode 2.0+ full
244
- subtable = cmap.subtable(0, 4)
245
- return subtable if subtable
246
-
247
- # Fallback to any available subtable
248
- cmap.subtables.first
249
- rescue StandardError
250
- nil
205
+ # Falls back to +"gidN"+ when the post table has no name for the
206
+ # given gid (post version 3.0 fonts omit per-glyph names, and
207
+ # version 2.0 may have gaps). Always returns a non-nil string so
208
+ # the SVG <glyph> element carries a useful glyph-name attribute.
209
+ #
210
+ # @param glyph_names [Array<String>] post.glyph_names
211
+ # @param glyph_id [Integer]
212
+ # @return [String]
213
+ def glyph_name_for(glyph_names, glyph_id)
214
+ glyph_names[glyph_id] || "gid#{glyph_id}"
251
215
  end
252
216
 
253
217
  # Extract advance width for glyph
@@ -265,22 +229,6 @@ palette_index: 0)
265
229
  rescue StandardError
266
230
  0
267
231
  end
268
-
269
- # Extract glyph name from post table
270
- #
271
- # @param post [Tables::Post, nil] Post table
272
- # @param glyph_id [Integer] Glyph ID
273
- # @return [String, nil] Glyph name or nil
274
- def extract_glyph_name(post, glyph_id)
275
- return nil unless post
276
-
277
- name = post.glyph_name_for(glyph_id)
278
- return nil if name.nil? || name.empty?
279
-
280
- name
281
- rescue StandardError
282
- nil
283
- end
284
232
  end
285
233
  end
286
234
  end
@@ -59,8 +59,16 @@ module Fontisan
59
59
  header.num_tables
60
60
  }
61
61
 
62
- # Table data is stored separately since it's at variable offsets
63
- attr_accessor :table_data
62
+ # Table data is stored separately since it's at variable offsets.
63
+ # The setter normalizes all keys to UTF-8 so Hash lookups don't
64
+ # silently miss due to encoding mismatch — BinData-derived tag
65
+ # strings are ASCII-8BIT, literals callers pass are usually UTF-8,
66
+ # and String#eql? is encoding-sensitive.
67
+ attr_reader :table_data
68
+
69
+ def table_data=(hash)
70
+ @table_data = hash.transform_keys { |tag| normalize_tag(tag) }
71
+ end
64
72
 
65
73
  # Parsed table instances cache
66
74
  attr_accessor :parsed_tables
@@ -470,6 +478,8 @@ module Fontisan
470
478
  # @return [Tables::*, nil] Parsed table object or nil if not found
471
479
  # @raise [ArgumentError] if table is not available in current loading mode
472
480
  def table(tag)
481
+ tag = normalize_tag(tag)
482
+
473
483
  # Check mode restrictions
474
484
  unless table_available?(tag)
475
485
  if has_table?(tag)
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ # Immutable metadata about a stitcher output format. Single source
6
+ # of truth for "given a format symbol, what compiler / collection
7
+ # format / file extension does it use?". Adding a new format =
8
+ # adding a +when+ branch in {resolve}; no other site in Stitcher
9
+ # needs to know the mapping.
10
+ class FormatMetadata
11
+ attr_reader :name, :compiler_class, :collection_format, :extension
12
+
13
+ # @param name [Symbol] canonical format name (:ttf, :otf, :otf2)
14
+ # @param compiler_class [Class] the +Ufo::Compile::*Compiler+ that
15
+ # compiles a +Ufo::Font+ target into the format's binary
16
+ # @param collection_format [Symbol] :ttc or :otc — the
17
+ # +Collection::Builder+ format used when packing multiple
18
+ # subfonts of this format into a collection
19
+ # @param extension [String] file extension including the dot
20
+ def initialize(name:, compiler_class:, collection_format:, extension:)
21
+ @name = name
22
+ @compiler_class = compiler_class
23
+ @collection_format = collection_format
24
+ @extension = extension
25
+ end
26
+
27
+ # Resolve a format name (Symbol or String) to its metadata.
28
+ # Constants are referenced inside +when+ branches so autoload
29
+ # fires only for the requested format, not all of them.
30
+ #
31
+ # @param name [Symbol, String]
32
+ # @raise [ArgumentError] if +name+ is not a known stitcher format
33
+ # @return [FormatMetadata]
34
+ def self.resolve(name)
35
+ case name.to_sym
36
+ when :ttf
37
+ new(name: :ttf, compiler_class: Ufo::Compile::TtfCompiler,
38
+ collection_format: :ttc, extension: ".ttf")
39
+ when :otf
40
+ new(name: :otf, compiler_class: Ufo::Compile::OtfCompiler,
41
+ collection_format: :otc, extension: ".otf")
42
+ when :otf2
43
+ new(name: :otf2, compiler_class: Ufo::Compile::Otf2Compiler,
44
+ collection_format: :otc, extension: ".otf")
45
+ else
46
+ raise ArgumentError, "unknown format: #{name.inspect}"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -6,9 +6,38 @@ module Fontisan
6
6
  # Abstract base. Concrete partitioners (ByPlane, ByBlock, …)
7
7
  # implement {#call} and return a {Blueprint}.
8
8
  class Base
9
- # Default cap: 65,535 .notdef safety margin. Matches the
10
- # Stitcher's own +GlyphLimit+ cap for TTF.
11
- DEFAULT_CAP = 65_484
9
+ # Glyph-cap headroom below the format's hard cap
10
+ # (GlyphLimit::TTF_GLYPH_CAP / OTF_GLYPH_CAP = 65,535). The
11
+ # partitioner must keep each partition's *codepoint* count low
12
+ # enough that compile-time *glyph* expansion still fits under
13
+ # the hard cap.
14
+ #
15
+ # Reserved slots:
16
+ # - .notdef (1 slot, mandated by OpenType — every font has it
17
+ # at GID 0 even when no codepoint binds to it)
18
+ # - composite expansion: compiling adds composites (ligatures,
19
+ # decomposable glyphs, variant attachments) on top of the
20
+ # source codepoint count. Empirically ~5% of typical BMP
21
+ # size for CJK-heavy fonts (≈2,500 composites on a 50k-cp
22
+ # BMP font). 5,534 slots covers that with margin.
23
+ NOTDEF_RESERVED = 1
24
+ COMPOSITE_HEADROOM = 5_534
25
+
26
+ # Derive the partition cap from a format's hard glyph limit.
27
+ # Mirrors GlyphLimit so a future bump (e.g. CFF2 card24 INDEX
28
+ # counts) cascades automatically — no second magic number to
29
+ # keep in sync.
30
+ #
31
+ # @param format [Symbol] :ttf, :otf, or :otf2
32
+ # @return [Integer]
33
+ def self.cap_for(format)
34
+ GlyphLimit.for_format(format) - NOTDEF_RESERVED - COMPOSITE_HEADROOM
35
+ end
36
+
37
+ # Default cap for the most common format (TTF, where the glyph
38
+ # cap equals 65,535). Use {.cap_for} when partitioning for a
39
+ # different format.
40
+ DEFAULT_CAP = cap_for(:ttf)
12
41
 
13
42
  # @param cp_map [Hash{Integer=>Object}] codepoint → donor label
14
43
  # @param cap [Integer] max codepoints per partition