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,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Audit
7
+ module Extractors
8
+ # Hinting summary: TrueType bytecode counts + gasp policy + CFF stem
9
+ # count, with derived `is_unhinted` and `hinting_format` fields.
10
+ #
11
+ # Returned fields:
12
+ # hinting: Models::Audit::Hinting instance, or nil for Type 1
13
+ #
14
+ # The fpgm/prep/cvt/gasp tables have no BinData classes yet — they
15
+ # are read as raw bytes from `font.table_data`. Bytecode is one byte
16
+ # per instruction; cvt is an array of FWord (int16), so the entry
17
+ # count is bytesize / 2.
18
+ class Hinting < Base
19
+ # Raw CFF2 / CFF2 charstring operator bytes that declare stem hints.
20
+ HSTEM = 1
21
+ VSTEM = 3
22
+ HSTEMHM = 18
23
+ VSTEMHM = 23
24
+ HINTMASK = 19
25
+ CNTRMASK = 20
26
+
27
+ def extract(context)
28
+ font = context.font
29
+ return { hinting: nil } unless sfnt?(font)
30
+
31
+ { hinting: Models::Audit::Hinting.new(**gather(font)) }
32
+ end
33
+
34
+ protected
35
+
36
+ def sfnt?(font)
37
+ font.is_a?(SfntFont)
38
+ end
39
+
40
+ private
41
+
42
+ def gather(font)
43
+ tt = truetype_fields(font)
44
+ cff = cff_fields(font)
45
+ gasp = parse_gasp(font)
46
+
47
+ derived = Models::Audit::Hinting.derive_flags(
48
+ has_tt: tt[:has_fpgm] || tt[:has_prep] || tt[:has_cvt],
49
+ has_cff: cff[:cff_has_private_dict],
50
+ has_gasp: !gasp.empty?,
51
+ )
52
+
53
+ tt.merge(cff).merge(gasp_ranges: gasp).merge(derived)
54
+ end
55
+
56
+ def truetype_fields(font)
57
+ {
58
+ has_fpgm: font.has_table?(Constants::FPGM_TAG),
59
+ fpgm_instruction_count: byte_count(font, Constants::FPGM_TAG),
60
+ has_prep: font.has_table?(Constants::PREP_TAG),
61
+ prep_instruction_count: byte_count(font, Constants::PREP_TAG),
62
+ has_cvt: font.has_table?(Constants::CVT_TAG),
63
+ cvt_entry_count: cvt_entry_count(font),
64
+ has_cvar: font.has_table?(Constants::CVAR_TAG),
65
+ }
66
+ end
67
+
68
+ def cff_fields(font)
69
+ has_cff1 = font.has_table?(Constants::CFF_TAG)
70
+ has_cff2 = font.has_table?(Constants::CFF2_TAG)
71
+ has_private = has_cff1 || has_cff2
72
+
73
+ {
74
+ cff_has_private_dict: has_private,
75
+ cff_hint_count: has_cff1 ? count_cff_stems(font) : nil,
76
+ }
77
+ end
78
+
79
+ def byte_count(font, tag)
80
+ return nil unless font.has_table?(tag)
81
+
82
+ font.table_data[tag]&.bytesize
83
+ end
84
+
85
+ def cvt_entry_count(font)
86
+ return nil unless font.has_table?(Constants::CVT_TAG)
87
+
88
+ bytes = font.table_data[Constants::CVT_TAG]
89
+ return nil unless bytes
90
+
91
+ bytes.bytesize / 2
92
+ end
93
+
94
+ # Parse the gasp table from raw bytes. Format: uint16 version,
95
+ # uint16 numRanges, then numRanges × (uint16 rangeMaxPPEM,
96
+ # uint16 rangeFlags). Returns [] if gasp is absent or truncated.
97
+ def parse_gasp(font)
98
+ return [] unless font.has_table?(Constants::GASP_TAG)
99
+
100
+ data = font.table_data[Constants::GASP_TAG]
101
+ return [] unless data && data.bytesize >= 4
102
+
103
+ _version, num_ranges = data.unpack("nn")
104
+ ranges = []
105
+ offset = 4
106
+ num_ranges.times do
107
+ break if offset + 4 > data.bytesize
108
+
109
+ max_ppem, flags = data[offset, 4].unpack("nn")
110
+ ranges << Models::Audit::GaspRange.from_flags(max_ppem, flags)
111
+ offset += 4
112
+ end
113
+ ranges
114
+ end
115
+
116
+ def count_cff_stems(font)
117
+ return nil unless font.has_table?(Constants::CFF_TAG)
118
+
119
+ cff = font.table(Constants::CFF_TAG)
120
+ return nil unless cff
121
+
122
+ index = cff.charstrings_index(0)
123
+ return nil unless index
124
+
125
+ total = 0
126
+ index.count.times do |glyph_index|
127
+ data = index[glyph_index]
128
+ next unless data
129
+
130
+ total += count_stems_in_charstring(data)
131
+ end
132
+ total
133
+ rescue CorruptedTableError
134
+ nil
135
+ end
136
+
137
+ # Lightweight Type-2 CharString scanner that counts stem hints
138
+ # without instantiating a full CharString (which needs a Private
139
+ # DICT, global/local subrs, etc.). Operates purely on bytes.
140
+ def count_stems_in_charstring(data)
141
+ io = StringIO.new(data)
142
+ stack = 0
143
+ stems = 0
144
+
145
+ until io.eof?
146
+ byte = io.getbyte
147
+ next if byte.nil?
148
+
149
+ stack, stems = process_byte(io, byte, stack, stems)
150
+ end
151
+
152
+ stems
153
+ end
154
+
155
+ def process_byte(io, byte, stack, stems)
156
+ if operator_byte?(byte)
157
+ apply_operator(io, byte, stack, stems)
158
+ else
159
+ [consume_operand(io, byte, stack), stems]
160
+ end
161
+ end
162
+
163
+ def operator_byte?(byte)
164
+ byte <= 31 && byte != 28
165
+ end
166
+
167
+ def apply_operator(io, byte, stack, stems)
168
+ case byte
169
+ when 12
170
+ io.getbyte
171
+ [0, stems]
172
+ when HSTEM, VSTEM, HSTEMHM, VSTEMHM
173
+ [0, stems + stack / 2]
174
+ when HINTMASK, CNTRMASK
175
+ new_stems = stems + stack / 2
176
+ io.read((new_stems + 7) / 8)
177
+ [0, new_stems]
178
+ else
179
+ [0, stems]
180
+ end
181
+ end
182
+
183
+ def consume_operand(io, byte, stack)
184
+ case byte
185
+ when 28
186
+ io.read(2)
187
+ when 255
188
+ io.read(4)
189
+ when 247..254
190
+ io.getbyte
191
+ end
192
+ stack + 1
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Extractors
6
+ # Identity fields: the human-readable names a font uses to describe
7
+ # itself, drawn from the `name` table (SFNT) or font dictionary
8
+ # (Type 1).
9
+ #
10
+ # Returned fields:
11
+ # family_name, subfamily_name, full_name, postscript_name,
12
+ # version, font_revision
13
+ class Identity < Base
14
+ def extract(context)
15
+ if context.font.is_a?(Type1Font)
16
+ type1_identity(context.font)
17
+ else
18
+ sfnt_identity(context.font)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def sfnt_identity(font)
25
+ name_table = font.table(Constants::NAME_TAG) if font.has_table?(Constants::NAME_TAG)
26
+ head_table = font.table(Constants::HEAD_TAG) if font.has_table?(Constants::HEAD_TAG)
27
+
28
+ {
29
+ family_name: name_table&.english_name(Tables::Name::FAMILY),
30
+ subfamily_name: name_table&.english_name(Tables::Name::SUBFAMILY),
31
+ full_name: name_table&.english_name(Tables::Name::FULL_NAME),
32
+ postscript_name: name_table&.english_name(Tables::Name::POSTSCRIPT_NAME),
33
+ version: name_table&.english_name(Tables::Name::VERSION),
34
+ font_revision: head_table&.font_revision,
35
+ }
36
+ end
37
+
38
+ def type1_identity(font)
39
+ font_info = font.font_dictionary&.font_info
40
+ {
41
+ family_name: font_info&.family_name,
42
+ subfamily_name: nil,
43
+ full_name: font_info&.full_name,
44
+ postscript_name: font.font_name,
45
+ version: font_info&.version,
46
+ font_revision: nil,
47
+ }
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Extractors
6
+ # Per-language CLDR coverage for one face.
7
+ #
8
+ # Returned fields:
9
+ # language_coverage, cldr_version
10
+ #
11
+ # Opt-in only — `--with-language-coverage`. When off, Context#cldr
12
+ # returns nil and this extractor emits an empty array + nil version.
13
+ # MECE: this extractor is CLDR-driven; UCD block/script coverage
14
+ # lives in {Extractors::Aggregations}.
15
+ class LanguageCoverage < Base
16
+ def extract(context)
17
+ cldr = context.cldr
18
+ return empty(nil) if cldr.nil?
19
+
20
+ return empty(cldr[:version]) if cldr[:index].nil?
21
+
22
+ {
23
+ language_coverage: Cldr::Aggregator.aggregate(context.codepoints,
24
+ cldr[:index]),
25
+ cldr_version: cldr[:version],
26
+ }
27
+ end
28
+
29
+ private
30
+
31
+ def empty(version)
32
+ { language_coverage: [], cldr_version: version }
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Extractors
6
+ # Licensing + embedding permissions + vendor provenance.
7
+ #
8
+ # Returned fields:
9
+ # licensing: Models::Audit::Licensing instance, or nil for Type 1
10
+ #
11
+ # Type 1 fonts have no OS/2 table; their licensing is nil. WOFF/
12
+ # WOFF2 carry the same OS/2 + name tables as TTF/OTF and need no
13
+ # special handling.
14
+ class Licensing < Base
15
+ # nameID → AuditReport field name, per OpenType name table spec.
16
+ NAME_IDS = {
17
+ copyright: 0,
18
+ trademark: 7,
19
+ manufacturer: 8,
20
+ designer: 9,
21
+ description: 10,
22
+ vendor_url: 11,
23
+ designer_url: 12,
24
+ license_description: 13,
25
+ license_url: 14,
26
+ }.freeze
27
+ private_constant :NAME_IDS
28
+
29
+ def extract(context)
30
+ font = context.font
31
+ return { licensing: nil } unless sfnt?(font)
32
+
33
+ os2 = os2_for(font)
34
+ name = name_table_for(font)
35
+
36
+ {
37
+ licensing: Models::Audit::Licensing.new(
38
+ **name_fields(name),
39
+ vendor_id: sanitized_vendor_id(os2),
40
+ embedding_type: Models::Audit::EmbeddingType.decode(os2&.fs_type&.to_i),
41
+ fs_selection_flags: Models::Audit::FsSelectionFlags.decode(os2&.fs_selection&.to_i),
42
+ ),
43
+ }
44
+ end
45
+
46
+ private
47
+
48
+ def sfnt?(font)
49
+ font.is_a?(SfntFont)
50
+ end
51
+
52
+ def os2_for(font)
53
+ return nil unless font.has_table?(Constants::OS2_TAG)
54
+
55
+ font.table(Constants::OS2_TAG)
56
+ end
57
+
58
+ def name_table_for(font)
59
+ return nil unless font.has_table?(Constants::NAME_TAG)
60
+
61
+ font.table(Constants::NAME_TAG)
62
+ end
63
+
64
+ def name_fields(name)
65
+ return {} unless name
66
+
67
+ NAME_IDS.transform_values { |id| name.english_name(id) }
68
+ end
69
+
70
+ def sanitized_vendor_id(os2)
71
+ raw = os2&.ach_vend_id
72
+ return nil if raw.nil?
73
+
74
+ raw.gsub(/[\x00\s]+$/, "")
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Extractors
6
+ # Layout-critical metrics consolidated from head, hhea, OS/2, post.
7
+ #
8
+ # Returned fields:
9
+ # metrics: Models::Audit::Metrics instance, or nil for Type 1
10
+ #
11
+ # All table reads are nil-safe; tables may be absent in stripped
12
+ # WOFF builds or legacy formats.
13
+ class Metrics < Base
14
+ def extract(context)
15
+ font = context.font
16
+ return { metrics: nil } unless sfnt?(font)
17
+
18
+ { metrics: Models::Audit::Metrics.new(**gather(font)) }
19
+ end
20
+
21
+ private
22
+
23
+ def sfnt?(font)
24
+ font.is_a?(SfntFont)
25
+ end
26
+
27
+ def gather(font)
28
+ {}.tap do |h|
29
+ h.merge!(head_fields(font))
30
+ h.merge!(hhea_fields(font))
31
+ h.merge!(os2_fields(font))
32
+ h.merge!(post_fields(font))
33
+ end
34
+ end
35
+
36
+ def head_fields(font)
37
+ head = table(font, Constants::HEAD_TAG)
38
+ return {} unless head
39
+
40
+ {
41
+ units_per_em: head.units_per_em&.to_i,
42
+ bbox_x_min: head.x_min&.to_i,
43
+ bbox_y_min: head.y_min&.to_i,
44
+ bbox_x_max: head.x_max&.to_i,
45
+ bbox_y_max: head.y_max&.to_i,
46
+ }
47
+ end
48
+
49
+ def hhea_fields(font)
50
+ hhea = table(font, Constants::HHEA_TAG)
51
+ return {} unless hhea
52
+
53
+ {
54
+ hhea_ascent: hhea.ascent&.to_i,
55
+ hhea_descent: hhea.descent&.to_i,
56
+ hhea_line_gap: hhea.line_gap&.to_i,
57
+ }
58
+ end
59
+
60
+ def os2_fields(font)
61
+ os2 = table(font, Constants::OS2_TAG)
62
+ return {} unless os2
63
+
64
+ {
65
+ typo_ascender: os2.s_typo_ascender&.to_i,
66
+ typo_descender: os2.s_typo_descender&.to_i,
67
+ typo_line_gap: os2.s_typo_line_gap&.to_i,
68
+ win_ascent: os2.us_win_ascent&.to_i,
69
+ win_descent: os2.us_win_descent&.to_i,
70
+ x_height: os2.sx_height&.to_i,
71
+ cap_height: os2.s_cap_height&.to_i,
72
+ subscript_x_size: os2.y_subscript_x_size&.to_i,
73
+ subscript_y_size: os2.y_subscript_y_size&.to_i,
74
+ subscript_x_offset: os2.y_subscript_x_offset&.to_i,
75
+ subscript_y_offset: os2.y_subscript_y_offset&.to_i,
76
+ superscript_x_size: os2.y_superscript_x_size&.to_i,
77
+ superscript_y_size: os2.y_superscript_y_size&.to_i,
78
+ superscript_x_offset: os2.y_superscript_x_offset&.to_i,
79
+ superscript_y_offset: os2.y_superscript_y_offset&.to_i,
80
+ strikeout_size: os2.y_strikeout_size&.to_i,
81
+ strikeout_position: os2.y_strikeout_position&.to_i,
82
+ }
83
+ end
84
+
85
+ def post_fields(font)
86
+ post = table(font, Constants::POST_TAG)
87
+ return {} unless post
88
+
89
+ {
90
+ underline_position: post.underline_position&.to_f,
91
+ underline_thickness: post.underline_thickness&.to_f,
92
+ }
93
+ end
94
+
95
+ def table(font, tag)
96
+ return nil unless font.has_table?(tag)
97
+
98
+ font.table(tag)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Extractors
6
+ # OpenType layout summary: union of GSUB + GPOS scripts and features,
7
+ # plus a per-script breakdown preserving which feature belongs to
8
+ # which script under which table.
9
+ #
10
+ # Returned fields:
11
+ # opentype_layout: Models::Audit::OpenTypeLayout, or nil for
12
+ # Type 1
13
+ #
14
+ # Owned here (MECE split from Aggregations, which is UCD-only).
15
+ class OpenTypeLayout < Base
16
+ def extract(context)
17
+ font = context.font
18
+ return { opentype_layout: nil } unless sfnt?(font)
19
+
20
+ gsub_scripts = scripts_in(font, Constants::GSUB_TAG)
21
+ gpos_scripts = scripts_in(font, Constants::GPOS_TAG)
22
+ all_scripts = (gsub_scripts + gpos_scripts).uniq.sort
23
+
24
+ by_script = all_scripts.map do |tag|
25
+ Models::Audit::ScriptFeatures.new(
26
+ script: tag,
27
+ gsub_features: features_for(font, Constants::GSUB_TAG, tag),
28
+ gpos_features: features_for(font, Constants::GPOS_TAG, tag),
29
+ )
30
+ end
31
+
32
+ { opentype_layout: Models::Audit::OpenTypeLayout.new(
33
+ scripts: all_scripts,
34
+ features: aggregate_features(by_script),
35
+ by_script: by_script,
36
+ has_gsub: font.has_table?(Constants::GSUB_TAG),
37
+ has_gpos: font.has_table?(Constants::GPOS_TAG),
38
+ ) }
39
+ end
40
+
41
+ protected
42
+
43
+ def sfnt?(font)
44
+ font.is_a?(SfntFont)
45
+ end
46
+
47
+ private
48
+
49
+ def scripts_in(font, tag)
50
+ return [] unless font.has_table?(tag)
51
+
52
+ font.table(tag).scripts
53
+ end
54
+
55
+ def features_for(font, tag, script)
56
+ return [] unless font.has_table?(tag)
57
+
58
+ font.table(tag).features(script_tag: script).sort
59
+ end
60
+
61
+ def aggregate_features(by_script)
62
+ gsub = by_script.flat_map(&:gsub_features)
63
+ gpos = by_script.flat_map(&:gpos_features)
64
+ (gsub + gpos).uniq.sort
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "time"
5
+
6
+ module Fontisan
7
+ module Audit
8
+ module Extractors
9
+ # Provenance fields: who generated this report, when, from what.
10
+ #
11
+ # Returned fields:
12
+ # generated_at, fontisan_version, source_file, source_sha256,
13
+ # source_format, font_index, num_fonts_in_source
14
+ class Provenance < Base
15
+ def extract(context)
16
+ {
17
+ generated_at: Time.now.utc.iso8601,
18
+ fontisan_version: Fontisan::VERSION,
19
+ source_file: File.expand_path(context.font_path),
20
+ source_sha256: Digest::SHA256.file(context.font_path).hexdigest,
21
+ source_format: context.source_format,
22
+ font_index: context.font_index,
23
+ num_fonts_in_source: context.num_fonts_in_source,
24
+ }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Extractors
6
+ # Style fields: weight, width, italic/bold flags, Panose family
7
+ # classification.
8
+ #
9
+ # Returned fields:
10
+ # weight_class, width_class, italic, bold, panose
11
+ #
12
+ # Variable-font axis inventory lives in {Extractors::VariationDetail}
13
+ # (MECE: this extractor is the OS/2 + head specialist, that one owns
14
+ # everything fvar-derived).
15
+ #
16
+ # Delegates to {Audit::StyleExtractor} — the existing specialist
17
+ # class that owns the OS/2 + head interpretation rules.
18
+ class Style < Base
19
+ def extract(context)
20
+ style = StyleExtractor.new(context.font)
21
+ {
22
+ weight_class: style.weight_class,
23
+ width_class: style.width_class,
24
+ italic: style.italic,
25
+ bold: style.bold,
26
+ panose: style.panose,
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Extractors
6
+ # Variable-font detail: fvar axes + named instances + presence flags
7
+ # for every variation side-table (avar, cvar, HVAR, VVAR, MVAR, gvar).
8
+ #
9
+ # Returned fields:
10
+ # variation: Models::Audit::VariationDetail, or nil for non-variable
11
+ # faces and Type 1 fonts
12
+ #
13
+ # A face is considered variable iff the fvar table is present. CFF2
14
+ # outlines without fvar are not "variable" by this definition (they
15
+ # may carry variation data but no user-facing axes).
16
+ class VariationDetail < Base
17
+ def extract(context)
18
+ font = context.font
19
+ return { variation: nil } unless variable?(font)
20
+
21
+ fvar = font.table(Constants::FVAR_TAG)
22
+ return { variation: nil } unless fvar
23
+
24
+ name_table = font.has_table?(Constants::NAME_TAG) ? font.table(Constants::NAME_TAG) : nil
25
+ axis_tags = axis_tags_from(fvar)
26
+
27
+ { variation: Models::Audit::VariationDetail.new(
28
+ axes: build_axes(name_table, fvar),
29
+ named_instances: build_instances(name_table, fvar, axis_tags),
30
+ has_avar: font.has_table?(Constants::AVAR_TAG),
31
+ has_cvar: font.has_table?(Constants::CVAR_TAG),
32
+ has_hvar: font.has_table?(Constants::HVAR_TAG),
33
+ has_vvar: font.has_table?(Constants::VVAR_TAG),
34
+ has_mvar: font.has_table?(Constants::MVAR_TAG),
35
+ has_gvar: font.has_table?(Constants::GVAR_TAG),
36
+ ) }
37
+ end
38
+
39
+ protected
40
+
41
+ def variable?(font)
42
+ font.is_a?(SfntFont) && font.has_table?(Constants::FVAR_TAG)
43
+ end
44
+
45
+ private
46
+
47
+ def build_axes(name_table, fvar)
48
+ return [] unless fvar.axes
49
+
50
+ fvar.axes.map do |axis|
51
+ Models::Audit::AuditAxis.new(
52
+ tag: axis.axis_tag,
53
+ min_value: axis.min_value,
54
+ default_value: axis.default_value,
55
+ max_value: axis.max_value,
56
+ name: english_name(name_table, axis.axis_name_id),
57
+ )
58
+ end
59
+ end
60
+
61
+ def build_instances(name_table, fvar, axis_tags)
62
+ instances = fvar.instances
63
+ return [] unless instances
64
+
65
+ instances.map do |instance|
66
+ build_instance(name_table, instance, axis_tags)
67
+ end
68
+ end
69
+
70
+ def build_instance(name_table, instance, axis_tags)
71
+ subfamily_name = english_name(name_table, instance[:name_id])
72
+ ps_name_id = instance[:postscript_name_id]
73
+ ps_name = ps_name_id ? english_name(name_table, ps_name_id) : nil
74
+ coords = Models::Audit::NamedInstance.format_coordinates(
75
+ axis_tags, instance[:coordinates]
76
+ )
77
+
78
+ Models::Audit::NamedInstance.new(
79
+ subfamily_name: subfamily_name,
80
+ postscript_name: ps_name,
81
+ coordinates: coords,
82
+ )
83
+ end
84
+
85
+ def english_name(name_table, name_id)
86
+ return nil unless name_table && name_id
87
+
88
+ name_table.english_name(name_id)
89
+ end
90
+
91
+ def axis_tags_from(fvar)
92
+ return [] unless fvar.axes
93
+
94
+ fvar.axes.map(&:axis_tag)
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end