fontisan 0.2.17 → 0.2.22

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.
Files changed (316) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +14 -90
  3. data/README.adoc +257 -1
  4. data/docs/.vitepress/config.ts +68 -8
  5. data/docs/.vitepress/theme/style.css +570 -272
  6. data/docs/CONVERSION_GUIDE.adoc +31 -8
  7. data/docs/EXTRACT_TTC_MIGRATION.md +1 -1
  8. data/docs/WOFF_WOFF2_FORMATS.adoc +53 -0
  9. data/docs/api/conversion-options.md +37 -14
  10. data/docs/cli/audit.md +337 -0
  11. data/docs/cli/convert.md +20 -1
  12. data/docs/cli/index.md +31 -0
  13. data/docs/guide/color.md +1 -1
  14. data/docs/guide/conversion/options.md +32 -3
  15. data/docs/guide/conversion/ttf-otf.md +1 -1
  16. data/docs/guide/conversion/type1.md +1 -1
  17. data/docs/guide/conversion/web.md +91 -32
  18. data/docs/guide/conversion.md +6 -5
  19. data/docs/guide/formats/woff.md +35 -11
  20. data/docs/guide/index.md +2 -2
  21. data/docs/guide/migrations/extract-ttc.md +1 -1
  22. data/docs/guide/quick-start.md +4 -4
  23. data/docs/guide/type1.md +4 -4
  24. data/docs/guide/woff.md +19 -17
  25. data/docs/index.md +2 -0
  26. data/docs/lychee.toml +5 -1
  27. data/docs/package.json +1 -1
  28. data/docs/public/robots.txt +4 -0
  29. data/docs/scripts/post-build.mjs +81 -0
  30. data/lib/fontisan/audit/codepoint_range_coalescer.rb +41 -0
  31. data/lib/fontisan/audit/context.rb +122 -0
  32. data/lib/fontisan/audit/differ.rb +124 -0
  33. data/lib/fontisan/audit/extractors/aggregations.rb +54 -0
  34. data/lib/fontisan/audit/extractors/base.rb +26 -0
  35. data/lib/fontisan/audit/extractors/color_capabilities.rb +141 -0
  36. data/lib/fontisan/audit/extractors/coverage.rb +48 -0
  37. data/lib/fontisan/audit/extractors/hinting.rb +197 -0
  38. data/lib/fontisan/audit/extractors/identity.rb +52 -0
  39. data/lib/fontisan/audit/extractors/language_coverage.rb +37 -0
  40. data/lib/fontisan/audit/extractors/licensing.rb +79 -0
  41. data/lib/fontisan/audit/extractors/metrics.rb +103 -0
  42. data/lib/fontisan/audit/extractors/opentype_layout.rb +69 -0
  43. data/lib/fontisan/audit/extractors/provenance.rb +29 -0
  44. data/lib/fontisan/audit/extractors/style.rb +32 -0
  45. data/lib/fontisan/audit/extractors/variation_detail.rb +99 -0
  46. data/lib/fontisan/audit/extractors.rb +27 -0
  47. data/lib/fontisan/audit/library_aggregator.rb +83 -0
  48. data/lib/fontisan/audit/library_auditor.rb +90 -0
  49. data/lib/fontisan/audit/registry.rb +60 -0
  50. data/lib/fontisan/audit/style_extractor.rb +80 -0
  51. data/lib/fontisan/audit.rb +20 -0
  52. data/lib/fontisan/base_collection.rb +23 -9
  53. data/lib/fontisan/binary/structures.rb +0 -2
  54. data/lib/fontisan/binary.rb +11 -0
  55. data/lib/fontisan/cldr/aggregator.rb +33 -0
  56. data/lib/fontisan/cldr/cache_manager.rb +110 -0
  57. data/lib/fontisan/cldr/config.rb +59 -0
  58. data/lib/fontisan/cldr/download_error.rb +9 -0
  59. data/lib/fontisan/cldr/downloader.rb +79 -0
  60. data/lib/fontisan/cldr/error.rb +8 -0
  61. data/lib/fontisan/cldr/index.rb +64 -0
  62. data/lib/fontisan/cldr/index_builder.rb +72 -0
  63. data/lib/fontisan/cldr/unicode_set_parser.rb +172 -0
  64. data/lib/fontisan/cldr/unknown_version_error.rb +9 -0
  65. data/lib/fontisan/cldr/version_resolver.rb +91 -0
  66. data/lib/fontisan/cldr.rb +23 -0
  67. data/lib/fontisan/cli/cldr_cli.rb +85 -0
  68. data/lib/fontisan/cli/ucd_cli.rb +97 -0
  69. data/lib/fontisan/cli.rb +201 -2
  70. data/lib/fontisan/collection/builder.rb +0 -4
  71. data/lib/fontisan/collection/dfont_builder.rb +0 -4
  72. data/lib/fontisan/collection/shared_logic.rb +0 -2
  73. data/lib/fontisan/collection/writer.rb +0 -3
  74. data/lib/fontisan/collection.rb +15 -0
  75. data/lib/fontisan/commands/audit_command.rb +123 -0
  76. data/lib/fontisan/commands/audit_compare_command.rb +66 -0
  77. data/lib/fontisan/commands/audit_library_command.rb +46 -0
  78. data/lib/fontisan/commands/base_command.rb +0 -3
  79. data/lib/fontisan/commands/convert_command.rb +25 -20
  80. data/lib/fontisan/commands/dump_table_command.rb +0 -3
  81. data/lib/fontisan/commands/export_command.rb +0 -4
  82. data/lib/fontisan/commands/features_command.rb +0 -3
  83. data/lib/fontisan/commands/instance_command.rb +0 -5
  84. data/lib/fontisan/commands/ls_command.rb +0 -6
  85. data/lib/fontisan/commands/optical_size_command.rb +0 -3
  86. data/lib/fontisan/commands/pack_command.rb +0 -5
  87. data/lib/fontisan/commands/scripts_command.rb +0 -2
  88. data/lib/fontisan/commands/subset_command.rb +0 -3
  89. data/lib/fontisan/commands/unicode_command.rb +0 -3
  90. data/lib/fontisan/commands/unpack_command.rb +0 -7
  91. data/lib/fontisan/commands/validate_command.rb +0 -8
  92. data/lib/fontisan/commands/variable_command.rb +0 -3
  93. data/lib/fontisan/commands.rb +29 -0
  94. data/lib/fontisan/config/cldr.yml +22 -0
  95. data/lib/fontisan/config/conversion_matrix.yml +38 -0
  96. data/lib/fontisan/config/ucd.yml +23 -0
  97. data/lib/fontisan/constants.rb +19 -0
  98. data/lib/fontisan/conversion_options.rb +30 -19
  99. data/lib/fontisan/converters/cff_table_builder.rb +0 -3
  100. data/lib/fontisan/converters/collection_converter.rb +0 -8
  101. data/lib/fontisan/converters/conversion_strategy.rb +161 -46
  102. data/lib/fontisan/converters/format_converter.rb +143 -32
  103. data/lib/fontisan/converters/glyf_table_builder.rb +0 -2
  104. data/lib/fontisan/converters/outline_converter.rb +0 -19
  105. data/lib/fontisan/converters/outline_extraction.rb +0 -5
  106. data/lib/fontisan/converters/outline_optimizer.rb +0 -5
  107. data/lib/fontisan/converters/svg_generator.rb +0 -4
  108. data/lib/fontisan/converters/table_copier.rb +0 -2
  109. data/lib/fontisan/converters/type1_converter.rb +0 -11
  110. data/lib/fontisan/converters/woff2_encoder.rb +49 -20
  111. data/lib/fontisan/converters/woff_writer.rb +211 -282
  112. data/lib/fontisan/converters.rb +21 -0
  113. data/lib/fontisan/dfont_collection.rb +29 -10
  114. data/lib/fontisan/export/exporter.rb +0 -6
  115. data/lib/fontisan/export/transformers/font_to_ttx.rb +0 -9
  116. data/lib/fontisan/export/transformers/head_transformer.rb +0 -2
  117. data/lib/fontisan/export/transformers/hhea_transformer.rb +0 -2
  118. data/lib/fontisan/export/transformers/maxp_transformer.rb +0 -2
  119. data/lib/fontisan/export/transformers/name_transformer.rb +0 -2
  120. data/lib/fontisan/export/transformers/os2_transformer.rb +0 -2
  121. data/lib/fontisan/export/transformers/post_transformer.rb +0 -2
  122. data/lib/fontisan/export/transformers.rb +17 -0
  123. data/lib/fontisan/export.rb +13 -0
  124. data/lib/fontisan/font_loader.rb +14 -19
  125. data/lib/fontisan/font_writer.rb +0 -1
  126. data/lib/fontisan/formatters/audit_diff_text_renderer.rb +122 -0
  127. data/lib/fontisan/formatters/audit_text_renderer.rb +324 -0
  128. data/lib/fontisan/formatters/library_summary_text_renderer.rb +99 -0
  129. data/lib/fontisan/formatters/text_formatter.rb +6 -0
  130. data/lib/fontisan/formatters.rb +12 -0
  131. data/lib/fontisan/hints/hint_converter.rb +0 -1
  132. data/lib/fontisan/hints/postscript_hint_applier.rb +0 -9
  133. data/lib/fontisan/hints/postscript_hint_extractor.rb +0 -2
  134. data/lib/fontisan/hints/truetype_hint_extractor.rb +0 -2
  135. data/lib/fontisan/hints.rb +16 -0
  136. data/lib/fontisan/metrics_calculator.rb +0 -2
  137. data/lib/fontisan/models/all_scripts_features_info.rb +0 -1
  138. data/lib/fontisan/models/audit/audit_axis.rb +30 -0
  139. data/lib/fontisan/models/audit/audit_block.rb +32 -0
  140. data/lib/fontisan/models/audit/audit_diff.rb +77 -0
  141. data/lib/fontisan/models/audit/audit_report.rb +153 -0
  142. data/lib/fontisan/models/audit/codepoint_range.rb +40 -0
  143. data/lib/fontisan/models/audit/codepoint_set_diff.rb +34 -0
  144. data/lib/fontisan/models/audit/color_capabilities.rb +93 -0
  145. data/lib/fontisan/models/audit/duplicate_group.rb +23 -0
  146. data/lib/fontisan/models/audit/embedding_type.rb +76 -0
  147. data/lib/fontisan/models/audit/field_change.rb +28 -0
  148. data/lib/fontisan/models/audit/fs_selection_flags.rb +61 -0
  149. data/lib/fontisan/models/audit/gasp_range.rb +63 -0
  150. data/lib/fontisan/models/audit/hinting.rb +93 -0
  151. data/lib/fontisan/models/audit/library_summary.rb +40 -0
  152. data/lib/fontisan/models/audit/licensing.rb +48 -0
  153. data/lib/fontisan/models/audit/metrics.rb +111 -0
  154. data/lib/fontisan/models/audit/named_instance.rb +41 -0
  155. data/lib/fontisan/models/audit/opentype_layout.rb +40 -0
  156. data/lib/fontisan/models/audit/script_coverage_row.rb +26 -0
  157. data/lib/fontisan/models/audit/script_features.rb +28 -0
  158. data/lib/fontisan/models/audit/variation_detail.rb +44 -0
  159. data/lib/fontisan/models/audit.rb +33 -0
  160. data/lib/fontisan/models/cldr/language_coverage.rb +31 -0
  161. data/lib/fontisan/models/cldr.rb +12 -0
  162. data/lib/fontisan/models/collection_brief_info.rb +0 -1
  163. data/lib/fontisan/models/collection_info.rb +0 -2
  164. data/lib/fontisan/models/collection_list_info.rb +0 -1
  165. data/lib/fontisan/models/collection_validation_report.rb +0 -2
  166. data/lib/fontisan/models/color_glyph.rb +0 -1
  167. data/lib/fontisan/models/font_report.rb +0 -1
  168. data/lib/fontisan/models/ttx/tables.rb +21 -0
  169. data/lib/fontisan/models/ttx/ttfont.rb +0 -8
  170. data/lib/fontisan/models/ttx.rb +14 -0
  171. data/lib/fontisan/models/ucd/ucd.rb +38 -0
  172. data/lib/fontisan/models/ucd/ucd_char.rb +67 -0
  173. data/lib/fontisan/models/ucd.rb +19 -0
  174. data/lib/fontisan/models.rb +47 -0
  175. data/lib/fontisan/open_type_collection.rb +6 -5
  176. data/lib/fontisan/open_type_font.rb +8 -2
  177. data/lib/fontisan/open_type_font_extensions.rb +9 -9
  178. data/lib/fontisan/optimizers/pattern_analyzer.rb +0 -1
  179. data/lib/fontisan/optimizers.rb +14 -0
  180. data/lib/fontisan/outline_extractor.rb +0 -2
  181. data/lib/fontisan/parsers/dfont_parser.rb +0 -1
  182. data/lib/fontisan/parsers.rb +10 -0
  183. data/lib/fontisan/pipeline/format_detector.rb +29 -102
  184. data/lib/fontisan/pipeline/output_writer.rb +11 -9
  185. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +0 -4
  186. data/lib/fontisan/pipeline/strategies/named_strategy.rb +0 -4
  187. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +0 -2
  188. data/lib/fontisan/pipeline/strategies.rb +14 -0
  189. data/lib/fontisan/pipeline/transformation_pipeline.rb +0 -7
  190. data/lib/fontisan/pipeline/variation_resolver.rb +0 -7
  191. data/lib/fontisan/pipeline.rb +13 -0
  192. data/lib/fontisan/sfnt_font.rb +29 -14
  193. data/lib/fontisan/sfnt_table.rb +0 -4
  194. data/lib/fontisan/subset/builder.rb +0 -6
  195. data/lib/fontisan/subset.rb +13 -0
  196. data/lib/fontisan/svg/font_generator.rb +0 -4
  197. data/lib/fontisan/svg/glyph_generator.rb +0 -2
  198. data/lib/fontisan/svg.rb +12 -0
  199. data/lib/fontisan/tables/cbdt.rb +0 -1
  200. data/lib/fontisan/tables/cblc.rb +0 -1
  201. data/lib/fontisan/tables/cff/charset.rb +0 -1
  202. data/lib/fontisan/tables/cff/charstring.rb +0 -1
  203. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +0 -4
  204. data/lib/fontisan/tables/cff/charstrings_index.rb +0 -3
  205. data/lib/fontisan/tables/cff/dict.rb +0 -1
  206. data/lib/fontisan/tables/cff/encoding.rb +0 -1
  207. data/lib/fontisan/tables/cff/header.rb +0 -2
  208. data/lib/fontisan/tables/cff/hint_operation_injector.rb +0 -2
  209. data/lib/fontisan/tables/cff/index.rb +0 -1
  210. data/lib/fontisan/tables/cff/private_dict.rb +0 -2
  211. data/lib/fontisan/tables/cff/private_dict_writer.rb +0 -2
  212. data/lib/fontisan/tables/cff/table_builder.rb +0 -6
  213. data/lib/fontisan/tables/cff/top_dict.rb +0 -2
  214. data/lib/fontisan/tables/cff.rb +22 -15
  215. data/lib/fontisan/tables/cff2/charstring_parser.rb +0 -2
  216. data/lib/fontisan/tables/cff2/table_builder.rb +0 -11
  217. data/lib/fontisan/tables/cff2/table_reader.rb +0 -2
  218. data/lib/fontisan/tables/cff2.rb +13 -14
  219. data/lib/fontisan/tables/cmap.rb +24 -2
  220. data/lib/fontisan/tables/cmap_table.rb +0 -3
  221. data/lib/fontisan/tables/colr.rb +0 -1
  222. data/lib/fontisan/tables/cpal.rb +0 -1
  223. data/lib/fontisan/tables/cvar.rb +0 -2
  224. data/lib/fontisan/tables/fvar.rb +0 -1
  225. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +0 -2
  226. data/lib/fontisan/tables/glyf/glyph_builder.rb +0 -3
  227. data/lib/fontisan/tables/glyf.rb +0 -6
  228. data/lib/fontisan/tables/glyf_table.rb +0 -3
  229. data/lib/fontisan/tables/gpos.rb +0 -2
  230. data/lib/fontisan/tables/gsub.rb +0 -2
  231. data/lib/fontisan/tables/gvar.rb +0 -2
  232. data/lib/fontisan/tables/head.rb +0 -2
  233. data/lib/fontisan/tables/head_table.rb +0 -3
  234. data/lib/fontisan/tables/hhea.rb +0 -2
  235. data/lib/fontisan/tables/hhea_table.rb +0 -3
  236. data/lib/fontisan/tables/hmtx.rb +0 -2
  237. data/lib/fontisan/tables/hmtx_table.rb +0 -3
  238. data/lib/fontisan/tables/hvar.rb +0 -3
  239. data/lib/fontisan/tables/loca.rb +0 -2
  240. data/lib/fontisan/tables/loca_table.rb +0 -3
  241. data/lib/fontisan/tables/maxp.rb +0 -2
  242. data/lib/fontisan/tables/maxp_table.rb +0 -3
  243. data/lib/fontisan/tables/mvar.rb +0 -3
  244. data/lib/fontisan/tables/name.rb +0 -2
  245. data/lib/fontisan/tables/name_table.rb +0 -3
  246. data/lib/fontisan/tables/os2_table.rb +0 -3
  247. data/lib/fontisan/tables/post_table.rb +0 -3
  248. data/lib/fontisan/tables/sbix.rb +0 -1
  249. data/lib/fontisan/tables/svg.rb +0 -1
  250. data/lib/fontisan/tables/variation_common.rb +0 -1
  251. data/lib/fontisan/tables/vvar.rb +0 -3
  252. data/lib/fontisan/tables.rb +54 -0
  253. data/lib/fontisan/true_type_collection.rb +6 -14
  254. data/lib/fontisan/true_type_font.rb +8 -2
  255. data/lib/fontisan/true_type_font_extensions.rb +9 -9
  256. data/lib/fontisan/type1/afm_generator.rb +0 -4
  257. data/lib/fontisan/type1/conversion_options.rb +0 -2
  258. data/lib/fontisan/type1/encodings.rb +0 -2
  259. data/lib/fontisan/type1/generator.rb +0 -8
  260. data/lib/fontisan/type1/pfa_generator.rb +0 -3
  261. data/lib/fontisan/type1/pfb_generator.rb +0 -5
  262. data/lib/fontisan/type1/pfm_generator.rb +0 -4
  263. data/lib/fontisan/type1.rb +42 -69
  264. data/lib/fontisan/type1_font.rb +40 -11
  265. data/lib/fontisan/ucd/aggregator.rb +73 -0
  266. data/lib/fontisan/ucd/cache_manager.rb +111 -0
  267. data/lib/fontisan/ucd/config.rb +59 -0
  268. data/lib/fontisan/ucd/download_error.rb +9 -0
  269. data/lib/fontisan/ucd/downloader.rb +88 -0
  270. data/lib/fontisan/ucd/error.rb +8 -0
  271. data/lib/fontisan/ucd/index.rb +103 -0
  272. data/lib/fontisan/ucd/index_builder.rb +107 -0
  273. data/lib/fontisan/ucd/range_entry.rb +56 -0
  274. data/lib/fontisan/ucd/unknown_version_error.rb +9 -0
  275. data/lib/fontisan/ucd/version_resolver.rb +79 -0
  276. data/lib/fontisan/ucd.rb +23 -0
  277. data/lib/fontisan/utilities/checksum_calculator.rb +0 -1
  278. data/lib/fontisan/utilities.rb +10 -0
  279. data/lib/fontisan/utils.rb +10 -0
  280. data/lib/fontisan/validation/collection_validator.rb +0 -2
  281. data/lib/fontisan/validation.rb +9 -0
  282. data/lib/fontisan/validators/basic_validator.rb +0 -2
  283. data/lib/fontisan/validators/font_book_validator.rb +0 -2
  284. data/lib/fontisan/validators/opentype_validator.rb +0 -2
  285. data/lib/fontisan/validators/profile_loader.rb +0 -5
  286. data/lib/fontisan/validators/validator.rb +0 -2
  287. data/lib/fontisan/validators/web_font_validator.rb +0 -2
  288. data/lib/fontisan/validators.rb +14 -0
  289. data/lib/fontisan/variable/delta_applicator.rb +0 -4
  290. data/lib/fontisan/variable/instancer.rb +0 -3
  291. data/lib/fontisan/variable/static_font_builder.rb +0 -3
  292. data/lib/fontisan/variable.rb +16 -0
  293. data/lib/fontisan/variation/blend_applier.rb +0 -2
  294. data/lib/fontisan/variation/cache.rb +0 -2
  295. data/lib/fontisan/variation/converter.rb +0 -3
  296. data/lib/fontisan/variation/data_extractor.rb +0 -2
  297. data/lib/fontisan/variation/delta_applier.rb +0 -5
  298. data/lib/fontisan/variation/inspector.rb +0 -1
  299. data/lib/fontisan/variation/instance_generator.rb +0 -6
  300. data/lib/fontisan/variation/instance_writer.rb +0 -5
  301. data/lib/fontisan/variation/metrics_adjuster.rb +0 -4
  302. data/lib/fontisan/variation/optimizer.rb +0 -3
  303. data/lib/fontisan/variation/parallel_generator.rb +0 -3
  304. data/lib/fontisan/variation/subsetter.rb +0 -4
  305. data/lib/fontisan/variation/tuple_variation_header.rb +0 -2
  306. data/lib/fontisan/variation/variable_svg_generator.rb +0 -3
  307. data/lib/fontisan/variation/variation_context.rb +0 -3
  308. data/lib/fontisan/variation/variation_preserver.rb +0 -3
  309. data/lib/fontisan/variation.rb +31 -0
  310. data/lib/fontisan/version.rb +1 -1
  311. data/lib/fontisan/woff2.rb +13 -0
  312. data/lib/fontisan/woff2_font.rb +31 -9
  313. data/lib/fontisan/woff_font.rb +31 -2
  314. data/lib/fontisan.rb +124 -196
  315. metadata +114 -7
  316. data/fontisan.gemspec +0 -48
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Fontisan
6
+ # Thor subcommand for managing the local UCD (Unicode Character
7
+ # Database) cache used by `fontisan audit`.
8
+ #
9
+ # fontisan ucd download [VERSION] fetch + index UCDXML
10
+ # fontisan ucd status show what's cached
11
+ # fontisan ucd path [VERSION] print local cache path
12
+ # fontisan ucd list list known versions
13
+ # fontisan ucd remove VERSION delete a cached version
14
+ #
15
+ # With no arguments, `download` resolves the configured default version
16
+ # (see lib/fontisan/config/ucd.yml).
17
+ class UcdCli < Thor
18
+ desc "download [VERSION]",
19
+ "Download and index UCDXML (default: configured default version)"
20
+ option :force, type: :boolean, default: false,
21
+ desc: "Re-download even if already cached"
22
+ option :latest, type: :boolean, default: false,
23
+ desc: "Probe unicode.org for the latest version"
24
+ # Download (and index) UCDXML for a version.
25
+ #
26
+ # @param version [String, nil] explicit version, or omit for default
27
+ def download(version = nil)
28
+ intent = resolve_intent(version, options[:latest])
29
+ actual = Ucd::VersionResolver.resolve(intent)
30
+
31
+ path = Ucd::Downloader.download(actual, force: options[:force])
32
+ Ucd::IndexBuilder.build(actual) unless index_present?(actual)
33
+ puts "UCD #{actual} ready at: #{path}"
34
+ rescue Ucd::Error => e
35
+ warn "ERROR: #{e.message}"
36
+ exit 1
37
+ end
38
+
39
+ desc "status", "Show cached UCD versions and default version"
40
+ # Print a one-screen summary of the local cache state.
41
+ def status
42
+ cached = Ucd::CacheManager.cached_versions
43
+ puts "Default version: #{Ucd::Config.default_version}"
44
+ puts "Cache root: #{Ucd::CacheManager.root}"
45
+ puts "Cached versions: #{cached.empty? ? '(none)' : cached.join(', ')}"
46
+ end
47
+
48
+ desc "path [VERSION]", "Print local cache directory for a version"
49
+ # Print the cache directory path for a version (default: default version).
50
+ #
51
+ # @param version [String, nil]
52
+ def path(version = nil)
53
+ actual = Ucd::VersionResolver.resolve(version)
54
+ puts Ucd::CacheManager.version_dir(actual)
55
+ rescue Ucd::UnknownVersionError => e
56
+ warn "ERROR: #{e.message}"
57
+ exit 1
58
+ end
59
+
60
+ desc "list", "List UCD versions known to this Fontisan release"
61
+ # Print the curated list of versions this Fontisan release supports.
62
+ def list
63
+ Ucd::Config.known_versions.each { |v| puts v }
64
+ end
65
+
66
+ desc "remove VERSION", "Remove a cached UCD version"
67
+ # Delete one cached version. No-op if absent.
68
+ #
69
+ # @param version [String]
70
+ def remove(version)
71
+ Ucd::VersionResolver.validate!(version)
72
+ unless Ucd::CacheManager.cached?(version)
73
+ warn "Version #{version} is not cached; nothing to remove."
74
+ return
75
+ end
76
+
77
+ Ucd::CacheManager.remove_version(version)
78
+ puts "Removed UCD #{version}."
79
+ rescue Ucd::UnknownVersionError => e
80
+ warn "ERROR: #{e.message}"
81
+ exit 1
82
+ end
83
+
84
+ private
85
+
86
+ def resolve_intent(version, latest)
87
+ return :latest if latest && version.nil?
88
+
89
+ version
90
+ end
91
+
92
+ def index_present?(version)
93
+ Ucd::CacheManager.blocks_index_path(version).exist? &&
94
+ Ucd::CacheManager.scripts_index_path(version).exist?
95
+ end
96
+ end
97
+ end
data/lib/fontisan/cli.rb CHANGED
@@ -25,6 +25,93 @@ module Fontisan
25
25
  desc: "Suppress non-error output",
