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).
This commit is contained in:
2026-07-22 18:17:43 +02:00
parent 6b75aa0c54
commit 8a2699a253
100 changed files with 1726 additions and 1152 deletions

View File

@@ -5,7 +5,7 @@ K := $(KLAMMERTEXT_HOME)
KS := $(K)/sks
KM := $(K)/mac
include $(KM)/env/makefile.env
include $(K)/env/makefile.env
# Commands to build
COMMANDS := kdiag kdesc ktext

View File

@@ -2,6 +2,7 @@
#include "command.h"
#include "error.h"
#include "file.h"
#include "font_store.h"
#include "ktype.h"
#include "log.h"
#include "argtype_set.h"
@@ -9,6 +10,52 @@
#include "show.h"
#include "util.h"
static void font_usage()
{
std::cout <<
"Font maintenance commands:\n"
" --font List the available fonts\n"
" --font list The same\n"
" --font samples <output-dir> Write a sample page of the available fonts\n"
" to <output-dir>/index.html\n"
" --font samples <input-dir> <output-dir>\n"
" Write a sample page for the (not yet\n"
" installed) font files under <input-dir>\n"
" --font install <input-dir> Install the font files found under\n"
" <input-dir> into the user font directory\n"
" --font install <input-dir> <output-dir>\n"
" Install into <output-dir> instead\n"
" --font help This description\n"
"\n"
"Fonts 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.\n";
}
static void font_command(const strings_t& words)
{
std::string verb = words.empty() ? "list" : words[0];
size_t n = words.size() - (words.empty() ? 0 : 1);
if (verb == "list" && n == 0) {
std::cout << boldblack << "Fonts\n" << black << describe_fonts();
} else if (verb == "samples" && n == 1) {
std::cout << "Font samples written to "
<< write_font_samples(words[1]) << "\n";
} else if (verb == "samples" && n == 2) {
std::cout << "Font samples written to "
<< write_font_samples(words[2], words[1]) << "\n";
} else if (verb == "install" && (n == 1 || n == 2)) {
std::cout << "Installing fonts from " << words[1] << ":\n"
<< install_fonts(words[1], n == 2 ? words[2] : "");
} else if (verb == "help") {
font_usage();
} else {
std::cout << "Unrecognized font command: --font " << join(words, " ")
<< "\n\n";
font_usage();
}
}
int main(int argc, char* argv[])
{
try {
@@ -22,6 +69,7 @@ int main(int argc, char* argv[])
args.opt("input", "Input filename", "filename", "", "'text'");
args.flag("targets", "Show targets defined by the input file");
args.flag("klammers", "Show klammers defined by the input file");
args.var("font", "List installed fonts. Enter \"--font help\" for font maintenance commands.");
args.opt("v", "'verbosity'", "n", "0", "'verbosity'");
if (show_usage(argc, argv)) {
@@ -55,6 +103,19 @@ int main(int argc, char* argv[])
describe_rewrite_patterns();
}
// The --font subcommands operate on the Klammertext font store
// (infrastructure) and load no klammer set.
if (args.given("font")) {
strings_t words {};
for (const std::string& w : word_split(args.get("font"))) {
if (!w.empty()) {
words.push_back(w);
}
}
font_command(words);
return 0;
}
Machine M;
strings_t input_filenames = args.as_vector("input");

View File

@@ -51,7 +51,7 @@ Klammertext's runtime environment is provided by a single self-configuring
file. Source it from your shell profile (e.g., `~/.bashrc` or `~/.zshrc`):
```bash
source /path/to/klammertext/mac/env/runtime.env
source /path/to/klammertext/env/runtime.env
```
It self-locates `KLAMMERTEXT_HOME` from its own path, adds `bin/` and
@@ -59,7 +59,7 @@ It self-locates `KLAMMERTEXT_HOME` from its own path, adds `bin/` and
TeX Live is installed there), sets `LD_LIBRARY_PATH` so `libklammertext.so` is
found, and sets the LSan suppressions. There is no per-host or per-OS variable
to set. For a TeX Live or library in a non-standard location, add it to an
optional, gitignored `mac/env/runtime.env.local` (sourced at the end).
optional, gitignored `env/runtime.env.local` (sourced at the end).
After editing your shell profile, reload it:
@@ -70,7 +70,7 @@ source ~/.bashrc
## Configure the build
No build configuration is needed. The single `mac/env/makefile.env` is
No build configuration is needed. The single `env/makefile.env` is
cross-platform: it reads `KLAMMERTEXT_HOME` from the environment (set by
`runtime.env` above), auto-detects the platform with `uname`, and auto-detects
Python with `python3-config` — no hardcoded version and no per-host file to
@@ -149,7 +149,7 @@ from the gitignored escape hatch instead, and install the SKS's extra packages
into it yourself (the package list is in `doc/install/texlive_additional_packages.sh`):
```bash
cat >> "$KLAMMERTEXT_HOME/mac/env/runtime.env.local" <<'EOF'
cat >> "$KLAMMERTEXT_HOME/env/runtime.env.local" <<'EOF'
export KLAMMERTEXT_TEXLIVE_BIN=/path/to/texlive/bin/x86_64-linux
export PATH="$KLAMMERTEXT_TEXLIVE_BIN:$PATH"
EOF

View File

@@ -3,7 +3,7 @@
Companion to `linux_source_install.md`. Verified on an Apple-Silicon Mac
(arm64, macOS 26 "Tahoe"). Klammertext's core (engine, SKS, HTML/LaTeX,
`@image`) builds and runs natively with Apple Clang; the cross-platform build
environment (`mac/env/makefile.env`) auto-detects the OS via `uname`.
environment (`env/makefile.env`) auto-detects the OS via `uname`.
## 1. Toolchain prerequisites
@@ -63,7 +63,7 @@ ffmpeg/openexr/etc.)
```sh
git clone https://git.andykopra.com/ack/klammertext.git ~/projects/klammertext
# Set up the runtime environment (KLAMMERTEXT_HOME, PATH); add to ~/.zprofile:
echo 'source "$HOME/projects/klammertext/mac/env/runtime.env"' >> ~/.zprofile
echo 'source "$HOME/projects/klammertext/env/runtime.env"' >> ~/.zprofile
```
The single self-configuring `runtime.env` self-locates `KLAMMERTEXT_HOME` from
@@ -132,7 +132,7 @@ variable **and** prepend it to `PATH`, since `runtime.env.local` is sourced
after the main `PATH` is built:
```sh
cat >> "$KLAMMERTEXT_HOME/mac/env/runtime.env.local" <<'EOF'
cat >> "$KLAMMERTEXT_HOME/env/runtime.env.local" <<'EOF'
export KLAMMERTEXT_TEXLIVE_BIN=/usr/local/texlive/2025basic/bin/universal-darwin
export PATH="$KLAMMERTEXT_TEXLIVE_BIN:$PATH"
EOF

View File

View File

@@ -1,7 +1,7 @@
# mac/env/makefile.env — single cross-platform build environment for Klammertext.
# env/makefile.env — single cross-platform build environment for Klammertext.
#
# Included identically by every component Makefile:
# include $(KLAMMERTEXT_HOME)/mac/env/makefile.env
# include $(KLAMMERTEXT_HOME)/env/makefile.env
#
# The platform is auto-detected with uname; the compiler is chosen with
# make COMPILER=gcc (default)
@@ -9,10 +9,10 @@
# There is no per-host/per-OS file and no DESKTOP_SESSION/HOST/SITE selector.
ifndef KLAMMERTEXT_HOME
$(error KLAMMERTEXT_HOME is not set -- source mac/env/runtime.env first)
$(error KLAMMERTEXT_HOME is not set -- source env/runtime.env first)
endif
include $(KLAMMERTEXT_HOME)/mac/env/optimize.env
include $(KLAMMERTEXT_HOME)/env/optimize.env
UNAME_S := $(shell uname -s)
CPP_VERSION := c++20

View File

@@ -1,22 +1,22 @@
# mac/env/runtime.env — single self-configuring runtime environment for Klammertext.
# env/runtime.env — single self-configuring runtime environment for Klammertext.
#
# Source this from your shell profile (~/.bashrc etc.):
# source /path/to/klammertext/K/mac/env/runtime.env
# source /path/to/klammertext/K/env/runtime.env
#
# This is the runtime counterpart of the shared mac/env/makefile.env: one file
# This is the runtime counterpart of the shared env/makefile.env: one file
# for every machine, with all machine-specific values AUTO-DETECTED. There is no
# per-host runtime.env.<host> file and no HOST/OS/SITE selector.
# - KLAMMERTEXT_HOME : derived from this file's own location (self-locating)
# - KLAMMERTEXT_TEXLIVE_BIN : newest ~/external/texlive/<year>/bin/<arch>
# Machine-unique, non-committable additions (extra library paths such as NVIDIA
# iray, local tools, etc.) go in an optional, gitignored mac/env/runtime.env.local
# iray, local tools, etc.) go in an optional, gitignored env/runtime.env.local
# sourced at the end -- NOT in this shared file, so one machine's bundled
# libraries can't shadow another's system libraries.
# --- KLAMMERTEXT_HOME: self-locate (bash sets BASH_SOURCE; zsh sets $0) --------
# This file lives at $KLAMMERTEXT_HOME/mac/env/runtime.env, so go up two levels.
# This file lives at $KLAMMERTEXT_HOME/env/runtime.env, so go up one level.
_kt_self="${BASH_SOURCE[0]:-$0}"
export KLAMMERTEXT_HOME="$(cd "$(dirname "$_kt_self")/../.." && pwd)"
export KLAMMERTEXT_HOME="$(cd "$(dirname "$_kt_self")/.." && pwd)"
unset _kt_self
_kt_uname="$(uname -s)"
@@ -58,7 +58,7 @@ else
# LSan suppressions for unactionable libpython/OpenImageIO leaks (see
# lsan.supp). Real leaks in Klammertext code are still reported; no effect
# in performance builds (OPTIMIZE=1, which disables ASan).
export LSAN_OPTIONS="suppressions=$KLAMMERTEXT_HOME/mac/env/lsan.supp:print_suppressions=0"
export LSAN_OPTIONS="suppressions=$KLAMMERTEXT_HOME/env/lsan.supp:print_suppressions=0"
export LD_LIBRARY_PATH="$KLAMMERTEXT_HOME/mac:$KLAMMERTEXT_HOME/sks/kutil:$KLAMMERTEXT_HOME/sks/document:$KLAMMERTEXT_HOME/doc/handbook${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
fi
unset _kt_uname
@@ -67,6 +67,6 @@ unset _kt_uname
# Use an if-block (not `[ -f ] && .`) so that when the local file is absent this
# file's final exit status is 0 -- otherwise `source runtime.env` returns
# non-zero, breaking `source runtime.env && ...` and `set -e` callers.
if [ -f "$KLAMMERTEXT_HOME/mac/env/runtime.env.local" ]; then
. "$KLAMMERTEXT_HOME/mac/env/runtime.env.local"
if [ -f "$KLAMMERTEXT_HOME/env/runtime.env.local" ]; then
. "$KLAMMERTEXT_HOME/env/runtime.env.local"
fi

View File

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -4,13 +4,13 @@
K := $(KLAMMERTEXT_HOME)
KS := $(K)/sks
include $(K)/mac/env/makefile.env
include $(K)/env/makefile.env
# Source files
BASENAMES := util error locator file argv character ktype katom katom_list \
log show command argument argument_set argtype argtype_set \
state eval eval_python eval_cpp klammer klammer_set deftype \
target target_set machine
target target_set machine font_store
SOURCES := $(addsuffix .cpp,$(BASENAMES))
OBJECTS := $(addsuffix .o,$(BASENAMES))

View File

@@ -7,12 +7,13 @@
#include "util.h"
Argtype::Argtype(std::string name, std::string desc, std::string symbolic_pattern, std::string pattern,
std::string python_cast, modify_string_f python_format,
std::string default_value, std::string python_cast, modify_string_f python_format,
const Locator& loc)
: m_name(name)
, m_desc(desc)
, m_symbolic_pattern(symbolic_pattern)
, m_pattern(pattern)
, m_default(default_value)
, m_python_cast(python_cast)
, m_python_format(python_format)
, m_regex(std::regex(pattern))
@@ -20,22 +21,25 @@ Argtype::Argtype(std::string name, std::string desc, std::string symbolic_patter
{
}
static bool empty_value(const strings_t& v)
{
return v.empty() || (v.size() == 1 && v[0].empty());
}
std::string pyformat_string(const strings_t& v)
{
std::string p = v[0];
std::string delim = "\"";
if (contains(p, "\"\"\"")) {
delim = "'''";
} else if (contains(p, "'''")) {
delim = "\"\"\"";
} else if (contains(p, "\"")) {
delim = "'";
}
return delim + p + delim;
std::string p = v.empty() ? "" : v[0];
p = string_replace(p, "\\", "\\\\");
p = string_replace(p, "\"", "\\\"");
p = string_replace(p, "\n", "\\n");
return "\"" + p + "\"";
}
std::string pyformat_bool(const strings_t& v)
{
if (empty_value(v)) {
return "None";
}
std::string value = v[0];
std::set<std::string> true_values { "1", "true", "True", "yes" };
std::set<std::string> false_values { "0", "false", "False", "no" };
@@ -50,6 +54,9 @@ std::string pyformat_bool(const strings_t& v)
std::string pyformat_number(const strings_t& value)
{
if (empty_value(value)) {
return "None";
}
return value[0];
}
@@ -62,12 +69,29 @@ std::string pylist(strings_t words)
std::string pyformat_list(const strings_t& value)
{
return pylist(value);
strings_t words {};
for (const std::string& v : value) {
for (const std::string& word : word_split(v)) {
if (!word.empty()) {
words.push_back(word);
}
}
}
return pylist(words);
}
std::string pyformat_dlist(const strings_t& value)
{
return pylist(value);
strings_t items {};
for (const std::string& v : value) {
if (v.empty()) {
continue;
}
for (const std::string& item : dlist_split(v)) {
items.push_back(item);
}
}
return pylist(items);
}
std::string Argtype::python_value(const std::string& var_name, std::vector<std::string> value, size_t name_size)
@@ -76,7 +100,18 @@ std::string Argtype::python_value(const std::string& var_name, std::vector<std::
ss << " " << std::left << std::setw(name_size) << var_name << " = ";
if (m_python_format) {
ss << m_python_format(value);
} else if (m_python_cast.empty() || m_python_cast == "str") {
// String-family types (and untyped variables): plain quoted string.
ss << pyformat_string(value);
} else if (!m_parameter.empty()) {
// Parameterized type: bind the type parameter as N around the
// cast, e.g. (lambda N: <cast>)(2)("A | B || C | D").
ss << "(lambda N: " << m_python_cast << ")(" << m_parameter << ")("
<< pyformat_string(value) << ")";
} else {
// User-defined :python_cast expression, applied to the raw value.
// The cast is applied to an empty value too, so e.g. a split lambda
// yields [] for an unsupplied argument.
ss << m_python_cast << "(" << pyformat_string(value) << ")";
}
return ss.str();

View File

@@ -17,23 +17,42 @@ public:
Argtype()
: m_name("default")
, m_desc("default argument type")
, m_symbolic_pattern(".+")
, m_pattern(".+")
, m_symbolic_pattern(R"((?:.|\n)*)")
, m_pattern(R"((?:.|\n)*)")
, m_python_cast("str")
, m_python_format()
, m_regex(std::regex(R"((?:.|\n)*)"))
, m_loc()
{};
Argtype(std::string name, std::string desc, std::string symbolic_pattern, std::string pattern,
std::string python_cast, modify_string_f python_format,
std::string default_value, std::string python_cast, modify_string_f python_format,
const Locator& loc);
std::string python_value(const std::string& var_name, std::vector<std::string> value, size_t name_size);
// True for the match-everything pattern shared by the string family
// (string, rest, literal, and the default type). Validating against
// it is pointless, and running std::regex over a large value (e.g.
// @document's :text holding a whole document) overflows the regex
// executor's recursion stack.
bool matches_all() const { return m_pattern == R"((?:.|\n)*)"; };
std::string m_name {};
std::string m_desc {};
std::string m_symbolic_pattern {};
std::string m_pattern {};
// Default value for parameters of this type; a default given in a
// klammer's parameter declaration overrides it (see
// parse_optional_parameter). Useful for single-purpose types
// (cell_hpos, column_width); general types (bool, float) have no
// sensible universal default and leave it empty.
std::string m_default {};
// Type parameter for parameterized types like rest(2): the value N
// is bound around the python cast as (lambda N: <cast>)(2)(...).
// Empty means unparameterized; a type with a default parameter
// (rest -> "1") is specialized by writing type(N) in a declaration.
std::string m_parameter {};
std::string m_python_cast {};
modify_string_f m_python_format {};
std::regex m_regex {};

View File

@@ -13,7 +13,7 @@
Parameter_set& Argtype_set::parameters()
{
static Parameter_set instance("name | desc :pattern .* :python_cast str");
static Parameter_set instance("name | desc :pattern .* :python_cast str :default");
return instance;
}
@@ -22,8 +22,11 @@ Argtype_set::Argtype_set()
(void)(void)K::log(2);
Locator loc = current_locator();
for (auto [name, desc, pattern, python_cast, python_format] : base_argtypes) {
add(name, desc, pattern, python_cast, python_format, loc);
add(name, desc, pattern, "", python_cast, python_format, loc);
}
// rest is a parameterized type (rest(N)); an unparameterized use is
// one-dimensional.
m_types["rest"].m_parameter = "1";
}
std::string Argtype_set::replace_symbols(const std::string& pattern, const Locator& loc)
@@ -59,7 +62,7 @@ void Argtype_set::check_for_existing_definition(
}
void Argtype_set::add(const std::string& name, const std::string& desc,
const std::string& pattern,
const std::string& pattern, const std::string& default_value,
const std::string& python_cast, modify_string_f python_format,
const Locator& loc)
{
@@ -68,17 +71,35 @@ void Argtype_set::add(const std::string& name, const std::string& desc,
std::string expanded_pattern = replace_symbols(pattern, loc);
m_name_size = std::max(m_name_size, name.size()); // For display
m_pattern_size = std::max(m_pattern_size, expanded_pattern.size());
m_types[name] = Argtype(name, desc, pattern, expanded_pattern, python_cast, python_format, loc);
try {
m_types[name] = Argtype(name, desc, pattern, expanded_pattern,
default_value, python_cast, python_format, loc);
} catch (const std::regex_error& e) {
std::stringstream ss {};
ss << "The pattern for argument type \"" << name
<< "\" is not a valid regular expression (" << e.what() << "):\n"
<< " " << pattern << "\n";
if (pattern != expanded_pattern) {
ss << "expanded to:\n " << expanded_pattern << "\n";
}
throw Definition_error(ss.str(), loc, false);
}
if (!default_value.empty() &&
!std::regex_match(default_value, m_types[name].m_regex)) {
std::stringstream ss {};
ss << "The default value \"" << default_value << "\" for argument type \""
<< name << "\" does not match its own pattern:\n"
<< " " << pattern << "\n";
throw Definition_error(ss.str(), loc, false);
}
m_names.push_back(name);
}
void Argtype_set::add(std::vector<Katom>::iterator begin, std::vector<Katom>::iterator end, std::vector<Katom>& katoms)
{
(void)K::log(3);
//Argument_set parameters("name | desc :pattern .* :python_cast str");
auto [positional, optional, rest] =
argument_split(begin + 1, end); //, Argtype_set::parameters.m_positional.size());
argument_split(begin + 1, end - 1); //, Argtype_set::parameters.m_positional.size());
check_for_existing_definition(positional[0][0].m_text, begin->m_loc);
@@ -86,11 +107,8 @@ void Argtype_set::add(std::vector<Katom>::iterator begin, std::vector<Katom>::it
// std::for_each(begin, end+1, [](Katom& k) { k.m_type = katom_t::replaced; });
modify_string_f pyformat {};
std::string pycast {};
add(values["name"], values["desc"], values["pattern"],
pycast, pyformat,
add(values["name"], values["desc"], values["pattern"], values["default"],
values["python_cast"], modify_string_f{},
begin->m_loc);
modify_type(katom_t::replaced, begin, end);

View File

@@ -18,7 +18,8 @@ public:
std::string replace_symbols(const std::string& pattern, const Locator& loc);
void check_for_existing_definition(const std::string& name, const Locator& loc);
void add(const std::string& name, const std::string& desc,
const std::string& pattern, const std::string& python_cast, modify_string_f python_format,
const std::string& pattern, const std::string& default_value,
const std::string& python_cast, modify_string_f python_format,
const Locator& loc);
void add(std::vector<Katom>::iterator begin, std::vector<Katom>::iterator end, std::vector<Katom>& katoms);
@@ -63,7 +64,7 @@ std::vector<std::tuple<std::string, std::string, std::string, std::string, modif
pyformat_number },
{ "float", "a floating-point number",
R"([-+]?\d+(\.\d*)?)", "float",
R"([-+]?(\d+(\.\d*)?|\.\d+))", "float",
pyformat_number },
{ "fraction", "a number in the form 'n/d'",
@@ -78,9 +79,11 @@ std::vector<std::tuple<std::string, std::string, std::string, std::string, modif
R"((?:.|\n)*)", "",
pyformat_dlist },
{ "rest", "a list of strings delimited by the bar character",
R"(.*)", "str",
pyformat_string },
{ "rest", "the remaining arguments as a list nested to N dimensions "
"(rest(N)); the delimiter for dimension n is a run of n bar "
"characters, so | separates elements and || lists of elements",
R"((?:.|\n)*)", R"((lambda s : __import__("kutil").rest_split(s, N)))",
nullptr },
{ "literal", "literal text passed without interpretation",
R"((?:.|\n)*)", "str",

View File

@@ -30,6 +30,10 @@ public:
Argtype m_argtype; // {};
bool m_optional {};
std::string m_default {};
// True when m_default was filled from the argument type's :default
// rather than declared in the klammer's parameter list (for kdesc
// provenance display).
bool m_default_from_type {};
Locator m_loc;
std::string m_target {};
};

View File

@@ -17,16 +17,44 @@ bool operator==(Parameter_set lhs, Parameter_set rhs)
std::regex parameter_regex(bool optional=false)
{
//std::string pattern = R"(([A-Za-z]\w*)(?:(\.\w*)(?:(\.\w*)))?)";
std::string pattern = R"((?:([A-Za-z]\w*))|(?:([A-Za-z]\w*)\.(\w+))|(?:([A-Za-z]\w*)\.(\w+)\.(\w+)))";
// The type component may carry a numeric type parameter, e.g.
// rows.rest(2) (see resolve_argtype below). In the optional
// three-part form the type may be empty (:paper_size..tex).
std::string type = R"(\w+(?:\(\d+\))?)";
std::string opt_type = R"(\w*(?:\(\d+\))?)";
std::string pattern = R"((?:([A-Za-z]\w*))|(?:([A-Za-z]\w*)\.()" + type +
R"())|(?:([A-Za-z]\w*)\.()" + type + R"()\.(\w+)))";
if (optional) {
pattern = R"((?::([A-Za-z]\w*))|(?::([A-Za-z]\w*)\.(\w+))|(?::([A-Za-z]\w*)\.(\w*)\.(\w+)))";
// x x
pattern = R"((?::([A-Za-z]\w*))|(?::([A-Za-z]\w*)\.()" + type +
R"())|(?::([A-Za-z]\w*)\.()" + opt_type + R"()\.(\w+)))";
}
// (void)K::log(3, pattern);
return std::regex(pattern);
}
// Look up an argument type, specializing a parameterized use such as
// rest(2): the base type is copied, its type parameter set, and its
// display name extended, so kdesc signatures show rows.rest(2).
static Argtype resolve_argtype(
const std::string& type_text, const Argtype_set& argtypes, const Locator& loc)
{
static const std::regex parameterized(R"((\w+)\((\d+)\))");
std::smatch match {};
if (std::regex_match(type_text, match, parameterized)) {
Argtype argtype = argtypes.get(match[1], loc);
argtype.m_parameter = match[2];
argtype.m_name += "(" + std::string(match[2]) + ")";
return argtype;
}
return argtypes.get(type_text, loc);
}
// True for the rest type in any parameterization (rest, rest(2), ...).
static bool is_rest(const Argtype& argtype)
{
return argtype.m_name == "rest" || argtype.m_name.rfind("rest(", 0) == 0;
}
Parameter_set::Parameter_set(const std::string parameter_string)
{
(void)K::log(3);
@@ -77,7 +105,7 @@ Parameter parse_positional_parameter(const katom_list& katoms, const Argtype_set
if (match_type.empty()) {
match_type = "string";
}
return Parameter(match_name, argtypes.get(match_type, k.m_loc), k.m_loc);
return Parameter(match_name, resolve_argtype(match_type, argtypes, k.m_loc), k.m_loc);
}
}
@@ -105,8 +133,19 @@ Parameter parse_optional_parameter(const katom_list& katoms, const Argtype_set&
if (match_type.empty()) {
match_type = "string";
}
return Parameter(match_name, argtypes.get(match_type, k.m_loc),
k.m_loc, true, default_value);
Parameter parameter(match_name, resolve_argtype(match_type, argtypes, k.m_loc),
k.m_loc, true, default_value);
// Two-level default resolution: a default declared in the
// parameter list wins; otherwise the argument type's :default
// fills in. Both are validated here, at definition time, so an
// invalid default cannot reach an application.
if (default_value.empty() && !parameter.m_argtype.m_default.empty()) {
parameter.m_default = parameter.m_argtype.m_default;
parameter.m_default_from_type = true;
} else if (!default_value.empty()) {
Parameter_set::validate(parameter, default_value, k.m_loc);
}
return parameter;
}
}
@@ -213,7 +252,7 @@ void Parameter_set::parse_parameters(const katom_list& katoms, const Argtype_set
auto [positional, optional] = parameter_split(katoms.cbegin(), katoms.cend());
for (auto req : positional) {
auto pos = parse_positional_parameter(req, argtypes);
if (pos.m_argtype.m_name == "rest") {
if (is_rest(pos.m_argtype)) {
m_rest.push_back(pos);
} else {
m_positional.push_back(pos);
@@ -295,6 +334,13 @@ argument_split(katom_list::const_iterator kbegin, katom_list::const_iterator ken
} else if (positional.size() < positional_limit) {
positional.push_back(trim_part(p));
} else {
// Parts were trimmed, so adjacent parts would abut their bar
// katoms (a row separator "||" next to an empty cell's "|"
// would serialize as "|||"). A space keeps the writer's
// bar/double-bar distinction parseable.
if (!rest.empty()) {
rest.push_back(Katom(" ", katom_t::space, p[0].m_loc));
}
rest.insert(rest.end(), p.begin(), p.end());
}
}
@@ -386,9 +432,59 @@ Parameter_set::value_map(
throw Argument_error(ss.str(), loc);
}
}
for (const auto& [name, value] : values) {
const Parameter* parameter = find(name);
if (parameter) {
validate(*parameter, value, loc);
}
}
return values;
}
// Check an argument value against its argument type's pattern. An empty
// value (an unsupplied optional argument without a default) is not checked.
// The error message includes the argument type's description from its
// @@@argtype definition, so the .k description text is what the writer
// sees when a complicated value (e.g. a table line specification) is wrong.
// The value-size limit guards against std::regex stack overflow: the
// libstdc++ executor recurses per character, so a pattern applied to a
// very large value crashes. Typed argument values are short; large
// values are content (rest, :text) whose types match everything and are
// excluded by matches_all() anyway.
const size_t validation_size_limit = 4096;
void Parameter_set::validate(
const Parameter& parameter, const std::string& value, const Locator& loc)
{
if (value.empty() || value.size() > validation_size_limit) {
return;
}
const Argtype& argtype = parameter.m_argtype;
if (argtype.matches_all()) {
return;
}
if (!std::regex_match(value, argtype.m_regex)) {
std::stringstream ss {};
ss << "The value \"" << value << "\" given for the argument \""
<< parameter.m_name << "\" does not match the \"" << argtype.m_name
<< "\" argument type:\n\n"
<< trim(argtype.m_desc) << "\n";
throw Argument_error(ss.str(), loc, false);
}
}
const Parameter* Parameter_set::find(const std::string& name) const
{
for (const auto& params : {&m_positional, &m_optional, &m_rest}) {
for (const Parameter& p : *params) {
if (p.m_name == name) {
return &p;
}
}
}
return nullptr;
}
// Parameter/argument substitution
std::string replace_arguments(

View File

@@ -34,6 +34,9 @@ public:
const std::vector<std::vector<Katom>>& optional,
const std::vector<Katom>& rest,
const Locator& loc);
const Parameter* find(const std::string& name) const;
static void validate(
const Parameter& parameter, const std::string& value, const Locator& loc);
bool empty() const { return m_katoms.size() == 0; };
std::vector<Katom> m_katoms {};

View File

@@ -145,6 +145,40 @@ void Argv::opt(const std::string& name, const std::string& desc, const std::stri
update_width(arg);
}
void Argv::var(const std::string& name, const std::string& desc)
{
(void)K::log(2, name, desc);
Arg arg {};
arg.m_type = "var";
arg.m_name = name;
arg.m_desc = get_regex_desc(desc);
arg.m_syntax = arg.symbol();
m_args[name] = arg;
m_names.push_back(name);
m_var_names.push_back(name);
m_hyphen_markers.push_back(flag_name(name));
update_width(arg);
}
void Argv::parse_vars(strings_t& words, string_map& named_args)
{
for (const std::string& name : m_var_names) {
auto it = std::ranges::find(words, flag_name(name));
named_args[name] = "";
if (it == words.end()) {
continue;
}
m_given.insert(name);
auto first = it + 1;
auto last = first;
while (last != words.end() && !(*last).empty() && (*last)[0] != '-') {
last++;
}
named_args[name] = join(strings_t(first, last), " ");
words.erase(it, last);
}
}
void Argv::usage_line(Arg arg)
{
std::cout.fill(' ');
@@ -330,6 +364,7 @@ Argv::classify_arguments(int argc, char* argv[], bool full_parse)
if (full_parse) {
check_flags_and_options(argv[0], words);
}
parse_vars(words, named_args);
parse_flags(words, named_args);
parse_optional(words, named_args);
parse_positional(argv_to_string(argc, argv), join(words, " "), named_args);

View File

@@ -3,6 +3,7 @@
// Delusions of generality, but it's really just for Klammertext commands.
#include <map>
#include <set>
#include <ranges>
#include <algorithm>
#include <regex>
@@ -52,12 +53,23 @@ public:
void flag(const std::string& name, const std::string& desc);
void req(const std::string& name, const std::string& desc, const std::string& regex_pattern="'text'");
void opt(const std::string& name, const std::string& desc="", const std::string& parameter="", const std::string& default_value="", const std::string& regex_pattern="text");
// A variadic option: --name collects every following word up to the
// next -/-- token (zero or more). get(name) returns the words
// space-joined; given(name) distinguishes "--name with no words"
// from an absent --name. Used for subcommand-style interfaces
// (kdesc --font install <dir>).
void var(const std::string& name, const std::string& desc);
void update_width(Arg arg);
void check_flags_and_options(std::string command, std::vector<std::string>& words);
void parse_flags(std::vector<std::string>& words, std::map<std::string, std::string>& named_args);
void parse_optional(std::vector<std::string>& words, std::map<std::string, std::string>& named_args);
void parse_vars(std::vector<std::string>& words, std::map<std::string, std::string>& named_args);
// True when the named variadic option appeared on the command line
// (even with no following words).
bool given(const std::string& name) { return m_given.contains(name); };
void parse_positional(
std::string command, // std::vector<std::string> words,
@@ -98,6 +110,8 @@ public:
std::vector<std::string> m_req_names {};
std::vector<std::string> m_flag_names {};
std::vector<std::string> m_opt_names {};
std::vector<std::string> m_var_names {};
std::set<std::string> m_given {};
std::vector<std::string> m_hyphen_markers {};
long unsigned int m_syntax_size = 0;
};

View File

@@ -1,46 +0,0 @@
KLAMMERTEXT_HOME = /home/ack/projects/klammertext/K
include $(KLAMMERTEXT_HOME)/mac/env/optimize.env
CPP_VERSION = c++20
#GCC_LIB = /usr/lib/gcc/x86_64-linux-gnu/7.4.0
CXX = /usr/bin/g++
#IMPORT = -fmodules -fsearch-include-path bits/std.cc
IMPORT =
# Hack for now; to be generalized:
ifneq ("$(wildcard /usr/include/python3.14)","")
PYTHON = python3.14
else ifneq ("$(wildcard /usr/include/python3.13)","")
PYTHON = python3.13
else
PYTHON = python3.12
endif
# -no-pie?
CXXFLAGS = -Wall -Wextra -Weffc++ -fPIC $(PROFILE) -std=$(CPP_VERSION) $(IMPORT) $(OPTIMIZE) \
-I$(KLAMMERTEXT_HOME)/mac \
-I/usr/include \
-I/usr/include/$(PYTHON)
# -L$(GCC_LIB) \
LDFLAGS = \
-L/usr/lib/x86_64-linux-gnu
LDLIBS = \
-ldl
ifndef NOPYTHON
LDLIBS += -l$(PYTHON)
endif
#GCC_ROOT = /h/dev/pkg/gcc-$(GCC_VERSION)
#GCC_LIB = $(GCC_ROOT)/$(GCC_DIR)/lib/gcc/$(GCC_DIR)/$(GCC_VERSION)
#$(GCC_LIB)
LD_LIBRARY_PATH=\
/usr/lib64\
:/usr/lib/x86_64-linux-gnu

View File

@@ -1,33 +0,0 @@
KLAMMERTEXT_HOME = /home/ack/projects/klammertext/K
include $(KLAMMERTEXT_HOME)/mac/env/optimize.env
CPP_VERSION = c++20
CXX = /usr/bin/g++
# Hack for now; to be generalized:
ifneq ("$(wildcard /usr/include/python3.14)","")
PYTHON = python3.14
else ifneq ("$(wildcard /usr/include/python3.13)","")
PYTHON = python3.13
else
PYTHON = python3.12
endif
CPPFLAGS = \
-I$(KLAMMERTEXT_HOME)/mac \
-I/usr/include \
-I/usr/include/$(PYTHON)
CXXFLAGS = -Wall -Wextra -Weffc++ -Wshadow -std=$(CPP_VERSION) -fPIC $(OPTIMIZE) $(SANITIZE)
LDFLAGS = \
-L/usr/lib/x86_64-linux-gnu
LDLIBS = \
-ldl
ifndef NOPYTHON
LDLIBS += -l$(PYTHON)
endif

View File

@@ -1,33 +0,0 @@
KLAMMERTEXT_HOME = /home/ack/projects/klammertext/K
include $(KLAMMERTEXT_HOME)/mac/env/optimize.env
CPP_VERSION = c++20
CXX = /usr/bin/g++
# Hack for now; to be generalized:
ifneq ("$(wildcard /usr/include/python3.14)","")
PYTHON = python3.14
else ifneq ("$(wildcard /usr/include/python3.13)","")
PYTHON = python3.13
else
PYTHON = python3.12
endif
CPPFLAGS = \
-I$(KLAMMERTEXT_HOME)/mac \
-I/usr/include \
-I/usr/include/$(PYTHON)
CXXFLAGS = -Wall -Wextra -Weffc++ -Wshadow -std=$(CPP_VERSION) -fPIC $(OPTIMIZE) $(SANITIZE)
LDFLAGS = \
-L/usr/lib/x86_64-linux-gnu
LDLIBS = \
-ldl
ifndef NOPYTHON
LDLIBS += -l$(PYTHON)
endif

View File

@@ -1,33 +0,0 @@
KLAMMERTEXT_HOME = /home/ack/projects/klammertext/K
include $(KLAMMERTEXT_HOME)/mac/env/optimize.env
CPP_VERSION = c++20
CXX = /usr/bin/g++
# Hack for now; to be generalized:
ifneq ("$(wildcard /usr/include/python3.14)","")
PYTHON = python3.14
else ifneq ("$(wildcard /usr/include/python3.13)","")
PYTHON = python3.13
else
PYTHON = python3.12
endif
CPPFLAGS = \
-I$(KLAMMERTEXT_HOME)/mac \
-I/usr/include \
-I/usr/include/$(PYTHON)
CXXFLAGS = -Wall -Wextra -Weffc++ -Wshadow -std=$(CPP_VERSION) -fPIC $(OPTIMIZE) $(SANITIZE)
LDFLAGS = \
-L/usr/lib/x86_64-linux-gnu
LDLIBS = \
-ldl
ifndef NOPYTHON
LDLIBS += -l$(PYTHON)
endif

602
mac/font_store.cpp Normal file
View File

@@ -0,0 +1,602 @@
// 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;
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;
file.extension = fs::path(path).extension();
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 = entry.path().extension();
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 "
<< "&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());
}
}
}

59
mac/font_store.h Normal file
View File

@@ -0,0 +1,59 @@
#pragma once
// The Klammertext font store (infrastructure; see font_store.cpp).
#include <string>
#include <vector>
struct Resolved_font {
std::string family_name {}; // "Crimson Pro"
std::string dir_name {}; // "crimson-pro"
std::string font_dir {}; // Full path to font directory
std::string css_file {}; // Full path to .css file
// .ttf filenames for each variant (empty if variant not available):
std::string regular {};
std::string bold {};
std::string italic {};
std::string bold_italic {};
float xheight_ratio = 0.0f; // x-height / unitsPerEm from OS/2 table
float capheight_ratio = 0.0f; // cap-height / unitsPerEm from OS/2 table
};
std::string name_to_dirname(std::string name);
// Directories searched for installed fonts (KLAMMERTEXT_FONTS, default
// ~/.klammertext/fonts); the default font set is searched after them.
std::vector<std::string> installed_font_dirs();
// The distribution's default fonts: $KLAMMERTEXT_HOME/fnt.
std::string default_font_dir();
// Every installable font family found across those directories.
std::vector<std::string> available_font_families();
Resolved_font resolve_font(std::string family_name);
void install_resolved_font(const Resolved_font& font, std::string output_dir);
// One font file classified by its internal metadata (name table, OS/2).
struct Font_file {
std::string path {};
std::string family {}; // from name table (nameID 16, else 1)
std::string variant {}; // Regular | Bold | Italic | BoldItalic
std::string extension {}; // ".ttf" or ".otf"
bool variable = false; // has an 'fvar' table
int weight = 0; // OS/2 usWeightClass
std::string note {}; // reason when the file is not installable
};
// Recursively classify every .ttf/.otf under a directory.
std::vector<Font_file> classify_font_files(const std::string& directory);
// Install the classified families from source_dir into dest_dir (default:
// the first KLAMMERTEXT_FONTS directory, created if necessary), in the
// canonical relocatable layout <dest>/<family-kebab>/{Variant}.ttf plus a
// generated <family-kebab>.css with relative urls. Returns a report.
std::string install_fonts(const std::string& source_dir, std::string dest_dir = "");
// Describe every font in the store with provenance and variants.
std::string describe_fonts();
// Write an HTML specimen page for fonts into output_dir. With an empty
// source_dir, samples every available font in the store; otherwise
// classifies and samples the (possibly uninstalled) fonts in source_dir.
std::string write_font_samples(const std::string& output_dir, const std::string& source_dir = "");

View File

@@ -405,7 +405,7 @@ katom_list Machine::apply_klammer(
katom_list result(klammer.m_body[target].begin(), klammer.m_body[target].end());
auto varmap = klammer.m_varmap[target];
m_state.open_frame("Arguments for klammer " + q_(klammer.m_name));
m_state.set(values);
m_state.set(values, klammer.m_parameters);
for (const auto& [name, indices] : varmap) {
std::regex arg("\\*" + name + "\\*");
for (auto i : indices) {

View File

@@ -25,9 +25,9 @@ std::vector<std::string> Frame::names() const
}
void Frame::set(std::string name, std::string value,
std::string delim, std::string desc, Locator loc)
std::string delim, std::string desc, Locator loc, Argtype argtype)
{
Var v(name, value, delim, desc, loc);
Var v(name, value, delim, desc, loc, argtype);
m_vars[name] = v;
}
@@ -70,7 +70,7 @@ void State::close_frame()
}
void State::set(std::string name, std::string value, bool update,
std::string delim, std::string desc, Locator loc)
std::string delim, std::string desc, Locator loc, Argtype argtype)
{
if (m_frames.empty()) {
std::stringstream ss {};
@@ -86,7 +86,7 @@ void State::set(std::string name, std::string value, bool update,
<< q_(current.m_value) << ".";
throw Argument_error(ss.str(), current.m_loc);
}
m_frames[0].set(name, value, delim, desc, loc);
m_frames[0].set(name, value, delim, desc, loc, argtype);
}
void State::set(std::map<std::string, std::string> varmap)
@@ -96,6 +96,16 @@ void State::set(std::map<std::string, std::string> varmap)
}
}
void State::set(const std::map<std::string, std::string>& varmap,
const Parameter_set& parameters)
{
for (const auto& [name, value] : varmap) {
const Parameter* parameter = parameters.find(name);
set(name, value, false, " ", "", Locator(),
parameter ? parameter->m_argtype : Argtype());
}
}
void State::replace(std::string name, std::string value, bool error_if_not_defined)
{
@@ -269,13 +279,8 @@ std::string State::python_code()
<< margin << std::left << std::setw(name_length) << "K_eval_id" << " = "
<< State::class_id++ << "\n";
for (const auto& name : names) {
// auto [var_value, argtype] = value_type(name);
// ss << argtype.python_value(name, var_value, name_length) << "\n";
std::string v = value(name);
v = string_replace(v, "\\", "\\\\");
v = string_replace(v, "\"", "\\\"");
std::string var_value = qq_(v);
ss << margin << std::left << std::setw(name_length) << name << " = " << var_value << "\n";
Var var = get(name);
ss << var.m_argtype.python_value(name, {var.m_value}, name_length) << "\n";
}
}
// msg() << ss.str() << "\n";

View File

@@ -15,12 +15,14 @@ class Var
public:
Var() = default;
Var(std::string name, std::string value=klammerstate::no_value,
std::string delim=":", std::string desc="", Locator loc=Locator())
std::string delim=":", std::string desc="", Locator loc=Locator(),
Argtype argtype=Argtype())
: m_name(name)
, m_value(value)
, m_delim(delim)
, m_desc(desc)
, m_loc(loc)
, m_argtype(argtype)
{};
bool defined();
@@ -29,6 +31,7 @@ public:
std::string m_delim {};
std::string m_desc {};
Locator m_loc {};
Argtype m_argtype {};
};
namespace klammerstate {
@@ -44,7 +47,8 @@ public:
std::vector<std::string> names() const;
void set(std::string name, std::string value,
std::string delim=":", std::string desc="", Locator loc=Locator());
std::string delim=":", std::string desc="", Locator loc=Locator(),
Argtype argtype=Argtype());
std::pair<Var, bool> get(std::string name);
std::string m_name {};
@@ -58,8 +62,11 @@ public:
void open_frame(std::string name);
void close_frame();
void set(std::string name, std::string value, bool update=false,
std::string delim=" ", std::string desc="", Locator loc=Locator());
std::string delim=" ", std::string desc="", Locator loc=Locator(),
Argtype argtype=Argtype());
void set(std::map<std::string, std::string> varmap);
void set(const std::map<std::string, std::string>& varmap,
const Parameter_set& parameters);
void replace(std::string name, std::string value, bool error_if_not_defined=true);
void add_environment_frame();
Var get(std::string name, bool error_if_not_defined=false, Locator loc=Locator());

