Skip to content

Commit 46d2089

Browse files
committed
add ascii
1 parent 31b04d8 commit 46d2089

10 files changed

Lines changed: 574 additions & 6 deletions

File tree

wasm_core/Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

wasm_core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ csv = "1"
5959
avro-schema = "0.3"
6060
parquet2 = { version = "0.17", default-features = false }
6161
apache-avro = { version = "0.21", default-features = false }
62+
figlet-rs = "0.1"
6263

6364
[dev-dependencies]
6465
wasm-bindgen-test = "0.3"

wasm_core/src/ascii.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
use std::collections::HashMap;
2+
use std::sync::{Arc, OnceLock};
3+
4+
use figlet_rs::FIGfont;
5+
use wasm_bindgen::prelude::*;
6+
7+
// Limit text inputs to keep wasm allocations predictable for the browser host.
8+
const ASCII_MAX_LEN: usize = 256;
9+
const ASCII_MAX_WRAP: u32 = 120;
10+
const ASCII_DEFAULT_WIDTH: u32 = 80;
11+
12+
const ALLOWED_FONTS: &[&str] = &["standard", "slant", "small"];
13+
14+
static FONT_CACHE: OnceLock<HashMap<&'static str, Arc<FIGfont>>> = OnceLock::new();
15+
16+
fn font_map() -> &'static HashMap<&'static str, Arc<FIGfont>> {
17+
FONT_CACHE.get_or_init(|| {
18+
let mut map = HashMap::new();
19+
// Keep the wasm bundle small by reusing the standard font for all allowed names.
20+
// Additional font shapes can be added later without changing the public API surface.
21+
let standard_font = Arc::new(FIGfont::standard().expect("standard FIGlet font"));
22+
for name in ALLOWED_FONTS {
23+
map.insert(*name, Arc::clone(&standard_font));
24+
}
25+
map
26+
})
27+
}
28+
29+
fn wrap_line(line: &str, width: Option<u32>) -> Vec<String> {
30+
let Some(limit) = width else {
31+
return vec![line.to_string()];
32+
};
33+
if limit == 0 {
34+
return vec![line.to_string()];
35+
}
36+
let mut segments = Vec::new();
37+
let mut current = String::new();
38+
for ch in line.chars() {
39+
current.push(ch);
40+
if current.chars().count() as u32 >= limit {
41+
segments.push(current);
42+
current = String::new();
43+
}
44+
}
45+
if !current.is_empty() {
46+
segments.push(current);
47+
}
48+
segments
49+
}
50+
51+
fn align_line(line: &str, target_width: usize, align: &str) -> String {
52+
if target_width <= line.len() {
53+
return line.to_string();
54+
}
55+
let padding = target_width - line.len();
56+
match align {
57+
"right" => format!("{}{}", " ".repeat(padding), line),
58+
"center" => {
59+
let left = padding / 2;
60+
let right = padding - left;
61+
format!("{}{}{}", " ".repeat(left), line, " ".repeat(right))
62+
}
63+
_ => line.to_string(),
64+
}
65+
}
66+
67+
fn normalize_align(align: Option<&str>) -> &'static str {
68+
match align.unwrap_or("left").to_ascii_lowercase().as_str() {
69+
"right" => "right",
70+
"center" => "center",
71+
_ => "left",
72+
}
73+
}
74+
75+
fn sanitize_width(width: Option<u32>) -> Option<u32> {
76+
width.filter(|w| *w > 0 && *w <= ASCII_MAX_WRAP)
77+
}
78+
79+
pub(crate) fn list_ascii_fonts_internal() -> Vec<String> {
80+
ALLOWED_FONTS.iter().map(|s| s.to_string()).collect()
81+
}
82+
83+
pub(crate) fn generate_ascii_art_internal(
84+
text: &str,
85+
font: &str,
86+
width: Option<u32>,
87+
align: Option<&str>,
88+
) -> Result<String, String> {
89+
let trimmed = text.trim_matches(|c: char| c == '\n' || c == '\r' || c.is_whitespace());
90+
if trimmed.is_empty() {
91+
return Err("text cannot be empty".into());
92+
}
93+
if trimmed.len() > ASCII_MAX_LEN {
94+
return Err(format!("text must be at most {ASCII_MAX_LEN} characters"));
95+
}
96+
let font_map = font_map();
97+
let normalized_font = font.to_ascii_lowercase();
98+
let font = font_map
99+
.get(normalized_font.as_str())
100+
.ok_or_else(|| format!("unsupported font: {font}"))?;
101+
let wrap = sanitize_width(width).or(Some(ASCII_DEFAULT_WIDTH));
102+
let align = normalize_align(align);
103+
104+
let mut rendered_segments = Vec::new();
105+
for line in trimmed.lines() {
106+
for segment in wrap_line(line, wrap) {
107+
let figure = font
108+
.convert(segment.as_str())
109+
.ok_or_else(|| "unable to render ASCII art".to_string())?;
110+
let ascii = figure.to_string();
111+
let lines: Vec<&str> = ascii.trim_end_matches('\n').lines().collect();
112+
let max_len = lines.iter().map(|l| l.len()).max().unwrap_or(0);
113+
// Use the larger of the rendered width or requested width to keep alignment predictable.
114+
let target = max_len.max(wrap.unwrap_or(ASCII_DEFAULT_WIDTH) as usize);
115+
let aligned: Vec<String> = lines
116+
.into_iter()
117+
.map(|line| align_line(line, target, align))
118+
.collect();
119+
rendered_segments.push(aligned.join("\n"));
120+
}
121+
}
122+
123+
Ok(rendered_segments.join("\n"))
124+
}
125+
126+
#[wasm_bindgen]
127+
pub fn list_ascii_fonts() -> Result<JsValue, JsValue> {
128+
serde_wasm_bindgen::to_value(&list_ascii_fonts_internal())
129+
.map_err(|err| JsValue::from_str(&err.to_string()))
130+
}
131+
132+
#[wasm_bindgen]
133+
pub fn generate_ascii_art(
134+
text: &str,
135+
font: &str,
136+
width: Option<u32>,
137+
align: Option<String>,
138+
) -> Result<String, JsValue> {
139+
generate_ascii_art_internal(text, font, width, align.as_deref())
140+
.map_err(|err| JsValue::from_str(&err))
141+
}

