243b2f8721
Co-Authored-By: Claude Code <noreply@anthropic.com>
168 lines
7.0 KiB
TypeScript
168 lines
7.0 KiB
TypeScript
// services/worker/src/watermark.ts —— 水印合成流水线(P4a Task 3)。
|
||
// 实现方式登记(技术方案 7.2 / 计划执行注意):sharp 无原生文字渲染,文字水印
|
||
// 经 librsvg 渲染 SVG `<text>` 层(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 (
|
||
`<svg xmlns="http://www.w3.org/2000/svg" width="${canvasWidth}" height="${canvasHeight}">` +
|
||
`<text x="${Math.ceil(fontSize / 2)}" y="${Math.ceil(fontSize * 1.5)}" ` +
|
||
`font-family="sans-serif" font-size="${fontSize}" fill="#ffffff" ` +
|
||
`fill-opacity="${(config.opacity / 100).toFixed(3)}">${content}</text>` +
|
||
`</svg>`
|
||
);
|
||
}
|
||
|
||
/** 渲染并裁紧文字层(透明底 PNG)。空内容产生零宽层 → 调用方视为无水印。 */
|
||
async function renderTextLayer(config: StoredWatermarkConfig): Promise<Buffer> {
|
||
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<WatermarkComposeResult> {
|
||
// 目标尺寸解析析计算(同 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,
|
||
};
|
||
}
|