View File

@@ -5,7 +5,7 @@ K := $(KLAMMERTEXT_HOME)
KS := $(K)/sks
KM := $(K)/mac
include $(KM)/env/makefile.env
include $(K)/env/makefile.env
# Additional include paths for sks components
LOCAL_CPPFLAGS := -I$(KM) -I$(KS)/kutil -I$(KS)/target
@@ -23,8 +23,7 @@ LOCAL_DEPFILES := document_class.d document_html.d document_latex.d heading.d re
# External object files from sks/ (mac/*.o now in libklammertext.so)
SKS_OBJECTS := $(KS)/kutil/kutil.o $(KS)/kutil/klammer_base.o \
$(KS)/target/html_util.o $(KS)/target/latex_util.o \
$(KS)/target/font_resolve.o
$(KS)/target/html_util.o $(KS)/target/latex_util.o
ALL_OBJECTS := $(LOCAL_OBJECTS) $(SKS_OBJECTS)

View File

@@ -1,8 +1,10 @@
@@@argtype document_structure |
Structure of a document: plain, article, or book
:pattern plain^|article^|book
:default plain
@@@
@@document.k
:title
:subtitle
@@ -19,7 +21,7 @@
:toc.bool false
]#
:structure.document_structure plain
:structure.document_structure
:text
:files
@@ -38,9 +40,6 @@
:include_sks_js.bool true
:include_fonts.bool true
:local_fonts.list linux-libertine
:google_fonts.list Inconsolata Open+Sans:wght^@500 Libre+Baskerville
:serif_font Crimson Pro
:sans_font Open Sans
:mono_font Inconsolata

