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.
@@ -20,7 +20,7 @@ module Fontisan
20
20
  #
21
21
  # @example Generate SVG glyph element
22
22
  # generator = GlyphGenerator.new(calculator)
23
- # xml = generator.generate_glyph_xml(outline, unicode: "A", advance_width: 600)
23
+ # xml = generator.generate_glyph_xml(outline, codepoints: [0x41], advance_width: 600)
24
24
  class GlyphGenerator
25
25
  # @return [ViewBoxCalculator] Coordinate calculator
26
26
  attr_reader :calculator
@@ -38,23 +38,30 @@ module Fontisan
38
38
  # Generate SVG glyph element
39
39
  #
40
40
  # @param outline [Models::GlyphOutline] Glyph outline
41
- # @param unicode [String, nil] Unicode character
42
- # @param glyph_name [String, nil] Glyph name
41
+ # @param codepoints [Array<Integer>] Unicode codepoints that cmap
42
+ # maps to this glyph. Empty for unmapped glyphs (.notdef, raw
43
+ # ligatures, etc.). Each codepoint is rendered per SVG Fonts
44
+ # spec: printable ASCII as the character (XML-escaped), others
45
+ # as &#xHEX; entities. Multiple codepoints are concatenated —
46
+ # rare but valid (e.g. a glyph mapped from both space and
47
+ # non-breaking space).
48
+ # @param glyph_name [String, nil] Glyph name from the post table
43
49
  # @param advance_width [Integer] Horizontal advance width
44
50
  # @param indent [String] Indentation string
45
51
  # @return [String] XML glyph element
46
- def generate_glyph_xml(outline, unicode: nil, glyph_name: nil,
52
+ def generate_glyph_xml(outline, codepoints: [], glyph_name: nil,
47
53
  advance_width: 0, indent: " ")
48
- # Build attribute parts
49
54
  attr_parts = []
50
55
 
51
- attr_parts << "unicode=\"#{escape_xml(unicode)}\"" if unicode
52
- attr_parts << "glyph-name=\"#{escape_xml(glyph_name)}\"" if glyph_name
53
- attr_parts << "horiz-adv-x=\"#{advance_width}\"" if advance_width&.positive?
56
+ unless codepoints.empty?
57
+ attr_parts << %(unicode="#{format_codepoints(codepoints)}")
58
+ end
59
+ attr_parts << %(glyph-name="#{escape_xml(glyph_name)}") if glyph_name
60
+ attr_parts << %(horiz-adv-x="#{advance_width}") if advance_width&.positive?
54
61
 
55
62
  # Generate SVG path with Y-axis transformation
56
63
  path_data = generate_svg_path(outline)
57
- attr_parts << "d=\"#{path_data}\"" if path_data && !path_data.empty?
64
+ attr_parts << %(d="#{path_data}") if path_data && !path_data.empty?
58
65
 
59
66
  "#{indent}<glyph #{attr_parts.join(' ')}/>"
60
67
  end
@@ -88,6 +95,34 @@ advance_width: 0, indent: " ")
88
95
 
89
96
  private
90
97
 
98
+ # Format an array of codepoints as the SVG `unicode=` attribute
99
+ # value. Returns the FINAL string — already escaped / entity-fied.
100
+ # The caller must not re-escape it (would double-escape the
101
+ # entities this method emits).
102
+ #
103
+ # Per SVG Fonts spec, each codepoint renders as either:
104
+ # - printable ASCII (0x20..0x7E): the character itself, with
105
+ # XML-special chars (&, <, >, ", ') escaped
106
+ # - any other codepoint: a numeric entity &#xHEX;
107
+ #
108
+ # Multiple codepoints are concatenated in codepoint order. The
109
+ # SVG spec accepts this for ligature-style mappings.
110
+ def format_codepoints(codepoints)
111
+ codepoints.map { |cp| format_codepoint(cp) }.join
112
+ end
113
+
114
+ def format_codepoint(cp)
115
+ if printable_ascii?(cp)
116
+ escape_xml(cp.chr(Encoding::UTF_8))
117
+ else
118
+ "&#x#{cp.to_s(16).upcase};"
119
+ end
120
+ end
121
+
122
+ def printable_ascii?(cp)
123
+ (0x20..0x7E).cover?(cp)
124
+ end
125
+
91
126
  # Build SVG path for a contour with Y-axis transformation
