diff --git a/com/Makefile b/com/Makefile index 9f62051..0786161 100644 --- a/com/Makefile +++ b/com/Makefile @@ -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 diff --git a/com/kdesc.cpp b/com/kdesc.cpp index e6b608e..a3d6720 100644 --- a/com/kdesc.cpp +++ b/com/kdesc.cpp @@ -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 Write a sample page of the available fonts\n" + " to /index.html\n" + " --font samples \n" + " Write a sample page for the (not yet\n" + " installed) font files under \n" + " --font install Install the font files found under\n" + " into the user font directory\n" + " --font install \n" + " Install into 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"); diff --git a/doc/install/linux_source_install.md b/doc/install/linux_source_install.md index 7da2ec8..3701b7d 100644 --- a/doc/install/linux_source_install.md +++ b/doc/install/linux_source_install.md @@ -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 diff --git a/doc/install/macos_source_install.md b/doc/install/macos_source_install.md index a5bb9ad..62e8255 100644 --- a/doc/install/macos_source_install.md +++ b/doc/install/macos_source_install.md @@ -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 diff --git a/mac/env/lsan.supp b/env/lsan.supp similarity index 100% rename from mac/env/lsan.supp rename to env/lsan.supp diff --git a/mac/env/makefile.env b/env/makefile.env similarity index 91% rename from mac/env/makefile.env rename to env/makefile.env index 82b6ba3..2fedfa3 100644 --- a/mac/env/makefile.env +++ b/env/makefile.env @@ -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 diff --git a/mac/env/optimize.env b/env/optimize.env similarity index 100% rename from mac/env/optimize.env rename to env/optimize.env diff --git a/mac/env/runtime.env b/env/runtime.env similarity index 83% rename from mac/env/runtime.env rename to env/runtime.env index 74a4038..b688b54 100644 --- a/mac/env/runtime.env +++ b/env/runtime.env @@ -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. file and no HOST/OS/SITE selector. # - KLAMMERTEXT_HOME : derived from this file's own location (self-locating) # - KLAMMERTEXT_TEXLIVE_BIN : newest ~/external/texlive//bin/ # 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 diff --git a/sks/font/fonts/crimson-pro.css b/fnt/crimson-pro.css similarity index 100% rename from sks/font/fonts/crimson-pro.css rename to fnt/crimson-pro.css diff --git a/sks/font/fonts/crimson-pro/Bold.ttf b/fnt/crimson-pro/Bold.ttf similarity index 100% rename from sks/font/fonts/crimson-pro/Bold.ttf rename to fnt/crimson-pro/Bold.ttf diff --git a/sks/font/fonts/crimson-pro/BoldItalic.ttf b/fnt/crimson-pro/BoldItalic.ttf similarity index 100% rename from sks/font/fonts/crimson-pro/BoldItalic.ttf rename to fnt/crimson-pro/BoldItalic.ttf diff --git a/sks/font/fonts/crimson-pro/Italic.ttf b/fnt/crimson-pro/Italic.ttf similarity index 100% rename from sks/font/fonts/crimson-pro/Italic.ttf rename to fnt/crimson-pro/Italic.ttf diff --git a/sks/font/fonts/crimson-pro/Regular.ttf b/fnt/crimson-pro/Regular.ttf similarity index 100% rename from sks/font/fonts/crimson-pro/Regular.ttf rename to fnt/crimson-pro/Regular.ttf diff --git a/sks/font/fonts/inconsolata.css b/fnt/inconsolata.css similarity index 100% rename from sks/font/fonts/inconsolata.css rename to fnt/inconsolata.css diff --git a/sks/font/fonts/inconsolata/Bold.ttf b/fnt/inconsolata/Bold.ttf similarity index 100% rename from sks/font/fonts/inconsolata/Bold.ttf rename to fnt/inconsolata/Bold.ttf diff --git a/sks/font/fonts/inconsolata/Regular.ttf b/fnt/inconsolata/Regular.ttf similarity index 100% rename from sks/font/fonts/inconsolata/Regular.ttf rename to fnt/inconsolata/Regular.ttf diff --git a/sks/font/fonts/libre-baskerville.css b/fnt/libre-baskerville.css similarity index 100% rename from sks/font/fonts/libre-baskerville.css rename to fnt/libre-baskerville.css diff --git a/sks/font/fonts/libre-baskerville/libre-baskerville-v5-latin-regular.eot b/fnt/libre-baskerville/libre-baskerville-v5-latin-regular.eot similarity index 100% rename from sks/font/fonts/libre-baskerville/libre-baskerville-v5-latin-regular.eot rename to fnt/libre-baskerville/libre-baskerville-v5-latin-regular.eot diff --git a/sks/font/fonts/libre-baskerville/libre-baskerville-v5-latin-regular.svg b/fnt/libre-baskerville/libre-baskerville-v5-latin-regular.svg similarity index 100% rename from sks/font/fonts/libre-baskerville/libre-baskerville-v5-latin-regular.svg rename to fnt/libre-baskerville/libre-baskerville-v5-latin-regular.svg diff --git a/sks/font/fonts/libre-baskerville/libre-baskerville-v5-latin-regular.ttf b/fnt/libre-baskerville/libre-baskerville-v5-latin-regular.ttf similarity index 100% rename from sks/font/fonts/libre-baskerville/libre-baskerville-v5-latin-regular.ttf rename to fnt/libre-baskerville/libre-baskerville-v5-latin-regular.ttf diff --git a/sks/font/fonts/libre-baskerville/libre-baskerville-v5-latin-regular.woff b/fnt/libre-baskerville/libre-baskerville-v5-latin-regular.woff similarity index 100% rename from sks/font/fonts/libre-baskerville/libre-baskerville-v5-latin-regular.woff rename to fnt/libre-baskerville/libre-baskerville-v5-latin-regular.woff diff --git a/sks/font/fonts/libre-baskerville/libre-baskerville-v5-latin-regular.woff2 b/fnt/libre-baskerville/libre-baskerville-v5-latin-regular.woff2 similarity index 100% rename from sks/font/fonts/libre-baskerville/libre-baskerville-v5-latin-regular.woff2 rename to fnt/libre-baskerville/libre-baskerville-v5-latin-regular.woff2 diff --git a/sks/font/fonts/linux-libertine.css b/fnt/linux-libertine.css similarity index 100% rename from sks/font/fonts/linux-libertine.css rename to fnt/linux-libertine.css diff --git a/sks/font/fonts/linux-libertine/GPL.txt b/fnt/linux-libertine/GPL.txt similarity index 100% rename from sks/font/fonts/linux-libertine/GPL.txt rename to fnt/linux-libertine/GPL.txt diff --git a/sks/font/fonts/linux-libertine/LICENCE.txt b/fnt/linux-libertine/LICENCE.txt similarity index 100% rename from sks/font/fonts/linux-libertine/LICENCE.txt rename to fnt/linux-libertine/LICENCE.txt diff --git a/sks/font/fonts/linux-libertine/LinLibertine_RBah.ttf b/fnt/linux-libertine/LinLibertine_RBah.ttf similarity index 100% rename from sks/font/fonts/linux-libertine/LinLibertine_RBah.ttf rename to fnt/linux-libertine/LinLibertine_RBah.ttf diff --git a/sks/font/fonts/linux-libertine/LinLibertine_RIah.ttf b/fnt/linux-libertine/LinLibertine_RIah.ttf similarity index 100% rename from sks/font/fonts/linux-libertine/LinLibertine_RIah.ttf rename to fnt/linux-libertine/LinLibertine_RIah.ttf diff --git a/sks/font/fonts/linux-libertine/LinLibertine_Rah.ttf b/fnt/linux-libertine/LinLibertine_Rah.ttf similarity index 100% rename from sks/font/fonts/linux-libertine/LinLibertine_Rah.ttf rename to fnt/linux-libertine/LinLibertine_Rah.ttf diff --git a/sks/font/fonts/linux-libertine/OFL-1.1.txt b/fnt/linux-libertine/OFL-1.1.txt similarity index 100% rename from sks/font/fonts/linux-libertine/OFL-1.1.txt rename to fnt/linux-libertine/OFL-1.1.txt diff --git a/sks/font/fonts/open-sans.css b/fnt/open-sans.css similarity index 100% rename from sks/font/fonts/open-sans.css rename to fnt/open-sans.css diff --git a/sks/font/fonts/open-sans/Bold.ttf b/fnt/open-sans/Bold.ttf similarity index 100% rename from sks/font/fonts/open-sans/Bold.ttf rename to fnt/open-sans/Bold.ttf diff --git a/sks/font/fonts/open-sans/BoldItalic.ttf b/fnt/open-sans/BoldItalic.ttf similarity index 100% rename from sks/font/fonts/open-sans/BoldItalic.ttf rename to fnt/open-sans/BoldItalic.ttf diff --git a/sks/font/fonts/open-sans/Italic.ttf b/fnt/open-sans/Italic.ttf similarity index 100% rename from sks/font/fonts/open-sans/Italic.ttf rename to fnt/open-sans/Italic.ttf diff --git a/sks/font/fonts/open-sans/Regular.ttf b/fnt/open-sans/Regular.ttf similarity index 100% rename from sks/font/fonts/open-sans/Regular.ttf rename to fnt/open-sans/Regular.ttf diff --git a/sks/font/fonts/roboto-mono.css b/fnt/roboto-mono.css similarity index 100% rename from sks/font/fonts/roboto-mono.css rename to fnt/roboto-mono.css diff --git a/sks/font/fonts/roboto-mono/LICENSE.txt b/fnt/roboto-mono/LICENSE.txt similarity index 100% rename from sks/font/fonts/roboto-mono/LICENSE.txt rename to fnt/roboto-mono/LICENSE.txt diff --git a/sks/font/fonts/roboto-mono/RobotoMono-Italic.ttf b/fnt/roboto-mono/RobotoMono-Italic.ttf similarity index 100% rename from sks/font/fonts/roboto-mono/RobotoMono-Italic.ttf rename to fnt/roboto-mono/RobotoMono-Italic.ttf diff --git a/sks/font/fonts/roboto-mono/RobotoMono-Regular.ttf b/fnt/roboto-mono/RobotoMono-Regular.ttf similarity index 100% rename from sks/font/fonts/roboto-mono/RobotoMono-Regular.ttf rename to fnt/roboto-mono/RobotoMono-Regular.ttf diff --git a/sks/font/fonts/source-code-pro.css b/fnt/source-code-pro.css similarity index 100% rename from sks/font/fonts/source-code-pro.css rename to fnt/source-code-pro.css diff --git a/sks/font/fonts/source-code-pro/source-code-pro-v7-latin-regular.eot b/fnt/source-code-pro/source-code-pro-v7-latin-regular.eot similarity index 100% rename from sks/font/fonts/source-code-pro/source-code-pro-v7-latin-regular.eot rename to fnt/source-code-pro/source-code-pro-v7-latin-regular.eot diff --git a/sks/font/fonts/source-code-pro/source-code-pro-v7-latin-regular.svg b/fnt/source-code-pro/source-code-pro-v7-latin-regular.svg similarity index 100% rename from sks/font/fonts/source-code-pro/source-code-pro-v7-latin-regular.svg rename to fnt/source-code-pro/source-code-pro-v7-latin-regular.svg diff --git a/sks/font/fonts/source-code-pro/source-code-pro-v7-latin-regular.ttf b/fnt/source-code-pro/source-code-pro-v7-latin-regular.ttf similarity index 100% rename from sks/font/fonts/source-code-pro/source-code-pro-v7-latin-regular.ttf rename to fnt/source-code-pro/source-code-pro-v7-latin-regular.ttf diff --git a/sks/font/fonts/source-code-pro/source-code-pro-v7-latin-regular.woff b/fnt/source-code-pro/source-code-pro-v7-latin-regular.woff similarity index 100% rename from sks/font/fonts/source-code-pro/source-code-pro-v7-latin-regular.woff rename to fnt/source-code-pro/source-code-pro-v7-latin-regular.woff diff --git a/sks/font/fonts/source-code-pro/source-code-pro-v7-latin-regular.woff2 b/fnt/source-code-pro/source-code-pro-v7-latin-regular.woff2 similarity index 100% rename from sks/font/fonts/source-code-pro/source-code-pro-v7-latin-regular.woff2 rename to fnt/source-code-pro/source-code-pro-v7-latin-regular.woff2 diff --git a/sks/font/fonts/source-sans-pro.css b/fnt/source-sans-pro.css similarity index 100% rename from sks/font/fonts/source-sans-pro.css rename to fnt/source-sans-pro.css diff --git a/sks/font/fonts/source-sans-pro/OFL.txt b/fnt/source-sans-pro/OFL.txt similarity index 100% rename from sks/font/fonts/source-sans-pro/OFL.txt rename to fnt/source-sans-pro/OFL.txt diff --git a/sks/font/fonts/source-sans-pro/SourceSansPro-Italic.ttf b/fnt/source-sans-pro/SourceSansPro-Italic.ttf similarity index 100% rename from sks/font/fonts/source-sans-pro/SourceSansPro-Italic.ttf rename to fnt/source-sans-pro/SourceSansPro-Italic.ttf diff --git a/sks/font/fonts/source-sans-pro/SourceSansPro-Regular.ttf b/fnt/source-sans-pro/SourceSansPro-Regular.ttf similarity index 100% rename from sks/font/fonts/source-sans-pro/SourceSansPro-Regular.ttf rename to fnt/source-sans-pro/SourceSansPro-Regular.ttf diff --git a/mac/Makefile b/mac/Makefile index 9fde99c..59ec1fa 100644 --- a/mac/Makefile +++ b/mac/Makefile @@ -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)) diff --git a/mac/argtype.cpp b/mac/argtype.cpp index bd5c621..f930ba2 100644 --- a/mac/argtype.cpp +++ b/mac/argtype.cpp @@ -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 true_values { "1", "true", "True", "yes" }; std::set 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 value, size_t name_size) @@ -76,7 +100,18 @@ std::string Argtype::python_value(const std::string& var_name, std::vector)(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(); diff --git a/mac/argtype.h b/mac/argtype.h index c7d1bb8..8e77360 100644 --- a/mac/argtype.h +++ b/mac/argtype.h @@ -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 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: )(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 {}; diff --git a/mac/argtype_set.cpp b/mac/argtype_set.cpp index f35aa62..7277c09 100644 --- a/mac/argtype_set.cpp +++ b/mac/argtype_set.cpp @@ -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) @@ -58,8 +61,8 @@ void Argtype_set::check_for_existing_definition( } } -void Argtype_set::add(const std::string& name, const std::string& desc, - const std::string& pattern, +void Argtype_set::add(const std::string& name, const std::string& desc, + 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::iterator begin, std::vector::iterator end, std::vector& 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::iterator begin, std::vector::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); diff --git a/mac/argtype_set.h b/mac/argtype_set.h index 6cc6804..e0aed62 100644 --- a/mac/argtype_set.h +++ b/mac/argtype_set.h @@ -17,8 +17,9 @@ 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, + void add(const std::string& name, const std::string& desc, + 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::iterator begin, std::vector::iterator end, std::vector& katoms); @@ -63,7 +64,7 @@ std::vector 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( diff --git a/mac/argument_set.h b/mac/argument_set.h index cabb957..4b424bf 100644 --- a/mac/argument_set.h +++ b/mac/argument_set.h @@ -34,6 +34,9 @@ public: const std::vector>& optional, const std::vector& 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 m_katoms {}; diff --git a/mac/argv.cpp b/mac/argv.cpp index a2ec46d..283a4c1 100644 --- a/mac/argv.cpp +++ b/mac/argv.cpp @@ -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); diff --git a/mac/argv.h b/mac/argv.h index cf21d53..40a84f2 100644 --- a/mac/argv.h +++ b/mac/argv.h @@ -3,6 +3,7 @@ // Delusions of generality, but it's really just for Klammertext commands. #include +#include #include #include #include @@ -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 ). + 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& words); void parse_flags(std::vector& words, std::map& named_args); void parse_optional(std::vector& words, std::map& named_args); + void parse_vars(std::vector& words, std::map& 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 words, @@ -98,6 +110,8 @@ public: std::vector m_req_names {}; std::vector m_flag_names {}; std::vector m_opt_names {}; + std::vector m_var_names {}; + std::set m_given {}; std::vector m_hyphen_markers {}; long unsigned int m_syntax_size = 0; }; diff --git a/mac/env/makefile.env.hollis.DISABLED b/mac/env/makefile.env.hollis.DISABLED deleted file mode 100644 index dcbd508..0000000 --- a/mac/env/makefile.env.hollis.DISABLED +++ /dev/null @@ -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 diff --git a/mac/env/makefile.env.jatke.DISABLED b/mac/env/makefile.env.jatke.DISABLED deleted file mode 100644 index 76e27db..0000000 --- a/mac/env/makefile.env.jatke.DISABLED +++ /dev/null @@ -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 diff --git a/mac/env/makefile.env.pop.DISABLED b/mac/env/makefile.env.pop.DISABLED deleted file mode 100644 index 76e27db..0000000 --- a/mac/env/makefile.env.pop.DISABLED +++ /dev/null @@ -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 diff --git a/mac/env/makefile.env.ubuntu.DISABLED b/mac/env/makefile.env.ubuntu.DISABLED deleted file mode 100644 index 76e27db..0000000 --- a/mac/env/makefile.env.ubuntu.DISABLED +++ /dev/null @@ -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 diff --git a/mac/font_store.cpp b/mac/font_store.cpp new file mode 100644 index 0000000..e0150e5 --- /dev/null +++ b/mac/font_store.cpp @@ -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 +#include +#include +#include +#include +#include + +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: // holding the +// .ttf files and /.css declaring the @font-face variants. +static Resolved_font resolve_in_directory( + const std::string& family_name, const std::string& dir_name, const std::string& base_dir) +{ + std::string font_dir = base_dir + "/" + dir_name; + std::string css_file = font_dir + ".css"; + if (fs::exists(font_dir) && fs::exists(css_file)) { + Resolved_font font {}; + font.family_name = family_name; + font.dir_name = dir_name; + font.font_dir = font_dir; + font.css_file = css_file; + classify_from_css(font, css_file); + return font; + } + return {}; +} + +// Every font available for the three @document role parameters: each +// .css with a matching / directory, across the installed +// directories and the default font set. The reported name is the family name +// from the CSS (what the writer types), falling back to the directory name. +strings_t available_font_families() +{ + strings_t result {}; + static const std::regex family_rgx(R"(font-family:\s*'([^']+)')"); + auto scan = [&](const std::string& base) { + if (!fs::exists(base)) { + return; + } + for (auto& entry : fs::directory_iterator(base)) { + if (entry.path().extension() == ".css" && + fs::is_directory(base + "/" + entry.path().stem().string())) { + std::string css = string_from_file(entry.path()); + std::smatch match {}; + if (std::regex_search(css, match, family_rgx)) { + result.push_back(match[1]); + } else { + result.push_back(entry.path().stem()); + } + } + } + }; + for (const std::string& dir : installed_font_dirs()) { + scan(dir); + } + scan(default_font_dir()); + std::sort(result.begin(), result.end()); + result.erase(std::unique(result.begin(), result.end()), result.end()); + return result; +} + + +static void extract_font_metrics(Resolved_font& font) +{ + if (font.regular.empty() || font.font_dir.empty()) + return; + std::string ttf_path = font.font_dir + "/" + font.regular; + if (!fs::exists(ttf_path)) + return; + // Extract both x-height and cap-height ratios from OS/2 table + std::string script = + "python3 -c \"" + "import struct; " + "f = open('" + ttf_path + "', 'rb'); " + "_, n = struct.unpack('>IH', f.read(6)); " + "f.read(6); " + "t = {};\n" + "for _ in range(n):\n" + " tag = f.read(4).decode('latin-1').strip('\\\\x00'); " + " _, o, l = struct.unpack('>III', f.read(12)); " + " t[tag] = o\n" + "f.seek(t['head'] + 18); " + "upm = struct.unpack('>H', f.read(2))[0]; " + "f.seek(t['OS/2']); " + "ver = struct.unpack('>H', f.read(2))[0]; " + "f.seek(t['OS/2'] + 86); " + "xh, ch = struct.unpack('>hh', f.read(4)); " + "print(f'{xh/upm:.4f} {ch/upm:.4f}') if ver >= 2 else None; " + "f.close()\""; + std::string result = trim(exec(script.c_str())); + if (!result.empty()) { + try { + auto pos = result.find(' '); + if (pos != std::string::npos) { + font.xheight_ratio = std::stof(result.substr(0, pos)); + font.capheight_ratio = std::stof(result.substr(pos + 1)); + } + } catch (...) {} + } +} + +Resolved_font resolve_font(std::string family_name) +{ + if (family_name.empty()) + return {}; + + std::string dir_name = name_to_dirname(family_name); + + // Installed directories in listed order, then the default font set, so an + // installed font can shadow a default one. + strings_t bases = installed_font_dirs(); + bases.push_back(default_font_dir()); + for (const std::string& base : bases) { + Resolved_font font = resolve_in_directory(family_name, dir_name, base); + if (!font.family_name.empty()) { + extract_font_metrics(font); + return font; + } + } + + std::stringstream ss {}; + ss << "The font \"" << family_name << "\" is not installed.\n\n" + << "Available fonts:\n"; + for (const std::string& name : available_font_families()) { + ss << " " << name << "\n"; + } + ss << "\nFonts are searched in the directories of the KLAMMERTEXT_FONTS\n" + << "environment variable (colon-separated; default $HOME/.klammertext/fonts)\n" + << "and then in the default font set. To install a font,\n" + << "place its files as /" << dir_name << "/*.ttf with a\n" + << "/" << dir_name << ".css declaring its @font-face variants."; + throw Argument_error(ss.str()); +} + + +// Font file classification: read the family name, weight, and style from +// the font's internal tables (sfnt 'name', 'OS/2', 'fvar') rather than +// from filenames, which vary by source (Google zips, foundries, ...). + +namespace { + +uint16_t be16(const std::string& d, size_t off) +{ + return (uint8_t(d[off]) << 8) | uint8_t(d[off + 1]); +} + +uint32_t be32(const std::string& d, size_t off) +{ + return (uint32_t(be16(d, off)) << 16) | be16(d, off + 2); +} + +std::string read_binary_file(const std::string& path) +{ + std::ifstream in(path, std::ios::binary); + std::stringstream ss {}; + ss << in.rdbuf(); + return ss.str(); +} + +// Decode a name-table string: UTF-16BE for Windows records (keep the BMP +// low bytes; family names are almost always Latin), bytes as-is otherwise. +std::string decode_name(const std::string& raw, bool utf16be) +{ + std::string result {}; + if (utf16be) { + for (size_t i = 0; i + 1 < raw.size(); i += 2) { + if (raw[i] == 0) { + result += raw[i + 1]; + } + } + } else { + result = raw; + } + return result; +} + +} // namespace + +Font_file classify_font_file(const std::string& path) +{ + Font_file file {}; + file.path = path; + 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> tables {}; // tag -> offset,length + for (uint16_t i = 0; i < num_tables; i++) { + size_t rec = 12 + i * 16; + if (rec + 16 > d.size()) { + break; + } + tables[d.substr(rec, 4)] = { be32(d, rec + 8), be32(d, rec + 12) }; + } + file.variable = tables.contains("fvar"); + + // Family name from the 'name' table: typographic family (16) wins + // over family (1); Windows records (platform 3) win over Macintosh. + if (auto it = tables.find("name"); it != tables.end()) { + size_t base = it->second.first; + uint16_t count = be16(d, base + 2); + uint16_t string_offset = be16(d, base + 4); + int best_rank = -1; + for (uint16_t i = 0; i < count; i++) { + size_t rec = base + 6 + i * 12; + if (rec + 12 > d.size()) { + break; + } + uint16_t platform = be16(d, rec); + uint16_t name_id = be16(d, rec + 6); + uint16_t length = be16(d, rec + 8); + uint16_t offset = be16(d, rec + 10); + if (name_id != 1 && name_id != 16) { + continue; + } + int rank = (name_id == 16 ? 2 : 0) + (platform == 3 ? 1 : 0); + size_t at = base + string_offset + offset; + if (rank > best_rank && at + length <= d.size()) { + file.family = decode_name(d.substr(at, length), platform == 3); + best_rank = rank; + } + } + } + if (file.family.empty()) { + file.note = "no family name found in the font's name table"; + return file; + } + + bool italic = false; + if (auto it = tables.find("OS/2"); it != tables.end()) { + size_t base = it->second.first; + file.weight = be16(d, base + 4); + italic = be16(d, base + 62) & 0x0001; // fsSelection italic bit + } else if (auto ht = tables.find("head"); ht != tables.end()) { + uint16_t mac_style = be16(d, ht->second.first + 44); + file.weight = (mac_style & 0x0001) ? 700 : 400; + italic = mac_style & 0x0002; + } + + if (file.variable) { + // A variable font covers the weight axis; use it as the regular + // (or italic) face and let renderers derive weights. + file.variant = italic ? "Italic" : "Regular"; + } else if (file.weight >= 380 && file.weight <= 450) { + file.variant = italic ? "Italic" : "Regular"; + } else if (file.weight >= 650 && file.weight <= 760) { + file.variant = italic ? "BoldItalic" : "Bold"; + } else { + std::stringstream note {}; + note << "weight " << file.weight + << " not installed (only regular 400 and bold 700 are used)"; + file.note = note.str(); + } + return file; +} + +std::vector classify_font_files(const std::string& directory) +{ + std::vector result {}; + if (!fs::exists(directory)) { + throw Argument_error( + "The font directory \"" + directory + "\" does not exist"); + } + for (auto& entry : fs::recursive_directory_iterator(directory)) { + std::string ext = 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& slots) +{ + std::stringstream css {}; + auto emit = [&](const std::string& variant, + const std::string& style, const std::string& weight) { + auto it = slots.find(variant); + if (it == slots.end()) { + return; + } + std::string format = + it->second->extension == ".otf" ? "opentype" : "truetype"; + css << "\n@font-face {\n" + << " font-family: '" << family << "';\n" + << " font-style: " << style << ";\n" + << " font-weight: " << weight << ";\n" + << " src: url('" << dir_name << "/" << variant + << it->second->extension << "') format('" << format << "');\n" + << "}\n"; + }; + emit("Regular", "normal", "400"); + emit("Bold", "normal", "700"); + emit("Italic", "italic", "400"); + emit("BoldItalic", "italic", "700"); + return css.str(); +} + +std::string install_fonts(const std::string& source_dir, std::string dest_dir) +{ + if (dest_dir.empty()) { + strings_t dirs = installed_font_dirs(); + if (dirs.empty()) { + throw Argument_error( + "No installation directory: KLAMMERTEXT_FONTS is empty and " + "HOME is not set"); + } + dest_dir = dirs[0]; + } + std::vector files = classify_font_files(source_dir); + if (files.empty()) { + throw Argument_error( + "No font files (.ttf or .otf) found under \"" + source_dir + "\""); + } + + // Choose one file per (family, variant) slot; a static face wins over + // a variable font's derived face. + std::map> families {}; + std::stringstream report {}; + for (const Font_file& file : files) { + if (file.variant.empty()) { + report << " skipped " << fs::path(file.path).filename().string() + << ": " << file.note << "\n"; + continue; + } + auto& slots = families[file.family]; + auto it = slots.find(file.variant); + if (it == slots.end() || (it->second->variable && !file.variable)) { + slots[file.variant] = &file; + } + } + + for (auto& [family, slots] : families) { + std::string dir_name = name_to_dirname(family); + std::string family_dir = dest_dir + "/" + dir_name; + fs::create_directories(family_dir); + strings_t variants {}; + for (auto& [variant, file] : slots) { + copy_file_stream(file->path, + family_dir + "/" + variant + file->extension); + variants.push_back(variant + (file->variable ? " (variable)" : "")); + } + string_to_file(dest_dir + "/" + dir_name + ".css", + font_face_css(family, dir_name, slots)); + report << " installed \"" << family << "\" (" << join(variants, ", ") + << ") in " << family_dir << "\n"; + } + return report.str(); +} + +std::string describe_fonts() +{ + std::stringstream ss {}; + std::set seen {}; + strings_t bases = installed_font_dirs(); + bases.push_back(default_font_dir()); + for (size_t i = 0; i < bases.size(); i++) { + const std::string& base = bases[i]; + bool is_default = (i == bases.size() - 1); + ss << base << (is_default ? " (default font set)" : "") << ":\n"; + if (!fs::exists(base)) { + ss << " [directory does not exist]\n"; + continue; + } + strings_t names {}; + for (auto& entry : fs::directory_iterator(base)) { + if (entry.path().extension() == ".css" && + fs::is_directory(base + "/" + entry.path().stem().string())) { + names.push_back(entry.path().stem()); + } + } + std::sort(names.begin(), names.end()); + if (names.empty()) { + ss << " [no fonts]\n"; + } + for (const std::string& dir_name : names) { + Resolved_font font = resolve_in_directory("?", dir_name, base); + std::string css = string_from_file(font.css_file); + std::smatch match {}; + std::string family = dir_name; + if (std::regex_search(css, match, + std::regex(R"(font-family:\s*'([^']+)')"))) { + family = match[1]; + } + strings_t variants {}; + if (!font.regular.empty()) variants.push_back("Regular"); + if (!font.bold.empty()) variants.push_back("Bold"); + if (!font.italic.empty()) variants.push_back("Italic"); + if (!font.bold_italic.empty()) variants.push_back("BoldItalic"); + ss << " " << family << " (" << join(variants, ", ") << ")"; + if (seen.contains(dir_name)) { + ss << " [shadowed by an earlier directory]"; + } + seen.insert(dir_name); + ss << "\n"; + } + } + return ss.str(); +} + +std::string write_font_samples(const std::string& output_dir, const std::string& source_dir) +{ + fs::create_directories(output_dir); + strings_t families {}; + if (source_dir.empty()) { + for (const std::string& family : available_font_families()) { + install_resolved_font(resolve_font(family), output_dir); + families.push_back(family); + } + } else { + // Uninstalled preview: install the classified fonts directly into + // the sample page's own fonts directory. + install_fonts(source_dir, output_dir + "/fonts"); + for (auto& entry : fs::directory_iterator(output_dir + "/fonts")) { + if (entry.path().extension() == ".css") { + std::string css = string_from_file(entry.path()); + std::smatch match {}; + if (std::regex_search(css, match, + std::regex(R"(font-family:\s*'([^']+)')"))) { + families.push_back(match[1]); + } + } + } + std::sort(families.begin(), families.end()); + } + + std::stringstream html {}; + html << "\n\n\n" + << "\nKlammertext font samples\n"; + for (const std::string& family : families) { + html << "\n"; + } + html << "\n\n\n

