feat(export): add export options (PNG, text, HTML)

This commit is contained in:
syntaxbullet
2026-02-09 22:37:53 +01:00
parent a137a98377
commit ea05b814b4
5 changed files with 289 additions and 17 deletions

View File

@@ -206,6 +206,34 @@ import Tooltip from "../components/Tooltip.astro";
</div> </div>
</div> </div>
</div> </div>
<!-- Divider -->
<div class="control-panel-divider"></div>
<!-- Export Section -->
<div class="control-panel-section export-section">
<div class="section-header">EXPORT</div>
<div class="actions-row">
<TuiButton
id="btn-save-png"
label="PNG"
title="Save as Image"
description="Download high-res PNG capture of the current view."
/>
<TuiButton
id="btn-copy-text"
label="TXT"
title="Copy Text"
description="Copy raw ASCII text to clipboard."
/>
<TuiButton
id="btn-copy-html"
label="HTML"
title="Copy HTML"
description="Copy colored HTML span elements to clipboard."
/>
</div>
</div>
</div> </div>
<!-- Keyboard shortcuts hint --> <!-- Keyboard shortcuts hint -->

View File

@@ -3,25 +3,13 @@
* Manages state, render loop, and grid calculations. * Manages state, render loop, and grid calculations.
*/ */
import { CHAR_SETS, type CharSetKey, type AsciiOptions } from './ascii-shared'; import { CHAR_SETS, type CharSetKey, type AsciiOptions, type AsciiSettings } from './ascii-shared';
import { WebGLAsciiRenderer, type RenderOptions } from './webgl-ascii'; import { WebGLAsciiRenderer, type RenderOptions } from './webgl-ascii';
import { AsciiExporter } from './ascii-exporter';
// ============= Types ============= // ============= Types =============
export interface AsciiSettings {
exposure: number;
contrast: number;
saturation: number;
gamma: number;
invert: boolean;
color: boolean;
dither: number;
denoise: boolean;
edgeMode: number;
overlayStrength: number;
resolution: number;
charSet: CharSetKey;
}
export interface GridCache { export interface GridCache {
widthCols: number; widthCols: number;
@@ -426,6 +414,35 @@ export class AsciiController {
} }
} }
// ============= Export =============
savePNG(): void {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
AsciiExporter.downloadPNG(this.canvas, `ascii-art-${timestamp}.png`);
}
async copyText(): Promise<void> {
if (!this.cachedGrid.imgEl) return;
const text = AsciiExporter.generateText(
this.cachedGrid.imgEl,
this.settings,
this.cachedGrid.widthCols,
Math.floor(this.cachedGrid.heightRows)
);
await AsciiExporter.copyToClipboard(text);
}
async copyHTML(): Promise<void> {
if (!this.cachedGrid.imgEl) return;
const html = AsciiExporter.generateHTML(
this.cachedGrid.imgEl,
this.settings,
this.cachedGrid.widthCols,
Math.floor(this.cachedGrid.heightRows)
);
await AsciiExporter.copyToClipboard(html);
}
// ============= Utilities ============= // ============= Utilities =============
private resolveImage(src: string): Promise<HTMLImageElement> { private resolveImage(src: string): Promise<HTMLImageElement> {

View File

@@ -0,0 +1,171 @@
import { CHAR_SETS, type AsciiSettings } from './ascii-shared';
export class AsciiExporter {
static downloadPNG(canvas: HTMLCanvasElement, filename = 'ascii-art.png') {
const link = document.createElement('a');
link.download = filename;
link.href = canvas.toDataURL('image/png');
link.click();
}
static async copyToClipboard(text: string): Promise<void> {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
// Fallback
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
}
static generateText(
img: HTMLImageElement,
settings: AsciiSettings,
widthCols: number,
heightRows: number
): string {
const { pixels } = this.getPixels(img, widthCols, heightRows);
let output = "";
const charSet = CHAR_SETS[settings.charSet] || CHAR_SETS.standard;
const charCount = charSet.length;
for (let y = 0; y < heightRows; y++) {
for (let x = 0; x < widthCols; x++) {
const i = (y * widthCols + x) * 4;
const r = pixels[i];
const g = pixels[i + 1];
const b = pixels[i + 2];
// const a = pixels[i + 3]; // Ignore alpha for now
// 1. Adjust Color (Exposure, Contrast, Saturation, Gamma)
const adjColor = this.adjustColor(r, g, b, settings);
// 2. Calculate Luma from adjusted color
let luma = (0.2126 * adjColor.r + 0.7152 * adjColor.g + 0.0722 * adjColor.b) / 255;
// 3. Map to Char
if (settings.invert) luma = 1.0 - luma;
const charIndex = Math.floor(luma * (charCount - 1) + 0.5);
const char = charSet[Math.max(0, Math.min(charCount - 1, charIndex))];
output += char;
}
output += "\n";
}
return output;
}
static generateHTML(
img: HTMLImageElement,
settings: AsciiSettings,
widthCols: number,
heightRows: number
): string {
const { pixels } = this.getPixels(img, widthCols, heightRows);
let output = `<pre style="font-family: monospace; line-height: 1; letter-spacing: 0; background-color: #000; color: #fff; font-size: 8px;">`;
const charSet = CHAR_SETS[settings.charSet] || CHAR_SETS.standard;
const charCount = charSet.length;
for (let y = 0; y < heightRows; y++) {
for (let x = 0; x < widthCols; x++) {
const i = (y * widthCols + x) * 4;
const r = pixels[i];
const g = pixels[i + 1];
const b = pixels[i + 2];
// 1. Calculate Luma
let luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
// 2. Apply Adjustments
// Note: For color mode, we might want to keep original color
// but apply luma to alpha or just use luma for char selection.
// The shader uses `color` argument.
// Adjust color for display
const adjColor = this.adjustColor(r, g, b, settings);
// Recalculate luma from adjusted color for char selection
let finalLuma = (0.2126 * adjColor.r + 0.7152 * adjColor.g + 0.0722 * adjColor.b) / 255;
// Adjust luma curve for char selection (gamma/contrast/exposure) explicitly?
// Actually `adjustColor` does that.
if (settings.invert) finalLuma = 1.0 - finalLuma;
const charIndex = Math.floor(finalLuma * (charCount - 1) + 0.5);
const char = charSet[Math.max(0, Math.min(charCount - 1, charIndex))];
// Escape HTML
const safeChar = char === '<' ? '&lt;' : char === '>' ? '&gt;' : char === '&' ? '&amp;' : char;
const colorStyle = `color: rgb(${Math.round(adjColor.r)}, ${Math.round(adjColor.g)}, ${Math.round(adjColor.b)})`;
output += `<span style="${colorStyle}">${safeChar}</span>`;
}
output += "<br>";
}
output += "</pre>";
return output;
}
private static getPixels(img: HTMLImageElement, width: number, height: number) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error("Canvas 2D context not available");
// High quality downscaling
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, width, height);
return {
pixels: ctx.getImageData(0, 0, width, height).data,
ctx
};
}
private static adjustColor(r: number, g: number, b: number, settings: AsciiSettings): { r: number, g: number, b: number } {
let rNorm = r / 255;
let gNorm = g / 255;
let bNorm = b / 255;
// Exposure
rNorm *= settings.exposure;
gNorm *= settings.exposure;
bNorm *= settings.exposure;
// Contrast
rNorm = (rNorm - 0.5) * settings.contrast + 0.5;
gNorm = (gNorm - 0.5) * settings.contrast + 0.5;
bNorm = (bNorm - 0.5) * settings.contrast + 0.5;
// Saturation
const luma = 0.2126 * rNorm + 0.7152 * gNorm + 0.0722 * bNorm;
rNorm = luma + (rNorm - luma) * settings.saturation;
gNorm = luma + (gNorm - luma) * settings.saturation;
bNorm = luma + (bNorm - luma) * settings.saturation;
// Gamma
rNorm = Math.pow(Math.max(0, rNorm), settings.gamma);
gNorm = Math.pow(Math.max(0, gNorm), settings.gamma);
bNorm = Math.pow(Math.max(0, bNorm), settings.gamma);
return {
r: Math.max(0, Math.min(255, rNorm * 255)),
g: Math.max(0, Math.min(255, gNorm * 255)),
b: Math.max(0, Math.min(255, bNorm * 255))
};
}
}

