From 6e7596ab2e14d033eb26242cc95605e57ae7b7db Mon Sep 17 00:00:00 2001 From: Andy Kopra Date: Thu, 6 Aug 2026 13:11:37 +0200 Subject: [PATCH] Option sets: a .o target for shared parameters A named group of optional parameters, declared once and used by several klammers, so a writer learns one vocabulary instead of a spelling per klammer. The "o" target is a pseudo-target beside "k": "k" declares a klammer's interface and documents it, "o" declares an option interface and documents it, and neither produces output for any target. @@caption_args.o :caption :number.bool true :caption_side.side : Arguments that define a caption for a block element @@ @@code.k :filename @hpos_args :hpos left @ @caption_args :caption_side top @ | text.literal : A source file displayed verbatim @@ A set is used only in the parameter list of a ".k" declaration -- the one place a klammer's interface is declared once for all of its targets -- and is resolved as that list is read. Names and types come from the set; a default may be overridden where it is used. A klammer application in a parameter list is now a definition-time error. The SKS gains the sets caption_args and hpos_args (:hpos and :offset), and @table, @image, @image_grid, @reference and @show gain .k declarations. A distance is no longer written as a position: :hpos 4em is rejected, and the same layout is :hpos left :offset 4em. Code listings are numbered by default, like tables and figures. New engine sources mac/option_set{,_registry}.{h,cpp}; tst/ ships two more suites, option_set_test.sh and signature_test.sh (twelve in all). (from dev 34e536cb0329) --- README.md | 2 +- com/kdesc.cpp | 5 + mac/Makefile | 3 +- mac/argument.h | 15 +- mac/argument_set.h | 7 + mac/klammer.cpp | 127 +++++++++++-- mac/klammer.h | 10 +- mac/klammer_registry.cpp | 17 +- mac/klammer_registry.h | 1 + mac/machine.cpp | 42 ++++- mac/machine.h | 5 + mac/option_set.cpp | 64 +++++++ mac/option_set.h | 70 +++++++ mac/option_set_registry.cpp | 359 +++++++++++++++++++++++++++++++++++ mac/option_set_registry.h | 60 ++++++ mac/show.cpp | 2 + mac/target_registry.cpp | 12 +- mac/target_registry.h | 4 + sks/block/block.k | 23 +++ sks/block/block.py | 13 +- sks/code/code.k | 122 +----------- sks/code/code_block.py | 361 ++++++++++++++++++++++++++++++++++++ sks/code/code_format.py | 87 ++++++++- sks/code/css/code.css | 77 +++++--- sks/code/sty/code.sty | 165 ++++++++++++++++ sks/image/image.k | 46 ++--- sks/image/image.py | 12 +- sks/image/image_grid.py | 6 + sks/kutil/kutil.k | 150 +++++++++++---- sks/table/table.k | 19 +- sks/table/table.py | 35 +++- sks/target/css/target.css | 8 + sks/target/html_util.py | 29 ++- sks/target/latex_util.py | 55 +++++- tst/Makefile | 2 + tst/option_set_test.sh | 307 ++++++++++++++++++++++++++++++ tst/signature_test.sh | 143 ++++++++++++++ 37 files changed, 2198 insertions(+), 267 deletions(-) create mode 100644 mac/option_set.cpp create mode 100644 mac/option_set.h create mode 100644 mac/option_set_registry.cpp create mode 100644 mac/option_set_registry.h create mode 100644 sks/code/code_block.py create mode 100755 tst/option_set_test.sh create mode 100755 tst/signature_test.sh diff --git a/README.md b/README.md index 4e11966..1ca62b6 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,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 `c27e63802406`. +This snapshot was assembled from development commit `34e536cb0329`. ## License diff --git a/com/kdesc.cpp b/com/kdesc.cpp index 9ee660b..63c6435 100644 --- a/com/kdesc.cpp +++ b/com/kdesc.cpp @@ -101,6 +101,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.flag("optionsets", "Show option sets declared by the input file"); args.var("font", "List installed fonts. Enter \"--font help\" for font maintenance commands."); args.var("klammerset", "List the klammersets on the search path. Enter \"--klammerset help\" for details."); args.opt("v", "'verbosity'", "n", "0", "'verbosity'"); @@ -190,6 +191,10 @@ int main(int argc, char* argv[]) std::cout << boldblack << "Klammers\n" << black << M.m_klammers.describe(2); } + if (p("optionsets")) { + std::cout << boldblack << "Option sets\n" << black << M.m_option_sets.describe(2); + } + } catch (Error& e) { e.print_message(); diff --git a/mac/Makefile b/mac/Makefile index a40321d..5f15c69 100644 --- a/mac/Makefile +++ b/mac/Makefile @@ -9,7 +9,8 @@ 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_registry \ - state eval eval_python eval_cpp klammer klammer_registry klammerset klammerset_registry deftype \ + state eval eval_python eval_cpp klammer klammer_registry klammerset klammerset_registry \ + option_set option_set_registry deftype \ target target_registry machine font_store check SOURCES := $(addsuffix .cpp,$(BASENAMES)) diff --git a/mac/argument.h b/mac/argument.h index 335f409..cfd7e20 100644 --- a/mac/argument.h +++ b/mac/argument.h @@ -29,10 +29,19 @@ public: Argtype m_argtype; // {}; bool m_optional {}; std::string m_default {}; - // True when m_default was filled from the argument type's :default - // rather than declared in the klammer's parameter list (for kdesc - // provenance display). + // Where m_default came from, for kdesc provenance display. Default + // resolution has three levels -- the argument type's :default, an option + // set's declaration, and the site where the set is used (or the klammer's + // own parameter list) -- and a reader of kdesc is entitled to know which + // one produced the value a klammer will use when the argument is absent. + // True when m_default was filled from the argument type's :default: bool m_default_from_type {}; + // The option set (".o" target) this parameter was declared in; empty when + // it was declared in the klammer's own parameter list. + std::string m_option_set {}; + // True when the option set declares a default for this parameter and the + // declaration using the set overrode it. + bool m_default_overridden {}; Locator m_loc; std::string m_target {}; }; diff --git a/mac/argument_set.h b/mac/argument_set.h index 7bbf4e7..f5b03a1 100644 --- a/mac/argument_set.h +++ b/mac/argument_set.h @@ -13,6 +13,13 @@ std::tuple>,std::vector>,std:: argument_split(std::vector::const_iterator kbegin, std::vector::const_iterator kend, long unsigned int positional_limit = std::numeric_limits::max()); +// The katoms of a parameter list, split into one list per parameter: +// positional first, then optional (each beginning with its option-name +// katom). An option set keeps the katoms of its members this way, because +// those katoms are what a declaration using the set ends up declaring. +std::tuple>,std::vector>> +parameter_split(std::vector::const_iterator kbegin, std::vector::const_iterator kend); + class Parameter_set { public: diff --git a/mac/klammer.cpp b/mac/klammer.cpp index 60176f6..abb03da 100644 --- a/mac/klammer.cpp +++ b/mac/klammer.cpp @@ -41,7 +41,10 @@ parse_name(const Target_registry& targets, const Katom& name_katom) } std::tuple -parse_definition_katoms(const std::string& klammer_name, const Argtype_registry& argtypes, katom_iter& begin, katom_iter& end) +parse_definition_katoms( + const std::string& klammer_name, const std::string& target_name, + const Argtype_registry& argtypes, Option_set_registry& option_sets, + katom_iter& begin, katom_iter& end) { (void)K::log(3, *begin, *(end - 1)); katom_iter deftype = std::find_if( @@ -68,7 +71,13 @@ parse_definition_katoms(const std::string& klammer_name, const Argtype_registry& std::erase_if(parameter_katoms, [](const Katom& k) { return k.m_type == katom_t::ignored; }); parameter_katoms = trim_whitespace(parameter_katoms); - if ((deftype->m_type == katom_t::klammer_instance || + // "::" and ":::" take their parameters from the ".k" declaration, so a + // parameter list written with them is a mistake -- EXCEPT for an option + // set, which has no separate declaration to inherit from: a set is its + // own declaration, so an override restates what it declares. + bool inherits_parameters = target_name != Target_registry::optionset_name; + if (inherits_parameters && + (deftype->m_type == katom_t::klammer_instance || deftype->m_type == katom_t::klammer_override) && !parameter_katoms.empty()) { std::string sym = deftype->m_type == katom_t::klammer_instance ? "::" : ":::"; @@ -76,7 +85,16 @@ parse_definition_katoms(const std::string& klammer_name, const Argtype_registry& "The \"" + klammer_name + "\" klammer uses the \"" + sym + "\" symbol but defines parameters.", begin->m_loc); } + // Replace the option sets used in the parameter list with the parameters + // they declare. This is where the difference between an option set and a + // klammer lies: a set is resolved HERE, as the parameter list is read, + // rather than in the fixed-point apply loop, so what it contributes is + // present when the list is parsed. Any other klammer application in a + // parameter list is rejected by the same pass. + option_set_uses_t option_set_uses = + expand_option_sets(parameter_katoms, klammer_name, target_name, option_sets); Parameter_set parameters(parameter_katoms, argtypes); + stamp_option_set_uses(parameters, option_set_uses); katom_list body_katoms(deftype + 1, end - 1); body_katoms = trim_whitespace(body_katoms); @@ -84,11 +102,12 @@ parse_definition_katoms(const std::string& klammer_name, const Argtype_registry& } void Klammer::add_target_definition( - const std::string& target_name, const Argtype_registry& argtypes, katom_iter begin, katom_iter end) + const std::string& target_name, const Argtype_registry& argtypes, + Option_set_registry& option_sets, katom_iter begin, katom_iter end) { (void)K::log(3, *begin, *(end-1)); auto [deftype, parameters, body, loc] = - parse_definition_katoms(m_name, argtypes, begin, end); // targets, begin, end); + parse_definition_katoms(m_name, target_name, argtypes, option_sets, begin, end); // msg() << "Klammer " << m_name << " add: " << target_name << "\n"; // parameters.describe_parameters(); @@ -208,7 +227,8 @@ void Klammer::copy_components( (void)K::log(4); for (const auto& target_name : targets.m_names) { if (target_name == Target_registry::declare_name || - target_name == Target_registry::general_name) { + target_name == Target_registry::general_name || + target_name == Target_registry::optionset_name) { continue; } m_parameters = parameters; @@ -243,7 +263,6 @@ void Klammer::check_for_declaration_and_definitions() if (def.target != Target_registry::declare_name) { if (def.deftype == katom_t::klammer_definition || def.deftype == katom_t::klammer_default) { - msg() << def << "\n"; definitions.push_back(def); } } @@ -286,7 +305,11 @@ void Klammer::copy_general_klammer_to_undefined(const Target_registry& targets) } for (const auto& target_name : targets.m_names) { // std::cout << "General copy, considering " << target_name << "\n"; - if (m_body.count(target_name) == 0 && target_name != Target_registry::declare_name) { + // "k" and "o" declare interfaces rather than produce output, so a + // general body is never copied to them. + if (m_body.count(target_name) == 0 && + target_name != Target_registry::declare_name && + target_name != Target_registry::optionset_name) { // std::cout << " Copying to " << target_name << "\n"; m_body[target_name] = body; m_body_generic[target_name] = true; // general body -> writer content @@ -299,6 +322,39 @@ void Klammer::copy_general_klammer_to_undefined(const Target_registry& targets) // Three declaration cases: none, one, many +namespace { + +// A compact, plain-text rendering of ONE definition's parameter list, for +// diagnostics that must show how two definitions differ. +// Klammer::signature_text() cannot serve here: it renders the rationalized +// m_parameters, which is exactly what does not exist yet when the per-target +// lists disagree. +std::string parameter_signature(const Parameter_set& parameters) +{ + std::string result {}; + auto add = [&result](const std::string& s) { + if (!result.empty()) result += " "; + result += s; + }; + auto typed = [](const Parameter& p) { + return p.m_argtype.m_name == default_argtype + ? p.m_name : p.m_name + "." + p.m_argtype.m_name; + }; + bool first = true; + for (const auto& pos : parameters.m_positional) { + add(first ? typed(pos) : "| " + typed(pos)); + first = false; + } + for (const auto& rest : parameters.m_rest) + add(typed(rest)); + for (const auto& opt : parameters.m_optional) + add(":" + typed(opt) + + (opt.m_default.empty() ? "" : " " + opt.m_default)); + return result.empty() ? "(no parameters)" : result; +} + +} // namespace + void Klammer::no_declarations(const Target_registry& targets) { (void)K::log(4); @@ -318,14 +374,24 @@ void Klammer::no_declarations(const Target_registry& targets) } } if (!all_equal(all_parameter_sets)) { - // std::cout << " Not all equal\n"; - throw Definition_error( - error_list("There is no declaration (.k) target for klammer \"" + m_name + "\"\n" - "but the parameters of all targets are not the same", - m_defs, - "Use a .k klammer to define the parameters and describe the klammer,\n" - "with \"::\" and no parameters for all targets."), - m_defs[0].loc, false); + // Show each target's own parameter list, not just its location: with + // more than two targets the designer would otherwise have to diff the + // definitions by hand to find which one drifted. + std::stringstream ss {}; + ss << "The parameters of klammer \"" << m_name + << "\" are not the same for every target,\n" + "and there is no declaration (.k) target to define them once:\n"; + for (const auto& def : m_defs) { + if (std::ranges::find(target_names, def.target) == target_names.end()) + continue; + std::string target = def.target; + target.resize(std::max(target.size(), size_t(6)), ' '); + ss << " " << target << " " << parameter_signature(def.parameters) + << "\n " << def.loc.desc() << "\n"; + } + ss << "Use a .k target to declare the parameters and describe the klammer,\n" + "and \"::\" with no parameters for each target's definition."; + throw Definition_error(ss.str(), m_defs[0].loc, false); } else { // std::cout << " All equal\n"; copy_components(m_defs[0].parameters, m_defs, targets); @@ -485,10 +551,39 @@ std::string Klammer::description_text() const } +// Which option sets this klammer's parameters came from, and which of their +// defaults it overrode. A reader of the signature sees the effective +// interface; this says how much of it the klammer shares with other klammers, +// which is the reason for declaring a set in the first place. +std::string Klammer::option_set_text() const +{ + std::vector sets {}; + std::map> overridden {}; + for (const auto& opt : m_parameters.m_optional) { + if (opt.m_option_set.empty()) continue; + if (!is_in(opt.m_option_set, sets)) { + sets.push_back(opt.m_option_set); + } + if (opt.m_default_overridden) { + overridden[opt.m_option_set].push_back(":" + opt.m_name); + } + } + if (sets.empty()) return ""; + std::vector descriptions {}; + for (const auto& set : sets) { + std::string desc = set; + if (overridden.count(set) > 0) { + desc += " (" + join(overridden[set], ", ") + " defaulted here)"; + } + descriptions.push_back(desc); + } + return "\n Option sets: " + join(descriptions, ", "); +} + std::string Klammer::describe(int margin) const { std::string result {}; - result += "@" + m_name + signature_text() + description_text(); + result += "@" + m_name + signature_text() + description_text() + option_set_text(); result = add_margin(result, margin) + "\n"; return result; } diff --git a/mac/klammer.h b/mac/klammer.h index 1e17987..0954e30 100644 --- a/mac/klammer.h +++ b/mac/klammer.h @@ -4,6 +4,7 @@ #include "deftype.h" #include "argument_set.h" +#include "option_set_registry.h" #include "target_registry.h" #include "locator.h" @@ -32,6 +33,7 @@ public: void add_target_definition( const std::string& target_name, const Argtype_registry& argtypes, + Option_set_registry& option_sets, std::vector::iterator begin, std::vector::iterator end); void remove_target_definition(const std::string& target_name); @@ -62,6 +64,7 @@ public: std::string signature_text() const; std::string description_text() const; + std::string option_set_text() const; std::string describe(int margin=0) const; bool has_literal_param() const { @@ -97,8 +100,13 @@ std::string klammer_name_from_katom(const std::string& s, const Locator& loc); std::tuple parse_name(const Target_registry& targets, const Katom& name_katom); +// Split a definition's katoms into its definition separator, parameters, +// body and location. The target name is needed because the parameter list +// is where option sets are used, and a set may be used only in a ".k" +// declaration; option_sets is not const because a use is recorded on the set. std::tuple, Locator> -parse_definition_katoms(const std::string& klammer_name, const Argtype_registry& argtypes, +parse_definition_katoms(const std::string& klammer_name, const std::string& target_name, + const Argtype_registry& argtypes, Option_set_registry& option_sets, std::vector::iterator& begin, std::vector::iterator& end); /* diff --git a/mac/klammer_registry.cpp b/mac/klammer_registry.cpp index bf50cfd..c09af22 100644 --- a/mac/klammer_registry.cpp +++ b/mac/klammer_registry.cpp @@ -12,7 +12,9 @@ bool Klammer_registry::has(std::string name, std::string target) } */ -void Klammer_registry::add(const Argtype_registry& argtypes, const Target_registry& targets, katom_iter begin, katom_iter end, katom_list& katoms) +void Klammer_registry::add( + const Argtype_registry& argtypes, const Target_registry& targets, + Option_set_registry& option_sets, katom_iter begin, katom_iter end, katom_list& katoms) { (void)K::log(3, *begin, *(end - 1)); restore_initial_type(begin, end); @@ -20,6 +22,13 @@ void Klammer_registry::add(const Argtype_registry& argtypes, const Target_regist if (!targets.has(target_name)) { throw Argument_error("The target \"" + target_name + "\" is not defined", begin->m_loc); } + if (target_name == Target_registry::optionset_name) { + // The Machine routes an ".o" definition to the option set registry; + // reaching here means it did not. + throw Internal_error( + "The option set declaration \"" + klammer_name + ".o\" reached the klammer registry", + begin->m_loc); + } // Find the incoming definition mode katom_t incoming_deftype = katom_t::klammer_definition; for (auto it = begin + 1; it != end - 1; ++it) { @@ -57,7 +66,8 @@ void Klammer_registry::add(const Argtype_registry& argtypes, const Target_regist } m_klammers[klammer_name].remove_target_definition(target_name); } - m_klammers[klammer_name].add_target_definition(target_name, argtypes, begin + 1, end - 1); + m_klammers[klammer_name].add_target_definition( + target_name, argtypes, option_sets, begin + 1, end - 1); // This add's target: Target target = targets.get(target_name, begin->m_loc); @@ -66,7 +76,8 @@ void Klammer_registry::add(const Argtype_registry& argtypes, const Target_regist if (m_klammers[klammer_name].m_defloc.count(provide_name) > 0) { m_klammers[klammer_name].remove_target_definition(provide_name); } - m_klammers[klammer_name].add_target_definition(provide_name, argtypes, begin + 1, end - 1); + m_klammers[klammer_name].add_target_definition( + provide_name, argtypes, option_sets, begin + 1, end - 1); } } diff --git a/mac/klammer_registry.h b/mac/klammer_registry.h index 75c6f2e..1fb850a 100644 --- a/mac/klammer_registry.h +++ b/mac/klammer_registry.h @@ -8,6 +8,7 @@ class Klammer_registry public: Klammer_registry() = default; void add(const Argtype_registry& argtypes, const Target_registry& targets, + Option_set_registry& option_sets, std::vector::iterator begin, std::vector::iterator end, std::vector& katoms); void rationalize(const Target_registry& targets); void check_klammer(const std::string& name, const std::string& target, const Locator& loc) const; diff --git a/mac/machine.cpp b/mac/machine.cpp index 478dc39..16b03d1 100644 --- a/mac/machine.cpp +++ b/mac/machine.cpp @@ -207,12 +207,27 @@ void Machine::process_cond_katoms(katom_list& katoms) } +// Expand the constant klammers written in a definition's BODY. A constant is +// expanded at definition time, which is what makes it a constant; the body is +// where that is meaningful. +// +// The parameter list is deliberately excluded. A klammer application there +// is an error (see expand_option_sets): parameters shared between klammers +// are declared by an option set, whose ".o" declaration is resolved as the +// parameter list is read. Expanding a constant into a parameter list used to +// be the way to share parameters, and it silently destroyed the parameter +// list of a ".k" declaration -- the spliced options AND the declared +// positionals -- surfacing only as an argument error at the first +// application, in the document rather than the declaration. void Machine::expand_constant_klammers(katom_list& katoms, const Katom& op, const Katom& cl) { auto [begin, end] = find_span_katoms(katoms, op, cl); restore_initial_type(begin + 1, end - 1); - if (std::find_if(begin + 1, end - 1, begin_klammer_apply) == end - 1) return; - for (const auto& [app_op, app_cl] : find_spans(begin + 1, end - 1, begin_apply, end_apply, false, "def-time")) { + auto body_begin = std::find_if( + begin + 1, end - 1, [](const Katom& k) { return is_deftype(k.m_type); }); + if (body_begin == end - 1) return; + if (std::find_if(body_begin, end - 1, begin_klammer_apply) == end - 1) return; + for (const auto& [app_op, app_cl] : find_spans(body_begin, end - 1, begin_apply, end_apply, false, "def-time")) { auto [app_begin, app_end] = find_span_katoms(katoms, app_op, app_cl); if (app_begin->m_type == katom_t::apply_begin) { std::string name = trim_char(app_begin->m_text, '@'); @@ -506,14 +521,27 @@ void Machine::load_klammerset_files(const Klammerset& klammerset, katom_iter ins m_katoms.insert(insert_at, loaded.begin(), loaded.end()); } +// Register one "@@...@@" definition. An ".o" target declares an option set +// -- parameters shared by klammers -- and goes to its own registry: it +// defines no klammer and produces no output for any target. +void Machine::add_definition(katom_list& katoms, const Katom& op, const Katom& cl) +{ + expand_constant_klammers(katoms, op, cl); + auto [begin, end] = find_span_katoms(katoms, op, cl); + auto [name, target] = parse_name(m_targets, *begin); + if (target == Target_registry::optionset_name) { + m_option_sets.add(m_argtypes, name, begin, end, katoms); + } else { + m_klammers.add(m_argtypes, m_targets, m_option_sets, begin, end, katoms); + } +} + void Machine::extract_klammer_definitions(katom_list katoms) { fmsg() << katoms << "\n"; (void)K::log(3); for (const auto& [op, cl] : find_spans(katoms, begin_klammer_def, end_klammer_def, true, command_name)) { - expand_constant_klammers(katoms, op, cl); - auto [begin, end] = find_span_katoms(katoms, op, cl); - m_klammers.add(m_argtypes, m_targets, begin, end, katoms); + add_definition(katoms, op, cl); } m_klammers.rationalize(m_targets); } @@ -522,9 +550,7 @@ void Machine::extract_klammer_definitions() { (void)K::log(3); for (const auto& [op, cl] : find_spans(m_katoms, begin_klammer_def, end_klammer_def, true, command_name)) { - expand_constant_klammers(m_katoms, op, cl); - auto [begin, end] = find_span_katoms(m_katoms, op, cl); - m_klammers.add(m_argtypes, m_targets, begin, end, m_katoms); + add_definition(m_katoms, op, cl); } m_klammers.rationalize(m_targets); } diff --git a/mac/machine.h b/mac/machine.h index 3b994e1..9a8d157 100644 --- a/mac/machine.h +++ b/mac/machine.h @@ -6,6 +6,7 @@ #include "katom_list.h" #include "klammer_registry.h" #include "klammerset_registry.h" +#include "option_set_registry.h" #include "target_registry.h" #include "argtype_registry.h" #include "state.h" @@ -27,6 +28,7 @@ public: , m_targets(other.m_targets) , m_klammers(other.m_klammers) , m_klammersets(other.m_klammersets) + , m_option_sets(other.m_option_sets) , m_result(other.m_result) {} @@ -38,6 +40,7 @@ public: m_targets = other.m_targets; m_klammers = other.m_klammers; m_klammersets = other.m_klammersets; + m_option_sets = other.m_option_sets; m_result = other.m_result; } return *this; @@ -68,6 +71,7 @@ public: bool klammers=true, bool eval=true, bool cond=true, bool read=true); void expand_constant_klammers(katom_list& katoms, const Katom& op, const Katom& cl); + void add_definition(katom_list& katoms, const Katom& op, const Katom& cl); void load_klammerset_files(const Klammerset& klammerset, katom_iter insert_at); void extract_machine_definitions(); void extract_klammer_definitions(); @@ -92,6 +96,7 @@ public: Target_registry m_targets {}; Klammer_registry m_klammers {}; Klammerset_registry m_klammersets {}; + Option_set_registry m_option_sets {}; input_sources_t m_sources {}; std::string m_result {}; std::vector m_katoms {}; diff --git a/mac/option_set.cpp b/mac/option_set.cpp new file mode 100644 index 0000000..f6019ba --- /dev/null +++ b/mac/option_set.cpp @@ -0,0 +1,64 @@ +#include + +#include "option_set.h" +#include "argtype_registry.h" +#include "util.h" + +Option_set::Option_set( + const std::string& name, const std::string& desc, + const Parameter_set& parameters, const katom_lists& members, const Locator& loc) + : m_name(name) + , m_desc(desc) + , m_parameters(parameters) + , m_members(members) + , m_loc(loc) +{} + +const Parameter* Option_set::find(const std::string& name) const +{ + for (const Parameter& p : m_parameters.m_optional) { + if (p.m_name == name) { + return &p; + } + } + return nullptr; +} + +std::string Option_set::member_names() const +{ + std::vector names {}; + for (const Parameter& p : m_parameters.m_optional) { + names.push_back(":" + p.m_name); + } + return join(names, " "); +} + +// One member per line: the name, its type, and its default -- the same three +// things a klammer's signature shows, since that is what the using klammer +// ends up declaring. +std::string Option_set::describe(int margin) const +{ + std::string tab(margin, ' '); + std::stringstream ss {}; + ss << tab << "@@" << m_name << ".o\n"; + for (const Parameter& p : m_parameters.m_optional) { + std::string line = ":" + p.m_name; + if (p.m_argtype.m_name != default_argtype) { + line += "." + p.m_argtype.m_name; + } + if (!p.m_default.empty()) { + line += " " + p.m_default; + if (p.m_default_from_type) { + line += " (from the " + p.m_argtype.m_name + " argument type)"; + } + } + ss << tab << " " << line << "\n"; + } + if (!m_desc.empty()) { + ss << justify(m_desc, 80, margin + 2) << "\n"; + } + if (!m_users.empty()) { + ss << tab << " Used by: " << join(m_users, ", ") << "\n"; + } + return ss.str(); +} diff --git a/mac/option_set.h b/mac/option_set.h new file mode 100644 index 0000000..c5230e3 --- /dev/null +++ b/mac/option_set.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include + +#include "argument_set.h" +#include "deftype.h" +#include "locator.h" +#include "util.h" + +// An Option_set is the construct declared by the "o" target: +// +// @@name.o :opt.argtype default ... : @@ +// +// a named group of OPTIONAL parameters declared once and used by several +// klammers, so that a writer learns one vocabulary (:hpos, :offset, the +// caption parameters) instead of a spelling per klammer. The "o" target is +// symmetric with "k": "k" declares a klammer's interface and documents it, +// "o" declares an option interface and documents it. Informally a klammer +// mix-in -- the term is from Flavors, where a mixin contributes slots +// without necessarily contributing methods, which is the shape here: a set +// contributes a DECLARATION, and the obligation to honor it stays with the +// klammer that uses it. +// +// A set is used in the parameter list of a ".k" declaration and nowhere +// else: +// +// @@code.k :filename @caption_args :caption_side top @ | text.literal : +// +// Names and types come from the set and cannot be changed at the use site; a +// DEFAULT may be overridden there, because what varies between klammers is +// only what silence means for that one klammer -- and kdesc shows a klammer's +// effective defaults anyway. Everything a reader relies on is the same +// wherever the set is used, which is the whole point of having one. +// +// The set keeps the katoms of each member as declared, because those are what +// is spliced into the using declaration's parameter list. The parsed +// Parameter_set beside them validates a use-site override at definition time +// and describes the set for kdesc. +class Option_set +{ +public: + Option_set() = default; + Option_set(const std::string& name, const std::string& desc, + const Parameter_set& parameters, const katom_lists& members, + const Locator& loc); + + // The member parameter named `name`, or nullptr if the set has none. + const Parameter* find(const std::string& name) const; + + // ":a :b :c" -- the members, for a diagnostic that has to show them. + std::string member_names() const; + + std::string describe(int margin = 2) const; + + std::string m_name {}; + std::string m_desc {}; + Parameter_set m_parameters {}; + // One katom list per member, in declared order, each beginning with the + // member's option-name katom. + katom_lists m_members {}; + Locator m_loc {}; + // How this set was declared, for the redefinition policy: the same + // transition table that governs klammer redefinition governs sets. + defmode_t m_defmode { defmode_t::def_create }; + // The klammers whose ".k" declaration uses this set, in declaration + // order. A set's users are as interesting as its members: they are the + // klammers that promise to honor the vocabulary. + std::vector m_users {}; +}; diff --git a/mac/option_set_registry.cpp b/mac/option_set_registry.cpp new file mode 100644 index 0000000..f5ef52d --- /dev/null +++ b/mac/option_set_registry.cpp @@ -0,0 +1,359 @@ +#include + +#include "option_set_registry.h" +#include "argument_set.h" +#include "error.h" +#include "katom_list.h" +#include "klammer.h" +#include "log.h" +#include "show.h" +#include "target_registry.h" +#include "util.h" + +// --- Registration --------------------------------------------------------- + +void Option_set_registry::add( + const Argtype_registry& argtypes, const std::string& name, + katom_iter begin, katom_iter end, katom_list& katoms) +{ + (void)K::log(3, *begin, *(end - 1)); + restore_initial_type(begin, end); + katom_iter definition_begin = begin + 1; + katom_iter definition_end = end - 1; + auto [deftype, parameters, body, loc] = parse_definition_katoms( + name, Target_registry::optionset_name, argtypes, *this, + definition_begin, definition_end); + + if (deftype.m_initial_type == katom_t::klammer_instance) { + throw Definition_error( + "The option set " + q_(name) + " is declared with \"::\", which takes its " + "parameters from a \".k\" declaration. An option set IS a declaration: " + "it declares its parameters itself, after \":\".", + begin->m_loc); + } + defmode_t incoming_mode = defmode_from_katom(deftype.m_initial_type); + if (has(name)) { + const Option_set& current = m_option_sets.at(name); + const auto& result = defmode_transition(current.m_defmode, incoming_mode); + // The transition table is the single statement of the redefinition + // policy, for klammers and option sets alike; only the noun in its + // messages is specific to what is being redefined. + std::string message = string_replace(result.message, "Klammer NAME", "Option set NAME"); + message = string_replace(message, "klammer NAME", "option set NAME"); + message = string_replace(message, "NAME", q_(name + ".o")); + message = string_replace(message, "AT", current.m_loc.desc()); + if (!result.replace) { + if (message.empty()) { // a default silently superseded + modify_type(katom_t::replaced, begin, end); + auto next_iter = end; + ignore_whitespace(next_iter, katoms); + return; + } + throw Definition_error(message, begin->m_loc); + } + if (result.warn) { + warning(message, begin->m_loc); + } + } + + // Optional parameters only. A positional parameter is not writer-facing + // -- the author never types its name -- so there is nothing for an option + // set to standardize, and a set of them would be a klammer signature + // rather than a shared vocabulary. + if (!parameters.m_positional.empty() || !parameters.m_rest.empty()) { + std::vector names {}; + for (const auto& p : parameters.m_positional) names.push_back(p.m_name); + for (const auto& p : parameters.m_rest) names.push_back(p.m_name); + throw Definition_error( + "The option set " + q_(name) + " declares the positional " + + plural("parameter", static_cast(names.size())) + " " + + join(names, ", ") + ".\n" + "An option set declares only optional parameters -- names written with " + "a leading \":\".", + begin->m_loc, false); + } + if (parameters.m_optional.empty()) { + throw Definition_error( + "The option set " + q_(name) + " declares no parameters.\n" + "The form is: @@" + name + ".o :name.argtype default ... : @@", + begin->m_loc, false); + } + + // The members as written: these katoms are what is spliced into the + // parameter list of a declaration that uses the set. + auto [positional_katoms, member_katoms] = + parameter_split(parameters.m_katoms.cbegin(), parameters.m_katoms.cend()); + (void)positional_katoms; // already rejected above + // The two views of the members -- as katoms and as parsed parameters -- + // are used together when the set is spliced into a parameter list, and + // are paired by position. + if (member_katoms.size() != parameters.m_optional.size()) { + throw Internal_error( + "The option set " + q_(name) + " parsed " + std::to_string(parameters.m_optional.size()) + + " parameters from " + std::to_string(member_katoms.size()) + " declarations", + begin->m_loc); + } + + Option_set option_set(name, to_string(body, true), parameters, member_katoms, begin->m_loc); + option_set.m_defmode = incoming_mode; + if (has(name)) { + // A redefinition keeps the users recorded so far: they used the name, + // and the name is what they are bound to. + option_set.m_users = m_option_sets.at(name).m_users; + } else { + m_names.push_back(name); + } + m_option_sets[name] = option_set; + + modify_type(katom_t::replaced, begin, end); + auto next_iter = end; + ignore_whitespace(next_iter, katoms); +} + +bool Option_set_registry::has(const std::string& name) const +{ + return m_option_sets.count(name) > 0; +} + +const Option_set& Option_set_registry::get(const std::string& name, const Locator& loc) const +{ + auto it = m_option_sets.find(name); + if (it == m_option_sets.end()) { + throw Definition_error("The option set " + q_(name) + " is not declared", loc); + } + return it->second; +} + +void Option_set_registry::add_user(const std::string& set_name, const std::string& klammer_name) +{ + auto it = m_option_sets.find(set_name); + if (it != m_option_sets.end() && !is_in(klammer_name, it->second.m_users)) { + it->second.m_users.push_back(klammer_name); + } +} + +std::string Option_set_registry::available() const +{ + return m_names.empty() ? "(none are declared)" : join(m_names, ", "); +} + +std::string Option_set_registry::describe(int margin) const +{ + std::string result {}; + for (const auto& name : m_names) { + result += m_option_sets.at(name).describe(margin) + "\n"; + } + return result; +} + +// --- Use in a parameter list ---------------------------------------------- + +namespace { + +// The end of the application opening at `begin`: one past its closing +// delimiter. Depth is counted over klammer applications only, which is all +// that can nest inside an option-set use. +katom_iter application_end(katom_iter begin, katom_iter end) +{ + int depth = 0; + for (auto k = begin; k != end; ++k) { + if (begin_klammer_apply(*k)) { + ++depth; + } else if (end_klammer_apply(*k)) { + if (--depth == 0) return k + 1; + } + } + throw Definition_error( + "The klammer " + q_(trim_char(begin->m_text, '@')) + + " in a parameter list is not closed", begin->m_loc); +} + +// The parameter name in an option-name katom, which carries the type and any +// target as written: ":number.bool" declares the parameter named "number". +std::string option_name(const Katom& katom) +{ + std::string name(katom.m_text, 1); + auto type = name.find('.'); + return type == std::string::npos ? name : name.substr(0, type); +} + +// How a definition is named in a diagnostic: a general definition has no +// target to name, and the pseudo-targets read better with their suffix. +std::string definition_name(const std::string& klammer_name, const std::string& target_name) +{ + return target_name == Target_registry::general_name + ? q_(klammer_name) : q_(klammer_name + "." + target_name); +} + +// A klammer application in a parameter list is never legal. Which of the two +// ways it is wrong decides what the writer has to do about it, so the message +// says which. +[[noreturn]] void reject_application( + const std::string& name, const std::string& klammer_name, + const std::string& target_name, const Option_set_registry& option_sets, + const Locator& loc) +{ + std::stringstream ss {}; + if (option_sets.has(name) && target_name == Target_registry::optionset_name) { + ss << "The option set " << q_(name) << " is used in the declaration of the option " + << "set " << q_(klammer_name) << ".\n" + << "An option set is used only in the parameter list of a \".k\" declaration, " + << "so a set does not include another set: a klammer that needs two " + << "vocabularies names two sets, and each set stays a vocabulary that can " + << "be learned whole."; + } else if (option_sets.has(name)) { + ss << "The option set " << q_(name) << " is used in the parameter list of " + << definition_name(klammer_name, target_name) << ".\n" + << "An option set may be used only in the parameter list of a \".k\" " + << "declaration, which is where a klammer's interface is declared once " + << "for all of its targets. Declare " + << q_(klammer_name + ".k") << " and give each target's definition as " + << "an instance (\"::\"), which inherits the declared parameters."; + } else { + ss << "The klammer " << q_(name) << " is applied in the parameter list of " + << definition_name(klammer_name, target_name) << ".\n" + << "A klammer application in a parameter list is not allowed: it is " + << "resolved after the parameters are parsed, so the parameter list it " + << "was meant to contribute is not there when the list is read. An " + << "option set, declared with a \".o\" target, is how parameters are " + << "shared between klammers. Declared option sets: " + << option_sets.available() << "."; + } + throw Definition_error(ss.str(), loc, false); +} + +// The default written for each member at the use site: +// @caption_args :number false :caption_side top @ +// Only defaults may be given -- names and types belong to the set. +std::map use_site_defaults( + const Option_set& option_set, katom_iter begin, katom_iter end) +{ + auto [positional, optional, rest] = argument_split(begin + 1, end - 1, 0); + if (!positional.empty() || active(rest)) { + throw Definition_error( + "The use of the option set " + q_(option_set.m_name) + + " gives a value that is not an option.\n" + "A set's names and types are fixed where the set is declared; only a " + "default may be given where it is used, written as \":name value\".", + begin->m_loc, false); + } + std::map defaults {}; + for (const auto& option : optional) { + std::string name = option_name(option[0]); + if (name.size() + 1 != option[0].m_text.size()) { + throw Definition_error( + "The use of the option set " + q_(option_set.m_name) + " gives a type for \":" + + name + "\".\n" + "A set's names and types are declared where the set is; only a default " + "may be given where it is used.", + option[0].m_loc, false); + } + const Parameter* member = option_set.find(name); + if (member == nullptr) { + throw Definition_error( + "The option set " + q_(option_set.m_name) + " has no parameter \":" + + name + "\".\n It declares: " + option_set.member_names(), + option[0].m_loc, false); + } + if (defaults.count(name) > 0) { + throw Definition_error( + "A default for \":" + name + "\" is given more than once in the use of " + "the option set " + q_(option_set.m_name), + option[0].m_loc); + } + std::string value = trim(to_string(katom_list(option.begin() + 1, option.end()))); + // A bare option name means the argument type's :alone value, exactly + // as it does where an argument is written. + if (value.empty() && !member->m_argtype.m_alone.empty()) { + value = member->m_argtype.m_alone; + } + // Validated here, at definition time: an invalid default must not + // wait for an application that happens not to supply the argument. + Parameter_set::validate(*member, value, option[0].m_loc); + defaults[name] = value; + } + return defaults; +} + +} // namespace + +option_set_uses_t expand_option_sets( + katom_list& parameters, const std::string& klammer_name, + const std::string& target_name, Option_set_registry& option_sets) +{ + option_set_uses_t uses {}; + // Every parameter name in the list, and where it came from, so that a + // collision between two sets (or between a set and a name written here) + // can name both origins rather than just the name. + std::map origin {}; + auto declare = [&](const std::string& name, const std::string& from, const Locator& loc) { + auto previous = origin.find(name); + if (previous != origin.end()) { + throw Definition_error( + "The parameter \":" + name + "\" of " + q_(klammer_name) + + " is declared twice:\n " + previous->second + "\n " + from, + loc, false); + } + origin[name] = from; + }; + + katom_list result {}; + result.reserve(parameters.size()); + for (auto k = parameters.begin(); k != parameters.end(); ) { + if (!begin_klammer_apply(*k)) { + if (k->m_type == katom_t::option_name) { + declare(option_name(*k), "declared in the parameter list", k->m_loc); + } + result.push_back(*k); + ++k; + continue; + } + std::string name = trim_char(k->m_text, '@'); + auto end = application_end(k, parameters.end()); + if (target_name != Target_registry::declare_name || !option_sets.has(name)) { + reject_application(name, klammer_name, target_name, option_sets, k->m_loc); + } + const Option_set& option_set = option_sets.get(name, k->m_loc); + std::map defaults = use_site_defaults(option_set, k, end); + + // m_members and m_parameters.m_optional are the same members in the + // same order -- one is the katoms as declared, the other what they + // parsed to -- so the parameter's name is taken from the parsed side + // rather than re-derived from the katom text (":number.bool"). + for (size_t i = 0; i < option_set.m_members.size(); ++i) { + const katom_list& member = option_set.m_members[i]; + const std::string& member_name = option_set.m_parameters.m_optional[i].m_name; + declare(member_name, "from the option set " + q_(name) + ", " + + option_set.m_loc.desc(), k->m_loc); + if (!result.empty()) { + result.push_back(Katom(" ", katom_t::space, member[0].m_loc)); + } + // The name katom comes from the set, so the parameter's location + // is where it is declared; an overriding default comes from here. + result.push_back(member[0]); + auto given = defaults.find(member_name); + if (given == defaults.end()) { + result.insert(result.end(), member.begin() + 1, member.end()); + } else if (!given->second.empty()) { + result.push_back(Katom(" ", katom_t::space, k->m_loc)); + result.push_back(Katom(given->second, katom_t::text, k->m_loc)); + } + uses[member_name] = {name, given != defaults.end()}; + } + option_sets.add_user(name, klammer_name); + k = end; + } + parameters = result; + return uses; +} + +void stamp_option_set_uses(Parameter_set& parameters, const option_set_uses_t& uses) +{ + if (uses.empty()) return; + for (Parameter& parameter : parameters.m_optional) { + auto use = uses.find(parameter.m_name); + if (use == uses.end()) continue; + parameter.m_option_set = use->second.m_set; + parameter.m_default_overridden = use->second.m_overridden; + } +} diff --git a/mac/option_set_registry.h b/mac/option_set_registry.h new file mode 100644 index 0000000..226f41d --- /dev/null +++ b/mac/option_set_registry.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include + +#include "argtype_registry.h" +#include "katom.h" +#include "option_set.h" + +class Option_set_registry +{ +public: + // Parse and register an "@@name.o : @@" + // declaration. The name is passed in because the caller has already + // split it from its ".o" target suffix in order to route here. + void add(const Argtype_registry& argtypes, const std::string& name, + katom_iter begin, katom_iter end, katom_list& katoms); + + bool has(const std::string& name) const; + const Option_set& get(const std::string& name, const Locator& loc) const; + void add_user(const std::string& set_name, const std::string& klammer_name); + + std::string describe(int margin = 2) const; + // "a, b, c" -- the sets that exist, for a diagnostic about one that does not. + std::string available() const; + + std::map m_option_sets {}; + std::vector m_names {}; +}; + +// What one parameter inherited from an option set: which set it came from, +// and whether its default was overridden where the set was used. Default +// resolution has three levels -- argument type, option set, use site -- and +// kdesc says which one a klammer's effective default came from, so the level +// has to be recorded rather than inferred from the value. +struct Option_set_use +{ + std::string m_set {}; + bool m_overridden {}; +}; + +// parameter name -> where it came from +using option_set_uses_t = std::map; + +// Replace every option-set use in a parameter list with the parameters it +// stands for. Any other klammer application in a parameter list is an +// error: an option set is the only construct that may put parameters there, +// and only in a ".k" declaration. +// +// `parameters` is modified in place. Sets are recorded as used by +// `klammer_name`, so `option_sets` is not const. +option_set_uses_t expand_option_sets( + katom_list& parameters, const std::string& klammer_name, + const std::string& target_name, Option_set_registry& option_sets); + +// Record on each parameter that came from a set which set it came from and +// whether its default was overridden. Called after the expanded parameter +// list has been parsed. +void stamp_option_set_uses(Parameter_set& parameters, const option_set_uses_t& uses); diff --git a/mac/show.cpp b/mac/show.cpp index 2b6e405..88ac4a0 100644 --- a/mac/show.cpp +++ b/mac/show.cpp @@ -570,6 +570,7 @@ std::ostream& operator<<(std::ostream& os, const Machine& m) std::string state_desc = m.m_state.describe(false, 3); std::string targets_desc = m.m_targets.describe(4); std::string klammersets_desc = m.m_klammersets.describe(4); + std::string option_sets_desc = m.m_option_sets.describe(4); std::string klammer_desc = m.m_klammers.describe(2); std::string source_desc = describe_sources(m); @@ -578,6 +579,7 @@ std::ostream& operator<<(std::ostream& os, const Machine& m) << label("Argtypes", m.m_argtypes.m_names.size()) << argtypes_desc << "\n" << label("Targets", m.m_targets.m_names.size()) << targets_desc << "\n" << label("Klammersets", m.m_klammersets.m_symbols.size()) << klammersets_desc << "\n" + << label("Option sets", m.m_option_sets.m_names.size()) << option_sets_desc << "\n" << label("Klammers", m.m_klammers.m_klammers.size()) << klammer_desc << "\n" << label("State", m.m_state.m_frames.size()) << state_desc; return os; diff --git a/mac/target_registry.cpp b/mac/target_registry.cpp index 093c22c..76bc6ff 100644 --- a/mac/target_registry.cpp +++ b/mac/target_registry.cpp @@ -11,14 +11,17 @@ std::string Target_registry::declare_name = "k"; std::string Target_registry::general_name = "*"; +std::string Target_registry::optionset_name = "o"; Target_registry::Target_registry() : m_parameters(Parameter_set("name | desc :after_apply :after_write :includes :escape | transforms.rest")) { Target k(declare_name, "Description of parameters and klammer result", Locator()); Target general(general_name, "General target, used when a target is not specified", Locator()); + Target option_set(optionset_name, "Declaration of an option set: parameters shared by klammers", Locator()); add(k); add(general); + add(option_set); } void Target_registry::add(Target target) @@ -104,14 +107,19 @@ std::vector Target_registry::user_defined() const { return collect_if( m_names, [](const auto& name) { - return name != Target_registry::declare_name && name != Target_registry::general_name; }); + return name != Target_registry::declare_name + && name != Target_registry::general_name + && name != Target_registry::optionset_name; }); } +// The targets a klammer body can be applied under. Neither "k" nor "o" +// produces output: both declare an interface and describe it. std::vector Target_registry::applicable() const { return collect_if( m_names, [](const auto& name) { - return name != Target_registry::declare_name; }); + return name != Target_registry::declare_name + && name != Target_registry::optionset_name; }); } std::string Target_registry::describe(int margin, bool long_format) const diff --git a/mac/target_registry.h b/mac/target_registry.h index 58c3ba4..c110110 100644 --- a/mac/target_registry.h +++ b/mac/target_registry.h @@ -11,6 +11,10 @@ class Target_registry public: static std::string declare_name; static std::string general_name; + // The pseudo-target of an option set declaration (@@name.o), reserved in + // the target namespace exactly as "k" is: both declare an interface and + // document it, and neither produces output for any target. + static std::string optionset_name; Target_registry(); diff --git a/sks/block/block.k b/sks/block/block.k index 219b3d1..b5799a7 100644 --- a/sks/block/block.k +++ b/sks/block/block.k @@ -1,3 +1,26 @@ +#[ +A paragraph is a document-structural object, like a section or a table of +contents, and making paragraphs is a service @document provides: inside a +@document, blank-line-separated text becomes paragraphs (the LaTeX +convention) and @par need not be written. @par is the explicit form, for +a text FRAGMENT rendered without a @document, where nothing infers them. +Written inside a @document it is harmless: html sees a