Klammertext font samples

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

" << family << "

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

The quick brown fox jumps over the lazy dog.

\n" + << "

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

\n" + << "

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

\n" + << "

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

\n" + << "

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

\n" + << "
\n"; + } + html << "\n\n"; + std::string index = output_dir + "/index.html"; + string_to_file(index, html.str()); + return index; +} + +// Font assets are copied with copy_file_stream() (mac/file.h) rather than +// std::filesystem::copy_file, which fails on Apple `container` virtiofs mounts +// — see the note on copy_file_stream() in file.cpp for the full rationale. + + +void install_resolved_font(const Resolved_font& font, std::string output_dir) +{ + if (font.family_name.empty()) + return; + + std::string output_font_dir = output_dir + "/fonts"; + if (!fs::exists(output_font_dir)) + fs::create_directory(output_font_dir); + + // Copy .css file and font directory to output + std::string dest_css = output_font_dir + "/" + font.dir_name + ".css"; + std::string dest_dir = output_font_dir + "/" + font.dir_name; + + copy_file_stream(font.css_file, dest_css); + + if (!fs::exists(dest_dir)) { + fs::create_directory(dest_dir); + for (auto& entry : fs::directory_iterator(font.font_dir)) { + copy_file_stream(entry.path(), + dest_dir + "/" + entry.path().filename().string()); + } + } +} diff --git a/mac/font_store.h b/mac/font_store.h new file mode 100644 index 0000000..73269a8 --- /dev/null +++ b/mac/font_store.h @@ -0,0 +1,59 @@ +#pragma once + +// The Klammertext font store (infrastructure; see font_store.cpp). + +#include +#include + +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 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 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 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 //{Variant}.ttf plus a +// generated .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 = ""); diff --git a/mac/machine.cpp b/mac/machine.cpp index 139a06c..7d8ebb1 100644 --- a/mac/machine.cpp +++ b/mac/machine.cpp @@ -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) { diff --git a/mac/state.cpp b/mac/state.cpp index ef6b4ed..b37969b 100644 --- a/mac/state.cpp +++ b/mac/state.cpp @@ -25,9 +25,9 @@ std::vector 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 varmap) @@ -96,6 +96,16 @@ void State::set(std::map varmap) } } +void State::set(const std::map& 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"; diff --git a/mac/state.h b/mac/state.h index d33150e..75232b1 100644 --- a/mac/state.h +++ b/mac/state.h @@ -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 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 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 varmap); + void set(const std::map& 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()); diff --git a/sks/document/Makefile b/sks/document/Makefile index d708aa6..dcf69ab 100644 --- a/sks/document/Makefile +++ b/sks/document/Makefile @@ -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) diff --git a/sks/document/document.k b/sks/document/document.k index 102d6ea..e988c84 100644 --- a/sks/document/document.k +++ b/sks/document/document.k @@ -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 diff --git a/sks/document/document_class.cpp b/sks/document/document_class.cpp index 466173b..0012cac 100644 --- a/sks/document/document_class.cpp +++ b/sks/document/document_class.cpp @@ -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() == '"') diff --git a/sks/document/document_class.h b/sks/document/document_class.h index 308fcf1..a163e18 100644 --- a/sks/document/document_class.h +++ b/sks/document/document_class.h @@ -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 {}; diff --git a/sks/document/document_html.cpp b/sks/document/document_html.cpp index 6c245b9..62d9155 100644 --- a/sks/document/document_html.cpp +++ b/sks/document/document_html.cpp @@ -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, diff --git a/sks/font/font.k b/sks/font/font.k index ec88289..a76c416 100644 --- a/sks/font/font.k +++ b/sks/font/font.k @@ -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. @@ diff --git a/sks/font/font.py b/sks/font/font.py index e16448c..9b8ec83 100644 --- a/sks/font/font.py +++ b/sks/font/font.py @@ -34,7 +34,7 @@ def html_fontify(text, font_symbol, font_size): if cls: if font_symbol == "c": result = re.sub(" ", " ", result) - cls = f'class="cls"' + cls = f'class="{cls}"' sty = "" if font_size != 1: sty = f'style="font-size: {float(font_size) * 100}%"' diff --git a/sks/image/image.k b/sks/image/image.k index 5d0e511..6d3c92c 100644 --- a/sks/image/image.k +++ b/sks/image/image.k @@ -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 diff --git a/sks/image/image.py b/sks/image/image.py index 3f8731f..383e374 100644 --- a/sks/image/image.py +++ b/sks/image/image.py @@ -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}}}' diff --git a/sks/image/image_grid.py b/sks/image/image_grid.py index 7637593..34c20cf 100644 --- a/sks/image/image_grid.py +++ b/sks/image/image_grid.py @@ -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]]) diff --git a/sks/kutil/Makefile b/sks/kutil/Makefile index 946fe98..3d160ff 100644 --- a/sks/kutil/Makefile +++ b/sks/kutil/Makefile @@ -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 diff --git a/sks/kutil/kutil.k b/sks/kutil/kutil.k index 260422e..d033576 100644 --- a/sks/kutil/kutil.k +++ b/sks/kutil/kutil.k @@ -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. diff --git a/sks/kutil/kutil.py b/sks/kutil/kutil.py index b3307a0..05d620e 100644 --- a/sks/kutil/kutil.py +++ b/sks/kutil/kutil.py @@ -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("(? full extent + () 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__() diff --git a/sks/table/sequences.py b/sks/table/sequences.py deleted file mode 100644 index d5331f5..0000000 --- a/sks/table/sequences.py +++ /dev/null @@ -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)) diff --git a/sks/table/span.py b/sks/table/span.py deleted file mode 100644 index c9a3f31..0000000 --- a/sks/table/span.py +++ /dev/null @@ -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)) - - - diff --git a/sks/table/table.k b/sks/table/table.k index c01b664..8099c94 100644 --- a/sks/table/table.k +++ b/sks/table/table.k @@ -1,63 +1,165 @@ -#[ f: 0->1; n: 1.. (integer) - l, c, r -[] -width is widest line in a cell f [default] -width is fraction of table t -width is specific length 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 , a closed range -, or an open range - (from 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 , a closed index range -, or an open index range - + (from 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 + + = ... + + where the target is a single cell written () 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 + (); 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 - :hline.table_hline + :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 : diff --git a/sks/table/table.py b/sks/table/table.py index 7d53205..af0a022 100644 --- a/sks/table/table.py +++ b/sks/table/table.py @@ -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 (), 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}{" @@ -289,20 +390,29 @@ class Table(klammer_base.Klammer_base): result += self.make_caption() result += self.tex_rows() result += "\\end{longtable}\n" - + 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" diff --git a/sks/table/table_cell.py b/sks/table/table_cell.py index 5ac5d3d..294ac3b 100644 --- a/sks/table/table_cell.py +++ b/sks/table/table_cell.py @@ -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: diff --git a/sks/target/Makefile b/sks/target/Makefile index 30fc8b9..daf3525 100644 --- a/sks/target/Makefile +++ b/sks/target/Makefile @@ -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 diff --git a/sks/target/font_resolve.cpp b/sks/target/font_resolve.cpp deleted file mode 100644 index a8e5d4d..0000000 --- a/sks/target/font_resolve.cpp +++ /dev/null @@ -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 -#include -#include -#include - -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 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()); - } - } -} diff --git a/sks/target/font_resolve.h b/sks/target/font_resolve.h deleted file mode 100644 index adef102..0000000 --- a/sks/target/font_resolve.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include -#include - -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); diff --git a/sks/target/html_util.cpp b/sks/target/html_util.cpp index b0cad63..ff97531 100644 --- a/sks/target/html_util.cpp +++ b/sks/target/html_util.cpp @@ -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; diff --git a/sks/target/html_util.h b/sks/target/html_util.h index c72b595..d71b17e 100644 --- a/sks/target/html_util.h +++ b/sks/target/html_util.h @@ -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 css_filenames, - std::vector local_fonts, - std::vector google_fonts); + std::vector local_fonts); bool tag_is_block_element(std::string tag); std::string make_paragraphs(std::string html_text); diff --git a/sks/target/html_util.py b/sks/target/html_util.py index ea7b6d0..5568a76 100644 --- a/sks/target/html_util.py +++ b/sks/target/html_util.py @@ -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 diff --git a/sks/target/latex_util.cpp b/sks/target/latex_util.cpp index bc23dbe..823f5d7 100644 --- a/sks/target/latex_util.cpp +++ b/sks/target/latex_util.cpp @@ -3,7 +3,7 @@ #include "util.h" #include "log.h" #include "show.h" -#include "font_resolve.h" +#include "font_store.h" namespace latex { diff --git a/sks/target/latex_util.h b/sks/target/latex_util.h index 74851c7..358769c 100644 --- a/sks/target/latex_util.h +++ b/sks/target/latex_util.h @@ -2,7 +2,7 @@ #include #include -#include "font_resolve.h" +#include "font_store.h" namespace latex { diff --git a/sks/target/latex_util.py b/sks/target/latex_util.py index 7862d21..ab56001 100644 --- a/sks/target/latex_util.py +++ b/sks/target/latex_util.py @@ -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) diff --git a/tst/cond_test.sh b/tst/cond_test.sh index 4229829..b66134c 100755 --- a/tst/cond_test.sh +++ b/tst/cond_test.sh @@ -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