fontisan 0.2.22 → 0.3.0

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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +6 -0
  3. data/.rubocop_todo.yml +93 -17
  4. data/CHANGELOG.md +12 -2
  5. data/README.adoc +6 -210
  6. data/fontisan.gemspec +48 -0
  7. data/lib/fontisan/cldr/unicode_set_parser.rb +23 -6
  8. data/lib/fontisan/cldr/version_resolver.rb +1 -1
  9. data/lib/fontisan/cli.rb +4 -168
  10. data/lib/fontisan/commands.rb +0 -3
  11. data/lib/fontisan/formatters/text_formatter.rb +0 -6
  12. data/lib/fontisan/formatters.rb +0 -3
  13. data/lib/fontisan/hints.rb +6 -3
  14. data/lib/fontisan/models.rb +4 -4
  15. data/lib/fontisan/pipeline/strategies.rb +4 -2
  16. data/lib/fontisan/pipeline.rb +2 -1
  17. data/lib/fontisan/stitcher/selector/codepoints.rb +29 -0
  18. data/lib/fontisan/stitcher/selector/gid.rb +25 -0
  19. data/lib/fontisan/stitcher/selector/range.rb +30 -0
  20. data/lib/fontisan/stitcher/selector.rb +26 -0
  21. data/lib/fontisan/stitcher/source.rb +97 -0
  22. data/lib/fontisan/stitcher.rb +182 -0
  23. data/lib/fontisan/stitcher_cli.rb +69 -0
  24. data/lib/fontisan/tables/cff.rb +2 -1
  25. data/lib/fontisan/tables.rb +2 -1
  26. data/lib/fontisan/ufo/anchor.rb +17 -0
  27. data/lib/fontisan/ufo/cli.rb +85 -0
  28. data/lib/fontisan/ufo/compile/base_compiler.rb +81 -0
  29. data/lib/fontisan/ufo/compile/cff.rb +224 -0
  30. data/lib/fontisan/ufo/compile/cmap.rb +129 -0
  31. data/lib/fontisan/ufo/compile/filters/cubic_to_quadratic.rb +174 -0
  32. data/lib/fontisan/ufo/compile/filters/decompose_components.rb +33 -0
  33. data/lib/fontisan/ufo/compile/filters/flatten_components.rb +22 -0
  34. data/lib/fontisan/ufo/compile/filters/reverse_contour_direction.rb +27 -0
  35. data/lib/fontisan/ufo/compile/filters.rb +57 -0
  36. data/lib/fontisan/ufo/compile/glyf_loca.rb +145 -0
  37. data/lib/fontisan/ufo/compile/head.rb +98 -0
  38. data/lib/fontisan/ufo/compile/hhea.rb +36 -0
  39. data/lib/fontisan/ufo/compile/hmtx.rb +27 -0
  40. data/lib/fontisan/ufo/compile/maxp.rb +57 -0
  41. data/lib/fontisan/ufo/compile/name.rb +79 -0
  42. data/lib/fontisan/ufo/compile/os2.rb +81 -0
  43. data/lib/fontisan/ufo/compile/otf_compiler.rb +43 -0
  44. data/lib/fontisan/ufo/compile/post.rb +32 -0
  45. data/lib/fontisan/ufo/compile/ttf_compiler.rb +69 -0
  46. data/lib/fontisan/ufo/compile.rb +48 -0
  47. data/lib/fontisan/ufo/component.rb +18 -0
  48. data/lib/fontisan/ufo/contour.rb +29 -0
  49. data/lib/fontisan/ufo/convert/from_bin_data.rb +246 -0
  50. data/lib/fontisan/ufo/convert.rb +18 -0
  51. data/lib/fontisan/ufo/data_set.rb +21 -0
  52. data/lib/fontisan/ufo/features.rb +17 -0
  53. data/lib/fontisan/ufo/font.rb +61 -0
  54. data/lib/fontisan/ufo/glyph.rb +421 -0
  55. data/lib/fontisan/ufo/guideline.rb +19 -0
  56. data/lib/fontisan/ufo/image.rb +16 -0
  57. data/lib/fontisan/ufo/image_set.rb +19 -0
  58. data/lib/fontisan/ufo/info.rb +79 -0
  59. data/lib/fontisan/ufo/kerning.rb +32 -0
  60. data/lib/fontisan/ufo/layer.rb +38 -0
  61. data/lib/fontisan/ufo/layer_set.rb +37 -0
  62. data/lib/fontisan/ufo/lib.rb +24 -0
  63. data/lib/fontisan/ufo/plist.rb +118 -0
  64. data/lib/fontisan/ufo/point.rb +38 -0
  65. data/lib/fontisan/ufo/reader.rb +144 -0
  66. data/lib/fontisan/ufo/transformation.rb +39 -0
  67. data/lib/fontisan/ufo/writer.rb +115 -0
  68. data/lib/fontisan/ufo.rb +44 -0
  69. data/lib/fontisan/version.rb +1 -1
  70. data/lib/fontisan.rb +3 -3
  71. metadata +57 -70
  72. data/lib/fontisan/audit/codepoint_range_coalescer.rb +0 -41
  73. data/lib/fontisan/audit/context.rb +0 -122
  74. data/lib/fontisan/audit/differ.rb +0 -124
  75. data/lib/fontisan/audit/extractors/aggregations.rb +0 -54
  76. data/lib/fontisan/audit/extractors/base.rb +0 -26
  77. data/lib/fontisan/audit/extractors/color_capabilities.rb +0 -141
  78. data/lib/fontisan/audit/extractors/coverage.rb +0 -48
  79. data/lib/fontisan/audit/extractors/hinting.rb +0 -197
  80. data/lib/fontisan/audit/extractors/identity.rb +0 -52
  81. data/lib/fontisan/audit/extractors/language_coverage.rb +0 -37
  82. data/lib/fontisan/audit/extractors/licensing.rb +0 -79
  83. data/lib/fontisan/audit/extractors/metrics.rb +0 -103
  84. data/lib/fontisan/audit/extractors/opentype_layout.rb +0 -69
  85. data/lib/fontisan/audit/extractors/provenance.rb +0 -29
  86. data/lib/fontisan/audit/extractors/style.rb +0 -32
  87. data/lib/fontisan/audit/extractors/variation_detail.rb +0 -99
  88. data/lib/fontisan/audit/extractors.rb +0 -27
  89. data/lib/fontisan/audit/library_aggregator.rb +0 -83
  90. data/lib/fontisan/audit/library_auditor.rb +0 -90
  91. data/lib/fontisan/audit/registry.rb +0 -60
  92. data/lib/fontisan/audit/style_extractor.rb +0 -80
  93. data/lib/fontisan/audit.rb +0 -20
  94. data/lib/fontisan/cli/ucd_cli.rb +0 -97
  95. data/lib/fontisan/commands/audit_command.rb +0 -123
  96. data/lib/fontisan/commands/audit_compare_command.rb +0 -66
  97. data/lib/fontisan/commands/audit_library_command.rb +0 -46
  98. data/lib/fontisan/config/ucd.yml +0 -23
  99. data/lib/fontisan/formatters/audit_diff_text_renderer.rb +0 -122
  100. data/lib/fontisan/formatters/audit_text_renderer.rb +0 -324
  101. data/lib/fontisan/formatters/library_summary_text_renderer.rb +0 -99
  102. data/lib/fontisan/models/audit/audit_axis.rb +0 -30
  103. data/lib/fontisan/models/audit/audit_block.rb +0 -32
  104. data/lib/fontisan/models/audit/audit_diff.rb +0 -77
  105. data/lib/fontisan/models/audit/audit_report.rb +0 -153
  106. data/lib/fontisan/models/audit/codepoint_range.rb +0 -40
  107. data/lib/fontisan/models/audit/codepoint_set_diff.rb +0 -34
  108. data/lib/fontisan/models/audit/color_capabilities.rb +0 -93
  109. data/lib/fontisan/models/audit/duplicate_group.rb +0 -23
  110. data/lib/fontisan/models/audit/embedding_type.rb +0 -76
  111. data/lib/fontisan/models/audit/field_change.rb +0 -28
  112. data/lib/fontisan/models/audit/fs_selection_flags.rb +0 -61
  113. data/lib/fontisan/models/audit/gasp_range.rb +0 -63
  114. data/lib/fontisan/models/audit/hinting.rb +0 -93
  115. data/lib/fontisan/models/audit/library_summary.rb +0 -40
  116. data/lib/fontisan/models/audit/licensing.rb +0 -48
  117. data/lib/fontisan/models/audit/metrics.rb +0 -111
  118. data/lib/fontisan/models/audit/named_instance.rb +0 -41
  119. data/lib/fontisan/models/audit/opentype_layout.rb +0 -40
  120. data/lib/fontisan/models/audit/script_coverage_row.rb +0 -26
  121. data/lib/fontisan/models/audit/script_features.rb +0 -28
  122. data/lib/fontisan/models/audit/variation_detail.rb +0 -44
  123. data/lib/fontisan/models/audit.rb +0 -33
  124. data/lib/fontisan/models/ucd/ucd.rb +0 -38
  125. data/lib/fontisan/models/ucd/ucd_char.rb +0 -67
  126. data/lib/fontisan/models/ucd.rb +0 -19
  127. data/lib/fontisan/ucd/aggregator.rb +0 -73
  128. data/lib/fontisan/ucd/cache_manager.rb +0 -111
  129. data/lib/fontisan/ucd/config.rb +0 -59
  130. data/lib/fontisan/ucd/download_error.rb +0 -9
  131. data/lib/fontisan/ucd/downloader.rb +0 -88
  132. data/lib/fontisan/ucd/error.rb +0 -8
  133. data/lib/fontisan/ucd/index.rb +0 -103
  134. data/lib/fontisan/ucd/index_builder.rb +0 -107
  135. data/lib/fontisan/ucd/range_entry.rb +0 -56
  136. data/lib/fontisan/ucd/unknown_version_error.rb +0 -9
  137. data/lib/fontisan/ucd/version_resolver.rb +0 -79
  138. data/lib/fontisan/ucd.rb +0 -23
