Escaping, table layout, and document fixes

Quoted Klammertext specials (^@ ^| ^# ^^ ^: ^*) and ^'...'^ regions now
survive re-processing (held as escape markers until final output);
:after_apply phase functions receive and return raw target text.

Tables: :hpos element position (center|left|right|<length>) replaces the
unimplemented :center/:indent; the ranged cell override is renamed
:justify; :column_width works in html (colgroup widths) and gains
'fill' -- the remaining width, capped at the column's widest entry, in
both targets; a table wider than the text column warns on the console;
table edges without an outer line set their text flush on the margins.

@document: no empty title bar for untitled documents; @vfill fills to
the bottom of the window in html (pure CSS); @vspace in plain text;
new @dot klammer; monospace email links.
This commit is contained in:
2026-07-25 21:16:21 +02:00
parent 4262fc6136
commit d61336b191
26 changed files with 650 additions and 75 deletions

View File

@@ -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)); (void)K::log(3, *begin, *(end - 1));
//msg() << "in Eval::eval:\n" << ktype << kall << kindex << std::pair(begin, end) << "\n"; //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_cpp E_cpp(m_machine, begin->m_loc);
eval_result = E_cpp.eval(libpath, funcname); 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 {}; katom_list result {};
Machine M = m_machine; Machine M = m_machine;
size_t before = M.m_katoms.size(); size_t before = M.m_katoms.size();

View File

@@ -25,6 +25,14 @@ public:
std::vector<Katom> eval( std::vector<Katom> eval(
std::vector<Katom>::iterator begin, std::vector<Katom>::iterator end); std::vector<Katom>::iterator begin, std::vector<Katom>::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<Katom>::iterator begin, std::vector<Katom>::iterator end);
Machine m_machine; Machine m_machine;
Locator m_loc; Locator m_loc;
}; };

View File

@@ -29,6 +29,16 @@ Eval_python::Eval_python(Machine& machine, Locator loc)
m_globals = PyDict_New(); m_globals = PyDict_New();
m_locals = PyDict_New(); m_locals = PyDict_New();
PyDict_SetItemString(m_globals, "__builtins__", PyEval_GetBuiltins()); 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); import_module("inspect", false);
if (!m_machine.m_state.m_frames.empty()) { 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); PyRun_String(m_machine.m_state.python_code().c_str(), Py_file_input, m_globals, m_locals);

View File

@@ -8,6 +8,7 @@
#include "log.h" #include "log.h"
#include "show.h" #include "show.h"
#include "character.h" #include "character.h"
#include "target.h"
std::string to_string(std::vector<Katom>::const_iterator begin, std::vector<Katom>::const_iterator end, bool trim_result) std::string to_string(std::vector<Katom>::const_iterator begin, std::vector<Katom>::const_iterator end, bool trim_result)
{ {
@@ -299,7 +300,75 @@ void mark_literal_katoms(katom_list& katoms)
//end->m_type = katom_t::replaced; //end->m_type = katom_t::replaced;
mark_as_replaced(*(end - 1)); 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, [](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<bool> 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);
} }
} }
} }

View File

