Width and height argtypes, kdesc --argtypes from ref/, txt @define/@left_right/@columns, kdiag --check value validation (from dev d6993110e9b3)

Also: the klammertext argtype (descriptions kept as source), txt table
wrapping and :hpos/:offset, @c between ‹ ›, txt section headings, the
ktext newline-only trim, capitalize(), and the argtype test suite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-09-16 16:59:49 +02:00
1 parent 9b0d0d7897
commit 0ead40f272
39 files changed
+734 -158

No files matched your search

+13 -10
View File
@@ -31,10 +31,10 @@ vertical mode is a no-op.
# Indented block
@@indent.k s :left.int 8 :right.int -1 :ragged.bool false :
Indented block. The *left* and *right* values are the number of characters for
indentation (as roughtly defined by "ex"). By default, *right* is set to the
*left* value (with -1 as the sentinel).
@@indent.k s :left.width 8 :right.width :ragged.bool false :
Indented block. *left* and *right* are widths: a bare number is that many
characters (the width of a digit in a proportional typeface), or a width
with a unit such as 2em. *right* defaults to the *left* value.
@@
@@indent.html,tex,txt :: @eval block.Indent(K) eval@ @@
@@ -124,13 +124,12 @@ indentation (as roughtly defined by "ex"). By default, *right* is set to the
@@extendpage.tex :: \enlargethispage{*linecount*\baselineskip} @@
@@extendpage.txt :: @@
# Vertical space
@@vspace.k lines.float : Vertical space, in multiples of the current line height @@
@@vspace.tex :: \vspace{*lines*\baselineskip} @@
@@vspace.html :: <div style="height: *lines*lh"></div> @@
@@vspace.txt :: @eval phases.txt_vspace_marker() * round(*lines*) @ @@
# The first parameter of the height type: a bare number is lines in every
# target (as it always was here), and a unit may now be given.
@@vspace.k lines.height : Vertical space: a number of lines, or a height such as 2em @@
@@vspace.html,tex,txt :: @eval block.Vspace(K) @ @@
# Vertical fill to end of page
@@ -146,6 +145,10 @@ only makes some vertical space. @@
@@vfill.html :: <div class="vfill"></div> @@
@@vfill.txt :: @vspace 3 @ @@
# Horizontal space
@@hspace.k width.width : Horizontal space of the given *width* (a bare number is characters) @@
@@hspace.html,tex,txt :: @eval block.Hspace(K) @ @@
# Questions and answers
@@ -202,7 +205,7 @@ bar (|) between each line. Then, two bars (||), and the right lines with a bar
betwen them.
@@
@@left_right.html,tex :: @eval block.Left_right(K) @ @@
@@left_right.html,tex,txt :: @eval block.Left_right(K) @ @@
# Footnote
+74 -13
View File
@@ -11,35 +11,56 @@ from html_util import E
import color
class Indent(klammer_base.Klammer_base):
"""An indented block (@indent). :left and :right are widths: a bare
number is characters in every target (2026-09-16); :right defaults to
:left."""
def __init__(self, K):
super().__init__(K)
# Doesn't do anything now:
# self.s = kutil.escape(self.s)
if self.right < 0:
if not self.right:
self.right = self.left
self.reduce = self.left + self.right
def html(self):
text_align = "justify" if not self.ragged else "left"
left = kutil.parse_width("html", self.left)[0]
right = kutil.parse_width("html", self.right)[0]
return E("div").body(self.s)\
.sty("margin", f"0.5rem {self.right}ex 0.5rem {self.left}ex")\
.sty("margin", f"0.5rem {right} 0.5rem {left}")\
.sty("text-align", text_align)\
.str()
def tex(self):
if self.ragged:
self.s = "\\raggedright " + self.s
result = rf"\hspace*{{{self.left}ex}}" + latex_util.minipage(
self.s, rf"\linewidth - {self.reduce}ex", center=False)
left = kutil.parse_width("tex", self.left)[0]
right = kutil.parse_width("tex", self.right)[0]
result = rf"\hspace*{{{left}}}" + latex_util.minipage(
self.s, rf"\linewidth - {left} - {right}", center=False)
result = latex_util.block(result)
return result
def txt(self):
indent = phases.txt_nbsp_marker() * self.left # a space that survives
width = int(self.Target_txt_width) - self.left - self.right + 1 # Off-by one for textwrap
result = self.s
result = re.sub("KK0022", '"', result)
result = indent + f"\n{indent}".join(textwrap.wrap(result, width=width, break_on_hyphens=True))
return result
left = int(kutil.parse_width("txt", self.left)[0])
right = int(kutil.parse_width("txt", self.right)[0])
indent = phases.txt_nbsp_marker() * left # a space that survives
width = int(self.Target_txt_width) - left - right
result = re.sub("KK0022", '"', self.s)
return phases.txt_lay_out(indent + f"\n{indent}".join(phases.txt_wrap(result, width)))
class Hspace(klammer_base.Klammer_base):
"""Horizontal space (@hspace): a width, so a bare number is characters
in every target."""
def __init__(self, K):
super().__init__(K)
def html(self):
return f'<span style="display: inline-block; width: {kutil.parse_width("html", self.width)[0]}"></span>'
def tex(self):
return f"\\hspace{{{kutil.parse_width('tex', self.width)[0]}}}"
def txt(self):
# The nbsp marker, not spaces: an @eval result of only whitespace
# is trimmed away entirely.
return phases.txt_nbsp_marker() * int(kutil.parse_width("txt", self.width)[0])
class Note(klammer_base.Klammer_base):
@@ -106,6 +127,26 @@ class Lines(klammer_base.Klammer_base):
def txt(self):
return self.lines
class Vspace(klammer_base.Klammer_base):
"""Vertical space (@vspace): the first parameter of the height type. A
bare number is lines in every target, as it always was here; a unit
(2em, 12pt) is now accepted as well, under html and tex."""
def __init__(self, K):
super().__init__(K)
def html(self):
height = kutil.parse_height("html", self.lines)[0]
return f'<div style="{kutil.css_height("height", height)}"></div>'
def tex(self):
return f"\\vspace{{{kutil.parse_height('tex', self.lines)[0]}}}"
def txt(self):
# One marker per line; the justification phase turns them into
# blank lines after paragraph normalization (a whitespace-only
# @eval result would be trimmed away, hence the markers).
return phases.txt_vspace_marker() * int(kutil.parse_height("txt", self.lines)[0])
class Left_right(klammer_base.Klammer_base):
def __init__(self, K):
super().__init__(K)
@@ -126,3 +167,23 @@ class Left_right(klammer_base.Klammer_base):
result += rf"\parbox{{\linewidth}}{{{left} \hfill {right}}}" + " \\\\\n"
result = result[:-3]
return result
def txt(self):
# Plain text: the left text at the block indent, the right text ending
# at Target_txt_width, the gap filled with the nbsp marker (the only
# space that survives to the output, and what marks the block as laid
# out). A pair that cannot share a line -- less than two columns
# between them -- goes on two lines, the right text still flush right.
nbsp = phases.txt_nbsp_marker()
indent = int(self.Target_txt_indent)
width = int(self.Target_txt_width)
lines = []
for left, right in self.rows:
left, right = left.strip(), right.strip()
gap = width - indent - phases.txt_visible_length(left) - phases.txt_visible_length(right)
if gap >= 2:
lines.append(nbsp * indent + left + nbsp * gap + right)
else:
lines.append(nbsp * indent + left)
lines.append(nbsp * max(indent, width - phases.txt_visible_length(right)) + right)
return phases.txt_lay_out("\n".join(lines))
+1 -1
View File
@@ -24,7 +24,7 @@ string, so a "^#" or a bare "^@" inside it was read as Klammertext and usually
failed the file, which is not what "verbatim" can mean.
@@
@@c.html,tex :: @eval code_block.Code_fragment(K) eval@
@@c.html,tex,txt :: @eval code_block.Code_fragment(K) eval@
@@
# :cwd makes the filename resolve against the DOCUMENT's directory, not
+12
View File
@@ -349,6 +349,18 @@ class Code_fragment(klammer_base.Klammer_base):
def tex(self):
return f"{{\\tt {tex_line(self.code_text.strip())}}}"
def txt(self):
# Plain text has no face to switch to: the fragment is its own text.
# As in html_line/tex_line, the Klammertext specials are quoted for
# the @eval read-back (a bare "@" in the result would be a klammer
# without a beginning) and the typographically active characters
# hidden, so a "--" in code stays two hyphens.
result = klammer_base.hide_typographic(quote_specials(self.code_text.strip()))
# Surround code symbols with ‹ and ›, though there are conflicts in Swiss
# German, Italian and some Scandinavian languages. Should be clear in
# context, though.
result = "\u2039" + result + "\u203A"
return result
def extract_marked_region(src, marker, filename):
"""The region of `src` between two lines that are exactly "//<marker>".
+1 -1
View File
@@ -34,7 +34,7 @@
@@image.k
basename
:id
:width.length .5w
:width.width .5w
@caption_args@
:vmargin.bool true
@hpos_args@
+7 -3
View File
@@ -76,12 +76,16 @@ class Image(klammer_base.Klammer_base):
# kutil.msg("rel: " + str(self.rel_fraction))
length, value, ltype = kutil.parse_length("html", self.width, self.rel_fraction)
length, value, ltype = kutil.parse_width("html", self.width, self.rel_fraction)
if ltype == "w":
percent = float(value) * 100
result.sty(f"width: {percent}%")
else:
elif ltype == "px":
# The html width ATTRIBUTE takes pixels only; every other unit
# is a style (an attribute of "2em" was silently ignored).
result.attr("width", length)
else:
result.sty(f"width: {length}")
if self.border:
result.cls("image_border")
@@ -124,7 +128,7 @@ class Image(klammer_base.Klammer_base):
#if self.caption or self.number:
# width = "\\textwidth"
#else:
width = kutil.parse_length("tex", self.width, self.rel_fraction)[0]
width = kutil.parse_width("tex", self.width, self.rel_fraction)[0]
width = re.sub("px", "pt", width)
#result = f'\\includegraphics[width={width}]{{{source}}}'
if self.number or self.caption:
+37 -6
View File
@@ -14,6 +14,34 @@
# :python_cast (lambda s : __import__("kutil").parse_length("tex", s))
@@@
# Lengths on an axis (2026-09-16). A width and a height are different
# types so that a value can only name a unit of its own axis: "w" and "pw"
# belong to widths, "h" and "ph" to heights, and a mistake is an argument
# error rather than a silent misreading. A BARE NUMBER is the natural
# unit of the axis in every target -- characters for a width (a column in
# txt, "ch" in html, the width of a digit in tex), lines for a height (a
# newline in txt, "lh" in html, \baselineskip in tex) -- which is what lets
# a klammer written for the page keep its meaning in a terminal. "length"
# stays for the quantities with no axis (a rule thickness).
@@@argtype width |
a horizontal length. A bare number is that many characters (one column
of plain text; in a proportional typeface, the width of a digit, so an
indent or a column lines up across lines even where the letters do not).
Otherwise a number with a unit: ^:width 2em, 12pt, 100px, .5w (that
fraction of the text width), .5pw (of the page width); f or none for an
element's natural width; a quoted string, whose set width is used where
the target can measure it.
:pattern 'float'^|'float'em^|'int'pt^|'int'px^|'float'w^|'float'pw^|f^|none^|"[^^"]+"
@@@
@@@argtype height |
a vertical length. A bare number is that many lines (blank lines in
plain text; the line height of the surrounding text otherwise).
Otherwise a number with a unit: ^:height 2em, 12pt, 100px, .5h (that
fraction of the text height), .5ph (of the page height).
:pattern 'float'^|'float'em^|'int'pt^|'int'px^|'float'h^|'float'ph
@@@
@@@argtype lengths | a list of lengths
:pattern ('length'^|\s+)*
#:python_cast (lambda s : [__import__("kutil").parse_length("tex", e) for e in s.split()])
@@ -37,15 +65,18 @@
An offset has no meaning for a centered element and is ignored there.
Written alone, ^:offset is the standard indentation (e.g. ^:hpos left
^:offset), and a value overrides it (^:hpos right ^:offset 4em).
# A single-purpose type, not a use of "length", because the two values
# A single-purpose type, not a use of "width", because the two values
# below are what the writer relies on and only a type can carry them: an
# element is flush unless an offset is asked for (:default), and asking
# without saying how much is the standard indentation (:alone). A general
# length type must not declare :alone -- a bare option name has to read the
# same way wherever it appears, and "2em" is meaningless for :width.
:pattern 'length'
:default 0pt
:alone 2em
# width type must not declare :alone -- a bare option name has to read the
# same way wherever it appears, and "4" is meaningless for :width.
# Both values are BARE numbers -- characters -- so that they mean the same
# thing under txt as on the page (2026-09-16): four characters is about
# the 2em the alone value used to be, and 0 is flush everywhere.
:pattern 'width'
:default 0
:alone 4
@@@
+49 -4
View File
@@ -112,10 +112,27 @@ def _rest_split_level(s, dimensions):
def rest_args(s, dimensions=1):
return rest_split(s, dimensions)
def parse_length(target, s, rel_fraction):
def choose(html_value, tex_value):
def parse_length(target, s, rel_fraction=1, axis="width"):
"""A length argument as the target's own length.
Returns (text, number, unit). `axis` is "width" or "height" and gives
a BARE number its meaning -- characters or lines, the natural unit of
the axis in every target (sks/kutil/kutil.k, the width and height
argtypes): under txt the integer count itself; under html "ch" and
"lh" (a consumer of "lh" writes the em fallback first, see
css_height); under tex the width of a digit in the current font
(\\fontcharwd) and \\baselineskip. `target` "txt" is accepted for the
bare units only, since plain text has no other.
"""
def choose(html_value, tex_value, txt_value=None):
if target == "txt" or target == "ansi":
if txt_value is None:
unit = "characters" if axis == "width" else "lines"
raise Exception(f'The {axis} "{s}" has no meaning in plain text: '
f'give a bare number of {unit}')
return txt_value
return html_value if target == "html" else tex_value
pat = re.compile(r'(?:([0-9.]+)([a-z]+))|(none|f)|(?:"([^"]+)")')
pat = re.compile(r'(?:([0-9.]+)([a-z]*))|(none|f)|(?:"([^"]+)")')
#pat = re.compile(r'(?:([0-9.]+)([a-z]+))|(none|f)|(?:{([^}]+)})')
@@ -138,6 +155,14 @@ def parse_length(target, s, rel_fraction):
result = choose("", f"\\textwidth - \\widthof{{ {text[1:]}}}")
else:
result = choose("", f"\\widthof{{ {text}}}")
elif units == "":
# The natural unit of the axis.
if axis == "height":
result = choose(f"{num}lh", f"{num}\\baselineskip", str(round(num)))
units = "lines"
else:
result = choose(f"{num}ch", f"{num}\\fontcharwd\\font`0 ", str(round(num)))
units = "ch"
elif units == "w":
result = choose(f"{100 * num}vw", f"{num}\\textwidth")
elif units == "h":
@@ -149,11 +174,31 @@ def parse_length(target, s, rel_fraction):
elif units == "px":
result = choose(f"{round(num)}", f"{num}px")
else:
result = f"{num}{units}"
# em, pt: the same text in html and tex; none in plain text.
result = choose(f"{num}{units}", f"{num}{units}")
result = re.sub("\t", "\\t", result)
return result, num, units
def parse_width(target, s, rel_fraction=1):
return parse_length(target, s, rel_fraction, "width")
def parse_height(target, s, rel_fraction=1):
return parse_length(target, s, rel_fraction, "height")
def css_height(prop, value):
"""A CSS declaration for a height from parse_height's html text. The
"lh" unit is recent (2023 in every major browser); an older browser
ignores a declaration it cannot parse and keeps the previous one, so
the em equivalent is written FIRST and lh second -- the standard
fallback idiom, no detection needed."""
if value.endswith("lh"):
# A short decimal, not the float's full expansion (1.5 * 1.2 is
# 1.7999999999999998 in binary).
em = f"{float(value[:-2]) * 1.2:.4g}"
return f"{prop}: {em}em; {prop}: {value}"
return f"{prop}: {value}"
def old_parse_length(target, s):
pat = re.compile(r'(?:([0-9.]+)([a-z]+))|(none|f)|("([^"]+)")')
match = pat.match(s)
+16 -6
View File
@@ -3,14 +3,24 @@
:value 7
@@@
@@ul.k list_items.rest :cmp.bool false :bullet : Unordered list, with list elements preceeded by the *bullet* character. @@
@@ul.k list_items.rest :cmp.bool false :bullet :
Unordered list, with list elements preceeded by the *bullet* character. @@
@@ul.html,tex,txt :: @eval list.List(K, "ul") eval@ @@
@@ol.k list_items.rest :cmp.bool false :initial.int 1 : Ordered list, with numbered list elements. @@
@@ol.k list_items.rest :cmp.bool false :initial.int 1 :
Ordered list, with numbered list elements. @@
@@ol.html,tex,txt :: @eval list.List(K, "ol") eval@ @@
@@define.k descriptions.rest(2) :style.style_code i : A list of words or phrases and their definitions or descriptions @@
@@define.html,tex :: @eval list.Define(K) eval@ @@
@@define.k descriptions.rest(2) :style.style_code i :
A list of words or phrases and their definitions or descriptions @@
@@columns.k items :n.int 2 : Create two or more columns from the text @@
@@columns.tex :: @eval list.columns(K) eval@ @@
@@define.html,tex,txt :: @eval list.Define(K) eval@ @@
@@columns.k items :n.int 2 :style.style_code r @hpos_args :hpos left @ :
Create two or more columns from the text. Placed like a table: flush at
the left by default, ^:offset inset from that margin (a bare number is
characters). @@
@@columns.html,tex,txt :: @eval list.Columns(K) eval@ @@
+59 -31
View File
@@ -3,6 +3,7 @@ import re
import textwrap
import kutil
import klammer_base
import phases
import itertools
import math
from html_util import E
@@ -81,17 +82,42 @@ class Define(klammer_base.Klammer_base):
'c' : r'\normalfont\ttfamily',
's' : r'\normalfont\sffamily'}[self.style]
return f"{font} {s}"
# The "\hfill\newline" was to force the description to the next line even if the item was small:
# result = "\n\n".join([f"\\item[{{{fontify(term)}}}]\\hfill\n\\newline {definition}"
strut = r"\rule[-8pt]{0pt}{8pt}"
strut = r"\rule[-4pt]{0pt}{4pt}"
result = "\n\n".join([f"\\item[{{{fontify(term)}{strut}}}] {definition}"
for term,definition in self.items])
result = f"\\begin{{description}}[labelindent=12pt,nosep,itemsep=6pt,parsep=.5\\parskip,style=nextline]\n{result}\n\\end{{description}}"
options = "style=nextline" # See list/list.sty
result = f"\\begin{{description}}[{options}]\n{result}\n\\end{{description}}"
return result
# Needs to be implemented:
# def txt(self):
# return "TXT DEFINITIONS"
def txt(self):
# Plain text: each term on its own line at the block indent, its
# definition beneath it indented once more, items separated by a
# blank line. Every line begins with the nbsp marker, which is what
# tells the justification pass the block is already laid out
# (phases.txt_justify_blocks); a definition's own paragraphs are
# wrapped to the remaining measure, and a paragraph that is already
# laid out (it begins with the marker -- a @table inside the
# definition) is only indented further. The term takes :style as
# italic or bold, the styles a terminal can show.
nbsp = phases.txt_nbsp_marker()
indent = int(self.Target_txt_indent)
term_indent, def_indent = nbsp * indent, nbsp * (2 * indent)
measure = int(self.Target_txt_width) - 2 * indent
style = {"i": "italic", "b": "bold"}.get(self.style)
items = []
for term, definition in self.items:
term = term.strip()
if style and term:
term = phases.txt_style_marker(style) + term + phases.txt_style_marker(style, False)
paragraphs = []
for par in re.split(r"\n\s*\n", definition.strip()):
if phases.txt_is_laid_out(par):
paragraphs.append("\n".join(def_indent + line for line in par.split("\n")))
else:
paragraphs.append("\n".join(def_indent + line
for line in phases.txt_wrap(par, measure)))
items.append(term_indent + term + "\n" + "\n\n".join(paragraphs))
return phases.txt_lay_out("\n\n".join(items))
class Lines(klammer_base.Klammer_base):
@@ -114,30 +140,32 @@ class Lines(klammer_base.Klammer_base):
return self.s.strip()
class Columns(klammer_base.Klammer_base):
def __init__(self, K):
super().__init__(K)
def make_column_lists(lst, count):
cols = math.ceil(len(lst) / count)
result = []
for i in range(count):
#print(i * cols, (i + 1) * cols)
result.append(lst[i * cols : (i+1) * cols])
return list(itertools.zip_longest(*result, fillvalue=""))
def make_column_lists(self):
lst = [f"@{self.style} {e} {self.style}@" for e in self.items.split()]
cols = math.ceil(len(lst) / self.n)
result = []
for i in range(self.n):
result.append(lst[i * cols : (i+1) * cols])
return list(itertools.zip_longest(*result, fillvalue=""))
def xmake_column_lists(lst, column_count):
count = len(lst)
row_count = math.ceil(count / column_count)
print(count, column_count, row_count)
i = 0
while i < count:
i += 1
def columns(self):
rows = self.make_column_lists()
result = (f"@table :header false :number false :leading 1.0 "
f":hpos {self.hpos} :offset {self.offset} |\n")
for row in rows:
result += " | ".join(row) + " ||\n"
result += "table@\n"
return result
def html(self):
return self.columns()
def columns(K):
rows = make_column_lists(K.items.split(), K.n)
result = "@table\n" # :lines false :header_line false :leading 1.1 :vmargin false |\n"
for row in rows:
#print("row:", row)
result += " | ".join(row) + " ||\n"
result += "table@\n"
if K.K_target == "latex":
result = f"\\vspace*{{-12pt}}\n{result}"
return result
def tex(self):
return self.columns()
def txt(self):
return self.columns()
+1 -1
View File
@@ -6,7 +6,7 @@
\setlistdepth{9}
\renewlist{enumerate}{enumerate}{9}
\renewlist{itemize}{itemize}{9}
\setlist{topsep=0pt, partopsep=0pt, parsep=2pt, itemsep=0pt}
\setlist{topsep=0pt, partopsep=0pt, parsep=.5\parskip, itemsep=0pt, labelindent=8pt, leftmargin=16pt}
\setlist[enumerate]{label*=\arabic*.}
\setlist[itemize,1]{label=\textbullet}
\setlist[itemize,2]{label=\normalfont\bfseries\textendash}
+16 -19
View File
@@ -2,56 +2,53 @@
# the front and back matter of a book):
@@preface.k :id :name Preface : Preface text @@
@@preface.html,tex :: @eval section.Section(K, "preface") eval@ @@
@@preface.html,tex,txt :: @eval section.Section(K, "preface") eval@ @@
# Numbered section headings:
@@part.k title :id :name Part : Part heading @@
@@part.html,tex :: @eval section.Section(K, 0) eval@ @@
@@part.html,tex,txt :: @eval section.Section(K, 0) eval@ @@
@@s1.k title :id :n : Top level division, numbered @@
@@s1.html,tex :: @eval section.Section(K, 1) eval@ @@
# Temporary hack for testing txt target; need to number section headings.
@@s1.txt :: *title* @@
@@s1.html,tex,txt :: @eval section.Section(K, 1) eval@ @@
@@s2.k title :id :n : Second level division, numbered @@
@@s2.html,tex :: @eval section.Section(K, 2) eval@ @@
@@s2.html,tex,txt :: @eval section.Section(K, 2) eval@ @@
@@s3.k title :id :n : Third level division, numbered @@
@@s3.html,tex :: @eval section.Section(K, 3) eval@ @@
@@s3.html,tex,txt :: @eval section.Section(K, 3) eval@ @@
@@s4.k title :id :n : Fourth level division, numbered @@
@@s4.html,tex :: @eval section.Section(K, 4) eval@ @@
@@s4.html,tex,txt :: @eval section.Section(K, 4) eval@ @@
@@s5.k title :id :n : Fifth level division, numbered @@
@@s5.html,tex :: @eval section.Section(K, 5) eval@ @@
@@s5.html,tex,txt :: @eval section.Section(K, 5) eval@ @@
@@s6.k title :id :n : Sixth level division, numbered @@
@@s6.html,tex :: @eval section.Section(K, 6) eval@ @@
@@s6.html,tex,txt :: @eval section.Section(K, 6) eval@ @@
@@s7.k title :id :n : Seventh level division, numbered @@
@@s7.html,tex :: @eval section.Section(K, 7) eval@ @@
@@s7.html,tex,txt :: @eval section.Section(K, 7) eval@ @@
# Unnumbered section headings:
@@h1.k title :id :n : Top level division, unnumbered @@
@@h1.html,tex :: @eval section.Section(K, 1, numbered=False) eval@ @@
@@h1.html,tex,txt :: @eval section.Section(K, 1, numbered=False) eval@ @@
@@h2.k title :id : Second level division, unnumbered @@
@@h2.html,tex :: @eval section.Section(K, 2, numbered=False) eval@ @@
@@h2.html,tex,txt :: @eval section.Section(K, 2, numbered=False) eval@ @@
@@h3.k title :id : Third level division, unnumbered @@
@@h3.html,tex :: @eval section.Section(K, 3, numbered=False) eval@ @@
@@h3.html,tex,txt :: @eval section.Section(K, 3, numbered=False) eval@ @@
@@h4.k title :id : Fourth level division, unnumbered @@
@@h4.html,tex :: @eval section.Section(K, 4, numbered=False) eval@ @@
@@h4.html,tex,txt :: @eval section.Section(K, 4, numbered=False) eval@ @@
@@h5.k title :id : Fifth level division, unnumbered @@
@@h5.html,tex :: @eval section.Section(K, 5, numbered=False) eval@ @@
@@h5.html,tex,txt :: @eval section.Section(K, 5, numbered=False) eval@ @@
@@h6.k title :id : Sixth level division, unnumbered @@
@@h6.html,tex :: @eval section.Section(K, 6, numbered=False) eval@ @@
@@h6.html,tex,txt :: @eval section.Section(K, 6, numbered=False) eval@ @@
@@h7.k title :id : Seventh level division, unnumbered @@
@@h7.html,tex :: @eval section.Section(K, 7, numbered=False) eval@ @@
@@h7.html,tex,txt :: @eval section.Section(K, 7, numbered=False) eval@ @@
+2 -3
View File
@@ -72,6 +72,5 @@ class Section(klammer_base.Klammer_base):
result = f"%__sectionlink__{id}__{self.title}__" + "\n" + result
return result
# Need to implement the procedural numbering for the txt target
# def txt(self):
# return self.title
def txt(self):
return f"@b {self.title} @"
+7
View File
@@ -232,3 +232,10 @@ column.
@@tbl.k spec.figure_id : The caption title for a table @@
@@tbl.html,tex,txt :: @reference *spec* | Table @ @@
#[
@@columns.k rows.rest : A minimal table without a caption, header, or lines @@
@@columns.html,tex,txt ::
@table rows :number false :header false @
@@
]#
+61 -22
View File
@@ -978,32 +978,71 @@ class Table(klammer_base.Klammer_base):
return (phases.txt_style_marker(STYLE[code]) + cell
+ phases.txt_style_marker(STYLE[code], False))
return cell
widths = self.txt_column_widths()
# A cell is one line of text: a newline written inside it is
# whitespace, as it is in html and tex.
rows = [[re.sub(r"\s*\n\s*", " ", cell) for cell in row] for row in self.rows]
widest = self.txt_column_widths()
pos = [hpos(p) for p in self.cell_hpos]
result = ""
indent = txt_util.NBSP * int(self.Target_txt_indent)
colsep = " "
# Horizontal lines are the same rule as the other targets (:hline,
# defaulting to under the header row and at the bottom when the
# table has a header): a rule of hyphens the width of the table at
# each boundary that has a line. A line over part of the columns
# is drawn full width here -- a partial rule in a plain-text table
# reads as an accident. Vertical lines have no plain-text form.
# The hyphens are HIDDEN (KTESC markers): a klammer's result is
# re-read as Klammertext and the txt target's dash transforms turn
# "--" into "-", so a bare run of hyphens came out a fraction of
# its length. (The caption rule escapes this by being drawn in a
# phase, after the transforms.)
rule = (indent + klammer_base.hide_typographic(
# Placement under txt (2026-09-16): an explicit :hpos left takes its
# indent from :offset (characters; 0 = flush), :hpos none flows with
# no indent, and anything else -- including the default, center --
# keeps the block convention, Target_txt_indent. (Centering and
# right-alignment are not attempted: a block that shifts with the
# width of its widest row reads as an accident in plain text.)
if self.hpos == "left":
indent = txt_util.NBSP * int(kutil.parse_width("txt", self.offset)[0])
elif self.hpos == "none":
indent = ""
else:
indent = txt_util.NBSP * int(self.Target_txt_indent)
colsep = " "
# Column widths, by the same rules as the other targets
# (:column_width): 'fit' is the widest entry; a fraction is that part
# of the text width; '*' takes the remaining width; 'fill' takes the
# remaining width but no more than its widest entry -- and, unlike a
# 'fit' column, its cells WRAP to that width (2026-09-13; until then
# the txt table never wrapped a cell, so a description column ran off
# the page). Several fill/'*' columns share the remainder in
# proportion to their widest entries.
text_width = (int(self.Target_txt_width) - int(self.Target_txt_indent)
- len(colsep) * (self.row_size - 1))
specs = extend(self.column_width, self.row_size)
widths = [0] * self.row_size
for i, w in enumerate(specs):
if w in ("fit", "f"):
widths[i] = widest[i]
elif w not in ("fill", "*"):
widths[i] = max(1, int(float(w) * text_width))
flexible = [i for i, w in enumerate(specs) if w in ("fill", "*")]
if flexible:
remaining = max(len(flexible), text_width - sum(widths))
total = sum(max(1, widest[i]) for i in flexible)
for i in flexible:
share = max(1, remaining * max(1, widest[i]) // total)
widths[i] = min(share, widest[i]) if specs[i] == "fill" else share
wrapped = {i for i, w in enumerate(specs) if w not in ("fit", "f")}
mark = phases.txt_verbatim_marker() # each line: already laid out
rule = (mark + indent + klammer_base.hide_typographic(
"-" * (sum(widths) + len(colsep) * (len(widths) - 1))) + "\n")
for row_i, row in enumerate(self.rows):
result = ""
for row_i, row in enumerate(rows):
if row_i in self.s_hline.by_index:
result += rule
result += indent + colsep.join(
[pad(styled(elt, row_i, cell_i), width, align)
for cell_i, (elt, width, align) in enumerate(zip(row, widths, pos))])
result += "\n"
if len(self.rows) in self.s_hline.by_index:
# Each cell becomes its lines: wrapped to the column's width in a
# wrapping column, one line otherwise; the row is as tall as its
# tallest cell, short cells padded with empty lines.
cell_lines = []
for cell_i, (elt, width) in enumerate(zip(row, widths)):
text = styled(elt, row_i, cell_i)
lines = phases.txt_wrap(text, width) if cell_i in wrapped and text else [text]
cell_lines.append(lines or [""])
height = max(len(lines) for lines in cell_lines)
for n in range(height):
result += mark + indent + colsep.join(
[pad(lines[n] if n < len(lines) else "", width, align)
for lines, width, align in zip(cell_lines, widths, pos)])
result += "\n"
if len(rows) in self.s_hline.by_index:
result += rule
result = txt_util.add_caption(
result, "Table", self.number, self.caption, self.caption_side, \
+2 -2
View File
@@ -223,11 +223,11 @@ def hpos_container(element, hpos, offset=None):
# coded, and only in html.)
styles = []
if hpos not in ("left", "center", "right", "none"):
length, _, _ = kutil.parse_length("html", hpos, 1)
length, _, _ = kutil.parse_width("html", hpos)
styles.append(f"margin-left: {length}")
hpos = "indent"
if offset and offset not in ("0", "0pt") and hpos in ("left", "right", "indent"):
length, _, _ = kutil.parse_length("html", offset, 1)
length, _, _ = kutil.parse_width("html", offset)
side = "right" if hpos == "right" else "left"
styles.append(f"padding-{side}: {length}")
result = E("div").cls("hpos_" + hpos).body(element)
+3 -3
View File
@@ -81,9 +81,9 @@ def minipage(content, width="\\textwidth", vertical="c", center=True, vmargin=""
def offset_length(offset):
r"""An :offset as a LaTeX length, or "0pt" when there is none."""
if not offset or offset in ("0", "0pt"):
if not offset or offset in ("0", "0pt", "0.0"):
return "0pt"
return kutil.parse_length("tex", offset, 1)[0]
return kutil.parse_width("tex", offset)[0]
def offset_space(offset):
r"""An :offset as an \hspace*, or "" when there is none."""
@@ -111,7 +111,7 @@ def caption_wrapper(element, hpos, bottom_margin=0, offset=None):
return minipage("\\hfill" + element + inset, vmargin=vmargin, center=False)
if hpos == "none":
return element
length, _, _ = kutil.parse_length("tex", hpos, 1)
length, _, _ = kutil.parse_width("tex", hpos)
return minipage(f"\\hspace*{{{length}}}" + element + "\\hfill",
vmargin=vmargin, center=False)
+23 -2
View File
@@ -25,6 +25,26 @@ def txt_vspace_marker():
def txt_nbsp_marker():
return "__KTEXTNBSP__"
# A ZERO-WIDTH mark that says "this line is already laid out" (2026-09-16).
# The justification pass takes a paragraph beginning with a space or the
# nbsp marker as verbatim, and until now that was the only way a block
# could say so -- which broke the moment a block was flush left: a @table
# with :hpos left :offset 0 (what @columns emits) began with its first
# cell, so its rows were re-flowed into one line. A block that lays itself
# out puts this at the start of every line (txt_lay_out); it is nothing to
# txt_visible_length and is removed at the end of txt_justify_blocks.
def txt_verbatim_marker():
return "__KTEXTVERBATIM__"
def txt_lay_out(text):
"""text with every line marked as already laid out."""
mark = txt_verbatim_marker()
return "\n".join(mark + line for line in text.split("\n"))
def txt_is_laid_out(par):
"""True for a paragraph the justification pass must leave alone."""
return par.startswith((" ", txt_nbsp_marker(), txt_verbatim_marker()))
# The type-style markers, emitted by @b.txt, @i.txt, @u.txt and @ri.txt
# (sks/font/font.k) around their text. Plain text has no bold, but a
# terminal does: the txt target STRIPS these in its last phase
@@ -68,7 +88,7 @@ def txt_visible_length(s):
"""How many columns s occupies once its markers resolve: a KTESC
marker is one glyph, an nbsp marker one space, a style marker nothing.
len() is the wrong measure for anything laid out (see txt_util)."""
s = _STYLE_MARKER_RGX.sub("", s)
s = _STYLE_MARKER_RGX.sub("", s).replace(txt_verbatim_marker(), "")
return len(klammer_base.unescape_ktesc(s).replace(txt_nbsp_marker(), " "))
def txt_wrap(text, width, subsequent_indent=""):
@@ -159,7 +179,7 @@ def txt_justify_blocks(text, K):
# branch that had nothing to do also did not copy the text through.
# An indented paragraph vanished from the output with no error.
nbsp = txt_nbsp_marker()
if par and par[0] != ' ' and not par.startswith(nbsp):
if par and not txt_is_laid_out(par):
label = re.match("(?:" + re.escape(nbsp) + r")*\[\d+\] ", par)
par = "\n".join(txt_wrap(
par, int(K.Target_txt_width),
@@ -167,6 +187,7 @@ def txt_justify_blocks(text, K):
result += par + "\n\n"
result = re.sub(" *" + txt_vspace_marker() + " *", "\n", result)
result = re.sub(r"\n? *" + txt_nl_marker() + r" *\n?", "\n", result)
result = result.replace(txt_verbatim_marker(), "")
result = result.replace(txt_nbsp_marker(), " ")
return result
+2 -2
View File
@@ -38,7 +38,7 @@ of this: neither the transforms nor the quoting reach it.
@@@state Target_txt_indent
:desc Width of indentation in the plain text target (txt)
:value 3
:value 2
@@@
# The txt target is not fully implemented in the SKS yet.
@@ -59,7 +59,7 @@ of this: neither the transforms nor the quoting reach it.
--- -- |
`` " |
'' " |
` ` |
` ' |
' ' |
~ __KTEXTNBSP__
:after_apply