Skip to content
Merged
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
28 changes: 28 additions & 0 deletions src/shared/utils/pieceUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';

import { intrinsicPxOf } from './pieceUtils';

test('intrinsicPxOf converts px/mm/pt dimensions to pixels', () => {
assert.equal(intrinsicPxOf('<svg width="512" height="512"/>'), 512);
assert.equal(
intrinsicPxOf('<svg width="50mm" height="50mm" viewBox="0 0 50 50"/>'),
189
);
assert.equal(
intrinsicPxOf('<svg width="700pt" height="700pt" viewBox="0 0 933 933"/>'),
933
);
assert.equal(intrinsicPxOf('<svg viewBox="0 0 45 45"/>'), 0);
});

test('intrinsicPxOf ignores child element width/height attributes', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 45" width="45" height="45">' +
'<rect width="9999" height="9999" fill="none"/></svg>';
assert.equal(intrinsicPxOf(svg), 45);
});

test('intrinsicPxOf returns 0 when no svg tag is present', () => {
assert.equal(intrinsicPxOf('not an svg string'), 0);
});
56 changes: 52 additions & 4 deletions src/shared/utils/pieceUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ export function getCachedPieceStyle(
const MAX_DATA_URL_CACHE = 48;
const pieceDataUrlCache = new Map<string, string>();

const MIN_PIECE_INTRINSIC_PX = 64;
const MAX_PIECE_INTRINSIC_PX = 2048;

const FALLBACK_PIECE_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 45">' +
'<circle cx="22.5" cy="11" r="7" fill="#555"/>' +
Expand Down Expand Up @@ -193,14 +196,49 @@ function toBase64Utf8(text: string): string {
return btoa(binary);
}

export function intrinsicPxOf(svgText: string): number {
const rootMatch = /<svg([^>]*)>/i.exec(svgText);
if (!rootMatch) return 0;
const rootAttrs = rootMatch[1] ?? '';
let maxPx = 0;
const re = /\b(?:width|height)\s*=\s*"([0-9.]+)\s*(px|mm|cm|pt)?"/gi;
let match: RegExpExecArray | null;
while ((match = re.exec(rootAttrs))) {
const value = parseFloat(match[1] ?? '');
if (!Number.isFinite(value)) continue;
const unit = (match[2] ?? 'px').toLowerCase();
let px = value;
if (unit === 'mm') px = (value / 25.4) * 96;
else if (unit === 'cm') px = (value / 2.54) * 96;
else if (unit === 'pt') px = (value / 72) * 96;
maxPx = Math.max(maxPx, px);
}
return Math.round(maxPx);
}

function resizePieceSvg(svgText: string, targetPx: number): string {
const size = String(Math.round(targetPx));
const resized = svgText.replace(/<svg([^>]*?)>/i, (_match, attrs: string) => {
let next = attrs;
next = next.replace(/\s+width\s*=\s*"[^"]*"/i, ` width="${size}"`);
next = next.replace(/\s+height\s*=\s*"[^"]*"/i, ` height="${size}"`);
if (!/\swidth\s*=/.test(next)) next += ` width="${size}"`;
if (!/\sheight\s*=/.test(next)) next += ` height="${size}"`;
return `<svg${next}>`;
});
return resized === svgText ? svgText : resized;
}
Comment on lines +219 to +230

export async function imageToEmbeddableDataURL(
img: HTMLImageElement
img: HTMLImageElement,
targetSize: number = 0
): Promise<string> {
if (!img) return '';
const src = img.currentSrc || img.src || '';
if (!src) return '';

const cached = pieceDataUrlCache.get(src);
const cacheKey = targetSize > 0 ? `${src}|${targetSize}` : src;
const cached = pieceDataUrlCache.get(cacheKey);
if (cached) return cached;

let dataUrl = '';
Expand Down Expand Up @@ -237,7 +275,17 @@ export async function imageToEmbeddableDataURL(
signal: controller.signal
});
if (response.ok) {
const svgText = await response.text();
let svgText = await response.text();
if (targetSize > 0) {
const intrinsic = intrinsicPxOf(svgText);
const target = Math.min(
MAX_PIECE_INTRINSIC_PX,
Math.max(MIN_PIECE_INTRINSIC_PX, intrinsic, targetSize)
);
if (target > intrinsic) {
svgText = resizePieceSvg(svgText, target);
}
}
dataUrl = `data:image/svg+xml;base64,${toBase64Utf8(svgText)}`;
}
} catch (err: unknown) {
Expand All @@ -251,7 +299,7 @@ export async function imageToEmbeddableDataURL(
if (!dataUrl) dataUrl = await imageToDataURL(img);
if (!dataUrl) dataUrl = FALLBACK_PIECE_DATA_URL;

pieceDataUrlCache.set(src, dataUrl);
pieceDataUrlCache.set(cacheKey, dataUrl);
evictOldest(pieceDataUrlCache, MAX_DATA_URL_CACHE);
return dataUrl;
}
4 changes: 3 additions & 1 deletion src/shared/utils/svgExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,12 @@ export async function generateBoardSVG(
Object.values(pieceImages).map((img) => waitForPieceImage(img))
);

const pieceOutputPx = Math.ceil((boardSize * 1200) / 20.32);

await Promise.all(
Object.entries(pieceImages).map(async ([key, img]) => {
if (img && img.complete && img.naturalWidth > 0) {
pieceDataURLs[key] = await imageToEmbeddableDataURL(img);
pieceDataURLs[key] = await imageToEmbeddableDataURL(img, pieceOutputPx);
}
})
);
Expand Down
Loading