mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-02 17:54:22 +10:00
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:
@@ -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",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"deepmerge-ts",
|
||||
"drizzle-zod",
|
||||
"fast-json-patch",
|
||||
"fast-png",
|
||||
"jsonrepair",
|
||||
"node-html-parser",
|
||||
"nodemailer",
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
Generated
+19
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user