Kaynağa Gözat

Rust 版工具箱:换座位 / 姓名牌 / 小分表

- workspace:core(纯算法+xlsx/docx)+ gui(egui,单窗口三工具页)
- 换座位:与 Python 原版 5 场景输出全一致(单元格值/底色/边框/合并)
- 姓名牌:字符串级 OOXML 引擎,真实模板输出与 Python 字节级一致;
  内置默认模板直接内嵌 Downloads/姓名牌/姓名牌_模板.docx(不再代码拼版式);
  zip 时间戳固定,输出可复现
- 小分表:与 pandas 原版逻辑逐条对照(读取时 dtype、银行家舍入、空表路径),
  12 轮随机模糊测试协议 12/12、导出 11/11 一致
- 全量单测 14/14,零警告

Co-Authored-By: Claude Code <noreply@anthropic.com>
nonvioce 2 ay önce
işleme
d94314cb94

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+/target

+ 5115 - 0
Cargo.lock

@@ -0,0 +1,5115 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "ab_glyph"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
+dependencies = [
+ "ab_glyph_rasterizer",
+ "owned_ttf_parser",
+]
+
+[[package]]
+name = "ab_glyph_rasterizer"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
+
+[[package]]
+name = "accesskit"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
+dependencies = [
+ "uuid",
+]
+
+[[package]]
+name = "accesskit_atspi_common"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer 0.36.0",
+ "atspi-common",
+ "phf 0.13.1",
+ "serde",
+ "zvariant",
+]
+
+[[package]]
+name = "accesskit_consumer"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
+dependencies = [
+ "accesskit",
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "accesskit_consumer"
+version = "0.36.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
+dependencies = [
+ "accesskit",
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "accesskit_consumer"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
+dependencies = [
+ "accesskit",
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "accesskit_macos"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer 0.38.0",
+ "hashbrown 0.16.1",
+ "objc2 0.5.2",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "accesskit_unix"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
+dependencies = [
+ "accesskit",
+ "accesskit_atspi_common",
+ "async-channel",
+ "async-executor",
+ "async-task",
+ "atspi",
+ "futures-lite",
+ "futures-util",
+ "serde",
+ "zbus",
+]
+
+[[package]]
+name = "accesskit_windows"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer 0.35.0",
+ "hashbrown 0.16.1",
+ "static_assertions",
+ "windows",
+ "windows-core",
+]
+
+[[package]]
+name = "accesskit_winit"
+version = "0.32.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
+dependencies = [
+ "accesskit",
+ "accesskit_macos",
+ "accesskit_unix",
+ "accesskit_windows",
+ "raw-window-handle",
+ "winit",
+]
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aes"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
+dependencies = [
+ "cipher",
+ "cpubits",
+ "cpufeatures",
+]
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom 0.3.4",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android-activity"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd"
+dependencies = [
+ "android-properties",
+ "bitflags 2.13.1",
+ "cc",
+ "jni",
+ "libc",
+ "log",
+ "ndk",
+ "ndk-context",
+ "ndk-sys",
+ "num_enum",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "android-properties"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "arboard"
+version = "3.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
+dependencies = [
+ "clipboard-win",
+ "image",
+ "log",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation 0.3.2",
+ "parking_lot",
+ "percent-encoding",
+ "windows-sys 0.60.2",
+ "x11rb",
+]
+
+[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
+[[package]]
+name = "as-raw-xcb-connection"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
+
+[[package]]
+name = "ash"
+version = "0.38.0+1.3.281"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix 1.1.4",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix 1.1.4",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix 1.1.4",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.4",
+]
+
+[[package]]
+name = "atoi_simd"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3cdb3708a128e559a30fb830e8a77a5022ee6902806925c216658652b452a44"
+dependencies = [
+ "debug_unsafe",
+ "rustversion",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "atspi"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d"
+dependencies = [
+ "atspi-common",
+ "atspi-proxies",
+]
+
+[[package]]
+name = "atspi-common"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d"
+dependencies = [
+ "enumflags2",
+ "serde",
+ "static_assertions",
+ "zbus",
+ "zbus-lockstep",
+ "zbus-lockstep-macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "atspi-proxies"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc"
+dependencies = [
+ "atspi-common",
+ "serde",
+ "zbus",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "bit-set"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "block-buffer"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
+dependencies = [
+ "hybrid-array",
+ "zeroize",
+]
+
+[[package]]
+name = "block2"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
+dependencies = [
+ "objc2 0.5.2",
+]
+
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2 0.6.4",
+]
+
+[[package]]
+name = "blocking"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.4",
+]
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "bzip2"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
+dependencies = [
+ "libbz2-rs-sys",
+]
+
+[[package]]
+name = "calamine"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fa68281b1a76b54a62156474adb06bb380a67e07dd60656e3217152b42183f3"
+dependencies = [
+ "atoi_simd",
+ "byteorder",
+ "codepage",
+ "encoding_rs",
+ "fast-float2",
+ "log",
+ "quick-xml",
+ "serde",
+ "zip",
+]
+
+[[package]]
+name = "calloop"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
+dependencies = [
+ "bitflags 2.13.1",
+ "log",
+ "polling",
+ "rustix 0.38.44",
+ "slab",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "calloop"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7"
+dependencies = [
+ "bitflags 2.13.1",
+ "polling",
+ "rustix 1.1.4",
+ "slab",
+ "tracing",
+]
+
+[[package]]
+name = "calloop-wayland-source"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
+dependencies = [
+ "calloop 0.13.0",
+ "rustix 0.38.44",
+ "wayland-backend",
+ "wayland-client",
+]
+
+[[package]]
+name = "calloop-wayland-source"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
+dependencies = [
+ "calloop 0.14.4",
+ "rustix 1.1.4",
+ "wayland-backend",
+ "wayland-client",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.61"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "cgl"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "cipher"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
+[[package]]
+name = "clipboard-win"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
+dependencies = [
+ "error-code",
+]
+
+[[package]]
+name = "cmov"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
+
+[[package]]
+name = "codepage"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4"
+dependencies = [
+ "encoding_rs",
+]
+
+[[package]]
+name = "codespan-reporting"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
+dependencies = [
+ "serde",
+ "termcolor",
+ "unicode-width",
+]
+
+[[package]]
+name = "color"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+
+[[package]]
+name = "constant_time_eq"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core-graphics"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "core-graphics-types",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "libc",
+]
+
+[[package]]
+name = "cpubits"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
+name = "crypto-common"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "ctutils"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
+dependencies = [
+ "cmov",
+]
+
+[[package]]
+name = "cursor-icon"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
+
+[[package]]
+name = "debug_unsafe"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2"
+
+[[package]]
+name = "deflate64"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "powerfmt",
+]
+
+[[package]]
+name = "digest"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
+dependencies = [
+ "block-buffer",
+ "const-oid",
+ "crypto-common",
+ "ctutils",
+ "zeroize",
+]
+
+[[package]]
+name = "dispatch"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2 0.6.2",
+ "libc",
+ "objc2 0.6.4",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.4",
+]
+
+[[package]]
+name = "dlib"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
+[[package]]
+name = "dpi"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+
+[[package]]
+name = "ecolor"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc883f505d446814c0226bef4b64564b9adb9966fc00c88c57ce6d827dd152d6"
+dependencies = [
+ "bytemuck",
+ "emath",
+]
+
+[[package]]
+name = "eframe"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2afc0cbcdb6896b7bfb1dbbebaf7b9af9635ff38fd01e89bdd0174c1717b1857"
+dependencies = [
+ "ahash",
+ "bytemuck",
+ "document-features",
+ "egui",
+ "egui-wgpu",
+ "egui-winit",
+ "egui_glow",
+ "glutin",
+ "glutin-winit",
+ "image",
+ "js-sys",
+ "log",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
+ "parking_lot",
+ "percent-encoding",
+ "pollster 1.0.1",
+ "profiling",
+ "raw-window-handle",
+ "static_assertions",
+ "wasm-bindgen",
+ "web-sys",
+ "web-time",
+ "wgpu",
+ "windows-sys 0.61.2",
+ "winit",
+]
+
+[[package]]
+name = "egui"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c977ac91dfaa651633fd9722e4ce9ccb32cda4c748b89a5cb57e504036e37c13"
+dependencies = [
+ "accesskit",
+ "ahash",
+ "bitflags 2.13.1",
+ "emath",
+ "epaint",
+ "itertools",
+ "log",
+ "nohash-hasher",
+ "profiling",
+ "smallvec",
+ "unicode-segmentation",
+ "web-sys",
+]
+
+[[package]]
+name = "egui-wgpu"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fc362cc6bc8c1d7169c9cb0e7741ff1e03063681373d6e55cc3b25fd21213f3"
+dependencies = [
+ "ahash",
+ "bytemuck",
+ "document-features",
+ "egui",
+ "epaint",
+ "log",
+ "profiling",
+ "thiserror 2.0.20",
+ "type-map",
+ "web-time",
+ "wgpu",
+ "winit",
+]
+
+[[package]]
+name = "egui-winit"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9327fc8edef2c57db9bcbcacc82c8a4b1e8cc64cd41a67db4419dcf643d88c83"
+dependencies = [
+ "accesskit_winit",
+ "arboard",
+ "bytemuck",
+ "egui",
+ "log",
+ "objc2 0.6.4",
+ "objc2-foundation 0.3.2",
+ "objc2-ui-kit 0.3.2",
+ "profiling",
+ "raw-window-handle",
+ "smithay-clipboard",
+ "web-time",
+ "webbrowser",
+ "winit",
+]
+
+[[package]]
+name = "egui_extras"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e651e08ddf6bb8bfd964ad70e33bc721c093f2b77ab59580e7140c008dab68c"
+dependencies = [
+ "ahash",
+ "egui",
+ "enum-map",
+ "itertools",
+ "log",
+ "mime_guess2",
+ "profiling",
+]
+
+[[package]]
+name = "egui_glow"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69b65e45f8d8d7c6d848b3ec41f642b54ad828033f1511d4b4c9adc09040166a"
+dependencies = [
+ "bytemuck",
+ "egui",
+ "glow",
+ "log",
+ "profiling",
+ "winit",
+]
+
+[[package]]
+name = "either"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
+
+[[package]]
+name = "emath"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dce03cf1604f74515830012c29991beb0b58f69e4e43f5afe22fa7afed65ccd5"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enum-map"
+version = "2.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
+dependencies = [
+ "enum-map-derive",
+]
+
+[[package]]
+name = "enum-map-derive"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "epaint"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11863a6b2b7823010c1090ddf4706947bdda46766742def0673195cbf7ff4a73"
+dependencies = [
+ "ahash",
+ "bytemuck",
+ "ecolor",
+ "emath",
+ "epaint_default_fonts",
+ "font-types",
+ "harfrust",
+ "log",
+ "nohash-hasher",
+ "parking_lot",
+ "profiling",
+ "self_cell",
+ "skrifa",
+ "smallvec",
+ "unicode-general-category",
+ "unicode-segmentation",
+ "vello_cpu",
+]
+
+[[package]]
+name = "epaint_default_fonts"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18dee69613aac468922cf28a32025eb7d7ed6985b61f73245848e58f37876c98"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "error-code"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
+
+[[package]]
+name = "euclid"
+version = "0.22.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fast-float2"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f"
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "fax"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "fearless_simd"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide 0.9.1",
+ "zlib-rs",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "font-types"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "foreign-types"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
+dependencies = [
+ "foreign-types-macros",
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-macros"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.4",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.4",
+]
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-macro",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix 1.1.4",
+ "windows-link",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi 6.0.0",
+ "wasip2",
+ "wasip3",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "gl_generator"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
+dependencies = [
+ "khronos_api",
+ "log",
+ "xml-rs",
+]
+
+[[package]]
+name = "glifo"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
+dependencies = [
+ "bytemuck",
+ "foldhash 0.2.0",
+ "hashbrown 0.17.1",
+ "log",
+ "peniko",
+ "skrifa",
+ "smallvec",
+ "vello_common",
+]
+
+[[package]]
+name = "glow"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
+dependencies = [
+ "js-sys",
+ "slotmap",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "glutin"
+version = "0.32.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325"
+dependencies = [
+ "bitflags 2.13.1",
+ "cfg_aliases",
+ "cgl",
+ "dispatch2",
+ "glutin_egl_sys",
+ "glutin_glx_sys",
+ "glutin_wgl_sys",
+ "libloading",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "once_cell",
+ "raw-window-handle",
+ "wayland-sys",
+ "windows-sys 0.52.0",
+ "x11-dl",
+]
+
+[[package]]
+name = "glutin-winit"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f"
+dependencies = [
+ "cfg_aliases",
+ "glutin",
+ "raw-window-handle",
+ "winit",
+]
+
+[[package]]
+name = "glutin_egl_sys"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2"
+dependencies = [
+ "gl_generator",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "glutin_glx_sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185"
+dependencies = [
+ "gl_generator",
+ "x11-dl",
+]
+
+[[package]]
+name = "glutin_wgl_sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
+dependencies = [
+ "gl_generator",
+]
+
+[[package]]
+name = "gpu-allocator"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795"
+dependencies = [
+ "ash",
+ "hashbrown 0.16.1",
+ "log",
+ "presser",
+ "thiserror 2.0.20",
+ "windows",
+]
+
+[[package]]
+name = "guillotiere"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab"
+dependencies = [
+ "euclid",
+]
+
+[[package]]
+name = "half"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
+dependencies = [
+ "cfg-if",
+ "crunchy",
+ "num-traits",
+ "zerocopy",
+]
+
+[[package]]
+name = "harfrust"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
+dependencies = [
+ "bitflags 2.13.1",
+ "bytemuck",
+ "read-fonts",
+ "smallvec",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash 0.1.5",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "foldhash 0.2.0",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hmac"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "hybrid-array"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "image"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "moxcms",
+ "num-traits",
+ "png",
+ "tiff",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "inout"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "itertools"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jni"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
+dependencies = [
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys 0.4.1",
+ "log",
+ "simd_cesu8",
+ "thiserror 2.0.20",
+ "walkdir",
+ "windows-link",
+]
+
+[[package]]
+name = "jni-macros"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jobserver"
+version = "0.1.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
+dependencies = [
+ "getrandom 0.4.2",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "khronos-egl"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
+dependencies = [
+ "libc",
+ "libloading",
+ "pkg-config",
+]
+
+[[package]]
+name = "khronos_api"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
+
+[[package]]
+name = "kurbo"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
+dependencies = [
+ "arrayvec",
+ "euclid",
+ "polycool",
+ "smallvec",
+]
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "libbz2-rs-sys"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "libredox"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed"
+dependencies = [
+ "bitflags 2.13.1",
+ "libc",
+ "plain",
+ "redox_syscall 0.9.3",
+]
+
+[[package]]
+name = "linebender_resource_handle"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "lzma-rust2"
+version = "0.16.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b"
+dependencies = [
+ "sha2",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memmap2"
+version = "0.9.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess2"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca"
+dependencies = [
+ "mime",
+ "phf 0.11.3",
+ "phf_shared 0.11.3",
+ "unicase",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "moxcms"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
+[[package]]
+name = "naga"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
+dependencies = [
+ "arrayvec",
+ "bit-set",
+ "bitflags 2.13.1",
+ "cfg-if",
+ "cfg_aliases",
+ "codespan-reporting",
+ "half",
+ "hashbrown 0.17.1",
+ "indexmap",
+ "libm",
+ "log",
+ "naga-types",
+ "num-traits",
+ "once_cell",
+ "rustc-hash 1.1.0",
+ "spirv",
+ "thiserror 2.0.20",
+ "unicode-ident",
+]
+
+[[package]]
+name = "naga-types"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
+dependencies = [
+ "hashbrown 0.17.1",
+ "indexmap",
+ "rustc-hash 1.1.0",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "ndk"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
+dependencies = [
+ "bitflags 2.13.1",
+ "jni-sys 0.3.1",
+ "log",
+ "ndk-sys",
+ "num_enum",
+ "raw-window-handle",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+[[package]]
+name = "ndk-sys"
+version = "0.6.0+11769913"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
+name = "nohash-hasher"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
+
+[[package]]
+name = "num-conv"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "objc-sys"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
+
+[[package]]
+name = "objc2"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
+dependencies = [
+ "objc-sys",
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2 0.5.1",
+ "libc",
+ "objc2 0.5.2",
+ "objc2-core-data",
+ "objc2-core-image",
+ "objc2-foundation 0.2.2",
+ "objc2-quartz-core 0.2.2",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2 0.6.2",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation 0.3.2",
+]
+
+[[package]]
+name = "objc2-cloud-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-contacts"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-data"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.13.1",
+ "dispatch2",
+ "objc2 0.6.4",
+]
+
+[[package]]
+name = "objc2-core-graphics"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
+dependencies = [
+ "bitflags 2.13.1",
+ "dispatch2",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-io-surface",
+]
+
+[[package]]
+name = "objc2-core-image"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-metal 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-location"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-contacts",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-foundation"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2 0.5.1",
+ "dispatch",
+ "libc",
+ "objc2 0.5.2",
+]
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2 0.6.2",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-io-surface"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
+dependencies = [
+ "bitflags 2.13.1",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-link-presentation"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-metal"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-metal"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2 0.6.2",
+ "objc2 0.6.4",
+ "objc2-foundation 0.3.2",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-metal 0.2.2",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
+dependencies = [
+ "bitflags 2.13.1",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation 0.3.2",
+ "objc2-metal 0.3.2",
+]
+
+[[package]]
+name = "objc2-symbols"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc"
+dependencies = [
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-cloud-kit",
+ "objc2-core-data",
+ "objc2-core-image",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+ "objc2-link-presentation",
+ "objc2-quartz-core 0.2.2",
+ "objc2-symbols",
+ "objc2-uniform-type-identifiers",
+ "objc2-user-notifications",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
+dependencies = [
+ "bitflags 2.13.1",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+]
+
+[[package]]
+name = "objc2-uniform-type-identifiers"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-user-notifications"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "orbclient"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747"
+dependencies = [
+ "libc",
+ "libredox",
+]
+
+[[package]]
+name = "ordered-float"
+version = "5.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "owned_ttf_parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
+dependencies = [
+ "ttf-parser",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.5.18",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "pbkdf2"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
+dependencies = [
+ "digest",
+ "hmac",
+]
+
+[[package]]
+name = "peniko"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6"
+dependencies = [
+ "bytemuck",
+ "color",
+ "kurbo",
+ "linebender_resource_handle",
+ "smallvec",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros 0.11.3",
+ "phf_shared 0.11.3",
+]
+
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_macros 0.13.1",
+ "phf_shared 0.13.1",
+ "serde",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared 0.11.3",
+ "rand",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared 0.13.1",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator 0.11.3",
+ "phf_shared 0.11.3",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "unicase",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
+dependencies = [
+ "phf_generator 0.13.1",
+ "phf_shared 0.13.1",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+ "unicase",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags 2.13.1",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide 0.8.9",
+]
+
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix 1.1.4",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "pollster"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
+
+[[package]]
+name = "pollster"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336"
+
+[[package]]
+name = "polycool"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
+dependencies = [
+ "arrayvec",
+]
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppmd-rust"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e9219bcb9d7aca6b2f63c83cf100cf78bcd619ac46e6ecbd0dd90869a39345d"
+
+[[package]]
+name = "presser"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "profiling"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
+
+[[package]]
+name = "pxfm"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
+
+[[package]]
+name = "quick-error"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+
+[[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "encoding_rs",
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+
+[[package]]
+name = "range-alloc"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
+
+[[package]]
+name = "raw-window-handle"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+
+[[package]]
+name = "raw-window-metal"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
+dependencies = [
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "objc2-quartz-core 0.3.2",
+]
+
+[[package]]
+name = "read-fonts"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
+dependencies = [
+ "bytemuck",
+ "font-types",
+ "once_cell",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.1",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5"
+dependencies = [
+ "bitflags 2.13.1",
+]
+
+[[package]]
+name = "renderdoc-sys"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
+
+[[package]]
+name = "rfd"
+version = "0.17.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220"
+dependencies = [
+ "block2 0.6.2",
+ "dispatch2",
+ "js-sys",
+ "libc",
+ "log",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "percent-encoding",
+ "pollster 0.4.0",
+ "raw-window-handle",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "web-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rust_xlsxwriter"
+version = "0.99.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c073c0bd3868defc46035bf95b2e70389cefc59c528b8674fe90594c9c2b3f6f"
+dependencies = [
+ "zip",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags 2.13.1",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.13.1",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.12.1",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "sctk-adwaita"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
+dependencies = [
+ "ab_glyph",
+ "log",
+ "memmap2",
+ "smithay-client-toolkit 0.19.2",
+ "tiny-skia",
+]
+
+[[package]]
+name = "self_cell"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.4",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.4",
+]
+
+[[package]]
+name = "sha1"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "simd_cesu8"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "skrifa"
+version = "0.44.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
+dependencies = [
+ "bytemuck",
+ "read-fonts",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
+
+[[package]]
+name = "smithay-client-toolkit"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
+dependencies = [
+ "bitflags 2.13.1",
+ "calloop 0.13.0",
+ "calloop-wayland-source 0.3.0",
+ "cursor-icon",
+ "libc",
+ "log",
+ "memmap2",
+ "rustix 0.38.44",
+ "thiserror 1.0.69",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-csd-frame",
+ "wayland-cursor",
+ "wayland-protocols",
+ "wayland-protocols-wlr",
+ "wayland-scanner",
+ "xkeysym",
+]
+
+[[package]]
+name = "smithay-client-toolkit"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
+dependencies = [
+ "bitflags 2.13.1",
+ "calloop 0.14.4",
+ "calloop-wayland-source 0.4.1",
+ "cursor-icon",
+ "libc",
+ "log",
+ "memmap2",
+ "rustix 1.1.4",
+ "thiserror 2.0.20",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-csd-frame",
+ "wayland-cursor",
+ "wayland-protocols",
+ "wayland-protocols-experimental",
+ "wayland-protocols-misc",
+ "wayland-protocols-wlr",
+ "wayland-scanner",
+ "xkeysym",
+]
+
+[[package]]
+name = "smithay-clipboard"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
+dependencies = [
+ "libc",
+ "smithay-client-toolkit 0.20.0",
+ "wayland-backend",
+]
+
+[[package]]
+name = "smol_str"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "spirv"
+version = "0.4.0+sdk-1.4.341.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
+dependencies = [
+ "bitflags 2.13.1",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "strict-num"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.2",
+ "once_cell",
+ "rustix 1.1.4",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl 2.0.20",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.4",
+]
+
+[[package]]
+name = "tiff"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
+dependencies = [
+ "fax",
+ "flate2",
+ "half",
+ "quick-error",
+ "weezl",
+ "zune-jpeg",
+]
+
+[[package]]
+name = "time"
+version = "0.3.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
+dependencies = [
+ "deranged",
+ "js-sys",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
+
+[[package]]
+name = "tiny-skia"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "bytemuck",
+ "cfg-if",
+ "log",
+ "tiny-skia-path",
+]
+
+[[package]]
+name = "tiny-skia-path"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
+dependencies = [
+ "arrayref",
+ "bytemuck",
+ "strict-num",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.13+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "toolbox-core"
+version = "0.1.0"
+dependencies = [
+ "calamine",
+ "rust_xlsxwriter",
+ "xmltree",
+ "zip",
+]
+
+[[package]]
+name = "toolbox-gui"
+version = "0.1.0"
+dependencies = [
+ "ab_glyph",
+ "eframe",
+ "egui",
+ "egui_extras",
+ "image",
+ "rfd",
+ "toolbox-core",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "ttf-parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
+
+[[package]]
+name = "type-map"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90"
+dependencies = [
+ "rustc-hash 2.1.3",
+]
+
+[[package]]
+name = "typed-path"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
+
+[[package]]
+name = "typenum"
+version = "1.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
+
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
+[[package]]
+name = "unicode-general-category"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
+dependencies = [
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "vello_common"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
+dependencies = [
+ "bytemuck",
+ "fearless_simd",
+ "guillotiere",
+ "log",
+ "peniko",
+ "smallvec",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "vello_cpu"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
+dependencies = [
+ "bytemuck",
+ "glifo",
+ "hashbrown 0.17.1",
+ "vello_common",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen 0.57.1",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags 2.13.1",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
+[[package]]
+name = "wayland-backend"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix 1.1.4",
+ "scoped-tls",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
+dependencies = [
+ "bitflags 2.13.1",
+ "rustix 1.1.4",
+ "wayland-backend",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-csd-frame"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e"
+dependencies = [
+ "bitflags 2.13.1",
+ "cursor-icon",
+ "wayland-backend",
+]
+
+[[package]]
+name = "wayland-cursor"
+version = "0.31.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
+dependencies = [
+ "rustix 1.1.4",
+ "wayland-client",
+ "xcursor",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
+dependencies = [
+ "bitflags 2.13.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-experimental"
+version = "20250721.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
+dependencies = [
+ "bitflags 2.13.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-misc"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
+dependencies = [
+ "bitflags 2.13.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-plasma"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91"
+dependencies = [
+ "bitflags 2.13.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-wlr"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
+dependencies = [
+ "bitflags 2.13.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
+dependencies = [
+ "proc-macro2",
+ "quick-xml",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
+dependencies = [
+ "dlib",
+ "log",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webbrowser"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
+dependencies = [
+ "jni",
+ "log",
+ "ndk-context",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
+ "url",
+ "web-sys",
+]
+
+[[package]]
+name = "weezl"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
+
+[[package]]
+name = "wgpu"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e"
+dependencies = [
+ "arrayvec",
+ "bitflags 2.13.1",
+ "bytemuck",
+ "cfg-if",
+ "cfg_aliases",
+ "document-features",
+ "hashbrown 0.17.1",
+ "js-sys",
+ "log",
+ "naga",
+ "parking_lot",
+ "portable-atomic",
+ "profiling",
+ "raw-window-handle",
+ "smallvec",
+ "static_assertions",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "wgpu-core",
+ "wgpu-hal",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-core"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
+dependencies = [
+ "arrayvec",
+ "bit-set",
+ "bit-vec",
+ "bitflags 2.13.1",
+ "bytemuck",
+ "cfg_aliases",
+ "document-features",
+ "hashbrown 0.17.1",
+ "indexmap",
+ "log",
+ "naga",
+ "naga-types",
+ "once_cell",
+ "parking_lot",
+ "portable-atomic",
+ "profiling",
+ "raw-window-handle",
+ "rustc-hash 1.1.0",
+ "smallvec",
+ "thiserror 2.0.20",
+ "wgpu-core-deps-apple",
+ "wgpu-core-deps-emscripten",
+ "wgpu-core-deps-wasm",
+ "wgpu-core-deps-windows-linux-android",
+ "wgpu-hal",
+ "wgpu-naga-bridge",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-core-deps-apple"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-core-deps-emscripten"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-core-deps-wasm"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-core-deps-windows-linux-android"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-hal"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58"
+dependencies = [
+ "android_system_properties",
+ "arrayvec",
+ "ash",
+ "bit-set",
+ "bitflags 2.13.1",
+ "block2 0.6.2",
+ "bytemuck",
+ "cfg-if",
+ "cfg_aliases",
+ "glow",
+ "glutin_wgl_sys",
+ "gpu-allocator",
+ "hashbrown 0.17.1",
+ "js-sys",
+ "khronos-egl",
+ "libc",
+ "libloading",
+ "log",
+ "naga",
+ "naga-types",
+ "ndk-sys",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation 0.3.2",
+ "objc2-metal 0.3.2",
+ "objc2-quartz-core 0.3.2",
+ "once_cell",
+ "ordered-float",
+ "parking_lot",
+ "portable-atomic",
+ "portable-atomic-util",
+ "profiling",
+ "range-alloc",
+ "raw-window-handle",
+ "raw-window-metal",
+ "renderdoc-sys",
+ "smallvec",
+ "static_assertions",
+ "thiserror 2.0.20",
+ "wasm-bindgen",
+ "wayland-sys",
+ "web-sys",
+ "wgpu-naga-bridge",
+ "wgpu-types",
+ "windows",
+ "windows-core",
+ "windows-result",
+]
+
+[[package]]
+name = "wgpu-naga-bridge"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081"
+dependencies = [
+ "naga",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-types"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286"
+dependencies = [
+ "bitflags 2.13.1",
+ "bytemuck",
+ "js-sys",
+ "log",
+ "naga-types",
+ "raw-window-handle",
+ "static_assertions",
+ "web-sys",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "windows"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections",
+ "windows-core",
+ "windows-future",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+dependencies = [
+ "windows-core",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core",
+ "windows-link",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core",
+ "windows-link",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows-threading"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "winit"
+version = "0.30.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
+dependencies = [
+ "ahash",
+ "android-activity",
+ "atomic-waker",
+ "bitflags 2.13.1",
+ "block2 0.5.1",
+ "bytemuck",
+ "calloop 0.13.0",
+ "cfg_aliases",
+ "concurrent-queue",
+ "core-foundation",
+ "core-graphics",
+ "cursor-icon",
+ "dpi",
+ "js-sys",
+ "libc",
+ "memmap2",
+ "ndk",
+ "objc2 0.5.2",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+ "objc2-ui-kit 0.2.2",
+ "orbclient",
+ "percent-encoding",
+ "pin-project",
+ "raw-window-handle",
+ "redox_syscall 0.4.1",
+ "rustix 0.38.44",
+ "sctk-adwaita",
+ "smithay-client-toolkit 0.19.2",
+ "smol_str",
+ "tracing",
+ "unicode-segmentation",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-protocols-plasma",
+ "web-sys",
+ "web-time",
+ "windows-sys 0.52.0",
+ "x11-dl",
+ "x11rb",
+ "xkbcommon-dl",
+]
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn 2.0.119",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags 2.13.1",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "x11-dl"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
+dependencies = [
+ "libc",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
+dependencies = [
+ "as-raw-xcb-connection",
+ "gethostname",
+ "libc",
+ "libloading",
+ "once_cell",
+ "rustix 1.1.4",
+ "x11rb-protocol",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+
+[[package]]
+name = "xcursor"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
+
+[[package]]
+name = "xkbcommon-dl"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5"
+dependencies = [
+ "bitflags 2.13.1",
+ "dlib",
+ "log",
+ "once_cell",
+ "xkeysym",
+]
+
+[[package]]
+name = "xkeysym"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
+
+[[package]]
+name = "xml"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52"
+
+[[package]]
+name = "xml-rs"
+version = "0.8.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
+
+[[package]]
+name = "xmltree"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbc04313cab124e498ab1724e739720807b6dc405b9ed0edc5860164d2e4ff70"
+dependencies = [
+ "xml",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zbus"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix 1.1.4",
+ "serde",
+ "serde_repr",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus-lockstep"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863"
+dependencies = [
+ "zbus_xml",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus-lockstep-macros"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "zbus-lockstep",
+ "zbus_xml",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.4",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
+dependencies = [
+ "serde",
+ "winnow",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_xml"
+version = "5.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a"
+dependencies = [
+ "serde",
+ "winnow",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zcheapstr"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.4",
+]
+
+[[package]]
+name = "zip"
+version = "8.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
+dependencies = [
+ "aes",
+ "bzip2",
+ "constant_time_eq",
+ "crc32fast",
+ "deflate64",
+ "flate2",
+ "getrandom 0.4.2",
+ "hmac",
+ "indexmap",
+ "lzma-rust2",
+ "memchr",
+ "pbkdf2",
+ "ppmd-rust",
+ "sha1",
+ "time",
+ "typed-path",
+ "zeroize",
+ "zopfli",
+ "zstd",
+]
+
+[[package]]
+name = "zlib-rs"
+version = "0.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
+[[package]]
+name = "zopfli"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
+
+[[package]]
+name = "zstd"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
+dependencies = [
+ "zstd-safe",
+]
+
+[[package]]
+name = "zstd-safe"
+version = "7.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.16+zstd.1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
+[[package]]
+name = "zune-core"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
+
+[[package]]
+name = "zune-jpeg"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
+dependencies = [
+ "zune-core",
+]
+
+[[package]]
+name = "zvariant"
+version = "5.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow",
+ "zcheapstr",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.4",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 3.0.4",
+ "winnow",
+]

+ 13 - 0
Cargo.toml

@@ -0,0 +1,13 @@
+[workspace]
+resolver = "2"
+members = ["core", "gui"]
+
+[workspace.package]
+version = "0.1.0"
+edition = "2024"
+
+[profile.release]
+# 单文件分发,压缩体积
+opt-level = "z"
+lto = "thin"
+strip = true

+ 11 - 0
core/Cargo.toml

@@ -0,0 +1,11 @@
+[package]
+name = "toolbox-core"
+version.workspace = true
+edition.workspace = true
+description = "工具箱核心库:纯算法与 Excel/docx 读写,无 GUI 依赖"
+
+[dependencies]
+calamine = "0.36.1"
+rust_xlsxwriter = "0.99.0"
+xmltree = "0.12.0"
+zip = "8.6.0"

+ 77 - 0
core/examples/nameplate_cli.rs

@@ -0,0 +1,77 @@
+//! 姓名牌命令行入口(开发期对照用;镜像 Python CLI 参数)。
+//!   cargo run -p toolbox-core --example nameplate_cli -- <名单.xlsx> [--班级 X] [--座右铭 M] [--模板 T] [--sheet S] [--输出目录 D] [--保留模板]
+
+use std::path::PathBuf;
+use toolbox_core::nameplate::{run_nameplate, NameplateInput, DEFAULT_MOTTO};
+
+fn main() {
+    let args: Vec<String> = std::env::args().collect();
+    if args.len() < 2 {
+        eprintln!("用法: nameplate_cli <名单.xlsx> [--班级 X] [--座右铭 M] [--模板 T] [--sheet S] [--输出目录 D] [--保留模板]");
+        std::process::exit(2);
+    }
+    let roster = PathBuf::from(&args[1]);
+    let mut class_override: Option<String> = None;
+    let mut motto: String = DEFAULT_MOTTO.to_string();
+    let mut template: Option<PathBuf> = None;
+    let mut sheet: Option<String> = None;
+    let mut out_dir: Option<PathBuf> = None;
+    let mut keep_template = false;
+
+    let mut i = 2;
+    while i < args.len() {
+        match args[i].as_str() {
+            "--班级" => {
+                i += 1;
+                class_override = Some(args[i].clone());
+            }
+            "--座右铭" => {
+                i += 1;
+                motto = args[i].clone();
+            }
+            "--模板" => {
+                i += 1;
+                template = Some(PathBuf::from(&args[i]));
+            }
+            "--sheet" => {
+                i += 1;
+                sheet = Some(args[i].clone());
+            }
+            "--输出目录" => {
+                i += 1;
+                out_dir = Some(PathBuf::from(&args[i]));
+            }
+            "--保留模板" => keep_template = true,
+            _ => {
+                eprintln!("未知参数: {}", args[i]);
+                std::process::exit(2);
+            }
+        }
+        i += 1;
+    }
+
+    let inp = NameplateInput {
+        roster_path: roster,
+        class_override,
+        motto,
+        template,
+        auto_dirs: vec![std::env::current_dir().unwrap_or_default()],
+        sheet,
+        out_dir: out_dir.unwrap_or_else(|| {
+            std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
+        }),
+        keep_template,
+    };
+
+    match run_nameplate(&inp) {
+        Ok(res) => {
+            for l in &res.log {
+                println!("{}", l);
+            }
+        }
+        Err(e) => {
+            eprintln!("{}", e);
+            std::process::exit(1);
+        }
+    }
+}

+ 60 - 0
core/examples/scoretable_cli.rs

@@ -0,0 +1,60 @@
+//! 小分表命令行入口(对照测试用)。
+//! 用法: scoretable_cli <xlsx> <has_header 0|1> <ignore文本> <颜色文本> <阈值> [--export <out.xlsx>]
+
+use std::path::PathBuf;
+use toolbox_core::scoretable::{export_xlsx, process, ScoreTableInput, PALETTE};
+
+fn main() {
+    let args: Vec<String> = std::env::args().collect();
+    if args.len() < 6 {
+        eprintln!("用法: scoretable_cli <xlsx> <has_header 0|1> <ignore> <colors> <阈值> [--export out.xlsx]");
+        std::process::exit(2);
+    }
+    let mut export: Option<PathBuf> = None;
+    let mut i = 6;
+    while i < args.len() {
+        if args[i] == "--export" && i + 1 < args.len() {
+            export = Some(PathBuf::from(&args[i + 1]));
+            i += 2;
+        } else {
+            i += 1;
+        }
+    }
+
+    let inp = ScoreTableInput {
+        path: PathBuf::from(&args[1]),
+        has_header: args[2] == "1",
+        ignore_cols_text: args[3].clone(),
+        color_groups_text: args[4].clone(),
+        threshold: args[5].parse().unwrap_or(10),
+    };
+
+    match process(&inp) {
+        Ok(t) => {
+            // 协议输出(与 reference.py 一致)
+            println!("H\t{}", t.headers.join("\t"));
+            for (r, row) in t.rows.iter().enumerate() {
+                println!("R\t{}", row.join("\t"));
+                let ci = match t.color_idx[r] {
+                    Some(i) => i as i64,
+                    None => -1,
+                };
+                println!("C\t{}", ci);
+            }
+            if let Some(p) = export {
+                match export_xlsx(&t, &p) {
+                    Ok(_) => println!("已导出: {}", p.display()),
+                    Err(e) => {
+                        eprintln!("{}", e);
+                        std::process::exit(1);
+                    }
+                }
+            }
+        }
+        Err(e) => {
+            println!("ERR {}", e);
+            std::process::exit(1);
+        }
+    }
+    let _ = PALETTE;
+}

+ 73 - 0
core/examples/seating_cli.rs

@@ -0,0 +1,73 @@
+//! 换座位命令行入口(开发期对照用;镜像 Python CLI 参数)。
+//! 用法:
+//!   cargo run -p toolbox-core --example seating_cli -- <xlsx> <班级> <样式> [策略] [--mixed] [--count N] [--seed S] [--output O]
+
+use std::path::PathBuf;
+use toolbox_core::seating::{run_seating, SeatingInput, Strategy};
+
+fn main() {
+    let args: Vec<String> = std::env::args().collect();
+    if args.len() < 4 {
+        eprintln!("用法: seating_cli <xlsx> <班级> <样式> [good-with-good|good-with-bad|random] [--mixed] [--count N] [--seed S] [--output O]");
+        std::process::exit(2);
+    }
+    let excel = PathBuf::from(&args[1]);
+    let class = args[2].clone();
+    let style = args[3].clone();
+    let mut strategy = Strategy::GoodWithGood;
+    let mut allow_mixed = false;
+    let mut count = 1usize;
+    let mut seed: Option<u64> = None;
+    let mut output: Option<PathBuf> = None;
+
+    let mut i = 4;
+    while i < args.len() {
+        match args[i].as_str() {
+            "--mixed" => allow_mixed = true,
+            "--count" => {
+                i += 1;
+                count = args[i].parse().unwrap_or(1);
+            }
+            "--seed" => {
+                i += 1;
+                seed = Some(args[i].parse().expect("seed 必须为数字"));
+            }
+            "--output" => {
+                i += 1;
+                output = Some(PathBuf::from(&args[i]));
+            }
+            "good-with-good" => strategy = Strategy::GoodWithGood,
+            "good-with-bad" => strategy = Strategy::GoodWithBad,
+            "random" => strategy = Strategy::Random,
+            _ => {
+                eprintln!("未知参数: {}", args[i]);
+                std::process::exit(2);
+            }
+        }
+        i += 1;
+    }
+
+    let inp = SeatingInput {
+        excel_path: excel,
+        sheet: None,
+        class_name: class,
+        style,
+        strategy,
+        allow_mixed,
+        count,
+        seed,
+        output,
+    };
+
+    match run_seating(&inp) {
+        Ok(res) => {
+            for l in &res.log {
+                println!("{}", l);
+            }
+        }
+        Err(e) => {
+            eprintln!("{}", e);
+            std::process::exit(1);
+        }
+    }
+}

BIN
core/resources/姓名牌_模板.docx


+ 7 - 0
core/src/lib.rs

@@ -0,0 +1,7 @@
+//! 工具箱核心库:纯算法 + Excel/docx 读写,不依赖任何 GUI 框架。
+//! 未来新增工具 = 在这里加模块,再到 gui 里注册一个页面。
+
+pub mod nameplate;
+pub mod scoretable;
+pub mod seating;
+pub mod xlsx;

+ 1015 - 0
core/src/nameplate.rs

@@ -0,0 +1,1015 @@
+//! 姓名牌:移植自 生成姓名牌.py,行为完全保真。
+//!
+//! docx 引擎策略:模板 document.xml 是机器生成的规范 XML(文本均已转义,
+//! 无 CDATA、无未转义的 '<'),因此用"元素配对切分 + 定点字节替换"实现与
+//! lxml 等价的效果——只改写需要改的位置(姓名/班级数字/座右铭段落/文本框
+//! 编号),其余字节原样保留,与 Python 版输出差异最小,天然兼容 WPS/Word。
+
+use crate::xlsx::read_sheet;
+use std::io::{Read, Write};
+use std::path::{Path, PathBuf};
+use zip::write::SimpleFileOptions;
+
+/// 内置默认模板:直接内嵌用户提供的学校原版模板文件。
+/// 找不到外部模板(手动指定/exe 同目录/当前目录)时,原样落盘使用。
+pub static DEFAULT_TEMPLATE_BYTES: &[u8] = include_bytes!("../resources/姓名牌_模板.docx");
+
+// ---------------------------------------------------------------------------
+// 命名空间与版式常量(与 Python 逐字一致)
+// ---------------------------------------------------------------------------
+
+pub const DEFAULT_CLASS: &str = "31";
+pub const DEFAULT_MOTTO: &str = "脚踏实地  知行合一  行稳致远";
+
+const FONT_HEI: &str =
+    "<w:rFonts w:ascii=\"黑体\" w:hAnsi=\"黑体\" w:eastAsia=\"黑体\" w:cs=\"黑体\"/>";
+const FONT_HEI_HINT: &str =
+    "<w:rFonts w:hint=\"eastAsia\" w:ascii=\"黑体\" w:hAnsi=\"黑体\" w:eastAsia=\"黑体\" w:cs=\"黑体\"/>";
+
+// ---------------------------------------------------------------------------
+// XML 转义
+// ---------------------------------------------------------------------------
+
+fn xml_escape(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    for c in s.chars() {
+        match c {
+            '&' => out.push_str("&amp;"),
+            '<' => out.push_str("&lt;"),
+            '>' => out.push_str("&gt;"),
+            _ => out.push(c),
+        }
+    }
+    out
+}
+
+fn xml_unescape(s: &str) -> String {
+    s.replace("&lt;", "<")
+        .replace("&gt;", ">")
+        .replace("&quot;", "\"")
+        .replace("&apos;", "'")
+        .replace("&amp;", "&")
+}
+
+// ---------------------------------------------------------------------------
+// 段落 XML 构建(与 Python 逐字一致)
+// ---------------------------------------------------------------------------
+
+#[allow(clippy::too_many_arguments)]
+fn rpr(
+    fonts: &str,
+    sz: Option<u32>,
+    sz_cs: Option<u32>,
+    u: Option<&str>,
+    lang: Option<&str>,
+    lang_ea: Option<&str>,
+) -> String {
+    let mut parts = String::new();
+    parts.push_str(fonts);
+    if let Some(s) = sz {
+        parts.push_str(&format!("<w:sz w:val=\"{}\"/>", s));
+    }
+    if let Some(s) = sz_cs {
+        parts.push_str(&format!("<w:szCs w:val=\"{}\"/>", s));
+    }
+    if let Some(u) = u {
+        parts.push_str(&format!("<w:u w:val=\"{}\"/>", u));
+    }
+    if let Some(l) = lang {
+        parts.push_str(&format!("<w:lang w:val=\"{}\"/>", l));
+    }
+    if let Some(l) = lang_ea {
+        parts.push_str(&format!("<w:lang w:eastAsia=\"{}\"/>", l));
+    }
+    format!("<w:rPr>{}</w:rPr>", parts)
+}
+
+fn run(text: &str, rpr_xml: &str, space_preserve: bool) -> String {
+    let sp = if space_preserve || text != text.trim() {
+        " xml:space=\"preserve\""
+    } else {
+        ""
+    };
+    format!(
+        "<w:r>{}<w:t{}>{}</w:t></w:r>",
+        rpr_xml,
+        sp,
+        xml_escape(text)
+    )
+}
+
+/// 取字符串中第一段连续数字,返回 (前缀, 数字, 后缀)
+fn split_first_digits(s: &str) -> (&str, &str, &str) {
+    match s.find(|c: char| c.is_ascii_digit()) {
+        Some(i0) => {
+            let rest = &s[i0..];
+            let len = rest
+                .find(|c: char| !c.is_ascii_digit())
+                .unwrap_or(rest.len());
+            (&s[..i0], &rest[..len], &rest[len..])
+        }
+        None => (s, "", ""),
+    }
+}
+
+fn motto_runs(motto: &str) -> Vec<(String, bool)> {
+    // 切分:词 token 与连续空格 token 交替
+    let mut tokens: Vec<(String, bool)> = Vec::new();
+    let mut buf = String::new();
+    let mut in_space = false;
+    for ch in motto.chars() {
+        if ch == ' ' {
+            if !in_space && !buf.is_empty() {
+                tokens.push((std::mem::take(&mut buf), true));
+            }
+            in_space = true;
+            buf.push(' ');
+        } else {
+            if in_space && !buf.is_empty() {
+                tokens.push((std::mem::take(&mut buf), false));
+            }
+            in_space = false;
+            buf.push(ch);
+        }
+    }
+    if !buf.is_empty() {
+        tokens.push((buf, !in_space));
+    }
+
+    // 空格 token:优先并入前一词尾部 1 个空格,其余为独立空格 run
+    let mut out: Vec<(String, bool)> = Vec::new();
+    for (text, is_word) in tokens {
+        if is_word {
+            out.push((text, true));
+        } else {
+            let mut n = text.len();
+            if let Some(last) = out.last_mut() {
+                if last.1 && !last.0.ends_with(' ') {
+                    last.0.push(' ');
+                    n -= 1;
+                }
+            }
+            for _ in 0..n {
+                out.push((" ".to_string(), false));
+            }
+        }
+    }
+    out
+}
+
+fn parse_tag(xml: &str, lt: usize) -> (u8, String, usize) {
+    let after = &xml[lt + 1..];
+    if after.starts_with("!--") {
+        // 注释
+        let end = xml[lt..].find("-->").map(|e| lt + e + 3).unwrap_or(xml.len());
+        return (0, String::new(), end);
+    }
+    if after.starts_with('!') || after.starts_with('?') {
+        let end = xml[lt..].find('>').map(|e| lt + e + 1).unwrap_or(xml.len());
+        return (0, String::new(), end);
+    }
+    // 闭合标签跳过 '/'
+    let is_close = after.starts_with('/');
+    let name_start = lt + 1 + usize::from(is_close);
+    // 读名称至结束字符
+    let mut name_end = name_start;
+    while name_end < xml.len() {
+        let c = xml.as_bytes()[name_end];
+        if matches!(c, b'>' | b'/' | b' ' | b'\t' | b'\n' | b'\r') {
+            break;
+        }
+        name_end += 1;
+    }
+    let name = xml[name_start..name_end].to_string();
+    // 找标签结束 '>'
+    let gt = xml[name_end..].find('>').map(|e| name_end + e).unwrap_or(xml.len());
+    let before_gt = xml[name_end..gt].trim_end();
+    let self_closing = !is_close && before_gt.ends_with('/');
+    let end = gt + 1;
+    if is_close {
+        (2, name, end)
+    } else if self_closing {
+        (3, name, end)
+    } else {
+        (1, name, end)
+    }
+}
+
+/// 在 xml 的 lt 处(指向 '<')读取一个完整元素,返回 (名称, 完整片段)。
+/// 用栈配对处理同名嵌套。
+fn read_element(xml: &str, lt: usize) -> Option<(String, &str)> {
+    let (kind, name, mut pos) = parse_tag(xml, lt);
+    if kind == 0 {
+        // 注释/特殊:已跳过到结束
+        return Some(("__special__".to_string(), &xml[lt..pos]));
+    }
+    if kind == 3 {
+        return Some((name, &xml[lt..pos]));
+    }
+    if kind == 2 {
+        return None; // 意外的闭合标签
+    }
+    let mut stack: Vec<String> = vec![name.clone()];
+    while pos < xml.len() {
+        let rel = xml[pos..].find('<')?;
+        let lt2 = pos + rel;
+        let (k2, n2, e2) = parse_tag(xml, lt2);
+        if k2 == 1 {
+            stack.push(n2);
+        } else if k2 == 2 {
+            if stack.last() == Some(&n2) {
+                stack.pop();
+                if stack.is_empty() {
+                    return Some((name, &xml[lt..e2]));
+                }
+            }
+        }
+        // k2==0/3 无需处理
+        pos = e2;
+    }
+    None
+}
+
+/// 枚举 content 中深度 1 的完整元素,返回 (名称, 起始, 结束)
+fn top_level_spans(content: &str) -> Vec<(String, usize, usize)> {
+    let mut out = Vec::new();
+    let mut pos = 0usize;
+    while pos < content.len() {
+        match content[pos..].find('<') {
+            None => break,
+            Some(rel) => {
+                let lt = pos + rel;
+                match read_element(content, lt) {
+                    Some((name, span)) => {
+                        out.push((name, lt, lt + span.len()));
+                        pos = lt + span.len();
+                    }
+                    None => break,
+                }
+            }
+        }
+    }
+    out
+}
+
+/// 提取片段内全部 w:t 文本(递归遍历所有后代元素;对应 Python 的 iter('w:t'))
+fn text_of(xml: &str) -> String {
+    // 输入为一个完整元素:取开标签之后、闭标签之前的内容
+    fn content_of(s: &str) -> &str {
+        let Some(gt) = s.find('>') else {
+            return "";
+        };
+        // 自闭合元素无内容
+        let trimmed_tail = s[gt + 1..].trim_end();
+        if trimmed_tail.starts_with("/>") || s[gt + 1..].trim_start().starts_with('/') {
+            return "";
+        }
+        // 非自闭合:最后一个 "</" 前的部分即内容
+        match s.rfind("</") {
+            Some(cl) => {
+                if s[cl..].ends_with('>') {
+                    &s[gt + 1..cl]
+                } else {
+                    ""
+                }
+            }
+            None => "",
+        }
+    }
+    fn walk(content: &str, out: &mut String) {
+        let mut pos = 0usize;
+        while pos < content.len() {
+            let Some(rel) = content[pos..].find('<') else {
+                return;
+            };
+            let lt = pos + rel;
+            if content[lt..].starts_with("</") {
+                return;
+            }
+            match read_element(content, lt) {
+                Some((n, span)) => {
+                    if n == "w:t" {
+                        let gt = span.find('>').unwrap();
+                        if let Some(cl) = span.rfind("</w:t>") {
+                            if cl > gt + 1 {
+                                out.push_str(&xml_unescape(&span[gt + 1..cl]));
+                            }
+                        }
+                    } else {
+                        walk(content_of(span), out);
+                    }
+                    pos = lt + span.len();
+                }
+                None => return,
+            }
+        }
+    }
+    let mut out = String::new();
+    walk(content_of(xml), &mut out);
+    out
+}
+
+/// 页面数据
+struct PageData<'a> {
+    class_num: &'a str,
+    student: &'a str,
+    motto: &'a str,
+}
+
+/// 变换上下文
+struct Ctx<'a> {
+    page: &'a PageData<'a>,
+    docpr: u32,
+    in_name_run: bool,
+    depth: usize,
+}
+
+/// 判断 run 的**直接子** rPr 是否含 <w:sz w:val="200"/>。
+/// (不能扫整个 run:外层 anchor run 内部嵌套着文本框,里面的 sz=200 会误伤)
+fn run_has_sz200(xml: &str) -> bool {
+    let open_end = xml.find('>').map(|e| e + 1).unwrap_or(xml.len());
+    let content = &xml[open_end..];
+    // 直接子元素序列:rPr 是第一个子元素
+    let Some(rel) = content.find('<') else {
+        return false;
+    };
+    if content[rel..].starts_with("</") {
+        return false;
+    }
+    match read_element(content, rel) {
+        Some((n, span)) => n == "w:rPr" && span.contains("w:val=\"200\""),
+        None => false,
+    }
+}
+
+/// 核心编辑:输入为一个完整元素片段,输出编辑后的字符串。
+/// 规则(与 Python 逐条对齐:姓名 → 班级 → 座右铭 → docPr 编号):
+///   - w:r 且其直接子 rPr 含 <w:sz w:val="200"/> → 其中 w:t 文本 = 学生名
+///   - w:t 文本恰为 "31" → 班级数字
+///   - w:p 拼接文本含"脚踏实地"且不含"坚持" → 删除直接子 run,重建座右铭 runs
+///   - wp:docPr → id/name 重编号
+fn edit(xml: &str, ctx: &mut Ctx) -> String {
+    ctx.depth += 1;
+    if ctx.depth > 1000 {
+        panic!("edit 递归过深 depth={} xml={}", ctx.depth, &xml[..xml.len().min(60)]);
+    }
+    let r = edit_inner(xml, ctx);
+    ctx.depth -= 1;
+    r
+}
+
+fn edit_inner(xml: &str, ctx: &mut Ctx) -> String {
+    let (kind, name, _) = parse_tag(xml, 0);
+    if kind != 1 {
+        // 注释/自闭合/其他:自闭合 docPr 需要重编号
+        return handle_special(xml, ctx);
+    }
+    let local = name.rsplit(':').next().unwrap_or(&name).to_string();
+
+    match local.as_str() {
+        "t" => edit_text(xml, ctx),
+        "p" => edit_paragraph(xml, ctx),
+        "r" => {
+            let old_in = ctx.in_name_run;
+            if run_has_sz200(xml) {
+                ctx.in_name_run = true;
+            }
+            let out = edit_children(xml, ctx);
+            ctx.in_name_run = old_in;
+            out
+        }
+        _ => edit_children(xml, ctx),
+    }
+}
+
+/// 非容器元素的定向处理(docPr 编号;其余原样)
+fn handle_special(xml: &str, ctx: &mut Ctx) -> String {
+    let (kind, name, _) = parse_tag(xml, 0);
+    if kind != 3 {
+        return xml.to_string();
+    }
+    if name.contains(":docPr") || name == "docPr" {
+        return renumber_docpr(xml, ctx);
+    }
+    xml.to_string()
+}
+
+/// wp:docPr id/name 重编号:保留其余属性,只覆盖 id/name(与 Python set 语义一致)
+fn renumber_docpr(xml: &str, ctx: &mut Ctx) -> String {
+    // 标签形态:<wp:docPr id="2" name="文本框 2"/>
+    // 或可能的 <wp:docPr name="文本框 2" id="2"/> 等顺序
+    let name_end = xml[1..]
+        .find(|c: char| c == ' ' || c == '\t' || c == '/' || c == '>')
+        .map(|e| e + 1)
+        .unwrap_or(xml.len());
+    let tag_name = &xml[1..name_end];
+    // 从 name_end 到标签尾部(排除 '>' 与可能的前置 '/')
+    let tail = &xml[name_end..];
+    let gt = tail.find('>').unwrap_or(tail.len());
+    let attr_region = tail[..gt].trim_end_matches('/');
+
+    // 拆属性对 k="v"(规范机器输出)
+    let mut kept = Vec::new();
+    let mut rest = attr_region;
+    while let Some(eq) = rest.find('=') {
+        let key_end = rest[..eq].trim_end();
+        let key = key_end.rsplit(|c: char| c == ' ' || c == '\t').next().unwrap_or(key_end);
+        let after = &rest[eq + 1..];
+        if after.starts_with('"') {
+            if let Some(q) = after[1..].find('"') {
+                let val = &after[1..1 + q];
+                if key != "id" && key != "name" {
+                    kept.push((key.trim().to_string(), val.to_string()));
+                }
+                rest = &after[2 + q..];
+                continue;
+            }
+        }
+        rest = &after;
+    }
+
+    ctx.docpr += 1;
+    let id = ctx.docpr;
+    let mut attrs = String::new();
+    for (k, v) in kept {
+        attrs.push_str(&format!(" {}=\"{}\"", k, v));
+    }
+    format!(
+        "<{}{} id=\"{}\" name=\"文本框 {}\"/>",
+        tag_name, attrs, id, id
+    )
+}
+
+/// 处理一个段落(可能递归到文本框内的子段落)
+fn edit_paragraph(xml: &str, ctx: &mut Ctx) -> String {
+    let (_, name, _) = parse_tag(xml, 0);
+    let open_end = xml.find('>').map(|e| e + 1).unwrap_or(xml.len());
+    let close_marker = format!("</{}>", name);
+    let close_start = xml.rfind(&close_marker).unwrap_or(xml.len());
+    let head = &xml[..open_end];
+    let content = &xml[open_end..close_start];
+
+    // 座右铭判断(整段拼接文本)
+    let all_text = text_of(&format!("<w:p>{}</w:p>", content));
+    let is_motto = all_text.contains("脚踏实地") && !all_text.contains("坚持");
+
+    if is_motto {
+        // 重建:保留 head,pPr 等子元素(除直接 run),尾部追加新 runs
+        let mut out = String::new();
+        out.push_str(head);
+        let mut seg = content;
+        while !seg.is_empty() {
+            match seg.find('<') {
+                None => {
+                    out.push_str(seg);
+                    break;
+                }
+                Some(li) => {
+                    out.push_str(&seg[..li]);
+                    let rest = &seg[li..];
+                    if rest.starts_with("</") {
+                        out.push_str(rest);
+                        break;
+                    }
+                    match read_element(rest, 0) {
+                        Some((n, span)) => {
+                            if n == "w:r" {
+                                // 直接 run:丢弃
+                            } else {
+                                let sub = edit(span, ctx);
+                                out.push_str(&sub);
+                            }
+                            seg = &rest[span.len()..];
+                        }
+                        None => {
+                            out.push_str(rest);
+                            break;
+                        }
+                    }
+                }
+            }
+        }
+        // 追加重建的座右铭 runs
+        let mut runs = String::new();
+        for (text, is_word) in motto_runs(ctx.page.motto) {
+            if is_word {
+                runs.push_str(&run(
+                    &text,
+                    &rpr(FONT_HEI_HINT, Some(52), Some(96), None, None, None),
+                    false,
+                ));
+            } else {
+                runs.push_str(&run(
+                    &text,
+                    &rpr(FONT_HEI, Some(52), Some(96), None, None, None),
+                    true,
+                ));
+            }
+        }
+        out.push_str(&runs);
+        out.push_str(&close_marker);
+        return out;
+    }
+
+    edit_children(xml, ctx)
+}
+
+/// 通用:对元素的子节点逐个 edit
+fn edit_children(xml: &str, ctx: &mut Ctx) -> String {
+    let (_, name, _) = parse_tag(xml, 0);
+    let open_end = xml.find('>').map(|e| e + 1).unwrap_or(xml.len());
+    let close_marker = format!("</{}>", name);
+    let close_start = xml.rfind(&close_marker).unwrap_or(xml.len());
+    let head = &xml[..open_end];
+    let content = &xml[open_end..close_start];
+
+    let mut out = String::new();
+    out.push_str(head);
+    let mut seg = content;
+    while !seg.is_empty() {
+        match seg.find('<') {
+            None => {
+                out.push_str(seg);
+                break;
+            }
+            Some(li) => {
+                out.push_str(&seg[..li]);
+                let rest = &seg[li..];
+                if rest.starts_with("</") {
+                    out.push_str(rest);
+                    break;
+                }
+                match read_element(rest, 0) {
+                    Some((_, span)) => {
+                        // w:t 文本替换在 edit 内按 ctx 处理
+                        let sub = edit(span, ctx);
+                        out.push_str(&sub);
+                        seg = &rest[span.len()..];
+                    }
+                    None => {
+                        out.push_str(rest);
+                        break;
+                    }
+                }
+            }
+        }
+    }
+    out.push_str(&close_marker);
+    out
+}
+
+/// w:t 文本替换统一入口
+fn edit_text(xml: &str, ctx: &mut Ctx) -> String {
+    let (_, name, _) = parse_tag(xml, 0);
+    let gt = xml.find('>').unwrap();
+    let close = format!("</{}>", name);
+    let content_end = xml.rfind(&close).unwrap();
+    let inner = &xml[gt + 1..content_end];
+    if ctx.in_name_run {
+        // 姓名 run 的 w:t → 学生名(无条件,与 Python 相同)
+        return format!("{}{}{}", &xml[..gt + 1], xml_escape(ctx.page.student), &close);
+    }
+    // 班级数字:文本恰为模板默认 "31" → 班级数字
+    if inner == DEFAULT_CLASS {
+        return format!("{}{}{}", &xml[..gt + 1], xml_escape(ctx.page.class_num), &close);
+    }
+    xml.to_string()
+}
+
+// ---------------------------------------------------------------------------
+// 模板复制 + 页面生成(与 Python build_document_xml 对齐)
+// ---------------------------------------------------------------------------
+
+pub fn build_document_xml(
+    template_xml: &str,
+    pages: &[(String, String, String)],
+) -> Result<String, String> {
+    let body_open = template_xml
+        .find("<w:body>")
+        .ok_or_else(|| "模板中找不到 <w:body>".to_string())?;
+    let body_content_start = body_open + "<w:body>".len();
+    let body_close = template_xml
+        .rfind("</w:body>")
+        .ok_or_else(|| "模板中找不到 </w:body>".to_string())?;
+    let body_inner = &template_xml[body_content_start..body_close];
+
+    let spans = top_level_spans(body_inner);
+
+    // body 级段落(w:p)
+    let paras: Vec<(usize, usize)> = spans
+        .iter()
+        .filter(|(n, _, _)| n == "w:p")
+        .map(|(_, s, e)| (*s, *e))
+        .collect();
+
+    // 第一个含 wp:anchor 的段落
+    let mut anchor_i: Option<usize> = None;
+    for (i, (s, e)) in paras.iter().enumerate() {
+        if body_inner[*s..*e].contains(":anchor") {
+            anchor_i = Some(i);
+            break;
+        }
+    }
+    let ai = anchor_i.ok_or_else(|| {
+        "模板中找不到姓名牌文本框(wp:anchor),请检查模板文件".to_string()
+    })?;
+
+    // 页面单元 = 该段 + 前一个段落(如存在)
+    let start_i = if ai > 0 { ai - 1 } else { ai };
+    let unit: Vec<(usize, usize)> = paras[start_i..=ai].to_vec();
+
+    // body 级末尾 sectPr
+    let body_sectpr: Option<(usize, usize)> = spans
+        .iter()
+        .filter(|(n, _, _)| n == "w:sectPr")
+        .map(|(_, s, e)| (*s, *e))
+        .last();
+
+    // 组装新 body(docPr 编号全程连续,与 Python 一致)
+    let mut new_body = String::new();
+    let mut docpr: u32 = 0;
+    for (cls_display, student, motto) in pages {
+        let (_, class_num, _) = split_first_digits(cls_display);
+        let page = PageData {
+            class_num,
+            student,
+            motto,
+        };
+        let mut ctx = Ctx {
+            page: &page,
+            docpr,
+            in_name_run: false,
+            depth: 0,
+        };
+        for &(s, e) in &unit {
+            let span = &body_inner[s..e];
+            new_body.push_str(&edit(span, &mut ctx));
+        }
+        docpr = ctx.docpr;
+    }
+    if let Some((s, e)) = body_sectpr {
+        new_body.push_str(&body_inner[s..e]);
+    }
+
+    Ok(format!(
+        "{}{}{}",
+        &template_xml[..body_content_start],
+        new_body,
+        &template_xml[body_close..]
+    ))
+}
+
+// ---------------------------------------------------------------------------
+// Excel 名单读取(与 read_roster 对齐)
+// ---------------------------------------------------------------------------
+
+pub fn read_roster(
+    xlsx_path: &Path,
+    sheet: Option<&str>,
+) -> Result<(Vec<(Option<String>, String)>, bool), String> {
+    let (_, rows) = read_sheet(xlsx_path, sheet)?;
+    if rows.is_empty() {
+        return Err("工作表为空".to_string());
+    }
+    let header: Vec<String> = rows[0]
+        .iter()
+        .map(|c| c.display().trim().to_string())
+        .collect();
+
+    fn find_col(header: &[String], keys: &[&str]) -> Option<usize> {
+        for (i, h) in header.iter().enumerate() {
+            let hl = h.to_lowercase();
+            if keys.iter().any(|k| hl.contains(k)) {
+                return Some(i);
+            }
+        }
+        None
+    }
+
+    let cls_col = find_col(&header, &["班级", "班别", "班号", "class", "班"]);
+    let mut name_col = find_col(&header, &["姓名", "名字", "name", "学生"]);
+    if name_col.is_none() {
+        name_col = Some(0);
+    }
+    let data_start = if cls_col.is_some() || name_col.is_some() { 1 } else { 0 };
+
+    let mut roster = Vec::new();
+    for r in rows.iter().skip(data_start) {
+        let name = r
+            .get(name_col.unwrap())
+            .map(|c| c.display())
+            .unwrap_or_default()
+            .trim()
+            .to_string();
+        if name.is_empty() {
+            continue;
+        }
+        let cls = cls_col.map(|i| {
+            r.get(i)
+                .map(|c| c.display())
+                .unwrap_or_default()
+                .trim()
+                .to_string()
+        });
+        roster.push((cls, name));
+    }
+    Ok((roster, cls_col.is_some()))
+}
+
+/// 班级显示名规范化:'31' → '高三31班';'31班' → '高三31班';已含'高三…班'原样
+pub fn normalize_class_display(cls: &str) -> Option<String> {
+    let s = cls.trim();
+    if s.is_empty() {
+        return None;
+    }
+    if let Some(i0) = s.find(|c: char| c.is_ascii_digit()) {
+        let rest = &s[i0..];
+        let num_end = rest
+            .find(|c: char| !c.is_ascii_digit())
+            .unwrap_or(rest.len());
+        let num = &rest[..num_end];
+        if s.contains("高三") && s.contains("班") {
+            return Some(s.to_string());
+        }
+        return Some(format!("高三{}班", num));
+    }
+    Some(s.to_string())
+}
+
+/// 输出文件名用班级标识:'高三31班' → '31班'
+pub fn class_file_key(cls_display: &str) -> String {
+    let (_, num, _) = split_first_digits(cls_display);
+    if num.is_empty() {
+        cls_display.to_string()
+    } else {
+        format!("{}班", num)
+    }
+}
+
+// ---------------------------------------------------------------------------
+// 主流程(与 main() 对齐,返回日志)
+// ---------------------------------------------------------------------------
+
+pub struct NameplateInput {
+    pub roster_path: PathBuf,
+    /// --班级:单列名单(无班级列)时指定
+    pub class_override: Option<String>,
+    pub motto: String,
+    /// --模板:手动指定(存在才使用;保留模板仅对手动模板生效,与 Python 一致)
+    pub template: Option<PathBuf>,
+    /// 自动查找模板的额外目录(程序目录、当前目录按顺序自动查)
+    pub auto_dirs: Vec<PathBuf>,
+    pub sheet: Option<String>,
+    pub out_dir: PathBuf,
+    /// --保留模板:手动指定模板时把模板复制到输出目录
+    pub keep_template: bool,
+}
+
+pub struct NameplateResult {
+    pub log: Vec<String>,
+    pub files: Vec<PathBuf>,
+}
+
+pub fn run_nameplate(inp: &NameplateInput) -> Result<NameplateResult, String> {
+    let mut log: Vec<String> = Vec::new();
+
+    if !inp.roster_path.exists() {
+        return Err(format!("找不到输入文件: {}", inp.roster_path.display()));
+    }
+    std::fs::create_dir_all(&inp.out_dir)
+        .map_err(|e| format!("创建输出目录失败: {}", e))?;
+
+    log.push(format!("读取名单: {}", inp.roster_path.display()));
+    let (roster, has_cls_col) = read_roster(&inp.roster_path, inp.sheet.as_deref())
+        .map_err(|e| format!("读取名单失败: {}", e))?;
+    let roster_total = roster.len();
+    if roster.is_empty() {
+        return Err("名单为空(未识别到数据行)".to_string());
+    }
+
+    // 按班级分组(保持首次出现顺序,与 OrderedDict 一致)
+    let mut groups: Vec<(String, Vec<String>)> = Vec::new();
+    for (cls, name) in roster {
+        let display = if has_cls_col {
+            cls.as_deref().map(normalize_class_display)
+        } else {
+            inp.class_override.as_deref().map(normalize_class_display)
+        };
+        match display {
+            Some(Some(d)) => {
+                if let Some((_, names)) = groups.iter_mut().find(|(d0, _)| d0 == &d) {
+                    names.push(name);
+                } else {
+                    groups.push((d, vec![name]));
+                }
+            }
+            _ => {
+                if !has_cls_col && inp.class_override.is_none() {
+                    return Err("名单中没有\"班级\"列,请用 --班级 参数指定班级".to_string());
+                }
+                log.push(format!("警告: 跳过无班级的学生 {}", name));
+            }
+        }
+    }
+    if groups.is_empty() {
+        return Err("没有可生成的学生".to_string());
+    }
+
+    // 模板解析(与 Python 优先级一致):
+    //   1. --模板 指定(存在才用)
+    //   2. auto_dirs(程序目录/当前目录)下的 姓名牌_模板.docx
+    //   3. 内置版式构建到输出目录
+    let manual = inp.template.as_ref().filter(|p| p.exists());
+    let auto = if manual.is_none() {
+        inp.auto_dirs
+            .iter()
+            .map(|d| d.join("姓名牌_模板.docx"))
+            .find(|p| p.exists())
+    } else {
+        None
+    };
+    let (template_path, from_manual) = match (manual, auto) {
+        (Some(p), _) => (p.clone(), true),
+        (None, Some(p)) => {
+            log.push(format!("使用模板: {}", p.display()));
+            (p, false)
+        }
+        (None, None) => {
+            log.push("未找到模板文件,使用内置默认模板".to_string());
+            let tpl = inp.out_dir.join("姓名牌_模板.docx");
+            std::fs::write(&tpl, DEFAULT_TEMPLATE_BYTES)
+                .map_err(|e| format!("写内置模板失败: {}", e))?;
+            log.push(format!("内置模板已输出到: {}", tpl.display()));
+            (tpl, false)
+        }
+    };
+    if from_manual {
+        log.push(format!("使用模板: {}", template_path.display()));
+        if inp.keep_template {
+            let dst = inp.out_dir.join("姓名牌_模板.docx");
+            std::fs::copy(&template_path, &dst).map_err(|e| format!("复制模板失败: {}", e))?;
+            log.push(format!("已复制模板到: {}", dst.display()));
+        }
+    }
+
+    // 读模板 document.xml
+    let template_bytes =
+        read_zip_entry(&template_path, "word/document.xml").map_err(|e| e.to_string())?;
+    let template_xml = String::from_utf8(template_bytes)
+        .map_err(|_| "模板 document.xml 不是合法 UTF-8".to_string())?;
+
+    // 逐班生成
+    let mut files = Vec::new();
+    for (cls_display, names) in &groups {
+        let pages: Vec<(String, String, String)> = names
+            .iter()
+            .map(|n| (cls_display.clone(), n.clone(), inp.motto.clone()))
+            .collect();
+        let out_path = inp
+            .out_dir
+            .join(format!("姓名牌_{}_输出.docx", class_file_key(cls_display)));
+        log.push(format!(
+            "生成 {}: {} 人 -> {}",
+            cls_display,
+            names.len(),
+            out_path.display()
+        ));
+        let new_xml = build_document_xml(&template_xml, &pages)?;
+        copy_and_replace_zip(&template_path, &out_path, "word/document.xml", new_xml.as_bytes())
+            .map_err(|e| e.to_string())?;
+        files.push(out_path);
+    }
+
+    log.push(format!("完成。共 {} 个班,{} 人。", groups.len(), roster_total));
+    Ok(NameplateResult { log, files })
+}
+
+fn read_zip_entry(zip_path: &Path, entry: &str) -> Result<Vec<u8>, String> {
+    let f = std::fs::File::open(zip_path).map_err(|e| e.to_string())?;
+    let mut z = zip::ZipArchive::new(f).map_err(|e| e.to_string())?;
+    let mut file = z
+        .by_name(entry)
+        .map_err(|_| format!("zip 中找不到 {}", entry))?;
+    let mut buf = Vec::new();
+    file.read_to_end(&mut buf).map_err(|e| e.to_string())?;
+    Ok(buf)
+}
+
+/// 复制模板 zip,替换指定条目内容
+fn copy_and_replace_zip(
+    src: &Path,
+    dst: &Path,
+    replace_entry: &str,
+    new_content: &[u8],
+) -> Result<(), String> {
+    let f = std::fs::File::open(src).map_err(|e| e.to_string())?;
+    let mut zin = zip::ZipArchive::new(f).map_err(|e| e.to_string())?;
+    let out = std::fs::File::create(dst).map_err(|e| e.to_string())?;
+    let mut zout = zip::ZipWriter::new(out);
+    // 固定 zip 条目时间戳:输出可复现(否则每次运行秒数不同,字节不一致)
+    let opts = SimpleFileOptions::default()
+        .compression_method(zip::CompressionMethod::Deflated)
+        .last_modified_time(zip::DateTime::default());
+
+    for i in 0..zin.len() {
+        let mut item = zin.by_index(i).map_err(|e| e.to_string())?;
+        let name = item.name().to_string();
+        let mut data = Vec::new();
+        item.read_to_end(&mut data).map_err(|e| e.to_string())?;
+        let content: &[u8] = if name == replace_entry {
+            new_content
+        } else {
+            &data
+        };
+        zout.start_file(name, opts).map_err(|e| e.to_string())?;
+        zout.write_all(content).map_err(|e| e.to_string())?;
+    }
+    zout.finish().map_err(|e| e.to_string())?;
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn class_display_normalize() {
+        assert_eq!(normalize_class_display("31").as_deref(), Some("高三31班"));
+        assert_eq!(normalize_class_display("31班").as_deref(), Some("高三31班"));
+        assert_eq!(normalize_class_display("高三31班").as_deref(), Some("高三31班"));
+        assert_eq!(normalize_class_display("实验班").as_deref(), Some("实验班"));
+        assert_eq!(normalize_class_display("   "), None);
+        // Python 原版:任何带数字的班都会补成"高三N班",含 高一5班 也一样(保真)
+    }
+
+    #[test]
+    fn file_key() {
+        assert_eq!(class_file_key("高三31班"), "31班");
+        assert_eq!(class_file_key("实验班"), "实验班");
+    }
+
+    #[test]
+    fn motto_runs_split() {
+        // 默认座右铭:词+尾随空格,词间空格合并后独立成 run
+        let runs = motto_runs("脚踏实地  知行合一  行稳致远");
+        let texts: Vec<(&str, bool)> = runs.iter().map(|(t, w)| (t.as_str(), *w)).collect();
+        assert_eq!(
+            texts,
+            vec![
+                ("脚踏实地 ", true),
+                (" ", false),
+                ("知行合一 ", true),
+                (" ", false),
+                ("行稳致远", true),
+            ]
+        );
+        // 前导空格:逐字符独立空格 run(Python 逐 n 个 push 单空格)
+        let runs = motto_runs("  天道酬勤");
+        assert_eq!(runs.iter().filter(|(_, w)| !w).count(), 2);
+        assert_eq!(runs.last().unwrap().0, "天道酬勤");
+        // 无空格
+        let runs = motto_runs("天道酬勤");
+        assert_eq!(runs.len(), 1);
+        assert_eq!(runs[0], ("天道酬勤".to_string(), true));
+        // 连续多空格
+        let runs = motto_runs("a     b");
+        let n_spaces = runs.iter().filter(|(_, w)| !w).count();
+        assert_eq!(n_spaces, 4);
+    }
+
+    #[test]
+    fn engine_smoke_builtin() {
+        // 用内嵌默认模板过一遍整条引擎链路(真实学校模板文件)
+        let mut z = zip::ZipArchive::new(std::io::Cursor::new(DEFAULT_TEMPLATE_BYTES)).unwrap();
+        let mut f = z.by_name("word/document.xml").unwrap();
+        let mut s = String::new();
+        std::io::Read::read_to_string(&mut f, &mut s).unwrap();
+        let pages = vec![
+            ("高三31班".to_string(), "李四".to_string(), "天道酬勤 厚积薄发".to_string()),
+            ("实验班".to_string(), "王五".to_string(), DEFAULT_MOTTO.to_string()),
+        ];
+        let out = build_document_xml(&s, &pages).expect("引擎应成功");
+        // 真实模板每页有 n 套姓名(mc:Choice 与 fallback 各一份),n≥2 且为偶数;
+        // 各替换结果应同比例出现:第 1 页李四/天道酬勤,第 2 页王五/默认座右铭
+        let n = out.matches(">李四<").count();
+        assert!(n >= 2 && n % 2 == 0, "每页姓名套数异常: {}", n);
+        assert_eq!(out.matches(">王五<").count(), n);
+        assert_eq!(out.matches(">天道酬勤 ").count(), n);
+        assert_eq!(out.matches(">厚积薄发<").count(), n);
+        // 脚踏实地/行稳致远只应出现在第 2 页(第 1 页已被替换)
+        assert_eq!(out.matches(">脚踏实地 ").count(), n);
+        assert_eq!(out.matches(">行稳致远<").count(), n);
+        // docPr 编号:每页两个文本框,全程唯一
+        let docprs: Vec<usize> = out
+            .match_indices("wp:docPr id=\"")
+            .map(|(i, _)| out[i + 13..].chars().take_while(|c| c.is_ascii_digit()).count())
+            .collect();
+        assert_eq!(docprs.len(), n);
+    }
+
+    #[test]
+    fn xml_escape_works() {
+        assert_eq!(xml_escape("a&b<c>d"), "a&amp;b&lt;c&gt;d");
+        assert_eq!(xml_unescape("a&amp;b&lt;c&gt;d"), "a&b<c>d");
+    }
+}

+ 434 - 0
core/src/scoretable.rs

@@ -0,0 +1,434 @@
+//! 小分表(分类汇总):移植自 小分表/python版/main.py 的数据逻辑,行为保真。
+//! 注意怪癖保留:勾选"包含表头"实际是拿**第二行**当表头(header=1);
+//! 关键字表含 'CLASSS' 拼写错误;总分非数字行静默丢弃。
+
+use crate::xlsx::{read_sheet, Cell};
+use rust_xlsxwriter::{Color, Format, FormatBorder, Workbook};
+use std::path::{Path, PathBuf};
+
+/// Python 关键字顺序(含 'CLASSS' 拼写错误,保真保留)
+const GROUP_KEYWORDS: [&str; 5] = ["班级", "班", "class", "Class", "CLASSS"];
+
+/// 8 个浅色(Python QColor 与 RGB 同色值)
+pub const PALETTE: [[u8; 3]; 8] = [
+    [255, 200, 200],
+    [200, 255, 200],
+    [200, 200, 255],
+    [255, 255, 200],
+    [255, 200, 255],
+    [200, 255, 255],
+    [255, 220, 200],
+    [220, 200, 255],
+];
+
+pub struct ScoreTableInput {
+    pub path: PathBuf,
+    /// true = 勾选"包含表头"(第二行当表头,第三行起为数据)
+    pub has_header: bool,
+    /// 忽略列文本,如 "1,2,3,8,9"
+    pub ignore_cols_text: String,
+    /// 班级颜色分组文本,如 "[1,2,30,31],[3,4,...]"
+    pub color_groups_text: String,
+    /// 总分阈值
+    pub threshold: u32,
+}
+
+/// 读取表头(供分组列下拉框):按 has_header 语义
+pub fn load_headers(path: &Path, has_header: bool) -> Result<Vec<String>, String> {
+    let (_, rows) = read_sheet(path, None)?;
+    if rows.is_empty() {
+        return Err("工作表为空".to_string());
+    }
+    let header_idx = if has_header { 1 } else { 0 };
+    if rows.len() <= header_idx {
+        return Err("工作表没有数据行".to_string());
+    }
+    let max_col = rows.iter().map(|r| r.len()).max().unwrap_or(0);
+    Ok((0..max_col)
+        .map(|c| rows[header_idx].get(c).map(|v| v.display()).unwrap_or_default())
+        .collect())
+}
+
+/// 按 Python 关键字顺序找分组列
+pub fn auto_group_col(headers: &[String]) -> usize {
+    for kw in GROUP_KEYWORDS {
+        for (i, h) in headers.iter().enumerate() {
+            if h.contains(kw) {
+                return i;
+            }
+        }
+    }
+    0
+}
+
+/// 汇总结果表(过滤后)
+pub struct SummaryTable {
+    /// 列名:第 0 列 = 分组列
+    pub headers: Vec<String>,
+    /// 数值列在 headers 中的位置
+    pub num_cols: Vec<usize>,
+    /// 每行显示文本(与 Python str() 对齐):[分组列文本, 各均值文本]
+    pub rows: Vec<Vec<String>>,
+    /// 每行数值([分组列数值(可无), 各均值 f64])
+    pub nums: Vec<Vec<Option<f64>>>,
+    /// 每行班级颜色组索引(None = 无色)
+    pub color_idx: Vec<Option<usize>>,
+}
+
+/// 处理主流程(对齐 process_data → display_result)
+pub fn process(inp: &ScoreTableInput) -> Result<SummaryTable, String> {
+    process_with_group(inp, None)
+}
+
+/// 处理主流程;group_override 为 GUI 下拉选择的原始列号(与 Python combo 对应)
+pub fn process_with_group(
+    inp: &ScoreTableInput,
+    group_override: Option<usize>,
+) -> Result<SummaryTable, String> {
+    let (_, rows) = read_sheet(&inp.path, None).map_err(|e| e.to_string())?;
+    if rows.is_empty() {
+        return Err("工作表为空".to_string());
+    }
+    let max_col = rows.iter().map(|r| r.len()).max().unwrap_or(0);
+
+    // 表头行与数据起始
+    let (header_idx, data_start) = if inp.has_header { (1, 2) } else { (0, 1) };
+    if rows.len() <= header_idx {
+        return Err("工作表没有数据行".to_string());
+    }
+    let headers: Vec<String> = (0..max_col)
+        .map(|c| rows[header_idx].get(c).map(|v| v.display()).unwrap_or_default())
+        .collect();
+
+    // 忽略列(解析失败回默认 1,2,3,8,9)
+    let parsed: Vec<Option<usize>> = inp
+        .ignore_cols_text
+        .split(',')
+        .map(|s| s.trim().parse::<usize>().ok())
+        .collect();
+    let ignore_ok = !inp.ignore_cols_text.trim().is_empty()
+        && !parsed.is_empty()
+        && parsed.iter().all(|r| r.is_some());
+    let mut ignore: Vec<usize> = if ignore_ok {
+        parsed.into_iter().map(|r| r.unwrap()).collect()
+    } else {
+        vec![1, 2, 3, 8, 9]
+    };
+
+    // 分组列:下拉选择优先,否则按关键字自动选择(与 Python 一致)
+    let group_col = match group_override {
+        Some(i) if i < headers.len() => i,
+        _ => auto_group_col(&headers),
+    };
+    ignore.retain(|&c| c != group_col);
+
+    // 过滤后坐标映射
+    let valid_cols: Vec<usize> = (0..max_col).filter(|i| !ignore.contains(i)).collect();
+    if valid_cols.is_empty() {
+        return Err("没有可显示的列(全部被忽略)".to_string());
+    }
+    let f_headers: Vec<String> = valid_cols.iter().map(|&i| headers[i].clone()).collect();
+    let f_group_col = valid_cols.iter().position(|&i| i == group_col).unwrap();
+    // 总分列(过滤后名称含"总分",取第一个)
+    let f_score_col = f_headers.iter().position(|h| h.contains("总分"));
+
+    // 数据行 → 过滤后单元格(跳过整行全空)
+    let mut data: Vec<Vec<Cell>> = Vec::new();
+    for r in rows.iter().skip(data_start) {
+        let cells: Vec<Cell> = valid_cols
+            .iter()
+            .map(|&ci| r.get(ci).cloned().unwrap_or(Cell::Empty))
+            .collect();
+        if cells.iter().all(|c| c.is_empty()) {
+            continue;
+        }
+        data.push(cells);
+    }
+
+    // 数值列判定(在行过滤**之前**,复刻 pandas 读取时 dtype:
+    // 列内一旦出现过文本/缺考等,整列即非数值,即使之后该行被过滤)
+    let mut num_cols: Vec<usize> = Vec::new();
+    for c in 0..f_headers.len() {
+        if c == f_group_col {
+            continue;
+        }
+        let all_num = data.iter().all(|r| match &r[c] {
+            Cell::Empty => true,
+            Cell::Float(_) => true,
+            _ => false,
+        });
+        if all_num && data.iter().any(|r| !matches!(&r[c], Cell::Empty)) {
+            num_cols.push(c);
+        }
+    }
+
+    // 总分阈值过滤:仅保留"数值且 ≥ 阈值"的行(空/非数值行丢弃)
+    if let Some(sc) = f_score_col {
+        data.retain(|r| matches!(&r[sc], Cell::Float(v) if *v >= inp.threshold as f64));
+    }
+
+    if num_cols.is_empty() {
+        return Err("错误:没有找到数值列进行汇总".to_string());
+    }
+
+    // 颜色分组
+    let color_groups = parse_color_groups(&inp.color_groups_text);
+
+    // 输出表头(与 Python 一致:过滤后 分组列 + 数值列)
+    let mut out_headers = Vec::with_capacity(num_cols.len() + 1);
+    out_headers.push(f_headers[f_group_col].clone());
+    for &nc in &num_cols {
+        out_headers.push(f_headers[nc].clone());
+    }
+
+    // 分组:首见顺序;数值列均值(空值跳过,与 pandas mean 一致)
+    struct G {
+        disp: String,
+        num: Option<f64>,
+        sums: Vec<(f64, usize)>,
+    }
+    let mut groups: Vec<G> = Vec::new();
+    for r in &data {
+        let gval = r[f_group_col].display();
+        if gval.trim().is_empty() {
+            continue; // pandas groupby 丢弃 NaN 组键
+        }
+        let gnum = match &r[f_group_col] {
+            Cell::Float(v) => Some(*v),
+            _ => gval.trim().parse::<f64>().ok(),
+        };
+        let gi = match groups.iter().position(|g| g.disp == gval) {
+            Some(i) => i,
+            None => {
+                groups.push(G {
+                    disp: gval.clone(),
+                    num: gnum,
+                    sums: vec![(0.0, 0); num_cols.len()],
+                });
+                groups.len() - 1
+            }
+        };
+        for (k, &nc) in num_cols.iter().enumerate() {
+            if let Cell::Float(v) = r[nc] {
+                if v.is_finite() {
+                    groups[gi].sums[k].0 += v;
+                    groups[gi].sums[k].1 += 1;
+                }
+            }
+        }
+    }
+    if groups.is_empty() {
+        // 阈值过滤后无数据:pandas 仍返回空表(仅表头),不报错
+        return Ok(SummaryTable {
+            headers: out_headers,
+            num_cols: (1..=num_cols.len()).collect(),
+            rows: Vec::new(),
+            nums: Vec::new(),
+            color_idx: Vec::new(),
+        });
+    }
+
+    // 均值 round(2):pandas 用银行家舍入(遇 5 取偶),Rust round() 是远离零,需 ties-even
+    let round2 = |v: f64| (v * 100.0).round_ties_even() / 100.0;
+    let means_of = |g: &G| -> Vec<Option<f64>> {
+        g.sums
+            .iter()
+            .map(|(s, c)| if *c == 0 { None } else { Some(round2(s / *c as f64)) })
+            .collect()
+    };
+
+    // 排序(分组列:全部数值 → 数值序;否则字符串序)
+    let all_num = groups.iter().all(|g| g.num.is_some());
+    let mut order: Vec<usize> = (0..groups.len()).collect();
+    if all_num {
+        order.sort_by(|&a, &b| {
+            groups[a]
+                .num
+                .partial_cmp(&groups[b].num)
+                .unwrap_or(std::cmp::Ordering::Equal)
+        });
+    } else {
+        order.sort_by(|&a, &b| groups[a].disp.cmp(&groups[b].disp));
+    }
+
+    // 输出
+    let mut out_rows: Vec<Vec<String>> = Vec::new();
+    let mut out_nums: Vec<Vec<Option<f64>>> = Vec::new();
+    let mut color_idx: Vec<Option<usize>> = Vec::new();
+    for &oi in &order {
+        let g = &groups[oi];
+        let means = means_of(g);
+        let mut row = vec![g.disp.clone()];
+        let mut nums_row = vec![g.num];
+        for m in &means {
+            row.push(m.map(py_float_display).unwrap_or_else(|| "nan".to_string()));
+            nums_row.push(*m);
+        }
+        let ci = g
+            .num
+            .and_then(|n| color_groups.iter().position(|grp| grp.iter().any(|&x| x as f64 == n)));
+        color_idx.push(ci);
+        out_rows.push(row);
+        out_nums.push(nums_row);
+    }
+
+    Ok(SummaryTable {
+        headers: out_headers,
+        num_cols: (1..=num_cols.len()).collect(),
+        rows: out_rows,
+        nums: out_nums,
+        color_idx,
+    })
+}
+
+/// Python float 显示:整数值尾缀 .0
+fn py_float_display(v: f64) -> String {
+    if v.fract() == 0.0 && v.abs() < 9.0e15 {
+        format!("{}.0", v as i64)
+    } else {
+        format!("{}", v)
+    }
+}
+
+/// 解析班级颜色分组 "[1,2,30,31],[3,4]" → 组列表(解析失败返回空)
+pub fn parse_color_groups(text: &str) -> Vec<Vec<u32>> {
+    let t = text.trim();
+    if t.is_empty() {
+        return Vec::new();
+    }
+    // Python 包装成 "[{text}]" 再 ast 解析;失败(非法字符/括号不配对)→ 空
+    // 先做括号配平校验,镜像 ast 语法失败
+    if !t.starts_with('[') || !t.ends_with(']') {
+        return Vec::new();
+    }
+    let mut depth = 0i32;
+    for c in t.chars() {
+        match c {
+            '[' => depth += 1,
+            ']' => {
+                depth -= 1;
+                if depth < 0 {
+                    return Vec::new();
+                }
+            }
+            _ => {}
+        }
+    }
+    if depth != 0 {
+        return Vec::new();
+    }
+    let inner = t.trim_start_matches('[').trim_end_matches(']');
+    let mut out = Vec::new();
+    for part in inner.split("],[") {
+        let p = part.trim().trim_start_matches('[').trim_end_matches(']');
+        if p.trim().is_empty() {
+            continue;
+        }
+        let mut g = Vec::new();
+        for x in p.split(',') {
+            match x.trim().parse::<u32>() {
+                Ok(v) => g.push(v),
+                Err(_) => return Vec::new(),
+            }
+        }
+        out.push(g);
+    }
+    out
+}
+
+// ---------------------------------------------------------------------------
+// 导出 Excel(对齐 export_to_excel:颜色、边框、列宽)
+// ---------------------------------------------------------------------------
+
+pub fn export_xlsx(table: &SummaryTable, path: &Path) -> Result<(), String> {
+    let mut wb = Workbook::new();
+    let ws = wb.add_worksheet().set_name("Sheet1").map_err(|e| e.to_string())?;
+
+    // 表头:细边框(Python 导出未设字体)
+    let header_fmt = Format::new().set_border(FormatBorder::Thin);
+    for (c, h) in table.headers.iter().enumerate() {
+        ws.write_string_with_format(0, c as u16, h, &header_fmt)
+            .map_err(|e| e.to_string())?;
+    }
+
+    // 数据行:组底色 + 细边框(表头行只有边框,与 Python 一致)
+    for (r, row) in table.rows.iter().enumerate() {
+        let color = table.color_idx[r]
+            .filter(|&i| i < PALETTE.len())
+            .map(|i| {
+                let [x, y, z] = PALETTE[i];
+                Color::RGB(((x as u32) << 16) | ((y as u32) << 8) | z as u32)
+            });
+        for (c, cell) in row.iter().enumerate() {
+            let mut f = Format::new().set_border(FormatBorder::Thin);
+            if let Some(col) = color {
+                f = f.set_background_color(col);
+            }
+            let nums = &table.nums[r];
+            let num = nums.get(c).copied().flatten();
+            match num {
+                Some(v) => {
+                    ws.write_number_with_format(r as u32 + 1, c as u16, v, &f)
+                        .map_err(|e| e.to_string())?;
+                }
+                None => {
+                    ws.write_string_with_format(r as u32 + 1, c as u16, cell, &f)
+                        .map_err(|e| e.to_string())?;
+                }
+            }
+        }
+    }
+
+    // 列宽:最大内容长度 + 2
+    for c in 0..table.headers.len() {
+        let mut max_len = table.headers[c].chars().count();
+        for row in &table.rows {
+            if let Some(v) = row.get(c) {
+                max_len = max_len.max(v.chars().count());
+            }
+        }
+        ws.set_column_width(c as u16, (max_len + 2) as f64)
+            .map_err(|e| e.to_string())?;
+    }
+
+    wb.save(path).map_err(|e| format!("保存失败 {}: {}", path.display(), e))?;
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn color_groups_parse() {
+        assert_eq!(
+            parse_color_groups("[1,2,30,31],[3,4,5]"),
+            vec![vec![1, 2, 30, 31], vec![3, 4, 5]]
+        );
+        assert_eq!(parse_color_groups("[1],[2]"), vec![vec![1], vec![2]]);
+        assert_eq!(parse_color_groups("abc"), Vec::<Vec<u32>>::new());
+        assert_eq!(parse_color_groups(""), Vec::<Vec<u32>>::new());
+        assert_eq!(parse_color_groups("[1,2"), Vec::<Vec<u32>>::new());
+    }
+
+    #[test]
+    fn group_col_auto() {
+        let h = vec!["学号".to_string(), "班主任".to_string(), "总分".to_string()];
+        // '班主任' 含 '班',与 Python 关键字行为一致
+        assert_eq!(auto_group_col(&h), 1);
+        let h2 = vec!["姓名".to_string(), "班级".to_string(), "语文".to_string()];
+        assert_eq!(auto_group_col(&h2), 1);
+        let h3 = vec!["A".to_string(), "B".to_string()];
+        assert_eq!(auto_group_col(&h3), 0);
+        // 'CLASSS' 拼写错误在最后,匹配优先级不影响
+        let h4 = vec!["CLASS".to_string()];
+        assert_eq!(auto_group_col(&h4), 0);
+    }
+
+    #[test]
+    fn py_float_display_works() {
+        assert_eq!(py_float_display(85.33), "85.33");
+        assert_eq!(py_float_display(85.0), "85.0");
+        assert_eq!(py_float_display(69.8), "69.8");
+    }
+}

+ 698 - 0
core/src/seating.rs

@@ -0,0 +1,698 @@
+//! 换座位:移植自 generate_seating.py,行为完全保真。
+//! 唯一不保真的部分:random 策略的具体随机序列(Python 的 Mersenne
+//! Twister 无需复刻)——但"同一种子 → 同一结果可复现"得到保留。
+
+use crate::xlsx::read_sheet;
+use rust_xlsxwriter::{Color, Format, FormatAlign, FormatBorder, Workbook};
+use std::path::{Path, PathBuf};
+
+/// 学生记录(与 Python 的 dict 对齐)
+#[derive(Debug, Clone)]
+pub struct Student {
+    pub name: String,
+    pub gender: String,
+    pub score: f64,
+}
+
+/// 成绩配对策略
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Strategy {
+    /// good-with-good 好+好
+    GoodWithGood,
+    /// good-with-bad 好+差
+    GoodWithBad,
+    /// random 与成绩无关
+    Random,
+}
+
+impl Default for Strategy {
+    /// 与 Python CLI 默认一致:good-with-good
+    fn default() -> Self {
+        Strategy::GoodWithGood
+    }
+}
+
+impl Strategy {
+    pub fn as_str(self) -> &'static str {
+        match self {
+            Strategy::GoodWithGood => "good-with-good",
+            Strategy::GoodWithBad => "good-with-bad",
+            Strategy::Random => "random",
+        }
+    }
+    pub fn label(self) -> &'static str {
+        match self {
+            Strategy::GoodWithGood => "好+好",
+            Strategy::GoodWithBad => "好+差",
+            Strategy::Random => "与成绩无关",
+        }
+    }
+}
+
+/// 输入参数(与 Python CLI 参数一一对应)
+#[derive(Debug, Clone)]
+pub struct SeatingInput {
+    pub excel_path: PathBuf,
+    /// 工作表名,None = 第一个
+    pub sheet: Option<String>,
+    pub class_name: String,
+    /// 样式串,如 "2222" / "3332"
+    pub style: String,
+    pub strategy: Strategy,
+    pub allow_mixed: bool,
+    /// 生成个数
+    pub count: usize,
+    /// 种子:None = 运行时随机
+    pub seed: Option<u64>,
+    /// 输出基础路径:None = 输入同目录 <stem>_<班级>_座位表.xlsx
+    pub output: Option<PathBuf>,
+}
+
+pub struct SeatingResult {
+    /// 与 Python 打印一致的过程日志(GUI 直接展示)
+    pub log: Vec<String>,
+    pub files: Vec<PathBuf>,
+}
+
+// ---------------------------------------------------------------------------
+// 读取学生
+// ---------------------------------------------------------------------------
+
+const REQUIRED: [&str; 4] = ["班级", "姓名", "性别", "总分"];
+
+fn is_male(gender: &str) -> bool {
+    matches!(gender, "男" | "M" | "m" | "male" | "Male")
+}
+
+fn is_female(gender: &str) -> bool {
+    matches!(gender, "女" | "F" | "f" | "female" | "Female")
+}
+
+/// 从 Excel 读取学生并按班级筛选;自动查找表头行(前 10 行)。
+fn read_students(
+    path: &Path,
+    class_name: &str,
+    sheet: Option<&str>,
+) -> Result<Vec<Student>, String> {
+    let (_, rows) = read_sheet(path, sheet)?;
+    let max_col = rows.iter().map(|r| r.len()).max().unwrap_or(0);
+    let rows_ref: Vec<Vec<String>> = rows
+        .iter()
+        .map(|row| {
+            (0..max_col)
+                .map(|c| {
+                    row.get(c)
+                        .map(|v| v.display())
+                        .unwrap_or_default()
+                        .trim()
+                        .to_string()
+                })
+                .collect()
+        })
+        .collect();
+
+    // 自动查找包含所有必需列的表头行(最多扫描前 10 行)
+    let mut header_row: Option<usize> = None;
+    let mut indices: Vec<Option<usize>> = vec![None; 4];
+    for (i, row) in rows_ref.iter().take(rows_ref.len().min(10)).enumerate() {
+        if REQUIRED.iter().all(|col| row.iter().any(|v| v == col)) {
+            for (ci, col) in REQUIRED.iter().enumerate() {
+                indices[ci] = row.iter().position(|v| v == col);
+            }
+            header_row = Some(i);
+            break;
+        }
+    }
+
+    let Some(header_row) = header_row else {
+        let mut msg = "错误:Excel文件中未找到 '班级'、'姓名'、'性别'、'总分' 列。".to_string();
+        for (i, r) in rows_ref.iter().take(rows_ref.len().min(6)).enumerate() {
+            msg.push_str(&format!("  第{}行:{:?}\n", i + 1, r));
+        }
+        return Err(msg);
+    };
+
+    // 规范化班级名:去除"班"后缀以便灵活匹配
+    let class_name_clean: String = class_name.trim_end_matches('班').to_string();
+    let data_start = header_row + 1;
+
+    let mut students = Vec::new();
+    for row in rows_ref.iter().skip(data_start) {
+        let class_val: String = row[indices[0].unwrap()].trim_end_matches('班').to_string();
+        if class_val != class_name_clean {
+            continue;
+        }
+        let name = row[indices[1].unwrap()].trim().to_string();
+        if name.is_empty() {
+            continue;
+        }
+        let gender = row[indices[2].unwrap()].trim().to_string();
+        let score = row[indices[3].unwrap()].trim().parse::<f64>().unwrap_or(0.0);
+
+        students.push(Student {
+            name,
+            gender,
+            score,
+        });
+    }
+    Ok(students)
+}
+
+// ---------------------------------------------------------------------------
+// 分桌逻辑
+// ---------------------------------------------------------------------------
+
+/// 将按分数降序排列的列表重新排列为 高-低-高-低 交错顺序。
+/// 与 Python:左右双指针逐个取走完全一致。
+fn interleave_high_low(items: &[Student]) -> Vec<Student> {
+    let mut result = Vec::with_capacity(items.len());
+    let mut left = 0usize;
+    let mut right = items.len();
+    while left < right {
+        result.push(items[left].clone());
+        left += 1;
+        if left < right {
+            right -= 1;
+            result.push(items[right].clone());
+        }
+    }
+    result
+}
+
+/// 根据样式字符串构建桌组大小列表(可能比需要多,分配时按人数截断)。
+fn build_desk_groups(style: &str, num_students: usize) -> Result<Vec<usize>, String> {
+    let mut sizes = Vec::new();
+    for c in style.chars() {
+        let s = c.to_digit(10).ok_or_else(|| {
+            format!("错误:样式中的数字必须大于0,发现 '{}'", c)
+        })? as usize;
+        if s < 1 {
+            return Err(format!("错误:样式中的数字必须大于0,发现 '{}'", c));
+        }
+        sizes.push(s);
+    }
+    let row_capacity: usize = sizes.iter().sum();
+    let num_rows = if num_students == 0 {
+        1
+    } else {
+        (num_students + row_capacity - 1) / row_capacity
+    };
+
+    let mut desk_groups = Vec::new();
+    for _ in 0..num_rows {
+        desk_groups.extend_from_slice(&sizes);
+    }
+    Ok(desk_groups)
+}
+
+/// 将学生分配到桌组(与 Python allocate_students 逐行对齐)。
+fn allocate_students(
+    students: &[Student],
+    desk_sizes: &[usize],
+    strategy: Strategy,
+    allow_mixed: bool,
+    seed: u64,
+) -> Vec<Vec<Student>> {
+    let mut males: Vec<Student> = students.iter().filter(|s| is_male(&s.gender)).cloned().collect();
+    let mut females: Vec<Student> =
+        students.iter().filter(|s| is_female(&s.gender)).cloned().collect();
+    let others: Vec<Student> = students
+        .iter()
+        .filter(|s| !is_male(&s.gender) && !is_female(&s.gender))
+        .cloned()
+        .collect();
+
+    // 无法识别性别的归入人数多的一方
+    if !others.is_empty() {
+        if males.len() >= females.len() {
+            males.extend(others);
+        } else {
+            females.extend(others);
+        }
+    }
+
+    // 按分数降序排序(稳定,同分保持名单顺序 —— Python sort 同样稳定)
+    fn sort_desc(v: &mut Vec<Student>) {
+        v.sort_by(|a, b| {
+            b.score
+                .partial_cmp(&a.score)
+                .unwrap_or(std::cmp::Ordering::Equal)
+        });
+    }
+
+    // --- 允许混坐:所有学生一起排 ---
+    if allow_mixed {
+        let mut all = males;
+        all.append(&mut females);
+        match strategy {
+            Strategy::GoodWithGood => sort_desc(&mut all),
+            Strategy::GoodWithBad => {
+                sort_desc(&mut all);
+                all = interleave_high_low(&all);
+            }
+            Strategy::Random => {
+                let mut rng = Rng::new(seed);
+                rng.shuffle(&mut all);
+            }
+        }
+
+        let mut groups: Vec<Vec<Student>> = Vec::new();
+        let mut idx = 0usize;
+        for &size in desk_sizes {
+            if idx >= all.len() {
+                break;
+            }
+            let end = (idx + size).min(all.len());
+            let group = all[idx..end].to_vec();
+            if !group.is_empty() {
+                groups.push(group);
+            }
+            idx = end;
+        }
+        return groups;
+    }
+
+    // --- 性别分开排 ---
+    match strategy {
+        Strategy::GoodWithGood => {
+            sort_desc(&mut males);
+            sort_desc(&mut females);
+        }
+        Strategy::GoodWithBad => {
+            sort_desc(&mut males);
+            males = interleave_high_low(&males);
+            sort_desc(&mut females);
+            females = interleave_high_low(&females);
+        }
+        Strategy::Random => {
+            let mut rng = Rng::new(seed);
+            rng.shuffle(&mut males);
+            rng.shuffle(&mut females);
+        }
+    }
+
+    let mut groups: Vec<Vec<Student>> = Vec::new();
+    let mut m_idx = 0usize;
+    let mut f_idx = 0usize;
+
+    for &size in desk_sizes {
+        let m_rem = males.len() - m_idx;
+        let f_rem = females.len() - f_idx;
+        let (group, m_used, f_used) = if m_rem >= f_rem {
+            let take = size.min(m_rem);
+            (males[m_idx..m_idx + take].to_vec(), take, 0)
+        } else {
+            let take = size.min(f_rem);
+            (females[f_idx..f_idx + take].to_vec(), 0, take)
+        };
+        m_idx += m_used;
+        f_idx += f_used;
+        if !group.is_empty() {
+            groups.push(group);
+        }
+    }
+
+    groups
+}
+
+// ---------------------------------------------------------------------------
+// 生成座位表 Excel(与 generate_excel 对齐)
+// ---------------------------------------------------------------------------
+
+fn generate_excel(
+    groups: &[Vec<Student>],
+    style: &str,
+    output_path: &Path,
+    class_name: &str,
+) -> Result<(), String> {
+    let sizes: Vec<usize> = style.chars().map(|c| c.to_digit(10).unwrap() as usize).collect();
+    let num_groups_per_row = sizes.len();
+    let num_rows = if groups.is_empty() {
+        0
+    } else {
+        (groups.len() + num_groups_per_row - 1) / num_groups_per_row
+    };
+    let total_cols: usize = sizes.iter().sum::<usize>() + (num_groups_per_row - 1);
+
+    let mut wb = Workbook::new();
+    let ws = wb
+        .add_worksheet()
+        .set_name(format!("{}座位表", class_name))
+        .map_err(|e| format!("错误:工作表名称过长: {}", e))?;
+
+    // 格式构造(与 Python 样式一一对应;rust_xlsxwriter 的 setter 是链式消费)
+    // 学生姓名格基础:边框 + 居中 + 14pt
+    let name_base = Format::new()
+        .set_border(FormatBorder::Thin)
+        .set_align(FormatAlign::Center)
+        .set_align(FormatAlign::VerticalCenter)
+        .set_text_wrap()
+        .set_font_size(14.0);
+    let male_fill = name_base.clone().set_background_color(Color::RGB(0xD6EAF8));
+    let female_fill = name_base.clone().set_background_color(Color::RGB(0xFADBD8));
+    let plain_name = name_base; // 无底色姓名格(性别未知者)
+
+    // --- 讲台行(第 1 行):边框+灰底逐格预写(第 1 格留给合并写入),
+    // 再合并整行写文本(openpyxl 的边框逐格赋值 + merge_cells 等价) ---
+    let podium_cell = Format::new()
+        .set_border(FormatBorder::Thin)
+        .set_background_color(Color::RGB(0xD5D8DC));
+    for c in 1..total_cols {
+        ws.write_blank(0, c as u16, &podium_cell).map_err(|e| e.to_string())?;
+    }
+    let podium_text = Format::new()
+        .set_border(FormatBorder::Thin)
+        .set_background_color(Color::RGB(0xD5D8DC))
+        .set_align(FormatAlign::Center)
+        .set_align(FormatAlign::VerticalCenter)
+        .set_text_wrap()
+        .set_bold()
+        .set_font_size(14.0);
+    ws.merge_range(0, 0, 0, (total_cols - 1) as u16, "讲  台", &podium_text)
+        .map_err(|e| e.to_string())?;
+    ws.set_row_height(0, 35.0).map_err(|e| e.to_string())?;
+
+    // --- 学生座位(第 3 行起,第 2 行留空,与 Python 一致) ---
+    // 空座位:只有边框,无内容无对齐
+    let border = Format::new().set_border(FormatBorder::Thin);
+    let mut group_idx = 0usize;
+    for row_num in 0..num_rows {
+        let excel_row = row_num + 2; // 0 基:第 3 行 = idx 2
+        let mut col_offset = 0usize;
+
+        for g in 0..num_groups_per_row {
+            let size = sizes[g];
+            if group_idx < groups.len() {
+                let group = &groups[group_idx];
+                group_idx += 1;
+                for i in 0..size {
+                    let col = (col_offset + i) as u16;
+                    if i < group.len() {
+                        let s = &group[i];
+                        let fmt = if is_male(&s.gender) {
+                            &male_fill
+                        } else if is_female(&s.gender) {
+                            &female_fill
+                        } else {
+                            // Python 中性别未知者无底色(分配时虽并入多数方,
+                            // 但写单元格时按原始性别判断)
+                            &plain_name
+                        };
+                        ws.write_with_format(excel_row as u32, col, &s.name, fmt)
+                            .map_err(|e| e.to_string())?;
+                    } else {
+                        // 座位空(本组人数不足):只有边框
+                        ws.write_blank(excel_row as u32, col, &border)
+                            .map_err(|e| e.to_string())?;
+                    }
+                    ws.set_column_width(col, 13.0).map_err(|e| e.to_string())?;
+                }
+            } else {
+                for i in 0..size {
+                    let col = (col_offset + i) as u16;
+                    ws.write_blank(excel_row as u32, col, &border).map_err(|e| e.to_string())?;
+                    ws.set_column_width(col, 13.0).map_err(|e| e.to_string())?;
+                }
+            }
+            col_offset += size;
+
+            // 过道列
+            if g + 1 < num_groups_per_row {
+                ws.set_column_width(col_offset as u16, 4.0).map_err(|e| e.to_string())?;
+                col_offset += 1;
+            }
+        }
+        ws.set_row_height(excel_row as u32, 36.0).map_err(|e| e.to_string())?;
+    }
+
+    wb.save(output_path)
+        .map_err(|e| format!("保存失败 {}: {}", output_path.display(), e))
+}
+
+// ---------------------------------------------------------------------------
+// 确定性随机(替代 Python random;同种子结果可复现)
+// ---------------------------------------------------------------------------
+
+/// xorshift64*:极简确定性 RNG,足够学校规模的学生洗牌
+struct Rng(u64);
+
+impl Rng {
+    fn new(seed: u64) -> Self {
+        // seed=0 会退化为全零状态,给固定非零初值
+        Rng(if seed == 0 { 0x9E37_79B9_7F4A_7C15 } else { seed })
+    }
+
+    fn next_u64(&mut self) -> u64 {
+        let mut x = self.0;
+        x ^= x >> 12;
+        x ^= x << 25;
+        x ^= x >> 27;
+        self.0 = x;
+        x.wrapping_mul(0x2545_F491_4F6C_DD1D)
+    }
+
+    fn shuffle<T>(&mut self, v: &mut [T]) {
+        for i in (1..v.len()).rev() {
+            let j = (self.next_u64() as usize) % (i + 1);
+            v.swap(i, j);
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------
+// 主流程(与 main() 对齐,返回日志)
+// ---------------------------------------------------------------------------
+
+pub fn run_seating(inp: &SeatingInput) -> Result<SeatingResult, String> {
+    let mut log: Vec<String> = Vec::new();
+
+    // 校验样式:必须为纯数字且至少 2 位
+    if !inp.style.chars().all(|c| c.is_ascii_digit()) || inp.style.len() < 2 {
+        return Err("错误:样式必须为纯数字字符串,至少2位,如 '2222' 或 '3332'".to_string());
+    }
+
+    let input_path = &inp.excel_path;
+    if !input_path.exists() {
+        return Err(format!("错误:文件不存在:{}", input_path.display()));
+    }
+    if inp.count < 1 {
+        return Err("错误:生成个数必须大于0".to_string());
+    }
+
+    // 输出路径模板
+    let output_base: PathBuf = match &inp.output {
+        Some(p) => p.clone(),
+        None => {
+            let stem = input_path
+                .file_stem()
+                .map(|s| s.to_string_lossy().to_string())
+                .unwrap_or_default();
+            input_path
+                .parent()
+                .unwrap_or(Path::new("."))
+                .join(format!("{}_{}_座位表.xlsx", stem, inp.class_name))
+        }
+    };
+
+    // 读取学生(只读一次)
+    log.push(format!("正在读取 {} ...", input_path.display()));
+    let students = read_students(input_path, &inp.class_name, inp.sheet.as_deref())?;
+
+    let (m_count, f_count) = if students.is_empty() {
+        (0, 0)
+    } else {
+        (
+            students.iter().filter(|s| is_male(&s.gender)).count(),
+            students.iter().filter(|s| is_female(&s.gender)).count(),
+        )
+    };
+    if students.is_empty() {
+        log.push(format!(
+            "警告:班级 '{}' 中没有找到任何学生,将生成空座位表。",
+            inp.class_name
+        ));
+    } else {
+        log.push(format!(
+            "班级 '{}' 共有 {} 名学生(男:{},女:{})",
+            inp.class_name, students.len(), m_count, f_count
+        ));
+    }
+
+    // 构建桌组(只算一次)
+    let desk_sizes = build_desk_groups(&inp.style, students.len())?;
+
+    // 基础种子:未指定时随机
+    let base_seed = match inp.seed {
+        Some(s) => s,
+        None => {
+            use std::time::{SystemTime, UNIX_EPOCH};
+            let nanos = SystemTime::now()
+                .duration_since(UNIX_EPOCH)
+                .map(|d| d.as_nanos())
+                .unwrap_or(0);
+            (nanos % 1_000_000) as u64
+        }
+    };
+
+    let mut files = Vec::new();
+    let mut last_groups: Vec<Vec<Student>> = Vec::new();
+
+    for i in 0..inp.count {
+        let seed = base_seed + i as u64;
+        let output_path = if inp.count > 1 {
+            let stem = output_base
+                .file_stem()
+                .map(|s| s.to_string_lossy().to_string())
+                .unwrap_or_default();
+            let ext = output_base
+                .extension()
+                .map(|e| e.to_string_lossy().to_string())
+                .unwrap_or_else(|| "xlsx".to_string());
+            output_base
+                .parent()
+                .unwrap_or(Path::new("."))
+                .join(format!("{}_{}.{}", stem, i + 1, ext))
+        } else {
+            output_base.clone()
+        };
+
+        let groups = allocate_students(
+            &students,
+            &desk_sizes,
+            inp.strategy,
+            inp.allow_mixed,
+            seed,
+        );
+        last_groups = groups.clone();
+
+        generate_excel(&groups, &inp.style, &output_path, &inp.class_name)?;
+        log.push(format!("座位表已保存至:{}", output_path.display()));
+        files.push(output_path);
+    }
+
+    // 摘要(与 Python 末尾打印一致)
+    log.push(String::new());
+    log.push("座位表配置:".to_string());
+    log.push(format!(
+        "  样式:{}({}组,每组{}人)",
+        inp.style,
+        inp.style.len(),
+        inp.style.chars().map(|c| c.to_string()).collect::<Vec<_>>().join("-")
+    ));
+    log.push(format!("  策略:{}", inp.strategy.label()));
+    log.push(format!(
+        "  男女生同桌:{}",
+        if inp.allow_mixed { "允许" } else { "不允许" }
+    ));
+    log.push(format!("  生成个数:{}", inp.count));
+    let rows = if groups_is_empty(&last_groups) {
+        0
+    } else {
+        (last_groups.len() + inp.style.len() - 1) / inp.style.len()
+    };
+    log.push(format!("  总桌组数:{},总排数:{}", last_groups.len(), rows));
+
+    Ok(SeatingResult { log, files })
+}
+
+fn groups_is_empty(groups: &[Vec<Student>]) -> bool {
+    groups.is_empty()
+}
+
+// 供测试直接调用的内部接口
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn interleave_works() {
+        let make = |names: &[&str]| {
+            names
+                .iter()
+                .map(|n| Student {
+                    name: n.to_string(),
+                    gender: "男".to_string(),
+                    score: 0.0,
+                })
+                .collect::<Vec<_>>()
+        };
+        let items = make(&["a", "b", "c", "d", "e"]);
+        let out = interleave_high_low(&items);
+        let names: Vec<&str> = out.iter().map(|s| s.name.as_str()).collect();
+        assert_eq!(names, vec!["a", "e", "b", "d", "c"]);
+
+        let items = make(&["a", "b", "c", "d"]);
+        let out = interleave_high_low(&items);
+        let names: Vec<&str> = out.iter().map(|s| s.name.as_str()).collect();
+        assert_eq!(names, vec!["a", "d", "b", "c"]);
+    }
+
+    #[test]
+    fn desk_groups_repeat_pattern() {
+        assert_eq!(build_desk_groups("22", 10).unwrap(), vec![2, 2, 2, 2, 2, 2]);
+        // style 3332,25 人:每行容量 11 → ceil(25/11)=3 行 → 12 组
+        assert_eq!(build_desk_groups("3332", 25).unwrap().len(), 12);
+    }
+
+    #[test]
+    fn allocation_separates_gender() {
+        // 3 男 2 女,样式 22(与真实流程一致:先 build_desk_groups 展开)
+        // 组1:男2;组2:女2(此时男剩1 女剩2 → 女先);组3:男1;组4 起全空
+        let students: Vec<Student> = ["男", "男", "男", "女", "女"]
+            .iter()
+            .enumerate()
+            .map(|(i, g)| Student {
+                name: format!("s{}", i),
+                gender: g.to_string(),
+                score: 50.0,
+            })
+            .collect();
+        let desk = build_desk_groups("22", students.len()).unwrap();
+        let groups = allocate_students(&students, &desk, Strategy::GoodWithGood, false, 1);
+        assert_eq!(groups.len(), 3);
+        assert!(groups[0].iter().all(|s| s.gender == "男"));
+        assert!(groups[1].iter().all(|s| s.gender == "女"));
+        assert!(groups[2].iter().all(|s| s.gender == "男"));
+    }
+
+    #[test]
+    fn allocation_mixed() {
+        let students: Vec<Student> = ["男", "女", "男", "女"]
+            .iter()
+            .enumerate()
+            .map(|(i, g)| Student {
+                name: format!("s{}", i),
+                gender: g.to_string(),
+                score: (i as f64) * 10.0,
+            })
+            .collect();
+        // 好+好 混坐:按分降序 3,2,1,0 → 组1 取 2 个
+        let groups = allocate_students(
+            &students,
+            &[2, 2],
+            Strategy::GoodWithGood,
+            true,
+            1,
+        );
+        assert_eq!(groups[0].iter().map(|s| s.name.clone()).collect::<Vec<_>>(), vec!["s3", "s2"]);
+    }
+
+    #[test]
+    fn seed_reproducible() {
+        let students: Vec<Student> = (0..20)
+            .map(|i| Student {
+                name: format!("s{}", i),
+                gender: if i % 2 == 0 { "男" } else { "女" }.to_string(),
+                score: i as f64,
+            })
+            .collect();
+        let a = allocate_students(&students, &[2, 2, 2], Strategy::Random, false, 42);
+        let b = allocate_students(&students, &[2, 2, 2], Strategy::Random, false, 42);
+        let key = |g: &Vec<Vec<Student>>| {
+            g.iter()
+                .map(|grp| grp.iter().map(|s| s.name.clone()).collect::<Vec<_>>().join(","))
+                .collect::<Vec<_>>()
+        };
+        assert_eq!(key(&a), key(&b));
+    }
+}

+ 159 - 0
core/src/xlsx.rs

@@ -0,0 +1,159 @@
+//! Excel 读取共享层:统一 .xlsx / .xls 的读取,提供与
+//! Python(openpyxl / pandas)对齐的单元格显示语义。
+//!
+//! 显示规则对齐 openpyxl(姓名牌/换座位用):
+//! - 空单元格 -> ""
+//! - 文本 -> 原样
+//! - 整数 / 整数值浮点(Excel 存储的 31 在 openpyxl 中是 int)-> "31"
+//! - 小数 -> 最短往返表示("86.5"、"86.55")
+
+use calamine::{Data, Range, Reader, Xls, Xlsx};
+use std::fs::File;
+use std::io::BufReader;
+use std::path::Path;
+
+pub type DataRange = Range<Data>;
+
+/// 单元格值(源自 calamine 的 Data,剥掉学校数据用不到的细节)
+#[derive(Debug, Clone, PartialEq)]
+pub enum Cell {
+    Empty,
+    Text(String),
+    Float(f64),
+    Bool(bool),
+    /// 日期时间等无法对齐 Python str() 语义的值,保留内部表示供错误提示
+    Other(String),
+}
+
+impl Cell {
+    /// openpyxl 风格显示:float 整数值显示为整数(与 openpyxl int 行为一致)
+    pub fn display(&self) -> String {
+        match self {
+            Cell::Empty => String::new(),
+            Cell::Text(s) => s.clone(),
+            Cell::Float(f) => {
+                if f.is_finite() && f.fract() == 0.0 && f.abs() < 9.0e15 {
+                    format!("{}", *f as i64)
+                } else {
+                    // Rust 与 Python 的浮点最短表示一致
+                    format!("{}", f)
+                }
+            }
+            Cell::Bool(b) => format!("{}", b),
+            Cell::Other(s) => s.clone(),
+        }
+    }
+
+    pub fn is_empty(&self) -> bool {
+        matches!(self, Cell::Empty) || matches!(self, Cell::Text(s) if s.trim().is_empty())
+    }
+}
+
+impl From<Data> for Cell {
+    fn from(d: Data) -> Self {
+        match d {
+            Data::Empty => Cell::Empty,
+            Data::String(s) => Cell::Text(s),
+            Data::Float(f) => Cell::Float(f),
+            Data::Int(i) => Cell::Float(i as f64),
+            Data::Bool(b) => Cell::Bool(b),
+            Data::DateTime(dt) => Cell::Other(dt.to_string()),
+            Data::DateTimeIso(s) => Cell::Other(s),
+            Data::DurationIso(s) => Cell::Other(s),
+            Data::Error(e) => Cell::Text(e.to_string()),
+        }
+    }
+}
+
+/// 工作表读取错误的统一中文文案(XlsxError / XlsError 类型不同,统一转 String)
+fn map_ws_err<E: std::fmt::Display>(res: Result<DataRange, E>, name: &str) -> Result<DataRange, String> {
+    res.map_err(|e| {
+        let msg = e.to_string();
+        if msg.contains(name) || msg.contains("not found") || msg.contains("cannot find") {
+            format!("找不到工作表: {}", name)
+        } else {
+            format!("读取工作表 '{}' 失败: {}", name, msg)
+        }
+    })
+}
+
+/// 统一 Xls / Xlsx 的最小接口(calamine 的 Reader 按具体类型参数化,这里包一层)
+enum AnyWorkbook {
+    Xlsx(Xlsx<BufReader<File>>),
+    Xls(Xls<BufReader<File>>),
+}
+
+impl AnyWorkbook {
+    fn sheet_names(&self) -> Vec<String> {
+        match self {
+            AnyWorkbook::Xlsx(w) => w.sheet_names().to_vec(),
+            AnyWorkbook::Xls(w) => w.sheet_names().to_vec(),
+        }
+    }
+
+    /// 读指定工作表;找不到时给中文报错
+    fn worksheet_range(&mut self, name: &str) -> Result<DataRange, String> {
+        let res: Result<DataRange, String> = match self {
+            AnyWorkbook::Xlsx(w) => map_ws_err(w.worksheet_range(name), name),
+            AnyWorkbook::Xls(w) => map_ws_err(w.worksheet_range(name), name),
+        };
+        res
+    }
+}
+
+/// 按扩展名分发 xls / xlsx(含 xlsm)
+fn open_workbook(path: &Path) -> Result<AnyWorkbook, String> {
+    let ext = path
+        .extension()
+        .and_then(|e| e.to_str())
+        .map(|s| s.to_ascii_lowercase())
+        .unwrap_or_default();
+
+    match ext.as_str() {
+        "xls" => {
+            let wb: Xls<BufReader<File>> =
+                calamine::open_workbook(path).map_err(|e| format!("打开文件失败: {}", e))?;
+            Ok(AnyWorkbook::Xls(wb))
+        }
+        "xlsx" | "xlsm" => {
+            let wb: Xlsx<BufReader<File>> =
+                calamine::open_workbook(path).map_err(|e| format!("打开文件失败: {}", e))?;
+            Ok(AnyWorkbook::Xlsx(wb))
+        }
+        other => Err(format!(
+            "不支持的文件类型: .{}(仅支持 .xlsx / .xls)",
+            other
+        )),
+    }
+}
+
+/// 打开工作簿并读取指定工作表为行数组。
+/// sheet: None = 第一个工作表;Some(name) = 按名字。
+/// 返回 (工作表名, 行数据)。
+pub fn read_sheet(path: &Path, sheet: Option<&str>) -> Result<(String, Vec<Vec<Cell>>), String> {
+    let mut wb = open_workbook(path)?;
+
+    let (name, range) = match sheet {
+        Some(name) => (name.to_string(), wb.worksheet_range(name)?),
+        None => {
+            let name = wb
+                .sheet_names()
+                .first()
+                .cloned()
+                .ok_or_else(|| "文件中没有工作表".to_string())?;
+            let range = wb.worksheet_range(&name)?;
+            (name, range)
+        }
+    };
+
+    let rows = range
+        .rows()
+        .map(|r| r.iter().map(|c| Cell::from(c.clone())).collect())
+        .collect();
+    Ok((name, rows))
+}
+
+/// 列出所有工作表名
+pub fn sheet_names(path: &Path) -> Result<Vec<String>, String> {
+    Ok(open_workbook(path)?.sheet_names())
+}

+ 18 - 0
gui/Cargo.toml

@@ -0,0 +1,18 @@
+[package]
+name = "toolbox-gui"
+version.workspace = true
+edition.workspace = true
+description = "工具箱 GUI 应用(eframe/egui)"
+
+[[bin]]
+name = "toolbox"
+path = "src/main.rs"
+
+[dependencies]
+ab_glyph = "0.2.32"
+eframe = "0.36.1"
+egui = "0.36"
+egui_extras = "0.36.1"
+image = { version = "0.25.10", default-features = false, features = ["png"] }
+rfd = "0.17.2"
+toolbox-core = { path = "../core" }

+ 138 - 0
gui/src/main.rs

@@ -0,0 +1,138 @@
+//! 工具箱 GUI:单窗口,顶部标签页切换三个工具。
+//! 结构:ToolboxApp 持有页面状态,每个工具一个页面模块。
+
+mod png_table;
+mod scoretable_page;
+mod seating_page;
+mod nameplate_page;
+
+use std::sync::Arc;
+
+fn main() -> eframe::Result<()> {
+    let options = eframe::NativeOptions {
+        viewport: egui::ViewportBuilder::default()
+            .with_inner_size([880.0, 640.0])
+            .with_min_inner_size([700.0, 500.0])
+            .with_title("工具箱"),
+        ..Default::default()
+    };
+    eframe::run_native(
+        "工具箱",
+        options,
+        Box::new(|cc| Ok(Box::new(ToolboxApp::new(cc)))),
+    )
+}
+
+/// 工具页注册表:加新工具 = 这里加一个变体 + 页面模块
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum ToolId {
+    Seating,
+    Nameplate,
+    ScoreTable,
+}
+
+impl ToolId {
+    fn label(self) -> &'static str {
+        match self {
+            ToolId::Seating => "换座位",
+            ToolId::Nameplate => "姓名牌",
+            ToolId::ScoreTable => "小分表",
+        }
+    }
+
+    const ALL: [ToolId; 3] = [ToolId::Seating, ToolId::Nameplate, ToolId::ScoreTable];
+}
+
+struct ToolboxApp {
+    selected: ToolId,
+    pages: Pages,
+}
+
+struct Pages {
+    seating: seating_page::SeatingPage,
+    nameplate: nameplate_page::NameplatePage,
+    scoretable: scoretable_page::ScoreTablePage,
+}
+
+impl ToolboxApp {
+    fn new(cc: &eframe::CreationContext<'_>) -> Self {
+        install_cjk_font(&cc.egui_ctx);
+        Self {
+            selected: ToolId::Seating,
+            pages: Pages {
+                seating: Default::default(),
+                nameplate: Default::default(),
+                scoretable: Default::default(),
+            },
+        }
+    }
+}
+
+impl eframe::App for ToolboxApp {
+    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
+        // 顶部标签页
+        egui::Panel::top("tabs").show(ui, |ui| {
+            ui.add_space(6.0);
+            ui.horizontal(|ui| {
+                ui.add_space(8.0);
+                for id in ToolId::ALL {
+                    let selected = self.selected == id;
+                    if ui
+                        .selectable_label(
+                            selected,
+                            egui::RichText::new(id.label()).size(16.0),
+                        )
+                        .clicked()
+                    {
+                        self.selected = id;
+                    }
+                    ui.add_space(4.0);
+                }
+            });
+            ui.add_space(6.0);
+        });
+
+        // 当前工具页
+        egui::CentralPanel::default().show(ui, |ui| {
+            match self.selected {
+                ToolId::Seating => self.pages.seating.ui(ui),
+                ToolId::Nameplate => self.pages.nameplate.ui(ui),
+                ToolId::ScoreTable => self.pages.scoretable.ui(ui),
+            }
+        });
+    }
+}
+
+/// 加载中文字体:Windows 找微软雅黑,Linux 依次尝试 Noto CJK / 霞鹜文楷 / simsun。
+/// 找不到时退回默认字体(中文会显示为方框,但程序不崩溃)。
+fn install_cjk_font(ctx: &egui::Context) {
+    let candidates: &[&str] = if cfg!(windows) {
+        &[
+            "C:\\Windows\\Fonts\\msyh.ttc",
+            "C:\\Windows\\Fonts\\simhei.ttf",
+        ]
+    } else {
+        &[
+            "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
+            "/usr/share/fonts/TTF/LXGWWenKai-Regular.ttf",
+            "/usr/share/fonts/TTF/simsun.ttc",
+            "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
+        ]
+    };
+
+    for path in candidates {
+        if let Ok(bytes) = std::fs::read(path) {
+            let mut fonts = egui::FontDefinitions::default();
+            fonts
+                .font_data
+                .insert("cjk".to_owned(), Arc::new(egui::FontData::from_owned(bytes)));
+            for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
+                if let Some(list) = fonts.families.get_mut(&family) {
+                    list.push("cjk".to_owned());
+                }
+            }
+            ctx.set_fonts(fonts);
+            return;
+        }
+    }
+}

+ 236 - 0
gui/src/nameplate_page.rs

@@ -0,0 +1,236 @@
+//! 姓名牌页面:名单 → 参数 → 批量生成 docx
+
+use std::path::PathBuf;
+use toolbox_core::nameplate::{run_nameplate, NameplateInput, DEFAULT_MOTTO};
+use toolbox_core::xlsx::sheet_names;
+
+#[derive(Default)]
+pub struct NameplatePage {
+    roster_path: String,
+    class_override: String,
+    motto: String,
+    template_path: String,
+    sheet: String,
+    out_dir: String,
+    keep_template: bool,
+    log: Vec<String>,
+}
+
+impl NameplatePage {
+    pub fn ui(&mut self, ui: &mut egui::Ui) {
+        egui::ScrollArea::vertical().show(ui, |ui| {
+            ui.add_space(8.0);
+
+            ui.horizontal(|ui| {
+                ui.label("名单文件:");
+                ui.add(
+                    egui::TextEdit::singleline(&mut self.roster_path)
+                        .desired_width(420.0)
+                        .hint_text("Excel 名单(.xlsx / .xls)"),
+                );
+                if ui.button("选择…").clicked() {
+                    if let Some(path) = rfd::FileDialog::new()
+                        .add_filter("Excel 文件", &["xlsx", "xls"])
+                        .pick_file()
+                    {
+                        self.roster_path = path.display().to_string();
+                        if self.out_dir.is_empty() {
+                            if let Some(parent) = path.parent() {
+                                self.out_dir = parent.display().to_string();
+                            }
+                        }
+                    }
+                }
+            });
+
+            ui.horizontal(|ui| {
+                ui.label("班级覆盖:");
+                ui.add(
+                    egui::TextEdit::singleline(&mut self.class_override)
+                        .desired_width(150.0)
+                        .hint_text("名单无班级列时填写,如 31"),
+                );
+                ui.add_space(16.0);
+                ui.label("工作表:");
+                ui.add(
+                    egui::TextEdit::singleline(&mut self.sheet)
+                        .desired_width(120.0)
+                        .hint_text("留空 = 第一个"),
+                );
+            });
+
+            ui.horizontal(|ui| {
+                ui.label("座右铭:");
+                ui.add(
+                    egui::TextEdit::singleline(&mut self.motto)
+                        .desired_width(420.0)
+                        .hint_text(DEFAULT_MOTTO),
+                );
+            });
+
+            ui.horizontal(|ui| {
+                ui.label("模板文件:");
+                ui.add(
+                    egui::TextEdit::singleline(&mut self.template_path)
+                        .desired_width(420.0)
+                        .hint_text("留空 = 自动查找或内置默认模板"),
+                );
+                if ui.button("选择…").clicked() {
+                    if let Some(path) = rfd::FileDialog::new()
+                        .add_filter("Word 模板", &["docx"])
+                        .pick_file()
+                    {
+                        self.template_path = path.display().to_string();
+                    }
+                }
+                if ui.button("清除").clicked() {
+                    self.template_path.clear();
+                }
+            });
+
+            ui.horizontal(|ui| {
+                ui.label("输出目录:");
+                ui.add(
+                    egui::TextEdit::singleline(&mut self.out_dir)
+                        .desired_width(420.0)
+                        .hint_text("留空 = 与名单同目录"),
+                );
+                if ui.button("选择…").clicked() {
+                    if let Some(dir) = rfd::FileDialog::new().pick_folder() {
+                        self.out_dir = dir.display().to_string();
+                    }
+                }
+                ui.checkbox(&mut self.keep_template, "把模板复制到输出目录");
+            });
+
+            ui.add_space(10.0);
+            ui.separator();
+            ui.add_space(4.0);
+
+            if ui.button("开始生成").clicked() {
+                self.run();
+            }
+
+            ui.add_space(8.0);
+            if !self.log.is_empty() {
+                egui::Frame::group(ui.style())
+                    .fill(ui.visuals().extreme_bg_color)
+                    .show(ui, |ui| {
+                        egui::ScrollArea::vertical()
+                            .max_height(240.0)
+                            .auto_shrink([false, false])
+                            .show(ui, |ui| {
+                                for line in &self.log {
+                                    ui.monospace(line);
+                                }
+                            });
+                    });
+            }
+            ui.add_space(12.0);
+        });
+    }
+
+    fn run(&mut self) {
+        self.log.clear();
+        let roster = PathBuf::from(self.roster_path.trim());
+        if self.roster_path.trim().is_empty() || !roster.exists() {
+            self.log.push(format!("找不到输入文件: {}", self.roster_path.trim()));
+            return;
+        }
+
+        // 默认输出目录 = 名单同目录
+        let out_dir = if self.out_dir.trim().is_empty() {
+            roster.parent().unwrap_or(PathBuf::from(".").as_path()).to_path_buf()
+        } else {
+            PathBuf::from(self.out_dir.trim())
+        };
+
+        // 默认座右铭
+        let motto = if self.motto.trim().is_empty() {
+            DEFAULT_MOTTO.to_string()
+        } else {
+            self.motto.trim().to_string()
+        };
+
+        // 工作表名校验
+        let sheet = {
+            let s = self.sheet.trim();
+            if s.is_empty() {
+                None
+            } else {
+                Some(s.to_string())
+            }
+        };
+        if let Some(n) = &sheet {
+            match sheet_names(&roster) {
+                Ok(names) => {
+                    if !names.iter().any(|x| x == n) {
+                        self.log.push(format!(
+                            "错误:工作表 '{}' 不存在。可用工作表:{:?}",
+                            n, names
+                        ));
+                        return;
+                    }
+                }
+                Err(e) => {
+                    self.log.push(e);
+                    return;
+                }
+            }
+        }
+
+        // 手动模板
+        let template = {
+            let s = self.template_path.trim();
+            if s.is_empty() {
+                None
+            } else if PathBuf::from(s).exists() {
+                Some(PathBuf::from(s))
+            } else {
+                self.log.push(format!("找不到模板: {}", s));
+                return;
+            }
+        };
+
+        // 自动查找目录:程序所在目录 + 当前目录
+        let mut auto_dirs: Vec<PathBuf> = Vec::new();
+        if let Ok(exe) = std::env::current_exe() {
+            if let Some(parent) = exe.parent() {
+                auto_dirs.push(parent.to_path_buf());
+            }
+        }
+        if let Ok(cwd) = std::env::current_dir() {
+            auto_dirs.push(cwd);
+        }
+
+        let inp = NameplateInput {
+            roster_path: roster,
+            class_override: {
+                let s = self.class_override.trim();
+                if s.is_empty() {
+                    None
+                } else {
+                    Some(s.to_string())
+                }
+            },
+            motto,
+            template,
+            auto_dirs,
+            sheet,
+            out_dir,
+            keep_template: self.keep_template,
+        };
+
+        match run_nameplate(&inp) {
+            Ok(res) => {
+                self.log.extend(res.log);
+                self.log.push("完成 ✓".to_string());
+            }
+            Err(e) => {
+                for line in e.lines() {
+                    self.log.push(line.to_string());
+                }
+            }
+        }
+    }
+}

+ 184 - 0
gui/src/png_table.rs

@@ -0,0 +1,184 @@
+//! 小分表"导出图片":把汇总表渲染成 PNG(等价于 Python matplotlib 表格导出:
+//! 表头白底、数据行按班级组浅色、黑色细边框、文字居中)。
+
+use ab_glyph::{Font, FontVec, PxScale};
+use toolbox_core::scoretable::{SummaryTable, PALETTE};
+
+/// 中文字体候选(与 main.rs 的 install_cjk_font 一致)
+fn load_font() -> Option<FontVec> {
+    let candidates: &[&str] = if cfg!(windows) {
+        &["C:\\Windows\\Fonts\\msyh.ttc", "C:\\Windows\\Fonts\\simhei.ttf"]
+    } else {
+        &[
+            "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
+            "/usr/share/fonts/TTF/LXGWWenKai-Regular.ttf",
+            "/usr/share/fonts/TTF/simsun.ttc",
+            "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
+        ]
+    };
+    for p in candidates {
+        if let Ok(bytes) = std::fs::read(p) {
+            if let Ok(f) = FontVec::try_from_vec(bytes) {
+                return Some(f);
+            }
+        }
+    }
+    None
+}
+
+const MARGIN: u32 = 24;
+
+/// 渲染汇总表为 PNG。返回错误信息或 ()
+pub fn render(table: &SummaryTable, path: &std::path::Path) -> Result<(), String> {
+    let font = load_font().ok_or("未找到中文字体文件,无法导出图片".to_string())?;
+
+    let ncols = table.headers.len();
+    let nrows = table.rows.len();
+    let cell_w = (3600u32 - MARGIN * 2) / ncols.max(1) as u32;
+    let cell_w = cell_w.clamp(220, 640);
+    let header_h = 150u32;
+    let row_h = 140u32;
+
+    let w = MARGIN * 2 + cell_w * ncols as u32;
+    let h = MARGIN * 2 + header_h + row_h * nrows as u32;
+
+    let mut img = vec![255u8; (w * h * 3) as usize]; // 白底 RGB
+
+    let set_px = |img: &mut [u8], x: i32, y: i32, c: [u8; 3]| {
+        if x >= 0 && y >= 0 && (x as u32) < w && (y as u32) < h {
+            let i = ((y as u32 * w + x as u32) * 3) as usize;
+            img[i] = c[0];
+            img[i + 1] = c[1];
+            img[i + 2] = c[2];
+        }
+    };
+
+    // 文字绘制(居中;手动排版:ab_glyph 0.2.32 无 layout,逐字推进)
+    let put_center = |img: &mut [u8],
+                      font: &FontVec,
+                      x0: u32,
+                      y0: u32,
+                      cw: u32,
+                      ch: u32,
+                      text: &str,
+                      color: [u8; 3]| {
+        use ab_glyph::ScaleFont;
+        let mut px = 76u32;
+        // 文字总宽(px 处测量)
+        let text_w = |font: &FontVec, px: f32, text: &str| -> f32 {
+            let scale = PxScale::from(px);
+            let sf = font.as_scaled(scale);
+            text.chars().map(|c| sf.h_advance(sf.glyph_id(c))).sum()
+        };
+        while px > 14 && text_w(font, px as f32, text) > (cw - 24).max(20) as f32 {
+            px -= 2;
+        }
+        let scale = PxScale::from(px as f32);
+        let sf = font.as_scaled(scale);
+        let tw = text_w(font, px as f32, text);
+        let x_off = ((cw as f32 - tw) / 2.0).max(0.0);
+        let mut cursor_x = x0 as f32 + x_off;
+        let baseline_y = y0 as f32 + ch as f32 * 0.66;
+        for c in text.chars() {
+            let id = sf.glyph_id(c);
+            let glyph = id.with_scale_and_position(scale, ab_glyph::point(cursor_x, baseline_y));
+            if let Some(og) = sf.outline_glyph(glyph) {
+                let b = og.px_bounds();
+                let gx = b.min.x as i32;
+                let gy = b.min.y as i32;
+                og.draw(|dx, dy, cov| {
+                    let x = gx + dx as i32;
+                    let y = gy + dy as i32;
+                    if cov > 0.0 {
+                        // 抗锯齿:按覆盖率混合底色
+                        let i = ((y as u32 * w + x as u32) * 3) as usize;
+                        if i + 2 < img.len() {
+                            let a = cov as f32;
+                            img[i] = (img[i] as f32 * (1.0 - a) + color[0] as f32 * a) as u8;
+                            img[i + 1] = (img[i + 1] as f32 * (1.0 - a) + color[1] as f32 * a) as u8;
+                            img[i + 2] = (img[i + 2] as f32 * (1.0 - a) + color[2] as f32 * a) as u8;
+                        }
+                    }
+                });
+            }
+            cursor_x += sf.h_advance(id);
+        }
+    };
+
+    // 边框
+    let border = |img: &mut [u8], x0: u32, y0: u32, cw: u32, ch: u32, color: [u8; 3]| {
+        for dx in 0..cw {
+            for dy in 0..3u32 {
+                set_px(img, (x0 + dx) as i32, (y0 + dy) as i32, color);
+                set_px(img, (x0 + dx) as i32, (y0 + ch - 1 - dy) as i32, color);
+            }
+        }
+        for dy in 0..ch {
+            for dx in 0..3u32 {
+                set_px(img, (x0 + dx) as i32, (y0 + dy) as i32, color);
+                set_px(img, (x0 + cw - 1 - dx) as i32, (y0 + dy) as i32, color);
+            }
+        }
+    };
+
+    // 表头:白底黑字
+    for (c, hdr) in table.headers.iter().enumerate() {
+        let x0 = MARGIN + c as u32 * cell_w;
+        let y0 = MARGIN;
+        border(&mut img, x0, y0, cell_w, header_h, [0, 0, 0]);
+        put_center(&mut img, &font, x0, y0, cell_w, header_h, hdr, [0, 0, 0]);
+    }
+
+    // 数据行:班级组底色或白
+    for (r, row) in table.rows.iter().enumerate() {
+        let y0 = MARGIN + header_h + r as u32 * row_h;
+        let bg: [u8; 3] = match table.color_idx[r] {
+            Some(i) if i < PALETTE.len() => PALETTE[i],
+            _ => [255, 255, 255],
+        };
+        for (c, cell) in row.iter().enumerate() {
+            let x0 = MARGIN + c as u32 * cell_w;
+            // 底色
+            for dy in 0..row_h {
+                for dx in 0..cell_w {
+                    set_px(&mut img, (x0 + dx) as i32, (y0 + dy) as i32, bg);
+                }
+            }
+            border(&mut img, x0, y0, cell_w, row_h, [0, 0, 0]);
+            put_center(&mut img, &font, x0, y0, cell_w, row_h, cell, [0, 0, 0]);
+        }
+    }
+
+    // 写出 PNG
+    let img2 = image::RgbImage::from_raw(w, h, img).ok_or("图像缓冲创建失败".to_string())?;
+    img2.save(path).map_err(|e| format!("保存图片失败: {}", e))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use toolbox_core::scoretable::SummaryTable;
+
+    #[test]
+    fn png_render_smoke() {
+        let t = SummaryTable {
+            headers: vec!["班级".into(), "语文".into(), "数学".into()],
+            num_cols: vec![1, 2],
+            rows: vec![
+                vec!["31".into(), "85.33".into(), "90.0".into()],
+                vec!["32".into(), "70.5".into(), "80.0".into()],
+            ],
+            nums: vec![
+                vec![Some(31.0), Some(85.33), Some(90.0)],
+                vec![Some(32.0), Some(70.5), Some(80.0)],
+            ],
+            color_idx: vec![Some(0), None],
+        };
+        let out = std::env::temp_dir().join("toolbox_png_smoke.png");
+        render(&t, &out).expect("render 应成功");
+        let img = image::open(&out).expect("PNG 应可打开");
+        // 3 列 → 宽 ≈ 24*2 + cell_w*3
+        assert!(img.width() > 600 && img.height() > 400, "{}x{}", img.width(), img.height());
+        std::fs::remove_file(out).ok();
+    }
+}

+ 291 - 0
gui/src/scoretable_page.rs

@@ -0,0 +1,291 @@
+//! 小分表页面:文件 → 参数 → 处理 → 彩色预览 → 导出 xlsx / PNG
+
+use std::path::PathBuf;
+use toolbox_core::scoretable::{
+    auto_group_col, export_xlsx, load_headers, ScoreTableInput, SummaryTable, PALETTE,
+};
+
+const DEFAULT_COLORS: &str = "[1,2,30,31],[3,4,5,6,32,33,34,35],[7,8,9,10,36,37,38,39],[12,13,14,42,43,44],[23,24,41]";
+
+pub struct ScoreTablePage {
+    file: String,
+    has_header: bool,
+    ignore_text: String,
+    colors_text: String,
+    threshold: u32,
+    /// 表头(按"包含表头"语义读取)
+    headers: Vec<String>,
+    group_sel: usize,
+    result: Option<SummaryTable>,
+    status: String,
+}
+
+impl Default for ScoreTablePage {
+    fn default() -> Self {
+        Self {
+            file: String::new(),
+            has_header: true,
+            ignore_text: "1,2,3,8,9".to_string(),
+            colors_text: DEFAULT_COLORS.to_string(),
+            threshold: 10,
+            headers: Vec::new(),
+            group_sel: 0,
+            result: None,
+            status: String::new(),
+        }
+    }
+}
+
+impl ScoreTablePage {
+    /// 重新读表头(文件变化或勾选变化时)
+    fn reload_headers(&mut self) {
+        if self.file.trim().is_empty() {
+            return;
+        }
+        let path = PathBuf::from(self.file.trim());
+        match load_headers(&path, self.has_header) {
+            Ok(headers) => {
+                self.headers = headers;
+                self.group_sel = auto_group_col(&self.headers);
+                self.status = format!("已加载文件,共 {} 列", self.headers.len());
+            }
+            Err(e) => {
+                self.status = format!("读取失败: {}", e);
+            }
+        }
+    }
+
+    fn run_process(&mut self) {
+        self.status.clear();
+        let path = PathBuf::from(self.file.trim());
+        if !path.exists() {
+            self.status = format!("找不到文件: {}", self.file.trim());
+            return;
+        }
+        let inp = ScoreTableInput {
+            path,
+            has_header: self.has_header,
+            ignore_cols_text: self.ignore_text.clone(),
+            color_groups_text: self.colors_text.clone(),
+            threshold: self.threshold,
+        };
+        match toolbox_core::scoretable::process_with_group(&inp, Some(self.group_sel)) {
+            Ok(t) => {
+                self.status = format!(
+                    "完成:{} 行 × {} 列",
+                    t.rows.len(),
+                    t.headers.len()
+                );
+                self.result = Some(t);
+            }
+            Err(e) => {
+                self.status = e;
+            }
+        }
+    }
+
+    fn export(&mut self, ext: &str) {
+        let Some(t) = &self.result else {
+            self.status = "请先处理数据".to_string();
+            return;
+        };
+        let default_name = PathBuf::from(self.file.trim())
+            .file_stem()
+            .map(|s| s.to_string_lossy().to_string())
+            .unwrap_or_else(|| "小分表".to_string());
+        if ext == "xlsx" {
+            if let Some(p) = rfd::FileDialog::new()
+                .add_filter("Excel 文件", &["xlsx"])
+                .set_file_name(format!("{}.xlsx", default_name))
+                .save_file()
+            {
+                match export_xlsx(t, &p) {
+                    Ok(_) => self.status.push_str(&format!("\n已导出: {}", p.display())),
+                    Err(e) => self.status = format!("导出失败: {}", e),
+                }
+            }
+        } else {
+            if let Some(p) = rfd::FileDialog::new()
+                .add_filter("PNG 图片", &["png"])
+                .set_file_name(format!("{}.png", default_name))
+                .save_file()
+            {
+                match crate::png_table::render(t, &p) {
+                    Ok(_) => self.status.push_str(&format!("\n已导出: {}", p.display())),
+                    Err(e) => self.status = format!("导出失败: {}", e),
+                }
+            }
+        }
+    }
+
+    fn preview_ui(&mut self, ui: &mut egui::Ui) {
+        let Some(t) = &self.result else {
+            return;
+        };
+        egui::ScrollArea::both()
+            .auto_shrink([false, false])
+            .show(ui, |ui| {
+                const CELL_W: f32 = 96.0;
+                const ROW_H: f32 = 26.0;
+                let ncols = t.headers.len();
+                let nrows = t.rows.len();
+                let total_w = CELL_W * ncols as f32;
+                let total_h = 30.0 + ROW_H * nrows as f32;
+                let (rect, _) = ui.allocate_exact_size(
+                    egui::vec2(total_w, total_h),
+                    egui::Sense::hover(),
+                );
+                let p = ui.painter();
+                let font = egui::FontId::proportional(13.0);
+
+                let col_rect = |c: usize, y0: f32, ch: f32| {
+                    egui::Rect::from_min_size(
+                        egui::pos2(rect.min.x + c as f32 * CELL_W, y0),
+                        egui::vec2(CELL_W, ch),
+                    )
+                };
+                // 表头:浅灰底
+                let hdr_y = rect.min.y;
+                for (c, h) in t.headers.iter().enumerate() {
+                    let r = col_rect(c, hdr_y, 30.0);
+                    p.rect_filled(r, 0.0, egui::Color32::from_rgb(220, 220, 220));
+                    p.rect_stroke(r, 0.0, egui::Stroke::new(1.0, egui::Color32::GRAY), egui::StrokeKind::Inside);
+                    p.text(
+                        r.center(),
+                        egui::Align2::CENTER_CENTER,
+                        h,
+                        font.clone(),
+                        egui::Color32::BLACK,
+                    );
+                }
+                // 数据行
+                for (r_i, row) in t.rows.iter().enumerate() {
+                    let y0 = hdr_y + 30.0 + r_i as f32 * ROW_H;
+                    let bg = match t.color_idx[r_i] {
+                        Some(i) if i < PALETTE.len() => {
+                            let [x, y, z] = PALETTE[i];
+                            egui::Color32::from_rgb(x, y, z)
+                        }
+                        _ => egui::Color32::WHITE,
+                    };
+                    for (c, cell) in row.iter().enumerate() {
+                        let r = col_rect(c, y0, ROW_H);
+                        p.rect_filled(r, 0.0, bg);
+                        p.rect_stroke(r, 0.0, egui::Stroke::new(1.0, egui::Color32::GRAY), egui::StrokeKind::Inside);
+                        p.text(
+                            r.center(),
+                            egui::Align2::CENTER_CENTER,
+                            cell,
+                            font.clone(),
+                            egui::Color32::BLACK,
+                        );
+                    }
+                }
+            });
+    }
+
+    pub fn ui(&mut self, ui: &mut egui::Ui) {
+        egui::ScrollArea::vertical().show(ui, |ui| {
+            ui.add_space(8.0);
+
+            ui.horizontal(|ui| {
+                ui.label("Excel文件:");
+                ui.add(
+                    egui::TextEdit::singleline(&mut self.file)
+                        .desired_width(380.0)
+                        .hint_text("成绩表(.xlsx / .xls)"),
+                );
+                if ui.button("选择…").clicked() {
+                    if let Some(path) = rfd::FileDialog::new()
+                        .add_filter("Excel 文件", &["xlsx", "xls"])
+                        .pick_file()
+                    {
+                        self.file = path.display().to_string();
+                        self.result = None;
+                        self.reload_headers();
+                    }
+                }
+            });
+
+            ui.horizontal(|ui| {
+                if ui.checkbox(&mut self.has_header, "包含表头").changed() {
+                    // Python:切换即重新加载
+                    self.result = None;
+                    self.reload_headers();
+                }
+                ui.add_space(16.0);
+                ui.label("忽略列索引:");
+                ui.add(
+                    egui::TextEdit::singleline(&mut self.ignore_text)
+                        .desired_width(160.0)
+                        .hint_text("1,2,3,8,9"),
+                );
+                ui.add_space(16.0);
+                ui.label("总分阈值:");
+                ui.add(egui::DragValue::new(&mut self.threshold).range(0..=999));
+            });
+
+            ui.horizontal(|ui| {
+                ui.label("分组列:");
+                if self.headers.is_empty() {
+                    ui.label("(先选择文件)");
+                } else {
+                    let sel = self.group_sel.min(self.headers.len() - 1);
+                    egui::ComboBox::from_id_salt("st_group")
+                        .selected_text(self.headers.get(sel).cloned().unwrap_or_default())
+                        .show_ui(ui, |ui| {
+                            for (i, h) in self.headers.iter().enumerate() {
+                                if ui.selectable_label(i == sel, h).clicked() {
+                                    self.group_sel = i;
+                                }
+                            }
+                        });
+                }
+            });
+
+            ui.horizontal(|ui| {
+                ui.label("班级颜色分组:");
+                ui.add(
+                    egui::TextEdit::singleline(&mut self.colors_text)
+                        .desired_width(460.0)
+                        .hint_text("[1,2,30,31],[3,4,...]"),
+                );
+            });
+
+            ui.add_space(8.0);
+            ui.separator();
+            ui.add_space(4.0);
+
+            if ui.button("开始处理").clicked() {
+                self.run_process();
+            }
+            if self.result.is_some() {
+                ui.horizontal(|ui| {
+                    if ui.button("导出 Excel…").clicked() {
+                        self.export("xlsx");
+                    }
+                    if ui.button("导出图片…").clicked() {
+                        self.export("png");
+                    }
+                });
+            }
+
+            if !self.status.is_empty() {
+                ui.add_space(6.0);
+                ui.label(
+                    egui::RichText::new(&self.status)
+                        .color(if self.status.starts_with("完成") || self.status.starts_with("已")
+                        {
+                            egui::Color32::from_rgb(0, 130, 0)
+                        } else {
+                            egui::Color32::from_rgb(190, 40, 40)
+                        }),
+                );
+            }
+
+            ui.add_space(8.0);
+            self.preview_ui(ui);
+            ui.add_space(12.0);
+        });
+    }
+}

+ 247 - 0
gui/src/seating_page.rs

@@ -0,0 +1,247 @@
+//! 换座位页面:参数表单 → 调用 core::seating → 日志区
+
+use toolbox_core::seating::{run_seating, SeatingInput, Strategy};
+use toolbox_core::xlsx::sheet_names;
+
+#[derive(Default)]
+pub struct SeatingPage {
+    excel_path: String,
+    class_name: String,
+    style: String,
+    strategy: Strategy,
+    allow_mixed: bool,
+    count: u32,
+    seed: String,
+    sheet: String,
+    output: String,
+    log: Vec<String>,
+}
+
+impl SeatingPage {
+    pub fn ui(&mut self, ui: &mut egui::Ui) {
+        egui::ScrollArea::vertical().show(ui, |ui| {
+            ui.add_space(8.0);
+
+            // --- 输入文件 ---
+            ui.horizontal(|ui| {
+                ui.label("Excel文件:");
+                ui.add(
+                    egui::TextEdit::singleline(&mut self.excel_path)
+                        .desired_width(420.0)
+                        .hint_text("学生名单(.xlsx / .xls)"),
+                );
+                if ui.button("选择…").clicked() {
+                    if let Some(path) = rfd::FileDialog::new()
+                        .add_filter("Excel 文件", &["xlsx", "xls"])
+                        .pick_file()
+                    {
+                        self.excel_path = path.display().to_string();
+                        // 顺带按文件回填默认输出路径
+                        if self.output.is_empty() {
+                            if let Some(stem) = path.file_stem() {
+                                self.output = path
+                                    .parent()
+                                    .unwrap_or(std::path::Path::new(""))
+                                    .join(format!("{}_{}_座位表.xlsx", stem.to_string_lossy(), self.class_name))
+                                    .display()
+                                    .to_string();
+                            }
+                        }
+                    }
+                }
+            });
+
+            ui.horizontal(|ui| {
+                ui.label("班级:");
+                ui.add(
+                    egui::TextEdit::singleline(&mut self.class_name)
+                        .desired_width(150.0)
+                        .hint_text("如 高三31班 或 31"),
+                );
+                ui.add_space(16.0);
+                ui.label("工作表:");
+                ui.add(
+                    egui::TextEdit::singleline(&mut self.sheet)
+                        .desired_width(120.0)
+                        .hint_text("留空 = 第一个"),
+                );
+            });
+
+            ui.horizontal(|ui| {
+                ui.label("座位样式:");
+                ui.add(
+                    egui::TextEdit::singleline(&mut self.style)
+                        .desired_width(120.0)
+                        .hint_text("如 2222 / 3332"),
+                );
+                ui.add_space(16.0);
+                ui.label("策略:");
+                egui::ComboBox::from_id_salt("strategy")
+                    .selected_text(self.strategy.label())
+                    .show_ui(ui, |ui| {
+                        ui.selectable_value(&mut self.strategy, Strategy::GoodWithGood, "好+好");
+                        ui.selectable_value(&mut self.strategy, Strategy::GoodWithBad, "好+差");
+                        ui.selectable_value(&mut self.strategy, Strategy::Random, "与成绩无关");
+                    });
+                ui.add_space(16.0);
+                ui.checkbox(&mut self.allow_mixed, "允许男女生同桌");
+            });
+
+            ui.horizontal(|ui| {
+                ui.label("生成个数:");
+                ui.add(egui::DragValue::new(&mut self.count).range(1..=99));
+                ui.add_space(16.0);
+                ui.label("随机种子:");
+                ui.add(
+                    egui::TextEdit::singleline(&mut self.seed)
+                        .desired_width(120.0)
+                        .hint_text("留空 = 随机"),
+                );
+            });
+
+            // --- 输出 ---
+            ui.horizontal(|ui| {
+                ui.label("输出文件:");
+                ui.add(
+                    egui::TextEdit::singleline(&mut self.output)
+                        .desired_width(420.0)
+                        .hint_text("留空 = 与输入同目录"),
+                );
+                if ui.button("另存为…").clicked() {
+                    if let Some(path) = rfd::FileDialog::new()
+                        .add_filter("Excel 文件", &["xlsx"])
+                        .save_file()
+                    {
+                        self.output = path.display().to_string();
+                    }
+                }
+            });
+
+            ui.add_space(10.0);
+            ui.separator();
+            ui.add_space(4.0);
+
+            // --- 运行 ---
+            if ui.button("开始生成").clicked() {
+                self.run();
+            }
+
+            ui.add_space(8.0);
+
+            // --- 日志 ---
+            if !self.log.is_empty() {
+                egui::Frame::group(ui.style())
+                    .fill(ui.visuals().extreme_bg_color)
+                    .show(ui, |ui| {
+                        egui::ScrollArea::vertical()
+                            .max_height(220.0)
+                            .auto_shrink([false, false])
+                            .show(ui, |ui| {
+                                for line in &self.log {
+                                    ui.monospace(line);
+                                }
+                            });
+                    });
+            }
+
+            ui.add_space(12.0);
+        });
+    }
+
+    fn run(&mut self) {
+        self.log.clear();
+        let mut inp = SeatingInput {
+            excel_path: std::path::PathBuf::from(self.excel_path.trim()),
+            sheet: {
+                let s = self.sheet.trim();
+                if s.is_empty() {
+                    None
+                } else {
+                    Some(s.to_string())
+                }
+            },
+            class_name: self.class_name.trim().to_string(),
+            style: self.style.trim().to_string(),
+            strategy: self.strategy,
+            allow_mixed: self.allow_mixed,
+            count: self.count.max(1) as usize,
+            seed: {
+                let s = self.seed.trim();
+                if s.is_empty() {
+                    None
+                } else {
+                    s.parse::<u64>().ok()
+                }
+            },
+            output: {
+                let s = self.output.trim();
+                if s.is_empty() {
+                    None
+                } else {
+                    Some(std::path::PathBuf::from(s))
+                }
+            },
+        };
+
+        // 基础校验,与 Python 一致的错误文案
+        if inp.excel_path.as_os_str().is_empty() {
+            self.log.push("错误:请先选择 Excel 文件".to_string());
+            return;
+        }
+        if inp.class_name.is_empty() {
+            self.log.push("错误:请填写班级名称".to_string());
+            return;
+        }
+        if !inp.style.chars().all(|c| c.is_ascii_digit()) || inp.style.len() < 2 {
+            self.log.push("错误:样式必须为纯数字字符串,至少2位,如 '2222' 或 '3332'".to_string());
+            return;
+        }
+        if inp.seed.is_none() && !self.seed.trim().is_empty() {
+            self.log.push("错误:随机种子必须为数字".to_string());
+            return;
+        }
+
+        // 工作表名补全:如果填的不是名字而是提示,交给 core 报错
+        if inp.sheet.is_some() {
+            if let Ok(names) = sheet_names(&inp.excel_path) {
+                let n = inp.sheet.clone().unwrap();
+                if !names.iter().any(|x| x == &n) {
+                    self.log.push(format!(
+                        "错误:工作表 '{}' 不存在。可用工作表:{:?}",
+                        n, names
+                    ));
+                    return;
+                }
+            }
+        }
+
+        // 输出缺省时按"输入同目录"生成(见上:选择文件时已回填)
+        if inp.output.is_none() {
+            if let Some(stem) = inp
+                .excel_path
+                .file_stem()
+                .map(|s| s.to_string_lossy().to_string())
+            {
+                inp.output = Some(
+                    inp
+                        .excel_path
+                        .parent()
+                        .unwrap_or(std::path::Path::new("."))
+                        .join(format!("{}_{}_座位表.xlsx", stem, inp.class_name)),
+                );
+            }
+        }
+
+        match run_seating(&inp) {
+            Ok(res) => {
+                self.log.extend(res.log);
+                self.log.push("完成 ✓".to_string());
+            }
+            Err(e) => {
+                for line in e.lines() {
+                    self.log.push(line.to_string());
+                }
+            }
+        }
+    }
+}