From c439dec289ff389ee4586d5977727e73b742d8ce Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Tue, 25 Aug 2026 01:04:37 -0400 Subject: [PATCH] Wordmark: an .ans is CP437, and its newlines are not its rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension is .ans now, not .ansi — the software that draws this stuff is old and particular about 8.3 names, and .ans is what it expects to open. The wide mark is a real ANSI-art export rather than something colour-logo.py painted, which brings two DOS habits the loader had never met. Its blocks and box-drawing are single bytes in codepage 437, so reading it as UTF-8 raised instead of drawing; we try UTF-8 first and fall back, and the 53-column mark keeps its own encoding either way. And an art editor drops the line break on a row that fills the canvas, trusting the terminal to wrap it — so the file has nine newlines for sixteen rows. The canvas width comes from the SAUCE record, which is trimmed off in the same breath, being metadata and not art. Re-breaking a wrapped row has to restate the colour across the seam: each row becomes its own Text.from_ansi, so nothing carries state the way a terminal does when it never stopped reading the one stream. Co-Authored-By: Claude Opus 5 (1M context) --- meshterm/assets/logo.ans | Bin 0 -> 2418 bytes meshterm/assets/logo.ansi | 6 - meshterm/assets/{logo_53.ansi => logo_53.ans} | 0 meshterm/ui/logo.py | 103 ++++++++++++++++-- scripts/colour-logo.py | 4 +- 5 files changed, 98 insertions(+), 15 deletions(-) create mode 100644 meshterm/assets/logo.ans delete mode 100644 meshterm/assets/logo.ansi rename meshterm/assets/{logo_53.ansi => logo_53.ans} (100%) diff --git a/meshterm/assets/logo.ans b/meshterm/assets/logo.ans new file mode 100644 index 0000000000000000000000000000000000000000..75d9c9ea4b130ab50e9ead50d112e62820cb6e0d GIT binary patch literal 2418 zcmb7Gy=ogl5I(0e21DwoxiT&S8{LlsS2-e>7z}}sG^$h_wu+U_As2b8$ffKDunIka z4SI&WK*)TvKR0)`CttSM^L{h?&CIv6E03m1$NR=;ntaqRqbX0RnU#L#F`9aSJXB#k zlT&?s)KBRN3v9k`jG-(Ij#Xd`%ceo|J2(arIi57L!WenvOj0SxxJMkY6hzTbq_M#E zf_RRP`UG|y0ZA-WbMzb$Ib`~!XJ9vlr*2|CCQ64XB7o8Ne68@rgaTE@6DFR<8YiP zSe)&aleQro+xiK(3+zye@gEV$yGwy1@03HJS4d`4pE?RY)w>-s(HpyHE!pNAch)GW zjrM#W$c({TkP^6gc#<5CoV5f&xgE}eh5a{>YG}@U6sr{%uk`xK9*##q*79is=5V}@ zTrAx&I!K(u>ze~Ui4~!7vu zx$Pkwjnt)}63k6Ez0}I1whlJ;<$`kXM4D^5C+5B+(N5d~rhCL2)kxf{MlZ3~k^6@3 zd;PW2dJk?G!O0hktX>5f9xs_EM@xda%~&I~FH#r(Z;&pj{!7f5C{peh6akQqCpWjh zs`=IRGJZcf8LM#~UzD@Ohi~(;s>=9sRxRg?a&{M65<}ss=vT^8^OsoPeLwp#`JzIh4NAK=IiIn_~-eVS^4=NkWybJ literal 0 HcmV?d00001 diff --git a/meshterm/assets/logo.ansi b/meshterm/assets/logo.ansi deleted file mode 100644 index 9986004..0000000 --- a/meshterm/assets/logo.ansi +++ /dev/null @@ -1,6 +0,0 @@ -███╗ ███╗███████╗███████╗██╗ ██╗████████╗███████╗██████╗ ███╗ ███╗ -████╗ ████║██╔════╝██╔════╝██║ ██║╚══██╔══╝██╔════╝██╔══██╗████╗ ████║ -██╔████╔██║█████╗ ███████╗███████║ ██║ █████╗ ██████╔╝██╔████╔██║ -██║╚██╔╝██║██╔══╝ ╚════██║██╔══██║ ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║ -██║ ╚═╝ ██║███████╗███████║██║ ██║ ██║ ███████╗██║ ██║██║ ╚═╝ ██║ -╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ diff --git a/meshterm/assets/logo_53.ansi b/meshterm/assets/logo_53.ans similarity index 100% rename from meshterm/assets/logo_53.ansi rename to meshterm/assets/logo_53.ans diff --git a/meshterm/ui/logo.py b/meshterm/ui/logo.py index 02a9f46..7d348b3 100644 --- a/meshterm/ui/logo.py +++ b/meshterm/ui/logo.py @@ -1,9 +1,23 @@ """The MeshTerm wordmark for the startup splash, loaded from the assets folder. -The art itself lives in ``meshterm/assets`` — hand-drawn ``.txt`` sources beside the -pre-coloured ``.ansi`` the splash actually draws (``scripts/colour-logo.py`` paints one -into the other). Files are read fresh on every call, so the wordmark can be re-styled by -editing the art alone: no code change and no restart of the design loop. +The art itself lives in ``meshterm/assets`` as pre-coloured ``.ans`` files — the classic +ANSI-art extension, kept short because the editors that draw this stuff are old and +particular about 8.3 names. A ``.txt`` beside one is its hand-drawn source for marks we +colour ourselves (``scripts/colour-logo.py`` paints one into the other); a mark drawn in +an ANSI-art editor arrives already finished and has no source but itself. Files are read +fresh on every call, so the wordmark can be re-styled by editing the art alone: no code +change and no restart of the design loop. + +Two things separate a real ``.ans`` from a text file with colour in it, and :func:`_rows` +handles both so the art stays authorable in the tools that drew it: + +* **Codepage 437.** The blocks and box-drawing (``█▓▒░`` and ``╔═╗``) are single bytes in + DOS's codepage, not UTF-8. We try UTF-8 first and fall back, so a mark we generated + ourselves and a mark exported from an art editor both read. +* **Auto-wrap.** An art editor omits the line break on a row that fills the canvas and + lets the terminal wrap it, so the file's newlines are *not* the picture's rows. The + canvas width comes from the SAUCE record on the end of the file — which has to be + trimmed off in the same breath, being metadata rather than art. There is more than one mark, at different widths, and the *screen* picks — not the platform. A 53-column PicoCalc console and a desktop terminal dragged narrow have the same @@ -13,6 +27,7 @@ mark that fits the columns I have? from __future__ import annotations +import re from pathlib import Path from typing import Optional @@ -25,18 +40,92 @@ _ASSETS = Path(__file__).resolve().parent.parent / "assets" #: Every wordmark, widest first. :func:`load_logo` walks this and takes the first that #: fits, so adding a size is dropping a file in and naming it here. -_VARIANTS: tuple[str, ...] = ("logo.ansi", "logo_53.ansi") +_VARIANTS: tuple[str, ...] = ("logo.ans", "logo_53.ans") + +#: A colour change and nothing else. Every escape these marks use is an SGR, so re-breaking +#: a row only ever has to carry a *colour* across the seam — never a cursor move. +_SGR = re.compile(r"\x1b\[[0-9;]*m") + +#: SAUCE rides on the end of an art file behind DOS's end-of-file mark: 128 bytes naming +#: the author, the canvas and the font. None of it is meant to reach the screen. +_EOF_MARK = b"\x1a" +_SAUCE_LEN = 128 + + +def _split_sauce(raw: bytes) -> tuple[bytes, Optional[int]]: + """The art alone, and the canvas width SAUCE claims for it (``None`` when unsigned). + + A file with no record is returned whole and unmeasured: its newlines are its rows, and + re-breaking one would be inventing a canvas the author never declared. + """ + cut = raw.rfind(_EOF_MARK) + if cut < 0: + return raw, None + trailer = raw[cut + 1 :] + if not trailer.startswith(b"SAUCE") or len(trailer) < _SAUCE_LEN: + return raw, None + width = int.from_bytes(trailer[96:98], "little") # TInfo1 — characters per row + return raw[:cut], width or None + + +def _decode(raw: bytes) -> str: + """An art file's text, in whichever of the two codepages it was written.""" + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + return raw.decode("cp437") + + +def _rewrap(line: str, width: int) -> list[str]: + """Re-break one auto-wrapping line into rows of ``width`` printable cells. + + The rows are drawn independently — each becomes its own ``Text.from_ansi`` — so a + colour set before the seam has to be restated after it. A real terminal needs no such + help: it never stopped reading the one stream, so the state simply persisted. + """ + rows: list[str] = [] + buf: list[str] = [] + state = "" + cells = 0 + at = 0 + while at < len(line): + found = _SGR.match(line, at) + if found: + seq = found.group() + buf.append(seq) + params = seq[2:-1] + if params in ("", "0"): + state = "" # a plain reset leaves nothing to restate + elif params.split(";")[0] in ("", "0"): + state = seq # reset-and-set: this sequence *is* the whole state + else: + state += seq + at = found.end() + continue + buf.append(line[at]) + at += 1 + cells += 1 + if cells == width: + rows.append("".join(buf)) + buf = [state] if state else [] + cells = 0 + if cells or not rows: # the short last row, or a blank line that is still a row + rows.append("".join(buf)) + return rows def _rows(name: str) -> list[str]: """The named mark's rows, or ``[]`` when it can't be read.""" try: - text = (_ASSETS / name).read_text(encoding="utf-8") + raw = (_ASSETS / name).read_bytes() except OSError: return [] - lines = text.split("\n") + art, width = _split_sauce(raw) + lines = [line.rstrip("\r") for line in _decode(art).split("\n")] if lines and lines[-1] == "": # drop the trailing newline's empty row lines.pop() + if width: + return [row for line in lines for row in _rewrap(line, width)] return lines diff --git a/scripts/colour-logo.py b/scripts/colour-logo.py index ac06dc3..3042e2f 100644 --- a/scripts/colour-logo.py +++ b/scripts/colour-logo.py @@ -2,7 +2,7 @@ """Colour a plain-text wordmark into the pre-coloured ANSI the splash draws. The art itself is hand-drawn and lives in ``meshterm/assets`` as a ``.txt``; this paints -it and writes the matching ``.ansi`` beside it, so the colour scheme is stated once here +it and writes the matching ``.ans`` beside it, so the colour scheme is stated once here rather than re-typed into every escape sequence by hand. python scripts/colour-logo.py # repaint every asset/*.txt @@ -64,7 +64,7 @@ def main() -> None: names = sys.argv[1:] or sorted(p.stem for p in ASSETS.glob("*.txt")) for name in names: source = ASSETS / f"{name}.txt" - target = ASSETS / f"{name}.ansi" + target = ASSETS / f"{name}.ans" target.write_text(colour(source.read_text(encoding="utf-8")), encoding="utf-8") print(f"{source.name} -> {target.name}")