@@ -47,6 +47,7 @@ find_span_katoms(
void encode_nonascii_characters(std::vector<Katom>& katoms); void encode_nonascii_characters(std::vector<Katom>& katoms);
void mark_literal_katoms(std::vector<Katom>& katoms); void mark_literal_katoms(std::vector<Katom>& katoms);
void hide_special_katoms(std::vector<Katom>& katoms);
void mark_ignored_katoms(std::vector<Katom>& katoms); void mark_ignored_katoms(std::vector<Katom>& katoms);
void process_klammer_katoms(std::vector<Katom>& katoms); void process_klammer_katoms(std::vector<Katom>& katoms);

View File

@@ -239,6 +239,7 @@ void Machine::process_katoms(
{ {
mark_literal_klammer_content(katoms); mark_literal_klammer_content(katoms);
if (literal) mark_literal_katoms(katoms); if (literal) mark_literal_katoms(katoms);
hide_special_katoms(katoms);
if (nonascii) encode_nonascii_characters(katoms); if (nonascii) encode_nonascii_characters(katoms);
if (ignore) mark_ignored_katoms(katoms); if (ignore) mark_ignored_katoms(katoms);
if (whitespace) process_whitespace_modifiers(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()); Target target = m_targets.get(m_state.value("K_target"), Locator());
if (!target.m_after_apply.empty()) { if (!target.m_after_apply.empty()) {
(void)K::log(2, target); (void)K::log(2, target);
Eval E(*this, Locator());
for (auto f : target.m_after_apply) { 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 + " @"; f = "@eval " + f + " @";
auto katoms = katomize(line_split(f), "phase"); auto katoms = katomize(line_split(f), "phase");
katom_list eval_katoms = E.eval(katoms.begin(), katoms.end() - 2); // A phase function's input and output are final target text, not
// msg() << "eval_katoms: " << eval_katoms << "\n"; // Klammertext: take the raw result string. Re-reading it as
m_result = to_string(eval_katoms.begin(), eval_katoms.end()); // 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; return m_result;

View File

@@ -1,3 +1,6 @@
#include <algorithm>
#include <cctype>
#include "target.h" #include "target.h"
#include "log.h" #include "log.h"
#include "show.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 std::string Target::unescape_text(std::string text) const
{ {
// Restore KTESC markers to original characters (for programmatic use) // Restore KTESC markers to original characters (for programmatic use)
for (const auto& [ch, repl] : m_escapes) { return ktesc_resolve(text);
text = string_replace(text, escape_marker(ch), ch);
}
return text;
} }
std::string Target::resolve_escapes(std::string text) const 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) { for (const auto& [ch, repl] : m_escapes) {
text = string_replace(text, escape_marker(ch), repl); 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; 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) void Target::add_after_apply(std::string function_specs)
{ {
for (auto f : regex_split(function_specs, std::regex(R"(\s+;\s+)"), true)) { for (auto f : regex_split(function_specs, std::regex(R"(\s+;\s+)"), true)) {

View File

@@ -47,3 +47,16 @@ public:
std::vector<std::pair<std::string, std::string>> std::vector<std::pair<std::string, std::string>>
parse_transforms(std::string transform_string); parse_transforms(std::string transform_string);
// Decode every KTESC<hex>KTESC 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);

View File

@@ -86,8 +86,23 @@ ANDY: QUOTE: *s*
@@extendpage.tex :: \enlargethispage{*linecount*\baselineskip} @@ @@extendpage.tex :: \enlargethispage{*linecount*\baselineskip} @@
@@extendpage.txt :: @@ @@extendpage.txt :: @@
@@vspace.k length : Vertical space @@ @@vspace.k lines.float : Vertical space, in multiples of the current line height @@
@@vspace.tex :: \vspace*{*length*} @@ @@vspace.tex :: \vspace{*lines*\baselineskip} @@
@@vspace.html :: <div style="height: *lines*lh"></div> @@
@@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 :: <div class="vfill"></div> @@
@@vfill.txt :: @vspace 3 @ @@
@@qa.k question | answer : Question and answer formatting @@ @@qa.k question | answer : Question and answer formatting @@
@@qa :: @@qa ::

View File

@@ -24,3 +24,28 @@ p {
.indent { .indent {
margin-left: 2rem; 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;
}

View File

@@ -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" extern "C"
std::string tex_to_pdf(Machine& machine) 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); throw Definition_error(ss.str(), Locator(), false);
} }
} }
warn_wide_tables(xelatex_log);
/* /*
if (std::stoi(machine.m_state.value("K_verbose_level")) < 2) { if (std::stoi(machine.m_state.value("K_verbose_level")) < 2) {
for (auto ext : word_split("tex out aux log toc")) { for (auto ext : word_split("tex out aux log toc")) {

View File

@@ -148,3 +148,8 @@ EB Garamond
@@rightarrow.k : Right-pointing arrow: @rightarrow@ @@ @@rightarrow.k : Right-pointing arrow: @rightarrow@ @@
@@rightarrow.html :: &^#8594; @@ @@rightarrow.html :: &^#8594; @@
@@rightarrow.tex :: $\rightarrow$ @@ @@rightarrow.tex :: $\rightarrow$ @@
@@dot.k : Vertically entered dot @@
@@dot.tex :: $\cdot$ @@
@@dot.html :: ^00B7^ @@
@@dot.txt :: ^00B7^ @@

View File

@@ -18,8 +18,10 @@
# @@image.html basename | width | height : @eval import image ; result = image.image(K) eval@ @@ # @@image.html basename | width | height : @eval import image ; result = image.image(K) eval@ @@
@@@argtype image_hpos | @@@argtype image_hpos |
horizontal position of an image horizontal position of an image: the element positions (center, left,
:pattern left^|center^|right^|none 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 :default center
@@@ @@@
@@ -29,7 +31,6 @@
:width.length .5w :width.length .5w
@caption_arguments@ @caption_arguments@
:vmargin.bool true :vmargin.bool true
:center.bool true
:hpos.image_hpos :hpos.image_hpos
:rel :rel
:abswidth.number 0.0 :abswidth.number 0.0

View File

@@ -35,6 +35,16 @@
#:python_cast (lambda s : [__import__("kutil").parse_length("tex", e) for e in s.split()]) #: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 | @@@argtype figure_id |
an identifier for a figure. an identifier for a figure.

View File

@@ -107,7 +107,7 @@ class Email(klammer_base.Klammer_base):
if self.body: if self.body:
href += val("body", self.body) href += val("body", self.body)
href = href[:-1] href = href[:-1]
result = '<a href="{}">{}</a>'.format(href, address) result = '<a href="{}" class="monospace">{}</a>'.format(href, address)
return result return result
def tex(self): def tex(self):

View File

@@ -21,11 +21,18 @@ td {
vertical-align: middle; vertical-align: middle;
} }
/* /* Edge cells of a table with no outer vertical line (classes emitted by
td:first-child { 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; padding-left: 0;
} }
*/
.Fr {
padding-right: 0;
}
.line_top { .line_top {
border-top: 1px black solid; border-top: 1px black solid;
} }
@@ -75,6 +82,21 @@ td:first-child {
text-align: right; 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 { .cell_arrow {
padding: 1rem; padding: 1rem;
font-size: 1.5rem; font-size: 1.5rem;

View File

@@ -28,3 +28,11 @@
\newsavebox{\tablebox} \newsavebox{\tablebox}
\newlength{\tableboxwidth} \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}

View File

@@ -30,11 +30,16 @@
@@@argtype column_width | @@@argtype column_width |
width of the table columns. Each column is one of 'fit' (widest line of the 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 cells in that column), 'fill' (the remaining width of the table after the
width), or '*' (use the remaining width of the table; there can only be one other columns, but no more than the column's widest line -- the table stops
column with '*'). If there are fewer positions than columns in the table, the growing once nothing needs a line break), a fraction 0.0->1.0 (that
last value is repeated. Extra positions generate a warning. fraction of the total table width), or '*' (the remaining width,
:pattern (fit^|f^|0?\.\d+^|\*^|\s+)+ 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()) :python_cast (lambda s : s.split())
:default fit :default fit
@@@ @@@
@@ -154,15 +159,15 @@
:default period :default period
@@@ @@@
@@@argtype table_hpos | @@@argtype table_justify |
cell position overrides, as one or more <cells> <position> pairs cell justification overrides, as one or more <cells> <position> pairs
separated by semicolons (the same list style as ^:calc). <cells> is an separated by semicolons (the same list style as ^:calc). <cells> is an
indexed_range selecting cells; <position> is l, c, or r and overrides indexed_range selecting cells; <position> is l, c, or r and overrides
the column position given by ^:cell_hpos for those cells. A colspan the column justification given by ^:cell_hpos for those cells. A colspan
anchor's override positions the whole merged cell. For example, 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 "-3--1(3) r" right-justifies the cells in column 3 of the last three
rows. rows.
# Coarse check ("<cells> <position>" pairs); hpos_overrides() in # Coarse check ("<cells> <position>" pairs); justify_overrides() in
# table.py validates the range and position. # table.py validates the range and position.
:pattern \s*([^^\s;]+\s+[lcr]\s*(;\s*^|\s*$))+ :pattern \s*([^^\s;]+\s+[lcr]\s*(;\s*^|\s*$))+
@@@ @@@
@@ -188,8 +193,7 @@
@@table rows.rest(2) @@table rows.rest(2)
:id :id
@caption_arguments@ @caption_arguments@
:center.bool true :hpos.element_hpos
:indent.length 1em
:header.bool true :header.bool true
:allow_break.bool false :allow_break.bool false
:column_width.column_width :column_width.column_width
@@ -197,7 +201,7 @@
:vline.table_vline :vline.table_vline
:grid.bool false :grid.bool false
:cell_hpos.cell_hpos :cell_hpos.cell_hpos
:hpos.table_hpos :justify.table_justify
:header_font.font i :header_font.font i
:font.font_list :font.font_list
:colspan.table_span :colspan.table_span

View File

@@ -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 = extend(parse_hpos(self.K_target, self.cell_hpos), self.row_size)
#self.cell_hpos = self.cell_hpos.split(";") #self.cell_hpos = self.cell_hpos.split(";")
self.font = extend(self.font, self.row_size) 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) self.make_cells(self.rows)
# Calculated cell values (:calc). A calculation is # 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 # colspan anchor's override positions the whole merged cell; in the tex
# target an ordinary overridden cell is wrapped in \multicolumn{1}. # target an ordinary overridden cell is wrapped in \multicolumn{1}.
def hpos_overrides(self): def justify_overrides(self):
result = {} result = {}
for stmt in [s.strip() for s in self.hpos.split(";") if s.strip()]: for stmt in [s.strip() for s in self.justify.split(";") if s.strip()]:
ctx = f'In :hpos "{stmt}"' ctx = f'In :justify "{stmt}"'
parts = stmt.split() parts = stmt.split()
if len(parts) != 2 or parts[1] not in ("l", "c", "r"): if len(parts) != 2 or parts[1] not in ("l", "c", "r"):
self.selector_error( self.selector_error(
@@ -476,7 +507,7 @@ class Table(klammer_base.Klammer_base):
# boundary at the END of the merged region. # boundary at the END of the merged region.
right_i = cell_i + max(cspan, 1) right_i = cell_i + max(cspan, 1)
bottom_i = row_i + max(rspan, 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( row_cells.append(
table_cell.Cell( table_cell.Cell(
cell, cell,
@@ -489,13 +520,54 @@ class Table(klammer_base.Klammer_base):
self.s_vline.by_index.get(right_i), self.s_vline.by_index.get(right_i),
rspan, cspan, rspan, cspan,
first_column=(cell_i == 0), 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) cells.append(row_cells)
self.cells = cells self.cells = cells
self.column_width_text() self.column_width_text()
# HTML # 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
# <col> 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 </colgroup>: @document's insert_missing_ids
# would wrap it as a stray <p> inside the table.
return E("colgroup").body(cols.strip(), newline=False).str(), layout, table_width
def html(self): def html(self):
result = "" result = ""
for row_i, row in enumerate(self.cells): for row_i, row in enumerate(self.cells):
@@ -505,21 +577,86 @@ class Table(klammer_base.Klammer_base):
continue continue
row_html += cell.html().strip() + "\n" row_html += cell.html().strip() + "\n"
result += E("tr").body(row_html).str() result += E("tr").body(row_html).str()
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) result = E("table").body(result)
if self.number or self.caption: if self.number or self.caption:
result = html_util.add_caption( result = html_util.add_caption(
result, "Table", self.number, self.caption, self.caption_font, result, "Table", self.number, self.caption,
side=self.caption_side, font_size=self.caption_font_size) self.caption_font, hpos=self.hpos,
side=self.caption_side,
font_size=self.caption_font_size, max_width="100%")
else: else:
result = result.str() 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,
hpos=self.hpos, side=self.caption_side,
font_size=self.caption_font_size, width=table_width)
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()
return result return result
# LaTeX # 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): def tex_hpos(self):
# One column specification per column: the width comes from # One column specification per column: the width comes from
# :column_width ('fit', a fraction of \tablewidth, or '*' for the # :column_width ('fit', 'fill' via its precomputed register, a
# remaining width), the justification from :cell_hpos. # fraction of \tablewidth, or '*' for the remaining width), the
# justification from :cell_hpos.
def par_format(s, justification): def par_format(s, justification):
command = {"l" : "raggedright", command = {"l" : "raggedright",
"c" : "centering", "c" : "centering",
@@ -527,9 +664,13 @@ class Table(klammer_base.Klammer_base):
return f">{{\\{command}}}p{{{s}}}" return f">{{\\{command}}}p{{{s}}}"
widths = [] widths = []
fill_ordinal = 0
for i, w in enumerate(extend(self.column_width, self.row_size)): for i, w in enumerate(extend(self.column_width, self.row_size)):
if w in ("fit", "f"): if w in ("fit", "f"):
widths.append(f"\\widthof{{{self.column_widths[i]}}}") widths.append(f"\\widthof{{{self.column_widths[i]}}}")
elif w == "fill":
widths.append(Table.fill_registers[fill_ordinal])
fill_ordinal += 1
elif w == "*": elif w == "*":
widths.append(None) widths.append(None)
else: else:
@@ -548,6 +689,12 @@ class Table(klammer_base.Klammer_base):
parts = [""] * (self.row_size * 2 + 1) parts = [""] * (self.row_size * 2 + 1)
for i in self.s_vline.by_index: for i in self.s_vline.by_index:
parts[i * 2] = "|" 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()): for i, hpos in enumerate(self.tex_hpos()):
parts[i * 2 + 1] = hpos parts[i * 2 + 1] = hpos
# print("tex_column_spec:", "".join(parts)) # print("tex_column_spec:", "".join(parts))
@@ -615,9 +762,45 @@ class Table(klammer_base.Klammer_base):
result += "}} \\\\ \\endlastfoot\n" result += "}} \\\\ \\endlastfoot\n"
return result 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): def tex(self):
result = self.get_width() name = f"Reference-Table-{Table.id}"
result += f"\\renewcommand*{{\\arraystretch}}{{{self.leading}}}\n" 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: if self.allow_break:
result += "\\vspace*{12pt}\n" result += "\\vspace*{12pt}\n"
result += "\\begin{longtable}{" result += "\\begin{longtable}{"
@@ -629,23 +812,37 @@ class Table(klammer_base.Klammer_base):
result += "\\end{longtable}\n" result += "\\end{longtable}\n"
if not self.allow_break: 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: if self.number or self.caption:
result = latex_util.add_caption( result = latex_util.add_caption(
result, "Table", self.number, self.caption, "\\tablewidth", result, "Table", self.number, self.caption, "\\tableboxwidth",
side=self.caption_side, font_symbol=self.caption_font, hpos=self.hpos, side=self.caption_side,
font_symbol=self.caption_font,
font_size=self.caption_font_size) font_size=self.caption_font_size)
else: 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}" result = f"\\hypertarget{{{name}}}{{}}\\label{{Label-{name}}}\n{result}"
# The wrapper (add_caption/caption_wrapper) owns all vertical space # The wrapper (add_caption/caption_wrapper) owns all vertical space
# around the table; longtable's own glue is zeroed. # around the table; longtable's own glue is zeroed.
result = (f"\\setlength{{\\tabcolsep}}{{{self.colsep}}}\n" result = (f"\\setlength{{\\tabcolsep}}{{{self.colsep}}}\n"
"\\setlength{\\LTpre}{0pt}\n" "\\setlength{\\LTpre}{0pt}\n"
"\\setlength{\\LTpost}{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)
result = re.sub(r"\newline", r"\\\\", result) result = re.sub(r"\newline", r"\\\\", result)
return result return result

View File

@@ -12,7 +12,8 @@ class Cell:
def __init__(self, text, font, hpos, def __init__(self, text, font, hpos,
top, right, bottom, left, top, right, bottom, left,
left_all, right_all, 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.text = text
self.font = font self.font = font
self.hpos = hpos self.hpos = hpos
@@ -23,6 +24,17 @@ class Cell:
self.rowspan = rowspan self.rowspan = rowspan
self.colspan = colspan self.colspan = colspan
self.first_column = first_column 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) #print("Cell:", text, hpos)
def __str__(self): def __str__(self):
@@ -47,6 +59,12 @@ class Cell:
if pred: if pred:
result.cls(cls_name) result.cls(cls_name)
result.cls(f"H{self.hpos}") 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() return result.str()
def tex(self, debug=False): # , left_line, right_line): def tex(self, debug=False): # , left_line, right_line):
@@ -70,6 +88,10 @@ class Cell:
pos = "|" + pos pos = "|" + pos
if self.border.right: if self.border.right:
pos = pos + "|" pos = pos + "|"
if self.flush_left:
pos = "@{}" + pos
if self.flush_right:
pos = pos + "@{}"
return f"\\multicolumn{{{self.colspan}}}{{{pos}}}{{{result}}}" return f"\\multicolumn{{{self.colspan}}}{{{pos}}}{{{result}}}"
remove_left = self.border.left_all and not self.border.left remove_left = self.border.left_all and not self.border.left
remove_right = self.border.right_all and not self.border.right remove_right = self.border.right_all and not self.border.right
@@ -83,6 +105,10 @@ class Cell:
pos = "|" + pos pos = "|" + pos
if self.border.right_all and self.border.right: if self.border.right_all and self.border.right:
pos = pos + "|" pos = pos + "|"
if self.flush_left:
pos = "@{}" + pos
if self.flush_right:
pos = pos + "@{}"
result = f"\\multicolumn{{1}}{{{pos}}}{{{result}}}" result = f"\\multicolumn{{1}}{{{pos}}}{{{result}}}"
return result return result

View File

@@ -160,6 +160,14 @@
display: inline-flex; 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 { .hpos_margin {
margin: 0 1em 0 1em; margin: 0 1em 0 1em;
} }

View File

@@ -309,7 +309,7 @@ namespace html {
elts.push_back(elt("style", css)); elts.push_back(elt("style", css));
} }
if (!title.empty()) { if (!trim(title).empty()) {
elts.push_back(elt("title", trim(title))); elts.push_back(elt("title", trim(title)));
} }
HTML result = elt("head", elts); HTML result = elt("head", elts);
@@ -486,12 +486,16 @@ namespace html {
elements_t body {}; elements_t body {};
if (!title.empty()) { if (!title.empty()) {
elements_t title_bar {}; elements_t title_bar {};
if (!trim(title).empty()) {
title_bar.push_back(elt("span", trim(title)).attr("id", "title_text")); title_bar.push_back(elt("span", trim(title)).attr("id", "title_text"));
}
if (!logo.empty()) { if (!logo.empty()) {
title_bar.push_back(elt("span", trim(logo)).attr("id", "logo")); title_bar.push_back(elt("span", trim(logo)).attr("id", "logo"));
} }
if (!title_bar.empty()) {
body.push_back(elt("div", title_bar).attr("id", "title")); body.push_back(elt("div", title_bar).attr("id", "title"));
} }
}
if (!nav.empty()) { if (!nav.empty()) {
body.push_back(navigation(max_level)); body.push_back(navigation(max_level));
} }
@@ -538,13 +542,19 @@ namespace html {
void add_title(elements_t& body, std::string title, std::string logo) 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 {}; elements_t title_bar {};
if (!trim(title).empty()) {
title_bar.push_back(elt("span", trim(title)).attr("id", "title_text")); title_bar.push_back(elt("span", trim(title)).attr("id", "title_text"));
}
if (!logo.empty()) { if (!logo.empty()) {
title_bar.push_back(elt("span", trim(logo)).attr("id", "logo")); title_bar.push_back(elt("span", trim(logo)).attr("id", "logo"));
} }
if (!title_bar.empty()) {
body.push_back(elt("div", title_bar).attr("id", "title")); body.push_back(elt("div", title_bar).attr("id", "title"));
} }
}
void add_nav(elements_t& body, int max_level) void add_nav(elements_t& body, int max_level)
{ {

View File

@@ -193,8 +193,27 @@ def element_tag(element):
def font_class(font): def font_class(font):
return {"r" : "", "i" : "ritalic", "t" : "monospace", "s" : "sanserif"}[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, 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) tag = element_tag(element)
# Caption # Caption
caption = "" caption = ""
@@ -232,12 +251,19 @@ def add_caption(element, caption_label, number, caption_text,
#print("-"*80) #print("-"*80)
element = E("div").cls("caption_" + side).attr("data-label", caption_label).body(element) 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 = hpos_container(element, hpos)
result = E("div").cls("hpos_" + hpos).body(element)
if hpos != "center":
result = result.cls("hpos_margin")
if number: if number:
result.cls("element_container") result.cls("element_container")

View File

@@ -55,15 +55,21 @@ def minipage(content, width="\\textwidth", vertical="c", center=True, vmargin=""
return result return result
def caption_wrapper(element, hpos, bottom_margin=.67): 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" vmargin = f"{bottom_margin}\\baselineskip"
result = element
if hpos == "center": if hpos == "center":
result = minipage(element, vmargin=vmargin) return minipage(element, vmargin=vmargin)
elif hpos == "left": if hpos == "left":
result = minipage("\\hfill" + element, vmargin=vmargin) return minipage(element + "\\hfill", vmargin=vmargin, center=False)
elif hpos == "right": if hpos == "right":
result = minipage(element + "\\hfill", vmargin=vmargin) return minipage("\\hfill" + element, vmargin=vmargin, center=False)
return result 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): def make_caption_text(number, label, text, font_symbol, font_size):
caption = None caption = None

View File

@@ -304,17 +304,28 @@ def make_pdf_from_tex(filename, K, twice=True):
os.system('rm -rf {}/{}.{}'.format(dirname, basename, unused_ext)) 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): def justify_blocks(text, K=None):
rgx = re.compile("\n\n+", re.S)
delim = '__DIVIDE__' delim = '__DIVIDE__'
text = rgx.sub(delim, text) text = re.sub("\n\n+", delim, text)
result = "" result = ""
for par in text.split(delim): for par in text.split(delim):
#print(par) stripped = par.strip()
if par[0] not in {' ', '['}: 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)) par = "\n".join(textwrap.wrap(par, width=80))
result += par + "\n\n" result += par + "\n\n"
return result return result.replace(vspace_marker, "\n")

View File

@@ -44,7 +44,7 @@ The LaTeX transformations supported by the SKS are:
@@@ @@@
@@@target tex | LaTeX @@@target tex | LaTeX
:escape \ \textbackslash{} & \& { \{ } \} $ \$ % \% _ \_ :escape \ \textbackslash{} & \& { \{ } \} $ \$ % \% _ \_ ^# \^# ^^ \textasciicircum{}
@@@ @@@
@@@target pdf | PDF from LaTeX @@@target pdf | PDF from LaTeX