92
127
  #
93
128
  # @param contour [Array<Hash>] Array of point hashes
@@ -0,0 +1,162 @@
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
@@ -0,0 +1,11 @@
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
@@ -15,18 +15,6 @@ module Fontisan
15
15
  TIP = 3
16
16
  SSP = 14
17
17
 
18
- # The handful of mega-blocks large enough to overflow a single
19
- # plane's 65,535-glyph cap when partitioning by plane. Range +
20
- # label only — full Unicode Blocks.txt data lives in +Block+
21
- # (follow-up).
22
- LARGE_CJK_BLOCKS = {
23
- "CJK_Ext_B" => 0x2A700..0x2B73F,
24
- "CJK_Ext_C" => 0x2B740..0x2B81F,
25
- "CJK_Ext_D" => 0x2B820..0x2CEAF,
26
- "CJK_Ext_E" => 0x2CEB0..0x2EBEF,
27
- "CJK_Ext_F" => 0x2EBF0..0x2EE5F,
28
- }.freeze
29
-
30
18
  # @param codepoint [Integer]
31
19
  # @return [Integer] plane number (0..16)
32
20
  def self.of(codepoint)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.9"
4
+ VERSION = "0.4.11"
5
5
  end
data/lib/fontisan.rb CHANGED
@@ -32,6 +32,7 @@ require "logger"
32
32
  require "bindata"
33
33
  require "zlib"
34
34
  require "stringio"
35
+ require "tmpdir"
35
36
  require "lutaml/model"
36
37
 
37
38
  # Configure lutaml-model to use Nokogiri adapter for XML serialization
@@ -117,6 +118,7 @@ module Fontisan
117
118
  autoload :SfntTable, "fontisan/sfnt_table"
118
119
  autoload :Stitcher, "fontisan/stitcher"
119
120
  autoload :StitcherCli, "fontisan/stitcher_cli"
121
+ autoload :Tasks, "fontisan/tasks"
120
122
  autoload :TrueTypeCollection, "fontisan/true_type_collection"
121
123
  autoload :TrueTypeFont, "fontisan/true_type_font"
122
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.9
4
+ version: 0.4.11
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-03 00:00:00.000000000 Z
11
+ date: 2026-07-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -434,12 +434,15 @@ files:
434
434
  - lib/fontisan/stitcher.rb
435
435
  - lib/fontisan/stitcher/collection_result.rb
436
436
  - lib/fontisan/stitcher/deduplicator.rb
437
+ - lib/fontisan/stitcher/format_metadata.rb
437
438
  - lib/fontisan/stitcher/glyph_limit.rb
438
439
  - lib/fontisan/stitcher/glyph_signature.rb
439
440
  - lib/fontisan/stitcher/partition_strategy.rb
440
441
  - lib/fontisan/stitcher/partition_strategy/base.rb
441
442
  - lib/fontisan/stitcher/partition_strategy/blueprint.rb
443
+ - lib/fontisan/stitcher/partition_strategy/by_block.rb
442
444
  - lib/fontisan/stitcher/partition_strategy/by_plane.rb
445
+ - lib/fontisan/stitcher/partition_strategy/by_script.rb
443
446
  - lib/fontisan/stitcher/partition_strategy/partition.rb
444
447
  - lib/fontisan/stitcher/selector.rb
445
448
  - lib/fontisan/stitcher/selector/codepoints.rb
@@ -546,6 +549,8 @@ files:
546
549
  - lib/fontisan/tables/svg.rb
547
550
  - lib/fontisan/tables/variation_common.rb
548
551
  - lib/fontisan/tables/vvar.rb
552
+ - lib/fontisan/tasks.rb
553
+ - lib/fontisan/tasks/fixture_downloader.rb
549
554
  - lib/fontisan/true_type_collection.rb
550
555
  - lib/fontisan/true_type_font.rb
551
556
  - lib/fontisan/true_type_font_extensions.rb