diff --git a/examples/fullsource-md5-nibble.svg b/examples/fullsource-md5-nibble.svg
index 67ad6e4..a9059b0 100644
--- a/examples/fullsource-md5-nibble.svg
+++ b/examples/fullsource-md5-nibble.svg
@@ -1,34 +1,34 @@
\ No newline at end of file
diff --git a/examples/fullsource-md5-randomart.svg b/examples/fullsource-md5-randomart.svg
index a355de3..985a5a0 100644
--- a/examples/fullsource-md5-randomart.svg
+++ b/examples/fullsource-md5-randomart.svg
@@ -1,44 +1,44 @@
\ No newline at end of file
diff --git a/examples/fullsource-sha1-nibble.svg b/examples/fullsource-sha1-nibble.svg
index 1a626bf..621ebb5 100644
--- a/examples/fullsource-sha1-nibble.svg
+++ b/examples/fullsource-sha1-nibble.svg
@@ -1,42 +1,42 @@
\ No newline at end of file
diff --git a/examples/fullsource-sha1-randomart.svg b/examples/fullsource-sha1-randomart.svg
index 078af03..a890533 100644
--- a/examples/fullsource-sha1-randomart.svg
+++ b/examples/fullsource-sha1-randomart.svg
@@ -1,44 +1,44 @@
\ No newline at end of file
diff --git a/examples/fullsource-sha224-nibble.svg b/examples/fullsource-sha224-nibble.svg
index f5b14bf..60d5e84 100644
--- a/examples/fullsource-sha224-nibble.svg
+++ b/examples/fullsource-sha224-nibble.svg
@@ -1,58 +1,58 @@
\ No newline at end of file
diff --git a/examples/fullsource-sha224-randomart.svg b/examples/fullsource-sha224-randomart.svg
index 7c20434..bc04e9f 100644
--- a/examples/fullsource-sha224-randomart.svg
+++ b/examples/fullsource-sha224-randomart.svg
@@ -1,58 +1,58 @@
\ No newline at end of file
diff --git a/examples/fullsource-sha256-nibble.svg b/examples/fullsource-sha256-nibble.svg
index a17b603..ff458cc 100644
--- a/examples/fullsource-sha256-nibble.svg
+++ b/examples/fullsource-sha256-nibble.svg
@@ -1,66 +1,66 @@
\ No newline at end of file
diff --git a/examples/fullsource-sha256-randomart.svg b/examples/fullsource-sha256-randomart.svg
index d99c4c2..eff6163 100644
--- a/examples/fullsource-sha256-randomart.svg
+++ b/examples/fullsource-sha256-randomart.svg
@@ -1,58 +1,58 @@
\ No newline at end of file
diff --git a/examples/fullsource-sha384-nibble.svg b/examples/fullsource-sha384-nibble.svg
index 117fc2d..b7dc7ff 100644
--- a/examples/fullsource-sha384-nibble.svg
+++ b/examples/fullsource-sha384-nibble.svg
@@ -1,98 +1,98 @@
\ No newline at end of file
diff --git a/examples/fullsource-sha384-randomart.svg b/examples/fullsource-sha384-randomart.svg
index 06a64de..5072200 100644
--- a/examples/fullsource-sha384-randomart.svg
+++ b/examples/fullsource-sha384-randomart.svg
@@ -1,112 +1,112 @@
\ No newline at end of file
diff --git a/examples/fullsource-sha512-nibble.svg b/examples/fullsource-sha512-nibble.svg
index fdb4d1c..10acdcc 100644
--- a/examples/fullsource-sha512-nibble.svg
+++ b/examples/fullsource-sha512-nibble.svg
@@ -1,130 +1,130 @@
\ No newline at end of file
diff --git a/examples/fullsource-sha512-randomart.svg b/examples/fullsource-sha512-randomart.svg
index 5963592..3136ea4 100644
--- a/examples/fullsource-sha512-randomart.svg
+++ b/examples/fullsource-sha512-randomart.svg
@@ -1,112 +1,112 @@
\ No newline at end of file
diff --git a/examples/fullsource.in b/examples/fullsource.in
new file mode 100644
index 0000000..c68e132
--- /dev/null
+++ b/examples/fullsource.in
@@ -0,0 +1,757 @@
+"Generate a graphic based on the hash of an input file."
+from .cli import cli_main
+
+
+if __name__ == '__main__':
+ cli_main()
+"All things that turn a numeric matrix into a colored matrix."
+import abc
+from typing import Sequence
+
+from .color import Color
+from .matricizer import Matrix
+from .palettes import Palette
+
+
+ColorMatrix = Sequence[Sequence[Color]]
+
+
+class Colorizer(metaclass=abc.ABCMeta):
+ """
+ The base Colorizer class.
+
+ A colorizer turns a numeric matrix into a color matrix, colors being represented by strings of
+ HTML colors.
+ """
+
+ @abc.abstractmethod
+ def colorize(self, matrix: Matrix) -> ColorMatrix:
+ """
+ Colorize a matrix.
+
+ :param matrix: the matrix to colorize.
+ :returns: the colorized matrix.
+ """
+
+
+class PaletteColorizer(Colorizer):
+ """
+ A palette colorizer.
+
+ This colorizer will use a palette to colorize its inputs. A palette is 16 colors.
+ """
+ def __init__(self, palette: Palette) -> None:
+ """
+ Create a new palette colorizer for a given palette.
+
+ :param palette: the palette to use for this colorizer.
+ """
+ self.palette = palette
+
+ def colorize(self, matrix: Matrix) -> ColorMatrix:
+ """
+ Colorize the given matrix using this colorizer's palette.
+
+ :param matrix: the matrix to colorize.
+ :returns: the colorized matrix.
+ """
+ return [[self.palette[v] for v in row] for row in matrix]
+"All things that turn a hash into a matrix."
+import abc
+from typing import Mapping, Sequence
+
+from .palettes import Palette, DEFAULT_PALETTES, GRADIENT_PALETTES, MULTICOLOR_PALETTES
+
+
+Matrix = Sequence[Sequence[int]]
+
+
+class Matricizer(metaclass=abc.ABCMeta):
+ """
+ The base Matricizer class.
+
+ A matricizer turns a collection of hash bytes into a matrix of values between 0x0 and 0xf
+ (inclusive). The method by which this is done is up to the matricizer.
+ """
+
+ def __init__(self, w: int, h: int) -> None:
+ """
+ Create a new matricizer for the given dimensions.
+
+ :param w: the width of the output matrix.
+ :param h: the height of hte output matrix.
+ """
+ self.w = w
+ self.h = h
+
+ @abc.abstractmethod
+ def matricize(self, data: bytes) -> Matrix:
+ """
+ Convert a hash to a matrix of given width and height.
+
+ :param data: the hash data to turn into a matrix.
+ :returns: the matrix converted from the hash data.
+ """
+
+ @staticmethod
+ @abc.abstractmethod
+ def choose_dimensions(hash: str) -> tuple[int, int]:
+ """
+ Choose the dimensions for this matrix based on the hash algorithm.
+
+ :param hash: the hash algorithm being used.
+ :returns: a width and height as a tuple.
+ """
+
+ def choose_palette(
+ self, data: bytes, palettes: Mapping[str, Palette] | None = None
+ ) -> Palette:
+ """
+ Choose a palette based on the give data and palettes.
+
+ By default, this method will choose the Nth palette from the sum of the data mod the length
+ of all palettes provided (using all palettes as the default).
+ """
+ if palettes is None:
+ palettes = DEFAULT_PALETTES
+ return list(palettes.values())[sum(data) % len(palettes)]
+
+
+class NibbleMatricizer(Matricizer):
+ """
+ A matricizer that converts a hash based on all of the nibbles in the hash.
+
+ While dimensions are not enforced by this matricizer, it is strongly recommended to use the
+ dimensions provided by the `NibbleMatricizer.DIMENSIONS` member.
+ """
+
+ DIMENSIONS = {
+ "md5": (8, 4),
+ "sha1": (8, 5),
+ "sha224": (8, 7),
+ "sha256": (8, 8),
+ "sha384": (12, 8),
+ "sha512": (16, 8),
+ }
+
+ def matricize(self, data: bytes) -> Matrix:
+ """
+ Convert a set of bytes to a list of rows of nibbles.
+
+ :param data: the hash data to turn into a matrix.
+ :returns: the matrix converted from the hash data.
+ """
+
+ nibbles = []
+ for b in data:
+ top = (b & 0xF0) >> 4
+ bottom = b & 0x0F
+ nibbles += [top, bottom]
+
+ if len(nibbles) != self.w * self.h:
+ raise ValueError(
+ f"input data length ({len(nibbles)}) must match matrix dimensions "
+ f"({self.w}x{self.h} = {self.w * self.h})"
+ )
+
+ cols = []
+ row = []
+ for b in nibbles:
+ row += [b]
+ if len(row) == self.w:
+ cols += [row]
+ row = []
+
+ return cols
+
+ @staticmethod
+ def choose_dimensions(hash: str) -> tuple[int, int]:
+ return NibbleMatricizer.DIMENSIONS[hash]
+
+ def choose_palette(
+ self, data: bytes, palettes: Mapping[str, Palette] | None = None
+ ) -> Palette:
+ return super().choose_palette(data, palettes or GRADIENT_PALETTES)
+
+
+class RandomartMatricizer(Matricizer):
+ """
+ A matricizer that converts hash data into a matrix based on the "randomart" algorithm from
+ ssh-keygen.
+
+ See: https://github.com/openssh/openssh-portable/blob/fc5dc092830de23767c6ef67baa18310a64ee533/sshkey.c#L1014
+ """
+
+ DIMENSIONS = {
+ "md5": (7, 6),
+ "sha1": (7, 6),
+ "sha224": (8, 7),
+ "sha256": (8, 7),
+ "sha384": (11, 10),
+ "sha512": (11, 10),
+ }
+
+ def matricize(self, data: bytes) -> Matrix:
+ """
+ Create a matrix based on the "randomart" algorithm from ssh-keygen.
+
+ The algorithm is as follows:
+
+ 1. Choose the point in the middle of the matrix.
+ 2. Iterate through the data, two bits at a time.
+ 3. If the low bit is set, then move the pointer right. Otherwise, move left.
+ 4. If the high bit is set, then move the pointer down. Otherwise, move up.
+ 5. If stepping in either direction would move us outside of the matrix, then don't move in
+ that direction.
+ 6. At the end of each step, increment the value in the matrix by one.
+
+ :param data: the hash data to turn into a matrix.
+ :returns: the matrix converted from the hash data.
+ """
+
+ rows = [[0] * self.w for _ in range(self.h)]
+ c = self.w // 2
+ r = self.h // 2
+ for value in data:
+ for _ in range(4):
+ if value & 0x1:
+ c += 1
+ else:
+ c -= 1
+ if value & 0x2:
+ r += 1
+ else:
+ r -= 1
+ c = min(max(c, 0), self.w - 1)
+ r = min(max(r, 0), self.h - 1)
+ # max value is 0xf
+ if rows[r][c] < 0xF:
+ rows[r][c] += 1
+ return rows
+
+ @staticmethod
+ def choose_dimensions(hash: str) -> tuple[int, int]:
+ return RandomartMatricizer.DIMENSIONS[hash]
+
+ def choose_palette(
+ self, data: bytes, palettes: Mapping[str, Palette] | None = None
+ ) -> Palette:
+ return super().choose_palette(data, palettes or MULTICOLOR_PALETTES)
+"Base color palette definitions."
+import abc
+from typing import Sequence
+
+from .color import Color, HSLColor
+
+
+class Palette(metaclass=abc.ABCMeta):
+ """
+ A 16-color palette.
+
+ All colors must be a `colorhash.Color`.
+ """
+
+ @abc.abstractmethod
+ def choose(self, color: int) -> Color:
+ """
+ Chooses the given color in this palette.
+ """
+
+ def __getitem__(self, color: int) -> Color:
+ return self.choose(color)
+
+
+class StaticPalette(Palette):
+ """
+ A static color palette with discrete colors.
+ """
+
+ def __init__(self, colors: Sequence[Color]) -> None:
+ """
+ Creates a new static color palette.
+
+ :param colors: the colors for this palette. Must be exactly 16 colors.
+ """
+ if len(colors) != 16:
+ raise ValueError(f"palette must have exactly 16 colors (got {len(colors)})")
+ self.colors = colors
+
+ def choose(self, color: int) -> Color:
+ if not isinstance(color, int):
+ raise KeyError("palette color indices must be an integer")
+ return self.colors[color]
+
+
+HSLRange = range | float | int | list[float | int]
+
+
+def quantize(r: range, steps: int = 16) -> list[float]:
+ """
+ Given a range and a number of steps, create a list of numbers starting and ending in the range
+ (inclusive) with that number of steps.
+
+ :param r: the range to quantize.
+ :param steps: the number of steps to use.
+ :returns: a list of the quantized range.
+ """
+ dist = abs(r.stop - r.start)
+ return [r.start + (i * dist / (steps - 1)) for i in range(steps)]
+
+
+def hsl_colors(hue: HSLRange, sat: HSLRange, light: HSLRange) -> list[HSLColor]:
+ """
+ Utility method to create 16 colors using HSL.
+
+ :param hue: the hue, or range of hues, to use for this palette.
+ :param sat: the saturation, or range of saturations, to use for this palette.
+ :param light: the light value, or range of light values, to use for this palette.
+ """
+
+ if isinstance(hue, (float, int)):
+ hue = [hue] * 16
+ elif isinstance(hue, range):
+ hue = quantize(hue)
+ assert len(hue) == 16, "hue values must be a list of 16 elements"
+
+ if isinstance(sat, (float, int)):
+ sat = [sat] * 16
+ elif isinstance(sat, range):
+ sat = quantize(sat)
+ assert len(sat) == 16, "saturation values must be a list of 16 elements"
+
+ if isinstance(light, (float, int)):
+ light = [light] * 16
+ elif isinstance(light, range):
+ light = quantize(light)
+ assert len(light) == 16, "light values must be a list of 16 elements"
+
+ return [HSLColor(round(h), round(s), round(l)) for h, s, l in zip(hue, sat, light)]
+
+
+GRADIENT_PALETTES = {
+ # Interesting thing with human perception.
+ # Between red and yellow, we can perceive "orange". We have a name for it and see it as a
+ # distinct color. However, between yellow and green, we see a sickly green; between green and
+ # cyan, a seafoam green; between cyan and blue, a lighter blue.
+ #
+ # Beside these, I think that between blue and magenta gives a color you could safely call
+ # "purple", and between magenta and red, you get a color you could safely call "pink". It seems
+ # that reds are more distinct to the human eye.
+ #
+ # For this reason, I have decided to pick these palettes as the "defaults", with a "dark" and
+ # "light" variant of each (lightness 0-50%, and 50-100% respectively), with an additional
+ # fully-saturated "rainbow" palette with all of the colors:
+ #
+ # red, orange, yellow, green, cyan, blue, purple, magenta, pink, gray, rainbow
+ #
+ # Also disabling yellow-light, that one just gives me a headache. It's hard to look at.
+
+ "red-light": StaticPalette(hsl_colors(0, 100, range(50, 100))),
+ "red-dark": StaticPalette(hsl_colors(0, 100, range(0, 50))),
+
+ "orange-light": StaticPalette(hsl_colors(30, 100, range(50, 100))),
+ "orange-dark": StaticPalette(hsl_colors(30, 100, range(0, 50))),
+
+ #"yellow-light": StaticPalette(hsl_colors(60, 100, range(50, 100))),
+ "yellow-dark": StaticPalette(hsl_colors(60, 100, range(0, 50))),
+
+ #"lime-light": StaticPalette(hsl_colors(90, 100, range(50, 100))),
+ #"lime-dark": StaticPalette(hsl_colors(90, 100, range(0, 50))),
+
+ "green-light": StaticPalette(hsl_colors(120, 100, range(50, 100))),
+ "green-dark": StaticPalette(hsl_colors(120, 100, range(0, 50))),
+
+ #"seafoam-light": StaticPalette(hsl_colors(150, 100, range(50, 100))),
+ #"seafoam-dark": StaticPalette(hsl_colors(150, 100, range(0, 50))),
+
+ "cyan-light": StaticPalette(hsl_colors(180, 100, range(50, 100))),
+ "cyan-dark": StaticPalette(hsl_colors(180, 100, range(0, 50))),
+
+ #"teal-light": StaticPalette(hsl_colors(210, 100, range(50, 100))),
+ #"teal-dark": StaticPalette(hsl_colors(210, 100, range(0, 50))),
+
+ "blue-light": StaticPalette(hsl_colors(240, 100, range(50, 100))),
+ "blue-dark": StaticPalette(hsl_colors(240, 100, range(0, 50))),
+
+ "purple-light": StaticPalette(hsl_colors(270, 100, range(50, 100))),
+ "purple-dark": StaticPalette(hsl_colors(270, 100, range(0, 50))),
+
+ "magenta-light": StaticPalette(hsl_colors(300, 100, range(50, 100))),
+ "magenta-dark": StaticPalette(hsl_colors(300, 100, range(0, 50))),
+
+ "pink-light": StaticPalette(hsl_colors(330, 100, range(50, 100))),
+ "pink-dark": StaticPalette(hsl_colors(330, 100, range(0, 50))),
+
+ "gray-light": StaticPalette(hsl_colors(0, 0, range(50, 100))),
+ "gray-dark": StaticPalette(hsl_colors(0, 0, range(0, 50))),
+}
+
+
+MULTICOLOR_PALETTES = {
+ "rainbow": StaticPalette(hsl_colors(range(0, 360), 100, 50)),
+}
+
+DEFAULT_PALETTES = {
+ **GRADIENT_PALETTES, **MULTICOLOR_PALETTES,
+}
+
+
+PALETTES = {**DEFAULT_PALETTES}
+import abc
+import colorsys
+import dataclasses
+
+
+class Color(metaclass=abc.ABCMeta):
+ """
+ An abstract color class.
+
+ This can be used to convert any color format into another color format.
+ """
+
+ def to_html_color(self) -> str:
+ """
+ Convert this color to an HTML color.
+
+ This may produce unexpected results if you are expecting an RGB color. If you are expecting
+ RGB, then you should use `color.to_rgb().to_html_color()` instead.
+ """
+
+ @abc.abstractmethod
+ def to_rgb(self) -> "RGBColor":
+ "Convert this color into an RGB color."
+
+ @abc.abstractmethod
+ def to_hsl(self) -> "HSLColor":
+ "Convert this color into an HSL color."
+
+
+@dataclasses.dataclass
+class RGBColor(Color):
+ """
+ An RGB color. Colors are expected to be a floating point value from [0.0-255.0).
+ """
+ r: float
+ g: float
+ b: float
+
+ def to_html_color(self) -> str:
+ r, g, b = round(self.r), round(self.g), round(self.b)
+ return f"#{r:02x}{g:02x}{b:02x}"
+
+ def to_rgb(self) -> "RGBColor":
+ return self
+
+ def to_hsl(self) -> "HSLColor":
+ r = self.r / 255.0
+ g = self.g / 255.0
+ b = self.b / 255.0
+ h, l, s = colorsys.rgb_to_hls(r, g, b)
+ return HSLColor(h * 360.0, s * 100.0, l * 100.0)
+
+
+@dataclasses.dataclass
+class HSLColor(Color):
+ h: float
+ s: float
+ l: float
+
+ def to_html_color(self) -> str:
+ return f"hsl({self.h:.02f},{self.s:.02f}%,{self.l:.02f}%)"
+
+ def to_rgb(self) -> "RGBColor":
+ h = self.h / 360.0
+ s = self.s / 100.0
+ l = self.l / 100.0
+ r, g, b = colorsys.hls_to_rgb(h, l, s)
+ return RGBColor(r * 255.0, g * 255.0, b * 255.0)
+
+
+ def to_hsl(self) -> "HSLColor":
+ return self
+import abc
+
+from .color import Color
+from .colorizer import ColorMatrix
+
+
+class Writer(metaclass=abc.ABCMeta):
+ """
+ Base writer class.
+
+ This is used to write an input colorized matrix to a string, which is then forwarded to the
+ appropriate output.
+ """
+
+ @abc.abstractmethod
+ def write(self, matrix: ColorMatrix) -> str:
+ "Write the color matrix to a string."
+
+
+class ANSIWriter(Writer):
+ def write(self, matrix: ColorMatrix) -> str:
+ ESC = '\x1b'
+ RESET = f"{ESC}[0m"
+ C = "██"
+ def ansi_color(c: Color) -> str:
+ c = c.to_rgb()
+ return f"{ESC}[38;2;{round(c.r)};{round(c.g)};{round(c.b)}m"
+ out = ''
+ for row in matrix:
+ for col in row:
+ out += ansi_color(col)
+ out += C
+ out += "\n"
+ out += RESET
+ return out
+
+
+class SVGWriter(Writer):
+ def __init__(self, square_size: int) -> None:
+ """
+ Create a new SVG writer that uses the given square size.
+
+ :param square_size: the size of the squares generated, in pixels.
+ """
+ self.square_size = square_size
+
+ def write(self, matrix: ColorMatrix) -> str:
+ """
+ Generate an SVG based on a given matrix.
+
+ :param matrix: the color matrix to generate the SVG for.
+ :returns: the full generated SVG as a string.
+ """
+ h = len(matrix)
+ w = len(matrix[0])
+
+ # Start SVG string
+ svg = f'"
+ return svg
+
+"Main driver for the colorhash program."
+import argparse
+import hashlib
+from pathlib import Path
+import sys
+import textwrap
+
+from .colorizer import PaletteColorizer
+from .matricizer import Matricizer, NibbleMatricizer, RandomartMatricizer
+from .palettes import Palette, DEFAULT_PALETTES, PALETTES
+from .writer import ANSIWriter, SVGWriter
+
+
+# TODO - WASM compile for embedding directly in HTML
+# - this may not be an option, sadly. might have to just port it to JS
+# TODO - option to add a caption based on the filename (for SVG)
+# TODO - load palettes from a file
+# TODO - PNG output
+
+
+def cli_main() -> None:
+ "Main function entrypoint."
+ # pylint: disable=invalid-name
+
+ MATRIX_CHOICES = {
+ "nibble": "Use each nibble (4 bits) of the hash to generate a matrix",
+ "randomart": "Use the SSH 'randomart' algorithm to generate a matrix",
+ }
+ MATRIX_HELP = "MATRIX STRATEGY (-m, --matrix)\n" + "\n".join(
+ f" {choice} - {desc}" for choice, desc in MATRIX_CHOICES.items()
+ )
+ PALETTE_CHOICES = [
+ "auto",
+ ] + list(PALETTES.keys())
+ PALETTE_HELP = "\n".join(
+ [
+ "PALETTE CHOICES",
+ '\n'.join(textwrap.wrap(
+ ", ".join(PALETTE_CHOICES),
+ initial_indent=" ",
+ subsequent_indent=" ",
+ )),
+ ]
+ )
+ HASH_CHOICES = ["md5", "sha1", "sha224", "sha256", "sha384", "sha512"]
+ INPUT_TYPE_CHOICES = {
+ "path": "the input should be treated as a path and data is read from the path",
+ "hash": "the input should be treated as a hexadecimal hash (requires -a or --hash to be supplied)",
+ "data": "the input should be treated as raw data",
+ }
+ INPUT_TYPE_HELP = "INPUT TYPE (-x, --input-type)\n" + "\n".join(
+ [f" {choice} - {desc}" for choice, desc in INPUT_TYPE_CHOICES.items()]
+ )
+ OUTPUT_TYPE_CHOICES = {
+ "ansi": "the output should be colored for ANSI terminals using 24 bit true color",
+ "svg": "the output should be an SVG format",
+ }
+ OUTPUT_TYPE_HELP = "OUTPUT TYPE (-y, --output-type)\n" + "\n".join(
+ [f" {choice} - {desc}" for choice, desc in OUTPUT_TYPE_CHOICES.items()]
+ )
+ EPILOGUE = "\n\n".join([MATRIX_HELP, PALETTE_HELP, INPUT_TYPE_HELP, OUTPUT_TYPE_HELP])
+
+ progname: str = sys.argv[0]
+ if progname.endswith("__main__.py"):
+ progname = "colorhash"
+
+ ap = argparse.ArgumentParser(
+ prog=progname,
+ description="Create a piece of art based on the hash of a file.",
+ epilog=EPILOGUE,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+
+ ap.add_argument(
+ "input",
+ type=str,
+ default="-",
+ help="The input to use. When acting as a path, set to '-' or blank for STDIN. Use -x or --input-type to control how input is treated. default: -",
+ )
+ ap.add_argument(
+ "-o",
+ "--out",
+ metavar="OUTFILE",
+ type=Path,
+ default="-",
+ help="The output file to use. Set to '-' or blank for STDOUT. default: STDOUT",
+ )
+ ap.add_argument(
+ "-m",
+ "--matrix",
+ metavar="MATRIX",
+ choices=MATRIX_CHOICES.keys(),
+ default="nibble",
+ help="Choose the strategy that turns the hash into a matrix. default: nibble",
+ )
+ ap.add_argument(
+ "-p",
+ "--palette",
+ metavar="PALETTE",
+ choices=PALETTE_CHOICES,
+ default="auto",
+ help="Choose the palette. default: auto",
+ )
+ ap.add_argument(
+ "-a", # the "a" is for "algorithm" (since -h is taken)
+ "--hash",
+ metavar="ALGORITHM",
+ choices=HASH_CHOICES,
+ # default="sha512",
+ required=False,
+ help="Choose the hash algorithm. default: sha512",
+ )
+ ap.add_argument(
+ "--svg-square-size",
+ metavar="PX",
+ type=int,
+ default=32,
+ help="For SVG outputs, decide how big the output squares are, in pixels. default: 32",
+ )
+ ap.add_argument(
+ "-x",
+ "--input-type",
+ default="path",
+ choices=INPUT_TYPE_CHOICES.keys(),
+ help="Determines how the input should be treated. default: path",
+ )
+ ap.add_argument(
+ "-y",
+ "--output-type",
+ default="ansi",
+ choices=OUTPUT_TYPE_CHOICES.keys(),
+ help="Determines how the output should be generated. default: ansi",
+ )
+ args = ap.parse_args()
+
+ ############################################################################
+ # End arg parsing
+ ############################################################################
+
+ # -a/--hash arg is not required when we're using file and data input types. only required for
+ # hash input type
+ if args.input_type in ("data", "path") and args.hash is None:
+ args.hash = "sha512"
+
+ # Get the hash
+ match args.input_type:
+ case "path":
+ if args.input == "-":
+ infile = sys.stdin.buffer
+ else:
+ # TODO - pretty error message for when the file doesn't exist
+ infile = open(args.input, "rb")
+ # file_digest (I hope) will not load too much into memory
+ hashdata = hashlib.file_digest(infile, args.hash).digest() # type: ignore
+ # NOTE : previous line has typing ignored because file_digest requires a
+ # "_BytesIOLike | _FileDigestFileObj", both of which look like API leaks. Specifying
+ # infile to be BinaryIO is not enough and causes the same error.
+ case "hash":
+ # TODO - maybe a better error message?
+ if args.hash is None:
+ print(
+ "ERROR: -a or --hash should be supplied on the command line when using the hash input type",
+ file=sys.stderr,
+ )
+ raise SystemExit(1)
+ # TODO - pretty error message for malformed input
+ hashdata = bytes([int(byte, 16) for byte in textwrap.wrap(args.input, 2)])
+ case "data":
+ hashdata = hashlib.new(args.hash, args.input.encode()).digest()
+ case _:
+ assert False, f"unknown input type {args.input_type}"
+
+ # Choose the dimensions and the matricizer
+ matricizer: Matricizer
+ match args.matrix:
+ case "nibble":
+ w, h = NibbleMatricizer.DIMENSIONS[args.hash]
+ matricizer = NibbleMatricizer(w, h)
+ case "randomart":
+ # 17x9 is what openssh uses
+ # TODO - allow configuring dimensions, maybe
+ w, h = RandomartMatricizer.DIMENSIONS[args.hash]
+ matricizer = RandomartMatricizer(w, h)
+ case _:
+ assert False, f"invalid args.matrix: {args.matrix}"
+
+ # Choose the palette
+ palette: Palette
+ if args.palette == "auto":
+ palette = matricizer.choose_palette(hashdata)
+ else:
+ palette = PALETTES[args.palette]
+
+ # Choose the colorizer
+ colorizer = PaletteColorizer(palette)
+
+ # Print SVG
+ matrix = matricizer.matricize(hashdata)
+ colors = colorizer.colorize(matrix)
+
+ # Choose the output writer
+ writer: Writer
+ match args.output_type:
+ case "ansi":
+ writer = ANSIWriter()
+ case "svg":
+ writer = SVGWriter(args.svg_square_size)
+
+ output = writer.write(colors)
+
+ if str(args.out) == "-":
+ sys.stdout.write(output)
+ else:
+ args.out.write_text(output)
+