26
26
  aliases: "-q"
27
27
 
28
+ desc "ucd", "Manage local UCD cache (subcommands)", hide: true
29
+ subcommand "ucd", UcdCli
30
+
31
+ desc "cldr", "Manage local CLDR cache (subcommands)", hide: true
32
+ subcommand "cldr", CldrCli
33
+
34
+ desc "audit PATH", "Produce a per-face font audit report, or diff/summarize"
35
+ long_desc <<~DESC
36
+ Produce a complete per-face font audit report covering identity, style,
37
+ metrics, coverage (Unicode blocks/scripts), licensing, hinting, color
38
+ capabilities, variable font detail, and OpenType layout features.
39
+
40
+ For TTC/OTC/dfont collections, one report per face is produced. Use
41
+ --output to write reports to disk; --font-index to audit a single face.
42
+
43
+ Variants:
44
+ fontisan audit FONT.ttf
45
+ fontisan audit COLLECTION.ttc
46
+ fontisan audit DIR/ --recursive --summary
47
+ fontisan audit --compare A.ttf B.ttf
48
+ fontisan audit --compare A.yaml B.yaml
49
+
50
+ Output formats: text (default), yaml, json.
51
+
52
+ Use --brief for a fast inventory pass that skips metrics, hinting,
53
+ color, variable-font detail, OpenType layout, and UCD/CLDR
54
+ aggregation — only identity, style, licensing, and codepoint coverage.
55
+ DESC
56
+ option :font_index, type: :numeric,
57
+ desc: "Audit only this face in a collection (default: all)"
58
+ option :all_codepoints, type: :boolean, default: false,
59
+ desc: "Include the full per-codepoint list " \
60
+ "(defaults to compact range view)"
61
+ option :ucd_version, type: :string,
62
+ desc: "UCD version to aggregate against " \
63
+ "(default: configured default; 'latest' to probe)"
64
+ option :with_language_coverage, type: :boolean, default: false,
65
+ desc: "Compute coverage % per CLDR language " \
66
+ "(requires CLDR cache; auto-downloads)"
67
+ option :cldr_version, type: :string,
68
+ desc: "CLDR version (default: configured default; " \
69
+ "'latest' to probe)"
70
+ option :brief, type: :boolean, default: false,
71
+ desc: "Skip metrics/hinting/color/layout/UCD/CLDR for a " \
72
+ "fast inventory pass"
73
+ option :compare, type: :boolean, default: false,
74
+ desc: "Diff two fonts or two saved reports " \
75
+ "(requires exactly two PATHs)"
76
+ option :recursive, type: :boolean, default: false,
77
+ desc: "Audit every font under a directory tree " \
78
+ "(library mode)"
79
+ option :summary, type: :boolean, default: false,
80
+ desc: "Produce a LibrarySummary over a directory of fonts"
81
+ option :output, type: :string,
82
+ desc: "Output directory (collections/library) or file " \
83
+ "(single font / compare)",
84
+ aliases: "-o"
85
+ # Produce a complete font audit report, or diff two fonts/reports,
86
+ # or summarize a whole library.
87
+ #
88
+ # @param paths [Array<String>] one path (audit/library), or two paths (--compare)
89
+ def audit(*paths)
90
+ raise Thor::Error, "audit requires one PATH (or two with --compare)" if paths.empty?
91
+
92
+ if options[:compare]
93
+ unless paths.length == 2
94
+ raise Thor::Error,
95
+ "audit --compare requires exactly two paths"
96
+ end
97
+
98
+ return run_compare(paths[0], paths[1])
99
+ end
100
+
101
+ raise Thor::Error, "audit requires exactly one PATH" unless paths.length == 1
102
+
103
+ path = paths[0]
104
+ if Dir.exist?(path) && !library_mode?(path)
105
+ raise Thor::Error,
106
+ "audit on a directory requires --recursive or --summary"
107
+ end
108
+ return run_library_audit(path) if library_mode?(path)
109
+
110
+ run_single_audit(path)
111
+ rescue Errno::ENOENT, Error, Thor::Error => e
112
+ handle_error(e)
113
+ end
114
+
28
115
  desc "info PATH", "Display font information"
