Files
colorhash/examples/fullsource.in
2024-05-30 12:04:57 -07:00

758 lines
24 KiB
Plaintext

"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'<svg width="{w * self.square_size}" height="{h * self.square_size}" xmlns="http://www.w3.org/2000/svg">\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' <rect x="{x}" y="{y}" width="{self.square_size}" height="{self.square_size}" fill="{color.to_html_color()}" />\n'
# Close SVG string
svg += "</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)