diff --git a/.gitignore b/.gitignore index 57dcc3e..f56a046 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,12 @@ obj/ *.user *.suo +# 便携版分发目录(dotnet build 后把单文件 exe 拷进来) +dist/ + +# Fody(Costura)构建时自动生成 +TfsExcelImporter/FodyWeavers.xsd + # Python __pycache__/ *.py[cod] diff --git a/README.md b/README.md index 71e177f..8ca3c99 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,17 @@ TFSImport/ ├── data/ │ └── work_items.example.csv # 列结构与示例行(UTF-8 BOM);真实台账不入库 ├── templates/ -│ └── TFS导入模板.xlsx # 默认模板:csproj 随构建拷到 exe 目录,「导出Excel模板」原样复制它 +│ └── TFS导入模板.xlsx # 默认模板:构建时嵌入 exe,「导出Excel模板」原样导出 +├── tools/ +│ ├── make_icon.py # 生成 app.ico(9 个尺寸帧,纯标准库 SDF 栅格化 + 手写 PNG/ICO) +│ ├── pe_icon.py # 解析 exe 的 PE 图标资源,导出某帧 PNG(换图标后核对用) +│ └── win_screenshot.py # 冒烟截图:拉起 exe、PrintWindow 抓窗、导出标题栏图标(ctypes) └── TfsExcelImporter/ # C# WinForms 桌面版(主用) ├── TfsExcelImporter.csproj # net48 + ClosedXML + TFS ExtendedClient 14.* + ├── FodyWeavers.xml # Costura 单文件打包配置(便携版) + ├── NativeLibraryLoader.cs # 释放并预加载内嵌的 TFS native DLL + ├── AppIcon.cs # 从内嵌 app.ico 取 Form.Icon(窗口/任务栏) + ├── app.ico # 应用图标(16~256 共 9 档,tools/make_icon.py 生成) ├── MainForm.cs # 导入 / 修 Area / 补挂父项流程 ├── MainFormUi.cs # 界面外壳:步骤卡片布局、手风琴折叠、状态呈现 ├── Theme.cs # 设计 token(颜色/字号/间距/缩放)与自绘控件 @@ -56,7 +64,14 @@ dotnet build TfsExcelImporter.sln -c Release TfsExcelImporter\bin\Release\net48\TfsExcelImporter.exe ``` -「导出Excel模板」复制的是 exe 同目录的 `TFS导入模板.xlsx`(构建时自动拷过去);单独分发 exe 时要带上这个文件,否则该按钮会报「未找到默认模板文件」。 +「导出Excel模板」优先用 exe 同目录的 `TFS导入模板.xlsx`(现场可替换),没有则从 exe 内嵌资源导出——**分发时只带一个 exe 就够,不用再附带模板文件**。 + +应用图标是 `TfsExcelImporter/app.ico`(蓝色渐变圆角底 + 白色上升箭头),csproj 里 `ApplicationIcon` 写进 exe 的 Win32 图标资源,同时以托管资源再嵌一份给 `Form.Icon` 用(多尺寸帧,高 DPI 下不糊)。要改设计就改 `tools/make_icon.py` 后重跑,覆盖 `app.ico` 再编译: + +```powershell +python tools/make_icon.py # 重新生成 app.ico,并出预览图 tools/icon_preview.png +python tools/win_screenshot.py TfsExcelImporter\bin\Release\net48\TfsExcelImporter.exe tools\_win.png +``` 界面按 4 步排:顶部状态条常驻显示服务器/项目/类型与连接状态,四张卡片一次只展开一张(展开一张会把其它收成一行摘要),预览表格永远占满剩余高度。 @@ -68,6 +83,28 @@ TfsExcelImporter\bin\Release\net48\TfsExcelImporter.exe 桌面版按 System DPI 感知实现,界面尺寸随 Windows 显示缩放换算;**改了显示缩放比例要重启程序**才生效。 +### 便携版(单 exe,免安装) + +构建产物只有一个文件:`TfsExcelImporter\bin\Release\net48\TfsExcelImporter.exe`(约 9.3 MB)。依赖和资源全部内嵌: + +| 内容 | 打包方式 | +| --- | --- | +| 58 个托管 DLL(ClosedXML / OpenXml / TFS Client OM …) | `Costura.Fody` 内嵌,运行时经 `AssemblyResolve` 从内存加载,不落盘 | +| `Microsoft.WITDataStore32/64.dll`(纯 win32,TFS OM 用) | 独立内嵌,启动时按进程位数释放到 `%TEMP%\TfsExcelImporter\native\{内容hash}` 并 `LoadLibrary` 预加载 | +| 默认模板 `TFS导入模板.xlsx` | 内嵌资源,「导出Excel模板」时释放;exe 同目录放同名文件则优先用它 | +| `config.json` | 首次保存时写到 exe 同目录(便携);目录只读时回退 `%AppData%\TfsExcelImporter\config.json` | + +分发: + +```powershell +mkdir dist +copy TfsExcelImporter\bin\Release\net48\TfsExcelImporter.exe dist\ +``` + +把 `dist\TfsExcelImporter.exe` 拷到任意目录双击即用。想连配置一起带走,就把 exe 同目录的 `config.json` 一起拷。 + +前提:目标机需有 .NET Framework 4.8(Win10 1903+ / Win11 系统自带,一般无需额外安装)。`FodyWeavers.xml` 是 Costura 的打包配置,删掉它或用 `-p:FodyDisable=true` 构建即回到「exe + 一堆 DLL」的普通输出。 + ### Python 版 ```powershell @@ -84,6 +121,8 @@ python tfs_import.py --input other.csv --limit 5 ## 配置说明 +桌面版的配置落在 exe 同目录的 `config.json`(界面改动后自动写回);exe 目录不可写时回退到 `%AppData%\TfsExcelImporter\config.json`。 + 配置的语义、留空字段如何解析见 [tfs_import.py:49](tfs_import.py#L49) 与 [Config.cs](TfsExcelImporter/Config.cs),关键字段: | 键 | 说明 | diff --git a/TfsExcelImporter/AppIcon.cs b/TfsExcelImporter/AppIcon.cs new file mode 100644 index 0000000..164eca3 --- /dev/null +++ b/TfsExcelImporter/AppIcon.cs @@ -0,0 +1,46 @@ +using System; +using System.Drawing; +using System.IO; +using System.Reflection; +using System.Windows.Forms; + +namespace TfsExcelImporter +{ + /// + /// 应用图标。exe 的 Win32 图标资源由 csproj 的 ApplicationIcon 提供, + /// 这里负责窗口标题栏/任务栏用的 Form.Icon:从内嵌的 app.ico(9 个尺寸帧) + /// 直接构造,系统按 DPI 取 16/32/48;取不到再退回提取 exe 自身图标。 + /// + internal static class AppIcon + { + private static Icon _cached; + private static bool _tried; + + public static Icon Load() + { + if (_tried) return _cached; + _tried = true; + + try + { + using (Stream s = Assembly.GetExecutingAssembly() + .GetManifestResourceStream("app.ico")) + { + if (s != null) _cached = new Icon(s); + } + } + catch + { + // 内嵌 ICO 解析失败(例如只认 BMP 帧的极端情况):忽略,走下面的兜底 + _cached = null; + } + + if (_cached == null) + { + try { _cached = Icon.ExtractAssociatedIcon(Application.ExecutablePath); } + catch { } + } + return _cached; + } + } +} diff --git a/TfsExcelImporter/Config.cs b/TfsExcelImporter/Config.cs index e11c842..b120770 100644 --- a/TfsExcelImporter/Config.cs +++ b/TfsExcelImporter/Config.cs @@ -44,11 +44,39 @@ namespace TfsExcelImporter // 父 Product Backlog Item:所有 Task 默认挂接的 PBI Id(如 219975);Excel 每行 PBI Id 列可覆盖 public string ParentPbiId { get; set; } = ""; - private static string ConfigPath + // 便携优先:配置写在 exe 同目录(拷走 exe 即可带走配置); + // exe 目录只读(如放在 Program Files)时回退到 %AppData%\TfsExcelImporter。 + private static string PortableConfigPath { get { return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "config.json"); } } + private static string RoamingConfigPath + { + get + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "TfsExcelImporter", "config.json"); + } + } + + private static string ConfigPath + { + get + { + if (File.Exists(PortableConfigPath)) return PortableConfigPath; + if (File.Exists(RoamingConfigPath)) return RoamingConfigPath; + return PortableConfigPath; + } + } + + /// 当前实际使用的配置文件路径(界面提示用)。 + public static string ConfigLocation + { + get { return ConfigPath; } + } + public static AppConfig Load() { try @@ -68,14 +96,28 @@ namespace TfsExcelImporter public void Save() { + var json = JsonConvert.SerializeObject(this, Formatting.Indented); try { - File.WriteAllText(ConfigPath, JsonConvert.SerializeObject(this, Formatting.Indented)); + File.WriteAllText(ConfigPath, json); + return; } catch (Exception ex) { System.Diagnostics.Debug.WriteLine("写入 config.json 失败: " + ex.Message); } + + // exe 目录不可写(只读介质 / Program Files)时落到用户配置目录 + try + { + var fallback = RoamingConfigPath; + Directory.CreateDirectory(Path.GetDirectoryName(fallback)); + File.WriteAllText(fallback, json); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine("写入用户配置目录失败: " + ex.Message); + } } } } diff --git a/TfsExcelImporter/ExcelService.cs b/TfsExcelImporter/ExcelService.cs index 2faecb0..e3ceb9d 100644 --- a/TfsExcelImporter/ExcelService.cs +++ b/TfsExcelImporter/ExcelService.cs @@ -22,7 +22,8 @@ namespace TfsExcelImporter /// /// Excel 读取与默认模板。模板内容(列宽、示例行、样式)只在 - /// templates\TFS导入模板.xlsx 里维护,随构建复制到 exe 目录后原样导出。 + /// templates\TFS导入模板.xlsx 里维护,构建时作为嵌入资源打进 exe; + /// 若 exe 同目录存在同名文件则优先用它(现场可替换模板而不用重编译)。 /// 列: Title | State | Assigned To | FinancialProject | Completed | LogDate | PBI Id | Description /// public static class ExcelService @@ -44,13 +45,33 @@ namespace TfsExcelImporter // --------------------------------------------------------- 默认模板 - /// 把随程序发布的默认模板原样复制到目标路径。 + /// + /// 把默认模板原样复制到目标路径。优先用 exe 同目录的同名文件(可由现场替换), + /// 没有则从 exe 内嵌资源释放——单文件便携分发时无需附带模板。 + /// public static void CopyTemplate(string path) { var src = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, TemplateFileName); - if (!File.Exists(src)) - throw new FileNotFoundException("未找到默认模板文件: " + src, src); - File.Copy(src, path, true); + if (File.Exists(src)) + { + File.Copy(src, path, true); + return; + } + + var asm = typeof(ExcelService).Assembly; + // 嵌入名取决于 Link 名:默认 .<文件名> + var name = asm.GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith(TemplateFileName, StringComparison.OrdinalIgnoreCase)); + if (name == null) + throw new FileNotFoundException( + "未找到默认模板:exe 内嵌资源与同目录 " + TemplateFileName + " 均缺失", src); + + using (var input = asm.GetManifestResourceStream(name)) + { + if (input == null) throw new FileNotFoundException("无法读取内嵌模板资源: " + name, src); + using (var output = File.Create(path)) + input.CopyTo(output); + } } // --------------------------------------------------------- 读取数据 diff --git a/TfsExcelImporter/FodyWeavers.xml b/TfsExcelImporter/FodyWeavers.xml new file mode 100644 index 0000000..758c089 --- /dev/null +++ b/TfsExcelImporter/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + diff --git a/TfsExcelImporter/MainForm.cs b/TfsExcelImporter/MainForm.cs index ee72fff..e34a31f 100644 --- a/TfsExcelImporter/MainForm.cs +++ b/TfsExcelImporter/MainForm.cs @@ -23,6 +23,7 @@ namespace TfsExcelImporter using (var g = Graphics.FromHwnd(IntPtr.Zero)) Theme.Scale = g.DpiX / 96f; _cfg = AppConfig.Load(); + Icon = AppIcon.Load(); BuildUi(); LoadConfigToUi(); WireEvents(); @@ -306,7 +307,7 @@ namespace TfsExcelImporter 标题 = r.Title, 状态 = r.State, 指派 = r.AssignedTo, - 财务项目 = r.FinancialProject, + 项目 = r.FinancialProject, 完成量 = r.Completed, 日期 = r.LogDate, 父PBI = r.PbiId, @@ -588,10 +589,9 @@ namespace TfsExcelImporter } catch (Exception ex) { Log("写报告失败: " + FirstLine(ex.Message)); } _pbar.Value = _pbar.Maximum; + // 成功/跳过/失败的分布已在右侧三个胶囊里,这里只补胶囊没有的「总数」,明细仍写日志 _lblStatus.Text = $"完成 {ok + skip + fail}/{_rows.Count}"; - var summary = $"汇总: 成功 {ok} / 跳过 {skip} / 失败 {fail}"; - _lblStatus.Text = summary; - Log(summary); + Log($"汇总: 成功 {ok} / 跳过 {skip} / 失败 {fail}"); SetChips(ok, skip, fail, false); RefreshSummaries(); if (fail == 0 && ok > 0) OpenDetail(_cardLog); diff --git a/TfsExcelImporter/MainFormUi.cs b/TfsExcelImporter/MainFormUi.cs index ad77a36..02c9d8a 100644 --- a/TfsExcelImporter/MainFormUi.cs +++ b/TfsExcelImporter/MainFormUi.cs @@ -341,7 +341,8 @@ namespace TfsExcelImporter // 透明背景会让父容器改画自己的 BackColor(灰),这里直接给成操作条的白 _badgeRun = new StepBadge { BackColor = Theme.Card }; _btnPreview = NewButton("预览数据", BtnKind.Secondary, IconKind.Table, 120); - _btnImport = NewButton("开始导入 TFS", BtnKind.Primary, IconKind.ArrowUp, 176); + // 148 已够图标+文字(实际量出约 130),省下的宽度留给右侧状态文字与进度条 + _btnImport = NewButton("开始导入 TFS", BtnKind.Primary, IconKind.ArrowUp, 148); // 主/次按钮同高(层级靠主色表达,不再用 38px 拔高),避免操作条里两个按钮大小不齐 _pbar = new ProgressTrack { @@ -390,13 +391,33 @@ namespace TfsExcelImporter _chipFail.SetBounds(_actionBar.Width - _chipFail.Width - M - Theme.Px(2), cy, _chipFail.Width, _chipFail.Height); _chipSkip.SetBounds(_chipFail.Left - G - _chipSkip.Width, cy, _chipSkip.Width, _chipSkip.Height); _chipOk.SetBounds(_chipSkip.Left - G - _chipOk.Width, cy, _chipOk.Width, _chipOk.Height); - int statW = Theme.Px(160); - _lblStatus.SetBounds(_chipOk.Left - statW - G, 0, statW, _actionBar.Height); + // 状态文字按最长常规文案实测给宽,且只给一行的高度: + // 宽度够了不折行,万一更长的文案也只省略尾巴,不会把计数拆到第二行。 + int statW = StatusLabelWidth(); + int statH = Theme.Caption.Height + Theme.Px(4); + _lblStatus.SetBounds(_chipOk.Left - statW - G, (_actionBar.Height - statH) / 2, statW, statH); int left = _btnImport.Right + Theme.S16; int barW = Math.Max(Theme.Px(40), _lblStatus.Left - G - left); _pbar.SetBounds(left, (_actionBar.Height - _pbar.Height) / 2, barW, _pbar.Height); } + /// + /// 操作条右侧状态文字的最大显示宽度:取各操作最长文案的实测宽度(160~280 之间夹取), + /// 保证常规状态既不折行、也不省略尾部计数。更长的文案(如「Area 修正完成…(详见日志)」) + /// 只省略尾巴——它们的数字在开头,不受影响。 + /// + private static int StatusLabelWidth() + { + string[] samples = + { + "完成 999/999", // 导入结束:只报总数,分布交给右侧胶囊 + "补挂完成: 挂接 99 / 已有父跳过 99 / 失败 0" // 补挂父项:最长的一条常规状态 + }; + int w = 0; + foreach (var s in samples) w = Math.Max(w, Theme.TextWidth(s, Theme.Caption)); + return Math.Min(Theme.Px(280), Math.Max(Theme.Px(160), w + Theme.Px(8))); + } + // --- 日志抽屉 --- private void BuildCardLog() { diff --git a/TfsExcelImporter/NativeLibraryLoader.cs b/TfsExcelImporter/NativeLibraryLoader.cs new file mode 100644 index 0000000..292100e --- /dev/null +++ b/TfsExcelImporter/NativeLibraryLoader.cs @@ -0,0 +1,83 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; + +namespace TfsExcelImporter +{ + /// + /// TFS Client OM 除托管程序集外还依赖两个纯 win32 DLL + /// (Microsoft.WITDataStore32/64.dll)。托管程序集由 Costura 内嵌并从内存加载, + /// native DLL 只能落盘后 LoadLibrary,所以单文件分发时: + /// 启动阶段把内嵌的 native DLL 释放到 %TEMP%\TfsExcelImporter\native\{内容hash}, + /// 再 LoadLibrary 预加载 —— 之后 TFS 内部的 DllImport 会直接命中已加载模块。 + /// 目录名带 DLL 内容 hash,升级后不会残留旧版本。 + /// exe 同目录存在同名 DLL(开发机 bin 目录、绿色解压版)时不做任何处理。 + /// + internal static class NativeLibraryLoader + { + [DllImport("kernel32", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "LoadLibraryW")] + private static extern IntPtr LoadLibrary(string path); + + public static void EnsureLoaded() + { + var fileName = Environment.Is64BitProcess + ? "Microsoft.WITDataStore64.dll" + : "Microsoft.WITDataStore32.dll"; + + if (File.Exists(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, fileName))) + return; + + try + { + var asm = typeof(NativeLibraryLoader).Assembly; + var resourceName = "native." + fileName; + using (var stream = asm.GetManifestResourceStream(resourceName)) + { + if (stream == null) + { + Debug.WriteLine("未找到内嵌 native 资源: " + resourceName); + return; + } + + byte[] bytes; + using (var buffer = new MemoryStream()) + { + stream.CopyTo(buffer); + bytes = buffer.ToArray(); + } + + var target = Path.Combine(NativeDir(bytes), fileName); + Directory.CreateDirectory(Path.GetDirectoryName(target)); + if (!File.Exists(target)) + { + try { File.WriteAllBytes(target, bytes); } + catch (IOException) + { + // 同 hash 目录已被另一实例写住,内容等价,直接用 + if (!File.Exists(target)) throw; + } + } + + if (LoadLibrary(target) == IntPtr.Zero) + Debug.WriteLine("LoadLibrary 失败(" + Marshal.GetLastWin32Error() + "): " + target); + } + } + catch (Exception ex) + { + // 释放失败不阻断启动:TFS 连接时会自行报 DllNotFound,提示仍是可读的 + Debug.WriteLine("释放 native DLL 失败: " + ex.Message); + } + } + + private static string NativeDir(byte[] bytes) + { + byte[] hash; + using (var sha = SHA256.Create()) + hash = sha.ComputeHash(bytes); + var stamp = BitConverter.ToString(hash, 0, 4).Replace("-", ""); + return Path.Combine(Path.GetTempPath(), "TfsExcelImporter", "native", stamp); + } + } +} diff --git a/TfsExcelImporter/Program.cs b/TfsExcelImporter/Program.cs index a417a71..7f2e240 100644 --- a/TfsExcelImporter/Program.cs +++ b/TfsExcelImporter/Program.cs @@ -8,6 +8,9 @@ namespace TfsExcelImporter [STAThread] private static void Main() { + // 单文件分发时 native DLL 从内嵌资源释放并预加载(TFS Client OM 需要) + NativeLibraryLoader.EnsureLoaded(); + Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new MainForm()); diff --git a/TfsExcelImporter/TfsExcelImporter.csproj b/TfsExcelImporter/TfsExcelImporter.csproj index bf66a2e..85dc59d 100644 --- a/TfsExcelImporter/TfsExcelImporter.csproj +++ b/TfsExcelImporter/TfsExcelImporter.csproj @@ -11,6 +11,10 @@ true app.manifest + + app.ico + + en @@ -22,16 +26,34 @@ + + + PreserveNewest - - - PreserveNewest - + + + + + + + native.Microsoft.WITDataStore64.dll + + + native.Microsoft.WITDataStore32.dll + diff --git a/TfsExcelImporter/app.ico b/TfsExcelImporter/app.ico new file mode 100644 index 0000000..ad5af13 Binary files /dev/null and b/TfsExcelImporter/app.ico differ diff --git a/debug.log b/debug.log index 23c47b9..c7e4ba1 100644 --- a/debug.log +++ b/debug.log @@ -2,3 +2,5 @@ [1101/173900.093:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: 系统找不到指定的文件。 (0x2) [1101/184906.941:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: 系统找不到指定的文件。 (0x2) [1101/191310.883:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: 系统找不到指定的文件。 (0x2) +[1101/193634.522:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: 系统找不到指定的文件。 (0x2) +[1101/195235.112:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: 系统找不到指定的文件。 (0x2) diff --git a/tools/icon_preview.png b/tools/icon_preview.png new file mode 100644 index 0000000..237caa3 Binary files /dev/null and b/tools/icon_preview.png differ diff --git a/tools/make_icon.py b/tools/make_icon.py new file mode 100644 index 0000000..91512ae --- /dev/null +++ b/tools/make_icon.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +"""生成 TfsExcelImporter 的应用图标(app.ico / 预览图)。 + +纯标准库实现(本机没有 Pillow / numpy):用有向距离场(SDF)解析求覆盖率做 +抗锯齿,再手写 PNG / ICO 容器,不依赖任何第三方包。 + +设计:蓝色渐变圆角方块 + 白色线性「上升箭头」字形(导入语义, +方向与界面「开始导入」按钮的 ArrowUp 一致)。按尺寸分档: + - 大尺寸:加顶部高光与 1px 内描边(立体感) + - 小尺寸(<=24):关掉高光/内描边、加粗描边并略微放大字形,避免糊成一团 + +用法: + python tools/make_icon.py # 生成 app.ico + tools/icon_preview.png + python tools/make_icon.py --only-preview # 只出预览图 +""" + +import argparse +import math +import os +import struct +import zlib + +# ----------------------------------------------------------------- 设计参数 +SIZES = [16, 20, 24, 32, 40, 48, 64, 128, 256] + +COLOR_TOP = (0x44, 0x8E, 0xFA) # 顶部亮蓝 +COLOR_BOT = (0x18, 0x42, 0xC4) # 底部深蓝 +WHITE = (255, 255, 255) + +TILE_MARGIN = 0.045 +TILE_RADIUS = 0.235 + + +def style(size): + """按尺寸分档的视觉参数。""" + if size <= 20: + return dict(stroke=0.150, gloss=0.00, rim=0.00, ring=True, grow=1.08) + if size <= 24: + return dict(stroke=0.138, gloss=0.00, rim=0.00, ring=True, grow=1.06) + if size <= 40: + return dict(stroke=0.118, gloss=0.09, rim=0.14, ring=False, grow=1.02) + return dict(stroke=0.100, gloss=0.10, rim=0.17, ring=False, grow=1.00) + + +# 字形骨架(归一化坐标,单位方格)。全部是圆头胶囊,端点已按描边半径内缩过, +# 这样描边加粗时外形不会溢出画布。 +# 返回 (箭头胶囊组, 副元素胶囊组, 副元素描边系数);副元素为空表示只画箭头。 +def glyph_capsules(stroke, variant="arrow"): + if variant == "arrow": + # 只有箭头:上下留白对称(0.14~0.85),比例比带盒子时更大更饱满 + arrow = [ + (0.500, 0.270, 0.500, 0.790), + (0.262, 0.418, 0.500, 0.196), + (0.500, 0.196, 0.738, 0.418), + ] + return arrow, [], 1.0 + shaft_end = 0.700 if variant == "tray" else 0.650 + arrow = [ + # 箭杆 + 箭头两撇(V 形,圆头在顶点自然并成箭头) + (0.500, 0.235, 0.500, shaft_end), + (0.330, 0.375, 0.500, 0.205), + (0.500, 0.205, 0.670, 0.375), + ] + if variant == "slot": + # 单根加粗横条("槽/落点"),无竖壁,小尺寸更干净 + return arrow, [(0.250, 0.780, 0.750, 0.780)], 1.30 + # 托盘:底边 + 两侧竖壁;底边端点落在竖壁中心线上,角部由此自然圆接 + return arrow, [ + (0.300, 0.800, 0.700, 0.800), + (0.300, 0.640, 0.300, 0.800), + (0.700, 0.640, 0.700, 0.800), + ], 1.0 + + +# ----------------------------------------------------------------- SDF 基础 +def sd_round_rect(x, y, cx, cy, hw, hh, r): + dx = abs(x - cx) - (hw - r) + dy = abs(y - cy) - (hh - r) + ox = dx if dx > 0.0 else 0.0 + oy = dy if dy > 0.0 else 0.0 + inner = dx if dx > dy else dy + if inner > 0.0: + inner = 0.0 + return math.hypot(ox, oy) + inner - r + + +def sd_seg(x, y, ax, ay, bx, by): + pax, pay = x - ax, y - ay + bax, bay = bx - ax, by - ay + den = bax * bax + bay * bay + t = 0.0 if den == 0.0 else max(0.0, min(1.0, (pax * bax + pay * bay) / den)) + return math.hypot(pax - bax * t, pay - bay * t) + + +def clamp01(v): + return 0.0 if v < 0.0 else (1.0 if v > 1.0 else v) + + +def cov(d, size): + """距离场 -> 覆盖率(1px 过渡带)。""" + return clamp01(0.5 - d * size) + + +# ----------------------------------------------------------------- 单尺寸渲染 +def render(size, variant="tray"): + st = style(size) + # 只有箭头时没有「托盘」当配重,线条要更粗一点才站得住 + stroke = st["stroke"] + (0.016 if variant == "arrow" else 0.0) + grow = st["grow"] + arrow, tray, tray_w = glyph_capsules(stroke, variant) + gr = stroke / 2.0 + # 大尺寸下托盘描边略细于箭头:用「粗细」而不是「透明度」做主次(透明度压在深蓝上会发灰) + tray_k = tray_w * (1.0 if size <= 32 else 0.94) + tray_tone = 1.0 + + hw = 0.5 - TILE_MARGIN + rad = TILE_RADIUS + inner_shrink = 1.0 / size # 内描边宽度 = 1px + + buf = bytearray(size * size * 4) + span = 1.0 - 2.0 * TILE_MARGIN + + for py in range(size): + v = (py + 0.5) / size + # 字形整体缩放:把坐标拉回以 0.5 为心 + gv = 0.5 + (v - 0.5) / grow + row_off = py * size * 4 + for px in range(size): + u = (px + 0.5) / size + + a = cov(sd_round_rect(u, v, 0.5, 0.5, hw, hw, rad), size) + if a <= 0.0: + continue + + # 渐变(略偏对角线,避免纯竖条的死板) + t = clamp01(((v - TILE_MARGIN) * 0.82 + (u - TILE_MARGIN) * 0.18) / span) + cr = COLOR_TOP[0] + (COLOR_BOT[0] - COLOR_TOP[0]) * t + cg = COLOR_TOP[1] + (COLOR_BOT[1] - COLOR_TOP[1]) * t + cb = COLOR_TOP[2] + (COLOR_BOT[2] - COLOR_TOP[2]) * t + + # 顶部高光 + if st["gloss"] > 0.0: + g = st["gloss"] * clamp01(1.0 - (v - TILE_MARGIN) / 0.38) + cr += (255.0 - cr) * g + cg += (255.0 - cg) * g + cb += (255.0 - cb) * g + + # 1px 内描边(沿边缘的细亮线,让方块边界更挺) + if st["rim"] > 0.0: + d_in = sd_round_rect(u, v, 0.5, 0.5, hw - inner_shrink, + hw - inner_shrink, rad - inner_shrink) + band = a - cov(d_in, size) + if band > 0.0: + k = st["rim"] * band + cr += (255.0 - cr) * k + cg += (255.0 - cg) * k + cb += (255.0 - cb) * k + + # 字形:托盘(略细)+ 箭头(主调) + gu = 0.5 + (u - 0.5) / grow + for group, tone, k in ((tray, tray_tone, tray_k), (arrow, 1.0, 1.0)): + d = 1e9 + rg = gr * k + for (ax, ay, bx, by) in group: + dd = sd_seg(gu, gv, ax, ay, bx, by) - rg + if dd < d: + d = dd + ag = cov(d, size) * a * tone + if ag > 0.0: + cr += (WHITE[0] - cr) * ag + cg += (WHITE[1] - cg) * ag + cb += (WHITE[2] - cb) * ag + + i = row_off + px * 4 + buf[i] = int(cr + 0.5) + buf[i + 1] = int(cg + 0.5) + buf[i + 2] = int(cb + 0.5) + buf[i + 3] = int(a * 255.0 + 0.5) + return buf + + +# ----------------------------------------------------------------- PNG / ICO +def png_bytes(w, h, rgba): + raw = bytearray() + for y in range(h): + raw.append(0) + raw += rgba[y * w * 4:(y + 1) * w * 4] + + def chunk(tag, data): + return (struct.pack(">I", len(data)) + tag + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)) + + ihdr = struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + chunk(b"IEND", b"")) + + +def dib_bytes(w, h, rgba): + """32bpp 自下而上的 BMP(ICO 里的 DIB,含 AND 掩码)。""" + xor = bytearray() + for y in range(h - 1, -1, -1): + for x in range(w): + i = (y * w + x) * 4 + xor += bytes((rgba[i + 2], rgba[i + 1], rgba[i], rgba[i + 3])) + and_stride = ((w + 31) // 32) * 4 + and_mask = bytes(and_stride * h) + hdr = struct.pack("= 256 else size + out.append(struct.pack("= dh: + break + for x in range(sw * scale): + sx = x // scale + dx = ox + x + if dx >= dw: + break + si = (sy * sw + sx) * 4 + di = (dy * dw + dx) * 4 + dst[di:di + 4] = src[si:si + 4] + + +def preview(renders, path): + pad = 10 + big = renders[256] + strip = SIZES[:-1] # 16..128 + zoom = 4 + strip_w = sum(s * zoom + pad for s in strip) + w = max(256 + 2 * pad, strip_w + pad) + h = 256 + strip_w * 0 + 64 + 128 * zoom + 3 * pad + h = 256 + pad * 3 + 128 * zoom + canvas = bytearray(w * h * 4) + for i in range(w * h): + canvas[i * 4 + 0] = 0xF3 + canvas[i * 4 + 1] = 0xF4 + canvas[i * 4 + 2] = 0xF6 + canvas[i * 4 + 3] = 0xFF + blit(canvas, w, h, big, 256, 256, pad, pad, 1) + x = pad + y = 256 + pad * 2 + for s in strip: + blit(canvas, w, h, renders[s], s, s, x, y + (128 - s) * zoom, zoom) + x += s * zoom + pad + with open(path, "wb") as f: + f.write(png_bytes(w, h, canvas)) + return w, h + + +# ----------------------------------------------------------------- main +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--out", default="TfsExcelImporter/app.ico") + ap.add_argument("--preview", default="tools/icon_preview.png") + ap.add_argument("--only-preview", action="store_true") + ap.add_argument("--variant", default="arrow", choices=["arrow", "tray", "slot"]) + args = ap.parse_args() + + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + out_ico = args.out if os.path.isabs(args.out) else os.path.join(root, args.out) + out_png = (args.preview if os.path.isabs(args.preview) + else os.path.join(root, args.preview)) + if args.variant != "arrow": + base, ext = os.path.splitext(out_png) + out_png = "%s_%s%s" % (base, args.variant, ext) + + renders = {} + for s in SIZES: + renders[s] = render(s, args.variant) + print("rendered %dx%d" % (s, s)) + + frames = [(s, png_bytes(s, s, renders[s]) if s >= 256 + else dib_bytes(s, s, renders[s])) for s in SIZES] + + if not args.only_preview: + with open(out_ico, "wb") as f: + f.write(ico_bytes(frames)) + print("wrote %s (%d bytes)" % (out_ico, os.path.getsize(out_ico))) + + with open(os.path.splitext(out_png)[0] + "_256.png", "wb") as f: + f.write(png_bytes(256, 256, renders[256])) + w, h = preview(renders, out_png) + print("wrote %s (%dx%d)" % (out_png, w, h)) + + +if __name__ == "__main__": + main() diff --git a/tools/pe_icon.py b/tools/pe_icon.py new file mode 100644 index 0000000..d3492ea --- /dev/null +++ b/tools/pe_icon.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""查 exe 里到底嵌了哪套图标(PE 资源解析),可导出最大帧为 PNG 核对。 + +用途:换图标后 Windows 仍显示旧图标时,先确认「exe 里是新图标」还是「外壳图标缓存没刷」。 + + python tools/pe_icon.py dist/TfsExcelImporter.exe [out_256.png] +""" + +import struct +import sys +import zlib + + +def u16(d, o): + return struct.unpack_from("I", len(data)) + tag + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)) + + with open(path, "wb") as f: + f.write(b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + chunk(b"IEND", b"")) + + +def dib_to_rgba(payload): + bw, bh2 = struct.unpack_from(" 2 else None + res = parse_resources(exe) + + groups = [(p, b) for p, b in res if p[0] == 14] + icons = {(p[1] if len(p) > 1 else 0): b for p, b in res if p[0] == 3} + print("%s: RT_GROUP_ICON=%d RT_ICON=%d" % (exe, len(groups), len(icons))) + if not groups: + print("!! exe 里没有图标资源(会用系统默认 exe 图标)") + return + + for path_ids, b in groups: + cnt, = struct.unpack_from("", out_png) + return + for size, idx, sz in frames: + if size == 48: + w, h, rgba = dib_to_rgba(icons[idx]) + png_write(out_png, w, h, rgba) + print(" 已导出 48 DIB 帧 ->", out_png) + return + + +if __name__ == "__main__": + main() diff --git a/tools/win_screenshot.py b/tools/win_screenshot.py new file mode 100644 index 0000000..ec435da --- /dev/null +++ b/tools/win_screenshot.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""冒烟验证:直接拉起 Release exe,截取真实窗口(含标题栏图标),存 PNG。 + +用途:确认 ApplicationIcon 与 Form.Icon 都生效、且换图标后窗口能正常起来。 +纯标准库 + ctypes,不依赖任何第三方包。 +""" + +import ctypes +import os +import struct +import subprocess +import sys +import time +import zlib +from ctypes import wintypes as wt + +u32 = ctypes.WinDLL("user32", use_last_error=True) +g32 = ctypes.WinDLL("gdi32", use_last_error=True) +k32 = ctypes.WinDLL("kernel32", use_last_error=True) + +u32.PrintWindow.argtypes = [wt.HWND, wt.HDC, wt.UINT] +u32.GetWindowRect.argtypes = [wt.HWND, ctypes.POINTER(wt.RECT)] +u32.GetWindowDC.argtypes = [wt.HWND] +u32.GetWindowDC.restype = wt.HDC +u32.GetWindowTextW.argtypes = [wt.HWND, wt.LPWSTR, ctypes.c_int] +u32.GetWindowThreadProcessId.argtypes = [wt.HWND, ctypes.POINTER(wt.DWORD)] +u32.IsWindowVisible.argtypes = [wt.HWND] +u32.SendMessageW.argtypes = [wt.HWND, wt.UINT, wt.WPARAM, wt.LPARAM] +u32.SendMessageW.restype = ctypes.c_void_p +g32.CreateCompatibleDC.argtypes = [wt.HDC] +g32.CreateCompatibleDC.restype = wt.HDC +g32.CreateCompatibleBitmap.argtypes = [wt.HDC, ctypes.c_int, ctypes.c_int] +g32.CreateCompatibleBitmap.restype = wt.HBITMAP +g32.SelectObject.argtypes = [wt.HDC, wt.HGDIOBJ] +g32.SelectObject.restype = wt.HGDIOBJ +g32.GetDIBits.argtypes = [wt.HDC, wt.HBITMAP, wt.UINT, wt.UINT, ctypes.c_void_p, + ctypes.c_void_p, wt.UINT] +u32.ReleaseDC.argtypes = [wt.HWND, wt.HDC] +u32.ReleaseDC.restype = ctypes.c_int +u32.GetDC.argtypes = [wt.HWND] +u32.GetDC.restype = wt.HDC +u32.FillRect.argtypes = [wt.HDC, ctypes.POINTER(wt.RECT), wt.HBRUSH] +g32.DeleteObject.argtypes = [wt.HGDIOBJ] +g32.DeleteObject.restype = wt.BOOL +g32.DeleteDC.argtypes = [wt.HDC] +g32.DeleteDC.restype = wt.BOOL +g32.CreateSolidBrush.argtypes = [wt.DWORD] +g32.CreateSolidBrush.restype = wt.HBRUSH + + +class BITMAPINFOHEADER(ctypes.Structure): + _fields_ = [("biSize", wt.DWORD), ("biWidth", wt.LONG), ("biHeight", wt.LONG), + ("biPlanes", wt.WORD), ("biBitCount", wt.WORD), + ("biCompression", wt.DWORD), ("biSizeImage", wt.DWORD), + ("biXPelsPerMeter", wt.LONG), ("biYPelsPerMeter", wt.LONG), + ("biClrUsed", wt.DWORD), ("biClrImportant", wt.DWORD)] + + +def bgra_to_rgba(buf): + """GetDIBits 给的是 BGRA,PNG 要 RGBA。""" + out = bytearray(buf) + for i in range(0, len(out), 4): + out[i], out[i + 2] = out[i + 2], out[i] + return bytes(out) + + +def png(path, w, h, rgba): + raw = bytearray() + for y in range(h): + raw.append(0) + raw += rgba[y * w * 4:(y + 1) * w * 4] + + def chunk(tag, data): + return (struct.pack(">I", len(data)) + tag + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)) + + with open(path, "wb") as f: + f.write(b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + chunk(b"IEND", b"")) + + +def find_window(pid, timeout=25.0): + result = [] + + @ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPARAM) + def cb(hwnd, _): + p = wt.DWORD() + u32.GetWindowThreadProcessId(hwnd, ctypes.byref(p)) + if p.value != pid or not u32.IsWindowVisible(hwnd): + return True + n = u32.GetWindowTextLengthW(hwnd) + if n <= 0: + return True + buf = ctypes.create_unicode_buffer(n + 2) + u32.GetWindowTextW(hwnd, buf, n + 2) + result.append((hwnd, buf.value)) + return False + + end = time.time() + timeout + while time.time() < end: + u32.EnumWindows(cb, 0) + if result: + return result[0] + time.sleep(0.3) + return None + + +def grab(hwnd, path): + r = wt.RECT() + u32.GetWindowRect(hwnd, ctypes.byref(r)) + w, h = r.right - r.left, r.bottom - r.top + if w <= 0 or h <= 0: + raise RuntimeError("window rect empty") + src = u32.GetWindowDC(hwnd) + mem = g32.CreateCompatibleDC(src) + bmp = g32.CreateCompatibleBitmap(src, w, h) + old = g32.SelectObject(mem, bmp) + ok = u32.PrintWindow(hwnd, mem, 2) # PW_RENDERFULLCONTENT + bi = BITMAPINFOHEADER() + bi.biSize = ctypes.sizeof(BITMAPINFOHEADER) + bi.biWidth, bi.biHeight = w, -h + bi.biPlanes, bi.biBitCount, bi.biCompression = 1, 32, 0 + buf = ctypes.create_string_buffer(w * h * 4) + lines = g32.GetDIBits(mem, bmp, 0, h, buf, ctypes.byref(bi), 0) + g32.SelectObject(mem, old) + g32.DeleteObject(bmp) + g32.DeleteDC(mem) + u32.ReleaseDC(hwnd, src) + rgba = bgra_to_rgba(buf.raw) + png(path, w, h, rgba) + return w, h, ok, lines, rgba + + +def crop_zoom(src_png_w, src_png_h, rgba, cw, ch, scale, path, ox=0, oy=0): + out_w, out_h = cw * scale, ch * scale + dst = bytearray(out_w * out_h * 4) + for y in range(out_h): + sy = oy + y // scale + for x in range(out_w): + sx = ox + x // scale + si = (sy * src_png_w + sx) * 4 + di = (y * out_w + x) * 4 + dst[di:di + 4] = rgba[si:si + 4] + png(path, out_w, out_h, dst) + + +def main(): + exe = sys.argv[1] + out = sys.argv[2] + icon_dump = sys.argv[3] if len(sys.argv) > 3 else None + + proc = subprocess.Popen([exe], cwd=os.path.dirname(exe)) + try: + got = find_window(proc.pid) + if not got: + print("FAIL: 没找到窗口(可能是启动崩溃),进程存活 =", proc.poll() is None) + if proc.poll() is None: + proc.terminate() + return 1 + hwnd, title = got + print("window title:", title) + + # WM_GETICON=0x7F, ICON_BIG=1 / ICON_SMALL=0 + big = u32.SendMessageW(hwnd, 0x7F, 1, 0) + small = u32.SendMessageW(hwnd, 0x7F, 0, 0) + print("WM_GETICON big=%#x small=%#x" % (big or 0, small or 0)) + + time.sleep(0.6) + w, h, ok, lines, pixels = grab(hwnd, out) + print("printed %dx%d ok=%s lines=%d -> %s" % (w, h, ok, lines, out)) + # 标题栏局部放大 4 倍,专门看图标 + crop_zoom(w, h, pixels, min(300, w), min(44, h), 4, + os.path.splitext(out)[0] + "_titlebar.png", 0, 0) + print("titlebar crop ->", os.path.splitext(out)[0] + "_titlebar.png") + + if icon_dump and big: + u32.DrawIconEx.argtypes = [wt.HDC, ctypes.c_int, ctypes.c_int, wt.HICON, + ctypes.c_int, ctypes.c_int, wt.UINT, + wt.HBRUSH, wt.UINT] + src = u32.GetDC(None) + mem = g32.CreateCompatibleDC(src) + size = 32 + bmp = g32.CreateCompatibleBitmap(src, size, size) + old = g32.SelectObject(mem, bmp) + # 白底,避免透明区域看不清 + brush = g32.CreateSolidBrush(0x00FFFFFF) + rect = wt.RECT(0, 0, size, size) + u32.FillRect(mem, ctypes.byref(rect), brush) + u32.DrawIconEx(mem, 0, 0, big, size, size, 0, None, 3) # DI_NORMAL + bi = BITMAPINFOHEADER() + bi.biSize = ctypes.sizeof(BITMAPINFOHEADER) + bi.biWidth, bi.biHeight = size, -size + bi.biPlanes, bi.biBitCount, bi.biCompression = 1, 32, 0 + buf2 = ctypes.create_string_buffer(size * size * 4) + g32.GetDIBits(mem, bmp, 0, size, buf2, ctypes.byref(bi), 0) + g32.SelectObject(mem, old) + g32.DeleteObject(bmp) + g32.DeleteObject(brush) + g32.DeleteDC(mem) + u32.ReleaseDC(None, src) + png(icon_dump, size, size, bgra_to_rgba(buf2.raw)) + print("icon dumped ->", icon_dump) + finally: + time.sleep(0.2) + if proc.poll() is None: + proc.terminate() + return 0 + + +if __name__ == "__main__": + sys.exit(main())