View File

@@ -62,8 +62,6 @@ Document_class::Document_class(Machine& machine) : Klammer_base(machine)
m_include_sks_js = strbool(get("include_sks_js"), loc);
// font_dirs = word_split(get("font_dirs"));
m_local_fonts = word_split(get("local_fonts"));
m_google_fonts = word_split(get("google_fonts"));
auto strip_quotes = [](std::string s) {
if (s.size() >= 2 && s.front() == '"' && s.back() == '"')

View File

@@ -5,7 +5,7 @@
#include "latex_util.h"
#include "machine.h"
#include "heading.h"
#include "font_resolve.h"
#include "font_store.h"
const std::string closed_symbol { "&^#9656;" };
const std::string open_symbol { "&^#9662;" };
@@ -120,8 +120,6 @@ public:
bool m_include_sks_js = true;
strings_t m_font_dirs {};
strings_t m_local_fonts {};
strings_t m_google_fonts {};
std::string m_serif_font {};
std::string m_sans_font {};

View File

@@ -87,8 +87,7 @@ strings_t Document_class::resolved_font_names()
{
strings_t result {};
auto add = [&](const Resolved_font& rf) {
if (!rf.family_name.empty() &&
std::find(m_local_fonts.begin(), m_local_fonts.end(), rf.dir_name) == m_local_fonts.end())
if (!rf.family_name.empty())
result.push_back(rf.dir_name);
};
add(m_resolved_serif);
@@ -101,13 +100,17 @@ std::string Document_class::font_definitions()
{
std::stringstream ss {};
if (!m_serif_font.empty() || !m_sans_font.empty() || !m_mono_font.empty()) {
// Family names are quoted: an unquoted name with a digit-initial
// word ("Source Sans 3") is invalid CSS, and a font-family using
// var() with such a value computes to inherit, silently losing
// the font.
ss << ":root {\n";
if (!m_serif_font.empty())
ss << " --serif: " << m_serif_font << ", serif;\n";
ss << " --serif: \"" << m_serif_font << "\", serif;\n";
if (!m_sans_font.empty())
ss << " --sans-serif: " << m_sans_font << ", sans-serif;\n";
ss << " --sans-serif: \"" << m_sans_font << "\", sans-serif;\n";
if (!m_mono_font.empty())
ss << " --monospace: " << m_mono_font << ", monospace;\n";
ss << " --monospace: \"" << m_mono_font << "\", monospace;\n";
ss << "}\n";
}
// Emit scale factors so sans and mono fonts match the serif font.
@@ -180,7 +183,6 @@ std::string Document_class::create_html_output_directories()
if (!file_exists(output_directory)) {
fs::create_directory(output_directory);
}
html::install_local_fonts(m_font_dirs, m_local_fonts, output_directory);
install_resolved_font(m_resolved_serif, output_directory);
install_resolved_font(m_resolved_sans, output_directory);
install_resolved_font(m_resolved_mono, output_directory);
@@ -665,13 +667,9 @@ elements_t Document_class::page(std::string body_text, std::string output_dir, i
std::string page_title = m_page_title.empty() ? m_title : m_page_title;
strings_t all_local_fonts = m_local_fonts;
for (auto& name : resolved_font_names())
all_local_fonts.push_back(name);
elements_t page = html::make_page(
body, page_title,
css_text, css_filenames, all_local_fonts, m_google_fonts);
css_text, css_filenames, resolved_font_names());
return page;
}
@@ -734,10 +732,7 @@ std::string Document_class::make_html_navigation_structure(
m_file_components[0].second,
m_date, m_version, m_copyright,
css_text, all_css_files, all_js_files,
[&]() { strings_t f = m_local_fonts;
for (auto& n : resolved_font_names()) f.push_back(n);
return f; }(),
m_google_fonts,
resolved_font_names(),
m_logo);
std::string result = t.str();
result = string_replace(result,

View File

@@ -12,9 +12,10 @@
'b' (bold), 's' (sans-serif), 't' (typewriter or monospace) or 'c'
(code) for each cell in a row. If there are fewer font symbols than
cells in a row, the last symbol is repeated. Extra symbols are
ignored. Default is 'r'
ignored.
:pattern ('font'^|\s)+
:python_cast (lambda s : s.split())
:default r
@@@
@@font.k fontname : Switch to an available font for PDF output. @@

View File

@@ -34,7 +34,7 @@ def html_fontify(text, font_symbol, font_size):
if cls:
if font_symbol == "c":
result = re.sub(" ", "&nbsp;", result)
cls = f'class="cls"'
cls = f'class="{cls}"'
sty = ""
if font_size != 1:
sty = f'style="font-size: {float(font_size) * 100}%"'

View File

@@ -16,6 +16,13 @@
# @@@argtype pixels | a pixel count :pattern \d+px @@@
# @@image.k basename : Image read from a file @@
# @@image.html basename | width | height : @eval import image ; result = image.image(K) eval@ @@
@@@argtype image_hpos |
horizontal position of an image
:pattern left^|center^|right^|none
:default center
@@@
@@image
basename
:id
@@ -23,7 +30,7 @@
@caption_arguments@
:vmargin.bool true
:center.bool true
:hpos.hpos center
:hpos.image_hpos
:rel
:abswidth.number 0.0
:border.bool false
@@ -32,13 +39,13 @@
@@
@@image_grid
image_specs.rest
image_specs.rest(2)
:caption
:number.bool true
:cell_number.bool false
:landscape.bool false
:scale.number 0.98
:caption_side.side bottom
:caption_side.caption_side
:caption_side_center.bool true
#
:thumbnail.bool false

View File

@@ -122,7 +122,6 @@ class Image(klammer_base.Klammer_base):
#if self.caption or self.number:
# width = "\\textwidth"
#else:
self.number = self.number == "true"
width = kutil.parse_length("tex", self.width, self.rel_fraction)[0]
width = re.sub("px", "pt", width)
#result = f'\\includegraphics[width={width}]{{{source}}}'

View File

@@ -25,11 +25,16 @@ class Kargs:
self.K_input_filenames = K.K_input_filenames
self.K_output_dir = K.K_output_dir
self.K_output_basename = K.K_output_basename
self.image_output_dir = K.Image_output_dir
self.Image_output_dir = K.Image_output_dir
self.K_toc_only = False
self.id = ""
self.as_string = False
self.rel = None
# Defaults for @image parameters the grid does not use per cell.
self.border = False
self.vmargin = False
self.abswidth = 0.0
self.caption_font_size = .9
def img(K, target, basename, caption, width):
result = image.Image(
@@ -46,7 +51,7 @@ class Image_grid(klammer_base.Klammer_base):
self.basenames = []
self.captions = []
for row in kutil.rest_args(self.image_specs, dimensions=2):
for row in self.image_specs:
self.images.append(
[e.groups() for e in [image_pat.fullmatch(s.strip()) for s in row]])
self.basenames.append([e[0] for e in self.images[-1]])

View File

@@ -4,7 +4,7 @@
K := $(KLAMMERTEXT_HOME)
KM := $(K)/mac
include $(KM)/env/makefile.env
include $(K)/env/makefile.env
# Source files
SOURCES := kutil.cpp klammer_base.cpp

View File

@@ -1,9 +1,15 @@
@@show s : @eval :cpp show show @ @@
@@@argtype caption_side |
the side of its element on which a caption is placed
:pattern top^|right^|bottom^|left
:default bottom
@@@
@@caption_arguments :
:caption
:number.bool true
:caption_side.side bottom
:caption_side.caption_side
:caption_font.font i
:caption_font_size.float .9
@@
@@ -29,14 +35,6 @@
#:python_cast (lambda s : [__import__("kutil").parse_length("tex", e) for e in s.split()])
@@@
@@@argtype side | a side of a box
:pattern top^|right^|bottom^|left
@@@
@@@argtype hpos | horizontal position
:pattern left^|center^|right^|none
@@@
@@@argtype figure_id |
an identifier for a figure.

View File

@@ -91,20 +91,32 @@ def caption_marker(name, caption, delimiter=" - "):
caption = f"{delimiter}{caption}"
return f"{d}{name}{d}{caption}{d}"
def rest_split(s, dimensions=1):
"""Split bar-delimited text into nested lists, one level per dimension.
The delimiter for dimension n is a run of exactly n bar characters:
| separates elements, || separates lists of elements, ||| lists of
lists, and so on. This is the cast behind the rest(N) argument type.
One trailing top-level delimiter (the customary dangling separator
before a closing @) is removed; all other empty elements are
preserved, so a trailing | still makes an empty final cell.
"""
s = s.strip()
if not s:
return [] if dimensions > 0 else s
delimiter = "|" * dimensions
if s.endswith(delimiter) and not s.endswith("|" + delimiter):
s = s[:-len(delimiter)]
return _rest_split_level(s, dimensions)
def _rest_split_level(s, dimensions):
if dimensions <= 0:
return s.strip()
pattern = re.compile("(?<!\\|)" + "\\|" * dimensions + "(?!\\|)")
return [_rest_split_level(part, dimensions - 1) for part in pattern.split(s)]
def rest_args(s, dimensions=1):
if s.endswith("||"):
s = s[:-2]
s = re.sub(r"\t", r"\\t", s)
double_bar_pat = re.compile(r"\|\|")
bar_pat = re.compile(r"\s*\|\s*")
parts = [e.strip() for e in double_bar_pat.split(s.strip())]
for p in parts:
elts = bar_pat.split(p.strip())
elts = ["~" if (e.strip() == "") else e.strip() for e in elts]
result = [bar_pat.split(e) for e in parts]
if dimensions == 1:
result = result[0]
return result
return rest_split(s, dimensions)
def parse_length(target, s, rel_fraction):
def choose(html_value, tex_value):

View File

@@ -63,7 +63,7 @@ class Link(klammer_base.Klammer_base):
# self.show("Link")
def html(self):
return html_link(self.target, self.text, self.section == "true", self.nq)
return html_link(self.target, self.text, self.section, self.nq)
def tex(self):
return latex_link(self.target, self.text, self.section, self.nq, self.footnote)

View File

@@ -9,9 +9,9 @@
@@ol.k list_items.rest :cmp.bool false :initial.int 1 : Ordered list, with numbered list elements. @@
@@ol :: @eval list.List(K, "ol") eval@ @@
@@define descriptions.rest :font i : @eval list.Define(K) eval@ @@
@@define descriptions.rest(2) :font i : @eval list.Define(K) eval@ @@
@@columns items.rest :n.int 2 : @eval list.columns(K) eval@ @@
@@columns items :n.int 2 : @eval list.columns(K) eval@ @@
##
#[

View File

@@ -11,9 +11,7 @@ class List(klammer_base.Klammer_base):
def __init__(self, K, list_type):
super().__init__(K)
self.list_type = list_type
self.items = kutil.rest_args(self.list_items)
if self.items[-1].strip() == "":
self.items = self.items[:-1]
self.items = self.list_items
word_count = max([len(e.strip().split()) for e in self.items])
self.compressed = False #and word_count < int(self.List_compressed_word_max)
@@ -31,11 +29,11 @@ class List(klammer_base.Klammer_base):
command = {'ol' : 'enumerate', 'ul' : 'itemize'}[self.list_type]
init = ""
if self.__dict__.get("initial") and self.initial != 1:
init = "\\addtocounter{enumi}{" + str(int(self.initial) - 1) + "}"
init = "\\addtocounter{enumi}{" + str(self.initial - 1) + "}"
#topsep = '[noitemsep,topsep=0pt]'
topsep = "[noitemsep,topsep=0pt," if self.compressed else "[topsep=0pt,itemsep=2pt,"
topsep += "leftmargin=16pt]"
listsep = '\\setlist{nolistsep}' if self.cmp == 'true' else ''
listsep = '\\setlist{nolistsep}' if self.cmp else ''
result = f'{listsep}\n\\begin{{{command}}}{topsep}{init}\n{body}\n\\end{{{command}}}\n'
return result
@@ -58,7 +56,7 @@ class List(klammer_base.Klammer_base):
class Define(klammer_base.Klammer_base):
def __init__(self, K):
super().__init__(K)
self.items = kutil.rest_args(self.descriptions, 2)
self.items = self.descriptions
#self.show()
#pprint.pprint(self.items)

View File

@@ -1,4 +1,4 @@
include $(KLAMMERTEXT_HOME)/mac/env/makefile.env
include $(KLAMMERTEXT_HOME)/env/makefile.env
FLAGS = -std=c++17 -fvisibility=hidden -I $(KLAMMERTEXT_HOME)/src -I$(KLAMMERTEXT_HOME)/sks/kutil $(CXXFLAGS)

197
sks/table/indexed_range.py Normal file
View File

@@ -0,0 +1,197 @@
"""Parsing for the indexed_range argument syntax.
An indexed_range selects positions in one dimension of a grid, with an
optional extent in the other dimension. The same syntax serves the table
klammer's :hline and :vline arguments (index = boundary, subsets = how far
along the line) and its :colspan and :rowspan arguments (index = row or
column, subsets = the cells to merge). Which dimension the index selects
is a property of the argument, not of the syntax.
This module replaces the former sequences.py and span.py (see debris).
"""
import re
syntax_description = """
An indexed_range is a selector, optionally followed by parenthesized
subsets, written with no spaces:
<selector> full extent
<selector>(<subsets>) restricted extent
The selector is a single index "3", a closed index range "2-5", an open
index range "2-" (to the last index), or a name defined by the argument
(for example "top" or "inner" for table lines). Subsets are separated by
commas; each is an index "4", a closed range "1-4", or an open range "6-"
(to the end). All indices are zero-origin.
Examples:
3 index 3, full extent
2-5(0-2) indices 2 through 5, each restricted to 0 through 2
3(1-4,6-9) index 3, restricted to 1-4 and 6-9
head(1-) with table hline names: boundary 1, from column 1 on
""".strip()
class Range_error(Exception):
def __init__(self, message):
super().__init__(f"{message}\n\n{syntax_description}")
item_rgx = re.compile(r"(?:(\d+)(-)?(\d*)|([A-Za-z]+))(?:\(([\d,\-]+)\))?$")
subset_rgx = re.compile(r"(\d+)(-)?(\d*)$")
def hline_names(count):
"""Boundary-name map for horizontal lines; count = row_count + 1."""
last = count - 1
return {"top": [0],
"head": [1],
"bottom": [last],
"inner": list(range(1, last)),
"all": list(range(count))}
def vline_names(count):
"""Boundary-name map for vertical lines; count = column_count + 1."""
last = count - 1
return {"outer": [0, last],
"inner": list(range(1, last)),
"all": list(range(count))}
class Indexed_range:
"""The selected extent for one primary-dimension index."""
def __init__(self, index, maxval):
self.index = index
self.maxval = maxval
self.all = False # Full extent (no subsets given)
self.ranges = [] # [[start, end], ...], inclusive
def add_full(self):
self.all = True
self.ranges = [[0, self.maxval]]
def add_ranges(self, ranges):
if not self.all:
self.ranges += ranges
def has(self, i):
return any(start <= i <= end for start, end in self.ranges)
def items(self, invert=False):
result = []
for start, end in self.ranges:
for e in range(start, end + 1):
result.append((e, self.index) if invert else (self.index, e))
return result
def __str__(self):
subsets = ",".join([f"{s}-{e}" for s, e in self.ranges])
return f"{self.index}({subsets})"
def __repr__(self):
return self.__str__()
class Indexed_ranges:
"""A parsed indexed_range argument: Indexed_range entries by index.
count - number of valid primary indices (0 .. count-1)
maxval - largest valid subset value (the cross dimension)
specs - the argument value: a list of items (from the argtype's
python_cast), a whitespace-separated string, or None
names - map of selector names to index lists (hline_names, ...)
argument - argument name for error messages (":hline", ...)
Items targeting the same index merge: their subsets are unioned, and a
full-extent item absorbs any subsets.
"""
def __init__(self, count, maxval, specs, names=None, argument=""):
self.count = count
self.maxval = maxval
self.names = names or {}
self.argument = argument
self.by_index = {}
if specs is None:
specs = []
elif isinstance(specs, str):
specs = specs.split()
for spec in specs:
self.parse(spec)
def error(self, message):
argument = f"{self.argument} argument: " if self.argument else ""
raise Range_error(f"{argument}{message}")
def parse(self, spec):
match = item_rgx.match(spec)
if not match:
self.error(f'"{spec}" is not a valid indexed_range.')
number, hyphen, end, name, subsets = match.groups()
if name is not None:
if name not in self.names:
known = " ".join(self.names) or "none"
self.error(f'"{name}" is not a valid name here '
f"(valid names: {known}).")
indices = self.names[name]
else:
start = int(number)
if not hyphen:
indices = [start]
else:
last = int(end) if end else self.count - 1
if start > last:
self.error(f'In "{spec}", the index range start {start} '
f"is greater than its end {last}.")
indices = list(range(start, last + 1))
for i in indices:
if i >= self.count:
self.error(f'In "{spec}", index {i} is out of range '
f"(0 through {self.count - 1}).")
ranges = self.parse_subsets(spec, subsets) if subsets else None
for i in indices:
entry = self.by_index.setdefault(i, Indexed_range(i, self.maxval))
if ranges is None:
entry.add_full()
else:
entry.add_ranges(ranges)
def parse_subsets(self, spec, subsets):
ranges = []
for part in subsets.split(","):
match = subset_rgx.match(part)
if not match:
self.error(f'In "{spec}", "{part}" is not a valid subset.')
number, hyphen, end = match.groups()
start = int(number)
if not hyphen:
last = start
else:
last = int(end) if end else self.maxval
if start > last:
self.error(f'In "{spec}", the subset start {start} '
f"is greater than its end {last}.")
if last > self.maxval:
self.error(f'In "{spec}", {last} is out of range '
f"(0 through {self.maxval}).")
ranges.append([start, last])
return ranges
def __getitem__(self, index):
return self.by_index.get(index)
def __iter__(self):
return iter(self.by_index)
def has(self, index, i):
entry = self[index]
return entry.has(i) if entry else False
def __str__(self):
return " ".join([str(self.by_index[i]) for i in sorted(self.by_index)])
def __repr__(self):
return self.__str__()

View File

@@ -1,147 +0,0 @@
import sys
import re
syntax_description = """
A "sequence" is an integer (the "index") followed by an optional description of one
or more sequence subsets. A subset is defined by a series of subset
descriptions, separated by a comma. A subset description is either an integer,
two integers separated by a hypen to indicate a range, or an integer followed
only by a hyphen, which will include all the following elements of the sequence to
the end. No spaces are allowed in a sequence. All indices are zero-origin.
Sequence examples for an index of "3":
3
3(1)
3(0-4)
3(1-4,6-9)
3(5-)
Note that some sequence subsets must include two numbers, for example, border
lines in a table.
""".strip()
class Sequence:
def __init__(self, count, maxval, spec):
def parse_range(match):
start, hyphen, end = match.groups()
if hyphen is None and end is None:
end = start
elif end is None:
end = maxval
return [int(start), int(end)]
self.maxval = maxval
self.all = True
subset_pat = "[-\\d,]+"
sequence_rgx = re.compile(fr"(\d+)(\({subset_pat}\))*")
match = sequence_rgx.match(spec)
self.spec = spec
if match and match.group(0) == spec:
range_rgx = re.compile(r"(\d+)(-)?(\d+)?")
self.index = int(match.group(1))
if (match.group(2)):
self.subsets = match.group(2).strip("()").split(",")
matches = [range_rgx.match(e) for e in self.subsets]
self.ranges = [parse_range(e) if e else None for e in matches]
self.all = False
else:
self.ranges = [[0, self.maxval]]
else:
print(f'The sequence specification "{spec}" is incorrect.\n\n{syntax_description}\n')
sys.exit(1)
def __str__(self):
#subsets = "all" if self.all else ",".join([f"{e[0]}-{e[1]}" for e in self.ranges])
subsets = "all" if False else ",".join([f"{e[0]}-{e[1]}" for e in self.ranges])
return f"{self.index}[{subsets}]"
def __repr__(self):
return self.__str__()
def has(self, i):
for start, end in self.ranges:
if i >= start and i <= end:
return True
return False
def items(self, invert=False):
result = []
for start,end in self.ranges:
for e in range(start, end+1):
result.append((e, self.index) if invert else (self.index, e))
return result
class Sequences:
def __init__(self, count, maxval, sequence_specs):
self.count = count
self.maxval = maxval
self.sequences = {}
match_all = re.compile(r"\*(.*)").match(sequence_specs)
match_some = re.compile(r"\[(\d+)-(\d+)\](.*)").match(sequence_specs)
spec_list = []
if match_all:
subseq = match_all.group(1)
for i in range(count):
spec_list.append(f"{i}{subseq}")
elif match_some:
start = int(match_some.group(1))
end = int(match_some.group(2))
subseq = match_some.group(3)
for i in range(start, end+1):
spec_list.append(f"{i}{subseq}")
else:
spec_list = sequence_specs.split()
for specs in spec_list:
for spec in self.parse_spec(specs):
self.sequences[spec.index] = spec
def __getitem__(self, index):
return self.sequences.get(index)
def has(self, index, subseq_index):
return self[index].has(subseq_index) if self[index] else None
def parse_spec(self, spec):
named_spec = { "last" : [str(self.count-1)],
"outer" : ["0", str(self.count-1)],
"inner" : [str(e) for e in range(1, self.count - 1)],
"all" : [str(e) for e in range(0, self.count)]
}.get(spec)
if named_spec is None:
return [Sequence(self.count, self.maxval, spec)]
else:
return [Sequence(self.count, self.maxval, e) for e in named_spec]
def items(self, invert=False):
result = []
for seq in self.sequences:
result += self.sequences[seq].items(invert)
return set(result)
if __name__ == "__main__":
import pprint
for s in [
Sequence(10, "0"),
Sequence(10, "1"),
Sequence(10, "2(2)"),
Sequence(10, "3(2-)"),
Sequence(10, "4(2-4)"),
Sequence(10, "5(2-4)"),
Sequence(10, "6(2-4,6)"),
Sequence(10, "7(2,6-8)"),
Sequence(10, "8(2,7-8,9-11,13-14)")]:
print(s.spec, "->", s)
print("Sequences")
S = Sequences(10, "0")
for name in "top bottom head outer inner all 1(2-3)".split():
print(name, "->", S.parse_spec(name))
print("Instantiate:")
s = Sequences(10, "3(1-2,4-5) 4(8-9)")
#print(s.items(True))
print(s)
print(s.has(3,1))

View File

@@ -1,111 +0,0 @@
import sys
import re
syntax_description = """
A "span" is an integer (the "index") followed by an optional description of one
or more sequence subsets. A subset is defined by a series of subset
descriptions, separated by a comma. A subset description is either an integer,
two integers separated by a hypen to indicate a range, or an integer followed
only by a hyphen, which will include all the following elements of the span to
the end. No spaces are allowed in a span. All indices are zero-origin.
Span examples for an index of "3":
3
3(1)
3(0-4)
3(1-4,6-9)
3(5-)
Note that some sequence subsets must include two numbers, for example, border
lines in a table.
""".strip()
class Span:
def __init__(self, count, spec):
def parse_range(match):
start, hyphen, end = match.groups()
if hyphen is None and end is None:
end = start
elif end is None:
end = count - 1
return [int(start), int(end)]
self.all = True
subset_pat = "[-\\d,]+"
span_rgx = re.compile(f"(\d+)(\({subset_pat}\))*")
match = span_rgx.match(spec)
self.spec = spec
if match and match.group(0) == spec:
range_rgx = re.compile("(\d+)(-)?(\d+)?")
self.index = int(match.group(1))
if (match.group(2)):
self.subsets = match.group(2).strip("()").split(",")
matches = [range_rgx.match(e) for e in self.subsets]
self.ranges = [parse_range(e) if e else None for e in matches]
self.all = False
else:
self.ranges = [[0, count-1]]
else:
print(f'The span specification "{spec}" is incorrect.\n\n{syntax_description}\n')
sys.exit(1)
def __str__(self):
subsets = "all" if self.all else ",".join([f"{e[0]}-{e[1]}" for e in self.ranges])
return f"{self.index}[{subsets}]"
def __repr__(self):
return self.__str__()
class Spanset:
def __init__(self, count, span_specs):
self.count = count
self.spans = {}
for specs in span_specs.split():
for spec in self.parse_spec(specs):
print("Spanset spec:", spec)
self.spans[spec.index] = spec
def parse_spec(self, spec):
named_spec = {"top" : ["0"],
"bottom" : [str(self.count-1)],
"head" : ["1"],
"outer" : ["0", str(self.count-1)],
"inner" : [str(e) for e in range(1,self.count-1)],
"all" : [str(e) for e in range(0,self.count+1)]
}.get(spec)
if named_spec is None:
return [Span(self.count, spec)]
else:
return [Span(self.count, e) for e in named_spec]
if __name__ == "__main__":
import pprint
for s in [
Span(10, "0"),
Span(10, "1"),
Span(10, "2(2)"),
Span(10, "3(2-)"),
Span(10, "4(2-4)"),
Span(10, "5(2-4)"),
Span(10, "6(2-4,6)"),
Span(10, "7(2,6-8)"),
Span(10, "8(2,7-8,9-11,13-14)")]:
print(s.spec, "->", s)
print("Spanset")
S = Spanset(10, "1")
for name in "top bottom head outer inner all 1(2-3)".split():
print(name, "->", S.parse_spec(name))
print("Instantiate:")
s = Spanset(10, "3(1-2,4-5)")
print(s.tex_hline(3))

View File

@@ -1,63 +1,165 @@
#[ f: 0->1; n: 1.. (integer)
l, c, r
[<numeric-size>] <width> <justification>
width is widest line in a cell f [default]
width is fraction of table <f>t
width is specific length <n>pt|px|in|cm [...not portable?]
width width is remaining (evenly divided) *
]#
# Table argument types and klammer declaration
@@@argtype table_hpos |
@@@argtype index_subsets |
one or more subsets in parentheses, attached to an index. Each subset is a
single index <n>, a closed range <n>-<m>, or an open range <n>- (from <n> to
the end). Several subsets are separated by commas, with no spaces.
Example: (1-4,6-9)
:pattern \((?^:\d+(?^:-\d*)?)(?^:,\d+(?^:-\d*)?)*\)
@@@
@@@argtype indexed_range |
an index with optional subsets, written with no spaces. The index part is a
single index <i>, a closed index range <i>-<j>, or an open index range <i>-
(from <i> to the last index). It may be followed by parenthesized subsets
(see the index_subsets type) restricting the extent in the other dimension.
All indices are zero-origin. Examples:
3 index 3, full extent
2-5 indices 2 through 5, full extent
3(1-4,6-9) index 3, restricted to 1 through 4 and 6 through 9
2-5(0-2) indices 2 through 5, each restricted to 0 through 2
:pattern \d+(?^:-\d*)?(?^:'index_subsets')?
@@@
@@@argtype column_width |
width of the table columns. Each column is one of 'fit' (widest line of the
cells in that column), a fraction 0.0->1.0 (that fraction of the total table
width), or '*' (use the remaining width of the table; there can only be one
column with '*'). If there are fewer positions than columns in the table, the
last value is repeated. Extra positions generate a warning.
:pattern (fit^|f^|0?\.\d+^|\*^|\s+)+
:python_cast (lambda s : s.split())
:default fit
@@@
@@@argtype cell_hpos |
horizontal formatting in a table cell. One of 'l', 'c' or 'r' for each
cell in a row. If there are fewer positions than cells in a row, the
last value is repeated. Extra positions generate a warning. Default is 'l'
# :pattern ((f^|(0?\.'uint't^|\\*))[lcr]?^|\s)+
# :pattern ((f^|'float't^|\\*)[lcr]?^|\s)+
:pattern ([.\w]+^|\*^|\s+)+
#:pattern (l^|c^|r^|\s)+
last value is repeated. Extra positions generate a warning.
:pattern (l^|c^|r^|\s+)+
:python_cast (lambda s : s.split())
:default l
@@@
@@@argtype table_hline |
a table's horizontal line description; one or more of 'top',
'head', 'inner', 'bottom', or a row number for a line at the bottom
of that row
#:pattern (top^|head^|inner^|bottom^|\d+^|\d+:\(\d+\-\d+\)^|\s+)*
#:python_cast (lambda s : s.split())
a table's horizontal lines, as one or more whitespace-separated items.
With N rows there are N+1 horizontal boundaries, numbered 0 to N from the
top; boundary i lies above row i, and boundary N is the bottom. An item
is either a boundary name or an indexed_range of boundary indices. The
names are 'top' (boundary 0), 'head' (boundary 1, under a header row),
'bottom' (boundary N), 'inner' (all boundaries between top and bottom),
and 'all' (every boundary). A name or index may be followed by
parenthesized subsets to draw only part of a line, given as zero-origin
column ranges. Examples:
top bottom lines above and below the table
head(1-) a line under the header, from column 1 to the last
3(1-4,6-9) two partial lines at boundary 3
all every line
:pattern ((?^:top^|head^|inner^|bottom^|all)(?^:'index_subsets')?^|'indexed_range'^|\s+)+
:python_cast (lambda s : s.split())
@@@
@@@argtype table_vline |
a table's vertical line description; one or more of 'outer', 'inner,
or a column number for a line at the right of that column
# :pattern (outer^|inner^|\d+^|\d+^|\d+:\(\d+\-\d+\)^|\s+)*
# :python_cast (lambda s : s.split())
a table's vertical lines, as one or more whitespace-separated items.
With M columns there are M+1 vertical boundaries, numbered 0 to M from
the left; boundary i lies to the left of column i, and boundary M is the
right edge. An item is either a boundary name or an indexed_range of
boundary indices. The names are 'outer' (boundaries 0 and M), 'inner'
(all boundaries between them), and 'all' (every boundary). A name or
index may be followed by parenthesized subsets to draw only part of a
line, given as zero-origin row ranges. Examples:
outer lines at the left and right edges
2(0-3) a line left of column 2, spanning rows 0 through 3
all every line
:pattern ((?^:outer^|inner^|all)(?^:'index_subsets')?^|'indexed_range'^|\s+)+
:python_cast (lambda s : s.split())
@@@
@@@argtype table_span |
a list of spans in a table in the form (X,Y):N (no spaces), where
(X,Y) is the position in the table (zero origin in the top left
corner) and N is the number of columns or rows in the span
#:pattern (\(\d+(?:-\d+)?,\d+(?:-\d+)?\):\d+\s*)*
#:python_cast (lambda s : s.split())
a list of cell spans, each an indexed_range whose index selects the row
(for colspan) or the column (for rowspan), and whose parenthesized subset
gives the zero-origin range of cells to merge. An index range repeats
the same span; several subsets make several spans. Examples for colspan:
1(2-4) in row 1, merge columns 2 through 4
1(0-1,3-5) two merges in row 1
2-4(0-1) the same merge in rows 2 through 4
:pattern ('indexed_range'^|\s+)+
:python_cast (lambda s : s.split())
@@@
@@@argtype table_calc |
calculations that fill table cells with computed values, separated by
semicolons. Each calculation has the form
<target> = <operator> <operand> <operand> ...
where the target is a single cell written <row>(<column>) with zero-origin
indices, the operator is one of + - * /, and each operand is either a cell
selection or a number. A cell selection is an indexed_range read as
<rows>(<columns>); a range expands to all of its cells in row order, so
"+ 1-2(3)" sums column 3 of rows 1 and 2. A plain number is a constant
and always uses a period as its decimal mark. Operators fold from the
left ("- 1(0-2)" is a minus b minus c); with a single operand, - negates
and / gives the reciprocal. Calculations run in the order given, and each
reads the values earlier calculations have written, as displayed.
Example:
1(3) = * 1(1-2) ;
2(3) = * 2(1-2) ;
3(3) = + 1-2(3)
:pattern \s*(\d+\(\d+\)\s*=\s*[-+*/](\s+(\d+(?^:-\d*)?'index_subsets'^|'float'))+\s*(;\s*^|\s*$))+
@@@
@@@argtype decimal_mark |
the character used as the decimal mark in numeric cell values, either
'period' (1,234.56) or 'comma' (1.234,56). Governs both the reading of
numbers from cells in table calculations and the formatting of
calculated values.
:pattern period^|comma
:default period
@@@
@@@argtype format_spec |
a Python format specification applied to calculated cell values, for
example ",.2f" for two decimal places with grouped thousands.
:pattern \S+
@@@
@@rowcolor.tex s : \colorrow{*s*} @@
@@table rows.rest
@@table rows.rest(2)
:id
@caption_arguments@
:center.bool true
:indent.length 1em
:header.bool true
:allow_break.bool false
:column_width.column_width
:hline.table_hline
:vline.table_vline
:grid.bool false
:cell_hpos.table_hpos c
:cell_hpos.cell_hpos
:header_font.font i
:font.font_list r
:font.font_list
:colspan.table_span
:rowspan.table_span
:calc.table_calc
:calc_format.format_spec
:decimal.decimal_mark
:leading.float 1.3
:colsep 4pt
:

View File

@@ -10,13 +10,13 @@ import klammer_base
import html_util
from html_util import E
import latex_util
from sequences import Sequences
from indexed_range import Indexed_ranges, hline_names, vline_names
import table_cell
import font
def extend(lst, count, fill=None):
if isinstance(lst, str):
lst = lst.strip().split("\\s+")
lst = lst.split()
if fill is None:
fill = lst[-1] if lst else ""
return lst + ([fill] * (count - len(lst)))
@@ -44,34 +44,154 @@ class Table(klammer_base.Klammer_base):
id = 0
def __init__(self, K):
super().__init__(K)
# pprint.pprint(self.__dict__)
if self.grid:
self.vline = "all"
self.hline = "all"
self.number = self.number == "true"
self.rows = kutil.rest_args(self.rows, 2)
self.vline = ["all"]
self.hline = ["all"]
self.row_count = len(self.rows)
if self.header:
self.hline += f" 1 {self.row_count}"
self.hline += ["1", str(self.row_count)]
self.row_size = max([len(e) for e in self.rows])
self.s_vline = Sequences(self.row_size + 1, self.row_count - 1, self.vline)
self.s_hline = Sequences(self.row_count + 1, self.row_size - 1, self.hline)
self.s_rowspan = Sequences(self.row_size, self.row_count, self.rowspan)
self.s_colspan = Sequences(self.row_count, self.row_size, self.colspan)
self.cell_hpos = extend(parse_hpos(self.K_target, self.cell_hpos.split()), self.row_size)
# Rows with fewer cells than the widest row are padded with empty
# cells (last-value duplication is for argument lists, not content).
self.rows = [row + [""] * (self.row_size - len(row)) for row in self.rows]
if self.calc:
self.calculate()
self.s_vline = Indexed_ranges(self.row_size + 1, self.row_count - 1, self.vline,
vline_names(self.row_size + 1), ":vline")
self.s_hline = Indexed_ranges(self.row_count + 1, self.row_size - 1, self.hline,
hline_names(self.row_count + 1), ":hline")
self.s_rowspan = Indexed_ranges(self.row_size, self.row_count - 1, self.rowspan,
argument=":rowspan")
self.s_colspan = Indexed_ranges(self.row_count, self.row_size - 1, self.colspan,
argument=":colspan")
self.cell_hpos = extend(parse_hpos(self.K_target, self.cell_hpos), self.row_size)
#self.cell_hpos = self.cell_hpos.split(";")
self.font = extend(self.font, self.row_size)
self.make_cells(self.rows)
def span_count(self, span_seq, row_i, col_i):
result = 0
row_seq = span_seq[row_i]
if row_seq:
for range in row_seq.ranges:
if range[0] == col_i:
result = range[1] - range[0] + 1
# Calculated cell values (:calc). Calculations run in the order given;
# each reads cell values as displayed (display-precision semantics), so
# a printed total always equals the sum of the printed lines. Only
# calculation targets are formatted (:calc_format); other cells keep
# their authored text. Future operators to consider: min, max, mean,
# and a per-calculation format override.
calc_target_rgx = re.compile(r"(\d+)\((\d+)\)$")
calc_cell_rgx = re.compile(r"\d+(-\d*)?\(")
def calc_error(self, calc, message):
raise Exception(f'In the :calc calculation "{calc}": {message}')
def parse_number(self, text, ref, calc):
s = text.strip()
if self.decimal == "comma":
s = s.translate(str.maketrans(",.", ".,"))
s = s.replace(",", "") # Remove thousands separators
try:
return float(s)
except ValueError:
self.calc_error(
calc, f'the cell {ref} contains "{text.strip()}", '
"which is not a number")
def format_number(self, value, calc):
if self.calc_format:
try:
s = format(value, self.calc_format)
except ValueError:
self.calc_error(
calc, f'"{self.calc_format}" is not a valid '
"format specification")
elif value.is_integer():
s = str(int(value))
else:
s = str(value)
if self.decimal == "comma":
s = s.translate(str.maketrans(",.", ".,"))
return s
def operand_values(self, token, calc):
# A token with subsets is a cell selection; a bare number is a
# constant (always period-decimal, independent of :decimal).
if not self.calc_cell_rgx.match(token):
return [float(token)]
selection = Indexed_ranges(self.row_count, self.row_size - 1,
[token], argument=":calc")
values = []
for row_i in selection.by_index:
for _, col_i in selection.by_index[row_i].items():
values.append(self.parse_number(
self.rows[row_i][col_i], f"{row_i}({col_i})", calc))
return values
def apply_operator(self, op, values, calc):
if len(values) == 1: # Lisp-style unary - and /
return {"+": values[0], "*": values[0],
"-": -values[0], "/": 1 / values[0]}[op]
result = values[0]
for v in values[1:]: # Fold from the left
if op == "+":
result += v
elif op == "-":
result -= v
elif op == "*":
result *= v
else:
result /= v
return result
def calculate(self):
for calc in [c.strip() for c in self.calc.split(";") if c.strip()]:
target, eq, expression = calc.partition("=")
match = self.calc_target_rgx.match(target.strip())
if not eq or not match:
self.calc_error(calc, "the target must be a single cell "
"written <row>(<column>), followed by \"=\"")
row_i, col_i = int(match.group(1)), int(match.group(2))
if row_i >= self.row_count or col_i >= self.row_size:
self.calc_error(
calc, f"the target {target.strip()} is outside the "
f"table (rows 0-{self.row_count - 1}, "
f"columns 0-{self.row_size - 1})")
tokens = expression.split()
if not tokens or tokens[0] not in "+-*/" or len(tokens) < 2:
self.calc_error(calc, "the expression must be an operator "
"(+ - * /) followed by at least one operand")
values = []
for token in tokens[1:]:
values += self.operand_values(token, calc)
try:
result = self.apply_operator(tokens[0], values, calc)
except ZeroDivisionError:
self.calc_error(calc, "division by zero")
self.rows[row_i][col_i] = self.format_number(result, calc)
def span_count(self, spans, index, cross_i):
# The count of cells merged by a span anchored at (index, cross_i):
# for colspan, index is the row and cross_i the column; for rowspan,
# index is the column and cross_i the row. Non-anchor cells get 0.
result = 0
entry = spans[index]
if entry:
for start, end in entry.ranges:
if start == cross_i:
result = end - start + 1
return result
def compute_coverage(self):
# Cells hidden by a span (every spanned cell except the anchor).
self.colspan_covered = set()
self.rowspan_covered = set()
for row_i in self.s_colspan.by_index:
for start, end in self.s_colspan.by_index[row_i].ranges:
for col_i in range(start + 1, end + 1):
self.colspan_covered.add((row_i, col_i))
for col_i in self.s_rowspan.by_index:
for start, end in self.s_rowspan.by_index[col_i].ranges:
for row_i in range(start + 1, end + 1):
self.rowspan_covered.add((row_i, col_i))
self.covered = self.colspan_covered | self.rowspan_covered
def remove_redundant_borders(self):
remove_right = []
for row_i in range(self.row_count):
@@ -83,66 +203,76 @@ class Table(klammer_base.Klammer_base):
a.border.right_all = False
def column_width_text(self):
# For each column, the text used to measure a 'fit' width in the
# tex target. The longest cell's font is applied, so a bold or
# italic cell is measured in the font it will be set in.
self.column_widths = []
# print("column_width_text:", len(self.cells), len(self.cells[0]))
for col_i in range(len(self.cells[0])):
longest = ""
longest_font = "r"
for row_i in range(len(self.cells)):
cell = self.cells[row_i][col_i]
if cell is not None:
cell_text = cell.text
#lines = [e.strip() for e in cell_text.split("\\newline")]
lines = [e.strip() for e in cell_text.split("\newline")]
# A colspan anchor's text spans several columns and must
# not set the width of its own column.
if cell is not None and cell.colspan <= 1:
lines = [e.strip() for e in cell.text.split("\\newline")]
lines = sorted(lines, key=len)
longest_in_line = lines[-1]
if len(longest_in_line) > len(longest):
longest = longest_in_line
longest_font = cell.font
if longest_font != "r":
longest = font.tex_fontify(longest, longest_font, 1.0)
self.column_widths.append(longest)
# kutil.msg("column_widths:")
# print(self.column_widths)
def make_cells(self, rows):
self.compute_coverage()
result = []
cells = []
for row_i, row in enumerate(rows):
row_cells = []
for cell_i, cell in enumerate(row):
rspan = self.span_count(self.s_rowspan, row_i, cell_i)
rspan = self.span_count(self.s_rowspan, cell_i, row_i)
cspan = self.span_count(self.s_colspan, row_i, cell_i)
font = self.font[cell_i]
if row_i == 0 and self.header:
font = self.header_font
# A span anchor's right and bottom borders come from the
# boundary at the END of the merged region.
right_i = cell_i + max(cspan, 1)
bottom_i = row_i + max(rspan, 1)
row_cells.append(
table_cell.Cell(
cell,
font, self.cell_hpos[cell_i],
self.s_hline.has(row_i, cell_i),
self.s_vline.has(cell_i+1, row_i),
self.s_hline.has(row_i+1, cell_i),
self.s_vline.has(right_i, row_i),
self.s_hline.has(bottom_i, cell_i),
self.s_vline.has(cell_i, row_i),
self.s_vline.sequences.get(cell_i),
self.s_vline.sequences.get(cell_i+1),
rspan, cspan))
self.s_vline.by_index.get(cell_i),
self.s_vline.by_index.get(right_i),
rspan, cspan,
first_column=(cell_i == 0)))
cells.append(row_cells)
widths = [len(e) for e in cells]
max_width = max(widths)
self.cells = [extend(row, max_width, None) for row in cells] \
if max_width != min(widths) else cells
self.cells = cells
self.column_width_text()
# HTML
def html(self):
result = ""
for row in self.cells:
for row_i, row in enumerate(self.cells):
row_html = ""
for cell in row:
for cell_i, cell in enumerate(row):
if (row_i, cell_i) in self.covered:
continue
row_html += cell.html().strip() + "\n"
result += E("tr").body(row_html).str()
result = E("table").body(result)
if self.number or self.caption:
result = html_util.add_caption(
result, "Table", self.number, self.caption, self.caption_font)
result, "Table", self.number, self.caption, self.caption_font,
side=self.caption_side, font_size=self.caption_font_size)
else:
result = result.str()
return result
@@ -150,6 +280,9 @@ class Table(klammer_base.Klammer_base):
# LaTeX
def tex_hpos(self):
# One column specification per column: the width comes from
# :column_width ('fit', a fraction of \tablewidth, or '*' for the
# remaining width), the justification from :cell_hpos.
def par_format(s, justification):
command = {"l" : "raggedright",
"c" : "centering",
@@ -157,68 +290,26 @@ class Table(klammer_base.Klammer_base):
return f">{{\\{command}}}p{{{s}}}"
widths = []
hpos_pat = re.compile("(f|(?:0?(\\.\\d+)(t))|\\*)?([lcr]?)")
def parse(s, width_text):
match = hpos_pat.match(s)
# print("MATCH:", match, match.groups())
width, frac, table, just = match.groups()
width = width or "f"
just = just or "l"
if width and width[0] == "{":
width = f"\\widthof{{{s}}}"
elif table == "t":
width = f"{frac}\\tablewidth"
elif width == "f":
width = f"\\widthof{{{width_text}}}"
if width != "*":
widths.append(width)
result = par_format(width, just) if width != "*" else s
# print("PARSE:", result)
return result
# print("self.column_widths:", len(self.column_widths), self.column_widths)
# return extend([parse(e) for e in self.cell_hpos], self.row_size)
hpos_list = []
for i, hpos in enumerate(extend(self.cell_hpos, self.row_size)):
# print(f" Loop {i}:", hpos)
if i >= len(self.column_widths):
print(f"Warning: Ignoring table column width: {hpos}")
for i, w in enumerate(extend(self.column_width, self.row_size)):
if w in ("fit", "f"):
widths.append(f"\\widthof{{{self.column_widths[i]}}}")
elif w == "*":
widths.append(None)
else:
hpos_list.append(parse(hpos, self.column_widths[i]))
# print("hpos_list:", hpos_list)
fill_count = sum([1 if "*" in e else 0 for e in hpos_list])
# print("fill_count:", fill_count)
widths.append(f"{w}\\tablewidth")
fill_count = widths.count(None)
if fill_count > 0:
margins = f"(\\tabcolsep * {2 * len(hpos_list)})"
# print("MARGINS:", margins)
#expr = "\\linewidth - " + " - ".join(widths) + str("
if fill_count == len(hpos_list):
expr = f"{1/fill_count}\\tablewidth"
fixed = [e for e in widths if e is not None]
if fixed:
expr = f"(\\tablewidth - {' - '.join(fixed)}) / {fill_count}"
else:
#expr = f"(\\textwidth - {margins} - {' - '.join(widths)}) / {fill_count}"
expr = f"(\\tablewidth - {' - '.join(widths)}) / {fill_count}"
# print(expr)
result = []
for h in hpos_list:
if h[0] == "*":
just = h[1] if len(h) > 1 else "l"
result.append(par_format(expr, just))
else:
result.append(h)
else:
result = hpos_list
# print("tex_hpos:", result)
return result
expr = f"{1 / fill_count}\\tablewidth"
widths = [e if e is not None else expr for e in widths]
return [par_format(w, j) for w, j in zip(widths, self.cell_hpos)]
def tex_column_spec(self):
parts = [""] * (self.row_size * 2 + 1)
for i in self.s_vline.sequences:
for i in self.s_vline.by_index:
parts[i * 2] = "|"
for i, hpos in enumerate(self.tex_hpos()):
parts[i * 2 + 1] = hpos
@@ -226,30 +317,42 @@ class Table(klammer_base.Klammer_base):
return "".join(parts)
def tex_hline(self, index):
hline = ""
# Contiguous cell borders coalesce into single \cline runs; a
# full-width line becomes \hline.
bottom = index == self.row_count
if bottom:
index -= 1
count = 0
for i, cell in enumerate(self.cells[index]):
has_border = cell.border.bottom if bottom else cell.border.top
if has_border:
hline += f"\\cline{{{i+1}-{i+1}}} "
count += 1
#if count == self.row_size:
# hline = "\\hline"
flags = [(cell.border.bottom if bottom else cell.border.top)
for cell in self.cells[index]]
if not bottom:
# No line through the interior of a merged (rowspan) cell.
flags = [flag and (index, col_i) not in self.rowspan_covered
for col_i, flag in enumerate(flags)]
if flags and all(flags):
return "\\hline\n"
hline = ""
start = None
for i, flag in enumerate(flags + [False]):
if flag and start is None:
start = i
elif not flag and start is not None:
hline += f"\\cline{{{start + 1}-{i}}} "
start = None
return hline.strip() + "\n"
def tex_rows(self):
result = ""
for row_i, row in enumerate(self.cells):
result += self.tex_hline(row_i)
tab = ""
parts = []
for cell_i, cell in enumerate(row):
result += tab + cell.tex()
tab = " & "
#result += " \\\\\n"
result += " \\tabularnewline\n"
if (row_i, cell_i) in self.colspan_covered:
continue # Absorbed by the \multicolumn anchor
if (row_i, cell_i) in self.rowspan_covered:
parts.append("") # Occupied by the \multirow anchor
else:
parts.append(cell.tex())
result += " & ".join(parts) + " \\tabularnewline\n"
result += self.tex_hline(self.row_count)
return result
@@ -277,9 +380,7 @@ class Table(klammer_base.Klammer_base):
def tex(self):
result = self.get_width()
result += "\\vspace*{-.75\\baselineskip}"
# result = ""
result += "\\renewcommand*{\\arraystretch}{1.3}\n"
result += f"\\renewcommand*{{\\arraystretch}}{{{self.leading}}}\n"
if self.allow_break:
result += "\\vspace*{12pt}\n"
result += "\\begin{longtable}{"
@@ -292,17 +393,26 @@ class Table(klammer_base.Klammer_base):
if not self.allow_break:
if self.number or self.caption:
result = latex_util.add_caption(result, "Table", self.number, self.caption, "\\tablewidth")
result = latex_util.add_caption(
result, "Table", self.number, self.caption, "\\tablewidth",
side=self.caption_side, font_symbol=self.caption_font,
font_size=self.caption_font_size)
else:
result = latex_util.caption_wrapper(result, "center")
name = f"Reference-Table-{Table.id}"
Table.id += 1
result = f"\\hypertarget{{{name}}}{{}}\\label{{Label-{name}}}\n{result}"
result = f"\\setlength{{\\tablewidth}}{{\\textwidth - {2 * self.row_count}\\tabcolsep}}\n" + result
# result += "\\vspace*{-8pt}"
# The wrapper (add_caption/caption_wrapper) owns all vertical space
# around the table; longtable's own glue is zeroed.
result = (f"\\setlength{{\\tabcolsep}}{{{self.colsep}}}\n"
"\\setlength{\\LTpre}{0pt}\n"
"\\setlength{\\LTpost}{0pt}\n"
f"\\setlength{{\\tablewidth}}{{\\textwidth - {2 * self.row_size}\\tabcolsep}}\n"
+ result)
result = re.sub(r"\newline", r"\\\\", result)
return result
def txt(self):
return "TXT"
return "Table in .txt format not implemented"

View File

@@ -1,19 +1,25 @@
import re
import border
import font
import html_util
from html_util import E
# A purely numeric cell value (either decimal-mark style, optional sign).
number_rgx = re.compile(r"[-+]?[\d.,]+$")
class Cell:
def __init__(self, text, font, hpos,
top, right, bottom, left,
left_all, right_all,
rowspan, colspan):
rowspan, colspan, first_column=False):
self.text = text
self.font = font
self.hpos = hpos
self.border = border.Border(top, right, bottom, left, left_all, right_all)
self.rowspan = rowspan
self.colspan = colspan
self.first_column = first_column
#print("Cell:", text, hpos)
def __str__(self):
@@ -30,6 +36,10 @@ class Cell:
def html(self):
result = self.text
result = E("td").body(font.html_fontify(result, self.font, 1.0))
if self.rowspan > 1:
result.attr("rowspan", self.rowspan)
if self.colspan > 1:
result.attr("colspan", self.colspan)
for pred, cls_name in zip(self.border.has(), "Bt Br Bb Bl".split()):
if pred:
result.cls(cls_name)
@@ -38,8 +48,26 @@ class Cell:
def tex(self, debug=False): # , left_line, right_line):
result = self.text
# A number must not line-break (LaTeX breaks after a minus sign
# read as a hyphen in narrow fit-width columns).
if number_rgx.match(result.strip()):
result = f"\\mbox{{{result.strip()}}}"
if self.font != "r":
result = font.tex_fontify(result, self.font, 1.0)
if self.rowspan > 1:
result = f"\\multirow{{{self.rowspan}}}{{*}}{{{result}}}"
if self.colspan > 1:
# \multicolumn carries the merged cell's own column spec. A
# left bar may only be given when the span starts at the
# table's first column: elsewhere the bar to the left belongs
# to the preceding column's preamble entry, and adding one
# here draws a doubled line.
pos = self.hpos
if self.border.left and self.first_column:
pos = "|" + pos
if self.border.right:
pos = pos + "|"
return f"\\multicolumn{{{self.colspan}}}{{{pos}}}{{{result}}}"
remove_left = self.border.left_all and not self.border.left
remove_right = self.border.right_all and not self.border.right
if debug:

