diff --git a/Cargo.lock b/Cargo.lock index 601a502..643ab12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,6 +878,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "dlopen2" version = "0.8.2" @@ -916,6 +925,12 @@ dependencies = [ "tendril", ] +[[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" @@ -2792,7 +2807,7 @@ checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" dependencies = [ "base64 0.23.1", "indexmap 2.14.2", - "quick-xml", + "quick-xml 0.42.0", "serde", "time", ] @@ -2837,6 +2852,12 @@ dependencies = [ "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 = "portable-atomic" version = "1.15.0" @@ -2944,6 +2965,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.42.0" @@ -3127,6 +3157,33 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "percent-encoding", + "pollster", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -3317,6 +3374,12 @@ dependencies = [ "syn 2.0.119", ] +[[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" @@ -4973,6 +5036,66 @@ dependencies = [ "web-sys", ] +[[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", + "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", + "wayland-backend", + "wayland-scanner", +] + +[[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-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.104" @@ -5521,6 +5644,7 @@ name = "winer" version = "0.0.0" dependencies = [ "embed-resource", + "rfd", "serde", "serde_json", "sha2", diff --git a/app/frontend/package.json b/app/frontend/package.json index b069122..bf437d4 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -15,6 +15,8 @@ "@fontsource-variable/jetbrains-mono": "5.3.0", "@tauri-apps/api": "2.11.1", "@winer/shared": "workspace:*", + "fflate": "0.8.2", + "html-to-image": "1.11.13", "lucide-react": "1.52.0", "react": "19.3.0", "react-dom": "19.3.0" diff --git a/app/frontend/src/App.test.tsx b/app/frontend/src/App.test.tsx index b9f7c35..b8f69ff 100644 --- a/app/frontend/src/App.test.tsx +++ b/app/frontend/src/App.test.tsx @@ -16,6 +16,7 @@ import type { } from "@winer/shared"; import { App } from "./App"; +import * as reports from "./pages/history/reports"; import type { Backend, CommandName } from "./lib/backend"; import { demoBackend, demoGame, demoLobby } from "./lib/demo"; import { AppStore, EMPTY_SNAPSHOT, StoreContext } from "./lib/store"; @@ -2175,6 +2176,65 @@ describe("history", () => { expect(how).toHaveTextContent(`落在「${rating.label}」`); }); + it("exports complete scoreboards with rune groups directly to the remembered folder", async () => { + const saved = vi.fn((_args: unknown) => "D:/Reports/match.png"); + const backend = demoWith({ + get_report_directory: () => "D:/Reports", + save_report_image: saved, + }); + const capture = vi.spyOn(reports, "captureReport").mockImplementation(async (node) => { + expect(node.querySelectorAll("li")).toHaveLength(10); + expect(node.querySelectorAll('[aria-label="主系符文"]')).toHaveLength(10); + expect(node.querySelectorAll('[aria-label="属性碎片"]')).toHaveLength(10); + return new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + }); + try { + const user = await openHistory(backend); + await screen.findByText("D:/Reports"); + const checks = screen.getAllByRole("checkbox", { name: /选择对局/ }); + await user.click(checks[0]!); + await user.click(checks[1]!); + await user.click(screen.getByRole("button", { name: zhCN["history.exportToFolder"] })); + await waitFor(() => expect(saved).toHaveBeenCalledTimes(2)); + expect(capture).toHaveBeenCalledTimes(2); + expect(saved.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + name: expect.stringMatching(/\.png$/), + contents: [137, 80, 78, 71, 13, 10, 26, 10], + }), + ); + } finally { + capture.mockRestore(); + } + }); + + it("shows only custom games despite the hide setting and selects across pages", async () => { + const backend = demoBackend(); + const call = vi.spyOn(backend, "call"); + const user = await openHistory(backend); + await user.type( + screen.getByRole("textbox", { name: zhCN["history.search"] }), + "野区观光客#10005", + ); + await user.click(screen.getByRole("button", { name: zhCN["history.find"] })); + await screen.findByRole("button", { name: zhCN["history.mine"] }, { timeout: 5000 }); + await screen.findByText("已隐藏 2 场"); + await user.click(screen.getByRole("radio", { name: zhCN["history.custom"] })); + expect(await screen.findByText("第 1–2 场 · 共 2 场")).toBeInTheDocument(); + expect(screen.getByRole("switch", { name: zhCN["history.hideCustom"] })).toBeDisabled(); + await user.click(screen.getByRole("checkbox", { name: zhCN["history.selectVisible"] })); + expect(screen.getByText("已选择 2 场(跨页保留)")).toBeInTheDocument(); + expect(screen.getAllByRole("checkbox", { name: /选择对局/, checked: true })).toHaveLength(2); + expect(call).not.toHaveBeenCalledWith("set_settings", expect.anything()); + await user.click(screen.getByRole("radio", { name: zhCN["history.all"] })); + await user.click(screen.getByRole("checkbox", { name: zhCN["history.selectVisible"] })); + expect(screen.getByText("已选择 12 场(跨页保留)")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "下一页" })); + expect(screen.getAllByRole("checkbox", { name: /选择对局/, checked: false })).toHaveLength(10); + await user.click(screen.getByRole("button", { name: zhCN["history.clearSelection"] })); + expect(screen.getByRole("button", { name: zhCN["history.exportReports"] })).toBeDisabled(); + }); + it("hides custom games by default, says how many, and shows them once switched", async () => { const backend = demoBackend(); const call = vi.spyOn(backend, "call"); @@ -2185,6 +2245,7 @@ describe("history", () => { "野区观光客#10005", ); await user.click(screen.getByRole("button", { name: zhCN["history.find"] })); + await screen.findByRole("button", { name: zhCN["history.mine"] }, { timeout: 5000 }); expect(await screen.findByText("已隐藏 2 场")).toBeInTheDocument(); expect(await screen.findByText("第 1–50 场 · 共 60 场")).toBeInTheDocument(); expect(screen.queryByText("嚎哭深渊 全随机"), "custom games are hidden").toBeNull(); diff --git a/app/frontend/src/game/MatchDetailView.tsx b/app/frontend/src/game/MatchDetailView.tsx index 619b38a..9bf6c86 100644 --- a/app/frontend/src/game/MatchDetailView.tsx +++ b/app/frontend/src/game/MatchDetailView.tsx @@ -147,6 +147,41 @@ function HeaderRow() { ); } +function RuneChoices({ player }: { player: PlayerLine }) { + const t = useT(); + const groups = [ + { + label: t("history.primaryRunes"), + ids: [player.primaryStyle, ...(player.primaryRunes ?? [player.keystone])], + }, + { + label: t("history.secondaryRunes"), + ids: [player.subStyle, ...(player.secondaryRunes ?? [])], + }, + { label: t("history.statShards"), ids: player.statShards ?? [] }, + ] + .map((group) => ({ ...group, ids: group.ids.filter((id) => id > 0) })) + .filter((group) => group.ids.length); + if (!groups.length) return null; + return ( + + {groups.map((group) => ( + + {group.ids.map((id, index) => ( + + ))} + + ))} + + ); +} + function PlayerRow({ player, maxDamage, @@ -196,6 +231,7 @@ function PlayerRow({ )} {player.award && } + {(player.augments.length > 0 || player.feats.length > 0 || title) && ( {player.augments.length > 0 && ( diff --git a/app/frontend/src/game/game.test.tsx b/app/frontend/src/game/game.test.tsx index 34403cf..a67a9f5 100644 --- a/app/frontend/src/game/game.test.tsx +++ b/app/frontend/src/game/game.test.tsx @@ -26,6 +26,10 @@ function line( spells: [4, 14], items: [0, 0, 0, 0, 0, 0, 0], augments: [], + primaryStyle: 8000, + primaryRunes: [8005, 9111, 9104, 8014], + secondaryRunes: [8139, 8135], + statShards: [5005, 5008, 5001], keystone: 0, subStyle: 0, kills: 10, diff --git a/app/frontend/src/lib/backend.ts b/app/frontend/src/lib/backend.ts index a7d7a3a..ef0c279 100644 --- a/app/frontend/src/lib/backend.ts +++ b/app/frontend/src/lib/backend.ts @@ -149,6 +149,10 @@ export interface Commands { list_player_notes: Command; delete_player_note: Command<{ puuid: string }, boolean>; /** Into Downloads under a new name when the one asked for is taken; answers the file's name. */ + get_report_directory: Command; + choose_report_directory: Command; + save_report_image: Command<{ name: string; contents: number[] }, string>; + save_report_archive: Command<{ contents: number[] }, string | null>; save_export: Command<{ stem: string; format: ExportFormat; contents: string }, string>; /** Asks again for a player whose record failed to load; nothing for one that loaded. */ retry_player: Command<{ puuid: string }, null>; diff --git a/app/frontend/src/lib/demo.ts b/app/frontend/src/lib/demo.ts index 176c50c..c11c322 100644 --- a/app/frontend/src/lib/demo.ts +++ b/app/frontend/src/lib/demo.ts @@ -178,6 +178,10 @@ function line( spells: [4, 14], items: [3031, 3006, 3094, 0, 0, 0, 3340], augments: [], + primaryStyle: 8000, + primaryRunes: [8005, 9111, 9104, 8014], + secondaryRunes: [8139, 8135], + statShards: [5005, 5008, 5001], keystone: 8005, subStyle: 8100, kills, @@ -1282,6 +1286,24 @@ export function demoBackend(): Backend { .map(([puuid, note]) => ({ puuid, note })) .sort((a, b) => b.note.updatedAt - a.note.updatedAt), delete_player_note: ({ puuid }) => notes.delete(puuid), + get_report_directory: () => null, + choose_report_directory: () => { + throw new Error("Folder export is available in the desktop app / 文件夹导出请使用桌面版"); + }, + save_report_image: () => { + throw new Error("Folder export requires the desktop app"); + }, + save_report_archive: ({ contents }) => { + const url = URL.createObjectURL( + new Blob([new Uint8Array(contents)], { type: "application/zip" }), + ); + const link = document.createElement("a"); + link.href = url; + link.download = "Winer_Battle_Reports.zip"; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 60000); + return link.download; + }, save_export: ({ stem, format }) => `${stem}.${format}`, retry_player: () => null, run_diagnostics: () => ({ diff --git a/app/frontend/src/lib/i18n/en.ts b/app/frontend/src/lib/i18n/en.ts index fc77881..5aa2b87 100644 --- a/app/frontend/src/lib/i18n/en.ts +++ b/app/frontend/src/lib/i18n/en.ts @@ -776,6 +776,25 @@ export const en: Record = { "history.paused": "The match-history server is busy; more are read as you page on ({n} read)", "history.loadingAll": "Reading all available games ({n} read)", "history.counted": "{n} available games counted (remakes left out)", + "history.custom": "Custom", + "history.selectVisible": "Select this page", + "history.selectedCount": "{n} selected (across pages)", + "history.clearSelection": "Clear selection", + "history.checkGame": "Select match {id}", + "history.exportReports": "Export battle report images", + "history.reportsFetching": "Fetching match details {n}/{total}", + "history.reportsRendering": "Rendering image {n}/{total}", + "history.reportsPacking": "Packing ZIP and waiting to save", + "history.reportsDone": "Exported: {ok} succeeded, {failed} failed", + "history.reportsFailed": "Export failed: no images generated for {n} matches", + "history.reportsCancelled": "Save cancelled", + "history.primaryRunes": "Primary runes", + "history.secondaryRunes": "Secondary runes", + "history.statShards": "Stat shards", + "history.chooseReportFolder": "Choose image folder", + "history.noReportFolder": "Choose once to save PNGs here next time", + "history.exportToFolder": "Export images to folder", + "history.reportsFolderDone": "Images saved to folder: {ok} succeeded, {failed} failed", "history.exportCsv": "Export CSV", "history.exportJson": "Export JSON", "history.exportPrefix": "winer history", diff --git a/app/frontend/src/lib/i18n/zh-CN.ts b/app/frontend/src/lib/i18n/zh-CN.ts index fad9923..5501ebf 100644 --- a/app/frontend/src/lib/i18n/zh-CN.ts +++ b/app/frontend/src/lib/i18n/zh-CN.ts @@ -746,6 +746,25 @@ export const zhCN = { "history.paused": "战绩服务器繁忙,翻页时再继续读取(已读取 {n} 场)", "history.loadingAll": "正在读取全部可查战绩(已读取 {n} 场)", "history.counted": "统计最近可查的 {n} 场(不含重开局)", + "history.custom": "自定义", + "history.selectVisible": "全选当前页", + "history.selectedCount": "已选择 {n} 场(跨页保留)", + "history.clearSelection": "清空选择", + "history.checkGame": "选择对局 {id}", + "history.exportReports": "批量导出战报图片", + "history.reportsFetching": "正在获取比赛详情 {n}/{total}", + "history.reportsRendering": "正在生成图片 {n}/{total}", + "history.reportsPacking": "正在打包 ZIP 并等待保存", + "history.reportsDone": "导出完成:成功 {ok} 场,失败 {failed} 场", + "history.reportsFailed": "导出失败:{n} 场均未生成图片", + "history.reportsCancelled": "已取消保存", + "history.primaryRunes": "主系符文", + "history.secondaryRunes": "副系符文", + "history.statShards": "属性碎片", + "history.chooseReportFolder": "选择图片保存文件夹", + "history.noReportFolder": "选择一次后记住,下次直接保存 PNG", + "history.exportToFolder": "导出图片到文件夹", + "history.reportsFolderDone": "图片已保存到所选文件夹:成功 {ok} 场,失败 {failed} 场", "history.exportCsv": "导出 CSV", "history.exportJson": "导出 JSON", "history.exportPrefix": "winer 战绩", diff --git a/app/frontend/src/lib/lib.test.ts b/app/frontend/src/lib/lib.test.ts index 359557b..6000a9d 100644 --- a/app/frontend/src/lib/lib.test.ts +++ b/app/frontend/src/lib/lib.test.ts @@ -332,6 +332,10 @@ describe("tiers", () => { spells: [4, 14], items: [0, 0, 0, 0, 0, 0, 0], augments: [], + primaryStyle: 8000, + primaryRunes: [8005, 9111, 9104, 8014], + secondaryRunes: [8139, 8135], + statShards: [5005, 5008, 5001], keystone: 0, subStyle: 0, kills: 5, diff --git a/app/frontend/src/pages/History.tsx b/app/frontend/src/pages/History.tsx index 66dc8ad..9215320 100644 --- a/app/frontend/src/pages/History.tsx +++ b/app/frontend/src/pages/History.tsx @@ -1,7 +1,15 @@ -import { riotId, type ExportFormat, type HistorySource, type MatchSummary } from "@winer/shared"; +import { + riotId, + type ExportFormat, + type HistorySource, + type MatchSummary, + type MatchDetail, +} from "@winer/shared"; import { Download, Search, UserRound } from "lucide-react"; import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { flushSync } from "react-dom"; + import { FormLabel, StandingRule } from "../game/FormRules"; import { MatchDetailView } from "../game/MatchDetailView"; import { MatchRow } from "../game/MatchRow"; @@ -32,6 +40,14 @@ import { ChampionTable } from "./history/ChampionTable"; import { historyFailure } from "./history/failure"; import { type ExportWords, championRows, exportStem, gamesCsv, gamesJson } from "./history/records"; +import { + captureReport, + collectReports, + packReports, + reportName, + withDeadline, +} from "./history/reports"; + const PAGE_SIZES = ["10", "15", "25", "50"] as const; type PageSize = (typeof PAGE_SIZES)[number]; const PAGE_SIZE_KEY = "winer.history.pageSize"; @@ -129,10 +145,10 @@ function HistoryPager({ ); } -const FILTERS = ["all", "ranked", "normal", "aram", "other"] as const; +const FILTERS = ["all", "ranked", "normal", "aram", "custom", "other"] as const; type Filter = (typeof FILTERS)[number]; -const QUEUES: Record, number[]> = { +const QUEUES: Record, number[]> = { ranked: [420, 440], normal: [400, 430, 480, 490], aram: [450, 100, 2400], @@ -140,6 +156,7 @@ const QUEUES: Record, number[]> = { function matches(filter: Filter, game: MatchSummary): boolean { if (filter === "all") return true; + if (filter === "custom") return game.kind === "custom"; if (filter === "other") return !Object.values(QUEUES).some((ids) => ids.includes(game.queueId)); return QUEUES[filter].includes(game.queueId); } @@ -374,7 +391,8 @@ function GameList({ const t = useT(); const store = useStore(); const { navigate } = useShell(); - const hideCustom = useSettings().history.hideCustomGames; + const settings = useSettings(); + const hideCustom = settings.history.hideCustomGames; // Where the list was left on the last visit, if it was this account's. const [kept] = useState(() => store.history.list(viewer, puuid)); const [loaded, setLoaded] = useState(() => @@ -388,6 +406,46 @@ function GameList({ const [view, setView] = useState<"games" | "champions">("games"); const catalog = useCatalog(); const [selected, setSelected] = useState(null); + const [checked, setChecked] = useState>(() => new Set()); + const [exporting, setExporting] = useState(false); + const [reportDirectory, setReportDirectory] = useState(null); + const [choosingDirectory, setChoosingDirectory] = useState(false); + useEffect(() => { + let active = true; + store.backend + .call("get_report_directory") + .then((value) => { + if (active) setReportDirectory(value); + }) + .catch((error) => { + if (active) toast(errorMessage(error), "danger"); + }); + return () => { + active = false; + }; + }, [store]); + const chooseDirectory = async () => { + setChoosingDirectory(true); + try { + const folder = await store.backend.call("choose_report_directory"); + if (folder) setReportDirectory(folder); + } catch (error) { + toast(errorMessage(error), "danger"); + } finally { + setChoosingDirectory(false); + } + }; + const [progress, setProgress] = useState(""); + const [report, setReport] = useState(null); + const capture = useRef(null); + const exportingRef = useRef(false); + const toggleGame = (id: number) => + setChecked((previous) => { + const next = new Set(previous); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); const top = useRef(null); const inFlight = useRef(false); const mounted = useRef(true); @@ -403,13 +461,15 @@ function GameList({ const shown = useMemo( () => loaded.games.filter( - (game) => matches(filter, game) && !(hideCustom && game.kind === "custom"), + (game) => + matches(filter, game) && !(hideCustom && filter !== "custom" && game.kind === "custom"), ), [loaded.games, filter, hideCustom], ); - const hidden = hideCustom - ? loaded.games.filter((game) => game.kind === "custom" && matches(filter, game)).length - : 0; + const hidden = + hideCustom && filter !== "custom" + ? loaded.games.filter((game) => game.kind === "custom" && matches(filter, game)).length + : 0; const known = Math.ceil(shown.length / perPage); // Past the last page there is nothing to show; the last one stands in. const current = loaded.more ? page : Math.min(page, Math.max(1, known)); @@ -448,6 +508,97 @@ function GameList({ } }; + const exportReports = async (toFolder = false) => { + if (exportingRef.current || checked.size === 0 || (toFolder && !reportDirectory)) return; + exportingRef.current = true; + setExporting(true); + const games = loaded.games.filter((game) => checked.has(game.gameId)); + try { + const result = await collectReports( + games, + async (game, index) => { + if (!mounted.current) throw new Error("History closed"); + setProgress(t("history.reportsFetching", { n: index + 1, total: games.length })); + const detail = + store.history.detail(viewer, game.gameId) ?? + (await withDeadline(store.backend.call("get_match_detail", { gameId: game.gameId }))); + store.history.putDetail(viewer, detail); + if (!mounted.current) throw new Error("History closed"); + setProgress(t("history.reportsRendering", { n: index + 1, total: games.length })); + flushSync(() => setReport(detail)); + const node = capture.current; + if (!node) throw new Error("Report container missing"); + const png = await withDeadline(captureReport(node)); + return { + png, + name: reportName( + game, + riotId(store.history.summary(viewer, puuid)?.name ?? game.line.name) || + puuid.slice(0, 8), + catalog?.champions.get(game.line.championId)?.name ?? String(game.line.championId), + t( + game.line.remake + ? "history.result.remake" + : game.line.win + ? "history.result.win" + : "history.result.loss", + ), + ), + }; + }, + toFolder + ? async (name, png) => { + if (!mounted.current) throw new Error("History closed"); + await store.backend.call("save_report_image", { name, contents: Array.from(png) }); + } + : undefined, + ); + if (!mounted.current) return; + setReport(null); + if (!result.succeeded) { + setProgress(t("history.reportsFailed", { n: result.failed })); + return; + } + if (toFolder) { + const message = t("history.reportsFolderDone", { + ok: result.succeeded, + failed: result.failed, + }); + setProgress(message); + toast(message, "ok"); + return; + } + setProgress(t("history.reportsPacking")); + await new Promise((resolve) => setTimeout(resolve, 0)); + const contents = Array.from(packReports(result.files)); + const file = await store.backend.call("save_report_archive", { contents }); + const message = + file === null + ? t("history.reportsCancelled") + : t("history.reportsDone", { ok: result.succeeded, failed: result.failed }); + setProgress(message); + toast(message, file === null ? "info" : "ok"); + } catch (error) { + setProgress(errorMessage(error)); + toast(errorMessage(error), "danger"); + } finally { + exportingRef.current = false; + if (mounted.current) { + setExporting(false); + setReport(null); + } + } + }; + + useEffect(() => { + if (loaded.more || loaded.stale) return; + const ids = new Set(loaded.games.map((game) => game.gameId)); + setChecked((previous) => { + const next = new Set([...previous].filter((id) => ids.has(id))); + return next.size === previous.size ? previous : next; + }); + }, [loaded.games, loaded.more, loaded.stale]); + // Kept for the next visit, the page and the filter with it. useEffect(() => { if (loaded.games.length === 0 && loaded.more) return; @@ -551,6 +702,7 @@ function GameList({ { value: "ranked", label: t("history.ranked") }, { value: "normal", label: t("history.normal") }, { value: "aram", label: t("history.aram") }, + { value: "custom", label: t("history.custom") }, { value: "other", label: t("history.other") }, ]; const from = (current - 1) * perPage + 1; @@ -577,7 +729,12 @@ function GameList({ size="sm" /> - + @@ -627,6 +784,83 @@ function GameList({ + {view === "games" && ( +
+ + {t("history.selectedCount", { n: checked.size })} + + +
+ )} + {view === "games" && ( +
+ + + {reportDirectory ?? t("history.noReportFolder")} + + +
+ )} + {progress && ( +

+ {progress} +

+ )} + {report && ( + + )} {view === "champions" ? ( <> {loaded.more ? ( @@ -697,13 +931,22 @@ function GameList({
    {visible.map((game) => (
  • - - setSelected((open) => (open === game.gameId ? null : game.gameId)) - } - /> +
    + toggleGame(game.gameId)} + /> + + setSelected((open) => (open === game.gameId ? null : game.gameId)) + } + /> +
    {selected === game.gameId && ( { + it("keeps Unicode names safe on Windows and preserves unique game IDs", () => { + const game = { startedAt: new Date(2026, 10, 5).getTime(), gameId: 123 } as MatchSummary; + expect(reportName(game, '玩家:/\\*?"<>|\n', "阿狸", "胜利")).toBe( + "2026-11-05_玩家___________阿狸_胜利_123.png", + ); + expect(reportName(game, "x".repeat(200), "阿狸", "胜利").length).toBeLessThan(100); + }); + + it("continues after a failed game and packs the successful PNG bytes with Unicode filenames", async () => { + const png = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + const visited: number[] = []; + const result = await collectReports([1, 2, 3], async (id) => { + visited.push(id); + if (id === 2) throw new Error("detail unavailable"); + return { name: `玩家_${id}.png`, png }; + }); + expect(visited).toEqual([1, 2, 3]); + expect(result).toMatchObject({ succeeded: 2, failed: 1 }); + const files = unzipSync(packReports(result.files)); + expect(Object.keys(files)).toEqual(["玩家_1.png", "玩家_3.png"]); + expect(files["玩家_3.png"]).toEqual(png); + }); + + it("saves each PNG immediately, counts write failures and continues without keeping PNGs", async () => { + const saved: string[] = []; + const result = await collectReports( + [1, 2, 3], + async (id) => ({ name: id + ".png", png: new Uint8Array([id]) }), + async (name, png) => { + if (png[0] === 2) throw new Error("disk full"); + saved.push(name); + }, + ); + expect(saved).toEqual(["1.png", "3.png"]); + expect(result).toEqual({ files: {}, succeeded: 2, failed: 1 }); + }); + + it("counts all failures and times out stalled work", async () => { + const result = await collectReports([1], () => withDeadline(new Promise(() => {}), 5)); + expect(result).toEqual({ files: {}, succeeded: 0, failed: 1 }); + }); +}); diff --git a/app/frontend/src/pages/history/reports.ts b/app/frontend/src/pages/history/reports.ts new file mode 100644 index 0000000..d5cd859 --- /dev/null +++ b/app/frontend/src/pages/history/reports.ts @@ -0,0 +1,84 @@ +import { zipSync } from "fflate"; +import { toBlob } from "html-to-image"; +import { flushSync } from "react-dom"; +import type { MatchSummary } from "@winer/shared"; + +export function reportName(game: MatchSummary, player: string, champion: string, result: string) { + const at = new Date(game.startedAt); + const day = [ + at.getFullYear(), + String(at.getMonth() + 1).padStart(2, "0"), + String(at.getDate()).padStart(2, "0"), + ].join("-"); + const safe = (text: string) => + Array.from(text.replace(/[<>:"/\\|?*\p{Cc}]/gu, "_")) + .slice(0, 48) + .join("") + .replace(/[. ]+$/u, ""); + const prefix = `${day}_${safe(player)}_${safe(champion)}_${safe(result)}`; + const id = String(game.gameId); + return `${Array.from(prefix) + .slice(0, 79 - id.length) + .join("")}_${id}.png`; +} + +/** A deadline also covers a stalled asset or webfont, so one game cannot stall the batch. */ +export async function withDeadline(work: Promise, milliseconds = 30000): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + work, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Battle report timed out")), milliseconds); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +export async function captureReport(node: HTMLElement): Promise { + // The existing icons are lazy-loaded. Offscreen reports must explicitly start their requests. + for (const img of node.querySelectorAll("img")) img.loading = "eager"; + await document.fonts.ready; + await Promise.all( + Array.from(node.querySelectorAll("img"), (img) => + img.decode().catch(() => { + // Use GameImage's existing fallback, including when decode rejects before onerror fires. + flushSync(() => img.dispatchEvent(new Event("error"))); + }), + ), + ); + // Failed client assets have React fallbacks; let those state updates commit before cloning. + await new Promise((resolve) => setTimeout(resolve, 0)); + const blob = await toBlob(node, { pixelRatio: 2, preferredFontFormat: "woff2" }); + if (!blob) throw new Error("PNG generation failed"); + return new Uint8Array(await blob.arrayBuffer()); +} + +export async function collectReports( + games: readonly T[], + render: (game: T, index: number) => Promise<{ name: string; png: Uint8Array }>, + save?: (name: string, png: Uint8Array) => Promise, +) { + const files: Record = {}; + let failed = 0; + let succeeded = 0; + for (const [index, game] of games.entries()) { + try { + const { name, png } = await render(game, index); + if (save) await save(name, png); + else files[name] = png; + succeeded += 1; + } catch (error) { + console.warn("Battle report failed", error); + failed += 1; + } + } + return { files, failed, succeeded }; +} + +// PNG already compresses its pixels. Store entries without recompressing them. +export function packReports(files: Record): Uint8Array { + return zipSync(files, { level: 0 }); +} diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 08c0315..173d575 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -41,6 +41,7 @@ tauri-runtime-wry = "=2.11.4" tauri-utils = "=2.9.3" [dependencies] +rfd = "0.17.2" serde.workspace = true serde_json.workspace = true tauri.workspace = true diff --git a/app/src-tauri/src/assets.rs b/app/src-tauri/src/assets.rs index 325dd33..ce50b22 100644 --- a/app/src-tauri/src/assets.rs +++ b/app/src-tauri/src/assets.rs @@ -27,6 +27,7 @@ pub(crate) fn protocol( let response = match service.asset(&path).await { Ok(asset) => Response::builder() .header(header::CONTENT_TYPE, asset.content_type.as_str()) + .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*") .header(header::CACHE_CONTROL, "max-age=86400, immutable") .body(asset.bytes.clone()) .unwrap_or_else(|_| status(StatusCode::INTERNAL_SERVER_ERROR)), diff --git a/app/src-tauri/src/commands.rs b/app/src-tauri/src/commands.rs index 07dc1a1..6f049bc 100644 --- a/app/src-tauri/src/commands.rs +++ b/app/src-tauri/src/commands.rs @@ -116,6 +116,10 @@ pub(crate) fn handler() -> impl Fn(Invoke) -> bool + Send + Sync delete_player_note, // History: the games as a file. save_export, + save_report_archive, + get_report_directory, + choose_report_directory, + save_report_image, // A player's record that failed to load, asked for again. retry_player, ] @@ -949,3 +953,54 @@ async fn save_export( async fn retry_player(app: AppHandle, puuid: String) -> Result<()> { Ok(service(&app).retry_player(&puuid)?) } + +/// The destination is chosen natively; the frontend cannot supply an arbitrary file path. +#[tauri::command] +async fn save_report_archive(contents: Vec) -> Result> { + let Some(file) = rfd::AsyncFileDialog::new() + .set_file_name("Winer_Battle_Reports.zip") + .add_filter("ZIP archive", &["zip"]) + .save_file() + .await + else { + return Ok(None); + }; + file.write(&contents).await.map_err(internal)?; + Ok(Some(file.file_name())) +} + +#[tauri::command] +async fn get_report_directory(app: AppHandle) -> Result> { + let config = app.path().app_config_dir().map_err(internal)?; + let path = crate::report_export::directory(&config).map_err(internal)?; + Ok(path.map(|path| path.to_string_lossy().into_owned())) +} + +#[tauri::command] +async fn choose_report_directory(app: AppHandle) -> Result> { + let config = app.path().app_config_dir().map_err(internal)?; + let Some(folder) = rfd::AsyncFileDialog::new().pick_folder().await else { + return Ok(None); + }; + let path = folder.path().to_path_buf(); + crate::report_export::remember(&config, &path).map_err(internal)?; + Ok(Some(path.to_string_lossy().into_owned())) +} + +#[tauri::command] +async fn save_report_image( + app: AppHandle, + name: String, + contents: Vec, +) -> Result { + let config = app.path().app_config_dir().map_err(internal)?; + tauri::async_runtime::spawn_blocking(move || { + let dir = crate::report_export::directory(&config) + .map_err(internal)? + .ok_or_else(|| internal("Choose a report folder first"))?; + let path = crate::report_export::write_png(&dir, &name, &contents).map_err(internal)?; + Ok(path.to_string_lossy().into_owned()) + }) + .await + .map_err(internal)? +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 0c44734..158aca9 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -11,6 +11,7 @@ mod game_keys; mod hotkey; mod logging; mod plugin_host; +mod report_export; mod storage; mod tray; mod updater; diff --git a/app/src-tauri/src/report_export.rs b/app/src-tauri/src/report_export.rs new file mode 100644 index 0000000..0351a49 --- /dev/null +++ b/app/src-tauri/src/report_export.rs @@ -0,0 +1,86 @@ +//! A user-chosen report folder, remembered separately from game settings. +use std::{ + fs, + io::{self, Write}, + path::{Path, PathBuf}, +}; + +pub(crate) fn directory(config: &Path) -> io::Result> { + match fs::read(config.join("report-directory.json")) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map(Some) + .map_err(io::Error::other), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +pub(crate) fn remember(config: &Path, directory: &Path) -> io::Result<()> { + fs::create_dir_all(config)?; + let tmp = config.join("report-directory.tmp"); + fs::write(&tmp, serde_json::to_vec(directory)?)?; + fs::rename(tmp, config.join("report-directory.json")) +} + +pub(crate) fn write_png(directory: &Path, name: &str, bytes: &[u8]) -> io::Result { + let stem = name + .strip_suffix(".png") + .ok_or_else(|| io::Error::other("Expected a PNG filename"))?; + winer_core::export::check_stem(stem).map_err(io::Error::other)?; + if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() > 64 * 1024 * 1024 { + return Err(io::Error::other("Invalid or oversized PNG")); + } + // Do not silently recreate a moved/deleted destination, or overwrite previous reports. + for copy in 1..=9999 { + let name = if copy == 1 { + format!("{stem}.png") + } else { + format!("{stem} ({copy}).png") + }; + let path = directory.join(name); + let mut file = match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + }; + if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) { + drop(file); + let _ = fs::remove_file(&path); + return Err(error); + } + return Ok(path); + } + Err(io::Error::other("Too many copies of this report")) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn remembers_folder_and_writes_without_overwriting_or_escaping() { + let root = tempfile::tempdir().unwrap(); + let png = b"\x89PNG\r\n\x1a\nexample"; + assert_eq!(directory(root.path()).unwrap(), None); + remember(root.path(), root.path()).unwrap(); + assert_eq!( + directory(root.path()).unwrap().as_deref(), + Some(root.path()) + ); + let replacement = root.path().join("other-folder"); + remember(root.path(), &replacement).unwrap(); + assert_eq!(directory(root.path()).unwrap(), Some(replacement)); + let first = write_png(root.path(), "玩家.png", png).unwrap(); + let second = write_png(root.path(), "玩家.png", png).unwrap(); + assert_ne!(first, second); + assert_eq!(fs::read(first).unwrap(), png); + for bad in ["../escape.png", "C:\\escape.png", "CON.png", "wrong.zip"] { + assert!(write_png(root.path(), bad, png).is_err()); + } + assert!(write_png(root.path(), "bad.png", b"not PNG").is_err()); + assert!(write_png(&root.path().join("missing"), "good.png", png).is_err()); + } +} diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 86f991d..977e6df 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -27,7 +27,7 @@ } ], "security": { - "csp": "default-src 'self'; img-src 'self' data: http://lcu.localhost lcu:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost" + "csp": "default-src 'self'; img-src 'self' data: http://lcu.localhost lcu:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost http://lcu.localhost lcu: data:" } }, "plugins": { diff --git a/crates/core/src/analysis.rs b/crates/core/src/analysis.rs index 350e52e..c3bf5fe 100644 --- a/crates/core/src/analysis.rs +++ b/crates/core/src/analysis.rs @@ -392,6 +392,16 @@ fn line(game: &Game, participant: &Participant) -> PlayerLine { spells: [participant.spell1_id, participant.spell2_id], items: stats.items(), augments: stats.augments(), + primary_style: stats.perk_primary_style, + primary_runes: [stats.perk0, stats.perk1, stats.perk2, stats.perk3] + .into_iter() + .filter(|id| *id > 0) + .collect(), + secondary_runes: [stats.perk4, stats.perk5] + .into_iter() + .filter(|id| *id > 0) + .collect(), + stat_shards: [stats.stat_perk0, stats.stat_perk1, stats.stat_perk2], keystone: stats.perk0, sub_style: stats.perk_sub_style, kills: stats.kills, @@ -1430,6 +1440,24 @@ mod tests { assert_eq!(summary.kind, GameKind::Custom, "a list row says it too"); } + #[test] + fn lcu_runes_reach_the_player_line() { + let participant: Participant = serde_json::from_value(serde_json::json!({"stats": { + "perkPrimaryStyle":8000,"perkSubStyle":8300, + "perk0":8005,"perk1":9111,"perk2":9104,"perk3":8014,"perk4":8304,"perk5":8345, + "statPerk0":5005,"statPerk1":5008,"statPerk2":5001 + }})) + .unwrap(); + let shown = line(&Game::default(), &participant); + assert_eq!(shown.primary_runes, [8005, 9111, 9104, 8014]); + assert_eq!(shown.secondary_runes, [8304, 8345]); + assert_eq!(shown.stat_shards, [5005, 5008, 5001]); + assert_eq!((shown.primary_style, shown.sub_style), (8000, 8300)); + let missing = line(&Game::default(), &Participant::default()); + assert!(missing.primary_runes.is_empty()); + assert_eq!(missing.stat_shards, [0, 0, 0]); + } + #[test] fn match_detail_groups_ten_players_into_two_teams() { let game: Game = fixture("live/ranked/match-game.json"); diff --git a/crates/core/src/model.rs b/crates/core/src/model.rs index 67a826a..acfdda1 100644 --- a/crates/core/src/model.rs +++ b/crates/core/src/model.rs @@ -324,6 +324,14 @@ pub struct Stats { pub item5: i64, pub item6: i64, pub perk0: i64, + pub perk1: i64, + pub perk2: i64, + pub perk3: i64, + pub perk4: i64, + pub perk5: i64, + pub stat_perk0: i64, + pub stat_perk1: i64, + pub stat_perk2: i64, pub perk_primary_style: i64, pub perk_sub_style: i64, pub largest_multi_kill: i64, diff --git a/crates/core/src/sgp.rs b/crates/core/src/sgp.rs index 32fa83e..f27291d 100644 --- a/crates/core/src/sgp.rs +++ b/crates/core/src/sgp.rs @@ -244,8 +244,9 @@ struct SgpParticipant { /// Runes nested by tree, where the LCU flattens them. #[derive(Debug, Default, Deserialize)] -#[serde(default)] +#[serde(rename_all = "camelCase", default)] struct Perks { + stat_perks: StatPerks, styles: Vec, } @@ -264,12 +265,24 @@ struct PerkSelection { perk: i64, } +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct StatPerks { + offense: i64, + flex: i64, + defense: i64, +} + impl Perks { fn style(&self, description: &str, position: usize) -> Option<&PerkStyle> { self.styles .iter() .find(|style| style.description == description) - .or_else(|| self.styles.get(position)) + .or_else(|| { + self.styles + .get(position) + .filter(|style| style.description.is_empty()) + }) } } @@ -342,6 +355,12 @@ impl SgpParticipant { .and_then(|style| style.selections.first()) .map_or(0, |selection| selection.perk); let primary_style = primary.map_or(0, |style| style.style); + let selection = |description: &str, position: usize, index: usize| { + self.perks + .style(description, position) + .and_then(|style| style.selections.get(index)) + .map_or(0, |selection| selection.perk) + }; let sub_style = self .perks .style("subStyle", 1) @@ -391,6 +410,14 @@ impl SgpParticipant { item5: self.item5, item6: self.item6, perk0: keystone, + perk1: selection("primaryStyle", 0, 1), + perk2: selection("primaryStyle", 0, 2), + perk3: selection("primaryStyle", 0, 3), + perk4: selection("subStyle", 1, 0), + perk5: selection("subStyle", 1, 1), + stat_perk0: self.perks.stat_perks.offense, + stat_perk1: self.perks.stat_perks.flex, + stat_perk2: self.perks.stat_perks.defense, perk_primary_style: primary_style, perk_sub_style: sub_style, largest_multi_kill: self.largest_multi_kill, @@ -445,6 +472,41 @@ mod tests { use super::*; use crate::{analysis, test_support::fixture}; + #[test] + fn full_runes_keep_style_identity_and_shard_order() { + let player: SgpParticipant = serde_json::from_value(serde_json::json!({ + "perks": { + "styles": [ + {"description":"subStyle","style":8300,"selections":[{"perk":8304},{"perk":8345}]}, + {"description":"primaryStyle","style":8000,"selections":[{"perk":8005},{"perk":9111},{"perk":9104},{"perk":8014}]} + ], + "statPerks":{"defense":5001,"offense":5005,"flex":5008} + } + })).unwrap(); + let (player, _) = player.split(); + let s = player.stats; + assert_eq!( + [s.perk0, s.perk1, s.perk2, s.perk3, s.perk4, s.perk5], + [8005, 9111, 9104, 8014, 8304, 8345] + ); + assert_eq!( + [s.stat_perk0, s.stat_perk1, s.stat_perk2], + [5005, 5008, 5001] + ); + assert_eq!((s.perk_primary_style, s.perk_sub_style), (8000, 8300)); + let empty: SgpParticipant = serde_json::from_str("{}").unwrap(); + assert_eq!(empty.split().0.stats.perk1, 0); + let partial: SgpParticipant = serde_json::from_value(serde_json::json!({ + "perks": {"styles": [ + {"description":"subStyle","style":8300,"selections":[{"perk":8304}]} + ]} + })) + .unwrap(); + let stats = partial.split().0.stats; + assert_eq!((stats.perk_primary_style, stats.perk0), (0, 0)); + assert_eq!((stats.perk_sub_style, stats.perk4), (8300, 8304)); + } + #[test] fn tencent_shards_have_a_server_and_their_issuer_names_them() { assert_eq!(base("NJ100"), Some("https://nj100-sgp.lol.qq.com:21019")); diff --git a/crates/core/src/view.rs b/crates/core/src/view.rs index 5155d36..3da6445 100644 --- a/crates/core/src/view.rs +++ b/crates/core/src/view.rs @@ -563,6 +563,11 @@ pub struct PlayerLine { pub items: [i64; 7], /// Arena and Hextech ARAM augments, in the order picked. pub augments: Vec, + pub primary_style: i64, + pub primary_runes: Vec, + pub secondary_runes: Vec, + /// Offense, flex and defense shards, in that order. Zero means unavailable. + pub stat_shards: [i64; 3], pub keystone: i64, pub sub_style: i64, pub kills: i64, diff --git a/packages/shared/src/bindings.ts b/packages/shared/src/bindings.ts index 9a58fe0..dcdaeeb 100644 --- a/packages/shared/src/bindings.ts +++ b/packages/shared/src/bindings.ts @@ -246,7 +246,11 @@ export type PlayerLine = { puuid: string, name: RiotId | null, iconId: number, c /** * Arena and Hextech ARAM augments, in the order picked. */ -augments: Array, keystone: number, subStyle: number, kills: number, deaths: number, assists: number, cs: number, gold: number, damage: number, damageTaken: number, vision: number, largestMultiKill: number, win: boolean, remake: boolean, +augments: Array, primaryStyle: number, primaryRunes: Array, secondaryRunes: Array, +/** + * Offense, flex and defense shards, in that order. Zero means unavailable. + */ +statShards: [number, number, number], keystone: number, subStyle: number, kills: number, deaths: number, assists: number, cs: number, gold: number, damage: number, damageTaken: number, vision: number, largestMultiKill: number, win: boolean, remake: boolean, /** * Arena placement, 1–8. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c909cc6..9ab9314 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,12 @@ importers: '@winer/shared': specifier: workspace:* version: link:../../packages/shared + fflate: + specifier: 0.8.2 + version: 0.8.2 + html-to-image: + specifier: 1.11.13 + version: 1.11.13 lucide-react: specifier: 1.52.0 version: 1.52.0(@types/react@19.3.0)(react@19.3.0) @@ -1055,6 +1061,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1067,6 +1076,9 @@ packages: resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} engines: {node: ^22.13.0 || >=24.0.0} + html-to-image@1.11.13: + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} @@ -2127,6 +2139,8 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + fflate@0.8.2: {} + fsevents@2.3.3: optional: true @@ -2138,6 +2152,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-to-image@1.11.13: {} + indent-string@4.0.0: {} is-potential-custom-element-name@1.0.1: {}