fontisan 0.2.16 → 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 (318) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +14 -90
  3. data/Gemfile +6 -3
  4. data/README.adoc +257 -1
  5. data/docs/.vitepress/config.ts +68 -8
  6. data/docs/.vitepress/theme/style.css +570 -272
  7. data/docs/CONVERSION_GUIDE.adoc +31 -8
  8. data/docs/EXTRACT_TTC_MIGRATION.md +1 -1
  9. data/docs/WOFF_WOFF2_FORMATS.adoc +53 -0
  10. data/docs/api/conversion-options.md +37 -14
  11. data/docs/api/font-loader.md +21 -15
  12. data/docs/cli/audit.md +337 -0
  13. data/docs/cli/convert.md +20 -1
  14. data/docs/cli/index.md +31 -0
  15. data/docs/guide/color.md +1 -1
  16. data/docs/guide/conversion/options.md +32 -3
  17. data/docs/guide/conversion/ttf-otf.md +1 -1
  18. data/docs/guide/conversion/type1.md +1 -1
  19. data/docs/guide/conversion/web.md +91 -32
  20. data/docs/guide/conversion.md +6 -5
  21. data/docs/guide/formats/woff.md +35 -11
  22. data/docs/guide/index.md +2 -2
  23. data/docs/guide/migrations/extract-ttc.md +1 -1
  24. data/docs/guide/quick-start.md +4 -4
  25. data/docs/guide/type1.md +4 -4
  26. data/docs/guide/woff.md +19 -17
  27. data/docs/index.md +2 -0
  28. data/docs/lychee.toml +5 -1
  29. data/docs/package.json +1 -1
  30. data/docs/public/robots.txt +4 -0
  31. data/docs/scripts/post-build.mjs +81 -0
  32. data/lib/fontisan/audit/codepoint_range_coalescer.rb +41 -0
  33. data/lib/fontisan/audit/context.rb +122 -0
  34. data/lib/fontisan/audit/differ.rb +124 -0
  35. data/lib/fontisan/audit/extractors/aggregations.rb +54 -0
  36. data/lib/fontisan/audit/extractors/base.rb +26 -0
  37. data/lib/fontisan/audit/extractors/color_capabilities.rb +141 -0
  38. data/lib/fontisan/audit/extractors/coverage.rb +48 -0
  39. data/lib/fontisan/audit/extractors/hinting.rb +197 -0
  40. data/lib/fontisan/audit/extractors/identity.rb +52 -0
  41. data/lib/fontisan/audit/extractors/language_coverage.rb +37 -0
  42. data/lib/fontisan/audit/extractors/licensing.rb +79 -0
  43. data/lib/fontisan/audit/extractors/metrics.rb +103 -0
  44. data/lib/fontisan/audit/extractors/opentype_layout.rb +69 -0
  45. data/lib/fontisan/audit/extractors/provenance.rb +29 -0
  46. data/lib/fontisan/audit/extractors/style.rb +32 -0
  47. data/lib/fontisan/audit/extractors/variation_detail.rb +99 -0
  48. data/lib/fontisan/audit/extractors.rb +27 -0
  49. data/lib/fontisan/audit/library_aggregator.rb +83 -0
  50. data/lib/fontisan/audit/library_auditor.rb +90 -0
  51. data/lib/fontisan/audit/registry.rb +60 -0
  52. data/lib/fontisan/audit/style_extractor.rb +80 -0
  53. data/lib/fontisan/audit.rb +20 -0
  54. data/lib/fontisan/base_collection.rb +23 -9
  55. data/lib/fontisan/binary/structures.rb +0 -2
  56. data/lib/fontisan/binary.rb +11 -0
  57. data/lib/fontisan/cldr/aggregator.rb +33 -0
  58. data/lib/fontisan/cldr/cache_manager.rb +110 -0
  59. data/lib/fontisan/cldr/config.rb +59 -0
  60. data/lib/fontisan/cldr/download_error.rb +9 -0
  61. data/lib/fontisan/cldr/downloader.rb +79 -0
  62. data/lib/fontisan/cldr/error.rb +8 -0
  63. data/lib/fontisan/cldr/index.rb +64 -0
  64. data/lib/fontisan/cldr/index_builder.rb +72 -0
  65. data/lib/fontisan/cldr/unicode_set_parser.rb +172 -0
  66. data/lib/fontisan/cldr/unknown_version_error.rb +9 -0
  67. data/lib/fontisan/cldr/version_resolver.rb +91 -0
  68. data/lib/fontisan/cldr.rb +23 -0
  69. data/lib/fontisan/cli/cldr_cli.rb +85 -0
  70. data/lib/fontisan/cli/ucd_cli.rb +97 -0
  71. data/lib/fontisan/cli.rb +201 -2
  72. data/lib/fontisan/collection/builder.rb +0 -4
  73. data/lib/fontisan/collection/dfont_builder.rb +0 -4
  74. data/lib/fontisan/collection/shared_logic.rb +0 -2
  75. data/lib/fontisan/collection/writer.rb +0 -3
  76. data/lib/fontisan/collection.rb +15 -0
  77. data/lib/fontisan/commands/audit_command.rb +123 -0
  78. data/lib/fontisan/commands/audit_compare_command.rb +66 -0
  79. data/lib/fontisan/commands/audit_library_command.rb +46 -0
  80. data/lib/fontisan/commands/base_command.rb +0 -3
  81. data/lib/fontisan/commands/convert_command.rb +25 -20
  82. data/lib/fontisan/commands/dump_table_command.rb +0 -3
  83. data/lib/fontisan/commands/export_command.rb +0 -4
  84. data/lib/fontisan/commands/features_command.rb +0 -3
  85. data/lib/fontisan/commands/instance_command.rb +0 -5
  86. data/lib/fontisan/commands/ls_command.rb +0 -6
  87. data/lib/fontisan/commands/optical_size_command.rb +0 -3
  88. data/lib/fontisan/commands/pack_command.rb +0 -5
  89. data/lib/fontisan/commands/scripts_command.rb +0 -2
  90. data/lib/fontisan/commands/subset_command.rb +0 -3
  91. data/lib/fontisan/commands/unicode_command.rb +0 -3
  92. data/lib/fontisan/commands/unpack_command.rb +0 -7
  93. data/lib/fontisan/commands/validate_command.rb +0 -8
  94. data/lib/fontisan/commands/variable_command.rb +0 -3
  95. data/lib/fontisan/commands.rb +29 -0
  96. data/lib/fontisan/config/cldr.yml +22 -0
  97. data/lib/fontisan/config/conversion_matrix.yml +38 -0
  98. data/lib/fontisan/config/ucd.yml +23 -0
  99. data/lib/fontisan/constants.rb +48 -6
  100. data/lib/fontisan/conversion_options.rb +30 -19
  101. data/lib/fontisan/converters/cff_table_builder.rb +0 -3
  102. data/lib/fontisan/converters/collection_converter.rb +0 -8
  103. data/lib/fontisan/converters/conversion_strategy.rb +161 -46
  104. data/lib/fontisan/converters/format_converter.rb +143 -32
  105. data/lib/fontisan/converters/glyf_table_builder.rb +0 -2
  106. data/lib/fontisan/converters/outline_converter.rb +0 -19
  107. data/lib/fontisan/converters/outline_extraction.rb +0 -5
  108. data/lib/fontisan/converters/outline_optimizer.rb +0 -5
  109. data/lib/fontisan/converters/svg_generator.rb +0 -4
  110. data/lib/fontisan/converters/table_copier.rb +0 -2
  111. data/lib/fontisan/converters/type1_converter.rb +0 -11
  112. data/lib/fontisan/converters/woff2_encoder.rb +49 -20
  113. data/lib/fontisan/converters/woff_writer.rb +211 -282
  114. data/lib/fontisan/converters.rb +21 -0
  115. data/lib/fontisan/dfont_collection.rb +29 -10
  116. data/lib/fontisan/export/exporter.rb +0 -6
  117. data/lib/fontisan/export/transformers/font_to_ttx.rb +0 -9
  118. data/lib/fontisan/export/transformers/head_transformer.rb +0 -2
  119. data/lib/fontisan/export/transformers/hhea_transformer.rb +0 -2
  120. data/lib/fontisan/export/transformers/maxp_transformer.rb +0 -2
  121. data/lib/fontisan/export/transformers/name_transformer.rb +0 -2
  122. data/lib/fontisan/export/transformers/os2_transformer.rb +0 -2
  123. data/lib/fontisan/export/transformers/post_transformer.rb +0 -2
  124. data/lib/fontisan/export/transformers.rb +17 -0
  125. data/lib/fontisan/export.rb +13 -0
  126. data/lib/fontisan/font_loader.rb +189 -328
  127. data/lib/fontisan/font_writer.rb +0 -1
  128. data/lib/fontisan/formatters/audit_diff_text_renderer.rb +122 -0
  129. data/lib/fontisan/formatters/audit_text_renderer.rb +324 -0
  130. data/lib/fontisan/formatters/library_summary_text_renderer.rb +99 -0
  131. data/lib/fontisan/formatters/text_formatter.rb +6 -0
  132. data/lib/fontisan/formatters.rb +12 -0
  133. data/lib/fontisan/hints/hint_converter.rb +0 -1
  134. data/lib/fontisan/hints/postscript_hint_applier.rb +0 -9
  135. data/lib/fontisan/hints/postscript_hint_extractor.rb +0 -2
  136. data/lib/fontisan/hints/truetype_hint_extractor.rb +0 -2
  137. data/lib/fontisan/hints.rb +16 -0
  138. data/lib/fontisan/metrics_calculator.rb +0 -2
  139. data/lib/fontisan/models/all_scripts_features_info.rb +0 -1
  140. data/lib/fontisan/models/audit/audit_axis.rb +30 -0
  141. data/lib/fontisan/models/audit/audit_block.rb +32 -0
  142. data/lib/fontisan/models/audit/audit_diff.rb +77 -0
  143. data/lib/fontisan/models/audit/audit_report.rb +153 -0
  144. data/lib/fontisan/models/audit/codepoint_range.rb +40 -0
  145. data/lib/fontisan/models/audit/codepoint_set_diff.rb +34 -0
  146. data/lib/fontisan/models/audit/color_capabilities.rb +93 -0
  147. data/lib/fontisan/models/audit/duplicate_group.rb +23 -0
  148. data/lib/fontisan/models/audit/embedding_type.rb +76 -0
  149. data/lib/fontisan/models/audit/field_change.rb +28 -0
  150. data/lib/fontisan/models/audit/fs_selection_flags.rb +61 -0
  151. data/lib/fontisan/models/audit/gasp_range.rb +63 -0
  152. data/lib/fontisan/models/audit/hinting.rb +93 -0
  153. data/lib/fontisan/models/audit/library_summary.rb +40 -0
  154. data/lib/fontisan/models/audit/licensing.rb +48 -0
  155. data/lib/fontisan/models/audit/metrics.rb +111 -0
  156. data/lib/fontisan/models/audit/named_instance.rb +41 -0
  157. data/lib/fontisan/models/audit/opentype_layout.rb +40 -0
  158. data/lib/fontisan/models/audit/script_coverage_row.rb +26 -0
  159. data/lib/fontisan/models/audit/script_features.rb +28 -0
  160. data/lib/fontisan/models/audit/variation_detail.rb +44 -0
  161. data/lib/fontisan/models/audit.rb +33 -0
  162. data/lib/fontisan/models/cldr/language_coverage.rb +31 -0
  163. data/lib/fontisan/models/cldr.rb +12 -0
  164. data/lib/fontisan/models/collection_brief_info.rb +0 -1
  165. data/lib/fontisan/models/collection_info.rb +0 -2
  166. data/lib/fontisan/models/collection_list_info.rb +0 -1
  167. data/lib/fontisan/models/collection_validation_report.rb +0 -2
  168. data/lib/fontisan/models/color_glyph.rb +0 -1
  169. data/lib/fontisan/models/font_report.rb +0 -1
  170. data/lib/fontisan/models/ttx/tables.rb +21 -0
  171. data/lib/fontisan/models/ttx/ttfont.rb +0 -8
  172. data/lib/fontisan/models/ttx.rb +14 -0
  173. data/lib/fontisan/models/ucd/ucd.rb +38 -0
  174. data/lib/fontisan/models/ucd/ucd_char.rb +67 -0
  175. data/lib/fontisan/models/ucd.rb +19 -0
  176. data/lib/fontisan/models.rb +47 -0
  177. data/lib/fontisan/open_type_collection.rb +6 -5
  178. data/lib/fontisan/open_type_font.rb +8 -2
  179. data/lib/fontisan/open_type_font_extensions.rb +9 -9
  180. data/lib/fontisan/optimizers/pattern_analyzer.rb +0 -1
  181. data/lib/fontisan/optimizers.rb +14 -0
  182. data/lib/fontisan/outline_extractor.rb +0 -2
  183. data/lib/fontisan/parsers/dfont_parser.rb +0 -1
  184. data/lib/fontisan/parsers.rb +10 -0
  185. data/lib/fontisan/pipeline/format_detector.rb +29 -102
  186. data/lib/fontisan/pipeline/output_writer.rb +11 -9
  187. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +0 -4
  188. data/lib/fontisan/pipeline/strategies/named_strategy.rb +0 -4
  189. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +0 -2
  190. data/lib/fontisan/pipeline/strategies.rb +14 -0
  191. data/lib/fontisan/pipeline/transformation_pipeline.rb +0 -7
  192. data/lib/fontisan/pipeline/variation_resolver.rb +0 -7
  193. data/lib/fontisan/pipeline.rb +13 -0
  194. data/lib/fontisan/sfnt_font.rb +29 -14
  195. data/lib/fontisan/sfnt_table.rb +0 -4
  196. data/lib/fontisan/subset/builder.rb +0 -6
  197. data/lib/fontisan/subset.rb +13 -0
  198. data/lib/fontisan/svg/font_generator.rb +0 -4
  199. data/lib/fontisan/svg/glyph_generator.rb +0 -2
  200. data/lib/fontisan/svg.rb +12 -0
  201. data/lib/fontisan/tables/cbdt.rb +0 -1
  202. data/lib/fontisan/tables/cblc.rb +0 -1
  203. data/lib/fontisan/tables/cff/charset.rb +0 -1
  204. data/lib/fontisan/tables/cff/charstring.rb +0 -1
  205. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +0 -4
  206. data/lib/fontisan/tables/cff/charstrings_index.rb +0 -3
  207. data/lib/fontisan/tables/cff/dict.rb +0 -1
  208. data/lib/fontisan/tables/cff/encoding.rb +0 -1
  209. data/lib/fontisan/tables/cff/header.rb +0 -2
  210. data/lib/fontisan/tables/cff/hint_operation_injector.rb +0 -2
  211. data/lib/fontisan/tables/cff/index.rb +0 -1
  212. data/lib/fontisan/tables/cff/private_dict.rb +0 -2
  213. data/lib/fontisan/tables/cff/private_dict_writer.rb +0 -2
  214. data/lib/fontisan/tables/cff/table_builder.rb +0 -6
  215. data/lib/fontisan/tables/cff/top_dict.rb +0 -2
  216. data/lib/fontisan/tables/cff.rb +22 -15
  217. data/lib/fontisan/tables/cff2/charstring_parser.rb +0 -2
  218. data/lib/fontisan/tables/cff2/table_builder.rb +0 -11
  219. data/lib/fontisan/tables/cff2/table_reader.rb +0 -2
  220. data/lib/fontisan/tables/cff2.rb +13 -14
  221. data/lib/fontisan/tables/cmap.rb +24 -2
  222. data/lib/fontisan/tables/cmap_table.rb +0 -3
  223. data/lib/fontisan/tables/colr.rb +0 -1
  224. data/lib/fontisan/tables/cpal.rb +0 -1
  225. data/lib/fontisan/tables/cvar.rb +0 -2
  226. data/lib/fontisan/tables/fvar.rb +0 -1
  227. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +0 -2
  228. data/lib/fontisan/tables/glyf/glyph_builder.rb +0 -3
  229. data/lib/fontisan/tables/glyf.rb +0 -6
  230. data/lib/fontisan/tables/glyf_table.rb +0 -3
  231. data/lib/fontisan/tables/gpos.rb +0 -2
  232. data/lib/fontisan/tables/gsub.rb +0 -2
  233. data/lib/fontisan/tables/gvar.rb +0 -2
  234. data/lib/fontisan/tables/head.rb +0 -2
  235. data/lib/fontisan/tables/head_table.rb +0 -3
  236. data/lib/fontisan/tables/hhea.rb +0 -2
  237. data/lib/fontisan/tables/hhea_table.rb +0 -3
  238. data/lib/fontisan/tables/hmtx.rb +0 -2
  239. data/lib/fontisan/tables/hmtx_table.rb +0 -3
  240. data/lib/fontisan/tables/hvar.rb +0 -3
  241. data/lib/fontisan/tables/loca.rb +0 -2
  242. data/lib/fontisan/tables/loca_table.rb +0 -3
  243. data/lib/fontisan/tables/maxp.rb +0 -2
  244. data/lib/fontisan/tables/maxp_table.rb +0 -3
  245. data/lib/fontisan/tables/mvar.rb +0 -3
  246. data/lib/fontisan/tables/name.rb +0 -2
  247. data/lib/fontisan/tables/name_table.rb +0 -3
  248. data/lib/fontisan/tables/os2_table.rb +0 -3
  249. data/lib/fontisan/tables/post_table.rb +0 -3
  250. data/lib/fontisan/tables/sbix.rb +0 -1
  251. data/lib/fontisan/tables/svg.rb +0 -1
  252. data/lib/fontisan/tables/variation_common.rb +0 -1
  253. data/lib/fontisan/tables/vvar.rb +0 -3
  254. data/lib/fontisan/tables.rb +54 -0
  255. data/lib/fontisan/true_type_collection.rb +6 -14
  256. data/lib/fontisan/true_type_font.rb +8 -2
  257. data/lib/fontisan/true_type_font_extensions.rb +9 -9
  258. data/lib/fontisan/type1/afm_generator.rb +0 -4
  259. data/lib/fontisan/type1/conversion_options.rb +0 -2
  260. data/lib/fontisan/type1/encodings.rb +0 -2
  261. data/lib/fontisan/type1/generator.rb +0 -8
  262. data/lib/fontisan/type1/pfa_generator.rb +0 -3
  263. data/lib/fontisan/type1/pfb_generator.rb +0 -5
  264. data/lib/fontisan/type1/pfm_generator.rb +0 -4
  265. data/lib/fontisan/type1.rb +42 -69
  266. data/lib/fontisan/type1_font.rb +40 -11
  267. data/lib/fontisan/ucd/aggregator.rb +73 -0
  268. data/lib/fontisan/ucd/cache_manager.rb +111 -0
  269. data/lib/fontisan/ucd/config.rb +59 -0
  270. data/lib/fontisan/ucd/download_error.rb +9 -0
  271. data/lib/fontisan/ucd/downloader.rb +88 -0
  272. data/lib/fontisan/ucd/error.rb +8 -0
  273. data/lib/fontisan/ucd/index.rb +103 -0
  274. data/lib/fontisan/ucd/index_builder.rb +107 -0
  275. data/lib/fontisan/ucd/range_entry.rb +56 -0
  276. data/lib/fontisan/ucd/unknown_version_error.rb +9 -0
  277. data/lib/fontisan/ucd/version_resolver.rb +79 -0
  278. data/lib/fontisan/ucd.rb +23 -0
  279. data/lib/fontisan/utilities/checksum_calculator.rb +0 -1
  280. data/lib/fontisan/utilities.rb +10 -0
  281. data/lib/fontisan/utils.rb +10 -0
  282. data/lib/fontisan/validation/collection_validator.rb +0 -2
  283. data/lib/fontisan/validation.rb +9 -0
  284. data/lib/fontisan/validators/basic_validator.rb +0 -2
  285. data/lib/fontisan/validators/font_book_validator.rb +0 -2
  286. data/lib/fontisan/validators/opentype_validator.rb +0 -2
  287. data/lib/fontisan/validators/profile_loader.rb +0 -5
  288. data/lib/fontisan/validators/validator.rb +0 -2
  289. data/lib/fontisan/validators/web_font_validator.rb +0 -2
  290. data/lib/fontisan/validators.rb +14 -0
  291. data/lib/fontisan/variable/delta_applicator.rb +0 -4
  292. data/lib/fontisan/variable/instancer.rb +0 -3
  293. data/lib/fontisan/variable/static_font_builder.rb +0 -3
  294. data/lib/fontisan/variable.rb +16 -0
  295. data/lib/fontisan/variation/blend_applier.rb +0 -2
  296. data/lib/fontisan/variation/cache.rb +0 -2
  297. data/lib/fontisan/variation/converter.rb +0 -3
  298. data/lib/fontisan/variation/data_extractor.rb +0 -2
  299. data/lib/fontisan/variation/delta_applier.rb +0 -5
  300. data/lib/fontisan/variation/inspector.rb +0 -1
  301. data/lib/fontisan/variation/instance_generator.rb +0 -6
  302. data/lib/fontisan/variation/instance_writer.rb +0 -5
  303. data/lib/fontisan/variation/metrics_adjuster.rb +0 -4
  304. data/lib/fontisan/variation/optimizer.rb +0 -3
  305. data/lib/fontisan/variation/parallel_generator.rb +0 -3
  306. data/lib/fontisan/variation/subsetter.rb +0 -4
  307. data/lib/fontisan/variation/tuple_variation_header.rb +0 -2
  308. data/lib/fontisan/variation/variable_svg_generator.rb +0 -3
  309. data/lib/fontisan/variation/variation_context.rb +0 -3
  310. data/lib/fontisan/variation/variation_preserver.rb +0 -3
  311. data/lib/fontisan/variation.rb +31 -0
  312. data/lib/fontisan/version.rb +1 -1
  313. data/lib/fontisan/woff2.rb +13 -0
  314. data/lib/fontisan/woff2_font.rb +31 -9
  315. data/lib/fontisan/woff_font.rb +31 -2
  316. data/lib/fontisan.rb +124 -196
  317. metadata +128 -7
  318. data/fontisan.gemspec +0 -47
