水印相机前端
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user