fontist 3.0.1 → 3.0.2

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.
@@ -0,0 +1,162 @@
1
+ require "yaml"
2
+ require "fileutils"
3
+
4
+ module Fontist
5
+ module Import
6
+ class Windows
7
+ HOMEPAGE = "https://learn.microsoft.com/en-us/typography/fonts/windows_11_font_list".freeze
8
+
9
+ def initialize(formulas_dir: nil)
10
+ @custom_formulas_dir = formulas_dir
11
+ end
12
+
13
+ def call
14
+ capabilities = WindowsFodMetadata.all_capabilities
15
+
16
+ Fontist.ui.say(
17
+ "Generating #{capabilities.size} Windows FOD formula files...",
18
+ )
19
+
20
+ capabilities.each do |cap_name|
21
+ generate_formula(cap_name)
22
+ end
23
+
24
+ Fontist.ui.say("Done. #{capabilities.size} formulas generated.")
25
+ end
26
+
27
+ private
28
+
29
+ def generate_formula(cap_name)
30
+ description = WindowsFodMetadata.description_for_capability(cap_name)
31
+ fonts_data = WindowsFodMetadata.fonts_for_capability(cap_name)
32
+ formula = build_formula(cap_name, description, fonts_data)
33
+ write_formula(description, formula, fonts_data.size)
34
+ end
35
+
36
+ def write_formula(description, formula, font_count)
37
+ path = formula_path(description)
38
+ FileUtils.mkdir_p(File.dirname(path))
39
+ File.write(path, YAML.dump(stringify_keys(formula)))
40
+ Fontist.ui.say(
41
+ " Created: #{File.basename(path)} (#{font_count} fonts)",
42
+ )
43
+ end
44
+
45
+ def build_formula(cap_name, description, fonts_data)
46
+ base_attrs(description).merge(
47
+ resources: build_resources(cap_name, description, fonts_data),
48
+ fonts: build_fonts(fonts_data),
49
+ **import_source_attrs(cap_name),
50
+ )
51
+ end
52
+
53
+ def base_attrs(description)
54
+ {
55
+ schema_version: 5,
56
+ name: description,
57
+ description: "#{description} for Windows",
58
+ homepage: HOMEPAGE,
59
+ platforms: ["windows"],
60
+ open_license: license_text,
61
+ }
62
+ end
63
+
64
+ def import_source_attrs(cap_name)
65
+ {
66
+ import_source: {
67
+ type: "windows",
68
+ capability_name: cap_name,
69
+ min_windows_version: "10.0",
70
+ },
71
+ }
72
+ end
73
+
74
+ def build_resources(cap_name, description, fonts_data)
75
+ all_fonts = collect_font_filenames(fonts_data)
76
+ formats = all_fonts.map { |f| detect_format(f) }.uniq
77
+
78
+ resource = {
79
+ source: "windows_fod",
80
+ capability_name: cap_name,
81
+ }
82
+ resource[:format] = formats.first if formats.size == 1
83
+
84
+ { normalize_key(description) => resource }
85
+ end
86
+
87
+ def collect_font_filenames(fonts_data)
88
+ fonts_data.flat_map do |_, data|
89
+ data["styles"].map { |s| s["font"] }
90
+ end
91
+ end
92
+
93
+ def build_fonts(fonts_data)
94
+ fonts_data.map do |family_name, data|
95
+ {
96
+ name: family_name,
97
+ styles: build_styles(family_name, data["styles"]),
98
+ }
99
+ end
100
+ end
101
+
102
+ def build_styles(family_name, styles)
103
+ styles.map do |style|
104
+ fmt = detect_format(style["font"])
105
+ {
106
+ family_name: family_name,
107
+ type: style["type"],
108
+ font: style["font"],
109
+ formats: [fmt],
110
+ variable_font: false,
111
+ }
112
+ end
113
+ end
114
+
115
+ def detect_format(filename)
116
+ File.extname(filename).downcase.delete(".").then do |ext|
117
+ %w[ttf ttc otf otc].include?(ext) ? ext : "ttf"
118
+ end
119
+ end
120
+
121
+ def formula_path(description)
122
+ filename = "#{normalize_key(description)}.yml"
123
+ formula_dir.join(filename)
124
+ end
125
+
126
+ def formula_dir
127
+ @formula_dir ||= if @custom_formulas_dir
128
+ Pathname.new(@custom_formulas_dir).tap do |path|
129
+ FileUtils.mkdir_p(path)
130
+ end
131
+ else
132
+ Fontist.formulas_path.join("windows").tap do |path|
133
+ FileUtils.mkdir_p(path)
134
+ end
135
+ end
136
+ end
137
+
138
+ def normalize_key(name)
139
+ name.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/^_|_$/, "")
140
+ end
141
+
142
+ def license_text
143
+ @license_text ||= File.read(
144
+ File.expand_path("windows/windows_license.txt", __dir__),
145
+ )
146
+ end
147
+
148
+ def stringify_keys(obj)
149
+ case obj
150
+ when Hash
151
+ obj.each_with_object({}) do |(k, v), result|
152
+ result[k.to_s] = stringify_keys(v)
153
+ end
154
+ when Array
155
+ obj.map { |item| stringify_keys(item) }
156
+ else
157
+ obj
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -23,6 +23,7 @@ module Fontist
23
23
  autoload :TemplateHelper, "#{__dir__}/import/template_helper"
