Tables in the plain-text target, with captions

A @table now renders to txt.  What it needed settled how plain text sets
a block off from the prose around it, which is by INDENTING it: that is
the only structural device the medium has, where html and LaTeX have
boxes, rules and space.  An indented block is one that has already been
laid out, and the target leaves its lines alone.

A caption is placed above a table or a listing and below a figure, in
every target rather than only in plain text.  A figure is taken in at a
glance, and the caption then names what the reader has already seen; a
table is read, and a reader who meets the rows before the subject has to
read them twice.

Also fixed, all three of them silent: an indented paragraph was dropped
from plain-text output rather than left as written; an @indent block was
re-flowed, so the indentation of its later lines appeared as gaps in the
middle of a line; and a table captioned with ":number false" lost the
rule under its caption, while an unnumbered table wrongly consumed a
number that a reader counting captions would then find missing.

(from dev 8173c371bba6)
This commit is contained in:
ack committed 2026-08-26 23:00:58 +02:00
1 parent af29bbf7ac
commit b80e823ab3
7 files changed
+196 -24

No files matched your search

+1 -1
View File
@@ -51,7 +51,7 @@ are regenerated on each release — patches cannot be merged directly.
Report problems (or send patches) to the author; accepted changes are Report problems (or send patches) to the author; accepted changes are
applied to the development tree and appear in a following snapshot. applied to the development tree and appear in a following snapshot.
This snapshot was assembled from development commit `61e598698891`. This snapshot was assembled from development commit `8173c371bba6`.
## License ## License
+5 -12
View File
@@ -74,23 +74,16 @@ def protect_klammertext_special_characters(text):
result = re.compile(r"\|").sub("̅B̅", result) result = re.compile(r"\|").sub("̅B̅", result)
return result return result
# When did this ever make sense? Before the new word patterns, probably.
def bar_delimiter():
return "\3"
def double_bar_delimiter():
return "\4"
def caption_delimiter(): def caption_delimiter():
return "__CAPTION__" return "__CAPTION__"
def caption_marker(name, caption, delimiter=" - "): def caption_marker(name, caption, label_delimiter=" -- "):
caption = re.sub("\n", " ", caption) caption = re.sub("\n", " ", caption)
d = caption_delimiter() d = caption_delimiter()
if caption: if caption and label_delimiter:
caption = f"{delimiter}{caption}" caption = f"{label_delimiter}{caption}"
return f"{d}{name}{d}{caption}{d}" result = f"{d}{name}{d}{caption}{d}"
return result
def rest_split(s, dimensions=1): def rest_split(s, dimensions=1):
"""Split bar-delimited text into nested lists, one level per dimension. """Split bar-delimited text into nested lists, one level per dimension.
+2 -2
View File
@@ -196,7 +196,7 @@
@@table.k rows.rest(2) @@table.k rows.rest(2)
:id :id
@caption_args@ @caption_args :caption_side top @
@hpos_args@ @hpos_args@
:header.bool true :header.bool true
:allow_break.bool false :allow_break.bool false
@@ -228,7 +228,7 @@ formats them, and ^:hpos and ^:offset place the whole table in the text
column. column.
@@ @@
@@table.html,tex :: @eval table.Table(K) eval@ @@ @@table.html,tex,txt :: @eval table.Table(K) eval@ @@
@@tbl.k spec.figure_id : The caption title for a table @@ @@tbl.k spec.figure_id : The caption title for a table @@
@@tbl.html,tex,txt :: @reference *spec* | Table @ @@ @@tbl.html,tex,txt :: @reference *spec* | Table @ @@
+36 -3
View File
@@ -11,9 +11,11 @@ import klammer_base
import html_util import html_util
from html_util import E from html_util import E
import latex_util import latex_util
import txt_util
from indexed_range import Indexed_ranges, hline_names, vline_names from indexed_range import Indexed_ranges, hline_names, vline_names
import table_cell import table_cell
import font import font
import phases
# ---- :format functions --------------------------------------------------- # ---- :format functions ---------------------------------------------------
# A :format function takes (value, target) and returns the formatted cell # A :format function takes (value, target) and returns the formatted cell
@@ -864,7 +866,38 @@ class Table(klammer_base.Klammer_base):
# there, so the rule stays unconditional.) # there, so the rule stays unconditional.)
return latex_util.block(result) return latex_util.block(result)
# This should signal that it has not be implemented by not being defined.
# def txt(self): # txt target
# return "Table in .txt format not implemented"
def txt_column_widths(self):
def ws(lst, ext=0):
result = [len(e) for e in lst]
if ext > 0:
result += ["~"] * (ext - (len(lst)))
return result
row_count = max(ws(self.rows))
widths = ws(self.rows[0], row_count)
for row in self.rows[1:]:
widths = list(max(widths, ws(row, row_count)))
return widths
def txt(self):
def hpos(p):
return {'l':'<', 'c':'^', 'r':'>'}[p]
def if_empty(char):
return "~" if len(char) == 0 else char
widths = self.txt_column_widths()
pos = [hpos(p) for p in self.cell_hpos]
result = ""
indent = "~" * int(self.Target_txt_indent)
colsep = " "
for row in self.rows:
result += indent + colsep.join(
[f"{if_empty(elt):{pos}{width}}" for elt, width, pos in
list(zip(row, widths, pos))])
result += "\n"
result = txt_util.add_caption(
result, "Table", self.number, self.caption, self.caption_side, \
int(self.Target_txt_indent))
return result
+55 -6
View File
@@ -1,6 +1,8 @@
import re import re
import textwrap import textwrap
import kutil
# Emitted by @vspace.txt (sks/block/block.k), one marker per line of space. # 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 # An @eval result consisting only of whitespace is trimmed away by the
# Klammermachine, so vertical space must travel as markers and become # Klammermachine, so vertical space must travel as markers and become
@@ -25,20 +27,69 @@ def txt_justify_blocks(text, K):
result += "\n" * n result += "\n" * n
continue continue
if par and par[0] != ' ': # A paragraph BEGINNING WITH WHITESPACE is verbatim: it is filled to
# no width and keeps the line breaks it was written with. The same
# in-text rule the error messages use (justify() in mac/util.cpp),
# and the one a klammer relies on to emit lines that must not
# re-flow -- a @table's rows, a listing.
#
# "~" counts as that whitespace, and MUST: a klammer's indent cannot
# be a real space, because Eval::eval TRIMS the result katoms, so
# the leading spaces a klammer emits never reach this pass at all
# (the "~" is the non-breaking space marker, resolved to a space at
# the end of this function -- the only space that survives). A
# writer's own "~" has become a real space long before this, in the
# target's transform table, so there is no ambiguity here.
#
# The emission below was INSIDE this "if" until 2026-08-26, so a
# verbatim paragraph was not left alone, it was DELETED: the one
# branch that had nothing to do also did not copy the text through.
# An indented paragraph vanished from the output with no error.
if par and par[0] not in ' ~':
label = re.match(r"~*\[\d+\] ", par) label = re.match(r"~*\[\d+\] ", par)
par = "\n".join(textwrap.wrap( par = "\n".join(textwrap.wrap(
par, width=int(K.Target_txt_width), break_long_words=False, par, width=int(K.Target_txt_width), break_long_words=False,
subsequent_indent=" " * len(label.group(0)) if label else "")) subsequent_indent=" " * len(label.group(0)) if label else ""))
# if par and par[0] != ' ':
# par = "\n".join(textwrap.wrap(
# par, width=int(K.Target_txt_width), break_long_words=False))
result += par + "\n\n" result += par + "\n\n"
result = re.sub(" *" + txt_vspace_marker() + " *", "\n", result) result = re.sub(" *" + txt_vspace_marker() + " *", "\n", result)
result = re.sub(r"\n? *" + txt_nl_marker() + r" *\n?", "\n", result) result = re.sub(r"\n? *" + txt_nl_marker() + r" *\n?", "\n", result)
result = re.sub("~", " ", result) result = re.sub("~", " ", result)
return result return result
def txt_insert_captions(text, K):
mark = kutil.caption_delimiter()
caption_rgx = re.compile(f"{mark}(.*?){mark}(.*?){mark}", re.S)
caption_number = 0
width = int(K.Target_txt_width)
indent = int(K.Target_txt_indent)
indented_width = width - indent
def replace(m):
nonlocal caption_number
name, text = m.groups()
# An empty label means an unnumbered caption (":number false"). It
# must NOT take a number from the sequence: a reader counting the
# captions they can see would then find the next one a number
# ahead. The same is true of html and tex, where an unnumbered
# caption emits no marker and so never reaches their counter.
if name:
caption_number += 1
caption = f"{name} {caption_number} - {text}"
else:
caption = text
caption_width = len(caption) + indent
if len(caption) > indented_width:
caption = textwrap.wrap(
caption, indented_width,
#initial_indent ="~"*indent,
subsequent_indent="~"*indent)
caption = "\n".join(caption)
caption_width = indented_width
line_length = max([len(e) for e in caption.split("\n")])
line = "~" * indent + "-" * line_length
return caption + "\n" + line
result = caption_rgx.sub(replace, text)
return result
def txt_footnote_marker(): def txt_footnote_marker():
return "KTEXTFOOTNOTE" return "KTEXTFOOTNOTE"
@@ -89,5 +140,3 @@ def html_format_footnotes(text, K=None):
# Temporarily add room for footnotes to move to the top of the window for the test: # Temporarily add room for footnotes to move to the top of the window for the test:
result += '<div style="height: 100lh"></div>\n' result += '<div style="height: 100lh"></div>\n'
return result return result
+5
View File
@@ -36,6 +36,10 @@ of this: neither the transforms nor the quoting reach it.
:value 72 :value 72
@@@ @@@
@@@state Target_txt_indent
:desc Width of indentation in the plain text target (txt)
:value 3
@@@
# The txt target is not fully implemented in the SKS yet. # The txt target is not fully implemented in the SKS yet.
@@@target txt | Plain text with formatting | @@@target txt | Plain text with formatting |
@@ -48,6 +52,7 @@ of this: neither the transforms nor the quoting reach it.
~ ^0020^ ~ ^0020^
:after_apply :after_apply
phases.txt_format_footnotes ; phases.txt_format_footnotes ;
phases.txt_insert_captions ;
phases.txt_justify_blocks phases.txt_justify_blocks
@@@ @@@
+92
View File
@@ -0,0 +1,92 @@
"""Layout helpers for the txt target — the plain-text sibling of
html_util.py and latex_util.py.
What is target-specific about plain text is that it has no elements to
hang anything on: a block is set off from the prose around it by being
INDENTED, and by having laid itself out before the target sees it. Two
consequences run through this module:
* The indent is written with "~", the non-breaking space, never with a
real space. Eval::eval TRIMS a klammer's result, so leading spaces
never survive to the output; and the txt target's justification pass
treats a paragraph beginning with " " or "~" as already laid out and
leaves it alone (phases.txt_justify_blocks). So the same character
both survives and declares "do not re-flow this".
* A block takes the indent out of its own measure. The page has
Target_txt_width columns; a block indented by N has that minus N, and
a block laid out to the page's width runs past the margin with
nothing to report it.
"""
import os
import re
import sys
import textwrap
sys.path.append(f'{os.environ.get("KLAMMERTEXT_HOME")}/sks/kutil')
import kutil
# The non-breaking space marker, resolved to a real space at the end of
# phases.txt_justify_blocks. Named here rather than written as "~" at
# each use, since it is the whole mechanism of this module.
NBSP = "~"
def indent_text(text, indent):
"""Every line of text prefixed with `indent` columns of NBSP."""
if indent <= 0:
return text
prefix = NBSP * indent
return "\n".join(prefix + line for line in text.split("\n"))
def block_width(text_width, indent):
"""The measure available to a block indented by `indent`."""
return max(1, int(text_width) - indent)
def add_caption(element, caption_label, number, caption_text,
side="top", indent=0):
"""Attach a caption to an already-laid-out block.
`element` is the block's text, indented by `indent` columns of NBSP.
The caption is placed above it (`side` "top", the default, and
"left") or below it ("bottom", "right"), on the same indent, so the
two read as one block and no blank line separates the caption from
what it captions.
Captions go ABOVE a table or a listing and BELOW a figure, and the
reason is not convention: a figure is taken in at a glance and the
caption then names what the reader has already seen, while a table or
a listing is READ, and a reader who meets the rows before the subject
has to read them twice. ("left"/"right" have no plain-text meaning —
a caption beside a block would need a second column — so they fall
back to the vertical side they are nearest.)
THE CAPTION IS NOT WRAPPED HERE, and must not be: when `number` is
true the text placed now is a __CAPTION__ marker, which the numbering
pass later replaces with something of a quite different length
("Table 1.2 - ..."), so any line breaks chosen now would be measured
against text that never reaches the reader. Wrapping the caption is
the numbering pass's job, once it holds the final text; it can read
the indent back off the leading NBSP run.
`caption_style` and `caption_size` are accepted by the html and tex
siblings and are absent here on purpose: plain text has one face at
one size, so there is nothing for them to select. That is stated in
@table's and @code's descriptions rather than left as a silent
difference between targets.
"""
# BOTH cases go out as a marker, the unnumbered one with an empty
# label. Everything the numbering pass does to a caption -- wrapping
# it to the block's measure, ruling it off -- it does inside the
# marker substitution, so a caption that arrived there as plain text
# got none of it: ":number false" silently lost its rule. The label
# is what says "number me", not the presence of the marker.
if number:
caption = kutil.caption_marker(caption_label, caption_text, None)
elif caption_text:
caption = kutil.caption_marker("", caption_text, None)
else:
return element
caption = indent_text(re.sub(r"\s*\n\s*", " ", caption.strip()), indent)
if side in {"top", "left"}:
return caption + "\n" + element
return element + "\n" + caption