fix(pdf): render picture borders and soft shadows (#3427)

* fix(pdf): render picture borders and soft shadows

* fix(pdf): preserve picture padding and bound shadow rendering
This commit is contained in:
Amruth Pillai
2026-09-05 08:29:17 -07:00
committed by GitHub
parent 47fc16d806
commit 57fee67d2d
8 changed files with 494 additions and 2 deletions
+1
View File
@@ -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",
+1
View File
@@ -47,6 +47,7 @@
"deepmerge-ts",
"drizzle-zod",
"fast-json-patch",
"fast-png",
"jsonrepair",
"node-html-parser",
"nodemailer",
+1
View File
@@ -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",
@@ -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<typeof renderToBuffer>[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);
});
});
@@ -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);
});
});
@@ -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<string, ShadowImage>();
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;
}
@@ -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<typeof
const visible = useSemanticNodeVisible(nodeKey);
if (!visible) return null;
return <Image {...props} style={composeStyles(asStyleInput(style), resolved.style)} />;
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 <Image {...props} style={pictureStyle} />;
// 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 (
<View wrap={false} style={frameStyle}>
{shadow && (
<Image
src={shadow.src}
style={{
position: "absolute",
left: -shadow.extent - borderInsets.left,
top: -shadow.extent - borderInsets.top,
right: -shadow.extent - borderInsets.right,
bottom: -shadow.extent - borderInsets.bottom,
opacity: pictureStyle.opacity ?? 1,
}}
/>
)}
<Image
{...props}
style={composeStyles(pictureStyle, {
padding: 0,
paddingTop: 0,
paddingRight: 0,
paddingBottom: 0,
paddingLeft: 0,
borderWidth: 0,
borderTopWidth: 0,
borderRightWidth: 0,
borderBottomWidth: 0,
borderLeftWidth: 0,
backgroundColor: "transparent",
position: "relative",
top: 0,
right: 0,
bottom: 0,
left: 0,
width: "100%",
height: "100%",
margin: 0,
marginTop: 0,
marginRight: 0,
marginBottom: 0,
marginLeft: 0,
transform: "rotate(0deg)",
})}
/>
</View>
);
};
export const SectionHeadingIcon = ({
+19
View File
@@ -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