@@ -1,41 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "zlib"
4
- require_relative "conversion_strategy"
5
- require_relative "../utilities/checksum_calculator"
6
4
 
7
5
  module Fontisan
8
6
  module Converters
9
7
  # WOFF font writer for creating WOFF files from TTF/OTF fonts
10
8
  #
11
- # [`WoffWriter`](lib/fontisan/converters/woff_writer.rb) handles conversion
12
- # from TrueType/OpenType fonts to WOFF format using zlib compression.
13
- # This implements the WOFF 1.0 specification for web font optimization.
9
+ # [`WoffWriter`](lib/fontisan/converters/woff_writer.rb) converts
10
+ # TrueType/OpenType fonts to WOFF 1.0 format. The WOFF spec mandates zlib
11
+ # compression; this writer exposes the spec-legal knobs only:
14
12
  #
15
- # **WOFF Format Features:**
16
- # - Individual table compression with zlib
17
- # - Optional metadata block (compressed XML)
18
- # - Optional private data block
19
- # - Proper header and table directory structure
20
- # - Cross-platform compatibility
13
+ # - `zlib_level` (0–9) — zlib compression level
14
+ # - `uncompressed` (bool) store tables uncompressed (legal per WOFF 1.0
15
+ # §5.1; `compLength == origLength`)
16
+ # - `compression_threshold` (bytes) skip compression for tables smaller
17
+ # than N bytes (rarely needed; keeps tiny tables uncompressed)
18
+ # - `metadata_xml` (string) — optional metadata block
19
+ # - `private_data` (string) — optional private data block
21
20
  #
