fontisan 0.2.2 → 0.2.4
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 +94 -48
- data/README.adoc +293 -3
- data/Rakefile +20 -7
- data/lib/fontisan/base_collection.rb +296 -0
- data/lib/fontisan/commands/base_command.rb +2 -19
- data/lib/fontisan/commands/convert_command.rb +16 -13
- data/lib/fontisan/commands/info_command.rb +156 -50
- data/lib/fontisan/config/conversion_matrix.yml +58 -20
- data/lib/fontisan/converters/outline_converter.rb +6 -3
- data/lib/fontisan/converters/svg_generator.rb +45 -0
- data/lib/fontisan/converters/woff2_encoder.rb +106 -13
- data/lib/fontisan/font_loader.rb +109 -26
- data/lib/fontisan/formatters/text_formatter.rb +72 -19
- data/lib/fontisan/models/bitmap_glyph.rb +123 -0
- data/lib/fontisan/models/bitmap_strike.rb +94 -0
- data/lib/fontisan/models/collection_brief_info.rb +6 -0
- data/lib/fontisan/models/collection_info.rb +6 -1
- data/lib/fontisan/models/color_glyph.rb +57 -0
- data/lib/fontisan/models/color_layer.rb +53 -0
- data/lib/fontisan/models/color_palette.rb +60 -0
- data/lib/fontisan/models/font_info.rb +26 -0
- data/lib/fontisan/models/svg_glyph.rb +89 -0
- data/lib/fontisan/open_type_collection.rb +17 -220
- data/lib/fontisan/open_type_font.rb +6 -0
- data/lib/fontisan/optimizers/charstring_rewriter.rb +19 -8
- data/lib/fontisan/optimizers/pattern_analyzer.rb +4 -2
- data/lib/fontisan/optimizers/subroutine_builder.rb +6 -5
- data/lib/fontisan/optimizers/subroutine_optimizer.rb +5 -2
- data/lib/fontisan/pipeline/output_writer.rb +2 -2
- data/lib/fontisan/tables/cbdt.rb +169 -0
- data/lib/fontisan/tables/cblc.rb +290 -0
- data/lib/fontisan/tables/cff.rb +6 -12
- data/lib/fontisan/tables/colr.rb +291 -0
- data/lib/fontisan/tables/cpal.rb +281 -0
- data/lib/fontisan/tables/glyf/glyph_builder.rb +5 -1
- data/lib/fontisan/tables/sbix.rb +379 -0
- data/lib/fontisan/tables/svg.rb +301 -0
- data/lib/fontisan/true_type_collection.rb +29 -113
- data/lib/fontisan/true_type_font.rb +6 -0
- data/lib/fontisan/validation/woff2_header_validator.rb +278 -0
- data/lib/fontisan/validation/woff2_table_validator.rb +270 -0
- data/lib/fontisan/validation/woff2_validator.rb +248 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/directory.rb +40 -11
- data/lib/fontisan/woff2/table_transformer.rb +506 -73
- data/lib/fontisan/woff2_font.rb +29 -9
- data/lib/fontisan/woff_font.rb +17 -4
- data/lib/fontisan.rb +12 -0
- metadata +18 -2
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
|
|
|
@@ -1269,6 +1300,173 @@ $ fontisan validate FONT.{ttc,otc}
|
|
|
1269
1300
|
NOTE: In `extract_ttc`, this was done with `extract_ttc --validate FONT.ttc`.
|
|
1270
1301
|
|
|
1271
1302
|
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
== Color font support
|
|
1306
|
+
|
|
1307
|
+
=== General
|
|
1308
|
+
|
|
1309
|
+
Fontisan provides comprehensive analysis of all color font formats defined in the OpenType specification.
|
|
1310
|
+
|
|
1311
|
+
=== Supported color font formats
|
|
1312
|
+
|
|
1313
|
+
Fontisan supports all four color font table formats:
|
|
1314
|
+
|
|
1315
|
+
COLR/CPAL:: Layered color glyphs with custom color palettes (Microsoft/Google standard)
|
|
1316
|
+
|
|
1317
|
+
SVG:: Embedded SVG graphics with gzip compression support (W3C standard)
|
|
1318
|
+
|
|
1319
|
+
CBDT/CBLC:: Bitmap glyphs at multiple ppem sizes (Google format)
|
|
1320
|
+
|
|
1321
|
+
sbix:: Bitmap graphics with PNG/JPEG/TIFF support (Apple format)
|
|
1322
|
+
|
|
1323
|
+
=== Analyzing color fonts
|
|
1324
|
+
|
|
1325
|
+
[source,ruby]
|
|
1326
|
+
----
|
|
1327
|
+
require 'fontisan'
|
|
1328
|
+
|
|
1329
|
+
# Load and analyze a color font
|
|
1330
|
+
info = Fontisan.info('emoji-font.ttf')
|
|
1331
|
+
|
|
1332
|
+
# Color glyph detection
|
|
1333
|
+
puts info.is_color_font # true if COLR/CPAL present
|
|
1334
|
+
puts info.has_svg_table # true if SVG table present
|
|
1335
|
+
puts info.has_bitmap_glyphs # true if CBDT/CBLC or sbix present
|
|
1336
|
+
|
|
1337
|
+
# Get color palette information (COLR/CPAL)
|
|
1338
|
+
if info.is_color_font
|
|
1339
|
+
puts "Color glyphs: #{info.color_glyphs}"
|
|
1340
|
+
puts "Color palettes: #{info.color_palettes}"
|
|
1341
|
+
puts "Colors per palette: #{info.colors_per_palette}"
|
|
1342
|
+
end
|
|
1343
|
+
|
|
1344
|
+
# Get SVG glyph information
|
|
1345
|
+
if info.has_svg_table
|
|
1346
|
+
puts "SVG glyphs: #{info.svg_glyph_count}"
|
|
1347
|
+
end
|
|
1348
|
+
|
|
1349
|
+
# Get bitmap information
|
|
1350
|
+
if info.has_bitmap_glyphs
|
|
1351
|
+
puts "Bitmap formats: #{info.bitmap_formats.join(', ')}"
|
|
1352
|
+
puts "Available sizes (ppem): #{info.bitmap_ppem_sizes.join(', ')}"
|
|
1353
|
+
|
|
1354
|
+
info.bitmap_strikes.each do |strike|
|
|
1355
|
+
puts "Strike at #{strike.ppem}ppem:"
|
|
1356
|
+
puts " - Glyphs: #{strike.num_glyphs}"
|
|
1357
|
+
puts " - Color depth: #{strike.color_depth}"
|
|
1358
|
+
end
|
|
1359
|
+
end
|
|
1360
|
+
----
|
|
1361
|
+
|
|
1362
|
+
=== Color font table access
|
|
1363
|
+
|
|
1364
|
+
Access color font tables directly:
|
|
1365
|
+
|
|
1366
|
+
[source,ruby]
|
|
1367
|
+
----
|
|
1368
|
+
font = Fontisan::FontLoader.load('emoji-font.ttf')
|
|
1369
|
+
|
|
1370
|
+
# Access COLR table (layered color)
|
|
1371
|
+
if font.has_table?('COLR')
|
|
1372
|
+
colr = font.table('COLR')
|
|
1373
|
+
puts "Color glyphs: #{colr.num_color_glyphs}"
|
|
1374
|
+
|
|
1375
|
+
# Get color layers for a glyph
|
|
1376
|
+
layers = colr.layers_for_glyph(42)
|
|
1377
|
+
layers.each do |layer|
|
|
1378
|
+
puts "Layer glyph: #{layer.glyph_id}, Palette index: #{layer.palette_index}"
|
|
1379
|
+
end
|
|
1380
|
+
end
|
|
1381
|
+
|
|
1382
|
+
# Access CPAL table (color palettes)
|
|
1383
|
+
if font.has_table?('CPAL')
|
|
1384
|
+
cpal = font.table('CPAL')
|
|
1385
|
+
|
|
1386
|
+
# Get colors from first palette
|
|
1387
|
+
palette = cpal.palette(0)
|
|
1388
|
+
palette.each_with_index do |color, i|
|
|
1389
|
+
puts "Color #{i}: R=#{color.red} G=#{color.green} B=#{color.blue} A=#{color.alpha}"
|
|
1390
|
+
end
|
|
1391
|
+
end
|
|
1392
|
+
|
|
1393
|
+
# Access SVG table
|
|
1394
|
+
if font.has_table?('SVG ')
|
|
1395
|
+
svg = font.table('SVG ')
|
|
1396
|
+
|
|
1397
|
+
# Get SVG document for glyph 100
|
|
1398
|
+
svg_doc = svg.svg_document_for_glyph(100)
|
|
1399
|
+
puts "Compressed: #{svg_doc.compressed?}"
|
|
1400
|
+
puts "SVG data: #{svg_doc.svg_data}"
|
|
1401
|
+
end
|
|
1402
|
+
|
|
1403
|
+
# Access bitmap tables
|
|
1404
|
+
if font.has_table?('CBLC')
|
|
1405
|
+
cblc = font.table('CBLC')
|
|
1406
|
+
puts "Available ppem sizes: #{cblc.ppem_sizes.join(', ')}"
|
|
1407
|
+
|
|
1408
|
+
# Check if glyph 50 has bitmap at 64ppem
|
|
1409
|
+
if cblc.has_bitmap_for_glyph?(50, 64)
|
|
1410
|
+
puts "Glyph 50 has bitmap at 64ppem"
|
|
1411
|
+
end
|
|
1412
|
+
end
|
|
1413
|
+
|
|
1414
|
+
if font.has_table?('sbix')
|
|
1415
|
+
sbix = font.table('sbix')
|
|
1416
|
+
|
|
1417
|
+
# Get bitmap data for glyph 42 at 128ppem
|
|
1418
|
+
glyph_data = sbix.glyph_data(42, 128)
|
|
1419
|
+
if glyph_data
|
|
1420
|
+
puts "Format: #{glyph_data[:graphic_type_name]}"
|
|
1421
|
+
puts "Origin: (#{glyph_data[:origin_x]}, #{glyph_data[:origin_y]})"
|
|
1422
|
+
puts "Data size: #{glyph_data[:data].length} bytes"
|
|
1423
|
+
end
|
|
1424
|
+
end
|
|
1425
|
+
----
|
|
1426
|
+
|
|
1427
|
+
=== Output formats
|
|
1428
|
+
|
|
1429
|
+
Color font information can be serialized to multiple formats:
|
|
1430
|
+
|
|
1431
|
+
[source,ruby]
|
|
1432
|
+
----
|
|
1433
|
+
info = Fontisan.info('emoji-font.ttf')
|
|
1434
|
+
|
|
1435
|
+
# YAML output
|
|
1436
|
+
puts info.to_yaml
|
|
1437
|
+
|
|
1438
|
+
# JSON output
|
|
1439
|
+
puts info.to_json
|
|
1440
|
+
|
|
1441
|
+
# XML output
|
|
1442
|
+
puts info.to_xml
|
|
1443
|
+
----
|
|
1444
|
+
|
|
1445
|
+
Example YAML output:
|
|
1446
|
+
|
|
1447
|
+
[source,yaml]
|
|
1448
|
+
----
|
|
1449
|
+
font_format: truetype
|
|
1450
|
+
family_name: "Emoji Font"
|
|
1451
|
+
is_color_font: true
|
|
1452
|
+
color_glyphs: 872
|
|
1453
|
+
color_palettes: 1
|
|
1454
|
+
colors_per_palette: 256
|
|
1455
|
+
has_svg_table: true
|
|
1456
|
+
svg_glyph_count: 872
|
|
1457
|
+
has_bitmap_glyphs: true
|
|
1458
|
+
bitmap_ppem_sizes: [16, 32, 64, 128, 256]
|
|
1459
|
+
bitmap_formats: ["PNG"]
|
|
1460
|
+
bitmap_strikes:
|
|
1461
|
+
- ppem: 128
|
|
1462
|
+
start_glyph_id: 1
|
|
1463
|
+
end_glyph_id: 872
|
|
1464
|
+
bit_depth: 32
|
|
1465
|
+
num_glyphs: 872
|
|
1466
|
+
color_depth: "32-bit (full color with alpha)"
|
|
1467
|
+
----
|
|
1468
|
+
|
|
1469
|
+
|
|
1272
1470
|
== Advanced features
|
|
1273
1471
|
|
|
1274
1472
|
Fontisan provides capabilities:
|
|
@@ -1358,6 +1556,9 @@ The loading mode can be queried at any time.
|
|
|
1358
1556
|
|
|
1359
1557
|
[source,ruby]
|
|
1360
1558
|
----
|
|
1559
|
+
# Check laziness
|
|
1560
|
+
font.lazy? # => true
|
|
1561
|
+
|
|
1361
1562
|
# Mode stored as font property
|
|
1362
1563
|
font.loading_mode # => :metadata or :full
|
|
1363
1564
|
|
|
@@ -1366,6 +1567,12 @@ font.table_available?(tag) # => boolean
|
|
|
1366
1567
|
|
|
1367
1568
|
# Access restricted based on mode
|
|
1368
1569
|
font.table(tag) # => Returns table or raises error
|
|
1570
|
+
|
|
1571
|
+
# Test lazy loading with an expensive operation
|
|
1572
|
+
glyphs = [] if font.subset_glyphs(lazy: true) { glyphs = font.subset_glyphs }
|
|
1573
|
+
glyphs.count # => Tests whether glyphs were already loaded
|
|
1574
|
+
font.table_available?("head") # => true (lazy loading enabled)
|
|
1575
|
+
font.table_available?("GSUB") # => false (lazy loading enabled)
|
|
1369
1576
|
----
|
|
1370
1577
|
|
|
1371
1578
|
|
|
@@ -1399,7 +1606,7 @@ fields:
|
|
|
1399
1606
|
`family_name`:: Font family name (nameID 1)
|
|
1400
1607
|
`subfamily_name`:: Font subfamily/style name (nameID 2)
|
|
1401
1608
|
`full_name`:: Full font name (nameID 4)
|
|
1402
|
-
`
|
|
1609
|
+
`postscript_name`:: PostScript name (nameID 6)
|
|
1403
1610
|
`preferred_family_name`:: Preferred family name (nameID 16, may be nil)
|
|
1404
1611
|
`preferred_subfamily_name`:: Preferred subfamily name (nameID 17, may be nil)
|
|
1405
1612
|
`units_per_em`:: Units per em from head table
|
|
@@ -1453,6 +1660,64 @@ font = Fontisan::FontLoader.load('font.ttf', mode: :full, lazy: false)
|
|
|
1453
1660
|
----
|
|
1454
1661
|
|
|
1455
1662
|
|
|
1663
|
+
=== Table preparedness
|
|
1664
|
+
|
|
1665
|
+
Every table uses preparedness to avoid lazy initialization overhead. Function
|
|
1666
|
+
`table_available?` was added to check if table prepared for access.
|
|
1667
|
+
|
|
1668
|
+
[source,ruby]
|
|
1669
|
+
----
|
|
1670
|
+
font = Fontisan::FontLoader.load('font.ttf')
|
|
1671
|
+
|
|
1672
|
+
# Check preparedness
|
|
1673
|
+
font.table_available?("head") # => true
|
|
1674
|
+
font.table_available?("GSUB") # => true
|
|
1675
|
+
font.table_available?("GPOS") # => true
|
|
1676
|
+
|
|
1677
|
+
# Force loading of a table
|
|
1678
|
+
head = font.table("head")
|
|
1679
|
+
puts head.metrics.units_per_em # => Asks explicitly for metric value
|
|
1680
|
+
puts head.metrics.weight_class # => Asks explicitly for metric value
|
|
1681
|
+
print head.ty # => Prints force loaded table
|
|
1682
|
+
|
|
1683
|
+
# Check preparedness after loading
|
|
1684
|
+
font.table_available?("head") # => true in cache
|
|
1685
|
+
font.table_available?("GSUB") # => true in cache
|
|
1686
|
+
font.table_available?("GPOS") # => true in cache
|
|
1687
|
+
----
|
|
1688
|
+
|
|
1689
|
+
A table not present in a font file is loaded lazily and `table_available?`
|
|
1690
|
+
returns `false` after loading this table or after calling directly `table` for
|
|
1691
|
+
the given tag:
|
|
1692
|
+
|
|
1693
|
+
[source,ruby]
|
|
1694
|
+
----
|
|
1695
|
+
font = Fontisan::FontLoader.load('font.ttf')
|
|
1696
|
+
|
|
1697
|
+
# Check preparedness
|
|
1698
|
+
font.standard_glyphs # => instantly loading glyphs (CFF and GDEF)
|
|
1699
|
+
font.candlestick_and_widget_chart # => instantly loading glyphs (Colr)
|
|
1700
|
+
font.txt_encoder # => instantly loading glyphs (OutlinedFont)
|
|
1701
|
+
font.table_available?("post") # => true (cached)
|
|
1702
|
+
|
|
1703
|
+
font.table_available?("OS/2") # => false (not loaded)
|
|
1704
|
+
font.table("OS/2") # => instant loading tables (lazy loading)
|
|
1705
|
+
font.table_available?("OS/2") # => true (cached)
|
|
1706
|
+
|
|
1707
|
+
font.table_available?("VORG") # => false (not loaded)
|
|
1708
|
+
font.table("VORG") # => instant loading tables (lazy loading)
|
|
1709
|
+
# Raises Fontisan::Tables::VorgTableNotInFont: Not a valid sfnt font or sfnt table VORG not included in input font
|
|
1710
|
+
font.table_available?("VORG") # => true (cached)
|
|
1711
|
+
|
|
1712
|
+
font.table_available?("GLYC") # => nil
|
|
1713
|
+
font.table("GLYC") # => instant loading tables (lazy loading)
|
|
1714
|
+
# Raises Fontisan::TableNotFound: Table GLYC not found in font
|
|
1715
|
+
font.table_available?("GLYC") # => nil (not cached)
|
|
1716
|
+
----
|
|
1717
|
+
|
|
1718
|
+
NOTE: If you do not need to check whether the table is present before access
|
|
1719
|
+
it is faster to access directly `table` method and catch error with validation
|
|
1720
|
+
than first call table_available? for the table not present in the font file.
|
|
1456
1721
|
|
|
1457
1722
|
|
|
1458
1723
|
== Outline format conversion
|
|
@@ -1568,6 +1833,31 @@ converter.supported_targets(:ttf)
|
|
|
1568
1833
|
# => [:ttf, :otf, :woff2, :svg]
|
|
1569
1834
|
----
|
|
1570
1835
|
|
|
1836
|
+
=== Convert into WOFF2
|
|
1837
|
+
|
|
1838
|
+
==== Validation
|
|
1839
|
+
|
|
1840
|
+
WOFF2 encoding can be validated automatically to ensure spec compliance:
|
|
1841
|
+
|
|
1842
|
+
.Validation example
|
|
1843
|
+
[example]
|
|
1844
|
+
====
|
|
1845
|
+
[source,ruby]
|
|
1846
|
+
----
|
|
1847
|
+
encoder = Fontisan::Converters::Woff2Encoder.new
|
|
1848
|
+
result = encoder.convert(font, {
|
|
1849
|
+
transform_tables: true,
|
|
1850
|
+
validate: true,
|
|
1851
|
+
validation_level: :strict # :strict, :standard, or :lenient
|
|
1852
|
+
})
|
|
1853
|
+
|
|
1854
|
+
report = result[:validation_report]
|
|
1855
|
+
puts "Valid: #{report.valid}"
|
|
1856
|
+
puts "Compression: #{report.info_issues.first.message}"
|
|
1857
|
+
----
|
|
1858
|
+
====
|
|
1859
|
+
|
|
1860
|
+
|
|
1571
1861
|
|
|
1572
1862
|
=== Validation
|
|
1573
1863
|
|
|
@@ -2086,7 +2376,7 @@ end
|
|
|
2086
2376
|
====
|
|
2087
2377
|
|
|
2088
2378
|
|
|
2089
|
-
== Round-
|
|
2379
|
+
== Round-trip validation
|
|
2090
2380
|
|
|
2091
2381
|
=== General
|
|
2092
2382
|
|
|
@@ -2159,7 +2449,7 @@ Loron.
|
|
|
2159
2449
|
|
|
2160
2450
|
Support for layered import CFF color glyphs rasterizing on demand, with
|
|
2161
2451
|
composite font support, a multi-layer color font represented by many
|
|
2162
|
-
CFF fonts stacked on top of each other.
|
|
2452
|
+
CFF fonts stacked on top of each other. ColorGlyph support contains
|
|
2163
2453
|
color glyphs, advanced color fonts glyphs and raster images (PNG or JPG)
|
|
2164
2454
|
combined with TrueType outlines.
|
|
2165
2455
|
|
data/Rakefile
CHANGED
|
@@ -87,6 +87,9 @@ namespace :fixtures do
|
|
|
87
87
|
|
|
88
88
|
# Create file tasks for each font
|
|
89
89
|
fonts.each do |name, config|
|
|
90
|
+
# Skip fonts that should not be downloaded (already committed)
|
|
91
|
+
next if config[:skip_download]
|
|
92
|
+
|
|
90
93
|
file config[:marker] do
|
|
91
94
|
if config[:single_file]
|
|
92
95
|
download_single_file(name, config[:url], config[:marker])
|
|
@@ -97,16 +100,26 @@ namespace :fixtures do
|
|
|
97
100
|
end
|
|
98
101
|
|
|
99
102
|
desc "Download all test fixture fonts"
|
|
100
|
-
task download: fonts.values.map { |config| config[:marker] }
|
|
103
|
+
task download: fonts.values.reject { |config| config[:skip_download] }.map { |config| config[:marker] }
|
|
101
104
|
|
|
102
105
|
desc "Clean downloaded fixtures"
|
|
103
106
|
task :clean do
|
|
104
|
-
fonts.values.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
fonts.values.reject { |config| config[:skip_download] }.each do |config|
|
|
108
|
+
if config[:single_file]
|
|
109
|
+
# For single files, just delete the marker file itself
|
|
110
|
+
if File.exist?(config[:marker])
|
|
111
|
+
puts "[fixtures:clean] Removing #{config[:marker]}..."
|
|
112
|
+
FileUtils.rm_f(config[:marker])
|
|
113
|
+
puts "[fixtures:clean] Removed #{config[:marker]}"
|
|
114
|
+
end
|
|
115
|
+
else
|
|
116
|
+
# For archives, delete the entire target directory
|
|
117
|
+
if File.exist?(config[:target_dir])
|
|
118
|
+
puts "[fixtures:clean] Removing #{config[:target_dir]}..."
|
|
119
|
+
FileUtils.rm_rf(config[:target_dir])
|
|
120
|
+
puts "[fixtures:clean] Removed #{config[:target_dir]}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
110
123
|
end
|
|
111
124
|
end
|
|
112
125
|
end
|
|
@@ -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
|