diff --git a/lib/components/canvas/_asset_cache.dart b/lib/components/canvas/_asset_cache.dart index eec7519d2..a3e5c9334 100644 --- a/lib/components/canvas/_asset_cache.dart +++ b/lib/components/canvas/_asset_cache.dart @@ -1,10 +1,15 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; - +import 'dart:math'; import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:pdfrx/pdfrx.dart'; import 'package:saber/components/canvas/image/editor_image.dart'; +import 'package:saber/data/file_manager/file_manager.dart'; /// A cache for assets that are loaded from disk. /// @@ -134,3 +139,679 @@ class OrderedAssetCache { } } } +/////////////////////////////////////////////////////////////////////////// +/// current cache and images problems: +/// 1. two caches assetCache (for working), OrderedAssetCache (for writing) +/// 2. keeping bytes of each asset in memory (do not know if it is problem, but it is problem when adding to cache +/// because all bytes must be compared with each already added) +/// 3. after first saving of note importing pdf, is pdf saved as one asset because: +/// 1. importPdfFromFilePath create one File " final pdfFile = File(path);" +/// and this File is used to create all instances of pages +/// 2. when saving, all pages are one asset, because File is the same object!!! +/// +/// 4. when loading note again and saving note, each page is treated as different pdf +/// why: PdfEditorImage class +/// 1. when reading fromJson is created "pdfFile = FileManager.getFile('$sbnPath${Editor.extension}.$assetIndex');" +/// for each page (even if they are the same asset file) +/// 2. PdfEditorImage constructor is called with this File - each page has its own File!!! +/// 1. OrderedCache.add adds each page as new asset because each page is different File +/// 5. problems of PdfEditorImage +/// 1. PdfEditorImage keeps bytes of the whole pdf (wasting memory) even if it renders only one page +/// 2. creates its own pdfDocument renderer - for each pdf page is new renderer keeping whole pdf +/// 3. while saving note is to the OrderedAssetCache added each page of pdf separately as new asset. +/// +/// +/// New approach to cache +/// +/// handles jpg, png, pdf (not svg yet) +/// for each photo item provides ValueNotifier so the same items have the same ImageProvider +/// for each pdf item provides PdfDocument every page of pdf use the same provider +/// +/// During reading note to Editor are new items added using addSync - which is fast +/// addSync method: +/// 1. must treat duplicated assets (especially pdfs created by current OrderedCache) +/// 2. it calculate fast hash from first 100 KB of file and file size, if hash is the same files are "identical" +/// this is important only for compatibility. +/// +/// In Editor is used async method when adding new image +/// add method: +/// it compares first paths, file size and then hashes of all cache items +/// calculation of hash is very time consuming, it will be better for pdfs to extract /Info and read author, creation date, etc. +/// and use this to recognize different pdfs. +/// +/// Cache properties: +/// 1. Every cache item is created and treated as File (path). Even picked Photos are first saved as temporary files and then added to chache. +/// 2. Each item provides PdfDocument for pdfs or ValueNotifier for images. It saves memory + + + + +// class returning preview data +class PreviewResult { + final int previewHash; + final int fileSize; + + PreviewResult(this.previewHash, this.fileSize); +} + +// object in cache +class CacheItem { + final Object value; + int? previewHash; // quick hash (from first 100KB bytes) + int? hash; // hash can be calculated later + final ValueNotifier imageProviderNotifier; // image provider for png, svg as value listener + PdfDocument? _pdfDocument; // pdf document provider for pdf + + // for files only + final int? fileSize; + final String? filePath; // only for files - for fast comparison without reading file contents + final String? fileExt; // file extension + int _refCount = 0; // number of references + bool _released = false; + + CacheItem(this.value, + {this.hash, + this.filePath, + this.previewHash, + this.fileSize, + this.fileExt, + ValueNotifier? imageProviderNotifier, + }): imageProviderNotifier = imageProviderNotifier ?? ValueNotifier(null); + + + // increase use of item + void addUse() { + if (_released) throw StateError('Trying to add use of released CacheItem'); + _refCount++; + } + + // when asset is released (no more used) + void freeUse() { + if (_refCount > 0) _refCount--; + if (_refCount == 0) _released = true; + } + + bool get isUnused => _refCount == 0; + bool get isReleased => _released; + + @override + bool operator ==(Object other) { + if (other is! CacheItem) return false; + + // compare hashes it is precise + if (hash != null && other.hash != null) { + return hash == other.hash; + } + if (filePath != null && other.filePath != null) { + if (filePath == other.filePath) { + // both file paths are the same + return true; + } + } + + // Quick check using previewHash + if (previewHash != null && other.previewHash != null) { + if (previewHash != other.previewHash) { + // preview hashes do not match - assets are different + return false; + } + } + return false; // consider not equal + } + + @override + int get hashCode { + // If hash is not null, prefer it (since you compare on it first in ==) + if (hash != null) return hash.hashCode; + + // If previewHash is available, use it + if (previewHash != null) return previewHash.hashCode; + + // Otherwise fall back to filePath + if (filePath != null) return filePath.hashCode; + + // Default fallback + return 0; + } + + // give image provider + ImageProvider getImageProvider(Object item) { + // return cached provider if already available + if (item is File) { + return FileImage(item); + } else if (item is Uint8List) { + return MemoryImage(item); + } else if (item is MemoryImage) { + return item; + } else if (item is FileImage) { + return item; + } else { + throw UnsupportedError('Unsupported type for ImageProvider: ${item.runtimeType}'); + } + } + + // invalidate image provider notifier value - called in case of image replacement + // this causes that new imageProvider will be created + void invalidateImageProvider() { + imageProviderNotifier.value = null; // will be recreated on next access + } + +// @override +// int? get hash => filePath?.hash ?? hash; + + @override + String toString() => + 'CacheItem(path: $filePath, preview=$previewHash, full=$hash, refs=$_refCount, released=$_released)'; +} + +// cache manager +class AssetCacheAll { + final List _items = []; + final Map _aliasMap = {}; // duplicit indices point to first indice - updated in finalize + final Map _previewHashIndex = {}; // Map from previewHash → first index in _items + + final Map> _openingDocs = {}; // Holds currently opening futures to avoid duplicate opens + + /// Whether items from the cache can be removed: + /// set to false during file save. + bool allowRemovingAssets = true; + + + + final log = Logger('OrderedAssetCache'); + + // return pdfDocument of asset it is lazy because it take some time to do it + Future getPdfDocument(int assetId) { + // if already opened, return it immediately + final item = _items[assetId]; + if (item._pdfDocument != null) return Future.value(item._pdfDocument!); + + // if someone else is already opening this doc, return their future + final pending = _openingDocs[assetId]; + if (pending != null) return pending; + + // otherwise start opening + final future = _openPdfDocument(item); + _openingDocs[assetId] = future; + + // when done, store the PdfDocument in the CacheItem and remove from _openingDocs + future.then((doc) { + item._pdfDocument = doc; + _openingDocs.remove(assetId); + }); + + return future; + } + + // open pdf document + Future _openPdfDocument(CacheItem item) async { + if (item.filePath != null) { + return PdfDocument.openFile(item.filePath!); + } else if (item.value is Uint8List) { + return PdfDocument.openData(item.value as Uint8List); + } else { + throw StateError('Asset is not a PDF'); + } + } + + // give image provider notifier for asset image + ValueNotifier getImageProviderNotifier(int assetId) { + // return cached provider if already available + final item = _items[assetId]; + if (item.imageProviderNotifier.value != null) return item.imageProviderNotifier; + + if (item.value is File) { + item.imageProviderNotifier.value = FileImage(item.value as File); + } else if (item.value is Uint8List) { + item.imageProviderNotifier.value = MemoryImage(item.value as Uint8List); + } else if (item.value is MemoryImage) { + item.imageProviderNotifier.value = item.value as MemoryImage; + } else if (item.value is FileImage) { + item.imageProviderNotifier.value = item.value as FileImage; + } else { + throw UnsupportedError('Unsupported type for ImageProvider: ${item.value.runtimeType}'); + } + return item.imageProviderNotifier; + } + + + + + + // calculate hash of bytes (all) + int calculateHash(List bytes) { // fnv1a + int hash = 0x811C9DC5; + for (var b in bytes) { + hash ^= b; + hash = (hash * 0x01000193) & 0xFFFFFFFF; + } + return hash; + } + +// Compute a quick hash based on the first 100 KB of the file. +// This can be done synchronously to quickly filter duplicates. +// calculate preview hash of file + PreviewResult getFilePreviewHash(File file) { + final stat = file.statSync(); // get file metadata + final fileSize = stat.size; + + final raf = file.openSync(mode: FileMode.read); + try { + // read either the whole file if small, or just the first 100 KB + final toRead = fileSize < 100 * 1024 ? fileSize : 100 * 1024; + final bytes = raf.readSync(toRead); + final previewHash = calculateHash(bytes); + return PreviewResult((fileSize.hashCode << 32) ^ previewHash, // hash + fileSize); // file size + } finally { + raf.closeSync(); + } + } + + // add to cache but read only small part of files - used when reading note from disk + // full hashes are established later + // is used during read of note when it is opened. + // everything from new notes sba2 is File! + // only old notes provide bytes instead File + int addSync(Object value) { + if (value is File) { + log.info('allCache.addSync: value = $value'); + final path = value.path; + + final existingPathIndex = _items.indexWhere((i) => i.filePath == path); + if (existingPathIndex != -1) { + _items[existingPathIndex].addUse(); + log.info('allCache.addSync: already in cache {$_items[existingPathIndex]._refCount}'); + return existingPathIndex; + } + final previewResult=getFilePreviewHash(value); // calculate preliminary hash of file + + // Check if already cached + if (_previewHashIndex.containsKey(previewResult.previewHash)) { + final existingIndex = _previewHashIndex[previewResult.previewHash]!; + _items[existingIndex].addUse(); + return existingIndex; + } + + final newItem = CacheItem(value, + filePath: value.path, + previewHash: previewResult.previewHash, + fileSize: previewResult.fileSize)..addUse(); + _items.add(newItem); + final index = _items.length - 1; + _previewHashIndex[previewResult.previewHash] = index; // add to previously hashed + return index; + } else if (value is FileImage) { + final path = value.file.path; + final File file = File(path); + final previewResult=getFilePreviewHash(file); // calculate preliminary hash of file + + final existingPathIndex = _items.indexWhere((i) => i.filePath == path); + if (existingPathIndex != -1){ + _items[existingPathIndex].addUse(); + return existingPathIndex; + } + + // Check if already cached + if (_previewHashIndex.containsKey(previewResult.previewHash)) { + final existingIndex = _previewHashIndex[previewResult.previewHash]!; + _items[existingIndex].addUse(); + return existingIndex; + } + + + final newItem = CacheItem(value, + filePath: path, + previewHash: previewResult.previewHash, + fileSize: previewResult.fileSize)..addUse(); + _items.add(newItem); + final index = _items.length - 1; + _previewHashIndex[previewResult.previewHash] = index; // add to previously hashed + return index; + } else if (value is MemoryImage) { // file images are first compared by file path + final hash = calculateHash(value.bytes); + final newItem = CacheItem(value, hash: hash)..addUse(); + + final existingHashIndex = _items.indexOf(newItem); + if (existingHashIndex != -1) return existingHashIndex; + + _items.add(newItem); + final index = _items.length - 1; + return index; + } else if (value is List) { // bytes + final hash = calculateHash(value); + final newItem = CacheItem(value, hash: hash)..addUse(); + + final existingHashIndex = _items.indexOf(newItem); + if (existingHashIndex != -1) return existingHashIndex; + + _items.add(newItem); + final index = _items.length - 1; + return index; + } else if (value is String){ + // directly calculate hash + final newItem = CacheItem(value, hash: value.hashCode)..addUse(); + final existingHashIndex = _items.indexOf(newItem); + if (existingHashIndex != -1) return existingHashIndex; + _items.add(newItem); + final index = _items.length - 1; + return index; + } else { + throw Exception( + 'OrderedAssetCache.getBytes: unknown type ${value.runtimeType}'); + } + } + +// is used from Editor, when adding asset using file picker +// always is used File! + Future add(Object value) async { + if (value is File) { // files are first compared by file path + final path = value.path; + + // 1. Fast path check + final existingPathIndex = + _items.indexWhere((i) => i.filePath == path); + if (existingPathIndex != -1) { + _items[existingPathIndex].addUse(); + return existingPathIndex; + } + final previewResult=getFilePreviewHash(value); // calculate preliminary hash of file + + // Check if already cached + if (_previewHashIndex.containsKey(previewResult.previewHash)) { + final existingIndex = _previewHashIndex[previewResult.previewHash]!; + _items[existingIndex].addUse(); + return existingIndex; + } + + // compute expensive content hash + final bytes = await value.readAsBytes(); + final hash = calculateHash(bytes); + + final newItem = CacheItem(value, + filePath: value.path, + previewHash: previewResult.previewHash, + hash: hash, + fileSize: previewResult.fileSize)..addUse(); + + final existingHashIndex = _items.indexOf(newItem); + if (existingHashIndex != -1){ + _items[existingHashIndex].addUse(); + return existingHashIndex; + } + _items.add(newItem); + final index = _items.length - 1; + _previewHashIndex[previewResult.previewHash] = index; // add to previously hashed + return index; + } else if (value is FileImage) { // file images are first compared by file path + final path = value.file.path; + + // 1. Fast path check + final existingPathIndex = + _items.indexWhere((i) => i.filePath == path); + if (existingPathIndex != -1) { + _items[existingPathIndex].addUse(); + return existingPathIndex; + } + + final File file = File(path); + final previewResult=getFilePreviewHash(file); // calculate preliminary hash of file + + // Check if already cached + if (_previewHashIndex.containsKey(previewResult.previewHash)) { + final existingIndex = _previewHashIndex[previewResult.previewHash]!; + _items[existingIndex].addUse(); + return existingIndex; + } + + final bytes = await value.file.readAsBytes(); + final hash = calculateHash(bytes); + + final newItem = CacheItem(value, hash: hash, filePath: path); + + final existingHashIndex = _items.indexOf(newItem); + if (existingHashIndex != -1) return existingHashIndex; + + _items.add(newItem); + return _items.length - 1; + } else if (value is MemoryImage) { // file images are first compared by file path + final hash = calculateHash(value.bytes); + + final newItem = CacheItem(value, hash: hash); + + final existingHashIndex = _items.indexOf(newItem); + if (existingHashIndex != -1) return existingHashIndex; + + _items.add(newItem); + return _items.length - 1; + } else { + final hash = value.hashCode; // string + final newItem = CacheItem(value, hash: hash); + + final existingIndex = _items.indexOf(newItem); + if (existingIndex != -1) return existingIndex; + + _items.add(newItem); + return _items.length - 1; + } + } + + /// The number of (distinct) items in the cache. + int get length => _items.length; + bool get isEmpty => _items.isEmpty; + bool get isNotEmpty => _items.isNotEmpty; + + /// Converts the item at position [indexIn] + /// to bytes and returns them. + Future> getBytes(int indexIn) async { + final index = resolveIndex(indexIn); // find first occurence in cache to avoid duplicities + final item = _items[index].value; + if (item is List) { + return item; + } else if (item is File) { + return item.readAsBytes(); + } else if (item is String) { + return utf8.encode(item); + } else if (item is MemoryImage) { + return item.bytes; + } else if (item is FileImage) { + return item.file.readAsBytes(); + } else { + throw Exception( + 'assetCacheAll.getBytes: unknown type ${item.runtimeType}'); + } + } + + // finalize cache after it was filled using addSync - without calculation of hashes + // is called after note is read to Editor + Future finalize() async { + final Map seenHashes = {}; // hash points to first index + for (int i = 0; i < _items.length; i++) { + final item = _items[i]; + int hash; + int? hashItem = item.hash; + if (hashItem == 0) { + final bytes = await getBytes(i); + hash = calculateHash(bytes); + _items[i] = CacheItem(item.value, hash: hash, filePath: item.filePath, fileSize: item.fileSize); + } + else { + hash=hashItem!; + } + + if (seenHashes.containsKey(hash)) { + // už existuje → aliasuj na první výskyt + _aliasMap[i] = seenHashes[hash]!; + } else { + seenHashes[hash] = i; + } + } + } + + /// retunr real index through alias map + int resolveIndex(int index) { + return _aliasMap[index] ?? index; + } + + // replace asset by another one - typically when resampling image to lower resolution + Future replaceImage(Object value,int id) async { + if (value is File) { + // compute expensive content hash + final bytes = await value.readAsBytes(); + final hash = calculateHash(bytes); + final previewResult=getFilePreviewHash(value); // calculate preliminary hash of file + + final oldItem=_items[id]; + // create new Cache item + final newItem = CacheItem(value, + filePath: value.path, + previewHash: previewResult.previewHash, + hash: hash, + fileSize: previewResult.fileSize, + imageProviderNotifier: oldItem.imageProviderNotifier, // keep original Notifier + ).._refCount=oldItem._refCount; // keep number of references + + // update original fields + _items[id] = newItem; + _items[id].invalidateImageProvider; // invalidate imageProvider so it is newly created when needed + } else { + throw Exception( + 'assetCacheAll.replaceImage: unknown type ${value.runtimeType}'); + } + } + + // return File associated with asset, used to save assets when saving note + File getAssetFile(int id){ + final item = _items[id]; + if (item.value is File) { + return (item.value as File); + } else if (item is FileImage) { + return (item.value as FileImage).file; + } else { + throw Exception( + 'assetCacheAll.getBytes: unknown type ${item.runtimeType}'); + } + } + + + // generate random file name + String generateRandomFileName([String extension = 'txt']) { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final random = Random().nextInt(1 << 32); // 32-bit random number + return 'file_${timestamp}_$random.$extension'; + } + + // create temporary file from bytes when inline bytes are read + Future createRuntimeFile(String ext, Uint8List bytes) async { + final dir = await getApplicationSupportDirectory(); + final file = File('${dir.path}/TmPmP_${generateRandomFileName(ext)}'); + return await file.writeAsBytes(bytes, flush: true); + } + + String readPdfMetadataSync(File file) { + if (!file.existsSync()) { + log.info('File not found'); + return ''; + } + + final fileSize = file.lengthSync(); + const trailerReadSize = 4096; + + final trailerStart = fileSize > trailerReadSize ? fileSize - trailerReadSize : 0; + final trailerLength = fileSize > trailerReadSize ? trailerReadSize : fileSize; + + final raf = file.openSync(); + final trailerBytes = Uint8List(trailerLength); + raf.setPositionSync(trailerStart); + raf.readIntoSync(trailerBytes); + raf.closeSync(); + + final trailerContent = ascii.decode(trailerBytes, allowInvalid: true); + final trailerIndex = trailerContent.lastIndexOf('trailer'); + if (trailerIndex == -1) { + log.info('Trailer not found'); + return ''; + } + + final trailerSlice = trailerContent.substring(trailerIndex); + final infoMatch = RegExp(r'/Info\s+(\d+)\s+(\d+)\s+R').firstMatch(trailerSlice); + if (infoMatch == null) { + log.info('Info object not found in trailer'); + return ''; + } + + final infoObjNumber = int.parse(infoMatch.group(1)!); + final infoObjGen = int.parse(infoMatch.group(2)!); + log.info('Info object reference: $infoObjNumber $infoObjGen'); + + // Find startxref + final startxrefIndex = trailerContent.lastIndexOf('startxref'); + if (startxrefIndex == -1) { + log.info('startxref not found'); + raf.closeSync(); + return ''; + } + + final startxrefLine = trailerContent.substring(startxrefIndex).split(RegExp(r'\r?\n'))[1].trim(); + final xrefOffset = int.tryParse(startxrefLine); + if (xrefOffset == null) { + log.info('Invalid startxref value'); + raf.closeSync(); + return ''; + } + + // Go to xref table + raf.setPositionSync(xrefOffset); + final xrefBuffer = Uint8List(8192); // read enough bytes for xref table + final bytesRead = raf.readIntoSync(xrefBuffer); + final xrefContent = ascii.decode(xrefBuffer.sublist(0, bytesRead), allowInvalid: true); + + // Find object offset in xref table + //final objPattern = RegExp(r'(\d{10})\s+(\d{5})\s+n'); + //final objMatches = objPattern.allMatches(xrefContent); + int? objOffset; + + //nt currentObjNumber = 0; + final subMatches = RegExp(r'(\d+)\s+(\d+)\s+(\d+)\s+n').allMatches(xrefContent); + for (final m in subMatches) { + final objNum = int.parse(m.group(1)!); + final offset = int.parse(m.group(2)!); + if (objNum == infoObjNumber) { + objOffset = offset; + break; + } + } + + if (objOffset == null) { + log.info('Info object offset not found in xref table'); + raf.closeSync(); + return ''; + } + + // Read Info object directly + raf.setPositionSync(objOffset); + final objBytes = Uint8List(1024); // read enough for object + final objRead = raf.readIntoSync(objBytes); + final objContent = ascii.decode(objBytes.sublist(0, objRead), allowInvalid: true); + + final objMatchContent = RegExp(r'obj(.*?)endobj', dotAll: true).firstMatch(objContent); + if (objMatchContent == null) { + log.info('Info object content not found'); + raf.closeSync(); + return ''; + } + + final infoContent = objMatchContent.group(1)!; + + final titleMatch = RegExp(r'/Title\s+\((.*?)\)').firstMatch(infoContent); + final authorMatch = RegExp(r'/Author\s+\((.*?)\)').firstMatch(infoContent); + final creationMatch = + RegExp(r'/CreationDate\s+\((.*?)\)').firstMatch(infoContent); + final String metadata='${titleMatch?.group(1) ?? 'N/A'}${authorMatch?.group(1) ?? 'N/A'}${creationMatch?.group(1) ?? 'N/A'}'; + return metadata; + } + + @override + String toString() => _items.toString(); + +} diff --git a/lib/components/canvas/image/editor_image.dart b/lib/components/canvas/image/editor_image.dart index 1929724b7..a7afd6849 100644 --- a/lib/components/canvas/image/editor_image.dart +++ b/lib/components/canvas/image/editor_image.dart @@ -34,6 +34,7 @@ sealed class EditorImage extends ChangeNotifier { final String extension; final AssetCache assetCache; + final AssetCacheAll assetCacheAll; bool _isThumbnail = false; bool get isThumbnail => _isThumbnail; @@ -89,6 +90,7 @@ sealed class EditorImage extends ChangeNotifier { EditorImage({ required this.id, required this.assetCache, + required this.assetCacheAll, required this.extension, required this.pageIndex, required this.pageSize, @@ -113,6 +115,7 @@ sealed class EditorImage extends ChangeNotifier { bool isThumbnail = false, required String sbnPath, required AssetCache assetCache, + required AssetCacheAll assetCacheAll, }) { String? extension = json['e']; if (extension == '.svg') { @@ -122,6 +125,7 @@ sealed class EditorImage extends ChangeNotifier { isThumbnail: isThumbnail, sbnPath: sbnPath, assetCache: assetCache, + assetCacheAll: assetCacheAll, ); } else if (extension == '.pdf') { return PdfEditorImage.fromJson( @@ -130,6 +134,7 @@ sealed class EditorImage extends ChangeNotifier { isThumbnail: isThumbnail, sbnPath: sbnPath, assetCache: assetCache, + assetCacheAll: assetCacheAll, ); } else { return PngEditorImage.fromJson( @@ -138,6 +143,7 @@ sealed class EditorImage extends ChangeNotifier { isThumbnail: isThumbnail, sbnPath: sbnPath, assetCache: assetCache, + assetCacheAll: assetCacheAll, ); } } diff --git a/lib/components/canvas/image/pdf_editor_image.dart b/lib/components/canvas/image/pdf_editor_image.dart index 07fc79f9c..7678c1904 100644 --- a/lib/components/canvas/image/pdf_editor_image.dart +++ b/lib/components/canvas/image/pdf_editor_image.dart @@ -1,6 +1,9 @@ part of 'editor_image.dart'; class PdfEditorImage extends EditorImage { + /// index of asset assigned to this pdf file + int assetId; + Uint8List? pdfBytes; final int pdfPage; @@ -15,6 +18,8 @@ class PdfEditorImage extends EditorImage { PdfEditorImage({ required super.id, required super.assetCache, + required super.assetCacheAll, + required this.assetId, required this.pdfBytes, required this.pdfFile, required this.pdfPage, @@ -45,20 +50,22 @@ class PdfEditorImage extends EditorImage { bool isThumbnail = false, required String sbnPath, required AssetCache assetCache, + required AssetCacheAll assetCacheAll, }) { String? extension = json['e'] as String?; assert(extension == null || extension == '.pdf'); - final assetIndex = json['a'] as int?; + final assetIndexJson = json['a'] as int?; final Uint8List? pdfBytes; + int? assetIndex; File? pdfFile; - if (assetIndex != null) { + if (assetIndexJson != null) { if (inlineAssets == null) { pdfFile = - FileManager.getFile('$sbnPath${Editor.extension}.$assetIndex'); + FileManager.getFile('$sbnPath${Editor.extension}.$assetIndexJson'); pdfBytes = assetCache.get(pdfFile); } else { - pdfBytes = inlineAssets[assetIndex]; + pdfBytes = inlineAssets[assetIndexJson]; } } else { if (kDebugMode) { @@ -67,10 +74,26 @@ class PdfEditorImage extends EditorImage { pdfBytes = Uint8List(0); } + assert(pdfBytes != null || pdfFile != null, + 'Either pdfBytes or pdfFile must be non-null'); + + // add to asset cache + if (pdfFile != null) { + assetIndex = assetCacheAll.addSync(pdfFile); + } + else { + assetIndex = assetCacheAll.addSync(pdfBytes!); + } + if (assetIndex<0){ + throw Exception('EditorImage.fromJson: pdf image not in assets'); + } + return PdfEditorImage( id: json['id'] ?? -1, // -1 will be replaced by EditorCoreInfo._handleEmptyImageIds() assetCache: assetCache, + assetCacheAll: assetCacheAll, + assetId: assetIndex, pdfBytes: pdfBytes, pdfFile: pdfFile, pdfPage: json['pdfi'], @@ -127,9 +150,10 @@ class PdfEditorImage extends EditorImage { dstRect = dstRect.topLeft & dstSize; } - _pdfDocument.value ??= pdfFile != null - ? await PdfDocument.openFile(pdfFile!.path) - : await PdfDocument.openData(pdfBytes!); + _pdfDocument.value ??= await assetCacheAll.getPdfDocument(assetId); +// _pdfDocument.value ??= pdfFile != null +// ? await PdfDocument.openFile(pdfFile!.path) +// : await PdfDocument.openData(pdfBytes!); } @override @@ -184,6 +208,8 @@ class PdfEditorImage extends EditorImage { PdfEditorImage copy() => PdfEditorImage( id: id, assetCache: assetCache, + assetCacheAll: assetCacheAll, + assetId: assetId, pdfBytes: pdfBytes, pdfPage: pdfPage, pdfFile: pdfFile, diff --git a/lib/components/canvas/image/png_editor_image.dart b/lib/components/canvas/image/png_editor_image.dart index bec7a4d30..812bfaecd 100644 --- a/lib/components/canvas/image/png_editor_image.dart +++ b/lib/components/canvas/image/png_editor_image.dart @@ -1,7 +1,16 @@ part of 'editor_image.dart'; class PngEditorImage extends EditorImage { - ImageProvider? imageProvider; + /// index of asset assigned to this image + int assetId; + + // ImageProvider is given by assetCacheAll using this notifier + final ValueNotifier imageProviderNotifier; + + /// Convenience getter to access current ImageProvider + ImageProvider? get imageProvider => imageProviderNotifier.value; + + Uint8List? thumbnailBytes; Size thumbnailSize = Size.zero; @@ -14,18 +23,21 @@ class PngEditorImage extends EditorImage { set isThumbnail(bool isThumbnail) { super.isThumbnail = isThumbnail; if (isThumbnail && thumbnailBytes != null) { - imageProvider = MemoryImage(thumbnailBytes!); - final scale = thumbnailSize.width / naturalSize.width; - srcRect = Rect.fromLTWH(srcRect.left * scale, srcRect.top * scale, - srcRect.width * scale, srcRect.height * scale); + // QBtodo - handle this thumbnail + //imageProvider = MemoryImage(thumbnailBytes!); + //final scale = thumbnailSize.width / naturalSize.width; + //srcRect = Rect.fromLTWH(srcRect.left * scale, srcRect.top * scale, + // srcRect.width * scale, srcRect.height * scale); } } PngEditorImage({ required super.id, required super.assetCache, + required super.assetCacheAll, + required this.assetId, required super.extension, - required this.imageProvider, + required this.imageProviderNotifier, required super.pageIndex, required super.pageSize, this.maxSize, @@ -49,17 +61,18 @@ class PngEditorImage extends EditorImage { bool isThumbnail = false, required String sbnPath, required AssetCache assetCache, + required AssetCacheAll assetCacheAll, }) { - final assetIndex = json['a'] as int?; - final Uint8List? bytes; + final assetIndexJson = json['a'] as int?; + Uint8List? bytes; + final int? assetIndex; File? imageFile; - if (assetIndex != null) { + if (assetIndexJson != null) { if (inlineAssets == null) { imageFile = - FileManager.getFile('$sbnPath${Editor.extension}.$assetIndex'); - bytes = assetCache.get(imageFile); + FileManager.getFile('$sbnPath${Editor.extension}.$assetIndexJson'); } else { - bytes = inlineAssets[assetIndex]; + bytes = inlineAssets[assetIndexJson]; } } else if (json['b'] != null) { bytes = Uint8List.fromList((json['b'] as List).cast()); @@ -72,14 +85,28 @@ class PngEditorImage extends EditorImage { assert(bytes != null || imageFile != null, 'Either bytes or imageFile must be non-null'); + // add to asset cache + if (imageFile != null) { + assetIndex = assetCacheAll.addSync(imageFile); + } + else { + final tempFile=assetCacheAll.createRuntimeFile(json['e'] ?? '.jpg',bytes!); + assetIndex = assetCacheAll.addSync(tempFile); + } + if (assetIndex<0){ + throw Exception('EditorImage.fromJson: image not in assets'); + } + + + return PngEditorImage( // -1 will be replaced by [EditorCoreInfo._handleEmptyImageIds()] id: json['id'] ?? -1, assetCache: assetCache, + assetCacheAll: assetCacheAll, + assetId: assetIndex, extension: json['e'] ?? '.jpg', - imageProvider: bytes != null - ? MemoryImage(bytes) as ImageProvider - : FileImage(imageFile!), + imageProviderNotifier: assetCacheAll.getImageProviderNotifier(assetIndex), pageIndex: json['i'] ?? 0, pageSize: Size.infinite, invertible: json['v'] ?? true, @@ -124,6 +151,7 @@ class PngEditorImage extends EditorImage { assert(Isolate.current.debugName == 'main'); if (srcRect.shortestSide == 0 || dstRect.shortestSide == 0) { + // when image was picked, its size is not determined. Do it final Uint8List bytes; if (imageProvider is MemoryImage) { bytes = (imageProvider as MemoryImage).bytes; @@ -153,7 +181,10 @@ class PngEditorImage extends EditorImage { height: reducedSize.height.toInt(), ); if (resizedByteData != null) { - imageProvider = MemoryImage(resizedByteData.buffer.asUint8List()); + // store resized bytes to temporary file + final tempImageFile = await assetCacheAll.createRuntimeFile('.png',resizedByteData.buffer.asUint8List()); + // replace image + assetCacheAll.replaceImage(tempImageFile, assetId); } naturalSize = reducedSize; @@ -186,8 +217,10 @@ class PngEditorImage extends EditorImage { @override Future precache(BuildContext context) async { - if (imageProvider == null) return; - return await precacheImage(imageProvider!, context); + final provider = imageProviderNotifier.value; + if (provider != null) { + await precacheImage(provider, context); + } } @override @@ -206,12 +239,21 @@ class PngEditorImage extends EditorImage { boxFit = BoxFit.fill; } - return InvertWidget( - invert: invert, - child: Image( - image: imageProvider!, - fit: boxFit, - ), + return ValueListenableBuilder( + valueListenable: imageProviderNotifier, + builder: (context, provider, _) { + if (provider == null) { + return const SizedBox.shrink(); // nothing yet + } + + return InvertWidget( + invert: invert, + child: Image( + image: provider, + fit: boxFit, + ), + ); + }, ); } @@ -219,8 +261,10 @@ class PngEditorImage extends EditorImage { PngEditorImage copy() => PngEditorImage( id: id, assetCache: assetCache, + assetCacheAll: assetCacheAll, + assetId: assetId, extension: extension, - imageProvider: imageProvider, + imageProviderNotifier: imageProviderNotifier, pageIndex: pageIndex, pageSize: Size.infinite, invertible: invertible, diff --git a/lib/components/canvas/image/svg_editor_image.dart b/lib/components/canvas/image/svg_editor_image.dart index 5303b0a9b..b74417d8c 100644 --- a/lib/components/canvas/image/svg_editor_image.dart +++ b/lib/components/canvas/image/svg_editor_image.dart @@ -12,6 +12,7 @@ class SvgEditorImage extends EditorImage { SvgEditorImage({ required super.id, required super.assetCache, + required super.assetCacheAll, required String? svgString, required File? svgFile, required super.pageIndex, @@ -45,6 +46,7 @@ class SvgEditorImage extends EditorImage { bool isThumbnail = false, required String sbnPath, required AssetCache assetCache, + required AssetCacheAll assetCacheAll, }) { String? extension = json['e'] as String?; assert(extension == null || extension == '.svg'); @@ -71,6 +73,7 @@ class SvgEditorImage extends EditorImage { id: json['id'] ?? -1, // -1 will be replaced by EditorCoreInfo._handleEmptyImageIds() assetCache: assetCache, + assetCacheAll: assetCacheAll, svgString: svgString, svgFile: svgFile, pageIndex: json['i'] ?? 0, @@ -195,6 +198,7 @@ class SvgEditorImage extends EditorImage { id: id, // ignore: deprecated_member_use_from_same_package assetCache: assetCache, + assetCacheAll: assetCacheAll, svgString: svgData.string, svgFile: svgData.file, pageIndex: pageIndex, diff --git a/lib/data/editor/editor_core_info.dart b/lib/data/editor/editor_core_info.dart index b12d215a7..8ae5d01ff 100644 --- a/lib/data/editor/editor_core_info.dart +++ b/lib/data/editor/editor_core_info.dart @@ -58,6 +58,7 @@ class EditorCoreInfo { String get fileName => filePath.substring(filePath.lastIndexOf('/') + 1); AssetCache assetCache; + AssetCacheAll assetCacheAll; int nextImageId; Color? backgroundColor; CanvasBackgroundPattern backgroundPattern; @@ -80,6 +81,7 @@ class EditorCoreInfo { pages: [], initialPageIndex: null, assetCache: null, + assetCacheAll: null, ).._migrateOldStrokesAndImages( fileVersion: sbnVersion, strokesJson: null, @@ -100,7 +102,8 @@ class EditorCoreInfo { lineHeight = stows.lastLineHeight.value, lineThickness = stows.lastLineThickness.value, pages = [], - assetCache = AssetCache(); + assetCache = AssetCache(), + assetCacheAll = AssetCacheAll(); EditorCoreInfo._({ required this.filePath, @@ -114,7 +117,10 @@ class EditorCoreInfo { required this.pages, required this.initialPageIndex, required AssetCache? assetCache, - }) : assetCache = assetCache ?? AssetCache() { + required AssetCacheAll? assetCacheAll, + }) : assetCache = assetCache ?? AssetCache(), + assetCacheAll = assetCacheAll ?? AssetCacheAll() + { _handleEmptyImageIds(); } @@ -157,6 +163,7 @@ class EditorCoreInfo { } final assetCache = AssetCache(); + final assetCacheAll = AssetCacheAll(); return EditorCoreInfo._( filePath: filePath, @@ -181,9 +188,11 @@ class EditorCoreInfo { fileVersion: fileVersion, sbnPath: filePath, assetCache: assetCache, + assetCacheAll: assetCacheAll, ), initialPageIndex: json['c'] as int?, assetCache: assetCache, + assetCacheAll: assetCacheAll, ) .._migrateOldStrokesAndImages( fileVersion: fileVersion, @@ -209,7 +218,8 @@ class EditorCoreInfo { lineHeight = stows.lastLineHeight.value, lineThickness = stows.lastLineThickness.value, pages = [], - assetCache = AssetCache() { + assetCache = AssetCache(), + assetCacheAll = AssetCacheAll(){ _migrateOldStrokesAndImages( fileVersion: 0, strokesJson: json, @@ -228,6 +238,7 @@ class EditorCoreInfo { required int fileVersion, required String sbnPath, required AssetCache assetCache, + required AssetCacheAll assetCacheAll, }) { if (pages == null || pages.isEmpty) return []; if (pages[0] is List) { @@ -249,6 +260,7 @@ class EditorCoreInfo { fileVersion: fileVersion, sbnPath: sbnPath, assetCache: assetCache, + assetCacheAll: assetCacheAll, )) .toList(); } @@ -306,6 +318,7 @@ class EditorCoreInfo { onlyFirstPage: onlyFirstPage, sbnPath: filePath, assetCache: assetCache, + assetCacheAll: assetCacheAll, ); for (EditorImage image in images) { if (onlyFirstPage) assert(image.pageIndex == 0); @@ -561,6 +574,7 @@ class EditorCoreInfo { pages: pages ?? this.pages, initialPageIndex: initialPageIndex, assetCache: assetCache, + assetCacheAll: assetCacheAll, ); } } diff --git a/lib/data/editor/page.dart b/lib/data/editor/page.dart index a8c1da5db..bc88d174f 100644 --- a/lib/data/editor/page.dart +++ b/lib/data/editor/page.dart @@ -129,6 +129,7 @@ class EditorPage extends Listenable implements HasSize { required int fileVersion, required String sbnPath, required AssetCache assetCache, + required AssetCacheAll assetCacheAll, }) { final size = Size(json['w'] ?? defaultWidth, json['h'] ?? defaultHeight); return EditorPage( @@ -146,6 +147,7 @@ class EditorPage extends Listenable implements HasSize { onlyFirstPage: false, sbnPath: sbnPath, assetCache: assetCache, + assetCacheAll: assetCacheAll, ), quill: QuillStruct( controller: json['q'] != null @@ -163,6 +165,7 @@ class EditorPage extends Listenable implements HasSize { isThumbnail: false, sbnPath: sbnPath, assetCache: assetCache, + assetCacheAll: assetCacheAll, ) : null, ); @@ -241,6 +244,7 @@ class EditorPage extends Listenable implements HasSize { required bool onlyFirstPage, required String sbnPath, required AssetCache assetCache, + required AssetCacheAll assetCacheAll, }) => images ?.cast>() @@ -252,6 +256,7 @@ class EditorPage extends Listenable implements HasSize { isThumbnail: isThumbnail, sbnPath: sbnPath, assetCache: assetCache, + assetCacheAll: assetCacheAll, ); }) .where((element) => element != null) @@ -265,6 +270,7 @@ class EditorPage extends Listenable implements HasSize { required bool isThumbnail, required String sbnPath, required AssetCache assetCache, + required AssetCacheAll assetCacheAll, }) => EditorImage.fromJson( json, @@ -272,6 +278,7 @@ class EditorPage extends Listenable implements HasSize { isThumbnail: isThumbnail, sbnPath: sbnPath, assetCache: assetCache, + assetCacheAll: assetCacheAll, ); final List _listeners = []; diff --git a/lib/data/file_manager/file_manager.dart b/lib/data/file_manager/file_manager.dart index abce3db3a..78486ef64 100644 --- a/lib/data/file_manager/file_manager.dart +++ b/lib/data/file_manager/file_manager.dart @@ -169,6 +169,18 @@ class FileManager { } } + // return file path (add document directory if needed) + static String getFilePath(String filePath) { + if (shouldUseRawFilePath) { + return filePath; + } else { + assert(filePath.startsWith('/'), + 'Expected filePath to start with a slash, got $filePath'); + return '$documentsDirectory$filePath'; + } + } + + static Directory getRootDirectory() => Directory(documentsDirectory); /// Writes [toWrite] to [filePath]. @@ -220,6 +232,56 @@ class FileManager { if (awaitWrite) await writeFuture; } + /// Copies [fileFrom] to [filePath]. + /// + /// The file at [toPath] will have its last modified timestamp set to + /// [lastModified], if specified. + /// This is useful when downloading remote files, to make sure that the + /// timestamp is the same locally and remotely. + static Future copyFile( + File fileFrom, + String filePath, + { + bool awaitWrite = false, + bool alsoUpload = true, + DateTime? lastModified, + }) async { + filePath = _sanitisePath(filePath); + await _createFileDirectory(filePath); // create directory filePath is "relative to saber documents directory") + + filePath = getFilePath(filePath); // if needed add documents directory to file path to have full path + log.fine('Copying to $filePath'); + + await _saveFileAsRecentlyAccessed(filePath); + final file = await fileFrom.copy(filePath); + Future writeFuture = Future.wait([ + if (lastModified != null) file.setLastModified(lastModified), + // if we're using a new format, also delete the old file + if (filePath.endsWith(Editor.extension)) + getFile( + '${filePath.substring(0, filePath.length - Editor.extension.length)}' + '${Editor.extensionOldJson}') + .delete() + // ignore if the file doesn't exist + .catchError((_) => File(''), + test: (e) => e is PathNotFoundException), + ]); + + void afterWrite() { + broadcastFileWrite(FileOperationType.write, filePath); + if (alsoUpload) syncer.uploader.enqueueRel(filePath); + if (filePath.endsWith(Editor.extension)) { + _removeReferences( + '${filePath.substring(0, filePath.length - Editor.extension.length)}' + '${Editor.extensionOldJson}'); + } + } + + writeFuture = writeFuture.then((_) => afterWrite()); + if (awaitWrite) await writeFuture; + } + + static Future createFolder(String folderPath) async { folderPath = _sanitisePath(folderPath); diff --git a/lib/pages/editor/editor.dart b/lib/pages/editor/editor.dart index e35d1cbb6..1ae47759b 100644 --- a/lib/pages/editor/editor.dart +++ b/lib/pages/editor/editor.dart @@ -12,6 +12,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_quill/flutter_quill.dart' as flutter_quill; import 'package:keybinder/keybinder.dart'; import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:printing/printing.dart'; import 'package:saber/components/canvas/_asset_cache.dart'; import 'package:saber/components/canvas/_stroke.dart'; @@ -52,7 +53,7 @@ import 'package:saber/pages/home/whiteboard.dart'; import 'package:screenshot/screenshot.dart'; import 'package:super_clipboard/super_clipboard.dart'; -typedef _PhotoInfo = ({Uint8List bytes, String extension}); +typedef _PhotoInfo = ({Uint8List bytes, String extension, String path}); class Editor extends StatefulWidget { Editor({ @@ -905,27 +906,29 @@ class EditorState extends State { final Uint8List bson; final OrderedAssetCache assets; coreInfo.assetCache.allowRemovingAssets = false; + coreInfo.assetCacheAll.allowRemovingAssets = false; try { + // go through all pages and prepare Json of each page. (bson, assets) = coreInfo.saveToBinary( currentPageIndex: currentPageIndex, ); } finally { coreInfo.assetCache.allowRemovingAssets = true; + coreInfo.assetCacheAll.allowRemovingAssets = true; } try { - await Future.wait([ - FileManager.writeFile(filePath, bson, awaitWrite: true), - for (int i = 0; i < assets.length; ++i) - assets.getBytes(i).then((bytes) => FileManager.writeFile( - '$filePath.$i', - bytes, - awaitWrite: true, - )), - FileManager.removeUnusedAssets( + // write note itself + await FileManager.writeFile(filePath, bson, awaitWrite: true); + + // write assets + for (int i = 0; i < coreInfo.assetCacheAll.length; ++i){ + final assetFile=coreInfo.assetCacheAll.getAssetFile(i); + await FileManager.copyFile(assetFile,'$filePath.$i', awaitWrite: true); + } + FileManager.removeUnusedAssets( filePath, - numAssets: assets.length, - ), - ]); + numAssets: coreInfo.assetCacheAll.length, + ); savingState.value = SavingState.saved; } catch (e) { log.severe('Failed to save file: $e', e); @@ -1082,10 +1085,14 @@ class EditorState extends State { // use the Select tool so that the user can move the new image currentTool = Select.currentSelect; - List images = [ - for (final _PhotoInfo photoInfo in photoInfos) - if (photoInfo.extension == '.svg') - SvgEditorImage( + final List images = []; + for (final _PhotoInfo photoInfo in photoInfos) { + + + if (photoInfo.extension == '.svg') { + // add image to assets using its path + int assetIndex = await coreInfo.assetCacheAll.add(File(photoInfo.path)); + images.add(SvgEditorImage( id: coreInfo.nextImageId++, svgString: utf8.decode(photoInfo.bytes), svgFile: null, @@ -1096,12 +1103,16 @@ class EditorState extends State { onMiscChange: autosaveAfterDelay, onLoad: () => setState(() {}), assetCache: coreInfo.assetCache, - ) - else - PngEditorImage( + assetCacheAll: coreInfo.assetCacheAll, + )); + } + else { + // add image to assets using its path + int assetIndex = await coreInfo.assetCacheAll.add(File(photoInfo.path)); + images.add(PngEditorImage( id: coreInfo.nextImageId++, extension: photoInfo.extension, - imageProvider: MemoryImage(photoInfo.bytes), + imageProviderNotifier: coreInfo.assetCacheAll.getImageProviderNotifier(assetIndex), pageIndex: currentPageIndex, pageSize: coreInfo.pages[currentPageIndex].size, onMoveImage: onMoveImage, @@ -1109,8 +1120,11 @@ class EditorState extends State { onMiscChange: autosaveAfterDelay, onLoad: () => setState(() {}), assetCache: coreInfo.assetCache, - ), - ]; + assetCacheAll: coreInfo.assetCacheAll, + assetId: assetIndex, + )); + } + } history.recordChange(EditorHistoryItem( type: EditorHistoryItemType.draw, @@ -1157,6 +1171,7 @@ class EditorState extends State { ( bytes: file.bytes!, extension: '.${file.extension}', + path: file.path!, ), ]; } @@ -1188,6 +1203,8 @@ class EditorState extends State { log.severe('Failed to read file when importing $path: $e', e); return false; } + int? assetIndex = await coreInfo.assetCacheAll.add(pdfFile); // add pdf to cache + final emptyPage = coreInfo.pages.removeLast(); assert(emptyPage.isEmpty); @@ -1225,6 +1242,8 @@ class EditorState extends State { onMiscChange: autosaveAfterDelay, onLoad: () => setState(() {}), assetCache: coreInfo.assetCache, + assetCacheAll: coreInfo.assetCacheAll, + assetId: assetIndex, ); coreInfo.pages.add(page); history.recordChange(EditorHistoryItem( @@ -1295,6 +1314,7 @@ class EditorState extends State { photoInfos.add(( bytes: Uint8List.fromList(bytes), extension: extension, + path: file.fileName!, )); }, ); diff --git a/test/isolate_message_test.dart b/test/isolate_message_test.dart index 7bfabe7e7..ec8527203 100644 --- a/test/isolate_message_test.dart +++ b/test/isolate_message_test.dart @@ -37,6 +37,7 @@ void main() { onDeleteImage: null, onMiscChange: null, assetCache: coreInfo.assetCache, + assetCacheAll: coreInfo.assetCacheAll, ), ], ), @@ -60,6 +61,8 @@ void main() { onDeleteImage: null, onMiscChange: null, assetCache: coreInfo.assetCache, + assetCacheAll: coreInfo.assetCacheAll, + assetId: -1, ), ], ), @@ -83,6 +86,8 @@ void main() { onDeleteImage: null, onMiscChange: null, assetCache: coreInfo.assetCache, + assetCacheAll: coreInfo.assetCacheAll, + assetId: -1, ), ], ), diff --git a/test/tools_select_test.dart b/test/tools_select_test.dart index eebdc4d04..e75a10299 100644 --- a/test/tools_select_test.dart +++ b/test/tools_select_test.dart @@ -106,6 +106,7 @@ void main() { // ignore: missing_override_of_must_be_overridden class TestImage extends PngEditorImage { static final _assetCache = AssetCache(); + static final _assetCacheAll = AssetCacheAll(); TestImage({ required super.dstRect, @@ -119,6 +120,8 @@ class TestImage extends PngEditorImage { onDeleteImage: null, onMiscChange: null, assetCache: _assetCache, + assetCacheAll:_assetCacheAll, + assetId: -1, ); @override