22
- # **Compression Strategy:**
23
- # - Each table is compressed individually for optimal ratios
24
- # - Tables smaller than compression threshold remain uncompressed
25
- # - Metadata and private data are compressed when present
26
- # - All data is properly aligned and padded
21
+ # Cross-format options (e.g., `brotli_quality`) are rejected by
22
+ # ConversionStrategy#validate_options! see {ConversionStrategy}.
27
23
  #
28
- # @example Converting TTF to WOFF
29
- # writer = Fontisan::Converters::WoffWriter.new
30
- # woff_data = writer.write_font(ttf_font, metadata: xml_metadata)
31
- # File.write("output.woff", woff_data)
24
+ # @example Convert TTF to WOFF with max zlib
25
+ # writer = WoffWriter.new
26
+ # woff = writer.convert(ttf_font, zlib_level: 9)
27
+ # File.binwrite("out.woff", woff)
32
28
  #
33
- # @example With compression options
34
- # writer = Fontisan::Converters::WoffWriter.new(
35
- # compression_level: 9, # Maximum compression
36
- # compression_threshold: 100 # Bytes - tables smaller than this stay uncompressed
37
- # )
38
- # woff_data = writer.write_font(ttf_font)
29
+ # @example Uncompressed WOFF (legal per spec; useful for tooling pipelines)
30
+ # writer = WoffWriter.new
31
+ # woff = writer.convert(ttf_font, uncompressed: true)
39
32
  class WoffWriter
