mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-11 01:02:01 +03:00
export sheet as PNG — clipboard + file-save fallback
This commit is contained in:
parent
661a4500ca
commit
bbbcc0497c
5 changed files with 118 additions and 3 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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: "Не удалось скопировать картинку",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
72
src/main.ts
72
src/main.ts
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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>> {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue