feat(studio): add Download PNG button

Rasterizes the live prompt SVG to a PNG on a hidden canvas. The font
is embedded as a base64 data URI in the SVG before rasterizing, since
an Image() loaded from a blob URL cannot see the page's own
@font-face and would otherwise fall back to a generic monospace font.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Entire-Checkpoint: 0401007e6324
This commit is contained in:
Jan De Dobbeleer
2026-07-30 19:44:41 +02:00
committed by Jan De Dobbeleer
co-authored by Copilot App
parent 73bda6801d
commit 8528d35a7a
3 changed files with 186 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
// Rasterizes the studio's live SVG preview into a downloadable PNG.
//
// The SVG's <text> elements render in "Victor Mono" purely by inheriting the *page's* own
// @font-face (see src/css/custom.css / font-metrics.mjs's FONT_FAMILY doc comment) - the SVG
// markup itself carries no font. That is fine for dangerouslySetInnerHTML, which paints inside
// the document, but an <img>/Image() loaded from a blob: URL renders in a sandboxed context with
// no access to the page's stylesheets: without a font of its own, every character cell would
// fall back to a generic monospace font and drift off the grid the SVG's textLength/lengthAdjust
// attributes assume. The fix - the same one every "SVG to PNG" tool uses - is to embed the font
// *inside* the SVG as a base64 data URI before rasterizing it, so the image is fully
// self-contained.
const FONT_URL = '/fonts/VictorMono.ttf';
// Fetched at most once per page load and cached: the font is already sitting in the browser's
// HTTP cache (the page itself loaded it for the on-screen preview), so this only costs a
// base64-encoding pass, not a second network round trip.
let fontDataUriPromise = null;
function getFontDataUri() {
if (!fontDataUriPromise) {
fontDataUriPromise = fetch(FONT_URL)
.then((res) => res.blob())
.then(
(blob) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
}),
);
}
return fontDataUriPromise;
}
// The SVG's own width/height (svg.go's newCanvasSize) are sized for a 1x screen; rasterizing at
// that size would look soft once downloaded and viewed at natural size or shared/embedded
// elsewhere. 2x matches a typical retina screenshot without ballooning the file for what is
// usually a few short lines of text.
const EXPORT_SCALE = 2;
const SVG_DIMENSIONS_RE = /<svg[^>]*\swidth="([\d.]+)"[^>]*\sheight="([\d.]+)"/;
// svgText is the exact markup already on screen (the same string useWasmRenderer handed to
// dangerouslySetInnerHTML) - this re-encodes it for export rather than re-rendering anything.
export async function exportSvgAsPng(svgText, filename = 'oh-my-posh-prompt.png') {
const fontDataUri = await getFontDataUri();
// Inserted as the very first child of <svg>, right after the opening tag, so the font-face
// exists before the browser lays out any <text> - a plain string splice is enough here since
// the target is exactly one well-known opening tag, not arbitrary markup.
const withFont = svgText.replace(
/(<svg[^>]*>)/,
`$1<style>@font-face { font-family: "Victor Mono"; src: url(${fontDataUri}) format("truetype"); }</style>`,
);
const dimensions = svgText.match(SVG_DIMENSIONS_RE);
const width = dimensions ? parseFloat(dimensions[1]) : null;
const height = dimensions ? parseFloat(dimensions[2]) : null;
const svgBlob = new Blob([withFont], { type: 'image/svg+xml;charset=utf-8' });
const svgUrl = URL.createObjectURL(svgBlob);
try {
const image = new Image();
await new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = () => reject(new Error('Failed to load the rendered prompt as an image.'));
image.src = svgUrl;
});
// decode() waits for the image's pixels to actually be ready to paint. onload alone fires
// once the SVG document has parsed, which is not the same guarantee for a data-URI
// @font-face's glyphs - without this, a fast machine could rasterize the very first frame
// with a fallback font while the embedded one was still being parsed.
if (image.decode) {
await image.decode();
}
const canvas = document.createElement('canvas');
canvas.width = (width ?? image.naturalWidth) * EXPORT_SCALE;
canvas.height = (height ?? image.naturalHeight) * EXPORT_SCALE;
const ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
const pngBlob = await new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('Failed to encode the PNG.'))),
'image/png',
);
});
const pngUrl = URL.createObjectURL(pngBlob);
try {
const link = document.createElement('a');
link.href = pngUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
} finally {
URL.revokeObjectURL(pngUrl);
}
} finally {
URL.revokeObjectURL(svgUrl);
}
}
+44
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import classnames from 'classnames';
import ConfigEditor from '../ConfigEditor';
import WasmMessage from '../ConfigEditor/WasmMessage';
import { useWasmRenderer } from '../ConfigEditor/useWasmRenderer';
@@ -12,6 +13,7 @@ import {
trySessionStorageRemove,
trySessionStorageSet,
} from '../ConfigEditor/studioHandoff';
import { exportSvgAsPng } from './exportPng';
import { CONFIG_FORMAT, CONFIG_FORMATS, STARTERS } from './config';
import styles from './styles.module.css';
@@ -25,6 +27,47 @@ const RENDER_OPTIONS = buildRenderOptions(120);
// preview still feels live.
const DEBOUNCE_MS = 200;
// Rasterizes the current preview svg (see exportPng.js) and saves it as a PNG. Disabled - inert,
// but still focusable - whenever there is no svg to export, same convention as Config.js's own
// "Add to Studio" action.
function DownloadPngButton({ svg, disabled }) {
const [state, setState] = useState('idle');
const handleClick = useCallback(async () => {
if (disabled || !svg) {
return;
}
setState('exporting');
try {
await exportSvgAsPng(svg);
setState('idle');
} catch {
// The embedded font fetch, image decode, or canvas encode can each fail independently
// (e.g. a browser without canvas.toBlob); there is nothing more specific to tell the
// reader beyond "try again", so the label itself carries the failure.
setState('error');
}
}, [svg, disabled]);
const label =
state === 'exporting' ? 'Exporting…' : state === 'error' ? 'Failed - retry' : 'Download PNG';
return (
<button
type="button"
className={classnames(styles.action, {
[styles.actionDisabled]: disabled || state === 'exporting',
})}
aria-disabled={disabled || state === 'exporting'}
onClick={handleClick}
>
{label}
</button>
);
}
function Studio() {
const [format, setFormat] = useState(CONFIG_FORMAT);
const [configText, setConfigText] = useState(STARTERS[CONFIG_FORMAT]);
@@ -281,6 +324,7 @@ function Studio() {
onFormatChange={handleFormatChange}
value={configText}
onChange={handleChange}
actions={<DownloadPngButton svg={svg} disabled={!svg} />}
/>
</div>
);
@@ -69,3 +69,34 @@
font-size: var(--omp-text-body);
color: var(--ifm-color-danger);
}
/* "Download PNG", rendered into ConfigEditor's actions slot (see index.js). Same bordered-pill
recipe as Config.module.css's own .action (Copy / Add to Studio on a segment doc) - duplicated
rather than imported since the two live in sibling components with no existing shared module
for it, same as ConfigEditor/styles.module.css's .actions container already being duplicated
in spirit across both. */
.action {
padding: 0.1rem 0.4rem;
font-family: var(--ifm-font-family-monospace);
font-size: var(--omp-label-size);
letter-spacing: var(--omp-label-tracking);
text-transform: uppercase;
color: var(--omp-text-secondary);
background: none;
border: 1px solid var(--omp-card-border-color);
border-radius: var(--omp-radius-sm);
cursor: pointer;
text-decoration: none;
}
.action:hover {
color: var(--ifm-color-primary);
border-color: var(--ifm-color-primary);
text-decoration: none;
}
.actionDisabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}