Module: Typstify::Fonts

Defined in:
lib/typstify/fonts.rb

Overview

Font resolution, done by us rather than by the compiler.

Typst warns about an unknown font family and then quietly substitutes another face — which is how a production invoice ends up in a font nobody chose. We would rather surface that, but the typst binding currently discards compiler warnings on a successful compile (it only formats them into the message when compilation fails), so there is nothing to listen to. See the upstream PR linked in the README.

Until that lands, we answer the question ourselves: read the family names a template asks for out of its source, and check them against the fonts we can actually see. Cheap, deterministic, and honest about what it covers — it reads static font: declarations, not families computed at runtime.

Constant Summary collapse

EMBEDDED =

Faces compiled into the Typst binary; always available, never on disk.

[
  "libertinus serif",
  "new computer modern",
  "new computer modern math",
  "deja vu sans mono",
  "dejavu sans mono"
].freeze
DECLARATION =

font: "Inter" or font: ("Inter", "Noto Sans")

/\bfont\s*:\s*(\((?:[^()]*)\)|"(?:[^"\\]|\\.)*")/m
STRING =
/"((?:[^"\\]|\\.)*)"/
SYSTEM_DIRECTORIES =
[
  "/System/Library/Fonts",
  "/System/Library/Fonts/Supplemental",
  "/Library/Fonts",
  "~/Library/Fonts",
  "/usr/share/fonts",
  "/usr/local/share/fonts",
  "~/.fonts",
  "~/.local/share/fonts"
].freeze
EXTENSIONS =
%w[.ttf .otf .ttc .otc].freeze

Class Method Summary collapse

Class Method Details

.available_families(font_paths, include_system: true) ⇒ Object

Families the compiler will be able to find, downcased for comparison.



57
58
59
60
61
62
63
64
65
66
67
# File 'lib/typstify/fonts.rb', line 57

def available_families(font_paths, include_system: true)
  key = [font_paths.map(&:to_s).sort, include_system]
  @available ||= {}
  @available[key] ||= begin
    families = EMBEDDED.dup
    search_paths(font_paths, include_system: include_system).each do |directory|
      families.concat(families_in(directory))
    end
    families.map(&:downcase).uniq.to_set
  end
end

.collection_offsets(file) ⇒ Object



114
115
116
117
118
119
120
# File 'lib/typstify/fonts.rb', line 114

def collection_offsets(file)
  file.seek(8)
  count = file.read(4).unpack1("N")
  return [] if count.nil? || count.zero? || count > 1024

  Array(file.read(4 * count)&.unpack("N*"))
end

.declared_families(source) ⇒ Object

Families a chunk of Typst source asks for, in declaration order.



50
51
52
53
54
# File 'lib/typstify/fonts.rb', line 50

def declared_families(source)
  source.to_s.scan(DECLARATION).flat_map do |(declaration)|
    declaration.scan(STRING).flatten
  end.map(&:strip).reject(&:empty?).uniq
end

.decode(bytes, platform) ⇒ Object



161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/typstify/fonts.rb', line 161

def decode(bytes, platform)
  return nil if bytes.nil? || bytes.empty?

  string =
    if platform == 3 || platform.zero?
      bytes.force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8)
    else
      bytes.force_encoding(Encoding::BINARY).encode(Encoding::UTF_8, Encoding::ISO_8859_1)
    end
  string.strip.empty? ? nil : string.strip
rescue EncodingError
  nil
end

.families_at(file, base) ⇒ Object



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/typstify/fonts.rb', line 122

def families_at(file, base)
  file.seek(base + 4)
  table_count = file.read(2)&.unpack1("n")
  return [] if table_count.nil? || table_count.zero?

  file.seek(base + 12)
  records = file.read(16 * table_count).to_s
  name_offset = nil
  table_count.times do |index|
    record = records[index * 16, 16]
    break if record.nil?

    name_offset = record[8, 4].unpack1("N") if record[0, 4] == "name"
  end
  return [] if name_offset.nil?

  parse_name_table(file, name_offset)
end

.families_in(directory) ⇒ Object



89
90
91
92
93
94
95
96
97
98
99
# File 'lib/typstify/fonts.rb', line 89

def families_in(directory)
  return [] unless File.directory?(directory)

  Dir.glob(File.join(directory, "**", "*")).flat_map do |path|
    next [] unless EXTENSIONS.include?(File.extname(path).downcase)

    read_families(path)
  end
rescue SystemCallError
  []
end

.missing(source, font_paths, include_system: true) ⇒ Array<String>

Returns families the template wants and nothing provides.

Returns:

  • (Array<String>)

    families the template wants and nothing provides



75
76
77
78
# File 'lib/typstify/fonts.rb', line 75

def missing(source, font_paths, include_system: true)
  available = available_families(font_paths, include_system: include_system)
  declared_families(source).reject { |family| available.include?(family.downcase) }
end

.parse_name_table(file, offset) ⇒ Object



141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/typstify/fonts.rb', line 141

def parse_name_table(file, offset)
  file.seek(offset)
  header = file.read(6)
  return [] if header.nil? || header.bytesize < 6

  _format, count, storage = header.unpack("n3")
  records = file.read(12 * count).to_s

  count.times.filter_map do |index|
    record = records[index * 12, 12]
    next if record.nil?

    platform, _encoding, _language, name_id, length, string_offset = record.unpack("n6")
    next unless [1, 16].include?(name_id)

    file.seek(offset + storage + string_offset)
    decode(file.read(length), platform)
  end.compact.uniq
end

.read_families(path) ⇒ Object

Family names out of an SFNT name table (nameID 1 and 16).



102
103
104
105
106
107
108
109
110
111
112
# File 'lib/typstify/fonts.rb', line 102

def read_families(path)
  File.open(path, "rb") do |file|
    header = file.read(4)
    return [] if header.nil?

    offsets = header == "ttcf" ? collection_offsets(file) : [0]
    offsets.flat_map { |offset| families_at(file, offset) }
  end
rescue SystemCallError, EOFError
  []
end

.reset!Object

Reset the memoized scan. Fonts installed mid-process are rare; specs are not.



70
71
72
# File 'lib/typstify/fonts.rb', line 70

def reset!
  @available = nil
end

.search_paths(font_paths, include_system: true) ⇒ Object

The directories the compiler will look in. Kept in step with ignore_system_fonts, so the check never calls a family available that the compiler will not actually reach for.



83
84
85
86
87
# File 'lib/typstify/fonts.rb', line 83

def search_paths(font_paths, include_system: true)
  paths = font_paths.map { |path| File.expand_path(path.to_s) }
  paths += SYSTEM_DIRECTORIES.map { |directory| File.expand_path(directory) } if include_system
  paths
end