40
33
  include ConversionStrategy
41
34
 
@@ -46,57 +39,57 @@ module Fontisan
46
39
  WOFF_VERSION_MAJOR = 1
47
40
  WOFF_VERSION_MINOR = 0
48
41
 
49
- # Default compression settings
50
- DEFAULT_COMPRESSION_LEVEL = 6
51
- DEFAULT_COMPRESSION_THRESHOLD = 100 # bytes - don't compress smaller tables
52
-
53
- # Compression level (0-9, where 9 is maximum)
54
- attr_accessor :compression_level
55
-
56
- # Minimum table size to compress (bytes)
57
- attr_accessor :compression_threshold
58
-
59
- # Optional metadata XML
60
- attr_accessor :metadata_xml
61
-
62
- # Optional private data
63
- attr_accessor :private_data
64
-
65
- # Initialize writer with compression options
66
- #
67
- # @param options [Hash] Writer options
68
- # @option options [Integer] :compression_level zlib compression level (0-9)
69
- # @option options [Integer] :compression_threshold minimum table size to compress
70
- # @option options [String] :metadata_xml optional metadata XML
71
- # @option options [String] :private_data optional private data
72
- def initialize(options = {})
73
- @compression_level = options[:compression_level] || DEFAULT_COMPRESSION_LEVEL
74
- @compression_threshold = options[:compression_threshold] || DEFAULT_COMPRESSION_THRESHOLD
75
- @metadata_xml = options[:metadata_xml]
76
- @private_data = options[:private_data]
77
-
78
- validate_compression_level!
79
- end
80
-
81
- # Convert font to WOFF format
42
+ option :zlib_level, type: :integer, range: 0..9, default: 6,
43
+ cli: "--zlib-level=N",
44
+ desc: "zlib compression level (0=fastest, 9=smallest)"
45
+ option :uncompressed, type: :boolean, default: false,
46
+ cli: "--uncompressed",
47
+ desc: "store tables uncompressed (legal per WOFF 1.0 §5.1)"
48
+ option :compression_threshold, type: :integer,
49
+ range: 0..(2**31 - 1),
50
+ default: 100,
51
+ cli: "--compression-threshold=N",
52
+ desc: "skip compression for tables smaller than N bytes"
53
+ option :metadata_xml, type: :string, default: nil,
54
+ cli: "--metadata-xml=XML",
55
+ desc: "optional metadata XML block"
56
+ option :private_data, type: :string, default: nil,
57
+ cli: "--private-data=DATA",
58
+ desc: "optional private data block"
59
+
60
+ # Initialize writer. The writer is stateless per call; all knobs come
61
+ # through the per-convert options hash.
62
+ def initialize; end
63
+
64
+ # Convert font to WOFF format.
82
65
  #
