Files
personal-website/src/scripts/ascii-exporter.ts

181 lines
6.6 KiB
TypeScript

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 downloadText(content: string, filename: string) {
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
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))
};
}
}