fontisan 0.4.9 → 0.4.10
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/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_plane.rb +59 -29
- data/lib/fontisan/stitcher/source.rb +89 -8
- data/lib/fontisan/stitcher.rb +9 -27
- data/lib/fontisan/svg/font_generator.rb +2 -2
- data/lib/fontisan/svg/glyph_generator.rb +44 -9
- data/lib/fontisan/unicode/plane.rb +0 -12
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 797250db52b46986a1458fd680d2c2f2eab6896ba1bcf617ba0d74622ce7ff51
|
|
4
|
+
data.tar.gz: 81cf20d3aff4f40b33e0fec8cc8de359a1a4acf139d14fe75ec3a4fc839ac827
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dc92df9fbb61326d199f51ceda010e8f990cd109355679d463066e0f590a461e71241239b6b90f3a1acf5832362fba778d51833d24eb01c46b42b95c488c3550
|
|
7
|
+
data.tar.gz: c331cceb05fd29864a85e4e7d7dca99aa96c9c2ef152c3e877842aaffd0a82d1ad46e6770d5e80d3799bb67a905306a1eb6e5b4d18d03f5c8a02072c030c33f8
|
data/docs/STITCHER_GUIDE.adoc
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
161
|
-
|
|
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:
|
|
184
|
-
|
|
185
|
-
name:
|
|
186
|
-
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
|
|
183
|
+
# Build reverse cmap: glyph_id => [codepoint, ...].
|
|
197
184
|
#
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
|
|
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
|
-
|
|
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
|
|
199
|
+
warn "Failed to build glyph to codepoints map: #{e.message}"
|
|
223
200
|
{}
|
|
224
201
|
end
|
|
225
202
|
|
|
226
|
-
#
|
|
203
|
+
# Look up glyph name from the post table's per-gid name array.
|
|
227
204
|
#
|
|
228
|
-
#
|
|
229
|
-
#
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
data/lib/fontisan/sfnt_font.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
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
|
|
@@ -8,15 +8,47 @@ module Fontisan
|
|
|
8
8
|
# For each plane with codepoints:
|
|
9
9
|
# - if the count fits under +cap+, emit one partition named
|
|
10
10
|
# +:plane_<n>+ (e.g. +:plane_0+, +:plane_2+);
|
|
11
|
-
# - otherwise sub-split
|
|
12
|
-
# boundaries, naming partitions
|
|
13
|
-
# and so on.
|
|
11
|
+
# - otherwise sub-split along the recognized sub-plane block
|
|
12
|
+
# boundaries (+RECOGNIZED_BLOCKS+), naming partitions
|
|
13
|
+
# +:plane_<n>_a+, +:plane_<n>_b+, and so on.
|
|
14
14
|
#
|
|
15
|
-
# If a single
|
|
16
|
-
# {Fontisan::
|
|
15
|
+
# If a single +ATOMIC_BLOCKS+ entry alone exceeds +cap+, raises
|
|
16
|
+
# {Fontisan::PartitionCapExceededError} — the partitioner cannot
|
|
17
17
|
# satisfy the cap and the caller must use a smaller cap, split
|
|
18
18
|
# manually, or switch to a format with a higher glyph limit.
|
|
19
|
+
# +CARVE_OUT_BLOCKS+ entries are chunkable and will be sliced
|
|
20
|
+
# like +:other+ if they exceed +cap+.
|
|
19
21
|
class ByPlane < Base
|
|
22
|
+
# Atomic sub-plane blocks: cannot be sub-split further because
|
|
23
|
+
# no finer-grained Unicode boundary exists inside them. If one
|
|
24
|
+
# alone exceeds +cap+, raise PartitionCapExceededError.
|
|
25
|
+
#
|
|
26
|
+
# Labels follow the Unicode Blocks.txt block names with spaces
|
|
27
|
+
# replaced by underscores.
|
|
28
|
+
ATOMIC_BLOCKS = {
|
|
29
|
+
"CJK_Ext_B" => 0x2A700..0x2B73F,
|
|
30
|
+
"CJK_Ext_C" => 0x2B740..0x2B81F,
|
|
31
|
+
"CJK_Ext_D" => 0x2B820..0x2CEAF,
|
|
32
|
+
"CJK_Ext_E" => 0x2CEB0..0x2EBEF,
|
|
33
|
+
"CJK_Ext_F" => 0x2EBF0..0x2EE5F,
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# Carve-out boundaries: sub-plane regions large enough to
|
|
37
|
+
# warrant their own partition bucket when a plane overflows
|
|
38
|
+
# +cap+. Unlike atomic blocks, these are chunkable — if one
|
|
39
|
+
# alone exceeds +cap+, it gets sliced into +cap+-sized chunks
|
|
40
|
+
# like +:other+ does, never raised.
|
|
41
|
+
CARVE_OUT_BLOCKS = {
|
|
42
|
+
"CJK_Unified_Ideographs" => 0x4E00..0x9FFF,
|
|
43
|
+
"Hangul_Syllables" => 0xAC00..0xD7AF,
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# Every codepoint range recognized as a distinct sub-plane
|
|
47
|
+
# bucket. Atomic blocks first, then carve-out blocks. Used by
|
|
48
|
+
# the bucketing pass to decide what gets its own partition vs.
|
|
49
|
+
# falls into +:other+.
|
|
50
|
+
RECOGNIZED_BLOCKS = ATOMIC_BLOCKS.merge(CARVE_OUT_BLOCKS).freeze
|
|
51
|
+
|
|
20
52
|
# @param cp_map [Hash{Integer=>Object}] codepoint → donor label
|
|
21
53
|
# @param cap [Integer] max codepoints per partition
|
|
22
54
|
# @return [Blueprint]
|
|
@@ -50,20 +82,21 @@ module Fontisan
|
|
|
50
82
|
)
|
|
51
83
|
end
|
|
52
84
|
|
|
53
|
-
# When a plane overflows +cap+, carve it along the
|
|
54
|
-
#
|
|
55
|
-
# outside
|
|
56
|
-
#
|
|
57
|
-
# if a single block alone
|
|
58
|
-
#
|
|
59
|
-
# boundaries to use),
|
|
85
|
+
# When a plane overflows +cap+, carve it along the recognized
|
|
86
|
+
# sub-plane block boundaries. The +:other+ bucket (everything
|
|
87
|
+
# outside +RECOGNIZED_BLOCKS+) and any +CARVE_OUT_BLOCKS+ entries
|
|
88
|
+
# are split into chunks of +cap+. +ATOMIC_BLOCKS+ entries become
|
|
89
|
+
# one partition atomically — if a single atomic block alone
|
|
90
|
+
# exceeds +cap+, we cannot sub-split it (its codepoints are
|
|
91
|
+
# contiguous and we don't have finer-grained boundaries to use),
|
|
92
|
+
# so raise.
|
|
60
93
|
def sub_split_by_block(plane_num, entries, cap)
|
|
61
|
-
buckets =
|
|
94
|
+
buckets = bucket_by_recognized_block(entries)
|
|
62
95
|
partitions = []
|
|
63
96
|
suffix = "a"
|
|
64
97
|
|
|
65
98
|
buckets.each do |label, bucket_entries|
|
|
66
|
-
if
|
|
99
|
+
if atomic_block?(label) && bucket_entries.size > cap
|
|
67
100
|
raise PartitionCapExceededError.new(
|
|
68
101
|
block_label: label,
|
|
69
102
|
actual: bucket_entries.size,
|
|
@@ -83,8 +116,8 @@ module Fontisan
|
|
|
83
116
|
partitions
|
|
84
117
|
end
|
|
85
118
|
|
|
86
|
-
def
|
|
87
|
-
|
|
119
|
+
def atomic_block?(label)
|
|
120
|
+
ATOMIC_BLOCKS.key?(label)
|
|
88
121
|
end
|
|
89
122
|
|
|
90
123
|
# Split +entries+ into sub-arrays of at most +cap+ each.
|
|
@@ -92,17 +125,16 @@ module Fontisan
|
|
|
92
125
|
entries.each_slice(cap).to_a
|
|
93
126
|
end
|
|
94
127
|
|
|
95
|
-
# Bucket entries by which
|
|
96
|
-
# into. Entries outside any known
|
|
97
|
-
# which is then packed into its own partition(s).
|
|
98
|
-
|
|
128
|
+
# Bucket entries by which recognized sub-plane block (or :other)
|
|
129
|
+
# they fall into. Entries outside any known block go into :other,
|
|
130
|
+
# which is then packed into its own partition(s). Order: :other
|
|
131
|
+
# first if non-empty, then recognized blocks in declaration order.
|
|
132
|
+
def bucket_by_recognized_block(entries)
|
|
99
133
|
buckets = { other: [] }
|
|
100
|
-
|
|
101
|
-
buckets[label] = []
|
|
102
|
-
end
|
|
134
|
+
RECOGNIZED_BLOCKS.each_key { |label| buckets[label] = [] }
|
|
103
135
|
|
|
104
136
|
entries.each do |cp, label|
|
|
105
|
-
block =
|
|
137
|
+
block = find_recognized_block(cp)
|
|
106
138
|
if block
|
|
107
139
|
buckets[block] << [cp, label]
|
|
108
140
|
else
|
|
@@ -110,18 +142,16 @@ module Fontisan
|
|
|
110
142
|
end
|
|
111
143
|
end
|
|
112
144
|
|
|
113
|
-
# Drop empty buckets; preserve order (other first if non-empty,
|
|
114
|
-
# then CJK blocks in declaration order).
|
|
115
145
|
ordered = []
|
|
116
146
|
ordered << [:other, buckets[:other]] unless buckets[:other].empty?
|
|
117
|
-
|
|
147
|
+
RECOGNIZED_BLOCKS.each_key do |label|
|
|
118
148
|
ordered << [label, buckets[label]] unless buckets[label].empty?
|
|
119
149
|
end
|
|
120
150
|
ordered
|
|
121
151
|
end
|
|
122
152
|
|
|
123
|
-
def
|
|
124
|
-
|
|
153
|
+
def find_recognized_block(codepoint)
|
|
154
|
+
RECOGNIZED_BLOCKS.find do |_label, range|
|
|
125
155
|
range.include?(codepoint)
|
|
126
156
|
end&.first
|
|
127
157
|
end
|
|
@@ -15,14 +15,49 @@ module Fontisan
|
|
|
15
15
|
# #bitmap_mode. When a source is :cbdt, the Stitcher propagates
|
|
16
16
|
# the raw CBDT/CBLC tables into the output instead of extracting
|
|
17
17
|
# outlines. The glyph data lives in the bitmap tables, not in glyf.
|
|
18
|
+
#
|
|
19
|
+
# = Codepoint remap
|
|
20
|
+
#
|
|
21
|
+
# Some donor fonts ship glyphs at non-canonical codepoints — a
|
|
22
|
+
# keyboard-mapped font whose "A" is at U+0041, or a PUA-allocated
|
|
23
|
+
# pre-Unicode font. Pass a +remap:+ hash at construction to expose
|
|
24
|
+
# those glyphs at their canonical codepoints without mutating the
|
|
25
|
+
# donor font's cmap:
|
|
26
|
+
#
|
|
27
|
+
# Source.new(font, remap: { 0x41 => 0x11DB0 })
|
|
28
|
+
#
|
|
29
|
+
# With a remap set, the source answers +gid_for_codepoint+ for the
|
|
30
|
+
# *target* codepoints (0x11DB0 in the example) by translating them
|
|
31
|
+
# to the donor's *source* codepoints (0x41). Source codepoints not
|
|
32
|
+
# listed in the remap are hidden — only the remapped coverage is
|
|
33
|
+
# exposed. This matches the typical "use this donor for exactly
|
|
34
|
+
# these output characters" intent, and avoids leaking the donor's
|
|
35
|
+
# ASCII/PUA noise into the output.
|
|
18
36
|
class Source
|
|
19
37
|
MAX_COMPOUND_DEPTH = 32
|
|
20
38
|
|
|
21
|
-
attr_reader :font
|
|
39
|
+
attr_reader :font, :remap
|
|
22
40
|
|
|
23
|
-
|
|
41
|
+
# @param font [TrueTypeFont, OpenTypeFont, Ufo::Font] donor font
|
|
42
|
+
# @param remap [Hash{Integer=>Integer}, nil] optional
|
|
43
|
+
# {source_codepoint => target_codepoint}. When present (even
|
|
44
|
+
# empty), source codepoints not listed are hidden from
|
|
45
|
+
# lookups. Pass +nil+ (the default) for the passthrough
|
|
46
|
+
# behavior — every donor codepoint is exposed as-is.
|
|
47
|
+
def initialize(font, remap: nil)
|
|
24
48
|
@font = font
|
|
25
49
|
@bin_data_cache = nil
|
|
50
|
+
|
|
51
|
+
# Distinguish "no remap kwarg" (nil → passthrough) from
|
|
52
|
+
# "explicit empty remap" ({} → drop everything). The latter
|
|
53
|
+
# still flips the source into remapped mode, just with no
|
|
54
|
+
# entries — every donor codepoint gets filtered out.
|
|
55
|
+
return unless remap
|
|
56
|
+
|
|
57
|
+
@remap = remap.to_h.transform_keys(&:to_i).transform_values(&:to_i)
|
|
58
|
+
@inverse_remap = @remap.each_with_object({}) do |(src, target), h|
|
|
59
|
+
h[target] = src
|
|
60
|
+
end
|
|
26
61
|
end
|
|
27
62
|
|
|
28
63
|
# @return [Symbol] :ufo, :ttf, :otf
|
|
@@ -57,12 +92,21 @@ module Fontisan
|
|
|
57
92
|
end
|
|
58
93
|
|
|
59
94
|
# Find the gid for a Unicode codepoint in this source.
|
|
95
|
+
#
|
|
96
|
+
# When +remap+ was set at construction, +codepoint+ is treated as
|
|
97
|
+
# a *target* codepoint — the source codepoint that maps to it via
|
|
98
|
+
# +remap+ is what actually gets looked up. Returns +nil+ for
|
|
99
|
+
# codepoints the remap does not cover.
|
|
100
|
+
#
|
|
60
101
|
# @param codepoint [Integer]
|
|
61
102
|
# @return [Integer, nil]
|
|
62
103
|
def gid_for_codepoint(codepoint)
|
|
104
|
+
src_cp = source_codepoint_for(codepoint)
|
|
105
|
+
return nil unless src_cp
|
|
106
|
+
|
|
63
107
|
case @font
|
|
64
|
-
when Fontisan::Ufo::Font then ufo_gid_for(
|
|
65
|
-
else bin_data_gid_for(
|
|
108
|
+
when Fontisan::Ufo::Font then ufo_gid_for(src_cp)
|
|
109
|
+
else bin_data_gid_for(src_cp)
|
|
66
110
|
end
|
|
67
111
|
end
|
|
68
112
|
|
|
@@ -317,14 +361,51 @@ module Fontisan
|
|
|
317
361
|
end
|
|
318
362
|
|
|
319
363
|
# Add Unicode codepoints from the cmap that map to this gid.
|
|
364
|
+
#
|
|
365
|
+
# When +remap+ was set at construction, the source's raw codepoints
|
|
366
|
+
# are translated through the remap (source→target), and source
|
|
367
|
+
# codepoints not in the remap are dropped. Otherwise the raw cmap
|
|
368
|
+
# codepoints are attached as-is.
|
|
320
369
|
def add_cmap_unicodes(gid, glyph)
|
|
321
|
-
|
|
322
|
-
|
|
370
|
+
effective_codepoints_for_gid(gid).each { |cp| glyph.add_unicode(cp) }
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Translate a caller-facing codepoint to the donor's own codepoint.
|
|
374
|
+
# Without remap, the two are identical. With remap, the caller's
|
|
375
|
+
# codepoint is the *target*; look up the source codepoint that
|
|
376
|
+
# maps to it. Returns +nil+ if no remap entry covers the target.
|
|
377
|
+
def source_codepoint_for(codepoint)
|
|
378
|
+
return codepoint unless @remap
|
|
323
379
|
|
|
324
|
-
|
|
325
|
-
|
|
380
|
+
@inverse_remap[codepoint]
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Codepoints that should be attached to a glyph at the given gid,
|
|
384
|
+
# after applying the remap (if any).
|
|
385
|
+
def effective_codepoints_for_gid(gid)
|
|
386
|
+
raw_cmap = self.raw_cmap
|
|
387
|
+
raw_cmap.each_with_object([]) do |(src_cp, g), cps|
|
|
388
|
+
next unless g == gid
|
|
389
|
+
|
|
390
|
+
if @remap
|
|
391
|
+
target = @remap[src_cp]
|
|
392
|
+
cps << target if target
|
|
393
|
+
else
|
|
394
|
+
cps << src_cp
|
|
395
|
+
end
|
|
326
396
|
end
|
|
327
397
|
end
|
|
398
|
+
|
|
399
|
+
# The raw cmap hash from the donor's table. Always {src_cp => gid};
|
|
400
|
+
# never remapped. UFO sources return {} (no cmap table).
|
|
401
|
+
def raw_cmap
|
|
402
|
+
cmap = @font.table("cmap")
|
|
403
|
+
return {} unless cmap
|
|
404
|
+
|
|
405
|
+
cmap.unicode_mappings || {}
|
|
406
|
+
rescue StandardError
|
|
407
|
+
{}
|
|
408
|
+
end
|
|
328
409
|
end
|
|
329
410
|
end
|
|
330
411
|
end
|
data/lib/fontisan/stitcher.rb
CHANGED
|
@@ -32,6 +32,7 @@ module Fontisan
|
|
|
32
32
|
autoload :GlyphLimit, "fontisan/stitcher/glyph_limit"
|
|
33
33
|
autoload :CollectionResult, "fontisan/stitcher/collection_result"
|
|
34
34
|
autoload :SubfontStats, "fontisan/stitcher/collection_result"
|
|
35
|
+
autoload :FormatMetadata, "fontisan/stitcher/format_metadata"
|
|
35
36
|
autoload :PartitionStrategy, "fontisan/stitcher/partition_strategy"
|
|
36
37
|
|
|
37
38
|
# Internal: pairs a compiled loaded font with its stats so
|
|
@@ -51,8 +52,8 @@ module Fontisan
|
|
|
51
52
|
@deduplicate = deduplicate
|
|
52
53
|
end
|
|
53
54
|
|
|
54
|
-
def add_source(label, font)
|
|
55
|
-
@sources[label.to_sym] = Source.new(font)
|
|
55
|
+
def add_source(label, font, remap: nil)
|
|
56
|
+
@sources[label.to_sym] = Source.new(font, remap: remap)
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
def include_range(range, from:, into:)
|
|
@@ -94,8 +95,8 @@ module Fontisan
|
|
|
94
95
|
target = build_target_for(subfont)
|
|
95
96
|
GlyphLimit.check!(target.glyphs.size, format: format)
|
|
96
97
|
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
metadata = FormatMetadata.resolve(format)
|
|
99
|
+
metadata.compiler_class.new(target).compile(output_path: path)
|
|
99
100
|
|
|
100
101
|
propagate_cbdt_tables(path) if cbdt_source
|
|
101
102
|
path
|
|
@@ -109,7 +110,7 @@ module Fontisan
|
|
|
109
110
|
end
|
|
110
111
|
fonts = compiled.map(&:font)
|
|
111
112
|
|
|
112
|
-
collection_format =
|
|
113
|
+
collection_format = FormatMetadata.resolve(format).collection_format
|
|
113
114
|
Collection::Builder.new(fonts, format: collection_format,
|
|
114
115
|
optimize: true).build_to_file(path)
|
|
115
116
|
|
|
@@ -138,20 +139,6 @@ module Fontisan
|
|
|
138
139
|
cbdts.first
|
|
139
140
|
end
|
|
140
141
|
|
|
141
|
-
def compiler_for(format)
|
|
142
|
-
case format.to_sym
|
|
143
|
-
when :ttf then Ufo::Compile::TtfCompiler
|
|
144
|
-
when :otf then Ufo::Compile::OtfCompiler
|
|
145
|
-
when :otf2 then Ufo::Compile::Otf2Compiler
|
|
146
|
-
else
|
|
147
|
-
raise ArgumentError, "unknown format: #{format.inspect}"
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def collection_format_for(subfont_format)
|
|
152
|
-
subfont_format == :ttf ? :ttc : :otc
|
|
153
|
-
end
|
|
154
|
-
|
|
155
142
|
def build_target_for(subfont_name)
|
|
156
143
|
bindings = @subfonts[subfont_name] || []
|
|
157
144
|
target = Ufo::Font.new
|
|
@@ -161,19 +148,14 @@ module Fontisan
|
|
|
161
148
|
target
|
|
162
149
|
end
|
|
163
150
|
|
|
164
|
-
def compile_subfont_to_loaded_font(subfont_name, format:)
|
|
165
|
-
compile_subfont_with_stats(subfont_name, format: format).font
|
|
166
|
-
end
|
|
167
|
-
|
|
168
151
|
def compile_subfont_with_stats(subfont_name, format:)
|
|
169
152
|
target = build_target_for(subfont_name)
|
|
170
153
|
GlyphLimit.check!(target.glyphs.size, format: format)
|
|
171
154
|
|
|
172
|
-
|
|
155
|
+
metadata = FormatMetadata.resolve(format)
|
|
173
156
|
Dir.mktmpdir do |dir|
|
|
174
|
-
sub_path = File.join(dir, "sub#{subfont_name}#{
|
|
175
|
-
|
|
176
|
-
compiler.new(target).compile(output_path: sub_path)
|
|
157
|
+
sub_path = File.join(dir, "sub#{subfont_name}#{metadata.extension}")
|
|
158
|
+
metadata.compiler_class.new(target).compile(output_path: sub_path)
|
|
177
159
|
propagate_cbdt_tables(sub_path) if cbdt_source
|
|
178
160
|
|
|
179
161
|
loaded = Fontisan::FontLoader.load(sub_path)
|
|
@@ -28,7 +28,7 @@ module Fontisan
|
|
|
28
28
|
# @return [TrueTypeFont, OpenTypeFont] Font instance
|
|
29
29
|
attr_reader :font
|
|
30
30
|
|
|
31
|
-
# @return [Hash] Glyph data map (glyph_id => {outline,
|
|
31
|
+
# @return [Hash] Glyph data map (glyph_id => {outline, codepoints, name, advance})
|
|
32
32
|
attr_reader :glyph_data
|
|
33
33
|
|
|
34
34
|
# @return [Hash] Generation options
|
|
@@ -179,7 +179,7 @@ module Fontisan
|
|
|
179
179
|
|
|
180
180
|
glyph_generator.generate_glyph_xml(
|
|
181
181
|
data[:outline],
|
|
182
|
-
|
|
182
|
+
codepoints: data[:codepoints],
|
|
183
183
|
glyph_name: data[:name],
|
|
184
184
|
advance_width: data[:advance],
|
|
185
185
|
indent: " ",
|
|
@@ -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
|
|
@@ -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
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.10
|
|
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-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|
|
@@ -434,6 +434,7 @@ 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
|