// services/worker/src/watermark.ts —— 水印合成流水线(P4a Task 3)。 // 实现方式登记(技术方案 7.2 / 计划执行注意):sharp 无原生文字渲染,文字水印 // 经 librsvg 渲染 SVG `` 层(font-family="sans-serif" 走系统字体链;CJK // 字形可用性取决于运行环境字体——e2e 以「输出非空且与无水印图不同」断言, // 精确字形不可断言,环境缺 CJK 字体时渲染为替代框/空白,英文水印为兜底, // G5 字体事项登记)。合成流程: // ① 名义字号渲染文字 SVG → PNG → .trim() 得紧凑文字层(对字形宽度不做任何 // 假设——CJK 全宽 / Latin 变宽都由 trim 后实测尺寸消化); // ② 文字层等比缩放到目标宽度 = 浏览图宽 × scalePercent%(scalePercent 语义 // 「水印尺寸相对浏览图宽度的百分比」); // ③ 与浏览图基线(rotate + resize ≤1920,同 pipeline.ts 规格)composite, // 位置按 config.position + 2% 边距,输出 JPEG q82(与 browse 变体同参, // 便于字节级对比断言)。 // 不透明度经 SVG fill-opacity 承载(缩放不改变相对透明度)。 import sharp from 'sharp'; import type { StoredWatermarkConfig, WatermarkPosition } from '@photo/contracts'; import { BROWSE_MAX_WIDTH } from './pipeline'; /** 文字层与浏览图边缘的边距(浏览图宽的 2%,至少 4px;center 无边距)。 */ const MARGIN_RATIO = 0.02; const MIN_MARGIN_PX = 4; /** XML 属性/文本转义(用户可控的 content 进入 SVG 标记,防注入)。 */ function escapeXml(value: string): string { return value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } /** * 名义渲染:fontSize 即 SVG font-size(px @72dpi)。画布按「最宽可能」预估 * (每字符 2×fontSize + 两侧 padding)——后续 trim 收紧,宁可画大不裁字。 */ function textSvg(config: StoredWatermarkConfig): string { const fontSize = config.text.fontSize; const content = escapeXml(config.text.content); const canvasWidth = Math.ceil(content.length * fontSize * 2 + fontSize * 2); const canvasHeight = Math.ceil(fontSize * 3); return ( `` + `${content}` + `` ); } /** 渲染并裁紧文字层(透明底 PNG)。空内容产生零宽层 → 调用方视为无水印。 */ async function renderTextLayer(config: StoredWatermarkConfig): Promise { const png = await sharp(Buffer.from(textSvg(config), 'utf8')) .png() .toBuffer(); return sharp(png).trim().toBuffer(); } /** composite 左上角偏移(position + 边距;层尺寸可能超过基线时收钳)。 */ function layerOffset( position: WatermarkPosition, baseWidth: number, baseHeight: number, layerWidth: number, layerHeight: number, ): { left: number; top: number } { const margin = Math.max( MIN_MARGIN_PX, Math.round(baseWidth * MARGIN_RATIO), ); const clamp = (value: number, max: number): number => Math.max(0, Math.min(value, max)); const right = baseWidth - layerWidth - margin; const bottom = baseHeight - layerHeight - margin; switch (position) { case 'topLeft': return { left: clamp(margin, baseWidth), top: clamp(margin, baseHeight) }; case 'topRight': return { left: clamp(right, baseWidth), top: clamp(margin, baseHeight) }; case 'bottomLeft': return { left: clamp(margin, baseWidth), top: clamp(bottom, baseHeight), }; case 'bottomRight': return { left: clamp(right, baseWidth), top: clamp(bottom, baseHeight) }; case 'center': return { left: clamp(Math.round((baseWidth - layerWidth) / 2), baseWidth), top: clamp(Math.round((baseHeight - layerHeight) / 2), baseHeight), }; } } export interface WatermarkComposeResult { buffer: Buffer; width: number; height: number; } /** * 原图 → 带水印浏览图(发布路径与历史重生成共用)。基线与 pipeline.ts 的 * browse 变体同参(rotate + ≤1920 withoutEnlargement + JPEG q82),仅多一层 * 文字合成——与 browse 变体的字节差异即水印本身(e2e 断言依据)。 * enabled=false 或空文字层:不合成,直接产出与 browse 同参的干净图(调用方 * 负责「关闭 = 不产变体」的语义,此返回仅防御性兜底)。 */ export async function composeWatermarkBrowse( input: Buffer, config: StoredWatermarkConfig, ): Promise { // 目标尺寸解析析计算(同 pipeline.ts 的 photos.width/height 口径):sharp 的 // .metadata() 返回**输入**尺寸而非 resize 后尺寸,管线实例上取 meta 会拿到 // 缩放前值;withoutEnlargement 下 outW = min(w, 1920) 精确,outH 按比例取整 // (±1px 对层偏移无影响——边距/收钳兜住;精确尺寸以最终输出 metadata 为准)。 const meta = await sharp(input).metadata(); const swap = (meta.orientation ?? 1) >= 5; const srcWidth = (swap ? meta.height : meta.width) ?? 0; const srcHeight = (swap ? meta.width : meta.height) ?? 0; if (srcWidth <= 0 || srcHeight <= 0) { throw new Error('watermark: 无法解析原图尺寸'); } const baseWidth = Math.min(srcWidth, BROWSE_MAX_WIDTH); const baseHeight = Math.max(1, Math.round((srcHeight * baseWidth) / srcWidth)); let pipeline = sharp(input) .rotate() .resize({ width: BROWSE_MAX_WIDTH, withoutEnlargement: true }); if (config.enabled && config.text.content !== '') { const nominal = await renderTextLayer(config); const nominalMeta = await sharp(nominal).metadata(); const nominalWidth = nominalMeta.width ?? 0; const nominalHeight = nominalMeta.height ?? 0; if (nominalWidth > 0 && nominalHeight > 0) { // 目标宽度 = 浏览图宽 × scalePercent%(min 1px;层不过基线——scalePercent // 上限 50%,只剩边距空间,天然满足)。 const targetWidth = Math.max( 1, Math.min( Math.round((baseWidth * config.scalePercent) / 100), baseWidth, ), ); const layer = await sharp(nominal) .resize({ width: targetWidth }) .png() .toBuffer(); const layerMeta = await sharp(layer).metadata(); const { left, top } = layerOffset( config.position, baseWidth, baseHeight, layerMeta.width ?? targetWidth, layerMeta.height ?? 1, ); pipeline = pipeline.composite([{ input: layer, left, top }]); } } const buffer = await pipeline.jpeg({ quality: 82 }).toBuffer(); const outMeta = await sharp(buffer).metadata(); return { buffer, width: outMeta.width ?? baseWidth, height: outMeta.height ?? baseHeight, }; }