diff --git a/apps/server/package.json b/apps/server/package.json index 497f8fb6a..166dfbb0f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -67,6 +67,7 @@ "drizzle-zod": "1.0.0-beta.14-a36c63d", "es-toolkit": "^1.52.0", "fast-json-patch": "^3.1.1", + "fast-png": "^8.0.0", "hono": "^4.13.5", "jsonrepair": "^3.15.0", "node-html-parser": "^9.0.3", diff --git a/knip.json b/knip.json index 9c4f3c462..c1eaaa8bf 100644 --- a/knip.json +++ b/knip.json @@ -47,6 +47,7 @@ "deepmerge-ts", "drizzle-zod", "fast-json-patch", + "fast-png", "jsonrepair", "node-html-parser", "nodemailer", diff --git a/packages/pdf/package.json b/packages/pdf/package.json index 6926d95c0..5051ea5a1 100644 --- a/packages/pdf/package.json +++ b/packages/pdf/package.json @@ -30,6 +30,7 @@ "@reactive-resume/schema": "workspace:*", "@reactive-resume/utils": "workspace:*", "cjk-regex": "^3.4.0", + "fast-png": "^8.0.0", "node-html-parser": "^9.0.3", "phosphor-icons-react-pdf": "^0.1.3", "react": "^19.2.8", diff --git a/packages/pdf/src/templates/shared/picture-border.test.tsx b/packages/pdf/src/templates/shared/picture-border.test.tsx new file mode 100644 index 000000000..6ece3573f --- /dev/null +++ b/packages/pdf/src/templates/shared/picture-border.test.tsx @@ -0,0 +1,229 @@ +import type { Style } from "@react-pdf/types"; +import { describe, expect, it } from "vitest"; +import { createCanvas } from "@napi-rs/canvas"; +import { renderToBuffer } from "@react-pdf/renderer"; +import { act, createElement } from "react"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { ResumeDocument } from "../../document"; +import { resolveResumeRuntime } from "../../semantic/resolve"; +import { rasterizePdf } from "../../semantic/test/rasterize-pdf"; + +const source = createCanvas(100, 100); +const context = source.getContext("2d"); +context.fillStyle = "#00aa00"; +context.fillRect(0, 0, 100, 100); + +async function picturePixels( + template: "onyx" | "ditto" | "glalie", + borderWidth: number, + mode: "semantic" | "legacy" = "semantic", + stylesheet = "@version 1;", + shadowWidth = 0, + url = source.toDataURL("image/png"), + styleOverride?: Style, +) { + const data = structuredClone(defaultResumeData); + data.basics.name = "Picture"; + data.metadata.typography.body.fontFamily = "Helvetica"; + data.metadata.typography.heading.fontFamily = "Helvetica"; + data.metadata.stylesheet = { mode, source: { languageVersion: 1, text: stylesheet } }; + data.metadata.layout.pages = [{ fullWidth: false, main: [], sidebar: [] }]; + Object.assign(data.picture, { + url, + hidden: false, + size: 100, + borderRadius: 0, + borderColor: "rgba(255, 0, 255, 1)", + borderWidth, + shadowWidth, + shadowColor: "rgba(0, 0, 255, 1)", + }); + let runtime = resolveResumeRuntime({ data, template, mode }); + if (styleOverride) { + const entry = Object.entries(runtime.presentation).find(([key]) => key.endsWith("/picture")); + if (!entry) throw new Error("Missing picture presentation"); + const [key, picture] = entry; + runtime = { + ...runtime, + presentation: { ...runtime.presentation, [key]: { ...picture, style: { ...picture.style, ...styleOverride } } }, + }; + } + const element = createElement(ResumeDocument, { + data, + template, + semanticRuntime: styleOverride ? runtime : undefined, + }) as unknown as Parameters[0]; + let bytes = new Uint8Array(); + await act(async () => { + bytes = new Uint8Array(await renderToBuffer(element)); + }); + const [page] = await rasterizePdf(bytes); + if (!page) throw new Error("Missing rendered page"); + let borderPixels = 0; + let imagePixels = 0; + let shadowPixels = 0; + const shadowBounds = { + left: Number.POSITIVE_INFINITY, + right: Number.NEGATIVE_INFINITY, + top: Number.POSITIVE_INFINITY, + bottom: Number.NEGATIVE_INFINITY, + }; + const positions: { x: number; y: number }[] = []; + for (let index = 0; index < page.data.length; index += 4) { + if ( + (page.data[index + 2] ?? 0) > (page.data[index] ?? 0) + 5 && + (page.data[index + 2] ?? 0) > (page.data[index + 1] ?? 0) + 5 + ) { + shadowPixels++; + const x = (index / 4) % page.width; + const y = Math.floor(index / 4 / page.width); + shadowBounds.left = Math.min(shadowBounds.left, x); + shadowBounds.right = Math.max(shadowBounds.right, x); + shadowBounds.top = Math.min(shadowBounds.top, y); + shadowBounds.bottom = Math.max(shadowBounds.bottom, y); + } + if (page.data[index] === 255 && page.data[index + 1] === 0 && page.data[index + 2] === 255) borderPixels++; + if ( + (page.data[index + 1] ?? 0) > (page.data[index] ?? 0) + 5 && + (page.data[index + 1] ?? 0) > (page.data[index + 2] ?? 0) + 5 + ) + imagePixels++; + if ( + (page.data[index] === 255 && page.data[index + 1] === 0 && page.data[index + 2] === 255) || + ((page.data[index + 1] ?? 0) > (page.data[index] ?? 0) + 5 && + (page.data[index + 1] ?? 0) > (page.data[index + 2] ?? 0) + 5) + ) + positions.push({ x: (index / 4) % page.width, y: Math.floor(index / 4 / page.width) }); + } + const bounds = { + left: Math.min(...positions.map(({ x }) => x)), + right: Math.max(...positions.map(({ x }) => x)), + top: Math.min(...positions.map(({ y }) => y)), + bottom: Math.max(...positions.map(({ y }) => y)), + }; + const center = + (Math.floor((bounds.top + bounds.bottom) / 2) * page.width + Math.floor((bounds.left + bounds.right) / 2)) * 4; + return { + borderPixels, + imagePixels, + shadowPixels, + shadowBounds, + bounds, + center: [...page.data.slice(center, center + 3)], + }; +} + +describe("picture border visibility (#3017)", () => { + it("keeps the shadow centered on the outer border box when borders and padding change", async () => { + const plain = await picturePixels("onyx", 0, "semantic", "@version 1;", 10); + const bordered = await picturePixels("onyx", 10, "semantic", "@version 1; picture { padding: 5pt; }", 10); + expect(bordered.shadowBounds).toEqual(plain.shadowBounds); + const asymmetric = await picturePixels( + "onyx", + 0, + "semantic", + "@version 1; picture { border-top: 12pt solid #ff00ff; border-right: 3pt solid #ff00ff; border-bottom: 8pt solid #ff00ff; border-left: 5pt solid #ff00ff; padding: 3pt 4pt 5pt 6pt; }", + 10, + ); + expect(asymmetric.shadowBounds).toEqual(plain.shadowBounds); + }); + it("preserves percentage dimensions, padding, rounded borders, rotation and opacity", async () => { + const css = + "@version 1; picture { width: 40%; height: 100pt; padding: 1%; border-radius: 25%; transform: rotate(15deg); }"; + const opaque = await picturePixels("onyx", 10, "semantic", css); + const plain = await picturePixels("onyx", 10, "semantic", css, 0, undefined, { opacity: 0.5 }); + const shadow = await picturePixels("onyx", 10, "semantic", css, 10, undefined, { opacity: 0.5 }); + expect(opaque.borderPixels).toBeGreaterThan(1000); + expect(plain.imagePixels).toBeGreaterThan(1000); + expect(shadow).toEqual(plain); // Unresolved percentage dimensions omit the shadow. + expect(plain.center[0]).toBeGreaterThanOrEqual(126); + expect(plain.center[0]).toBeLessThanOrEqual(129); + }); + it.each([0, 10])("keeps percentage picture padding outside the border inset (%s)", async (shadowWidth) => { + const plain = await picturePixels("onyx", 10, "semantic", "@version 1;", shadowWidth); + const padded = await picturePixels("onyx", 10, "semantic", "@version 1; picture { padding: 1%; }", shadowWidth); + expect(padded.borderPixels).toBe(plain.borderPixels); + expect(padded.imagePixels).toBeLessThan(plain.imagePixels); + }); + it.each([0, 10])("preserves border and authored padding with shadow width %s", async (shadowWidth) => { + const plain = await picturePixels("onyx", 10, "semantic", "@version 1;", shadowWidth); + const zero = await picturePixels("onyx", 10, "semantic", "@version 1; picture { padding: 0; }", shadowWidth); + const padded = await picturePixels("onyx", 10, "semantic", "@version 1; picture { padding: 5pt; }", shadowWidth); + expect(zero).toEqual(plain); + expect(padded.borderPixels).toBe(plain.borderPixels); + expect(padded.imagePixels).toBeLessThan(plain.imagePixels); + }); + it.each(["onyx", "ditto", "glalie"] as const)("draws the border around an opaque picture (%s)", async (template) => { + const plain = await picturePixels(template, 0); + const bordered = await picturePixels(template, 10); + expect(plain.borderPixels).toBe(0); + expect(bordered.borderPixels).toBeGreaterThan(1000); + expect(bordered.imagePixels).toBeGreaterThan(1000); + expect(bordered.imagePixels).toBeLessThan(plain.imagePixels); + // Rasterized border/image edges can differ by one antialiased pixel. + for (const edge of ["left", "right", "top", "bottom"] as const) + expect(Math.abs(bordered.bounds[edge] - plain.bounds[edge])).toBeLessThanOrEqual(1); + }); + it("honors semantic border width overrides", async () => { + const metadataBorder = await picturePixels("onyx", 10); + const overridden = await picturePixels( + "onyx", + 0, + "semantic", + "@version 1; picture { border: 10pt solid #ff00ff; }", + ); + expect(overridden).toEqual(metadataBorder); + }); + it("keeps legacy picture borders visible", async () => { + const bordered = await picturePixels("onyx", 10, "legacy"); + expect(bordered.borderPixels).toBeGreaterThan(1000); + }); + it.each(["onyx", "ditto", "glalie"] as const)( + "draws a soft centered shadow without moving the photo (%s)", + async (template) => { + const plain = await picturePixels(template, 0); + const shadow = await picturePixels(template, 0, "semantic", "@version 1;", 10); + expect(plain.shadowPixels).toBe(0); + expect(shadow.shadowPixels).toBeGreaterThan(100); + expect(shadow.bounds).toEqual(plain.bounds); + expect(shadow.imagePixels).toBe(plain.imagePixels); + }, + ); + it("resolves semantic shadow color, width, and rounded shape", async () => { + const shadow = await picturePixels( + "onyx", + 0, + "semantic", + "@version 1; picture { -resume-shadow-width: 10pt; -resume-shadow-color: rgba(0, 0, 255, 0.5); border-radius: 50pt; }", + ); + expect(shadow.shadowPixels).toBeGreaterThan(100); + expect(shadow.center).toEqual([0, 170, 0]); + }); + it("does not tint a transparent photo interior", async () => { + const transparent = createCanvas(100, 100); + const context = transparent.getContext("2d"); + context.fillStyle = "#00aa00"; + context.fillRect(0, 0, 100, 100); + context.clearRect(30, 30, 40, 40); + const shadow = await picturePixels("onyx", 0, "semantic", "@version 1;", 10, transparent.toDataURL("image/png")); + expect(shadow.shadowPixels).toBeGreaterThan(100); + expect(shadow.center).toEqual([255, 255, 255]); + }); + it("preserves rotated photo geometry and opacity", async () => { + const css = "@version 1; picture { transform: rotate(25deg); }"; + const plain = await picturePixels("onyx", 0, "semantic", css, 0, undefined, { opacity: 0.5 }); + const shadow = await picturePixels("onyx", 0, "semantic", css, 10, undefined, { opacity: 0.5 }); + expect(shadow.shadowPixels).toBeGreaterThan(100); + expect(shadow.bounds).toEqual(plain.bounds); + expect(shadow.center).toEqual(plain.center); + }); + it("keeps the outer shadow visible when the picture clips its contents", async () => { + const shadow = await picturePixels("onyx", 0, "semantic", "@version 1;", 10, undefined, { overflow: "hidden" }); + expect(shadow.shadowPixels).toBeGreaterThan(100); + }); + it("matches percentage and absolute picture corner radii", async () => { + const absolute = await picturePixels("onyx", 0, "semantic", "@version 1; picture { border-radius: 50pt; }", 10); + const percent = await picturePixels("onyx", 0, "semantic", "@version 1; picture { border-radius: 50%; }", 10); + expect(percent).toEqual(absolute); + }); +}); diff --git a/packages/pdf/src/templates/shared/picture-shadow.test.ts b/packages/pdf/src/templates/shared/picture-shadow.test.ts new file mode 100644 index 000000000..76d5c62c7 --- /dev/null +++ b/packages/pdf/src/templates/shared/picture-shadow.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import { decode } from "fast-png"; +import { getPictureShadow } from "./picture-shadow"; + +function pixels(borderRadius = 0, shadowColor = "rgba(0, 0, 255, 0.5)") { + const shadow = getPictureShadow({ width: 100, height: 100, borderRadius, shadowColor, shadowWidth: 10 }); + if (!shadow) throw new Error("Missing shadow"); + const png = decode(Uint8Array.from(atob(shadow.src.split(",")[1] ?? ""), (char) => char.charCodeAt(0))); + return { shadow, png, alpha: (x: number, y: number) => png.data[(y * png.width + x) * 4 + 3] ?? 0 }; +} + +describe("portable picture shadow", () => { + it("bounds raster work for broad shadows while preserving their soft falloff", () => { + const shadow = getPictureShadow({ width: 100, height: 100, shadowWidth: 50, shadowColor: "#000000" }); + if (!shadow) throw new Error("Missing shadow"); + const png = decode(Uint8Array.from(atob(shadow.src.split(",")[1] ?? ""), (char) => char.charCodeAt(0))); + expect(png.width).toBeLessThanOrEqual(192); + expect(png.height).toBeLessThanOrEqual(192); + const scale = png.width / (100 + shadow.extent * 2); + const alpha = (x: number) => + png.data[ + (Math.floor((50 + shadow.extent) * scale) * png.width + Math.floor((x + shadow.extent) * scale)) * 4 + 3 + ] ?? 0; + expect(alpha(-5)).toBeGreaterThan(85); + expect(alpha(-5)).toBeLessThan(115); + expect(alpha(-30)).toBeLessThan(alpha(-5)); + }); + it("omits shadows whose finite dimensions overflow derived raster bounds", () => { + expect( + getPictureShadow({ width: 100, height: 100, shadowWidth: Number.MAX_VALUE, shadowColor: "#000000" }), + ).toBeUndefined(); + }); + it("keeps the inside clear and softens outward symmetrically", () => { + const { alpha } = pixels(); + expect(alpha(130, 130)).toBe(0); + expect(alpha(27, 130)).toBeGreaterThan(alpha(15, 130)); + expect(alpha(15, 130)).toBeGreaterThan(alpha(1, 130)); + expect(alpha(27, 130)).toBe(alpha(232, 130)); + expect(alpha(27, 130)).toBeLessThan(128); + }); + it("follows rounded corners and preserves transparent picture interiors", () => { + const { alpha } = pixels(50); + expect(alpha(130, 130)).toBe(0); + expect(alpha(50, 50)).toBeGreaterThan(0); + expect(alpha(130, 40)).toBe(0); + }); + it("produces stable PNG bytes with independent caches", async () => { + const expected = pixels().shadow.src; + vi.resetModules(); + const fresh = await import("./picture-shadow"); + expect( + fresh.getPictureShadow({ + width: 100, + height: 100, + borderRadius: 0, + shadowColor: "rgba(0, 0, 255, 0.5)", + shadowWidth: 10, + })?.src, + ).toBe(expected); + }); + it("bounds raster size for large pictures", () => { + const shadow = getPictureShadow({ width: 2000, height: 1000, shadowWidth: 100, shadowColor: "#000000" }); + if (!shadow) throw new Error("Missing shadow"); + const png = decode(Uint8Array.from(atob(shadow.src.split(",")[1] ?? ""), (char) => char.charCodeAt(0))); + expect(png.width).toBeLessThanOrEqual(512); + expect(png.height).toBeLessThanOrEqual(512); + }); +}); diff --git a/packages/pdf/src/templates/shared/picture-shadow.ts b/packages/pdf/src/templates/shared/picture-shadow.ts new file mode 100644 index 000000000..6ceef018e --- /dev/null +++ b/packages/pdf/src/templates/shared/picture-shadow.ts @@ -0,0 +1,112 @@ +import type { Style } from "@react-pdf/types"; +import { encode } from "fast-png"; +import { parseColorString, rgbaStringToHex } from "@reactive-resume/utils/color"; + +type PictureShadowStyle = Style & { shadowColor?: string; shadowWidth?: number }; +type ShadowImage = { src: string; extent: number }; +const cache = new Map(); +const maxRasterSize = 512; +const maxBlurSigma = 16; + +function contains(x: number, y: number, width: number, height: number, radii: number[]) { + if (x < 0 || y < 0 || x >= width || y >= height) return false; + const corner = (x < width / 2 ? 0 : 1) + (y < height / 2 ? 0 : 2); + const radius = radii[corner] ?? 0; + const cx = x < width / 2 ? radius : width - radius; + const cy = y < height / 2 ? radius : height - radius; + if ((x < radius || x > width - radius) && (y < radius || y > height - radius)) { + return (x - cx) ** 2 + (y - cy) ** 2 <= radius ** 2; + } + return true; +} + +function convolve(input: Float32Array, width: number, height: number, kernel: number[], horizontal: boolean) { + const output = new Float32Array(input.length); + const radius = (kernel.length - 1) / 2; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let value = 0; + for (let offset = -radius; offset <= radius; offset++) { + const sx = horizontal ? x + offset : x; + const sy = horizontal ? y : y + offset; + if (sx >= 0 && sx < width && sy >= 0 && sy < height) { + value += (input[sy * width + sx] ?? 0) * (kernel[offset + radius] ?? 0); + } + } + output[y * width + x] = value; + } + } + return output; +} + +/** Restores the original CSS box-shadow: zero offset/spread, sigma = half the blur radius. */ +export function getPictureShadow(style: PictureShadowStyle): ShadowImage | undefined { + const { width, height, shadowWidth: blur, shadowColor } = style; + // Percentage dimensions are resolved later by Yoga; avoid guessing their shadow geometry. + if (typeof width !== "number" || typeof height !== "number" || !blur || blur <= 0 || !shadowColor) return; + if (![width, height, blur].every(Number.isFinite) || width <= 0 || height <= 0 || shadowColor === "transparent") + return; + const color = parseColorString(shadowColor) ?? parseColorString(rgbaStringToHex(shadowColor)); + if (!color || color.a <= 0) return; + const resolveRadius = (value: unknown) => { + if (typeof value === "number") return value; + if (typeof value === "string" && value.endsWith("%")) + return (Number.parseFloat(value) / 100) * Math.min(width, height); + return 0; + }; + const radii = [ + style.borderTopLeftRadius, + style.borderTopRightRadius, + style.borderBottomLeftRadius, + style.borderBottomRightRadius, + ].map((value) => Math.max(0, Math.min(resolveRadius(value ?? style.borderRadius), width / 2, height / 2))); + const key = JSON.stringify([width, height, blur, color, radii]); + const cached = cache.get(key); + if (cached) return cached; + const extent = Math.ceil(blur * 1.5); + const outerWidth = width + extent * 2; + const outerHeight = height + extent * 2; + if (!Number.isFinite(outerWidth) || !Number.isFinite(outerHeight)) return; + // Lower the raster scale with broad blur, preserving its point-space width + // while bounding the convolution kernel and synchronous render work. + const scale = Math.min(2, maxRasterSize / Math.max(outerWidth, outerHeight), (maxBlurSigma * 2) / blur); + const rasterWidth = Math.max(1, Math.ceil(outerWidth * scale)); + const rasterHeight = Math.max(1, Math.ceil(outerHeight * scale)); + const mask = new Float32Array(rasterWidth * rasterHeight); + for (let y = 0; y < rasterHeight; y++) { + for (let x = 0; x < rasterWidth; x++) { + mask[y * rasterWidth + x] = contains((x + 0.5) / scale - extent, (y + 0.5) / scale - extent, width, height, radii) + ? 1 + : 0; + } + } + const sigma = Math.max(0.5, (blur / 2) * scale); + const kernelRadius = Math.ceil(3 * sigma); + const weights = Array.from({ length: kernelRadius * 2 + 1 }, (_, index) => + Math.exp(-((index - kernelRadius) ** 2) / (2 * sigma ** 2)), + ); + const total = weights.reduce((sum, weight) => sum + weight, 0); + const kernel = weights.map((weight) => weight / total); + const blurred = convolve( + convolve(mask, rasterWidth, rasterHeight, kernel, true), + rasterWidth, + rasterHeight, + kernel, + false, + ); + const pixels = new Uint8Array(mask.length * 4); + for (let index = 0; index < mask.length; index++) { + pixels[index * 4] = color.r; + pixels[index * 4 + 1] = color.g; + pixels[index * 4 + 2] = color.b; + // Outer box shadows never tint transparent pixels inside the picture's border box. + pixels[index * 4 + 3] = Math.round(255 * color.a * (blurred[index] ?? 0) * (1 - (mask[index] ?? 0))); + } + const png = encode({ width: rasterWidth, height: rasterHeight, data: pixels, channels: 4, depth: 8 }); + let binary = ""; + for (const byte of png) binary += String.fromCharCode(byte); + const result = { src: `data:image/png;base64,${btoa(binary)}`, extent }; + if (cache.size >= 8) cache.delete(cache.keys().next().value ?? ""); + cache.set(key, result); + return result; +} diff --git a/packages/pdf/src/templates/shared/primitives.tsx b/packages/pdf/src/templates/shared/primitives.tsx index 0e6b4d345..30fb4db3e 100644 --- a/packages/pdf/src/templates/shared/primitives.tsx +++ b/packages/pdf/src/templates/shared/primitives.tsx @@ -18,8 +18,9 @@ import { import { semanticNodeKeys } from "../../semantic/node-keys"; import { useSectionStyleRule, useTemplateIconSlot, useTemplatePageNodeKey, useTemplateStyle } from "./context"; import { resolveIconSize } from "./icon-size"; +import { getPictureShadow } from "./picture-shadow"; import { safeTextStyle } from "./safe-text-style"; -import { composeLinkStyles, composeStyles } from "./styles"; +import { composeLinkStyles, composeStyles, mergeStyles } from "./styles"; const asStyleInput = (style: unknown): StyleInput => style as StyleInput; @@ -451,7 +452,67 @@ export const SemanticHeaderPicture = ({ style, ...props }: ComponentProps; + const pictureStyle = mergeStyles(asStyleInput(style), resolved.style); + const shadow = getPictureShadow(pictureStyle); + const borderWidth = (value: Style["borderWidth"]) => (typeof value === "number" ? value : 0); + const borderInsets = { + top: borderWidth(pictureStyle.borderTopWidth ?? pictureStyle.borderWidth), + right: borderWidth(pictureStyle.borderRightWidth ?? pictureStyle.borderWidth), + bottom: borderWidth(pictureStyle.borderBottomWidth ?? pictureStyle.borderWidth), + left: borderWidth(pictureStyle.borderLeftWidth ?? pictureStyle.borderWidth), + }; + const hasBorder = Object.values(borderInsets).some((width) => width > 0); + if (!shadow && !hasBorder) return ; + // The frame owns the border and authored padding. Yoga places the bitmap in + // its content box, including when padding or picture dimensions are percentages. + const { overflow: _overflow, ...frameStyle } = pictureStyle; + + return ( + + {shadow && ( + + )} + + + ); }; export const SectionHeadingIcon = ({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 134cd997f..826285026 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,6 +231,9 @@ importers: fast-json-patch: specifier: ^3.1.1 version: 3.1.1 + fast-png: + specifier: ^8.0.0 + version: 8.0.0 hono: specifier: ^4.13.5 version: 4.13.5 @@ -1097,6 +1100,9 @@ importers: cjk-regex: specifier: ^3.4.0 version: 3.4.0 + fast-png: + specifier: ^8.0.0 + version: 8.0.0 node-html-parser: specifier: ^9.0.3 version: 9.0.3 @@ -6116,6 +6122,9 @@ packages: fast-json-patch@3.1.1: resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==} + fast-png@8.0.0: + resolution: {integrity: sha512-gCysNasJ8KEMgfdYIKd/wTDo6ENK1PWT0RJO7O+0pgmuHPw2O6tA1WvdxFRJoLf9V8yFYpG0FA1YgI8X97OhJA==} + fast-uri@3.1.7: resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} @@ -6396,6 +6405,9 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + iobuffer@6.0.1: + resolution: {integrity: sha512-SZWYkWNfjIXIBYSDpXDYIgshqtbOPsi4lviawAEceR1Kqk+sHDlcQjWrzNQsii80AyBY0q5c8HCTNjqo74ul+Q==} + ioredis@6.0.0: resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==} engines: {node: '>=20.0.0'} @@ -13238,6 +13250,11 @@ snapshots: fast-json-patch@3.1.1: {} + fast-png@8.0.0: + dependencies: + fflate: 0.8.3 + iobuffer: 6.0.1 + fast-uri@3.1.7: {} fastq@1.20.3: @@ -13530,6 +13547,8 @@ snapshots: inline-style-parser@0.2.7: {} + iobuffer@6.0.1: {} + ioredis@6.0.0(supports-color@7.2.0): dependencies: '@ioredis/commands': 2.0.0