Files
klammertext/mac/font_store.cpp
T

594 lines
22 KiB
C++
Raw Normal View History

// The Klammertext font store: INFRASTRUCTURE, not part of the
// Klammermachine (the Machine class never references it) and not part of
// any klammer set. Klammer sets (the SKS's @document, a future music
// set) consume the store; the kdesc command lists, installs into, and
// samples it without loading any klammer set. The store's search runs
// over the KLAMMERTEXT_FONTS directories and ends at the distribution's
// default font set in $KLAMMERTEXT_HOME/fnt.
#include "font_store.h"
#include "file.h"
#include "locator.h"
#include "util.h"
#include "error.h"
#include "log.h"
#include "show.h"
#include <algorithm>
#include <fstream>
#include <regex>
#include <set>
#include <sstream>
#include <filesystem>
namespace fs = std::filesystem;
// The interpreter spawned for the font-metric script: python.org's Windows
// build installs "python" only; Linux and macOS name it "python3".
#ifdef _WIN32
static const char* const python_interpreter = "python";
#else
static const char* const python_interpreter = "python3";
#endif
// A filesystem extension lowercased for case-insensitive matching, so a
// font named Foo.TTF or Foo.OTF is recognized like Foo.ttf / Foo.otf.
static std::string lower_extension(const fs::path& path)
{
std::string ext = path.extension().string();
for (char& c : ext) {
c = std::tolower(static_cast<unsigned char>(c));
}
return ext;
}
std::string name_to_dirname(const std::string& name)
{
std::string result {};
for (char c : name) {
if (c == ' ')
result += '-';
else
result += std::tolower(c);
}
return result;
}
// The directories searched for installed fonts: the KLAMMERTEXT_FONTS
// environment variable (a list in the system's PATH convention: ':' or, on
// Windows, ';'), or ~/.klammertext/fonts when it is not set. Directories are
// searched in listed order, and the default fonts in $KLAMMERTEXT_HOME/fnt are
// searched last, so an installed font can deliberately shadow a default one.
strings_t installed_font_dirs()
{
return directory_list("KLAMMERTEXT_FONTS", ".klammertext/fonts");
}
std::string default_font_dir()
{
const char* home = std::getenv(klammertext_home_var.c_str());
if (home == nullptr || *home == '\0') {
throw Argument_error(
"The environment variable " + klammertext_home_var + " is not defined");
}
return std::string(home) + "/fnt";
}
static void classify_from_css(Resolved_font& font, std::string css_path)
{
// Parse the @font-face blocks in the CSS to determine variant → filename mapping
std::string css = string_from_file(css_path);
// The family name is what the css DECLARES, not what the caller wrote:
// a typeface may be asked for by its directory name
// ("noto-sans-egyptian-hieroglyphs"), and a font-family value in
// generated html must match the @font-face or the browser substitutes
// silently (until 2026-09-12 the request was echoed back).
static const std::regex family_rgx(R"(font-family:\s*'([^']+)')");
std::smatch match;
if (std::regex_search(css, match, family_rgx))
font.family_name = match[1];
std::regex face_rgx(
R"(@font-face\s*\{[^}]*font-style:\s*(\w+);[^}]*font-weight:\s*(\w+);[^}]*url\('([^']+\.ttf)'\)[^}]*\})",
std::regex::multiline);
auto begin = std::sregex_iterator(css.begin(), css.end(), face_rgx);
auto end = std::sregex_iterator();
for (auto it = begin; it != end; ++it) {
std::string style = (*it)[1];
std::string weight = (*it)[2];
std::string url_path = (*it)[3];
// URL is like 'dir-name/Filename.ttf' — extract just the filename
std::string filename = url_path.substr(url_path.rfind('/') + 1);
bool is_bold = (weight == "700" || weight == "bold");
bool is_italic = (style == "italic" || style == "oblique");
if (is_bold && is_italic)
font.bold_italic = filename;
else if (is_bold)
font.bold = filename;
else if (is_italic)
font.italic = filename;
else
font.regular = filename;
}
}
// Resolve a font within one base directory: <base>/<dir-name>/ holding the
// .ttf files and <base>/<dir-name>.css declaring the @font-face variants.
static Resolved_font resolve_in_directory(
const std::string& family_name, const std::string& dir_name, const std::string& base_dir)
{
std::string font_dir = base_dir + "/" + dir_name;
std::string css_file = font_dir + ".css";
if (fs::exists(font_dir) && fs::exists(css_file)) {
Resolved_font font {};
font.family_name = family_name;
font.dir_name = dir_name;
font.font_dir = font_dir;
font.css_file = css_file;
classify_from_css(font, css_file);
return font;
}
return {};
}
std::vector<Resolved_font> fonts_in_directory(const std::string& base)
{
std::vector<Resolved_font> result {};
if (!fs::is_directory(base)) {
return result;
}
strings_t names {};
for (auto& entry : fs::directory_iterator(base)) {
if (entry.path().extension() == ".css" &&
fs::is_directory(base + "/" + entry.path().stem().string())) {
names.push_back(entry.path().stem().string());
}
}
std::sort(names.begin(), names.end());
for (const std::string& dir_name : names) {
// The directory name is the family name's fallback: classify_from_css
// overwrites it with the name the css declares, when it declares one.
result.push_back(resolve_in_directory(dir_name, dir_name, base));
}
return result;
}
// Every font available for the three @document role parameters, across the
// installed directories and the default font set. The reported name is the
// family name from the CSS (what the writer types).
strings_t available_font_families()
{
strings_t result {};
strings_t bases = installed_font_dirs();
bases.push_back(default_font_dir());
for (const std::string& base : bases) {
for (const Resolved_font& font : fonts_in_directory(base)) {
result.push_back(font.family_name);
}
}
std::sort(result.begin(), result.end());
result.erase(std::unique(result.begin(), result.end()), result.end());
return result;
}
bool font_installed(const std::string& name)
{
const std::string wanted = name_to_dirname(name);
for (const std::string& family : available_font_families())
if (name_to_dirname(family) == wanted)
return true;
return false;
}
static void extract_font_metrics(Resolved_font& font)
{
if (font.regular.empty() || font.font_dir.empty())
return;
std::string ttf_path = font.font_dir + "/" + font.regular;
if (!fs::exists(ttf_path))
return;
// Extract both x-height and cap-height ratios from OS/2 table
// The path goes inside a Python literal: forward slashes, which Python
// opens on every system, where a Windows backslash would be an escape.
std::string script =
std::string(python_interpreter) + " -c \""
"import struct; "
"f = open('" + fs::path(ttf_path).generic_string() + "', 'rb'); "
"_, n = struct.unpack('>IH', f.read(6)); "
"f.read(6); "
"t = {};\n"
"for _ in range(n):\n"
" tag = f.read(4).decode('latin-1').strip('\\\\x00'); "
" _, o, l = struct.unpack('>III', f.read(12)); "
" t[tag] = o\n"
"f.seek(t['head'] + 18); "
"upm = struct.unpack('>H', f.read(2))[0]; "
"f.seek(t['OS/2']); "
"ver = struct.unpack('>H', f.read(2))[0]; "
"f.seek(t['OS/2'] + 86); "
"xh, ch = struct.unpack('>hh', f.read(4)); "
"print(f'{xh/upm:.4f} {ch/upm:.4f}') if ver >= 2 else None; "
"f.close()\"";
std::string result = trim(exec(script.c_str()));
// A DERIVED value a designer cannot read off the font: the two ratios
// (or nothing, when the script failed) decide the html size normalization.
(void)K::log(3, "Font metrics of " + ttf_path + ": " + (result.empty() ? std::string("(none)") : result));
if (!result.empty()) {
try {
auto pos = result.find(' ');
if (pos != std::string::npos) {
font.xheight_ratio = std::stof(result.substr(0, pos));
font.capheight_ratio = std::stof(result.substr(pos + 1));
}
} catch (...) {}
}
}
Resolved_font resolve_font(const std::string& family_name, const Locator& loc)
{
if (family_name.empty())
return {};
std::string dir_name = name_to_dirname(family_name);
// Installed directories in listed order, then the default font set, so an
// installed font can shadow a default one.
strings_t bases = installed_font_dirs();
bases.push_back(default_font_dir());
for (const std::string& base : bases) {
Resolved_font font = resolve_in_directory(family_name, dir_name, base);
if (!font.family_name.empty()) {
extract_font_metrics(font);
// A DERIVED value: an installed font may shadow a default one, and
// which directory won is not readable from ":serif_typeface Whatever".
// The commonest font question -- "why does it not look like the
// one I installed?" -- is answered by this line.
(void)K::log(1, "Typeface \"" + family_name + "\": " + base + "/" + dir_name);
return font;
}
}
std::stringstream ss {};
ss << "The typeface \"" << family_name << "\" is not installed.\n\n"
<< "Available typefaces:\n";
for (const std::string& name : available_font_families()) {
ss << " " << name << "\n";
}
ss << "\nTypefaces are searched in the directories of the KLAMMERTEXT_FONTS\n"
<< "environment variable (colon-separated; default $HOME/.klammertext/fonts)\n"
<< "and then in the default set. To install a typeface,\n"
<< "place its files as <fonts-dir>/" << dir_name << "/*.ttf with a\n"
<< "<fonts-dir>/" << dir_name << ".css declaring its @font-face variants.";
throw Argument_error(ss.str(), loc);
}
// Font file classification: read the family name, weight, and style from
// the font's internal tables (sfnt 'name', 'OS/2', 'fvar') rather than
// from filenames, which vary by source (Google zips, foundries, ...).
namespace {
uint16_t be16(const std::string& d, size_t off)
{
return (uint8_t(d[off]) << 8) | uint8_t(d[off + 1]);
}
uint32_t be32(const std::string& d, size_t off)
{
return (uint32_t(be16(d, off)) << 16) | be16(d, off + 2);
}
std::string read_binary_file(const std::string& path)
{
std::ifstream in(path, std::ios::binary);
std::stringstream ss {};
ss << in.rdbuf();
return ss.str();
}
// Decode a name-table string: UTF-16BE for Windows records (keep the BMP
// low bytes; family names are almost always Latin), bytes as-is otherwise.
std::string decode_name(const std::string& raw, bool utf16be)
{
std::string result {};
if (utf16be) {
for (size_t i = 0; i + 1 < raw.size(); i += 2) {
if (raw[i] == 0) {
result += raw[i + 1];
}
}
} else {
result = raw;
}
return result;
}
} // namespace
Font_file classify_font_file(const std::string& path)
{
Font_file file {};
file.path = path;
// Normalize to lowercase so the installed filename, its @font-face url,
// and the truetype/opentype format detection are uniform regardless of
// how the source file's extension was capitalized.
file.extension = lower_extension(path);
std::string d = read_binary_file(path);
if (d.size() < 12) {
file.note = "not a font file (too short)";
return file;
}
uint32_t tag = be32(d, 0);
if (tag == 0x74746366) { // 'ttcf'
file.note = "font collections (.ttc) are not supported; "
"use the individual font files";
return file;
}
if (tag != 0x00010000 && tag != 0x4F54544F) { // sfnt or 'OTTO'
file.note = "not a TrueType or OpenType font";
return file;
}
uint16_t num_tables = be16(d, 4);
std::map<std::string, std::pair<uint32_t, uint32_t>> tables {}; // tag -> offset,length
for (uint16_t i = 0; i < num_tables; i++) {
size_t rec = 12 + i * 16;
if (rec + 16 > d.size()) {
break;
}
tables[d.substr(rec, 4)] = { be32(d, rec + 8), be32(d, rec + 12) };
}
file.variable = tables.contains("fvar");
// Family name from the 'name' table: typographic family (16) wins
// over family (1); Windows records (platform 3) win over Macintosh.
if (auto it = tables.find("name"); it != tables.end()) {
size_t base = it->second.first;
uint16_t count = be16(d, base + 2);
uint16_t string_offset = be16(d, base + 4);
int best_rank = -1;
for (uint16_t i = 0; i < count; i++) {
size_t rec = base + 6 + i * 12;
if (rec + 12 > d.size()) {
break;
}
uint16_t platform = be16(d, rec);
uint16_t name_id = be16(d, rec + 6);
uint16_t length = be16(d, rec + 8);
uint16_t offset = be16(d, rec + 10);
if (name_id != 1 && name_id != 16) {
continue;
}
int rank = (name_id == 16 ? 2 : 0) + (platform == 3 ? 1 : 0);
size_t at = base + string_offset + offset;
if (rank > best_rank && at + length <= d.size()) {
file.family = decode_name(d.substr(at, length), platform == 3);
best_rank = rank;
}
}
}
if (file.family.empty()) {
file.note = "no family name found in the font's name table";
return file;
}
bool italic = false;
if (auto it = tables.find("OS/2"); it != tables.end()) {
size_t base = it->second.first;
file.weight = be16(d, base + 4);
italic = be16(d, base + 62) & 0x0001; // fsSelection italic bit
} else if (auto ht = tables.find("head"); ht != tables.end()) {
uint16_t mac_style = be16(d, ht->second.first + 44);
file.weight = (mac_style & 0x0001) ? 700 : 400;
italic = mac_style & 0x0002;
}
if (file.variable) {
// A variable font covers the weight axis; use it as the regular
// (or italic) face and let renderers derive weights.
file.variant = italic ? "Italic" : "Regular";
} else if (file.weight >= 380 && file.weight <= 450) {
file.variant = italic ? "Italic" : "Regular";
} else if (file.weight >= 650 && file.weight <= 760) {
file.variant = italic ? "BoldItalic" : "Bold";
} else {
std::stringstream note {};
note << "weight " << file.weight
<< " not installed (only regular 400 and bold 700 are used)";
file.note = note.str();
}
return file;
}
std::vector<Font_file> classify_font_files(const std::string& directory)
{
std::vector<Font_file> result {};
if (!fs::exists(directory)) {
throw Argument_error(
"The font directory \"" + directory + "\" does not exist");
}
for (auto& entry : fs::recursive_directory_iterator(directory)) {
std::string ext = lower_extension(entry.path());
if (entry.is_regular_file() && (ext == ".ttf" || ext == ".otf")) {
result.push_back(classify_font_file(entry.path().string()));
}
}
return result;
}
// The css is always generated, never copied, so its urls are relative and
// the installed pair stays relocatable.
static std::string font_face_css(
const std::string& family, const std::string& dir_name,
const std::map<std::string, const Font_file*>& slots)
{
std::stringstream css {};
auto emit = [&](const std::string& variant,
const std::string& style, const std::string& weight) {
auto it = slots.find(variant);
if (it == slots.end()) {
return;
}
std::string format =
it->second->extension == ".otf" ? "opentype" : "truetype";
css << "\n@font-face {\n"
<< " font-family: '" << family << "';\n"
<< " font-style: " << style << ";\n"
<< " font-weight: " << weight << ";\n"
<< " src: url('" << dir_name << "/" << variant
<< it->second->extension << "') format('" << format << "');\n"
<< "}\n";
};
emit("Regular", "normal", "400");
emit("Bold", "normal", "700");
emit("Italic", "italic", "400");
emit("BoldItalic", "italic", "700");
return css.str();
}
std::string install_fonts(const std::string& source_dir, std::string dest_dir)
{
if (dest_dir.empty()) {
strings_t dirs = installed_font_dirs();
if (dirs.empty()) {
throw Argument_error(
"No installation directory: KLAMMERTEXT_FONTS is empty and "
"HOME is not set");
}
dest_dir = dirs[0];
}
std::vector<Font_file> files = classify_font_files(source_dir);
if (files.empty()) {
throw Argument_error(
"No font files (.ttf or .otf) found under \"" + source_dir + "\"");
}
// Choose one file per (family, variant) slot; a static face wins over
// a variable font's derived face.
std::map<std::string, std::map<std::string, const Font_file*>> families {};
std::stringstream report {};
for (const Font_file& file : files) {
if (file.variant.empty()) {
report << " skipped " << fs::path(file.path).filename().string()
<< ": " << file.note << "\n";
continue;
}
auto& slots = families[file.family];
auto it = slots.find(file.variant);
if (it == slots.end() || (it->second->variable && !file.variable)) {
slots[file.variant] = &file;
}
}
for (auto& [family, slots] : families) {
std::string dir_name = name_to_dirname(family);
std::string family_dir = dest_dir + "/" + dir_name;
fs::create_directories(family_dir);
strings_t variants {};
for (auto& [variant, file] : slots) {
copy_file_stream(file->path,
family_dir + "/" + variant + file->extension);
variants.push_back(variant + (file->variable ? " (variable)" : ""));
}
string_to_file(dest_dir + "/" + dir_name + ".css",
font_face_css(family, dir_name, slots));
report << " installed \"" << family << "\" (" << join(variants, ", ")
<< ") in " << family_dir << "\n";
}
return report.str();
}
std::string write_font_samples(const std::string& output_dir, const std::string& source_dir)
{
fs::create_directories(output_dir);
strings_t families {};
if (source_dir.empty()) {
for (const std::string& family : available_font_families()) {
install_resolved_font(resolve_font(family), output_dir);
families.push_back(family);
}
} else {
// Uninstalled preview: install the classified fonts directly into
// the sample page's own fonts directory.
install_fonts(source_dir, output_dir + "/fonts");
for (auto& entry : fs::directory_iterator(output_dir + "/fonts")) {
if (entry.path().extension() == ".css") {
std::string css = string_from_file(entry.path().string());
std::smatch match {};
if (std::regex_search(css, match,
std::regex(R"(font-family:\s*'([^']+)')"))) {
families.push_back(match[1]);
}
}
}
std::sort(families.begin(), families.end());
}
std::stringstream html {};
html << "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n"
<< "<meta charset=\"UTF-8\">\n<title>Klammertext font samples</title>\n";
for (const std::string& family : families) {
html << "<link href=\"fonts/" << name_to_dirname(family)
<< ".css\" rel=\"stylesheet\">\n";
}
html << "<style>\n"
<< "body { margin: 2rem auto; max-width: 46rem; font-family: sans-serif; }\n"
<< "h2 { border-bottom: 1px solid #999; margin-top: 2.5rem; }\n"
<< ".sample { font-size: 1.3rem; margin: .3rem 0; }\n"
<< ".alphabet { font-size: 1.0rem; color: #333; margin: .3rem 0; }\n"
<< "</style>\n</head>\n<body>\n<h1>Klammertext font samples</h1>\n";
for (const std::string& family : families) {
html << "<h2>" << family << "</h2>\n"
<< "<div style=\"font-family: '" << family << "'\">\n"
<< "<p class=\"sample\">The quick brown fox jumps over the lazy dog.</p>\n"
<< "<p class=\"sample\" style=\"font-style: italic\">"
<< "The quick brown fox jumps over the lazy dog.</p>\n"
<< "<p class=\"sample\" style=\"font-weight: bold\">"
<< "The quick brown fox jumps over the lazy dog.</p>\n"
<< "<p class=\"sample\" style=\"font-weight: bold; font-style: italic\">"
<< "The quick brown fox jumps over the lazy dog.</p>\n"
<< "<p class=\"alphabet\">ABCDEFGHIJKLMNOPQRSTUVWXYZ "
<< "abcdefghijklmnopqrstuvwxyz 0123456789 "
<< "&auml;&ouml;&uuml;&szlig; &ldquo;quoted&rdquo; 3.14159</p>\n"
<< "</div>\n";
}
html << "</body>\n</html>\n";
std::string index = output_dir + "/index.html";
string_to_file(index, html.str());
return index;
}
// Font assets are copied with copy_file_stream() (mac/file.h) rather than
// std::filesystem::copy_file, which fails on Apple `container` virtiofs mounts
// — see the note on copy_file_stream() in file.cpp for the full rationale.
void install_resolved_font(const Resolved_font& font, std::string output_dir)
{
if (font.family_name.empty())
return;
std::string output_font_dir = output_dir + "/fonts";
if (!fs::exists(output_font_dir))
fs::create_directory(output_font_dir);
// Copy .css file and font directory to output
std::string dest_css = output_font_dir + "/" + font.dir_name + ".css";
std::string dest_dir = output_font_dir + "/" + font.dir_name;
copy_file_stream(font.css_file, dest_css);
if (!fs::exists(dest_dir)) {
fs::create_directory(dest_dir);
for (auto& entry : fs::directory_iterator(font.font_dir)) {
copy_file_stream(entry.path(),
dest_dir + "/" + entry.path().filename().string());
}
}
}