Files
klammertext/sks/kutil/kutil.py
T
ack 830b19394d Klammertext runs natively on Windows; a Windows source-install guide; the bundled sh for @eval :shell
Assembled from klammertext-dev f344a37d772c. The commands, the library,
the Standard Klammer Set, the reflection klammerset and the Python
binding build and run natively on Windows (MSYS2 UCRT64 gcc, python.org
Python), and both test tiers pass there. doc/install/windows_source_install.md
is the fifth install guide: a source install parallel to the Linux and
macOS ones, verified by following it on a pristine Windows 11.

Behaviour on every system: paths Klammertext prints use forward slashes;
output is LF; the command line is UTF-8 on Windows; @eval :shell is a
POSIX shell command everywhere -- on Windows it runs the sh Klammertext
ships, BusyBox for Windows (win/busybox/, GPLv2, with its licence and the
source pointer). KLAMMERTEXT_FONTS and KLAMMERTEXT_KLAMMERSETS are lists in
the system's PATH convention (';' on Windows). On Linux and macOS nothing
observable changes.
2026-09-25 00:33:26 +02:00

297 lines
10 KiB
Python

import os
import re
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=""):
frame = inspect.currentframe().f_back
print(blue_text(f"[{os.path.basename(frame.f_code.co_filename)}:{frame.f_lineno}]"), text)
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]