371 lines
11 KiB
Dart
371 lines
11 KiB
Dart
import 'dart:typed_data';
|
||
import 'dart:ui' as ui;
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:image/image.dart' as img;
|
||
|
||
import '../../../models/watermark.dart';
|
||
|
||
/// 水印绘制核心 —— **预览、拖动、导出三处共用同一套几何**。
|
||
///
|
||
/// 关键约定:所有尺寸都按 `scale = 画布宽度 / [kWatermarkDesignWidth]` 等比缩放。
|
||
/// 于是同一个 [paintWatermark],在预览时传屏幕显示尺寸、在导出时传图片像素尺寸,
|
||
/// 出来的**视觉完全一致**——这就是「所见即所得」的实现方式。
|
||
///
|
||
/// 改样式(字号/间距/颜色/位置)只改这里,三处自动同步。
|
||
|
||
/// 设计稿基准宽度(沿用原水印相机项目的 1080)
|
||
const double kWatermarkDesignWidth = 1080;
|
||
|
||
// ---- 设计稿尺寸(单位:设计稿像素,实际绘制时乘 scale)----
|
||
const double _margin = 28; // 水印块到画布边缘的默认距离
|
||
const double _lineWidth = 4; // 左侧黄色竖线宽
|
||
const double _lineToText = 14; // 竖线到文字的水平距离
|
||
const double _cardPaddingY = 16; // 水印块上下内边距
|
||
const double _fontSize = 37;
|
||
const double _rowHeight = 54;
|
||
const double _rowGap = 8;
|
||
const int _maxCharsPerLine = 18; // 每行最多几个字,超了折行
|
||
|
||
// 右下角品牌角标
|
||
const double _brandMargin = 8;
|
||
const double _brandIconSize = 48;
|
||
const double _brandTextSize = 32;
|
||
const double _brandGap = 10;
|
||
const double _brandPaddingX = 16;
|
||
const double _brandPaddingY = 12;
|
||
const double _brandOpacity = 0.8;
|
||
const String _brandText = '水印相机';
|
||
|
||
/// 黄色竖线的颜色(品牌装饰,不跟随文字颜色变)
|
||
const Color _accentColor = Color(0xFFFFB800);
|
||
|
||
/// 水印正文块的几何信息。
|
||
///
|
||
/// 抽出来是为了让「拖动」能用和「绘制」完全一样的公式算位置 ——
|
||
/// 两边各算一遍的话,迟早会漂移。
|
||
class WatermarkLayout {
|
||
const WatermarkLayout({
|
||
required this.scale,
|
||
required this.lines,
|
||
required this.textHeight,
|
||
required this.cardHeight,
|
||
required this.cardWidth,
|
||
required this.origin,
|
||
});
|
||
|
||
final double scale;
|
||
|
||
/// 折行后的实际文本行(已按 [_maxCharsPerLine] 拆好)
|
||
final List<String> lines;
|
||
|
||
/// 所有文本行的总高度
|
||
final double textHeight;
|
||
|
||
/// 块总高(含上下内边距);没有内容时为 0
|
||
final double cardHeight;
|
||
|
||
/// 块总宽(最宽一行 + 竖线 + 间距)
|
||
final double cardWidth;
|
||
|
||
/// 块左上角在画布上的位置
|
||
final Offset origin;
|
||
|
||
bool get isEmpty => lines.isEmpty;
|
||
}
|
||
|
||
/// 按 [size] 算出水印块的位置和尺寸
|
||
WatermarkLayout layoutWatermark(
|
||
Size size,
|
||
WatermarkSettings settings,
|
||
WatermarkData data,
|
||
) {
|
||
final scale = size.width / kWatermarkDesignWidth;
|
||
final lines = <String>[
|
||
for (final line in buildWatermarkLines(settings, data))
|
||
// 标了「不折行」的(时间)原样保留,其余按字数折
|
||
...(line.wrap ? _wrapByCharCount(line.text, _maxCharsPerLine) : [line.text]),
|
||
];
|
||
|
||
if (lines.isEmpty) {
|
||
return WatermarkLayout(
|
||
scale: scale,
|
||
lines: const [],
|
||
textHeight: 0,
|
||
cardHeight: 0,
|
||
cardWidth: 0,
|
||
origin: Offset(
|
||
settings.posX != null ? settings.posX! * size.width : _margin * scale,
|
||
settings.posY != null ? settings.posY! * size.height : size.height - _margin * scale,
|
||
),
|
||
);
|
||
}
|
||
|
||
final rowH = _rowHeight * scale;
|
||
final gap = _rowGap * scale;
|
||
final padding = _cardPaddingY * scale;
|
||
|
||
final textHeight = lines.length * rowH + (lines.length - 1) * gap;
|
||
final cardHeight = textHeight + padding * 2;
|
||
|
||
// 最宽一行决定块的宽度(拖动时用它做边界夹紧)
|
||
var maxLineWidth = 0.0;
|
||
for (final line in lines) {
|
||
final painter = _layoutText(
|
||
line,
|
||
fontSize: _fontSize * scale,
|
||
color: Colors.white,
|
||
scale: scale,
|
||
);
|
||
if (painter.width > maxLineWidth) maxLineWidth = painter.width;
|
||
}
|
||
final cardWidth = _lineWidth * scale + _lineToText * scale + maxLineWidth;
|
||
|
||
final origin = Offset(
|
||
settings.posX != null ? settings.posX! * size.width : _margin * scale,
|
||
settings.posY != null
|
||
? settings.posY! * size.height
|
||
: size.height - _margin * scale - cardHeight,
|
||
);
|
||
|
||
return WatermarkLayout(
|
||
scale: scale,
|
||
lines: lines,
|
||
textHeight: textHeight,
|
||
cardHeight: cardHeight,
|
||
cardWidth: cardWidth,
|
||
origin: origin,
|
||
);
|
||
}
|
||
|
||
/// 画一笔水印。
|
||
///
|
||
/// [size] 既可以是屏幕显示尺寸(预览),也可以是图片像素尺寸(导出)。
|
||
void paintWatermark(
|
||
Canvas canvas,
|
||
Size size,
|
||
WatermarkSettings settings,
|
||
WatermarkData data, {
|
||
ui.Image? brandIcon,
|
||
}) {
|
||
if (size.width <= 0 || size.height <= 0) return;
|
||
|
||
final layout = layoutWatermark(size, settings, data);
|
||
final scale = layout.scale;
|
||
final textColor = Color(settings.colorValue);
|
||
|
||
// ---- 正文块 ----
|
||
if (!layout.isEmpty) {
|
||
final origin = layout.origin;
|
||
final padding = _cardPaddingY * scale;
|
||
|
||
// 左侧黄色竖线
|
||
canvas.drawRect(
|
||
Rect.fromLTWH(origin.dx, origin.dy + padding, _lineWidth * scale, layout.textHeight),
|
||
Paint()..color = _accentColor,
|
||
);
|
||
|
||
// 多行文字
|
||
final textX = origin.dx + _lineWidth * scale + _lineToText * scale;
|
||
var y = origin.dy + padding;
|
||
for (final line in layout.lines) {
|
||
final painter = _layoutText(
|
||
line,
|
||
fontSize: _fontSize * scale,
|
||
color: textColor,
|
||
scale: scale,
|
||
);
|
||
painter.paint(canvas, Offset(textX, y));
|
||
y += _rowHeight * scale + _rowGap * scale;
|
||
}
|
||
}
|
||
|
||
// ---- 右下角品牌角标 ----
|
||
if (settings.showBrand) {
|
||
_paintBrand(canvas, size, scale, brandIcon);
|
||
}
|
||
}
|
||
|
||
/// 右下角「相机图标 + 水印相机」,整体 80% 透明
|
||
void _paintBrand(Canvas canvas, Size size, double scale, ui.Image? brandIcon) {
|
||
final margin = _brandMargin * scale;
|
||
final iconSize = _brandIconSize * scale;
|
||
final gap = _brandGap * scale;
|
||
final padX = _brandPaddingX * scale;
|
||
final padY = _brandPaddingY * scale;
|
||
|
||
final textPainter = _layoutText(
|
||
_brandText,
|
||
fontSize: _brandTextSize * scale,
|
||
color: Colors.white,
|
||
bold: true,
|
||
scale: scale,
|
||
);
|
||
|
||
final hasIcon = brandIcon != null;
|
||
final brandW = (hasIcon ? iconSize + gap : 0) + textPainter.width + padX * 2;
|
||
final brandH = (hasIcon ? iconSize : textPainter.height) + padY * 2;
|
||
final brandX = size.width - margin - brandW;
|
||
final brandY = size.height - margin - brandH;
|
||
|
||
// saveLayer 的 paint alpha 就是整组元素的透明度(Flutter 的 Opacity 内部也这么做)
|
||
canvas.saveLayer(
|
||
Rect.fromLTWH(brandX, brandY, brandW, brandH),
|
||
Paint()..color = const Color(0xFF000000).withValues(alpha: _brandOpacity),
|
||
);
|
||
|
||
double contentX = brandX + padX;
|
||
|
||
if (hasIcon) {
|
||
canvas.drawImageRect(
|
||
brandIcon,
|
||
Rect.fromLTWH(0, 0, brandIcon.width.toDouble(), brandIcon.height.toDouble()),
|
||
Rect.fromLTWH(contentX, brandY + padY, iconSize, iconSize),
|
||
Paint()..filterQuality = FilterQuality.medium,
|
||
);
|
||
contentX += iconSize + gap;
|
||
}
|
||
|
||
// 文字在内容区里垂直居中
|
||
final contentCenterY = brandY + brandH / 2;
|
||
textPainter.paint(canvas, Offset(contentX, contentCenterY - textPainter.height / 2));
|
||
|
||
canvas.restore();
|
||
}
|
||
|
||
/// 排一段文字。加了淡阴影 —— 白字直接压在浅色照片上会糊,老项目没做这一步。
|
||
TextPainter _layoutText(
|
||
String text, {
|
||
required double fontSize,
|
||
required Color color,
|
||
required double scale,
|
||
bool bold = false,
|
||
}) {
|
||
final painter = TextPainter(
|
||
text: TextSpan(
|
||
text: text,
|
||
style: TextStyle(
|
||
fontSize: fontSize,
|
||
color: color,
|
||
fontWeight: bold ? FontWeight.bold : FontWeight.normal,
|
||
height: 1.2,
|
||
shadows: [
|
||
Shadow(
|
||
color: const Color(0x99000000),
|
||
blurRadius: 5 * scale,
|
||
offset: Offset(0, 1 * scale),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
textDirection: TextDirection.ltr,
|
||
maxLines: 1,
|
||
);
|
||
painter.layout();
|
||
return painter;
|
||
}
|
||
|
||
/// 按字符数折行(中文按字数折最直观,和原项目一致)
|
||
List<String> _wrapByCharCount(String text, int maxChars) {
|
||
if (text.length <= maxChars) return [text];
|
||
final result = <String>[];
|
||
for (var i = 0; i < text.length; i += maxChars) {
|
||
final end = (i + maxChars < text.length) ? i + maxChars : text.length;
|
||
result.add(text.substring(i, end));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/// 预览用:把水印盖在照片上
|
||
class WatermarkPainter extends CustomPainter {
|
||
const WatermarkPainter({
|
||
required this.settings,
|
||
required this.data,
|
||
this.brandIcon,
|
||
});
|
||
|
||
final WatermarkSettings settings;
|
||
final WatermarkData data;
|
||
final ui.Image? brandIcon;
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
paintWatermark(canvas, size, settings, data, brandIcon: brandIcon);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(WatermarkPainter old) {
|
||
return old.settings != settings ||
|
||
old.data != data ||
|
||
old.brandIcon != brandIcon;
|
||
}
|
||
}
|
||
|
||
/// 把原图和水印合成为一张 JPEG。
|
||
///
|
||
/// 走离屏绘制而不是「截图 Widget」:这样能按**图片原始像素**出图,
|
||
/// 不受屏幕分辨率限制,也不会因为页面滚动/遮挡出问题。
|
||
///
|
||
/// [maxWidth] 限制最长边(原项目也是 1920)—— 一是省内存,二是纯 Dart 的
|
||
/// JPEG 编码在大图上会明显变慢。
|
||
Future<Uint8List> composeWatermarkedJpeg({
|
||
required Uint8List originalBytes,
|
||
required WatermarkSettings settings,
|
||
required WatermarkData data,
|
||
ui.Image? brandIcon,
|
||
double maxWidth = 1920,
|
||
int quality = 92,
|
||
}) async {
|
||
// 先只读文件头拿原始尺寸,再决定按多大解码。
|
||
// 直接全尺寸解码一张 12MP 照片要 48MB 内存,没必要。
|
||
final buffer = await ui.ImmutableBuffer.fromUint8List(originalBytes);
|
||
final descriptor = await ui.ImageDescriptor.encoded(buffer);
|
||
|
||
final ratio = descriptor.width > maxWidth ? maxWidth / descriptor.width : 1.0;
|
||
final outW = (descriptor.width * ratio).round();
|
||
final outH = (descriptor.height * ratio).round();
|
||
|
||
final codec = await descriptor.instantiateCodec(targetWidth: outW, targetHeight: outH);
|
||
final frame = await codec.getNextFrame();
|
||
final source = frame.image;
|
||
|
||
final recorder = ui.PictureRecorder();
|
||
final canvas = Canvas(recorder);
|
||
canvas.drawImage(source, Offset.zero, Paint());
|
||
paintWatermark(
|
||
canvas,
|
||
Size(outW.toDouble(), outH.toDouble()),
|
||
settings,
|
||
data,
|
||
brandIcon: brandIcon,
|
||
);
|
||
|
||
final picture = recorder.endRecording();
|
||
final rendered = await picture.toImage(outW, outH);
|
||
|
||
// 到这里内容已经栅格化,可以把中间对象都放掉
|
||
source.dispose();
|
||
picture.dispose();
|
||
codec.dispose();
|
||
descriptor.dispose();
|
||
buffer.dispose();
|
||
|
||
final byteData = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||
rendered.dispose();
|
||
if (byteData == null) {
|
||
throw StateError('水印合成失败:拿不到位图数据');
|
||
}
|
||
|
||
// toByteData 只有 PNG / rawRgba,出不了 JPEG,所以自己用 image 包编码。
|
||
// 之所以非要 JPEG:1920×1440 的照片 PNG 要 3~6MB,JPEG 只要 ~500KB。
|
||
// 画面不透明(底图铺满),预乘与非预乘 RGBA 等价,直接当 RGBA 用。
|
||
final raster = img.Image.fromBytes(
|
||
width: outW,
|
||
height: outH,
|
||
bytes: byteData.buffer,
|
||
bytesOffset: byteData.offsetInBytes,
|
||
numChannels: 4,
|
||
);
|
||
return img.encodeJpg(raster, quality: quality);
|
||
}
|