diff --git a/README.md b/README.md index f6d817a..ac50c5c 100644 --- a/README.md +++ b/README.md @@ -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 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 diff --git a/sks/kutil/kutil.py b/sks/kutil/kutil.py index 0778359..dc24ad5 100644 --- a/sks/kutil/kutil.py +++ b/sks/kutil/kutil.py @@ -74,23 +74,16 @@ def protect_klammertext_special_characters(text): result = re.compile(r"\|").sub("̅B̅", 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(): return "__CAPTION__" -def caption_marker(name, caption, delimiter=" - "): +def caption_marker(name, caption, label_delimiter=" -- "): caption = re.sub("\n", " ", caption) d = caption_delimiter() - if caption: - caption = f"{delimiter}{caption}" - return f"{d}{name}{d}{caption}{d}" + if caption and label_delimiter: + caption = f"{label_delimiter}{caption}" + result = f"{d}{name}{d}{caption}{d}" + return result def rest_split(s, dimensions=1): """Split bar-delimited text into nested lists, one level per dimension. diff --git a/sks/table/table.k b/sks/table/table.k index 93da7cb..fb3b53d 100644 --- a/sks/table/table.k +++ b/sks/table/table.k @@ -196,7 +196,7 @@ @@table.k rows.rest(2) :id - @caption_args@ + @caption_args :caption_side top @ @hpos_args@ :header.bool true :allow_break.bool false @@ -228,7 +228,7 @@ formats them, and ^:hpos and ^:offset place the whole table in the text 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.html,tex,txt :: @reference *spec* | Table @ @@ diff --git a/sks/table/table.py b/sks/table/table.py index 97e43f7..1b9ac92 100644 --- a/sks/table/table.py +++ b/sks/table/table.py @@ -11,9 +11,11 @@ import klammer_base import html_util from html_util import E import latex_util +import txt_util from indexed_range import Indexed_ranges, hline_names, vline_names import table_cell import font +import phases # ---- :format functions --------------------------------------------------- # 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.) return latex_util.block(result) -# This should signal that it has not be implemented by not being defined. -# def txt(self): -# return "Table in .txt format not implemented" + + # txt target + + 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 diff --git a/sks/target/phases.py b/sks/target/phases.py index 03d5902..41b8baa 100644 --- a/sks/target/phases.py +++ b/sks/target/phases.py @@ -1,6 +1,8 @@ import re import textwrap +import kutil + # 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 @@ -25,20 +27,69 @@ def txt_justify_blocks(text, K): result += "\n" * n 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) par = "\n".join(textwrap.wrap( par, width=int(K.Target_txt_width), break_long_words=False, 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(r"\n? *" + txt_nl_marker() + r" *\n?", "\n", result) result = re.sub("~", " ", 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(): 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: result += '
\n' return result - - diff --git a/sks/target/target.k b/sks/target/target.k index e1e9769..9cd9b2b 100644 --- a/sks/target/target.k +++ b/sks/target/target.k @@ -36,6 +36,10 @@ of this: neither the transforms nor the quoting reach it. :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. @@@target txt | Plain text with formatting | @@ -48,6 +52,7 @@ of this: neither the transforms nor the quoting reach it. ~ ^0020^ :after_apply phases.txt_format_footnotes ; + phases.txt_insert_captions ; phases.txt_justify_blocks @@@ diff --git a/sks/target/txt_util.py b/sks/target/txt_util.py new file mode 100644 index 0000000..d1433cf --- /dev/null +++ b/sks/target/txt_util.py @@ -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