Typed arguments, calculated tables, spans, closed-world fonts, top-level fnt/ and env/
Sync with klammertext-dev through b90b0e09:
- Argument types end to end: :python_cast values are applied (Python
@eval receives real bools/numbers/lists), argument values are
validated against their argtype patterns with the argtype's
description as the error message, argtypes can declare :default
(overridable per declaration), and parameterized type families are
supported: rest(N) casts a rest argument to an N-dimensional list
(bar-count = dimension).
- Unified indexed_range syntax (selector with parenthesized subsets,
composable mnemonic names) for table lines and spans.
- Table klammer: caption fonts fixed in both targets, :column_width /
:leading / :colsep wired, :colspan and :rowspan render (HTML
attributes; \multicolumn / \multirow), calculated cell values (:calc)
with prefix operators, display-precision semantics, :calc_format and
:decimal period|comma.
- Fonts: closed-world resolution on the Klammertext font store
(infrastructure in mac/font_store; no Google Fonts links or fetch).
Default fonts live in the top-level fnt/; additional fonts install
into KLAMMERTEXT_FONTS directories via kdesc --font (list, samples,
preview, install — classification by font metadata). CSS font family
names are quoted (digit-initial families were silently lost).
- Environment files moved from mac/env/ to the top-level env/; shell
profiles source env/runtime.env. Dead per-host variants removed.
- Container: fnt/ ships in the image; curl removed (no network use).
2026-07-22 18:17:43 +02:00
|
|
|
// 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;
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 21:37:58 +02:00
|
|
|
// 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<unsigned char>(c));
|
|
|
|
|
}
|
|
|
|
|
return ext;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
Typed arguments, calculated tables, spans, closed-world fonts, top-level fnt/ and env/
Sync with klammertext-dev through b90b0e09:
- Argument types end to end: :python_cast values are applied (Python
@eval receives real bools/numbers/lists), argument values are
validated against their argtype patterns with the argtype's
description as the error message, argtypes can declare :default
(overridable per declaration), and parameterized type families are
supported: rest(N) casts a rest argument to an N-dimensional list
(bar-count = dimension).
- Unified indexed_range syntax (selector with parenthesized subsets,
composable mnemonic names) for table lines and spans.
- Table klammer: caption fonts fixed in both targets, :column_width /
:leading / :colsep wired, :colspan and :rowspan render (HTML
attributes; \multicolumn / \multirow), calculated cell values (:calc)
with prefix operators, display-precision semantics, :calc_format and
:decimal period|comma.
- Fonts: closed-world resolution on the Klammertext font store
(infrastructure in mac/font_store; no Google Fonts links or fetch).
Default fonts live in the top-level fnt/; additional fonts install
into KLAMMERTEXT_FONTS directories via kdesc --font (list, samples,
preview, install — classification by font metadata). CSS font family
names are quoted (digit-initial families were silently lost).
- Environment files moved from mac/env/ to the top-level env/; shell
profiles source env/runtime.env. Dead per-host variants removed.
- Container: fnt/ ships in the image; curl removed (no network use).
2026-07-22 18:17:43 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 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: <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 {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Every font available for the three @document role parameters: each
|
|
|
|
|
// <name>.css with a matching <name>/ 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(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 <fonts-dir>/" << dir_name << "/*.ttf with a\n"
|
|
|
|
|
<< "<fonts-dir>/" << 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;
|
2026-07-24 21:37:58 +02:00
|
|
|
// 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);
|
Typed arguments, calculated tables, spans, closed-world fonts, top-level fnt/ and env/
Sync with klammertext-dev through b90b0e09:
- Argument types end to end: :python_cast values are applied (Python
@eval receives real bools/numbers/lists), argument values are
validated against their argtype patterns with the argtype's
description as the error message, argtypes can declare :default
(overridable per declaration), and parameterized type families are
supported: rest(N) casts a rest argument to an N-dimensional list
(bar-count = dimension).
- Unified indexed_range syntax (selector with parenthesized subsets,
composable mnemonic names) for table lines and spans.
- Table klammer: caption fonts fixed in both targets, :column_width /
:leading / :colsep wired, :colspan and :rowspan render (HTML
attributes; \multicolumn / \multirow), calculated cell values (:calc)
with prefix operators, display-precision semantics, :calc_format and
:decimal period|comma.
- Fonts: closed-world resolution on the Klammertext font store
(infrastructure in mac/font_store; no Google Fonts links or fetch).
Default fonts live in the top-level fnt/; additional fonts install
into KLAMMERTEXT_FONTS directories via kdesc --font (list, samples,
preview, install — classification by font metadata). CSS font family
names are quoted (digit-initial families were silently lost).
- Environment files moved from mac/env/ to the top-level env/; shell
profiles source env/runtime.env. Dead per-host variants removed.
- Container: fnt/ ships in the image; curl removed (no network use).
2026-07-22 18:17:43 +02:00
|
|
|
|
|
|
|
|
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)) {
|
2026-07-24 21:37:58 +02:00
|
|
|
std::string ext = lower_extension(entry.path());
|
Typed arguments, calculated tables, spans, closed-world fonts, top-level fnt/ and env/
Sync with klammertext-dev through b90b0e09:
- Argument types end to end: :python_cast values are applied (Python
@eval receives real bools/numbers/lists), argument values are
validated against their argtype patterns with the argtype's
description as the error message, argtypes can declare :default
(overridable per declaration), and parameterized type families are
supported: rest(N) casts a rest argument to an N-dimensional list
(bar-count = dimension).
- Unified indexed_range syntax (selector with parenthesized subsets,
composable mnemonic names) for table lines and spans.
- Table klammer: caption fonts fixed in both targets, :column_width /
:leading / :colsep wired, :colspan and :rowspan render (HTML
attributes; \multicolumn / \multirow), calculated cell values (:calc)
with prefix operators, display-precision semantics, :calc_format and
:decimal period|comma.
- Fonts: closed-world resolution on the Klammertext font store
(infrastructure in mac/font_store; no Google Fonts links or fetch).
Default fonts live in the top-level fnt/; additional fonts install
into KLAMMERTEXT_FONTS directories via kdesc --font (list, samples,
preview, install — classification by font metadata). CSS font family
names are quoted (digit-initial families were silently lost).
- Environment files moved from mac/env/ to the top-level env/; shell
profiles source env/runtime.env. Dead per-host variants removed.
- Container: fnt/ ships in the image; curl removed (no network use).
2026-07-22 18:17:43 +02:00
|
|
|
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<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 describe_fonts()
|
|
|
|
|
{
|
|
|
|
|
std::stringstream ss {};
|
|
|
|
|
std::set<std::string> 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 << "<!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 "
|
|
|
|
|
<< "äöüß “quoted” 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());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|