View File

@@ -5,12 +5,12 @@ K := $(KLAMMERTEXT_HOME)
KS := $(K)/sks
KM := $(K)/mac
include $(KM)/env/makefile.env
include $(K)/env/makefile.env
# Source files
SOURCES := html_util.cpp latex_util.cpp font_resolve.cpp
OBJECTS := html_util.o latex_util.o font_resolve.o
DEPFILES := html_util.d latex_util.d font_resolve.d
SOURCES := html_util.cpp latex_util.cpp
OBJECTS := html_util.o latex_util.o
DEPFILES := html_util.d latex_util.d
# Additional include paths
LOCAL_CPPFLAGS := -I$(KM) -I$(KS)/kutil

View File

@@ -1,329 +0,0 @@
#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());
}
}
}

View File

@@ -1,24 +0,0 @@
#pragma once
#include <string>
#include <vector>
struct Resolved_font {
std::string family_name {}; // "Crimson Pro"
std::string dir_name {}; // "crimson-pro"
std::string font_dir {}; // Full path to font directory
std::string css_file {}; // Full path to .css file
bool from_cache = false;
// .ttf filenames for each variant (empty if variant not available):
std::string regular {};
std::string bold {};
std::string italic {};
std::string bold_italic {};
float xheight_ratio = 0.0f; // x-height / unitsPerEm from OS/2 table
float capheight_ratio = 0.0f; // cap-height / unitsPerEm from OS/2 table
};
std::string name_to_dirname(std::string name);
std::string name_to_google_query(std::string name);
Resolved_font resolve_font(std::string family_name);
void install_resolved_font(const Resolved_font& font, std::string output_dir);

