krz/nfogen
A scene-style ASCII .nfo generator.
clone: git clone https://gitbay.org/krz/nfogen.git
83f9f24efa383a310ce1dfbdd50d5864764e4a10
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-08T05:56:07Z
.gitignore | 2 + README.nfo | 15 +- examples/krazy-toolkit-v3.cp437.nfo | 43 ++++++ examples/krazy-toolkit-v3.nfo | 43 ++++++ examples/krazy-toolkit-v3.toml | 43 ++++++ examples/krz-logo.asc | 5 + examples/the-midnight-cartographer-2026.cp437.nfo | 2 +- examples/the-midnight-cartographer-2026.nfo | 2 +- nfogen.py | 179 ++++++++++++++++------ nfogen.toml | 15 +- 10 files changed, 286 insertions(+), 63 deletions(-) new file mode 100644 @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc @@ -29,17 +29,20 @@ │ default plus group, site, width. Or use -c PATH. │ │ │ │ FIELDS (all optional but title): │ -│ core: title date type source size files │ -│ url notes greets │ -│ media: format video audio resolution │ -│ language genre runtime │ +│ release: cracker supplier packager protection │ +│ install rating date source │ +│ program: type publisher format disks size files url │ +│ media: video audio resolution language genre runtime │ +│ text: notes greets │ │ │ +│ LAYOUT: rows (default) or panel (2-col, --layout) │ │ STYLES: single, double, block (--style) │ │ ENCODING: utf8 or cp437 (--encoding cp437 = DOS) │ +│ LOGO: --logo art.asc places art above the box │ │ BANNERS: --presents --footer scene boilerplate │ │ │ -│ OPTIONS: -o -c -g -s -w --style --encoding │ -│ --presents --footer -i --no-input │ +│ OPTIONS: -o -c -g -s -w --style --layout --logo │ +│ --encoding --presents --footer -i --no-input │ │ │ │ Generated by nfogen itself. │ ├──────────────────────────────────────────────────────────────┤ new file mode 100644 @@ -0,0 +1,43 @@ + _ _______ ___ + | |/ / _ \ \ / / | + | ' /| |_) \ V /| | k r a z y w a r e z + | . \| _ < | | |_| + |_|\_\_| \_\|_| (_) ------- since 1985 ------- + +ͻ + K R A Z Y . T O O L K I T . V 3 . 2 . 1 - K R Z + [ KRZ ] krz.sh + -={ KRZ proudly presents }=- + + Release Information Game Information + + DATE : 2026-08-08 TYPE : APP + CRACKER : Lime PUBLISHER : Krazy Softworks + SUPPLIER : The Witch King FORMAT : ZIP / self-extracting + PACKAGER : Renegade Chemist DISKS : 7 x 1.44 HD + PROTECTION : Serial + Doc Check SIZE : 9.84 MB + INSTALL : unzip, run SETUP.EXE.. FILES : 42 x 240 KB + RATING : 10/10 URL : krz.sh/toolkit + + NOTES + + Here is the release everyone has been waiting for. The + complete Krazy Toolkit, cracked clean -- no nag screens, + no phone-home, no time limit. Tested on a fresh install. + + Install : unzip to a folder of your choice + run SETUP.EXE and follow the prompts + apply the included keygen when asked for a serial + + Greets to everyone who keeps the scene alive. Fix it + yourself if you think you can do better. + + GREETS + + razor-blade jack knightmare stingray the-couriers + + + SUPPORT THE COMPANIES THAT PRODUCE QUALITY SOFTWARE + if you enjoyed this release, buy it! + +ͼ new file mode 100644 @@ -0,0 +1,43 @@ + _ _______ ___ + | |/ / _ \ \ / / | + | ' /| |_) \ V /| | k r a z y w a r e z + | . \| _ < | | |_| + |_|\_\_| \_\|_| (_) ------- since 1985 ------- + +╔═════════════════════════════════════════════════════════════════════════════╗ +║ K R A Z Y . T O O L K I T . V 3 . 2 . 1 - K R Z ║ +║ [ KRZ ] krz.sh ║ +║ -={ KRZ proudly presents }=- ║ +╠══════════════════════════════════════╦══════════════════════════════════════╣ +║ Release Information ║ Game Information ║ +╠══════════════════════════════════════╬══════════════════════════════════════╣ +║ DATE : 2026-08-08 ║ TYPE : APP ║ +║ CRACKER : Lime ║ PUBLISHER : Krazy Softworks ║ +║ SUPPLIER : The Witch King ║ FORMAT : ZIP / self-extracting ║ +║ PACKAGER : Renegade Chemist ║ DISKS : 7 x 1.44 HD ║ +║ PROTECTION : Serial + Doc Check ║ SIZE : 9.84 MB ║ +║ INSTALL : unzip, run SETUP.EXE.. ║ FILES : 42 x 240 KB ║ +║ RATING : 10/10 ║ URL : krz.sh/toolkit ║ +╠══════════════════════════════════════╩══════════════════════════════════════╣ +║ NOTES ║ +║ ║ +║ Here is the release everyone has been waiting for. The ║ +║ complete Krazy Toolkit, cracked clean -- no nag screens, ║ +║ no phone-home, no time limit. Tested on a fresh install. ║ +║ ║ +║ Install : unzip to a folder of your choice ║ +║ run SETUP.EXE and follow the prompts ║ +║ apply the included keygen when asked for a serial ║ +║ ║ +║ Greets to everyone who keeps the scene alive. Fix it ║ +║ yourself if you think you can do better. ║ +╠═════════════════════════════════════════════════════════════════════════════╣ +║ GREETS ║ +║ ║ +║ razor-blade jack knightmare stingray the-couriers ║ +╠═════════════════════════════════════════════════════════════════════════════╣ +║ ║ +║ SUPPORT THE COMPANIES THAT PRODUCE QUALITY SOFTWARE ║ +║ if you enjoyed this release, buy it! ║ +║ ║ +╚═════════════════════════════════════════════════════════════════════════════╝ new file mode 100644 @@ -0,0 +1,43 @@ +group = "KRZ" +site = "krz.sh" +style = "double" +layout = "panel" +presents = true +footer = true +logo = "examples/krz-logo.asc" +logo_encoding = "utf8" + +title = "Krazy.Toolkit.v3.2.1-KRZ" +date = "2026-08-08" + +# release information (left panel) +cracker = "Lime" +supplier = "The Witch King" +packager = "Renegade Chemist" +protection = "Serial + Doc Check" +install = "unzip, run SETUP.EXE, use keygen" +rating = "10/10" + +# game / program information (right panel) +type = "APP" +publisher = "Krazy Softworks" +format = "ZIP / self-extracting" +disks = "7 x 1.44 HD" +size = "9.84 MB" +files = "42 x 240 KB" +url = "krz.sh/toolkit" + +notes = """ +Here is the release everyone has been waiting for. The +complete Krazy Toolkit, cracked clean -- no nag screens, +no phone-home, no time limit. Tested on a fresh install. + +Install : unzip to a folder of your choice + run SETUP.EXE and follow the prompts + apply the included keygen when asked for a serial + +Greets to everyone who keeps the scene alive. Fix it +yourself if you think you can do better. +""" + +greets = ["razor-blade", "jack", "knightmare", "stingray", "the-couriers"] new file mode 100644 @@ -0,0 +1,5 @@ + _ _______ ___ +| |/ / _ \ \ / / | +| ' /| |_) \ V /| | k r a z y w a r e z +| . \| _ < | | |_| +|_|\_\_| \_\|_| (_) ------- since 1985 ------- @@ -4,8 +4,8 @@ -={ KRZ proudly presents }=- DATE 2026-08-08 - TYPE MOVIE SOURCE BluRay (Retail) + TYPE MOVIE FORMAT Matroska (MKV) VIDEO x264 @ 8500 kbps, 23.976 fps AUDIO DTS-HD MA 5.1 @ 1509 kbps (English) @@ -4,8 +4,8 @@ ║ -={ KRZ proudly presents }=- ║ ╠═════════════════════════════════════════════════════════════════════════════╣ ║ DATE 2026-08-08 ║ -║ TYPE MOVIE ║ ║ SOURCE BluRay (Retail) ║ +║ TYPE MOVIE ║ ║ FORMAT Matroska (MKV) ║ ║ VIDEO x264 @ 8500 kbps, 23.976 fps ║ ║ AUDIO DTS-HD MA 5.1 @ 1509 kbps (English) ║ @@ -13,6 +13,7 @@ import os import sys import textwrap from datetime import date +from itertools import zip_longest from pathlib import Path # --- field schema ------------------------------------------------------------ @@ -23,27 +24,39 @@ from pathlib import Path # kind : "line" scalar shown as an aligned INFO row # "list" many values, shown space-joined in its own section # "block" free text, wrapped in its own section +# col : "L"/"R" panel column for "line" fields; None otherwise FIELDS = [ - ("title", "TITLE", "release title (required)", "line"), - ("date", "DATE", "release date", "line"), - ("type", "TYPE", "release type (MOVIE, TV, APP, ...)", "line"), - ("source", "SOURCE", "source (BluRay, WEB-DL, CD, ...)", "line"), - ("format", "FORMAT", "container / format", "line"), - ("video", "VIDEO", "video codec / details", "line"), - ("audio", "AUDIO", "audio codec / details", "line"), - ("resolution", "RESOLUTION", "resolution", "line"), - ("language", "LANGUAGE", "language(s)", "line"), - ("genre", "GENRE", "genre", "line"), - ("runtime", "RUNTIME", "runtime / duration", "line"), - ("size", "SIZE", "total size", "line"), - ("files", "FILES", "file count / listing", "line"), - ("url", "URL", "reference url (imdb, homepage, ...)", "line"), - ("notes", "NOTES", "free-text notes / description", "block"), - ("greets", "GREETS", "groups to greet (space/comma sep)", "list"), + ("title", "TITLE", "release title (required)", "line", None), + # --- release information (left panel) --- + ("date", "DATE", "release date", "line", "L"), + ("source", "SOURCE", "source (BluRay, WEB-DL, CD, ...)", "line", "L"), + ("cracker", "CRACKER", "cracker / cracked by", "line", "L"), + ("supplier", "SUPPLIER", "supplier / supplied by", "line", "L"), + ("packager", "PACKAGER", "packager / packed by", "line", "L"), + ("protection", "PROTECTION", "protection type", "line", "L"), + ("install", "INSTALL", "install method", "line", "L"), + ("rating", "RATING", "rating", "line", "L"), + # --- game / media information (right panel) --- + ("type", "TYPE", "release type (MOVIE, TV, APP, ...)", "line", "R"), + ("publisher", "PUBLISHER", "publisher / vendor", "line", "R"), + ("format", "FORMAT", "container / format", "line", "R"), + ("disks", "DISKS", "number of disks / archives", "line", "R"), + ("video", "VIDEO", "video codec / details", "line", "R"), + ("audio", "AUDIO", "audio codec / details", "line", "R"), + ("resolution", "RESOLUTION", "resolution", "line", "R"), + ("language", "LANGUAGE", "language(s)", "line", "R"), + ("genre", "GENRE", "genre", "line", "R"), + ("runtime", "RUNTIME", "runtime / duration", "line", "R"), + ("size", "SIZE", "total size", "line", "R"), + ("files", "FILES", "file count / listing", "line", "R"), + ("url", "URL", "reference url (imdb, homepage, ...)", "line", "R"), + # --- free-form sections --- + ("notes", "NOTES", "free-text notes / description", "block", None), + ("greets", "GREETS", "groups to greet (space/comma sep)", "list", None), ] -FIELD_KINDS = {k: kind for k, _, _, kind in FIELDS} -INFO_KEYS = [k for k, _, _, kind in FIELDS if kind == "line" and k != "title"] +FIELD_KINDS = {k: kind for k, _, _, kind, _ in FIELDS} +INFO_KEYS = [k for k, _, _, kind, _ in FIELDS if kind == "line" and k != "title"] DEFAULT_SITE = "krz.sh" DEFAULT_GROUP = "KRZ" @@ -53,16 +66,20 @@ DEFAULT_FOOTER = ( "SUPPORT THE COMPANIES THAT PRODUCE QUALITY SOFTWARE\n" "if you enjoyed this release, buy it!" ) +DEFAULT_LAYOUT = "rows" +PANEL_TITLES = ("Release Information", "Game Information") # Box-drawing character sets. "single"/"double" are CP437-encodable; "block" # is a solid fill frame. Keys: corners + horizontal/vertical + tee separators. +# jt/jm/jb are the down-tee, cross, and up-tee used where the panel's +# vertical column divider meets a horizontal rule. STYLES = { - "single": dict(tl="┌", tr="┐", bl="└", br="┘", - h="─", v="│", ls="├", rs="┤"), - "double": dict(tl="╔", tr="╗", bl="╚", br="╝", - h="═", v="║", ls="╠", rs="╣"), - "block": dict(tl="█", tr="█", bl="█", br="█", - h="█", v="█", ls="█", rs="█"), + "single": dict(tl="┌", tr="┐", bl="└", br="┘", h="─", v="│", + ls="├", rs="┤", jt="┬", jm="┼", jb="┴"), + "double": dict(tl="╔", tr="╗", bl="╚", br="╝", h="═", v="║", + ls="╠", rs="╣", jt="╦", jm="╬", jb="╩"), + "block": dict(tl="█", tr="█", bl="█", br="█", h="█", v="█", + ls="█", rs="█", jt="█", jm="█", jb="█"), } CONFIG_CANDIDATES = [ @@ -105,7 +122,7 @@ def prompt_missing(data: dict, force_all: bool) -> None: return print("nfogen: interactive mode (blank to skip)\n", file=sys.stderr) for key in keys: - _, label, help_text, _ = next(f for f in FIELDS if f[0] == key) + _, label, help_text, _, _ = next(f for f in FIELDS if f[0] == key) current = data.get(key, "") suffix = f" [{current}]" if current else "" try: @@ -125,20 +142,42 @@ def _split_list(value) -> list[str]: def render(data: dict, group: str, site: str, width: int, style: str = DEFAULT_STYLE, presents: bool = False, - footer: str | None = None) -> str: - inner = width - 4 # borders + one space of padding on each side + footer: str | None = None, layout: str = DEFAULT_LAYOUT, + logo: str | None = None, + panel_titles: tuple[str, str] = PANEL_TITLES) -> str: s = STYLES[style] - top = s["tl"] + s["h"] * (width - 2) + s["tr"] - sep = s["ls"] + s["h"] * (width - 2) + s["rs"] - bot = s["bl"] + s["h"] * (width - 2) + s["br"] + h, v = s["h"], s["v"] + inner = width - 4 # single-column content width (1 space padding) + cl = (width - 7) // 2 # left panel content width + cr = (width - 7) - cl # right panel content width + rfill = width - cl - 5 # h-run right of the divider in a rule + + def hrule(left: str, right: str, joint: str | None = None) -> str: + if joint is None: + return left + h * (width - 2) + right + return left + h * (cl + 2) + joint + h * rfill + right def line(content: str = "") -> str: - return f"{s['v']} " + content.ljust(inner) + f" {s['v']}" + return f"{v} " + content.ljust(inner) + f" {v}" def center(content: str) -> str: - return f"{s['v']} " + content.center(inner) + f" {s['v']}" + return f"{v} " + content.center(inner) + f" {v}" + + def prow(left: str, right: str, centered: bool = False) -> str: + fn = str.center if centered else str.ljust + return (f"{v} " + fn(_clip(left, cl), cl) + + f" {v} " + fn(_clip(right, cr), cr) + f" {v}") + + out: list[str] = [] + + # logo art above the box, block-centered to the full width + if logo: + art = logo.rstrip("\n").split("\n") + pad = max((width - max((len(a) for a in art), default=0)) // 2, 0) + out.extend((" " * pad + a).rstrip() for a in art) + out.append("") - out: list[str] = [top] + out.append(hrule(s["tl"], s["tr"])) # header: spaced title, then group tag + site, optional presents banner title = str(data.get("title") or "UNTITLED").upper() @@ -149,24 +188,46 @@ def render(data: dict, group: str, site: str, width: int, if presents: out.append(center(_clip(f"-={{ {group.upper()} proudly presents }}=-", inner))) - # info rows - rows = [(label, str(data[key])) - for key, label, _, _ in FIELDS - if key in INFO_KEYS and data.get(key)] - if rows: - out.append(sep) - lw = max(len(label) for label, _ in rows) + line_fields = [(k, lbl, col) for k, lbl, _, kind, col in FIELDS + if kind == "line" and k != "title" and data.get(k)] + prev_panel = False + + def sep_before() -> str: + nonlocal prev_panel + joint = s["jb"] if prev_panel else None + prev_panel = False + return hrule(s["ls"], s["rs"], joint) + + # info: two-column panel or single-column rows + if layout == "panel" and line_fields: + left = [(lbl, str(data[k])) for k, lbl, col in line_fields if col == "L"] + right = [(lbl, str(data[k])) for k, lbl, col in line_fields if col == "R"] + lw_l = max((len(lbl) for lbl, _ in left), default=0) + lw_r = max((len(lbl) for lbl, _ in right), default=0) + + def cell(pair, lw): + return f" {pair[0]:<{lw}} : {pair[1]}" if pair else "" + + out.append(hrule(s["ls"], s["rs"], s["jt"])) + out.append(prow(panel_titles[0], panel_titles[1], centered=True)) + out.append(hrule(s["ls"], s["rs"], s["jm"])) + for lp, rp in zip_longest(left, right): + out.append(prow(cell(lp, lw_l), cell(rp, lw_r))) + prev_panel = True + elif line_fields: + out.append(hrule(s["ls"], s["rs"])) + lw = max(len(lbl) for _, lbl, _ in line_fields) avail = inner - (2 + lw + 2) - for label, value in rows: - wrapped = textwrap.wrap(value, avail) or [""] - out.append(line(f" {label:<{lw}} {wrapped[0]}")) + for k, lbl, _ in line_fields: + wrapped = textwrap.wrap(str(data[k]), avail) or [""] + out.append(line(f" {lbl:<{lw}} {wrapped[0]}")) for cont in wrapped[1:]: out.append(line(" " * (2 + lw + 2) + cont)) # notes block notes = str(data.get("notes") or "").strip() if notes: - out.append(sep) + out.append(sep_before()) out.append(line(" NOTES")) out.append(line()) for para in notes.splitlines() or [""]: @@ -176,7 +237,7 @@ def render(data: dict, group: str, site: str, width: int, # greets block greets = _split_list(data.get("greets") or []) if greets: - out.append(sep) + out.append(sep_before()) out.append(line(" GREETS")) out.append(line()) for wrapped in textwrap.wrap(" ".join(greets), inner - 4): @@ -184,14 +245,14 @@ def render(data: dict, group: str, site: str, width: int, # footer disclaimer, centered if footer: - out.append(sep) + out.append(sep_before()) out.append(line()) for para in footer.splitlines(): for wrapped in (textwrap.wrap(para, inner - 4) or [""]): out.append(center(wrapped)) out.append(line()) - out.append(bot) + out.append(hrule(s["bl"], s["br"], s["jb"] if prev_panel else None)) return "\n".join(out) + "\n" @@ -215,6 +276,12 @@ def build_parser() -> argparse.ArgumentParser: help=f"box width in chars (default {DEFAULT_WIDTH})") p.add_argument("--style", choices=sorted(STYLES), help=f"box-drawing style (default {DEFAULT_STYLE})") + p.add_argument("--layout", choices=["rows", "panel"], + help=f"info layout (default {DEFAULT_LAYOUT})") + p.add_argument("--logo", metavar="PATH", + help="art file to place above the box") + p.add_argument("--logo-encoding", choices=["cp437", "utf8"], + help="encoding of the logo file (default cp437)") p.add_argument("--encoding", choices=["utf8", "cp437"], default="utf8", help="output encoding (default utf8; cp437 for true DOS nfos)") p.add_argument("--presents", action=argparse.BooleanOptionalAction, @@ -226,7 +293,7 @@ def build_parser() -> argparse.ArgumentParser: help="prompt for every field") p.add_argument("--no-input", action="store_true", help="never prompt, even if fields are missing") - for key, _, help_text, _ in FIELDS: + for key, _, help_text, _, _ in FIELDS: p.add_argument(f"--{key}", help=help_text) return p @@ -258,6 +325,19 @@ def main(argv: list[str] | None = None) -> int: footer_text = args.footer_text or config.get("footer_text") or DEFAULT_FOOTER footer = footer_text if show_footer else None + layout = args.layout or config.get("layout") or DEFAULT_LAYOUT + panel_titles = (config.get("panel_left") or PANEL_TITLES[0], + config.get("panel_right") or PANEL_TITLES[1]) + + logo = None + logo_path = args.logo or config.get("logo") + if logo_path: + enc = args.logo_encoding or config.get("logo_encoding") or "cp437" + try: + logo = Path(logo_path).read_text(encoding=enc, errors="replace") + except FileNotFoundError: + sys.exit(f"nfogen: logo not found: {logo_path}") + interactive = sys.stdin.isatty() and not args.no_input if args.interactive: prompt_missing(data, force_all=True) @@ -268,7 +348,8 @@ def main(argv: list[str] | None = None) -> int: sys.exit("nfogen: a title is required (use --title, a config, or -i)") text = render(data, group=group, site=site, width=width, style=style, - presents=presents, footer=footer) + presents=presents, footer=footer, layout=layout, + logo=logo, panel_titles=panel_titles) if args.encoding == "cp437": raw = text.encode("cp437", errors="replace") @@ -28,17 +28,20 @@ CONFIG: a .toml or .json auto-loaded from default plus group, site, width. Or use -c PATH. FIELDS (all optional but title): -core: title date type source size files -url notes greets -media: format video audio resolution -language genre runtime +release: cracker supplier packager protection + install rating date source +program: type publisher format disks size files url +media: video audio resolution language genre runtime +text: notes greets +LAYOUT: rows (default) or panel (2-col, --layout) STYLES: single, double, block (--style) ENCODING: utf8 or cp437 (--encoding cp437 = DOS) +LOGO: --logo art.asc places art above the box BANNERS: --presents --footer scene boilerplate -OPTIONS: -o -c -g -s -w --style --encoding - --presents --footer -i --no-input +OPTIONS: -o -c -g -s -w --style --layout --logo + --encoding --presents --footer -i --no-input Generated by nfogen itself. """