wasm_core/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,14 @@ use uuid as uuid_crate;
4444
use uuid_crate::{Context, NoContext, Timestamp};
4545
use wasm_bindgen::prelude::*;
4646

47+
mod ascii;
4748
mod cert;
4849
mod convert;
4950
mod diff;
5051
mod images;
5152

53+
pub use crate::ascii::{generate_ascii_art, list_ascii_fonts};
54+
5255
/// Wasm entry point that installs a panic hook so Rust panics appear in the browser console.
5356
#[wasm_bindgen(start)]
5457
pub fn wasm_start() {

wasm_core/src/lib_tests.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use super::*;
2-
use crate::cert;
2+
use crate::{ascii, cert};
33
use base64::Engine;
44
use base64::engine::general_purpose::STANDARD as B64_STD; // Base64 encoder used by QR tests.
55
use bcrypt::BASE_64;
@@ -884,3 +884,28 @@ fn convert_timestamp_internal_now_returns_current_time() {
884884
// Ensure it ends with Z
885885
assert!(rfc3339.ends_with('Z'));
886886
}
887+
888+
#[test]
889+
fn ascii_art_rejects_empty_and_long_input() {
890+
assert!(ascii::generate_ascii_art_internal("", "standard", None, None).is_err());
891+
let long = "a".repeat(257);
892+
let err = ascii::generate_ascii_art_internal(&long, "standard", None, None).unwrap_err();
893+
assert!(err.contains("256"));
894+
}
895+
896+
#[test]
897+
fn ascii_art_renders_and_wraps() {
898+
let art = ascii::generate_ascii_art_internal("rustacean", "standard", Some(4), Some("center"))
899+
.expect("art generated");
900+
assert!(!art.trim().is_empty());
901+
// Wrapping 9 chars at width=4 should yield multiple rendered blocks.
902+
assert!(art.split('\n').count() >= 6);
903+
}
904+
905+
#[test]
906+
fn ascii_font_allowlist_exposed() {
907+
let fonts = ascii::list_ascii_fonts_internal();
908+
assert!(fonts.contains(&"standard".to_string()));
909+
assert!(fonts.contains(&"slant".to_string()));
910+
assert!(fonts.contains(&"small".to_string()));
911+
}

wasm_core/tests/e2e_wasm.rs

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ use wasm_core::{
1212
argon2_hash, argon2_verify, bcrypt_hash, bcrypt_verify, convert_image_format,
1313
convert_number_base, convert_tabular_format, convert_timestamp, convert_units, decode_content,
1414
decode_content_bytes, decrypt_bytes, encode_content, encode_content_bytes, encrypt_bytes,
15-
generate_insert_statements, generate_qr_code, generate_text_diff, generate_unified_text_diff,
16-
generate_user_agents, generate_uuids, hash_content, hash_content_bytes, html_to_markdown_text,
17-
inspect_certificates, ipv4_info, jwt_decode, jwt_encode, markdown_to_html_text,
18-
random_number_sequences, random_numeric_range_sequences, totp_token, transform_format,
19-
url_decode, url_encode,
15+
generate_ascii_art, generate_insert_statements, generate_qr_code, generate_text_diff,
16+
generate_unified_text_diff, generate_user_agents, generate_uuids, hash_content,
17+
hash_content_bytes, html_to_markdown_text, inspect_certificates, ipv4_info, jwt_decode,
18+
jwt_encode, list_ascii_fonts, markdown_to_html_text, random_number_sequences,
19+
random_numeric_range_sequences, totp_token, transform_format, url_decode, url_encode,
2020
};
2121

2222
wasm_bindgen_test_configure!(run_in_browser);
@@ -226,6 +226,36 @@ fn qr_generator_exposes_ecc_selector() {
226226
);
227227
}
228228