83
66
  # @param font [TrueTypeFont, OpenTypeFont] Source font
84
- # @param options [Hash] Additional options for this conversion
67
+ # @param options [Hash{Symbol => Object}] Per-call options; see declared
68
+ # options above. Unknown keys (framework metadata like
69
+ # `target_format`) are tolerated silently. Cross-format misuse
70
+ # (`brotli_quality` on a WOFF target) is caught upstream by
71
+ # `FormatConverter.validate_options_for_target!`.
85
72
  # @return [String] WOFF file data as binary string
86
- # @raise [ArgumentError] if font is invalid
73
+ # @raise [ArgumentError] if any declared option fails validation
74
+ # @raise [ArgumentError] if font does not respond to required methods
87
75
  def convert(font, options = {})
88
- validate_font(font)
89
-
90
- # Override instance options with per-conversion options
91
- metadata = options[:metadata_xml] || @metadata_xml
92
- private_data = options[:private_data] || @private_data
93
-
94
- write_font(font, metadata: metadata, private_data: private_data)
76
+ self.class.validate_options!(strategy_options(options))
77
+ validate(font, :woff)
78
+
79
+ opts = self.class.default_options.merge(strategy_options(options))
80
+ write_font(
81
+ font,
82
+ zlib_level: opts[:zlib_level],
83
+ uncompressed: opts[:uncompressed],
84
+ compression_threshold: opts[:compression_threshold],
85
+ metadata: opts[:metadata_xml],
86
+ private_data: opts[:private_data],
87
+ )[:woff_binary]
95
88
  end
96
89
 
97
- # Get supported conversions
90
+ # Get supported conversions.
98
91
  #
99
- # @return [Array<Array<Symbol>>] Supported conversion pairs
92
+ # @return [Array<Array<Symbol>>] Pairs this strategy handles
100
93
  def supported_conversions
101
94
  [
102
95
  %i[ttf woff],
@@ -104,40 +97,19 @@ module Fontisan
104
97
  ]
105
98
  end
106
99
 
107
- # Write font data to WOFF format
100
+ # Validate that the given font can be converted to WOFF.
108
101
  #
109
- # @param font [TrueTypeFont, OpenTypeFont] Source font
110
- # @param metadata [String, nil] Optional metadata XML
111
- # @param private_data [String, nil] Optional private data
112
- # @return [String] WOFF file data
113
- def write_font(font, metadata: nil, private_data: nil)
114
- # Collect all table data from font
115
- tables_data = collect_tables_data(font)
116
-
117
- # Compress tables
118
- compressed_tables = compress_tables(tables_data)
119
-
120
- # Build WOFF file
121
- build_woff_file(compressed_tables, font, metadata, private_data)
122
- end
123
-
124
- private
125
-
126
- # Validate compression level
127
- #
128
- # @raise [ArgumentError] if compression level is invalid
129
- def validate_compression_level!
130
- unless @compression_level.between?(0, 9)
131
- raise ArgumentError,
132
- "Compression level must be between 0 and 9, got #{@compression_level}"
102
+ # @param font [Object] Font to validate
103
+ # @param target_format [Symbol] Must be :woff
104
+ # @return [Boolean]
105
+ # @raise [ArgumentError] if font is nil or missing required methods
106
+ # @raise [Fontisan::Error] if target_format is not :woff
107
+ def validate(font, target_format)
108
+ unless target_format == :woff
109
+ raise Fontisan::Error,
110
+ "WoffWriter only supports conversion to woff, got: #{target_format}"
133
111
  end