View File

@@ -254,25 +254,6 @@ namespace html {
return result;
}
elements_t google_font_elements(strings_t fontnames)
{
elements_t result {};
if (fontnames.size() > 0) {
result.push_back(elt("link")
.attr("rel", "preconnect")
.attr("href", "https://fonts.googleapis.com"));
result.push_back(elt("link")
.attr("rel", "preconnect")
.attr("href", "https://fonts.gstatic.com"));
}
for (auto name : fontnames)
result.push_back(
elt("link")
.attr("href", "https://fonts.googleapis.com/css2?family=" + name + "&display=swap")
.attr("rel", "stylesheet"));
return result;
}
elements_t javascript(std::string output_dir, strings_t js_filenames)
{
//elements_t result { jquery_elements() };
@@ -298,15 +279,12 @@ namespace html {
HTML head(std::string title,
std::string css,
strings_t css_filenames,
strings_t local_fonts, strings_t google_fonts)
strings_t local_fonts)
{
elements_t elts = meta_elements();
// msg() << "ELTS sks: " << elts << "\n";
for (auto e : local_font_elements(local_fonts))
elts.push_back(e);
for (auto e : google_font_elements(google_fonts))
elts.push_back(e);
//elts += google_font_prolog();
//std::string css_dir = output_dir + "/css/";
// No; a single file at the top level...?
@@ -471,55 +449,6 @@ namespace html {
return result;
}
void install_local_fonts(strings_t font_dirs, strings_t names, std::string output_dir)
{
std::string output_font_dir = output_dir + "/fonts";
//std::cout << "Font directory for website: " << output_font_dir << "\n";
if (!file_exists(output_font_dir)) {
fs::create_directory(output_font_dir);
//std::cout << " Font directory created: " << output_font_dir << "\n";
}
for (auto name : names) {
//std::cout << "Font search for " << name << ": " << klammertext_dir() << "/sks/font/fonts/\n";
std::string src_font_dir = klammertext_dir() + "/sks/font/fonts/" + name;
if (!file_exists(src_font_dir)) {
bool found = false;
for (std::string font_dir : font_dirs) {
src_font_dir = font_dir + "/" + name;
//std::cout << "Font search for " << name << ": " << font_dir << "/\n";
if (file_exists(src_font_dir)) {
found = true;
break;
}
}
if (!found) {
throw Argument_error(
"Local font directory \"" + src_font_dir + "\" does not exist");
}
}
//std::cout << "Font " << name << ": " << src_font_dir << "\n";
std::string src_font_css = src_font_dir + ".css";
if (!fs::exists(src_font_css)) {
throw Argument_error(
"Local font CSS file \"" + src_font_css + "\" does not exist");
}
std::string font_copy =
"cp -r " + src_font_css + " " + src_font_dir + " " + output_font_dir;
//std::string files_copy = "cp -r " + basename + " " + font_dir;
//cout << css_copy << "\n" << files_copy << "\n";
//std::cout << system(css_copy.c_str()) << "\n";
//std::cout << system(files_copy.c_str()) << "\n";
if (system(font_copy.c_str()) != 0) {
std::cout << "Font copy command: " << font_copy << "\n";
throw Argument_error("Local font \"" + name + "\" not found");
}
}
}
elements_t status(std::string date, std::string version, std::string copyright)
{
elements_t result {};
@@ -550,7 +479,6 @@ namespace html {
strings_t css_filenames,
strings_t js_filenames,
strings_t local_fonts,
strings_t google_fonts,
std::string logo)
{
if (page_title.empty())
@@ -601,7 +529,7 @@ namespace html {
page_title = title;
result.push_back(
elt("html",
{ head(page_title, css, css_filenames, local_fonts, google_fonts),
{ head(page_title, css, css_filenames, local_fonts),
elt("body", body)
}).attr("lang", "en"));
return result;
@@ -666,12 +594,12 @@ namespace html {
elements_t make_page(
elements_t body,
std::string page_title, std::string css,
strings_t css_filenames, strings_t local_fonts, strings_t google_fonts)
strings_t css_filenames, strings_t local_fonts)
{
elements_t page { preamble() };
page.push_back(
elt("html",
{ head(page_title, css, css_filenames, local_fonts, google_fonts),
{ head(page_title, css, css_filenames, local_fonts),
elt("body", body)
}).attr("lang", "en"));
return page;

View File

@@ -76,8 +76,6 @@ namespace html {
//HTML preamble();
//HTML head(strings_t css_filenames, strings_t js_filenames);
void install_local_fonts(strings_t font_dirs, strings_t names, std::string output_dir);
elements_t page(
std::string title,
std::string page_title,
@@ -93,7 +91,6 @@ namespace html {
strings_t css_filenames = {},
strings_t js_filenames = {},
strings_t local_fonts = {},
strings_t google_fonts = {},
std::string logo = {});
void add_title(elements_t& body, std::string title, std::string logo="");
@@ -108,8 +105,7 @@ namespace html {
elements_t body,
std::string page_title, std::string css,
std::vector<std::string> css_filenames,
std::vector<std::string> local_fonts,
std::vector<std::string> google_fonts);
std::vector<std::string> local_fonts);
bool tag_is_block_element(std::string tag);
std::string make_paragraphs(std::string html_text);

View File

@@ -198,7 +198,7 @@ def add_caption(element, caption_label, number, caption_text,
tag = element_tag(element)
# Caption
caption = ""
if number == "true":
if number:
caption = kutil.caption_marker(caption_label, caption_text)
elif caption_text:
caption = caption_text

View File

@@ -3,7 +3,7 @@
#include "util.h"
#include "log.h"
#include "show.h"
#include "font_resolve.h"
#include "font_store.h"
namespace latex {

View File

@@ -2,7 +2,7 @@
#include <string>
#include <vector>
#include "font_resolve.h"
#include "font_store.h"
namespace latex {

View File

@@ -73,9 +73,8 @@ def make_caption_text(number, label, text, font_symbol, font_size):
# caption = text # f"{{\\small \\it \\par {caption_text}}}"
caption = text
font_symbol = "r"
font_size = 1.2
if caption is None:
return ""
caption = font.tex_fontify(caption, font_symbol, font_size)
return caption
@@ -108,9 +107,9 @@ def add_caption(element, caption_label, number, caption_text, latex_width,
# minipage(caption),
latex_width, vertical="t")
elif side == "top":
element = minipage(caption + "\n" + element,
# minipage(caption, vertical="t") +
# caption_margin + "\n" +
# minipage(element),
# A depth strut: the bottom-caption case uses \vstrut (height)
# above the caption; a top caption needs the mirror image,
# space below its line.
element = minipage(caption + "\\rule[-0.75\\baselineskip]{0pt}{0pt}\n" + element,
latex_width, vertical="t")
return caption_wrapper(element, hpos)

View File

@@ -17,7 +17,7 @@
# theoretical basis (operadic arity, the precedence-order proposition, and
# @cond as a non-strict special form).
#
# Usage: ./cond_test.sh (LSan suppressions come from mac/env/runtime.env.*)
# Usage: ./cond_test.sh (LSan suppressions come from env/runtime.env.*)
# Exit code: 0 if all tests pass, 1 otherwise.
PASS=0