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())