24
24
  autoload :TextHelper, "#{__dir__}/import/text_helper"
25
25
  autoload :UpgradeFormulas, "#{__dir__}/import/upgrade_formulas"
26
+ autoload :Windows, "#{__dir__}/import/windows"
26
27
 
27
28
  class << self
28
29
  def name_to_filename(name)
@@ -16,6 +16,7 @@ module Fontist
16
16
  "macos" => "Fontist::MacosImportSource",
17
17
  "google" => "Fontist::GoogleImportSource",
18
18
  "sil" => "Fontist::SilImportSource",
19
+ "windows" => "Fontist::WindowsImportSource",
19
20
  }
20
21
  end
21
22
 
@@ -15,6 +15,9 @@ module Fontist
15
15
  attribute :format, :string # ttf, otf, woff2, ttc, otc
16
16
  attribute :variable_axes, :string, collection: true # [wght], [ital,wght], etc.
17
17
 
18
+ # Windows FOD support
19
+ attribute :capability_name, :string
20
+
18
21
  key_value do
19
22
  map "name", to: :name
20
23
  map "source", to: :source
@@ -25,10 +28,11 @@ module Fontist
25
28
  map "files", to: :files
26
29
  map "format", to: :format
27
30
  map "variable_axes", to: :variable_axes
31
+ map "capability_name", to: :capability_name
28
32
  end
29
33
 
30
34
  def empty?
31
- Array(urls).empty? && Array(files).empty?
35
+ Array(urls).empty? && Array(files).empty? && capability_name.nil?
32
36
  end
33
37
 
34
38
  def variable_font?
@@ -0,0 +1,51 @@
1
+ module Fontist
2
+ module Resources
3
+ class WindowsFodResource
4
+ def initialize(resource, options = {})
5
+ @resource = resource
6
+ @options = options
7
+ end
8
+
9
+ def files(source_names, &block)
10
+ install_capability!
11
+
12
+ source_names.each do |filename|
13
+ path = File.join(system_fonts_dir, filename)
14
+ yield path if File.exist?(path)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def install_capability!
21
+ cap_name = @resource.capability_name
22
+ return if capability_installed?(cap_name)
23
+
24
+ Fontist.ui.say("Installing Windows font capability: #{cap_name}")
25
+ result = Utils::System.run_powershell(
26
+ "Add-WindowsCapability -Online -Name '#{ps_escape(cap_name)}'",
27
+ )
28
+ unless result.success?
29
+ raise Errors::WindowsFodInstallError.new(cap_name, result.stderr)
30
+ end
31
+ end
32
+
33
+ def capability_installed?(name)
34
+ result = Utils::System.run_powershell(
35
+ "(Get-WindowsCapability -Online -Name '#{ps_escape(name)}').State",
36
+ )
37
+ result.stdout.strip == "Installed"
38
+ end
39
+
40
+ # Escape single quotes for PowerShell single-quoted strings
41
+ def ps_escape(str)
42
+ str.gsub("'", "''")
43
+ end
44
+
45
+ def system_fonts_dir
46
+ windir = ENV["windir"] || ENV["SystemRoot"] || "C:/Windows"
47
+ File.join(windir, "Fonts")
48
+ end
49
+ end
50
+ end
51
+ end
@@ -3,5 +3,6 @@ module Fontist
3
3
  autoload :AppleCDNResource, "#{__dir__}/resources/apple_cdn_resource"
