summarum/src/ui/export.ts
Horizontal42 c1a7254fdf fix arbitrary file write via write_text_file/write_image_file
Both Tauri commands took a raw path string straight from the frontend
and wrote to it with no validation — any code able to call invoke()
(a compromised or malicious extension script, which run with full page
privileges) could write to any path the OS user has access to, not just
one chosen through the save dialog. Move the save-file dialog into the
Rust command itself so the only path ever used is one the native dialog
returned; the frontend now only sends the content to save.
2026-07-08 15:50:13 +03:00

79 lines
2.7 KiB
TypeScript

// Renders the active sheet + its results to a PNG and copies it to the
// clipboard, falling back to a Save As dialog when the Clipboard API is
// unavailable (e.g. the window lost focus).
import { writeImageFile } from "../storage";
export interface ExportImageDeps {
lines: string[];
results: { text: string | null }[];
fontSize: number;
toast(msg: string): void;
t(key: string): string;
}
export async function exportSheetImage(deps: ExportImageDeps): Promise<void> {
const { lines: rawLines, results: doc, fontSize, toast, t } = deps;
const dpr = window.devicePixelRatio || 1;
const style = getComputedStyle(document.documentElement);
const bgColor = style.getPropertyValue("--bg").trim() || "#ffffff";
const fgColor = style.getPropertyValue("--fg").trim() || "#333333";
const resultColor = style.getPropertyValue("--result").trim() || "#d57d2c";
const monoFont = style.getPropertyValue("--mono").trim() || "monospace";
const lineH = Math.round(fontSize * 1.7);
const padX = 20, padTop = 16, padBot = 16;
const canvas = document.createElement("canvas");
const ctx2 = canvas.getContext("2d")!;
ctx2.font = `${fontSize}px ${monoFont}`;
const maxWidth = rawLines.reduce((m, l) => Math.max(m, ctx2.measureText(l).width), 0) + padX * 4 + 150;
const w = Math.max(400, maxWidth);
const h = padTop + rawLines.length * lineH + padBot;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx2.scale(dpr, dpr);
ctx2.fillStyle = bgColor;
ctx2.fillRect(0, 0, w, h);
ctx2.font = `${fontSize}px ${monoFont}`;
for (let i = 0; i < rawLines.length; i++) {
const y = padTop + i * lineH + fontSize;
ctx2.fillStyle = fgColor;
ctx2.fillText(rawLines[i], padX, y);
const res = doc[i]?.text;
if (res) {
ctx2.fillStyle = resultColor;
const rx = w - padX - ctx2.measureText(res).width;
ctx2.fillText(res, rx, y);
}
}
const blob = await new Promise<Blob | null>((res) => canvas.toBlob(res, "image/png"));
if (!blob) { toast(t("imageFailed")); return; }
try {
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
toast(t("imageCopied"));
} catch {
// clipboard API failed — save to file
try {
const buf = await blob.arrayBuffer();
const b64 = bytesToBase64(new Uint8Array(buf));
if (await writeImageFile(b64)) {
toast(t("saved"));
}
} catch {
toast(t("imageFailed"));
}
}
}
/** chunked to avoid "Maximum call stack size exceeded" from spreading a large array as args */
function bytesToBase64(bytes: Uint8Array): string {
let bin = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(bin);
}