181 lines
6.6 KiB
TypeScript
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 === '<' ? '<' : char === '>' ? '>' : char === '&' ? '&' : 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))
|
|
};
|
|
}
|
|
}
|