From 7bc452e54c985992101c707a8de19cee66f73144 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:11:11 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20HWP=20=EA=B3=A0=EC=B9=A8=20(=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EC=A7=80=EC=8B=9C=2010/20,=2028=20?= =?UTF-8?q?=EA=B2=80=ED=86=A0):=20=E2=91=A0=20=EC=95=B1=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=EC=9D=98=20HWP=20=EC=9E=91=EC=97=85=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=A0=95=EB=A6=AC=EB=A5=BC=20=EA=B8=B0=EB=8B=A4?= =?UTF-8?q?=EB=A6=AC=EC=A7=80=20=EC=95=8A=EC=9D=8C=20(queue=5Ftest=20?= =?UTF-8?q?=EA=B0=80=20=EA=B0=80=EC=A7=9C=20=EC=8B=9C=EA=B0=84=20=EC=95=88?= =?UTF-8?q?=EC=97=90=EC=84=9C=2010=EB=B6=84=20=EA=B1=B8=EB=A6=AC=EB=8D=98?= =?UTF-8?q?=20=ED=9A=8C=EA=B7=80)=20=E2=91=A1=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=EC=9D=80=20=EB=B0=94=EA=BE=B8=EA=B8=B0?= =?UTF-8?q?=EA=B0=80=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20=EC=8B=9C=EC=9E=91?= =?UTF-8?q?=EB=90=9C=20=EB=95=8C=EB=B6=80=ED=84=B0=20=C2=B7=20=EB=A9=88?= =?UTF-8?q?=EC=B6=A4=EC=9D=80=20=EC=9A=94=EC=B2=AD=EB=A7=88=EB=8B=A4=20(Qu?= =?UTF-8?q?euedHwpDoc=20=EB=A1=9C=20=EC=A4=84=20=EC=84=B8=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=8B=9C=EA=B0=84=20=C2=B7=20=EB=A9=88?= =?UTF-8?q?=EC=B6=A4=EC=9D=84=20FFI=20=EC=97=90=EC=84=9C=20=EB=96=BC?= =?UTF-8?q?=EC=96=B4=20=EC=8B=9C=ED=97=98=ED=95=A0=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EA=B2=8C),=20Rust=20ABI=203:=20export=20=EC=97=90=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EB=B2=88=ED=98=B8=20=C2=B7=20jj=5Frhwp=5Fcancel(?= =?UTF-8?q?=EB=B2=88=ED=98=B8)=20=C2=B7=20jj=5Frhwp=5Fcancel=5Fall=20(?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=ED=95=A0=20=EB=95=8C=20=EA=B9=83=EB=B0=9C?= =?UTF-8?q?=EC=9D=84=20=EC=A7=80=EC=9A=B0=EC=A7=80=20=EC=95=8A=EC=9D=8C),?= =?UTF-8?q?=20=EB=8B=AB=EC=9C=BC=EB=A9=B4=20=EC=A4=84=EC=97=90=20=EC=84=A0?= =?UTF-8?q?=20=EA=B2=83=EC=9D=80=20=EC=8B=9C=EC=9E=91=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=9D=8C=20=C2=B7=20check=5Fapk=20=ED=95=84=EC=9A=94?= =?UTF-8?q?=20=ED=95=A8=EC=88=98=208=EA=B0=9C=20=E2=91=A2=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=EC=B0=BD=20close=20=EB=8A=94=20=EA=B7=B8=20?= =?UTF-8?q?=EC=B0=BD=EC=9D=B4=20=EB=A7=A8=20=EC=9C=84=EC=9D=BC=20=EB=95=8C?= =?UTF-8?q?=EB=A7=8C=20=C2=B7=20=EC=8B=9C=ED=97=98:=20=EC=A4=84=20?= =?UTF-8?q?=EC=84=B8=EC=9A=B0=EA=B8=B0=203=EA=B0=9C=20=C2=B7=20ABI=20?= =?UTF-8?q?=ED=8C=90=203=20(=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- integration_test/hwp_source_test.dart | 30 +++--- lib/app/app_controller.dart | 13 ++- lib/core/hwp_source.dart | 99 ++++++++++++++++--- lib/platform/android/hwp_pdf_ffi.dart | 75 ++++++++------- lib/ui/progress_dialog.dart | 3 +- test/hwp_abi_test.dart | 8 +- test/hwp_source_unit_test.dart | 133 +++++++++++++++++++++++--- tool/check_apk.ps1 | 2 +- tool/rhwp_ffi/src/lib.rs | 55 ++++++++--- 9 files changed, 323 insertions(+), 95 deletions(-) diff --git a/integration_test/hwp_source_test.dart b/integration_test/hwp_source_test.dart index 2356b4b..c56bb9f 100644 --- a/integration_test/hwp_source_test.dart +++ b/integration_test/hwp_source_test.dart @@ -23,28 +23,38 @@ class _FakeEngine implements HwpPdfEngine { @override Future open(String path, {String? password}) async { if (password == 'bad') throw const HwpPdfException(HwpErrorKind.passwordWrong, 'CryptoError WrongPassword'); - return _FakeDoc(this); + return QueuedHwpDoc(_FakeNative(this)); } } -class _FakeDoc implements HwpPdfDoc { +/// 앱과 같은 [QueuedHwpDoc] 아래의 가짜 C 함수 +class _FakeNative implements HwpNative { final _FakeEngine e; var closed = false; - _FakeDoc(this.e); + _FakeNative(this.e); @override int get pageCount => e.pages; @override - Future exportPdf(int from, int to, String out, {List fontDirs = const []}) async { + void cancel(int req) { + final h = e.hold; + if (h != null && !h.isCompleted) h.complete(); // Rust 처럼 다음 쪽에서 멈춤 + } + + @override + void cancelAll() => cancel(0); + + @override + Future<(int, String?)> export(int req, int from, int to, String fontDirs, String out) async { e.calls.add((from, to)); - // 끝나지 않는 변환 (시간 제한 시험): 풀려도 아무것도 쓰지 않는다 (시험이 끝난 뒤 폴더에 쓰지 않게) + // 끝나지 않는 변환 (시간 제한 시험): 멈춤으로 풀리면 아무것도 쓰지 않는다 (시험이 끝난 뒤 폴더에 쓰지 않게) if (e.hold != null) { await e.hold!.future; - throw const HwpPdfException(HwpErrorKind.cancelled, 'late'); + return (1, 'cancelled'); } if (e.failNext > 0) { e.failNext--; - throw const HwpPdfException(HwpErrorKind.render, 'page 1: boom'); + return (7, 'page 1: boom'); } final docs = []; for (var i = from; i < to; i++) { @@ -60,11 +70,9 @@ class _FakeDoc implements HwpPdfDoc { for (final d in docs) { await d.dispose(); } + return (0, null); } - @override - void cancel() {} - @override Future close() async => closed = true; } @@ -124,7 +132,7 @@ void main() { })); expect(err2, isA()); expect('$err2', contains('1~3')); - slow.hold!.complete(); + expect(slow.hold!.isCompleted, isTrue, reason: '시간 제한이 변환을 멈춤 (기다림만 끊지 않음)'); await tester.runAsync(s2!.dispose); // 열기 실패 (암호 등) 는 그대로 알린다 - 파일 탐색기는 다른 앱으로 연다 diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 89a9bcb..a153aa4 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -214,11 +214,14 @@ class AppController extends ChangeNotifier { final gone = removeUpdateLeftovers(Directory.systemTemp.path); if (gone.isNotEmpty) _log(trf('지난 업데이트가 남긴 임시 파일을 지움: {0}', [gone.join(', ')])); } - // 관리자 지시 (10/20): 강제로 끝나 남은 HWP 보기 작업 폴더 (바꾼 PDF 들) - try { - final hwp = removeStaleHwpWork(await services.storage.tempDirectory()); - if (hwp.isNotEmpty) _log(trf('지난번에 남은 문서 보기 임시 폴더를 지움: {0}개', [hwp.length])); - } catch (_) {} + // 관리자 지시 (10/20): 강제로 끝나 남은 HWP 보기 작업 폴더 (바꾼 PDF 들). 앱 시작을 기다리게 하지 않는다 (28 지적: 파일 IO 를 + // 기다리면 시험의 가짜 시간 안에서 끝나지 않았다 - 시작을 늦출 이유도 없음) + unawaited(() async { + try { + final hwp = removeStaleHwpWork(await services.storage.tempDirectory()); + if (hwp.isNotEmpty) _log(trf('지난번에 남은 문서 보기 임시 폴더를 지움: {0}개', [hwp.length])); + } catch (_) {} + }()); ffmpegVersion = await _tool.version(); if (ffmpegVersion == null) { _log(tr('⚠ FFmpeg 를 찾을 수 없습니다. MKV 만들기를 사용할 수 없습니다.')); diff --git a/lib/core/hwp_source.dart b/lib/core/hwp_source.dart index 2a595e5..f2b7443 100644 --- a/lib/core/hwp_source.dart +++ b/lib/core/hwp_source.dart @@ -41,15 +41,93 @@ abstract class HwpPdfEngine { abstract class HwpPdfDoc { int get pageCount; - /// [from] 부터 [to] 앞까지 (0 부터) 를 PDF 파일 [out] 으로. [fontDirs] 는 글꼴을 찾을 폴더들 - Future exportPdf(int from, int to, String out, {List fontDirs = const []}); + /// [from] 부터 [to] 앞까지 (0 부터) 를 PDF 파일 [out] 으로. [fontDirs] 는 글꼴을 찾을 폴더들. + /// [timeout]: 바꾸기가 실제로 시작된 때부터 재고, 넘으면 그 요청만 멈추고 [HwpErrorKind.timeout] + Future exportPdf(int from, int to, String out, {List fontDirs = const [], Duration? timeout}); - /// 지금 바꾸는 것을 멈춘다 (시간 제한을 넘겼을 때 · 닫을 때 - 관리자 지시 10/20: 기다림만 끊으면 뒤에서 계속 돌아 - /// 다음 묶음이 줄을 서서 연달아 실패했다). 멈춘 바꾸기는 [HwpErrorKind.cancelled] 로 끝난다 - void cancel(); + /// 닫는다: 도는 바꾸기는 멈추고, 줄에 선 것은 시작하지 않는다 ([HwpErrorKind.closed]) Future close(); } +/// 변환기의 C 함수 (Android: libjj_rhwp.so - lib/platform/android/hwp_pdf_ffi.dart, 시험: 가짜). +/// 요청 번호 [export] 의 req 는 [cancel] 로 그 요청만 멈출 때 쓴다 +abstract class HwpNative { + int get pageCount; + + /// (오류 코드 - 0 이면 성공, 원문) + Future<(int, String?)> export(int req, int from, int to, String fontDirs, String out); + + /// 요청 [req] 만 멈춘다 (도는 중이면 다음 쪽에서, 시작 전이면 시작하자마자) + void cancel(int req); + + /// 닫기 전에 모두 멈춘다 + void cancelAll(); + Future close(); +} + +/// 관리자 지시 (10/20, 28 지적): 한 문서의 바꾸기를 한 번에 하나씩 줄 세우고, 시간 제한 · 멈춤을 요청마다 한다. +/// - 시간 제한은 바꾸기가 실제로 시작된 때부터 잰다 (예전에는 줄에 선 때부터 - 앞 묶음이 길면 뒤 묶음이 시작 전에 넘었다). +/// - 넘으면 그 요청만 멈춘다 (예전에는 문서 하나의 깃발이라 도는 앞 묶음이 멈췄다). 멈춘 바꾸기가 끝날 때까지 다음 것은 +/// 시작하지 않는다 (한 문서를 두 스레드가 함께 쓰지 않게). +/// - 닫으면 도는 것은 멈추고 줄에 선 것은 시작하지 않는다 (예전에는 시작할 때 깃발을 지워 닫기 요청이 사라졌다). +class QueuedHwpDoc implements HwpPdfDoc { + final HwpNative native; + QueuedHwpDoc(this.native); + + @override + int get pageCount => native.pageCount; + + var _seq = 0; + var _closed = false; + Future _queue = Future.value(); + Future? _closing; + + @override + Future exportPdf(int from, int to, String out, {List fontDirs = const [], Duration? timeout}) { + if (_closed) return Future.error(const HwpPdfException(HwpErrorKind.closed)); + final req = ++_seq; + final result = Completer(); + _queue = _queue.then((_) async { + if (_closed) { + result.completeError(const HwpPdfException(HwpErrorKind.closed)); + return; + } + Timer? timer; + if (timeout != null) { + timer = Timer(timeout, () { + if (result.isCompleted) return; + native.cancel(req); + result.completeError(HwpPdfException( + HwpErrorKind.timeout, 'timeout ${timeout.inSeconds}s (pages ${from + 1}~$to)', [from + 1, to, timeout.inSeconds])); + }); + } + try { + final (code, err) = await native.export(req, from, to, fontDirs.join('\n'), out); + if (!result.isCompleted) { + if (code == 0) { + result.complete(); + } else { + result.completeError(HwpPdfException(_closed ? HwpErrorKind.closed : hwpErrorKindOf(code), err ?? '')); + } + } + } catch (e, st) { + if (!result.isCompleted) result.completeError(e, st); + } finally { + timer?.cancel(); + } + }); + return result.future; + } + + @override + Future close() => _closing ??= () async { + _closed = true; + native.cancelAll(); + await _queue; // 도는 것이 멈춰 끝날 때까지 (줄에 선 것은 시작하지 않고 넘어감) + await native.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 } @@ -217,12 +295,8 @@ class HwpSource extends ReaderSource { 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]); + // 시간 제한은 문서 ([QueuedHwpDoc]) 가 바꾸기가 시작된 때부터 재고, 넘으면 그 요청만 멈춘다 + await doc.exportPdf(from, to, out, fontDirs: fontDirs, timeout: timeout); } catch (e) { // 닫은 뒤에 끝난 변환은 (임시 폴더를 이미 지워 · 멈춰) 실패한다 - 그 이유 대신 "닫힌 문서" if (_disposed) throw StateError(tr('닫힌 문서입니다')); @@ -281,8 +355,7 @@ class HwpSource extends ReaderSource { final closing = f.then((c) => c.dispose(), onError: (Object _) {}); await Future.any([closing, Future.delayed(const Duration(milliseconds: 200))]); } - doc.cancel(); // 바꾸는 중이면 다음 쪽에서 멈춘다 - await doc.close(); // 멈춘 바꾸기가 끝난 뒤에 닫힌다 (엔진이 한 번에 하나씩) + await doc.close(); // 도는 바꾸기는 다음 쪽에서 멈추고, 줄에 선 것은 시작하지 않는다 try { if (work.existsSync()) work.deleteSync(recursive: true); } catch (_) {} diff --git a/lib/platform/android/hwp_pdf_ffi.dart b/lib/platform/android/hwp_pdf_ffi.dart index 40e04ae..1bbc06c 100644 --- a/lib/platform/android/hwp_pdf_ffi.dart +++ b/lib/platform/android/hwp_pdf_ffi.dart @@ -7,8 +7,8 @@ import 'package:ffi/ffi.dart'; import '../../core/hwp_source.dart'; /// 관리자 지시 (10/20): Android 의 HWP · HWPX → PDF - APK 에 든 libjj_rhwp.so (rhwp v0.8.7, MIT - tool/build_rhwp_android.sh · -/// tool/rhwp_ffi) 를 dart:ffi 로 부른다. 여는 것 · 바꾸는 것은 다른 isolate 에서 (화면을 막지 않게), 한 문서는 한 번에 하나씩. -/// 라이브러리는 HWP 를 처음 열 때 읽는다 (앱을 켤 때 읽지 않음). +/// tool/rhwp_ffi) 를 dart:ffi 로 부른다. 여는 것 · 바꾸는 것은 다른 isolate 에서 (화면을 막지 않게). 줄 세우기 · 시간 제한 · +/// 멈춤은 [QueuedHwpDoc] 가 한다 (요청마다). 라이브러리는 HWP 를 처음 열 때 읽는다 (앱을 켤 때 읽지 않음). class RhwpEngine implements HwpPdfEngine { static const libName = 'libjj_rhwp.so'; @@ -16,18 +16,20 @@ class RhwpEngine implements HwpPdfEngine { /// 관리자 판단 (10/20): 한글 글꼴 컴포넌트를 받으면 그 폴더를 앞에 더한다 (아직 없음) static const fontDirs = ['/system/fonts']; - /// 이 Dart 가 기대하는 C ABI 의 판 (tool/rhwp_ffi 의 JJ_RHWP_ABI_VERSION 과 같아야 함) - static const abiVersion = 2; + /// 이 Dart 가 기대하는 C ABI 의 판 (tool/rhwp_ffi 의 JJ_RHWP_ABI_VERSION 과 같아야 함). + /// 3: 요청별 멈춤 (export 에 요청 번호 · jj_rhwp_cancel(번호) · jj_rhwp_cancel_all) + static const abiVersion = 3; /// 꼭 있어야 하는 C 함수 static const requiredSymbols = [ - 'jj_rhwp_abi_version', 'jj_rhwp_open', 'jj_rhwp_page_count', 'jj_rhwp_export_pdf', 'jj_rhwp_cancel', 'jj_rhwp_close', 'jj_rhwp_free', // + 'jj_rhwp_abi_version', 'jj_rhwp_open', 'jj_rhwp_page_count', 'jj_rhwp_export_pdf', 'jj_rhwp_cancel', 'jj_rhwp_cancel_all', // + 'jj_rhwp_close', 'jj_rhwp_free', ]; static bool? _available; /// 라이브러리를 읽을 수 있고 ABI 가 맞는지 (아니면 다른 앱으로 연다). 처음 HWP 를 열 때 한 번 본다. - /// 관리자 지시 (10/20, 28 지적): 옛 .so (판 1 - code 칸 · cancel 없음) 에 새 인수를 넘기면 힙이 망가지므로, 기호와 판이 맞을 때만 + /// 관리자 지시 (10/20, 28 지적): 옛 .so 에 새 인수를 넘기면 힙이 망가지므로, 기호와 판이 맞을 때만 static bool get available => _available ??= () { try { final lib = DynamicLibrary.open(libName); @@ -53,47 +55,41 @@ class RhwpEngine implements HwpPdfEngine { final (handle, code, err) = await Isolate.run(() => _open(path, password ?? '')); if (handle == 0) throw HwpPdfException(hwpErrorKindOf(code), err ?? ''); final pages = await Isolate.run(() => _pageCount(handle)); - return _RhwpDoc(handle, pages); + return QueuedHwpDoc(_RhwpNative(handle, pages)); } } -class _RhwpDoc implements HwpPdfDoc { +class _RhwpNative implements HwpNative { final int _handle; @override final int pageCount; var _closed = false; + _RhwpNative(this._handle, this.pageCount); - /// 한 번에 하나씩 (rhwp 문서는 여러 스레드에서 함께 바꾸지 않는다) - Future _queue = Future.value(); + @override + Future<(int, String?)> export(int req, int from, int to, String fontDirs, String out) { + final h = _handle; + return Isolate.run(() => _export(h, req, from, to, fontDirs, out)); + } - _RhwpDoc(this._handle, this.pageCount); - - Future _serial(Future Function() f) { - final run = _queue.then((_) => f()); - _queue = run.then((_) {}, onError: (Object _) {}); - return run; + /// 줄을 서지 않고 바로 (다른 isolate 의 바꾸기가 다음 쪽에서 끝난다) + @override + void cancel(int req) { + if (!_closed) _cancel(_handle, req); } @override - Future exportPdf(int from, int to, String out, {List fontDirs = const []}) => _serial(() async { - if (_closed) throw const HwpPdfException(HwpErrorKind.closed); - final h = _handle, dirs = fontDirs.join('\n'); - final (code, err) = await Isolate.run(() => _export(h, from, to, dirs, out)); - if (code != 0) throw HwpPdfException(hwpErrorKindOf(code), err ?? ''); - }); - - /// 지금 바꾸는 것을 멈춘다 (줄을 서지 않고 바로 - 다른 isolate 의 바꾸기가 다음 쪽에서 끝난다) - @override - void cancel() { - if (!_closed) _cancel(_handle); + void cancelAll() { + if (!_closed) _cancelAll(_handle); } + /// [QueuedHwpDoc.close] 가 도는 바꾸기가 끝난 뒤에 부른다 @override - Future close() => _serial(() async { - if (_closed) return; - _closed = true; - _close(_handle); - }); + Future close() async { + if (_closed) return; + _closed = true; + _close(_handle); + } } // ───────── C 함수 (isolate 안에서 부른다 - 핸들은 주소 숫자로 넘긴다) ───────── @@ -101,8 +97,10 @@ class _RhwpDoc implements HwpPdfDoc { typedef _OpenC = Pointer Function(Pointer, Pointer, Pointer, Pointer>); typedef _CountC = Int32 Function(Pointer); typedef _CountD = int Function(Pointer); -typedef _ExportC = Int32 Function(Pointer, Int32, Int32, Pointer, Pointer, Pointer, Pointer>); -typedef _ExportD = int Function(Pointer, int, int, Pointer, Pointer, Pointer, Pointer>); +typedef _ExportC = Int32 Function(Pointer, Uint64, Int32, Int32, Pointer, Pointer, Pointer, Pointer>); +typedef _ExportD = int Function(Pointer, int, int, int, Pointer, Pointer, Pointer, Pointer>); +typedef _CancelC = Void Function(Pointer, Uint64); +typedef _CancelD = void Function(Pointer, int); typedef _VoidC = Void Function(Pointer); typedef _VoidD = void Function(Pointer); typedef _FreeC = Void Function(Pointer); @@ -138,14 +136,14 @@ String? _takeError(DynamicLibrary lib, Pointer> err) { int _pageCount(int handle) => _lib().lookupFunction<_CountC, _CountD>('jj_rhwp_page_count')(Pointer.fromAddress(handle)); -(int, String?) _export(int handle, int from, int to, String fontDirs, String out) { +(int, String?) _export(int handle, int req, int from, int to, String fontDirs, String out) { final lib = _lib(); final export = lib.lookupFunction<_ExportC, _ExportD>('jj_rhwp_export_pdf'); final d = fontDirs.toNativeUtf8(), o = out.toNativeUtf8(); final code = calloc(); final err = calloc>(); try { - final r = export(Pointer.fromAddress(handle), from, to, d, o, code, err); + final r = export(Pointer.fromAddress(handle), req, from, to, d, o, code, err); final e = _takeError(lib, err); return r == 0 ? (0, null) : (code.value == 0 ? 9 : code.value, e); } finally { @@ -156,6 +154,9 @@ int _pageCount(int handle) => _lib().lookupFunction<_CountC, _CountD>('jj_rhwp_p } } -void _cancel(int handle) => _lib().lookupFunction<_VoidC, _VoidD>('jj_rhwp_cancel')(Pointer.fromAddress(handle)); +void _cancel(int handle, int req) => + _lib().lookupFunction<_CancelC, _CancelD>('jj_rhwp_cancel')(Pointer.fromAddress(handle), req); + +void _cancelAll(int handle) => _lib().lookupFunction<_VoidC, _VoidD>('jj_rhwp_cancel_all')(Pointer.fromAddress(handle)); void _close(int handle) => _lib().lookupFunction<_VoidC, _VoidD>('jj_rhwp_close')(Pointer.fromAddress(handle)); diff --git a/lib/ui/progress_dialog.dart b/lib/ui/progress_dialog.dart index e16337e..c6922d8 100644 --- a/lib/ui/progress_dialog.dart +++ b/lib/ui/progress_dialog.dart @@ -36,6 +36,7 @@ ProgressDialog showProgressDialog(BuildContext context, String text) { if (!open) return; byUser = false; final x = dctx; - if (x != null && x.mounted) Navigator.of(x).pop(); + // 그 창이 맨 위일 때만 (28 지적: 이론상 그 위에 다른 화면이 올라와 있으면 그것을 닫을 수 있다) + if (x != null && x.mounted && (ModalRoute.of(x)?.isCurrent ?? false)) Navigator.of(x).pop(); }); } diff --git a/test/hwp_abi_test.dart b/test/hwp_abi_test.dart index 4336139..8e6a355 100644 --- a/test/hwp_abi_test.dart +++ b/test/hwp_abi_test.dart @@ -8,14 +8,16 @@ import 'package:jj_mkvmaker/platform/android/hwp_pdf_ffi.dart'; void main() { const v1 = {'jj_rhwp_open', 'jj_rhwp_page_count', 'jj_rhwp_export_pdf', 'jj_rhwp_close', 'jj_rhwp_free', 'jj_rhwp_version'}; final v2 = {...v1, 'jj_rhwp_cancel', 'jj_rhwp_abi_version'}; + final v3 = {...v2, 'jj_rhwp_cancel_all'}; test('옛 .so (기호 없음) · 판이 다름 · 부르다 실패 → 쓰지 않음, 맞으면 씀', () { expect(RhwpEngine.abiCompatible(has: v1.contains, abi: () => throw ArgumentError('no symbol')), isFalse, reason: '판 1'); expect(RhwpEngine.abiCompatible(has: {...v1, 'jj_rhwp_abi_version'}.contains, abi: () => 2), isFalse, reason: 'cancel 없음'); - expect(RhwpEngine.abiCompatible(has: v2.contains, abi: () => 1), isFalse, reason: '판이 다름'); - expect(RhwpEngine.abiCompatible(has: v2.contains, abi: () => 3), isFalse, reason: '더 새 판도'); + expect(RhwpEngine.abiCompatible(has: v2.contains, abi: () => 2), isFalse, reason: '판 2 (cancel_all 없음 - 문서 하나의 멈춤 깃발)'); + expect(RhwpEngine.abiCompatible(has: v3.contains, abi: () => 2), isFalse, reason: '판이 다름'); + expect(RhwpEngine.abiCompatible(has: v3.contains, abi: () => 4), isFalse, reason: '더 새 판도'); expect(RhwpEngine.abiCompatible(has: (_) => throw StateError('x'), abi: () => 2), isFalse); - expect(RhwpEngine.abiCompatible(has: v2.contains, abi: () => RhwpEngine.abiVersion), isTrue); + expect(RhwpEngine.abiCompatible(has: v3.contains, abi: () => RhwpEngine.abiVersion), isTrue); }); test('Dart 의 판 = tool/rhwp_ffi 의 JJ_RHWP_ABI_VERSION · 꼭 있어야 하는 함수는 lib.rs 에 모두', () { diff --git a/test/hwp_source_unit_test.dart b/test/hwp_source_unit_test.dart index 862f276..1dbf0ac 100644 --- a/test/hwp_source_unit_test.dart +++ b/test/hwp_source_unit_test.dart @@ -6,35 +6,55 @@ import 'package:jj_mkvmaker/core/hwp_source.dart'; import 'package:jj_mkvmaker/core/reader_sources.dart'; import 'package:path/path.dart' as p; -/// 쪽 범위를 글로 적은 파일을 "PDF" 로 만드는 가짜 엔진 (rhwp 대신) - 부른 범위를 적어 둔다 +/// 쪽 범위를 글로 적은 파일을 "PDF" 로 만드는 가짜 엔진 (rhwp 대신) - 부른 범위를 적어 둔다. +/// 문서는 앱과 같은 [QueuedHwpDoc] (줄 세우기 · 시간 제한 · 멈춤) 위의 가짜 C 함수 ([_Native]) class _Engine implements HwpPdfEngine { final int pages; final calls = <(int, int)>[]; + + /// 있으면 바꾸기가 이것을 기다린다 (멈춤 · 닫기를 받으면 바로 끝남 - Rust 가 쪽마다 멈춤 표시를 보는 것처럼) Completer? hold; var closed = false; - var cancels = 0; + final cancelled = {}; + var cancelledAll = false; _Engine(this.pages); @override - Future open(String path, {String? password}) async => _Doc(this); + Future open(String path, {String? password}) async => QueuedHwpDoc(_Native(this)); } -class _Doc implements HwpPdfDoc { +class _Native implements HwpNative { final _Engine e; - _Doc(this.e); + _Native(this.e); @override int get pageCount => e.pages; @override - Future exportPdf(int from, int to, String out, {List fontDirs = const []}) async { + Future<(int, String?)> export(int req, int from, int to, String fontDirs, String out) async { e.calls.add((from, to)); final h = e.hold; if (h != null) await h.future; + if (e.cancelledAll || e.cancelled.contains(req)) return (1, 'cancelled'); File(out).writeAsStringSync('$from-$to'); + return (0, null); + } + + void _wake() { + final h = e.hold; + if (h != null && !h.isCompleted) h.complete(); } @override - void cancel() => e.cancels++; + void cancel(int req) { + e.cancelled.add(req); + _wake(); + } + + @override + void cancelAll() { + e.cancelledAll = true; + _wake(); + } @override Future close() async => e.closed = true; @@ -151,10 +171,9 @@ void main() { expect(err, isA()); expect((err! as HwpPdfException).kind, HwpErrorKind.timeout); expect('$err', allOf(contains('1~5'), contains('0초'))); - expect(e.cancels, 1, reason: '시간 제한을 넘기면 변환도 멈춘다 (기다림만 끊지 않음)'); + expect(e.cancelled, {1}, reason: '시간 제한을 넘기면 그 요청의 변환도 멈춘다 (기다림만 끊지 않음)'); expect(s.openChunks, isNot(contains(0)), reason: '실패한 묶음은 기억하지 않음 - [다시 시도] 가 다시 만든다'); - e.hold!.complete(); - e.hold = null; + e.hold = null; // 멈춤이 기다림을 깨웠다 await s.load(0, maxWidth: 100); expect(e.calls.where((c) => c == (0, 5)).length, greaterThanOrEqualTo(2)); await s.dispose(); @@ -166,9 +185,9 @@ void main() { final s = await HwpSource.open(doc.path, engine: e, tempDir: dir.path, chunk: 5, opener: o.call); final pending = s.load(0, maxWidth: 100).then((_) => null, onError: (Object x) => x); await settle(); - await s.dispose(); // 기다리지 않고 바로 끝난다 - expect(e.cancels, 1, reason: '닫으면 바꾸던 것을 멈춘다'); - e.hold!.complete(); + await s.dispose(); // 도는 바꾸기는 멈춤으로 곧 끝나고, 닫힌다 + expect(e.cancelledAll, isTrue, reason: '닫으면 바꾸던 것을 멈춘다'); + expect(e.closed, isTrue); final r = await pending; expect(r, isA(), reason: '닫힌 문서'); expect(o.opened, isEmpty, reason: '닫은 뒤에는 PDF 를 열지 않음'); @@ -206,4 +225,92 @@ void main() { expect(rs, contains('pub const JJ_RHWP_E_$n: c_int = ${i + 1};'), reason: n); } }); + + group('QueuedHwpDoc (28 지적: 시간 제한 · 멈춤은 요청마다)', () { + test('앞 묶음이 오래 돌아도, 줄에서 기다린 뒤 묶음의 시간 제한은 제 시작부터 잰다', () async { + final n = _Gated(); + final d = QueuedHwpDoc(n); + final a = d.exportPdf(0, 5, 'a', timeout: const Duration(seconds: 30)); // 오래 도는 앞 묶음 + final b = d.exportPdf(5, 10, 'b', timeout: const Duration(milliseconds: 300)); + await Future.delayed(const Duration(milliseconds: 700)); // 뒤 묶음의 제한보다 오래 줄에서 기다림 + expect(n.started, [1]); + n.release(1); + await a; + await Future.delayed(const Duration(milliseconds: 50)); + expect(n.started, [1, 2]); + n.release(2); + await b; // 시간 제한 없이 끝남 + expect(n.cancelled, isEmpty); + }); + + test('한 요청이 시간을 넘기면 그 요청만 멈추고, 멈춘 것이 끝난 뒤 다음 것이 돈다', () async { + final n = _Gated(); + final d = QueuedHwpDoc(n); + Object? err; + final a = d.exportPdf(0, 5, 'a', timeout: const Duration(milliseconds: 200)).catchError((Object e) => err = e); + final b = d.exportPdf(5, 10, 'b'); + await a; + expect(err, isA().having((e) => e.kind, 'kind', HwpErrorKind.timeout)); + expect(n.cancelled, {1}, reason: '다른 요청 (2) 은 멈추지 않음'); + await Future.delayed(const Duration(milliseconds: 50)); + expect(n.started, [1, 2], reason: '멈춘 1 이 끝난 뒤에 2 가 시작'); + n.release(2); + await b; + }); + + test('닫으면 도는 것은 멈추고 줄에 선 것은 시작하지 않는다 (닫기 요청이 지워지지 않음)', () async { + final n = _Gated(); + final d = QueuedHwpDoc(n); + final a = d.exportPdf(0, 5, 'a').then((_) => null, onError: (Object e) => e); + final b = d.exportPdf(5, 10, 'b').then((_) => null, onError: (Object e) => e); + await Future.delayed(const Duration(milliseconds: 50)); + await d.close(); + expect(n.all, isTrue); + expect(n.started, [1], reason: '줄에 선 2 는 시작하지 않음'); + expect(n.closed, isTrue); + expect(await a, isA().having((e) => e.kind, 'kind', HwpErrorKind.closed)); + expect(await b, isA().having((e) => e.kind, 'kind', HwpErrorKind.closed)); + expect(() => d.exportPdf(0, 1, 'c'), throwsA(isA()), reason: '닫은 뒤의 요청'); + }); + }); +} + +/// 요청마다 문이 있는 가짜 C 함수 - 멈춤 · 닫기를 받으면 문이 열리고 (cancelled) 로 끝난다 +class _Gated implements HwpNative { + final started = []; + final gates = >{}; + final cancelled = {}; + var all = false; + var closed = false; + @override + int get pageCount => 10; + + Completer _gate(int req) => gates.putIfAbsent(req, Completer.new); + void release(int req) { + if (!_gate(req).isCompleted) _gate(req).complete(); + } + + @override + Future<(int, String?)> export(int req, int from, int to, String fontDirs, String out) async { + started.add(req); + await _gate(req).future; + return all || cancelled.contains(req) ? (1, 'cancelled') : (0, null); + } + + @override + void cancel(int req) { + cancelled.add(req); + release(req); + } + + @override + void cancelAll() { + all = true; + for (final r in [...gates.keys, ...started]) { + release(r); + } + } + + @override + Future close() async => closed = true; } diff --git a/tool/check_apk.ps1 b/tool/check_apk.ps1 index 8a0763f..e73f9c9 100644 --- a/tool/check_apk.ps1 +++ b/tool/check_apk.ps1 @@ -77,7 +77,7 @@ if ($rhwpSo) { $nm = Get-ChildItem (Join-Path $app '..\tools\android-sdk\ndk') -Recurse -Filter llvm-nm.exe -ErrorAction SilentlyContinue | Select-Object -Last 1 if (-not $nm) { throw 'llvm-nm.exe 를 찾지 못했습니다 (tools\android-sdk\ndk)' } $syms = @(& $nm.FullName -D --defined-only $rhwpSo 2>$null | ForEach-Object { ($_ -split '\s+')[-1] }) - $need = 'jj_rhwp_abi_version', 'jj_rhwp_open', 'jj_rhwp_page_count', 'jj_rhwp_export_pdf', 'jj_rhwp_cancel', 'jj_rhwp_close', 'jj_rhwp_free' + $need = 'jj_rhwp_abi_version', 'jj_rhwp_open', 'jj_rhwp_page_count', 'jj_rhwp_export_pdf', 'jj_rhwp_cancel', 'jj_rhwp_cancel_all', 'jj_rhwp_close', 'jj_rhwp_free' $miss = @($need | Where-Object { $syms -notcontains $_ }) if ($miss) { $bad += "libjj_rhwp.so 가 옛 C ABI 입니다 - 없는 함수: $($miss -join ', ') (sh tool/build_rhwp_android.sh 로 다시 빌드)" } else { $rhwpNote = " · libjj_rhwp.so 함수 $($need.Count)개 있음" } diff --git a/tool/rhwp_ffi/src/lib.rs b/tool/rhwp_ffi/src/lib.rs index d61fb71..1031d48 100644 --- a/tool/rhwp_ffi/src/lib.rs +++ b/tool/rhwp_ffi/src/lib.rs @@ -2,14 +2,18 @@ //! 관리자 지시 (10/20): Android 에서 HWP · HWPX 를 앱의 보기 화면 (PDF) 으로 연다 - 문서를 한 번 열고, 쪽 범위마다 PDF 를 만든다 //! (큰 문서는 앞쪽부터 몇 쪽씩 보이면서 뒤를 이어 만든다). Dart 는 dart:ffi 로 부른다 (lib/platform/android/hwp_pdf_ffi.dart). //! - 오류는 종류 코드 (JJ_RHWP_E_*) 로 돌려주고, 원문 (rhwp 의 글) 은 err 로 (화면은 코드로 고른 번역 글, 원문은 [자세히] · 작업 기록). -//! - jj_rhwp_cancel: 다른 스레드에서 지금 바꾸는 것을 멈춘다 (쪽마다 확인 - 관리자 지시 10/20, 시간 제한 뒤에도 계속 돌지 않게). +//! - jj_rhwp_cancel(요청 번호): 다른 스레드에서 그 요청만 멈춘다 (쪽마다 확인 - 관리자 지시 10/20, 시간 제한 뒤에도 계속 돌지 않게). +//! jj_rhwp_cancel_all: 닫을 때 - 도는 것 · 앞으로 올 것 모두 멈춘다 (ABI 3: 예전에는 문서 하나의 깃발이라 한 요청의 시간 제한이 +//! 앞 묶음까지 멈췄고, 바꾸기를 시작할 때 깃발을 지워 닫기 요청이 사라졌다 - 28 지적). //! 오류 글 · 돌려준 글은 jj_rhwp_free 로 푼다. 어느 함수도 panic 을 C 쪽으로 넘기지 않는다. use std::ffi::{c_char, c_int, CStr, CString}; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::PathBuf; use std::ptr::null_mut; +use std::collections::HashSet; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; use rhwp::document_core::DocumentCore; use rhwp::paint::RenderProfile; @@ -39,7 +43,16 @@ pub const JJ_RHWP_E_INTERNAL: c_int = 9; /// 연 문서 pub struct JjRhwpDoc { core: DocumentCore, - cancel: AtomicBool, + /// 멈추라고 한 요청 번호 (끝나면 지운다) + cancelled: Mutex>, + /// 닫는 중 - 모든 요청을 멈춘다 (되돌리지 않는다) + closing: AtomicBool, +} + +impl JjRhwpDoc { + fn stopped(&self, req: u64) -> bool { + self.closing.load(Ordering::SeqCst) || self.cancelled.lock().map(|s| s.contains(&req)).unwrap_or(false) + } } struct Fail(c_int, String); @@ -103,7 +116,7 @@ fn open_kind(msg: &str, with_password: bool) -> c_int { /// 이 C ABI 의 판 - Dart (lib/platform/android/hwp_pdf_ffi.dart 의 RhwpEngine.abiVersion) 와 같아야 앱 안 보기를 쓴다 /// (관리자 지시 10/20, 28 지적: 옛 .so (판 1 - code 칸 · cancel 없음) 에 새 인수를 넘기면 힙이 망가진다). /// 함수의 인수 · 오류 코드를 바꾸면 올린다 -pub const JJ_RHWP_ABI_VERSION: c_int = 2; +pub const JJ_RHWP_ABI_VERSION: c_int = 3; #[no_mangle] pub extern "C" fn jj_rhwp_abi_version() -> c_int { @@ -136,7 +149,7 @@ pub extern "C" fn jj_rhwp_open( let msg = e.to_string(); Fail(open_kind(&msg, pw.is_some()), msg) })?; - Ok(Box::into_raw(Box::new(JjRhwpDoc { core, cancel: AtomicBool::new(false) }))) + Ok(Box::into_raw(Box::new(JjRhwpDoc { core, cancelled: Mutex::new(HashSet::new()), closing: AtomicBool::new(false) }))) }) } @@ -150,22 +163,33 @@ pub extern "C" fn jj_rhwp_page_count(doc: *const JjRhwpDoc) -> c_int { catch_unwind(AssertUnwindSafe(|| doc.core.page_count() as c_int)).unwrap_or(-1) } -/// 지금 바꾸는 것을 멈춘다 (다른 스레드에서 불러도 됨 - 다음 쪽에서 JJ_RHWP_E_CANCELLED 로 끝난다). -/// 다음 jj_rhwp_export_pdf 는 표시를 지우고 시작한다 +/// 요청 [req] 만 멈춘다 (다른 스레드에서 불러도 됨 - 도는 중이면 다음 쪽에서, 아직 시작 전이면 시작하자마자 +/// JJ_RHWP_E_CANCELLED 로 끝난다). 다른 요청은 그대로 #[no_mangle] -pub extern "C" fn jj_rhwp_cancel(doc: *const JjRhwpDoc) { +pub extern "C" fn jj_rhwp_cancel(doc: *const JjRhwpDoc, req: u64) { if !doc.is_null() { - unsafe { &*doc }.cancel.store(true, Ordering::SeqCst); + if let Ok(mut s) = unsafe { &*doc }.cancelled.lock() { + s.insert(req); + } } } -/// [from, to) 쪽 (0 부터) 을 PDF 파일 out 으로. font_dirs 는 줄바꿈 (\n) 으로 나눈 글꼴 폴더들 (Android 는 시스템 글꼴을 +/// 닫기 전에: 도는 요청 · 앞으로 올 요청 모두 멈춘다 (되돌리지 않음) +#[no_mangle] +pub extern "C" fn jj_rhwp_cancel_all(doc: *const JjRhwpDoc) { + if !doc.is_null() { + unsafe { &*doc }.closing.store(true, Ordering::SeqCst); + } +} + +/// 요청 [req] (부르는 쪽이 정한 번호 - jj_rhwp_cancel 로 멈출 때 씀): [from, to) 쪽 (0 부터) 을 PDF 파일 out 으로. font_dirs 는 줄바꿈 (\n) 으로 나눈 글꼴 폴더들 (Android 는 시스템 글꼴을 /// 스스로 찾지 않으므로 /system/fonts 와 앱이 받은 한글 글꼴 폴더를 넘긴다). 성공 0, 실패 -1 과 code · err. /// rhwp 의 render_pages_pdf_native_with_options 와 같게 (Print 로 쪽마다 SVG → 한 PDF) 하되, 쪽마다 멈춤 표시를 본다 /// (하이퍼링크는 넣지 않는다 - 보기용) #[no_mangle] pub extern "C" fn jj_rhwp_export_pdf( doc: *const JjRhwpDoc, + req: u64, from: c_int, to: c_int, font_dirs: *const c_char, @@ -178,7 +202,16 @@ pub extern "C" fn jj_rhwp_export_pdf( return Err(Fail(JJ_RHWP_E_RANGE, "no document".into())); } let doc = unsafe { &*doc }; - doc.cancel.store(false, Ordering::SeqCst); + // 끝나면 (어떻게 끝나든) 이 요청의 멈춤 표시를 지운다 - 시작할 때는 지우지 않는다 (먼저 온 멈춤 · 닫기를 지키려고) + struct Forget<'a>(&'a JjRhwpDoc, u64); + impl Drop for Forget<'_> { + fn drop(&mut self) { + if let Ok(mut s) = self.0.cancelled.lock() { + s.remove(&self.1); + } + } + } + let _forget = Forget(doc, req); let count = doc.core.page_count() as c_int; if from < 0 || to > count || from >= to { return Err(Fail(JJ_RHWP_E_RANGE, format!("pages {from}..{to} / {count}"))); @@ -192,7 +225,7 @@ pub extern "C" fn jj_rhwp_export_pdf( .filter(|s| !s.is_empty()) .map(PathBuf::from) .collect(); - let cancelled = || doc.cancel.load(Ordering::SeqCst); + let cancelled = || doc.stopped(req); let mut svgs = Vec::with_capacity((to - from) as usize); for page in from as u32..to as u32 { if cancelled() {