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 +4 -4
- data/Rakefile +53 -39
- data/docs/STITCHER_GUIDE.adoc +12 -2
- data/lib/fontisan/converters/svg_generator.rb +30 -82
- data/lib/fontisan/sfnt_font.rb +12 -2
- data/lib/fontisan/stitcher/format_metadata.rb +51 -0
- data/lib/fontisan/stitcher/partition_strategy/base.rb +32 -3
- data/lib/fontisan/stitcher/partition_strategy/by_block.rb +429 -0
- data/lib/fontisan/stitcher/partition_strategy/by_plane.rb +59 -29
- data/lib/fontisan/stitcher/partition_strategy/by_script.rb +305 -0
- data/lib/fontisan/stitcher/partition_strategy.rb +2 -0
- data/lib/fontisan/stitcher/source.rb +89 -8
- data/lib/fontisan/stitcher.rb +9 -27
- data/lib/fontisan/subset/table_subsetter.rb +123 -5
- data/lib/fontisan/svg/font_generator.rb +2 -2
- data/lib/fontisan/svg/glyph_generator.rb +44 -9
- data/lib/fontisan/tasks/fixture_downloader.rb +162 -0
- data/lib/fontisan/tasks.rb +11 -0
- data/lib/fontisan/unicode/plane.rb +0 -12
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +2 -0
- metadata +7 -2
|
@@ -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,
|
|
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
|
|
42
|
-
#
|
|
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,
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 <<
|
|
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)
|
data/lib/fontisan/version.rb
CHANGED
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.
|
|
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-
|
|
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
|