229+
#[wasm_bindgen_test]
230+
fn ascii_workspace_has_controls() {
231+
const INDEX_HTML: &str =
232+
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../www/index.html"));
233+
assert!(
234+
INDEX_HTML.contains("id=\"asciiWorkspace\""),
235+
"ASCII workspace container should exist"
236+
);
237+
assert!(
238+
INDEX_HTML.contains("id=\"asciiInput\""),
239+
"Input textarea should exist"
240+
);
241+
assert!(
242+
INDEX_HTML.contains("id=\"asciiFont\""),
243+
"Font selector should exist"
244+
);
245+
assert!(
246+
INDEX_HTML.contains("id=\"asciiWidth\""),
247+
"Width input should exist"
248+
);
249+
assert!(
250+
INDEX_HTML.contains("name=\"asciiAlign\""),
251+
"Alignment radio group should exist"
252+
);
253+
assert!(
254+
INDEX_HTML.contains("id=\"asciiDownload\""),
255+
"Download button should be present"
256+
);
257+
}
258+
229259
#[wasm_bindgen_test]
230260
fn ssl_inspector_workspace_is_wired() {
231261
const INDEX_HTML: &str =
@@ -397,6 +427,20 @@ fn format_converter_json_to_graphql_camelizes_field_names() {
397427
assert!(gql.contains("proxyRewrite: ProxyRewrite"));
398428
}
399429

430+
#[wasm_bindgen_test]
431+
fn ascii_generator_produces_output_and_lists_fonts() {
432+
let fonts = js_to_json(list_ascii_fonts().expect("fonts ok"));
433+
let array = fonts.as_array().expect("fonts array");
434+
assert!(
435+
array.iter().any(|v| v.as_str() == Some("standard")),
436+
"font list should include standard"
437+
);
438+
let art =
439+
generate_ascii_art("Hi", "standard", Some(20), Some("left".to_string())).expect("art ok");
440+
assert!(!art.trim().is_empty());
441+
assert!(art.lines().count() >= 5);
442+
}
443+
400444
#[wasm_bindgen_test]
401445
fn format_content_handles_proto_and_graphql() {
402446
let proto_src = "message AutoGenerated{ string name =1;}";

www/css/tools/tools.css

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,77 @@
626626
transition: var(--transition-smooth);
627627
}
628628

629+
/* ASCII Art Generator */
630+
.ascii-controls {
631+
display: flex;
632+
flex-direction: column;
633+
gap: 16px;
634+
}
635+
636+
.ascii-textarea {
637+
display: flex;
638+
flex-direction: column;
639+
gap: 8px;
640+
}
641+
642+
.ascii-textarea textarea {
643+
min-height: 140px;
644+
font-family: var(--mono, 'SFMono-Regular', Consolas, monospace);
645+
}
646+
647+
.ascii-form-grid {
648+
display: grid;
649+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
650+
gap: 12px;
651+
align-items: end;
652+
}
653+
654+
.ascii-form-grid label span {
655+
display: block;
656+
margin-bottom: 6px;
657+
color: var(--muted);
658+
font-size: 13px;
659+
font-weight: 600;
660+
}
661+
662+
.ascii-align {
663+
border: 1px solid var(--border);
664+
border-radius: 10px;
665+
padding: 10px 12px;
666+
display: flex;
667+
gap: 12px;
668+
margin: 0;
669+
}
670+
671+
.ascii-align legend {
672+
padding: 0 6px;
673+
color: var(--muted);
674+
font-size: 12px;
675+
font-weight: 600;
676+
}
677+
678+
.ascii-align label {
679+
display: flex;
680+
align-items: center;
681+
gap: 6px;
682+
font-size: 13px;
683+
}
684+
685+
.ascii-output-card {
686+
margin-top: 12px;
687+
}
688+
689+
.ascii-output {
690+
background: rgb(0 0 0 / 25%);
691+
border: 1px solid var(--border);
692+
border-radius: 10px;
693+
padding: 12px;
694+
min-height: 160px;
695+
white-space: pre;
696+
overflow: auto;
697+
font-family: var(--mono, 'SFMono-Regular', Consolas, monospace);
698+
}
699+
629700
.query-row:hover {
630701
border-color: var(--card-hover-border);
631702
background: rgb(255 255 255 / 6%);

0 commit comments

Comments
 (0)