Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 100 additions & 19 deletions src/components/Studio.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,14 @@
import { History } from '~/lib/history';
import { detectBackgroundColor, type PathBox } from '~/lib/background';
import { applyEffects, type EffectOptions } from '~/lib/effects';
import { imageToAscii, type AsciiColor, TERMINAL_CELL_ASPECT } from '~/lib/ascii';
import {
imageToAscii,
strokeToAscii,
type AsciiColor,
type AsciiMode,
TERMINAL_CELL_ASPECT,
} from '~/lib/ascii';
import { sampleSvgStrokes } from '~/lib/ascii-stroke-dom';
import { previewView, hasImage } from '~/lib/view-store';
import { buildSizeSet, DEFAULT_SCALES } from '~/lib/export-set';
import {
Expand Down Expand Up @@ -1136,15 +1143,34 @@
return imageToAscii(id.data, id.width, id.height, {
cols: asciiCols,
charAspect: TERMINAL_CELL_ASPECT,
mode: asciiMode,
ramp: asciiRamp,
invert: asciiInvert,
color,
});
}

// Vector mode: sample the composed SVG's path geometry and draw the centreline
// as connected box-drawing art — no rasterise step. Sync (DOM parse); cheap
// enough behind the preview debounce. Returns '' when there's nothing to draw.
function vectorAscii(color: AsciiColor): string | null {
const finalSvg = buildFinalSvg();
if (!finalSvg) return null;
const s = sampleSvgStrokes(finalSvg, asciiCols, TERMINAL_CELL_ASPECT);
if (!s) return '';
return strokeToAscii(s.polylines, {
cols: s.cols,
rows: s.rows,
color,
rounded: asciiRounded,
strokeColor: { r: 0, g: 0, b: 0 },
});
}