134
- end
135
112
 
136
- # Validate font for conversion
137
- #
138
- # @param font [TrueTypeFont, OpenTypeFont] Font to validate
139
- # @raise [ArgumentError] if font is invalid
140
- def validate_font(font)
141
113
  raise ArgumentError, "Font cannot be nil" if font.nil?
142
114
 
143
115
  unless font.respond_to?(:tables) && font.respond_to?(:table_data)
@@ -145,251 +117,208 @@ module Fontisan
145
117
  end
146
118
  end
147
119
 
148
- # Collect all table data from font
120
+ # Write font to WOFF binary.
149
121
  #
150
122
  # @param font [TrueTypeFont, OpenTypeFont] Source font
151
- # @return [Hash<String, String>] Map of table tags to binary data
152
- def collect_tables_data(font)
153
- tables_data = {}
123
+ # @param zlib_level [Integer] 0–9
124
+ # @param uncompressed [Boolean] skip zlib; store as-is
125
+ # @param compression_threshold [Integer] skip compression below N bytes
126
+ # @param metadata [String, nil] optional metadata XML
127
+ # @param private_data [String, nil] optional private data
128
+ # @return [Hash{Symbol => String}] `{ woff_binary: <bytes> }`
129
+ def write_font(font, zlib_level:, uncompressed:, compression_threshold:,
130
+ metadata: nil, private_data: nil)
131
+ tables_data = collect_tables_data(font)
132
+ compressed_tables = compress_tables(
133
+ tables_data,
134
+ zlib_level: uncompressed ? 0 : zlib_level,
135
+ skip_compression: uncompressed,
136
+ compression_threshold: compression_threshold,
137
+ )
138
+ compressed_metadata = compress_metadata(metadata, zlib_level: zlib_level,
139
+ skip_compression: uncompressed)
140
+ binary = build_woff_file(compressed_tables, font, compressed_metadata,
141
+ private_data)
142
+ { woff_binary: binary }
143
+ end
154
144
 
155
- font.table_names.each do |tag|
156
- data = font.table_data[tag]
157
- tables_data[tag] = data if data
158
- end
145
+ private
159
146
 
160
- tables_data
147
+ # Slice options to those declared by this strategy. Tolerates extra
148
+ # keys (e.g., `target_format`) silently so FormatConverter can pass the
149
+ # full options hash through.
150
+ def strategy_options(options)
151
+ names = self.class.supported_options.to_set(&:name)
152
+ options.select { |k, _| names.include?(k.to_sym) }
161
153
  end
162
154
 
163
- # Compress tables with zlib
155
+ # Collect all table data from font.
164
156
  #
165
- # @param tables_data [Hash<String, String>] Original table data
166
- # @return [Hash<String, Hash>] Compressed table info with original/compressed sizes
167
- def compress_tables(tables_data)
168
- compressed_tables = {}
157
+ # @param font [TrueTypeFont, OpenTypeFont]
158
+ # @return [Hash<String, String>]
159
+ def collect_tables_data(font)
160
+ font.table_names.to_h do |tag|
161
+ [tag, font.table_data[tag]]
162
+ end.compact
163
+ end
169
164
 
170
- tables_data.each do |tag, data|
165
+ # Compress tables with zlib (or skip compression entirely).
166
+ #
167
+ # @param tables_data [Hash<String, String>]
168
+ # @param zlib_level [Integer] 0–9 (ignored if skip_compression)
169
+ # @param skip_compression [Boolean] store all tables uncompressed
170
+ # @param compression_threshold [Integer] tables below this size are kept
171
+ # uncompressed even when skip_compression is false
172
+ # @return [Hash<String, Hash>] per-table compressed info
173
+ def compress_tables(tables_data, zlib_level:, skip_compression:,
174
+ compression_threshold:)
175
+ tables_data.to_h do |tag, data|
171
176
  original_size = data.bytesize
177
+ should_compress =
178
+ !skip_compression && original_size >= compression_threshold
172
179
 
173
- # Only compress if table is large enough and compression is beneficial
174
- if original_size >= @compression_threshold
175
- compressed_data = Zlib::Deflate.deflate(data, @compression_level)
176
- compressed_size = compressed_data.bytesize
177
-
178
- # Only use compression if it actually reduces size
179
- compressed_tables[tag] = if compressed_size < original_size
180
- {
181
- original_data: data,
182
- compressed_data: compressed_data,
183
- original_length: original_size,
184
- compressed_length: compressed_size,
185
- is_compressed: true,
186
- }
187
- else
188
- # Compression didn't help, store uncompressed
189
- {
190
- original_data: data,
191
- compressed_data: data,
192
- original_length: original_size,
193
- compressed_length: original_size,
194
- is_compressed: false,
195
- }
196
- end
180
+ if should_compress
181
+ compressed = Zlib::Deflate.deflate(data, zlib_level)
182
+ use_compressed = compressed.bytesize < original_size
197
183
  else
198
- # Table too small to compress
199
- compressed_tables[tag] = {
184
+ use_compressed = false
185
+ end
186
+
187
+ [
188
+ tag,
189
+ {
200
190
  original_data: data,
201
- compressed_data: data,
191
+ compressed_data: use_compressed ? compressed : data,
202
192
  original_length: original_size,
203
- compressed_length: original_size,
204
- is_compressed: false,
205
- }
206
- end
193
+ compressed_length: use_compressed ? compressed.bytesize : original_size,
194
+ is_compressed: use_compressed,
195
+ },
196
+ ]
197
+ end
198
+ end
199
+
200
+ # Compress metadata with zlib.
201
+ #
202
+ # @param metadata [String, nil]
203
+ # @param zlib_level [Integer]
204
+ # @param skip_compression [Boolean]
205
+ # @return [Hash, nil]
206
+ def compress_metadata(metadata, zlib_level:, skip_compression:)
207
+ return nil unless metadata
208
+
209
+ original_length = metadata.bytesize
210
+ if skip_compression
211
+ return {
212
+ original_data: metadata,
213
+ compressed_data: metadata,
214
+ original_length: original_length,
215
+ compressed_length: original_length,
216
+ }
207
217
  end
