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"orfont: ("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
-
.available_families(font_paths, include_system: true) ⇒ Object
Families the compiler will be able to find, downcased for comparison.
- .collection_offsets(file) ⇒ Object
-
.declared_families(source) ⇒ Object
Families a chunk of Typst source asks for, in declaration order.
- .decode(bytes, platform) ⇒ Object
- .families_at(file, base) ⇒ Object
- .families_in(directory) ⇒ Object
-
.missing(source, font_paths, include_system: true) ⇒ Array<String>
Families the template wants and nothing provides.
- .parse_name_table(file, offset) ⇒ Object
-
.read_families(path) ⇒ Object
Family names out of an SFNT
nametable (nameID 1 and 16). -
.reset! ⇒ Object
Reset the memoized scan.
-
.search_paths(font_paths, include_system: true) ⇒ Object
The directories the compiler will look in.
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.
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.(path.to_s) } paths += SYSTEM_DIRECTORIES.map { |directory| File.(directory) } if include_system paths end |