/** Build the ASCII rendering for EXPORT (copy / .txt / .ans) — same bytes the
* preview shows. */
async function buildAsciiText(color: AsciiColor = 'none'): Promise<string | null> {
if (isVector) return vectorAscii(color);
const id = await buildAsciiData();
return id ? asciiFrom(id, color) : null;
}
Expand Down Expand Up @@ -1211,7 +1237,37 @@
let asciiBusy = $state(false);
let asciiSeq = 0;
let lastAsciiSig = ''; // inputs that produced the current asciiArt
let asciiRamp = $state<'standard' | 'blocks' | 'detailed'>('standard');
// ASCII style = fill method. The ramp styles drive the density-glyph renderer
// (`ramp` mode); braille/halfblock/edge are the smoother raster modes; `vector`
// is the standalone stroke renderer that samples the SVG geometry directly
// (strokeToAscii) instead of a rasterised buffer. One picker maps to all of it.
type AsciiStyle =
| 'standard'
| 'blocks'
| 'detailed'
| 'braille'
| 'halfblock'
| 'edge'
| 'vector';
let asciiStyle = $state<AsciiStyle>('standard');
// The vector path doesn't go through imageToAscii — it samples paths directly.
const isVector = $derived(asciiStyle === 'vector');
const asciiMode = $derived<AsciiMode>(
asciiStyle === 'braille' || asciiStyle === 'halfblock' || asciiStyle === 'edge'
? asciiStyle
: 'ramp',
);
// Ramp name only matters in 'ramp' mode; harmless default otherwise.
const asciiRamp = $derived(
asciiMode === 'ramp' && !isVector ? (asciiStyle as string) : 'standard',
);
// Seam-fill the tilers/connected line art so glyphs touch across the stretched
// preview line box: solid blocks (█ ▀▄█) and the box-drawing vector strokes.
// Braille dots and sparse edge strokes read best crisp, so leave them be.
const asciiTight = $derived(
asciiStyle === 'blocks' || asciiStyle === 'halfblock' || asciiStyle === 'vector',
);
let asciiRounded = $state(true); // vector: rounded corners (╭╮╯╰) vs sharp (┌┐└┘)
let asciiInvert = $state(false);
let asciiColor = $state(false); // keep each glyph's source colour (web + ANSI)
// Measured advance ratio (glyph width ÷ font-size) of the preview's monospace
Expand Down Expand Up @@ -1244,7 +1300,8 @@
const sig = [
displaySvg,
asciiCols,
asciiRamp,
asciiStyle,
asciiRounded,
asciiInvert,
asciiColor,
backdrop.color,
Expand All @@ -1258,10 +1315,17 @@
const wantColor = asciiColor;
asciiBusy = true;
const timer = setTimeout(async () => {
const id = await buildAsciiData();
if (seq !== asciiSeq) return;
asciiArt = id ? asciiFrom(id, 'none') : '';
asciiHtml = id && wantColor ? asciiFrom(id, 'html') : '';
if (isVector) {
const plain = vectorAscii('none');
if (seq !== asciiSeq) return;
asciiArt = plain ?? '';
asciiHtml = wantColor ? (vectorAscii('html') ?? '') : '';
} else {
const id = await buildAsciiData();
if (seq !== asciiSeq) return;
asciiArt = id ? asciiFrom(id, 'none') : '';
asciiHtml = id && wantColor ? asciiFrom(id, 'html') : '';
}
lastAsciiSig = sig;
asciiBusy = false;
}, 120);
Expand Down Expand Up @@ -2408,17 +2472,34 @@
<span class="value">{asciiCols} cols</span>
</label>
<label class="ascii-opt">
<span>Charset</span>
<select bind:value={asciiRamp} aria-label="ASCII character set">
<option value="standard">Standard</option>
<option value="blocks">Blocks</option>
<option value="detailed">Detailed</option>
<span>Style</span>
<select bind:value={asciiStyle} aria-label="ASCII fill style">
<optgroup label="Density ramp">
<option value="standard">Standard</option>
<option value="blocks">Blocks</option>
<option value="detailed">Detailed</option>
</optgroup>
<optgroup label="Smooth">
<option value="braille">Braille ⣿ (2×4)</option>
<option value="halfblock">Half-block ▀ (2-colour)</option>
<option value="edge">Edge ╱ (outline)</option>
</optgroup>
<optgroup label="Vector">
<option value="vector">Stroke ╭╮ (path-traced)</option>
</optgroup>
</select>
</label>
<label class="ascii-opt">
<input type="checkbox" bind:checked={asciiInvert} />
<span>Invert</span>
</label>
{#if isVector}
<label class="ascii-opt">
<input type="checkbox" bind:checked={asciiRounded} />
<span>Rounded</span>
</label>
{:else}
<label class="ascii-opt">
<input type="checkbox" bind:checked={asciiInvert} />
<span>Invert</span>
</label>
{/if}
<label class="ascii-opt">
<input type="checkbox" bind:checked={asciiColor} />
<span>Color</span>
Expand Down Expand Up @@ -2472,22 +2553,22 @@
ascii={asciiArt}
asciiHtml={asciiColor ? asciiHtml : undefined}
lineHeight={previewLineHeight}
tight={asciiRamp === 'blocks'}
tight={asciiTight}
busy={asciiBusy}
/>
{:else if asciiColor && asciiHtml}
<!-- eslint-disable-next-line svelte/no-at-html-tags — escaped, canvas-derived markup -->
<pre
class="ascii-preview big"
class:busy={asciiBusy}
class:tight={asciiRamp === 'blocks'}
class:tight={asciiTight}
style:line-height={previewLineHeight}
aria-label="ASCII preview">{@html asciiHtml}</pre>
{:else}
<pre
class="ascii-preview big"
class:busy={asciiBusy}
class:tight={asciiRamp === 'blocks'}
class:tight={asciiTight}
style:line-height={previewLineHeight}
aria-label="ASCII preview">{asciiArt || '…'}</pre>
{/if}
Expand Down
16 changes: 16 additions & 0 deletions src/lib/ascii-stroke-dom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { sampleSvgStrokes } from './ascii-stroke-dom';

// The geometry sampling itself needs a real SVG layout (getPointAtLength /
// getCTM), so it's exercised in CI's build + the maintainer's click-test, not
// here. This smoke test just pins the node-safe guard and that the module
// transforms/loads cleanly — the connectivity→glyph logic it feeds is covered
// by the strokeToAscii suite in ascii.test.ts.
describe('sampleSvgStrokes', () => {
it('returns null without a DOM (node environment)', () => {
expect(typeof document).toBe('undefined');
expect(sampleSvgStrokes('<svg viewBox="0 0 10 10"><path d="M0 0 L10 10"/></svg>', 40)).toBe(
null,
);
});
});
152 changes: 152 additions & 0 deletions src/lib/ascii-stroke-dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* Browser-only bridge for the vector-stroke ASCII mode: turn a composed SVG
* string into the grid-space polylines that {@link strokeToAscii} rasterises.
*
* Why this is separate (and untested): it leans on real SVG geometry —
* getTotalLength / getPointAtLength / getCTM — which only exist on elements
* mounted in a rendered document. The pure half (polylines → glyphs) lives in
* ascii.ts and carries the unit tests; this half is the thin DOM membrane.
*
* The studio already owns the real vector (no Sobel guessing): we sample each
* geometry element's centreline, project it through the element's CTM into the
* viewBox, then into the cell grid. Big jumps between sub-paths (M…Z M…Z) break
* the polyline so we never draw a stray line across a hole.
*/
import { TERMINAL_CELL_ASPECT, type StrokePoint } from './ascii';

export interface SampledStrokes {
polylines: StrokePoint[][];
cols: number;
rows: number;
}

const GEOMETRY_SELECTOR = 'path, line, polyline, polygon, circle, ellipse, rect';

/** Parse "rgb(r, g, b)" / "rgba(r, g, b, a)" → channels, or null (e.g. 'none'). */
function parseRgb(value: string): { r: number; g: number; b: number } | null {
const m = value.match(/rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/i);
if (!m) return null;
return { r: +m[1], g: +m[2], b: +m[3] };
}

/** The colour to stamp on a geometry element's samples: its fill, else stroke. */
function elementColor(el: Element): { r: number; g: number; b: number } | null {
const cs = getComputedStyle(el);
if (cs.fill && cs.fill !== 'none') {
const c = parseRgb(cs.fill);
if (c) return c;
}
if (cs.stroke && cs.stroke !== 'none') {
const c = parseRgb(cs.stroke);
if (c) return c;
}
return null;
}

/**
* Sample an SVG string into grid-space polylines. `cols` is the target width in
* characters; rows follow from the viewBox aspect and the terminal cell aspect,
* matching imageToAscii so the vector and raster modes line up.
*
* Returns null if the SVG can't be parsed or has no usable viewBox/geometry.
*/
export function sampleSvgStrokes(
svgText: string,
cols: number,
charAspect: number = TERMINAL_CELL_ASPECT,
): SampledStrokes | null {
if (typeof document === 'undefined') return null;

const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml');
const root = doc.documentElement;
if (!root || root.nodeName === 'parsererror' || root.tagName.toLowerCase() !== 'svg') return null;

// Resolve the user-space box: explicit viewBox, else width/height.
const vb = root.getAttribute('viewBox');
let vbX = 0;
let vbY = 0;
let vbW = 0;
let vbH = 0;
if (vb) {
const n = vb
.trim()
.split(/[\s,]+/)
.map(Number);
if (n.length === 4 && n.every((v) => Number.isFinite(v))) [vbX, vbY, vbW, vbH] = n;
}
if (!(vbW > 0 && vbH > 0)) {
vbW = parseFloat(root.getAttribute('width') ?? '') || 0;
vbH = parseFloat(root.getAttribute('height') ?? '') || 0;
}
if (!(vbW > 0 && vbH > 0)) return null;

const c = Math.max(1, Math.floor(cols));
const rows = Math.max(1, Math.round(c * (vbH / vbW) * charAspect));

// Mount offscreen so getCTM / getPointAtLength have a live layout to read.
// Force the viewport to the viewBox size so the viewBox→viewport transform is
// 1:1 (translate(-vbX,-vbY), scale 1): getCTM then maps element-local space to
// user space minus the viewBox origin, exactly what toGrid expects below.
const host = document.createElement('div');
host.setAttribute(
'style',
'position:absolute;left:-99999px;top:0;width:0;height:0;overflow:hidden',
);
const mounted = document.importNode(root, true) as SVGSVGElement;
mounted.setAttribute('width', String(vbW));
mounted.setAttribute('height', String(vbH));
mounted.setAttribute('preserveAspectRatio', 'xMidYMid meet');
host.appendChild(mounted);
document.body.appendChild(host);

// px,py are post-CTM viewport coords (origin already at the viewBox corner).
const toGrid = (px: number, py: number): [number, number] => [(px / vbW) * c, (py / vbH) * rows];
// A jump larger than this (in cells) means a sub-path hop — break the line.
const BREAK = 1.8;
// Sample roughly every 0.4 cells along each path.
const stepUser = (vbW / c) * 0.4 || 1;
const MAX_SAMPLES = 40000;

const polylines: StrokePoint[][] = [];
try {
const geoms = Array.from(mounted.querySelectorAll(GEOMETRY_SELECTOR));
let budget = MAX_SAMPLES;
for (const el of geoms) {
const geo = el as SVGGeometryElement;
let total = 0;
try {
total = geo.getTotalLength();
} catch {
continue;
}
if (!(total > 0)) continue;
const ctm = geo.getCTM();
const col = elementColor(el) ?? undefined;

let cur: StrokePoint[] = [];
let prev: [number, number] | null = null;
const steps = Math.min(budget, Math.max(1, Math.ceil(total / stepUser)));
for (let i = 0; i <= steps && budget > 0; i++) {
budget--;
const raw = geo.getPointAtLength((i / steps) * total);
// With a CTM the point lands in viewport space (origin at viewBox corner);
// without one it's raw user space, so drop the viewBox origin ourselves.
const pt = ctm ? raw.matrixTransform(ctm) : { x: raw.x - vbX, y: raw.y - vbY };
const [gx, gy] = toGrid(pt.x, pt.y);
if (prev && Math.hypot(gx - prev[0], gy - prev[1]) > BREAK) {
if (cur.length > 1) polylines.push(cur);
cur = [];
}
cur.push(col ? { x: gx, y: gy, ...col } : { x: gx, y: gy });
prev = [gx, gy];
}
if (cur.length > 1) polylines.push(cur);
if (budget <= 0) break;
}
} finally {
document.body.removeChild(host);
}

if (!polylines.length) return null;
return { polylines, cols: c, rows };
}
Loading
Loading