29
116
  option :brief, type: :boolean, default: false,
30
117
  desc: "Brief mode - only essential info (5x faster, uses metadata loading)",
@@ -203,7 +290,9 @@ module Fontisan
203
290
 
204
291
  desc "convert FONT_FILE", "Convert font to different format"
205
292
  option :to, type: :string, required: true,
206
- desc: "Target format (ttf, otf, type1, t1, woff, woff2)",
293
+ desc: "Target format: ttf, otf, type1, t1, ttc, otc, dfont, svg, " \
294
+ "woff (zlib — works on all browsers incl. legacy), " \
295
+ "woff2 (Brotli — ~30% smaller, modern browsers only)",
207
296
  aliases: "-t"
208
297
  option :output, type: :string,
209
298
  desc: "Output file path (required unless --show-options)",
@@ -255,6 +344,19 @@ module Fontisan
255
344
  desc: "Enable table optimization"
256
345
  option :decompose_on_output, type: :boolean,
257
346
  desc: "Decompose on output (generating option)"
347
+ # Compression knobs — declared by each strategy (single source of truth:
348
+ # Converters::* strategies). Cross-format misuse is caught at convert time
349
+ # by FormatConverter.validate_options_for_target! with a clear ArgumentError.
350
+ option :zlib_level, type: :numeric,
351
+ desc: "WOFF only: zlib compression level (0–9, default 6)"
352
+ option :uncompressed, type: :boolean,
353
+ desc: "WOFF only: store tables uncompressed (legal per WOFF 1.0 §5.1)"
354
+ option :compression_threshold, type: :numeric,
355
+ desc: "WOFF only: skip compression for tables smaller than N bytes (default 100)"
356
+ option :brotli_quality, type: :numeric,
357
+ desc: "WOFF2 only: Brotli quality (0–11, default 11)"
358
+ option :transform_tables, type: :boolean,
359
+ desc: "WOFF2 only: apply glyf/loca and hmtx transformations"
258
360
  # Convert a font to a different format using the universal transformation pipeline.
