// Rasterize a feed thumbnail SVG to PNG so every published page has a guaranteed // raster og:image (WeChat / browsers don't render SVG cover images). // // PNGs are derived lazily from the sibling `.thumbnail.svg` and cached on disk // as `.thumbnail.png`. Regenerated when the SVG is newer than the cached PNG. import fs from 'node:fs'; import { Resvg } from '@resvg/resvg-js'; const RENDER_WIDTH = 540; // matches the 540x720 feed card aspect /** SVG → PNG buffer. White background avoids black fills where clients ignore alpha. */ export function rasterizeThumbnailSvgToPng(svg) { const resvg = new Resvg(svg, { background: 'white', fitTo: { mode: 'width', value: RENDER_WIDTH }, font: { loadSystemFonts: true }, // CJK + serif via the host font stack (PingFang/Georgia on macOS) }); return resvg.render().asPng(); } /** Absolute path of the PNG that mirrors a `.thumbnail.svg`. */ export function thumbnailPngPathForSvg(svgAbsPath) { return svgAbsPath.replace(/\.svg$/i, '.png'); } /** * Ensure a `.thumbnail.png` exists and is at least as fresh as its `.thumbnail.svg`. * @returns {string|null} the PNG path, or null when no source SVG is available. */ export function ensureThumbnailPng(svgAbsPath) { if (!fs.existsSync(svgAbsPath)) return null; const pngPath = thumbnailPngPathForSvg(svgAbsPath); try { const svgStat = fs.statSync(svgAbsPath); if (fs.existsSync(pngPath) && fs.statSync(pngPath).mtimeMs >= svgStat.mtimeMs) { return pngPath; } const svg = fs.readFileSync(svgAbsPath, 'utf8'); fs.writeFileSync(pngPath, rasterizeThumbnailSvgToPng(svg)); return pngPath; } catch { // If rasterization fails, fall back to an existing PNG if present, else give up. return fs.existsSync(pngPath) ? pngPath : null; } }