@@ -1,123 +0,0 @@
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
@@ -1,66 +0,0 @@
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
@@ -1,46 +0,0 @@
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,23 +0,0 @@
1
- # UCD (Unicode Character Database) configuration.
2
- #
3
- # This file is the single source of truth for which UCD version Fontisan
4
- # uses by default, what versions are recognized, and where to fetch them.
5
- #
6
- # Update `default_version` and `known_versions` when a new Unicode version
7
- # is released (typically twice a year). Users may override at runtime via
8
- # `fontisan ucd download --version X.Y.Z` or `fontisan audit --ucd-version X.Y.Z`.
9
-
10
- default_version: "17.0.0"
11
-
12
- known_versions:
13
- - "15.0.0"
14
- - "15.1.0"
15
- - "16.0.0"
16
- - "17.0.0"
17
-
18
- # Base URL for fetching UCDXML artifacts.
19
- # Full URL: <base_url>/<version>/ucdxml/ucd.all.flat.zip
20
- base_url: "https://www.unicode.org/Public"
21
-
22
- # Listing URL for `--latest` probing. HTML scraping, best-effort.
23
- listing_url: "https://www.unicode.org/Public/ucdxml/"
@@ -1,122 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Fontisan
4
- module Formatters
5
- # Human-readable diff of two {Models::Audit::AuditReport}s.
6
- #
7
- # Output groups changes by kind: scalar field changes, codepoint set
8
- # deltas (added/removed counts and a preview of the ranges), then
9
- # structural inventory changes (scripts, features, blocks, languages).
10
- # Empty sections are omitted so a no-op diff prints only the header.
11
- class AuditDiffTextRenderer
12
- SEPARATOR = "=" * 80
13
- LIST_LIMIT = 10
14
-
15
- # @param diff [Models::Audit::AuditDiff]
16
- def initialize(diff)
17
- @diff = diff
18
- @lines = []
19
- end
20
-
21
- # @return [String]
22
- def render
23
- render_header
24
- render_field_changes
25
- render_codepoint_delta
26
- render_structural_changes
27
- render_empty_note
28
- @lines.join("\n")
29
- end
30
-
31
- private
32
-
33
- def render_header
34
- @lines << "AUDIT DIFF"
35
- @lines << SEPARATOR
36
- @lines << "left: #{@diff.left_source}"
37
- @lines << "right: #{@diff.right_source}"
38
- end
39
-
40
- def render_field_changes
41
- changes = Array(@diff.field_changes)
42
- return if changes.empty?
43
-
44
- section("FIELD CHANGES (#{changes.size})")
45
- changes.each do |change|
46
- @lines << " #{change.field}: #{change.left.inspect} → #{change.right.inspect}"
47
- end
48
- end
49
-
50
- def render_codepoint_delta
51
- delta = @diff.codepoints
52
- return unless delta && (delta.added_count.to_i.positive? || delta.removed_count.to_i.positive?)
53
-
54
- section("CODEPOINT COVERAGE")
55
- @lines << " added: #{delta.added_count}"
56
- @lines << " removed: #{delta.removed_count}"
57
- @lines << " unchanged: #{delta.unchanged_count}"
58
- preview_added(delta)
59
- preview_removed(delta)
60
- end
61
-
62
- def preview_added(delta)
63
- ranges = Array(delta.added)
64
- return if ranges.empty?
65
-
66
- @lines << " + #{format_ranges(ranges)}"
67
- end
68
-
69
- def preview_removed(delta)
70
- ranges = Array(delta.removed)
71
- return if ranges.empty?
72
-
73
- @lines << " - #{format_ranges(ranges)}"
74
- end
75
-
76
- def render_structural_changes
77
- render_set("SCRIPTS", @diff.added_scripts, @diff.removed_scripts)
78
- render_set("FEATURES", @diff.added_features, @diff.removed_features)
79
- render_set("BLOCKS", @diff.added_blocks, @diff.removed_blocks)
80
- render_set("LANGUAGES", @diff.added_languages, @diff.removed_languages)
81
- end
82
-
83
- def render_set(name, added, removed)
84
- added = Array(added)
85
- removed = Array(removed)
86
- return if added.empty? && removed.empty?
87
-
88
- section("#{name} CHANGES")
89
- @lines << " + #{truncate(added)}" unless added.empty?
90
- @lines << " - #{truncate(removed)}" unless removed.empty?
91
- end
92
-
93
- def render_empty_note
94
- return unless @diff.empty?
95
-
96
- @lines << ""
97
- @lines << "(no differences)"
98
- end
99
-
100
- # ---- helpers --------------------------------------------------------
101
-
102
- def section(title)
103
- @lines << ""
104
- @lines << title
105
- end
106
-
107
- def truncate(list)
108
- shown = list.first(LIST_LIMIT).join(", ")
109
- shown += ", ..." if list.size > LIST_LIMIT
110
- shown
111
- end
112
-
113
- def format_ranges(ranges)
114
- shown = ranges.first(LIST_LIMIT).map do |r|
115
- "U+#{format('%04X', r.first_cp)}-U+#{format('%04X', r.last_cp)}"
116
- end.join(", ")
117
- shown += ", ..." if ranges.size > LIST_LIMIT
118
- shown
119
- end
120
- end
121
- end
122
- end