259
361
  #
260
362
  # Supported conversions:
@@ -264,6 +366,14 @@ module Fontisan
264
366
  # - Variable fonts: Automatic variation preservation or instance generation
265
367
  # - Collections (TTC/OTC/dfont): Preserve mixed TTF+OTF by default, or standardize with --target-format
266
368
  #
369
+ # Web fonts — WOFF vs WOFF2:
370
+ # WOFF uses zlib compression and works on every browser that supports
371
+ # web fonts at all (IE9+, all evergreen browsers). WOFF2 uses Brotli and
372
+ # is ~30% smaller but requires modern browsers (Chrome 36+, Firefox 39+,
373
+ # Safari 12+, Edge 14+). Old browsers cannot decode Brotli, so for legacy
374
+ # support serve WOFF. Tune knobs with --zlib-level / --brotli-quality /
375
+ # --uncompressed (WOFF only) / --transform-tables (WOFF2 only).
376
+ #
267
377
  # Collection Format Support:
268
378
  # TTC, OTC, and dfont all support mixed TrueType and OpenType fonts. By default, original font formats
269
379
  # are preserved during collection conversion (--target-format preserve). Use --target-format ttf to
@@ -678,6 +788,96 @@ module Fontisan
678
788
  puts output unless options[:quiet]
