fontisan 0.1.0 → 0.2.1
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/.rubocop_todo.yml +672 -69
- data/Gemfile +1 -0
- data/LICENSE +5 -1
- data/README.adoc +1477 -297
- data/Rakefile +63 -41
- data/benchmark/variation_quick_bench.rb +47 -0
- data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
- data/fontisan.gemspec +4 -1
- data/lib/fontisan/binary/base_record.rb +22 -1
- data/lib/fontisan/cli.rb +364 -4
- data/lib/fontisan/collection/builder.rb +341 -0
- data/lib/fontisan/collection/offset_calculator.rb +227 -0
- data/lib/fontisan/collection/table_analyzer.rb +204 -0
- data/lib/fontisan/collection/table_deduplicator.rb +317 -0
- data/lib/fontisan/collection/writer.rb +306 -0
- data/lib/fontisan/commands/base_command.rb +24 -1
- data/lib/fontisan/commands/convert_command.rb +218 -0
- data/lib/fontisan/commands/export_command.rb +161 -0
- data/lib/fontisan/commands/info_command.rb +40 -6
- data/lib/fontisan/commands/instance_command.rb +286 -0
- data/lib/fontisan/commands/ls_command.rb +113 -0
- data/lib/fontisan/commands/pack_command.rb +241 -0
- data/lib/fontisan/commands/subset_command.rb +245 -0
- data/lib/fontisan/commands/unpack_command.rb +338 -0
- data/lib/fontisan/commands/validate_command.rb +203 -0
- data/lib/fontisan/commands/variable_command.rb +30 -1
- data/lib/fontisan/config/collection_settings.yml +56 -0
- data/lib/fontisan/config/conversion_matrix.yml +212 -0
- data/lib/fontisan/config/export_settings.yml +66 -0
- data/lib/fontisan/config/subset_profiles.yml +100 -0
- data/lib/fontisan/config/svg_settings.yml +60 -0
- data/lib/fontisan/config/validation_rules.yml +149 -0
- data/lib/fontisan/config/variable_settings.yml +99 -0
- data/lib/fontisan/config/woff2_settings.yml +77 -0
- data/lib/fontisan/constants.rb +79 -0
- data/lib/fontisan/converters/conversion_strategy.rb +96 -0
- data/lib/fontisan/converters/format_converter.rb +408 -0
- data/lib/fontisan/converters/outline_converter.rb +998 -0
- data/lib/fontisan/converters/svg_generator.rb +244 -0
- data/lib/fontisan/converters/table_copier.rb +117 -0
- data/lib/fontisan/converters/woff2_encoder.rb +416 -0
- data/lib/fontisan/converters/woff_writer.rb +391 -0
- data/lib/fontisan/error.rb +203 -0
- data/lib/fontisan/export/exporter.rb +262 -0
- data/lib/fontisan/export/table_serializer.rb +255 -0
- data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
- data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
- data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
- data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
- data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
- data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
- data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
- data/lib/fontisan/export/ttx_generator.rb +527 -0
- data/lib/fontisan/export/ttx_parser.rb +300 -0
- data/lib/fontisan/font_loader.rb +122 -15
- data/lib/fontisan/font_writer.rb +302 -0
- data/lib/fontisan/formatters/text_formatter.rb +102 -0
- data/lib/fontisan/glyph_accessor.rb +503 -0
- data/lib/fontisan/hints/hint_converter.rb +310 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +266 -0
- data/lib/fontisan/hints/postscript_hint_extractor.rb +354 -0
- data/lib/fontisan/hints/truetype_hint_applier.rb +117 -0
- data/lib/fontisan/hints/truetype_hint_extractor.rb +289 -0
- data/lib/fontisan/loading_modes.rb +115 -0
- data/lib/fontisan/metrics_calculator.rb +277 -0
- data/lib/fontisan/models/collection_font_summary.rb +52 -0
- data/lib/fontisan/models/collection_info.rb +76 -0
- data/lib/fontisan/models/collection_list_info.rb +37 -0
- data/lib/fontisan/models/font_export.rb +158 -0
- data/lib/fontisan/models/font_summary.rb +48 -0
- data/lib/fontisan/models/glyph_outline.rb +343 -0
- data/lib/fontisan/models/hint.rb +405 -0
- data/lib/fontisan/models/outline.rb +664 -0
- data/lib/fontisan/models/table_sharing_info.rb +40 -0
- data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
- data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
- data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
- data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
- data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
- data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
- data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
- data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
- data/lib/fontisan/models/ttx/ttfont.rb +49 -0
- data/lib/fontisan/models/validation_report.rb +203 -0
- data/lib/fontisan/open_type_collection.rb +156 -2
- data/lib/fontisan/open_type_font.rb +321 -19
- data/lib/fontisan/open_type_font_extensions.rb +54 -0
- data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
- data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
- data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
- data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
- data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
- data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
- data/lib/fontisan/outline_extractor.rb +423 -0
- data/lib/fontisan/pipeline/format_detector.rb +249 -0
- data/lib/fontisan/pipeline/output_writer.rb +154 -0
- data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
- data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
- data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
- data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
- data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
- data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
- data/lib/fontisan/subset/builder.rb +268 -0
- data/lib/fontisan/subset/glyph_mapping.rb +215 -0
- data/lib/fontisan/subset/options.rb +142 -0
- data/lib/fontisan/subset/profile.rb +152 -0
- data/lib/fontisan/subset/table_subsetter.rb +461 -0
- data/lib/fontisan/svg/font_face_generator.rb +278 -0
- data/lib/fontisan/svg/font_generator.rb +264 -0
- data/lib/fontisan/svg/glyph_generator.rb +168 -0
- data/lib/fontisan/svg/view_box_calculator.rb +137 -0
- data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
- data/lib/fontisan/tables/cff/charset.rb +282 -0
- data/lib/fontisan/tables/cff/charstring.rb +934 -0
- data/lib/fontisan/tables/cff/charstring_builder.rb +356 -0
- data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
- data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
- data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
- data/lib/fontisan/tables/cff/dict.rb +351 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +257 -0
- data/lib/fontisan/tables/cff/encoding.rb +274 -0
- data/lib/fontisan/tables/cff/header.rb +102 -0
- data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
- data/lib/fontisan/tables/cff/index.rb +237 -0
- data/lib/fontisan/tables/cff/index_builder.rb +170 -0
- data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
- data/lib/fontisan/tables/cff/private_dict.rb +284 -0
- data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
- data/lib/fontisan/tables/cff/table_builder.rb +221 -0
- data/lib/fontisan/tables/cff/top_dict.rb +236 -0
- data/lib/fontisan/tables/cff.rb +489 -0
- data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
- data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
- data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
- data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
- data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
- data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
- data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
- data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
- data/lib/fontisan/tables/cff2.rb +346 -0
- data/lib/fontisan/tables/cvar.rb +203 -0
- data/lib/fontisan/tables/fvar.rb +2 -2
- data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
- data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
- data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
- data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
- data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
- data/lib/fontisan/tables/glyf.rb +235 -0
- data/lib/fontisan/tables/gvar.rb +231 -0
- data/lib/fontisan/tables/hhea.rb +124 -0
- data/lib/fontisan/tables/hmtx.rb +287 -0
- data/lib/fontisan/tables/hvar.rb +191 -0
- data/lib/fontisan/tables/loca.rb +322 -0
- data/lib/fontisan/tables/maxp.rb +192 -0
- data/lib/fontisan/tables/mvar.rb +185 -0
- data/lib/fontisan/tables/name.rb +99 -30
- data/lib/fontisan/tables/variation_common.rb +346 -0
- data/lib/fontisan/tables/vvar.rb +234 -0
- data/lib/fontisan/true_type_collection.rb +156 -2
- data/lib/fontisan/true_type_font.rb +321 -20
- data/lib/fontisan/true_type_font_extensions.rb +54 -0
- data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +60 -0
- data/lib/fontisan/utils/thread_pool.rb +134 -0
- data/lib/fontisan/validation/checksum_validator.rb +170 -0
- data/lib/fontisan/validation/consistency_validator.rb +197 -0
- data/lib/fontisan/validation/structure_validator.rb +198 -0
- data/lib/fontisan/validation/table_validator.rb +158 -0
- data/lib/fontisan/validation/validator.rb +152 -0
- data/lib/fontisan/validation/variable_font_validator.rb +218 -0
- data/lib/fontisan/variable/axis_normalizer.rb +215 -0
- data/lib/fontisan/variable/delta_applicator.rb +313 -0
- data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
- data/lib/fontisan/variable/instancer.rb +344 -0
- data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
- data/lib/fontisan/variable/region_matcher.rb +208 -0
- data/lib/fontisan/variable/static_font_builder.rb +213 -0
- data/lib/fontisan/variable/table_updater.rb +219 -0
- data/lib/fontisan/variation/blend_applier.rb +199 -0
- data/lib/fontisan/variation/cache.rb +298 -0
- data/lib/fontisan/variation/cache_key_builder.rb +162 -0
- data/lib/fontisan/variation/converter.rb +375 -0
- data/lib/fontisan/variation/data_extractor.rb +86 -0
- data/lib/fontisan/variation/delta_applier.rb +266 -0
- data/lib/fontisan/variation/delta_parser.rb +228 -0
- data/lib/fontisan/variation/inspector.rb +275 -0
- data/lib/fontisan/variation/instance_generator.rb +273 -0
- data/lib/fontisan/variation/instance_writer.rb +341 -0
- data/lib/fontisan/variation/interpolator.rb +231 -0
- data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
- data/lib/fontisan/variation/optimizer.rb +418 -0
- data/lib/fontisan/variation/parallel_generator.rb +150 -0
- data/lib/fontisan/variation/region_matcher.rb +221 -0
- data/lib/fontisan/variation/subsetter.rb +463 -0
- data/lib/fontisan/variation/table_accessor.rb +105 -0
- data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
- data/lib/fontisan/variation/validator.rb +345 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
- data/lib/fontisan/variation/variation_context.rb +211 -0
- data/lib/fontisan/variation/variation_preserver.rb +288 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/version.rb.orig +9 -0
- data/lib/fontisan/woff2/directory.rb +257 -0
- data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
- data/lib/fontisan/woff2/header.rb +101 -0
- data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
- data/lib/fontisan/woff2/table_transformer.rb +163 -0
- data/lib/fontisan/woff2_font.rb +717 -0
- data/lib/fontisan/woff_font.rb +488 -0
- data/lib/fontisan.rb +132 -0
- data/scripts/compare_stack_aware.rb +187 -0
- data/scripts/measure_optimization.rb +141 -0
- metadata +234 -4
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "options"
|
|
4
|
+
require_relative "profile"
|
|
5
|
+
require_relative "glyph_mapping"
|
|
6
|
+
require_relative "table_subsetter"
|
|
7
|
+
require_relative "../font_writer"
|
|
8
|
+
|
|
9
|
+
module Fontisan
|
|
10
|
+
module Subset
|
|
11
|
+
# Main font subsetting engine
|
|
12
|
+
#
|
|
13
|
+
# The [`Builder`](lib/fontisan/subset/builder.rb) class orchestrates the entire
|
|
14
|
+
# subsetting process:
|
|
15
|
+
# 1. Validates input parameters
|
|
16
|
+
# 2. Calculates glyph closure (including composite dependencies)
|
|
17
|
+
# 3. Builds glyph ID mapping (old GID → new GID)
|
|
18
|
+
# 4. Subsets each table according to the selected profile
|
|
19
|
+
# 5. Assembles the final subset font binary
|
|
20
|
+
#
|
|
21
|
+
# The subsetting process ensures that .notdef (GID 0) is always included
|
|
22
|
+
# as the first glyph, as required by the OpenType specification.
|
|
23
|
+
#
|
|
24
|
+
# @example Basic subsetting
|
|
25
|
+
# font = Fontisan::TrueTypeFont.from_file('font.ttf')
|
|
26
|
+
# builder = Fontisan::Subset::Builder.new(
|
|
27
|
+
# font,
|
|
28
|
+
# [0, 65, 66, 67], # .notdef, A, B, C
|
|
29
|
+
# Options.new(profile: 'pdf')
|
|
30
|
+
# )
|
|
31
|
+
# subset_data = builder.build
|
|
32
|
+
#
|
|
33
|
+
# @example Subsetting with retain_gids
|
|
34
|
+
# options = Options.new(profile: 'pdf', retain_gids: true)
|
|
35
|
+
# builder = Fontisan::Subset::Builder.new(font, glyph_ids, options)
|
|
36
|
+
# subset_data = builder.build
|
|
37
|
+
#
|
|
38
|
+
# @example Web subsetting with dropped hints
|
|
39
|
+
# options = Options.new(profile: 'web', drop_hints: true, drop_names: true)
|
|
40
|
+
# builder = Fontisan::Subset::Builder.new(font, glyph_ids, options)
|
|
41
|
+
# subset_data = builder.build
|
|
42
|
+
#
|
|
43
|
+
# Reference: [`docs/ttfunk-feature-analysis.md:455-492`](docs/ttfunk-feature-analysis.md:455)
|
|
44
|
+
class Builder
|
|
45
|
+
# Font instance to subset
|
|
46
|
+
# @return [TrueTypeFont, OpenTypeFont]
|
|
47
|
+
attr_reader :font
|
|
48
|
+
|
|
49
|
+
# Base set of glyph IDs requested for subsetting
|
|
50
|
+
# @return [Array<Integer>]
|
|
51
|
+
attr_reader :glyph_ids
|
|
52
|
+
|
|
53
|
+
# Subsetting options
|
|
54
|
+
# @return [Options]
|
|
55
|
+
attr_reader :options
|
|
56
|
+
|
|
57
|
+
# Complete set of glyph IDs after closure calculation
|
|
58
|
+
# @return [Set<Integer>]
|
|
59
|
+
attr_reader :closure
|
|
60
|
+
|
|
61
|
+
# Glyph ID mapping (old GID → new GID)
|
|
62
|
+
# @return [GlyphMapping]
|
|
63
|
+
attr_reader :mapping
|
|
64
|
+
|
|
65
|
+
# Initialize a new subsetting builder
|
|
66
|
+
#
|
|
67
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font to subset
|
|
68
|
+
# @param glyph_ids [Array<Integer>] Base glyph IDs to include
|
|
69
|
+
# @param options [Options, Hash] Subsetting options
|
|
70
|
+
# @raise [ArgumentError] If parameters are invalid
|
|
71
|
+
#
|
|
72
|
+
# @example
|
|
73
|
+
# builder = Builder.new(font, [0, 65, 66], Options.new(profile: 'pdf'))
|
|
74
|
+
def initialize(font, glyph_ids, options = {})
|
|
75
|
+
@font = font
|
|
76
|
+
@glyph_ids = Array(glyph_ids)
|
|
77
|
+
@options = options.is_a?(Options) ? options : Options.new(options)
|
|
78
|
+
@closure = nil
|
|
79
|
+
@mapping = nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Build the subset font
|
|
83
|
+
#
|
|
84
|
+
# This is the main entry point that performs the entire subsetting
|
|
85
|
+
# workflow:
|
|
86
|
+
# 1. Validates all input parameters
|
|
87
|
+
# 2. Calculates the glyph closure (composite dependencies)
|
|
88
|
+
# 3. Builds the glyph ID mapping
|
|
89
|
+
# 4. Subsets all required tables
|
|
90
|
+
# 5. Assembles the final font binary
|
|
91
|
+
#
|
|
92
|
+
# @return [String] Binary data of the subset font
|
|
93
|
+
# @raise [ArgumentError] If validation fails
|
|
94
|
+
# @raise [Fontisan::SubsettingError] If subsetting fails
|
|
95
|
+
#
|
|
96
|
+
# @example
|
|
97
|
+
# subset_binary = builder.build
|
|
98
|
+
# File.binwrite('subset.ttf', subset_binary)
|
|
99
|
+
def build
|
|
100
|
+
validate_input!
|
|
101
|
+
calculate_closure
|
|
102
|
+
build_mapping
|
|
103
|
+
tables = subset_tables
|
|
104
|
+
assemble_font(tables)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# Validate input parameters
|
|
110
|
+
#
|
|
111
|
+
# Ensures that the font, glyph IDs, and options are all valid for
|
|
112
|
+
# subsetting. Checks that required tables exist and that glyph IDs
|
|
113
|
+
# are within valid range.
|
|
114
|
+
#
|
|
115
|
+
# @raise [ArgumentError] If validation fails
|
|
116
|
+
def validate_input!
|
|
117
|
+
raise ArgumentError, "Font cannot be nil" if font.nil?
|
|
118
|
+
|
|
119
|
+
unless font.respond_to?(:table)
|
|
120
|
+
raise ArgumentError, "Font must respond to :table method"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Validate options
|
|
124
|
+
options.validate!
|
|
125
|
+
|
|
126
|
+
# Ensure we have at least one glyph ID
|
|
127
|
+
if glyph_ids.empty?
|
|
128
|
+
raise ArgumentError, "At least one glyph ID must be provided"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Validate that required tables exist
|
|
132
|
+
validate_required_tables!
|
|
133
|
+
|
|
134
|
+
# Validate glyph IDs are within range
|
|
135
|
+
validate_glyph_ids!
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Validate that required tables exist in the font
|
|
139
|
+
#
|
|
140
|
+
# @raise [Fontisan::MissingTableError] If required tables are missing
|
|
141
|
+
def validate_required_tables!
|
|
142
|
+
required = %w[head maxp]
|
|
143
|
+
required.each do |tag|
|
|
144
|
+
table = font.table(tag)
|
|
145
|
+
next if table
|
|
146
|
+
|
|
147
|
+
raise Fontisan::MissingTableError,
|
|
148
|
+
"Required table '#{tag}' not found in font"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Validate that all glyph IDs are within valid range
|
|
153
|
+
#
|
|
154
|
+
# @raise [ArgumentError] If any glyph ID is invalid
|
|
155
|
+
def validate_glyph_ids!
|
|
156
|
+
maxp = font.table("maxp")
|
|
157
|
+
num_glyphs = maxp.num_glyphs
|
|
158
|
+
|
|
159
|
+
glyph_ids.each do |gid|
|
|
160
|
+
if gid.nil? || gid.negative?
|
|
161
|
+
raise ArgumentError, "Invalid glyph ID: #{gid.inspect}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
if gid >= num_glyphs
|
|
165
|
+
raise ArgumentError,
|
|
166
|
+
"Glyph ID #{gid} exceeds font's glyph count " \
|
|
167
|
+
"(#{num_glyphs})"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Calculate glyph closure
|
|
173
|
+
#
|
|
174
|
+
# Uses [`GlyphAccessor`](lib/fontisan/glyph_accessor.rb) to recursively
|
|
175
|
+
# collect all glyphs needed, including component glyphs referenced by
|
|
176
|
+
# composite glyphs. Always ensures GID 0 (.notdef) is included.
|
|
177
|
+
#
|
|
178
|
+
# The closure is stored in the `@closure` instance variable as a Set.
|
|
179
|
+
def calculate_closure
|
|
180
|
+
accessor = Fontisan::GlyphAccessor.new(font)
|
|
181
|
+
|
|
182
|
+
# Ensure .notdef (GID 0) is included if specified in options
|
|
183
|
+
base_gids = glyph_ids.dup
|
|
184
|
+
base_gids.unshift(0) if options.include_notdef && !base_gids.include?(0)
|
|
185
|
+
|
|
186
|
+
# Calculate closure using GlyphAccessor
|
|
187
|
+
@closure = accessor.closure_for(base_gids)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Build glyph mapping
|
|
191
|
+
#
|
|
192
|
+
# Creates a [`GlyphMapping`](lib/fontisan/subset/glyph_mapping.rb)
|
|
193
|
+
# object that maps old glyph IDs to new glyph IDs. The mapping respects
|
|
194
|
+
# the `retain_gids` option:
|
|
195
|
+
# - Compact mode (retain_gids: false): Sequential renumbering
|
|
196
|
+
# - Retain mode (retain_gids: true): Preserve original GIDs
|
|
197
|
+
#
|
|
198
|
+
# The mapping is stored in the `@mapping` instance variable.
|
|
199
|
+
def build_mapping
|
|
200
|
+
@mapping = GlyphMapping.new(
|
|
201
|
+
closure.to_a,
|
|
202
|
+
retain_gids: options.retain_gids,
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Subset all tables according to profile
|
|
207
|
+
#
|
|
208
|
+
# For each table specified in the subsetting profile, performs
|
|
209
|
+
# table-specific subsetting operations using [`TableSubsetter`](lib/fontisan/subset/table_subsetter.rb).
|
|
210
|
+
# Tables not in the profile are excluded from the subset font.
|
|
211
|
+
#
|
|
212
|
+
# @return [Hash<String, String>] Hash of table tag => binary data
|
|
213
|
+
# @raise [Fontisan::SubsettingError] If table subsetting fails
|
|
214
|
+
def subset_tables
|
|
215
|
+
profile_tables = Profile.for_name(options.profile)
|
|
216
|
+
subset = {}
|
|
217
|
+
|
|
218
|
+
# Create table subsetter
|
|
219
|
+
subsetter = TableSubsetter.new(font, mapping, options)
|
|
220
|
+
|
|
221
|
+
profile_tables.each do |tag|
|
|
222
|
+
table = font.table(tag)
|
|
223
|
+
next unless table
|
|
224
|
+
|
|
225
|
+
begin
|
|
226
|
+
subset[tag] = subsetter.subset_table(tag, table)
|
|
227
|
+
rescue StandardError => e
|
|
228
|
+
raise Fontisan::SubsettingError,
|
|
229
|
+
"Failed to subset table '#{tag}': #{e.message}"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
subset
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Assemble final font
|
|
237
|
+
#
|
|
238
|
+
# Builds the complete font binary from subset tables, including:
|
|
239
|
+
# - Offset table (font directory)
|
|
240
|
+
# - Table directory entries
|
|
241
|
+
# - Table data
|
|
242
|
+
# - Proper padding and checksums
|
|
243
|
+
#
|
|
244
|
+
# @param tables [Hash<String, String>] Table tag => binary data
|
|
245
|
+
# @return [String] Complete font binary
|
|
246
|
+
def assemble_font(tables)
|
|
247
|
+
# Determine sfnt version based on font type
|
|
248
|
+
sfnt_version = determine_sfnt_version(tables)
|
|
249
|
+
|
|
250
|
+
# Use FontWriter to assemble the complete font
|
|
251
|
+
FontWriter.write_font(tables, sfnt_version: sfnt_version)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Determine the sfnt version for the font
|
|
255
|
+
#
|
|
256
|
+
# @param tables [Hash<String, String>] Table tag => binary data
|
|
257
|
+
# @return [Integer] sfnt version number
|
|
258
|
+
def determine_sfnt_version(tables)
|
|
259
|
+
# If font has CFF or CFF2 table, use OpenType version
|
|
260
|
+
if tables.key?("CFF ") || tables.key?("CFF2")
|
|
261
|
+
0x4F54544F # 'OTTO' for OpenType/CFF
|
|
262
|
+
else
|
|
263
|
+
0x00010000 # 1.0 for TrueType
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Subset
|
|
5
|
+
# Glyph ID mapping management
|
|
6
|
+
#
|
|
7
|
+
# This class manages the mapping between original glyph IDs (GIDs) in the
|
|
8
|
+
# source font and new GIDs in the subset font. It supports two modes:
|
|
9
|
+
#
|
|
10
|
+
# 1. Compact mode (retain_gids: false): Glyphs are renumbered sequentially,
|
|
11
|
+
# eliminating gaps from removed glyphs. This produces smaller fonts.
|
|
12
|
+
#
|
|
13
|
+
# 2. Retain mode (retain_gids: true): Original glyph IDs are preserved,
|
|
14
|
+
# with removed glyphs leaving empty slots. This maintains glyph
|
|
15
|
+
# references but produces larger fonts.
|
|
16
|
+
#
|
|
17
|
+
# @example Compact mode (default)
|
|
18
|
+
# mapping = Fontisan::Subset::GlyphMapping.new([0, 5, 10, 15])
|
|
19
|
+
# mapping.new_id(5) # => 1
|
|
20
|
+
# mapping.new_id(10) # => 2
|
|
21
|
+
# mapping.size # => 4
|
|
22
|
+
#
|
|
23
|
+
# @example Retain mode
|
|
24
|
+
# mapping = Fontisan::Subset::GlyphMapping.new([0, 5, 10, 15], retain_gids: true)
|
|
25
|
+
# mapping.new_id(5) # => 5
|
|
26
|
+
# mapping.new_id(10) # => 10
|
|
27
|
+
# mapping.size # => 16 (0..15)
|
|
28
|
+
#
|
|
29
|
+
# @example Reverse lookup
|
|
30
|
+
# mapping = Fontisan::Subset::GlyphMapping.new([0, 5, 10])
|
|
31
|
+
# mapping.old_id(1) # => 5
|
|
32
|
+
class GlyphMapping
|
|
33
|
+
# @return [Hash<Integer, Integer>] mapping from old GIDs to new GIDs
|
|
34
|
+
attr_reader :old_to_new
|
|
35
|
+
|
|
36
|
+
# @return [Hash<Integer, Integer>] mapping from new GIDs to old GIDs
|
|
37
|
+
attr_reader :new_to_old
|
|
38
|
+
|
|
39
|
+
# @return [Boolean] whether original GIDs are retained
|
|
40
|
+
attr_reader :retain_gids
|
|
41
|
+
|
|
42
|
+
# Initialize glyph mapping
|
|
43
|
+
#
|
|
44
|
+
# @param old_glyph_ids [Array<Integer>] array of glyph IDs to include
|
|
45
|
+
# in the subset, typically sorted
|
|
46
|
+
# @param retain_gids [Boolean] whether to preserve original glyph IDs
|
|
47
|
+
#
|
|
48
|
+
# @example Create compact mapping
|
|
49
|
+
# mapping = GlyphMapping.new([0, 3, 5, 10])
|
|
50
|
+
#
|
|
51
|
+
# @example Create mapping that retains GIDs
|
|
52
|
+
# mapping = GlyphMapping.new([0, 3, 5, 10], retain_gids: true)
|
|
53
|
+
def initialize(old_glyph_ids, retain_gids: false)
|
|
54
|
+
@old_to_new = {}
|
|
55
|
+
@new_to_old = {}
|
|
56
|
+
@retain_gids = retain_gids
|
|
57
|
+
|
|
58
|
+
build_mappings(old_glyph_ids)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Get new glyph ID for an old glyph ID
|
|
62
|
+
#
|
|
63
|
+
# @param old_id [Integer] original glyph ID
|
|
64
|
+
# @return [Integer, nil] new glyph ID, or nil if not in subset
|
|
65
|
+
#
|
|
66
|
+
# @example
|
|
67
|
+
# mapping = GlyphMapping.new([0, 5, 10])
|
|
68
|
+
# mapping.new_id(5) # => 1
|
|
69
|
+
# mapping.new_id(99) # => nil
|
|
70
|
+
def new_id(old_id)
|
|
71
|
+
old_to_new[old_id]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Get old glyph ID for a new glyph ID
|
|
75
|
+
#
|
|
76
|
+
# @param new_id [Integer] new glyph ID in subset
|
|
77
|
+
# @return [Integer, nil] original glyph ID, or nil if invalid
|
|
78
|
+
#
|
|
79
|
+
# @example
|
|
80
|
+
# mapping = GlyphMapping.new([0, 5, 10])
|
|
81
|
+
# mapping.old_id(1) # => 5
|
|
82
|
+
# mapping.old_id(99) # => nil
|
|
83
|
+
def old_id(new_id)
|
|
84
|
+
new_to_old[new_id]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Get number of glyphs in the subset
|
|
88
|
+
#
|
|
89
|
+
# In compact mode, this is the number of included glyphs.
|
|
90
|
+
# In retain mode, this is the highest old GID + 1.
|
|
91
|
+
#
|
|
92
|
+
# @return [Integer] number of glyphs
|
|
93
|
+
#
|
|
94
|
+
# @example Compact mode
|
|
95
|
+
# mapping = GlyphMapping.new([0, 5, 10])
|
|
96
|
+
# mapping.size # => 3
|
|
97
|
+
#
|
|
98
|
+
# @example Retain mode
|
|
99
|
+
# mapping = GlyphMapping.new([0, 5, 10], retain_gids: true)
|
|
100
|
+
# mapping.size # => 11 (0..10)
|
|
101
|
+
def size
|
|
102
|
+
new_to_old.size
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Check if a glyph is included in the subset
|
|
106
|
+
#
|
|
107
|
+
# @param old_id [Integer] original glyph ID to check
|
|
108
|
+
# @return [Boolean] true if glyph is in subset
|
|
109
|
+
#
|
|
110
|
+
# @example
|
|
111
|
+
# mapping = GlyphMapping.new([0, 5, 10])
|
|
112
|
+
# mapping.include?(5) # => true
|
|
113
|
+
# mapping.include?(99) # => false
|
|
114
|
+
def include?(old_id)
|
|
115
|
+
old_to_new.key?(old_id)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get array of all old glyph IDs in subset
|
|
119
|
+
#
|
|
120
|
+
# @return [Array<Integer>] sorted array of old glyph IDs
|
|
121
|
+
#
|
|
122
|
+
# @example
|
|
123
|
+
# mapping = GlyphMapping.new([10, 0, 5])
|
|
124
|
+
# mapping.old_ids # => [0, 5, 10]
|
|
125
|
+
def old_ids
|
|
126
|
+
old_to_new.keys.sort
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Get array of all new glyph IDs in subset
|
|
130
|
+
#
|
|
131
|
+
# @return [Array<Integer>] sorted array of new glyph IDs
|
|
132
|
+
#
|
|
133
|
+
# @example
|
|
134
|
+
# mapping = GlyphMapping.new([0, 5, 10])
|
|
135
|
+
# mapping.new_ids # => [0, 1, 2]
|
|
136
|
+
def new_ids
|
|
137
|
+
new_to_old.keys.sort
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Iterate over all glyph mappings
|
|
141
|
+
#
|
|
142
|
+
# Yields old_id and new_id pairs in order of old glyph IDs.
|
|
143
|
+
#
|
|
144
|
+
# @yield [old_id, new_id] each glyph mapping
|
|
145
|
+
# @yieldparam old_id [Integer] original glyph ID
|
|
146
|
+
# @yieldparam new_id [Integer] new glyph ID
|
|
147
|
+
#
|
|
148
|
+
# @example
|
|
149
|
+
# mapping = GlyphMapping.new([0, 5, 10])
|
|
150
|
+
# mapping.each do |old_id, new_id|
|
|
151
|
+
# puts "#{old_id} => #{new_id}"
|
|
152
|
+
# end
|
|
153
|
+
# # Output:
|
|
154
|
+
# # 0 => 0
|
|
155
|
+
# # 5 => 1
|
|
156
|
+
# # 10 => 2
|
|
157
|
+
def each
|
|
158
|
+
return enum_for(:each) unless block_given?
|
|
159
|
+
|
|
160
|
+
old_ids.each do |old_id|
|
|
161
|
+
yield old_id, old_to_new[old_id]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
# Build the bidirectional mapping tables
|
|
168
|
+
#
|
|
169
|
+
# @param old_glyph_ids [Array<Integer>] glyph IDs to map
|
|
170
|
+
def build_mappings(old_glyph_ids)
|
|
171
|
+
if retain_gids
|
|
172
|
+
build_retained_mappings(old_glyph_ids)
|
|
173
|
+
else
|
|
174
|
+
build_compact_mappings(old_glyph_ids)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Build mappings in compact mode
|
|
179
|
+
#
|
|
180
|
+
# Assigns sequential new GIDs starting from 0, preserving the order
|
|
181
|
+
# of old GIDs.
|
|
182
|
+
#
|
|
183
|
+
# @param old_glyph_ids [Array<Integer>] glyph IDs to map
|
|
184
|
+
def build_compact_mappings(old_glyph_ids)
|
|
185
|
+
sorted_ids = old_glyph_ids.sort.uniq
|
|
186
|
+
sorted_ids.each_with_index do |old_id, new_id|
|
|
187
|
+
old_to_new[old_id] = new_id
|
|
188
|
+
new_to_old[new_id] = old_id
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Build mappings in retain GID mode
|
|
193
|
+
#
|
|
194
|
+
# Preserves original GIDs, creating empty slots for removed glyphs.
|
|
195
|
+
#
|
|
196
|
+
# @param old_glyph_ids [Array<Integer>] glyph IDs to map
|
|
197
|
+
def build_retained_mappings(old_glyph_ids)
|
|
198
|
+
sorted_ids = old_glyph_ids.sort.uniq
|
|
199
|
+
max_id = sorted_ids.max || 0
|
|
200
|
+
|
|
201
|
+
# Map each glyph to itself
|
|
202
|
+
sorted_ids.each do |old_id|
|
|
203
|
+
old_to_new[old_id] = old_id
|
|
204
|
+
new_to_old[old_id] = old_id
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Fill in empty slots for removed glyphs with nil mappings
|
|
208
|
+
# This ensures size calculation includes the empty slots
|
|
209
|
+
(0..max_id).each do |gid|
|
|
210
|
+
new_to_old[gid] ||= nil
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Subset
|
|
7
|
+
# Subsetting configuration class
|
|
8
|
+
#
|
|
9
|
+
# This class defines all available options for font subsetting operations.
|
|
10
|
+
# It provides sensible defaults for various subsetting scenarios and uses
|
|
11
|
+
# Lutaml::Model for serialization support.
|
|
12
|
+
#
|
|
13
|
+
# @example Create default PDF subsetting options
|
|
14
|
+
# options = Fontisan::Subset::Options.new
|
|
15
|
+
# options.profile # => "pdf"
|
|
16
|
+
# options.drop_hints # => false
|
|
17
|
+
#
|
|
18
|
+
# @example Create custom web subsetting options
|
|
19
|
+
# options = Fontisan::Subset::Options.new(
|
|
20
|
+
# profile: "web",
|
|
21
|
+
# drop_hints: true,
|
|
22
|
+
# unicode_ranges: false
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
# @example Retain original glyph IDs
|
|
26
|
+
# options = Fontisan::Subset::Options.new(retain_gids: true)
|
|
27
|
+
class Options < Lutaml::Model::Serializable
|
|
28
|
+
# Subsetting profile name (pdf, web, minimal, or custom)
|
|
29
|
+
#
|
|
30
|
+
# @return [String] the profile name
|
|
31
|
+
attribute :profile, :string, default: -> { "pdf" }
|
|
32
|
+
|
|
33
|
+
# Whether to drop hinting instructions
|
|
34
|
+
#
|
|
35
|
+
# Hinting improves text rendering at small sizes but increases file size.
|
|
36
|
+
# Web fonts typically don't need hints due to modern rendering engines.
|
|
37
|
+
#
|
|
38
|
+
# @return [Boolean] true to drop hints, false to retain them
|
|
39
|
+
attribute :drop_hints, :boolean, default: -> { false }
|
|
40
|
+
|
|
41
|
+
# Whether to drop glyph names from the post table
|
|
42
|
+
#
|
|
43
|
+
# Glyph names are useful for debugging but not required for rendering.
|
|
44
|
+
# Dropping them reduces file size.
|
|
45
|
+
#
|
|
46
|
+
# @return [Boolean] true to drop names, false to retain them
|
|
47
|
+
attribute :drop_names, :boolean, default: -> { false }
|
|
48
|
+
|
|
49
|
+
# Whether to prune OS/2 Unicode ranges
|
|
50
|
+
#
|
|
51
|
+
# Updates the OS/2 table's Unicode range bits to reflect only the
|
|
52
|
+
# glyphs present in the subset.
|
|
53
|
+
#
|
|
54
|
+
# @return [Boolean] true to prune ranges, false to keep original
|
|
55
|
+
attribute :unicode_ranges, :boolean, default: -> { true }
|
|
56
|
+
|
|
57
|
+
# Whether to retain original glyph IDs
|
|
58
|
+
#
|
|
59
|
+
# When true, removed glyphs leave empty slots in the glyf table,
|
|
60
|
+
# preserving original GID assignments. When false, glyphs are
|
|
61
|
+
# compacted to eliminate gaps.
|
|
62
|
+
#
|
|
63
|
+
# @return [Boolean] true to retain GIDs, false to compact
|
|
64
|
+
attribute :retain_gids, :boolean, default: -> { false }
|
|
65
|
+
|
|
66
|
+
# Whether to include the .notdef glyph
|
|
67
|
+
#
|
|
68
|
+
# The .notdef glyph is displayed for missing characters. It is
|
|
69
|
+
# typically required by font specifications.
|
|
70
|
+
#
|
|
71
|
+
# @return [Boolean] true to include .notdef, false to exclude
|
|
72
|
+
attribute :include_notdef, :boolean, default: -> { true }
|
|
73
|
+
|
|
74
|
+
# Whether to include the .null glyph
|
|
75
|
+
#
|
|
76
|
+
# The .null glyph (U+0000) is sometimes used for control purposes.
|
|
77
|
+
#
|
|
78
|
+
# @return [Boolean] true to include .null, false to exclude
|
|
79
|
+
attribute :include_null, :boolean, default: -> { false }
|
|
80
|
+
|
|
81
|
+
# OpenType features to retain in the subset
|
|
82
|
+
#
|
|
83
|
+
# An empty array means all features are retained. Specify feature
|
|
84
|
+
# tags (e.g., ['liga', 'kern']) to keep only those features.
|
|
85
|
+
#
|
|
86
|
+
# @return [Array<String>] array of feature tags to retain
|
|
87
|
+
attribute :features, :string, collection: true, default: -> { [] }
|
|
88
|
+
|
|
89
|
+
# Script tags to retain in the subset
|
|
90
|
+
#
|
|
91
|
+
# An array containing "*" means all scripts are retained. Specify
|
|
92
|
+
# script tags (e.g., ['latn', 'arab']) to keep only those scripts.
|
|
93
|
+
#
|
|
94
|
+
# @return [Array<String>] array of script tags to retain
|
|
95
|
+
attribute :scripts, :string, collection: true, default: -> { ["*"] }
|
|
96
|
+
|
|
97
|
+
# Initialize options with custom values
|
|
98
|
+
#
|
|
99
|
+
# @param attributes [Hash] hash of attribute values
|
|
100
|
+
# @option attributes [String] :profile ("pdf") subsetting profile
|
|
101
|
+
# @option attributes [Boolean] :drop_hints (false) drop hinting
|
|
102
|
+
# @option attributes [Boolean] :drop_names (false) drop glyph names
|
|
103
|
+
# @option attributes [Boolean] :unicode_ranges (true) prune OS/2 ranges
|
|
104
|
+
# @option attributes [Boolean] :retain_gids (false) retain glyph IDs
|
|
105
|
+
# @option attributes [Boolean] :include_notdef (true) include .notdef
|
|
106
|
+
# @option attributes [Boolean] :include_null (false) include .null
|
|
107
|
+
# @option attributes [Array<String>] :features ([]) features to keep
|
|
108
|
+
# @option attributes [Array<String>] :scripts (["*"]) scripts to keep
|
|
109
|
+
def initialize(attributes = {})
|
|
110
|
+
super
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Check if all features should be retained
|
|
114
|
+
#
|
|
115
|
+
# @return [Boolean] true if features array is empty
|
|
116
|
+
def all_features?
|
|
117
|
+
features.empty?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Check if all scripts should be retained
|
|
121
|
+
#
|
|
122
|
+
# @return [Boolean] true if scripts contains "*"
|
|
123
|
+
def all_scripts?
|
|
124
|
+
scripts.include?("*")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Validate the options configuration
|
|
128
|
+
#
|
|
129
|
+
# @raise [ArgumentError] if profile is invalid
|
|
130
|
+
# @return [Boolean] true if valid
|
|
131
|
+
def validate!
|
|
132
|
+
valid_profiles = %w[pdf web minimal custom]
|
|
133
|
+
unless valid_profiles.include?(profile)
|
|
134
|
+
raise ArgumentError,
|
|
135
|
+
"Invalid profile '#{profile}'. Must be one of: #{valid_profiles.join(', ')}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
true
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|