fontisan 0.2.2 → 0.2.3
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 +13 -19
- data/README.adoc +31 -0
- data/lib/fontisan/base_collection.rb +296 -0
- data/lib/fontisan/commands/info_command.rb +68 -50
- data/lib/fontisan/font_loader.rb +109 -26
- data/lib/fontisan/formatters/text_formatter.rb +72 -19
- data/lib/fontisan/models/collection_brief_info.rb +6 -0
- data/lib/fontisan/models/collection_info.rb +6 -1
- data/lib/fontisan/open_type_collection.rb +17 -220
- data/lib/fontisan/true_type_collection.rb +29 -113
- data/lib/fontisan/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d342cbc82376aa9327747fd679edf9199ab0e1fbed9a729d4385aae52d6c5de9
|
|
4
|
+
data.tar.gz: 8427c56d4e65ca035a5bff415ef7020e3f6e17c31f4c43d8b28b5ca8e41e4fe4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e5c9b89e658113edcd96e98c4c56733538a4e47eb18220d49ca8e21c5117285795759a6e3dd5b4f2a0b29f7a4a66c0bf0bf25757780dd438045eb705feef97ef
|
|
7
|
+
data.tar.gz: 5ec8c48befb405a5d9911a6f00a2287a9cae461847b52094abe1367cd5f1fd5c451363182ed3c9a924c96d13299659869c6ffa2f192951301d1218eb840e0623
|
data/.rubocop_todo.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# This configuration was generated by
|
|
2
2
|
# `rubocop --auto-gen-config`
|
|
3
|
-
# on 2025-12-
|
|
3
|
+
# on 2025-12-30 09:54:17 UTC using RuboCop version 1.81.7.
|
|
4
4
|
# The point is for the user to remove these configuration records
|
|
5
5
|
# one by one as the offenses are removed from the code base.
|
|
6
6
|
# Note that changes in the inspected code, or installation of new
|
|
@@ -31,21 +31,13 @@ Layout/ExtraSpacing:
|
|
|
31
31
|
Exclude:
|
|
32
32
|
- 'lib/fontisan/hints/truetype_instruction_analyzer.rb'
|
|
33
33
|
|
|
34
|
-
# Offense count:
|
|
34
|
+
# Offense count: 1109
|
|
35
35
|
# This cop supports safe autocorrection (--autocorrect).
|
|
36
36
|
# Configuration parameters: Max, AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings.
|
|
37
37
|
# URISchemes: http, https
|
|
38
38
|
Layout/LineLength:
|
|
39
39
|
Enabled: false
|
|
40
40
|
|
|
41
|
-
# Offense count: 1
|
|
42
|
-
# This cop supports safe autocorrection (--autocorrect).
|
|
43
|
-
# Configuration parameters: EnforcedStyle.
|
|
44
|
-
# SupportedStyles: final_newline, final_blank_line
|
|
45
|
-
Layout/TrailingEmptyLines:
|
|
46
|
-
Exclude:
|
|
47
|
-
- 'spec/fontisan_spec.rb'
|
|
48
|
-
|
|
49
41
|
# Offense count: 26
|
|
50
42
|
# Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches, IgnoreDuplicateElseBranch.
|
|
51
43
|
Lint/DuplicateBranch:
|
|
@@ -106,7 +98,7 @@ Lint/UselessAssignment:
|
|
|
106
98
|
- 'lib/fontisan/hints/truetype_instruction_analyzer.rb'
|
|
107
99
|
- 'spec/fontisan/hints/hint_round_trip_spec.rb'
|
|
108
100
|
|
|
109
|
-
# Offense count:
|
|
101
|
+
# Offense count: 432
|
|
110
102
|
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
|
|
111
103
|
Metrics/AbcSize:
|
|
112
104
|
Enabled: false
|
|
@@ -122,7 +114,7 @@ Metrics/BlockLength:
|
|
|
122
114
|
Metrics/BlockNesting:
|
|
123
115
|
Max: 5
|
|
124
116
|
|
|
125
|
-
# Offense count:
|
|
117
|
+
# Offense count: 223
|
|
126
118
|
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
|
|
127
119
|
Metrics/CyclomaticComplexity:
|
|
128
120
|
Enabled: false
|
|
@@ -138,7 +130,7 @@ Metrics/ParameterLists:
|
|
|
138
130
|
Max: 39
|
|
139
131
|
MaxOptionalParameters: 4
|
|
140
132
|
|
|
141
|
-
# Offense count:
|
|
133
|
+
# Offense count: 157
|
|
142
134
|
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
|
|
143
135
|
Metrics/PerceivedComplexity:
|
|
144
136
|
Enabled: false
|
|
@@ -192,10 +184,11 @@ Performance/CollectionLiteralInLoop:
|
|
|
192
184
|
- 'spec/fontisan/commands/info_command_spec.rb'
|
|
193
185
|
- 'spec/fontisan/commands/tables_command_spec.rb'
|
|
194
186
|
|
|
195
|
-
# Offense count:
|
|
187
|
+
# Offense count: 3
|
|
196
188
|
# This cop supports unsafe autocorrection (--autocorrect-all).
|
|
197
189
|
Performance/TimesMap:
|
|
198
190
|
Exclude:
|
|
191
|
+
- 'lib/fontisan/font_loader.rb'
|
|
199
192
|
- 'lib/fontisan/tables/cff2.rb'
|
|
200
193
|
|
|
201
194
|
# Offense count: 24
|
|
@@ -225,7 +218,7 @@ RSpec/DescribeMethod:
|
|
|
225
218
|
Exclude:
|
|
226
219
|
- 'spec/fontisan/collection/variable_font_builder_spec.rb'
|
|
227
220
|
|
|
228
|
-
# Offense count:
|
|
221
|
+
# Offense count: 1156
|
|
229
222
|
# Configuration parameters: CountAsOne.
|
|
230
223
|
RSpec/ExampleLength:
|
|
231
224
|
Max: 66
|
|
@@ -295,11 +288,11 @@ RSpec/MultipleDescribes:
|
|
|
295
288
|
- 'spec/fontisan/utils/thread_pool_spec.rb'
|
|
296
289
|
- 'spec/fontisan/variation/cache_spec.rb'
|
|
297
290
|
|
|
298
|
-
# Offense count:
|
|
291
|
+
# Offense count: 1494
|
|
299
292
|
RSpec/MultipleExpectations:
|
|
300
293
|
Max: 22
|
|
301
294
|
|
|
302
|
-
# Offense count:
|
|
295
|
+
# Offense count: 134
|
|
303
296
|
# Configuration parameters: AllowSubject.
|
|
304
297
|
RSpec/MultipleMemoizedHelpers:
|
|
305
298
|
Max: 13
|
|
@@ -400,7 +393,7 @@ Style/CombinableLoops:
|
|
|
400
393
|
Exclude:
|
|
401
394
|
- 'lib/fontisan/woff2_font.rb'
|
|
402
395
|
|
|
403
|
-
# Offense count:
|
|
396
|
+
# Offense count: 2
|
|
404
397
|
# This cop supports safe autocorrection (--autocorrect).
|
|
405
398
|
# Configuration parameters: EnforcedStyle, SingleLineConditionsOnly, IncludeTernaryExpressions.
|
|
406
399
|
# SupportedStyles: assign_to_condition, assign_inside_condition
|
|
@@ -434,13 +427,14 @@ Style/HashLikeCase:
|
|
|
434
427
|
- 'lib/fontisan/commands/unpack_command.rb'
|
|
435
428
|
- 'lib/fontisan/models/validation_report.rb'
|
|
436
429
|
|
|
437
|
-
# Offense count:
|
|
430
|
+
# Offense count: 6
|
|
438
431
|
# This cop supports unsafe autocorrection (--autocorrect-all).
|
|
439
432
|
# Configuration parameters: EnforcedStyle, AllowedMethods, AllowedPatterns.
|
|
440
433
|
# SupportedStyles: predicate, comparison
|
|
441
434
|
Style/NumericPredicate:
|
|
442
435
|
Exclude:
|
|
443
436
|
- 'spec/**/*'
|
|
437
|
+
- 'lib/fontisan/font_loader.rb'
|
|
444
438
|
- 'lib/fontisan/hints/hint_converter.rb'
|
|
445
439
|
- 'lib/fontisan/hints/hint_validator.rb'
|
|
446
440
|
- 'lib/fontisan/hints/truetype_instruction_analyzer.rb'
|
data/README.adoc
CHANGED
|
@@ -1043,6 +1043,37 @@ Fontisan provides comprehensive tools for managing TrueType Collections (TTC)
|
|
|
1043
1043
|
and OpenType Collections (OTC). You can list fonts in a collection, extract
|
|
1044
1044
|
individual fonts, unpack entire collections, and validate collection integrity.
|
|
1045
1045
|
|
|
1046
|
+
Both TTC and OTC files use the same `ttcf` tag in their binary format, but
|
|
1047
|
+
differ in the type of font data they contain:
|
|
1048
|
+
|
|
1049
|
+
TTC (TrueType Collection):: Supported since OpenType 1.4. Contains fonts with
|
|
1050
|
+
TrueType outlines (glyf table). Multiple fonts can share identical tables for
|
|
1051
|
+
efficient storage. File extension: `.ttc`
|
|
1052
|
+
|
|
1053
|
+
OTC (OpenType Collection):: Supported since OpenType 1.8. Contains fonts with
|
|
1054
|
+
CFF-format outlines (CFF table). Provides the same storage benefits and
|
|
1055
|
+
glyph-count advantages as TTC but for CFF fonts. File extension: `.otc`
|
|
1056
|
+
|
|
1057
|
+
The collection format allows:
|
|
1058
|
+
|
|
1059
|
+
Table sharing::
|
|
1060
|
+
Identical tables are stored once and referenced by multiple fonts
|
|
1061
|
+
|
|
1062
|
+
Gap mode::
|
|
1063
|
+
Overcomes the 65,535 glyph limit per font by distributing glyphs across multiple
|
|
1064
|
+
fonts in a single file
|
|
1065
|
+
|
|
1066
|
+
Efficient storage::
|
|
1067
|
+
Significant size reduction, especially for CJK fonts (e.g., Noto CJK OTC is ~10
|
|
1068
|
+
MB smaller than separate OTF files)
|
|
1069
|
+
|
|
1070
|
+
Fontist returns the appropriate collection type based on the font data:
|
|
1071
|
+
|
|
1072
|
+
* Examines font data within collection to determine type (TTC vs OTC)
|
|
1073
|
+
* TTC contains fonts with TrueType outlines (glyf table)
|
|
1074
|
+
* OTC contains fonts with CFF outlines (CFF table)
|
|
1075
|
+
* If ANY font in the collection has CFF outlines, use OpenTypeCollection
|
|
1076
|
+
* Only use TrueTypeCollection if ALL fonts have TrueType outlines
|
|
1046
1077
|
|
|
1047
1078
|
=== List fonts
|
|
1048
1079
|
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bindata"
|
|
4
|
+
require_relative "constants"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
# Abstract base class for font collections (TTC/OTC)
|
|
8
|
+
#
|
|
9
|
+
# This class implements the shared logic for TrueTypeCollection and OpenTypeCollection
|
|
10
|
+
# using the Template Method pattern. Subclasses must implement the abstract methods
|
|
11
|
+
# to specify their font class and collection format.
|
|
12
|
+
#
|
|
13
|
+
# The BinData structure definition is shared between both collection types since
|
|
14
|
+
# both TTC and OTC files use the same "ttcf" tag and binary format. The only
|
|
15
|
+
# differences are:
|
|
16
|
+
# 1. The type of fonts contained (TrueType vs OpenType)
|
|
17
|
+
# 2. The format string used for display ("TTC" vs "OTC")
|
|
18
|
+
#
|
|
19
|
+
# @abstract Subclass and override {font_class} and {collection_format}
|
|
20
|
+
#
|
|
21
|
+
# @example Implementing a collection subclass
|
|
22
|
+
# class TrueTypeCollection < BaseCollection
|
|
23
|
+
# def self.font_class
|
|
24
|
+
# TrueTypeFont
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# def self.collection_format
|
|
28
|
+
# "TTC"
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
class BaseCollection < BinData::Record
|
|
32
|
+
endian :big
|
|
33
|
+
|
|
34
|
+
string :tag, length: 4, assert: "ttcf"
|
|
35
|
+
uint16 :major_version
|
|
36
|
+
uint16 :minor_version
|
|
37
|
+
uint32 :num_fonts
|
|
38
|
+
array :font_offsets, type: :uint32, initial_length: :num_fonts
|
|
39
|
+
|
|
40
|
+
# Abstract method: Get the font class for this collection type
|
|
41
|
+
#
|
|
42
|
+
# Subclasses must override this to return their specific font class
|
|
43
|
+
# (TrueTypeFont or OpenTypeFont).
|
|
44
|
+
#
|
|
45
|
+
# @return [Class] The font class (TrueTypeFont or OpenTypeFont)
|
|
46
|
+
# @raise [NotImplementedError] if not overridden by subclass
|
|
47
|
+
def self.font_class
|
|
48
|
+
raise NotImplementedError,
|
|
49
|
+
"#{name} must implement self.font_class"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Abstract method: Get the collection format string
|
|
53
|
+
#
|
|
54
|
+
# Subclasses must override this to return "TTC" or "OTC".
|
|
55
|
+
#
|
|
56
|
+
# @return [String] Collection format ("TTC" or "OTC")
|
|
57
|
+
# @raise [NotImplementedError] if not overridden by subclass
|
|
58
|
+
def self.collection_format
|
|
59
|
+
raise NotImplementedError,
|
|
60
|
+
"#{name} must implement self.collection_format"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Read collection from a file
|
|
64
|
+
#
|
|
65
|
+
# @param path [String] Path to the collection file
|
|
66
|
+
# @return [BaseCollection] A new instance
|
|
67
|
+
# @raise [ArgumentError] if path is nil or empty
|
|
68
|
+
# @raise [Errno::ENOENT] if file does not exist
|
|
69
|
+
# @raise [RuntimeError] if file format is invalid
|
|
70
|
+
def self.from_file(path)
|
|
71
|
+
if path.nil? || path.to_s.empty?
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"path cannot be nil or empty"
|
|
74
|
+
end
|
|
75
|
+
raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
|
|
76
|
+
|
|
77
|
+
File.open(path, "rb") { |io| read(io) }
|
|
78
|
+
rescue BinData::ValidityError => e
|
|
79
|
+
raise "Invalid #{collection_format} file: #{e.message}"
|
|
80
|
+
rescue EOFError => e
|
|
81
|
+
raise "Invalid #{collection_format} file: unexpected end of file - #{e.message}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Extract fonts from the collection
|
|
85
|
+
#
|
|
86
|
+
# Reads each font from the collection file and returns them as font objects.
|
|
87
|
+
#
|
|
88
|
+
# @param io [IO] Open file handle to read fonts from
|
|
89
|
+
# @return [Array] Array of font objects (TrueTypeFont or OpenTypeFont)
|
|
90
|
+
def extract_fonts(io)
|
|
91
|
+
font_class = self.class.font_class
|
|
92
|
+
|
|
93
|
+
font_offsets.map do |offset|
|
|
94
|
+
font_class.from_collection(io, offset)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Get a single font from the collection
|
|
99
|
+
#
|
|
100
|
+
# @param index [Integer] Index of the font (0-based)
|
|
101
|
+
# @param io [IO] Open file handle
|
|
102
|
+
# @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
|
|
103
|
+
# @return [TrueTypeFont, OpenTypeFont, nil] Font object or nil if index out of range
|
|
104
|
+
def font(index, io, mode: LoadingModes::FULL)
|
|
105
|
+
return nil if index >= num_fonts
|
|
106
|
+
|
|
107
|
+
font_class = self.class.font_class
|
|
108
|
+
font_class.from_collection(io, font_offsets[index], mode: mode)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Get font count
|
|
112
|
+
#
|
|
113
|
+
# @return [Integer] Number of fonts in collection
|
|
114
|
+
def font_count
|
|
115
|
+
num_fonts
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Validate format correctness
|
|
119
|
+
#
|
|
120
|
+
# @return [Boolean] true if the format is valid, false otherwise
|
|
121
|
+
def valid?
|
|
122
|
+
tag == Constants::TTC_TAG && num_fonts.positive? && font_offsets.length == num_fonts
|
|
123
|
+
rescue StandardError
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Get the collection version as a single integer
|
|
128
|
+
#
|
|
129
|
+
# @return [Integer] Version number (e.g., 0x00010000 for version 1.0)
|
|
130
|
+
def version
|
|
131
|
+
(major_version << 16) | minor_version
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Get the collection version as a string
|
|
135
|
+
#
|
|
136
|
+
# @return [String] Version string (e.g., "1.0")
|
|
137
|
+
def version_string
|
|
138
|
+
"#{major_version}.#{minor_version}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# List all fonts in the collection with basic metadata
|
|
142
|
+
#
|
|
143
|
+
# Returns a CollectionListInfo model containing summaries of all fonts.
|
|
144
|
+
# This is the API method used by the `ls` command for collections.
|
|
145
|
+
#
|
|
146
|
+
# @param io [IO] Open file handle to read fonts from
|
|
147
|
+
# @return [CollectionListInfo] List of fonts with metadata
|
|
148
|
+
#
|
|
149
|
+
# @example List fonts in collection
|
|
150
|
+
# File.open("fonts.ttc", "rb") do |io|
|
|
151
|
+
# collection = TrueTypeCollection.read(io)
|
|
152
|
+
# list = collection.list_fonts(io)
|
|
153
|
+
# list.fonts.each { |f| puts "#{f.index}: #{f.family_name}" }
|
|
154
|
+
# end
|
|
155
|
+
def list_fonts(io)
|
|
156
|
+
require_relative "models/collection_list_info"
|
|
157
|
+
require_relative "models/collection_font_summary"
|
|
158
|
+
require_relative "tables/name"
|
|
159
|
+
|
|
160
|
+
font_class = self.class.font_class
|
|
161
|
+
|
|
162
|
+
fonts = font_offsets.map.with_index do |offset, index|
|
|
163
|
+
font = font_class.from_collection(io, offset)
|
|
164
|
+
|
|
165
|
+
# Extract basic font info
|
|
166
|
+
name_table = font.table("name")
|
|
167
|
+
post_table = font.table("post")
|
|
168
|
+
|
|
169
|
+
family_name = name_table&.english_name(Tables::Name::FAMILY) || "Unknown"
|
|
170
|
+
subfamily_name = name_table&.english_name(Tables::Name::SUBFAMILY) || "Regular"
|
|
171
|
+
postscript_name = name_table&.english_name(Tables::Name::POSTSCRIPT_NAME) || "Unknown"
|
|
172
|
+
|
|
173
|
+
# Determine font format
|
|
174
|
+
sfnt = font.header.sfnt_version
|
|
175
|
+
font_format = case sfnt
|
|
176
|
+
when 0x00010000, 0x74727565 # 0x74727565 = 'true'
|
|
177
|
+
"TrueType"
|
|
178
|
+
when 0x4F54544F # 'OTTO'
|
|
179
|
+
"OpenType"
|
|
180
|
+
else
|
|
181
|
+
"Unknown"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
num_glyphs = post_table&.glyph_names&.length || 0
|
|
185
|
+
num_tables = font.table_names.length
|
|
186
|
+
|
|
187
|
+
Models::CollectionFontSummary.new(
|
|
188
|
+
index: index,
|
|
189
|
+
family_name: family_name,
|
|
190
|
+
subfamily_name: subfamily_name,
|
|
191
|
+
postscript_name: postscript_name,
|
|
192
|
+
font_format: font_format,
|
|
193
|
+
num_glyphs: num_glyphs,
|
|
194
|
+
num_tables: num_tables,
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
Models::CollectionListInfo.new(
|
|
199
|
+
collection_path: nil, # Will be set by command
|
|
200
|
+
num_fonts: num_fonts,
|
|
201
|
+
fonts: fonts,
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Get comprehensive collection metadata
|
|
206
|
+
#
|
|
207
|
+
# Returns a CollectionInfo model with header information, offsets,
|
|
208
|
+
# and table sharing statistics.
|
|
209
|
+
# This is the API method used by the `info` command for collections.
|
|
210
|
+
#
|
|
211
|
+
# @param io [IO] Open file handle to read fonts from
|
|
212
|
+
# @param path [String] Collection file path (for file size)
|
|
213
|
+
# @return [CollectionInfo] Collection metadata
|
|
214
|
+
#
|
|
215
|
+
# @example Get collection info
|
|
216
|
+
# File.open("fonts.ttc", "rb") do |io|
|
|
217
|
+
# collection = TrueTypeCollection.read(io)
|
|
218
|
+
# info = collection.collection_info(io, "fonts.ttc")
|
|
219
|
+
# puts "Version: #{info.version_string}"
|
|
220
|
+
# end
|
|
221
|
+
def collection_info(io, path)
|
|
222
|
+
require_relative "models/collection_info"
|
|
223
|
+
require_relative "models/table_sharing_info"
|
|
224
|
+
|
|
225
|
+
# Calculate table sharing statistics
|
|
226
|
+
table_sharing = calculate_table_sharing(io)
|
|
227
|
+
|
|
228
|
+
# Get file size
|
|
229
|
+
file_size = path ? File.size(path) : 0
|
|
230
|
+
|
|
231
|
+
Models::CollectionInfo.new(
|
|
232
|
+
collection_path: path,
|
|
233
|
+
collection_format: self.class.collection_format,
|
|
234
|
+
ttc_tag: tag,
|
|
235
|
+
major_version: major_version,
|
|
236
|
+
minor_version: minor_version,
|
|
237
|
+
num_fonts: num_fonts,
|
|
238
|
+
font_offsets: font_offsets.to_a,
|
|
239
|
+
file_size_bytes: file_size,
|
|
240
|
+
table_sharing: table_sharing,
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private
|
|
245
|
+
|
|
246
|
+
# Calculate table sharing statistics
|
|
247
|
+
#
|
|
248
|
+
# Analyzes which tables are shared between fonts and calculates
|
|
249
|
+
# space savings from deduplication.
|
|
250
|
+
#
|
|
251
|
+
# @param io [IO] Open file handle
|
|
252
|
+
# @return [TableSharingInfo] Sharing statistics
|
|
253
|
+
def calculate_table_sharing(io)
|
|
254
|
+
require_relative "models/table_sharing_info"
|
|
255
|
+
|
|
256
|
+
font_class = self.class.font_class
|
|
257
|
+
|
|
258
|
+
# Extract all fonts
|
|
259
|
+
fonts = font_offsets.map do |offset|
|
|
260
|
+
font_class.from_collection(io, offset)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Build table hash map (checksum -> size)
|
|
264
|
+
table_map = {}
|
|
265
|
+
total_table_size = 0
|
|
266
|
+
|
|
267
|
+
fonts.each do |font|
|
|
268
|
+
font.tables.each do |entry|
|
|
269
|
+
key = entry.checksum
|
|
270
|
+
size = entry.table_length
|
|
271
|
+
table_map[key] ||= size
|
|
272
|
+
total_table_size += size
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Count unique vs shared
|
|
277
|
+
unique_tables = table_map.size
|
|
278
|
+
total_tables = fonts.sum { |f| f.tables.length }
|
|
279
|
+
shared_tables = total_tables - unique_tables
|
|
280
|
+
|
|
281
|
+
# Calculate space saved
|
|
282
|
+
unique_size = table_map.values.sum
|
|
283
|
+
space_saved = total_table_size - unique_size
|
|
284
|
+
|
|
285
|
+
# Calculate sharing percentage
|
|
286
|
+
sharing_pct = total_tables.positive? ? (shared_tables.to_f / total_tables * 100).round(2) : 0.0
|
|
287
|
+
|
|
288
|
+
Models::TableSharingInfo.new(
|
|
289
|
+
shared_tables: shared_tables,
|
|
290
|
+
unique_tables: unique_tables,
|
|
291
|
+
sharing_percentage: sharing_pct,
|
|
292
|
+
space_saved_bytes: space_saved,
|
|
293
|
+
)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
@@ -50,62 +50,80 @@ module Fontisan
|
|
|
50
50
|
# Brief mode: load each font and populate brief info
|
|
51
51
|
brief_info = Models::CollectionBriefInfo.new
|
|
52
52
|
brief_info.collection_path = @font_path
|
|
53
|
+
brief_info.collection_type = collection.class.collection_format
|
|
54
|
+
brief_info.collection_version = collection.version_string
|
|
53
55
|
brief_info.num_fonts = collection.num_fonts
|
|
54
|
-
brief_info.fonts =
|
|
55
|
-
|
|
56
|
-
collection.num_fonts.times do |index|
|
|
57
|
-
# Load individual font from collection
|
|
58
|
-
font = FontLoader.load(@font_path, font_index: index, mode: LoadingModes::METADATA)
|
|
59
|
-
|
|
60
|
-
# Populate brief info for this font
|
|
61
|
-
info = Models::FontInfo.new
|
|
62
|
-
|
|
63
|
-
# Font format and variable status
|
|
64
|
-
info.font_format = case font
|
|
65
|
-
when TrueTypeFont
|
|
66
|
-
"truetype"
|
|
67
|
-
when OpenTypeFont
|
|
68
|
-
"cff"
|
|
69
|
-
else
|
|
70
|
-
"unknown"
|
|
71
|
-
end
|
|
72
|
-
info.is_variable = font.has_table?(Constants::FVAR_TAG)
|
|
73
|
-
|
|
74
|
-
# Collection offset (only populated for fonts in collections)
|
|
75
|
-
info.collection_offset = collection.font_offsets[index]
|
|
76
|
-
|
|
77
|
-
# Essential names
|
|
78
|
-
if font.has_table?(Constants::NAME_TAG)
|
|
79
|
-
name_table = font.table(Constants::NAME_TAG)
|
|
80
|
-
info.family_name = name_table.english_name(Tables::Name::FAMILY)
|
|
81
|
-
info.subfamily_name = name_table.english_name(Tables::Name::SUBFAMILY)
|
|
82
|
-
info.full_name = name_table.english_name(Tables::Name::FULL_NAME)
|
|
83
|
-
info.postscript_name = name_table.english_name(Tables::Name::POSTSCRIPT_NAME)
|
|
84
|
-
info.version = name_table.english_name(Tables::Name::VERSION)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
# Essential metrics
|
|
88
|
-
if font.has_table?(Constants::HEAD_TAG)
|
|
89
|
-
head = font.table(Constants::HEAD_TAG)
|
|
90
|
-
info.font_revision = head.font_revision
|
|
91
|
-
info.units_per_em = head.units_per_em
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# Vendor ID
|
|
95
|
-
if font.has_table?(Constants::OS2_TAG)
|
|
96
|
-
os2_table = font.table(Constants::OS2_TAG)
|
|
97
|
-
info.vendor_id = os2_table.vendor_id
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
brief_info.fonts << info
|
|
101
|
-
end
|
|
56
|
+
brief_info.fonts = load_collection_fonts(collection, @font_path)
|
|
102
57
|
|
|
103
58
|
brief_info
|
|
104
59
|
else
|
|
105
|
-
# Full mode: show detailed sharing statistics
|
|
106
|
-
collection.collection_info(io, @font_path)
|
|
60
|
+
# Full mode: show detailed sharing statistics AND font information
|
|
61
|
+
full_info = collection.collection_info(io, @font_path)
|
|
62
|
+
|
|
63
|
+
# Add font information to full mode
|
|
64
|
+
full_info.fonts = load_collection_fonts(collection, @font_path)
|
|
65
|
+
|
|
66
|
+
full_info
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Load font information for all fonts in a collection
|
|
72
|
+
#
|
|
73
|
+
# @param collection [TrueTypeCollection, OpenTypeCollection] The collection
|
|
74
|
+
# @param collection_path [String] Path to the collection file
|
|
75
|
+
# @return [Array<Models::FontInfo>] Array of font info objects
|
|
76
|
+
def load_collection_fonts(collection, collection_path)
|
|
77
|
+
fonts = []
|
|
78
|
+
|
|
79
|
+
collection.num_fonts.times do |index|
|
|
80
|
+
# Load individual font from collection
|
|
81
|
+
font = FontLoader.load(collection_path, font_index: index, mode: LoadingModes::METADATA)
|
|
82
|
+
|
|
83
|
+
# Populate font info
|
|
84
|
+
info = Models::FontInfo.new
|
|
85
|
+
|
|
86
|
+
# Font format and variable status
|
|
87
|
+
info.font_format = case font
|
|
88
|
+
when TrueTypeFont
|
|
89
|
+
"truetype"
|
|
90
|
+
when OpenTypeFont
|
|
91
|
+
"cff"
|
|
92
|
+
else
|
|
93
|
+
"unknown"
|
|
94
|
+
end
|
|
95
|
+
info.is_variable = font.has_table?(Constants::FVAR_TAG)
|
|
96
|
+
|
|
97
|
+
# Collection offset (only populated for fonts in collections)
|
|
98
|
+
info.collection_offset = collection.font_offsets[index]
|
|
99
|
+
|
|
100
|
+
# Essential names
|
|
101
|
+
if font.has_table?(Constants::NAME_TAG)
|
|
102
|
+
name_table = font.table(Constants::NAME_TAG)
|
|
103
|
+
info.family_name = name_table.english_name(Tables::Name::FAMILY)
|
|
104
|
+
info.subfamily_name = name_table.english_name(Tables::Name::SUBFAMILY)
|
|
105
|
+
info.full_name = name_table.english_name(Tables::Name::FULL_NAME)
|
|
106
|
+
info.postscript_name = name_table.english_name(Tables::Name::POSTSCRIPT_NAME)
|
|
107
|
+
info.version = name_table.english_name(Tables::Name::VERSION)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Essential metrics
|
|
111
|
+
if font.has_table?(Constants::HEAD_TAG)
|
|
112
|
+
head = font.table(Constants::HEAD_TAG)
|
|
113
|
+
info.font_revision = head.font_revision
|
|
114
|
+
info.units_per_em = head.units_per_em
|
|
107
115
|
end
|
|
116
|
+
|
|
117
|
+
# Vendor ID
|
|
118
|
+
if font.has_table?(Constants::OS2_TAG)
|
|
119
|
+
os2_table = font.table(Constants::OS2_TAG)
|
|
120
|
+
info.vendor_id = os2_table.vendor_id
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
fonts << info
|
|
108
124
|
end
|
|
125
|
+
|
|
126
|
+
fonts
|
|
109
127
|
end
|
|
110
128
|
|
|
111
129
|
# Get individual font information
|