208
218
 
209
- compressed_tables
219
+ compressed = Zlib::Deflate.deflate(metadata, zlib_level)
220
+ use_compressed = compressed.bytesize < original_length
221
+ {
222
+ original_data: metadata,
223
+ compressed_data: use_compressed ? compressed : metadata,
224
+ original_length: original_length,
225
+ compressed_length: use_compressed ? compressed.bytesize : original_length,
226
+ }
210
227
  end
211
228
 
212
- # Build complete WOFF file
229
+ # Assemble complete WOFF binary.
213
230
  #
214
- # @param compressed_tables [Hash] Compressed table information
215
- # @param font [TrueTypeFont, OpenTypeFont] Source font
216
- # @param metadata [String, nil] Optional metadata XML
217
- # @param private_data [String, nil] Optional private data
218
- # @return [String] Complete WOFF file data
219
- def build_woff_file(compressed_tables, font, metadata, private_data)
231
+ # @param compressed_tables [Hash]
232
+ # @param font [TrueTypeFont, OpenTypeFont]
233
+ # @param compressed_metadata [Hash, nil]
234
+ # @param private_data [String, nil]
235
+ # @return [String]
236
+ def build_woff_file(compressed_tables, font, compressed_metadata,
237
+ private_data)
220
238
  io = StringIO.new
221
239
  io.set_encoding(Encoding::BINARY)
222
240
 
223
- # Compress metadata if provided
224
- compressed_metadata = compress_metadata(metadata)
225
-
226
- # Calculate offsets and sizes
227
- header_size = 44 # WOFF header size
241
+ header_size = 44
228
242
  num_tables = compressed_tables.length
229
- table_dir_size = num_tables * 20 # Each table directory entry is 20 bytes
230
-
231
- # Calculate data offset (after header + table directory)
243
+ table_dir_size = num_tables * 20
232
244
  data_offset = header_size + table_dir_size
233
-
234
- # Calculate metadata and private data offsets
235
245
  metadata_offset = data_offset
236
246
  metadata_size = compressed_metadata ? compressed_metadata[:compressed_length] : 0
237
-
238
- # Calculate total compressed data size
239
- total_compressed_size = compressed_tables.values.sum do |table|
240
- table[:compressed_length]
247
+ total_compressed_size = compressed_tables.values.sum do |t|
248
+ t[:compressed_length]
241
249
  end
242
-
243
- # Calculate private data offset (after table data + metadata)
244
250
  private_offset = data_offset + total_compressed_size + metadata_size
245
251
  private_size = private_data ? private_data.bytesize : 0
246
-
247
- # Calculate total WOFF file size
248
252
  total_size = private_offset + private_size
249
-
250
- # Calculate total SFNT size (uncompressed)
251
- total_sfnt_size = compressed_tables.values.sum do |table|
252
- table[:original_length]
253
+ total_sfnt_size = compressed_tables.values.sum do |t|
254
+ t[:original_length]
253
255
  end +
254
256
  header_size + table_dir_size
255
257
 
256
- # Write WOFF header
257
- write_woff_header(io, font, total_size, total_sfnt_size, num_tables,
258
- compressed_metadata, metadata_offset, metadata_size,
259
- private_offset, private_size)
260
-
261
- # Write table directory
258
+ write_woff_header(
259
+ io, font, total_size, total_sfnt_size, num_tables,
260
+ compressed_metadata, metadata_offset, metadata_size,
261
+ private_offset, private_size
262
+ )
262
263
  write_table_directory(io, compressed_tables, data_offset)
263
-
264
- # Write compressed table data
265
264
  write_compressed_table_data(io, compressed_tables)
266
-
267
- # Write compressed metadata if present
268
265
  write_metadata(io, compressed_metadata) if compressed_metadata
269
-
270
- # Write private data if present
271
266
  write_private_data(io, private_data) if private_data
272
267
 
273
268
  io.string
274
269
  end
275
270
 
276
- # Compress metadata with zlib
277
- #
278
- # @param metadata [String, nil] Metadata XML
279
- # @return [Hash, nil] Compressed metadata info or nil
280
- def compress_metadata(metadata)
281
- return nil unless metadata
282
-
283
- original_length = metadata.bytesize
284
- compressed_data = Zlib::Deflate.deflate(metadata, @compression_level)
285
- compressed_length = compressed_data.bytesize
286
-
287
- {
288
- original_data: metadata,
289
- compressed_data: compressed_data,
290
- original_length: original_length,
291
- compressed_length: compressed_length,
292
- }
293
- end
294
-
295
- # Write WOFF header
296
- #
297
- # @param io [StringIO] Output stream
298
- # @param font [TrueTypeFont, OpenTypeFont] Source font
299
- # @param total_size [Integer] Total WOFF file size
300
- # @param total_sfnt_size [Integer] Uncompressed SFNT size
301
- # @param num_tables [Integer] Number of tables
302
- # @param compressed_metadata [Hash, nil] Compressed metadata info
303
- # @param metadata_offset [Integer] Metadata offset
304
- # @param metadata_size [Integer] Compressed metadata size
305
- # @param private_offset [Integer] Private data offset
306
- # @param private_size [Integer] Private data size
307
- # @return [void]
271
+ # Write WOFF header (44 bytes).
308
272
  def write_woff_header(io, font, total_size, total_sfnt_size, num_tables,
309
273
  compressed_metadata, metadata_offset, metadata_size,
310
274
  private_offset, private_size)
311
- # Determine flavor from font
312
275
  flavor = if font.respond_to?(:cff?) && font.cff?
313
276
  Constants::SFNT_VERSION_OTTO
314
277
  else
315
- # Default to TrueType for TrueType fonts and unknown types
316
278
  Constants::SFNT_VERSION_TRUETYPE
317
279
  end
318
280
 
