From 0ead40f272467ff4222369302a5170c104b877a5 Mon Sep 17 00:00:00 2001 From: Andy Kopra Date: Wed, 16 Sep 2026 16:59:49 +0200 Subject: [PATCH] Width and height argtypes, kdesc --argtypes from ref/, txt @define/@left_right/@columns, kdiag --check value validation (from dev d6993110e9b3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also: the klammertext argtype (descriptions kept as source), txt table wrapping and :hpos/:offset, @c between ‹ ›, txt section headings, the ktext newline-only trim, capitalize(), and the argtype test suite. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- com/kdesc.cpp | 13 ++++-- com/ktext.cpp | 7 ++- doc/klammertext_for_llm.md | 31 +++++++------ mac/argtype_registry.cpp | 7 ++- mac/argtype_registry.h | 15 ++++++- mac/argument_set.cpp | 13 +++++- mac/check.cpp | 66 ++++++++++++++++++++++++++++ mac/file.cpp | 7 +++ mac/file.h | 1 + mac/katom.cpp | 12 +++++ mac/katom.h | 4 ++ mac/util.cpp | 23 ++++++++++ mac/util.h | 11 +++++ ref/ref.k | 6 +++ ref/reflect.cpp | 59 ++++++++++++++++++++++--- sks/block/block.k | 23 +++++----- sks/block/block.py | 87 ++++++++++++++++++++++++++++++------ sks/code/code.k | 2 +- sks/code/code_block.py | 12 +++++ sks/image/image.k | 2 +- sks/image/image.py | 10 +++-- sks/kutil/kutil.k | 43 +++++++++++++++--- sks/kutil/kutil.py | 53 ++++++++++++++++++++-- sks/list/list.k | 22 +++++++--- sks/list/list.py | 90 +++++++++++++++++++++++++------------- sks/list/sty/list.sty | 2 +- sks/section/section.k | 35 +++++++-------- sks/section/section.py | 5 +-- sks/table/table.k | 7 +++ sks/table/table.py | 83 +++++++++++++++++++++++++---------- sks/target/html_util.py | 4 +- sks/target/latex_util.py | 6 +-- sks/target/phases.py | 25 ++++++++++- sks/target/target.k | 4 +- tst/Makefile | 1 + tst/argtype_test.sh | 71 ++++++++++++++++++++++++++++++ tst/check_test.sh | 22 ++++++++++ tst/modulepath_test.sh | 6 ++- 39 files changed, 734 insertions(+), 158 deletions(-) create mode 100755 tst/argtype_test.sh diff --git a/README.md b/README.md index 08a917c..75512c0 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ are regenerated on each release — patches cannot be merged directly. Report problems (or send patches) to the author; accepted changes are applied to the development tree and appear in a following snapshot. -This snapshot was assembled from development commit `fcec5e2e77ac`. +This snapshot was assembled from development commit `d6993110e9b3`. ## License diff --git a/com/kdesc.cpp b/com/kdesc.cpp index b890fa3..5ad5fda 100644 --- a/com/kdesc.cpp +++ b/com/kdesc.cpp @@ -177,6 +177,14 @@ void show_unicode_examples() std::cout << "\n" << M.apply(text_target()) << "\n\n"; } +void show_argtype_definitions() +{ + Machine M; + M.read(M.m_state.value("KLAMMERTEXT_HOME") + "/ref/ref.k"); + M.read_string("@argtype_definitions@"); + std::cout << "\n" << M.apply(text_target()) << "\n\n"; +} + int main(int argc, char* argv[]) { try { @@ -224,10 +232,9 @@ int main(int argc, char* argv[]) show_unicode_examples(); } + if (p("argtypes")) { - Argtype_registry argtypes; - std::cout << boldblack << "\nStandard klammer argument types\n" << black; - std::cout << argtypes.describe() << "\n"; + show_argtype_definitions(); } if (args.given("katoms")) { diff --git a/com/ktext.cpp b/com/ktext.cpp index f0d7de8..1e43edf 100644 --- a/com/ktext.cpp +++ b/com/ktext.cpp @@ -70,7 +70,12 @@ int main(int argc, char* argv[]) M.read(fs::path(p)); } - std::string result = trim(M.apply(target)); + // Trim NEWLINES only, as Machine::apply itself does: a leading space + // is content under txt -- the indent of a block that opens the + // document (a table, a definition list) -- and trim() took it off + // the first line alone, every other line keeping its indent + // (TODO #60, 2026-09-13). + std::string result = trim_char(M.apply(target), '\n'); if (display_only && !result.empty()) { std::cout << result << "\n"; } else if (!result.empty()) { diff --git a/doc/klammertext_for_llm.md b/doc/klammertext_for_llm.md index f4ebcd6..cbc59b1 100644 --- a/doc/klammertext_for_llm.md +++ b/doc/klammertext_for_llm.md @@ -51,10 +51,12 @@ shows as `name.type default`, an option as `:name.type default`. kdiag --process --check doc.kt ``` -reports every klammer application whose name, argument count, option names, or -target coverage is wrong — including inside `@cond` branches this render will -not take and inside klammer bodies never applied. It applies nothing. Use it -before claiming a document is correct. +reports every klammer application whose name, argument count, option names, +target coverage, or **literal argument value** is wrong (a value that fails +its argument type, such as `:width .5h`) — including inside `@cond` branches +this render will not take and inside klammer bodies never applied. A value +holding a nested klammer or a `*variable*` is computed at run time and is not +judged. It applies nothing. Use it before claiming a document is correct. **Inspect how text is parsed:** `kdiag --type ''` shows the katoms and their types; `kdiag --args ''` shows how text splits into arguments; @@ -227,8 +229,8 @@ which reads `kt/preface.kt`, `kt/one.kt`, `kt/two.kt` beside the root file. ktext doc.kt -t pdf # also: -t html, -t tex ktext doc.kt -t html # writes a DIRECTORY doc/ containing index.html (+css, fonts, js) ktext doc.kt -t txt -d # -d displays on stdout instead of writing -- but this - # example errors under txt/ansi: @c and @image have no - # txt definition (§5), and coverage is never silently degraded + # example errors under txt/ansi: @image has no txt + # definition (§5), and coverage is never silently degraded ktext -s '@i-x' -t html -d # -s takes a string; no file needed ktext doc.kt -s '@@version : 3.2.1 @@' -t pdf # -s is processed BEFORE the files: # define a klammer the document uses @@ -274,11 +276,12 @@ Bar depth is the dimension: one `|` separates items, two `||` separates rows. Argument order is free for options, fixed for positionals. **Coverage today** (`kdesc --coverage`; 89 klammers): html, tex and pdf cover -nearly all. **txt and ansi cover 37**, among them `@b @i @t @u @s @r @ri @ts +nearly all. **txt and ansi cover 40**, among them `@b @i @t @u @s @r @ri @ts @typeface`, `@s1`–`@s7`, `@par @newpage @vspace @vfill @nl`, `@indent @right -@lines @qa`, `@ul @ol`, `@table @tbl @reference`, `@footnote @link`, `@date -@datetime`. Not for txt: `@c @code @source_listing @image @image_grid @quote -@note @center @block @columns @multicolumn @define @sup @sub @color @h1`–`@h7`. +@lines @qa`, `@ul @ol`, `@table @tbl @reference @define @left_right`, +`@footnote @link`, `@date @datetime`, `@c`. Not for txt: `@code +@source_listing @image @image_grid @quote @note @center @block @columns +@multicolumn @sup @sub @color @h1`–`@h7`. `@u` has no html definition. A klammer applied under a target that lacks it is an **error**, never silently dropped. @@ -328,7 +331,7 @@ the range (here: one sum per column). | `html` | a directory `/` with `index.html`, `css/`, `fonts/`, `js/` | self-contained, fonts localized; `book` structure adds navigation, search | | `tex` | `.tex` | XeLaTeX source; fonts via fontspec from the font store | | `pdf` | `.pdf` | `tex` plus a xelatex run (TeX Live required) | -| `txt` | `.txt` | plain text filled to `Target_txt_width` (72), blocks indented by `Target_txt_indent` (3); tables ruled and padded; captions numbered; **37 of 89 klammers** (§5) | +| `txt` | `.txt` | plain text filled to `Target_txt_width` (72), blocks indented by `Target_txt_indent` (3); tables ruled and padded; captions numbered; **40 of 89 klammers** (§5) | | `ansi` | txt + SGR escape sequences | `@b`/`@i`/`@u` and table header styles shown by a terminal; **identical to txt once the escape sequences are stripped**; pick it with `-t ansi -d`; a terminal needs a font with an italic face (macOS Terminal: Menlo, not Monaco) | `k`, `o`, `*` also appear in `kdesc -t`: they are declaration pseudo-targets @@ -368,7 +371,9 @@ ones. CJK is deliberately not shipped. - Separators: `:` create, `::` create inheriting parameters, `:::` override an existing definition, `::::` define a default a later `:` may replace. - Argument types: `name.type` — `string` (default), `int`, `float`, `bool`, - `list`, `length`, `literal` (raw content, named close required), `rest` + `list`, `width`/`height` (a bare number = characters / lines in every + target; units `em pt px`, `w pw` for widths, `h ph` for heights; the axes + are checked), `literal` (raw content, named close required), `rest` (everything left, bars included; `rest(2)` = list of lists), and any `@@@argtype` the klammerset declares (`kdesc --argtypes`). An argtype may carry `:default` and `:alone`; a klammer may override the default, never the @@ -451,7 +456,7 @@ ones. CJK is deliberately not shipped. `-c`/`-u` render through `ansi` on a terminal and `txt` in a pipe. **When something is wrong:** `kdiag --process --check doc.kt` first (name, -arity, option and coverage faults without rendering); then `kdesc -k ` +arity, option, literal-value and coverage faults without rendering); then `kdesc -k ` against what was written; then `kdiag --type ''` if the question is how something is parsed; then `ktext … -v 1` if the question is which file or klammerset was used. diff --git a/mac/argtype_registry.cpp b/mac/argtype_registry.cpp index 5720b82..94d1386 100644 --- a/mac/argtype_registry.cpp +++ b/mac/argtype_registry.cpp @@ -13,7 +13,10 @@ Parameter_set& Argtype_registry::parameters() { - static Parameter_set instance("name | desc :pattern .* :python_cast str :default :alone"); + // desc.klammertext: the description is Klammertext, kept exactly as + // written (Argtype::m_desc) for a Machine to render -- kdesc's listing, + // the Handbook. Plain-text displays (an argument error) unquote it. + static Parameter_set instance("name | desc.klammertext :pattern .* :python_cast str :default :alone"); return instance; } @@ -205,6 +208,8 @@ std::string Argtype_registry::describe(bool long_form, int indent_width) const int pat_width = name_width + 2 + indent_width + label.size(); result << indent << std::setw(name_width) << name << sp_arrow; if (long_form) { + // The stored text, as written: this is the programmer's dump + // (kdiag --machine), and a quoted "^@" in it is the fact. result << m_types.at(name).m_desc << "\n"; result << std::setw(pat_width) << "regex: " << m_types.at(name).m_symbolic_pattern; if (m_types.at(name).m_symbolic_pattern != m_types.at(name).m_pattern) diff --git a/mac/argtype_registry.h b/mac/argtype_registry.h index 777f28e..e46ac65 100644 --- a/mac/argtype_registry.h +++ b/mac/argtype_registry.h @@ -27,6 +27,7 @@ public: Argtype get(const std::string& name, const Locator& loc) const; std::string eval(const std::string& value, const std::string& type_name, const Locator& loc); + std::string describe(bool long_form=false, int indent_width=4) const; inline bool is_defined(const std::string& name) const { @@ -51,7 +52,7 @@ std::vector #include "check.h" +#include "error.h" #include "machine.h" #include "katom.h" #include "katom_process.h" @@ -23,12 +24,32 @@ namespace { // check time nothing has been reduced, so the nesting is still present and // must be tracked. (The depth-0 rule is the same one cond_separator_bars() // applies for @cond; see doc/cond_evaluation_order.md.) +// A value as written, and whether it is LITERAL: made of nothing but text +// and whitespace at depth 0, so that what the writer typed is what the +// argument type will see. A value holding a nested klammer, a *variable*, +// a quoted special or a caret form is computed at run time and cannot be +// judged here -- the static-structure rule: check what is fixed, skip what +// is computed. +struct Argument_value +{ + std::string m_text {}; + bool m_literal { true }; +}; + struct Application_shape { int m_positional { 0 }; std::vector m_options {}; + std::vector m_positional_values {}; + std::vector> m_option_values {}; }; +bool is_literal_katom(const Katom& k) +{ + return k.m_type == katom_t::text || k.m_type == katom_t::word + || k.m_type == katom_t::space || k.m_type == katom_t::newline; +} + bool is_boundary_katom(const Katom& k) { return k.m_type == katom_t::bar || k.m_type == katom_t::option_name; @@ -49,14 +70,36 @@ Application_shape application_shape(katom_list::const_iterator begin, katom_list bool in_positional = first->m_type != katom_t::option_name; if (in_positional) shape.m_positional = 1; + // The value being accumulated: a positional part, or the option named + // last. Closed at each depth-0 boundary and at the end. + Argument_value current {}; + bool current_is_option = !in_positional; + std::string current_option {}; + auto close = [&]() { + current.m_text = trim(current.m_text); + if (current_is_option) { + shape.m_option_values.emplace_back(current_option, current); + } else { + shape.m_positional_values.push_back(current); + } + current = Argument_value {}; + }; + int depth = 0; for (auto k = first; k != end; ++k) { if (depth == 0 && is_boundary_katom(*k)) { + close(); if (k->m_type == katom_t::bar) { ++shape.m_positional; + current_is_option = false; } else { shape.m_options.push_back(k->m_text.substr(1)); + current_is_option = true; + current_option = k->m_text.substr(1); } + } else { + if (depth > 0 || !is_literal_katom(*k)) current.m_literal = false; + current.m_text += k->m_text; } if (level_increase(*k)) { ++depth; @@ -64,6 +107,7 @@ Application_shape application_shape(katom_list::const_iterator begin, katom_list --depth; } } + close(); return shape; } @@ -159,6 +203,28 @@ void Checker::check_application( error(ss.str(), context, begin->m_loc); } + // Argument VALUES against their argument types -- the check that finds + // a height unit on a width, a word where a number is wanted -- for every + // value that is literal (see Argument_value). The same test the engine + // applies when the klammer is applied, Parameter_set::validate(), so the + // two can never disagree; its exception becomes a diagnostic here. + auto validate = [&](const Parameter& parameter, const Argument_value& value) { + if (!value.m_literal || value.m_text.empty()) return; + try { + Parameter_set::validate(parameter, value.m_text, begin->m_loc); + } catch (const Argument_error& e) { + error("@" + name + ": " + collapse_whitespace(e.m_desc), context, begin->m_loc); + } + }; + for (size_t i = 0; i < shape.m_positional_values.size() && i < parameters.m_positional.size(); i++) { + validate(parameters.m_positional[i], shape.m_positional_values[i]); + } + for (const auto& [option, value] : shape.m_option_values) { + if (const Parameter* parameter = parameters.find(option)) { + validate(*parameter, value); + } + } + std::vector seen {}; for (const auto& option : shape.m_options) { if (std::ranges::count(parameters.m_optional_names, option) == 0) { diff --git a/mac/file.cpp b/mac/file.cpp index 53912fc..8a16bc5 100644 --- a/mac/file.cpp +++ b/mac/file.cpp @@ -454,6 +454,13 @@ strings_t get_sks_directories(const std::string& s, bool include_argument) return result; } +std::string symbolic_klammertext_filename(std::string filename) +{ + return string_replace( + filename, get_env_var("KLAMMERTEXT_HOME"), "$KLAMMERTEXT_HOME"); +} + + std::string cache_directory(const std::string& subdirectory, std::string parent_directory) { if (parent_directory.empty()) { diff --git a/mac/file.h b/mac/file.h index a6a72b6..d7211d7 100644 --- a/mac/file.h +++ b/mac/file.h @@ -55,6 +55,7 @@ fs::path klammertext_filename( const std::string& basename, bool error_if_missing=true, bool make_directory_if_missing=false); std::vector sks_dirs(); std::vector get_sks_directories(const std::string& s, bool include_argument=true); +std::string symbolic_klammertext_filename(std::string filename); std::string cache_directory(const std::string& subdirectory, std::string parent_directory=""); std::time_t to_time_t(const fs::file_time_type& ftime); bool in_modification_order(std::string filename1, std::string filename2); diff --git a/mac/katom.cpp b/mac/katom.cpp index 8bf3753..84fdbcc 100644 --- a/mac/katom.cpp +++ b/mac/katom.cpp @@ -140,6 +140,18 @@ std::string as_string(const katom_list& ks, bool strip_whitespace) return as_string(ks.begin(), ks.end(), strip_whitespace); } +std::string as_source(std::vector::const_iterator begin, std::vector::const_iterator end, bool strip_whitespace) +{ + std::string result {}; + for (auto ki = begin; ki < end; ki++) { + if (ki->m_type != katom_t::replaced && ki->m_type != katom_t::ignored) + result += ki->m_src; + } + if (strip_whitespace) + result = trim(result); + return result; +} + katom_list trim(katom_list& katoms, std::set trim_types) { katom_iter begin = diff --git a/mac/katom.h b/mac/katom.h index d057e84..4153b94 100644 --- a/mac/katom.h +++ b/mac/katom.h @@ -97,6 +97,10 @@ std::vector text_katoms( std::string as_string(std::vector::const_iterator begin, std::vector::const_iterator end, bool strip_whitespace); std::string as_string(const std::vector& katoms, bool strip_whitespace); +// The text of a katom range EXACTLY AS WRITTEN (Katom::m_src): a quoted +// special keeps its caret, so the result is Klammertext source that can be +// re-read. What a literal parameter of a definition holds. +std::string as_source(std::vector::const_iterator begin, std::vector::const_iterator end, bool strip_whitespace); std::vector trim(std::vector& katoms, std::set trim_types = {katom_t::space, katom_t::newline}); diff --git a/mac/util.cpp b/mac/util.cpp index d728fa2..5b47c85 100644 --- a/mac/util.cpp +++ b/mac/util.cpp @@ -134,6 +134,29 @@ strings_t word_split(const std::string& s) return regex_split(s, std::regex("\\s+")); } +std::string capitalize(std::string s) +{ + // The cast through unsigned char is what makes std::toupper safe on a + // byte above 127 (negative as a char, and undefined behaviour to pass). + if (!s.empty()) + s[0] = static_cast(std::toupper(static_cast(s[0]))); + return s; +} + +std::string unquote_punctuation(const std::string& s) +{ + std::string result {}; + result.reserve(s.size()); + for (size_t i = 0; i < s.size(); i++) { + if (s[i] == '^' && i + 1 < s.size() && std::ispunct(static_cast(s[i + 1]))) { + result += s[++i]; + } else { + result += s[i]; + } + } + return result; +} + std::string collapse_whitespace(const std::string& s) { std::string result {}; diff --git a/mac/util.h b/mac/util.h index e65a542..de973da 100644 --- a/mac/util.h +++ b/mac/util.h @@ -46,6 +46,17 @@ std::vector word_split(const std::string& s); // whose description happens to fit one line and miss the identically worded // one that does not. std::string collapse_whitespace(const std::string& s); +// Klammertext source shown as PLAIN text: "^" before an ASCII punctuation +// character is removed ("^@date" -> "@date"), nothing else changes. For an +// error message or a log line that quotes a literal description; a display +// that can render should hand the source to a Machine instead. +std::string unquote_punctuation(const std::string& s); + +// The string with its first character in upper case ("table" -> "Table"). +// ASCII only: a non-ASCII first byte is left as it is, since a std::string +// is bytes and the standard library has no Unicode case mapping. Unlike +// Python's str.capitalize(), the rest of the string is untouched. +std::string capitalize(std::string s); // Case-insensitive substring test, ASCII folding only. The project takes no // ICU or locale dependency (the same rule as the language tables and diff --git a/ref/ref.k b/ref/ref.k index ded1ee7..30a8ba5 100644 --- a/ref/ref.k +++ b/ref/ref.k @@ -30,3 +30,9 @@ @@unicode_examples.* :: @eval :cpp *KLAMMERTEXT_HOME*/ref/reflect unicode_examples @ @@ + +@@argtype_definitions.k : + Show the defined klammer argument types @@ + +@@argtype_definitions.* :: + @eval :cpp *KLAMMERTEXT_HOME*/ref/reflect argtype_definitions @ @@ diff --git a/ref/reflect.cpp b/ref/reflect.cpp index 580fae4..3fb2aa7 100644 --- a/ref/reflect.cpp +++ b/ref/reflect.cpp @@ -1,3 +1,5 @@ +#include + #include "machine.h" #include "character.h" #include "error.h" @@ -53,7 +55,6 @@ std::string extended_latin_examples([[maybe_unused]] Machine& machine) return kt.str(); } - extern "C" std::string pinyin_examples([[maybe_unused]] Machine& machine) { @@ -74,7 +75,7 @@ extern "C" std::string unicode_examples([[maybe_unused]] Machine& machine) { std::stringstream kt {}; - kt << "\n@b Including Unicode in Klammertext files @\n\n" + kt << "\n@s2 Including Unicode in Klammertext files @\n\n" << "To specify a wide variety of different letter forms, the Unicode Standard " << "defines a large set of \"code points\" organized by groups. To include a " << "Unicode character in Klammertext, you surround its hexadecimal number with " @@ -83,8 +84,8 @@ std::string unicode_examples([[maybe_unused]] Machine& machine) << "To search for Unicode characters by their description, see: @nl@\n" << "@link https://www.fileformat.info/info/unicode/char/search.htm @\n\n"; - kt << "@table :caption Examples of Chinese characters :cell_hpos c |\n" - << " Character | Unicode | Pinyin ||\n"; + kt << "@table :caption Examples of Chinese characters :cell_hpos c c l |\n" + << " Character | Written | Pinyin ||\n"; for (auto chr : dao) { std::string code = "^" + chr[1] + "^"; @@ -96,7 +97,7 @@ std::string unicode_examples([[maybe_unused]] Machine& machine) kt << "@table :caption Examples of Egyptian hieroglyphs :cell_hpos c c c l |\n" - << "Hieroglyph | Unicode | Sound | Gardiner ||\n"; + << "Hieroglyph | Written | Sound | Gardiner ||\n"; // The body font carries no hieroglyphs, so the glyph names the // typeface it needs (shipped in fnt/); @document declares and localizes @@ -115,3 +116,51 @@ std::string unicode_examples([[maybe_unused]] Machine& machine) return kt.str(); } + +extern "C" +std::string argtype_definitions(Machine& machine) +{ + auto argtypes = machine.m_argtypes; + std::stringstream kt {}; + std::set custom {}; + + std::map> custom_types {}; + + kt << "@s2 Types of klammer arguments @\n\n" + << "@define :style r | \n"; + // Hack to make the "Pattern:" label line up vertically; + // there should be a klammer that aligns text at "tab stops". + auto name_width = std::string("klammertext").size() + 1; + for (const auto& name : argtypes.m_names) { + auto argtype = argtypes.m_types.at(name); + auto filename = argtype.m_loc.m_filename; + if (contains(filename, "argtype_registry")) { + std::string fill(name_width - name.size(), '~'); + kt << "\n@c " << name << " c@ @t " << fill << " t@ @i Pattern: i@ @c " + << argtype.m_symbolic_pattern << " c@ |\n" + << capitalize(trim(argtype.m_desc)) << " ||\n"; + } else { + custom.insert(filename); + custom_types[filename].push_back(name); + } + } + kt << "\ndefine@\n\n" + << "@vspace .5 @\n@s3 Reusing argtype symbols @\n\n" + << "A previously defined type can be included in the definition of a new type " + << "by surrounding the name of the existing type in single quotation marks. " + << "For example, the @c uint c@ type (unsigned integer) is used to define an integer with a " + << "`+' or `-' character before it.\n\n" + << "@vspace .5 @\n@s3 Custom argument types @\n\n" + << "Other argtypes are defined for special purposes for klammers defined in the following files:\n\n"; + + kt << "@define :style t |\n"; + for (auto [filename, types] : custom_types) { + auto type_count = types.size(); + int column_count = type_count < 4 ? 1 : int(float(type_count)/3); + // auto count = int(float(types.size()) / 3); + kt << symbolic_klammertext_filename(filename) << " |\n" + << "@columns :style c :n " << column_count << " | " << join(types, " ") << " @ ||\n\n"; + } + kt << "define@\n\n"; + return kt.str(); +} diff --git a/sks/block/block.k b/sks/block/block.k index 6c75950..5af754d 100644 --- a/sks/block/block.k +++ b/sks/block/block.k @@ -31,10 +31,10 @@ vertical mode is a no-op. # Indented block -@@indent.k s :left.int 8 :right.int -1 :ragged.bool false : -Indented block. The *left* and *right* values are the number of characters for -indentation (as roughtly defined by "ex"). By default, *right* is set to the -*left* value (with -1 as the sentinel). +@@indent.k s :left.width 8 :right.width :ragged.bool false : +Indented block. *left* and *right* are widths: a bare number is that many +characters (the width of a digit in a proportional typeface), or a width +with a unit such as 2em. *right* defaults to the *left* value. @@ @@indent.html,tex,txt :: @eval block.Indent(K) eval@ @@ @@ -124,13 +124,12 @@ indentation (as roughtly defined by "ex"). By default, *right* is set to the @@extendpage.tex :: \enlargethispage{*linecount*\baselineskip} @@ @@extendpage.txt :: @@ - # Vertical space -@@vspace.k lines.float : Vertical space, in multiples of the current line height @@ -@@vspace.tex :: \vspace{*lines*\baselineskip} @@ -@@vspace.html ::
@@ -@@vspace.txt :: @eval phases.txt_vspace_marker() * round(*lines*) @ @@ +# The first parameter of the height type: a bare number is lines in every +# target (as it always was here), and a unit may now be given. +@@vspace.k lines.height : Vertical space: a number of lines, or a height such as 2em @@ +@@vspace.html,tex,txt :: @eval block.Vspace(K) @ @@ # Vertical fill to end of page @@ -146,6 +145,10 @@ only makes some vertical space. @@ @@vfill.html ::
@@ @@vfill.txt :: @vspace 3 @ @@ +# Horizontal space +@@hspace.k width.width : Horizontal space of the given *width* (a bare number is characters) @@ +@@hspace.html,tex,txt :: @eval block.Hspace(K) @ @@ + # Questions and answers @@ -202,7 +205,7 @@ bar (|) between each line. Then, two bars (||), and the right lines with a bar betwen them. @@ -@@left_right.html,tex :: @eval block.Left_right(K) @ @@ +@@left_right.html,tex,txt :: @eval block.Left_right(K) @ @@ # Footnote diff --git a/sks/block/block.py b/sks/block/block.py index 02567f4..a0d25cd 100644 --- a/sks/block/block.py +++ b/sks/block/block.py @@ -11,35 +11,56 @@ from html_util import E import color class Indent(klammer_base.Klammer_base): + """An indented block (@indent). :left and :right are widths: a bare + number is characters in every target (2026-09-16); :right defaults to + :left.""" def __init__(self, K): super().__init__(K) - # Doesn't do anything now: - # self.s = kutil.escape(self.s) - if self.right < 0: + if not self.right: self.right = self.left - self.reduce = self.left + self.right def html(self): text_align = "justify" if not self.ragged else "left" + left = kutil.parse_width("html", self.left)[0] + right = kutil.parse_width("html", self.right)[0] return E("div").body(self.s)\ - .sty("margin", f"0.5rem {self.right}ex 0.5rem {self.left}ex")\ + .sty("margin", f"0.5rem {right} 0.5rem {left}")\ .sty("text-align", text_align)\ .str() def tex(self): if self.ragged: self.s = "\\raggedright " + self.s - result = rf"\hspace*{{{self.left}ex}}" + latex_util.minipage( - self.s, rf"\linewidth - {self.reduce}ex", center=False) + left = kutil.parse_width("tex", self.left)[0] + right = kutil.parse_width("tex", self.right)[0] + result = rf"\hspace*{{{left}}}" + latex_util.minipage( + self.s, rf"\linewidth - {left} - {right}", center=False) result = latex_util.block(result) return result def txt(self): - indent = phases.txt_nbsp_marker() * self.left # a space that survives - width = int(self.Target_txt_width) - self.left - self.right + 1 # Off-by one for textwrap - result = self.s - result = re.sub("KK0022", '"', result) - result = indent + f"\n{indent}".join(textwrap.wrap(result, width=width, break_on_hyphens=True)) - return result + left = int(kutil.parse_width("txt", self.left)[0]) + right = int(kutil.parse_width("txt", self.right)[0]) + indent = phases.txt_nbsp_marker() * left # a space that survives + width = int(self.Target_txt_width) - left - right + result = re.sub("KK0022", '"', self.s) + return phases.txt_lay_out(indent + f"\n{indent}".join(phases.txt_wrap(result, width))) + +class Hspace(klammer_base.Klammer_base): + """Horizontal space (@hspace): a width, so a bare number is characters + in every target.""" + def __init__(self, K): + super().__init__(K) + + def html(self): + return f'' + + def tex(self): + return f"\\hspace{{{kutil.parse_width('tex', self.width)[0]}}}" + + def txt(self): + # The nbsp marker, not spaces: an @eval result of only whitespace + # is trimmed away entirely. + return phases.txt_nbsp_marker() * int(kutil.parse_width("txt", self.width)[0]) class Note(klammer_base.Klammer_base): @@ -106,6 +127,26 @@ class Lines(klammer_base.Klammer_base): def txt(self): return self.lines +class Vspace(klammer_base.Klammer_base): + """Vertical space (@vspace): the first parameter of the height type. A + bare number is lines in every target, as it always was here; a unit + (2em, 12pt) is now accepted as well, under html and tex.""" + def __init__(self, K): + super().__init__(K) + + def html(self): + height = kutil.parse_height("html", self.lines)[0] + return f'
' + + def tex(self): + return f"\\vspace{{{kutil.parse_height('tex', self.lines)[0]}}}" + + def txt(self): + # One marker per line; the justification phase turns them into + # blank lines after paragraph normalization (a whitespace-only + # @eval result would be trimmed away, hence the markers). + return phases.txt_vspace_marker() * int(kutil.parse_height("txt", self.lines)[0]) + class Left_right(klammer_base.Klammer_base): def __init__(self, K): super().__init__(K) @@ -126,3 +167,23 @@ class Left_right(klammer_base.Klammer_base): result += rf"\parbox{{\linewidth}}{{{left} \hfill {right}}}" + " \\\\\n" result = result[:-3] return result + + def txt(self): + # Plain text: the left text at the block indent, the right text ending + # at Target_txt_width, the gap filled with the nbsp marker (the only + # space that survives to the output, and what marks the block as laid + # out). A pair that cannot share a line -- less than two columns + # between them -- goes on two lines, the right text still flush right. + nbsp = phases.txt_nbsp_marker() + indent = int(self.Target_txt_indent) + width = int(self.Target_txt_width) + lines = [] + for left, right in self.rows: + left, right = left.strip(), right.strip() + gap = width - indent - phases.txt_visible_length(left) - phases.txt_visible_length(right) + if gap >= 2: + lines.append(nbsp * indent + left + nbsp * gap + right) + else: + lines.append(nbsp * indent + left) + lines.append(nbsp * max(indent, width - phases.txt_visible_length(right)) + right) + return phases.txt_lay_out("\n".join(lines)) diff --git a/sks/code/code.k b/sks/code/code.k index 17565e6..48ac665 100644 --- a/sks/code/code.k +++ b/sks/code/code.k @@ -24,7 +24,7 @@ string, so a "^#" or a bare "^@" inside it was read as Klammertext and usually failed the file, which is not what "verbatim" can mean. @@ -@@c.html,tex :: @eval code_block.Code_fragment(K) eval@ +@@c.html,tex,txt :: @eval code_block.Code_fragment(K) eval@ @@ # :cwd makes the filename resolve against the DOCUMENT's directory, not diff --git a/sks/code/code_block.py b/sks/code/code_block.py index d55bd6d..0d93572 100644 --- a/sks/code/code_block.py +++ b/sks/code/code_block.py @@ -349,6 +349,18 @@ class Code_fragment(klammer_base.Klammer_base): def tex(self): return f"{{\\tt {tex_line(self.code_text.strip())}}}" + def txt(self): + # Plain text has no face to switch to: the fragment is its own text. + # As in html_line/tex_line, the Klammertext specials are quoted for + # the @eval read-back (a bare "@" in the result would be a klammer + # without a beginning) and the typographically active characters + # hidden, so a "--" in code stays two hyphens. + result = klammer_base.hide_typographic(quote_specials(self.code_text.strip())) + # Surround code symbols with ‹ and ›, though there are conflicts in Swiss + # German, Italian and some Scandinavian languages. Should be clear in + # context, though. + result = "\u2039" + result + "\u203A" + return result def extract_marked_region(src, marker, filename): """The region of `src` between two lines that are exactly "//". diff --git a/sks/image/image.k b/sks/image/image.k index 595e209..7a0c886 100644 --- a/sks/image/image.k +++ b/sks/image/image.k @@ -34,7 +34,7 @@ @@image.k basename :id - :width.length .5w + :width.width .5w @caption_args@ :vmargin.bool true @hpos_args@ diff --git a/sks/image/image.py b/sks/image/image.py index 11a9f3c..5a1d7b7 100644 --- a/sks/image/image.py +++ b/sks/image/image.py @@ -76,12 +76,16 @@ class Image(klammer_base.Klammer_base): # kutil.msg("rel: " + str(self.rel_fraction)) - length, value, ltype = kutil.parse_length("html", self.width, self.rel_fraction) + length, value, ltype = kutil.parse_width("html", self.width, self.rel_fraction) if ltype == "w": percent = float(value) * 100 result.sty(f"width: {percent}%") - else: + elif ltype == "px": + # The html width ATTRIBUTE takes pixels only; every other unit + # is a style (an attribute of "2em" was silently ignored). result.attr("width", length) + else: + result.sty(f"width: {length}") if self.border: result.cls("image_border") @@ -124,7 +128,7 @@ class Image(klammer_base.Klammer_base): #if self.caption or self.number: # width = "\\textwidth" #else: - width = kutil.parse_length("tex", self.width, self.rel_fraction)[0] + width = kutil.parse_width("tex", self.width, self.rel_fraction)[0] width = re.sub("px", "pt", width) #result = f'\\includegraphics[width={width}]{{{source}}}' if self.number or self.caption: diff --git a/sks/kutil/kutil.k b/sks/kutil/kutil.k index 596e519..78e609f 100644 --- a/sks/kutil/kutil.k +++ b/sks/kutil/kutil.k @@ -14,6 +14,34 @@ # :python_cast (lambda s : __import__("kutil").parse_length("tex", s)) @@@ +# Lengths on an axis (2026-09-16). A width and a height are different +# types so that a value can only name a unit of its own axis: "w" and "pw" +# belong to widths, "h" and "ph" to heights, and a mistake is an argument +# error rather than a silent misreading. A BARE NUMBER is the natural +# unit of the axis in every target -- characters for a width (a column in +# txt, "ch" in html, the width of a digit in tex), lines for a height (a +# newline in txt, "lh" in html, \baselineskip in tex) -- which is what lets +# a klammer written for the page keep its meaning in a terminal. "length" +# stays for the quantities with no axis (a rule thickness). +@@@argtype width | + a horizontal length. A bare number is that many characters (one column + of plain text; in a proportional typeface, the width of a digit, so an + indent or a column lines up across lines even where the letters do not). + Otherwise a number with a unit: ^:width 2em, 12pt, 100px, .5w (that + fraction of the text width), .5pw (of the page width); f or none for an + element's natural width; a quoted string, whose set width is used where + the target can measure it. + :pattern 'float'^|'float'em^|'int'pt^|'int'px^|'float'w^|'float'pw^|f^|none^|"[^^"]+" +@@@ + +@@@argtype height | + a vertical length. A bare number is that many lines (blank lines in + plain text; the line height of the surrounding text otherwise). + Otherwise a number with a unit: ^:height 2em, 12pt, 100px, .5h (that + fraction of the text height), .5ph (of the page height). + :pattern 'float'^|'float'em^|'int'pt^|'int'px^|'float'h^|'float'ph +@@@ + @@@argtype lengths | a list of lengths :pattern ('length'^|\s+)* #:python_cast (lambda s : [__import__("kutil").parse_length("tex", e) for e in s.split()]) @@ -37,15 +65,18 @@ An offset has no meaning for a centered element and is ignored there. Written alone, ^:offset is the standard indentation (e.g. ^:hpos left ^:offset), and a value overrides it (^:hpos right ^:offset 4em). - # A single-purpose type, not a use of "length", because the two values + # A single-purpose type, not a use of "width", because the two values # below are what the writer relies on and only a type can carry them: an # element is flush unless an offset is asked for (:default), and asking # without saying how much is the standard indentation (:alone). A general - # length type must not declare :alone -- a bare option name has to read the - # same way wherever it appears, and "2em" is meaningless for :width. - :pattern 'length' - :default 0pt - :alone 2em + # width type must not declare :alone -- a bare option name has to read the + # same way wherever it appears, and "4" is meaningless for :width. + # Both values are BARE numbers -- characters -- so that they mean the same + # thing under txt as on the page (2026-09-16): four characters is about + # the 2em the alone value used to be, and 0 is flush everywhere. + :pattern 'width' + :default 0 + :alone 4 @@@ diff --git a/sks/kutil/kutil.py b/sks/kutil/kutil.py index dc24ad5..5c7ca8d 100644 --- a/sks/kutil/kutil.py +++ b/sks/kutil/kutil.py @@ -112,10 +112,27 @@ def _rest_split_level(s, dimensions): def rest_args(s, dimensions=1): return rest_split(s, dimensions) -def parse_length(target, s, rel_fraction): - def choose(html_value, tex_value): +def parse_length(target, s, rel_fraction=1, axis="width"): + """A length argument as the target's own length. + + Returns (text, number, unit). `axis` is "width" or "height" and gives + a BARE number its meaning -- characters or lines, the natural unit of + the axis in every target (sks/kutil/kutil.k, the width and height + argtypes): under txt the integer count itself; under html "ch" and + "lh" (a consumer of "lh" writes the em fallback first, see + css_height); under tex the width of a digit in the current font + (\\fontcharwd) and \\baselineskip. `target` "txt" is accepted for the + bare units only, since plain text has no other. + """ + def choose(html_value, tex_value, txt_value=None): + if target == "txt" or target == "ansi": + if txt_value is None: + unit = "characters" if axis == "width" else "lines" + raise Exception(f'The {axis} "{s}" has no meaning in plain text: ' + f'give a bare number of {unit}') + return txt_value return html_value if target == "html" else tex_value - pat = re.compile(r'(?:([0-9.]+)([a-z]+))|(none|f)|(?:"([^"]+)")') + pat = re.compile(r'(?:([0-9.]+)([a-z]*))|(none|f)|(?:"([^"]+)")') #pat = re.compile(r'(?:([0-9.]+)([a-z]+))|(none|f)|(?:{([^}]+)})') @@ -138,6 +155,14 @@ def parse_length(target, s, rel_fraction): result = choose("", f"\\textwidth - \\widthof{{ {text[1:]}}}") else: result = choose("", f"\\widthof{{ {text}}}") + elif units == "": + # The natural unit of the axis. + if axis == "height": + result = choose(f"{num}lh", f"{num}\\baselineskip", str(round(num))) + units = "lines" + else: + result = choose(f"{num}ch", f"{num}\\fontcharwd\\font`0 ", str(round(num))) + units = "ch" elif units == "w": result = choose(f"{100 * num}vw", f"{num}\\textwidth") elif units == "h": @@ -149,11 +174,31 @@ def parse_length(target, s, rel_fraction): elif units == "px": result = choose(f"{round(num)}", f"{num}px") else: - result = f"{num}{units}" + # em, pt: the same text in html and tex; none in plain text. + result = choose(f"{num}{units}", f"{num}{units}") result = re.sub("\t", "\\t", result) return result, num, units +def parse_width(target, s, rel_fraction=1): + return parse_length(target, s, rel_fraction, "width") + +def parse_height(target, s, rel_fraction=1): + return parse_length(target, s, rel_fraction, "height") + +def css_height(prop, value): + """A CSS declaration for a height from parse_height's html text. The + "lh" unit is recent (2023 in every major browser); an older browser + ignores a declaration it cannot parse and keeps the previous one, so + the em equivalent is written FIRST and lh second -- the standard + fallback idiom, no detection needed.""" + if value.endswith("lh"): + # A short decimal, not the float's full expansion (1.5 * 1.2 is + # 1.7999999999999998 in binary). + em = f"{float(value[:-2]) * 1.2:.4g}" + return f"{prop}: {em}em; {prop}: {value}" + return f"{prop}: {value}" + def old_parse_length(target, s): pat = re.compile(r'(?:([0-9.]+)([a-z]+))|(none|f)|("([^"]+)")') match = pat.match(s) diff --git a/sks/list/list.k b/sks/list/list.k index 464f80f..863c6c3 100755 --- a/sks/list/list.k +++ b/sks/list/list.k @@ -3,14 +3,24 @@ :value 7 @@@ -@@ul.k list_items.rest :cmp.bool false :bullet : Unordered list, with list elements preceeded by the *bullet* character. @@ +@@ul.k list_items.rest :cmp.bool false :bullet : + Unordered list, with list elements preceeded by the *bullet* character. @@ + @@ul.html,tex,txt :: @eval list.List(K, "ul") eval@ @@ -@@ol.k list_items.rest :cmp.bool false :initial.int 1 : Ordered list, with numbered list elements. @@ +@@ol.k list_items.rest :cmp.bool false :initial.int 1 : + Ordered list, with numbered list elements. @@ + @@ol.html,tex,txt :: @eval list.List(K, "ol") eval@ @@ -@@define.k descriptions.rest(2) :style.style_code i : A list of words or phrases and their definitions or descriptions @@ -@@define.html,tex :: @eval list.Define(K) eval@ @@ +@@define.k descriptions.rest(2) :style.style_code i : + A list of words or phrases and their definitions or descriptions @@ -@@columns.k items :n.int 2 : Create two or more columns from the text @@ -@@columns.tex :: @eval list.columns(K) eval@ @@ +@@define.html,tex,txt :: @eval list.Define(K) eval@ @@ + +@@columns.k items :n.int 2 :style.style_code r @hpos_args :hpos left @ : + Create two or more columns from the text. Placed like a table: flush at + the left by default, ^:offset inset from that margin (a bare number is + characters). @@ + +@@columns.html,tex,txt :: @eval list.Columns(K) eval@ @@ diff --git a/sks/list/list.py b/sks/list/list.py index 52133f8..3d671c3 100644 --- a/sks/list/list.py +++ b/sks/list/list.py @@ -3,6 +3,7 @@ import re import textwrap import kutil import klammer_base +import phases import itertools import math from html_util import E @@ -81,17 +82,42 @@ class Define(klammer_base.Klammer_base): 'c' : r'\normalfont\ttfamily', 's' : r'\normalfont\sffamily'}[self.style] return f"{font} {s}" - # The "\hfill\newline" was to force the description to the next line even if the item was small: - # result = "\n\n".join([f"\\item[{{{fontify(term)}}}]\\hfill\n\\newline {definition}" - strut = r"\rule[-8pt]{0pt}{8pt}" + strut = r"\rule[-4pt]{0pt}{4pt}" result = "\n\n".join([f"\\item[{{{fontify(term)}{strut}}}] {definition}" for term,definition in self.items]) - result = f"\\begin{{description}}[labelindent=12pt,nosep,itemsep=6pt,parsep=.5\\parskip,style=nextline]\n{result}\n\\end{{description}}" + options = "style=nextline" # See list/list.sty + result = f"\\begin{{description}}[{options}]\n{result}\n\\end{{description}}" return result -# Needs to be implemented: -# def txt(self): -# return "TXT DEFINITIONS" + def txt(self): + # Plain text: each term on its own line at the block indent, its + # definition beneath it indented once more, items separated by a + # blank line. Every line begins with the nbsp marker, which is what + # tells the justification pass the block is already laid out + # (phases.txt_justify_blocks); a definition's own paragraphs are + # wrapped to the remaining measure, and a paragraph that is already + # laid out (it begins with the marker -- a @table inside the + # definition) is only indented further. The term takes :style as + # italic or bold, the styles a terminal can show. + nbsp = phases.txt_nbsp_marker() + indent = int(self.Target_txt_indent) + term_indent, def_indent = nbsp * indent, nbsp * (2 * indent) + measure = int(self.Target_txt_width) - 2 * indent + style = {"i": "italic", "b": "bold"}.get(self.style) + items = [] + for term, definition in self.items: + term = term.strip() + if style and term: + term = phases.txt_style_marker(style) + term + phases.txt_style_marker(style, False) + paragraphs = [] + for par in re.split(r"\n\s*\n", definition.strip()): + if phases.txt_is_laid_out(par): + paragraphs.append("\n".join(def_indent + line for line in par.split("\n"))) + else: + paragraphs.append("\n".join(def_indent + line + for line in phases.txt_wrap(par, measure))) + items.append(term_indent + term + "\n" + "\n\n".join(paragraphs)) + return phases.txt_lay_out("\n\n".join(items)) class Lines(klammer_base.Klammer_base): @@ -114,30 +140,32 @@ class Lines(klammer_base.Klammer_base): return self.s.strip() +class Columns(klammer_base.Klammer_base): + def __init__(self, K): + super().__init__(K) -def make_column_lists(lst, count): - cols = math.ceil(len(lst) / count) - result = [] - for i in range(count): - #print(i * cols, (i + 1) * cols) - result.append(lst[i * cols : (i+1) * cols]) - return list(itertools.zip_longest(*result, fillvalue="")) + def make_column_lists(self): + lst = [f"@{self.style} {e} {self.style}@" for e in self.items.split()] + cols = math.ceil(len(lst) / self.n) + result = [] + for i in range(self.n): + result.append(lst[i * cols : (i+1) * cols]) + return list(itertools.zip_longest(*result, fillvalue="")) -def xmake_column_lists(lst, column_count): - count = len(lst) - row_count = math.ceil(count / column_count) - print(count, column_count, row_count) - i = 0 - while i < count: - i += 1 + def columns(self): + rows = self.make_column_lists() + result = (f"@table :header false :number false :leading 1.0 " + f":hpos {self.hpos} :offset {self.offset} |\n") + for row in rows: + result += " | ".join(row) + " ||\n" + result += "table@\n" + return result + + def html(self): + return self.columns() -def columns(K): - rows = make_column_lists(K.items.split(), K.n) - result = "@table\n" # :lines false :header_line false :leading 1.1 :vmargin false |\n" - for row in rows: - #print("row:", row) - result += " | ".join(row) + " ||\n" - result += "table@\n" - if K.K_target == "latex": - result = f"\\vspace*{{-12pt}}\n{result}" - return result + def tex(self): + return self.columns() + + def txt(self): + return self.columns() diff --git a/sks/list/sty/list.sty b/sks/list/sty/list.sty index b6bfafc..7189c78 100644 --- a/sks/list/sty/list.sty +++ b/sks/list/sty/list.sty @@ -6,7 +6,7 @@ \setlistdepth{9} \renewlist{enumerate}{enumerate}{9} \renewlist{itemize}{itemize}{9} -\setlist{topsep=0pt, partopsep=0pt, parsep=2pt, itemsep=0pt} +\setlist{topsep=0pt, partopsep=0pt, parsep=.5\parskip, itemsep=0pt, labelindent=8pt, leftmargin=16pt} \setlist[enumerate]{label*=\arabic*.} \setlist[itemize,1]{label=\textbullet} \setlist[itemize,2]{label=\normalfont\bfseries\textendash} diff --git a/sks/section/section.k b/sks/section/section.k index 6d08845..9d6328d 100644 --- a/sks/section/section.k +++ b/sks/section/section.k @@ -2,56 +2,53 @@ # the front and back matter of a book): @@preface.k :id :name Preface : Preface text @@ -@@preface.html,tex :: @eval section.Section(K, "preface") eval@ @@ +@@preface.html,tex,txt :: @eval section.Section(K, "preface") eval@ @@ # Numbered section headings: @@part.k title :id :name Part : Part heading @@ -@@part.html,tex :: @eval section.Section(K, 0) eval@ @@ +@@part.html,tex,txt :: @eval section.Section(K, 0) eval@ @@ @@s1.k title :id :n : Top level division, numbered @@ -@@s1.html,tex :: @eval section.Section(K, 1) eval@ @@ - -# Temporary hack for testing txt target; need to number section headings. -@@s1.txt :: *title* @@ +@@s1.html,tex,txt :: @eval section.Section(K, 1) eval@ @@ @@s2.k title :id :n : Second level division, numbered @@ -@@s2.html,tex :: @eval section.Section(K, 2) eval@ @@ +@@s2.html,tex,txt :: @eval section.Section(K, 2) eval@ @@ @@s3.k title :id :n : Third level division, numbered @@ -@@s3.html,tex :: @eval section.Section(K, 3) eval@ @@ +@@s3.html,tex,txt :: @eval section.Section(K, 3) eval@ @@ @@s4.k title :id :n : Fourth level division, numbered @@ -@@s4.html,tex :: @eval section.Section(K, 4) eval@ @@ +@@s4.html,tex,txt :: @eval section.Section(K, 4) eval@ @@ @@s5.k title :id :n : Fifth level division, numbered @@ -@@s5.html,tex :: @eval section.Section(K, 5) eval@ @@ +@@s5.html,tex,txt :: @eval section.Section(K, 5) eval@ @@ @@s6.k title :id :n : Sixth level division, numbered @@ -@@s6.html,tex :: @eval section.Section(K, 6) eval@ @@ +@@s6.html,tex,txt :: @eval section.Section(K, 6) eval@ @@ @@s7.k title :id :n : Seventh level division, numbered @@ -@@s7.html,tex :: @eval section.Section(K, 7) eval@ @@ +@@s7.html,tex,txt :: @eval section.Section(K, 7) eval@ @@ # Unnumbered section headings: @@h1.k title :id :n : Top level division, unnumbered @@ -@@h1.html,tex :: @eval section.Section(K, 1, numbered=False) eval@ @@ +@@h1.html,tex,txt :: @eval section.Section(K, 1, numbered=False) eval@ @@ @@h2.k title :id : Second level division, unnumbered @@ -@@h2.html,tex :: @eval section.Section(K, 2, numbered=False) eval@ @@ +@@h2.html,tex,txt :: @eval section.Section(K, 2, numbered=False) eval@ @@ @@h3.k title :id : Third level division, unnumbered @@ -@@h3.html,tex :: @eval section.Section(K, 3, numbered=False) eval@ @@ +@@h3.html,tex,txt :: @eval section.Section(K, 3, numbered=False) eval@ @@ @@h4.k title :id : Fourth level division, unnumbered @@ -@@h4.html,tex :: @eval section.Section(K, 4, numbered=False) eval@ @@ +@@h4.html,tex,txt :: @eval section.Section(K, 4, numbered=False) eval@ @@ @@h5.k title :id : Fifth level division, unnumbered @@ -@@h5.html,tex :: @eval section.Section(K, 5, numbered=False) eval@ @@ +@@h5.html,tex,txt :: @eval section.Section(K, 5, numbered=False) eval@ @@ @@h6.k title :id : Sixth level division, unnumbered @@ -@@h6.html,tex :: @eval section.Section(K, 6, numbered=False) eval@ @@ +@@h6.html,tex,txt :: @eval section.Section(K, 6, numbered=False) eval@ @@ @@h7.k title :id : Seventh level division, unnumbered @@ -@@h7.html,tex :: @eval section.Section(K, 7, numbered=False) eval@ @@ +@@h7.html,tex,txt :: @eval section.Section(K, 7, numbered=False) eval@ @@ diff --git a/sks/section/section.py b/sks/section/section.py index 8ae62e0..f637e6c 100644 --- a/sks/section/section.py +++ b/sks/section/section.py @@ -72,6 +72,5 @@ class Section(klammer_base.Klammer_base): result = f"%__sectionlink__{id}__{self.title}__" + "\n" + result return result -# Need to implement the procedural numbering for the txt target -# def txt(self): -# return self.title + def txt(self): + return f"@b {self.title} @" diff --git a/sks/table/table.k b/sks/table/table.k index fb3b53d..0258e92 100644 --- a/sks/table/table.k +++ b/sks/table/table.k @@ -232,3 +232,10 @@ column. @@tbl.k spec.figure_id : The caption title for a table @@ @@tbl.html,tex,txt :: @reference *spec* | Table @ @@ + +#[ +@@columns.k rows.rest : A minimal table without a caption, header, or lines @@ +@@columns.html,tex,txt :: + @table rows :number false :header false @ +@@ +]# diff --git a/sks/table/table.py b/sks/table/table.py index 033944b..d974525 100644 --- a/sks/table/table.py +++ b/sks/table/table.py @@ -978,32 +978,71 @@ class Table(klammer_base.Klammer_base): return (phases.txt_style_marker(STYLE[code]) + cell + phases.txt_style_marker(STYLE[code], False)) return cell - widths = self.txt_column_widths() + # A cell is one line of text: a newline written inside it is + # whitespace, as it is in html and tex. + rows = [[re.sub(r"\s*\n\s*", " ", cell) for cell in row] for row in self.rows] + widest = self.txt_column_widths() pos = [hpos(p) for p in self.cell_hpos] - result = "" - indent = txt_util.NBSP * int(self.Target_txt_indent) - colsep = " " - # Horizontal lines are the same rule as the other targets (:hline, - # defaulting to under the header row and at the bottom when the - # table has a header): a rule of hyphens the width of the table at - # each boundary that has a line. A line over part of the columns - # is drawn full width here -- a partial rule in a plain-text table - # reads as an accident. Vertical lines have no plain-text form. - # The hyphens are HIDDEN (KTESC markers): a klammer's result is - # re-read as Klammertext and the txt target's dash transforms turn - # "--" into "-", so a bare run of hyphens came out a fraction of - # its length. (The caption rule escapes this by being drawn in a - # phase, after the transforms.) - rule = (indent + klammer_base.hide_typographic( + # Placement under txt (2026-09-16): an explicit :hpos left takes its + # indent from :offset (characters; 0 = flush), :hpos none flows with + # no indent, and anything else -- including the default, center -- + # keeps the block convention, Target_txt_indent. (Centering and + # right-alignment are not attempted: a block that shifts with the + # width of its widest row reads as an accident in plain text.) + if self.hpos == "left": + indent = txt_util.NBSP * int(kutil.parse_width("txt", self.offset)[0]) + elif self.hpos == "none": + indent = "" + else: + indent = txt_util.NBSP * int(self.Target_txt_indent) + colsep = " " + # Column widths, by the same rules as the other targets + # (:column_width): 'fit' is the widest entry; a fraction is that part + # of the text width; '*' takes the remaining width; 'fill' takes the + # remaining width but no more than its widest entry -- and, unlike a + # 'fit' column, its cells WRAP to that width (2026-09-13; until then + # the txt table never wrapped a cell, so a description column ran off + # the page). Several fill/'*' columns share the remainder in + # proportion to their widest entries. + text_width = (int(self.Target_txt_width) - int(self.Target_txt_indent) + - len(colsep) * (self.row_size - 1)) + specs = extend(self.column_width, self.row_size) + widths = [0] * self.row_size + for i, w in enumerate(specs): + if w in ("fit", "f"): + widths[i] = widest[i] + elif w not in ("fill", "*"): + widths[i] = max(1, int(float(w) * text_width)) + flexible = [i for i, w in enumerate(specs) if w in ("fill", "*")] + if flexible: + remaining = max(len(flexible), text_width - sum(widths)) + total = sum(max(1, widest[i]) for i in flexible) + for i in flexible: + share = max(1, remaining * max(1, widest[i]) // total) + widths[i] = min(share, widest[i]) if specs[i] == "fill" else share + wrapped = {i for i, w in enumerate(specs) if w not in ("fit", "f")} + mark = phases.txt_verbatim_marker() # each line: already laid out + rule = (mark + indent + klammer_base.hide_typographic( "-" * (sum(widths) + len(colsep) * (len(widths) - 1))) + "\n") - for row_i, row in enumerate(self.rows): + result = "" + for row_i, row in enumerate(rows): if row_i in self.s_hline.by_index: result += rule - result += indent + colsep.join( - [pad(styled(elt, row_i, cell_i), width, align) - for cell_i, (elt, width, align) in enumerate(zip(row, widths, pos))]) - result += "\n" - if len(self.rows) in self.s_hline.by_index: + # Each cell becomes its lines: wrapped to the column's width in a + # wrapping column, one line otherwise; the row is as tall as its + # tallest cell, short cells padded with empty lines. + cell_lines = [] + for cell_i, (elt, width) in enumerate(zip(row, widths)): + text = styled(elt, row_i, cell_i) + lines = phases.txt_wrap(text, width) if cell_i in wrapped and text else [text] + cell_lines.append(lines or [""]) + height = max(len(lines) for lines in cell_lines) + for n in range(height): + result += mark + indent + colsep.join( + [pad(lines[n] if n < len(lines) else "", width, align) + for lines, width, align in zip(cell_lines, widths, pos)]) + result += "\n" + if len(rows) in self.s_hline.by_index: result += rule result = txt_util.add_caption( result, "Table", self.number, self.caption, self.caption_side, \ diff --git a/sks/target/html_util.py b/sks/target/html_util.py index b6b96d0..0463971 100644 --- a/sks/target/html_util.py +++ b/sks/target/html_util.py @@ -223,11 +223,11 @@ def hpos_container(element, hpos, offset=None): # coded, and only in html.) styles = [] if hpos not in ("left", "center", "right", "none"): - length, _, _ = kutil.parse_length("html", hpos, 1) + length, _, _ = kutil.parse_width("html", hpos) styles.append(f"margin-left: {length}") hpos = "indent" if offset and offset not in ("0", "0pt") and hpos in ("left", "right", "indent"): - length, _, _ = kutil.parse_length("html", offset, 1) + length, _, _ = kutil.parse_width("html", offset) side = "right" if hpos == "right" else "left" styles.append(f"padding-{side}: {length}") result = E("div").cls("hpos_" + hpos).body(element) diff --git a/sks/target/latex_util.py b/sks/target/latex_util.py index 4907131..6ea7168 100644 --- a/sks/target/latex_util.py +++ b/sks/target/latex_util.py @@ -81,9 +81,9 @@ def minipage(content, width="\\textwidth", vertical="c", center=True, vmargin="" def offset_length(offset): r"""An :offset as a LaTeX length, or "0pt" when there is none.""" - if not offset or offset in ("0", "0pt"): + if not offset or offset in ("0", "0pt", "0.0"): return "0pt" - return kutil.parse_length("tex", offset, 1)[0] + return kutil.parse_width("tex", offset)[0] def offset_space(offset): r"""An :offset as an \hspace*, or "" when there is none.""" @@ -111,7 +111,7 @@ def caption_wrapper(element, hpos, bottom_margin=0, offset=None): return minipage("\\hfill" + element + inset, vmargin=vmargin, center=False) if hpos == "none": return element - length, _, _ = kutil.parse_length("tex", hpos, 1) + length, _, _ = kutil.parse_width("tex", hpos) return minipage(f"\\hspace*{{{length}}}" + element + "\\hfill", vmargin=vmargin, center=False) diff --git a/sks/target/phases.py b/sks/target/phases.py index e87b085..d571b50 100644 --- a/sks/target/phases.py +++ b/sks/target/phases.py @@ -25,6 +25,26 @@ def txt_vspace_marker(): def txt_nbsp_marker(): return "__KTEXTNBSP__" +# A ZERO-WIDTH mark that says "this line is already laid out" (2026-09-16). +# The justification pass takes a paragraph beginning with a space or the +# nbsp marker as verbatim, and until now that was the only way a block +# could say so -- which broke the moment a block was flush left: a @table +# with :hpos left :offset 0 (what @columns emits) began with its first +# cell, so its rows were re-flowed into one line. A block that lays itself +# out puts this at the start of every line (txt_lay_out); it is nothing to +# txt_visible_length and is removed at the end of txt_justify_blocks. +def txt_verbatim_marker(): + return "__KTEXTVERBATIM__" + +def txt_lay_out(text): + """text with every line marked as already laid out.""" + mark = txt_verbatim_marker() + return "\n".join(mark + line for line in text.split("\n")) + +def txt_is_laid_out(par): + """True for a paragraph the justification pass must leave alone.""" + return par.startswith((" ", txt_nbsp_marker(), txt_verbatim_marker())) + # The type-style markers, emitted by @b.txt, @i.txt, @u.txt and @ri.txt # (sks/font/font.k) around their text. Plain text has no bold, but a # terminal does: the txt target STRIPS these in its last phase @@ -68,7 +88,7 @@ def txt_visible_length(s): """How many columns s occupies once its markers resolve: a KTESC marker is one glyph, an nbsp marker one space, a style marker nothing. len() is the wrong measure for anything laid out (see txt_util).""" - s = _STYLE_MARKER_RGX.sub("", s) + s = _STYLE_MARKER_RGX.sub("", s).replace(txt_verbatim_marker(), "") return len(klammer_base.unescape_ktesc(s).replace(txt_nbsp_marker(), " ")) def txt_wrap(text, width, subsequent_indent=""): @@ -159,7 +179,7 @@ def txt_justify_blocks(text, K): # branch that had nothing to do also did not copy the text through. # An indented paragraph vanished from the output with no error. nbsp = txt_nbsp_marker() - if par and par[0] != ' ' and not par.startswith(nbsp): + if par and not txt_is_laid_out(par): label = re.match("(?:" + re.escape(nbsp) + r")*\[\d+\] ", par) par = "\n".join(txt_wrap( par, int(K.Target_txt_width), @@ -167,6 +187,7 @@ def txt_justify_blocks(text, K): result += par + "\n\n" result = re.sub(" *" + txt_vspace_marker() + " *", "\n", result) result = re.sub(r"\n? *" + txt_nl_marker() + r" *\n?", "\n", result) + result = result.replace(txt_verbatim_marker(), "") result = result.replace(txt_nbsp_marker(), " ") return result diff --git a/sks/target/target.k b/sks/target/target.k index 16a44a4..9a3bc1b 100644 --- a/sks/target/target.k +++ b/sks/target/target.k @@ -38,7 +38,7 @@ of this: neither the transforms nor the quoting reach it. @@@state Target_txt_indent :desc Width of indentation in the plain text target (txt) - :value 3 + :value 2 @@@ # The txt target is not fully implemented in the SKS yet. @@ -59,7 +59,7 @@ of this: neither the transforms nor the quoting reach it. --- -- | `` " | '' " | - ` ` | + ` ' | ' ' | ~ __KTEXTNBSP__ :after_apply diff --git a/tst/Makefile b/tst/Makefile index 4b54469..f535ee9 100644 --- a/tst/Makefile +++ b/tst/Makefile @@ -19,6 +19,7 @@ test: ./katomize_test.sh ./filename_test.sh ./alone_test.sh + ./argtype_test.sh ./state_test.sh ./target_test.sh ./modulepath_test.sh diff --git a/tst/argtype_test.sh b/tst/argtype_test.sh new file mode 100755 index 0000000..0f6adaa --- /dev/null +++ b/tst/argtype_test.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# +# argtype_test.sh -- the @@@argtype description is Klammertext source +# (engine tier: -k none, every definition inline). +# +# Decided 2026-09-13 (Andy): the second argument of @@@argtype has the +# "klammertext" type, so Argtype::m_desc holds the description EXACTLY AS +# WRITTEN -- +# a quoted "^@date" keeps its caret -- and whoever displays it hands the +# text to a Machine (kdesc's listing through ref/, the Handbook). Before, +# the description was assembled from katom text, where the katomizer has +# already stripped the caret of a quoted special, so "(^@date and +# ^@datetime)" reached the generator as "(@date and @datetime)" and re-read +# as two klammer applications. A display that cannot render -- an argument +# error -- shows the source unquoted instead. +# +# Note the neighbour: tst/argtype_test (no .sh) is the C++ diagnostic +# program, the fourth such name collision after eval, state and target. +# +# Usage: ./argtype_test.sh (needs KLAMMERTEXT_HOME set; ktext/kdiag on PATH) +# Exit code: 0 if all tests pass, 1 otherwise. + +KTEXT="${KTEXT:-ktext}"; KDIAG="${KDIAG:-kdiag}" +PASS=0; FAIL=0 +red=$'\033[31m'; green=$'\033[32m'; bold=$'\033[1m'; reset=$'\033[0m' +pass() { echo "${green}PASS${reset} $1"; PASS=$((PASS+1)); } +fail() { echo "${red}FAIL${reset} $1"; [ -n "$2" ] && echo " $2"; FAIL=$((FAIL+1)); } +flat() { tr '\n' ' ' | tr -s ' '; } # error text is justified: match wrap-insensitively + +echo "${bold}@@@argtype description tests${reset}" +echo "============================" +echo + +DEF='@@@target tt | T @@@ @@@argtype lang | see ^@date and ^: here, ^| bar too :pattern [a-z][a-z] @@@' +T=$(mktemp -d) + +# 1. The registry holds the source as written (kdiag --machine is the +# programmer's dump and shows the stored text). +out=$("$KDIAG" --system --machine "$DEF" 2>&1 | flat) +if echo "$out" | grep -qF 'see ^@date and ^: here, ^| bar too'; then pass " 1. the description is stored exactly as written (carets kept)" +else fail " 1. the description is stored exactly as written (carets kept)" "$(echo "$out" | grep -o 'lang.\{0,60\}' | head -1)"; fi + +# 2. A quoted ":" inside the description does not start an option: the +# :pattern that follows is still the pattern (a valid value passes). +out=$("$KTEXT" --klammersets none -s "$DEF @@w.k c.lang : W @@ @@w.tt :: [*c*] @@ @w de @" -t tt -d 2>&1 | flat) +if echo "$out" | grep -qF '[de]'; then pass " 2. ^: in the description does not end it; :pattern still applies" +else fail " 2. ^: in the description does not end it; :pattern still applies" "$out"; fi + +# 3. An invalid value's error shows the description UNQUOTED -- plain text +# for a reader who cannot render it. +out=$("$KTEXT" --klammersets none -s "$DEF @@w.k c.lang : W @@ @@w.tt :: [*c*] @@ @w 123 @" -t tt -d 2>&1 | flat) +if echo "$out" | grep -qF 'describes see @date and : here, | bar too'; then pass " 3. an argument error shows the description unquoted" +else fail " 3. an argument error shows the description unquoted" "$out"; fi +if ! echo "$out" | grep -qF '^@'; then pass " 4. ... and no caret leaks into the error" +else fail " 4. ... and no caret leaks into the error"; fi + +# 5. A klammer's LITERAL parameter is a different type and is untouched: +# its "^" forms are processed ("^@" prints "@"), as they always were. The application is in a +# FILE after the -s definitions: the literal-marking pass needs the +# klammer registered before the unit holding the application is read, +# and "--" ends the --klammersets list before the filename. +printf '@lit ^@ and # and | x lit@\n' > "$T/lit.kt" +out=$("$KTEXT" --klammersets none -s '@@@target tt | T @@@ @@lit.k t.literal : L @@ @@lit.tt :: [*t*] @@' -- "$T/lit.kt" -t tt -d 2>&1 | flat) +if echo "$out" | grep -qF '[@ and # and | x]'; then pass " 5. the literal parameter of a klammer still holds its content as written" +else fail " 5. the literal parameter of a klammer still holds its content as written" "$out"; fi + +rm -rf "$T" +echo +echo "============================" +echo "Results: ${green}$PASS passed${reset}, ${red}$FAIL failed${reset}" +[ $FAIL -eq 0 ] diff --git a/tst/check_test.sh b/tst/check_test.sh index d7fee07..32ad108 100755 --- a/tst/check_test.sh +++ b/tst/check_test.sh @@ -209,6 +209,28 @@ check_clean "17. a correct document checks clean" \ check_clean "18. the Standard Klammer Set checks clean" \ "@read $K/sks/sks.k @" +# Argument VALUES are checked against their argument types (2026-09-16): +# the same test the engine applies at application time, run here on every +# value that is LITERAL -- nothing but text -- so a height unit on a width, +# or a word where a number is wanted, is found without rendering. A value +# holding a nested klammer, a *variable*, or a caret form is computed at run +# time and is skipped: check what is fixed, not what is computed. +W='@@@target tt | T @@@ @@@argtype em | a length in em :pattern \d+em @@@ @@f.k x.em :opt.em : F @@ @@f.tt :: [*x*] @@' +check_finds "19. a literal positional value that fails its argument type" \ + 'does not match the "em" argument type' "$W @f 3px @" +check_finds "20. ... and the argtype description is quoted" \ + 'a length in em' "$W @f 3px @" +check_clean "21. a literal value that matches is clean" "$W @f 3em @" +check_finds "22. a literal option value that fails its argument type" \ + 'does not match the "em" argument type' "$W @f 3em :opt 4 @" +check_clean "23. an option written alone is not a value to check" "$W @f 3em :opt @" +check_clean "24. a value holding a nested klammer is computed: skipped" \ + "$W @@n : 3em @@ @f @n@ @" +check_clean "25. a value holding a *variable* in a body is computed: skipped" \ + "$W @@g.k v : G @@ @@g.tt :: @f *v* @ @@" +check_clean "26. a value with a caret form is computed: skipped" \ + "$W @f 3^:em @" + echo echo "=============================" echo "Results: ${green}$PASS passed${reset}, ${red}$FAIL failed${reset}" diff --git a/tst/modulepath_test.sh b/tst/modulepath_test.sh index a46b037..4e98b20 100755 --- a/tst/modulepath_test.sh +++ b/tst/modulepath_test.sh @@ -99,9 +99,13 @@ printf '@eval :cwd *K_input_dir* os.getcwd() @\n' > "$T/setb/pycwd.kt" check " 5. :cwd changes the Python working directory" \ "$T/setb" "$T/setb/pycwd.kt" +# The first @eval renders nothing, so the document begins with the space +# the author wrote before the "/": ktext keeps it (it trims only newlines +# from the result, as Machine::apply and the Python binding do -- a leading +# space is the indent of a txt block that opens a document; TODO #60). printf '@eval :cwd *K_input_dir* :shell true @ / @eval :shell pwd @\n' > "$T/setb/restore.kt" check " 6. the cwd is restored after the eval" \ - "/ $T/elsewhere" "$T/setb/restore.kt" + " / $T/elsewhere" "$T/setb/restore.kt" mkdir -p "$T/sp ace" printf 'spaced\n' > "$T/sp ace/f.txt"