use crate::entropy::{self, tokenize_entropy, ParseError, BASE64URL};
use crate::second_digest;
use crate::{
choose_grid, closest_palette_color, compute_fingerprint, median_token, nucleus_colors,
quartile_tokens, select_visual_style, tokenize_fingerprint, Grid, Token, VisualStyle,
};
const DPI: f64 = 96.0;
const NOTE_MAX_LEN: usize = 8;
const MAX_INPUT_CHARS: usize = 65536;
const MONOSPACE_FONT_FAMILY: &str = "\"JetBrains Mono\", \"Menlo\", \"Consolas\", \"DejaVu Sans Mono\", \"Liberation Mono\", \"Roboto Mono\", \"Noto Sans Mono\", monospace";
#[derive(Debug)]
pub enum RenderError {
Note(String),
InputTooLong,
FontSizeRange,
AspectRatioRange,
NoTokens,
Eip55,
}
impl From<ParseError> for RenderError {
fn from(_: ParseError) -> Self {
RenderError::Eip55
}
}
fn sanitize_note(note: Option<&str>) -> Result<Option<String>, RenderError> {
match note {
None => Ok(None),
Some("") => Ok(None),
Some(n) => {
if n.chars().count() > NOTE_MAX_LEN {
return Err(RenderError::Note(format!(
"note too long: {}",
n.chars().count()
)));
}
if n.is_empty() || !n.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(RenderError::Note("note must be ASCII alphanumeric".into()));
}
Ok(Some(n.to_string()))
}
}
}
fn esc_attr(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn esc_text(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn n(x: f64) -> String {
format!("{}", x)
}
pub fn render(
entropy_text: &str,
target_ar: f64,
font_size_pt: f64,
note: Option<&str>,
) -> Result<String, RenderError> {
let note = sanitize_note(note)?;
if entropy_text.chars().count() > MAX_INPUT_CHARS {
return Err(RenderError::InputTooLong);
}
if !(6.0..=30.0).contains(&font_size_pt) {
return Err(RenderError::FontSizeRange);
}
if !(0.01..=100.0).contains(&target_ar) {
return Err(RenderError::AspectRatioRange);
}
let raw_input = entropy_text.trim().to_string();
let parsed = entropy::parse(&raw_input)?;
let (core, mut type_name, alphabet, prefix, suffix, prefix_semantic);
match parsed {
None => {
core = b64url_encode(raw_input.as_bytes());
type_name = format!("txt({})->b64url", raw_input.chars().count());
alphabet = BASE64URL;
prefix = None;
suffix = None;
prefix_semantic = false;
}
Some(p) => {
core = p.core;
type_name = p.type_name;
alphabet = p.alphabet;
prefix = p.prefix;
suffix = p.suffix;
prefix_semantic = p.prefix_semantic;
if type_name == "hex" {
type_name = format!("hex({})", core.chars().count());
} else if type_name == "base64" {
type_name = format!("b64({})", core.chars().count());
} else if type_name == "base64url" {
type_name = format!("b64url({})", core.chars().count());
}
}
}
let (tokens, is_truncated) = tokenize_entropy(&core, &alphabet);
if tokens.is_empty() {
return Err(RenderError::NoTokens);
}
let truncated_bytes: Option<usize> = if is_truncated {
Some(raw_input.len())
} else {
None
};
let token_count = tokens.len();
let fingerprint_core = match (&prefix, prefix_semantic) {
(Some(p), true) => format!("{p}{core}"),
_ => core.clone(),
};
let primary = compute_fingerprint(&fingerprint_core);
let ftoks_all = tokenize_fingerprint(&primary);
let used_ftoks: Vec<Token> = ftoks_all.into_iter().take(token_count).collect();
let grid = choose_grid(if is_truncated { 22 } else { token_count }, target_ar);
let median_ftok = median_token(&used_ftoks);
let quartile_ftoks = quartile_tokens(&used_ftoks);
let style = select_visual_style(median_ftok.as_ref().expect("non-empty ftoks"));
let cell_indices = assign_cell_indices(&tokens, &grid, &median_ftok, &used_ftoks);
let font_px = font_size_pt * DPI / 72.0;
let nucleus_w = font_px * 3.0;
let nucleus_h = font_px * 1.25;
let box_w = nucleus_w / 8.0;
let box_h = nucleus_h / 2.0;
let cell_w = nucleus_w + 2.0 * box_w;
let cell_h = nucleus_h + 2.0 * box_h;
let gm = box_h / 2.0;
let bar_w = 2.0 * box_h;
let grid_w = cell_w * grid.cols as f64;
let grid_h = cell_h * grid.rows as f64;
let bounding_w = 1.0 + bar_w + 1.0 + gm + grid_w + gm + 1.0;
let has_bottom_label = suffix.is_some() || note.is_some();
let bottom_region = if has_bottom_label { nucleus_h + gm } else { gm };
let bounding_h = 1.0 + gm + nucleus_h + grid_h + bottom_region + 1.0;
let grid_left = 1.0 + bar_w + 1.0 + gm;
let grid_top = 1.0 + gm + nucleus_h;
let grid_right = grid_left + grid_w;
let grid_bottom = grid_top + grid_h;
let cell_count = grid.cols * grid.rows;
let used_cells: std::collections::HashSet<usize> = cell_indices.iter().copied().collect();
let cell_text_pt = if alphabet.bits_per_char == 4 {
(font_size_pt * 0.75).round_ties_even()
} else {
font_size_pt
};
let cell_text_px = cell_text_pt * DPI / 72.0;
let label_text_px = (font_size_pt * 0.75).round_ties_even() * DPI / 72.0;
let fp_middle_text_px = (font_size_pt * 0.80).round_ties_even() * DPI / 72.0;
let mut fp_edge_cells: std::collections::HashSet<usize> = std::collections::HashSet::new();
if used_cells.contains(&0) {
fp_edge_cells.insert(0);
}
for q in quartile_ftoks.iter().take(2).flatten() {
fp_edge_cells.insert(cell_indices[q.index]);
}
let mut token_cells: Vec<(&Token, &Token, usize, String)> = Vec::with_capacity(token_count);
for token in &tokens {
let ci = cell_indices[token.index];
let nucleus_bg = if is_truncated && (8..=11).contains(&token.index) {
style.bg_color.clone()
} else {
nucleus_colors(token.quant).0
};
token_cells.push((token, &used_ftoks[token.index], ci, nucleus_bg));
}
let mut s = String::with_capacity(8192);
s.push_str(&format!(
"<svg width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\" xmlns=\"http://www.w3.org/2000/svg\" \
data-entviz-version=\"v10\" data-entviz-lib=\"0.10.0\" data-input-bytes=\"{ib}\" \
data-cols=\"{c}\" data-rows=\"{r}\"{trunc}>",
w = n(bounding_w),
h = n(bounding_h),
ib = raw_input.len(),
c = grid.cols,
r = grid.rows,
trunc = if is_truncated { " data-truncated=\"true\"" } else { "" },
));
let digest_hex: String = primary[..8].iter().map(|b| format!("{:02x}", b)).collect();
let clip_id = format!("grid-clip-{}-{}x{}", digest_hex, grid.cols, grid.rows);
s.push_str(&format!(
"<defs><clipPath id=\"{cid}\"><rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\"/></clipPath></defs>",
cid = esc_attr(&clip_id),
x = n(grid_left),
y = n(grid_top),
w = n(grid_w),
h = n(grid_h),
));
s.push_str(&format!(
"<rect x=\"0\" y=\"0\" width=\"{}\" height=\"{}\" fill=\"#ffffff\"/>",
n(bounding_w),
n(bounding_h)
));
s.push_str("<g data-channel=\"grid\">");
s.push_str(&format!(
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{}\"/>",
n(grid_left),
n(grid_top),
n(grid_w),
n(grid_h),
esc_attr(&style.bg_color)
));
s.push_str("<g>");
for &(_token, ftok, ci, ref nucleus_bg) in &token_cells {
let edge_color = if fp_edge_cells.contains(&ci) {
style.edge_colors[(ftok.quant & 0b11) as usize].clone()
} else {
closest_palette_color(nucleus_bg, &edge_color_refs(&style)).to_string()
};
let col = ci % grid.cols;
let row = ci / grid.cols;
let cell_left = grid_left + col as f64 * cell_w;
let cell_top = grid_top + row as f64 * cell_h;
for i in 0..24u32 {
if (ftok.quant >> i) & 1 == 0 {
continue;
}
let (ox, oy) = box_origin(i, cell_left, cell_top, box_w, box_h);
s.push_str(&format!(
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{}\"/>",
n(ox),
n(oy),
n(box_w),
n(box_h),
esc_attr(&edge_color)
));
}
}
s.push_str("</g>");
draw_ellipse_overlay(
&mut s,
&primary,
&grid,
grid_left,
grid_top,
grid_w,
grid_h,
cell_w,
cell_h,
&style.bg_color,
&clip_id,
);
let mut min_cell = (u32::MAX, 0usize);
let mut max_cell = (0u32, 0usize);
for token in &tokens {
let q = used_ftoks[token.index].quant;
let ci = cell_indices[token.index];
if q < min_cell.0 || (q == min_cell.0 && ci > min_cell.1) {
min_cell = (q, ci);
}
if q > max_cell.0 || (q == max_cell.0 && ci > max_cell.1) {
max_cell = (q, ci);
}
}
let min_cell_idx = min_cell.1;
let max_cell_idx = max_cell.1;
let blank_indices: Vec<usize> = (0..cell_count)
.filter(|ci| !used_cells.contains(ci))
.collect();
let map_cell_idx = blank_indices.iter().copied().min();
let sole_blank = blank_indices.len() == 1;
let map_fill = if style.bg_color == "#ffffff" {
"#e7be00"
} else {
"#ffffff"
};
let mut blank_fill_color: std::collections::HashMap<usize, String> =
std::collections::HashMap::new();
let mut j = 0usize;
for &bi in &blank_indices {
if Some(bi) != map_cell_idx || sole_blank {
let color = style.edge_colors[(primary[32 + j] & 0b11) as usize].clone();
blank_fill_color.insert(bi, color);
j += 1;
}
}
let token_by_index: std::collections::HashMap<usize, &Token> =
tokens.iter().map(|t| (t.index, t)).collect();
let mut quartile_of_cell: std::collections::HashMap<usize, (usize, String)> =
std::collections::HashMap::new();
for (q_idx, q_ftok) in quartile_ftoks.iter().enumerate() {
if let Some(q) = q_ftok {
let ci = cell_indices[q.index];
if let Some(token) = token_by_index.get(&q.index) {
let fg = nucleus_colors(token.quant).1;
quartile_of_cell.insert(ci, (q_idx, fg));
}
}
}
let fingerprint_cells: std::collections::HashSet<usize> = if is_truncated {
(8..12).map(|t| cell_indices[t]).collect()
} else {
std::collections::HashSet::new()
};
s.push_str("<g>");
let fp_border = if style.bg_color == "#ffffff" {
"#e7be00"
} else {
"#ffffff"
};
let corner_radius = nucleus_h / 2.0;
for ci in 0..cell_count {
let col = ci % grid.cols;
let row = ci / grid.cols;
let mut attrs = format!(
" data-channel=\"cell\" data-cell-index=\"{}\" data-cell-row=\"{}\" data-cell-col=\"{}\"",
ci, row, col
);
let is_blank = !used_cells.contains(&ci);
if is_blank {
attrs.push_str(" data-cell-blank=\"true\"");
}
if fingerprint_cells.contains(&ci) {
attrs.push_str(" data-cell-fingerprint=\"true\"");
}
let is_map = is_blank && Some(ci) == map_cell_idx;
if is_map {
attrs.push_str(" data-cell-blank-map=\"true\"");
}
if let Some((q_idx, _)) = quartile_of_cell.get(&ci) {
attrs.push_str(&format!(" data-cell-quartile=\"{}\"", q_idx + 1));
}
s.push_str(&format!("<g{}>", attrs));
if is_blank {
let nx = grid_left + col as f64 * cell_w + box_w;
let ny = grid_top + row as f64 * cell_h + box_h;
let blank_fill: String = if is_map && !sole_blank {
map_fill.to_string()
} else {
blank_fill_color.get(&ci).cloned().unwrap()
};
s.push_str(&format!(
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" rx=\"{}\" ry=\"{}\" fill=\"{}\" stroke=\"#000000\" stroke-width=\"1\"/>",
n(nx),
n(ny),
n(nucleus_w),
n(nucleus_h),
n(corner_radius),
n(corner_radius),
esc_attr(&blank_fill),
));
if is_map {
let sub_w = nucleus_w / grid.cols as f64;
let sub_h = nucleus_h / grid.rows as f64;
let dot_r = nucleus_h / 8.0 + font_px / 16.0;
let (max_cx, max_cy) = sub_center(max_cell_idx, nx, ny, &grid, sub_w, sub_h);
let (min_cx, min_cy) = sub_center(min_cell_idx, nx, ny, &grid, sub_w, sub_h);
let (max_row, max_col) = (max_cell_idx / grid.cols, max_cell_idx % grid.cols);
let (min_row, min_col) = (min_cell_idx / grid.cols, min_cell_idx % grid.cols);
let plus_arm = dot_r * 1.2;
let plus_w = (dot_r * 0.55).max(1.0);
let (min_color, max_color) = if sole_blank {
let f = blank_fill_color.get(&map_cell_idx.unwrap()).unwrap();
let quant = u32::from_str_radix(&f[1..3], 16).unwrap()
| (u32::from_str_radix(&f[3..5], 16).unwrap() << 8)
| (u32::from_str_radix(&f[5..7], 16).unwrap() << 16);
let mc = nucleus_colors(quant).1;
(mc.clone(), mc)
} else {
("#1d4ed8".to_string(), "#d62828".to_string())
};
s.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{}\" data-blank-map-min=\"{},{}\"/>",
n(min_cx),
n(min_cy),
n(dot_r),
esc_attr(&min_color),
min_row,
min_col,
));
s.push_str(&format!(
"<path d=\"M {},{} H {} M {},{} V {}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{}\" stroke-linecap=\"butt\" data-blank-map-max=\"{},{}\"/>",
n(max_cx - plus_arm),
n(max_cy),
n(max_cx + plus_arm),
n(max_cx),
n(max_cy - plus_arm),
n(max_cy + plus_arm),
esc_attr(&max_color),
n(plus_w),
max_row,
max_col,
));
}
} else {
let (token, _ftok, _ci, nucleus_bg) = token_cells.iter().find(|tc| tc.2 == ci).unwrap();
let is_fp_middle = is_truncated && (8..=11).contains(&token.index);
let (bg_color, fg_color) = {
let r = u32::from_str_radix(&nucleus_bg[1..3], 16).unwrap();
let g = u32::from_str_radix(&nucleus_bg[3..5], 16).unwrap();
let b = u32::from_str_radix(&nucleus_bg[5..7], 16).unwrap();
nucleus_colors(r | (g << 8) | (b << 16))
};
let nx = grid_left + col as f64 * cell_w + box_w;
let ny = grid_top + row as f64 * cell_h + box_h;
s.push_str(&format!(
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{}\"/>",
n(nx),
n(ny),
n(nucleus_w),
n(nucleus_h),
esc_attr(&bg_color)
));
if is_fp_middle {
s.push_str(&format!(
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"1\"/>",
n(nx + 0.5),
n(ny + 0.5),
n(nucleus_w - 1.0),
n(nucleus_h - 1.0),
esc_attr(fp_border)
));
}
let text_px = if is_fp_middle {
fp_middle_text_px
} else {
cell_text_px
};
let cx = nx + nucleus_w / 2.0;
let cy = ny + nucleus_h / 2.0;
s.push_str(&format!(
"<text x=\"{}\" y=\"{}\" fill=\"{}\" style=\"font-family: {}; font-size: {}px;\" text-anchor=\"middle\" dominant-baseline=\"central\">{}</text>",
n(cx),
n(cy),
esc_attr(&fg_color),
esc_attr(MONOSPACE_FONT_FAMILY),
n(text_px),
esc_text(&token.text)
));
if let Some((q_idx, fg)) = quartile_of_cell.get(&ci) {
let poly = quartile_polygon(*q_idx, nx, ny, nucleus_w, nucleus_h);
s.push_str(&format!(
"<polygon points=\"{}\" fill=\"{}\"/>",
poly,
esc_attr(fg)
));
}
}
s.push_str("</g>");
}
s.push_str("</g>"); s.push_str("</g>");
draw_color_bar(
&mut s,
&primary,
&second_digest(&core),
&style,
bar_w,
bounding_h,
cell_text_px,
);
draw_label_strips(
&mut s,
grid_left,
grid_right,
grid_top,
grid_bottom,
nucleus_h,
&type_name,
&prefix,
&suffix,
label_text_px,
truncated_bytes,
¬e,
);
let bl = |s: &mut String, x1: f64, y1: f64, x2: f64, y2: f64| {
s.push_str(&format!(
"<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"#808080\" stroke-width=\"1\" shape-rendering=\"crispEdges\"/>",
n(x1), n(y1), n(x2), n(y2)
));
};
bl(&mut s, 0.0, 0.5, bounding_w, 0.5);
bl(&mut s, bounding_w - 0.5, 0.0, bounding_w - 0.5, bounding_h);
bl(&mut s, 0.0, bounding_h - 0.5, bounding_w, bounding_h - 0.5);
bl(&mut s, 0.5, 0.0, 0.5, bounding_h);
bl(
&mut s,
1.0 + bar_w + 0.5,
0.0,
1.0 + bar_w + 0.5,
bounding_h,
);
s.push_str("</svg>");
Ok(s)
}
fn edge_color_refs(style: &VisualStyle) -> Vec<&str> {
style.edge_colors.iter().map(|s| s.as_str()).collect()
}
fn box_origin(i: u32, cell_left: f64, cell_top: f64, bw: f64, bh: f64) -> (f64, f64) {
if i < 10 {
(cell_left + i as f64 * bw, cell_top)
} else if i < 12 {
(cell_left + 9.0 * bw, cell_top + bh + (i - 10) as f64 * bh)
} else if i < 22 {
(cell_left + (21 - i) as f64 * bw, cell_top + 3.0 * bh)
} else {
(cell_left, cell_top + bh + (23 - i) as f64 * bh)
}
}
fn sub_center(
cell_idx: usize,
nx: f64,
ny: f64,
grid: &Grid,
sub_w: f64,
sub_h: f64,
) -> (f64, f64) {
(
nx + (cell_idx % grid.cols) as f64 * sub_w + 0.5 * sub_w,
ny + (cell_idx / grid.cols) as f64 * sub_h + 0.5 * sub_h,
)
}
fn quartile_polygon(q_idx: usize, nx: f64, ny: f64, w: f64, h: f64) -> String {
let leg = h / 2.0;
let (left, top, right, bottom) = (nx, ny, nx + w, ny + h);
let pts: [(f64, f64); 3] = match q_idx {
0 => [(left, top), (left + leg, top), (left, top + leg)],
1 => [(right, top), (right - leg, top), (right, top + leg)],
2 => [
(right, bottom),
(right, bottom - leg),
(right - leg, bottom),
],
_ => [(left, bottom), (left, bottom - leg), (left + leg, bottom)],
};
pts.iter()
.map(|p| format!("{},{}", n(p.0), n(p.1)))
.collect::<Vec<_>>()
.join(" ")
}
fn assign_cell_indices(
tokens: &[Token],
grid: &Grid,
median: &Option<Token>,
sort_keys: &[Token],
) -> Vec<usize> {
let token_count = tokens.len();
let cell_count = grid.cols * grid.rows;
let mut ci: Vec<usize> = (0..token_count).collect();
if token_count >= cell_count || tokens.is_empty() {
return ci;
}
let shift = |ci: &mut Vec<usize>, start: usize| {
for (t, c) in ci.iter_mut().enumerate() {
if t >= start {
*c += 1;
}
}
};
if let Some(m) = median {
shift(&mut ci, m.index);
}
let mut sorted: Vec<&Token> = sort_keys.iter().collect();
sorted.sort_by(|a, b| a.text.cmp(&b.text).then(a.index.cmp(&b.index)));
if token_count + 1 < cell_count {
shift(&mut ci, sorted[sorted.len() - 1].index);
}
if token_count + 2 < cell_count {
shift(&mut ci, sorted[0].index);
}
ci
}
#[allow(clippy::too_many_arguments)]
fn draw_ellipse_overlay(
s: &mut String,
digest: &[u8; 64],
grid: &Grid,
grid_left: f64,
grid_top: f64,
grid_w: f64,
grid_h: f64,
cell_w: f64,
cell_h: f64,
bg_color: &str,
clip_id: &str,
) {
let interior_count = (grid.cols.saturating_sub(1)) * (grid.rows.saturating_sub(1));
let points: Vec<(f64, f64)> = if interior_count >= 6 {
let mut p = Vec::new();
for r in 1..grid.rows {
for c in 1..grid.cols {
p.push((grid_left + c as f64 * cell_w, grid_top + r as f64 * cell_h));
}
}
p
} else {
let mut p = Vec::new();
for c in 0..=grid.cols {
p.push((grid_left + c as f64 * cell_w, grid_top));
}
for r in 1..grid.rows {
p.push((grid_left, grid_top + r as f64 * cell_h));
p.push((
grid_left + grid.cols as f64 * cell_w,
grid_top + r as f64 * cell_h,
));
}
for c in 0..=grid.cols {
p.push((
grid_left + c as f64 * cell_w,
grid_top + grid.rows as f64 * cell_h,
));
}
p
};
if points.is_empty() {
return;
}
let anchor = points[(digest[60] as usize) % points.len()];
let grid_right = grid_left + grid_w;
let grid_bottom = grid_top + grid_h;
let corners = [
(grid_left, grid_top),
(grid_right, grid_top),
(grid_left, grid_bottom),
(grid_right, grid_bottom),
];
let d_far = corners
.iter()
.map(|c| ((c.0 - anchor.0).powi(2) + (c.1 - anchor.1).powi(2)).sqrt())
.fold(0.0_f64, f64::max);
let r_min = 0.22 * d_far;
let r_max = 0.58 * d_far;
if r_max <= r_min {
return;
}
let rx = r_min + ((digest[61] % 16) as f64 / 15.0) * (r_max - r_min);
let ry = r_min + ((digest[62] % 16) as f64 / 15.0) * (r_max - r_min);
let rotation = ((digest[63] % 16) as f64 / 15.0) * 180.0;
let (fill, fill_op, edge_op) = overlay_for_bg(bg_color);
let stroke_w = cell_h / 20.0;
s.push_str(&format!(
"<g clip-path=\"url(#{cid})\" data-channel=\"ellipse\" data-ellipse-anchor-x=\"{ax}\" data-ellipse-anchor-y=\"{ay}\" data-ellipse-rx=\"{rx}\" data-ellipse-ry=\"{ry}\" data-ellipse-rotation-deg=\"{rot}\">",
cid = esc_attr(clip_id),
ax = n(anchor.0),
ay = n(anchor.1),
rx = n(rx),
ry = n(ry),
rot = n(rotation),
));
s.push_str(&format!(
"<ellipse cx=\"{cx}\" cy=\"{cy}\" rx=\"{rx}\" ry=\"{ry}\" transform=\"rotate({rot} {cx} {cy})\" fill=\"{fill}\" stroke=\"{fill}\" fill-opacity=\"{fo}\" stroke-opacity=\"{eo}\" stroke-width=\"{sw}\"/>",
cx = n(anchor.0),
cy = n(anchor.1),
rx = n(rx),
ry = n(ry),
rot = n(rotation),
fill = fill,
fo = n(fill_op),
eo = n(edge_op),
sw = n(stroke_w),
));
s.push_str("</g>");
}
fn overlay_for_bg(bg: &str) -> (&'static str, f64, f64) {
match bg {
"#ffffff" => ("#000000", 0.20, 0.30),
"#e7be00" => ("#000000", 0.20, 0.30),
"#ff3f2f" => ("#000000", 0.25, 0.35),
"#2f3fbf" => ("#ffffff", 0.35, 0.45),
_ => ("#000000", 0.20, 0.30),
}
}
fn two_bit_counts(digest: &[u8; 64]) -> [usize; 4] {
let mut c = [0usize; 4];
for &byte in digest.iter() {
for shift in [0u32, 2, 4, 6] {
c[((byte >> shift) & 3) as usize] += 1;
}
}
c
}
fn first_appearance(digest: &[u8; 64]) -> [usize; 4] {
let mut first = [usize::MAX; 4];
let mut idx = 0;
for &byte in digest.iter() {
for shift in [0u32, 2, 4, 6] {
let p = ((byte >> shift) & 3) as usize;
if first[p] == usize::MAX {
first[p] = idx;
}
idx += 1;
}
}
let mut order = [0usize, 1, 2, 3];
order.sort_by_key(|&p| (first[p], p));
order
}
fn band_letter(color: &str) -> Option<&'static str> {
match color {
"#ffffff" => Some("W"),
"#e7be00" => Some("G"),
"#ff3f2f" => Some("R"),
"#2f3fbf" => Some("B"),
"#000000" => Some("K"),
_ => None,
}
}
fn draw_color_bar(
s: &mut String,
digest: &[u8; 64],
second: &[u8; 64],
style: &VisualStyle,
bar_w: f64,
bounding_h: f64,
cell_text_px: f64,
) {
let bar_left = 1.0;
let bar_top = 1.0;
let bar_height = bounding_h - 2.0;
let counts = two_bit_counts(digest);
let edge = &style.edge_colors;
let order = first_appearance(digest);
let order_pos: std::collections::HashMap<&str, usize> = order
.iter()
.enumerate()
.map(|(i, &p)| (edge[p].as_str(), i))
.collect();
let color_order: std::collections::HashMap<&str, usize> = edge
.iter()
.enumerate()
.map(|(i, c)| (c.as_str(), i))
.collect();
let mut used: Vec<(String, usize)> = (0..4)
.filter(|&i| counts[i] > 0)
.map(|i| (edge[i].clone(), counts[i]))
.collect();
if used.is_empty() {
return;
}
used.sort_by_key(|(c, _)| {
(
*order_pos.get(c.as_str()).unwrap_or(&4),
*color_order.get(c.as_str()).unwrap_or(&4),
)
});
let total: f64 = used.iter().map(|(_, nn)| (*nn as f64).powi(4)).sum();
s.push_str("<g data-channel=\"color-bar\">");
let bar_cx = bar_left + bar_w / 2.0;
let mut y = bar_top;
let last = used.len() - 1;
for (i, (color, nn)) in used.iter().enumerate() {
let h = if i == last {
(bar_top + bar_height) - y
} else {
bar_height * (*nn as f64).powi(4) / total
};
let letter = band_letter(color);
let band_attrs = match letter {
Some(l) => format!(
" data-color-bar-rank=\"{}\" data-color-bar-band=\"{}\"",
i, l
),
None => format!(" data-color-bar-rank=\"{}\"", i),
};
s.push_str(&format!("<g{}>", band_attrs));
s.push_str(&format!(
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{}\"/>",
n(bar_left),
n(y),
n(bar_w),
n(h),
esc_attr(color)
));
if let Some(l) = letter {
let r = u32::from_str_radix(&color[1..3], 16).unwrap();
let g = u32::from_str_radix(&color[3..5], 16).unwrap();
let b = u32::from_str_radix(&color[5..7], 16).unwrap();
let fg = nucleus_colors(r | (g << 8) | (b << 16)).1;
let font_size = cell_text_px;
let baseline_y = (y + h) - 0.22 * font_size;
s.push_str(&format!(
"<text x=\"{}\" y=\"{}\" fill=\"{}\" style=\"font-family: {}; font-size: {}px;\" text-anchor=\"middle\" data-color-bar-letter=\"true\">{}</text>",
n(bar_cx),
n(baseline_y),
esc_attr(&fg),
esc_attr(MONOSPACE_FONT_FAMILY),
n(font_size),
esc_text(&l.to_lowercase())
));
}
s.push_str("</g>");
y += h;
}
let k = ((bar_height / 12.0).floor() as i64).clamp(4, 16) as usize;
let slot_h = bar_height / k as f64;
let radius = bar_w * 0.17;
let inset = bar_w * 0.06;
let left_slot = (second[12] as usize) % k;
let right_slot = (second[13] as usize) % k;
for (side, slot) in [("left", left_slot), ("right", right_slot)] {
let cy = bar_top + (slot as f64 + 0.5) * slot_h;
let cx = if side == "left" {
bar_left + inset + radius
} else {
bar_left + bar_w - inset - radius
};
s.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"#ffffff\" stroke=\"#000000\" stroke-width=\"0.75\" data-bar-marker=\"{}\"/>",
n(cx),
n(cy),
n(radius),
side
));
}
s.push_str("</g>");
patch_color_bar_attrs(s, k, left_slot, right_slot);
}
fn patch_color_bar_attrs(s: &mut String, k: usize, left: usize, right: usize) {
let needle = "<g data-channel=\"color-bar\">";
if let Some(pos) = s.rfind(needle) {
let replacement = format!(
"<g data-channel=\"color-bar\" data-bar-slots=\"{}\" data-bar-marker-left=\"{}\" data-bar-marker-right=\"{}\">",
k, left, right
);
s.replace_range(pos..pos + needle.len(), &replacement);
}
}
#[allow(clippy::too_many_arguments)]
fn draw_label_strips(
s: &mut String,
grid_left: f64,
grid_right: f64,
grid_top: f64,
grid_bottom: f64,
nucleus_h: f64,
type_name: &str,
prefix: &Option<String>,
suffix: &Option<String>,
text_px: f64,
truncated_bytes: Option<usize>,
note: &Option<String>,
) {
let style_attr = format!(
"font-family: {}; font-size: {}px;",
MONOSPACE_FONT_FAMILY,
n(text_px)
);
let rest_text = if !type_name.is_empty() {
let mut t = format!("{}:", type_name);
if let Some(p) = prefix {
t.push_str(&format!(" {}...", p));
}
t
} else if let Some(p) = prefix {
format!("{}...", p)
} else {
String::new()
};
let top_cy = grid_top - nucleus_h / 2.0;
s.push_str("<g data-channel=\"label-top\">");
if truncated_bytes.is_some() {
s.push_str(&format!(
"<text x=\"{}\" y=\"{}\" fill=\"#666666\" style=\"{}\" dominant-baseline=\"central\"><tspan fill=\"#a00000\" font-weight=\"bold\">fingerprint of </tspan>{}</text>",
n(grid_left),
n(top_cy),
esc_attr(&style_attr),
esc_text(&rest_text),
));
} else {
s.push_str(&format!(
"<text x=\"{}\" y=\"{}\" fill=\"#666666\" style=\"{}\" dominant-baseline=\"central\">{}</text>",
n(grid_left),
n(top_cy),
esc_attr(&style_attr),
esc_text(&rest_text),
));
}
s.push_str("</g>");
if suffix.is_some() || note.is_some() {
let bottom_cy = grid_bottom + nucleus_h / 2.0;
s.push_str("<g data-channel=\"label-bottom\">");
s.push_str(&format!(
"<text x=\"{}\" y=\"{}\" fill=\"#666666\" style=\"{}\" text-anchor=\"end\" dominant-baseline=\"central\">",
n(grid_right),
n(bottom_cy),
esc_attr(&style_attr),
));
match (suffix, note) {
(Some(suf), Some(nt)) => {
s.push_str(&format!("<tspan>...{} </tspan>", esc_text(suf)));
s.push_str(&format!(
"<tspan fill=\"#808080\" data-user-note=\"{}\">({})</tspan>",
esc_attr(nt),
esc_text(nt)
));
}
(Some(suf), None) => {
s.push_str(&format!("...{}", esc_text(suf)));
}
(None, Some(nt)) => {
s.push_str(&format!(
"<tspan fill=\"#808080\" data-user-note=\"{}\">({})</tspan>",
esc_attr(nt),
esc_text(nt)
));
}
(None, None) => {}
}
s.push_str("</text></g>");
}
}
fn b64url_encode(data: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_hex256() {
let svg = render(
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
1.0,
12.0,
None,
)
.unwrap();
assert!(svg.starts_with("<svg "));
assert!(svg.contains("data-cols=\"3\""));
assert!(svg.contains("data-rows=\"4\""));
assert!(svg.contains("data-channel=\"color-bar\" data-bar-slots="));
assert!(svg.ends_with("</svg>"));
}
#[test]
fn rejects_bad_eip55() {
assert!(matches!(
render(
"0x5aaeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
1.0,
12.0,
None
),
Err(RenderError::Eip55)
));
}
#[test]
fn rejects_bad_note_and_fontsize() {
assert!(render("a1b2c3d4e5f6a7b8", 1.0, 12.0, Some("two words")).is_err());
assert!(render("a1b2c3d4e5f6a7b8", 1.0, 4.0, None).is_err());
assert!(render("a1b2c3d4e5f6a7b8", 1.0, 40.0, None).is_err());
}
}