679
789
  end
680
790
 
791
+ # Write audit reports to disk. If `target` is a directory (or there are
792
+ # multiple reports), one file per face is written under it. If `target`
793
+ # is a file path and there's exactly one report, that exact path is used.
794
+ #
795
+ # @param reports [Array<Models::Audit::AuditReport>]
796
+ # @param target [String] directory (collection) or file path (single)
797
+ # @param format [String] "yaml" or "json"
798
+ # @return [void]
799
+ def write_audit_outputs(reports, target, format)
800
+ sym_format = format.to_sym
801
+
802
+ if reports.one? && !Dir.exist?(target) && File.extname(target) != ""
803
+ File.write(target, serialize_report(reports.first, sym_format))
804
+ puts "Wrote #{target}" unless options[:quiet]
805
+ return
806
+ end
807
+
808
+ paths = Commands::AuditCommand.write_reports(reports, to: target,
809
+ format: sym_format)
810
+ paths.each { |p| puts "Wrote #{p}" unless options[:quiet] }
811
+ end
812
+
813
+ def run_single_audit(path)
814
+ cmd_options = options.dup
815
+ cmd_options.delete(:output)
816
+ # Audit's --brief selects a cheap extractor subset but still needs FULL
817
+ # font loading (Coverage reads cmap). Translate to :audit_brief so
818
+ # BaseCommand's :brief → METADATA shortcut does not fire.
819
+ cmd_options[:audit_brief] = cmd_options.delete(:brief) if cmd_options.key?(:brief)
820
+ command = Commands::AuditCommand.new(path, cmd_options)
821
+ reports = Array(command.run)
822
+
823
+ if options[:output]
824
+ write_audit_outputs(reports, options[:output], options[:format])
825
+ else
826
+ reports.each { |r| output_result(r) }
827
+ end
828
+ end
829
+
830
+ def run_compare(left_path, right_path)
831
+ cmd = Commands::AuditCompareCommand.new(left_path, right_path, options)
832
+ diff = cmd.run
833
+ return if options[:quiet]
834
+
835
+ if options[:output]
836
+ File.write(options[:output], serialize_report(diff, options[:format].to_sym))
837
+ puts "Wrote #{options[:output]}"
838
+ return
839
+ end
840
+
841
+ output_result(diff)
842
+ end
843
+
844
+ def run_library_audit(path)
845
+ cmd = Commands::AuditLibraryCommand.new(
846
+ path,
847
+ recursive: options[:recursive],
848
+ options: options.dup,
849
+ )
850
+ summary = cmd.run
851
+ announce_skipped(cmd.skipped)
852
+ return if options[:quiet]
853
+
854
+ if options[:output]
855
+ File.write(options[:output],
856
+ serialize_report(summary, options[:format].to_sym))
857
+ puts "Wrote #{options[:output]}"
858
+ return
859
+ end
860
+
861
+ output_result(summary)
862
+ end
863
+
864
+ # Library mode triggers when the path is a directory and either
865
+ # --recursive or --summary is requested. A single-file audit ignores
866
+ # both flags (a TTC is audited face-by-face via run_single_audit).
867
+ def library_mode?(path)
868
+ Dir.exist?(path) && (options[:recursive] || options[:summary])
869
+ end
870
+
871
+ def announce_skipped(skipped)
872
+ return if skipped.empty?
873
+
874
+ skipped.each { |p| warn "skipped #{p}" }
875
+ end
876
+
877
+ def serialize_report(report, format)
878
+ format == :json ? report.to_json : report.to_yaml
879
+ end
880
+
681
881
  # Format result as human-readable text.
