Files
readest/apps/readest-app/src/utils/image.ts
T
Huang Xin 7185dca1a2 feat(reader): add save/share button to image gallery toolbar (#4680)
* feat(reader): add save/share button to image gallery toolbar

Add a button to the top-right toolbar of the fullscreen image viewer
that saves the currently viewed image to the device. It uses the native
or web Share flow where available (iOS/Android/macOS, navigator.share)
and falls back to a save dialog or browser download otherwise, reusing
the existing export path via appService.saveFile.

The button icon and label reflect the active flow (share vs save).
Adds dataUrlToBytes/imageExtensionFromMime helpers, unit and component
tests, and translations for the new strings across all locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(share): write shareable file to a Temp subdirectory to avoid 0-byte share

On Android, Tauri's Temp dir is the app cache dir, and the sharekit plugin
copies the shared file to <cacheDir>/<name> before firing the share intent.
When saveFile wrote the shareable file to the Temp root, that copy became a
copy onto itself whose output stream truncated the source to 0 bytes, so the
shared image (and any shared export) arrived as a 0 KB file. Write the file
to a Temp subdirectory instead so the plugin's copy has a distinct source.

Verified on a Xiaomi device: sharing a file in the Temp root truncated it to
0 bytes, while sharing from the subdirectory produced a real, non-empty copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): save image to system gallery on Android

The Android share sheet cannot save an image to a file (no file manager
registers as an ACTION_SEND target), so the Save Image button now writes
the image straight into the system photo gallery via MediaStore. It lands
in Pictures/Readest, visible in Gallery and the Files app, with no picker
and no storage permission on Android 10+.

Adds a save_image_to_gallery command to the native-bridge plugin (Rust +
Kotlin MediaStore insert) and an appService.saveImageToGallery method. On
Android the Save button uses it; iOS/macOS/desktop/web keep the existing
share/export flow, and the button label/icon reflect the actual action.

Also includes local agent memory notes that were staged alongside.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 06:28:08 +02:00

240 lines
6.8 KiB
TypeScript

export interface ParsedDataUrl {
bytes: Uint8Array;
mimeType: string;
}
/**
* Decode a `data:` URL into raw bytes plus its MIME type. Used to turn the
* in-memory image shown in the gallery viewer back into a file for the
* share / export flow.
*/
export function dataUrlToBytes(dataUrl: string): ParsedDataUrl {
const match = /^data:([^;,]*)(;base64)?,(.*)$/s.exec(dataUrl);
if (!match) {
throw new Error('Not a data URL');
}
const mimeType = match[1] || 'application/octet-stream';
const isBase64 = !!match[2];
const data = match[3] ?? '';
if (isBase64) {
const binary = atob(data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return { bytes, mimeType };
}
// Non-base64 data URLs hold percent-encoded text (e.g. inline SVG).
return { bytes: new TextEncoder().encode(decodeURIComponent(data)), mimeType };
}
/** Derive a file extension from an image MIME type (e.g. image/svg+xml -> svg). */
export function imageExtensionFromMime(mimeType: string): string {
const subtype = (mimeType.split('/')[1] || 'png').toLowerCase();
const base = subtype.split('+')[0]!;
return base === 'jpeg' ? 'jpg' : base;
}
/**
* Process book cover for Discord Rich Presence:
* - Fit to 512x512 with transparent background
* - Add Readest icon overlay at bottom right (10px padding)
* - Return as JPEG blob
*/
export async function processDiscordCover(coverUrl: string, iconUrl: string): Promise<Blob> {
const SIZE = 512;
const ICON_WIDTH = 224;
const ICON_HEIGHT = 182;
const PADDING = 10;
try {
const coverResponse = await fetch(coverUrl);
const coverBlob = await coverResponse.blob();
const coverImg = new Image();
coverImg.crossOrigin = 'anonymous';
const iconResponse = await fetch(iconUrl);
const iconBlob = await iconResponse.blob();
const iconImg = new Image();
iconImg.crossOrigin = 'anonymous';
return new Promise((resolve, reject) => {
let coverLoaded = false;
let iconLoaded = false;
const checkBothLoaded = () => {
if (!coverLoaded || !iconLoaded) return;
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
canvas.width = SIZE;
canvas.height = SIZE;
// Calculate cover dimensions to fit in 512x512
const aspectRatio = coverImg.width / coverImg.height;
let drawWidth, drawHeight, offsetX, offsetY;
if (aspectRatio > 1) {
// Wider than tall
drawWidth = SIZE;
drawHeight = SIZE / aspectRatio;
offsetX = 0;
offsetY = (SIZE - drawHeight) / 2;
} else {
// Taller than wide
drawHeight = SIZE;
drawWidth = SIZE * aspectRatio;
offsetX = (SIZE - drawWidth) / 2;
offsetY = 0;
}
// Draw cover image centered
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(coverImg, offsetX, offsetY, drawWidth, drawHeight);
// Draw icon at bottom right
ctx.drawImage(
iconImg,
SIZE - ICON_WIDTH - PADDING,
SIZE - ICON_HEIGHT - PADDING,
ICON_WIDTH,
ICON_HEIGHT,
);
// Convert to JPEG blob
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to create blob'));
}
},
'image/jpeg',
0.9,
);
} catch (error) {
reject(new Error(`Failed to process cover: ${error}`));
}
};
coverImg.onload = () => {
coverLoaded = true;
checkBothLoaded();
};
iconImg.onload = () => {
iconLoaded = true;
checkBothLoaded();
};
coverImg.onerror = () => reject(new Error('Failed to load cover image'));
iconImg.onerror = () => reject(new Error('Failed to load icon image'));
const coverObjectUrl = URL.createObjectURL(coverBlob);
const iconObjectUrl = URL.createObjectURL(iconBlob);
coverImg.src = coverObjectUrl;
iconImg.src = iconObjectUrl;
coverImg.onload = function () {
URL.revokeObjectURL(coverObjectUrl);
coverLoaded = true;
checkBothLoaded();
};
iconImg.onload = function () {
URL.revokeObjectURL(iconObjectUrl);
iconLoaded = true;
checkBothLoaded();
};
});
} catch (error) {
console.error('Error processing Discord cover:', error);
throw error;
}
}
export async function fetchImageAsBase64(
url: string,
options: {
targetWidth?: number;
format?: 'image/jpeg' | 'image/png' | 'image/webp';
quality?: number;
} = {},
): Promise<string> {
const { targetWidth = 256, format = 'image/jpeg', quality = 0.85 } = options;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
const img = new Image();
img.crossOrigin = 'anonymous';
return new Promise((resolve, reject) => {
img.onload = () => {
try {
const aspectRatio = img.height / img.width;
const newWidth = targetWidth;
const newHeight = Math.round(newWidth * aspectRatio);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
canvas.width = newWidth;
canvas.height = newHeight;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, newWidth, newHeight);
const base64 = canvas.toDataURL(format, quality);
resolve(base64);
} catch (error) {
reject(new Error(`Failed to scale image: ${error}`));
}
};
img.onerror = () => reject(new Error('Failed to load image for scaling'));
const objectUrl = URL.createObjectURL(blob);
img.src = objectUrl;
const cleanup = () => URL.revokeObjectURL(objectUrl);
const originalOnload = img.onload;
const originalOnerror = img.onerror;
img.onload = function (ev) {
cleanup();
if (originalOnload) originalOnload.call(this, ev);
};
img.onerror = function (ev) {
cleanup();
if (originalOnerror) originalOnerror.call(this, ev);
};
});
} catch (error) {
console.error('Error fetching and encoding image:', error);
throw error;
}
}