import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:archive/archive_io.dart'; import 'package:flutter/painting.dart'; import 'package:image/image.dart' as img; import 'package:path/path.dart' as p; import 'package:pdfrx/pdfrx.dart'; import 'playlist.dart' show naturalCompare; import 'vfs.dart'; /// 그림 보기 (만화 보기) 의 기본 대상 확장자 (환경 설정에서 바꿈) const defaultImageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']; /// 만화 압축 파일 (ZIP · CBZ) const zipExtensions = ['zip', 'cbz']; String extOf(String path) { final name = vBasename(path); final i = name.lastIndexOf('.'); return i < 0 ? '' : name.substring(i + 1).toLowerCase(); } /// 보기 화면의 한 장: 그림 파일 · 압축 안 그림은 [provider], PDF 페이지는 그린 [image] class ReaderImage { final ImageProvider? provider; final ui.Image? image; const ReaderImage({this.provider, this.image}); void dispose() => image?.dispose(); } /// 넘겨 보는 페이지들 (그림 파일들 · 압축 파일 안 그림 · PDF) abstract class ReaderSource { String get title; int get length; String pageName(int i); /// [i] 번째 장을 너비 [maxWidth] 픽셀 정도로 (메모리를 아끼려고 화면 크기에 맞춰 줄임) Future load(int i, {required int maxWidth}); Future dispose() async {} } /// 폴더의 그림 파일들 (로컬 · WebDAV). WebDAV 는 볼 때 임시 폴더로 받는다. class ImageFilesSource extends ReaderSource { final List paths; final String tempDir; @override final String title; ImageFilesSource(this.paths, {required this.tempDir, required this.title}); @override int get length => paths.length; @override String pageName(int i) => vBasename(paths[i]); @override Future load(int i, {required int maxWidth}) async { final local = await vLocalCopy(paths[i], tempDir); return ReaderImage(provider: ResizeImage.resizeIfNeeded(maxWidth, null, FileImage(File(local)))); } } /// ZIP · CBZ 안의 그림들 (이름의 자연 순서). 큰 파일도 목록만 읽고 그림은 볼 때 푼다. class ZipImagesSource extends ReaderSource { final Archive archive; final InputFileStream _input; final List entries; @override final String title; /// ZIP 파일 (121: 해상도를 올린 그림은 ZIP 을 바꾸지 않고 이 옆 폴더에 저장) final String path; ZipImagesSource._(this.archive, this._input, this.entries, this.title, this.path); /// [imageExts] 그림으로 볼 확장자 static Future open(String path, {required String tempDir, required List imageExts}) async { final local = await vLocalCopy(path, tempDir); final input = InputFileStream(local); final archive = ZipDecoder().decodeStream(input); final images = [ for (final f in archive.files) if (f.isFile && imageExts.contains(extOf(f.name)) && !f.name.startsWith('__MACOSX/')) f, ]..sort((a, b) => naturalCompare(a.name, b.name)); return ZipImagesSource._(archive, input, images, vBasename(path), path); } @override int get length => entries.length; @override String pageName(int i) => p.posix.basename(entries[i].name); @override Future load(int i, {required int maxWidth}) async { final bytes = entries[i].readBytes() ?? Uint8List(0); return ReaderImage(provider: ResizeImage.resizeIfNeeded(maxWidth, null, MemoryImage(bytes))); } @override Future dispose() async { await _input.close(); } } /// PDF: 보기 + 페이지 지우기 · 그림을 페이지로 넣기 · 저장 class PdfSource extends ReaderSource { final String path; final String localPath; final PdfDocument doc; @override final String title; /// 넣은 그림으로 만든 1쪽 문서들 (저장할 때까지 살아 있어야 함) final _added = []; bool dirty = false; /// 보이는 페이지 (원본 문서 · 넣은 그림 문서의 페이지). 문서 자체는 고치지 않고 저장할 때 한 번에 조립한다 /// (같은 문서의 페이지 목록을 여러 번 바꾸면 pdfrx 가 저장할 때 엉뚱한 페이지를 남겼다). late final List _view = [...doc.pages]; /// 보이는 페이지들 (시험용) List get pages => List.unmodifiable(_view); PdfSource._(this.path, this.localPath, this.doc, this.title); static Future open(String path, {required String tempDir}) async { await pdfrxFlutterInitialize(); final local = await vLocalCopy(path, tempDir); // 사본을 연다: pdfium 이 연 파일을 잡고 있어 (Windows) 저장할 때 원본을 덮어쓸 수 없으므로 final work = p.join(tempDir, 'jj_pdf_${DateTime.now().microsecondsSinceEpoch}.pdf'); await File(local).copy(work); final doc = await PdfDocument.openFile(work); return PdfSource._(path, work, doc, vBasename(path)); } @override int get length => _view.length; @override String pageName(int i) => '${i + 1} / ${_view.length}'; @override Future load(int i, {required int maxWidth}) async { final page = _view[i]; final w = maxWidth.toDouble(); final h = w * page.height / page.width; final pdfImage = await page.render(fullWidth: w, fullHeight: h, backgroundColor: 0xffffffff); if (pdfImage == null) return const ReaderImage(); try { return ReaderImage(image: await pdfImage.createImage()); } finally { pdfImage.dispose(); } } /// [i] 번째 페이지 지우기 (저장해야 파일에 반영) Future deletePage(int i) async { _view.removeAt(i); dirty = true; } /// 그림들을 한 장씩 [after] 번째 페이지 뒤에 넣는다 (-1 이면 맨 앞) Future insertImages(List images, {required int after, required String tempDir}) async { final newPages = []; for (final (k, path) in images.indexed) { final one = await imagePageDocument(path, tempDir: tempDir, sourceName: 'jj_add_${DateTime.now().microsecondsSinceEpoch}_$k'); _added.add(one); newPages.add(one.pages.first); } _view.insertAll(after + 1, newPages); dirty = true; } /// 저장 ([to] 가 없으면 원래 파일에 덮어쓰기, WebDAV 도): 빈 새 문서에 보이는 순서대로 한 번에 조립. /// (원본 문서의 페이지 목록에 다른 문서의 페이지를 섞어 넣으면 pdfrx 가 엉뚱한 페이지를 남겨, 모두 바깥 페이지로) Future save({String? to}) async { final out = await PdfDocument.createNew(sourceName: 'jj_save_${DateTime.now().microsecondsSinceEpoch}'); Uint8List bytes; try { out.pages = [..._view]; bytes = await out.encodePdf(); } finally { await out.dispose(); } final target = to ?? path; await vWrite(target, Stream.value(bytes), length: bytes.length); dirty = false; return target; } @override Future dispose() async { await doc.dispose(); for (final d in _added) { await d.dispose(); } try { await File(localPath).delete(); } catch (_) {} } } /// 그림 한 장으로 1쪽 PDF (JPEG 가 아니면 JPEG 로 바꿈). 페이지 크기는 그림 픽셀 × 0.75 pt (96dpi 기준). Future imagePageDocument(String path, {required String tempDir, required String sourceName}) async { await pdfrxFlutterInitialize(); final local = await vLocalCopy(path, tempDir); final bytes = await File(local).readAsBytes(); final decoded = img.decodeImage(bytes); if (decoded == null) throw FormatException('그림을 읽을 수 없습니다', vBasename(path)); final jpeg = extOf(path) == 'jpg' || extOf(path) == 'jpeg' ? bytes : Uint8List.fromList(img.encodeJpg(decoded, quality: 92)); return PdfDocument.createFromJpegData(jpeg, width: decoded.width * 0.75, height: decoded.height * 0.75, sourceName: sourceName); } /// 그림들을 한 장씩 PDF 로 ([out] 에 저장, 로컬 · WebDAV) Future imagesToPdf(List images, String out, {required String tempDir, void Function(int done)? onProgress}) async { await pdfrxFlutterInitialize(); final doc = await PdfDocument.createNew(sourceName: 'jj_new_${DateTime.now().microsecondsSinceEpoch}'); final parts = []; try { final pages = []; for (final (k, path) in images.indexed) { final one = await imagePageDocument(path, tempDir: tempDir, sourceName: 'jj_img_${DateTime.now().microsecondsSinceEpoch}_$k'); parts.add(one); pages.add(one.pages.first); onProgress?.call(k + 1); } doc.pages = pages; final bytes = await doc.encodePdf(); await vWrite(out, Stream.value(bytes), length: bytes.length); } finally { await doc.dispose(); for (final d in parts) { await d.dispose(); } } } /// ZIP 목록 (폴더 · 파일 크기) Future> zipEntries(String path, {required String tempDir}) async { final local = await vLocalCopy(path, tempDir); final input = InputFileStream(local); try { final archive = ZipDecoder().decodeStream(input); return [for (final f in archive.files) f]; } finally { await input.close(); } } /// ZIP 에서 [names] (빈 목록이면 모두) 를 [dest] 폴더로 풀기 (로컬 · WebDAV). 푼 파일 수. Future extractZip(String path, String dest, {required String tempDir, List names = const [], void Function(int done, int total)? onProgress}) async { final local = await vLocalCopy(path, tempDir); final input = InputFileStream(local); var n = 0; try { final archive = ZipDecoder().decodeStream(input); final todo = [ for (final f in archive.files) if (f.isFile && (names.isEmpty || names.any((x) => f.name == x || f.name.startsWith(x.endsWith('/') ? x : '$x/')))) f, ]; for (final f in todo) { // 압축 안 경로를 그대로 (../ 같은 바깥 경로는 막는다) final parts = [for (final s in f.name.split('/')) if (s.isNotEmpty && s != '.' && s != '..') s]; if (parts.isEmpty) continue; var target = dest; for (final s in parts.take(parts.length - 1)) { target = vJoin(target, s); } await vMkdirs(target); final bytes = f.readBytes() ?? Uint8List(0); await vWrite(vJoin(target, parts.last), Stream.value(bytes), length: bytes.length); n++; onProgress?.call(n, todo.length); } } finally { await input.close(); } return n; }