Assembled from development commit d5f9c20b94b3. Since 5c29da5: a print() in
@eval, or in any Python a klammer set runs, is a log line -- on the
error stream under the command's name at -v 1, silent otherwise, never
in the document -- and K_log(level, text) is the builtin behind it; a
Python script using the binding keeps its own stdout. Previously such
output was lost, or on a terminal landed in the document.
307 lines
11 KiB
Python
307 lines
11 KiB
Python
import os
|
|
import re
|
|
import sys
|
|
import inspect
|
|
import html_util
|
|
from html_util import E
|
|
|
|
|
|
def black_text():
|
|
return "\033[0;30m"
|
|
|
|
def blue_text(text):
|
|
return f"\033[0;34m{text}\033[0m"
|
|
|
|
def red_text(text):
|
|
return f"\033[31m{text}\033[0m"
|
|
|
|
def msg(text="", level=1):
|
|
"""A message from the klammer set: the engine's log (K_log, a builtin
|
|
under ktext), shown at -v LEVEL or higher on stderr, never in the
|
|
document. Outside a Machine (a module run by hand) it goes to stderr.
|
|
Until 2026-09-25 this was a print(): embedded Python's stdout, which no
|
|
user ever saw."""
|
|
frame = inspect.currentframe().f_back
|
|
where = f"[{os.path.basename(frame.f_code.co_filename)}:{frame.f_lineno}]"
|
|
try:
|
|
K_log(level, f"{where} {text}")
|
|
except NameError:
|
|
print(where, text, file=sys.stderr)
|
|
|
|
|
|
def escape(s):
|
|
result = s
|
|
# These were commented out -- has it been replaced?
|
|
# It's only used in kutil/klammer_base.py and block/block.py
|
|
# and does nothing now.
|
|
#result = re.sub(r"\b", r"\b", result)
|
|
#result = re.sub(r"\t", r"\t", result)
|
|
#result = re.sub("\f", r"\\f", result)
|
|
#result = re.sub("\v", r"\\v", result)
|
|
#result = re.compile("\\(.)").sub(r"\\\1", result)
|
|
return result
|
|
|
|
def klammertext_dir():
|
|
var = 'KLAMMERTEXT_HOME'
|
|
kdir = os.environ.get(var)
|
|
if kdir is None:
|
|
raise Exception(
|
|
f"The environment variable {var} must be defined as the top-level Klammertext directory")
|
|
return kdir
|
|
|
|
def cache_directory(relative_pathname, basename):
|
|
# Forward slashes: the path ends up in document text (\includegraphics,
|
|
# an html src), where a Windows backslash is a TeX command or an escape.
|
|
directory = relative_pathname.replace(os.sep, "/")
|
|
if not os.path.isdir(directory):
|
|
os.makedirs(directory)
|
|
result = directory + "/_klammertext_cache/" + basename
|
|
return result;
|
|
|
|
def sks_dirs():
|
|
result = 'kutil book document block font link section list image table code color'.split()
|
|
k = klammertext_dir()
|
|
result = [[e, f"{k}/sks/{e}"] for e in result]
|
|
return result
|
|
|
|
def sks_files_of_type(extension):
|
|
result = []
|
|
for base, sks_dir in sks_dirs():
|
|
filename = f'{sks_dir}/{base}.{extension}'
|
|
if os.path.exists(filename):
|
|
result.append([base, filename])
|
|
return result
|
|
|
|
def make_dir_if_necessary(d, delete_contents=False):
|
|
if not os.path.exists(d):
|
|
os.makedirs(d)
|
|
if delete_contents:
|
|
os.system(f'rm -rf {d}/*')
|
|
|
|
def protect_klammertext_special_characters(text):
|
|
result = text
|
|
result = re.compile(r"\^").sub("H̅", result)
|
|
result = re.compile(r"@").sub("A̅", result)
|
|
result = re.compile(r":").sub("̅C̅", result)
|
|
result = re.compile(r"\|").sub("̅B̅", result)
|
|
return result
|
|
|
|
def caption_delimiter():
|
|
return "__CAPTION__"
|
|
|
|
def caption_marker(name, caption, label_delimiter=" -- "):
|
|
caption = re.sub("\n", " ", caption)
|
|
d = caption_delimiter()
|
|
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.
|
|
|
|
The delimiter for dimension n is a run of exactly n bar characters:
|
|
| separates elements, || separates lists of elements, ||| lists of
|
|
lists, and so on. This is the cast behind the rest(N) argument type.
|
|
One trailing top-level delimiter (the customary dangling separator
|
|
before a closing @) is removed; all other empty elements are
|
|
preserved, so a trailing | still makes an empty final cell.
|
|
"""
|
|
s = s.strip()
|
|
if not s:
|
|
return [] if dimensions > 0 else s
|
|
delimiter = "|" * dimensions
|
|
if s.endswith(delimiter) and not s.endswith("|" + delimiter):
|
|
s = s[:-len(delimiter)]
|
|
return _rest_split_level(s, dimensions)
|
|
|
|
def _rest_split_level(s, dimensions):
|
|
if dimensions <= 0:
|
|
return s.strip()
|
|
pattern = re.compile("(?<!\\|)" + "\\|" * dimensions + "(?!\\|)")
|
|
return [_rest_split_level(part, dimensions - 1) for part in pattern.split(s)]
|
|
|
|
def rest_args(s, dimensions=1):
|
|
return rest_split(s, dimensions)
|
|
|
|
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]+))|(f)')
|
|
match = pat.match(s)
|
|
if match is None: # But already checked by the klammer
|
|
raise Exception(f'The argument "{s}" is not a length')
|
|
|
|
#print(match.groups())
|
|
num, units, fit, text = match.groups()
|
|
num = float(num) if num else ""
|
|
|
|
num *= rel_fraction
|
|
|
|
if fit:
|
|
result = "f" # choose("100vw", "\\textwidth")
|
|
elif text:
|
|
text = re.compile(r"\{\}\\textbackslash\{\}").sub(r"\\", text)
|
|
if text[0] == "-":
|
|
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":
|
|
result = choose(f"{100 * num}vh", f"{num}\\textheight")
|
|
elif units == "pw":
|
|
result = choose(f"{100 * num}vw", f"{num}\\paperwidth")
|
|
elif units == "ph":
|
|
result = choose(f"{100 * num}vh", f"{num}\\paperheight")
|
|
elif units == "px":
|
|
result = choose(f"{round(num)}", f"{num}px")
|
|
else:
|
|
# 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)
|
|
if match is None:
|
|
raise Exception(f'The argument "{s}" is not a length')
|
|
if match.group(3) in {"none", "f"}:
|
|
return "f"
|
|
if s[0] == '"':
|
|
if target in {"tex", "pdf"}:
|
|
return f"\\widthof{{{match.group(5)} }}"
|
|
else:
|
|
return ""
|
|
num, units = match.groups()[:2]
|
|
if float(num) == 0:
|
|
return None
|
|
result = f"{num}{units}"
|
|
if target in {"tex", "pdf"}:
|
|
if units == "w":
|
|
result = fr"{num}\textwidth"
|
|
elif units == "h":
|
|
result = fr"{numb}\textheight"
|
|
elif target in {"html"}:
|
|
scale_x = None
|
|
scale_y = None
|
|
if units == "w":
|
|
#result = fr"{int(float(num)*100)}vw"
|
|
result = fr"calc({int(float(num)*100)}vw - 2rem)"
|
|
#result = 0
|
|
scale_x = num
|
|
result = "none"
|
|
elif units == "h":
|
|
result = fr"{int(float(num)*100)}vw"
|
|
#result = 0
|
|
scale_y = num
|
|
result = "none"
|
|
result = [result, scale_x, scale_y]
|
|
else:
|
|
raise Exception(f"Uknown length: '{s}'")
|
|
return result
|
|
|
|
def parse_lengths(target, s):
|
|
# Fill strings with space character for the split:
|
|
#print("parse_lengths:", s)
|
|
def replace(match):
|
|
return re.sub(r"\s", "~", match.group(1))
|
|
result = re.compile(r'("[^"]+")').sub(replace, s)
|
|
#print(result, result.split())
|
|
result = [parse_length(target, e)[0] for e in result.split()]
|
|
return result
|
|
|
|
paragraph_separator_re = re.compile(r'\n *\n', re.S)
|
|
|
|
def format_for_paragraphs(s):
|
|
result = s
|
|
if len(paragraph_separator_re.findall(result)) > 0:
|
|
result = '\n\n{}\n\n'.format(result)
|
|
return result
|
|
|
|
|
|
|
|
def filename_list(text):
|
|
"""Split a filename list; the Python twin of resolve_filename_list()
|
|
in mac/file.cpp (keep the two in sync). A standalone "/" (whitespace
|
|
on both sides) separates names, whose inner spacing is preserved;
|
|
without a separator, whitespace-separated tokens that do not name
|
|
existing files are rejoined with their neighbors into names that do.
|
|
A leading ~ expands to the home directory."""
|
|
text = text.strip()
|
|
if not text:
|
|
return []
|
|
parts = re.split(r'(?:^|(?<=\s))/(?:\s|$)', text)
|
|
if len(parts) > 1:
|
|
return [os.path.expanduser(p.strip()) for p in parts if p.strip()]
|
|
tokens = text.split()
|
|
result, i = [], 0
|
|
while i < len(tokens):
|
|
if os.path.isfile(os.path.expanduser(tokens[i])):
|
|
result.append(tokens[i])
|
|
i += 1
|
|
continue
|
|
acc, j, found = tokens[i], i + 1, False
|
|
while j < len(tokens):
|
|
acc += " " + tokens[j]
|
|
j += 1
|
|
if os.path.isfile(os.path.expanduser(acc)):
|
|
result.append(acc)
|
|
i = j
|
|
found = True
|
|
break
|
|
if not found:
|
|
result.append(tokens[i])
|
|
i += 1
|
|
return [os.path.expanduser(p) for p in result]
|