laser-cut-templates/visual-cryptography/render.py

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()