diff --git a/lib/main.dart b/lib/main.dart index 5f0208a..9e4deb2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,7 +3,6 @@ import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:rhttp/rhttp.dart'; import 'package:venera/foundation/log.dart'; import 'package:venera/pages/auth_page.dart'; @@ -197,11 +196,6 @@ class _MyAppState extends State with WidgetsBindingObserver { 'dark' => ThemeMode.dark, _ => ThemeMode.system }, - localizationsDelegates: const [ - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], locale: () { var lang = appdata.settings['language']; if (lang == 'system') { diff --git a/lib/utils/image.dart b/lib/utils/image.dart index b7488b8..9566634 100644 --- a/lib/utils/image.dart +++ b/lib/utils/image.dart @@ -26,7 +26,7 @@ class Image { var codec = await ui.instantiateImageCodec(data); var frame = await codec.getNextFrame(); codec.dispose(); - var info = await frame.image.toByteData(); + var info = await frame.image.toByteData(format: ui.ImageByteFormat.rawStraightRgba); if (info == null) { throw Exception('Failed to decode image'); } @@ -39,6 +39,14 @@ class Image { return image; } + int getPixelAtIndex(int index) { + if (index < 0 || index >= _data.length) { + throw ArgumentError( + 'Invalid argument: index must be in the range of [0, ${_data.length}).'); + } + return _data[index]; + } + Image copyRange(int x, int y, int width, int height) { if (width + x > this.width) { throw ArgumentError(''' diff --git a/lib/utils/pdf.dart b/lib/utils/pdf.dart index 95c8e2b..98bf521 100644 --- a/lib/utils/pdf.dart +++ b/lib/utils/pdf.dart @@ -1,33 +1,28 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:isolate'; - -import 'package:pdf/widgets.dart'; +import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/local.dart'; +import 'package:venera/utils/image.dart'; import 'package:venera/utils/io.dart'; +import 'package:zip_flutter/zip_flutter.dart'; + +typedef DecodeImage = Future Function(Uint8List data); Future _createPdfFromComic({ required LocalComic comic, required String savePath, required String localPath, + required DecodeImage decodeImage, }) async { - final pdf = Document( - title: comic.title, - author: comic.subTitle ?? "", - producer: "Venera", - ); - - pdf.document.outline; + var images = []; var baseDir = comic.directory.contains('/') || comic.directory.contains('\\') ? comic.directory : FilePath.join(localPath, comic.directory); // add cover - var imageData = File(FilePath.join(baseDir, comic.cover)).readAsBytesSync(); - pdf.addPage(Page( - build: (Context context) { - return Image(MemoryImage(imageData), fit: BoxFit.contain); - }, - )); + images.add(FilePath.join(baseDir, comic.cover)); bool multiChapters = comic.chapters != null; @@ -51,42 +46,360 @@ Future _createPdfFromComic({ reorderFiles(files); for (var file in files) { - var imageData = (file as File).readAsBytesSync(); - pdf.addPage(Page( - build: (Context context) { - return Image(MemoryImage(imageData), fit: BoxFit.contain); - }, - )); + images.add(file.path); } } else { for (var chapter in comic.chapters!.keys) { var files = Directory(FilePath.join(baseDir, chapter)).listSync(); reorderFiles(files); for (var file in files) { - var imageData = (file as File).readAsBytesSync(); - pdf.addPage(Page( - build: (Context context) { - return Image(MemoryImage(imageData), fit: BoxFit.contain); - }, - )); + images.add(file.path); } } } - final file = File(savePath); - file.writeAsBytesSync(await pdf.save()); + var generator = PdfGenerator( + title: comic.title, + author: comic.subtitle, + imagePaths: images, + outputPath: savePath, + decodeImage: decodeImage, + ); + await generator.generate(); +} + +Future _runIsolate( + LocalComic comic, String savePath, SendPort sendPort) { + var localPath = LocalManager().path; + return Isolate.spawn( + (sendPort) => overrideIO( + () async { + var receivePort = ReceivePort(); + sendPort.send(receivePort.sendPort); + + Completer? completer; + + Future decodeImage(Uint8List data) async { + if (completer != null) { + throw Exception('Another image is being decoded'); + } + sendPort.send(data); + completer = Completer(); + return completer!.future; + } + + receivePort.listen((message) { + if (message is Image) { + if (completer == null) { + throw Exception('No image is being decoded'); + } + completer!.complete(message); + completer = null; + } + }); + + await _createPdfFromComic( + comic: comic, + savePath: savePath, + localPath: localPath, + decodeImage: decodeImage, + ); + + sendPort.send(null); + }, + ), + sendPort, + ); } Future createPdfFromComicIsolate({ required LocalComic comic, required String savePath, }) async { - var localPath = LocalManager().path; - return Isolate.run(() => overrideIO(() async { - return await _createPdfFromComic( - comic: comic, - savePath: savePath, - localPath: localPath, - ); - })); + var receivePort = ReceivePort(); + SendPort? sendPort; + Isolate? isolate; + var completer = Completer(); + receivePort.listen((message) { + if (message is SendPort) { + sendPort = message; + } else if (message is Uint8List) { + Image.decodeImage(message).then((image) { + sendPort!.send(image); + }); + } else if (message == null) { + receivePort.close(); + completer.complete(); + isolate!.kill(); + } + }); + isolate = await _runIsolate(comic, savePath, receivePort.sendPort); + return completer.future; +} + +class PdfGenerator { + final String title; + final String author; + final List imagePaths; + final String outputPath; + final DecodeImage decodeImage; + + // PDF文件的对象ID计数器 + int _objectId = 1; + + // 存储每个对象在PDF中的字节位置 + final Map _objectOffsets = {}; + + static const double a4Width = 595.0; // points + static const double a4Height = 842.0; // points + + PdfGenerator({ + required this.title, + required this.author, + required this.imagePaths, + required this.outputPath, + required this.decodeImage, + }); + + Future generate() async { + var file = File(outputPath); + final output = file.openWrite(); + + int length = 0; + + void write(String str) { + var data = utf8.encode(str); + output.add(data); + length += data.length; + } + + void writeData(Uint8List data) { + output.add(data); + length += data.length; + } + + int getCurrentLength() { + return length; + } + + // 1. 写入PDF头部 + write('%PDF-1.7\n%\xFF\xFF\xFF\xFF\n\n'); + + // 2. 写入Catalog对象 + _objectOffsets[_objectId] = getCurrentLength(); + write('$_objectId 0 obj\n'); + write('<<\n'); + write('/Type /Catalog\n'); + write('/Pages ${_objectId + 1} 0 R\n'); + write('>>\nendobj\n\n'); + + final catalogId = _objectId++; + + // 3. 写入Pages对象 + _objectOffsets[_objectId] = getCurrentLength(); + write('$_objectId 0 obj\n'); + write('<<\n'); + write('/Type /Pages\n'); + write('/Kids ['); + final pageIds = []; + for (var i = 0; i < imagePaths.length; i++) { + pageIds.add(_objectId + 1 + i * 3); + write('${_objectId + 1 + i * 3} 0 R '); + } + write(']\n'); + write('/Count ${imagePaths.length}\n'); + write('>>\nendobj\n\n'); + + final pagesId = _objectId++; + + // 4. 为每个图片创建Page和Image对象 + for (var i = 0; i < imagePaths.length; i++) { + final imagePath = imagePaths[i]; + final image = await _getImage(imagePath); + + // 写入Page对象 + _objectOffsets[_objectId] = getCurrentLength(); + write('$_objectId 0 obj\n'); + write('<<\n'); + write('/Type /Page\n'); + write('/Parent $pagesId 0 R\n'); + write('/Resources <<\n'); + write('/XObject << /Im${i + 1} ${_objectId + 1} 0 R >>\n'); + write('>>\n'); + write('/MediaBox [0 0 $a4Width $a4Height]\n'); + write('/Contents ${_objectId + 2} 0 R\n'); + write('>>\nendobj\n\n'); + + _objectId++; + + // 写入Image对象 + _objectOffsets[_objectId] = getCurrentLength(); + write('$_objectId 0 obj\n'); + write('<<\n'); + write('/Type /XObject\n'); + write('/Subtype /Image\n'); + write('/Width ${image.width}\n'); + write('/Height ${image.height}\n'); + write('/ColorSpace /DeviceRGB\n'); + write('/BitsPerComponent 8\n'); + write('/Filter /FlateDecode\n'); + write('/Length ${image.data.length}\n'); + write('>>\nstream\n'); + writeData(image.data); + write('\nendstream\nendobj\n\n'); + + _objectId++; + + // 写入Contents对象(绘制图片的指令) + _objectOffsets[_objectId] = getCurrentLength(); + write('$_objectId 0 obj\n'); + write('<<\n'); + var stream = ''; + stream += 'q\n'; + // Calculate scaling factors + var scaleX = a4Width / image.width; + var scaleY = a4Height / image.height; + var scale = scaleX < scaleY ? scaleX : scaleY; + // Calculate centering offsets + var offsetX = (a4Width - (image.width * scale)) / 2; + var offsetY = (a4Height - (image.height * scale)) / 2; + // Apply transformation matrix + stream += '1 0 0 1 $offsetX $offsetY cm\n'; // Translate + stream += '${scale * image.width} 0 0 ${scale * image.height} 0 0 cm\n'; + stream += '/Im${i + 1} Do\n'; + stream += 'Q\n'; + var streamData = utf8.encode(stream); + write('/Length ${streamData.length}\n'); + write('>>\nstream\n'); + writeData(streamData); + write('endstream\nendobj\n\n'); + + _objectId++; + } + + // 5. 写入Info对象(元数据) + final infoId = _objectId; + _objectOffsets[_objectId] = getCurrentLength(); + write('$_objectId 0 obj\n'); + write('<<\n'); + write('/Title <'); + writeData(_toPdfString(title)); + write('>\n'); + write('/Author <'); + writeData(_toPdfString(author)); + write('>\n'); + write('/Producer (venera v${App.version})\n'); + write('/CreationDate (D:${_formatDateTime(DateTime.now())})\n'); + write('>>\nendobj\n\n'); + + _objectId++; + + // 6. 写入交叉引用表 + final xrefOffset = getCurrentLength(); + write('xref\n'); + write('0 $_objectId\n'); + write('0000000000 65535 f\r\n'); + + for (var i = 1; i < _objectId; i++) { + final offset = _objectOffsets[i]!; + write('${offset.toString().padLeft(10, '0')} 00000 n\r\n'); // 使用\r\n + } + + // 7. 写入文件尾部 + write('trailer\n'); + write('<<\n'); + write('/Size $_objectId\n'); + write('/Root $catalogId 0 R\n'); + write('/Info $infoId 0 R\n'); + write('>>\n'); + write('startxref\n'); + write('$xrefOffset\n'); + write('%%EOF\n'); + + await output.close(); + } + + int _codeUnitForDigit(int digit) => + digit < 10 ? digit + 0x30 : digit + 0x61 - 10; + + Uint8List _toPdfString(String str) { + Uint8List data; + try { + data = latin1.encode(str); + } catch (e) { + data = Uint8List.fromList([0xfe, 0xff] + _encodeUtf16be(str)); + } + var result = []; + for (final byte in data) { + result.add(_codeUnitForDigit((byte & 0xF0) >> 4)); + result.add(_codeUnitForDigit(byte & 0x0F)); + } + return Uint8List.fromList(result); + } + + List _encodeUtf16be(String str) { + const unicodeReplacementCharacterCodePoint = 0xfffd; + const unicodeByteZeroMask = 0xff; + const unicodeByteOneMask = 0xff00; + const unicodeValidRangeMax = 0x10ffff; + const unicodePlaneOneMax = 0xffff; + const unicodeUtf16ReservedLo = 0xd800; + const unicodeUtf16ReservedHi = 0xdfff; + const unicodeUtf16Offset = 0x10000; + const unicodeUtf16SurrogateUnit0Base = 0xd800; + const unicodeUtf16SurrogateUnit1Base = 0xdc00; + const unicodeUtf16HiMask = 0xffc00; + const unicodeUtf16LoMask = 0x3ff; + + final encoding = []; + + void add(int unit) { + encoding.add((unit & unicodeByteOneMask) >> 8); + encoding.add(unit & unicodeByteZeroMask); + } + + for (final unit in str.codeUnits) { + if ((unit >= 0 && unit < unicodeUtf16ReservedLo) || + (unit > unicodeUtf16ReservedHi && unit <= unicodePlaneOneMax)) { + add(unit); + } else if (unit > unicodePlaneOneMax && unit <= unicodeValidRangeMax) { + final base = unit - unicodeUtf16Offset; + add(unicodeUtf16SurrogateUnit0Base + + ((base & unicodeUtf16HiMask) >> 10)); + add(unicodeUtf16SurrogateUnit1Base + (base & unicodeUtf16LoMask)); + } else { + add(unicodeReplacementCharacterCodePoint); + } + } + return encoding; + } + + // 格式化日期时间 + String _formatDateTime(DateTime dt) { + return dt + .toUtc() + .toString() + .replaceAll('-', '') + .replaceAll(':', '') + .replaceAll(' ', '') + .replaceAll('.', '') + .substring(0, 14); + } + + Future<({int width, int height, Uint8List data})> _getImage( + String imagePath) async { + var data = await File(imagePath).readAsBytes(); + var image = await decodeImage(data); + var width = image.width; + var height = image.height; + data = Uint8List(width * height * 3); + for (var i = 0; i < width * height; i++) { + var pixel = image.getPixelAtIndex(i); // RGBA + data[i * 3] = pixel & 0xFF; // R + data[i * 3 + 1] = (pixel >> 8) & 0xFF; // G + data[i * 3 + 2] = (pixel >> 16) & 0xFF; // B + } + data = tdeflCompressData(data, true, true, 9); + return (width: width, height: height, data: data); + } } diff --git a/pubspec.lock b/pubspec.lock index 1512006..262d70b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,14 +33,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - archive: - dependency: transitive - description: - name: archive - sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d - url: "https://pub.dev" - source: hosted - version: "3.6.1" args: dependency: transitive description: @@ -57,14 +49,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" - barcode: - dependency: transitive - description: - name: barcode - sha256: ab180ce22c6555d77d45f0178a523669db67f95856e3378259ef2ffeb43e6003 - url: "https://pub.dev" - source: hosted - version: "2.2.8" battery_plus: dependency: "direct main" description: @@ -81,14 +65,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" - bidi: - dependency: transitive - description: - name: bidi - sha256: "9a712c7ddf708f7c41b1923aa83648a3ed44cfd75b04f72d598c45e5be287f9d" - url: "https://pub.dev" - source: hosted - version: "2.0.12" boolean_selector: dependency: transitive description: @@ -392,11 +368,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" - flutter_localizations: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" flutter_memory_info: dependency: "direct main" description: @@ -521,14 +492,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.0" - image: - dependency: transitive - description: - name: image - sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d - url: "https://pub.dev" - source: hosted - version: "4.3.0" intl: dependency: "direct main" description: @@ -690,14 +653,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" - path_parsing: - dependency: transitive - description: - name: path_parsing - sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" - url: "https://pub.dev" - source: hosted - version: "1.1.0" path_provider: dependency: "direct main" description: @@ -746,14 +701,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" - pdf: - dependency: "direct main" - description: - name: pdf - sha256: "05df53f8791587402493ac97b9869d3824eccbc77d97855f4545cf72df3cae07" - url: "https://pub.dev" - source: hosted - version: "3.11.1" petitparser: dependency: transitive description: @@ -795,14 +742,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.9.1" - qr: - dependency: transitive - description: - name: qr - sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" - url: "https://pub.dev" - source: hosted - version: "3.0.2" rhttp: dependency: "direct main" description: @@ -1131,7 +1070,7 @@ packages: source: hosted version: "6.5.0" yaml: - dependency: "direct main" + dependency: transitive description: name: yaml sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" @@ -1142,10 +1081,10 @@ packages: dependency: "direct main" description: name: zip_flutter - sha256: ea7fdc86c988174ef3bb80dc26e8e8bfdf634c55930e2d18d7e77e991acf0483 + sha256: fe63ef9098bb2426b001adba2e28029820d71ce80cce957a36676bd6b3227245 url: "https://pub.dev" source: hosted - version: "0.0.8" + version: "0.0.9" sdks: dart: ">=3.6.0 <4.0.0" flutter: ">=3.27.2" diff --git a/pubspec.yaml b/pubspec.yaml index 5489b8c..3c8a8fe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,8 +12,6 @@ dependencies: flutter: sdk: flutter path_provider: any - flutter_localizations: - sdk: flutter intl: ^0.19.0 window_manager: ^0.4.3 sqlite3: ^2.4.7 @@ -25,7 +23,7 @@ dependencies: crypto: ^3.0.6 dio: ^5.7.0 html: ^0.15.5 - pointycastle: any + pointycastle: ^3.9.1 url_launcher: ^6.3.0 path: ^1.9.0 photo_view: @@ -40,7 +38,6 @@ dependencies: ref: 09e756b1f1b04e6298318d99ec20a787fb360f59 path: packages/scrollable_positioned_list flutter_reorderable_grid_view: ^5.4.0 - yaml: any uuid: ^4.5.1 desktop_webview_window: git: @@ -51,7 +48,7 @@ dependencies: sliver_tools: ^0.2.12 flutter_file_dialog: ^3.0.2 file_selector: ^1.0.3 - zip_flutter: ^0.0.8 + zip_flutter: ^0.0.9 lodepng_flutter: git: url: https://github.com/venera-app/lodepng_flutter @@ -67,7 +64,6 @@ dependencies: git: url: https://github.com/pkuislm/flutter_saf.git ref: 7637b8b67d0a831f3cd7e702b8173e300880d32e - pdf: ^3.11.1 dynamic_color: ^1.7.0 shimmer_animation: ^2.1.0 flutter_memory_info: ^0.0.1