diff --git a/assets/init.js b/assets/init.js index b9d68cd..932590c 100644 --- a/assets/init.js +++ b/assets/init.js @@ -224,7 +224,25 @@ let Convert = { key: key, isEncode: false }); - } + }, + /** Encode bytes to hex string + * @param bytes {ArrayBuffer} + * @return {string} + */ + hexEncode: (bytes) => { + const hexDigits = '0123456789abcdef'; + const view = new Uint8Array(bytes); + let charCodes = new Uint8Array(view.length * 2); + let j = 0; + + for (let i = 0; i < view.length; i++) { + let byte = view[i]; + charCodes[j++] = hexDigits.charCodeAt((byte >> 4) & 0xF); + charCodes[j++] = hexDigits.charCodeAt(byte & 0xF); + } + + return String.fromCharCode(...charCodes); + }, } /** @@ -1064,26 +1082,28 @@ class Image { } /** - * Create a new image based on the current image without copying the data. - * Modifying the new image will affect the current image. + * fill [image] with range(srcX, srcY, width, height) to this image at (x, y) * @param x * @param y + * @param image + * @param srcX + * @param srcY * @param width * @param height - * @returns {Image|null} */ - subImage(x, y, width, height) { - let key = sendMessage({ + fillImageRangeAt(x, y, image, srcX, srcY, width, height) { + sendMessage({ method: "image", - function: "subImage", + function: "fillImageRangeAt", key: this.key, x: x, y: y, + image: image.key, + srcX: srcX, + srcY: srcY, width: width, height: height }) - if(key == null) return null; - return new Image(key); } get width() { diff --git a/lib/foundation/app.dart b/lib/foundation/app.dart index 056cfc7..e1456b6 100644 --- a/lib/foundation/app.dart +++ b/lib/foundation/app.dart @@ -10,7 +10,7 @@ export "widget_utils.dart"; export "context.dart"; class _App { - final version = "1.0.1"; + final version = "1.0.2"; bool get isAndroid => Platform.isAndroid; diff --git a/lib/foundation/image_provider/base_image_provider.dart b/lib/foundation/image_provider/base_image_provider.dart index cdc1797..57dd120 100644 --- a/lib/foundation/image_provider/base_image_provider.dart +++ b/lib/foundation/image_provider/base_image_provider.dart @@ -87,17 +87,16 @@ abstract class BaseImageProvider> return await decode(buffer); } catch (e) { await CacheManager().delete(this.key); - Object error = e; if (data.length < 2 * 1024) { // data is too short, it's likely that the data is text, not image try { var text = const Utf8Codec(allowMalformed: false).decoder.convert(data); - error = Exception("Expected image data, but got text: $text"); + throw Exception("Expected image data, but got text: $text"); } catch (e) { // ignore } } - throw error; + rethrow; } } catch (e) { scheduleMicrotask(() { diff --git a/lib/network/images.dart b/lib/network/images.dart index ea887bd..29c9005 100644 --- a/lib/network/images.dart +++ b/lib/network/images.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/consts.dart'; +import 'package:venera/utils/image.dart'; import 'app_dio.dart'; @@ -27,8 +28,8 @@ class ImageDownloader { configs = comicSource?.getThumbnailLoadingConfig?.call(url) ?? {}; } configs['headers'] ??= {}; - if(configs['headers']['user-agent'] == null - && configs['headers']['User-Agent'] == null) { + if (configs['headers']['user-agent'] == null && + configs['headers']['User-Agent'] == null) { configs['headers']['user-agent'] = webUA; } @@ -120,11 +121,22 @@ class ImageDownloader { buffer = configs['onResponse'](buffer); } - await CacheManager().writeCache(cacheKey, buffer); + var data = Uint8List.fromList(buffer); + buffer.clear(); + + if (configs['modifyImage'] != null) { + var newData = await modifyImageWithScript( + data, + configs['modifyImage'], + ); + data = newData; + } + + await CacheManager().writeCache(cacheKey, data); yield ImageDownloadProgress( - currentBytes: buffer.length, - totalBytes: buffer.length, - imageBytes: Uint8List.fromList(buffer), + currentBytes: data.length, + totalBytes: data.length, + imageBytes: data, ); } } diff --git a/lib/pages/comic_source_page.dart b/lib/pages/comic_source_page.dart index 5710473..d6ff26e 100644 --- a/lib/pages/comic_source_page.dart +++ b/lib/pages/comic_source_page.dart @@ -161,71 +161,76 @@ class _BodyState extends State<_Body> { for (var item in source.settings!.entries) { var key = item.key; String type = item.value['type']; - if (type == "select") { - var current = source.data['settings'][key]; - if (current == null) { - var d = item.value['default']; - for (var option in item.value['options']) { - if (option['value'] == d) { - current = option['text'] ?? option['value']; - break; + try { + if (type == "select") { + var current = source.data['settings'][key]; + if (current == null) { + var d = item.value['default']; + for (var option in item.value['options']) { + if (option['value'] == d) { + current = option['text'] ?? option['value']; + break; + } } } + yield ListTile( + title: Text((item.value['title'] as String).ts(source.key)), + trailing: Select( + current: (current as String).ts(source.key), + values: (item.value['options'] as List) + .map( + (e) => ((e['text'] ?? e['value']) as String).ts(source.key)) + .toList(), + onTap: (i) { + source.data['settings'][key] = item.value['options'][i]['value']; + source.saveData(); + setState(() {}); + }, + ), + ); + } else if (type == "switch") { + var current = source.data['settings'][key] ?? item.value['default']; + yield ListTile( + title: Text((item.value['title'] as String).ts(source.key)), + trailing: Switch( + value: current, + onChanged: (v) { + source.data['settings'][key] = v; + source.saveData(); + setState(() {}); + }, + ), + ); + } else if (type == "input") { + var current = + source.data['settings'][key] ?? item.value['default'] ?? ''; + yield ListTile( + title: Text((item.value['title'] as String).ts(source.key)), + subtitle: Text(current, maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: IconButton( + icon: const Icon(Icons.edit), + onPressed: () { + showInputDialog( + context: context, + title: (item.value['title'] as String).ts(source.key), + initialValue: current, + inputValidator: item.value['validator'] == null + ? null + : RegExp(item.value['validator']), + onConfirm: (value) { + source.data['settings'][key] = value; + source.saveData(); + setState(() {}); + return null; + }, + ); + }, + ), + ); } - yield ListTile( - title: Text((item.value['title'] as String).ts(source.key)), - trailing: Select( - current: (current as String).ts(source.key), - values: (item.value['options'] as List) - .map( - (e) => ((e['text'] ?? e['value']) as String).ts(source.key)) - .toList(), - onTap: (i) { - source.data['settings'][key] = item.value['options'][i]['value']; - source.saveData(); - setState(() {}); - }, - ), - ); - } else if (type == "switch") { - var current = source.data['settings'][key] ?? item.value['default']; - yield ListTile( - title: Text((item.value['title'] as String).ts(source.key)), - trailing: Switch( - value: current, - onChanged: (v) { - source.data['settings'][key] = v; - source.saveData(); - setState(() {}); - }, - ), - ); - } else if (type == "input") { - var current = - source.data['settings'][key] ?? item.value['default'] ?? ''; - yield ListTile( - title: Text((item.value['title'] as String).ts(source.key)), - subtitle: Text(current, maxLines: 1, overflow: TextOverflow.ellipsis), - trailing: IconButton( - icon: const Icon(Icons.edit), - onPressed: () { - showInputDialog( - context: context, - title: (item.value['title'] as String).ts(source.key), - initialValue: current, - inputValidator: item.value['validator'] == null - ? null - : RegExp(item.value['validator']), - onConfirm: (value) { - source.data['settings'][key] = value; - source.saveData(); - setState(() {}); - return null; - }, - ); - }, - ), - ); + } + catch(e, s) { + Log.error("ComicSourcePage", "Failed to build a setting\n$e\n$s"); } } } diff --git a/lib/pages/reader/scaffold.dart b/lib/pages/reader/scaffold.dart index 537f2b0..c48c3b7 100644 --- a/lib/pages/reader/scaffold.dart +++ b/lib/pages/reader/scaffold.dart @@ -389,7 +389,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { Widget buildPageInfoText() { var epName = context.reader.widget.chapters?.values - .elementAt(context.reader.chapter - 1) ?? + .elementAtOrNull(context.reader.chapter - 1) ?? "E${context.reader.chapter}"; if (epName.length > 8) { epName = "${epName.substring(0, 8)}..."; diff --git a/lib/utils/image.dart b/lib/utils/image.dart index d8b1411..b7488b8 100644 --- a/lib/utils/image.dart +++ b/lib/utils/image.dart @@ -1,3 +1,4 @@ +import 'dart:ffi'; import 'dart:isolate'; import 'dart:typed_data'; import 'dart:ui' as ui; @@ -12,7 +13,12 @@ class Image { final int height; - Image(this._data, this.width, this.height); + Image(this._data, this.width, this.height) { + if (_data.length != width * height) { + throw ArgumentError( + 'Invalid argument: data length must be equal to width * height.'); + } + } Image.empty(this.width, this.height) : _data = Uint32List(width * height); @@ -25,7 +31,7 @@ class Image { throw Exception('Failed to decode image'); } var image = Image( - Uint32List.fromList(info.buffer.asUint32List()), + info.buffer.asUint32List(), frame.image.width, frame.image.height, ); @@ -34,6 +40,20 @@ class Image { } Image copyRange(int x, int y, int width, int height) { + if (width + x > this.width) { + throw ArgumentError(''' + Invalid argument: x + width must be less than or equal to the image width. + x: $x, width: $width, image width: ${this.width} + ''' + .trim()); + } + if (height + y > this.height) { + throw ArgumentError(''' + Invalid argument: y + height must be less than or equal to the image height. + y: $y, height: $height, image height: ${this.height} + ''' + .trim()); + } var data = Uint32List(width * height); for (var j = 0; j < height; j++) { for (var i = 0; i < width; i++) { @@ -44,6 +64,20 @@ class Image { } void fillImageAt(int x, int y, Image image) { + if (x + image.width > width) { + throw ArgumentError(''' + Invalid argument: x + image width must be less than or equal to the image width. + x: $x, image width: ${image.width}, image width: $width + ''' + .trim()); + } + if (y + image.height > height) { + throw ArgumentError(''' + Invalid argument: y + image height must be less than or equal to the image height. + y: $y, image height: ${image.height}, image height: $height + ''' + .trim()); + } for (var j = 0; j < image.height && (j + y) < height; j++) { for (var i = 0; i < image.width && (i + x) < width; i++) { _data[(j + y) * width + i + x] = image._data[j * image.width + i]; @@ -51,6 +85,44 @@ class Image { } } + void fillImageRangeAt( + int x, int y, Image image, int srcX, int srcY, int width, int height) { + if (x + width > this.width) { + throw ArgumentError(''' + Invalid argument: x + width must be less than or equal to the image width. + x: $x, width: $width, image width: ${this.width} + ''' + .trim()); + } + if (y + height > this.height) { + throw ArgumentError(''' + Invalid argument: y + height must be less than or equal to the image height. + y: $y, height: $height, image height: ${this.height} + ''' + .trim()); + } + if (srcX + width > image.width) { + throw ArgumentError(''' + Invalid argument: srcX + width must be less than or equal to the image width. + srcX: $srcX, width: $width, image width: ${image.width} + ''' + .trim()); + } + if (srcY + height > image.height) { + throw ArgumentError(''' + Invalid argument: srcY + height must be less than or equal to the image height. + srcY: $srcY, height: $height, image height: ${image.height} + ''' + .trim()); + } + for (var j = 0; j < height; j++) { + for (var i = 0; i < width; i++) { + _data[(j + y) * this.width + i + x] = + image._data[(j + srcY) * image.width + i + srcX]; + } + } + } + Image copyAndRotate90() { var data = Uint32List(width * height); for (var j = 0; j < height; j++) { @@ -62,28 +134,37 @@ class Image { } Color getPixel(int x, int y) { + if (x < 0 || x >= width) { + throw ArgumentError( + 'Invalid argument: x must be in the range of [0, $width).'); + } + if (y < 0 || y >= height) { + throw ArgumentError( + 'Invalid argument: y must be in the range of [0, $height).'); + } return Color.fromValue(_data[y * width + x]); } void setPixel(int x, int y, Color color) { + if (x < 0 || x >= width) { + throw ArgumentError( + 'Invalid argument: x must be in the range of [0, $width).'); + } + if (y < 0 || y >= height) { + throw ArgumentError( + 'Invalid argument: y must be in the range of [0, $height).'); + } _data[y * width + x] = color.value; } - Image subImage(int x, int y, int width, int height) { - var data = Uint32List.sublistView( - _data, - y * this.width + x, - width * height, - ); - return Image(data, width, height); - } - Uint8List encodePng() { - return lodepng.encodePng(lodepng.Image( + var data = lodepng.encodePngToPointer(lodepng.Image( _data.buffer.asUint8List(), width, height, )); + return Pointer.fromAddress(data.address).asTypedList(data.length, + finalizer: lodepng.ByteBuffer.finalizer); } } @@ -166,16 +247,21 @@ class JsEngine { if (image2 == null) return null; image.fillImageAt(x, y, image2); return null; - case 'subImage': + case 'fillImageRangeAt': var key = message['key']; var image = images[key]; if (image == null) return null; var x = message['x']; var y = message['y']; + var key2 = message['image']; + var image2 = images[key2]; + if (image2 == null) return null; + var srcX = message['srcX']; + var srcY = message['srcY']; var width = message['width']; var height = message['height']; - var newImage = image.subImage(x, y, width, height); - return setImage(newImage); + image.fillImageRangeAt(x, y, image2, srcX, srcY, width, height); + return null; case 'getWidth': var key = message['key']; var image = images[key]; @@ -213,16 +299,18 @@ Future modifyImageWithScript(Uint8List data, String script) async { jsEngine.runCode(script); var key = jsEngine.setImage(image); var res = jsEngine.runCode(''' - let image = new Image($key); - let result = modifyImage(image); - return result.key; - '''); + let func = () => { + let image = new Image($key); + let result = modifyImage(image); + return result.key; + } + func(); + '''); var newImage = jsEngine.images[res]; var data = newImage!.encodePng(); - return data; + return Uint8List.fromList(data); }); - } - finally { + } finally { _tasksCount--; } -} \ No newline at end of file +} diff --git a/pubspec.lock b/pubspec.lock index 8ef4e04..5f59f6f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -461,7 +461,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "115b896a04c270a8d6d5d7bea09dcd04047bfad3" + resolved-ref: "5223cf4ce8aad1c2315db0093db3cc5c6c7191a8" url: "https://github.com/venera-app/lodepng_flutter" source: git version: "0.0.1" diff --git a/pubspec.yaml b/pubspec.yaml index 6bbdd3b..c77fc04 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: venera description: "A comic app." publish_to: 'none' -version: 1.0.1+101 +version: 1.0.2+102 environment: sdk: '>=3.5.0 <4.0.0'