682
882
  #
683
883
  # @param result [Object] The result object to format
@@ -720,7 +920,6 @@ module Fontisan
720
920
  #
721
921
  # @return [void]
722
922
  def list_available_tests
723
- require_relative "validators/profile_loader"
724
923
  profiles = Validators::ProfileLoader.all_profiles
725
924
  puts "Available validation profiles:"
726
925
  profiles.each do |profile_name, config|
@@ -1,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "table_analyzer"
4
- require_relative "table_deduplicator"
5
- require_relative "offset_calculator"
6
- require_relative "writer"
7
3
  require "yaml"
8
4
 
9
5
  module Fontisan
@@ -1,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../font_writer"
4
- require_relative "../error"
5
- require_relative "../validation/collection_validator"
6
-
7
3
  module Fontisan
8
4
  module Collection
9
5
  # DfontBuilder creates Apple dfont (Data Fork Font) resource fork structures
@@ -15,8 +15,6 @@ module Fontisan
15
15
  # @param fonts [Array<TrueTypeFont, OpenTypeFont>] Array of fonts
16
16
  # @return [Models::TableSharingInfo] Sharing statistics
17
17
  def calculate_table_sharing_for_fonts(fonts)
18
- require_relative "../models/table_sharing_info"
19
-
20
18
  # Build table hash map (checksum -> size)