, which the +paragraph pass recognizes as a block and leaves alone, and \par in +vertical mode is a no-op. +]# + +@@par.k s : A paragraph of text @@ +@@par.html ::

*s*

@@ +@@par.tex :: +\par +*s* +\par +@@ +# In plain text a paragraph is delimited by blank lines, which +# phases.justify_blocks then fills; #/2 inserts them without depending on +# the definition body's own whitespace surviving extraction. +@@par.txt :: #/2*s*#/2 @@ + @@sp.k : Non-breaking space character @@ @@sp.html :: &^#160; @@ @@sp.tex :: ~ @@ diff --git a/sks/block/block.py b/sks/block/block.py index 40f3b6e..cf97fc0 100644 --- a/sks/block/block.py +++ b/sks/block/block.py @@ -27,7 +27,7 @@ class Indent(klammer_base.Klammer_base): result = tab + latex_util.minipage( "\\raggedright " + self.s, f"\\textwidth - {self.w}ex", center=False, vmargin="4pt") #print(result) - return result + return latex_util.block(result) def txt(self): indent = " " * self.w @@ -54,13 +54,15 @@ class Note(klammer_base.Klammer_base): #width = r'\\textwidth - 16pt - {}\\leftmargin'.format(self.level) width = r'\\linewidth - \\leftmargin + 2pt' + # The \par on each side comes from latex_util.block() below: an + # \fcolorbox is box material and must sit in vertical mode. result = ''' -\\par\\begingroup +\\begingroup COLOR\\setlength{\\fboxsep}{8pt} \\fcolorbox{bordercolor}{localcolor}{ \\parbox{WIDTH}{\\raggedright\\setlength{\\parskip}{8pt} \\textbf{LABEL:} TEXT -}}\\endgroup\\par +}}\\endgroup ''' result = re.sub('LABEL', self.label, result) result = re.sub('TEXT', re.sub(r'\\', r'\\\\', self.s), result) @@ -68,7 +70,7 @@ COLOR\\setlength{\\fboxsep}{8pt} result = re.sub('COLOR', r'\\definecolor{{bordercolor}}{{rgb}}{{{}}}\n'.format(self.bordercolor), result) result = re.sub('WIDTH', width, result) print(result) - return result + return latex_util.block(result) class Block(klammer_base.Klammer_base): @@ -80,6 +82,9 @@ class Block(klammer_base.Klammer_base): return "" def tex(self): + # NOT latex_util.block(): a textblock is absolutely positioned and + # does not participate in the normal flow, so breaking the paragraph + # around it would move the surrounding text. to_x, to_y = [float(e) for e in self.to.split()] pt_x, pt_y = [float(e) for e in self.point.split()] result = f""" diff --git a/sks/code/code.k b/sks/code/code.k index 878c6c0..a9d1abe 100644 --- a/sks/code/code.k +++ b/sks/code/code.k @@ -1,128 +1,20 @@ -@@code.k :filename :pattern :caption :number.bool | text.literal : +@@code.k :filename :pattern + @hpos_args :hpos left @ + @caption_args :caption_side top @ + | text.literal : A source file displayed verbatim @@ -@@code :: @eval code_format.Code(K) @ @@ +@@code :: @eval code_block.Code(K) @ @@ @@c.k code_text : A word or phrase displayed verbatim in a line @@ -@@c :: @eval code_format.Code_fragment(K) eval@ +@@c :: @eval code_block.Code_fragment(K) eval@ @@ # :cwd makes the filename resolve against the DOCUMENT's directory, not # the directory ktext happens to run in. -@@source_file filename : @eval :cwd *K_input_dir* code_format.Source(K) @ @@ - - -#[ -@@code.k text :file :number.bool true :caption : Code listing with formatted comments @@ - -@@code : -@eval code_format.Code(K) eval@ -@@ - - -@@lst spec.figure_id : - @reference *spec* | Listing @ -@@ - - - -c -code :caption :filename :pattern - - -# -------------------------------------------------------------------------------- -@@codebox.k s :color 1,1,1 :size normalsize :escapechar ^^ - :space_break_only.bool false :linenumber.bool false :scale 1.0 - :indent :standalone :vcenter : -Verbatim text for source code preserving whitepace, surrounded by a box -that extends to the margins @@ - -@@codebox.html :: - @code :text *s* @ -@@ - -@@codebox.tex :: -\definecolor{codeboxbgcolor}{rgb}{*color*} -\setlength{\codeboxlinelength}{\linewidth - 6pt} -\vspace*{4pt} -\begin{lstlisting}% -[frame=single, -framerule=1pt, -basicstyle=\*size*\ttfamily, -lineskip=0pt, -linewidth=*scale*\codeboxlinelength, -columns=fullflexible, -keepspaces=true, -framesep=6pt, -xleftmargin=6pt, -escapechar=*escapechar*, -breaklines=true, -prebreak=\hbox{\large$\mapsto$}, -%postbreak={\textbf{\hbox{$\rightarrow$}}}, -rulecolor=\color{codeboxcolor}, -backgroundcolor=\color{codeboxbgcolor}, -breakatwhitespace=*space_break_only*, -numbers=none, # #- @if *linenumber* | left | none @ #- , -numbersep=12pt, -numberstyle=\small\color{Darkred}] -*s* -\end{lstlisting} -@@ - -@@codebox.txt :: - @code :text *s* @ -@@ - -@@pathname.k s :small.bool false : Pathname @@ -@@pathname :: @eval code_format.Pathname(K) eval@ @@ - -@@annotate.k text :caption : -Comments put in boxes to the right of the code -@@ - -# @@annotate : @eval code_format.Annotate(K) eval@ @@ -# @@annotate : @codebox *text* @ @@ -@@annotate :: @code :text *text @ @@ - -@@listing s : @code :text *s* @ @@ - -# -------------------------------------------------------------------------------- -@@sv.k s : Italic font for variable in @t syntax @ argument @@ -@@sv.tex :: ^^textrm"^^textit"*s*$$ @@ -@@sv.html :: @ri *s* @ @@ - -@@svs.k s : Sans-serif font for variable in @t syntax @ argument @@ -@@svs.tex :: "^^small^^textsf"^^textit"*s*$$$ @@ -@@svs.html :: @s @i *s* @ @ @@ - -@@svsub.k base | sub : Italic font for subscripted variable in @t syntax @ argument @@ -@@svsub.tex :: ^^textrm"^^textit"*base*$$^^textsubscript"*sub*$ @@ -@@svsub.html :: *base**sub* @@ - -@@syntax.k s :fontsize normalsize :indent.bool true : -Verbatim text that includes italic font for syntax descriptions @@ - -@@syntax.html :: -@code :text *s* @ # :indent *indent* @ -@@ - -@@syntax.tex :: -\vspace*{8pt}\begin{LVerbatim}[xleftmargin=0pt, # @if true | 0pt | -24pt @ , -baselinestretch=1.05, fontsize=\*fontsize*, frame=single, framesep=8pt, -commandchars=\\̈\$, fontfamily = @verbatimfont@, framesep=12pt] -*s* -\end{LVerbatim} -@@ - -@@svspace.tex length : - ^^vspace*"*length*$ -@@ - -@@svspace.html length : -@@ -]# +@@source_file filename : @eval :cwd *K_input_dir* code_block.Source(K) @ @@ diff --git a/sks/code/code_block.py b/sks/code/code_block.py new file mode 100644 index 0000000..bb62ce0 --- /dev/null +++ b/sks/code/code_block.py @@ -0,0 +1,361 @@ +if __name__ == "__main__": + import sys + sys.path.append("../kutil") + sys.path.append("../target") + +import sys +import re +import klammer_base +import kutil +import html_util +from html_util import E +import latex_util as L +import pprint +import phases + +def escape_newlines(s): + # A newline becomes the marker the html paragraph pass turns back into a + # line break, so a verbatim source file keeps its lines. Used by Source. + return re.sub("\n", " ___NL___ ", s) + +def undash(s): + # Verbatim text must show the hyphens the writer typed: the target's + # "--"/"---" transforms have already run, so put them back. Used by + # Code_fragment. + result = re.sub("__MDASH__", "---", s) + return re.sub("__NDASH__", "--", result) + +def is_comment(s): + return s.strip().startswith("//") + +def split_blocks(s): + kutil.msg() + blocks = [] + in_code = True + block = "" + for line in s.rstrip().split("\n"): + if is_comment(line): + if in_code: + blocks.append(block) + block = line + "\n" + in_code = False + else: + block += line + "\n" + else: + if not in_code: + blocks.append(block) + block = line + "\n" + in_code = True + else: + block += line + "\n" + if block: + blocks.append(block) + return blocks + +def get_lines(comment, code, n): + kutil.msg() + lines = code.split("\n") + if len(lines) < n: + raise Exception( + f"Error in @code block comment:\n{comment}\n{code}\nNeed {n} lines, but there are only {len(lines)}") + if len(lines) == n: + return (code, "") + else: + return ("\n".join(lines[:n]), "\n".join(lines[n:])) + +def parse_blocks(blocks): + kutil.msg() + box_comment_rgx = re.compile("\\s*//(\\d+)\\s+.*", re.S) + i = 0 + block_pairs = [] + while i < len(blocks): + match = box_comment_rgx.match(blocks[i]) + if match: + boxed_code, rest_code = get_lines(blocks[i], blocks[i+1], int(match.group(1))) + block_pairs.append([boxed_code, blocks[i]]) + block_pairs.append([rest_code, ""]) + i += 2 + else: + block_pairs.append([blocks[i], ""]) + i += 1 + return block_pairs + + +# Characters that are special to LATEX but not to Klammertext. # and ^ are +# deliberately ABSENT: they are Klammertext specials and are handled by +# quote_specials() below, not here -- see tex_line(). +latex_escapes = { + "\\": "\\textbackslash{}", "{": "\\{", "}": "\\}", "%": "\\%", + "$": "\\$", "&": "\\&", "_": "\\_", "~": "\\textasciitilde{}", + "<": "\\textless{}", ">": "\\textgreater{}", +} +latex_escape_rgx = re.compile("[" + re.escape("".join(latex_escapes)) + "]") + +def escape_latex(s): + r"""Escape LaTeX special characters in code text. + + ONE pass, not a sequence of str.replace() calls: a sequence corrupts + the replacements it has already made. Replacing \ first yields + \textbackslash{}, whose braces the later { and } replacements then + escape in turn -- so a C++ "\n" typesets as \{}n. A single regex pass + over the original string cannot revisit its own output. + """ + return latex_escape_rgx.sub(lambda m: latex_escapes[m.group()], s) + +def quote_specials(s): + r"""Quote the Klammertext special characters in code text. + + An @eval result is RE-READ as Klammertext, so a special character that + reaches the result raw is interpreted again: a # in "#include" starts a + text removal and silently eats the rest of the line. Quoting is the + fix, and it must be quoting rather than LaTeX escaping -- \# would put + the # back and be eaten in turn. + + The quoted forms resolve at final processing: the tex target declares + ^# -> \# and ^^ -> \textasciicircum{} in its :escape list, and any + other quoted special decodes back to its own character. So the target, + not this code, decides what a # becomes in LaTeX. + + ^ is quoted FIRST, since the other quotings introduce ^ characters. + """ + for ch in "^@#|": + s = s.replace(ch, "^" + ch) + return s + +def tex_line(line): + r"""One code line, ready to be a \klline argument. + + Three steps in this order: LaTeX-escape the characters only LaTeX cares + about; quote the ones Klammertext would re-interpret (the escapes above + introduce none of them, so the two passes cannot interfere); then make + every space a ~ -- a non-breaking space, exactly one character wide in + a monospace font, which LaTeX will not collapse -- so the code's + indentation and internal alignment survive. + """ + return quote_specials(escape_latex(line)).replace(" ", "~") + +def widest_line(lines): + r"""The line to measure the block's width with. + + Measured on the RAW lines: in a monospace font character count is + exact, whereas the escaped text is longer than it typesets + (\textbackslash{} is eleven characters and one glyph). The chosen + line is escaped afterwards, for \settowidth. + """ + return max(lines, key=len) if lines else "" + +def tex_block(code, comment): + r"""One block: its lines, shaded if the block is annotated, beside its + comment. See the "Annotated code listings" section of sty/code.sty, + which owns the layout; this only supplies the three arguments.""" + lines = code.strip("\n").split("\n") + if not any(line.strip() for line in lines): + return "" + # Every line is a plain \klline; whether the BLOCK is shaded is decided + # by \klcodebox from the comment, because the shading is one box around + # the block (that is what gives it vertical padding). + body = "\\\\\n".join(f"\\klline{{{tex_line(line)}}}" for line in lines) + return ("\\klblock{" + tex_line(widest_line(lines)) + "}{%\n" + + body + "}{" + comment + "}\n") + +def comment_text(comment): + """The prose of a block comment: the // and the line count removed.""" + return re.sub(r"^\s*//\d*\s*", "", comment.strip(), flags=re.S).strip() + +html_escapes = {"&": "&", "<": "<", ">": ">"} +html_escape_rgx = re.compile("[" + re.escape("".join(html_escapes)) + "]") + +def html_line(text): + """Code text as html: the markup characters escaped, then the + Klammertext specials quoted for the @eval read-back (same reason as + tex_line -- a raw # in "#include" would start a text removal). The + html entities introduce no Klammertext special, so the two passes + cannot interfere. No target :escape entries apply here, so each + quoted special decodes back to its own character.""" + return quote_specials(html_escape_rgx.sub( + lambda m: html_escapes[m.group()], text)) + +def html_block(code, comment): + r"""One block: its lines beside its comment. + + The layout is CSS (sks/code/css/code.css): .code_block is a flex row + with align-items center -- the same model as the LaTeX \parbox[c] + pair -- and .code_text is an inline-block with white-space: pre, so + its shrink-to-fit width IS the block's longest line and the shading + is one solid rectangle with no per-line work. Nothing is measured + here: unlike LaTeX, the browser does the layout. + """ + lines = code.strip("\n").split("\n") + if not any(line.strip() for line in lines): + return "" + shade = "code_border" if comment else "code_no_border" + result = f'
{html_line(code.strip(chr(10)))}
' + if comment: + result += f'
{comment}
' + return f'
{result}
\n' + + +class Code(klammer_base.Klammer_base): + id = 0 + def __init__(self, K): + super().__init__(K) + self.text = phases.expand_whitespace_markers(self.text) + + def annotated(self, pairs): + """Does any block of this listing carry a comment?""" + return any(comment.strip() for _, comment in pairs) + + def tex(self): + # No table: comments sit a fixed distance from their own block and + # deliberately do not align with each other, and each block's box is + # as wide as that block's longest line — so there is no column to + # align and nothing for a table to do. The layout is in + # sty/code.sty; see its "Annotated code listings" section. + # + # This is a RENDERER (final LaTeX, no klammers in the result), so + # the Klammermachine leaves it alone. It must never emit @code: + # a klammer that generates itself re-enters its own body with no + # base case, which the depth guard catches at 200 levels. + pairs = parse_blocks(split_blocks(self.text)) + blocks = "".join(tex_block(code, comment_text(comment)) + for code, comment in pairs) + listing = "\\begin{klcode}\n" + blocks + "\\end{klcode}\n" + captioned = bool(self.number or self.caption) + annotated = self.annotated(pairs) + inset = L.offset_length(self.offset) + + # A caption does NOT by itself require a box, and boxing a listing + # costs page breaking -- a minipage cannot break, and listings are + # often long. Only two things genuinely need the shared box that + # add_caption builds: + # + # * a caption BESIDE the listing (:caption_side left or right), + # which has to know the listing's width; + # * an unannotated listing that must be centered or right-aligned, + # which has to be measured before it can be moved. + # + # Everything else is placed unboxed, and keeps breaking: the offset + # becomes \klshift/\klindent inside the environment, and the caption + # becomes a paragraph above or below, indented and width-matched to + # the listing. An annotated listing never needs measuring -- it + # spans the text column by construction. + beside = captioned and self.caption_side in ("left", "right") + must_measure = not annotated and (self.hpos != "left" or inset != "0pt") + if beside or must_measure: + return L.block(self.tex_boxed(listing, pairs, annotated, + captioned, inset)) + return L.block(self.tex_unboxed(listing, annotated, captioned, inset)) + + def tex_boxed(self, listing, pairs, annotated, captioned, inset): + r"""The listing as a box: placed and captioned like a table or an + image, at the cost of not breaking across pages.""" + measure = "" + if annotated: + # It spans the text column, so an offset narrows it rather than + # moving it; a centered element has no margin to be inset from. + width = ("\\linewidth" if self.hpos == "center" + else f"\\dimexpr\\linewidth-{inset}\\relax") + else: + # The \settowidth must stay OUTSIDE the minipage it sizes. + lines = [line for code, _ in pairs + for line in code.strip("\n").split("\n")] + measure = ("\\settowidth{\\kllistingwidth}{\\ttfamily " + + tex_line(widest_line(lines)) + "}\n") + width = "\\kllistingwidth" + boxed = L.minipage(listing, width, vertical="t", center=False) + if captioned: + # add_caption attaches the caption to the box and hands the pair + # to caption_wrapper, so :pos and :offset move both together. + return measure + L.add_caption( + boxed, "Listing", self.number, self.caption, width, + hpos=self.hpos, side=self.caption_side, + font_symbol=self.caption_font, + font_size=self.caption_font_size, offset=self.offset) + return measure + L.caption_wrapper(boxed, self.hpos, + offset=self.offset) + + def tex_unboxed(self, listing, annotated, captioned, inset): + r"""The listing in the running vertical list, so it can break across + pages. The offset is \leftskip plus a matching reduction of the + width \klblock computes its comment column from; the caption is a + paragraph on the same indent and width.""" + shift = inset if self.hpos == "left" else "0pt" + # \hpos center cannot move a full-width listing, so it takes no + # offset -- the same rule the boxed path and caption_wrapper use. + indent = "0pt" if (annotated and self.hpos == "center") else inset + setup = (f"\\setlength{{\\klshift}}{{{shift}}}" + f"\\setlength{{\\klindent}}{{{indent}}}\n") + result = setup + listing + if captioned: + caption = L.make_caption_text( + self.number, "Listing", self.caption, + self.caption_font, self.caption_font_size) + # \nobreak: a caption must not be separated from its listing by + # a page break, even though the listing itself may break. + block = (f"\\noindent\\hspace*{{{shift}}}" + f"\\parbox[t]{{\\dimexpr\\linewidth-{indent}\\relax}}" + f"{{{caption}}}\\par") + if self.caption_side == "top": + result = block + "\\nobreak\n" + result + else: + result = result + "\\nobreak\n" + block + "\n" + return result + + def html(self): + pairs = parse_blocks(split_blocks(self.text)) + result = "".join(html_block(code, comment_text(comment)) + for code, comment in pairs) + # The same two cases as tex_place, with the browser doing the work. + # An annotated listing's rows must stay full width so the comment + # column's flex: 1 has a remainder to take; an unannotated one gets + # width: fit-content, which is what lets the position container + # center or right-align it. + listing = E("div").cls("code_listing").body(result) + if not self.annotated(pairs): + listing.cls("code_listing_box") + # As in tex: the caption goes through the shared helper, so it sits + # in the same position container as the listing and moves with it. + if self.number or self.caption: + return html_util.add_caption( + listing, "Listing", self.number, self.caption, + self.caption_font, self.hpos, self.caption_side, True, + self.caption_font_size, offset=self.offset) + return html_util.hpos_container(listing, self.hpos, self.offset).str() + + +class Code_fragment(klammer_base.Klammer_base): + def __init__(self, K): + super().__init__(K) + + def html(self): + #print(f"code: |{self.code_text}|") + result = self.code_text.strip() + result = undash(result) + #result = re.escape(result) + result = re.sub("<", "<", result) + result = re.sub(" ", " ", result) + #print(f"code: |{self.code_text}| -> |{result}|") + return f'{result}' + + def tex(self): + return f"{{\\tt {self.code_text.strip()}}}" + + +class Source(klammer_base.Klammer_base): + def __init__(self, K): + super().__init__(K) + with open(self.filename) as fp: + self.src = fp.read() + + def tex(self): + result = self.src + # result = re.sub("#", "^#", result) + # result = re.sub("\\^", "\\^", result) + result = f"\\begin{{verbatim}}\n{result}\n\\end{{verbatim}}\n" + return result + + def html(self): + result = escape_newlines(self.src.strip()) + "\n" + result = re.sub("@", "^@", result) + result = E("div").body(result).cls("code_text").str() + return result diff --git a/sks/code/code_format.py b/sks/code/code_format.py index 96f146b..1f4f034 100644 --- a/sks/code/code_format.py +++ b/sks/code/code_format.py @@ -1,3 +1,5 @@ +print("DEPRECATED") + if __name__ == "__main__": import sys sys.path.append("../kutil") @@ -22,6 +24,49 @@ def escape_newlines(s): # backslash_pat = re.compile(r'(".*?)\n(.*?")', re.S) # return backslash_pat.sub(replace, s) +def is_comment(s): + return s.strip().startswith("//") + +def split_blocks(s): + print('-'*40) + print(s) + print('-'*40) + blocks = [] + in_code = True + block = "" + for line in s.rstrip().split("\n"): + if is_comment(line): + if in_code: + blocks.append(block) + block = line + "\n" + in_code = False + else: + block += line + "\n" + else: + if not in_code: + blocks.append(block) + block = line + "\n" + in_code = True + else: + block += line + "\n" + if block: + blocks.append(block) + for block in blocks: + print("B:") + print(block) + return blocks + +def parse_blocks(blocks): + box_comment_rgx = re.compile("\s*//(\d+)\s+.*", re.S) + i = 0 + while i < len(blocks): + match = box_comment_rgx.match(blocks[i]) + if match: + print(match.group(1)) + i += 1 + + + def get_blocks(s): comment_pat = re.compile(r"(\s*)//(\d+)\s+(.*)", re.S) blocks = [] @@ -134,7 +179,27 @@ class Code(klammer_base.Klammer_base): s = s.replace(">", "\\textgreater{}") return s + def code_box(self, text): + kutil.msg(text) + result = "" + for line in text.rstrip().split("\n"): + indent = len(line) - len(line.lstrip()) + eline = ("~" * indent) + line[indent:] + print(indent, line) + print(eline) + result += eline + "\\\\\n" + result = result[:-3] + print("RESULT:") + print(result) + return result + def tex(self): + parse_blocks(split_blocks(self.text)) + return "" + + + + # Escape Klammertext special characters self.text = self.text.replace("^", "^^") self.text = self.text.replace("#", "^#") @@ -153,6 +218,14 @@ class Code(klammer_base.Klammer_base): result = "" count = len(self.blocks) for text, comment in self.blocks: + print("TEXT:") + print(text) + print("COMMENT:") + print(comment) + + + + text = " " + re.sub("\n", " \n ", text) + " " longest = longest_line(text) text = latex_spaces(text) @@ -160,11 +233,23 @@ class Code(klammer_base.Klammer_base): text = f"{start_strut}\\ttfamily {text}{end_strut}" width = f"\\widthof{{\\ttfamily {longest}}}" code = L.environment("minipage", text, width) + "\\\\\n" + print(code) + + #code = "\\asymbox{" + self.code_box(text) + "}" + #print(code) + + code = text + if comment: - width = f"\\linewidth - {width} - {indent} - {comment_sep}" + #width = f"\\linewidth - {width} - {indent} - {comment_sep}" + """ + width = f"\\linewidth - \widestline - {indent} - {comment_sep}" code = f"\\fcolorbox{{Gray}}{{LightGray}}{{{code}}}" code += f"\\rule{{{comment_sep}}}{{{strutvis}}}" \ + L.environment("minipage", "\\sffamily\\small\\raggedright " + comment, width) + """ + code = "\\asymbox{" + self.code_box(text) + "}" + comment + result += f"\\rule{{{indent}}}{{{strutvis}}}{code}" if comment: if i < count - 1 and self.blocks[i+1][1]: diff --git a/sks/code/css/code.css b/sks/code/css/code.css index 41f9c49..6ba7afb 100644 --- a/sks/code/css/code.css +++ b/sks/code/css/code.css @@ -2,12 +2,36 @@ font-family: var(--monospace); } +/* A whole listing, inside its :hpos position container. Full width by + default so an annotated listing's comment column has a remainder to take + (.code_comment is flex: 1); an UNANNOTATED listing is shrink-to-fit + instead, which is what lets :hpos center or right-align it -- a + full-width child cannot be moved within its container. The LaTeX + counterpart is Code.tex_place. */ +.code_listing { +} + +.code_listing_box { + width: fit-content; +} + +/* An annotated code listing (sks/code/code_block.py). The same model as + the LaTeX side in sty/code.sty: each BLOCK is a run of code lines with + an optional comment beside it, the shaded box is as wide as that block's + longest line, and comments sit a fixed distance from their own block + without aligning with each other. + + align-items: center is the counterpart of LaTeX's \parbox[c] pair. No + vertical margin or padding anywhere in the stack: consecutive blocks + must abut exactly, so that adjacent shaded blocks read as one region and + the line rhythm is the same whether a line is shaded or not. That is + what \strut and \offinterlineskip buy on the LaTeX side; here it is just + the absence of vertical space. */ .code_block { display: flex; align-items: center; - margin: .125rem 0 0 1rem; + margin: 0 0 0 1rem; padding: 0; -/* flex-direction: column-reverse; */ } .code_caption { @@ -15,20 +39,28 @@ font-style: italic; } +/* white-space: pre keeps the code's own spacing, and the shrink-to-fit + width of an inline-block IS the block's longest line — so the shading is + one solid rectangle with no per-line work and nothing measured. Padding + is horizontal only; see the note on .code_block. */ .code_text { display: inline-block; -/* vertical-align: top; */ white-space: pre; font-family: var(--monospace); - line-height: 1.2; + line-height: 1.4; + /* No horizontal padding: the box's left edge sits ON the code column, + where the line's own indentation begins, rather than out to the left + of it. LaTeX counterpart: \klcodepad 0pt. */ + padding: 0; } +/* The comment takes what the block leaves of the containing width — the + browser's counterpart of \dimexpr\linewidth-\klcodewidth-\klcodegap. + min-width is the counterpart of \klcodemin: below it the comment would + be too narrow to set prose in. */ .code_comment { - display: inline-block; -/* vertical-align: top; */ - padding: .25rem; - border: solid white 1px; - padding: .25rem .25rem .25rem .5rem; + flex: 1; + padding-left: .75rem; font-family: var(--sans-serif); font-style: italic; font-size: .8rem; @@ -36,22 +68,23 @@ line-height: 1.25; } +/* An annotated block: background only, no border. A border would need a + matching one on the unshaded blocks to keep the code aligned, and the + design settled on shading alone. */ .code_border { - border: solid gray 1px; - /* margin: .125rem; */ - margin: .125rem .125rem .125rem .25rem; - padding: .125rem .5rem .25rem .5rem; - background-color: rgb(95%,95%,95%); - + background-color: rgb(100%,100%,60%); + /* Clear space above and below a boxed block, so two boxed blocks with + no unboxed lines between them read as two boxes rather than one. + On the block, never on the line: the lines within a block must still + abut, or the shading stops being a solid rectangle. The LaTeX + counterpart is \klboxgap in sty/code.sty. */ + margin: 2px 0; + /* The box's own vertical margin: space inside the shading, above the + first line and below the last. LaTeX counterpart: \klboxpad. */ + padding-top: 2px; + padding-bottom: 2px; } .code_no_border { - padding: .125rem 0 .125rem .5rem; - margin: 0 0 0 .125rem; - border: solid white 1px; - /* Debugging: - border: solid lightgray 1px; - background-color: rgb(250,250,127); - */ } diff --git a/sks/code/sty/code.sty b/sks/code/sty/code.sty index 200cb29..b942ebe 100644 --- a/sks/code/sty/code.sty +++ b/sks/code/sty/code.sty @@ -7,3 +7,168 @@ \usepackage[strings,nohyphen]{underscore} \usepackage{mdframed} + +\newsavebox{\measurebox} +\newlength{\widestline} + +% \measurewidest{line one\\line two\\...} -> \widestline = width of widest +\newcommand{\measurewidest}[1]{% + \begin{lrbox}{\measurebox}% + \begin{tabular}{@{}l@{}}#1\end{tabular}% + \end{lrbox}% + \setlength{\widestline}{\wd\measurebox}% +} + +% \asymbox[]{line one\\line two\\...} other three sides: 8pt +\newcommand{\asymbox}[2][0pt]{% + \begin{lrbox}{\measurebox}% + \begin{tabular}{@{}l@{}}\tt #2\end{tabular}% + \end{lrbox}% + {\setlength{\fboxsep}{0pt}% + \fcolorbox{black}{yellow!20}{% + \kern#1% + \vbox{\kern0pt\hbox{\usebox{\measurebox}}\kern0pt}% + \kern0pt}}% +} + +% =========================================================================== +% Annotated code listings. Emitted by sks/code/code_block.py; the layout +% policy lives here so it can be tuned without touching the Python. +% +% The model: a listing is a vertical stack of BLOCKS, each block a run of +% code lines with an optional comment beside it. There is deliberately no +% table -- comments are a fixed distance from their own block and do not +% align with each other, and each block's shaded box is as wide as that +% block's longest line. +% +% Two mechanisms carry the whole appearance: +% +% 1. Every line is a \strut'ed \makebox of the block's width. A LaTeX +% \strut is exactly 0.7\baselineskip high and 0.3 deep -- one full +% \baselineskip -- so consecutive lines inside a block abut exactly, +% with no gap and no overlap, at any point size. Without the strut a +% line with no descender would sit closer to its neighbour and the +% rhythm would follow the text rather than the grid. +% +% 2. \offinterlineskip in the listing removes the interline glue BETWEEN +% BLOCKS, so consecutive blocks abut too and a boxed block continues +% the rhythm of the plain lines around it. (It does not reach inside +% a \parbox, which restores normal interline spacing -- that is why +% the struts in point 1 are doing the work there, and why the box's +% vertical padding comes from \fboxsep below rather than from a +% padding line, which would be spaced off the grid.) +% =========================================================================== + +\definecolor{klcodeshade}{rgb}{1,1,.6} + +\newlength{\kllistingwidth} % width of a whole unannotated listing +% How the listing is placed WITHOUT being boxed, so that it can still break +% across pages: \klshift moves it right (an :offset with :hpos left) and +% \klindent is the width taken out of \linewidth (either side's offset). +% A minipage would do both at once but cannot break -- see Code.tex in +% sks/code/code_block.py, which sets these before \begin{klcode}. +\newlength{\klshift} \setlength{\klshift}{0pt} +\newlength{\klindent} \setlength{\klindent}{0pt} +\newlength{\klcodewidth} % width of the current block's box +\newlength{\klcommentwidth} % what is left for its comment +% Horizontal inset of the code from the box's left edge. 0pt puts the +% box's left edge ON the code column, where the line's own indentation +% begins, rather than out to the left of it; a positive value moves the +% CODE right, never the box left. +\newlength{\klcodepad} \setlength{\klcodepad}{0pt} +\newlength{\klcodegap} \setlength{\klcodegap}{14pt} % box to comment +\newlength{\klcodemin} \setlength{\klcodemin}{6em} % narrower: see below +% Clear space above and below a block that HAS a box, so that two boxed +% blocks with no unboxed lines between them read as two boxes rather than +% one. Applied per block, never per line: the lines within a block must +% still abut, or the shading stops being a solid rectangle. +% 1.5pt is 2px at the CSS reference 96dpi, matching the html margin in +% css/code.css -- these two are meant to look the same, so change them +% together. +\newlength{\klboxgap} \setlength{\klboxgap}{1.5pt} +% The box's own vertical margin: space INSIDE the shading, above the first +% line and below the last. Same 2px equivalent, matching the html padding. +\newlength{\klboxpad} \setlength{\klboxpad}{1.5pt} + +% \klline{} — one code line, padded and strut'ed to the block width. +\newcommand{\klline}[1]{% + \makebox[\klcodewidth][l]{\hspace{\klcodepad}\strut\ttfamily #1}} + +% \klcodebox{} — the block's lines, shaded when the block has a +% comment (\klcomment is set by \klblock). +% +% The shading is ONE \colorbox around the whole block, not one per line, +% because only a box around the whole block can have a vertical margin. +% +% That margin is \vspace* INSIDE the \parbox rather than \fboxsep: \fboxsep +% pads all four sides, and any horizontal padding would put the box's left +% edge out to the LEFT of the code column. With \fboxsep 0 the box spans +% exactly the code's own extent, so it begins where the line's indentation +% begins and the code stays aligned with the unshaded lines around it. +\newcommand{\klcodebox}[1]{% + \ifx\klcomment\empty + \parbox[c]{\klcodewidth}{#1}% + \else + \setlength{\fboxsep}{0pt}% + \colorbox{klcodeshade}{% + \parbox[c]{\klcodewidth}{\vspace*{\klboxpad}#1\vspace*{\klboxpad}}}% + \fi} + +% \klblock{}{}{} +% is \klline/\klshaded calls separated by \\; may be +% empty. The comment gets what the box leaves of \linewidth (NOT +% \textwidth: inside a list or minipage they differ, and \textwidth would +% push the comment into the margin). If that remainder is too narrow to +% set prose in, the comment goes BELOW the block rather than being +% squeezed into an overfull box. +\newcommand{\klblock}[3]{% + \begingroup + % A block is boxed exactly when it has a comment, so this one test also + % decides whether the block gets the \klboxgap separation. + \def\klcomment{#3}% + \ifx\klcomment\empty\else\vskip\klboxgap\fi + \settowidth{\klcodewidth}{\ttfamily #1}% + \addtolength{\klcodewidth}{2\klcodepad}% + % The box advances the line by exactly \klcodewidth (\fboxsep is 0 in + % \klcodebox). If a horizontal padding is ever reintroduced there, its + % width must be subtracted here too: leaving it out overfills the line, + % and \raggedright then breaks it -- dropping the comment onto the next + % line at the margin, which looks like the narrow-comment fallback. + \setlength{\klcommentwidth}% + {\dimexpr\linewidth-\klindent-\klcodewidth-\klcodegap\relax}% + \noindent + \ifdim\klcommentwidth<\klcodemin + \klcodebox{#2}% + \ifx\klcomment\empty\else + \\\parbox[t]{\linewidth}{\klcommentfont #3}% + \fi + \else + \klcodebox{#2}% + \ifx\klcomment\empty\else + \hspace{\klcodegap}% + \parbox[c]{\klcommentwidth}{\klcommentfont #3}% + \fi + \fi + % End the block's line. Without this every block joins ONE horizontal + % list and the blocks are broken into lines and justified like words. + \par + \ifx\klcomment\empty\else\vskip\klboxgap\fi + \endgroup} + +% The annotation font. Ragged right: a justified annotation beside a +% narrow box hyphenates badly. +\newcommand{\klcommentfont}{\sffamily\itshape\small\raggedright} + +% \begin{klcode} ... \end{klcode} — the listing itself. \offinterlineskip +% is what makes the struts the only thing setting vertical rhythm. +\newenvironment{klcode} + {\par\addvspace{0.5\baselineskip}% + \begingroup + \setlength{\parindent}{0pt}% + \setlength{\parskip}{0pt}% + \raggedright + % An unboxed listing is shifted with \leftskip rather than wrapped in a + % minipage, so that it can still break across pages. + \leftskip=\klshift + \offinterlineskip} + {\endgroup\par\addvspace{0.5\baselineskip}} diff --git a/sks/image/image.k b/sks/image/image.k index da9a843..030f790 100644 --- a/sks/image/image.k +++ b/sks/image/image.k @@ -17,51 +17,45 @@ # @@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: the element positions (center, left, - right, or a length used as the left margin), or none for an inline image - with no positioning container - :pattern 'element_hpos'^|none - :default center -@@@ +# An image once had its own position type, for the sake of the "none" value +# (no positioning container). That value is meaningful for any block +# element, so the shared hpos type carries it and an image is placed by the +# hpos_args option set like every other block element. -@@image +@@image.k basename :id :width.length .5w - @caption_arguments@ + @caption_args@ :vmargin.bool true - :hpos.image_hpos + @hpos_args@ :rel :abswidth.number 0.0 :border.bool false : - @eval image.Image(K) eval@ +An image read from a file. The basename names the file, which is looked +for in the image search path and converted to a format the target can use. +^:width is the image's width in the text column, ^:hpos and ^:offset place +it there, and the caption arguments caption it. @@ -@@image_grid +@@image :: @eval image.Image(K) eval@ @@ + +@@image_grid.k image_specs.rest(2) - :caption - :number.bool true :cell_number.bool false :landscape.bool false :scale.number 0.98 - :caption_side.caption_side - :caption_side_center.bool true -# + @caption_args@ + @hpos_args@ :thumbnail.bool false :allow_break.bool true -# :indent.length -# :xmargin.length :id - :captionfont - :caption_width.float .9 :rel :hsep.number 0.02 - : - @eval image_grid.Image_grid(K) eval@ +: A grid of images @@ -@@fig spec.figure_id : - @reference *spec* | Figure @ -@@ +@@image_grid :: @eval image_grid.Image_grid(K) eval@ @@ + +@@fig spec.figure_id : @reference *spec* | Figure @ @@ diff --git a/sks/image/image.py b/sks/image/image.py index 369bb72..5c5a3dd 100644 --- a/sks/image/image.py +++ b/sks/image/image.py @@ -103,7 +103,8 @@ class Image(klammer_base.Klammer_base): result = html_util.add_caption( result, "Figure", self.number, self.caption, self.caption_font, - self.hpos, self.caption_side, False, self.caption_font_size) + self.hpos, self.caption_side, False, self.caption_font_size, + offset=self.offset) result = E("div").body(result).cls("image_margin") @@ -131,16 +132,19 @@ class Image(klammer_base.Klammer_base): self.caption += self.file_error_message result = latex_util.add_caption( result, "Figure", self.number, self.caption, width, - self.hpos, self.caption_side) + self.hpos, self.caption_side, offset=self.offset) #result, "Figure", self.number, self.caption, self.hpos, self.caption_side, #self.width, self.caption_side_center, self.vmargin, self.caption_margin) else: result = f'\\includegraphics[width={width}]{{{source}}}' - result = latex_util.caption_wrapper(result, self.hpos) + result = latex_util.caption_wrapper(result, self.hpos, offset=self.offset) name = f"Reference-Figure-{Image.id}" result = f"\\hypertarget{{{name}}}{{}}\\label{{Label-{name}}}\n{result}" Image.id += 1 - return result + # \includegraphics (bare or inside a caption wrapper's minipage) is + # box material: it must sit in vertical mode or it is typeset beside + # any text it follows. + return latex_util.block(result) def txt(self): diff --git a/sks/image/image_grid.py b/sks/image/image_grid.py index a7bde9e..4581966 100644 --- a/sks/image/image_grid.py +++ b/sks/image/image_grid.py @@ -20,7 +20,13 @@ class Kargs: self.caption_side_center = False self.Image_search_path = K.Image_search_path self.number = K.cell_number + # A cell is placed by the grid, not by itself: no positioning + # container, and nothing to inset it from. Both members of the + # hpos_args option set have to be set here -- image.py reads them + # unconditionally, so a missing one is an AttributeError at render + # time rather than a message. self.hpos = "none" + self.offset = "0pt" self.K_target = target self.K_input_dir = K.K_input_dir self.K_output_dir = K.K_output_dir diff --git a/sks/kutil/kutil.k b/sks/kutil/kutil.k index 573ecc1..658419b 100644 --- a/sks/kutil/kutil.k +++ b/sks/kutil/kutil.k @@ -1,22 +1,6 @@ -@@show s : @eval :cpp show show @ @@ +# General definitions for the Standard Klammer Set -@@@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.caption_side - :caption_font.font i - :caption_font_size.float .9 -@@ - -@@reference spec | name : - __REF__*spec*__*name*__ -@@ +# Argtypes @@@argtype number | a number :pattern 'float'^|'int' @@ -24,9 +8,9 @@ @@@ @@@argtype length | a length specifier - #:pattern f^|none^|'float'w^|'float'h^|'int'px^|'float'em^|'int'pt - # The last pattern is a string, used where possible to determine its length - :pattern f^|none^|'float'w^|'float'h^|'int'px^|'float'em^|'int'pt^|"[^^"]+"^|'float'pw^|'float'ph + #:pattern f^|none^|'float'w^|'float'h^|'int'px^|'float'em^|'int'pt + # The last pattern is a string, used where possible to determine its length + :pattern f^|none^|'float'w^|'float'h^|'int'px^|'float'em^|'int'pt^|"[^^"]+"^|'float'pw^|'float'ph # :python_cast (lambda s : __import__("kutil").parse_length("tex", s)) @@@ @@ -35,16 +19,49 @@ #:python_cast (lambda s : [__import__("kutil").parse_length("tex", e) for e in s.split()]) @@@ -@@@argtype element_hpos | - the horizontal position of a block element (a table or an image) within - the text column: center, left, right, or a length, which places the - element's left edge that far from the left margin (e.g. ^:hpos 4em, ^:hpos - .25w). When the element is as wide as the text column, the positions are - indistinguishable. - :pattern center^|left^|right^|'length' +@@@argtype side | + the side of its element on which a caption is placed: top, right, bottom, left + :pattern top^|right^|bottom^|left + :default bottom +@@@ + +@@@argtype hpos | + a horizontal position: left, center, right, none + :pattern left^|center^|right^|none :default center @@@ +@@@argtype offset_length | + how far a block element is inset from the margin that ^:hpos names: from + the left margin for ^:hpos left, from the right margin for ^:hpos right. + 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 + # 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 +@@@ + + +#[ +@@@argtype element_hpos | + the horizontal position of a block element (a table, an image, a code + listing) within the text column: center, left, right, or a length, which + places the element's left edge that far from the left margin (e.g. ^:hpos + 4em, ^:hpos .25w). When the element is as wide as the text column, the + positions are indistinguishable. The value none puts the element in no + positioning container at all, so that it flows with the text around it. + :pattern center^|left^|right^|none^|'length' + :default center +@@@ +]# + @@@argtype figure_id | an identifier for a figure. @@ -89,8 +106,75 @@ An is the value of the ^:id argument for an image. :pattern [a-z][a-z] @@@ -# The document-wide language for generated text. A klammer's own :lang -# argument overrides it; see the language argtype above. Consumers today: -# @date and @datetime (month names and date form). Set it for a whole -# document with @@@state Language :value de @@@ -@@@state Language :desc Language (ISO 639-1) for generated text :value en @@@ + +# State variables + +@@@state Language :desc Language (ISO 639-1) for generated text. + The document-wide language for generated text. A klammer's own ^:lang + argument overrides it; see the language argtype above. Currently used by + ^@date and ^@datetime (month names and date form). Set it for a whole + document with ^@^@^@state Language ^:value de ^@^@^@ + :value en +@@@ + + +# Option sets + +#[ +The horizontal placement of a block element is two parameters: where it sits +(hpos_arg) and how far it is inset from that side (offset_arg). They are two +sets rather than one because a klammer may need the second without the first +-- and because a set is a vocabulary a reader learns whole, so a small one is +easier to learn than a large one. + +An option set is a claim about behavior, not just a saving of keystrokes: a +klammer whose declaration uses these must also POSITION itself with them, +through latex_util.caption_wrapper (tex) and html_util.hpos_container +(html), or the parameter is accepted and silently ignored. Asserted by +outcome in sks/tst/placement_test.sh, so a new block klammer that takes the +parameters and ignores them is caught. + +A klammer that wants a different DEFAULT position says so where it uses the +set (@hpos_arg :hpos left @ in the code klammer): a set owns the names and +the types, and the klammer owns what silence means for it. +]# + +#[ +@@offset_arg.o + :offset.offset_length +: How far a block element is inset from the margin its position names @@ + +@@hpos_arg.o + :hpos.element_hpos +: Where a block element sits in the text column @@ +]# + +@@hpos_args.o + :hpos.hpos + :offset.offset_length +: +The horizontal position of a block element. If the ^:hpos value is "none", no +outer structure is added to the element so that it can be used inline or in +other structures. The ^:offset is used only if the ^:hpos value is "left" or +"right". +@@ + +@@caption_args.o + :caption + :number.bool true + :caption_side.side + :caption_font.font i + :caption_font_size.float .9 +: Arguments that define a caption for a block element. +@@ + + +# Klammers + +@@reference.k spec | name : Reference marker for captioned elements @@ +@@reference :: + __REF__*spec*__*name*__ +@@ + +@@show.k s : Show the raw Klammertext and the result @@ +@@show :: @eval :cpp show show @ @@ diff --git a/sks/table/table.k b/sks/table/table.k index 73ba2fe..da93b93 100644 --- a/sks/table/table.k +++ b/sks/table/table.k @@ -193,10 +193,10 @@ @@rowcolor.tex s : \colorrow{*s*} @@ -@@table rows.rest(2) +@@table.k rows.rest(2) :id - @caption_arguments@ - :hpos.element_hpos + @caption_args@ + @hpos_args@ :header.bool true :allow_break.bool false :column_width.column_width @@ -215,9 +215,20 @@ :leading.float 1.3 :colsep 4pt : - @eval table.Table(K) eval@ +A table of rows of cells. The cells of a row are separated by "|" and the +rows by "||", so the argument is two-dimensional; the first row is the +header unless ^:header is false. + +Everything else is optional, and a table written with no options is a plain +grid of its cells. ^:column_width lays the columns out, ^:hline and ^:vline +draw lines, ^:colspan and ^:rowspan merge cells, ^:justify and ^:cell_hpos +place text within them, ^:calc computes cells from other cells and ^:format +formats them, and ^:hpos and ^:offset place the whole table in the text +column. @@ +@@table :: @eval table.Table(K) eval@ @@ + @@tbl spec.figure_id : @reference *spec* | Table @ @@ diff --git a/sks/table/table.py b/sks/table/table.py index 5840bb7..bccfec3 100644 --- a/sks/table/table.py +++ b/sks/table/table.py @@ -591,10 +591,11 @@ class Table(klammer_base.Klammer_base): result, "Table", self.number, self.caption, self.caption_font, hpos=self.hpos, side=self.caption_side, - font_size=self.caption_font_size, max_width="100%") + font_size=self.caption_font_size, max_width="100%", + offset=self.offset) else: result.attr("style", "max-width: 100%") - result = html_util.hpos_container(result, self.hpos).str() + result = html_util.hpos_container(result, self.hpos, self.offset).str() return result colgroup, layout, table_width = self.html_colgroup() result = E("table").body(colgroup + result) @@ -607,13 +608,14 @@ class Table(klammer_base.Klammer_base): result = html_util.add_caption( result, "Table", self.number, self.caption, self.caption_font, hpos=self.hpos, side=self.caption_side, - font_size=self.caption_font_size, width=table_width) + font_size=self.caption_font_size, width=table_width, + offset=self.offset) else: # An uncaptioned table still gets the position container, so # html and tex agree on where the table sits. if table_width: result.attr("style", f"{layout}width: {table_width}") - result = html_util.hpos_container(result, self.hpos).str() + result = html_util.hpos_container(result, self.hpos, self.offset).str() return result # LaTeX @@ -768,14 +770,25 @@ class Table(klammer_base.Klammer_base): # by its :hpos wrapper instead; its glue is left neutral (\fill on # both sides collapses in the exactly-fitting box), because a fixed # length would overflow the box. A length is the left margin. + # :offset insets the table from the margin :hpos names -- the same + # rule the boxed path gets from latex_util.caption_wrapper, here + # expressed as the fixed side of the glue pair. + inset = latex_util.offset_length(self.offset) if not self.allow_break: left, right = "\\fill", "\\fill" elif self.hpos == "center": left, right = "\\fill", "\\fill" elif self.hpos == "left": - left, right = "0pt", "\\fill" + left, right = inset, "\\fill" elif self.hpos == "right": - left, right = "\\fill", "0pt" + left, right = "\\fill", inset + elif self.hpos == "none": + # No positioning container: the table starts at the text margin + # like ordinary text, which is what "none" does in html (the + # hpos_none class is inline-flex). A longtable cannot flow + # inline, so flush left is as close as the target gets. The + # offset names no margin here and is ignored, as when centered. + left, right = "0pt", "\\fill" else: length, _, _ = kutil.parse_length("tex", self.hpos, 1) left, right = length, "\\fill" @@ -824,9 +837,9 @@ class Table(klammer_base.Klammer_base): result, "Table", self.number, self.caption, "\\tableboxwidth", hpos=self.hpos, side=self.caption_side, font_symbol=self.caption_font, - font_size=self.caption_font_size) + font_size=self.caption_font_size, offset=self.offset) else: - result = latex_util.caption_wrapper(result, self.hpos) + result = latex_util.caption_wrapper(result, self.hpos, offset=self.offset) result = measure + result result = f"\\hypertarget{{{name}}}{{}}\\label{{Label-{name}}}\n{result}" @@ -845,7 +858,11 @@ class Table(klammer_base.Klammer_base): + self.tex_fill_widths() + result) result = re.sub(r"\newline", r"\\\\", result) - return result + # A boxed table is box material: it must sit in vertical mode or it + # is typeset beside any text it follows. (A page-breaking longtable + # breaks the paragraph itself, but \par on both sides is a no-op + # there, so the rule stays unconditional.) + return latex_util.block(result) def txt(self): return "Table in .txt format not implemented" diff --git a/sks/target/css/target.css b/sks/target/css/target.css index 5988bb0..d0061e0 100644 --- a/sks/target/css/target.css +++ b/sks/target/css/target.css @@ -168,6 +168,14 @@ justify-content: left; } +/* Retired 2026-08-03: this fixed 1em on every left- and right-placed + element was the :offset argument, hard coded and only in html -- the tex + side had no counterpart, so the two targets disagreed. It is now the + offset_length argtype (sks/kutil/kutil.k), applied by + html_util.hpos_container and latex_util.caption_wrapper. A document + that wants the old inset writes ":offset", whose alone value is the + standard indentation. .hpos_margin { margin: 0 1em 0 1em; } +*/ diff --git a/sks/target/html_util.py b/sks/target/html_util.py index 06b49bc..a323462 100644 --- a/sks/target/html_util.py +++ b/sks/target/html_util.py @@ -193,27 +193,38 @@ def element_tag(element): def font_class(font): return {"r" : "", "i" : "ritalic", "t" : "monospace", "s" : "sanserif"}[font] -def hpos_container(element, hpos): +def hpos_container(element, hpos, offset=None): # Wrap element in its horizontal-position container. hpos is left, # center, right, none, or a length, which becomes the left margin # (the tex counterparts are the \LTleft glue for tables and the # \hspace* in latex_util.caption_wrapper). - style = None + # + # offset insets the element from the margin hpos names, as padding on + # that side of the flex container: the left for "left", the right for + # "right". A centered element has no such margin, so the offset is + # ignored there rather than being an error -- same rule as + # latex_util.caption_wrapper, which is the tex counterpart. (It + # replaces the fixed 1em that the hpos_margin class used to add to + # every left- and right-placed element: that was this offset, hard + # coded, and only in html.) + styles = [] if hpos not in ("left", "center", "right", "none"): length, _, _ = kutil.parse_length("html", hpos, 1) - style = f"margin-left: {length}" + 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) + side = "right" if hpos == "right" else "left" + styles.append(f"padding-{side}: {length}") result = E("div").cls("hpos_" + hpos).body(element) - if style: - result.attr("style", style) - if hpos not in ("center", "indent"): - result.cls("hpos_margin") + if styles: + result.attr("style", "; ".join(styles)) return result def add_caption(element, caption_label, number, caption_text, font_symbol="i", hpos="center", side="bottom", as_string=True, - font_size=.9, width=None, max_width=None): + font_size=.9, width=None, max_width=None, offset=None): tag = element_tag(element) # Caption caption = "" @@ -263,7 +274,7 @@ def add_caption(element, caption_label, number, caption_text, element.attr("style", f"max-width: {max_width}") - result = hpos_container(element, hpos) + result = hpos_container(element, hpos, offset) if number: result.cls("element_container") diff --git a/sks/target/latex_util.py b/sks/target/latex_util.py index 1104c63..fe424f1 100644 --- a/sks/target/latex_util.py +++ b/sks/target/latex_util.py @@ -12,6 +12,31 @@ def tex_style(): # \usepackage{quoting} +def block(material): + r"""Return tex material as a block: vertical mode on both sides. + + A block element -- a table, an image, a code listing -- is box + material. Appended to a non-empty horizontal list (an @image written + after text on the same line, with no blank line between) a box is + typeset BESIDE the text rather than below it, and text following it + flows to its right. A \par on each side ends the paragraph in + progress and starts a new one after. \par in vertical mode is a + no-op, so this is safe wherever it is applied: a block klammer needs + no knowledge of what preceded it. + + Every SKS klammer whose tex output is box material returns it through + this function. LaTeX ENVIRONMENTS -- center, flushright, quote, + itemize, verbatim -- already break the paragraph themselves and do + not need it. + + The paragraph policy this serves: in an @document, paragraphs are + made from blank-line-separated text (@par is the explicit form for a + fragment rendered without @document), and the author is never + required to know that a target distinguishes horizontal from + vertical mode. Asserted by sks/tst/paragraph_test.sh. + """ + return "\\par\n" + material.strip("\n") + "\n\\par\n" + def environment(name, body, required=None, optional=None): req = f"{{{required}}}" if required else "" opt = f"[{optional}]" if optional else "" @@ -54,17 +79,36 @@ def minipage(content, width="\\textwidth", vertical="c", center=True, vmargin="" result = f"\\fbox{{{result}}}" return result -def caption_wrapper(element, hpos, bottom_margin=.67): +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"): + return "0pt" + return kutil.parse_length("tex", offset, 1)[0] + +def offset_space(offset): + r"""An :offset as an \hspace*, or "" when there is none.""" + length = offset_length(offset) + return "" if length == "0pt" else f"\\hspace*{{{length}}}" + +def caption_wrapper(element, hpos, bottom_margin=.67, offset=None): # hpos is left, center, right, none (no wrapper), or a length, which # becomes the left margin. The element is a box on a line inside a # full-width minipage; \hfill on the empty side pushes it into place. + # + # offset insets the element from the margin hpos names -- from the left + # for "left", from the right for "right". A centered element has no + # such margin, so the offset is ignored there rather than being an + # error: the two arguments are independent, and :hpos is what decides + # whether the offset has anything to measure from. (The html + # counterpart is html_util.hpos_container.) vmargin = f"{bottom_margin}\\baselineskip" + inset = offset_space(offset) if hpos == "center": return minipage(element, vmargin=vmargin) if hpos == "left": - return minipage(element + "\\hfill", vmargin=vmargin, center=False) + return minipage(inset + element + "\\hfill", vmargin=vmargin, center=False) if hpos == "right": - return minipage("\\hfill" + element, vmargin=vmargin, center=False) + return minipage("\\hfill" + element + inset, vmargin=vmargin, center=False) if hpos == "none": return element length, _, _ = kutil.parse_length("tex", hpos, 1) @@ -85,7 +129,8 @@ def make_caption_text(number, label, text, font_symbol, font_size): return caption def add_caption(element, caption_label, number, caption_text, latex_width, - hpos="center", side="bottom", font_symbol="i", font_size=.9): + hpos="center", side="bottom", font_symbol="i", font_size=.9, + offset=None): top_margin = .75 if "includegraphics" in element else .5 caption = make_caption_text(number, caption_label, caption_text, font_symbol, font_size) if caption: @@ -118,4 +163,4 @@ def add_caption(element, caption_label, number, caption_text, latex_width, # space below its line. element = minipage(caption + "\\rule[-0.75\\baselineskip]{0pt}{0pt}\n" + element, latex_width, vertical="t") - return caption_wrapper(element, hpos) + return caption_wrapper(element, hpos, offset=offset) diff --git a/tst/Makefile b/tst/Makefile index ce284ce..2f645ba 100644 --- a/tst/Makefile +++ b/tst/Makefile @@ -17,4 +17,6 @@ test: ./alone_test.sh ./modulepath_test.sh ./klammerset_test.sh + ./option_set_test.sh + ./signature_test.sh ./editor_test.sh diff --git a/tst/option_set_test.sh b/tst/option_set_test.sh new file mode 100755 index 0000000..11746b3 --- /dev/null +++ b/tst/option_set_test.sh @@ -0,0 +1,307 @@ +#!/bin/bash +# +# option_set_test.sh — Regression tests for option sets (the ".o" target). +# +# An option set is a named group of OPTIONAL parameters, declared once and +# used by several klammers, so that a writer learns one vocabulary instead of +# a spelling per klammer: +# +# @@caption_args.o :caption :number.bool true : A caption @@ +# @@table.k rows.rest(2) @caption_args@ : A table of rows of cells @@ +# +# The rules this suite holds to: +# +# * "o" is a pseudo-target beside "k". A ".o" declaration defines no +# klammer and produces no output for any target. +# * A set declares optional parameters only. A positional is not +# writer-facing, so there is nothing for a set to standardize. +# * Names and types come from the set; a DEFAULT may be overridden where +# the set is used, because what varies between klammers is only what +# silence means for that one klammer. +# * A set may be used only in the parameter list of a ".k" declaration -- +# the one place a klammer's interface is declared once for all of its +# targets. Not in a per-target definition, and not in another set. +# * A klammer application in a parameter list is an error. Splicing a +# constant klammer there used to be the way to share parameters, and in +# a ".k" declaration it silently destroyed the whole parameter list. +# +# Engine tier: no SKS. Every fixture defines its own target inline. +# +# Usage: ./option_set_test.sh +# Exit code: 0 if all tests pass, 1 otherwise. + +PASS=0 +FAIL=0 +KTEXT=ktext +K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set} + +red=$'\033[31m' +green=$'\033[32m' +bold=$'\033[1m' +reset=$'\033[0m' + +# The fixture prelude: a target, and two sets to use in declarations. +PRELUDE='@@@target fix | a fixture target @@@ +@@cap.o :caption :number.bool true :side bottom : A caption for an element @@ +@@pos.o :hpos left : Where an element sits @@' + +# check_eq NAME EXPECTED SOURCE [TARGET] — render SOURCE (for the fix target +# unless TARGET says otherwise) and compare the trimmed output with EXPECTED. +check_eq() { + local name="$1" expected="$2" source="$3" target="${4:-fix}" + local output status + output=$("$KTEXT" -k none -s "$PRELUDE +$source" -t "$target" -d 2>&1) + status=$? + output=$(printf '%s' "$output" | tr -d '\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') + if [ $status -ne 0 ]; then + echo "${red}FAIL${reset} $name — ktext exited $status" + echo " output: $(printf '%s' "$output" | head -3)" + FAIL=$((FAIL + 1)) + return + fi + if [ "$output" = "$expected" ]; then + echo "${green}PASS${reset} $name" + PASS=$((PASS + 1)) + else + echo "${red}FAIL${reset} $name" + echo " expected: [$expected]" + echo " got: [$output]" + FAIL=$((FAIL + 1)) + fi +} + +# check_fails NAME SUBSTRING SOURCE — SOURCE must be rejected, with a +# message containing SUBSTRING. +check_fails() { + local name="$1" needle="$2" source="$3" + local output status + output=$("$KTEXT" -k none -s "$PRELUDE +$source" -t fix -d 2>&1) + status=$? + if [ $status -eq 0 ]; then + echo "${red}FAIL${reset} $name — expected an error, ktext exited 0" + FAIL=$((FAIL + 1)) + return + fi + if printf '%s' "$output" | tr '\n' ' ' | grep -qF "$needle"; then + echo "${green}PASS${reset} $name" + PASS=$((PASS + 1)) + else + echo "${red}FAIL${reset} $name" + echo " expected error containing: [$needle]" + echo " got: $(printf '%s' "$output" | tr '\n' ' ' | head -c 300)" + FAIL=$((FAIL + 1)) + fi +} + +# check_warns NAME SUBSTRING SOURCE — SOURCE must be accepted, and its +# combined output must contain SUBSTRING (used where a warning is expected +# beside the result, so an exact comparison would test the warning's wording). +check_warns() { + local name="$1" needle="$2" source="$3" + local output status + output=$("$KTEXT" -k none -s "$PRELUDE +$source" -t fix -d 2>&1) + status=$? + if [ $status -ne 0 ]; then + echo "${red}FAIL${reset} $name — ktext exited $status" + FAIL=$((FAIL + 1)) + return + fi + if printf '%s' "$output" | tr '\n' ' ' | grep -qF "$needle"; then + echo "${green}PASS${reset} $name" + PASS=$((PASS + 1)) + else + echo "${red}FAIL${reset} $name" + echo " expected a warning containing: [$needle]" + echo " got: $(printf '%s' "$output" | tr '\n' ' ' | head -c 300)" + FAIL=$((FAIL + 1)) + fi +} + +echo "${bold}Option set (.o) tests${reset}" +echo "=====================" +echo + +# --- The members become the klammer's parameters -------------------------- + +# A declaration using a set accepts the set's options, and the writer's +# arguments reach the body under the names the set declares. +check_eq " 1. a set's parameters are the klammer's" \ + "[A|top]" \ + '@@e.k x @cap@ : an element @@ + @@e.fix :: [*caption*|*side*] @@ + @e X :caption A :side top @' + +# Absent argument, declared default: the set supplies it. +check_eq " 2. a member's default applies when the argument is absent" \ + "[|true|bottom]" \ + '@@e.k x @cap@ : an element @@ + @@e.fix :: [*caption*|*number*|*side*] @@ + @e X @' + +# The use site may override a default -- and only the default. +check_eq " 3. a use-site override changes the default" \ + "[|true|top]" \ + '@@e.k x @cap :side top @ : an element @@ + @@e.fix :: [*caption*|*number*|*side*] @@ + @e X @' + +check_eq " 4. a boolean default can be overridden to false" \ + "[false]" \ + '@@e.k x @cap :number false @ : an element @@ + @@e.fix :: [*number*] @@ + @e X @' + +# An option written alone at the use site takes the argument type's :alone +# value, exactly as it does where an argument is written. +check_eq " 5. an override written alone takes the argtype's alone value" \ + "[true]" \ + '@@n.o :number.bool false : A number @@ + @@e.k x @n :number @ : an element @@ + @@e.fix :: [*number*] @@ + @e X @' + +# Three levels: argument type, option set, use site -- and the writer's +# argument still wins over all of them. +check_eq " 6. a written argument wins over the overridden default" \ + "[maybe]" \ + '@@e.k x @cap :side top @ : an element @@ + @@e.fix :: [*side*] @@ + @e X :side maybe @' + +check_eq " 7. two sets in one declaration" \ + "[bottom|left]" \ + '@@e.k x @cap@ @pos@ : an element @@ + @@e.fix :: [*side*|*hpos*] @@ + @e X @' + +check_eq " 8. a set's parameters mix with the klammer's own" \ + "[own|bottom]" \ + '@@e.k x :mine own @cap@ : an element @@ + @@e.fix :: [*mine*|*side*] @@ + @e X @' + +# Every target definition is an instance, so all of them get the expanded +# list: the set is resolved once, in the declaration. +check_eq " 9. a second target's instance inherits the same parameters" \ + "" \ + '@@@target fix2 | another fixture target @@@ + @@e.k x @cap@ : an element @@ + @@e.fix :: [*side*] @@ + @@e.fix2 :: <*side*> @@ + @e X @' \ + fix2 + +# --- The declaration is not a klammer ------------------------------------- + +check_fails "10. a set defines no klammer" \ + 'The klammer "cap" is not defined' \ + '@cap@' + +# --- Where a set may be used ---------------------------------------------- + +check_fails "11. not in a per-target definition" \ + 'may be used only in the parameter list of a ".k" declaration' \ + '@@e.fix x @cap@ : [*caption*] @@' + +check_fails "12. not in a general definition" \ + 'may be used only in the parameter list of a ".k" declaration' \ + '@@e x @cap@ : [*caption*] @@' + +check_fails "13. not in another set" \ + 'a set does not include another set' \ + '@@both.o :extra @cap@ : two vocabularies @@' + +# --- A klammer application in a parameter list ---------------------------- + +check_fails "14. a klammer application in a parameter list is rejected" \ + 'A klammer application in a parameter list is not allowed' \ + '@@c : :spliced @@ + @@e.k x @c@ : an element @@' + +check_fails "15. ... and the message names the declared sets" \ + 'Declared option sets: cap, pos' \ + '@@e.k x @nosuch@ : an element @@' + +# --- Use-site overrides --------------------------------------------------- + +check_fails "16. an override must name a member of the set" \ + 'The option set "cap" has no parameter ":nope"' \ + '@@e.k x @cap :nope 1 @ : an element @@' + +check_fails "17. an override value is validated at definition time" \ + 'does not match the "bool" argument type' \ + '@@e.k x @cap :number perhaps @ : an element @@' + +check_fails "18. an override may not restate the type" \ + 'only a default may be given where it is used' \ + '@@e.k x @cap :number.bool false @ : an element @@' + +check_fails "19. an override may not give a positional value" \ + 'gives a value that is not an option' \ + '@@e.k x @cap here @ : an element @@' + +# --- What a set may declare ----------------------------------------------- + +check_fails "20. a positional parameter is rejected" \ + 'An option set declares only optional parameters' \ + '@@bad.o p :q : oops @@' + +check_fails "21. a rest parameter is rejected" \ + 'An option set declares only optional parameters' \ + '@@bad.o r.rest :q : oops @@' + +check_fails "22. a set with no parameters is rejected" \ + 'declares no parameters' \ + '@@bad.o : nothing at all @@' + +check_fails '23. "::" has no meaning for a set' \ + 'An option set IS a declaration' \ + '@@cap.o :: nope @@' + +# --- Collisions ----------------------------------------------------------- + +check_fails "24. two sets declaring the same name name both sets" \ + 'from the option set "cap"' \ + '@@other.o :side right : another side @@ + @@e.k x @cap@ @other@ : an element @@' + +check_fails "25. a set colliding with a declared parameter" \ + 'declared in the parameter list' \ + '@@e.k x :side own @cap@ : an element @@' + +# --- Redefinition --------------------------------------------------------- + +check_fails "26. declaring a set twice is an error" \ + 'Option set "cap.o" already defined' \ + '@@cap.o :caption : a second caption @@' + +# A set is its own declaration, so an override restates what it declares -- +# there is no ".k" for it to inherit a parameter list from. +check_warns '27. ":::" overrides a set, with a warning' \ + 'overridden' \ + '@@cap.o :caption :number.bool true :side top ::: a replaced caption @@' + +check_warns "28. ... and the overriding declaration is what a klammer gets" \ + "[top]" \ + '@@cap.o :caption :number.bool true :side top ::: a replaced caption @@ + @@e.k x @cap@ : an element @@ + @@e.fix :: [*side*] @@ + @e X @' + +# A "::::" default is silently superseded by a later create, and the create +# is what the using declaration gets. +check_eq "29. a default set is superseded by a later declaration" \ + "[right]" \ + '@@d.o :where left :::: a default @@ + @@d.o :where right : the real one @@ + @@e.k x @d@ : an element @@ + @@e.fix :: [*where*] @@ + @e X @' + +echo +echo "=====================" +echo "Results: ${green}$PASS passed${reset}, ${red}$FAIL failed${reset}" +[ $FAIL -eq 0 ] diff --git a/tst/signature_test.sh b/tst/signature_test.sh new file mode 100755 index 0000000..5f7b99f --- /dev/null +++ b/tst/signature_test.sh @@ -0,0 +1,143 @@ +#!/bin/bash +# +# signature_test.sh — one klammer, one interface (engine suite, tst/). +# +# A klammer may be defined separately for each target. When it is, every +# target's definition carries its own parameter list, and those lists must +# agree: a klammer's interface is a property of the KLAMMER, not of the +# target it is being rendered to. If they disagree, the same document would +# bind arguments differently — or fail — depending only on the target, which +# is exactly the thing an author must be able to rely on not happening. +# +# The engine enforces this rather than warning about it, and the remedy it +# names is the .k declaration: declare the parameters once, and give each +# target a "::" instance with no parameter list of its own. That is the +# migration from repeating an argument list per target to declaring it once. +# +# These are engine tests, so they use -k none and define their own targets +# inline: a target is a Machine construct (@@@target), not owned by any +# klammer set. +# +# Usage: ./signature_test.sh (needs KLAMMERTEXT_HOME set; ktext on PATH) +# Exit code: 0 if all tests pass, 1 otherwise. + +PASS=0 +FAIL=0 +KTEXT=ktext +K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set} + +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" + echo " expected: $2" + echo " got: $3" + FAIL=$((FAIL+1)) +} + +TARGETS='@@@target ta | Target A @@@ +@@@target tb | Target B @@@ +' + +# accepted NAME SRC — the definitions are consistent and the klammer applies. +accepted() { + local name="$1" src="$2" out + out=$("$KTEXT" -k none -s "$TARGETS$src" -t ta -d 2>&1) + if echo "$out" | grep -qi "error"; then + fail "$name" "no error" "$(echo "$out" | grep -i -A1 error | tail -1)" + else + pass "$name" + fi +} + +# rejected NAME SRC PATTERN — the drift is caught, and the message says how. +rejected() { + local name="$1" src="$2" want="$3" out + out=$("$KTEXT" -k none -s "$TARGETS$src" -t ta -d 2>&1) + if ! echo "$out" | grep -qi "error"; then + fail "$name" "a definition error" "accepted" + elif ! echo "$out" | grep -qF "$want"; then + fail "$name" "message containing: $want" "$(echo "$out" | head -6 | tail -3)" + else + pass "$name" + fi +} + +echo "${bold}Klammer signature consistency${reset}" +echo "=============================" +echo + +accepted " 1. identical parameter lists" \ +'@@k1.ta s :n : [*s*] @@ +@@k1.tb s :n : [*s*] @@ +@k1 x @' + +rejected " 2. positional names differ" \ +'@@k2.ta s : [*s*] @@ +@@k2.tb t : [*t*] @@ +@k2 x @' 'are not the same for every target' + +rejected " 3. option names differ" \ +'@@k3.ta s :one : [*s*] @@ +@@k3.tb s :two : [*s*] @@ +@k3 x @' 'are not the same for every target' + +rejected " 4. defaults differ" \ +'@@k4.ta s :n abc : [*s*] @@ +@@k4.tb s :n xyz : [*s*] @@ +@k4 x @' 'are not the same for every target' + +rejected " 5. argument types differ" \ +'@@k5.ta s :n.int : [*s*] @@ +@@k5.tb s :n.word : [*s*] @@ +@k5 x @' 'are not the same for every target' + +echo +echo "-- the message must show HOW they differ, not just where" + +# With more than two targets the designer would otherwise have to diff the +# definitions by hand; each target's own signature is listed beside its name. +name=" 6. each target's signature is shown" +out=$("$KTEXT" -k none -s "$TARGETS"'@@k6.ta s :one : [*s*] @@ +@@k6.tb s :two : [*s*] @@ +@k6 x @' -t ta -d 2>&1) +if echo "$out" | grep -q "ta .*s :one" && echo "$out" | grep -q "tb .*s :two"; then + pass "$name" +else + fail "$name" "both signatures listed by target" "$(echo "$out" | head -8 | tail -4)" +fi + +# The error path once printed internal katom detail through msg(), which is +# debugging scaffolding and must never reach a user-facing message. +name=" 7. no internal debug output on the error path" +out=$("$KTEXT" -k none -s "$TARGETS"'@@k7.k s : a declaration @@ +@@k7.ta s2 :other : [*s2*] @@ +@k7 x @' -t ta -d 2>&1) +if echo "$out" | grep -q "klammer-definition"; then + fail "$name" "no internal dump" "$(echo "$out" | head -2)" +else + pass "$name" +fi + +echo +echo "-- the remedy the message names" + +accepted " 8. a .k declaration with :: instances" \ +'@@k8.k s :n : a declaration @@ +@@k8.ta :: [*s*] @@ +@@k8.tb :: [*s*] @@ +@k8 x @' + +rejected " 9. a .k declaration plus a parameterized definition" \ +'@@k9.k s : a declaration @@ +@@k9.ta s2 :other : [*s2*] @@ +@k9 x @' 'both a declaration' + +echo +echo "=============================" +echo "Results: ${green}$PASS passed${reset}, ${red}$FAIL failed${reset}" +[ $FAIL -eq 0 ]