* fix(canvas): bound image decoding for large Figma documents * test(canvas): update lifecycle fixture for image caches * test(canvas): align image regressions with contribution guides Use native wheel zoom in the browser regression instead of inline store access. Keep export state restoration in the existing raster suite and explicitly format the new image tests omitted by the root format script. * fix(harness): pin patched proxy address dependency Override the transitive proxy-addr dependency with 2.0.8 to fix IPv4-mapped IPv6 trust subnet checks and pass the critical dependency audit (GHSA-jqcg-44mw-7w3h). * build: drop the proxy-addr override master already resolves Master's lockfile has proxy-addr 2.0.8 since the advisory was fixed upstream, so the override this branch added is redundant. * perf(canvas): encode image previews as WebP PNG previews of photos ran to several megabytes each, so a page of them filled the 64 MiB preview cache and evicted itself: page 2 of the #924 reproduction held 48 MB of PNG previews against 7.3 MB as WebP. Browsers that cannot encode WebP hand back PNG. Co-authored-by: sableangle <sableangle@gmail.com> * perf(canvas): decode four image previews at once The browser decodes previews off the main thread, so one at a time left a page of photos filling in for seconds: page 2 of the #924 reproduction took 4.6 s to finish and now takes 2.0 s. Co-authored-by: sableangle <sableangle@gmail.com> * refactor(canvas): decide on image previews once per document size needsImagePreviews replaces useViewportImageRendering, which was not a Vue composable, names its two thresholds, and keeps its answer per document and image count instead of summing every image on each frame. Choosing a preview by zoom carries a lint exception: preview mode draws the scene uncached. Co-authored-by: sableangle <sableangle@gmail.com> * docs: describe the image memory fix in the changelog Co-authored-by: sableangle <sableangle@gmail.com> * fix(canvas): let go of a document's image previews when the document changes Switching to a document that needs no previews left the preview cache holding the previous graph and its encoded images, often hundreds of megabytes, until previews were used again. The renderer now releases them when its document changes, keeping the decoder for the next one. --------- Co-authored-by: sableangle <sableangle@gmail.com>
289 lines
8.3 KiB
TypeScript
289 lines
8.3 KiB
TypeScript
import { beforeAll, describe, expect, test } from 'bun:test'
|
|
|
|
import type { Canvas } from 'canvaskit-wasm'
|
|
|
|
import { renderNodesToImage, SceneGraph, SkiaRenderer } from '@open-pencil/core'
|
|
import { getWorldMatrix } from '@open-pencil/scene-graph'
|
|
|
|
import { initCanvasKit } from '#cli/headless'
|
|
import { renderSceneToCanvas } from '#core/canvas/renderer/pipeline'
|
|
import { prepareSelectionRenderGraph } from '#core/io/formats/raster/render'
|
|
import { extractExportGraph } from '#core/io/subgraph'
|
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
|
import { asDouble } from '#tests/helpers/doubles'
|
|
|
|
let ck: Awaited<ReturnType<typeof initCanvasKit>>
|
|
|
|
function rectangleCommandsBlob(x: number, y: number, width: number, height: number): Uint8Array {
|
|
const blob = new Uint8Array(1 + 4 * 9 + 1)
|
|
const view = new DataView(blob.buffer)
|
|
const points = [
|
|
{ command: 1, x, y },
|
|
{ command: 2, x: x + width, y },
|
|
{ command: 2, x: x + width, y: y + height },
|
|
{ command: 2, x, y: y + height }
|
|
]
|
|
let offset = 0
|
|
for (const point of points) {
|
|
blob[offset] = point.command
|
|
view.setFloat32(offset + 1, point.x, true)
|
|
view.setFloat32(offset + 5, point.y, true)
|
|
offset += 9
|
|
}
|
|
blob[offset] = 0
|
|
return blob
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
ck = await initCanvasKit()
|
|
})
|
|
|
|
describe('raster export', () => {
|
|
test('full-resolution rendering disables previews and restores viewport/mode even on failure', () => {
|
|
const graph = new SceneGraph()
|
|
const page = graph.getPages()[0]
|
|
graph.createNode('RECTANGLE', page.id)
|
|
const viewport = { x: 1, y: 2, w: 3, h: 4 }
|
|
const renderer = {
|
|
viewportImageRendering: true,
|
|
worldViewport: viewport,
|
|
renderNode() {
|
|
expect(this.viewportImageRendering).toBe(false)
|
|
throw new Error('drawing failed')
|
|
}
|
|
}
|
|
expect(() => renderSceneToCanvas(renderer, asDouble<Canvas>({}), graph, page.id)).toThrow(
|
|
'drawing failed'
|
|
)
|
|
expect(renderer.viewportImageRendering).toBe(true)
|
|
expect(renderer.worldViewport).toBe(viewport)
|
|
})
|
|
|
|
test('preserves transformed top-level nodes when preparing a page export', () => {
|
|
const graph = new SceneGraph()
|
|
const page = graph.getPages()[0]
|
|
const connector = graph.createNode('FRAME', page.id, {
|
|
x: 120,
|
|
y: 80,
|
|
width: 40,
|
|
height: 20,
|
|
rotation: 90,
|
|
flipX: true,
|
|
fills: []
|
|
})
|
|
const before = getWorldMatrix(connector, graph)
|
|
|
|
const extracted = extractExportGraph(graph, { scope: 'selection', nodeIds: page.childIds })
|
|
const exportPageId = expectDefined(extracted.pageId, 'export page')
|
|
prepareSelectionRenderGraph(graph, extracted.graph, exportPageId, page.childIds)
|
|
|
|
const exportedConnector = expectDefined(extracted.graph.getNode(connector.id), 'connector')
|
|
expect(getWorldMatrix(exportedConnector, extracted.graph)).toEqual(before)
|
|
})
|
|
|
|
test('preserves the world transform when flattening a nested export selection', () => {
|
|
const graph = new SceneGraph()
|
|
const page = graph.getPages()[0]
|
|
const parent = graph.createNode('FRAME', page.id, {
|
|
x: 200,
|
|
y: 100,
|
|
width: 100,
|
|
height: 80,
|
|
rotation: -90,
|
|
flipX: true,
|
|
fills: []
|
|
})
|
|
const connector = graph.createNode('FRAME', parent.id, {
|
|
x: 15,
|
|
y: 25,
|
|
width: 40,
|
|
height: 20,
|
|
rotation: 90,
|
|
flipX: true,
|
|
fills: []
|
|
})
|
|
const before = getWorldMatrix(connector, graph)
|
|
const extracted = extractExportGraph(graph, {
|
|
scope: 'selection',
|
|
nodeIds: [connector.id]
|
|
})
|
|
const exportPageId = expectDefined(extracted.pageId, 'export page')
|
|
prepareSelectionRenderGraph(graph, extracted.graph, exportPageId, [connector.id])
|
|
|
|
const exportedConnector = expectDefined(extracted.graph.getNode(connector.id), 'connector')
|
|
const after = getWorldMatrix(exportedConnector, extracted.graph)
|
|
for (let index = 0; index < before.length; index++) {
|
|
expect(after[index]).toBeCloseTo(before[index], 8)
|
|
}
|
|
})
|
|
|
|
test('selection export excludes ancestor backgrounds', async () => {
|
|
const graph = new SceneGraph()
|
|
const page = graph.getPages()[0]
|
|
const section = graph.createNode('SECTION', page.id, {
|
|
x: 20,
|
|
y: 30,
|
|
width: 100,
|
|
height: 100,
|
|
fills: [
|
|
{
|
|
type: 'SOLID',
|
|
color: { r: 0.4, g: 0.4, b: 0.4, a: 1 },
|
|
opacity: 1,
|
|
visible: true
|
|
}
|
|
]
|
|
})
|
|
const component = graph.createNode('COMPONENT', section.id, {
|
|
x: 10,
|
|
y: 10,
|
|
width: 10,
|
|
height: 10,
|
|
fills: []
|
|
})
|
|
graph.createNode('RECTANGLE', component.id, {
|
|
x: 3,
|
|
y: 3,
|
|
width: 4,
|
|
height: 4,
|
|
fills: [
|
|
{
|
|
type: 'SOLID',
|
|
color: { r: 1, g: 0, b: 0, a: 1 },
|
|
opacity: 1,
|
|
visible: true
|
|
}
|
|
]
|
|
})
|
|
|
|
const surface = expectDefined(ck.MakeSurface(1, 1), 'surface')
|
|
const renderer = new SkiaRenderer(ck, surface)
|
|
|
|
try {
|
|
const png = expectDefined(
|
|
renderNodesToImage(ck, renderer, graph, page.id, [component.id], {
|
|
scale: 1,
|
|
format: 'PNG'
|
|
}),
|
|
'png'
|
|
)
|
|
const image = expectDefined(ck.MakeImageFromEncoded(png), 'image')
|
|
const pixels = expectDefined(
|
|
image.readPixels(0, 0, {
|
|
alphaType: ck.AlphaType.Unpremul,
|
|
colorType: ck.ColorType.RGBA_8888,
|
|
colorSpace: ck.ColorSpace.SRGB,
|
|
width: image.width(),
|
|
height: image.height()
|
|
}),
|
|
'pixels'
|
|
)
|
|
|
|
expect(pixels[3]).toBe(0)
|
|
const centerAlpha = pixels[(5 * image.width() + 5) * 4 + 3]
|
|
expect(centerAlpha).toBeGreaterThan(0)
|
|
|
|
image.delete()
|
|
} finally {
|
|
surface.delete()
|
|
}
|
|
})
|
|
|
|
test('keeps one-pixel transparent fringes when trimming exports', async () => {
|
|
const graph = new SceneGraph()
|
|
const page = graph.getPages()[0]
|
|
const vector = graph.createNode('VECTOR', page.id, {
|
|
width: 10,
|
|
height: 10,
|
|
fillGeometry: [{ windingRule: 'NONZERO', commandsBlob: rectangleCommandsBlob(1, 1, 8, 8) }],
|
|
fills: [
|
|
{
|
|
type: 'SOLID',
|
|
color: { r: 0, g: 0, b: 0, a: 1 },
|
|
opacity: 1,
|
|
visible: true
|
|
}
|
|
]
|
|
})
|
|
|
|
const surface = expectDefined(ck.MakeSurface(1, 1), 'surface')
|
|
const renderer = new SkiaRenderer(ck, surface)
|
|
|
|
try {
|
|
const png = expectDefined(
|
|
renderNodesToImage(ck, renderer, graph, page.id, [vector.id], {
|
|
scale: 1,
|
|
format: 'PNG',
|
|
trimTransparent: true
|
|
}),
|
|
'png'
|
|
)
|
|
const image = expectDefined(ck.MakeImageFromEncoded(png), 'image')
|
|
|
|
expect(image.width()).toBe(10)
|
|
expect(image.height()).toBe(10)
|
|
|
|
image.delete()
|
|
} finally {
|
|
surface.delete()
|
|
}
|
|
})
|
|
|
|
test('page exports can trim transparent text padding', async () => {
|
|
const graph = new SceneGraph()
|
|
const page = graph.getPages()[0]
|
|
const text = graph.createNode('TEXT', page.id, {
|
|
x: 0,
|
|
y: 0,
|
|
width: 120,
|
|
height: 40,
|
|
text: 'Primitives',
|
|
fontSize: 30,
|
|
lineHeight: 40,
|
|
fills: [
|
|
{
|
|
type: 'SOLID',
|
|
color: { r: 0, g: 0, b: 0, a: 1 },
|
|
opacity: 1,
|
|
visible: true
|
|
}
|
|
]
|
|
})
|
|
|
|
const surface = expectDefined(ck.MakeSurface(1, 1), 'surface')
|
|
const renderer = new SkiaRenderer(ck, surface)
|
|
await renderer.loadFonts()
|
|
|
|
try {
|
|
const untrimmed = expectDefined(
|
|
renderNodesToImage(ck, renderer, graph, page.id, [text.id], {
|
|
scale: 1,
|
|
format: 'PNG'
|
|
}),
|
|
'untrimmed png'
|
|
)
|
|
const trimmed = expectDefined(
|
|
renderNodesToImage(ck, renderer, graph, page.id, [text.id], {
|
|
scale: 1,
|
|
format: 'PNG',
|
|
trimTransparent: true
|
|
}),
|
|
'trimmed png'
|
|
)
|
|
|
|
const untrimmedImage = expectDefined(ck.MakeImageFromEncoded(untrimmed), 'untrimmed image')
|
|
const trimmedImage = expectDefined(ck.MakeImageFromEncoded(trimmed), 'trimmed image')
|
|
|
|
expect(untrimmedImage.height()).toBe(40)
|
|
expect(trimmedImage.height()).toBeLessThan(untrimmedImage.height())
|
|
expect(trimmedImage.width()).toBeLessThan(untrimmedImage.width())
|
|
|
|
untrimmedImage.delete()
|
|
trimmedImage.delete()
|
|
} finally {
|
|
surface.delete()
|
|
}
|
|
})
|
|
})
|