diff --git a/tools/imperial-typewriter/.gitignore b/tools/imperial-typewriter/.gitignore new file mode 100644 index 0000000..ea1472e --- /dev/null +++ b/tools/imperial-typewriter/.gitignore @@ -0,0 +1 @@ +output/ diff --git a/tools/imperial-typewriter/test-words.csv b/tools/imperial-typewriter/test-words.csv new file mode 100644 index 0000000..8b9ab0c --- /dev/null +++ b/tools/imperial-typewriter/test-words.csv @@ -0,0 +1,6 @@ +# test wordlist for imperial-typewriter — comments and blank lines are skipped +DAFIT +MOIRA +NIMMER +EROS +SOPHROSYNE diff --git a/tools/imperial-typewriter/typewriter.py b/tools/imperial-typewriter/typewriter.py new file mode 100644 index 0000000..a89b272 --- /dev/null +++ b/tools/imperial-typewriter/typewriter.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Imperial typewriter — compose imperial-tongue words from glyph PNGs. + +Reads glyph assets from the closed studio repo; writes composed word PNGs +to an output directory for VLM-decoder training data generation. + +Composition is glyph-cell-tight (no gap between glyphs) — matches the +in-game inscription-typing layout. The imperial machine always screams: +input is auto-uppercased before composition. +""" + +import argparse +import csv +import sys +from pathlib import Path + +from PIL import Image + +DEFAULT_STUDIO_ROOT = "/home/dafit/nimmerverse/studio.nimmerworld.eachpath.local" +DEFAULT_GENERATION = "03" +DEFAULT_RESOLUTION = "512x1024" + +VOWELS = set("AEIOUY") +CONSONANTS = set("BCDFGHJKLMNPQRSTVWXZ") +DIGITS = set("0123456789") + +CATEGORY_INFO = { + "vowel": ("vowels", "vowel"), + "consonant": ("consonant", "consonant"), + "number": ("numbers", "number"), +} + + +def category_for(char: str) -> tuple[str, str]: + """Return (category-dir-name, file-prefix) for a single character.""" + if char in VOWELS: + return CATEGORY_INFO["vowel"] + if char in CONSONANTS: + return CATEGORY_INFO["consonant"] + if char in DIGITS: + return CATEGORY_INFO["number"] + raise ValueError(f"No imperial-tongue glyph for character: {char!r}") + + +def glyph_path( + char: str, + polarity: str, + studio_root: str, + generation: str = DEFAULT_GENERATION, + resolution: str = DEFAULT_RESOLUTION, +) -> Path: + cat_dir, prefix = category_for(char) + suffix = "_neg" if polarity == "negative" else "" + filename = f"{prefix}_{char}_{resolution}{suffix}.png" + return ( + Path(studio_root) + / "imperial-cult" + / "assets" + / "imperial-tongue" + / cat_dir + / generation + / f"{prefix}_{char}" + / filename + ) + + +def compose_word( + word: str, + polarity: str, + studio_root: str, + generation: str = DEFAULT_GENERATION, + resolution: str = DEFAULT_RESOLUTION, +) -> Image.Image: + glyphs = [] + for char in word: + path = glyph_path(char, polarity, studio_root, generation, resolution) + if not path.exists(): + raise FileNotFoundError(f"Missing glyph asset: {path}") + glyphs.append(Image.open(path).convert("RGBA")) + + width = sum(g.width for g in glyphs) + height = max(g.height for g in glyphs) + composed = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + + x = 0 + for g in glyphs: + composed.paste(g, (x, 0), g) + x += g.width + + return composed + + +def words_from_csv(path: Path) -> list[str]: + """Read words from a CSV file. Takes the first column of each non-empty row. + Skips blank rows and rows whose first cell is empty or starts with '#'.""" + words = [] + with open(path, newline="") as f: + reader = csv.reader(f) + for row in reader: + if not row: + continue + cell = row[0].strip() + if not cell or cell.startswith("#"): + continue + words.append(cell) + return words + + +def render_one( + word: str, + output_dir: Path, + polarities: list[str], + studio_root: str, + generation: str, + resolution: str, +) -> None: + word = word.upper() + for pol in polarities: + img = compose_word(word, pol, studio_root, generation, resolution) + suffix = "_neg" if pol == "negative" else "" + out_path = output_dir / f"{word}{suffix}.png" + img.save(out_path) + print(f"wrote {out_path} ({img.width}x{img.height})") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Imperial typewriter — compose imperial-tongue words from glyph PNGs.", + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument( + "word", + nargs="?", + help="A single Latin word to compose. Auto-uppercased (imperial machine always screams).", + ) + source.add_argument( + "--csv", + type=Path, + help="Path to a CSV/wordlist file. First column of each non-empty, non-'#'-prefixed row is used as a word.", + ) + parser.add_argument( + "--output", + type=Path, + default=Path("./output"), + help="Output directory (created if missing). Default: ./output", + ) + parser.add_argument( + "--polarity", + choices=["positive", "negative", "both"], + default="both", + help="Which polarity to render. Default: both", + ) + parser.add_argument( + "--studio-root", + default=DEFAULT_STUDIO_ROOT, + help=f"Path to studio repo root. Default: {DEFAULT_STUDIO_ROOT}", + ) + parser.add_argument( + "--generation", + default=DEFAULT_GENERATION, + help=f"Resolution generation subdir. Default: {DEFAULT_GENERATION}", + ) + parser.add_argument( + "--resolution", + default=DEFAULT_RESOLUTION, + help=f"Glyph resolution. Default: {DEFAULT_RESOLUTION}", + ) + + args = parser.parse_args() + + args.output.mkdir(parents=True, exist_ok=True) + polarities = ["positive", "negative"] if args.polarity == "both" else [args.polarity] + + if args.csv: + words = words_from_csv(args.csv) + if not words: + print(f"no words found in {args.csv}", file=sys.stderr) + sys.exit(1) + print(f"composing {len(words)} word(s) from {args.csv}") + for word in words: + try: + render_one(word, args.output, polarities, args.studio_root, args.generation, args.resolution) + except (ValueError, FileNotFoundError) as e: + print(f" skip {word!r}: {e}", file=sys.stderr) + else: + render_one(args.word, args.output, polarities, args.studio_root, args.generation, args.resolution) + + +if __name__ == "__main__": + main()