diff --git a/mac/eval.cpp b/mac/eval.cpp index 3c7763f..90db731 100644 --- a/mac/eval.cpp +++ b/mac/eval.cpp @@ -114,7 +114,7 @@ void check_cpp_arguments(katom_list args, Locator loc) } -katom_list Eval::eval(katom_iter begin, katom_iter end) +std::string Eval::eval_command(katom_iter begin, katom_iter end) { (void)K::log(3, *begin, *(end - 1)); //msg() << "in Eval::eval:\n" << ktype << kall << kindex << std::pair(begin, end) << "\n"; @@ -150,6 +150,12 @@ katom_list Eval::eval(katom_iter begin, katom_iter end) Eval_cpp E_cpp(m_machine, begin->m_loc); eval_result = E_cpp.eval(libpath, funcname); } + return eval_result; +} + +katom_list Eval::eval(katom_iter begin, katom_iter end) +{ + std::string eval_result = eval_command(begin, end); katom_list result {}; Machine M = m_machine; size_t before = M.m_katoms.size(); diff --git a/mac/eval.h b/mac/eval.h index c762809..454881b 100644 --- a/mac/eval.h +++ b/mac/eval.h @@ -25,6 +25,14 @@ public: std::vector eval( std::vector::iterator begin, std::vector::iterator end); + // Dispatch the @eval command (python/shell/haskell/cpp) and return its + // raw string result, without re-reading it as Klammertext. Used by + // :after_apply phase functions, whose input and output are final target + // text -- re-katomizing it would misparse target characters (a "@" in + // justified txt output) as Klammertext syntax. + std::string eval_command( + std::vector::iterator begin, std::vector::iterator end); + Machine m_machine; Locator m_loc; }; diff --git a/mac/eval_python.cpp b/mac/eval_python.cpp index 980ccc8..0dcfea6 100644 --- a/mac/eval_python.cpp +++ b/mac/eval_python.cpp @@ -29,6 +29,16 @@ Eval_python::Eval_python(Machine& machine, Locator loc) m_globals = PyDict_New(); m_locals = PyDict_New(); PyDict_SetItemString(m_globals, "__builtins__", PyEval_GetBuiltins()); + // The machine's result text, so :after_apply phase functions can take it + // as an argument (the Python counterpart of a :cpp phase function reading + // machine.m_result). Set directly rather than through the state's + // python_code() because document text cannot be safely embedded in a + // quoted Python source string. + PyObject* result_text = PyUnicode_FromString(m_machine.m_result.c_str()); + if (result_text) { + PyDict_SetItemString(m_globals, "K_result", result_text); + Py_DECREF(result_text); + } import_module("inspect", false); if (!m_machine.m_state.m_frames.empty()) { PyRun_String(m_machine.m_state.python_code().c_str(), Py_file_input, m_globals, m_locals); diff --git a/mac/katom_list.cpp b/mac/katom_list.cpp index 3e58efe..e204555 100644 --- a/mac/katom_list.cpp +++ b/mac/katom_list.cpp @@ -8,6 +8,7 @@ #include "log.h" #include "show.h" #include "character.h" +#include "target.h" std::string to_string(std::vector::const_iterator begin, std::vector::const_iterator end, bool trim_result) { @@ -299,7 +300,75 @@ void mark_literal_katoms(katom_list& katoms) //end->m_type = katom_t::replaced; mark_as_replaced(*(end - 1)); //std::for_each(begin + 1, end, [](Katom& k) { k.m_type = katom_t::literal; }); - std::for_each(begin + 1, end - 1, mark_as_literal); + // Hide Klammertext structural characters in the content as KTESC + // markers so the literal text survives re-katomization (the + // @document :text sub-Machine, the @eval result read-back). + // Resolved back to the characters at final processing. Literal + // KLAMMER content (@code) is NOT treated this way -- it is marked + // by mark_literal_klammer_content() and reaches the @eval code raw. + std::for_each(begin + 1, end - 1, [](Katom& k) { + mark_as_literal(k); + k.m_text = hide_structural_characters(k.m_text); + }); + } + } +} + +void hide_special_katoms(katom_list& katoms) +{ + // Replace the text of ^-quoted special-character katoms (^@, ^|, ^#, ^^, + // ^:, ^*) with KTESC markers. The katomizer strips the "^" when the + // katom is constructed, so without this the bare character leaks into + // assembled strings (state values, @eval results) and is re-interpreted + // as Klammertext syntax when those strings are re-katomized -- by + // @document's :text sub-Machine or the @eval result read-back in + // Eval::eval. Markers are inert text at every level and are resolved to + // the characters at final processing (Target::resolve_escapes). + // + // Skipped inside: + // * @@...@@ and @@@...@@@ definition spans -- parameter declarations, + // descriptions, and argtype patterns are extracted as plain strings + // (kdesc display, validation regexes); a klammer BODY is re-processed + // through process_katoms() at application time, outside any + // definition span, so its quoted specials are hidden then. + // * @eval/@read/@cond argument spans -- code, filenames, and + // predicates consumed by the primitive, not target text (the same + // rule as the general-body escape pass in Machine::apply_klammer). + (void)K::log(4); + int definition_depth = 0; + int code_depth = 0; // inside an @eval/@read/@cond span + std::vector apply_is_code; // one entry per open application + for (auto& k : katoms) { + switch (k.m_type) { + case katom_t::define_begin: + case katom_t::machine_begin: + ++definition_depth; + continue; + case katom_t::define_end: + case katom_t::machine_end: + if (definition_depth > 0) --definition_depth; + continue; + case katom_t::eval_begin: + case katom_t::read_begin: + case katom_t::cond_begin: + apply_is_code.push_back(true); + ++code_depth; + continue; + case katom_t::apply_begin: + apply_is_code.push_back(false); + continue; + case katom_t::apply_end: + if (!apply_is_code.empty()) { + if (apply_is_code.back()) --code_depth; + apply_is_code.pop_back(); + } + continue; + default: + break; + } + if (k.m_type == katom_t::special && + definition_depth == 0 && code_depth == 0) { + k.m_text = hide_structural_characters(k.m_text); } } } diff --git a/mac/katom_list.h b/mac/katom_list.h index b8fd296..06b5d47 100644 --- a/mac/katom_list.h +++ b/mac/katom_list.h @@ -47,6 +47,7 @@ find_span_katoms( void encode_nonascii_characters(std::vector& katoms); void mark_literal_katoms(std::vector& katoms); +void hide_special_katoms(std::vector& katoms); void mark_ignored_katoms(std::vector& katoms); void process_klammer_katoms(std::vector& katoms); diff --git a/mac/machine.cpp b/mac/machine.cpp index f6d7039..b481aec 100644 --- a/mac/machine.cpp +++ b/mac/machine.cpp @@ -239,6 +239,7 @@ void Machine::process_katoms( { mark_literal_klammer_content(katoms); if (literal) mark_literal_katoms(katoms); + hide_special_katoms(katoms); if (nonascii) encode_nonascii_characters(katoms); if (ignore) mark_ignored_katoms(katoms); if (whitespace) process_whitespace_modifiers(katoms); @@ -516,13 +517,22 @@ std::string Machine::run_phase_functions() Target target = m_targets.get(m_state.value("K_target"), Locator()); if (!target.m_after_apply.empty()) { (void)K::log(2, target); - Eval E(*this, Locator()); for (auto f : target.m_after_apply) { + // A mode-tagged spec (":cpp ...") names a function that receives + // the Machine itself; a bare Python function is called with the + // result text. The Eval is constructed per phase so a chained + // phase sees its predecessor's result in K_result. + Eval E(*this, Locator()); + if (!f.empty() && f[0] != ':') { + f += "(K_result)"; + } f = "@eval " + f + " @"; auto katoms = katomize(line_split(f), "phase"); - katom_list eval_katoms = E.eval(katoms.begin(), katoms.end() - 2); - // msg() << "eval_katoms: " << eval_katoms << "\n"; - m_result = to_string(eval_katoms.begin(), eval_katoms.end()); + // A phase function's input and output are final target text, not + // Klammertext: take the raw result string. Re-reading it as + // Klammertext (Eval::eval) would misparse target characters -- + // e.g. a "@" from a quoted ^@ in justified txt output. + m_result = E.eval_command(katoms.begin(), katoms.end() - 2); } } return m_result; diff --git a/mac/target.cpp b/mac/target.cpp index ca244f7..ea70a49 100644 --- a/mac/target.cpp +++ b/mac/target.cpp @@ -1,3 +1,6 @@ +#include +#include + #include "target.h" #include "log.h" #include "show.h" @@ -85,20 +88,60 @@ std::string Target::escape_text(std::string text) const std::string Target::unescape_text(std::string text) const { // Restore KTESC markers to original characters (for programmatic use) - for (const auto& [ch, repl] : m_escapes) { - text = string_replace(text, escape_marker(ch), ch); - } - return text; + return ktesc_resolve(text); } std::string Target::resolve_escapes(std::string text) const { + // Target-declared escapes first (marker -> declared replacement), then + // the generic decode for the remaining markers (marker -> the character + // itself: quoted Klammertext specials and literal-span content). for (const auto& [ch, repl] : m_escapes) { text = string_replace(text, escape_marker(ch), repl); } + return ktesc_resolve(text); +} + +std::string ktesc_resolve(std::string text) +{ + // Hand-rolled scan: no std::regex here, this runs over document-sized + // strings. + static const std::string tag = "KTESC"; + size_t pos = 0; + while ((pos = text.find(tag, pos)) != std::string::npos) { + size_t start = pos + tag.size(); + size_t close = text.find(tag, start); + if (close == std::string::npos) break; + size_t len = close - start; + bool is_hex = len > 0 && len % 4 == 0 && + std::all_of(text.begin() + start, text.begin() + close, + [](unsigned char c) { return std::isxdigit(c) != 0; }); + if (!is_hex) { + // Not a marker body; the closing tag may open a real marker. + pos = start; + continue; + } + std::string chars {}; + for (size_t i = start; i < close; i += 4) + chars += (char)std::stoi(text.substr(i, 4), nullptr, 16); + text.replace(pos, close + tag.size() - pos, chars); + pos += chars.size(); + } return text; } +std::string hide_structural_characters(const std::string& s) +{ + std::string result {}; + for (char c : s) { + if (c == '@' || c == '|' || c == '#' || c == '^' || c == ':' || c == '*') + result += Target::escape_marker(std::string(1, c)); + else + result += c; + } + return result; +} + void Target::add_after_apply(std::string function_specs) { for (auto f : regex_split(function_specs, std::regex(R"(\s+;\s+)"), true)) { diff --git a/mac/target.h b/mac/target.h index c7a36e4..3d1e4fe 100644 --- a/mac/target.h +++ b/mac/target.h @@ -47,3 +47,16 @@ public: std::vector> parse_transforms(std::string transform_string); + +// Decode every KTESCKTESC marker in text back to its original +// characters. Used for the final output (after target-declared escapes have +// been resolved to their replacements) and for programmatic use of argument +// values. The Python counterpart is unescape_ktesc() in klammer_base.py. +std::string ktesc_resolve(std::string text); + +// Replace each Klammertext structural character (@ | # ^ : *) in s with its +// KTESC marker, so text that has already been interpreted once (quoted +// specials, ^'...'^ literal content) survives re-katomization by +// sub-Machines and the @eval result read-back. Resolved by ktesc_resolve() +// at final processing. +std::string hide_structural_characters(const std::string& s); diff --git a/sks/block/block.k b/sks/block/block.k index f5fb399..219b3d1 100644 --- a/sks/block/block.k +++ b/sks/block/block.k @@ -86,8 +86,23 @@ ANDY: QUOTE: *s* @@extendpage.tex :: \enlargethispage{*linecount*\baselineskip} @@ @@extendpage.txt :: @@ -@@vspace.k length : Vertical space @@ -@@vspace.tex :: \vspace*{*length*} @@ +@@vspace.k lines.float : Vertical space, in multiples of the current line height @@ +@@vspace.tex :: \vspace{*lines*\baselineskip} @@ +@@vspace.html ::
@@ +@@vspace.txt :: @eval "__VSPACE__" * round(*lines*) @ @@ + +@@vfill.k : +Fill the vertical space so that any following text is flush with the bottom +of the page (in HTML, the bottom of the window; once the content is taller +than the window the space collapses, as on a full LaTeX page). Several +vfills divide the space equally, like LaTeX's \vfill glue. In plain text, +only makes some vertical space. @@ +@@vfill.tex :: \vfill @@ +# An empty glue div; block.css gives it flex-grow 1 and makes the text +# column a flex column only in documents that use it (the :has() rule). +@@vfill.html ::
@@ +@@vfill.txt :: @vspace 3 @ @@ + @@qa.k question | answer : Question and answer formatting @@ @@qa :: diff --git a/sks/block/css/block.css b/sks/block/css/block.css index 857deea..f593b4c 100644 --- a/sks/block/css/block.css +++ b/sks/block/css/block.css @@ -24,3 +24,28 @@ p { .indent { margin-left: 2rem; } + +/* @vfill: LaTeX's \vfill glue as flex-grow. The :has() rule turns the + text column into a flex column ONLY in documents that use @vfill (flex + containers do not collapse vertical margins, so paragraph spacing + shifts slightly there); several vfills share the free space equally, + like \vfill. The min-height ties the fill to the window: with a title + or status bar the column overshoots by their height (a small scroll); + content taller than the window collapses the glue, as on a full LaTeX + page. Fills the window, not a browser-printed page. */ +#text:has(.vfill) { + display: flex; + flex-direction: column; + min-height: calc(100vh - 1lh); +} + +/* One line of whitespace below the filled content, so the last block + does not touch the bottom of the window. A margin on the last flex + item, not #text padding: #middle's overflow clips the padding. */ +#text:has(.vfill) > :last-child { + margin-bottom: 1lh; +} + +.vfill { + flex-grow: 1; +} diff --git a/sks/document/document.cpp b/sks/document/document.cpp index 38bb22c..4eb92c3 100644 --- a/sks/document/document.cpp +++ b/sks/document/document.cpp @@ -26,6 +26,46 @@ std::string document(Machine& machine) } } +// Print a console warning for each KT-WIDE-TABLE marker a table's runtime +// width check (tex_width_check() in table.py) left in the xelatex log, +// followed by a short :column_width primer. Plain line scanning -- no +// std::regex over the (arbitrarily large) log text. +static void warn_wide_tables(const std::string& xelatex_log) +{ + bool any = false; + std::istringstream lines(xelatex_log); + std::string line; + while (std::getline(lines, line)) { + auto pos = line.find("KT-WIDE-TABLE "); + if (pos == std::string::npos) continue; + if (!any) { + std::cerr << yellow + << "Warning: a table is wider than the text column " + << "and extends past the right margin:\n"; + any = true; + } + std::string detail = line.substr(pos + 14); + // The log's newline encoding can leave a trailing backslash. + while (!detail.empty() && + (detail.back() == '\\' || detail.back() == ' ')) + detail.pop_back(); + std::cerr << " " << detail << "\n"; + } + if (any) { + std::cerr << + " The :column_width values and how they interact:\n" + " fit the column's widest entry, never wrapped\n" + " fill the width left over after the other columns, but\n" + " no more than the widest entry; wraps when needed\n" + " 0.0-1.0 that fraction of the text column width\n" + " * the width left over, unconditionally (the table\n" + " always spans the full text column)\n" + " A long-text column set to \"fit\" never wraps and pushes\n" + " the table off the page; give it \"fill\" instead.\n" + << black; + } +} + extern "C" std::string tex_to_pdf(Machine& machine) { @@ -67,6 +107,7 @@ std::string tex_to_pdf(Machine& machine) throw Definition_error(ss.str(), Locator(), false); } } + warn_wide_tables(xelatex_log); /* if (std::stoi(machine.m_state.value("K_verbose_level")) < 2) { for (auto ext : word_split("tex out aux log toc")) { diff --git a/sks/font/font.k b/sks/font/font.k index a76c416..e805813 100644 --- a/sks/font/font.k +++ b/sks/font/font.k @@ -148,3 +148,8 @@ EB Garamond @@rightarrow.k : Right-pointing arrow: @rightarrow@ @@ @@rightarrow.html :: &^#8594; @@ @@rightarrow.tex :: $\rightarrow$ @@ + +@@dot.k : Vertically entered dot @@ +@@dot.tex :: $\cdot$ @@ +@@dot.html :: ^00B7^ @@ +@@dot.txt :: ^00B7^ @@ diff --git a/sks/image/image.k b/sks/image/image.k index 6d3c92c..da9a843 100644 --- a/sks/image/image.k +++ b/sks/image/image.k @@ -18,8 +18,10 @@ # @@image.html basename | width | height : @eval import image ; result = image.image(K) eval@ @@ @@@argtype image_hpos | - horizontal position of an image - :pattern left^|center^|right^|none + 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 @@@ @@ -29,7 +31,6 @@ :width.length .5w @caption_arguments@ :vmargin.bool true - :center.bool true :hpos.image_hpos :rel :abswidth.number 0.0 diff --git a/sks/kutil/kutil.k b/sks/kutil/kutil.k index d033576..aabe8f5 100644 --- a/sks/kutil/kutil.k +++ b/sks/kutil/kutil.k @@ -35,6 +35,16 @@ #: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' + :default center +@@@ + @@@argtype figure_id | an identifier for a figure. diff --git a/sks/link/link.py b/sks/link/link.py index 32e1486..cf5a688 100755 --- a/sks/link/link.py +++ b/sks/link/link.py @@ -107,7 +107,7 @@ class Email(klammer_base.Klammer_base): if self.body: href += val("body", self.body) href = href[:-1] - result = '{}'.format(href, address) + result = '{}'.format(href, address) return result def tex(self): diff --git a/sks/table/css/table.css b/sks/table/css/table.css index e2d3e3e..5902bc6 100644 --- a/sks/table/css/table.css +++ b/sks/table/css/table.css @@ -21,11 +21,18 @@ td { vertical-align: middle; } -/* -td:first-child { +/* Edge cells of a table with no outer vertical line (classes emitted by + table.py): the outer padding is dropped so the cell text aligns with + the text margin. With an outer line the padding stays -- text against + a border looks worse than text inset from a margin. The tex + counterpart is @{} in the column spec. */ +.Fl { padding-left: 0; } -*/ + +.Fr { + padding-right: 0; +} .line_top { border-top: 1px black solid; } @@ -75,6 +82,21 @@ td:first-child { text-align: right; } +/* A cell of a 'fit' column mixed with sized columns: nowrap floors the + column at its widest entry (the tex \widthof semantics). In a + full-width table (fractions/'*') the Wpct 1% width is added -- the + classic shrink idiom, so the column survives surplus distribution and + the extra window width flows to the sized columns. In a content-sized + 'fill' table Wpct must NOT be used: a percentage cell blows an + auto-width table up to full width. */ +.Wfit { + white-space: nowrap; +} + +.Wpct { + width: 1%; +} + .cell_arrow { padding: 1rem; font-size: 1.5rem; diff --git a/sks/table/sty/table.sty b/sks/table/sty/table.sty index 9a0d4e6..04b801e 100644 --- a/sks/table/sty/table.sty +++ b/sks/table/sty/table.sty @@ -28,3 +28,11 @@ \newsavebox{\tablebox} \newlength{\tableboxwidth} + +% Computed widths of :column_width 'fill' columns (up to four per table): +% min(share of the remaining width, widest entry), set per table in the +% generated LaTeX via calc's \minof/\ratio. +\newlength{\klfilla} +\newlength{\klfillb} +\newlength{\klfillc} +\newlength{\klfilld} diff --git a/sks/table/table.k b/sks/table/table.k index 30f28ac..c58f209 100644 --- a/sks/table/table.k +++ b/sks/table/table.k @@ -30,11 +30,16 @@ @@@argtype column_width | width of the table columns. Each column is one of 'fit' (widest line of the - cells in that column), a fraction 0.0->1.0 (that fraction of the total table - width), or '*' (use the remaining width of the table; there can only be one - column with '*'). If there are fewer positions than columns in the table, the - last value is repeated. Extra positions generate a warning. - :pattern (fit^|f^|0?\.\d+^|\*^|\s+)+ + cells in that column), 'fill' (the remaining width of the table after the + other columns, but no more than the column's widest line -- the table stops + growing once nothing needs a line break), a fraction 0.0->1.0 (that + fraction of the total table width), or '*' (the remaining width, + unconditionally -- the table always spans the full width). Several 'fill' + columns divide the remaining width in proportion to their widest lines; + 'fill' cannot be combined with a fraction or '*'. If there are fewer + positions than columns in the table, the last value is repeated. Extra + positions generate a warning. + :pattern (fill^|fit^|f^|0?\.\d+^|\*^|\s+)+ :python_cast (lambda s : s.split()) :default fit @@@ @@ -154,15 +159,15 @@ :default period @@@ -@@@argtype table_hpos | - cell position overrides, as one or more pairs +@@@argtype table_justify | + cell justification overrides, as one or more pairs separated by semicolons (the same list style as ^:calc). is an indexed_range selecting cells; is l, c, or r and overrides - the column position given by ^:cell_hpos for those cells. A colspan - anchor's override positions the whole merged cell. For example, + the column justification given by ^:cell_hpos for those cells. A colspan + anchor's override justifies the whole merged cell. For example, "-3--1(3) r" right-justifies the cells in column 3 of the last three rows. - # Coarse check (" " pairs); hpos_overrides() in + # Coarse check (" " pairs); justify_overrides() in # table.py validates the range and position. :pattern \s*([^^\s;]+\s+[lcr]\s*(;\s*^|\s*$))+ @@@ @@ -188,8 +193,7 @@ @@table rows.rest(2) :id @caption_arguments@ - :center.bool true - :indent.length 1em + :hpos.element_hpos :header.bool true :allow_break.bool false :column_width.column_width @@ -197,7 +201,7 @@ :vline.table_vline :grid.bool false :cell_hpos.cell_hpos - :hpos.table_hpos + :justify.table_justify :header_font.font i :font.font_list :colspan.table_span diff --git a/sks/table/table.py b/sks/table/table.py index fe6007a..5840bb7 100644 --- a/sks/table/table.py +++ b/sks/table/table.py @@ -85,7 +85,38 @@ class Table(klammer_base.Klammer_base): self.cell_hpos = extend(parse_hpos(self.K_target, self.cell_hpos), self.row_size) #self.cell_hpos = self.cell_hpos.split(";") self.font = extend(self.font, self.row_size) - self.hpos_map = self.hpos_overrides() if self.hpos else {} + # In html, a 'fit' column mixed with sized columns must be clamped + # to its widest entry (tex's \widthof semantics) or it absorbs the + # window width: its cells get nowrap (class Wfit; see table.css). + # In a full-width table (fractions/'*') they also need the classic + # 1%-shrink width (class Wpct) to survive surplus distribution; in + # a 'fill' table the table is content-sized (max-width) and a + # percentage would blow it up to full width, so nowrap alone. + # All-'fit' tables shrink to content anyway and keep wrapping. + widths = extend(self.column_width, self.row_size) + self.fill_columns = [i for i, w in enumerate(widths) if w == "fill"] + if self.fill_columns: + if any(w not in ("fit", "f", "fill") for w in widths): + raise Exception( + ':column_width: "fill" cannot be combined with a ' + 'fraction or "*" -- fill computes the remaining width ' + 'itself') + if len(self.fill_columns) > 4: + raise Exception( + ':column_width: at most four "fill" columns are ' + 'supported') + mixed = not all(w in ("fit", "f") for w in widths) + self.fit_columns = {i for i, w in enumerate(widths) + if mixed and w in ("fit", "f")} + self.fit_class = "Wfit" if self.fill_columns else "Wfit Wpct" + # A table edge with no outer vertical line drops its outer cell + # padding (html Fl/Fr classes, tex @{}) so the edge cells' text + # aligns with the text margin; with an outer line the padding + # stays -- text against a border looks worse than text inset + # from a margin. + self.flush_l = 0 not in self.s_vline.by_index + self.flush_r = self.row_size not in self.s_vline.by_index + self.justify_map = self.justify_overrides() if self.justify else {} self.make_cells(self.rows) # Calculated cell values (:calc). A calculation is @@ -438,10 +469,10 @@ class Table(klammer_base.Klammer_base): # colspan anchor's override positions the whole merged cell; in the tex # target an ordinary overridden cell is wrapped in \multicolumn{1}. - def hpos_overrides(self): + def justify_overrides(self): result = {} - for stmt in [s.strip() for s in self.hpos.split(";") if s.strip()]: - ctx = f'In :hpos "{stmt}"' + for stmt in [s.strip() for s in self.justify.split(";") if s.strip()]: + ctx = f'In :justify "{stmt}"' parts = stmt.split() if len(parts) != 2 or parts[1] not in ("l", "c", "r"): self.selector_error( @@ -476,7 +507,7 @@ class Table(klammer_base.Klammer_base): # boundary at the END of the merged region. right_i = cell_i + max(cspan, 1) bottom_i = row_i + max(rspan, 1) - hpos = self.hpos_map.get((row_i, cell_i), self.cell_hpos[cell_i]) + hpos = self.justify_map.get((row_i, cell_i), self.cell_hpos[cell_i]) row_cells.append( table_cell.Cell( cell, @@ -489,13 +520,54 @@ class Table(klammer_base.Klammer_base): self.s_vline.by_index.get(right_i), rspan, cspan, first_column=(cell_i == 0), - hpos_forced=(row_i, cell_i) in self.hpos_map)) + hpos_forced=(row_i, cell_i) in self.justify_map, + fit_class=(self.fit_class + if cell_i in self.fit_columns else ""), + flush_left=(self.flush_l and cell_i == 0), + flush_right=(self.flush_r and + cell_i + max(cspan, 1) == self.row_size))) cells.append(row_cells) self.cells = cells self.column_width_text() # HTML + def html_colgroup(self): + # The CSS counterpart of tex_hpos()'s width resolution: 'fit' is the + # widest entry of the column and no larger (tex's \widthof — the + # cells' Wfit class clamps it there), a + # fraction is of the text column, and '*' shares the width the sized + # columns leave over. All-'fit' (the default) needs no markup at + # all. With no 'fit' column the fixed layout makes the fractions + # exact (long content wraps, as LaTeX's p{} columns do); a 'fit' + # column forces the auto layout, where the fraction widths are + # honored approximately. + widths = extend(self.column_width, self.row_size) + if all(w in ("fit", "f") for w in widths): + return "", "", None + fractions = sum(float(w) for w in widths if w not in ("fit", "f", "*")) + if any(w in ("fit", "f", "*") for w in widths): + table_width = "100%" + else: + table_width = f"{min(fractions, 1) * 100:g}%" + cols = "" + for w in widths: + if w in ("fit", "f"): + # Clamped to the widest entry by the cells' Wfit class + # (width 1% + nowrap; see table.css) -- a px width on the + # does NOT survive surplus distribution when no '*' + # column exists (seen in both Firefox and Chrome). + cols += E("col").str() + elif w == "*": + cols += E("col").str() + else: + share = float(w) if table_width == "100%" else float(w) / fractions + cols += E("col").attr("style", f"width: {share * 100:g}%").str() + layout = "" if any(w in ("fit", "f") for w in widths) else "table-layout: fixed; " + # No blank line before : @document's insert_missing_ids + # would wrap it as a stray

inside the table. + return E("colgroup").body(cols.strip(), newline=False).str(), layout, table_width + def html(self): result = "" for row_i, row in enumerate(self.cells): @@ -505,21 +577,86 @@ class Table(klammer_base.Klammer_base): continue row_html += cell.html().strip() + "\n" result += E("tr").body(row_html).str() - result = E("table").body(result) + if self.fill_columns: + # 'fill': the table sizes itself -- the browser's auto layout + # computes min(available, widest entries) natively, so the fill + # column grows only until nothing needs a line break. Several + # fill columns share in proportion to their content (the auto + # algorithm), matching the tex \ratio division. The max-width + # cap goes on the caption wrapper when there is one (the table's + # own percentage would be circular in a shrink-to-fit wrapper). + result = E("table").body(result) + if self.number or self.caption: + result = html_util.add_caption( + result, "Table", self.number, self.caption, + self.caption_font, hpos=self.hpos, + side=self.caption_side, + font_size=self.caption_font_size, max_width="100%") + else: + result.attr("style", "max-width: 100%") + result = html_util.hpos_container(result, self.hpos).str() + return result + colgroup, layout, table_width = self.html_colgroup() + result = E("table").body(colgroup + result) if self.number or self.caption: + # The caption wrapper carries the width (a percentage on the + # shrink-to-fit wrapper itself would be circular) and the table + # fills it -- which also makes the caption track the table. + if table_width: + result.attr("style", f"{layout}width: 100%") result = html_util.add_caption( result, "Table", self.number, self.caption, self.caption_font, - side=self.caption_side, font_size=self.caption_font_size) + hpos=self.hpos, side=self.caption_side, + font_size=self.caption_font_size, width=table_width) else: - result = result.str() + # 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() return result # LaTeX + # The length registers holding computed 'fill' column widths, declared + # in table.sty; one per fill column, in column order. + fill_registers = ["\\klfilla", "\\klfillb", "\\klfillc", "\\klfilld"] + + def tex_fill_widths(self): + # Set each 'fill' column's register to min(its share of the + # remaining width, its widest entry) -- the same rule the html auto + # layout applies. The shares divide the remaining width in + # proportion to the columns' widest entries (calc's \ratio): either + # the space covers them all and every column caps at its widest + # entry, or no column caps and all the space is used -- no stranded + # whitespace, and no iterative redistribution. + if not self.fill_columns: + return "" + widths = extend(self.column_width, self.row_size) + fit = [f"\\widthof{{{self.column_widths[i]}}}" + for i, w in enumerate(widths) if w in ("fit", "f")] + remaining = "\\tablewidth" + "".join(f" - {e}" for e in fit) + widest = {i: f"\\widthof{{{self.column_widths[i]}}}" + for i in self.fill_columns} + total = " + ".join(widest[i] for i in self.fill_columns) + result = "" + for k, i in enumerate(self.fill_columns): + reg = Table.fill_registers[k] + if len(self.fill_columns) == 1: + result += (f"\\setlength{{{reg}}}" + f"{{\\minof{{{remaining}}}{{{widest[i]}}}}}\n") + else: + result += (f"\\setlength{{{reg}}}{{({remaining})" + f"*\\ratio{{{widest[i]}}}{{{total}}}}}\n") + result += (f"\\setlength{{{reg}}}" + f"{{\\minof{{{reg}}}{{{widest[i]}}}}}\n") + return result + def tex_hpos(self): # One column specification per column: the width comes from - # :column_width ('fit', a fraction of \tablewidth, or '*' for the - # remaining width), the justification from :cell_hpos. + # :column_width ('fit', 'fill' via its precomputed register, a + # fraction of \tablewidth, or '*' for the remaining width), the + # justification from :cell_hpos. def par_format(s, justification): command = {"l" : "raggedright", "c" : "centering", @@ -527,9 +664,13 @@ class Table(klammer_base.Klammer_base): return f">{{\\{command}}}p{{{s}}}" widths = [] + fill_ordinal = 0 for i, w in enumerate(extend(self.column_width, self.row_size)): if w in ("fit", "f"): widths.append(f"\\widthof{{{self.column_widths[i]}}}") + elif w == "fill": + widths.append(Table.fill_registers[fill_ordinal]) + fill_ordinal += 1 elif w == "*": widths.append(None) else: @@ -548,6 +689,12 @@ class Table(klammer_base.Klammer_base): parts = [""] * (self.row_size * 2 + 1) for i in self.s_vline.by_index: parts[i * 2] = "|" + # A lineless table edge drops its outer \tabcolsep (matched by the + # \tablewidth arithmetic in tex()). + if self.flush_l: + parts[0] = "@{}" + if self.flush_r: + parts[-1] = "@{}" for i, hpos in enumerate(self.tex_hpos()): parts[i * 2 + 1] = hpos # print("tex_column_spec:", "".join(parts)) @@ -615,9 +762,45 @@ class Table(klammer_base.Klammer_base): result += "}} \\\\ \\endlastfoot\n" return result + def tex_position(self): + # Position a PAGE-BREAKING table with longtable's own glue (it + # cannot be boxed). A boxed table (allow_break false) is positioned + # 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. + 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" + elif self.hpos == "right": + left, right = "\\fill", "0pt" + else: + length, _, _ = kutil.parse_length("tex", self.hpos, 1) + left, right = length, "\\fill" + return (f"\\setlength{{\\LTleft}}{{{left}}}\n" + f"\\setlength{{\\LTright}}{{{right}}}\n") + + def tex_width_check(self, name): + # Emit a marker into the xelatex log when the measured table is + # wider than the text column (2pt tolerance for exactly-full-width + # tables). tex_to_pdf() in document.cpp scans the log for the + # marker and prints the console warning with the :column_width + # primer -- the widths are only known at LaTeX run time, and prose + # kept out of TeX avoids the log's 79-column line wrapping. + return ("\\ifdim\\tableboxwidth>\\dimexpr\\textwidth+2pt\\relax\n" + f"\\message{{^^JKT-WIDE-TABLE {name} overfull by " + "\\the\\dimexpr\\tableboxwidth-\\textwidth\\relax^^J}\n" + "\\fi\n") + def tex(self): - result = self.get_width() - result += f"\\renewcommand*{{\\arraystretch}}{{{self.leading}}}\n" + name = f"Reference-Table-{Table.id}" + Table.id += 1 + # The measuring \savebox must stay OUTSIDE the \tableboxwidth + # minipage below: it computes the width the minipage consumes. + measure = self.get_width() + self.tex_width_check(name) + result = f"\\renewcommand*{{\\arraystretch}}{{{self.leading}}}\n" if self.allow_break: result += "\\vspace*{12pt}\n" result += "\\begin{longtable}{" @@ -629,23 +812,37 @@ class Table(klammer_base.Klammer_base): result += "\\end{longtable}\n" if not self.allow_break: + # Box the table at its measured width so the caption tracks it + # and the box can be positioned as one piece; the LT glue then + # has no room and positioning falls to the :hpos wrapper. (The + # page-breaking table cannot be boxed -- there the LT glue + # positions and make_caption's \multicolumn tracks.) + result = latex_util.minipage( + result, "\\tableboxwidth", vertical="t", center=False) if self.number or self.caption: result = latex_util.add_caption( - result, "Table", self.number, self.caption, "\\tablewidth", - side=self.caption_side, font_symbol=self.caption_font, + 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) else: - result = latex_util.caption_wrapper(result, "center") + result = latex_util.caption_wrapper(result, self.hpos) + result = measure + result - name = f"Reference-Table-{Table.id}" - Table.id += 1 result = f"\\hypertarget{{{name}}}{{}}\\label{{Label-{name}}}\n{result}" # The wrapper (add_caption/caption_wrapper) owns all vertical space # around the table; longtable's own glue is zeroed. result = (f"\\setlength{{\\tabcolsep}}{{{self.colsep}}}\n" "\\setlength{\\LTpre}{0pt}\n" "\\setlength{\\LTpost}{0pt}\n" - f"\\setlength{{\\tablewidth}}{{\\textwidth - {2 * self.row_size}\\tabcolsep}}\n" + + self.tex_position() + + # 2 \tabcolsep per column, minus the ones @{} removes at + # flush (lineless) edges. + f"\\setlength{{\\tablewidth}}{{\\textwidth - " + f"{2 * self.row_size - self.flush_l - self.flush_r}\\tabcolsep}}\n" + # Fill widths need \tablewidth and must precede the + # measuring \savebox, whose column spec reads them. + + self.tex_fill_widths() + result) result = re.sub(r"\newline", r"\\\\", result) return result diff --git a/sks/table/table_cell.py b/sks/table/table_cell.py index 3b72bf4..aec1b8e 100644 --- a/sks/table/table_cell.py +++ b/sks/table/table_cell.py @@ -12,7 +12,8 @@ class Cell: def __init__(self, text, font, hpos, top, right, bottom, left, left_all, right_all, - rowspan, colspan, first_column=False, hpos_forced=False): + rowspan, colspan, first_column=False, hpos_forced=False, + fit_class="", flush_left=False, flush_right=False): self.text = text self.font = font self.hpos = hpos @@ -23,6 +24,17 @@ class Cell: self.rowspan = rowspan self.colspan = colspan self.first_column = first_column + # html only: the class(es) clamping a 'fit' column's cell to its + # widest entry -- "Wfit Wpct" in a full-width table, "Wfit" in a + # content-sized ('fill') table, "" when not a fit column. + self.fit_class = fit_class + # Cell sits on a table edge with no outer vertical line: its outer + # padding (html) / \tabcolsep (tex, via @{}) is removed so the text + # aligns with the text margin. A tex \multicolumn replaces the + # whole preamble entry including the @{}, so edge cells must + # re-emit it in their own spec. + self.flush_left = flush_left + self.flush_right = flush_right #print("Cell:", text, hpos) def __str__(self): @@ -47,6 +59,12 @@ class Cell: if pred: result.cls(cls_name) result.cls(f"H{self.hpos}") + if self.fit_class: + result.cls(self.fit_class) + if self.flush_left: + result.cls("Fl") + if self.flush_right: + result.cls("Fr") return result.str() def tex(self, debug=False): # , left_line, right_line): @@ -70,6 +88,10 @@ class Cell: pos = "|" + pos if self.border.right: pos = pos + "|" + if self.flush_left: + pos = "@{}" + pos + if self.flush_right: + pos = pos + "@{}" return f"\\multicolumn{{{self.colspan}}}{{{pos}}}{{{result}}}" remove_left = self.border.left_all and not self.border.left remove_right = self.border.right_all and not self.border.right @@ -83,6 +105,10 @@ class Cell: pos = "|" + pos if self.border.right_all and self.border.right: pos = pos + "|" + if self.flush_left: + pos = "@{}" + pos + if self.flush_right: + pos = pos + "@{}" result = f"\\multicolumn{{1}}{{{pos}}}{{{result}}}" return result diff --git a/sks/target/css/target.css b/sks/target/css/target.css index 08040bd..5988bb0 100644 --- a/sks/target/css/target.css +++ b/sks/target/css/target.css @@ -160,6 +160,14 @@ display: inline-flex; } +/* A length :hpos; the margin-left comes as an inline style. */ +.hpos_indent { + display: flex; + flex-direction: row; + align-items: center; + justify-content: left; +} + .hpos_margin { margin: 0 1em 0 1em; } diff --git a/sks/target/html_util.cpp b/sks/target/html_util.cpp index ff97531..604e5da 100644 --- a/sks/target/html_util.cpp +++ b/sks/target/html_util.cpp @@ -309,7 +309,7 @@ namespace html { elts.push_back(elt("style", css)); } - if (!title.empty()) { + if (!trim(title).empty()) { elts.push_back(elt("title", trim(title))); } HTML result = elt("head", elts); @@ -486,11 +486,15 @@ namespace html { elements_t body {}; if (!title.empty()) { elements_t title_bar {}; - title_bar.push_back(elt("span", trim(title)).attr("id", "title_text")); + if (!trim(title).empty()) { + title_bar.push_back(elt("span", trim(title)).attr("id", "title_text")); + } if (!logo.empty()) { title_bar.push_back(elt("span", trim(logo)).attr("id", "logo")); } - body.push_back(elt("div", title_bar).attr("id", "title")); + if (!title_bar.empty()) { + body.push_back(elt("div", title_bar).attr("id", "title")); + } } if (!nav.empty()) { body.push_back(navigation(max_level)); @@ -538,12 +542,18 @@ namespace html { void add_title(elements_t& body, std::string title, std::string logo) { + // No title bar at all when there is nothing to put in it (an + // untitled document); a logo alone still gets the bar. elements_t title_bar {}; - title_bar.push_back(elt("span", trim(title)).attr("id", "title_text")); + if (!trim(title).empty()) { + title_bar.push_back(elt("span", trim(title)).attr("id", "title_text")); + } if (!logo.empty()) { title_bar.push_back(elt("span", trim(logo)).attr("id", "logo")); } - body.push_back(elt("div", title_bar).attr("id", "title")); + if (!title_bar.empty()) { + body.push_back(elt("div", title_bar).attr("id", "title")); + } } void add_nav(elements_t& body, int max_level) diff --git a/sks/target/html_util.py b/sks/target/html_util.py index 5568a76..06b49bc 100644 --- a/sks/target/html_util.py +++ b/sks/target/html_util.py @@ -193,8 +193,27 @@ def element_tag(element): def font_class(font): return {"r" : "", "i" : "ritalic", "t" : "monospace", "s" : "sanserif"}[font] +def hpos_container(element, hpos): + # 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 + if hpos not in ("left", "center", "right", "none"): + length, _, _ = kutil.parse_length("html", hpos, 1) + style = f"margin-left: {length}" + hpos = "indent" + result = E("div").cls("hpos_" + hpos).body(element) + if style: + result.attr("style", style) + if hpos not in ("center", "indent"): + result.cls("hpos_margin") + return result + + def add_caption(element, caption_label, number, caption_text, - font_symbol="i", hpos="center", side="bottom", as_string=True, font_size=.9): + font_symbol="i", hpos="center", side="bottom", as_string=True, + font_size=.9, width=None, max_width=None): tag = element_tag(element) # Caption caption = "" @@ -232,12 +251,19 @@ def add_caption(element, caption_label, number, caption_text, #print("-"*80) element = E("div").cls("caption_" + side).attr("data-label", caption_label).body(element) + if width: + # The element's width (e.g. a table's :column_width total) lives + # on this wrapper: the element fills it, and the caption tracks + # the element. + element.attr("style", f"width: {width}") + elif max_width: + # A content-sized element (a 'fill' table): the wrapper shrinks + # to it but never past the text column, so the element wraps at + # narrow windows instead of overflowing. + element.attr("style", f"max-width: {max_width}") - result = element - result = E("div").cls("hpos_" + hpos).body(element) - if hpos != "center": - result = result.cls("hpos_margin") + result = hpos_container(element, hpos) if number: result.cls("element_container") diff --git a/sks/target/latex_util.py b/sks/target/latex_util.py index ab56001..1104c63 100644 --- a/sks/target/latex_util.py +++ b/sks/target/latex_util.py @@ -55,15 +55,21 @@ def minipage(content, width="\\textwidth", vertical="c", center=True, vmargin="" return result def caption_wrapper(element, hpos, bottom_margin=.67): + # 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. vmargin = f"{bottom_margin}\\baselineskip" - result = element if hpos == "center": - result = minipage(element, vmargin=vmargin) - elif hpos == "left": - result = minipage("\\hfill" + element, vmargin=vmargin) - elif hpos == "right": - result = minipage(element + "\\hfill", vmargin=vmargin) - return result + return minipage(element, vmargin=vmargin) + if hpos == "left": + return minipage(element + "\\hfill", vmargin=vmargin, center=False) + if hpos == "right": + return minipage("\\hfill" + element, vmargin=vmargin, center=False) + if hpos == "none": + return element + length, _, _ = kutil.parse_length("tex", hpos, 1) + return minipage(f"\\hspace*{{{length}}}" + element + "\\hfill", + vmargin=vmargin, center=False) def make_caption_text(number, label, text, font_symbol, font_size): caption = None diff --git a/sks/target/phases.py b/sks/target/phases.py index b7f36ee..7a095aa 100644 --- a/sks/target/phases.py +++ b/sks/target/phases.py @@ -304,17 +304,28 @@ def make_pdf_from_tex(filename, K, twice=True): os.system('rm -rf {}/{}.{}'.format(dirname, basename, unused_ext)) +# Emitted by @vspace.txt (sks/block/block.k), one marker per line of space. +# An @eval result consisting only of whitespace is trimmed away by the +# Klammermachine, so vertical space must travel as markers and become +# newlines here, after blank-line runs have been normalized. +vspace_marker = "__VSPACE__" + def justify_blocks(text, K=None): - rgx = re.compile("\n\n+", re.S) delim = '__DIVIDE__' - text = rgx.sub(delim, text) + text = re.sub("\n\n+", delim, text) result = "" for par in text.split(delim): - #print(par) - if par[0] not in {' ', '['}: + stripped = par.strip() + n = stripped.count(vspace_marker) + if n and stripped == vspace_marker * n: + # A paragraph of only @vspace markers: n blank lines in addition + # to the normal paragraph separation. + result += "\n" * n + continue + if par and par[0] not in {' ', '['}: par = "\n".join(textwrap.wrap(par, width=80)) result += par + "\n\n" - return result + return result.replace(vspace_marker, "\n") diff --git a/sks/target/target.k b/sks/target/target.k index 9b6bd69..f771ebf 100644 --- a/sks/target/target.k +++ b/sks/target/target.k @@ -44,7 +44,7 @@ The LaTeX transformations supported by the SKS are: @@@ @@@target tex | LaTeX -:escape \ \textbackslash{} & \& { \{ } \} $ \$ % \% _ \_ +:escape \ \textbackslash{} & \& { \{ } \} $ \$ % \% _ \_ ^# \^# ^^ \textasciicircum{} @@@ @@@target pdf | PDF from LaTeX