UNPKG

@tldraw/editor

Version:

tldraw infinite canvas SDK (editor).

353 lines (301 loc) • 10.3 kB
import { FileHelpers, Image, PngHelpers, sleep } from '@tldraw/utils' import { tlenv } from '../globals/environment' import { clampToBrowserMaxCanvasSize } from '../utils/browserCanvasMaxSize' import { debugFlags } from '../utils/debug-flags' import { getGlobalDocument } from '../utils/dom' /** @public */ export async function getSvgAsImage( svgString: string, options: { type: 'png' | 'jpeg' | 'webp' width: number height: number quality?: number pixelRatio?: number } ) { const result = await getSvgAsImageWithOptions(svgString, options) return result?.blob ?? null } /** @internal */ export async function getSvgAsImageWithOptions( svgString: string, options: { type: 'png' | 'jpeg' | 'webp' width: number height: number quality?: number pixelRatio?: number trimPadding?: number scale?: number } ): Promise<{ blob: Blob; width: number; height: number } | null> { const { type, width, height, quality = 1, pixelRatio = 2, trimPadding = 0, scale = 1 } = options if (width <= 0 || height <= 0) return null let [clampedWidth, clampedHeight] = clampToBrowserMaxCanvasSize( width * pixelRatio, height * pixelRatio ) clampedWidth = Math.floor(clampedWidth) clampedHeight = Math.floor(clampedHeight) const effectiveScale = clampedWidth / width const canvas = await renderSvgToCanvas(svgString, clampedWidth, clampedHeight) if (!canvas) return null // If we rendered with extra padding to capture visual overflow, trim it now const outputCanvas = trimPadding > 0 ? trimExtraPadding(canvas, trimPadding * scale * effectiveScale) : { canvas, width: clampedWidth, height: clampedHeight } const blob = await new Promise<Blob | null>((resolve) => outputCanvas.canvas.toBlob( (blob) => { if (!blob || debugFlags.throwToBlob.get()) { resolve(null) } resolve(blob) }, 'image/' + type, quality ) ) if (!blob) return null let resultBlob: Blob if (type === 'png') { resultBlob = PngHelpers.setPhysChunk(new DataView(await blob.arrayBuffer()), effectiveScale, { type: 'image/' + type, }) } else { resultBlob = blob } return { blob: resultBlob, width: outputCanvas.width / effectiveScale, height: outputCanvas.height / effectiveScale, } } async function renderSvgToCanvas( svgString: string, width: number, height: number ): Promise<HTMLCanvasElement | null> { // usually we would use `URL.createObjectURL` here, but chrome has a bug where `blob:` URLs of // SVGs that use <foreignObject> mark the canvas as tainted, where data: ones do not. // https://issues.chromium.org/issues/41054640 const svgUrl = await FileHelpers.blobToDataUrl(new Blob([svgString], { type: 'image/svg+xml' })) return new Promise<HTMLCanvasElement | null>((resolve) => { const image = Image() image.crossOrigin = 'anonymous' image.onload = async () => { // safari will fire `onLoad` before the fonts in the SVG are // actually loaded. just waiting around a while is brittle, but // there doesn't seem to be any better solution for now :( see // https://bugs.webkit.org/show_bug.cgi?id=219770 if (tlenv.isSafari) { await sleep(250) } const canvas = getGlobalDocument().createElement('canvas') as HTMLCanvasElement const ctx = canvas.getContext('2d')! canvas.width = width canvas.height = height ctx.imageSmoothingEnabled = true ctx.imageSmoothingQuality = 'high' ctx.drawImage(image, 0, 0, width, height) resolve(canvas) } image.onerror = () => { resolve(null) } image.src = svgUrl }) } /** * Scans a canvas from each edge inward (up to trimPaddingPx pixels) to find * the first row/column containing non-background content. Returns the crop * rectangle in canvas pixel coordinates, or null if no trimming is needed. */ function measureContentBounds( canvas: HTMLCanvasElement, trimPaddingPx: number ): { cropLeft: number; cropTop: number; cropRight: number; cropBottom: number } | null { const w = canvas.width const h = canvas.height const ctx = canvas.getContext('2d')! const extraPx = Math.ceil(trimPaddingPx) // Nothing to trim if the extra padding is negligible or larger than half the canvas // (extraPx * 2 >= w means declaredRight <= declaredLeft, producing zero/negative crop) if (extraPx <= 0 || extraPx * 2 >= w || extraPx * 2 >= h) return null const imageData = ctx.getImageData(0, 0, w, h) const data = imageData.data // Determine how to detect "empty" pixels. // Sample the corner pixel to detect the background color. const cornerR = data[0] const cornerG = data[1] const cornerB = data[2] const cornerA = data[3] const hasTransparentBackground = cornerA === 0 function isContentPixel(offset: number): boolean { if (hasTransparentBackground) { // For transparent background, any non-transparent pixel is content return data[offset + 3] > 0 } else { // For opaque background, look for pixels that differ from the background const a = data[offset + 3] if (a !== cornerA) return true const r = data[offset] const g = data[offset + 1] const b = data[offset + 2] return r !== cornerR || g !== cornerG || b !== cornerB } } // The declared bounds area (content area without extra padding) const declaredLeft = extraPx const declaredTop = extraPx const declaredRight = w - extraPx const declaredBottom = h - extraPx // Scan from top edge inward: find first row with content or declared bounds let cropTop = declaredTop for (let y = 0; y < declaredTop; y++) { let hasContent = false for (let x = 0; x < w; x++) { if (isContentPixel((y * w + x) * 4)) { hasContent = true break } } if (hasContent) { cropTop = y break } } // Scan from bottom edge inward let cropBottom = declaredBottom for (let y = h - 1; y >= declaredBottom; y--) { let hasContent = false for (let x = 0; x < w; x++) { if (isContentPixel((y * w + x) * 4)) { hasContent = true break } } if (hasContent) { cropBottom = y + 1 break } } // Scan from left edge inward let cropLeft = declaredLeft for (let x = 0; x < declaredLeft; x++) { let hasContent = false for (let y = cropTop; y < cropBottom; y++) { if (isContentPixel((y * w + x) * 4)) { hasContent = true break } } if (hasContent) { cropLeft = x break } } // Scan from right edge inward let cropRight = declaredRight for (let x = w - 1; x >= declaredRight; x--) { let hasContent = false for (let y = cropTop; y < cropBottom; y++) { if (isContentPixel((y * w + x) * 4)) { hasContent = true break } } if (hasContent) { cropRight = x + 1 break } } // If no trimming needed (content fills or exceeds the entire render area) if (cropLeft === 0 && cropTop === 0 && cropRight === w && cropBottom === h) { return null } return { cropLeft, cropTop, cropRight, cropBottom } } /** * Trims extra padding from a canvas by scanning from each edge inward to find * non-transparent (or non-background) pixels. Stops at either content pixels or * the declared bounds (the area without extra padding). */ function trimExtraPadding( canvas: HTMLCanvasElement, trimPaddingPx: number ): { canvas: HTMLCanvasElement; width: number; height: number } { const w = canvas.width const h = canvas.height const bounds = measureContentBounds(canvas, trimPaddingPx) if (!bounds) return { canvas, width: w, height: h } const { cropLeft, cropTop, cropRight, cropBottom } = bounds const cropW = cropRight - cropLeft const cropH = cropBottom - cropTop // Create a new cropped canvas const croppedCanvas = getGlobalDocument().createElement('canvas') croppedCanvas.width = cropW croppedCanvas.height = cropH const croppedCtx = croppedCanvas.getContext('2d')! croppedCtx.drawImage(canvas, cropLeft, cropTop, cropW, cropH, 0, 0, cropW, cropH) return { canvas: croppedCanvas, width: cropW, height: cropH } } /** * Trims an SVG string to its visual content bounds by rendering it to a * temporary canvas, measuring the actual content area, then adjusting the * SVG's viewBox and dimensions to match. * * @param svgString - The SVG string to trim. * @param options - Options for trimming. * @returns The trimmed SVG string with updated dimensions, or null if no trimming was needed. * * @internal */ export async function trimSvgToContent( svgString: string, options: { width: number height: number trimPadding: number scale: number } ): Promise<{ svg: string; width: number; height: number } | null> { const { width, height, trimPadding, scale } = options if (trimPadding <= 0) return null // Render SVG to a temporary canvas at 1:1 pixel ratio const canvasWidth = Math.floor(width) const canvasHeight = Math.floor(height) if (canvasWidth <= 0 || canvasHeight <= 0) return null const canvas = await renderSvgToCanvas(svgString, canvasWidth, canvasHeight) if (!canvas) return null // Measure content bounds on the canvas const trimPaddingPx = trimPadding * scale const bounds = measureContentBounds(canvas, trimPaddingPx) if (!bounds) return null const { cropLeft, cropTop, cropRight, cropBottom } = bounds // Parse the SVG to get the current viewBox const parser = new DOMParser() const doc = parser.parseFromString(svgString, 'image/svg+xml') const svgEl = doc.documentElement const viewBoxAttr = svgEl.getAttribute('viewBox') if (!viewBoxAttr) return null const [vbMinX, vbMinY, vbW, vbH] = viewBoxAttr.split(/\s+/).map(Number) // Convert canvas pixel coords to viewBox coords const newMinX = vbMinX + (cropLeft / canvasWidth) * vbW const newMinY = vbMinY + (cropTop / canvasHeight) * vbH const newVbW = ((cropRight - cropLeft) / canvasWidth) * vbW const newVbH = ((cropBottom - cropTop) / canvasHeight) * vbH // New SVG dimensions maintain the same scale const newWidth = newVbW * scale const newHeight = newVbH * scale // Update SVG attributes svgEl.setAttribute('viewBox', `${newMinX} ${newMinY} ${newVbW} ${newVbH}`) svgEl.setAttribute('width', String(newWidth)) svgEl.setAttribute('height', String(newHeight)) // Serialize back const serializer = new XMLSerializer() const newSvgString = serializer.serializeToString(svgEl) return { svg: newSvgString, width: newWidth, height: newHeight } }