View File

@@ -42,6 +42,21 @@ export interface ImageMetadata {
has_fine_detail?: boolean; has_fine_detail?: boolean;
} }
export interface AsciiSettings {
exposure: number;
contrast: number;
saturation: number;
gamma: number;
invert: boolean;
color: boolean;
dither: number;
denoise: boolean;
edgeMode: number;
overlayStrength: number;
resolution: number;
charSet: CharSetKey;
}
// ============= Constants ============= // ============= Constants =============
export const CHAR_SETS: Record<CharSetKey, string> = { export const CHAR_SETS: Record<CharSetKey, string> = {

View File

@@ -3,8 +3,8 @@
* Event listeners, keyboard shortcuts, and UI synchronization. * Event listeners, keyboard shortcuts, and UI synchronization.
*/ */
import { CHARSET_SHORT_MAP, CHARSET_REVERSE_MAP } from './ascii-shared'; import { CHARSET_SHORT_MAP, CHARSET_REVERSE_MAP, type AsciiSettings } from './ascii-shared';
import type { AsciiController, AsciiSettings } from './ascii-controller'; import type { AsciiController } from './ascii-controller';
import type { ImageQueue } from './image-queue'; import type { ImageQueue } from './image-queue';
// ============= Window Extensions ============= // ============= Window Extensions =============
@@ -136,9 +136,19 @@ export class UIBindings {
// Cleanup Resize // Cleanup Resize
if (this.resizeHandler) { if (this.resizeHandler) {
window.removeEventListener('resize', this.resizeHandler);
window.removeEventListener('resize', this.resizeHandler); window.removeEventListener('resize', this.resizeHandler);
this.resizeHandler = null; this.resizeHandler = null;
} }
// Cleanup Export Buttons
['btn-save-png', 'btn-copy-text', 'btn-copy-html'].forEach(id => {
const el = document.getElementById(id);
const handler = this.buttonHandlers.get(id);
if (el && handler) {
el.removeEventListener('click', handler);
}
});
} }
// ============= Sliders ============= // ============= Sliders =============
@@ -284,6 +294,37 @@ export class UIBindings {
this.buttonHandlers.set('btn-next', handler); this.buttonHandlers.set('btn-next', handler);
btnNext.addEventListener('click', handler); btnNext.addEventListener('click', handler);
} }
// Export Buttons
const btnSavePng = document.getElementById('btn-save-png');
if (btnSavePng) {
const handler = (e: Event) => {
e.stopPropagation();
this.controller.savePNG();
};
this.buttonHandlers.set('btn-save-png', handler);
btnSavePng.addEventListener('click', handler);
}
const btnCopyText = document.getElementById('btn-copy-text');
if (btnCopyText) {
const handler = async (e: Event) => {
e.stopPropagation();
await this.controller.copyText();
};
this.buttonHandlers.set('btn-copy-text', handler);
btnCopyText.addEventListener('click', handler);
}
const btnCopyHtml = document.getElementById('btn-copy-html');
if (btnCopyHtml) {
const handler = async (e: Event) => {
e.stopPropagation();
await this.controller.copyHTML();
};
this.buttonHandlers.set('btn-copy-html', handler);
btnCopyHtml.addEventListener('click', handler);
}
} }
// ============= Keyboard ============= // ============= Keyboard =============