4
4
  autoload :ArchiveResource, "#{__dir__}/resources/archive_resource"
5
5
  autoload :GoogleResource, "#{__dir__}/resources/google_resource"
6
+ autoload :WindowsFodResource, "#{__dir__}/resources/windows_fod_resource"
6
7
  end
7
8
  end
@@ -1,6 +1,10 @@
1
1
  module Fontist
2
2
  module Utils
3
3
  class Downloader
4
+ DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " \
5
+ "AppleWebKit/537.36 (KHTML, like Gecko) " \
6
+ "Chrome/131.0.0.0 Safari/537.36".freeze
7
+
4
8
  class << self
5
9
  def download(*args)
6
10
  new(*args).download
@@ -135,10 +139,11 @@ module Fontist
135
139
 
136
140
  def headers
137
141
  obj = Helpers.url_object(@file)
138
- (obj.respond_to?(:headers) &&
142
+ formula_headers = (obj.respond_to?(:headers) &&
139
143
  obj.headers &&
140
- obj.headers.to_h.to_h { |k, v| [k.to_s, v] }) || # rubocop:disable Style/HashTransformKeys, Metrics/LineLength
141
- {}
144
+ obj.headers.to_h.to_h { |k, v| [k.to_s, v] }) || {} # rubocop:disable Style/HashTransformKeys, Metrics/LineLength
145
+
146
+ { "User-Agent" => DEFAULT_USER_AGENT }.merge(formula_headers)
142
147
  end
143
148
 
144
149
  def extract_raw_url
@@ -179,6 +179,23 @@ module Fontist
179
179
  true
180
180
  end
181
181
 
182
+ PowerShellResult = Struct.new(:stdout, :stderr, :success,
183
+ keyword_init: true) do
184
+ alias_method :success?, :success
185
+ end
186
+
187
+ def self.run_powershell(command)
188
+ require "open3"
189
+ stdout, stderr, status = Open3.capture3(
190
+ "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command
191
+ )
192
+ PowerShellResult.new(stdout: stdout, stderr: stderr,
193
+ success: status.success?)
194
+ rescue Errno::ENOENT
195
+ PowerShellResult.new(stdout: "", stderr: "powershell.exe not found",
196
+ success: false)
197
+ end
198
+
182
199
  def self.catalog_version_for_macos
183
200
  # Check for platform override first
184
201
  if platform_override?
@@ -1,3 +1,3 @@
1
1
  module Fontist
2
- VERSION = "3.0.1".freeze
2
+ VERSION = "3.0.2".freeze
3
3
  end
