// 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 #include #include #include #include #include namespace fs = std::filesystem; // 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(); for (char& c : ext) { c = std::tolower(static_cast(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 colon-separated // KLAMMERTEXT_FONTS environment variable, 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() { strings_t result {}; std::string paths {}; const char* env = std::getenv("KLAMMERTEXT_FONTS"); if (env && *env) { paths = env; } else if (const char* home = std::getenv("HOME"); home && *home) { paths = std::string(home) + "/.klammertext/fonts"; } std::stringstream ss(paths); std::string dir; while (std::getline(ss, dir, ':')) { if (!dir.empty()) { result.push_back(dir); } } return result; } 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); 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: // holding the // .ttf files and /.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 {}; } // Every font available for the three @document role parameters: each // .css with a matching / directory, across the installed // directories and the default font set. The reported name is the family name // from the CSS (what the writer types), falling back to the directory name. strings_t available_font_families() { strings_t result {}; static const std::regex family_rgx(R"(font-family:\s*'([^']+)')"); auto scan = [&](const std::string& base) { if (!fs::exists(base)) { return; } for (auto& entry : fs::directory_iterator(base)) { if (entry.path().extension() == ".css" && fs::is_directory(base + "/" + entry.path().stem().string())) { std::string css = string_from_file(entry.path()); std::smatch match {}; if (std::regex_search(css, match, family_rgx)) { result.push_back(match[1]); } else { result.push_back(entry.path().stem()); } } } }; for (const std::string& dir : installed_font_dirs()) { scan(dir); } scan(default_font_dir()); std::sort(result.begin(), result.end()); result.erase(std::unique(result.begin(), result.end()), result.end()); return result; } 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 std::string script = "python3 -c \"" "import struct; " "f = open('" + ttf_path + "', '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())); 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) { 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); return font; } } std::stringstream ss {}; ss << "The font \"" << family_name << "\" is not installed.\n\n" << "Available fonts:\n"; for (const std::string& name : available_font_families()) { ss << " " << name << "\n"; } ss << "\nFonts are searched in the directories of the KLAMMERTEXT_FONTS\n" << "environment variable (colon-separated; default $HOME/.klammertext/fonts)\n" << "and then in the default font set. To install a font,\n" << "place its files as /" << dir_name << "/*.ttf with a\n" << "/" << dir_name << ".css declaring its @font-face variants."; throw Argument_error(ss.str()); } // 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> 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 classify_font_files(const std::string& directory) { std::vector 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())); } } 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& 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 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> 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 describe_fonts() { std::stringstream ss {}; std::set seen {}; strings_t bases = installed_font_dirs(); bases.push_back(default_font_dir()); for (size_t i = 0; i < bases.size(); i++) { const std::string& base = bases[i]; bool is_default = (i == bases.size() - 1); ss << base << (is_default ? " (default font set)" : "") << ":\n"; if (!fs::exists(base)) { ss << " [directory does not exist]\n"; continue; } 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()); } } std::sort(names.begin(), names.end()); if (names.empty()) { ss << " [no fonts]\n"; } for (const std::string& dir_name : names) { Resolved_font font = resolve_in_directory("?", dir_name, base); std::string css = string_from_file(font.css_file); std::smatch match {}; std::string family = dir_name; if (std::regex_search(css, match, std::regex(R"(font-family:\s*'([^']+)')"))) { family = match[1]; } strings_t variants {}; if (!font.regular.empty()) variants.push_back("Regular"); if (!font.bold.empty()) variants.push_back("Bold"); if (!font.italic.empty()) variants.push_back("Italic"); if (!font.bold_italic.empty()) variants.push_back("BoldItalic"); ss << " " << family << " (" << join(variants, ", ") << ")"; if (seen.contains(dir_name)) { ss << " [shadowed by an earlier directory]"; } seen.insert(dir_name); ss << "\n"; } } return ss.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()); 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 << "\n\n\n" << "\nKlammertext font samples\n"; for (const std::string& family : families) { html << "\n"; } html << "\n\n\n

Klammertext font samples

\n"; for (const std::string& family : families) { html << "

" << family << "

\n" << "
\n" << "

The quick brown fox jumps over the lazy dog.

\n" << "

" << "The quick brown fox jumps over the lazy dog.

\n" << "

" << "The quick brown fox jumps over the lazy dog.

\n" << "

" << "The quick brown fox jumps over the lazy dog.

\n" << "

ABCDEFGHIJKLMNOPQRSTUVWXYZ " << "abcdefghijklmnopqrstuvwxyz 0123456789 " << "äöüß “quoted” 3.14159

\n" << "
\n"; } html << "\n\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()); } } }