—— 水印模塊開發實錄)
1. 前言上篇《C# WPF 獨立開發看圖工具 PixSight 經驗介紹》介紹了整體技術架構、圖片瀏覽/查看/編輯/OCR/格式轉換以及幾個最耗時的性能坑大圖內存溢出、多格式兼容、啟動速度、2000 圖片假死、OCR 語言選擇等。原文鏈接https://blog.csdn.net/weixin_53483426/article/details/162941010本文作為續篇記錄最近新增的核心模塊——「添加水印」。需求很樸素日常要把證件、材料照片發給別人時最怕被拿去亂用于是需要給圖片批量鋪上平鋪文字水印含用途、限制等且要所見即所得、能一次處理一堆圖。下面就從功能、原理到開發踩坑完整講一遍重點依然是那些“文檔里搜不到、只能自己踩出來”的坑。2. 功能介紹入口在主界面的「添加水印」。典型流程添加圖片支持對話框多選也可從瀏覽視圖直接選擇帶入→ 設置水印參數 →實時預覽 → 批量保存到各自原目錄同名自動加序號不覆蓋原圖。面向普通用戶做了幾處易用性處理字體下拉顯示中文名微軟雅黑 / 黑體 / 宋體 / 楷體 / 微軟雅黑 UI…內部再映射到真正的字體名普通用戶無需懂英文/拼音。顏色既可用預設色板紅橙黃綠青藍紫黑白灰也能點「取色」打開系統取色器完全不需要了解十六進制。參數改完自動持久化下次進入沿用上次設定。授權添加水印累計 50 次整批成功計 1 次足夠日常使用超出后可購買激活碼解鎖。默認參數WatermarkOptions參數默認值說明水印文字Text空必填整段平鋪文字例如“僅限辦理XX業務使用”字體FontFamilyMicrosoft YaHei下拉顯示中文名渲染用真實字體名字號FontSize00 按圖寬自適應字號 圖寬 / 27顏色ColorHex紅色#FF0000可用取色器任意選不透明度Opacity45≈ alpha 115/255半透明不擋內容角度Angle30°整數度負值向左上傾斜行數Rows6縱向平鋪密度參考常見證件照水印后綴OutputSuffix_水印輸出文件名附加如 photo_水印.jpg3. 水印原理平鋪整段文字而不是單字很多人以為“平鋪水印”是讓每個小瓦片只寫一個字其實不是。本項目每個瓦片寫的就是整段水印文字比如“僅限辦理業務使用”。做法是先算出單個瓦片的尺寸文字寬 × 行高把整段文字柵格化到一張小透明瓦片上字體只柵格化一次把這張小瓦片整體旋轉到目標角度只旋轉一次然后在源圖上按“旋轉后的網格”逐塊 Composite 這張小瓦片鋪滿整張圖。因為旋轉是線性變換逐塊旋轉放置等價于“先把整層旋轉好再和原圖合成”視覺上完全一致。預覽圖會先把長邊限制到 1280px 降采樣避免超大圖旋轉帶來的內存/性能問題。4. 開發踩坑總結4.1 超大旋轉畫布的性能災難最耗時的一坑最初的直觀寫法建一張覆蓋整張圖的巨大透明層 → 在上面寫好所有文字 → 把整層旋轉 → 裁切 → 放大回原圖尺寸 → 再和原圖整體合成。問題在水印是斜鋪的要覆蓋原圖四角這張“巨大透明層”必須比原圖大不少約 1.4 倍對角線。在 6000×4000 原圖上這一步要 6~11 秒/張批量幾百張根本沒法用。新方案關鍵思路完全不創建超大畫布、不做放大上采樣。文字柵格化到一張幾十~幾百像素的小瓦片 → 旋轉瓦片一次 → 直接在源圖上按“旋轉后的網格”逐塊合成小瓦片一張圖約 100 次廉價操作。速度提升一個數量級以上效果肉眼無差別。核心網格合成代碼如下// 直接在源圖上按“旋轉后的網格”逐塊合成小瓦片不再建超大畫布 // 旋轉矩陣與 Magick Rotate(angle) 一致圖像坐標系 y 向下正角順時針 // x cx ux·cosθ - uy·sinθ // y cy ux·sinθ uy·cosθ double rad angle * Math.PI / 180.0; double cosR Math.Cos(rad); double sinR Math.Sin(rad); int tw (int)rotated.Width, th (int)rotated.Height; double cx W / 2.0, cy H / 2.0; // 覆蓋范圍取旋轉后外接矩形在網格空間的半幅 余量確保整圖被覆蓋 double absCos Math.Abs(cosR), absSin Math.Abs(sinR); int halfX (int)((W * absCos H * absSin) / 2.0) stepX; int halfY (int)((W * absSin H * absCos) / 2.0) stepY; int nx (int)(halfX / (double)stepX) 1; int ny (int)(halfY / (double)stepY) 1; for (int ix -nx; ix nx; ix) { for (int iy -ny; iy ny; iy) { double ux ix * stepX, uy iy * stepY; double rx ux * cosR - uy * sinR; double ry ux * sinR uy * cosR; int px (int)(cx rx - tw / 2.0); int py (int)(cy ry - th / 2.0); src.Composite(rotated, px, py, CompositeOperator.Over); } }4.2 Magick.NET Q16 下顏色變“暗紅”的坑用 new MagickColor((byte)255, 0, 0) 想畫純紅結果出來是暗紅肉眼都能看出不對。原因是本項目用的是 Q16 版本16 位色深Quantum 范圍 0~65535而 new MagickColor(byte,byte,byte) 這個構造函數會把 0~255 當作 Quantum 的“小值”直接填進去沒有映射到 Quantum 全范圍于是紅色被壓到很暗。修復用 MagickColor.FromRgb(r, g, b)它會正確地把 0~255 映射到 Quantum 全范圍。同理不透明度也統一在合成水印層時用 Evaluate(Channels.Alpha, Multiply, alpha) 處理保證整層一致// Q16 下 new MagickColor(byte,byte,byte) 會把 255 當成 Quantum 小值導致顏色極暗 // 必須用 FromRgb 才能將 0-255 正確映射到 Quantum 全范圍。 var opaqueColor MagickColor.FromRgb(r, g, b); // 在小瓦片上統一處理不透明度平鋪后整體一致 double alphaFactor opacity / 100.0; if (alphaFactor 1.0) tile.Evaluate(Channels.Alpha, EvaluateOperator.Multiply, alphaFactor);4.3 中文與字體名處理ImageMagick 不吃「微軟雅黑」這種中文顯示名它要么認字體名Microsoft YaHei要么認字體文件路徑。另外反復枚舉 MagickNET.FontFamilies 會觸發字體緩存重建偶發卡頓甚至異常。本項目的解法下拉用 FontOption 類Name 顯示中文名給用戶看Family 存真實字體名供渲染選中后再把 Family 寫回渲染參數。ResolveFont 優先到 Windows\Fonts 找字體文件路徑微軟雅黑→msyh.ttc[0]、黑體→simhei.ttf 等繞過 ImageMagick 的字體名緩存都找不到才回退到字體名字符串。字體列表用 Lazy 只枚舉一次避免反復觸發緩存重建。// 字體選項Name中文顯示名給用戶看Family真實字體名供渲染 public class FontOption { public string Name { get; set; } public string Family { get; set; } } // 在 Windows 字體目錄中查找字體文件優先 .ttf再 .ttc/.otf // .ttc 集合字體加 [0] 幫助 ImageMagick 選第一個 private static string FindFontFile(params string[] names) { string fontsDir Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.Windows), Fonts); foreach (var name in names) { string lower name.ToLowerInvariant(); string[] candidates; if (lower.Contains(yahei) || lower.Contains(微軟雅黑)) candidates new[] { msyh.ttc, msyh.ttc[0], msyhbd.ttc, msyhl.ttc }; else if (lower.Contains(heiti) || lower simhei) candidates new[] { simhei.ttf, simhei.ttc, simhei.ttc[0] }; else if (lower.Contains(sun) || lower simsun) candidates new[] { simsun.ttc, simsun.ttc[0], simsunb.ttf }; else if (lower.Contains(kai) || lower kaiti) candidates new[] { simkai.ttf }; else if (lower arial) candidates new[] { arial.ttf }; else if (lower times new roman) candidates new[] { times.ttf, timesbd.ttf }; else candidates new[] { ${lower}.ttf, ${lower}.ttc[0], ${lower}.ttc, ${lower}.otf }; foreach (var c in candidates) { string raw c.Contains([) ? c.Substring(0, c.IndexOf([)) : c; string full Path.Combine(fontsDir, raw); if (File.Exists(full)) return Path.Combine(fontsDir, c); } } return null; }4.4 預覽與保存分離 防抖/取消預覽RenderPreview不寫盤且長邊限 1280px 降采樣保存ApplyAndSave才真正落盤。難點在于拖動滑塊時如果每次都重渲染隊列會堆積導致界面假死。處理手段參數變更走 200ms 防抖停手后才重建一次預覽并后臺持久化默認參數預覽在后臺 Task.Run 渲染每次請求帶遞增 token舊的請求完成后若 token 已過期就丟棄結果避免亂序/堆積角度/不透明度/行數這類滑塊拖動過程中只更新數字松手CommitSliderChange才觸發一次預覽重建避免持續重渲染。// 防抖停手 200ms 后才重建預覽 持久化默認參數 _debounceTimer new DispatcherTimer { Interval TimeSpan.FromMilliseconds(200) }; _debounceTimer.Tick (s, e) { _debounceTimer.Stop(); RebuildPreview(); Task.Run(() PersistDefaults()); }; // 重建預覽后臺渲染 token 取消陳舊任務避免隊列堆積假死 private void RebuildPreview() { var item CurrentItem; if (item null) { PreviewImage null; return; } _previewCts?.Cancel(); // 取消上一次 _previewCts new CancellationTokenSource(); var cts _previewCts; int token _previewToken; // 遞增 token Task.Run(() { var (bmp, error) ServiceLocator.Watermark.RenderPreview(item.SourcePath, opt); lock (_previewLock) { if (token ! _previewToken) return; // 已被新請求接管丟棄 } Application.Current?.Dispatcher?.Invoke(() { if (cts.IsCancellationRequested) return; if (bmp ! null) PreviewImage bmp; else StatusText $預覽失敗{error}; }); }, cts.Token); }4.5 批量保存的并發與授權額度保存階段逐張并行限制并發 3既利用多核又避免內存峰值過高MagickImage 各實例獨立、線程安全。原格式不可寫水印時自動回退 PNG/JPG。授權額度按“整批”計只有整批全部成功才扣 1 次中途失敗不扣符合用戶預期。4.6 順帶填的坑清理 ImageMagick 臨時文件Magick.NET 處理超大圖時像素緩存可能溢出到 %TEMP% 的 magick-* 文件無擴展名。程序正常退出會釋放但若強關/中斷這些文件會遺留——曾一次堆積 40GB 占滿系統盤。于是在 App 啟動時加了一段自清理刪除「最后寫入早于 5 分鐘前、且當前沒被占用獨占打開失敗」的孤兒文件單文件失敗跳過、整體異常兜底絕不阻塞啟動。5. 軟件獲取與授權更新本工具提供 30 天免費試用正式授權為一次性買斷無年費正式版與試用版是同一程序首次運行即 30 天試用激活后轉為正式版。相比上篇授權項增加「添加水印累計 50 次」整批成功計 1 次。激活采用「機器碼 激活碼」方式一臺機器對應一個授權便于離線使用。獲取方式請關注作者后續動態同前篇網盤鏈接。6. 結尾水印模塊從“想要”到“好用”最值錢的經驗是平鋪水印不要建超大旋轉畫布改成「小瓦片旋轉一次 網格逐塊合成」以及 Magick.NET Q16 下務必用 FromRgb 設色。后續會持續更新文章、迭代功能歡迎開發同行交流 WPF 圖像開發相關問題。