export sheet as PNG — clipboard + file-save fallback

This commit is contained in:
Horizontal42 2026-07-01 20:44:04 +03:00
parent 661a4500ca
commit bbbcc0497c
5 changed files with 118 additions and 3 deletions

View file

@ -35,6 +35,7 @@
<div id="export-wrap"> <div id="export-wrap">
<button id="export-btn" title="Export">⤓</button> <button id="export-btn" title="Export">⤓</button>
<div id="export-menu" class="hidden"> <div id="export-menu" class="hidden">
<button data-action="image" data-i18n="exportImage">Copy as image</button>
<button data-action="copy" data-i18n="exportCopy">Copy sheet</button> <button data-action="copy" data-i18n="exportCopy">Copy sheet</button>
<button data-action="save-sum" data-i18n="exportSum">Save as .sum</button> <button data-action="save-sum" data-i18n="exportSum">Save as .sum</button>
<button data-action="export-txt" data-i18n="exportTxt">Export as text</button> <button data-action="export-txt" data-i18n="exportTxt">Export as text</button>

View file

@ -42,6 +42,38 @@ fn write_text_file(path: String, contents: String) -> Result<(), String> {
write_atomic(std::path::Path::new(&path), &contents) write_atomic(std::path::Path::new(&path), &contents)
} }
#[tauri::command]
fn write_image_file(path: String, data_base64: String) -> Result<(), String> {
use std::io::Write;
let bytes = base64_decode(&data_base64)?;
let p = std::path::Path::new(&path);
let tmp = p.with_extension("tmp");
std::fs::File::create(&tmp)
.and_then(|mut f| f.write_all(&bytes))
.map_err(|e| e.to_string())?;
fs::rename(&tmp, p).map_err(|e| e.to_string())
}
fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
use std::collections::HashMap;
let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut map = HashMap::new();
for (i, c) in alphabet.chars().enumerate() { map.insert(c, i as u8); }
let s: String = s.chars().filter(|c| *c != '=').collect();
let mut out = Vec::with_capacity(s.len() * 3 / 4);
let bytes: Vec<u8> = s.chars().map(|c| map.get(&c).copied().ok_or_else(|| format!("bad base64 char {}", c))).collect::<Result<_, _>>()?;
for chunk in bytes.chunks(4) {
let b0 = chunk[0];
let b1 = *chunk.get(1).unwrap_or(&0);
let b2 = *chunk.get(2).unwrap_or(&0);
let b3 = *chunk.get(3).unwrap_or(&0);
out.push((b0 << 2) | (b1 >> 4));
if chunk.len() > 2 { out.push((b1 << 4) | (b2 >> 2)); }
if chunk.len() > 3 { out.push((b2 << 6) | b3); }
}
Ok(out)
}
/// write via a temp file + rename so a crash mid-write cannot corrupt the target /// write via a temp file + rename so a crash mid-write cannot corrupt the target
fn write_atomic(path: &Path, contents: &str) -> Result<(), String> { fn write_atomic(path: &Path, contents: &str) -> Result<(), String> {
let tmp = path.with_extension("tmp"); let tmp = path.with_extension("tmp");
@ -611,7 +643,8 @@ fn main() {
read_text_file, read_text_file,
exit_app, exit_app,
is_hidden_launch, is_hidden_launch,
write_text_file write_text_file,
write_image_file
]) ])
.setup(|app| { .setup(|app| {
let handle = app.handle().clone(); let handle = app.handle().clone();

View file

@ -63,6 +63,9 @@ const dict: Record<string, Record<string, string>> = {
moveUp: "Move up", moveUp: "Move up",
moveDown: "Move down", moveDown: "Move down",
marketUpdated: "Market data updated", marketUpdated: "Market data updated",
exportImage: "Copy as image",
imageCopied: "Image copied",
imageFailed: "Could not copy image",
}, },
ru: { ru: {
settings: "Настройки", settings: "Настройки",
@ -126,6 +129,9 @@ const dict: Record<string, Record<string, string>> = {
moveUp: "Выше", moveUp: "Выше",
moveDown: "Ниже", moveDown: "Ниже",
marketUpdated: "Рыночные данные обновлены", marketUpdated: "Рыночные данные обновлены",
exportImage: "Скопировать картинкой",
imageCopied: "Картинка скопирована",
imageFailed: "Не удалось скопировать картинку",
}, },
}; };

View file

@ -5,7 +5,7 @@ import { SumEditor } from "./ui/editor";
import { import {
AppData, DocMeta, SettingsData, defaultSettingsData, AppData, DocMeta, SettingsData, defaultSettingsData,
loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings, loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings,
fetchRates, fetchMarketData, loadExtensionScripts, openExtensionsFolder, isTauri, fetchRates, fetchMarketData, writeImageFile, loadExtensionScripts, openExtensionsFolder, isTauri,
getLaunchFile, onOpenFile, onFileDrop, getLaunchFile, onOpenFile, onFileDrop,
setDataDir, runBackups, backupDeletedSheet, openBackupsFolder, setDataDir, runBackups, backupDeletedSheet, openBackupsFolder,
chooseFolder, dataDirHasDocuments, migrateDataDir, chooseFolder, dataDirHasDocuments, migrateDataDir,
@ -625,6 +625,72 @@ function renderRatesInfo(): void {
} }
} }
// ---------- PNG export
async function renderSheetImage(): Promise<void> {
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 fontSize = settings.fontSize;
const lineH = Math.round(fontSize * 1.7);
const padX = 20, padTop = 16, padBot = 16;
const doc = engine.evaluateDocument(editor.getText());
const rawLines = editor.getText().split("\n");
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 { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({ filters: [{ name: "PNG Image", extensions: ["png"] }] });
if (path) {
const buf = await blob.arrayBuffer();
const b64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
await writeImageFile(path, b64);
toast(t("saved"));
}
} catch (e) {
toast(t("imageFailed"));
}
}
}
// ---------- modal // ---------- modal
/** two-button modal; resolves "a" | "b" | null (click outside = cancel) */ /** two-button modal; resolves "a" | "b" | null (click outside = cancel) */
@ -859,7 +925,9 @@ async function boot(): Promise<void> {
if (!btn) return; if (!btn) return;
exportMenu.classList.add("hidden"); exportMenu.classList.add("hidden");
const action = btn.dataset.action; const action = btn.dataset.action;
if (action === "copy") { if (action === "image") {
void renderSheetImage();
} else if (action === "copy") {
void navigator.clipboard.writeText(editor.getSheetWithResults()); void navigator.clipboard.writeText(editor.getSheetWithResults());
toast(t("copied")); toast(t("copied"));
} else if (action === "print") { } else if (action === "print") {

View file

@ -205,6 +205,13 @@ export async function fetchRates(force = false): Promise<RatesPayload | null> {
return null; return null;
} }
// ---------- image export
export async function writeImageFile(path: string, dataBase64: string): Promise<void> {
if (!isTauri()) return;
await invoke("write_image_file", { path, dataBase64 });
}
// ---------- market data // ---------- market data
export async function fetchMarketData(symbols: string[]): Promise<Record<string, number>> { export async function fetchMarketData(symbols: string[]): Promise<Record<string, number>> {