@@ -0,0 +1,83 @@
1
+ require "yaml"
2
+
3
+ module Fontist
4
+ # Metadata for Windows Features on Demand (FOD) font capabilities
5
+ #
6
+ # Provides lookup methods to map between FOD capability names and
7
+ # their associated font families/filenames.
8
+ class WindowsFodMetadata
9
+ DATA_PATH = File.expand_path("import/windows/fod_capabilities.yml", __dir__)
10
+
11
+ class << self
12
+ # Reverse lookup: font family name -> capability name
13
+ #
14
+ # @param font_name [String] The font family name (e.g., "Meiryo")
15
+ # @return [String, nil] The capability name or nil if not found
16
+ def capability_for_font(font_name)
17
+ reverse_map[font_name.downcase]
18
+ end
19
+
20
+ # Get all font families for a capability
21
+ #
22
+ # @param cap_name [String] The capability name
23
+ # @return [Hash, nil] Hash of font family names to their metadata
24
+ def fonts_for_capability(cap_name)
25
+ cap = metadata.dig("capabilities", cap_name)
26
+ cap&.fetch("fonts", nil)
27
+ end
28
+
29
+ # Get the description for a capability
30
+ #
31
+ # @param cap_name [String] The capability name
32
+ # @return [String, nil] The description
33
+ def description_for_capability(cap_name)
34
+ metadata.dig("capabilities", cap_name, "description")
35
+ end
36
+
37
+ # Flat list of all FOD font family names
38
+ #
39
+ # @return [Array<String>] All font family names
40
+ def all_font_names
41
+ metadata["capabilities"].flat_map do |_cap, data|
42
+ data["fonts"].keys
43
+ end
44
+ end
45
+
46
+ # List of all capability names
47
+ #
48
+ # @return [Array<String>] All capability names
49
+ def all_capabilities
50
+ metadata["capabilities"].keys
51
+ end
52
+
53
+ # Raw parsed YAML metadata
54
+ #
55
+ # @return [Hash] The parsed YAML data
56
+ def metadata
57
+ @metadata ||= YAML.safe_load(File.read(DATA_PATH))
58
+ end
59
+
60
+ # Reset cached metadata (for testing)
61
+ # @api private
62
+ def reset_cache
63
+ @metadata = nil
64
+ @reverse_map = nil
65
+ end
66
+
67
+ private
68
+
69
+ # Build reverse lookup map: lowercase font name -> capability name
70
+ def reverse_map
71
+ @reverse_map ||= begin
72
+ map = {}
73
+ metadata["capabilities"].each do |cap_name, data|
74
+ data["fonts"].each_key do |font_name|
75
+ map[font_name.downcase] = cap_name
76
+ end
77
+ end
78
+ map
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,54 @@
1
+ module Fontist
2
+ # Import source for Windows Features on Demand (FOD) supplementary fonts
3
+ #
4
+ # Tracks the capability name and minimum Windows version for fonts
5
+ # installed via Add-WindowsCapability.
6
+ class WindowsImportSource < ImportSource
7
+ attribute :capability_name, :string
8
+ attribute :min_windows_version, :string
9
+
10
+ key_value do
11
+ map "capability_name", to: :capability_name
12
+ map "min_windows_version", to: :min_windows_version
13
+ end
14
+
15
+ # Returns the capability name for differentiation
16
+ #
17
+ # @return [String, nil] Capability name or nil
18
+ def differentiation_key
19
+ capability_name
20
+ end
21
+
22
+ # Checks if this import source is older than the provided new source
23
+ #
24
+ # @param new_source [WindowsImportSource] The new source to compare against
25
+ # @return [Boolean] true if this source is outdated
26
+ def outdated?(new_source)
27
+ return false unless new_source.is_a?(WindowsImportSource)
28
+
29
+ # Windows FOD capabilities don't have versioned updates in the same way;
30
+ # they are either present or not. Always return false.
31
+ false
32
+ end
33
+
34
+ # Returns a human-readable string representation
35
+ #
36
+ # @return [String] String representation for debugging/logging
37
+ def to_s
38
+ "Windows FOD (capability: #{capability_name}, " \
39
+ "min_version: #{min_windows_version})"
40
+ end
41
+
42
+ # Equality check based on capability name
43
+ #
44
+ # @param other [Object] The object to compare
45
+ # @return [Boolean] true if objects are equal
46
+ def ==(other)
47
+ return false unless other.is_a?(WindowsImportSource)
48
+
49
+ capability_name == other.capability_name
50
+ end
51
+
52
+ alias eql? ==
53
+ end
54
+ end
data/lib/fontist.rb CHANGED
@@ -54,6 +54,8 @@ module Fontist
54
54
  autoload :SilImportSource, "#{__dir__}/fontist/sil_import_source"
