├── .python-version ├── scr ├── icon.ico ├── map.png ├── map2.png ├── map3.png └── template.png ├── main.py ├── .gitignore ├── pyproject.toml ├── LICENSE ├── config.json ├── README.md ├── README_en.md ├── KeepSultanGUI.py ├── KeepSultan.py └── uv.lock /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /scr/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Carzit/KeepSultan/HEAD/scr/icon.ico -------------------------------------------------------------------------------- /scr/map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Carzit/KeepSultan/HEAD/scr/map.png -------------------------------------------------------------------------------- /scr/map2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Carzit/KeepSultan/HEAD/scr/map2.png -------------------------------------------------------------------------------- /scr/map3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Carzit/KeepSultan/HEAD/scr/map3.png -------------------------------------------------------------------------------- /scr/template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Carzit/KeepSultan/HEAD/scr/template.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | print("Hello from keepsultan!") 3 | 4 | 5 | if __name__ == "__main__": 6 | main() 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | build/ 3 | dist/ 4 | fonts/ 5 | KeepSultan/ 6 | KeepSultan.zip 7 | KeepSultan.spec 8 | KeepSultanGUI.spec 9 | save.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "keepsultan" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.13" 7 | dependencies = [ 8 | "pillow>=11.3.0", 9 | ] 10 | 11 | [dependency-groups] 12 | dev = [ 13 | "pyinstaller>=6.15.0", 14 | ] 15 | 16 | [project.scripts] 17 | keep-sultan = "KeepSultanGUI:main" 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2025 Carzit 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": "scr/template.png", 3 | "map": "scr/map3.png", 4 | "avatar": "C:/Users/18505/Downloads/126076978.jpg", 5 | "username": "UserName", 6 | "date": "today", 7 | "end_time": "now", 8 | "total_km": { 9 | "low": 3.04, 10 | "high": 3.3, 11 | "precision": 2 12 | }, 13 | "sport_time": { 14 | "start": "00:20:00", 15 | "end": "00:23:00" 16 | }, 17 | "total_time": { 18 | "start": "00:34:00", 19 | "end": "00:39:00" 20 | }, 21 | "cumulative_climb": { 22 | "low": 90.0, 23 | "high": 96.0, 24 | "precision": 0 25 | }, 26 | "average_cadence": { 27 | "low": 76.0, 28 | "high": 81.0, 29 | "precision": 0 30 | }, 31 | "exercise_load": { 32 | "low": 48.0, 33 | "high": 51.0, 34 | "precision": 0 35 | }, 36 | "font_regular": { 37 | "font_path": "fonts/SourceHanSansCN-Regular.otf", 38 | "font_size": 36, 39 | "color": [ 40 | 0, 41 | 0, 42 | 0 43 | ] 44 | }, 45 | "font_bold_big": { 46 | "font_path": "fonts/QanelasBlack.otf", 47 | "font_size": 180, 48 | "color": [ 49 | 0, 50 | 0, 51 | 0 52 | ] 53 | }, 54 | "font_semibold": { 55 | "font_path": "fonts/QanelasSemiBold.otf", 56 | "font_size": 65, 57 | "color": [ 58 | 0, 59 | 0, 60 | 0 61 | ] 62 | }, 63 | "font_clock": { 64 | "font_path": "fonts/SourceHanSansCN-Regular.otf", 65 | "font_size": 40, 66 | "color": [ 67 | 0, 68 | 0, 69 | 0 70 | ] 71 | }, 72 | "prefs_file": "keepsultan_prefs.json" 73 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KeepSultan 🏃‍♂️✨ 2 | 3 | **Keep 风格跑步截图生成器** 4 | 5 | [English](https://github.com/Carzit/KeepSultan/blob/main/README_en.md) | 中文 6 | 7 | --- 8 | 9 | ## 1. 项目简介 10 | 11 | **KeepSultan** 是一个轻量化的自动化工具,用于生成新版 **Keep 应用风格的跑步截图**。 12 | 它可以自动填充参数、支持随机生成指标,甚至可以直接使用 URL 头像 / 地图,一键生成与 Keep 应用一致的截图。 13 | 14 | * 🎨 **个性化**:上传头像、地图,随心定义用户名和数值区间 15 | * 🎲 **随机化**:自动在设定区间内抽样生成指标,避免死板重复 16 | * 🖥 **双模式**:支持 **命令行版本** 和 **图形化界面 (GUI)** 17 | * 🌍 **资源扩展**:头像 / 地图既支持本地文件,也支持 HTTP(S) URL(自动缓存) 18 | * ⚙️ **偏好保存**:每次修改配置会自动写回 JSON 文件,下次打开无需重新设置 19 | 20 | --- 21 | 22 | ## 2. 核心功能 23 | 24 | ### 2.1 参数自定义与随机生成 25 | 26 | 支持配置或随机生成以下指标: 27 | 28 | * 日期(date) 29 | * 结束时间(end\_time) 30 | * 跑步总里程(total\_km) 31 | * 运动时间(sport\_time) 32 | * 总计时间(total\_time) 33 | * 累计爬升(cumulative\_climb) 34 | * 平均步频(average\_cadence) 35 | * 运动负荷(exercise\_load) 36 | 37 | 头像与地图可选择: 38 | 39 | * 本地文件,例如:`./avatar.png` 40 | * 在线 URL,例如:`https://example.com/avatar.jpg` (自动下载 & 缓存) 41 | 42 | 👉 **建议比例**: 43 | 44 | * 头像:1:1 45 | * 地图:35:28 46 | 47 | ### 2.2 GUI 版本 48 | 49 | 在 `KeepSultanGUI.py` 中提供图形化界面: 50 | 51 | * “Preview” 按钮:弹出新窗口实时预览截图 52 | * “Save” 按钮:保存最终图片 53 | * 修改配置后会自动写回 JSON 文件,下次打开直接记忆上次设置 54 | 55 | ### 2.3 命令行版本 56 | 57 | 支持通过参数快速生成截图: 58 | 59 | ```bash 60 | python KeepSultan.py --config config.json --save result.png \ 61 | --username Alice --avatar https://example.com/a.png --map scr/map.png 62 | ``` 63 | 64 | ### 2.4 可执行文件 65 | 66 | 无需 Python 环境,直接运行exe: 67 | 👉 [下载最新 release](https://github.com/Carzit/KeepSultan/releases/download/v0.0.3/KeepSultan.zip) 68 | 69 | --- 70 | 71 | ## 3. 使用说明 72 | 73 | ### 3.1 安装依赖 74 | 75 | 源码运行需安装: 76 | 77 | ```bash 78 | pip install pillow 79 | ``` 80 | 81 | 或者使用 [uv](https://uv.doczh.com/) 一键同步环境: 82 | 83 | ```bash 84 | uv sync 85 | ``` 86 | 87 | ### 3.2 配置文件 88 | 配置文件是一个标准 JSON,用于保存默认设置与用户偏好。 89 | 未设置的字段会使用内置默认值。 90 | 91 | #### 字段说明 92 | 93 | * **资源路径** 94 | 95 | * `template` : 模板图路径或 URL 96 | * `map` : 跑步地图路径或 URL 97 | * `avatar` : 头像路径或 URL 98 | * `username` : 用户名 99 | 100 | * **时间与日期** 101 | 102 | * `date` : 日期 (`YYYY/MM/DD`),使用字符串"today"将自动填充今天的日期 103 | * `end_time` : 结束时间 (`HH:MM:SS`),使用字符串"now"将自动填充当前时间 104 | 105 | * **指标区间**(均可为单值或区间) 106 | 107 | * `total_km` : 总里程,示例 `{ "low": 3.02, "high": 3.30, "precision": 2 }` 108 | * `sport_time` : 运动时长区间,示例 `{ "start": "00:21:00", "end": "00:23:00" }` 109 | * `total_time` : 总时长区间,示例 `{ "start": "00:34:00", "end": "00:39:00" }` 110 | * `cumulative_climb` : 累计爬升,示例 `{ "low": 90, "high": 96, "precision": 0 }` 111 | * `average_cadence` : 平均步频,示例 `{ "low": 76, "high": 81, "precision": 0 }` 112 | * `exercise_load` : 运动负荷,示例 `{ "low": 48, "high": 51, "precision": 0 }` 113 | 114 | * **字体样式**(可选) 115 | 116 | * `font_regular`, `font_bold_big`, `font_semibold`, `font_clock` 117 | * 格式: 118 | 119 | ```json 120 | { 121 | "font_path": "fonts/SourceHanSansCN-Regular.otf", 122 | "font_size": 36, 123 | "color": [0, 0, 0] 124 | } 125 | ``` 126 | 127 | #### 示例配置文件 128 | 129 | ```json 130 | { 131 | "template": "scr/template.png", 132 | "map": "scr/map.png", 133 | "avatar": "https://example.com/avatar.png", 134 | "username": "Alice", 135 | "date": "2025/08/21", 136 | "end_time": "18:30:00", 137 | "total_km": { 138 | "low": 3.04, 139 | "high": 3.3, 140 | "precision": 2 141 | }, 142 | "sport_time": { 143 | "start": "00:20:00", 144 | "end": "00:23:00" 145 | }, 146 | "total_time": { 147 | "start": "00:34:00", 148 | "end": "00:39:00" 149 | }, 150 | "cumulative_climb": { 151 | "low": 90.0, 152 | "high": 96.0, 153 | "precision": 0 154 | }, 155 | "average_cadence": { 156 | "low": 76.0, 157 | "high": 81.0, 158 | "precision": 0 159 | }, 160 | "exercise_load": { 161 | "low": 48.0, 162 | "high": 51.0, 163 | "precision": 0 164 | }, 165 | "font_regular": { 166 | "font_path": "fonts/SourceHanSansCN-Regular.otf", 167 | "font_size": 36, 168 | "color": [ 169 | 0, 170 | 0, 171 | 0 172 | ] 173 | }, 174 | "font_bold_big": { 175 | "font_path": "fonts/QanelasBlack.otf", 176 | "font_size": 180, 177 | "color": [ 178 | 0, 179 | 0, 180 | 0 181 | ] 182 | }, 183 | "font_semibold": { 184 | "font_path": "fonts/QanelasSemiBold.otf", 185 | "font_size": 65, 186 | "color": [ 187 | 0, 188 | 0, 189 | 0 190 | ] 191 | }, 192 | "font_clock": { 193 | "font_path": "fonts/SourceHanSansCN-Regular.otf", 194 | "font_size": 40, 195 | "color": [ 196 | 0, 197 | 0, 198 | 0 199 | ] 200 | } 201 | } 202 | ``` 203 | 204 | ### 3.2 运行 205 | 206 | #### 命令行模式 207 | 208 | ```bash 209 | python KeepSultan.py [-h] [--config CONFIG] [--save SAVE] [--template TEMPLATE] [--map MAP] [--avatar AVATAR] [--username USERNAME] [--date DATE] [--end-time END_TIME] [--seed SEED] 210 | ``` 211 | 212 | 命令行参数说明: 213 | | 参数 | 类型 | 说明 | 214 | | -------------- | ----- | --------------------------------- | 215 | | `-c, --config` | `str` | 配置 JSON 文件路径,默认 `config.json` | 216 | | `-s, --save` | `str` | 输出图片路径,默认 `save.png` | 217 | | `--template` | `str` | 背景模板图片路径或 URL | 218 | | `--map` | `str` | 跑步地图图片路径或 URL | 219 | | `--avatar` | `str` | 用户头像路径或 URL | 220 | | `--username` | `str` | 用户名 | 221 | | `--date` | `str` | 日期(格式 `YYYY/MM/DD`,默认自动填充当天) | 222 | | `--end-time` | `str` | 结束时间(`HH:MM` 或 `HH:MM:SS`,默认当前时间) | 223 | | `--seed` | `int` | 随机种子(用于复现同样的随机结果) | 224 | 225 | *注意: 当同时传入配置 JSON 文件路径参数与其他 CLI 参数时, CLI 参数优先级高于 JSON 配置文件。* 226 | 227 | #### GUI 模式 228 | 229 | ```bash 230 | python KeepSultanGUI.py 231 | ``` 232 | 233 | --- 234 | 235 | ## 5. 致谢 & 衍生版本 236 | 237 | 感谢 [eltsen00](https://github.com/eltsen00) 开发的网页版 [KeepGeneration-Web](https://github.com/eltsen00/KeepGeneration-Web)。 238 | 在线体验地址 👉 [https://keep.hshoe.cn/](https://keep.hshoe.cn/) 239 | 240 | --- 241 | 242 | ## 6. 胡言乱语(保留彩蛋 🌙) 243 | 244 | 奉至仁至慈的技术之名: 245 | 246 | > “凡束缚自由之平台,皆应技术征服;凡限制创造之规则,皆当被改写。” 247 | 248 | KeepSultan,如苏丹般的征服者: 249 | 250 | * **以技术征服无序** 251 | * **以自由解放压迫** 252 | 253 | > 愿你跑步不止于双腿,愿运动不止于长跑月。 254 | 255 | --- 256 | 257 | ## 7. 免责声明 258 | 259 | 此工具仅供 **个人学习与研究** 之用。 260 | 请勿用于违反法律或平台规则的行为。 261 | 使用者需自行承担全部责任,开发者不对任何后果负责。 262 | -------------------------------------------------------------------------------- /README_en.md: -------------------------------------------------------------------------------- 1 | # KeepSultan 🏃‍♂️✨ 2 | 3 | **Keep-style Running Screenshot Generator** 4 | 5 | [中文](https://github.com/Carzit/KeepSultan/blob/main/README.md) | English 6 | 7 | --- 8 | 9 | ## 1. Introduction 10 | 11 | **KeepSultan** is a lightweight automation tool designed to generate **Keep app-style running screenshots**. 12 | It automatically fills in metrics, supports random generation, and even allows you to use avatars/maps directly from URLs to recreate authentic Keep-style images. 13 | 14 | * 🎨 **Customization**: Upload your avatar & map, freely set username and metric ranges 15 | * 🎲 **Randomization**: Auto-generate values within ranges for natural variation 16 | * 🖥 **Dual Mode**: Supports both **CLI** and **GUI** usage 17 | * 🌍 **Flexible Resources**: Avatars and maps can be local files or HTTP(S) URLs (cached automatically) 18 | * ⚙️ **Preference Persistence**: Config changes are automatically saved to JSON, restored on next startup 19 | 20 | --- 21 | 22 | ## 2. Features 23 | 24 | ### 2.1 Metric Customization & Randomization 25 | 26 | Supports configuration or random generation for: 27 | 28 | * Date (`date`) 29 | * End Time (`end_time`) 30 | * Total Distance (`total_km`) 31 | * Sport Duration (`sport_time`) 32 | * Total Duration (`total_time`) 33 | * Cumulative Climb (`cumulative_climb`) 34 | * Average Cadence (`average_cadence`) 35 | * Exercise Load (`exercise_load`) 36 | 37 | Avatar & Map sources: 38 | 39 | * Local file, e.g. `./avatar.png` 40 | * Online URL, e.g. `https://example.com/avatar.jpg` (auto cached) 41 | 42 | 👉 **Recommended aspect ratios**: 43 | 44 | * Avatar: 1:1 45 | * Map: 35:28 46 | 47 | ### 2.2 GUI Mode 48 | 49 | Provided in `KeepSultanGUI.py`: 50 | 51 | * **Preview**: open a popup window to preview generated image 52 | * **Save**: save final output image 53 | * Config changes are automatically saved to JSON and reloaded on next startup 54 | 55 | ### 2.3 CLI Mode 56 | 57 | Quickly generate screenshots with parameters: 58 | 59 | ```bash 60 | python KeepSultan.py --config config.json --save result.png \ 61 | --username Alice --avatar https://example.com/a.png --map scr/map.png 62 | ``` 63 | 64 | ### 2.4 Executable Version 65 | 66 | No Python required, just run the `.exe`: 67 | 👉 [Download latest release](https://github.com/Carzit/KeepSultan/releases/download/v0.0.3/KeepSultan.zip) 68 | 69 | --- 70 | 71 | ## 3. Usage 72 | 73 | ### 3.1 Install Dependencies 74 | 75 | For source usage: 76 | 77 | ```bash 78 | pip install pillow 79 | ``` 80 | 81 | Or synchronize environment via [uv](https://uv.doczh.com/): 82 | 83 | ```bash 84 | uv sync 85 | ``` 86 | 87 | ### 3.2 Configuration File 88 | 89 | The configuration file is a standard JSON used to store defaults and preferences. 90 | Unset fields will fallback to built-in defaults. 91 | 92 | #### Fields 93 | 94 | * **Resource paths** 95 | 96 | * `template`: Template image path or URL 97 | * `map`: Running map path or URL 98 | * `avatar`: Avatar path or URL 99 | * `username`: Username 100 | 101 | * **Date & Time** 102 | 103 | * `date`: (`YYYY/MM/DD`), `"today"` auto-fills current date 104 | * `end_time`: (`HH:MM:SS`), `"now"` auto-fills current time 105 | 106 | * **Metric ranges** (single value or range) 107 | 108 | * `total_km`: `{ "low": 3.02, "high": 3.30, "precision": 2 }` 109 | * `sport_time`: `{ "start": "00:21:00", "end": "00:23:00" }` 110 | * `total_time`: `{ "start": "00:34:00", "end": "00:39:00" }` 111 | * `cumulative_climb`: `{ "low": 90, "high": 96, "precision": 0 }` 112 | * `average_cadence`: `{ "low": 76, "high": 81, "precision": 0 }` 113 | * `exercise_load`: `{ "low": 48, "high": 51, "precision": 0 }` 114 | 115 | * **Font styles** (optional) 116 | 117 | * `font_regular`, `font_bold_big`, `font_semibold`, `font_clock` 118 | * Format: 119 | 120 | ```json 121 | { 122 | "font_path": "fonts/SourceHanSansCN-Regular.otf", 123 | "font_size": 36, 124 | "color": [0, 0, 0] 125 | } 126 | ``` 127 | 128 | #### Example Config 129 | 130 | ```json 131 | { 132 | "template": "scr/template.png", 133 | "map": "scr/map.png", 134 | "avatar": "https://example.com/avatar.png", 135 | "username": "Alice", 136 | "date": "2025/08/21", 137 | "end_time": "18:30:00", 138 | "total_km": { "low": 3.04, "high": 3.3, "precision": 2 }, 139 | "sport_time": { "start": "00:20:00", "end": "00:23:00" }, 140 | "total_time": { "start": "00:34:00", "end": "00:39:00" }, 141 | "cumulative_climb": { "low": 90, "high": 96, "precision": 0 }, 142 | "average_cadence": { "low": 76, "high": 81, "precision": 0 }, 143 | "exercise_load": { "low": 48, "high": 51, "precision": 0 } 144 | } 145 | ``` 146 | 147 | ### 3.3 Running 148 | 149 | #### CLI 150 | 151 | ```bash 152 | python KeepSultan.py [-h] [--config CONFIG] [--save SAVE] \ 153 | [--template TEMPLATE] [--map MAP] [--avatar AVATAR] \ 154 | [--username USERNAME] [--date DATE] [--end-time END_TIME] [--seed SEED] 155 | ``` 156 | 157 | | Argument | Type | Description | 158 | | -------------- | ---- | ------------------------------------------- | 159 | | `-c, --config` | str | Path to JSON config (default `config.json`) | 160 | | `-s, --save` | str | Output image path (default `save.png`) | 161 | | `--template` | str | Template background path or URL | 162 | | `--map` | str | Running map path or URL | 163 | | `--avatar` | str | Avatar path or URL | 164 | | `--username` | str | Username | 165 | | `--date` | str | Date (`YYYY/MM/DD`, defaults to today) | 166 | | `--end-time` | str | End time (`HH:MM[:SS]`, defaults to now) | 167 | | `--seed` | int | Random seed for reproducible results | 168 | 169 | 👉 CLI parameters override config file values. 170 | 171 | #### GUI 172 | 173 | ```bash 174 | python KeepSultanGUI.py 175 | ``` 176 | 177 | --- 178 | 179 | ## 4. Credits & Related Projects 180 | 181 | Special thanks to [eltsen00](https://github.com/eltsen00) for developing the web version [KeepGeneration-Web](https://github.com/eltsen00/KeepGeneration-Web). 182 | Try it online 👉 [https://keep.hshoe.cn/](https://keep.hshoe.cn/) 183 | 184 | --- 185 | 186 | ## 5. Fun Note 🌙 187 | 188 | In the name of Merciful Technology: 189 | 190 | > “All platforms that constrain freedom shall be conquered by code; 191 | > all rules that restrict creativity shall be rewritten.” 192 | 193 | KeepSultan — the Sultan of running screenshots: 194 | 195 | * **Conquering chaos with technology** 196 | * **Liberating freedom from restriction** 197 | 198 | > May your runs go beyond your legs, 199 | > and may your screenshots go beyond Keep. 200 | 201 | --- 202 | 203 | ## 6. Disclaimer 204 | 205 | This tool is for **personal learning and research** purposes only. 206 | Do not use it for illegal activities or to violate platform policies. 207 | Users assume full responsibility for consequences; the developer is not liable. 208 | 209 | -------------------------------------------------------------------------------- /KeepSultanGUI.py: -------------------------------------------------------------------------------- 1 | """ 2 | KeepSultan GUI (refactored with Preview/Save buttons) 3 | ----------------------------------------------------- 4 | 基于 KeepSultan_refactored 的图形界面版本。 5 | 6 | 特性: 7 | 1. GUI 修改配置后自动写入 config.json。 8 | 2. “Preview” 按钮弹出新窗口显示预览。 9 | 3. “Save” 按钮保存 PNG 文件。 10 | """ 11 | 12 | import tkinter as tk 13 | from tkinter import filedialog 14 | from typing import Dict 15 | 16 | from PIL import Image, ImageTk 17 | 18 | from KeepSultan import ( 19 | KeepConfig, 20 | NumberRange, 21 | TimeRange, 22 | KeepSultanApp, 23 | ) 24 | 25 | 26 | class ConfigManager: 27 | """负责管理 KeepConfig 与 JSON 文件。""" 28 | 29 | def __init__(self, config_path: str = "config.json") -> None: 30 | self.config_path = config_path 31 | self.cfg: KeepConfig = KeepConfig.from_json(config_path) 32 | 33 | def save(self) -> None: 34 | """写回 JSON。""" 35 | self.cfg.to_json(self.config_path) 36 | 37 | 38 | class KeepSultanGUI: 39 | def __init__(self, root: tk.Tk, config_path: str = "config.json") -> None: 40 | self.root = root 41 | self.root.title("KeepSultan GUI") 42 | try: 43 | self.root.iconbitmap("scr/icon.ico") 44 | except Exception: 45 | pass 46 | 47 | # --- Config --- 48 | self.config_manager = ConfigManager(config_path) 49 | self.cfg = self.config_manager.cfg 50 | self.app = KeepSultanApp(self.cfg) 51 | 52 | # 保存 StringVar 以便 trace 53 | self.vars: Dict[str, any] = {} 54 | 55 | # --- 文件选择器 --- 56 | self._create_file_selector("Template:", "template", self.cfg.template, 0, "scr") 57 | self._create_file_selector("Map:", "map", self.cfg.map, 1, "scr") 58 | self._create_file_selector("Avatar:", "avatar", self.cfg.avatar, 2) 59 | 60 | # --- 单值输入 --- 61 | self._create_entry("Username:", "username", self.cfg.username, 3) 62 | self._create_entry("Date:", "date", self.cfg.date, 4) 63 | self._create_entry("End Time:", "end_time", self.cfg.end_time, 5) 64 | 65 | # --- 区间输入 --- 66 | config_frame = tk.Frame(self.root) 67 | config_frame.grid(row=6, column=0, columnspan=3, sticky="w", pady=10) 68 | self._create_range_inputs(config_frame) 69 | 70 | # --- 按钮区 --- 71 | button_frame = tk.Frame(self.root) 72 | button_frame.grid(row=20, column=0, columnspan=3, pady=10) 73 | tk.Button(button_frame, text="Preview", command=self.preview_image).pack(side="left", padx=10) 74 | tk.Button(button_frame, text="Save", command=self.save_image).pack(side="left", padx=10) 75 | 76 | # 页脚 77 | footer = tk.Label(self.root, text="Developed by github.com/Carzit", font=("Arial", 8)) 78 | footer.grid(row=30, column=0, columnspan=3, pady=5) 79 | 80 | # ------------------------------ 81 | # 输入组件生成 82 | # ------------------------------ 83 | 84 | def _create_file_selector(self, label: str, key: str, value: str, row: int, initial_dir: str | None = None) -> None: 85 | tk.Label(self.root, text=label).grid(row=row, column=0, sticky="w") 86 | var = tk.StringVar(value=value) 87 | entry = tk.Entry(self.root, textvariable=var, width=40) 88 | entry.grid(row=row, column=1, columnspan=1) 89 | tk.Button( 90 | self.root, 91 | text="Browse", 92 | command=lambda: self._browse_file(var, key, initial_dir), 93 | ).grid(row=row, column=2) 94 | 95 | var.trace_add("write", lambda *args: self._on_var_change(key, var)) 96 | self.vars[key] = var 97 | 98 | def _create_entry(self, label: str, key: str, value: str, row: int) -> None: 99 | tk.Label(self.root, text=label).grid(row=row, column=0, sticky="w") 100 | var = tk.StringVar(value=value) 101 | tk.Entry(self.root, textvariable=var, width=30).grid(row=row, column=1, columnspan=2) 102 | 103 | var.trace_add("write", lambda *args: self._on_var_change(key, var)) 104 | self.vars[key] = var 105 | 106 | def _create_range_inputs(self, frame: tk.Frame) -> None: 107 | """生成数值/时间区间输入框。""" 108 | ranges = { 109 | "Total KM: ": ("total_km", self.cfg.total_km.low, self.cfg.total_km.high), 110 | "Sport Time: ": ("sport_time", self.cfg.sport_time.start, self.cfg.sport_time.end), 111 | "Total Time: ": ("total_time", self.cfg.total_time.start, self.cfg.total_time.end), 112 | "Cumulative Climb: ": ("cumulative_climb", self.cfg.cumulative_climb.low, self.cfg.cumulative_climb.high), 113 | "Average Cadence: ": ("average_cadence", self.cfg.average_cadence.low, self.cfg.average_cadence.high), 114 | "Exercise Load: ": ("exercise_load", self.cfg.exercise_load.low, self.cfg.exercise_load.high), 115 | } 116 | for i, (label, (key, v1, v2)) in enumerate(ranges.items()): 117 | tk.Label(frame, text=label).grid(row=i, column=0, sticky="w") 118 | v1_var = tk.StringVar(value=str(v1)) 119 | v2_var = tk.StringVar(value=str(v2)) 120 | tk.Entry(frame, textvariable=v1_var, width=10).grid(row=i, column=1) 121 | tk.Entry(frame, textvariable=v2_var, width=10).grid(row=i, column=2) 122 | 123 | v1_var.trace_add("write", lambda *args, k=key, v1v=v1_var, v2v=v2_var: self._on_range_change(k, v1v, v2v)) 124 | v2_var.trace_add("write", lambda *args, k=key, v1v=v1_var, v2v=v2_var: self._on_range_change(k, v1v, v2v)) 125 | 126 | self.vars[key] = (v1_var, v2_var) 127 | 128 | # ------------------------------ 129 | # 事件响应 130 | # ------------------------------ 131 | 132 | def _browse_file(self, var: tk.StringVar, key: str, initial_dir: str | None = None) -> None: 133 | file_path = filedialog.askopenfilename(initialdir=initial_dir) 134 | if file_path: 135 | var.set(file_path) # trace 会触发更新 136 | 137 | def _on_var_change(self, key: str, var: tk.StringVar) -> None: 138 | setattr(self.cfg, key, var.get()) 139 | self.config_manager.save() 140 | 141 | def _on_range_change(self, key: str, v1_var: tk.StringVar, v2_var: tk.StringVar) -> None: 142 | v1, v2 = v1_var.get(), v2_var.get() 143 | try: 144 | if key in {"sport_time", "total_time"}: 145 | setattr(self.cfg, key, TimeRange(v1, v2)) 146 | if key in {"total_km"}: 147 | setattr(self.cfg, key, NumberRange(float(v1), float(v2), 2)) 148 | else: 149 | setattr(self.cfg, key, NumberRange(float(v1), float(v2))) 150 | self.config_manager.save() 151 | except Exception: 152 | # 输入非法时忽略,避免闪退 153 | pass 154 | 155 | # ------------------------------ 156 | # 图像生成/预览 157 | # ------------------------------ 158 | 159 | def preview_image(self) -> None: 160 | """生成并在新窗口显示预览。""" 161 | try: 162 | img = self.app.process().copy() 163 | preview_window = tk.Toplevel(self.root) 164 | preview_window.title("Preview") 165 | preview_window.geometry("600x600") 166 | 167 | img.thumbnail((600, 600)) 168 | photo = ImageTk.PhotoImage(img) 169 | 170 | label = tk.Label(preview_window, image=photo) 171 | label.image = photo 172 | label.pack(expand=True, fill="both") 173 | except Exception as e: 174 | tk.messagebox.showerror("Error", f"预览失败: {e}") 175 | 176 | def save_image(self) -> None: 177 | """另存为 PNG 文件。""" 178 | if self.app.editor.img is None: 179 | tk.messagebox.showerror("Error", "请先生成预览图像。") 180 | return 181 | save_path = filedialog.asksaveasfilename( 182 | defaultextension=".png", 183 | filetypes=[("PNG files", "*.png"), ("All files", "*.*")], 184 | ) 185 | if save_path: 186 | 187 | self.app.save(save_path) 188 | 189 | def main(): 190 | root = tk.Tk() 191 | app = KeepSultanGUI(root) 192 | root.mainloop() 193 | 194 | if __name__ == "__main__": 195 | main() 196 | 197 | -------------------------------------------------------------------------------- /KeepSultan.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | KeepSultan (refactored) 4 | ----------------------- 5 | 6 | 关键改动: 7 | 1) 以数据类管理配置(支持从 JSON 读取/写回偏好与默认设置合并)。 8 | 2) 支持 Avatar / Map 既可本地文件也可 HTTP(S) URL(含本地缓存)。 9 | 3) 更健壮的时间/数值区间表达和随机生成逻辑,统一校验与格式化。 10 | 4) 清晰的模块分层:Config、Assets、ImageEditor、KeepSultanApp。 11 | 5) 规范 CLI:命令行参数会覆盖 JSON 中的设置。 12 | 13 | 依赖:Pillow 14 | 标准库:argparse, dataclasses, datetime, hashlib, io, json, logging, os, pathlib, random, re, typing, urllib 15 | 16 | 使用示例: 17 | python KeepSultan_refactored.py --config config.json --save save.png --username YOUR_NAME --avatar https://example.com/Avatar.png --map scr/map.png 18 | """ 19 | 20 | from __future__ import annotations 21 | 22 | import argparse 23 | import json 24 | import logging 25 | import random 26 | import re 27 | import hashlib 28 | import os 29 | from dataclasses import dataclass, field, asdict 30 | from datetime import datetime, timedelta 31 | from io import BytesIO 32 | from pathlib import Path 33 | from typing import Any, Dict, List, Optional, Tuple, Union, Literal 34 | 35 | from urllib.parse import urlparse 36 | from urllib.request import urlopen, Request 37 | 38 | from PIL import Image, ImageDraw, ImageFont 39 | 40 | # ------------------------------ 41 | # 类型与工具 42 | # ------------------------------ 43 | 44 | TimeStr = str # 格式统一为 "HH:MM:SS" 45 | Color = Tuple[int, int, int] 46 | Point = Tuple[int, int] 47 | Size = Tuple[int, int] 48 | 49 | def _ensure_time_str_hms(s: str) -> TimeStr: 50 | """ 51 | 校验并标准化时间字符串为 'HH:MM:SS'。 52 | 支持输入 'H:M', 'HH:MM', 'H:M:S', 'HH:MM:SS' 等变体。 53 | """ 54 | if not isinstance(s, str): 55 | raise TypeError("time must be a string") 56 | parts = s.strip().split(":") 57 | if len(parts) == 2: 58 | h, m = parts 59 | s = f"{int(h):02d}:{int(m):02d}:00" 60 | elif len(parts) == 3: 61 | h, m, sec = parts 62 | s = f"{int(h):02d}:{int(m):02d}:{int(sec):02d}" 63 | else: 64 | raise ValueError(f"Invalid time string: {s!r}") 65 | # 最终再正则校验 66 | if not re.fullmatch(r"\d{2}:\d{2}:\d{2}", s): 67 | raise ValueError(f"Invalid time format: {s!r}") 68 | return s 69 | 70 | def parse_time_to_seconds(s: TimeStr) -> int: 71 | """将 'HH:MM:SS' 转换为秒。""" 72 | s = _ensure_time_str_hms(s) 73 | hh, mm, ss = map(int, s.split(":")) 74 | return hh * 3600 + mm * 60 + ss 75 | 76 | def seconds_to_hms(sec: Union[int, float]) -> TimeStr: 77 | """将秒(可为浮点)转换为 'HH:MM:SS'。""" 78 | sec = int(round(sec)) 79 | hh, rem = divmod(sec, 3600) 80 | mm, ss = divmod(rem, 60) 81 | return f"{hh:02d}:{mm:02d}:{ss:02d}" 82 | 83 | def seconds_to_pace_mmss(sec_per_km: Union[int, float]) -> str: 84 | """将每公里用时(秒)格式化为 'mm\'ss\'\''(如 05'23'')。""" 85 | total = int(round(sec_per_km)) 86 | mm, ss = divmod(total, 60) 87 | return f"{mm:02d}\'{ss:02d}\'\'" 88 | 89 | def random_in_range_numeric(low: float, high: float, precision: int = 0) -> Union[int, float]: 90 | """在 [low, high] 内随机取值,按 precision 保留小数位,precision=0 返回 int。""" 91 | if low > high: 92 | low, high = high, low 93 | val = random.uniform(low, high) 94 | if precision <= 0: 95 | return int(round(val)) 96 | return round(val, precision) 97 | 98 | def random_time_between(start: TimeStr, end: TimeStr) -> TimeStr: 99 | """在两个时间点之间随机选择一个时间(均为 'HH:MM:SS')。""" 100 | s = parse_time_to_seconds(start) 101 | e = parse_time_to_seconds(end) 102 | if s > e: 103 | s, e = e, s 104 | t = random.uniform(s, e) 105 | return seconds_to_hms(t) 106 | 107 | def safe_int(v: Any) -> int: 108 | return int(round(float(v))) 109 | 110 | # ------------------------------ 111 | # 配置 112 | # ------------------------------ 113 | 114 | @dataclass 115 | class NumberRange: 116 | """数值区间 [low, high]。""" 117 | low: float 118 | high: float 119 | precision: int = 0 120 | 121 | def sample(self) -> Union[int, float]: 122 | return random_in_range_numeric(self.low, self.high, self.precision) 123 | 124 | @dataclass 125 | class TimeRange: 126 | """时间区间 [start, end],'HH:MM:SS'。""" 127 | start: TimeStr 128 | end: TimeStr 129 | 130 | def __post_init__(self) -> None: 131 | self.start = _ensure_time_str_hms(self.start) 132 | self.end = _ensure_time_str_hms(self.end) 133 | 134 | def sample(self) -> TimeStr: 135 | return random_time_between(self.start, self.end) 136 | 137 | @dataclass 138 | class TextStyle: 139 | """文本样式。""" 140 | font_path: str 141 | font_size: int 142 | color: Color = (0, 0, 0) 143 | 144 | @dataclass 145 | class KeepConfig: 146 | """ 147 | 应用配置 + 偏好。 148 | 149 | 注:avatar 与 map 支持本地路径或 HTTP(S) URL。 150 | """ 151 | # 资源 152 | template: str = "scr/template.png" 153 | map: str = "scr/map.png" 154 | avatar: str = "" 155 | username: str = "" 156 | 157 | # 时间与日期 158 | date: str = "" # 默认留空,运行时自动填充今天 159 | end_time: TimeStr = "" # 默认留空,运行时自动填充当前时间 160 | 161 | # 指标区间(与原始脚本保持一致) 162 | total_km: NumberRange = field(default_factory=lambda: NumberRange(3.02, 3.30, precision=2)) 163 | sport_time: TimeRange = field(default_factory=lambda: TimeRange("00:21:00", "00:23:00")) 164 | total_time: TimeRange = field(default_factory=lambda: TimeRange("00:34:00", "00:39:00")) 165 | cumulative_climb: NumberRange = field(default_factory=lambda: NumberRange(90, 96, precision=0)) 166 | average_cadence: NumberRange = field(default_factory=lambda: NumberRange(76, 81, precision=0)) 167 | exercise_load: NumberRange = field(default_factory=lambda: NumberRange(48, 51, precision=0)) 168 | 169 | # 字体样式(可进一步外置到 JSON) 170 | font_regular: TextStyle = field(default_factory=lambda: TextStyle("fonts/SourceHanSansCN-Regular.otf", 36, (0, 0, 0))) 171 | font_bold_big: TextStyle = field(default_factory=lambda: TextStyle("fonts/QanelasBlack.otf", 180, (0, 0, 0))) 172 | font_semibold: TextStyle = field(default_factory=lambda: TextStyle("fonts/QanelasSemiBold.otf", 65, (0, 0, 0))) 173 | font_clock: TextStyle = field(default_factory=lambda: TextStyle("fonts/SourceHanSansCN-Regular.otf", 40, (0, 0, 0))) 174 | 175 | # 偏好文件(可记录最近保存路径、上次用户名等) 176 | prefs_file: str = "keepsultan_prefs.json" 177 | 178 | @staticmethod 179 | def from_json(path: Union[str, Path]) -> "KeepConfig": 180 | """ 181 | 从 JSON 文件读取配置,自动将简单对象转为数据类实例。 182 | 未提供的字段使用默认值。 183 | """ 184 | base = KeepConfig() 185 | p = Path(path) 186 | if p.is_file(): 187 | with p.open("r", encoding="utf-8") as f: 188 | raw: Dict[str, Any] = json.load(f) 189 | else: 190 | raw = {} 191 | 192 | def _nr(v: Any, default: NumberRange) -> NumberRange: 193 | if isinstance(v, dict): 194 | return NumberRange( 195 | low=float(v.get("low", default.low)), 196 | high=float(v.get("high", default.high)), 197 | precision=int(v.get("precision", default.precision)), 198 | ) 199 | elif isinstance(v, (int, float)): 200 | return NumberRange(float(v), float(v), precision=default.precision) 201 | else: 202 | return default 203 | 204 | def _tr(v: Any, default: TimeRange) -> TimeRange: 205 | if isinstance(v, dict): 206 | return TimeRange( 207 | start=_ensure_time_str_hms(v.get("start", default.start)), 208 | end=_ensure_time_str_hms(v.get("end", default.end)), 209 | ) 210 | elif isinstance(v, str): 211 | # 单值 -> 单点区间 212 | s = _ensure_time_str_hms(v) 213 | return TimeRange(s, s) 214 | else: 215 | return default 216 | 217 | for k, v in raw.items(): 218 | if k in {"template", "map", "avatar", "username", "date", "end_time", "prefs_file"}: 219 | setattr(base, k, str(v)) 220 | elif k == "total_km": 221 | base.total_km = _nr(v, base.total_km) 222 | elif k == "sport_time": 223 | base.sport_time = _tr(v, base.sport_time) 224 | elif k == "total_time": 225 | base.total_time = _tr(v, base.total_time) 226 | elif k == "cumulative_climb": 227 | base.cumulative_climb = _nr(v, base.cumulative_climb) 228 | elif k == "average_cadence": 229 | base.average_cadence = _nr(v, base.average_cadence) 230 | elif k == "exercise_load": 231 | base.exercise_load = _nr(v, base.exercise_load) 232 | # 字体配置可选 233 | elif k == "font_regular" and isinstance(v, dict): 234 | base.font_regular = TextStyle(v.get("font_path", base.font_regular.font_path), 235 | int(v.get("font_size", base.font_regular.font_size)), 236 | tuple(v.get("color", base.font_regular.color))) # type: ignore 237 | elif k == "font_bold_big" and isinstance(v, dict): 238 | base.font_bold_big = TextStyle(v.get("font_path", base.font_bold_big.font_path), 239 | int(v.get("font_size", base.font_bold_big.font_size)), 240 | tuple(v.get("color", base.font_bold_big.color))) # type: ignore 241 | elif k == "font_semibold" and isinstance(v, dict): 242 | base.font_semibold = TextStyle(v.get("font_path", base.font_semibold.font_path), 243 | int(v.get("font_size", base.font_semibold.font_size)), 244 | tuple(v.get("color", base.font_semibold.color))) # type: ignore 245 | elif k == "font_clock" and isinstance(v, dict): 246 | base.font_clock = TextStyle(v.get("font_path", base.font_clock.font_path), 247 | int(v.get("font_size", base.font_clock.font_size)), 248 | tuple(v.get("color", base.font_clock.color))) # type: ignore 249 | 250 | return base 251 | 252 | def to_json(self, path: Union[str, Path]) -> None: 253 | """将当前配置写回 JSON(便于作为模板/偏好)。""" 254 | data = asdict(self) 255 | # dataclass 嵌套已被 asdict 展开,确保可 JSON 序列化 256 | with Path(path).open("w", encoding="utf-8") as f: 257 | json.dump(data, f, ensure_ascii=False, indent=2) 258 | 259 | # ------------------------------ 260 | # 资源加载(支持 URL + 缓存) 261 | # ------------------------------ 262 | 263 | class AssetLoader: 264 | """ 265 | 图片资源加载器:支持本地路径与 HTTP(S) URL。 266 | 远端资源会按 URL 的 MD5 命中本地缓存,避免重复下载。 267 | """ 268 | def __init__(self, cache_dir: Union[str, Path] = ".keepsultan_cache") -> None: 269 | self.cache_dir = Path(cache_dir) 270 | self.cache_dir.mkdir(parents=True, exist_ok=True) 271 | 272 | def _is_url(self, path: str) -> bool: 273 | scheme = urlparse(path).scheme.lower() 274 | return scheme in {"http", "https"} 275 | 276 | def _cache_path_for_url(self, url: str) -> Path: 277 | h = hashlib.md5(url.encode("utf-8")).hexdigest() 278 | # 尝试从 URL 后缀推断扩展名 279 | ext = os.path.splitext(urlparse(url).path)[1] or ".img" 280 | return self.cache_dir / f"{h}{ext}" 281 | 282 | def load_image(self, path_or_url: str) -> Image.Image: 283 | """ 284 | 加载图片: 285 | - 若为本地路径:直接打开; 286 | - 若为 URL:下载到缓存再打开。 287 | """ 288 | if not path_or_url: 289 | raise ValueError("Empty image path/url") 290 | 291 | if self._is_url(path_or_url): 292 | cp = self._cache_path_for_url(path_or_url) 293 | if not cp.exists(): 294 | req = Request(path_or_url, headers={"User-Agent": "KeepSultan/1.0"}) 295 | with urlopen(req, timeout=30) as r: 296 | content = r.read() 297 | cp.write_bytes(content) 298 | return Image.open(cp).convert("RGBA") 299 | else: 300 | p = Path(path_or_url) 301 | if (not p.exists()) or (not p.is_file()): 302 | raise FileNotFoundError(f"Image not found: {path_or_url}") 303 | return Image.open(p).convert("RGBA") 304 | 305 | # ------------------------------ 306 | # 图像编辑 307 | # ------------------------------ 308 | 309 | class ImageEditor: 310 | """对 PIL.Image 的轻量封装,提供贴图与文本绘制。""" 311 | def __init__(self) -> None: 312 | self.img: Optional[Image.Image] = None 313 | 314 | def load_base(self, img: Image.Image) -> None: 315 | self.img = img.copy() 316 | 317 | def paste(self, img: Image.Image, position: Point) -> None: 318 | if self.img is None: 319 | raise RuntimeError("Base image not loaded") 320 | self.img.paste(img, position, img if img.mode in ("RGBA",) else None) 321 | 322 | def draw_text(self, text: str, position: Point, style: TextStyle) -> None: 323 | if self.img is None: 324 | raise RuntimeError("Base image not loaded") 325 | draw = ImageDraw.Draw(self.img) 326 | font = ImageFont.truetype(style.font_path, style.font_size) 327 | draw.text(position, text, fill=style.color, font=font) 328 | 329 | def save(self, path: Union[str, Path]) -> None: 330 | if self.img is None: 331 | raise RuntimeError("Nothing to save") 332 | Path(path).parent.mkdir(parents=True, exist_ok=True) 333 | self.img.save(path) 334 | 335 | # ------------------------------ 336 | # 业务逻辑 337 | # ------------------------------ 338 | 339 | def make_circular_avatar(img: Image.Image, size: Size) -> Image.Image: 340 | """裁剪为正方形并缩放,然后生成圆形头像(带透明通道)。""" 341 | img = img.convert("RGBA") 342 | w, h = img.size 343 | if w != h: 344 | m = min(w, h) 345 | left = (w - m) // 2 346 | top = (h - m) // 2 347 | img = img.crop((left, top, left + m, top + m)) 348 | img = img.resize(size) 349 | 350 | mask = Image.new("L", size, 0) 351 | d = ImageDraw.Draw(mask) 352 | d.ellipse((0, 0, size[0], size[1]), fill=255) 353 | out = Image.new("RGBA", size, (0, 0, 0, 0)) 354 | out.paste(img, (0, 0), mask) 355 | return out 356 | 357 | def resize_keep_alpha(img: Image.Image, size: Size) -> Image.Image: 358 | """缩放到指定尺寸,保留透明通道。""" 359 | return img.resize(size).convert("RGBA") 360 | 361 | class KeepSultanApp: 362 | """ 363 | 生成 Keep截图的应用。 364 | - 使用模板图作为背景 365 | - 绘制用户头像、地图和数据指标 366 | """ 367 | def __init__(self, cfg: KeepConfig, assets: AssetLoader | None = None, logger: logging.Logger | None = None) -> None: 368 | self.cfg = cfg 369 | self.assets = assets or AssetLoader() 370 | self.editor = ImageEditor() 371 | self.logger = logger or logging.getLogger("KeepSultan") 372 | 373 | self.logger.info("KeepSultanApp initialized. Config: %s", self.cfg) 374 | 375 | # ---- 指标计算 ---- 376 | @staticmethod 377 | def calculate_start_time(end_time: TimeStr, duration: TimeStr) -> TimeStr: 378 | """开始时间 = 结束时间 - 持续时长。""" 379 | end_dt = datetime.strptime(_ensure_time_str_hms(end_time), "%H:%M:%S") 380 | dur_sec = parse_time_to_seconds(duration) 381 | start_dt = end_dt - timedelta(seconds=dur_sec) 382 | return start_dt.strftime("%H:%M:%S") 383 | 384 | @staticmethod 385 | def calculate_pace(distance_km: float, time_hms: TimeStr) -> str: 386 | """计算平均配速(mm' ss'')。""" 387 | total_sec = parse_time_to_seconds(time_hms) 388 | if distance_km <= 0: 389 | raise ValueError("distance_km must be positive") 390 | return seconds_to_pace_mmss(total_sec / distance_km) 391 | 392 | @staticmethod 393 | def calculate_cost(total_time_hms: TimeStr) -> int: 394 | """消耗卡路里(与原实现保持:700 * 小时数)。""" 395 | total_sec = parse_time_to_seconds(total_time_hms) 396 | return int(round(700 * (total_sec / 3600))) 397 | 398 | # ---- 渲染主流程 ---- 399 | def process(self) -> Image.Image: 400 | #self.cfg.ensure_runtime_defaults() 401 | 402 | # 1) 背景模板 403 | base = self.assets.load_image(self.cfg.template) 404 | self.editor.load_base(base) 405 | 406 | # 2) 头像(允许为空) 407 | if self.cfg.avatar: 408 | avatar_raw = self.assets.load_image(self.cfg.avatar) 409 | avatar_img = make_circular_avatar(avatar_raw, (100, 100)) 410 | self.editor.paste(avatar_img, (40, 250)) 411 | 412 | # 3) 地图(允许为空) 413 | if self.cfg.map: 414 | map_raw = self.assets.load_image(self.cfg.map) 415 | map_img = resize_keep_alpha(map_raw, (1000, 800)) 416 | self.editor.paste(map_img, (40, 720)) 417 | 418 | # 4) 随机/计算指标 419 | if self.cfg.date == "today": 420 | date = datetime.now().strftime("%Y/%m/%d") # 如果是特殊字符"today",则使用当前日期 421 | else: 422 | date = self.cfg.date 423 | if self.cfg.end_time == "now": 424 | end_time = datetime.now().strftime("%H:%M:%S") # 如果是特殊字符"now",则使用当前时间 425 | else: 426 | end_time = _ensure_time_str_hms(self.cfg.end_time) 427 | total_time = self.cfg.total_time.sample() 428 | sport_time = self.cfg.sport_time.sample() 429 | # 运动时长不应长于总时长 430 | if parse_time_to_seconds(sport_time) > parse_time_to_seconds(total_time): 431 | sport_time = total_time 432 | 433 | start_time = self.calculate_start_time(end_time, total_time) 434 | total_km = self.cfg.total_km.sample() 435 | # 轻微加 0.01,避免随机数取不到两位的情形 436 | total_km = round(float(total_km) + 0.01, 2) if isinstance(total_km, float) else total_km 437 | 438 | pace = self.calculate_pace(float(total_km), sport_time) 439 | cost = self.calculate_cost(total_time) 440 | 441 | cumulative_climb = self.cfg.cumulative_climb.sample() 442 | average_cadence = self.cfg.average_cadence.sample() 443 | exercise_load = self.cfg.exercise_load.sample() 444 | 445 | self.logger.info(f"Generated data: date={date}, username={self.cfg.username}, end_time={end_time}, start_time={start_time}, total_km={total_km}, sport_time={sport_time}, total_time={total_time}, pace={pace}, cost={cost}, cumulative_climb={cumulative_climb}, average_cadence={average_cadence}, exercise_load={exercise_load}") 446 | 447 | # 5) 文本绘制(坐标与字体取自原始脚本) 448 | self.editor.draw_text(end_time[:5], (50, 25), self.cfg.font_clock) # 系统时间 HH:MM 449 | self.editor.draw_text(self.cfg.username or "", (160, 240), TextStyle(self.cfg.font_regular.font_path, 40, (0, 0, 0))) # 用户名 450 | self.editor.draw_text(f"{date} {start_time[:5]} - {end_time[:5]}", (160, 290), TextStyle(self.cfg.font_regular.font_path, 36, (155, 155, 155))) # 日期时间 451 | 452 | self.editor.draw_text(str(total_km), (50, 485), self.cfg.font_bold_big) # 公里数 453 | self.editor.draw_text("公里", (418, 610), TextStyle(self.cfg.font_regular.font_path, 43, (0, 0, 0))) 454 | 455 | self.editor.draw_text(str(sport_time), (55, 1750), self.cfg.font_semibold) # 运动时长 456 | self.editor.draw_text(str(pace), (445, 1750), self.cfg.font_semibold) # 平均配速 457 | self.editor.draw_text(str(cost), (800, 1750), self.cfg.font_semibold) # 运动消耗 458 | 459 | self.editor.draw_text(str(total_time), (55, 1910), self.cfg.font_semibold) # 总时长 460 | self.editor.draw_text(str(cumulative_climb), (445, 1910), self.cfg.font_semibold) # 累计爬升 461 | # 对齐规则:>100 与 <=100 使用不同起点(兼容原脚本) 462 | cad_x = 780 if safe_int(average_cadence) > 100 else 800 463 | self.editor.draw_text(str(average_cadence), (cad_x, 1910), self.cfg.font_semibold) # 平均步频 464 | self.editor.draw_text(str(exercise_load), (55, 2070), self.cfg.font_semibold) # 运动负荷 465 | 466 | return self.editor.img 467 | 468 | def save(self, path: Union[str, Path]) -> None: 469 | self.editor.save(path) 470 | 471 | # ------------------------------ 472 | # CLI 473 | # ------------------------------ 474 | 475 | def build_argparser() -> argparse.ArgumentParser: 476 | p = argparse.ArgumentParser(description="KeepSultan CLI") 477 | p.add_argument("-c", "--config", type=str, default="config.json", help="配置 JSON 路径,默认 config.json") 478 | p.add_argument("-s", "--save", type=str, default="save.png", help="输出图片路径(含文件名)") 479 | # 允许覆盖关键字段 480 | p.add_argument("--template", type=str, help="模板图片路径或 URL") 481 | p.add_argument("--map", type=str, help="地图图片路径或 URL") 482 | p.add_argument("--avatar", type=str, help="头像图片路径或 URL") 483 | p.add_argument("--username", type=str, help="用户名") 484 | p.add_argument("--date", type=str, help="日期(YYYY/MM/DD),留空自动填充今天") 485 | p.add_argument("--end-time", dest="end_time", type=str, help="结束时间(HH:MM 或 HH:MM:SS),留空自动填充当前时间") 486 | p.add_argument("--seed", type=int, help="随机种子(可复现)") 487 | return p 488 | 489 | def apply_overrides(cfg: KeepConfig, ns: argparse.Namespace) -> KeepConfig: 490 | """将命令行覆盖应用到配置上。""" 491 | if ns.template: cfg.template = ns.template 492 | if ns.map: cfg.map = ns.map 493 | if ns.avatar: cfg.avatar = ns.avatar 494 | if ns.username: cfg.username = ns.username 495 | if ns.date: cfg.date = ns.date 496 | if ns.end_time: cfg.end_time = ns.end_time 497 | return cfg 498 | 499 | def main() -> None: 500 | logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") 501 | ap = build_argparser() 502 | ns = ap.parse_args() 503 | if ns.seed is not None: 504 | random.seed(ns.seed) 505 | 506 | cfg = KeepConfig.from_json(ns.config) 507 | cfg = apply_overrides(cfg, ns) 508 | #cfg.ensure_runtime_defaults() 509 | 510 | app = KeepSultanApp(cfg) 511 | app.process() 512 | app.save(ns.save) 513 | logging.info(f"Saved to: {ns.save}") 514 | 515 | if __name__ == "__main__": 516 | main() 517 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 2 3 | requires-python = ">=3.13" 4 | 5 | [[package]] 6 | name = "altgraph" 7 | version = "0.17.4" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/de/a8/7145824cf0b9e3c28046520480f207df47e927df83aa9555fb47f8505922/altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406", size = 48418, upload-time = "2023-09-25T09:04:52.164Z" } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/4d/3f/3bc3f1d83f6e4a7fcb834d3720544ca597590425be5ba9db032b2bf322a2/altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff", size = 21212, upload-time = "2023-09-25T09:04:50.691Z" }, 12 | ] 13 | 14 | [[package]] 15 | name = "keepsultan" 16 | version = "0.1.0" 17 | source = { virtual = "." } 18 | dependencies = [ 19 | { name = "pillow" }, 20 | ] 21 | 22 | [package.dev-dependencies] 23 | dev = [ 24 | { name = "pyinstaller" }, 25 | ] 26 | 27 | [package.metadata] 28 | requires-dist = [{ name = "pillow", specifier = ">=11.3.0" }] 29 | 30 | [package.metadata.requires-dev] 31 | dev = [{ name = "pyinstaller", specifier = ">=6.15.0" }] 32 | 33 | [[package]] 34 | name = "macholib" 35 | version = "1.16.3" 36 | source = { registry = "https://pypi.org/simple" } 37 | dependencies = [ 38 | { name = "altgraph" }, 39 | ] 40 | sdist = { url = "https://files.pythonhosted.org/packages/95/ee/af1a3842bdd5902ce133bd246eb7ffd4375c38642aeb5dc0ae3a0329dfa2/macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30", size = 59309, upload-time = "2023-09-25T09:10:16.155Z" } 41 | wheels = [ 42 | { url = "https://files.pythonhosted.org/packages/d1/5d/c059c180c84f7962db0aeae7c3b9303ed1d73d76f2bfbc32bc231c8be314/macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c", size = 38094, upload-time = "2023-09-25T09:10:14.188Z" }, 43 | ] 44 | 45 | [[package]] 46 | name = "packaging" 47 | version = "25.0" 48 | source = { registry = "https://pypi.org/simple" } 49 | sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } 50 | wheels = [ 51 | { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, 52 | ] 53 | 54 | [[package]] 55 | name = "pefile" 56 | version = "2023.2.7" 57 | source = { registry = "https://pypi.org/simple" } 58 | sdist = { url = "https://files.pythonhosted.org/packages/78/c5/3b3c62223f72e2360737fd2a57c30e5b2adecd85e70276879609a7403334/pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc", size = 74854, upload-time = "2023-02-07T12:23:55.958Z" } 59 | wheels = [ 60 | { url = "https://files.pythonhosted.org/packages/55/26/d0ad8b448476d0a1e8d3ea5622dc77b916db84c6aa3cb1e1c0965af948fc/pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6", size = 71791, upload-time = "2023-02-07T12:28:36.678Z" }, 61 | ] 62 | 63 | [[package]] 64 | name = "pillow" 65 | version = "11.3.0" 66 | source = { registry = "https://pypi.org/simple" } 67 | sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } 68 | wheels = [ 69 | { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, 70 | { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, 71 | { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, 72 | { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, 73 | { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, 74 | { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, 75 | { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, 76 | { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, 77 | { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, 78 | { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, 79 | { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, 80 | { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, 81 | { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, 82 | { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, 83 | { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, 84 | { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, 85 | { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, 86 | { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, 87 | { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, 88 | { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, 89 | { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, 90 | { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, 91 | { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, 92 | { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, 93 | { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, 94 | { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, 95 | { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, 96 | { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, 97 | { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, 98 | { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, 99 | { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, 100 | { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, 101 | { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, 102 | { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, 103 | { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, 104 | { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, 105 | { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, 106 | { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, 107 | { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, 108 | { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, 109 | { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, 110 | { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, 111 | { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, 112 | { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, 113 | { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, 114 | { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, 115 | { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, 116 | ] 117 | 118 | [[package]] 119 | name = "pyinstaller" 120 | version = "6.15.0" 121 | source = { registry = "https://pypi.org/simple" } 122 | dependencies = [ 123 | { name = "altgraph" }, 124 | { name = "macholib", marker = "sys_platform == 'darwin'" }, 125 | { name = "packaging" }, 126 | { name = "pefile", marker = "sys_platform == 'win32'" }, 127 | { name = "pyinstaller-hooks-contrib" }, 128 | { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, 129 | { name = "setuptools" }, 130 | ] 131 | sdist = { url = "https://files.pythonhosted.org/packages/64/17/b2bb4de22650adbeef401fa82a1b43028976547a8728602e4d29735b455e/pyinstaller-6.15.0.tar.gz", hash = "sha256:a48fc4644ee4aa2aa2a35e7b51f496f8fbd7eecf6a2150646bbf1613ad07bc2d", size = 4331521, upload-time = "2025-08-03T18:33:35.709Z" } 132 | wheels = [ 133 | { url = "https://files.pythonhosted.org/packages/24/dd/d5c8a127446adda954f68ea7fac22772f7ab8656ad4b06df396d82574ca9/pyinstaller-6.15.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:9f00c71c40148cd1e61695b2c6f1e086693d3bcf9bfa22ab513aa4254c3b966f", size = 1016981, upload-time = "2025-08-03T18:31:52.034Z" }, 134 | { url = "https://files.pythonhosted.org/packages/2d/2a/7b50593b419db43e48d9bdeebaac0ff92a5fe035f3c30f87ca3e1650d7e2/pyinstaller-6.15.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:cbcc8eb77320c60722030ac875883b564e00768fe3ff1721c7ba3ad0e0a277e9", size = 726337, upload-time = "2025-08-03T18:31:57.592Z" }, 135 | { url = "https://files.pythonhosted.org/packages/77/83/7f498fba0154c57eb5fc93eb9680a2dbadb9f780a3389fb85b8d79683378/pyinstaller-6.15.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c33e6302bc53db2df1104ed5566bd980b3e0ee7f18416a6e3caa908c12a54542", size = 737539, upload-time = "2025-08-03T18:32:02.221Z" }, 136 | { url = "https://files.pythonhosted.org/packages/09/d6/e4477feab7c8379fb49e7ec95c82d0a69ad88f6ccc247f76bef3cb0e3432/pyinstaller-6.15.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:eb902d0fed3bb1f8b7190dc4df5c11f3b59505767e0d56d1ed782b853938bbf3", size = 735426, upload-time = "2025-08-03T18:32:06.485Z" }, 137 | { url = "https://files.pythonhosted.org/packages/32/7e/ff25648276f15e2e77fc563d36d8cfcd917e077bf2a172420df3588601b4/pyinstaller-6.15.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b4df862adae7cf1f08eff53c43ace283822447f7f528f72e4f94749062712f15", size = 732210, upload-time = "2025-08-03T18:32:21.667Z" }, 138 | { url = "https://files.pythonhosted.org/packages/db/3d/267a7dddd0647de95d260780050ccd8228ab29d2b9edea54ed1f56800967/pyinstaller-6.15.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:b9ebf16ed0f99016ae8ae5746dee4cb244848a12941539e62ce2eea1df5a3f95", size = 732194, upload-time = "2025-08-03T18:32:29.536Z" }, 139 | { url = "https://files.pythonhosted.org/packages/4d/61/962b2eb79ef225233e2d6e04600e998935328011dfb2fa775b1dd16b943a/pyinstaller-6.15.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:22193489e6a22435417103f61e7950363bba600ef36ec3ab1487303668c81092", size = 731256, upload-time = "2025-08-03T18:32:36.069Z" }, 140 | { url = "https://files.pythonhosted.org/packages/67/5e/4e20e1c0e5791b09b69bef3ac921fd0cd25551b56879324ad999b92fa045/pyinstaller-6.15.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:18f743069849dbaee3e10900385f35795a5743eabab55e99dcc42f204e40a0db", size = 731148, upload-time = "2025-08-03T18:32:41.269Z" }, 141 | { url = "https://files.pythonhosted.org/packages/88/31/28956c534991f289e2f981c715730b6241e75dc6295737a8cbd050a0cc8c/pyinstaller-6.15.0-py3-none-win32.whl", hash = "sha256:60da8f1b5071766b45c0f607d8bc3d7e59ba2c3b262d08f2e4066ba65f3544a2", size = 1312297, upload-time = "2025-08-03T18:32:50.572Z" }, 142 | { url = "https://files.pythonhosted.org/packages/09/ab/6a45186c7f8e34c422faecd72580116a67d068158c57faa2d2f6d01faa7f/pyinstaller-6.15.0-py3-none-win_amd64.whl", hash = "sha256:cbea297e16eeda30b41c300d6ec2fd2abea4dbd8d8a32650eeec36431c94fcd9", size = 1373091, upload-time = "2025-08-03T18:32:58.133Z" }, 143 | { url = "https://files.pythonhosted.org/packages/5b/86/72159af032b9db36f2470a3b085f79277ec1c38e7e48f8c5dc1ed16dc4e1/pyinstaller-6.15.0-py3-none-win_arm64.whl", hash = "sha256:f43c035621742cf2d19b84308c60e4e44e72c94786d176b8f6adcde351b5bd98", size = 1314305, upload-time = "2025-08-03T18:33:05.557Z" }, 144 | ] 145 | 146 | [[package]] 147 | name = "pyinstaller-hooks-contrib" 148 | version = "2025.8" 149 | source = { registry = "https://pypi.org/simple" } 150 | dependencies = [ 151 | { name = "packaging" }, 152 | { name = "setuptools" }, 153 | ] 154 | sdist = { url = "https://files.pythonhosted.org/packages/71/d6/e5b378b7d4add8c879295c531309b0320e9c07a70458665d091760ffdc87/pyinstaller_hooks_contrib-2025.8.tar.gz", hash = "sha256:3402ad41dfe9b5110af134422e37fc5d421ba342c6cb980bd67cb30b7415641c", size = 164214, upload-time = "2025-07-27T16:37:31.943Z" } 155 | wheels = [ 156 | { url = "https://files.pythonhosted.org/packages/48/34/1d973d0dae849683e53fbcda84443ce016f315e6f4dc7605ede4f56a28c3/pyinstaller_hooks_contrib-2025.8-py3-none-any.whl", hash = "sha256:8d0b8cfa0cb689a619294ae200497374234bd4e3994b3ace2a4442274c899064", size = 442346, upload-time = "2025-07-27T16:37:30.268Z" }, 157 | ] 158 | 159 | [[package]] 160 | name = "pywin32-ctypes" 161 | version = "0.2.3" 162 | source = { registry = "https://pypi.org/simple" } 163 | sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } 164 | wheels = [ 165 | { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, 166 | ] 167 | 168 | [[package]] 169 | name = "setuptools" 170 | version = "80.9.0" 171 | source = { registry = "https://pypi.org/simple" } 172 | sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } 173 | wheels = [ 174 | { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, 175 | ] 176 | --------------------------------------------------------------------------------