/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
import 'dart:convert';
import 'dart:isolate';
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui';
import 'package:fast_equatable/hash.dart';
import 'package:flutter_svg/flutter_svg.dart' as svg;
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/domain/entities/equatable_image.dart';
import 'package:weblibre/utils/lru_cache.dart';
final _cache = LRUCache(100);
const _defaultSvgIconSize = 32;
/// Clears the global image decode cache.
void clearImageCache() {
_cache.clear();
}
Future tryDecodeImage(
Uint8List bytes, {
int? targetWidth,
int? targetHeight,
bool allowUpscaling = true,
bool cacheResult = true,
}) async {
// The decode options are part of the identity of the result, not just of the
// request: the same bytes decoded at a thumbnail's target width and at an
// icon's native size are different images. Keying on the content digest
// alone would both hand back a wrongly-sized image to whichever caller ran
// second, and make two genuinely different decodes compare equal.
final identity = (
digest: secureHash(bytes),
targetWidth: targetWidth,
targetHeight: targetHeight,
allowUpscaling: allowUpscaling,
);
if (cacheResult) {
final cached = _cache.get(identity);
if (cached?.value != null) {
return cached;
} else if (cached != null) {
_cache.remove(identity);
}
}
try {
final codec = await instantiateImageCodec(
bytes,
targetWidth: targetWidth,
targetHeight: targetHeight,
allowUpscaling: allowUpscaling,
);
final frameInfo = await codec.getNextFrame();
final image = EquatableImage(frameInfo.image, identity: identity);
if (image.value != null && image.value!.width > 0) {
if (cacheResult) {
_cache.set(identity, image);
}
return image;
}
} catch (e, s) {
if (_isLikelySvg(bytes)) {
try {
final svgImage = await _tryDecodeSvg(
bytes,
identity: identity,
targetWidth: targetWidth,
targetHeight: targetHeight,
);
if (svgImage != null) {
if (cacheResult) {
_cache.set(identity, svgImage);
}
return svgImage;
}
} catch (svgError, svgStackTrace) {
logger.w(
'Failed to rasterize SVG image',
error: svgError,
stackTrace: svgStackTrace,
);
}
}
logger.w('Failed to decode image', error: e, stackTrace: s);
}
return null;
}
/// Transcodes a native screenshot (delivered as WebP) to PNG off the main
/// isolate.
///
/// Decoding a full-resolution screenshot and re-encoding it as PNG costs tens
/// of milliseconds on the UI thread, and every caller does it right as a share
/// sheet or file picker is animating in — so the cost lands as a visible hitch.
/// dart:ui's codec APIs are usable from background isolates, so the whole
/// transcode runs there; if that ever fails we fall back to doing it inline
/// rather than losing the feature.
Future encodeScreenshotAsPng(Uint8List screenshot) async {
try {
return await Isolate.run(() => _transcodeToPng(screenshot));
} catch (e, s) {
logger.w(
'Screenshot PNG transcode failed in background isolate, retrying inline',
error: e,
stackTrace: s,
);
return _transcodeToPng(screenshot);
}
}
Future _transcodeToPng(Uint8List bytes) async {
final codec = await instantiateImageCodec(bytes);
try {
final frameInfo = await codec.getNextFrame();
try {
final png = await frameInfo.image.toByteData(format: ImageByteFormat.png);
return png?.buffer.asUint8List();
} finally {
frameInfo.image.dispose();
}
} finally {
codec.dispose();
}
}
bool _isLikelySvg(Uint8List bytes) {
final prefix = utf8
.decode(bytes.sublist(0, min(bytes.length, 512)), allowMalformed: true)
.trimLeft();
final normalized = prefix.isNotEmpty && prefix.codeUnitAt(0) == 0xFEFF
? prefix.substring(1)
: prefix;
final lower = normalized.toLowerCase();
return lower.contains('