#!/usr/bin/env python
import os
import sys
import json
def usage():
return '''
constructs adjacency_graphs.coffee from QWERTY and DVORAK keyboard layouts
usage:
%s adjacency_graphs.coffee
''' % sys.argv[0]
qwerty = r'''
`~ 1! 2@ 3# 4$ 5% 6^ 7& 8* 9( 0) -_ =+
qQ wW eE rR tT yY uU iI oO pP [{ ]} \|
aA sS dD fF gG hH jJ kK lL ;: '"
zZ xX cC vV bB nN mM ,< .> /?
'''
dvorak = r'''
`~ 1! 2@ 3# 4$ 5% 6^ 7& 8* 9( 0) [{ ]}
'" ,< .> pP yY fF gG cC rR lL /? =+ \|
aA oO eE uU iI dD hH tT nN sS -_
;: qQ jJ kK xX bB mM wW vV zZ
'''
keypad = r'''
/ * -
7 8 9 +
4 5 6
1 2 3
0 .
'''
mac_keypad = r'''
= / *
7 8 9 -
4 5 6 +
1 2 3
0 .
'''
def get_slanted_adjacent_coords(x, y):
'''
returns the six adjacent coordinates on a standard keyboard, where each row is slanted to the
right from the last. adjacencies are clockwise, starting with key to the left, then two keys
above, then right key, then two keys below. (that is, only near-diagonal keys are adjacent,
so g's coordinate is adjacent to those of t,y,b,v, but not those of r,u,n,c.)
'''
return [(x-1, y), (x, y-1), (x+1, y-1), (x+1, y), (x, y+1), (x-1, y+1)]
def get_aligned_adjacent_coords(x, y):
'''
returns the nine clockwise adjacent coordinates on a keypad, where each row is vert aligned.
'''
return [(x-1, y), (x-1, y-1), (x, y-1), (x+1, y-1), (x+1, y), (x+1, y+1), (x, y+1), (x-1, y+1)]
def build_graph(layout_str, slanted):
'''
builds an adjacency graph as a dictionary: {character: [adjacent_characters]}.
adjacent characters occur in a clockwise order.
for example:
* on qwerty layout, 'g' maps to ['fF', 'tT', 'yY', 'hH', 'bB', 'vV']
* on keypad layout, '7' maps to [None, None, None, '=', '8', '5', '4', None]
'''
position_table = {} # maps from tuple (x,y) -> characters at that position.
tokens = layout_str.split()
token_size = len(tokens[0])
x_unit = token_size + 1 # x position unit len is token len plus 1 for the following whitespace.
adjacency_func = get_slanted_adjacent_coords if slanted else get_aligned_adjacent_coords
assert all(len(token) == token_size for token in tokens), 'token len mismatch:\n ' + layout_str
for y, line in enumerate(layout_str.split('\n')):
# the way I illustrated keys above, each qwerty row is indented one space in from the last
slant = y - 1 if slanted else 0
for token in line.split():
x, remainder = divmod(line.index(token) - slant, x_unit)
assert remainder == 0, 'unexpected x offset for %s in:\n%s' % (token, layout_str)
position_table[(x,y)] = token
adjacency_graph = {}
for (x,y), chars in position_table.items():
for char in chars:
adjacency_graph[char] = []
for coord in adjacency_func(x, y):
# position in the list indicates direction
# (for qwerty, 0 is left, 1 is top, 2 is top right, ...)
# for edge chars like 1 or m, insert None as a placeholder when needed
# so that each character in the graph has a same-length adjacency list.
adjacency_graph[char].append(position_table.get(coord, None))
return adjacency_graph
# on qwerty, 'g' has degree 6, being adjacent to 'ftyhbv'. '\' has degree 1.
# this calculates the average over all keys.
def calc_average_deg_and_len(layout_str, slanted):
graph = build_graph(layout_str, slanted)
count = sum(a is not None for adj in graph.values() for a in adj)
length = len(graph)
return float(count) / length, length
GRAPHS = [('qwerty', (qwerty, True)),
('dvorak', (dvorak, True)),
('keypad', (keypad, False)),
('mac_keypad', (mac_keypad, False))]
def output_coffee(path):
with open(path, 'w') as f:
f.write('# generated by scripts/build_keyboard_adjacency_graphs.py\n')
f.write('adjacency_graphs = \n ')
lines = []
for graph_name, args in GRAPHS:
graph = build_graph(*args)
lines.append('%s: %s' % (graph_name, json.dumps(graph, sort_keys=True)))
f.write('\n '.join(lines))
f.write('\n\n')
f.write('module.exports = adjacency_graphs\n')
def escape(x):
return x.replace("\\", "\\\\").replace("\"", "\\\"")
def output_hpp(hpp_file):
with open(hpp_file, 'w') as f:
f.write('// generated by scripts/build_keyboard_adjacency_graphs.py\n')
tags = ',\n '.join(k.upper() for (k, _) in GRAPHS)
f.write("""#ifndef __ZXCVBN__ADJACENCY_GRAPHS_HPP
#define __ZXCVBN__ADJACENCY_GRAPHS_HPP
#include <zxcvbn/optional.hpp>
#include <array>
#include <initializer_list>
#include <string>
#include <string_view>
#include <unordered_map>
#include <utility>
#include <vector>
namespace zxcvbn {
enum class GraphTag {
%s
};
}
namespace std {
template<>
struct hash<zxcvbn::GraphTag> {
std::size_t operator()(const zxcvbn::GraphTag & v) const {
return static_cast<std::size_t>(v);
}
};
}
namespace zxcvbn {
using Graph = std::unordered_map<std::string_view, std::vector<std::string_view>>;
using Graphs = std::unordered_map<GraphTag, Graph>;
const Graphs & graphs();
using degree_t = double;
extern const degree_t KEYBOARD_AVERAGE_DEGREE;
extern const degree_t KEYPAD_AVERAGE_DEGREE;
extern const std::size_t KEYBOARD_STARTING_POSITIONS;
extern const std::size_t KEYPAD_STARTING_POSITIONS;
}
#endif
""" % (tags,))
def output_cpp(cpp_file):
with open(cpp_file, 'w') as f:
f.write("""// generated by scripts/build_keyboard_adjacency_graphs.py
#include <zxcvbn/adjacency_graphs.hpp>
#include <array>
#include <initializer_list>
#include <utility>
#include "base/no_destructor.h"
""")
qwerty_avg_deg, qwerty_len = calc_average_deg_and_len(qwerty, True)
keypad_avg_deg, keypad_len = calc_average_deg_and_len(keypad, False)
f.write("""namespace zxcvbn {
extern constexpr degree_t KEYBOARD_AVERAGE_DEGREE = %f;
// slightly different for keypad/mac keypad, but close enough
extern constexpr degree_t KEYPAD_AVERAGE_DEGREE = %f;
extern constexpr std::size_t KEYBOARD_STARTING_POSITIONS = %d;
extern constexpr std::size_t KEYPAD_STARTING_POSITIONS = %d;
""" % (qwerty_avg_deg, keypad_avg_deg, qwerty_len, keypad_len))
f.write("const Graphs& graphs() {\n")
f.write(" static base::NoDestructor<Graphs> graphs({\n")
for (name, args2) in GRAPHS:
graph = build_graph(*args2)
f.write(" {GraphTag::%s, {\n" % (name.upper(),));
for key, adj in sorted(graph.items()):
f.write(' {"%s", {%s}},\n' %
(escape(key), ', '.join('"' + escape(a) + '"'
if a else
'{}'
for a in adj)))
f.write(" }},\n")
f.write(" });\n\n")
f.write(" return *graphs;\n")
f.write("}\n\n")
f.write("}\n")
if __name__ == '__main__':
if len(sys.argv) != 2:
print(usage())
sys.exit(0)
output_file = sys.argv[1]
_, ext = os.path.splitext(output_file.lower())
if ext == ".cpp":
output_fn = output_cpp
elif ext == ".hpp":
output_fn = output_hpp
else:
output_fn = output_coffee
output_fn(output_file)
sys.exit(0)