import 'dart:async'; import 'dart:io'; import 'dart:math' as math; import 'package:path/path.dart' as p; import 'package:pdfrx/pdfrx.dart'; import 'reader_sources.dart'; import 'vfs.dart'; import '../l10n/tr.dart'; /// HWP · HWPX 확장자 (앱 안에서 PDF 로 바꿔 보기 - Android, rhwp) const hwpExtensions = ['hwp', 'hwpx']; /// 관리자 지시 (10/20): 앱이 강제로 끝나 남은 HWP 작업 폴더 (앱 임시 폴더의 jj_hwp_…, 바꾼 PDF 들) 를 앱이 켜질 때 지운다. /// 막 생긴 것 ([minAge] 안) 은 남긴다. 지운 이름을 돌려준다 List removeStaleHwpWork(String tempDir, {Duration minAge = const Duration(minutes: 1), DateTime? now}) { final before = (now ?? DateTime.now()).subtract(minAge); final removed = []; try { for (final e in Directory(tempDir).listSync(followLinks: false)) { final name = p.basename(e.path); if (e is! Directory || !name.startsWith('jj_hwp_')) continue; try { if (!e.statSync().modified.isBefore(before)) continue; e.deleteSync(recursive: true); removed.add(name); } catch (_) {} } } catch (_) {} return removed; } /// HWP · HWPX → PDF 엔진 (Android: APK 의 libjj_rhwp.so - lib/platform/android/hwp_pdf_ffi.dart, 시험: 가짜) abstract class HwpPdfEngine { /// 문서 열기 (암호가 있으면 [password]). 실패하면 [HwpPdfException] Future open(String path, {String? password}); } /// 연 문서: 쪽 수와 쪽 범위 → PDF abstract class HwpPdfDoc { int get pageCount; /// [from] 부터 [to] 앞까지 (0 부터) 를 PDF 파일 [out] 으로. [fontDirs] 는 글꼴을 찾을 폴더들 Future exportPdf(int from, int to, String out, {List fontDirs = const []}); /// 지금 바꾸는 것을 멈춘다 (시간 제한을 넘겼을 때 · 닫을 때 - 관리자 지시 10/20: 기다림만 끊으면 뒤에서 계속 돌아 /// 다음 묶음이 줄을 서서 연달아 실패했다). 멈춘 바꾸기는 [HwpErrorKind.cancelled] 로 끝난다 void cancel(); Future close(); } /// 관리자 지시 (10/20, 28 지적): 변환기 오류의 종류 - 화면에는 종류에 맞는 번역 글, 원문은 [HwpPdfException.detail] ([자세히] · 작업 기록). /// 앞 9개는 tool/rhwp_ffi 의 JJ_RHWP_E_* 와 같은 순서 (1 부터) enum HwpErrorKind { cancelled, read, invalid, passwordRequired, passwordWrong, range, render, write, internal, timeout, closed } /// tool/rhwp_ffi 의 오류 코드 (JJ_RHWP_E_*) → 종류 (모르는 코드는 내부 오류) HwpErrorKind hwpErrorKindOf(int code) => code >= 1 && code <= 9 ? HwpErrorKind.values[code - 1] : HwpErrorKind.internal; class HwpPdfException implements Exception { final HwpErrorKind kind; /// 변환기 원문 (화면에는 보이지 않고 [자세히] · 작업 기록에) final String detail; /// 글에 넣을 값 (시간 제한: 첫 쪽 · 끝 쪽 · 초) final List args; const HwpPdfException(this.kind, [this.detail = '', this.args = const []]); /// 화면에 보일 글 (화면 언어) String get message => switch (kind) { HwpErrorKind.cancelled => tr('그만두었습니다'), HwpErrorKind.read => tr('파일을 읽을 수 없습니다'), HwpErrorKind.invalid => tr('HWP · HWPX 문서로 읽을 수 없습니다 (깨졌거나 지원하지 않는 형식)'), HwpErrorKind.passwordRequired => tr('암호가 걸린 문서입니다'), HwpErrorKind.passwordWrong => tr('암호가 맞지 않습니다'), HwpErrorKind.range => tr('쪽 범위가 맞지 않습니다'), HwpErrorKind.render => tr('이 쪽을 그리지 못했습니다'), HwpErrorKind.write => tr('바꾼 PDF 를 저장하지 못했습니다 (저장 공간을 확인해 주세요)'), HwpErrorKind.internal => tr('변환기 안에서 오류가 났습니다'), HwpErrorKind.timeout => trf('{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다', args), HwpErrorKind.closed => tr('닫힌 문서입니다'), }; @override String toString() => message; } /// 바꾼 PDF 한 묶음 (몇 쪽). 기본은 pdfrx ([pdfChunkOpener]) - 시험은 PDFium 없이 가짜로 abstract class HwpChunk { int get pageCount; Future render(int index, {required int maxWidth}); Future dispose(); } /// PDF 파일을 묶음으로 연다 typedef HwpChunkOpener = Future Function(String pdfPath); /// 기본: pdfrx 로 연다 Future pdfChunkOpener(String pdfPath) async { await pdfrxFlutterInitialize(); return _PdfChunk(await PdfDocument.openFile(pdfPath)); } class _PdfChunk implements HwpChunk { final PdfDocument doc; _PdfChunk(this.doc); @override int get pageCount => doc.pages.length; @override Future render(int index, {required int maxWidth}) async { final page = doc.pages[index]; final w = maxWidth.toDouble(); final h = w * page.height / page.width; final image = await page.render(fullWidth: w, fullHeight: h, backgroundColor: 0xffffffff); if (image == null) return const ReaderImage(); try { return ReaderImage(image: await image.createImage()); } finally { image.dispose(); } } @override Future dispose() => doc.dispose(); } /// 관리자 지시 (10/20): HWP · HWPX 를 보기 화면으로 - 큰 문서 (편람 383쪽은 PC 에서도 14분) 를 통째로 바꾸지 않고, /// 보는 쪽이 든 [chunk] 쪽 묶음만 PDF 로 바꿔 보이면서 다음 묶음을 뒤에서 미리 만든다. 묶음마다 [timeout], /// 닫으면 (dispose) 더 만들지 않는다. 실패한 묶음은 그 쪽에 이유가 보이고 [다시 시도] 로 다시 만든다 (보기 화면). /// 2단계 (10/20): 메모리 - 열어 둔 PDF 묶음은 최근 것 [maxOpenChunks] 개까지 (몇백 쪽 문서도 늘지 않게). 닫은 묶음의 파일은 /// 남겨 다시 볼 때 바꾸지 않고 파일만 연다. 그리고 있는 묶음은 닫지 않는다. class HwpSource extends ReaderSource { final String path; final HwpPdfDoc doc; final Directory work; final List fontDirs; final int chunk; final Duration timeout; final int maxOpenChunks; final HwpChunkOpener opener; @override final String title; /// 열어 둔 묶음 (넣은 순서 = 최근에 쓴 순서, 앞이 오래된 것) final _open = >{}; /// PDF 파일을 다 만든 묶음 final _made = {}; /// 지금 그리고 있는 묶음 (닫지 않는다) final _busy = {}; var _disposed = false; HwpSource._(this.path, this.doc, this.work, this.title, {required this.fontDirs, required this.chunk, required this.timeout, required this.maxOpenChunks, required this.opener, this.log}); /// 실패의 원문을 남길 곳 (작업 기록 - 파일 탐색기가 AppController.note 를 준다) final void Function(String line)? log; /// 문서를 연다 (WebDAV 등은 [tempDir] 로 받은 뒤). 열지 못하면 [HwpPdfException] static Future open(String path, {required HwpPdfEngine engine, required String tempDir, String? password, List fontDirs = const [], int chunk = 5, Duration timeout = const Duration(minutes: 3), int maxOpenChunks = 3, HwpChunkOpener opener = pdfChunkOpener, void Function(String line)? log}) async { final local = await vLocalCopy(path, tempDir); final HwpPdfDoc doc; try { doc = await engine.open(local, password: password); } on HwpPdfException catch (e) { log?.call('HWP ${vBasename(path)}: ${e.kind.name} ${e.detail}'); rethrow; } final work = Directory(p.join(tempDir, 'jj_hwp_${DateTime.now().microsecondsSinceEpoch}'))..createSync(recursive: true); return HwpSource._(path, doc, work, vBasename(path), fontDirs: fontDirs, chunk: chunk, timeout: timeout, maxOpenChunks: math.max(2, maxOpenChunks), opener: opener, log: log); } @override int get length => doc.pageCount; @override String pageName(int i) => '${i + 1} / $length'; /// 열어 둔 묶음 번호 (오래된 것부터 - 시험용) List get openChunks => _open.keys.toList(); /// PDF 파일을 만든 묶음 번호 (시험용) Set get madeChunks => Set.unmodifiable(_made); /// [k] 번째 묶음 (실패하면 지워서 다음에 다시) Future _chunk(int k) { final had = _open.remove(k); if (had != null) { _open[k] = had; // 최근에 쓴 것으로 return had; } late final Future f; f = _openChunk(k).onError((e, st) { if (identical(_open[k], f)) _open.remove(k); Error.throwWithStackTrace(e, st); }); _open[k] = f; _evict(); return f; } Future _openChunk(int k) async { if (_disposed) throw StateError(tr('닫힌 문서입니다')); final out = p.join(work.path, 'p$k.pdf'); if (!_made.contains(k) || !File(out).existsSync()) { final from = k * chunk, to = math.min(from + chunk, length); try { await doc.exportPdf(from, to, out, fontDirs: fontDirs).timeout(timeout); } on TimeoutException { // 관리자 지시 (10/20): 기다림만 끊지 않고 변환도 멈춘다 (다음 묶음이 줄 서서 실패하지 않게) doc.cancel(); log?.call('HWP ${vBasename(path)}: timeout ${from + 1}~$to'); throw HwpPdfException(HwpErrorKind.timeout, 'timeout ${timeout.inSeconds}s', [from + 1, to, timeout.inSeconds]); } catch (e) { // 닫은 뒤에 끝난 변환은 (임시 폴더를 이미 지워 · 멈춰) 실패한다 - 그 이유 대신 "닫힌 문서" if (_disposed) throw StateError(tr('닫힌 문서입니다')); if (e is HwpPdfException) log?.call('HWP ${vBasename(path)} ${from + 1}~$to: ${e.kind.name} ${e.detail}'); rethrow; } _made.add(k); } if (_disposed) throw StateError(tr('닫힌 문서입니다')); final c = await opener(out); if (_disposed) { await c.dispose(); throw StateError(tr('닫힌 문서입니다')); } return c; } /// 열어 둔 묶음이 [maxOpenChunks] 를 넘으면 오래된 것부터 닫는다 (그리고 있는 것은 빼고) void _evict() { while (_open.length > maxOpenChunks) { final victim = _open.keys.where((k) => (_busy[k] ?? 0) == 0).firstOrNull; if (victim == null) return; final f = _open.remove(victim)!; unawaited(f.then((c) => c.dispose(), onError: (Object _) {})); } } @override Future load(int i, {required int maxWidth}) async { final k = i ~/ chunk; _busy[k] = (_busy[k] ?? 0) + 1; try { final c = await _chunk(k); // 다음 묶음을 미리 (넘겨 볼 때 기다리지 않게) - 실패해도 여기서는 알리지 않는다 (그 쪽을 볼 때 보임) if ((k + 1) * chunk < length) unawaited(_chunk(k + 1).then((_) {}, onError: (Object _) {})); return await c.render(i - k * chunk, maxWidth: maxWidth); } finally { final n = (_busy[k] ?? 1) - 1; if (n <= 0) { _busy.remove(k); } else { _busy[k] = n; } if (!_disposed) _evict(); } } @override Future dispose() async { _disposed = true; final open = [..._open.values]; _open.clear(); // 다 연 묶음은 닫기를 기다리고 (열린 PDF 를 닫아야 임시 폴더를 지울 수 있다 - Windows 는 연 파일을 잡는다), // 만드는 중인 묶음은 기다리지 않는다 (몇 분 걸릴 수 있음 - 끝나면 스스로 닫힌다, [_openChunk] 가 _disposed 를 본다) for (final f in open) { final closing = f.then((c) => c.dispose(), onError: (Object _) {}); await Future.any([closing, Future.delayed(const Duration(milliseconds: 200))]); } doc.cancel(); // 바꾸는 중이면 다음 쪽에서 멈춘다 await doc.close(); // 멈춘 바꾸기가 끝난 뒤에 닫힌다 (엔진이 한 번에 하나씩) try { if (work.existsSync()) work.deleteSync(recursive: true); } catch (_) {} } }