import type { SharpImageServiceConfig } from 'astro/assets/services/sharp' import baseSharpService, { resolveSharpEncoderOptions } from 'astro/assets/services/sharp' import prettyBytes from 'pretty-bytes' import sharp from 'sharp' /** * Resolved watermark runtime config, stashed on the Astro image service config * under the `mediaKitWatermark` key by the integration. */ export type ResolvedWatermarkConfig = { angle: number minDimension: number opacity: number } /** * Combined service config: passes through sharp's own config, plus our * watermark settings. */ type WatermarkServiceConfig = SharpImageServiceConfig & { mediaKitWatermark?: ResolvedWatermarkConfig } /** Formats we refuse to re-encode (SVG stays vector). */ const SKIP_FORMATS = new Set(['svg']) function escapeXml(input: string): string { return input .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", ''') } /** * Build an SVG sized to the variant that tiles a rotated label repeating the * dimensions and byte count across the whole frame. Returned as a `Uint8Array` * ready for `sharp.composite`. */ function buildTiledStampSvg( width: number, height: number, bytes: number, config: ResolvedWatermarkConfig, ) { const label = `${width} × ${height} · ${prettyBytes(bytes, { maximumFractionDigits: 0, })}` // Font size scales gently with the smaller dimension so tiny thumbs still read. const shortEdge = Math.min(width, height) const fontSize = Math.max(30, Math.min(50, Math.round(shortEdge / 22))) // Rough metric: monospace glyphs are ~0.6em wide. const labelWidthPx = label.length * fontSize * 0.6 const tileWidth = Math.ceil(labelWidthPx + fontSize * 4) const tileHeight = Math.ceil(fontSize * 3) const strokeWidth = Math.max(2, Math.round(fontSize / 10)) const safeLabel = escapeXml(label) const svg = ` ${safeLabel} ` return Buffer.from(svg) } type EncoderOptions = ReturnType /** * Read encoder options the base sharp service would use, so we re-encode with * matching quality. */ function encoderOptions( imageConfig: { service: { config: WatermarkServiceConfig } }, format: string, quality: string | undefined, ): EncoderOptions { return resolveSharpEncoderOptions( quality === undefined ? { format } : { format, quality }, format, imageConfig.service.config, ) } const service: typeof baseSharpService = { ...baseSharpService, async transform(inputBuffer, transform, imageConfig, logger) { const base = await baseSharpService.transform(inputBuffer, transform, imageConfig, logger) const config = (imageConfig.service.config as WatermarkServiceConfig).mediaKitWatermark if (!config) { return base } if (SKIP_FORMATS.has(base.format)) { return base } const { height, pages, width } = await sharp(base.data).metadata() // Animated formats (GIF, animated WebP): compositing flattens to a single frame. if (pages !== undefined && pages > 1) { return base } if (width < config.minDimension || height < config.minDimension) { return base } const overlay = buildTiledStampSvg(width, height, base.data.byteLength, config) const format = base.format as Parameters['toFormat']>[0] const quality = typeof transform.quality === 'string' ? transform.quality : undefined const composited = await sharp(base.data) .composite([{ input: overlay, left: 0, top: 0 }]) .toFormat(format, encoderOptions(imageConfig, base.format, quality)) .toBuffer() return { data: composited, format: base.format } }, } export default service