21
19
  table_map = {}
22
20
  total_table_size = 0
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../constants"
4
- require_relative "../utilities/checksum_calculator"
5
-
6
3
  module Fontisan
7
4
  module Collection
8
5
  # CollectionWriter writes binary TTC/OTC files
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Autoload hub for the Fontisan::Collection namespace.
4
+
5
+ module Fontisan
6
+ module Collection
7
+ autoload :Builder, "fontisan/collection/builder"
8
+ autoload :DfontBuilder, "fontisan/collection/dfont_builder"
9
+ autoload :OffsetCalculator, "fontisan/collection/offset_calculator"
10
+ autoload :SharedLogic, "fontisan/collection/shared_logic"
11
+ autoload :TableAnalyzer, "fontisan/collection/table_analyzer"
12
+ autoload :TableDeduplicator, "fontisan/collection/table_deduplicator"
13
+ autoload :Writer, "fontisan/collection/writer"
14
+ end
15
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Fontisan
6
+ module Commands
7
+ # Produces a complete per-face font audit report.
8
+ #
9
+ # One AuditReport per face. For standalone fonts (TTF/OTF/WOFF/WOFF2),
10
+ # #run returns a single AuditReport. For collections (TTC/OTC/dfont),
11
+ # #run returns an Array<AuditReport> — one per face, in source order.
12
+ #
13
+ # The report is assembled by running every extractor in
14
+ # {Audit::Registry} against an {Audit::Context}. Each extractor
15
+ # owns one concern (provenance, identity, style, coverage,
16
+ # aggregations, …). Adding a new concern means adding one
17
+ # extractor class and one line in the registry — AuditCommand
18
+ # itself never changes.
19
+ class AuditCommand < BaseCommand
20
+ # @return [Models::Audit::AuditReport, Array<Models::Audit::AuditReport>]
21
+ def run
22
+ if FontLoader.collection?(@font_path)
23
+ audit_collection
24
+ else
25
+ audit_face(@font, 0, 1)
26
+ end
27
+ end
28
+
29
+ # Write one file per face under `to` (a directory). Pure utility —
30
+ # operates on a pre-built reports array, no font_path required.
31
+ #
32
+ # @param reports [Array<Models::Audit::AuditReport>]
33
+ # @param to [String] output directory; created if missing
34
+ # @param format [Symbol] :yaml or :json
35
+ # @return [Array<String>] written file paths
36
+ def self.write_reports(reports, to:, format: :yaml)
37
+ FileUtils.mkdir_p(to)
38
+
39
+ reports.map do |report|
40
+ path = File.join(to, output_filename(report, format))
41
+ content = format == :json ? report.to_json : report.to_yaml
42
+ File.write(path, content)
43
+ path
44
+ end
45
+ end
46
+
47
+ # Compute the per-face filename for a report.
48
+ #
49
+ # @param report [Models::Audit::AuditReport]
50
+ # @param format [Symbol] :yaml or :json
51
+ # @return [String] filename only (no directory)
52
+ def self.output_filename(report, format)
53
+ ext = format == :json ? "json" : "yaml"
54
+ base = if report.num_fonts_in_source == 1
55
+ safe_filename(report.postscript_name || report.family_name || "font")
56
+ else
57
+ format("%<idx>02d-%<name>s",
58
+ idx: report.font_index,
59
+ name: safe_filename(report.postscript_name || "face"))
60
+ end
61
+ "#{base}.#{ext}"
62
+ end
63
+
64
+ # Sanitize an arbitrary string into a filesystem-safe basename.
65
+ #
66
+ # @param name [String, nil]
67
+ # @return [String]
68
+ def self.safe_filename(name)
69
+ return "font" if name.nil? || name.empty?
70
+
71
+ name.gsub(/[^A-Za-z0-9._-]/, "_")
72
+ end
73
+
74
+ private
75
+
76
+ def audit_collection
77
+ collection = FontLoader.load_collection(@font_path)
78
+ num = collection.num_fonts
79
+ Array.new(num) do |index|
80
+ font = FontLoader.load(@font_path, font_index: index,
81
+ mode: LoadingModes::FULL)
82
+ audit_face(font, index, num)
83
+ end
84
+ end
85
+
86
+ def audit_face(font, font_index, num_fonts_in_source)
87
+ context = Audit::Context.new(
88
+ font: font,
89
+ font_path: @font_path,
90
+ font_index: font_index,
91
+ num_fonts_in_source: num_fonts_in_source,
92
+ options: @options,
93
+ )
94
+
95
+ fields = {}
96
+ Audit::Registry.each(mode: audit_mode) do |extractor_class|
97
+ fields.merge!(extractor_class.new.extract(context))
98
+ end
99
+
100
+ fields[:warning] = combine_warnings(
101
+ context.ucd[:warning],
102
+ context.cldr&.dig(:warning),
103
+ )
104
+
105
+ Models::Audit::AuditReport.new(**fields)
106
+ end
107
+
108
+ # Audit's --brief selects a cheap extractor subset (identity, style,
109
+ # licensing, coverage) but still requires FULL font loading — the
110
+ # Coverage extractor reads `cmap`. The CLI translates the user-facing
111
+ # `--brief` flag into `:audit_brief` so BaseCommand's `:brief →
112
+ # LoadingModes::METADATA` shortcut doesn't fire.
113
+ def audit_mode
114
+ @options[:audit_brief] ? :brief : :full
115
+ end
116
+
117
+ def combine_warnings(*warnings)
118
+ compacted = warnings.flatten.compact
119
+ compacted.empty? ? nil : compacted.join("; ")
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Commands
5
+ # Diffs two faces or two saved audit reports.
6
+ #
7
+ # Each input is one of:
8
+ # - A path to a `.yaml`/`.json` file previously written by
9
+ # `fontisan audit -o`. Loaded as an AuditReport.
10
+ # - A path to a font file. Audited on-the-fly via AuditCommand.
11
+ #
12
+ # Returns an {Models::Audit::AuditDiff}. The CLI renders it as
13
+ # YAML/JSON (text formatter lands in TODO 25).
14
+ #
15
+ # Mixed inputs are allowed (font vs. saved report), which is useful
16
+ # for tracking a font's evolution against a checked-in baseline.
17
+ class AuditCompareCommand
18
+ # @param left_path [String] path to font file or saved report
19
+ # @param right_path [String] path to font file or saved report
20
+ # @param options [Hash] forwarded to AuditCommand for any input
21
+ # that needs to be audited fresh
22
+ def initialize(left_path, right_path, options = {})
23
+ @left_path = left_path
24
+ @right_path = right_path
25
+ @options = options
26
+ end
27
+
28
+ # @return [Models::Audit::AuditDiff]
29
+ def run
30
+ left_report = load_report(@left_path)
31
+ right_report = load_report(@right_path)
32
+ Audit::Differ.new(left_report, right_report).diff
33
+ end
34
+
35
+ private
36
+
37
+ def load_report(path)
38
+ if saved_report?(path)
39
+ load_saved_report(path)
40
+ else
41
+ AuditCommand.new(path, audit_options).run
42
+ end
43
+ end
44
+
45
+ def saved_report?(path)
46
+ ext = File.extname(path).downcase
47
+ [".yaml", ".yml", ".json"].include?(ext)
48
+ end
49
+
50
+ def load_saved_report(path)
51
+ case File.extname(path).downcase
52
+ when ".json"
53
+ Models::Audit::AuditReport.from_json(File.read(path))
54
+ else
55
+ Models::Audit::AuditReport.from_yaml(File.read(path))
56
+ end
57
+ end
58
+
59
+ # Forward only the audit-relevant options when auditing fresh fonts.
60
+ # Drops `--compare` (consumed here) and `--output` (no file output).
61
+ def audit_options
62
+ @options.except(:compare, :output)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Commands
5
+ # Audits every font in a directory (tree) and rolls the per-face
6
+ # reports up into a {Models::Audit::LibrarySummary}.
7
+ #
8
+ # Thin wrapper over {Audit::LibraryAuditor}: validates the root
9
+ # path exists, delegates to the auditor, returns the summary.
10
+ # The auditor itself owns file discovery and per-face auditing;
11
+ # this command is the CLI-facing boundary that maps user-facing
12
+ # options onto auditor inputs.
13
+ class AuditLibraryCommand
14
+ # @param root_path [String] directory containing fonts
15
+ # @param recursive [Boolean] walk into subdirectories
16
+ # @param options [Hash] forwarded to AuditCommand for each face
17
+ def initialize(root_path, recursive:, options:)
18
+ @root_path = root_path
19
+ @recursive = recursive
20
+ @options = options
21
+ end
22
+
23
+ # @return [Models::Audit::LibrarySummary]
24
+ def run
25
+ raise Error, "library audit requires an existing directory: #{@root_path}" unless Dir.exist?(@root_path)
26
+
27
+ auditor.audit
28
+ end
29
+
30
+ # @return [Array<String>] files skipped during the audit pass
31
+ def skipped
32
+ auditor.skipped
33
+ end
34
+
35
+ private
36
+
37
+ def auditor
38
+ @auditor ||= Audit::LibraryAuditor.new(
39
+ @root_path,
40
+ recursive: @recursive,
41
+ options: @options,
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../font_loader"
4
- require_relative "../error"
5
-
6
3
  module Fontisan
7
4
  module Commands
8
5
  # Abstract base class for all CLI commands.