diff --git a/templates/design/.generated/bridge/editor-chrome.generated.ts b/templates/design/.generated/bridge/editor-chrome.generated.ts index 133c7cede3e..c516a67cd9a 100644 --- a/templates/design/.generated/bridge/editor-chrome.generated.ts +++ b/templates/design/.generated/bridge/editor-chrome.generated.ts @@ -903,6 +903,79 @@ export const editorChromeBridgeScript: string = `"use strict"; return index; } + // shared/export-image-source.ts + var EXPORT_IMAGE_NATURAL_WIDTH = "data-agent-native-export-image-natural-width"; + function isResponsiveExportImage(image) { + return image.hasAttribute("srcset") || Boolean(image.closest("picture")?.querySelector("source[srcset]")) || image.hasAttribute(EXPORT_IMAGE_NATURAL_WIDTH); + } + function copyExportImageSource(source, clone) { + if (source.currentSrc) clone.setAttribute("src", source.currentSrc); + if (isResponsiveExportImage(source)) { + clone.setAttribute(EXPORT_IMAGE_NATURAL_WIDTH, String(source.naturalWidth)); + } + clone.removeAttribute("srcset"); + } + async function decodeExportImage(image, signal) { + signal.throwIfAborted(); + let onAbort; + try { + await Promise.race([ + image.decode(), + new Promise((_resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + }) + ]); + } finally { + signal.removeEventListener("abort", onAbort); + } + } + async function prepareExportImageSource(source, signal) { + let image = source; + if (source.loading === "lazy" && !source.complete) { + const picture = source.closest("picture"); + if (picture) { + const sourceImages = Array.from(picture.querySelectorAll("img")); + const imageIndex = sourceImages.indexOf(source); + const clonedImages = picture.cloneNode(true).querySelectorAll("img"); + const clonedImage = clonedImages[imageIndex]; + if (!clonedImage) { + throw new Error("Export image source is unavailable."); + } + image = clonedImage; + } else { + image = source.cloneNode(true); + } + image.loading = "eager"; + } + await decodeExportImage(image, signal); + return image; + } + async function resolveExportImageSource(image, source, signal) { + const naturalWidth = image.getAttribute(EXPORT_IMAGE_NATURAL_WIDTH); + if (naturalWidth === null) { + image.removeAttribute("srcset"); + return; + } + const width = Number(naturalWidth); + if (!Number.isFinite(width) || width <= 0) { + throw new Error("Responsive export image is not ready."); + } + const intrinsic = new Image(); + intrinsic.src = source; + await decodeExportImage(intrinsic, signal); + const density = intrinsic.naturalWidth / width; + if (!Number.isFinite(density) || density <= 0) { + throw new Error("Responsive export image dimensions are unavailable."); + } + image.removeAttribute("src"); + const srcsetSource = source.replace( + /[ \\t\\n\\f\\r]/g, + (character) => \`%\${character.charCodeAt(0).toString(16).padStart(2, "0")}\` + ); + image.setAttribute("srcset", \`\${srcsetSource} \${density}x\`); + } + // shared/relative-position-containing-block.ts function relativePositionContainingBlockDirection(element, getComputedStyle) { let ancestor = element.parentElement; @@ -1027,6 +1100,41 @@ export const editorChromeBridgeScript: string = `"use strict"; return inFlow && isAutoLayoutDisplay(context.parentDisplay); } + // shared/srcset.ts + function parseSrcset(value) { + const candidates = []; + let position = 0; + while (position < value.length) { + while (position < value.length && /[\\s,]/.test(value[position])) + position += 1; + if (position >= value.length) break; + const urlStart = position; + while (position < value.length && !/\\s/.test(value[position])) + position += 1; + let url = value.slice(urlStart, position); + const hasTrailingSeparator = url.endsWith(","); + while (url.endsWith(",")) url = url.slice(0, -1); + let descriptor = ""; + if (!hasTrailingSeparator) { + while (position < value.length && /\\s/.test(value[position])) + position += 1; + const descriptorStart = position; + let parentheses = 0; + while (position < value.length) { + const character = value[position]; + if (character === "(") parentheses += 1; + if (character === ")" && parentheses > 0) parentheses -= 1; + if (character === "," && parentheses === 0) break; + position += 1; + } + descriptor = value.slice(descriptorStart, position).trim(); + if (value[position] === ",") position += 1; + } + if (url) candidates.push({ url, descriptor }); + } + return candidates; + } + // shared/text-layer-rule.ts var TEXT_LAYER_TAGS = /* @__PURE__ */ new Set([ "a", @@ -2575,7 +2683,11 @@ export const editorChromeBridgeScript: string = `"use strict"; var runtimeLayerSnapshotPendingPostReadinessRequestId = null; var lastRuntimeLayerSnapshotHtml = ""; var lastRuntimeLayerSnapshotReservationToken = ""; + var runtimeLayerSnapshotImageSources = /* @__PURE__ */ new WeakMap(); var runtimeDocumentId = "runtime-" + Date.now() + "-" + Math.random().toString(16).slice(2); + function runtimeLayerImageSource(image) { + return image.currentSrc || image.src || ""; + } function runtimeLayerHash(value) { var hash = 2166136261; for (var index = 0; index < value.length; index += 1) { @@ -2629,7 +2741,7 @@ export const editorChromeBridgeScript: string = `"use strict"; } return !(isOverlayElement(el) || el.closest("[data-agent-native-edit-overlay]")); } - function serializeRuntimeLayerSnapshot(excludedRoot) { + function serializeRuntimeLayerSnapshot(excludedRoot, preparedImageSources, unavailableImageSources) { if (!document.body) return { ok: false, reason: "snapshot-unavailable" }; var snapshotFailures = []; function cloneRuntimeStylesheet(sourceNode2, cloneNode2) { @@ -2726,10 +2838,11 @@ export const editorChromeBridgeScript: string = `"use strict"; snapshotFailures.push("foreign-object-unavailable"); } if (sourceNode instanceof HTMLImageElement && cloneNode instanceof HTMLImageElement) { - if (sourceNode.currentSrc) { - cloneNode.setAttribute("src", sourceNode.currentSrc); + var preparedImage = preparedImageSources?.get(sourceNode); + copyExportImageSource(preparedImage || sourceNode, cloneNode); + if (unavailableImageSources?.has(sourceNode)) { + snapshotFailures.push("image-unavailable"); } - cloneNode.removeAttribute("srcset"); } var provenance = elementDebugProvenance(sourceNode); if (provenance.sourceFile) { @@ -3276,18 +3389,37 @@ export const editorChromeBridgeScript: string = `"use strict"; } var imageNodes = Array.from( parsed.querySelectorAll( - "img[src], image[href], image[xlink\\\\:href]" + "img[src], img[data-agent-native-export-image-natural-width], image[href], image[xlink\\\\:href]" ) ); for (var imageIndex = 0; imageIndex < imageNodes.length; imageIndex += 1) { var image = imageNodes[imageIndex]; - var attribute = image.hasAttribute("src") ? "src" : image.hasAttribute("href") ? "href" : "xlink:href"; - var source = image.getAttribute(attribute); - if (!source || /^(?:data|about):/i.test(source)) continue; - var dataSource = await resourceDataUrl(source, document.baseURI); - if (dataSource) image.setAttribute(attribute, dataSource); - else markFailure("image-fetch"); - if (image.localName === "img") image.removeAttribute("srcset"); + var markedResponsiveImage = image.localName === "img" && image.hasAttribute("data-agent-native-export-image-natural-width"); + var responsiveSrcset = markedResponsiveImage && !image.hasAttribute("src") ? parseSrcset(image.getAttribute("srcset") || "")[0] : null; + var attribute = responsiveSrcset ? "srcset" : image.hasAttribute("src") ? "src" : image.hasAttribute("href") ? "href" : "xlink:href"; + var source = responsiveSrcset ? responsiveSrcset.url : image.getAttribute(attribute); + if (!source) { + if (markedResponsiveImage) markFailure("image-unavailable"); + continue; + } + var dataSource = /^(?:data|about):/i.test(source) ? source : await resourceDataUrl(source, document.baseURI); + if (dataSource) { + image.setAttribute( + attribute, + responsiveSrcset ? \`\${dataSource} \${responsiveSrcset.descriptor}\` : dataSource + ); + } else markFailure("image-fetch"); + if (image.localName === "img") { + try { + if (dataSource) { + await resolveExportImageSource(image, dataSource, resourceSignal); + } + } catch { + markFailure( + resourceTimedOut ? "resource-timeout" : "image-unavailable" + ); + } + } } var tooLarge = failures.includes("export-resource-size-limit"); var outputHtml = "" + parsed.documentElement.outerHTML; @@ -3355,39 +3487,47 @@ export const editorChromeBridgeScript: string = `"use strict"; ); clearPendingPostReadiness(); } - var snapshot = serializeRuntimeLayerSnapshot(); - if (!snapshot.ok) { - window.parent.postMessage( - { - type: "agent-native:runtime-layer-snapshot-error", - payload: { - ...snapshot, - requestId, - documentId: runtimeDocumentId, - ...Number.isSafeInteger(postReadinessRequestId) ? { readinessRequestId: postReadinessRequestId } : {}, - ...reservationToken ? { reservationToken } : {} - } - }, - "*" - ); - clearPendingPostReadiness(); - return; - } - void inlineRuntimeSnapshotResources(snapshot.html, 5e6).then(function(inlined) { + var imagePreparationSignal = AbortSignal.timeout(15e3); + var responsiveImages = Array.prototype.slice.call(document.querySelectorAll("img")).filter(isResponsiveExportImage); + void Promise.all( + responsiveImages.map(async function(image) { + try { + return [ + image, + await prepareExportImageSource(image, imagePreparationSignal) + ]; + } catch { + return [image, null]; + } + }) + ).then(function(preparedResults) { if (postGeneration !== runtimeLayerSnapshotPostGeneration) return; - snapshot.html = inlined.html; - if (inlined.errorCode === "export_too_large" || snapshot.html.length > 14e6) { - postSnapshotTooLargeError(); - return; - } - var snapshotReservationToken = reservationToken || ""; - if (snapshot.html === lastRuntimeLayerSnapshotHtml && snapshotReservationToken === lastRuntimeLayerSnapshotReservationToken && !Number.isSafeInteger(postReadinessRequestId)) { + var preparedImageSources = /* @__PURE__ */ new Map(); + var unavailableImageSources = /* @__PURE__ */ new Set(); + var snapshotImageSources = /* @__PURE__ */ new Map(); + preparedResults.forEach(function([image, prepared]) { + if (prepared) preparedImageSources.set(image, prepared); + else unavailableImageSources.add(image); + }); + Array.from(document.querySelectorAll("img")).forEach(function(image) { + snapshotImageSources.set( + image, + runtimeLayerImageSource(preparedImageSources.get(image) || image) + ); + }); + var snapshot = serializeRuntimeLayerSnapshot( + void 0, + preparedImageSources, + unavailableImageSources + ); + if (!snapshot.ok) { window.parent.postMessage( { - type: "agent-native:runtime-layer-snapshot-unchanged", + type: "agent-native:runtime-layer-snapshot-error", payload: { + ...snapshot, requestId, - documentId: snapshot.documentId, + documentId: runtimeDocumentId, ...Number.isSafeInteger(postReadinessRequestId) ? { readinessRequestId: postReadinessRequestId } : {}, ...reservationToken ? { reservationToken } : {} } @@ -3397,23 +3537,60 @@ export const editorChromeBridgeScript: string = `"use strict"; clearPendingPostReadiness(); return; } - if (requestId !== void 0) snapshot.requestId = requestId; - if (Number.isSafeInteger(postReadinessRequestId)) { - snapshot.readinessRequestId = postReadinessRequestId; - } - if (reservationToken) snapshot.reservationToken = reservationToken; - var snapshotMessage = { - type: "agent-native:runtime-layer-snapshot", - payload: snapshot - }; - if (new TextEncoder().encode(JSON.stringify(snapshotMessage)).byteLength > 5e6) { - postSnapshotTooLargeError(); - return; - } - lastRuntimeLayerSnapshotHtml = snapshot.html; - lastRuntimeLayerSnapshotReservationToken = snapshotReservationToken; - window.parent.postMessage(snapshotMessage, "*"); - clearPendingPostReadiness(); + return inlineRuntimeSnapshotResources(snapshot.html, 5e6).then( + function(inlined) { + if (postGeneration !== runtimeLayerSnapshotPostGeneration) return; + snapshot.html = inlined.html; + if (inlined.errorCode === "export_too_large" || snapshot.html.length > 14e6) { + postSnapshotTooLargeError(); + return; + } + var snapshotReservationToken = reservationToken || ""; + if (snapshot.html === lastRuntimeLayerSnapshotHtml && snapshotReservationToken === lastRuntimeLayerSnapshotReservationToken && !Number.isSafeInteger(postReadinessRequestId)) { + if (inlined.complete) { + snapshotImageSources.forEach(function(source, image) { + runtimeLayerSnapshotImageSources.set(image, source); + }); + } + window.parent.postMessage( + { + type: "agent-native:runtime-layer-snapshot-unchanged", + payload: { + requestId, + documentId: snapshot.documentId, + ...Number.isSafeInteger(postReadinessRequestId) ? { readinessRequestId: postReadinessRequestId } : {}, + ...reservationToken ? { reservationToken } : {} + } + }, + "*" + ); + clearPendingPostReadiness(); + return; + } + if (requestId !== void 0) snapshot.requestId = requestId; + if (Number.isSafeInteger(postReadinessRequestId)) { + snapshot.readinessRequestId = postReadinessRequestId; + } + if (reservationToken) snapshot.reservationToken = reservationToken; + var snapshotMessage = { + type: "agent-native:runtime-layer-snapshot", + payload: snapshot + }; + if (new TextEncoder().encode(JSON.stringify(snapshotMessage)).byteLength > 5e6) { + postSnapshotTooLargeError(); + return; + } + if (inlined.complete) { + snapshotImageSources.forEach(function(source, image) { + runtimeLayerSnapshotImageSources.set(image, source); + }); + } + lastRuntimeLayerSnapshotHtml = snapshot.html; + lastRuntimeLayerSnapshotReservationToken = snapshotReservationToken; + window.parent.postMessage(snapshotMessage, "*"); + clearPendingPostReadiness(); + } + ); }).catch(function() { if (postGeneration !== runtimeLayerSnapshotPostGeneration) return; window.parent.postMessage( @@ -25068,6 +25245,20 @@ export const editorChromeBridgeScript: string = `"use strict"; "title" ] }); + ["load", "error"].forEach(function(type) { + document.addEventListener( + type, + function(event) { + if (!(event.target instanceof HTMLImageElement)) return; + var image = event.target; + if (isOverlayElement(image)) return; + if (event.type === "error" || isResponsiveExportImage(image) || runtimeLayerSnapshotImageSources.get(image) !== runtimeLayerImageSource(image)) { + scheduleRuntimeLayerSnapshot(); + } + }, + true + ); + }); } var frameLabelRefreshScheduled = false; function scheduleFrameNameLabels() { diff --git a/templates/design/actions/render-export-png.ts b/templates/design/actions/render-export-png.ts index 6abac354fdf..9c9c05dbc69 100644 --- a/templates/design/actions/render-export-png.ts +++ b/templates/design/actions/render-export-png.ts @@ -8,6 +8,7 @@ import { launchChromium, } from "../server/lib/playwright-runtime.js"; import { degradeWideColorsInHtml } from "../shared/export-color-fallback.js"; +import { parseSrcset } from "../shared/srcset.js"; const MAX_RENDER_SIDE = 16_384; const MAX_RENDER_PIXELS = 64 * 1024 * 1024; @@ -221,7 +222,12 @@ function findSnapshotDataUrls(html: string): string[] { while (stack.length > 0) { const node = stack.pop()!; for (const attribute of node.attrs ?? []) { - add(attribute.value); + if (attribute.name.toLowerCase() === "srcset") { + for (const candidate of parseSrcset(attribute.value)) + add(candidate.url); + } else { + add(attribute.value); + } } if (node.tagName?.toLowerCase() === "style") { add((node.childNodes ?? []).map((child) => child.value ?? "").join("")); diff --git a/templates/design/app/components/design/bridge/bridge.guard.spec.ts b/templates/design/app/components/design/bridge/bridge.guard.spec.ts index 9885e20b825..b741364e14d 100644 --- a/templates/design/app/components/design/bridge/bridge.guard.spec.ts +++ b/templates/design/app/components/design/bridge/bridge.guard.spec.ts @@ -595,6 +595,8 @@ describe("source vector stroke overlay reconciliation", () => { const BRIDGE_SAFE_IMPORTS: Readonly> = { "editor-chrome.bridge.ts": [ + "../../../../shared/export-image-source", + "../../../../shared/srcset", "./native-region-export", "@agent-native/toolkit/canvas-interactions", "@jridgewell/trace-mapping", @@ -11915,7 +11917,7 @@ it( ?.getAttribute("src"), imageSrcset: doc .querySelector("#responsive-image") - ?.hasAttribute("srcset"), + ?.getAttribute("srcset"), pictureSources: doc.querySelectorAll("picture source").length, resourceRequests: (window as any).__resourceRequests, }; @@ -11928,8 +11930,8 @@ it( "https://export.test/assets/pixel.svg", ]); expect(snapshot.bodyRules).toContain("letter-spacing: 3px"); - expect(snapshot.imageSrc).toContain("id='chosen'"); - expect(snapshot.imageSrcset).toBe(false); + expect(snapshot.imageSrc).toBeNull(); + expect(snapshot.imageSrcset).toContain("id='chosen'"); expect(snapshot.pictureSources).toBe(0); } finally { await browser.close(); @@ -11937,6 +11939,137 @@ it( }, ); +it( + "ordinary image events skip captured sources and refresh changed sources", + { timeout: 30_000 }, + async () => { + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.setContent(` +`); + const ordinaryImageUrl = "https://export.test/assets/ordinary-image.svg"; + const updatedImageUrl = + "https://export.test/assets/updated-ordinary-image.svg"; + await page.route(ordinaryImageUrl, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 1_200)); + await route.fulfill({ + status: 200, + contentType: "image/svg+xml", + body: '', + }); + }); + await page.route(updatedImageUrl, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 1_200)); + await route.fulfill({ + status: 200, + contentType: "image/svg+xml", + body: '', + }); + }); + await page.evaluate(() => { + (window as any).__resourceRequests = []; + (window as any).__ordinaryImageLoaded = false; + window.fetch = (async (input: RequestInfo | URL) => { + const source = String(input); + (window as any).__resourceRequests.push(source); + return new Response( + new Blob( + [ + `${source}`, + ], + { type: "image/svg+xml" }, + ), + { status: 200 }, + ); + }) as typeof fetch; + const image = + document.querySelector("#ordinary-image")!; + image.addEventListener( + "load", + () => ((window as any).__ordinaryImageLoaded = true), + { once: true }, + ); + image.setAttribute("src", "assets/ordinary-image.svg"); + }); + await collectBridgeMessages(page); + await page.addScriptTag({ + content: hydratedEditorChromeBridgeScript(true), + }); + await page.waitForFunction(() => + ((window as any).__bridgeMessages ?? []).some( + (message: any) => + message.type === "agent-native:runtime-layer-snapshot", + ), + ); + + const initialRequests = await page.evaluate( + () => (window as any).__resourceRequests as string[], + ); + expect(initialRequests).toEqual([ordinaryImageUrl]); + await page.waitForFunction(() => (window as any).__ordinaryImageLoaded); + await page.waitForTimeout(500); + + const requestsAfterLoad = await page.evaluate( + () => (window as any).__resourceRequests as string[], + ); + expect(requestsAfterLoad).toEqual(initialRequests); + + await page.locator("#ordinary-image").evaluate((image, source) => { + (image as HTMLImageElement).src = source; + }, updatedImageUrl); + await page.waitForFunction((source) => { + return ((window as any).__bridgeMessages ?? []).some((message: any) => { + if ( + message.type !== "agent-native:runtime-layer-snapshot" || + typeof message.payload?.html !== "string" + ) { + return false; + } + const doc = new DOMParser().parseFromString( + message.payload.html, + "text/html", + ); + const imageSource = + doc.querySelector("#ordinary-image")?.src || ""; + return ( + imageSource.startsWith("data:image/svg+xml;base64,") && + atob(imageSource.split(",")[1] || "").includes(source) + ); + }); + }, updatedImageUrl); + + const finalState = await page.evaluate(() => ({ + resourceRequests: (window as any).__resourceRequests as string[], + ordinaryImageSource: document + .querySelector("#ordinary-image") + ?.getAttribute("src"), + latestSnapshotSource: (() => { + const snapshots = ((window as any).__bridgeMessages ?? []).filter( + (message: any) => + message.type === "agent-native:runtime-layer-snapshot", + ); + const html = snapshots.at(-1)?.payload?.html as string | undefined; + if (!html) return ""; + const doc = new DOMParser().parseFromString(html, "text/html"); + const imageSource = + doc.querySelector("#ordinary-image")?.src || ""; + return imageSource.startsWith("data:image/svg+xml;base64,") + ? atob(imageSource.split(",")[1] || "") + : imageSource; + })(), + })); + expect(finalState.resourceRequests).toEqual( + expect.arrayContaining([updatedImageUrl]), + ); + expect(finalState.ordinaryImageSource).toBe(updatedImageUrl); + expect(finalState.latestSnapshotSource).toContain(updatedImageUrl); + } finally { + await browser.close(); + } + }, +); + it( "runtime export resource inlining fails when its shared deadline aborts fetches", { timeout: 30_000 }, diff --git a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts index 956c3ca8aba..cf6d0d9ac0c 100644 --- a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +++ b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts @@ -30,6 +30,12 @@ import { createCanvasGestureController } from "@agent-native/toolkit/canvas-interactions"; import { originalPositionFor, TraceMap } from "@jridgewell/trace-mapping"; +import { + copyExportImageSource, + isResponsiveExportImage, + prepareExportImageSource, + resolveExportImageSource, +} from "../../../../shared/export-image-source"; import { relativePositionContainingBlockDirection } from "../../../../shared/relative-position-containing-block"; import { resolveSourceEquivalentSelector, @@ -39,6 +45,7 @@ import { isAutoLayoutDisplay, marginHandlesApply, } from "../../../../shared/spacing-handle-rule"; +import { parseSrcset } from "../../../../shared/srcset"; import { INLINE_TEXT_TAGS, padsBox, @@ -2163,9 +2170,17 @@ declare var __INITIAL_SOURCE_OWNERSHIP__: string; var runtimeLayerSnapshotPendingPostReadinessRequestId: number | null = null; var lastRuntimeLayerSnapshotHtml = ""; var lastRuntimeLayerSnapshotReservationToken = ""; + var runtimeLayerSnapshotImageSources = new WeakMap< + HTMLImageElement, + string + >(); var runtimeDocumentId = "runtime-" + Date.now() + "-" + Math.random().toString(16).slice(2); + function runtimeLayerImageSource(image: HTMLImageElement): string { + return image.currentSrc || image.src || ""; + } + function runtimeLayerHash(value: string): string { var hash = 0x811c9dc5; for (var index = 0; index < value.length; index += 1) { @@ -2243,6 +2258,8 @@ declare var __INITIAL_SOURCE_OWNERSHIP__: string; function serializeRuntimeLayerSnapshot( excludedRoot?: Element, + preparedImageSources?: Map, + unavailableImageSources?: Set, ): | { ok: true; html: string; nodeCount: number; documentId: string } | { ok: false; reason: "snapshot-unavailable" | "snapshot-too-large" } { @@ -2387,10 +2404,11 @@ declare var __INITIAL_SOURCE_OWNERSHIP__: string; sourceNode instanceof HTMLImageElement && cloneNode instanceof HTMLImageElement ) { - if (sourceNode.currentSrc) { - cloneNode.setAttribute("src", sourceNode.currentSrc); + var preparedImage = preparedImageSources?.get(sourceNode); + copyExportImageSource(preparedImage || sourceNode, cloneNode); + if (unavailableImageSources?.has(sourceNode)) { + snapshotFailures.push("image-unavailable"); } - cloneNode.removeAttribute("srcset"); } var provenance = elementDebugProvenance(sourceNode); if (provenance.sourceFile) { @@ -3052,22 +3070,54 @@ declare var __INITIAL_SOURCE_OWNERSHIP__: string; var imageNodes = Array.from( parsed.querySelectorAll( - "img[src], image[href], image[xlink\\:href]", + "img[src], img[data-agent-native-export-image-natural-width], image[href], image[xlink\\:href]", ), ); for (var imageIndex = 0; imageIndex < imageNodes.length; imageIndex += 1) { var image = imageNodes[imageIndex]!; - var attribute = image.hasAttribute("src") - ? "src" - : image.hasAttribute("href") - ? "href" - : "xlink:href"; - var source = image.getAttribute(attribute); - if (!source || /^(?:data|about):/i.test(source)) continue; - var dataSource = await resourceDataUrl(source, document.baseURI); - if (dataSource) image.setAttribute(attribute, dataSource); - else markFailure("image-fetch"); - if (image.localName === "img") image.removeAttribute("srcset"); + var markedResponsiveImage = + image.localName === "img" && + image.hasAttribute("data-agent-native-export-image-natural-width"); + var responsiveSrcset = + markedResponsiveImage && !image.hasAttribute("src") + ? parseSrcset(image.getAttribute("srcset") || "")[0] + : null; + var attribute = responsiveSrcset + ? "srcset" + : image.hasAttribute("src") + ? "src" + : image.hasAttribute("href") + ? "href" + : "xlink:href"; + var source = responsiveSrcset + ? responsiveSrcset.url + : image.getAttribute(attribute); + if (!source) { + if (markedResponsiveImage) markFailure("image-unavailable"); + continue; + } + var dataSource = /^(?:data|about):/i.test(source) + ? source + : await resourceDataUrl(source, document.baseURI); + if (dataSource) { + image.setAttribute( + attribute, + responsiveSrcset + ? `${dataSource} ${responsiveSrcset.descriptor}` + : dataSource, + ); + } else markFailure("image-fetch"); + if (image.localName === "img") { + try { + if (dataSource) { + await resolveExportImageSource(image, dataSource, resourceSignal); + } + } catch { + markFailure( + resourceTimedOut ? "resource-timeout" : "image-unavailable", + ); + } + } } var tooLarge = failures.includes("export-resource-size-limit"); @@ -3157,50 +3207,53 @@ declare var __INITIAL_SOURCE_OWNERSHIP__: string; ); clearPendingPostReadiness(); } - var snapshot = serializeRuntimeLayerSnapshot(); - if (!snapshot.ok) { - (window.parent as Window).postMessage( - { - type: "agent-native:runtime-layer-snapshot-error", - payload: { - ...snapshot, - requestId, - documentId: runtimeDocumentId, - ...(Number.isSafeInteger(postReadinessRequestId) - ? { readinessRequestId: postReadinessRequestId } - : {}), - ...(reservationToken ? { reservationToken } : {}), - }, - }, - "*", - ); - clearPendingPostReadiness(); - return; - } - void inlineRuntimeSnapshotResources(snapshot.html, 5_000_000) - .then(function (inlined) { - if (postGeneration !== runtimeLayerSnapshotPostGeneration) return; - snapshot.html = inlined.html; - if ( - inlined.errorCode === "export_too_large" || - snapshot.html.length > 14_000_000 - ) { - postSnapshotTooLargeError(); - return; + var imagePreparationSignal = AbortSignal.timeout(15_000); + var responsiveImages = Array.prototype.slice + .call(document.querySelectorAll("img")) + .filter(isResponsiveExportImage) as HTMLImageElement[]; + void Promise.all( + responsiveImages.map(async function (image) { + try { + return [ + image, + await prepareExportImageSource(image, imagePreparationSignal), + ] as const; + } catch { + return [image, null] as const; } - var snapshotReservationToken = reservationToken || ""; - if ( - snapshot.html === lastRuntimeLayerSnapshotHtml && - snapshotReservationToken === - lastRuntimeLayerSnapshotReservationToken && - !Number.isSafeInteger(postReadinessRequestId) - ) { + }), + ) + .then(function (preparedResults) { + if (postGeneration !== runtimeLayerSnapshotPostGeneration) return; + var preparedImageSources = new Map< + HTMLImageElement, + HTMLImageElement + >(); + var unavailableImageSources = new Set(); + var snapshotImageSources = new Map(); + preparedResults.forEach(function ([image, prepared]) { + if (prepared) preparedImageSources.set(image, prepared); + else unavailableImageSources.add(image); + }); + Array.from(document.querySelectorAll("img")).forEach(function (image) { + snapshotImageSources.set( + image, + runtimeLayerImageSource(preparedImageSources.get(image) || image), + ); + }); + var snapshot = serializeRuntimeLayerSnapshot( + undefined, + preparedImageSources, + unavailableImageSources, + ); + if (!snapshot.ok) { (window.parent as Window).postMessage( { - type: "agent-native:runtime-layer-snapshot-unchanged", + type: "agent-native:runtime-layer-snapshot-error", payload: { + ...snapshot, requestId, - documentId: snapshot.documentId, + documentId: runtimeDocumentId, ...(Number.isSafeInteger(postReadinessRequestId) ? { readinessRequestId: postReadinessRequestId } : {}), @@ -3212,26 +3265,73 @@ declare var __INITIAL_SOURCE_OWNERSHIP__: string; clearPendingPostReadiness(); return; } - if (requestId !== undefined) snapshot.requestId = requestId; - if (Number.isSafeInteger(postReadinessRequestId)) { - snapshot.readinessRequestId = postReadinessRequestId; - } - if (reservationToken) snapshot.reservationToken = reservationToken; - var snapshotMessage = { - type: "agent-native:runtime-layer-snapshot", - payload: snapshot, - }; - if ( - new TextEncoder().encode(JSON.stringify(snapshotMessage)).byteLength > - 5_000_000 - ) { - postSnapshotTooLargeError(); - return; - } - lastRuntimeLayerSnapshotHtml = snapshot.html; - lastRuntimeLayerSnapshotReservationToken = snapshotReservationToken; - (window.parent as Window).postMessage(snapshotMessage, "*"); - clearPendingPostReadiness(); + return inlineRuntimeSnapshotResources(snapshot.html, 5_000_000).then( + function (inlined) { + if (postGeneration !== runtimeLayerSnapshotPostGeneration) return; + snapshot.html = inlined.html; + if ( + inlined.errorCode === "export_too_large" || + snapshot.html.length > 14_000_000 + ) { + postSnapshotTooLargeError(); + return; + } + var snapshotReservationToken = reservationToken || ""; + if ( + snapshot.html === lastRuntimeLayerSnapshotHtml && + snapshotReservationToken === + lastRuntimeLayerSnapshotReservationToken && + !Number.isSafeInteger(postReadinessRequestId) + ) { + if (inlined.complete) { + snapshotImageSources.forEach(function (source, image) { + runtimeLayerSnapshotImageSources.set(image, source); + }); + } + (window.parent as Window).postMessage( + { + type: "agent-native:runtime-layer-snapshot-unchanged", + payload: { + requestId, + documentId: snapshot.documentId, + ...(Number.isSafeInteger(postReadinessRequestId) + ? { readinessRequestId: postReadinessRequestId } + : {}), + ...(reservationToken ? { reservationToken } : {}), + }, + }, + "*", + ); + clearPendingPostReadiness(); + return; + } + if (requestId !== undefined) snapshot.requestId = requestId; + if (Number.isSafeInteger(postReadinessRequestId)) { + snapshot.readinessRequestId = postReadinessRequestId; + } + if (reservationToken) snapshot.reservationToken = reservationToken; + var snapshotMessage = { + type: "agent-native:runtime-layer-snapshot", + payload: snapshot, + }; + if ( + new TextEncoder().encode(JSON.stringify(snapshotMessage)) + .byteLength > 5_000_000 + ) { + postSnapshotTooLargeError(); + return; + } + if (inlined.complete) { + snapshotImageSources.forEach(function (source, image) { + runtimeLayerSnapshotImageSources.set(image, source); + }); + } + lastRuntimeLayerSnapshotHtml = snapshot.html; + lastRuntimeLayerSnapshotReservationToken = snapshotReservationToken; + (window.parent as Window).postMessage(snapshotMessage, "*"); + clearPendingPostReadiness(); + }, + ); }) .catch(function () { if (postGeneration !== runtimeLayerSnapshotPostGeneration) return; @@ -32184,6 +32284,25 @@ declare var __INITIAL_SOURCE_OWNERSHIP__: string; "title", ], }); + ["load", "error"].forEach(function (type) { + document.addEventListener( + type, + function (event) { + if (!(event.target instanceof HTMLImageElement)) return; + var image = event.target; + if (isOverlayElement(image)) return; + if ( + event.type === "error" || + isResponsiveExportImage(image) || + runtimeLayerSnapshotImageSources.get(image) !== + runtimeLayerImageSource(image) + ) { + scheduleRuntimeLayerSnapshot(); + } + }, + true, + ); + }); } var frameLabelRefreshScheduled = false; function scheduleFrameNameLabels(): void { diff --git a/templates/design/app/components/design/design-canvas/private-replay-screenshot-preview.ts b/templates/design/app/components/design/design-canvas/private-replay-screenshot-preview.ts index c104b579f98..8c33af046a2 100644 --- a/templates/design/app/components/design/design-canvas/private-replay-screenshot-preview.ts +++ b/templates/design/app/components/design/design-canvas/private-replay-screenshot-preview.ts @@ -1,3 +1,5 @@ +import { parseSrcset, type SrcsetCandidate } from "../../../../shared/srcset"; +export { parseSrcset, type SrcsetCandidate } from "../../../../shared/srcset"; import { injectDocumentMarkup } from "@agent-native/core/shared"; import { parse, serialize, type DefaultTreeAdapterMap } from "parse5"; @@ -34,50 +36,6 @@ function randomNonce(): string { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; } -export interface SrcsetCandidate { - url: string; - descriptor: string; -} - -export function parseSrcset(value: string): SrcsetCandidate[] { - const candidates: SrcsetCandidate[] = []; - let position = 0; - - while (position < value.length) { - while (position < value.length && /[\s,]/.test(value[position]!)) - position += 1; - if (position >= value.length) break; - - const urlStart = position; - while (position < value.length && !/\s/.test(value[position]!)) - position += 1; - let url = value.slice(urlStart, position); - const hasTrailingSeparator = url.endsWith(","); - while (url.endsWith(",")) url = url.slice(0, -1); - - let descriptor = ""; - if (!hasTrailingSeparator) { - while (position < value.length && /\s/.test(value[position]!)) - position += 1; - const descriptorStart = position; - let parentheses = 0; - while (position < value.length) { - const character = value[position]!; - if (character === "(") parentheses += 1; - if (character === ")" && parentheses > 0) parentheses -= 1; - if (character === "," && parentheses === 0) break; - position += 1; - } - descriptor = value.slice(descriptorStart, position).trim(); - if (value[position] === ",") position += 1; - } - - if (url) candidates.push({ url, descriptor }); - } - - return candidates; -} - function formatSrcsetCandidate(candidate: SrcsetCandidate): string { return candidate.descriptor ? `${candidate.url} ${candidate.descriptor}` diff --git a/templates/design/app/pages/design-editor/commands/board-export.spec.ts b/templates/design/app/pages/design-editor/commands/board-export.spec.ts index eeb5a07613b..1b2d17a00b8 100644 --- a/templates/design/app/pages/design-editor/commands/board-export.spec.ts +++ b/templates/design/app/pages/design-editor/commands/board-export.spec.ts @@ -23,14 +23,14 @@ vi.mock("@/pages/design-editor/export-capture", async (importOriginal) => { }; }); vi.mock("sonner", () => ({ toast: { success: vi.fn() } })); +vi.mock("@/lib/design-export-tracking", () => ({ + trackBrowserDesignExported: vi.fn(), + browserDesignExportErrorType: (error: { code?: string }) => error.code, +})); -import { getExportCompositeBounds } from "../export-capture"; import type { PngCaptureScope } from "../png-export-render"; import { runDownloadPdf } from "./download-pdf"; -import { - resolveSelectedScreenExportFrames, - runRenderPngBlob, -} from "./render-png-blob"; +import { runRenderPngBlob, runRenderPngCapture } from "./render-png-blob"; const originalDimensions = [ [document.documentElement, "scrollWidth"], @@ -183,9 +183,7 @@ describe("board document exports", () => { { fallbackExportName: () => "Test - Export.pdf", pngExportingRef: { current: false }, - renderPngBlob: (arg) => runRenderPngBlob(args, arg), - resolveSelectedScreensBounds: () => null, - resolvePngCaptureTarget: args.resolvePngCaptureTarget, + renderPngCapture: (arg) => runRenderPngCapture(args, arg), setPngExporting: vi.fn(), showRasterCaptureError: vi.fn(), t: () => "PDF downloaded", @@ -245,19 +243,12 @@ describe("board document exports", () => { screenLease, }; }); - const resolvePngCaptureTarget = vi.fn(() => ({ - cropSelection: null, - doc: iframe.contentDocument!, - iframe, - })); await runDownloadPdf( { fallbackExportName: () => "Test - Export.pdf", pngExportingRef: { current: false }, - renderPngBlob: (arg) => runRenderPngBlob(args, arg), - resolveSelectedScreensBounds: () => null, - resolvePngCaptureTarget, + renderPngCapture: (arg) => runRenderPngCapture(args, arg), setPngExporting: vi.fn(), showRasterCaptureError: vi.fn(), t: () => "PDF downloaded", @@ -271,7 +262,6 @@ describe("board document exports", () => { "screens", "screen-1", ); - expect(resolvePngCaptureTarget).toHaveBeenCalledWith("screens"); expect(args.releaseScreenFromExport).toHaveBeenCalledWith(screenLease); expect(events).toEqual(["target:screen-1", "release"]); expect(mocks.renderNativeExportPng).toHaveBeenCalledOnce(); @@ -289,6 +279,41 @@ describe("board document exports", () => { } }); + it("keeps the captured PDF frame geometry when the document overflows vertically", async () => { + const iframe = document.createElement("iframe"); + iframe.dataset.screenIframeId = "overflow-screen"; + Object.defineProperties(iframe, { + clientWidth: { value: 1200 }, + clientHeight: { value: 600 }, + }); + document.body.appendChild(iframe); + const doc = iframe.contentDocument!; + Object.defineProperties(doc.documentElement, { + scrollWidth: { value: 1200 }, + scrollHeight: { value: 1000 }, + }); + const args = { + ...renderArgs({ iframe, doc }), + overviewScreens: [ + { id: "overflow-screen", width: 1200, height: 600 }, + ] as never[], + }; + const showRasterCaptureError = vi.fn(); + await runDownloadPdf({ + fallbackExportName: () => "frame.pdf", + pngExportingRef: { current: false }, + renderPngCapture: (options) => runRenderPngCapture(args, options), + setPngExporting: vi.fn(), + showRasterCaptureError, + t: (key) => key, + triggerBlobDownload: vi.fn(), + }); + expect(showRasterCaptureError).not.toHaveBeenCalled(); + expect(mocks.createSinglePageRasterPdf).toHaveBeenCalledWith( + expect.objectContaining({ width: 1200, height: 600 }), + ); + }); + it("renders multiple selected screens before resolving a single-frame PDF target", async () => { const screens = [ { id: "screen-a", width: 320, height: 200 }, @@ -352,29 +377,13 @@ describe("board document exports", () => { selectedScreenIds, viewMode: "overview" as const, }; - const resolveSelectedScreensBounds = () => { - const selectedFrames = resolveSelectedScreenExportFrames({ - selectedScreenIds, - overviewScreens: screens, - canvasFrameGeometryById: geometries, - iframeSizeById: new Map( - iframes.map((iframe, index) => [ - selectedScreenIds[index]!, - { width: iframe.clientWidth, height: iframe.clientHeight }, - ]), - ), - }); - return getExportCompositeBounds(selectedFrames.map(({ frame }) => frame)); - }; try { await runDownloadPdf( { fallbackExportName: () => "Test - Export.pdf", pngExportingRef: { current: false }, - renderPngBlob: (arg) => runRenderPngBlob(captureArgs, arg), - resolveSelectedScreensBounds, - resolvePngCaptureTarget, + renderPngCapture: (arg) => runRenderPngCapture(captureArgs, arg), setPngExporting: vi.fn(), showRasterCaptureError: vi.fn(), t: () => "PDF downloaded", @@ -439,29 +448,4 @@ describe("board document exports", () => { expect(html).toContain('font-family: "PlaceholderFont"'); expect(html).toContain("font-size: 14px"); }); - - it("uses the live responsive image source without retaining picture srcsets", async () => { - const fixture = createReportedBoardFixture(); - const picture = fixture.doc.createElement("picture"); - const source = fixture.doc.createElement("source"); - source.setAttribute("srcset", "https://images.example.test/large.webp"); - const image = fixture.doc.createElement("img"); - image.setAttribute("src", "https://images.example.test/fallback.png"); - image.setAttribute("srcset", "https://images.example.test/other.png 2x"); - Object.defineProperty(image, "currentSrc", { - configurable: true, - value: "https://images.example.test/chosen.webp", - }); - picture.append(source, image); - fixture.doc.body.append(picture); - - await runRenderPngBlob(renderArgs(fixture), { scope: "document" }); - - const calls = mocks.renderNativeExportPng.mock.calls; - const html = calls[calls.length - 1]?.[0].html; - expect(html).toContain('src="https://images.example.test/chosen.webp"'); - expect(html).not.toContain("srcset"); - expect(html).not.toContain(" { exportTracking, fallbackExportName: (extension: string) => `design.${extension}`, pngExportingRef: ref, - renderPngBlob, - resolveSelectedScreensBounds: () => ({ - x: 0, - y: 0, + renderPngCapture: async () => ({ + blob: await renderPngBlob(), width: 100, height: 100, + scale: 2, }), - resolvePngCaptureTarget: () => { - throw new Error("selected-screen bounds skip the capture target"); - }, setPngExporting: () => undefined, showRasterCaptureError: () => undefined, t, diff --git a/templates/design/app/pages/design-editor/commands/download-pdf.ts b/templates/design/app/pages/design-editor/commands/download-pdf.ts index 1ece51b35ad..b261624f21d 100644 --- a/templates/design/app/pages/design-editor/commands/download-pdf.ts +++ b/templates/design/app/pages/design-editor/commands/download-pdf.ts @@ -2,7 +2,6 @@ import type { Dispatch, RefObject, SetStateAction } from "react"; import { toast } from "sonner"; import type { ExportSettingsValue } from "@/components/design/inspector"; -import type { ElementInfo } from "@/components/design/types"; import { browserDesignExportErrorType, trackBrowserDesignExported, @@ -12,36 +11,16 @@ import { PDF_MIN_PRINT_RASTER_SCALE, createSinglePageRasterPdf, } from "@/pages/design-editor/export-capture"; -import type { ExportCropRect } from "@/pages/design-editor/export-capture"; -import { prepareExportCaptureTarget } from "@/pages/design-editor/export-snapshot-frame"; -import type { ExportCaptureTarget } from "@/pages/design-editor/export-snapshot-frame"; import type { PngCaptureScope } from "@/pages/design-editor/png-export-render"; -import { - resolveBoardExportCropRect, - resolveExportCropRect, -} from "@/pages/design-editor/png-export-render"; + +import type { PngCaptureResult, PngRenderOptions } from "./render-png-blob"; export interface DownloadPdfArgs { /** Join keys for the `design_exported` this command reports. */ exportTracking?: BrowserDesignExportContext; fallbackExportName: (extension: string, suffix?: string) => string; pngExportingRef: RefObject; - renderPngBlob: (arg0: { - scope: PngCaptureScope; - settings?: Partial; - format?: "png" | "jpg" | "webp"; - }) => Promise; - resolveSelectedScreensBounds: () => ExportCropRect | null; - resolvePngCaptureTarget: (scope: PngCaptureScope) => - | (ExportCaptureTarget & { - cropSelection: ElementInfo | readonly ElementInfo[] | null; - }) - | Promise< - ExportCaptureTarget & { - cropSelection: ElementInfo | readonly ElementInfo[] | null; - } - >; - releaseScreenFromExport?: (lease: symbol) => void; + renderPngCapture: (options: PngRenderOptions) => Promise; setPngExporting: Dispatch>; showRasterCaptureError: (error: unknown, format?: "png" | "pdf") => void; t: (key: string, options?: Record) => string; @@ -53,10 +32,7 @@ export async function runDownloadPdf( exportTracking, fallbackExportName, pngExportingRef, - renderPngBlob, - resolveSelectedScreensBounds, - resolvePngCaptureTarget, - releaseScreenFromExport, + renderPngCapture, setPngExporting, showRasterCaptureError, t, @@ -69,53 +45,11 @@ export async function runDownloadPdf( pngExportingRef.current = true; setPngExporting(true); try { - const selectedScreensBounds = - scope === "screens" ? resolveSelectedScreensBounds() : null; - let pageWidth: number; - let pageHeight: number; - if (selectedScreensBounds) { - pageWidth = Math.max(1, selectedScreensBounds.width); - pageHeight = Math.max(1, selectedScreensBounds.height); - } else { - const target = await resolvePngCaptureTarget(scope); - let prepared: Awaited< - ReturnType - > | null = null; - try { - prepared = await prepareExportCaptureTarget(target); - const crop = resolveExportCropRect(prepared.doc, target.cropSelection); - const pageCrop = - crop ?? resolveBoardExportCropRect(prepared.doc, prepared.iframe); - pageWidth = Math.max( - 1, - pageCrop?.width ?? - Math.max( - prepared.doc.documentElement.scrollWidth, - prepared.doc.body?.scrollWidth ?? 0, - prepared.iframe.clientWidth, - ), - ); - pageHeight = Math.max( - 1, - pageCrop?.height ?? - Math.max( - prepared.doc.documentElement.scrollHeight, - prepared.doc.body?.scrollHeight ?? 0, - prepared.iframe.clientHeight, - ), - ); - } finally { - prepared?.dispose(); - if (target.screenLease) { - releaseScreenFromExport?.(target.screenLease); - } - } - } const pdfScale = Math.max( PDF_MIN_PRINT_RASTER_SCALE, settings?.scale ?? PDF_MIN_PRINT_RASTER_SCALE, ); - const png = await renderPngBlob({ + const capture = await renderPngCapture({ scope, settings: { ...settings, scale: pdfScale }, format: "png", @@ -126,12 +60,12 @@ export async function runDownloadPdf( reject(reader.error ?? new Error("PDF read failed")); reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : ""); - reader.readAsDataURL(png); + reader.readAsDataURL(capture.blob); }); const pdf = await createSinglePageRasterPdf({ dataUrl, - width: pageWidth, - height: pageHeight, + width: capture.width, + height: capture.height, }); triggerBlobDownload(pdf, fallbackExportName("pdf", settings?.suffix)); toast.success(t("designEditor.toasts.pdfDownloaded")); diff --git a/templates/design/app/pages/design-editor/commands/render-png-blob.ts b/templates/design/app/pages/design-editor/commands/render-png-blob.ts index 2ffe8adfab4..3e2d2bcb56a 100644 --- a/templates/design/app/pages/design-editor/commands/render-png-blob.ts +++ b/templates/design/app/pages/design-editor/commands/render-png-blob.ts @@ -91,18 +91,27 @@ export function resolveSelectedScreenExportFrames(args: { }); } -export function resolveSelectedScreensExportBounds(args: { - selectedScreenIds: string[]; - overviewScreens: OverviewScreen[]; - canvasFrameGeometryById: CanvasFrameGeometryById; - iframeSizeById?: ReadonlyMap; -}) { - if (args.selectedScreenIds.length === 0) return null; - const frames = resolveSelectedScreenExportFrames(args); - return getExportCompositeBounds(frames.map(({ frame }) => frame)); +export interface PngCaptureResult { + blob: Blob; + width: number; + height: number; + scale: number; +} + +export interface PngRenderOptions { + scope: PngCaptureScope; + settings?: Partial; + format?: "png" | "jpg" | "webp"; } export async function runRenderPngBlob( + args: RenderPngBlobArgs, + options: PngRenderOptions, +): Promise { + return (await runRenderPngCapture(args, options)).blob; +} + +export async function runRenderPngCapture( { activeCanvasSourceType, designId, @@ -114,19 +123,14 @@ export async function runRenderPngBlob( selectedScreenIds, viewMode, }: RenderPngBlobArgs, - { - scope, - settings, - format = "png", - }: { - scope: PngCaptureScope; - settings?: Partial; - format?: "png" | "jpg" | "webp"; - }, -): Promise { + { scope, settings, format = "png" }: PngRenderOptions, +): Promise { const requestedExportScale = settings?.scale ?? Math.max(2, window.devicePixelRatio || 1); let outputCanvas: HTMLCanvasElement; + let width: number; + let height: number; + let scale: number; if ( scope === "screens" && @@ -204,6 +208,9 @@ export async function runRenderPngBlob( height: bounds.height, requestedScale: requestedExportScale, }); + width = bounds.width; + height = bounds.height; + scale = exportScale; outputCanvas = document.createElement("canvas"); outputCanvas.width = Math.max(1, Math.ceil(bounds.width * exportScale)); outputCanvas.height = Math.max(1, Math.ceil(bounds.height * exportScale)); @@ -315,7 +322,7 @@ export async function runRenderPngBlob( : undefined; if (selectedNodeIds && selectedNodeIds.length === 0) throw new PngCaptureError("selection-unresolved"); - return await captureNativeCurrentFramePng({ + const blob = await captureNativeCurrentFramePng({ designId, fileId, iframe: target.iframe, @@ -323,6 +330,12 @@ export async function runRenderPngBlob( ...(cropRect ? { cropRect } : {}), ...(selectedNodeIds ? { selectedNodeIds } : {}), }); + return { + blob, + width: cropRect?.width ?? target.iframe.clientWidth, + height: cropRect?.height ?? target.iframe.clientHeight, + scale: requestedExportScale, + }; } prepared = await prepareExportCaptureTarget(target); const { cropSelection, doc, iframe } = { @@ -374,6 +387,9 @@ export async function runRenderPngBlob( : [], }); outputCanvas = rendered.canvas; + width = rendered.width; + height = rendered.height; + scale = rendered.scale; } finally { prepared?.dispose(); if (screenLease) releaseScreenFromExport?.(screenLease); @@ -385,7 +401,7 @@ export async function runRenderPngBlob( : format === "webp" ? "image/webp" : "image/png"; - return await new Promise((resolve, reject) => { + const blob = await new Promise((resolve, reject) => { outputCanvas.toBlob( (blob) => { if (blob) resolve(blob); @@ -395,4 +411,5 @@ export async function runRenderPngBlob( mimeType === "image/png" ? undefined : 0.95, ); }); + return { blob, width, height, scale }; } diff --git a/templates/design/app/pages/design-editor/commands/selected-layer-pdf.snapshot.test.ts b/templates/design/app/pages/design-editor/commands/selected-layer-pdf.snapshot.test.ts index f07904d16be..bf9786f4930 100644 --- a/templates/design/app/pages/design-editor/commands/selected-layer-pdf.snapshot.test.ts +++ b/templates/design/app/pages/design-editor/commands/selected-layer-pdf.snapshot.test.ts @@ -52,9 +52,13 @@ vi.mock("@/pages/design-editor/png-export-render", async (importOriginal) => { }; }); vi.mock("sonner", () => ({ toast: { success: vi.fn() } })); +vi.mock("@/lib/design-export-tracking", () => ({ + trackBrowserDesignExported: vi.fn(), + browserDesignExportErrorType: (error: { code?: string }) => error.code, +})); import { runDownloadPdf } from "./download-pdf"; -import { runRenderPngBlob } from "./render-png-blob"; +import { runRenderPngCapture } from "./render-png-blob"; describe("selected-layer PDF export from runtime snapshots", () => { let toBlob: ReturnType; @@ -83,6 +87,8 @@ describe("selected-layer PDF export from runtime snapshots", () => { mocks.renderExportDocumentCanvas.mockReset().mockResolvedValue({ canvas: renderCanvas, scale: 2, + width: 80, + height: 40, }); mocks.resolveBoardExportCropRect.mockReset().mockReturnValue(null); mocks.resolveExportCropRect.mockReset().mockReturnValue({ @@ -151,9 +157,7 @@ describe("selected-layer PDF export from runtime snapshots", () => { { fallbackExportName: () => "Design - selection.pdf", pngExportingRef: { current: false }, - renderPngBlob: (arg) => runRenderPngBlob(renderArgs, arg), - resolveSelectedScreensBounds: () => null, - resolvePngCaptureTarget, + renderPngCapture: (arg) => runRenderPngCapture(renderArgs, arg), setPngExporting: vi.fn(), showRasterCaptureError, t: (key) => key, @@ -164,7 +168,7 @@ describe("selected-layer PDF export from runtime snapshots", () => { ); expect(resolvePngCaptureTarget).toHaveBeenCalledWith("element"); - expect(mocks.prepareExportCaptureTarget).toHaveBeenCalledTimes(2); + expect(mocks.prepareExportCaptureTarget).toHaveBeenCalledTimes(1); expect(mocks.renderExportDocumentCanvas).toHaveBeenCalledWith( expect.objectContaining({ doc: expect.any(Object), diff --git a/templates/design/app/pages/design-editor/domains/use-editor-export-and-handoff.ts b/templates/design/app/pages/design-editor/domains/use-editor-export-and-handoff.ts index ca1bc542d72..ddcd647ce0d 100644 --- a/templates/design/app/pages/design-editor/domains/use-editor-export-and-handoff.ts +++ b/templates/design/app/pages/design-editor/domains/use-editor-export-and-handoff.ts @@ -34,8 +34,11 @@ import { runDownloadPdf } from "../commands/download-pdf"; import { runDownloadPng } from "../commands/download-png"; import { runDownloadSvg } from "../commands/download-svg"; import { - resolveSelectedScreensExportBounds, + runRenderPngCapture, runRenderPngBlob, + type RenderPngBlobArgs, + type PngCaptureResult, + type PngRenderOptions, } from "../commands/render-png-blob"; import { runSelectAll, @@ -860,57 +863,19 @@ export function useEditorExportAndHandoff({ ], ); - const resolveSelectedScreensBounds = useCallback(() => { - if (viewMode !== "overview") return null; - const iframeSizeById = new Map( - selectedScreenIds.flatMap((screenId) => { - const iframe = document.querySelector( - `iframe[data-screen-iframe-id="${CSS.escape(screenId)}"]`, - ); - return iframe - ? [ - [ - screenId, - { width: iframe.clientWidth, height: iframe.clientHeight }, - ] as const, - ] - : []; - }), - ); - return resolveSelectedScreensExportBounds({ - selectedScreenIds, - overviewScreens, + const pngCaptureArgs = useMemo( + () => ({ + activeCanvasSourceType, + designId: id, + fileId: activeFile?.id, + canEditDesign, canvasFrameGeometryById: exportCanvasFrameGeometryById, - iframeSizeById, - }); - }, [ - exportCanvasFrameGeometryById, - overviewScreens, - selectedScreenIds, - viewMode, - ]); - - const renderPngBlob = useCallback( - async (arg0: { - scope: PngCaptureScope; - settings?: Partial; - format?: "png" | "jpg" | "webp"; - }): Promise => - runRenderPngBlob( - { - activeCanvasSourceType, - designId: id, - fileId: activeFile?.id, - canEditDesign, - canvasFrameGeometryById: exportCanvasFrameGeometryById, - overviewScreens, - releaseScreenFromExport, - resolvePngCaptureTarget, - selectedScreenIds, - viewMode, - }, - arg0, - ), + overviewScreens, + releaseScreenFromExport, + resolvePngCaptureTarget, + selectedScreenIds, + viewMode, + }), [ activeCanvasSourceType, id, @@ -925,6 +890,16 @@ export function useEditorExportAndHandoff({ viewMode, ], ); + const renderPngCapture = useCallback( + (options: PngRenderOptions): Promise => + runRenderPngCapture(pngCaptureArgs, options), + [pngCaptureArgs], + ); + const renderPngBlob = useCallback( + (options: PngRenderOptions): Promise => + runRenderPngBlob(pngCaptureArgs, options), + [pngCaptureArgs], + ); const showRasterCaptureError = useCallback( (error: unknown, format: "png" | "pdf" = "png") => { @@ -1126,10 +1101,7 @@ export function useEditorExportAndHandoff({ exportTracking, fallbackExportName, pngExportingRef, - renderPngBlob, - resolveSelectedScreensBounds, - resolvePngCaptureTarget, - releaseScreenFromExport, + renderPngCapture, setPngExporting, showRasterCaptureError, t, @@ -1141,10 +1113,7 @@ export function useEditorExportAndHandoff({ [ exportTracking, fallbackExportName, - renderPngBlob, - resolveSelectedScreensBounds, - resolvePngCaptureTarget, - releaseScreenFromExport, + renderPngCapture, t, triggerBlobDownload, ], diff --git a/templates/design/app/pages/design-editor/png-export-fidelity.browser.spec.ts b/templates/design/app/pages/design-editor/png-export-fidelity.browser.spec.ts new file mode 100644 index 00000000000..1da3ed50519 --- /dev/null +++ b/templates/design/app/pages/design-editor/png-export-fidelity.browser.spec.ts @@ -0,0 +1,652 @@ +import { createServer, type Server, type ServerResponse } from "node:http"; + +import { build } from "esbuild"; +import { chromium, type Browser } from "playwright"; +import { afterAll, beforeAll, expect, it, vi } from "vitest"; + +import { editorChromeBridgeScript } from "../../../.generated/bridge/editor-chrome.generated"; +import renderExportPng from "../../../actions/render-export-png"; + +const browsers: Browser[] = []; +let server: Server; +let base: string; +const slowResponses: ServerResponse[] = []; +let slowReleased = false; + +beforeAll(async () => { + const launch = chromium.launch.bind(chromium); + vi.spyOn(chromium, "launch").mockImplementation(async (options) => { + const browser = await launch(options); + browsers.push(browser); + return browser; + }); + const bundle = await build({ + stdin: { + contents: + 'export { renderExportDocumentCanvas } from "./app/pages/design-editor/png-export-render";', + resolveDir: process.cwd(), + }, + bundle: true, + platform: "browser", + format: "iife", + globalName: "exporter", + write: false, + logLevel: "silent", + define: { + "process.env.NODE_ENV": '"production"', + "import.meta.env": "{}", + }, + plugins: [ + { + name: "local-export-transport", + setup(build) { + build.onResolve( + { filter: /^@agent-native\/core\/client\/hooks$/ }, + () => ({ path: "transport", namespace: "fixture" }), + ); + build.onLoad({ filter: /.*/, namespace: "fixture" }, () => ({ + contents: `export async function callActionBlob(name,args) { + const response = await fetch('/render', {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(args)}); + if (!response.ok) throw new Error(await response.text()); + return response.blob(); + }`, + })); + }, + }, + ], + }); + server = createServer(async (request, response) => { + try { + if (request.url === "/render") { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(chunk); + const result = await renderExportPng.run( + JSON.parse(Buffer.concat(chunks).toString()), + { caller: "frontend" }, + ); + response.writeHead(result.status, { "content-type": "image/png" }); + response.end(Buffer.from(await result.arrayBuffer())); + } else if (request.url === "/exporter.js") { + response.writeHead(200, { "content-type": "text/javascript" }); + response.end(bundle.outputFiles[0]!.text); + } else if (request.url === "/slow-density.svg") { + if (slowReleased) { + response.writeHead(200, { "content-type": "image/svg+xml" }); + response.end( + '', + ); + } else { + slowResponses.push(response); + } + } else if (request.url === "/density.svg") { + response.writeHead(200, { "content-type": "image/svg+xml" }); + response.end( + '', + ); + } else if (request.url === "/alternate-density.svg") { + response.writeHead(200, { "content-type": "image/svg+xml" }); + response.end( + '', + ); + } else { + response.writeHead(200, { "content-type": "text/html" }); + response.end(""); + } + } catch (error) { + response.writeHead(500); + response.end(error instanceof Error ? error.message : String(error)); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + base = `http://127.0.0.1:${(server.address() as { port: number }).port}`; +}); + +afterAll(async () => { + for (const browser of browsers) await browser.close(); + for (const response of slowResponses) response.destroy(); + vi.restoreAllMocks(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); +}); + +function hydrateBridge(runtime: boolean): string { + return editorChromeBridgeScript + .replace("__READ_ONLY__", "false") + .replace("__TEXT_EDITING_ENABLED__", "false") + .replace("__EDITOR_CHROME_SCALE_X__", "1") + .replace("__EDITOR_CHROME_SCALE_Y__", "1") + .replace("__DESIGN_CANVAS_SCREEN_ID__", '"export-fidelity"') + .replace("__DESIGN_CANVAS_BOARD_SURFACE__", "true") + .replace("__DESIGN_CANVAS_CONTENT_OFFSET_X__", "0") + .replace("__DESIGN_CANVAS_CONTENT_OFFSET_Y__", "0") + .replace("__RUNTIME_LAYER_SNAPSHOT_ENABLED__", String(runtime)) + .replace(/__INITIAL_SOURCE_HEAD__/g, '""'); +} + +type ExportFidelityFixture = { + name: string; + width: number; + height: number; + frameHeight: number; + content: string; + redWidth: number; + redHeight: number; + exportWidth?: number; + runtime?: boolean; + lazy?: boolean; + modeParity?: boolean; + verifySrcsetDataUrl?: boolean; + pictureSourcesRemoved?: boolean; + directRedHeight?: number; +}; + +const exportFidelityFixtures: ExportFidelityFixture[] = [ + { + name: "responsive layout with horizontal overflow", + width: 1000, + height: 600, + frameHeight: 600, + content: + '
', + redWidth: 100, + redHeight: 100, + }, + { + name: "density-corrected responsive image", + width: 400, + height: 400, + frameHeight: 400, + content: + '', + modeParity: true, + redWidth: 100, + redHeight: 50, + }, + { + name: "responsive inline SVG data URL with whitespace", + width: 400, + height: 400, + frameHeight: 400, + modeParity: true, + verifySrcsetDataUrl: true, + content: + "\" srcset=\"\">", + redWidth: 100, + redHeight: 50, + }, + { + name: "the browser-selected picture source at a narrower viewport", + width: 400, + exportWidth: 50, + height: 400, + frameHeight: 400, + modeParity: true, + pictureSourcesRemoved: true, + content: + '', + redWidth: 50, + redHeight: 50, + }, + { + name: "an offscreen lazy responsive image", + width: 400, + height: 400, + frameHeight: 400, + lazy: true, + modeParity: true, + content: + '', + redWidth: 100, + redHeight: 50, + }, + { + name: "an offscreen lazy image with only a fallback source", + width: 400, + height: 400, + frameHeight: 400, + lazy: true, + modeParity: true, + content: + '', + redWidth: 200, + redHeight: 100, + }, + { + name: "unsupported picture source sharing the fallback URL", + width: 400, + height: 400, + frameHeight: 400, + content: + '', + modeParity: true, + redWidth: 200, + redHeight: 100, + }, + { + name: "runtime responsive image in a narrower viewport", + width: 400, + exportWidth: 50, + height: 400, + frameHeight: 400, + modeParity: true, + content: + '', + redWidth: 50, + redHeight: 25, + directRedHeight: 50, + }, + { + name: "an empty inline SVG image reference", + width: 400, + height: 200, + frameHeight: 200, + runtime: true, + content: + '', + redWidth: 100, + redHeight: 50, + }, + { + name: "frame taller than the loaded document", + width: 1000, + height: 200, + frameHeight: 1000, + content: + '
', + redWidth: 100, + redHeight: 100, + }, +]; + +const exportFidelityCases: ExportFidelityFixture[] = + exportFidelityFixtures.flatMap((fixture) => { + if (!fixture.modeParity) return [fixture]; + return [false, true].map((runtime) => ({ + ...fixture, + runtime, + name: `${fixture.name} (${runtime ? "runtime snapshot" : "direct export"})`, + })); + }); + +it.each(exportFidelityCases)( + "preserves $name in the exported pixels", + async (fixture) => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ deviceScaleFactor: 2 }); + await page.goto(base); + await page.addScriptTag({ url: `${base}/exporter.js` }); + await page.evaluate(() => { + window.addEventListener("message", (event) => { + if (event.data?.type === "agent-native:runtime-layer-snapshot") { + (window as any).__exportSnapshot = event.data.payload.html; + } + if ( + event.data?.type === + "agent-native:runtime-layer-snapshot-reservation-request" + ) { + (event.source as Window).postMessage( + { + type: "grant-runtime-layer-snapshot-reservation", + requestId: event.data.requestId, + documentId: event.data.documentId, + reservationToken: "export-fidelity", + }, + "*", + ); + } + }); + }); + await page.evaluate(async (fixture) => { + const iframe = document.createElement("iframe"); + iframe.width = String(fixture.width); + iframe.height = String(fixture.height); + iframe.style.cssText = + "border:0;transform:scale(.5935);transform-origin:0 0"; + if ("lazy" in fixture && fixture.lazy) iframe.style.top = "8000px"; + if ("lazy" in fixture && fixture.lazy) iframe.style.position = "absolute"; + const ready = new Promise((resolve) => { + iframe.onload = () => resolve(); + }); + iframe.srcdoc = `${fixture.content}`; + document.body.appendChild(iframe); + await ready; + if (!("lazy" in fixture && fixture.lazy)) { + await Promise.all( + Array.from(iframe.contentDocument!.images, (image) => image.decode()), + ); + } + }, fixture); + if ("lazy" in fixture && fixture.lazy) { + await page.frames()[1]!.evaluate(() => { + (window as any).__initialImageCurrentSrc = + document.querySelector("img")?.currentSrc ?? ""; + }); + } + await page.frames()[1]!.addScriptTag({ + content: hydrateBridge("runtime" in fixture && fixture.runtime === true), + }); + if ("runtime" in fixture && fixture.runtime) { + if ("lazy" in fixture && fixture.lazy) { + expect( + await page + .frames()[1]! + .evaluate(() => (window as any).__initialImageCurrentSrc), + ).toBe(""); + } + await page.waitForFunction( + () => typeof (window as any).__exportSnapshot === "string", + ); + expect( + await page.evaluate(() => (window as any).__exportSnapshot), + ).not.toContain("data-agent-native-export-resource-failures"); + if ("pictureSourcesRemoved" in fixture && fixture.pictureSourcesRemoved) { + expect( + await page.evaluate(() => { + const snapshot = new DOMParser().parseFromString( + (window as any).__exportSnapshot, + "text/html", + ); + return snapshot.querySelectorAll("picture source").length; + }), + ).toBe(0); + } + await page.evaluate(async (width) => { + const original = document.querySelector("iframe")!; + const replacement = document.createElement("iframe"); + replacement.width = String(width); + replacement.height = "400"; + replacement.style.border = "0"; + const ready = new Promise( + (resolve) => (replacement.onload = () => resolve()), + ); + replacement.srcdoc = (window as any).__exportSnapshot; + original.replaceWith(replacement); + await ready; + }, fixture.exportWidth); + } + if ( + "verifySrcsetDataUrl" in fixture && + fixture.verifySrcsetDataUrl && + "runtime" in fixture && + fixture.runtime + ) { + expect( + await page + .frames()[1]! + .locator("#probe") + .evaluate(async (element) => { + const image = new Image(); + image.srcset = (element as HTMLImageElement).srcset; + await image.decode(); + return image.naturalWidth; + }), + ).toBe(200); + } + const result = await page.evaluate(async (fixture) => { + const iframe = document.querySelector("iframe")!; + const { canvas } = await ( + window as any + ).exporter.renderExportDocumentCanvas({ + doc: iframe.contentDocument, + iframe, + exportScale: 1, + cropRect: { + x: 0, + y: 0, + width: "exportWidth" in fixture ? fixture.exportWidth : fixture.width, + height: fixture.frameHeight, + }, + }); + const pixels = canvas + .getContext("2d") + .getImageData(0, 0, canvas.width, canvas.height).data; + let left = canvas.width, + top = canvas.height, + right = -1, + bottom = -1; + for (let y = 0; y < canvas.height; y++) + for (let x = 0; x < canvas.width; x++) { + const index = (y * canvas.width + x) * 4; + if ( + pixels[index] > 200 && + pixels[index + 1] < 80 && + pixels[index + 2] < 80 && + pixels[index + 3] > 200 + ) { + left = Math.min(left, x); + top = Math.min(top, y); + right = Math.max(right, x); + bottom = Math.max(bottom, y); + } + } + return { + width: canvas.width, + height: canvas.height, + redWidth: right - left + 1, + redHeight: bottom - top + 1, + }; + }, fixture); + expect(result).toEqual({ + width: "exportWidth" in fixture ? fixture.exportWidth : fixture.width, + height: fixture.frameHeight, + redWidth: fixture.redWidth, + redHeight: + "directRedHeight" in fixture && + "runtime" in fixture && + fixture.runtime === false + ? fixture.directRedHeight + : fixture.redHeight, + }); + if ("lazy" in fixture && fixture.lazy) { + expect( + await page.frames()[1]!.locator("img").getAttribute("loading"), + ).toBe("lazy"); + } + await browser.close(); + }, +); + +it("preserves transparent padding for negative-origin element crops", async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ deviceScaleFactor: 1 }); + await page.goto(base); + await page.addScriptTag({ url: `${base}/exporter.js` }); + await page.evaluate(async () => { + const iframe = document.createElement("iframe"); + iframe.width = "400"; + iframe.height = "400"; + iframe.style.border = "0"; + const ready = new Promise( + (resolve) => (iframe.onload = () => resolve()), + ); + iframe.srcdoc = `
`; + document.body.appendChild(iframe); + await ready; + }); + await page.frames()[1]!.addScriptTag({ content: hydrateBridge(false) }); + const result = await page.evaluate(async () => { + const iframe = document.querySelector("iframe")!; + const { canvas } = await ( + window as any + ).exporter.renderExportDocumentCanvas({ + doc: iframe.contentDocument, + iframe, + exportScale: 1, + cropRect: { x: -16, y: -8, width: 116, height: 68 }, + }); + const pixels = canvas + .getContext("2d") + .getImageData(0, 0, canvas.width, canvas.height).data; + let left = canvas.width, + top = canvas.height, + right = -1, + bottom = -1; + for (let y = 0; y < canvas.height; y++) + for (let x = 0; x < canvas.width; x++) { + const index = (y * canvas.width + x) * 4; + if ( + pixels[index] > 200 && + pixels[index + 1] < 80 && + pixels[index + 2] < 80 && + pixels[index + 3] > 200 + ) { + left = Math.min(left, x); + top = Math.min(top, y); + right = Math.max(right, x); + bottom = Math.max(bottom, y); + } + } + return { + width: canvas.width, + height: canvas.height, + redLeft: left, + redTop: top, + redWidth: right - left + 1, + redHeight: bottom - top + 1, + }; + }); + expect(result).toEqual({ + width: 116, + height: 68, + redLeft: 16, + redTop: 8, + redWidth: 84, + redHeight: 52, + }); + const offscreenError = await page.evaluate(async () => { + const iframe = document.querySelector("iframe")!; + try { + await (window as any).exporter.renderExportDocumentCanvas({ + doc: iframe.contentDocument, + iframe, + exportScale: 1, + cropRect: { x: 500, y: 500, width: 10, height: 10 }, + }); + return null; + } catch (error) { + return (error as { code?: string }).code ?? null; + } + }); + expect(offscreenError).toBe("selection-unresolved"); + await browser.close(); +}); + +it("waits for a loading width-described image and refreshes its runtime snapshot", async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ deviceScaleFactor: 2 }); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(base); + await page.addScriptTag({ url: `${base}/exporter.js` }); + await page.evaluate(async () => { + (window as any).__snapshots = []; + window.addEventListener("message", (event) => { + if (event.data?.type === "agent-native:runtime-layer-snapshot") { + (window as any).__snapshots.push(event.data.payload.html); + } + if ( + event.data?.type === + "agent-native:runtime-layer-snapshot-reservation-request" + ) { + (window as any).__reservation = true; + (event.source as Window).postMessage( + { + type: "grant-runtime-layer-snapshot-reservation", + requestId: event.data.requestId, + documentId: event.data.documentId, + reservationToken: "loading-image", + }, + "*", + ); + } + }); + const iframe = document.createElement("iframe"); + iframe.width = "400"; + iframe.height = "400"; + iframe.style.border = "0"; + const ready = new Promise( + (resolve) => (iframe.onload = () => resolve()), + ); + iframe.srcdoc = + ''; + document.body.appendChild(iframe); + await ready; + }); + await page.frames()[1]!.addScriptTag({ content: hydrateBridge(true) }); + const requested = page.waitForRequest((request) => + request.url().endsWith("/slow-density.svg"), + ); + await page.frames()[1]!.evaluate(() => { + const image = document.createElement("img"); + image.srcset = "/slow-density.svg 200w"; + image.sizes = "100px"; + document.body.appendChild(image); + }); + await requested; + await page.waitForFunction(() => (window as any).__reservation === true); + const capture = page.evaluate(async () => { + const iframe = document.querySelector("iframe")!; + const { canvas } = await ( + window as any + ).exporter.renderExportDocumentCanvas({ + doc: iframe.contentDocument, + iframe, + exportScale: 1, + cropRect: { x: 0, y: 0, width: 400, height: 400 }, + }); + const row = canvas + .getContext("2d") + .getImageData(0, 0, canvas.width, 1).data; + let red = 0; + for (let x = 0; x < canvas.width; x++) { + if (row[x * 4] > 200 && row[x * 4 + 1] < 80 && row[x * 4 + 2] < 80) red++; + } + return red; + }); + await new Promise((resolve) => setTimeout(resolve, 10_500)); + slowReleased = true; + for (const response of slowResponses) { + response.writeHead(200, { "content-type": "image/svg+xml" }); + response.end( + '', + ); + } + expect(await capture).toBe(100); + await page.waitForFunction(() => + ((window as any).__snapshots as string[]).some((html) => { + const snapshot = new DOMParser().parseFromString(html, "text/html"); + return ( + snapshot.querySelector("img") && + !snapshot.documentElement.hasAttribute( + "data-agent-native-export-resource-failures", + ) + ); + }), + ); + const width = await page.evaluate(async () => { + const html = (window as any).__snapshots.at(-1); + const snapshot = new DOMParser().parseFromString(html, "text/html"); + const source = snapshot.querySelector("img")!; + const image = new Image(); + image.srcset = source.srcset; + await image.decode(); + return image.naturalWidth; + }); + expect( + await page.evaluate(() => { + const source = new DOMParser() + .parseFromString((window as any).__snapshots.at(-1), "text/html") + .querySelector("img"); + return { + hasSrc: source?.hasAttribute("src"), + hasSrcset: source?.hasAttribute("srcset"), + }; + }), + ).toEqual({ hasSrc: false, hasSrcset: true }); + expect(width).toBe(100); + expect(errors).toEqual([]); + await browser.close(); +}, 20_000); diff --git a/templates/design/app/pages/design-editor/png-export-render.ts b/templates/design/app/pages/design-editor/png-export-render.ts index 41f9f69e66c..3a0d1d84b62 100644 --- a/templates/design/app/pages/design-editor/png-export-render.ts +++ b/templates/design/app/pages/design-editor/png-export-render.ts @@ -1,6 +1,11 @@ import type { ElementInfo } from "@/components/design/types"; import { isDesignHotkeyEditableTarget } from "@/hooks/useDesignHotkeys"; +import { + copyExportImageSource, + prepareExportImageSource, + resolveExportImageSource, +} from "../../../shared/export-image-source"; import { computeExportCropBox, EDITOR_CHROME_OVERLAY_SELECTOR, @@ -83,30 +88,37 @@ function resolveClonedElement( return clonedElement; } -function normalizeClonedResponsiveImages( +async function normalizeClonedResponsiveImages( sourceDocument: Document, clonedDocument: Document, -): void { - for (const sourceImage of Array.from( - sourceDocument.querySelectorAll("img"), - )) { - const clonedImage = resolveClonedElement( - sourceDocument, - clonedDocument, - sourceImage, - ) as HTMLImageElement | null; - if (!clonedImage) continue; - if (sourceImage.currentSrc) { - clonedImage.setAttribute("src", sourceImage.currentSrc); - } - clonedImage.removeAttribute("srcset"); - clonedImage - .closest("picture") - ?.querySelectorAll("source") - .forEach((source) => { - source.remove(); - }); - } +): Promise { + const signal = AbortSignal.timeout(15_000); + await Promise.all( + Array.from(sourceDocument.querySelectorAll("img")).map( + async (sourceImage) => { + const loadedImage = await prepareExportImageSource(sourceImage, signal); + const clonedImage = resolveClonedElement( + sourceDocument, + clonedDocument, + sourceImage, + ) as HTMLImageElement | null; + if (!clonedImage) return; + clonedImage.loading = "eager"; + copyExportImageSource(loadedImage, clonedImage); + await resolveExportImageSource( + clonedImage, + loadedImage.currentSrc, + signal, + ); + clonedImage + .closest("picture") + ?.querySelectorAll("source") + .forEach((source) => { + source.remove(); + }); + }, + ), + ); } export function preserveLiveStylesheets( @@ -611,8 +623,23 @@ export async function renderExportDocumentCanvas({ exportScale: number; cropRect?: ExportCropRect | null; isolateSelectedElements?: readonly Element[]; -}): Promise<{ canvas: HTMLCanvasElement; scale: number }> { +}): Promise<{ + canvas: HTMLCanvasElement; + scale: number; + width: number; + height: number; +}> { await waitForExportReady(doc); + const clonedDocument = doc.cloneNode(true) as Document; + try { + await normalizeClonedResponsiveImages(doc, clonedDocument); + } catch { + throw new NativeExportRenderError( + "The export contains an image that could not be loaded.", + "export_resources_unavailable", + ); + } + preserveLiveStylesheets(doc, clonedDocument); const width = Math.max( doc.documentElement.scrollWidth, doc.body?.scrollWidth ?? 0, @@ -623,22 +650,20 @@ export async function renderExportDocumentCanvas({ doc.body?.scrollHeight ?? 0, iframe.clientHeight, ); - const renderCropRect = cropRect - ? intersectExportCropRect(cropRect, width, height) + const outputCropRect = cropRect ?? null; + const renderCropRect = outputCropRect + ? intersectExportCropRect(outputCropRect, width, height) : null; if (cropRect && !renderCropRect) { throw new PngCaptureError("selection-unresolved"); } - const renderWidth = renderCropRect?.width ?? width; - const renderHeight = renderCropRect?.height ?? height; + const renderWidth = outputCropRect?.width ?? width; + const renderHeight = outputCropRect?.height ?? height; const effectiveScale = resolveRasterExportScale({ width: renderWidth, height: renderHeight, requestedScale: exportScale, }); - const clonedDocument = doc.cloneNode(true) as Document; - preserveLiveStylesheets(doc, clonedDocument); - normalizeClonedResponsiveImages(doc, clonedDocument); isolateSelectedExportElements(doc, clonedDocument, isolateSelectedElements); removeEditorChromeOverlays(clonedDocument); const serializedHtml = `${clonedDocument.documentElement.outerHTML}`; @@ -665,25 +690,39 @@ export async function renderExportDocumentCanvas({ } const png = await renderNativeExportPng({ html: inlinedSnapshot?.html ?? serializedHtml, - width, + width: Math.max(1, iframe.clientWidth), height: Math.max(1, iframe.clientHeight), scale: effectiveScale, clip: renderCropRect ?? undefined, }); const bitmap = await createImageBitmap(png); const canvas = document.createElement("canvas"); - canvas.width = bitmap.width; - canvas.height = bitmap.height; + canvas.width = outputCropRect + ? Math.max(1, Math.ceil(renderWidth * effectiveScale)) + : bitmap.width; + canvas.height = outputCropRect + ? Math.max(1, Math.ceil(renderHeight * effectiveScale)) + : bitmap.height; const context = canvas.getContext("2d"); if (!context) { bitmap.close(); throw new PngCaptureError("blob-failed"); } - context.drawImage(bitmap, 0, 0); + context.drawImage( + bitmap, + renderCropRect && outputCropRect + ? (renderCropRect.x - outputCropRect.x) * effectiveScale + : 0, + renderCropRect && outputCropRect + ? (renderCropRect.y - outputCropRect.y) * effectiveScale + : 0, + ); bitmap.close(); return { canvas, scale: effectiveScale, + width: outputCropRect?.width ?? canvas.width / effectiveScale, + height: outputCropRect?.height ?? canvas.height / effectiveScale, }; } diff --git a/templates/design/changelog/2026-10-23-png-and-pdf-exports-preserve-frame-proportions-responsive-la.md b/templates/design/changelog/2026-10-23-png-and-pdf-exports-preserve-frame-proportions-responsive-la.md new file mode 100644 index 00000000000..5fe7ad1e540 --- /dev/null +++ b/templates/design/changelog/2026-10-23-png-and-pdf-exports-preserve-frame-proportions-responsive-la.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-10-23 +--- + +PNG and PDF exports preserve frame proportions, responsive layouts, and image sizing. diff --git a/templates/design/e2e/public-visual-edit.spec.ts b/templates/design/e2e/public-visual-edit.spec.ts index 0c9351082a1..f101c30e6af 100644 --- a/templates/design/e2e/public-visual-edit.spec.ts +++ b/templates/design/e2e/public-visual-edit.spec.ts @@ -432,6 +432,7 @@ test.describe("public visual edit", () => { } if (pathname === "/slow-image.svg") { response.writeHead(200, { + "access-control-allow-origin": "*", "cache-control": "no-store", "content-type": "image/svg+xml", }); diff --git a/templates/design/shared/export-image-source.ts b/templates/design/shared/export-image-source.ts new file mode 100644 index 00000000000..8bdefcbeaa4 --- /dev/null +++ b/templates/design/shared/export-image-source.ts @@ -0,0 +1,98 @@ +const EXPORT_IMAGE_NATURAL_WIDTH = + "data-agent-native-export-image-natural-width"; + +export function isResponsiveExportImage(image: HTMLImageElement): boolean { + return ( + image.hasAttribute("srcset") || + Boolean(image.closest("picture")?.querySelector("source[srcset]")) || + image.hasAttribute(EXPORT_IMAGE_NATURAL_WIDTH) + ); +} + +export function copyExportImageSource( + source: HTMLImageElement, + clone: HTMLImageElement, +): void { + if (source.currentSrc) clone.setAttribute("src", source.currentSrc); + if (isResponsiveExportImage(source)) { + clone.setAttribute(EXPORT_IMAGE_NATURAL_WIDTH, String(source.naturalWidth)); + } + clone.removeAttribute("srcset"); +} + +export async function decodeExportImage( + image: HTMLImageElement, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + let onAbort: () => void; + try { + await Promise.race([ + image.decode(), + new Promise((_resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + }), + ]); + } finally { + signal.removeEventListener("abort", onAbort!); + } +} + +export async function prepareExportImageSource( + source: HTMLImageElement, + signal: AbortSignal, +): Promise { + let image = source; + if (source.loading === "lazy" && !source.complete) { + const picture = source.closest("picture"); + if (picture) { + const sourceImages = Array.from(picture.querySelectorAll("img")); + const imageIndex = sourceImages.indexOf(source); + const clonedImages = ( + picture.cloneNode(true) as Element + ).querySelectorAll("img"); + const clonedImage = clonedImages[imageIndex]; + if (!clonedImage) { + throw new Error("Export image source is unavailable."); + } + image = clonedImage; + } else { + image = source.cloneNode(true) as HTMLImageElement; + } + image.loading = "eager"; + } + await decodeExportImage(image, signal); + return image; +} + +export async function resolveExportImageSource( + image: Element, + source: string, + signal: AbortSignal, +): Promise { + const naturalWidth = image.getAttribute(EXPORT_IMAGE_NATURAL_WIDTH); + if (naturalWidth === null) { + image.removeAttribute("srcset"); + return; + } + const width = Number(naturalWidth); + if (!Number.isFinite(width) || width <= 0) { + throw new Error("Responsive export image is not ready."); + } + // CSSOM naturalWidth already includes srcset density; decoding the chosen + // asset alone recovers its physical width without guessing picture support. + const intrinsic = new Image(); + intrinsic.src = source; + await decodeExportImage(intrinsic, signal); + const density = intrinsic.naturalWidth / width; + if (!Number.isFinite(density) || density <= 0) { + throw new Error("Responsive export image dimensions are unavailable."); + } + image.removeAttribute("src"); + const srcsetSource = source.replace( + /[ \t\n\f\r]/g, + (character) => `%${character.charCodeAt(0).toString(16).padStart(2, "0")}`, + ); + image.setAttribute("srcset", `${srcsetSource} ${density}x`); +} diff --git a/templates/design/shared/srcset.ts b/templates/design/shared/srcset.ts new file mode 100644 index 00000000000..ffe82a31728 --- /dev/null +++ b/templates/design/shared/srcset.ts @@ -0,0 +1,43 @@ +export interface SrcsetCandidate { + url: string; + descriptor: string; +} + +export function parseSrcset(value: string): SrcsetCandidate[] { + const candidates: SrcsetCandidate[] = []; + let position = 0; + + while (position < value.length) { + while (position < value.length && /[\s,]/.test(value[position]!)) + position += 1; + if (position >= value.length) break; + + const urlStart = position; + while (position < value.length && !/\s/.test(value[position]!)) + position += 1; + let url = value.slice(urlStart, position); + const hasTrailingSeparator = url.endsWith(","); + while (url.endsWith(",")) url = url.slice(0, -1); + + let descriptor = ""; + if (!hasTrailingSeparator) { + while (position < value.length && /\s/.test(value[position]!)) + position += 1; + const descriptorStart = position; + let parentheses = 0; + while (position < value.length) { + const character = value[position]!; + if (character === "(") parentheses += 1; + if (character === ")" && parentheses > 0) parentheses -= 1; + if (character === "," && parentheses === 0) break; + position += 1; + } + descriptor = value.slice(descriptorStart, position).trim(); + if (value[position] === ",") position += 1; + } + + if (url) candidates.push({ url, descriptor }); + } + + return candidates; +}