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'\n' + + # Generate grid + for r in range(h): + for c in range(w): + x = c * self.square_size + y = r * self.square_size + color = matrix[r][c] + svg += f' \n' + + # Close SVG string + svg += "" + 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) +