Files
klammertext/sks/target/font_resolve.cpp
Andy Kopra 2ba7ceee7a Initial commit: Klammertext source distribution
Curated source subset assembled by klammertext-dev's doc/make_dist.sh: the Klammermachine (mac), the Standard Klammer Set (sks), the commands (com), editor plugins and install guides (doc), a test subset (tst), and lib/bin placeholders. Builds with 'make -C com'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 19:32:38 +02:00

330 lines
10 KiB
C++

#include "font_resolve.h"
#include "file.h"
#include "util.h"
#include "error.h"
#include "log.h"
#include "show.h"
#include "kutil.h"
#include <algorithm>
#include <regex>
#include <sstream>
#include <filesystem>
namespace fs = std::filesystem;
std::string name_to_dirname(std::string name)
{
std::string result {};
for (char c : name) {
if (c == ' ')
result += '-';
else
result += std::tolower(c);
}
return result;
}
std::string name_to_google_query(std::string name)
{
std::string result {};
for (char c : name) {
if (c == ' ')
result += '+';
else
result += c;
}
return result;
}
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;
}
}
static Resolved_font resolve_bundled(std::string family_name, std::string dir_name)
{
std::string bundled_dir = klammertext_dir() + "/sks/font/fonts/" + dir_name;
std::string bundled_css = bundled_dir + ".css";
if (fs::exists(bundled_dir) && fs::exists(bundled_css)) {
Resolved_font font {};
font.family_name = family_name;
font.dir_name = dir_name;
font.font_dir = bundled_dir;
font.css_file = bundled_css;
font.from_cache = false;
classify_from_css(font, bundled_css);
return font;
}
return {};
}
static Resolved_font resolve_cached(std::string family_name, std::string dir_name)
{
std::string cache_base = cache_directory("_fonts");
std::string cache_dir = cache_base + "/" + dir_name;
std::string cache_css = cache_base + "/" + dir_name + ".css";
if (fs::exists(cache_dir) && fs::exists(cache_css)) {
Resolved_font font {};
font.family_name = family_name;
font.dir_name = dir_name;
font.font_dir = cache_dir;
font.css_file = cache_css;
font.from_cache = true;
classify_from_css(font, cache_css);
return font;
}
return {};
}
static Resolved_font fetch_google_font(std::string family_name, std::string dir_name)
{
(void)K::log(1, "Fetching font \"" + family_name + "\" from Google Fonts");
std::string query = name_to_google_query(family_name);
std::string url =
"https://fonts.googleapis.com/css2?family=" + query +
":ital,wght@0,400;0,700;1,400;1,700&display=swap";
std::string cmd = "curl -s -H 'User-Agent: Mozilla/4.0' '" + url + "'";
std::string css_response = exec(cmd.c_str());
if (css_response.empty() || css_response.find("@font-face") == std::string::npos) {
return {};
}
// Parse @font-face blocks to extract style, weight, and .ttf URL
struct Font_variant {
std::string style; // "normal" or "italic"
std::string weight; // "400" or "700"
std::string url;
};
std::vector<Font_variant> variants {};
std::regex face_rgx(
R"(@font-face\s*\{[^}]*font-style:\s*(\w+);[^}]*font-weight:\s*(\d+);[^}]*src:\s*url\((https?://[^)]+\.ttf)\)[^}]*\})",
std::regex::multiline);
auto begin = std::sregex_iterator(css_response.begin(), css_response.end(), face_rgx);
auto end = std::sregex_iterator();
for (auto it = begin; it != end; ++it) {
Font_variant v {};
v.style = (*it)[1];
v.weight = (*it)[2];
v.url = (*it)[3];
variants.push_back(v);
}
if (variants.empty()) {
return {};
}
// Create cache directory
std::string cache_base = cache_directory("_fonts");
if (!fs::exists(cache_base))
fs::create_directories(cache_base);
std::string cache_dir = cache_base + "/" + dir_name;
if (!fs::exists(cache_dir))
fs::create_directory(cache_dir);
Resolved_font font {};
font.family_name = family_name;
font.dir_name = dir_name;
font.font_dir = cache_dir;
font.from_cache = true;
// Download each .ttf file with descriptive names
for (auto& v : variants) {
std::string local_name;
if (v.style == "normal" && v.weight == "400")
local_name = "Regular.ttf";
else if (v.style == "normal" && v.weight == "700")
local_name = "Bold.ttf";
else if (v.style == "italic" && v.weight == "400")
local_name = "Italic.ttf";
else if (v.style == "italic" && v.weight == "700")
local_name = "BoldItalic.ttf";
else
continue;
std::string ttf_path = cache_dir + "/" + local_name;
if (!fs::exists(ttf_path)) {
std::string dl_cmd = "curl -s -o '" + ttf_path + "' '" + v.url + "'";
(void)exec(dl_cmd.c_str());
if (!fs::exists(ttf_path)) {
(void)K::log(1, "Failed to download font file: " + v.url);
continue;
}
}
if (local_name == "Regular.ttf")
font.regular = local_name;
else if (local_name == "Bold.ttf")
font.bold = local_name;
else if (local_name == "Italic.ttf")
font.italic = local_name;
else if (local_name == "BoldItalic.ttf")
font.bold_italic = local_name;
}
// Generate @font-face CSS file
std::string css_file = cache_base + "/" + dir_name + ".css";
std::stringstream css {};
auto emit_face = [&](std::string style, std::string weight, std::string filename) {
if (filename.empty())
return;
css << "\n@font-face {\n"
<< " font-family: '" << family_name << "';\n"
<< " font-style: " << style << ";\n"
<< " font-weight: " << weight << ";\n"
<< " src: url('" << dir_name << "/" << filename << "') format('truetype');\n"
<< "}\n";
};
emit_face("normal", "400", font.regular);
emit_face("normal", "700", font.bold);
emit_face("italic", "400", font.italic);
emit_face("italic", "700", font.bold_italic);
string_to_file(css_file, css.str());
font.css_file = css_file;
return font;
}
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(std::string family_name)
{
if (family_name.empty())
return {};
std::string dir_name = name_to_dirname(family_name);
// 1. Check bundled fonts
Resolved_font font = resolve_bundled(family_name, dir_name);
if (!font.family_name.empty()) {
extract_font_metrics(font);
return font;
}
// 2. Check font cache
font = resolve_cached(family_name, dir_name);
if (!font.family_name.empty()) {
extract_font_metrics(font);
return font;
}
// 3. Fetch from Google Fonts
font = fetch_google_font(family_name, dir_name);
if (!font.family_name.empty()) {
extract_font_metrics(font);
return font;
}
// 4. Error
throw Argument_error(
"Font \"" + family_name + "\" not found.\n"
" Not bundled in sks/font/fonts/" + dir_name + "/,\n"
" not cached, and not available from Google Fonts.\n"
" Check the font name or install it locally.");
}
// 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());
}
}
}