526 lines
17 KiB
Python
Executable File
526 lines
17 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Visual Cryptograpy Experiment.
|
|
|
|
Create two paper cards (credit card size) which reveal the plaintext message when put over each other.
|
|
"""
|
|
|
|
import argparse
|
|
import secrets
|
|
import svgwrite
|
|
from svgwrite import cm, mm
|
|
from bdfparser import Font
|
|
import sys
|
|
import string
|
|
import math
|
|
|
|
import xml.etree.ElementTree as ET
|
|
|
|
STROKE_WIDTH = 0.2
|
|
CARD_WIDTH = 85.6
|
|
CARD_HEIGHT = 53.98
|
|
PIXEL_WIDTH = 3.6
|
|
SPACE_X = 0.4
|
|
SPACE_Y = 0.4
|
|
|
|
|
|
tree = ET.parse("7segment.svg")
|
|
root = tree.getroot()
|
|
|
|
SEVEN_SEGMENT_PATH_DATA = {}
|
|
|
|
|
|
for child in root:
|
|
if child.tag.endswith("path"):
|
|
SEVEN_SEGMENT_PATH_DATA[child.attrib["id"]] = child.attrib["d"]
|
|
|
|
tree = ET.parse("3x3.svg")
|
|
root = tree.getroot()
|
|
|
|
THREE_GLYPH_DATA = {}
|
|
|
|
for child in root.iter():
|
|
if child.tag.endswith("path"):
|
|
THREE_GLYPH_DATA[child.attrib["id"]] = child.attrib["d"]
|
|
|
|
tree = ET.parse("hexagons.svg")
|
|
root = tree.getroot()
|
|
|
|
HEXAGONS_PATH_DATA = {}
|
|
|
|
|
|
for child in root.iter():
|
|
if child.tag.endswith("path"):
|
|
HEXAGONS_PATH_DATA[child.attrib["id"]] = child.attrib["d"]
|
|
|
|
|
|
# https://en.wikipedia.org/wiki/Seven-segment_display
|
|
SEVEN_SEGMENT_DIGITS = {
|
|
"0": "abcdef",
|
|
"1": "bc",
|
|
"2": "abdeg",
|
|
"3": "abcdg",
|
|
"4": "bcfg",
|
|
"5": "acdfg",
|
|
"6": "acdefg",
|
|
"7": "abc",
|
|
"8": "abcdefg",
|
|
"9": "abcdfg",
|
|
"A": "abcefg",
|
|
"B": "cdefg",
|
|
"C": "adef",
|
|
"D": "bcdeg",
|
|
"E": "adefg",
|
|
"F": "aefg",
|
|
}
|
|
|
|
|
|
def read_pbm(fn):
|
|
data = ""
|
|
width = 0
|
|
height = 0
|
|
with open(fn) as fd:
|
|
for i, line in enumerate(fd):
|
|
if i == 2:
|
|
width, height = map(int, line.strip().split(None))
|
|
elif i > 2:
|
|
data += line.strip()
|
|
assert len(data) == width * height
|
|
result = {}
|
|
for i, b in enumerate(data):
|
|
y = i // width
|
|
x = i % width
|
|
result[(x, y)] = b == "1"
|
|
return result
|
|
|
|
|
|
HALF_CIRCLE_PATH_DATA = "M 4.553619e-8,-1.7000001 C -0.93213296,-1.7000001 -1.6999283,-0.94460233 -1.6999283,-0.02752037 a 0.56669943,0.557549 0 1 0 1.13328557,0 c 0,-0.31449868 0.24698263,-0.5574932 0.56664277553619,-0.5574932 C 0.31965975,-0.58501357 0.56664283,-0.34201905 0.56664283,-0.02752037 a 0.56669943,0.557549 0 1 0 1.13328567,0 C 1.6999285,-0.94460233 0.93213245,-1.7000001 4.553619e-8,-1.7000001"
|
|
CIRCLE_PATH_DATA = "M 1.5454038e-4,-1.7003093 A 1.7,1.7 0 0 0 -0.97032865,-1.3959351 L -0.585856,-1.0114625 A 1.1699999,1.1699999 0 0 1 1.5454038e-4,-1.1701091 1.1699999,1.1699999 0 0 1 1.1701092,-1.5479865e-4 1.1699999,1.1699999 0 0 1 1.0114624,0.585856 L 1.3943849,0.96877839 A 1.7,1.7 0 0 0 1.7003093,-1.5479865e-4 1.7,1.7 0 0 0 1.5454038e-4,-1.7003093 Z M -1.3930418,-0.970638 A 1.7,1.7 0 0 0 -1.7,-1.5479865e-4 1.7,1.7 0 0 0 1.5454038e-4,1.7 1.7,1.7 0 0 0 0.97115472,1.395109 L 0.5871988,1.0101196 A 1.1699999,1.1699999 0 0 1 1.5454038e-4,1.1697998 1.1699999,1.1699999 0 0 1 -1.1697996,-1.5479865e-4 1.1699999,1.1699999 0 0 1 -1.0101196,-0.5877158 Z"
|
|
|
|
|
|
DEGREES = {}
|
|
|
|
|
|
def get_random_degree(x, y):
|
|
degree = DEGREES.get((x, y))
|
|
if degree is None:
|
|
degree = secrets.choice(range(360))
|
|
DEGREES[(x, y)] = degree
|
|
return degree
|
|
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("output")
|
|
parser.add_argument("--text")
|
|
parser.add_argument("--stroke-width", type=float, default=STROKE_WIDTH)
|
|
parser.add_argument("--pixel-width", type=float, default=PIXEL_WIDTH)
|
|
parser.add_argument("--space-x", type=float, default=SPACE_X)
|
|
parser.add_argument("--space-y", type=float, default=SPACE_Y)
|
|
parser.add_argument("--random-data", action="store_true")
|
|
parser.add_argument("--random-chars", default=string.printable)
|
|
parser.add_argument("--random-orientation", action="store_true")
|
|
parser.add_argument("--circular", action="store_true")
|
|
parser.add_argument("--bitmap")
|
|
parser.add_argument("--width", type=int, default=19)
|
|
parser.add_argument("--height", type=int, default=11)
|
|
parser.add_argument("--skip-rows", type=int, default=0)
|
|
parser.add_argument("--skip-cols", type=int, default=0)
|
|
parser.add_argument("--card-width", type=float, default=CARD_WIDTH)
|
|
parser.add_argument("--card-height", type=float, default=CARD_HEIGHT)
|
|
parser.add_argument("--shape", default="triangle")
|
|
parser.add_argument("--font", default="tom-thumb.bdf")
|
|
parser.add_argument("--fill-data-bits", action="store_true")
|
|
parser.add_argument("--only-data-bits", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
text = args.text
|
|
|
|
if not text:
|
|
chars = ""
|
|
font = Font(args.font)
|
|
for glyph in font.iterglyphs():
|
|
char = glyph.chr()
|
|
if char in args.random_chars:
|
|
chars += glyph.chr()
|
|
print(f"Supported font chars ({len(chars)}): {chars}")
|
|
text = ""
|
|
for i in range(16):
|
|
text += secrets.choice(chars)
|
|
|
|
|
|
PIXELS_X = args.width
|
|
PIXELS_Y = args.height
|
|
PIXEL_WIDTH = args.pixel_width
|
|
STROKE_WIDTH = args.stroke_width
|
|
SPACE_X = args.space_x
|
|
SPACE_Y = args.space_y
|
|
CARD_WIDTH = args.card_width
|
|
CARD_HEIGHT = args.card_height
|
|
|
|
data = {}
|
|
|
|
if args.bitmap:
|
|
result = read_pbm(args.bitmap)
|
|
data.update(result)
|
|
elif args.random_data:
|
|
for x in range(PIXELS_X):
|
|
for y in range(PIXELS_Y):
|
|
data[(x, y)] = secrets.choice([True, False])
|
|
|
|
elif args.shape == "7segment":
|
|
x = y = 0
|
|
for i, char in enumerate(text):
|
|
for _y in range(4):
|
|
for _x in range(2):
|
|
index = _y * 2 + _x
|
|
if index <= 7:
|
|
segment = "abcdefgh"[index]
|
|
if segment in SEVEN_SEGMENT_DIGITS.get(char, "") or (
|
|
segment == "h" and i % 3 == 2
|
|
):
|
|
data[(x + _x, y + _y)] = True
|
|
x += 2
|
|
if x >= PIXELS_X:
|
|
x = 0
|
|
y += 4
|
|
|
|
else:
|
|
# from https://robey.lag.net/2010/01/23/tiny-monospace-font.html
|
|
font = Font(args.font)
|
|
DATA = str(font.draw(text, linelimit=PIXELS_X + 1))
|
|
|
|
for y, line in enumerate(DATA.strip().splitlines()):
|
|
for x, char in enumerate(line):
|
|
if char == "#":
|
|
data[(x, y)] = True
|
|
|
|
for y in range(PIXELS_Y):
|
|
line = ""
|
|
for x in range(PIXELS_X):
|
|
if args.skip_rows and (y + 1) % args.skip_rows == 0:
|
|
line += " "
|
|
elif args.skip_cols and (x + 1) % args.skip_cols == 0:
|
|
line += " "
|
|
else:
|
|
line += "#" if data.get((x, y)) else "."
|
|
print(line)
|
|
|
|
|
|
dwg = svgwrite.Drawing(
|
|
args.output, ("210mm", "297mm"), profile="tiny", viewBox="0 0 210 297"
|
|
)
|
|
|
|
|
|
def draw_pixel(x, y, bit, orientation, offsetx=0, offsety=0, fill=False):
|
|
if args.circular:
|
|
position = (
|
|
offsetx + math.sin(2 * math.pi / PIXELS_X * x)*((y+2)*(PIXEL_WIDTH + SPACE_Y)*math.sqrt(2)),
|
|
offsety + math.cos(2 * math.pi / PIXELS_X * x)*((y+2)*(PIXEL_WIDTH + SPACE_Y)*math.sqrt(2))
|
|
)
|
|
else:
|
|
position = (
|
|
STROKE_WIDTH / 2 + x * (PIXEL_WIDTH + SPACE_X) + offsetx,
|
|
STROKE_WIDTH / 2 + y * (PIXEL_WIDTH + SPACE_Y) + offsety,
|
|
)
|
|
|
|
if fill:
|
|
fill = "red"
|
|
else:
|
|
fill = "none"
|
|
|
|
if args.shape == "rect":
|
|
size = (PIXEL_WIDTH / 2 - STROKE_WIDTH, PIXEL_WIDTH - STROKE_WIDTH)
|
|
if orientation:
|
|
size = (PIXEL_WIDTH - STROKE_WIDTH, PIXEL_WIDTH / 2 - STROKE_WIDTH)
|
|
if bit == 1:
|
|
position = (position[0], position[1] + PIXEL_WIDTH / 2)
|
|
elif bit == 1:
|
|
position = (position[0] + PIXEL_WIDTH / 2, position[1])
|
|
|
|
#
|
|
pl = dwg.rect(
|
|
position,
|
|
size,
|
|
rx=0.1,
|
|
ry=0.1,
|
|
stroke="black",
|
|
stroke_width=STROKE_WIDTH,
|
|
fill=fill,
|
|
)
|
|
elif args.shape == "box":
|
|
size = (PIXEL_WIDTH / 2 - STROKE_WIDTH, PIXEL_WIDTH / 2 - STROKE_WIDTH)
|
|
pl = dwg.g()
|
|
if bit == 1:
|
|
r1 = dwg.rect(
|
|
position,
|
|
size,
|
|
rx=0.2,
|
|
ry=0.2,
|
|
stroke="black",
|
|
stroke_width=STROKE_WIDTH,
|
|
fill=fill,
|
|
)
|
|
r2 = dwg.rect(
|
|
(
|
|
position[0] + size[0] + STROKE_WIDTH,
|
|
position[1] + size[1] + STROKE_WIDTH,
|
|
),
|
|
size,
|
|
rx=0.2,
|
|
ry=0.2,
|
|
stroke="black",
|
|
stroke_width=STROKE_WIDTH,
|
|
fill=fill,
|
|
)
|
|
else:
|
|
r1 = dwg.rect(
|
|
(position[0] + size[0] + STROKE_WIDTH, position[1]),
|
|
size,
|
|
rx=0.2,
|
|
ry=0.2,
|
|
stroke="black",
|
|
stroke_width=STROKE_WIDTH,
|
|
fill=fill,
|
|
)
|
|
r2 = dwg.rect(
|
|
(position[0], position[1] + size[1] + STROKE_WIDTH),
|
|
size,
|
|
rx=0.2,
|
|
ry=0.2,
|
|
stroke="black",
|
|
stroke_width=STROKE_WIDTH,
|
|
fill=fill,
|
|
)
|
|
pl.add(r1)
|
|
pl.add(r2)
|
|
|
|
elif args.shape == "3dots":
|
|
pl = dwg.g()
|
|
for i in range(6):
|
|
deg = math.pi * 2 * i / 6 + get_random_degree(x, y)
|
|
if bit == 1 and i % 2 == 0 or bit == 0 and i % 2 == 1:
|
|
p = dwg.circle(
|
|
(1.3 * math.sin(deg), 1.3 * math.cos(deg)),
|
|
0.6,
|
|
stroke_width=STROKE_WIDTH,
|
|
fill=fill,
|
|
stroke="black",
|
|
)
|
|
pl.add(p)
|
|
# we need to add half a pixel width as the path is centered on 0x0
|
|
pl.translate(position[0] + (PIXEL_WIDTH / 2), position[1] + (PIXEL_WIDTH / 2))
|
|
elif args.shape == "circle":
|
|
if bit == 1:
|
|
p = dwg.circle(
|
|
(0, 0),
|
|
1.27 - (STROKE_WIDTH / 2),
|
|
stroke_width=STROKE_WIDTH,
|
|
fill=fill,
|
|
stroke="black",
|
|
)
|
|
else:
|
|
p = dwg.path(
|
|
CIRCLE_PATH_DATA,
|
|
stroke="black",
|
|
stroke_width=STROKE_WIDTH,
|
|
fill=fill,
|
|
)
|
|
degree = get_random_degree(x, y)
|
|
p.rotate(degree)
|
|
pl = dwg.g()
|
|
# we need to add half a pixel width as the path is centered on 0x0
|
|
pl.translate(position[0] + (PIXEL_WIDTH / 2), position[1] + (PIXEL_WIDTH / 2))
|
|
pl.add(p)
|
|
elif args.shape == "half-circle":
|
|
p = dwg.path(
|
|
HALF_CIRCLE_PATH_DATA,
|
|
stroke="black",
|
|
stroke_width=STROKE_WIDTH,
|
|
fill=fill,
|
|
)
|
|
degree = get_random_degree(x, y)
|
|
if bit == 1:
|
|
degree += 180
|
|
p.rotate(degree) #:, (PIXEL_WIDTH/2, PIXEL_WIDTH/2))
|
|
pl = dwg.g()
|
|
if y % 2 == 1:
|
|
pl.translate(position[0] + ((PIXEL_WIDTH + SPACE_X) / 2), position[1])
|
|
else:
|
|
pl.translate(position[0], position[1])
|
|
pl.add(p)
|
|
# c = dwg.circle((0, 0), PIXEL_WIDTH/2, stroke_width=STROKE_WIDTH, fill=fill, stroke="green")
|
|
# pl.add(c)
|
|
elif args.shape == "3triangles":
|
|
pl = dwg.g()
|
|
for i in range(6):
|
|
onesixth = math.pi * 2 / 6.0
|
|
deg = onesixth * i + get_random_degree(x, y)
|
|
sep = 0.059
|
|
d = f"M {0.2*math.sin(deg+onesixth/2)} {0.2*math.cos(deg+onesixth/2)} {1.7*math.sin(deg+sep)} {1.7*math.cos(deg+sep)} {1.7*math.sin(deg+onesixth-sep)} {1.7*math.cos(deg+onesixth-sep)} Z"
|
|
if bit == 1 and i % 2 == 0 or bit == 0 and i % 2 == 1:
|
|
p = dwg.path(d, stroke="black", stroke_width=STROKE_WIDTH, fill=fill)
|
|
pl.add(p)
|
|
# we need to add half a pixel width as the path is centered on 0x0
|
|
pl.translate(position[0] + (PIXEL_WIDTH / 2), position[1] + (PIXEL_WIDTH / 2))
|
|
elif args.shape == "7segment":
|
|
pl = dwg.g()
|
|
# 2x4 pixels are one digit
|
|
_y = y % 4
|
|
_x = x % 2
|
|
index = _y * 2 + _x
|
|
if index <= 7:
|
|
segment = "abcdefgh"[index]
|
|
segment += "1" if bit == 1 else "2"
|
|
p = dwg.path(
|
|
SEVEN_SEGMENT_PATH_DATA[segment],
|
|
stroke="black",
|
|
stroke_width=STROKE_WIDTH,
|
|
fill=fill,
|
|
)
|
|
pl.add(p)
|
|
|
|
position = (
|
|
STROKE_WIDTH / 2 + (x - _x) * (PIXEL_WIDTH + SPACE_X) + offsetx,
|
|
STROKE_WIDTH / 2 + (y - _y) * (PIXEL_WIDTH + SPACE_Y) + offsety,
|
|
)
|
|
# r = dwg.rect((0,0),(8,12), stroke="green", stroke_width=STROKE_WIDTH, fill=fill)
|
|
# pl.add(r)
|
|
pl.translate(position[0], position[1])
|
|
elif args.shape == "hexagons":
|
|
pl = dwg.g()
|
|
# 2x2 pixels are one unit
|
|
_y = y % 2
|
|
_x = x % 2
|
|
index = _y * 2 + _x
|
|
hexno = index + 1
|
|
if bit == 1:
|
|
hexno += 1
|
|
if hexno > 4:
|
|
hexno = 1
|
|
p = dwg.path(
|
|
HEXAGONS_PATH_DATA[f"hex{hexno}"],
|
|
stroke="black",
|
|
stroke_width=STROKE_WIDTH,
|
|
fill=fill,
|
|
)
|
|
pl.add(p)
|
|
|
|
position = (
|
|
STROKE_WIDTH / 2 + (x - _x) * (PIXEL_WIDTH + SPACE_X) + offsetx,
|
|
STROKE_WIDTH / 2 + (y - _y) * (PIXEL_WIDTH + SPACE_Y) + offsety,
|
|
)
|
|
pl.translate(position[0], position[1])
|
|
elif args.shape == "3x3":
|
|
pl = dwg.g()
|
|
# 3x3 pixels are one glyph (+1 distance)
|
|
_y = y % 4
|
|
_x = x % 4
|
|
index = _y * 4 + _x
|
|
segment = "r"
|
|
segment += "012 345 678 ___ "[index]
|
|
segment += "a" if bit == 1 else "b"
|
|
if segment in THREE_GLYPH_DATA:
|
|
p = dwg.path(
|
|
THREE_GLYPH_DATA[segment],
|
|
stroke="black",
|
|
stroke_width=STROKE_WIDTH,
|
|
fill=fill,
|
|
)
|
|
pl.add(p)
|
|
|
|
position = (
|
|
STROKE_WIDTH / 2 + (x - _x) * (PIXEL_WIDTH + SPACE_X) + offsetx,
|
|
STROKE_WIDTH / 2 + (y - _y) * (PIXEL_WIDTH + SPACE_Y) + offsety,
|
|
)
|
|
# r = dwg.rect((0,0),(8,12), stroke="green", stroke_width=STROKE_WIDTH, fill=fill)
|
|
# pl.add(r)
|
|
pl.translate(position[0], position[1])
|
|
elif args.shape == "triangle":
|
|
size = (PIXEL_WIDTH - STROKE_WIDTH, PIXEL_WIDTH - STROKE_WIDTH)
|
|
# offset to not have the stroke overlap (would be a visible cut when putting cards over each other)
|
|
hsw = STROKE_WIDTH * 0.75
|
|
if orientation:
|
|
if bit == 1:
|
|
# top right
|
|
d = f"M {position[0]+hsw} {position[1]} {position[0]+size[0]} {position[1]} {position[0]+size[0]} {position[1]+size[1]-hsw} Z"
|
|
else:
|
|
# bottom left
|
|
d = f"M {position[0]} {position[1]+hsw} {position[0]} {position[1]+size[1]} {position[0]+size[0]-hsw} {position[1]+size[1]} Z"
|
|
else:
|
|
if bit == 1:
|
|
# top left
|
|
d = f"M {position[0]} {position[1]} {position[0]+size[0]-hsw} {position[1]} {position[0]} {position[1]+size[1]-hsw} Z"
|
|
else:
|
|
# bottom right
|
|
d = f"M {position[0]+hsw} {position[1]+size[1]} {position[0]+size[0]} {position[1]+hsw} {position[0]+size[0]} {position[1]+size[1]} Z"
|
|
pl = dwg.path(d, stroke="black", stroke_width=STROKE_WIDTH, fill=fill)
|
|
else:
|
|
raise Exception("Shape not supported")
|
|
return pl
|
|
|
|
|
|
if args.circular:
|
|
left = (args.card_width +STROKE_WIDTH)/2
|
|
top = (args.card_height +STROKE_WIDTH)/2
|
|
else:
|
|
left = (args.card_width + STROKE_WIDTH - (PIXELS_X * (PIXEL_WIDTH + SPACE_X))) / 2
|
|
top = (args.card_height + STROKE_WIDTH - (PIXELS_Y * (PIXEL_WIDTH + SPACE_Y))) / 2
|
|
|
|
card_offset = ((args.card_height + 10) // 10) * 10
|
|
|
|
for x in range(PIXELS_X):
|
|
for y in range(PIXELS_Y):
|
|
|
|
if args.skip_rows and (y + 1) % args.skip_rows == 0:
|
|
continue
|
|
|
|
if args.skip_cols and (x + 1) % args.skip_cols == 0:
|
|
continue
|
|
|
|
bit = secrets.choice([0, 1])
|
|
if args.random_orientation:
|
|
orientation = secrets.choice([False, True])
|
|
else:
|
|
orientation = x % 2 == y % 2
|
|
|
|
pl = draw_pixel(x, y, bit, orientation, offsetx=left, offsety=top)
|
|
dwg.add(pl)
|
|
|
|
fill = False
|
|
if data.get((x, y)):
|
|
# do not invert, hole is going through both cards
|
|
cipher_bit = bit
|
|
if args.fill_data_bits:
|
|
fill = True
|
|
else:
|
|
# invert so holes don't overlap
|
|
cipher_bit = 1 - bit
|
|
|
|
if not args.only_data_bits or cipher_bit == bit:
|
|
pl = draw_pixel(
|
|
x,
|
|
y,
|
|
cipher_bit,
|
|
orientation,
|
|
offsetx=left,
|
|
offsety=card_offset + top,
|
|
fill=fill,
|
|
)
|
|
dwg.add(pl)
|
|
|
|
# draw the two card borders
|
|
for i in range(2):
|
|
y = 0 if i == 0 else card_offset
|
|
card = dwg.rect(
|
|
(0, y),
|
|
(args.card_width + STROKE_WIDTH, args.card_height + STROKE_WIDTH),
|
|
rx=3.4,
|
|
ry=3.4,
|
|
stroke="red",
|
|
stroke_width=STROKE_WIDTH,
|
|
fill="none",
|
|
)
|
|
dwg.add(card)
|
|
|
|
dwg.save()
|