@tldraw/editor
Version:
tldraw infinite canvas SDK (editor).
353 lines (301 loc) • 10.3 kB
text/typescript
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 }
}