diff --git a/render.py b/render.py
index 7f19118..a727721 100644
--- a/render.py
+++ b/render.py
@@ -64,6 +64,15 @@ WIDE_PALETTES = {
"hieroglyph", "cuneiform", "alchemical",
}
+# Palettes whose glyphs aren't reliably monospaced in JetBrains Mono.
+# These need fixed-width cells in HTML output to maintain grid alignment.
+VARIABLE_WIDTH_PALETTES = WIDE_PALETTES | {
+ "wingdings", "zodiac", "chess", "arrows", "music", "cosmic", "mystic",
+ "runes", "geometric", "kanji", "thai", "arabic", "devanagari", "ethiopic",
+ "georgian", "tibetan", "hieroglyph", "cuneiform", "alchemical", "dingbats",
+ "yijing", "math", "flora", "weather",
+}
+
def is_wide_palette(name: str) -> bool:
"""Check if a palette uses double-width (emoji/supplementary) characters."""
@@ -128,6 +137,7 @@ def _render_frame(
double_width: bool = False,
dither: bool = False,
output_format: str = "ansi",
+ fixed_width_cells: bool = False,
) -> str:
"""Render a single prepared (resized RGB) frame to art string."""
width, height = img.size
@@ -159,13 +169,18 @@ def _render_frame(
char = chars[idx]
if double_width:
char = char + " "
+ escaped = html_escape(char)
+ if fixed_width_cells:
+ # Wrap each char in a fixed-width cell so non-monospace
+ # glyphs don't break the grid alignment
+ escaped = f'{escaped}'
color = f"{r},{g},{b}"
if color == prev_color:
- run_chars.append(html_escape(char))
+ run_chars.append(escaped)
else:
if run_chars:
line_parts.append(f'{"".join(run_chars)}')
- run_chars = [html_escape(char)]
+ run_chars = [escaped]
prev_color = color
if run_chars:
line_parts.append(f'{"".join(run_chars)}')
@@ -216,7 +231,8 @@ def image_to_art(
wide = is_wide_palette(palette_name)
img = _prepare_frame(img, width, bg, wide_chars=wide)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
- return _render_frame(img, chars, double_width, dither, output_format)
+ fix_cells = palette_name in VARIABLE_WIDTH_PALETTES and output_format == "html"
+ return _render_frame(img, chars, double_width, dither, output_format, fixed_width_cells=fix_cells)
def gif_to_art(
@@ -232,6 +248,7 @@ def gif_to_art(
img = Image.open(image_path)
wide = is_wide_palette(palette_name)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
+ fix_cells = palette_name in VARIABLE_WIDTH_PALETTES and output_format == "html"
frames = []
frame_list = list(ImageSequence.Iterator(img))
@@ -245,7 +262,7 @@ def gif_to_art(
for frame in frame_list:
duration = frame.info.get("duration", 100)
prepared = _prepare_frame(frame.copy(), width, bg, wide_chars=wide)
- art = _render_frame(prepared, chars, double_width, dither, output_format)
+ art = _render_frame(prepared, chars, double_width, dither, output_format, fixed_width_cells=fix_cells)
frames.append({"art": art, "duration": max(duration, 20)})
return frames
@@ -264,7 +281,8 @@ def render_from_pil(
wide = is_wide_palette(palette_name)
prepared = _prepare_frame(img, width, bg, wide_chars=wide)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
- return _render_frame(prepared, chars, double_width, dither, output_format)
+ fix_cells = palette_name in VARIABLE_WIDTH_PALETTES and output_format == "html"
+ return _render_frame(prepared, chars, double_width, dither, output_format, fixed_width_cells=fix_cells)
def is_animated_gif(image_path: str) -> bool:
@@ -428,12 +446,13 @@ def animate_image(
prepared = _prepare_frame(img, width, bg, wide_chars=wide)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
effect_fn = _EFFECTS.get(effect, _effect_color_cycle)
+ fix_cells = palette_name in VARIABLE_WIDTH_PALETTES and output_format == "html"
frames = []
for i in range(num_frames):
t = i / num_frames
effected = effect_fn(prepared, t)
- art = _render_frame(effected, chars, double_width, dither, output_format)
+ art = _render_frame(effected, chars, double_width, dither, output_format, fixed_width_cells=fix_cells)
frame = {"art": art, "duration": frame_duration}
if return_pil:
frame["pil_frame"] = effected
@@ -459,6 +478,7 @@ def generate_pattern(
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
num_chars = len(chars)
+ fix_cells = palette_name in VARIABLE_WIDTH_PALETTES and output_format == "html"
# Random parameters for variety
freq1 = rng.uniform(0.02, 0.12)
@@ -627,7 +647,10 @@ def generate_pattern(
char = chars[idx]
if output_format == "html":
- line.append(f'{html_escape(char)}')
+ escaped = html_escape(char)
+ if fix_cells:
+ escaped = f'{escaped}'
+ line.append(f'{escaped}')
elif output_format == "ansi":
line.append(f"\033[38;2;{r};{g};{b}m{char}\033[0m")
else:
diff --git a/static/app.js b/static/app.js
index 8108e45..f90f5e0 100644
--- a/static/app.js
+++ b/static/app.js
@@ -257,9 +257,40 @@ async function generatePattern() {
randomBtn.disabled = false;
}
-// ── GIF Animation ──────────────────────────────
+// ── GIF Animation (runs even when tab is inactive) ──────────────────────────────
let animFrames = null;
+let animationWorker = null;
+
+function createAnimationWorker() {
+ const blob = new Blob([`
+ let timerId = null;
+ self.onmessage = function(e) {
+ if (e.data.cmd === 'start') {
+ if (timerId) clearTimeout(timerId);
+ timerId = setTimeout(() => self.postMessage('tick'), e.data.delay);
+ } else if (e.data.cmd === 'stop') {
+ if (timerId) { clearTimeout(timerId); timerId = null; }
+ }
+ };
+ `], { type: 'application/javascript' });
+ return new Worker(URL.createObjectURL(blob));
+}
+
+function scheduleNextFrame(delay) {
+ if (animationWorker) {
+ animationWorker.postMessage({ cmd: 'start', delay });
+ }
+}
+
+function advanceFrame() {
+ if (!animationPlaying || !animFrames) return;
+ animFrames[currentFrameIdx].style.display = 'none';
+ currentFrameIdx = (currentFrameIdx + 1) % animFrames.length;
+ animFrames[currentFrameIdx].style.display = 'block';
+ updateFrameCounter();
+ scheduleNextFrame(parseInt(animFrames[currentFrameIdx].dataset.duration) || 100);
+}
function startAnimation(frames) {
animFrames = frames;
@@ -271,18 +302,15 @@ function startAnimation(frames) {
playPauseBtn.innerHTML = '⏸';
updateFrameCounter();
- function nextFrame() {
- if (!animationPlaying) return;
- frames[currentFrameIdx].style.display = 'none';
- currentFrameIdx = (currentFrameIdx + 1) % frames.length;
- frames[currentFrameIdx].style.display = 'block';
- updateFrameCounter();
- animationInterval = setTimeout(nextFrame, parseInt(frames[currentFrameIdx].dataset.duration) || 100);
- }
- animationInterval = setTimeout(nextFrame, parseInt(frames[0].dataset.duration) || 100);
+ if (animationWorker) animationWorker.terminate();
+ animationWorker = createAnimationWorker();
+ animationWorker.onmessage = advanceFrame;
+
+ scheduleNextFrame(parseInt(frames[0].dataset.duration) || 100);
}
function stopAnimation() {
+ if (animationWorker) { animationWorker.postMessage({ cmd: 'stop' }); animationWorker.terminate(); animationWorker = null; }
if (animationInterval) { clearTimeout(animationInterval); animationInterval = null; }
animationPlaying = false;
animFrames = null;
@@ -298,21 +326,13 @@ playPauseBtn.addEventListener('click', () => {
if (animationPlaying) {
// Pause
animationPlaying = false;
- if (animationInterval) { clearTimeout(animationInterval); animationInterval = null; }
+ if (animationWorker) animationWorker.postMessage({ cmd: 'stop' });
playPauseBtn.innerHTML = '▶';
} else {
// Resume
animationPlaying = true;
playPauseBtn.innerHTML = '⏸';
- function nextFrame() {
- if (!animationPlaying || !animFrames) return;
- animFrames[currentFrameIdx].style.display = 'none';
- currentFrameIdx = (currentFrameIdx + 1) % animFrames.length;
- animFrames[currentFrameIdx].style.display = 'block';
- updateFrameCounter();
- animationInterval = setTimeout(nextFrame, parseInt(animFrames[currentFrameIdx].dataset.duration) || 100);
- }
- animationInterval = setTimeout(nextFrame, parseInt(animFrames[currentFrameIdx].dataset.duration) || 100);
+ scheduleNextFrame(parseInt(animFrames[currentFrameIdx].dataset.duration) || 100);
}
});
diff --git a/static/style.css b/static/style.css
index 1f2b89a..13aedc0 100644
--- a/static/style.css
+++ b/static/style.css
@@ -419,6 +419,14 @@ header {
position: relative;
}
+/* Fixed-width character cells for non-monospace glyphs */
+.preview-area .c {
+ display: inline-block;
+ width: 1ch;
+ text-align: center;
+ overflow: hidden;
+}
+
.placeholder {
position: absolute;
top: 50%;