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

add two-column info panel, scene field aliases, and logo art (tier 2)
 .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(-)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7a60b85
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+__pycache__/
+*.pyc
diff --git a/README.nfo b/README.nfo
index ccebb95..cdc2eb6 100644
--- a/README.nfo
+++ b/README.nfo
@@ -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.                                │
 ├──────────────────────────────────────────────────────────────┤
diff --git a/examples/krazy-toolkit-v3.cp437.nfo b/examples/krazy-toolkit-v3.cp437.nfo
new file mode 100644
index 0000000..5c60103
--- /dev/null
+++ b/examples/krazy-toolkit-v3.cp437.nfo
@@ -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!                    
+                                                                             

diff --git a/examples/krazy-toolkit-v3.nfo b/examples/krazy-toolkit-v3.nfo
new file mode 100644
index 0000000..1ad63a9
--- /dev/null
+++ b/examples/krazy-toolkit-v3.nfo
@@ -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!                    ║
+║                                                                             ║
+╚═════════════════════════════════════════════════════════════════════════════╝
diff --git a/examples/krazy-toolkit-v3.toml b/examples/krazy-toolkit-v3.toml
new file mode 100644
index 0000000..f3bdf2b
--- /dev/null
+++ b/examples/krazy-toolkit-v3.toml
@@ -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"]
diff --git a/examples/krz-logo.asc b/examples/krz-logo.asc
new file mode 100644
index 0000000..c5677a6
--- /dev/null
+++ b/examples/krz-logo.asc
@@ -0,0 +1,5 @@
+ _  _______   ___
+| |/ /  _ \ \ / / |
+| ' /| |_) \ V /| |     k r a z y   w a r e z
+| . \|  _ < | | |_|
+|_|\_\_| \_\|_| (_)     ------- since 1985 -------
diff --git a/examples/the-midnight-cartographer-2026.cp437.nfo b/examples/the-midnight-cartographer-2026.cp437.nfo
index 81ae88f..662a4ee 100644
--- a/examples/the-midnight-cartographer-2026.cp437.nfo
+++ b/examples/the-midnight-cartographer-2026.cp437.nfo
@@ -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)                           
diff --git a/examples/the-midnight-cartographer-2026.nfo b/examples/the-midnight-cartographer-2026.nfo
index 6b9bad8..e1bd78b 100644
--- a/examples/the-midnight-cartographer-2026.nfo
+++ b/examples/the-midnight-cartographer-2026.nfo
@@ -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)                           ║
diff --git a/nfogen.py b/nfogen.py
index 11043f7..c403d2d 100755
--- a/nfogen.py
+++ b/nfogen.py
@@ -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")
diff --git a/nfogen.toml b/nfogen.toml
index bb7d63a..17f35be 100644
--- a/nfogen.toml
+++ b/nfogen.toml
@@ -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.
 """