319
- # Write WOFF header (44 bytes total)
320
- io.write([WOFF_SIGNATURE].pack("N")) # signature (4 bytes)
321
- io.write([flavor].pack("N")) # flavor (4 bytes)
322
- io.write([total_size].pack("N")) # length (4 bytes)
323
- io.write([num_tables].pack("n")) # numTables (2 bytes)
324
- io.write([0].pack("n")) # reserved (2 bytes)
325
- io.write([total_sfnt_size].pack("N")) # totalSfntSize (4 bytes)
326
- io.write([WOFF_VERSION_MAJOR].pack("n")) # majorVersion (2 bytes)
327
- io.write([WOFF_VERSION_MINOR].pack("n")) # minorVersion (2 bytes)
328
- io.write([metadata_offset].pack("N")) # metaOffset (4 bytes)
329
- io.write([metadata_size].pack("N")) # metaLength (4 bytes)
330
- io.write([compressed_metadata ? compressed_metadata[:original_length] : 0].pack("N")) # metaOrigLength (4 bytes)
331
- io.write([private_offset].pack("N")) # privOffset (4 bytes)
332
- io.write([private_size].pack("N")) # privLength (4 bytes)
281
+ io.write([WOFF_SIGNATURE].pack("N")) # signature
282
+ io.write([flavor].pack("N")) # flavor
283
+ io.write([total_size].pack("N")) # length
284
+ io.write([num_tables].pack("n")) # numTables
285
+ io.write([0].pack("n")) # reserved
286
+ io.write([total_sfnt_size].pack("N")) # totalSfntSize
287
+ io.write([WOFF_VERSION_MAJOR].pack("n")) # majorVersion
288
+ io.write([WOFF_VERSION_MINOR].pack("n")) # minorVersion
289
+ io.write([metadata_offset].pack("N")) # metaOffset
290
+ io.write([metadata_size].pack("N")) # metaLength
291
+ io.write([compressed_metadata ? compressed_metadata[:original_length] : 0].pack("N")) # metaOrigLength
292
+ io.write([private_offset].pack("N")) # privOffset
293
+ io.write([private_size].pack("N")) # privLength
333
294
  end
334
295
 
335
- # Write table directory
336
- #
337
- # @param io [StringIO] Output stream
338
- # @param compressed_tables [Hash] Compressed table information
339
- # @param data_offset [Integer] Starting offset for table data
340
- # @return [void]
296
+ # Write table directory entries (20 bytes each).
341
297
  def write_table_directory(io, compressed_tables, data_offset)
342
298
  current_offset = data_offset
343
-
344
- # Sort tables by tag for consistent output
345
- sorted_tables = compressed_tables.sort_by { |tag, _| tag }
346
-
347
- sorted_tables.each do |tag, table_info|
348
- # Calculate checksum of original table data
349
- checksum = Utilities::ChecksumCalculator.calculate_table_checksum(table_info[:original_data])
350
-
351
- # Write table directory entry (20 bytes)
352
- io.write(tag) # tag (4 bytes)
353
- io.write([current_offset].pack("N")) # offset (4 bytes)
354
- io.write([table_info[:compressed_length]].pack("N")) # compLength (4 bytes)
355
- io.write([table_info[:original_length]].pack("N")) # origLength (4 bytes)
356
- io.write([checksum].pack("N")) # origChecksum (4 bytes)
357
-
358
- # Update offset for next table
359
- current_offset += table_info[:compressed_length]
299
+ compressed_tables.sort_by { |tag, _| tag }.each do |tag, info|
300
+ checksum = Utilities::ChecksumCalculator
301
+ .calculate_table_checksum(info[:original_data])
302
+ io.write(tag) # tag
303
+ io.write([current_offset].pack("N")) # offset
304
+ io.write([info[:compressed_length]].pack("N")) # compLength
305
+ io.write([info[:original_length]].pack("N")) # origLength
306
+ io.write([checksum].pack("N")) # origChecksum
307
+ current_offset += info[:compressed_length]
360
308
  end
361
309
  end
362
310
 
363
- # Write compressed table data
364
- #
365
- # @param io [StringIO] Output stream
366
- # @param compressed_tables [Hash] Compressed table information
367
- # @return [void]
311
+ # Write compressed table data, sorted by tag (matches directory order).
368
312
  def write_compressed_table_data(io, compressed_tables)
369
- # Sort tables by tag for consistent output (same order as directory)
370
- sorted_tables = compressed_tables.sort_by { |tag, _| tag }
371
-
372
- # rubocop:disable Style/HashEachMethods - sorted_tables is an Array, not a Hash
373
- sorted_tables.each do |_, table_info|
374
- io.write(table_info[:compressed_data])
313
+ compressed_tables.sort_by { |tag, _| tag }.each do |_, info|
314
+ io.write(info[:compressed_data])
375
315
  end
376
- # rubocop:enable Style/HashEachMethods
377
316
  end
378
317
 
379
- # Write metadata to output
380
- #
381
- # @param io [StringIO] Output stream
382
- # @param compressed_metadata [Hash] Compressed metadata info
383
- # @return [void]
384
318
  def write_metadata(io, compressed_metadata)
385
319
  io.write(compressed_metadata[:compressed_data])
386
320
  end
387
321
 
388
- # Write private data to output
389
- #
390
- # @param io [StringIO] Output stream
391
- # @param private_data [String] Private data
392
- # @return [void]
393
322
  def write_private_data(io, private_data)
394
323
  io.write(private_data)
395
324
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Autoload hub for the Fontisan::Converters namespace.
4
+
5
+ module Fontisan
6
+ module Converters
7
+ autoload :CffTableBuilder, "fontisan/converters/cff_table_builder"
8
+ autoload :CollectionConverter, "fontisan/converters/collection_converter"
9
+ autoload :ConversionStrategy, "fontisan/converters/conversion_strategy"
10
+ autoload :FormatConverter, "fontisan/converters/format_converter"
11
+ autoload :GlyfTableBuilder, "fontisan/converters/glyf_table_builder"
12
+ autoload :OutlineConverter, "fontisan/converters/outline_converter"
13
+ autoload :OutlineExtraction, "fontisan/converters/outline_extraction"
14
+ autoload :OutlineOptimizer, "fontisan/converters/outline_optimizer"
15
+ autoload :SvgGenerator, "fontisan/converters/svg_generator"
16
+ autoload :TableCopier, "fontisan/converters/table_copier"
17
+ autoload :Type1Converter, "fontisan/converters/type1_converter"
18
+ autoload :Woff2Encoder, "fontisan/converters/woff2_encoder"
19
+ autoload :WoffWriter, "fontisan/converters/woff_writer"
20
+ end
21
+ end