55
55
  autoload :MacosFrameworkMetadata,
56
56
  "#{__dir__}/fontist/macos_framework_metadata"
57
+ autoload :WindowsImportSource, "#{__dir__}/fontist/windows_import_source"
58
+ autoload :WindowsFodMetadata, "#{__dir__}/fontist/windows_fod_metadata"
57
59
 
58
60
  # Manifest classes
59
61
  autoload :Manifest, "#{__dir__}/fontist/manifest"
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Generate Windows FOD (Features on Demand) formula files
5
+ #
6
+ # Usage:
7
+ # bundle exec ruby script/generate_windows_formulas.rb [output_dir]
8
+ #
9
+ # Generates formula YAML files for all 25 Windows FOD font capabilities.
10
+ # Output goes to Formulas/windows/ by default, or to the specified directory.
11
+
12
+ require "bundler/setup"
13
+ require "fontist"
14
+ require_relative "../lib/fontist/import/windows"
15
+
16
+ output_dir = ARGV[0]
17
+
18
+ importer = if output_dir
19
+ Fontist::Import::Windows.new(formulas_dir: output_dir)
20
+ else
21
+ Fontist::Import::Windows.new
22
+ end
23
+
24
+ importer.call
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Validate Windows FOD capabilities against live Windows system
5
+ #
6
+ # Usage:
7
+ # bundle exec ruby script/validate_windows_fod_ci.rb
8
+ #
9
+ # This script:
10
+ # 1. Loads fod_capabilities.yml
11
+ # 2. Queries Windows for real FOD capabilities via PowerShell
12
+ # 3. Compares our YAML capability names against what Windows reports
13
+ # 4. For installed capabilities, checks that expected font files exist
14
+ # 5. Exits 0 on success, 1 on critical mismatches
15
+
16
+ require "yaml"
17
+ require "set"
18
+
19
+ FONTS_DIR = 'C:\Windows\Fonts'
20
+ YAML_PATH = File.expand_path(
21
+ "../lib/fontist/import/windows/fod_capabilities.yml",
22
+ __dir__,
23
+ )
24
+
25
+ def main
26
+ unless Gem.win_platform?
27
+ puts "SKIP: Not running on Windows"
28
+ exit 0
29
+ end
30
+
31
+ puts "=== Windows FOD Capability Validation ==="
32
+ puts ""
33
+
34
+ yaml_caps = load_yaml_capabilities
35
+ live_caps = query_live_capabilities
36
+
37
+ puts "YAML capabilities: #{yaml_caps.size}"
38
+ puts "Live capabilities: #{live_caps.size}"
39
+ puts ""
40
+
41
+ matched, missing_from_yaml, extra_in_yaml = compare(yaml_caps, live_caps)
42
+
43
+ print_results(matched, missing_from_yaml, extra_in_yaml)
44
+
45
+ errors = check_installed_fonts(yaml_caps, live_caps)
46
+
47
+ puts ""
48
+ summarize(matched, missing_from_yaml, extra_in_yaml, errors)
49
+ end
50
+
51
+ def load_yaml_capabilities
52
+ data = YAML.safe_load(File.read(YAML_PATH))
53
+ data.fetch("capabilities", {})
54
+ rescue StandardError => e
55
+ puts "ERROR: Failed to load YAML: #{e.message}"
56
+ exit 1
57
+ end
58
+
59
+ def query_live_capabilities
60
+ output = `powershell -NoProfile -Command "Get-WindowsCapability -Online -Name 'Language.Fonts.*' | Select-Object Name, State | ConvertTo-Csv -NoTypeInformation"`
61
+
62
+ unless $?.success?
63
+ puts "ERROR: PowerShell command failed (exit #{$?.exitstatus})"
64
+ exit 1
65
+ end
66
+
67
+ caps = {}
68
+ output.each_line do |line|
69
+ line = line.strip.delete('"')
70
+ next if line.empty? || line.start_with?("Name")
71
+
72
+ name, state = line.split(",", 2)
73
+ caps[name] = state if name && state
74
+ end
75
+ caps
76
+ end
77
+
78
+ def compare(yaml_caps, live_caps)
79
+ yaml_names = yaml_caps.keys.to_set
80
+ live_names = live_caps.keys.to_set
81
+
82
+ matched = yaml_names & live_names
83
+ missing_from_yaml = live_names - yaml_names
84
+ extra_in_yaml = yaml_names - live_names
85
+
86
+ [matched, missing_from_yaml, extra_in_yaml]
87
+ end
88
+
89
+ def print_results(matched, missing_from_yaml, extra_in_yaml)
90
+ puts "=== Matched (#{matched.size}) ==="
91
+ matched.sort.each { |name| puts " OK: #{name}" }
92
+
93
+ if missing_from_yaml.any?
94
+ puts ""
95
+ puts "=== Missing from our YAML (#{missing_from_yaml.size}) ==="
96
+ missing_from_yaml.sort.each { |name| puts " MISSING: #{name}" }
97
+ end
98
+
99
+ if extra_in_yaml.any?
100
+ puts ""
101
+ puts "=== Extra in our YAML (#{extra_in_yaml.size}) ==="
102
+ extra_in_yaml.sort.each { |name| puts " EXTRA: #{name}" }
103
+ end
104
+ end
105
+
106
+ def check_installed_fonts(yaml_caps, live_caps)
107
+ installed = live_caps.select { |_, state| state == "Installed" }
108
+ return [] if installed.empty?
109
+
110
+ puts ""
111
+ puts "=== Font File Checks for Installed Capabilities ==="
112
+
113
+ errors = []
114
+
115
+ installed.each_key do |cap_name|
116
+ cap_data = yaml_caps[cap_name]
117
+ next unless cap_data
118
+
119
+ fonts = cap_data.fetch("fonts", {})
120
+ fonts.each do |family_name, data|
121
+ files = data.fetch("files", [])
122
+ files.each do |filename|
123
+ path = File.join(FONTS_DIR, filename)
124
+ if File.exist?(path)
125
+ puts " OK: #{filename} (#{family_name})"
126
+ else
127
+ puts " MISSING: #{filename} (#{family_name}) — expected at #{path}"
128
+ errors << "#{cap_name}: #{filename} not found"
129
+ end
130
+ end
131
+ end
132
+ end
133
+
134
+ errors
135
+ end
136
+
137
+ def summarize(matched, missing_from_yaml, extra_in_yaml, font_errors)
138
+ puts "========================================="
139
+ puts " Validation Summary"
140
+ puts "========================================="
141
+ puts " Capability matches: #{matched.size}"
142
+ puts " Missing from our YAML: #{missing_from_yaml.size}"
143
+ puts " Extra in our YAML: #{extra_in_yaml.size}"
144
+ puts " Font file errors: #{font_errors.size}"
145
+ puts ""
146
+
147
+ if missing_from_yaml.any?
148
+ puts "WARNING: #{missing_from_yaml.size} capabilities exist on Windows but not in our YAML."
149
+ puts " Consider adding them to fod_capabilities.yml"
150
+ puts ""
151
+ end
152
+
153
+ if extra_in_yaml.any?
154
+ puts "WARNING: #{extra_in_yaml.size} capabilities in our YAML not found on this Windows version."
155
+ puts " These may be version-specific or renamed."
156
+ puts ""
157
+ end
158
+
159
+ if font_errors.any?
160
+ puts "WARNING: #{font_errors.size} font files missing for installed capabilities."
161
+ font_errors.each { |e| puts " - #{e}" }
162
+ puts ""
163
+ end
164
+
165
+ # Exit 1 only if we have zero matches (indicates a fundamental problem)
166
+ if matched.empty? && (missing_from_yaml.any? || extra_in_yaml.any?)
167
+ puts "CRITICAL: No capability names matched — our YAML may be completely wrong."
168
+ exit 1
169
+ end
170
+
171
+ puts "Validation passed."
172
+ exit 0
173
+ end
174
+
175
+ main