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 = ``
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