From 34efa34193f279a62a8241cf10738273463fcfeb Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:46:30 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20HWP=202=EB=8B=A8=EA=B3=84=20(=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EC=A7=80=EC=8B=9C=2010/20):=20=EC=97=B4?= =?UTF-8?q?=EC=96=B4=20=EB=91=90=EB=8A=94=20PDF=20=EB=AC=B6=EC=9D=8C?= =?UTF-8?q?=EC=9D=80=20=EC=B5=9C=EA=B7=BC=203=EA=B0=9C=EA=B9=8C=EC=A7=80?= =?UTF-8?q?=20(=EB=AA=87=EB=B0=B1=20=EC=AA=BD=EB=8F=84=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=EB=A6=AC=EA=B0=80=20=EB=8A=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C,=20=EA=B7=B8=EB=A6=AC=EB=8A=94=20=EB=AC=B6=EC=9D=8C?= =?UTF-8?q?=EC=9D=80=20=EB=8B=AB=EC=A7=80=20=EC=95=8A=EC=9D=8C,=20?= =?UTF-8?q?=EB=8B=AB=EC=9D=80=20=EB=AC=B6=EC=9D=8C=EC=9D=80=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=A7=8C=20=EB=8B=A4=EC=8B=9C=20=EC=97=B4=EA=B8=B0)?= =?UTF-8?q?=20=C2=B7=20=EB=B3=B4=EA=B8=B0=20=ED=99=94=EB=A9=B4=EC=9D=98=20?= =?UTF-8?q?"=EC=97=B4=20=EC=88=98=20=EC=97=86=EC=8A=B5=EB=8B=88=EB=8B=A4"?= =?UTF-8?q?=20=EC=97=90=20[=EB=8B=A4=EC=8B=9C=20=EC=8B=9C=EB=8F=84]=20(?= =?UTF-8?q?=EA=B7=B8=20=EC=9E=A5=EC=9D=98=20=EA=B8=B0=EC=96=B5=EC=9D=84=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B0=EA=B3=A0=20=EB=8B=A4=EC=8B=9C=20=EB=B6=80?= =?UTF-8?q?=EB=A6=84=20-=20=EB=AA=A8=EB=93=A0=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B8)=20=C2=B7=20=EB=8B=AB=EC=9D=84=20=EB=95=8C?= =?UTF-8?q?=20=EB=A7=8C=EB=93=9C=EB=8A=94=20=EC=A4=91=EC=9D=B8=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=EC=9D=84=20=EA=B8=B0=EB=8B=A4=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=9D=8C=20=C2=B7=20=EB=AC=B6=EC=9D=8C=20=EC=97=AC?= =?UTF-8?q?=EB=8A=94=20=EB=B6=80=EB=B6=84=EC=9D=84=20=EB=B0=94=EA=BF=94=20?= =?UTF-8?q?=EB=81=BC=EC=9A=B8=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20(HwpChunk?= =?UTF-8?q?Opener)=20=ED=95=B4=EC=84=9C=20=EB=8B=A8=EC=9C=84=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20hwp=5Fsource=5Funit=5Ftest=20(PDFium=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=204=EA=B0=9C)=20(=EA=B2=80=ED=86=A0=20=EB=8C=80?= =?UTF-8?q?=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 --- lib/core/hwp_source.dart | 232 +++++++++++++++++++++++---------- lib/ui/reader_page.dart | 17 ++- test/hwp_source_unit_test.dart | 169 ++++++++++++++++++++++++ 3 files changed, 345 insertions(+), 73 deletions(-) create mode 100644 test/hwp_source_unit_test.dart diff --git a/lib/core/hwp_source.dart b/lib/core/hwp_source.dart index 8b5c0cf..f623a7e 100644 --- a/lib/core/hwp_source.dart +++ b/lib/core/hwp_source.dart @@ -34,77 +34,31 @@ class HwpPdfException implements Exception { String toString() => message; } -/// 관리자 지시 (10/20): HWP · HWPX 를 보기 화면으로 - 큰 문서 (편람 383쪽은 PC 에서도 14분) 를 통째로 바꾸지 않고, -/// 보는 쪽이 든 [chunk] 쪽 묶음만 PDF 로 바꿔 보이면서 다음 묶음을 뒤에서 미리 만든다. 묶음마다 [timeout], -/// 닫으면 (dispose) 더 만들지 않는다. 바꾸는 중의 쪽은 보기 화면이 기다리는 표시를 보이고, 실패한 묶음은 그 쪽에 이유가 보인다. -class HwpSource extends ReaderSource { - final String path; - final HwpPdfDoc doc; - final Directory work; - final List fontDirs; - final int chunk; - final Duration timeout; +/// 바꾼 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 - final String title; - - final _chunks = >{}; - final _opened = []; - var _disposed = false; - - HwpSource._(this.path, this.doc, this.work, this.title, {required this.fontDirs, required this.chunk, required this.timeout}); - - /// 문서를 연다 (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)}) async { - final local = await vLocalCopy(path, tempDir); - final doc = await engine.open(local, password: password); - 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); - } + int get pageCount => doc.pages.length; @override - int get length => doc.pageCount; - @override - String pageName(int i) => '${i + 1} / $length'; - - /// [k] 번째 묶음의 PDF (한 번만 만든다 - 실패하면 다음에 다시) - Future _chunk(int k) => _chunks[k] ??= _make(k).onError((e, st) { - _chunks.remove(k); - Error.throwWithStackTrace(e, st); - }); - - Future _make(int k) async { - if (_disposed) throw StateError(tr('닫힌 문서입니다')); - final from = k * chunk, to = math.min(from + chunk, length); - final out = p.join(work.path, 'p$k.pdf'); - try { - await doc.exportPdf(from, to, out, fontDirs: fontDirs).timeout(timeout); - } on TimeoutException { - throw HwpPdfException(trf('{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다', [from + 1, to, timeout.inSeconds])); - } - if (_disposed) throw StateError(tr('닫힌 문서입니다')); - await pdfrxFlutterInitialize(); - final pdf = await PdfDocument.openFile(out); - if (_disposed) { - await pdf.dispose(); - throw StateError(tr('닫힌 문서입니다')); - } - _opened.add(pdf); - return pdf; - } - - @override - Future load(int i, {required int maxWidth}) async { - final k = i ~/ chunk; - final pdf = await _chunk(k); - // 다음 묶음을 미리 (넘겨 볼 때 기다리지 않게) - 실패해도 여기서는 알리지 않는다 (그 쪽을 볼 때 보임) - if ((k + 1) * chunk < length) unawaited(_chunk(k + 1).then((_) {}, onError: (Object _) {})); - final page = pdf.pages[i - k * chunk]; + 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); @@ -116,13 +70,149 @@ class HwpSource extends ReaderSource { } } + @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}); + + /// 문서를 연다 (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}) async { + final local = await vLocalCopy(path, tempDir); + final doc = await engine.open(local, password: password); + 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); + } + + @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 { + throw HwpPdfException(trf('{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다', [from + 1, to, timeout.inSeconds])); + } catch (_) { + // 닫은 뒤에 끝난 변환은 (임시 폴더를 이미 지워) 실패한다 - 그 이유 대신 "닫힌 문서" + if (_disposed) throw StateError(tr('닫힌 문서입니다')); + 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; - for (final d in _opened) { - await d.dispose(); + final open = [..._open.values]; + _open.clear(); + // 만드는 중인 묶음은 기다리지 않는다 (몇 분 걸릴 수 있음) - 끝나면 닫힌다 ([_openChunk] 가 _disposed 를 본다) + for (final f in open) { + unawaited(f.then((c) => c.dispose(), onError: (Object _) {})); } - _opened.clear(); await doc.close(); // 바꾸는 중이면 그것이 끝난 뒤에 닫힌다 (엔진이 한 번에 하나씩) try { if (work.existsSync()) work.deleteSync(recursive: true); diff --git a/lib/ui/reader_page.dart b/lib/ui/reader_page.dart index 71c12c0..f21251d 100644 --- a/lib/ui/reader_page.dart +++ b/lib/ui/reader_page.dart @@ -441,10 +441,23 @@ class _ReaderPageState extends State { final fit = c.settings.readerFit; final slot = _v[i]; final half = slot.part >= 0; + // 관리자 지시 (10/20, HWP 2단계): 이유와 [다시 시도] - 그 장의 기억을 지우고 다시 부른다 (시간 제한을 넘긴 HWP 묶음 등) Widget failed(Object? e) => SizedBox( width: 320, - height: 120, - child: Center(child: Text(trf('열 수 없습니다: {0}', [e]), style: const TextStyle(color: Colors.white70)))); + height: 160, + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Text(trf('열 수 없습니다: {0}', [e]), textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70)), + const SizedBox(height: 8), + OutlinedButton( + onPressed: () => setState(() { + _cache.remove(slot.page); + _cacheHalf.remove(slot.page); + _sharp.remove(i); + _sharpWidth.remove(i); + }), + child: Text(tr('다시 시도')), + ), + ])); // 확대해 다시 그린 것이 있으면 그것 (다 그릴 때까지는 앞의 그림을 그대로 보인다) final page = FutureBuilder( key: ValueKey('$_version:${slot.page}:${slot.part}'), diff --git a/test/hwp_source_unit_test.dart b/test/hwp_source_unit_test.dart new file mode 100644 index 0000000..b137ecf --- /dev/null +++ b/test/hwp_source_unit_test.dart @@ -0,0 +1,169 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +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 대신) - 부른 범위를 적어 둔다 +class _Engine implements HwpPdfEngine { + final int pages; + final calls = <(int, int)>[]; + Completer? hold; + var closed = false; + _Engine(this.pages); + + @override + Future open(String path, {String? password}) async => _Doc(this); +} + +class _Doc implements HwpPdfDoc { + final _Engine e; + _Doc(this.e); + @override + int get pageCount => e.pages; + + @override + Future exportPdf(int from, int to, String out, {List fontDirs = const []}) async { + e.calls.add((from, to)); + final h = e.hold; + if (h != null) await h.future; + File(out).writeAsStringSync('$from-$to'); + } + + @override + Future close() async => e.closed = true; +} + +/// 파일을 읽어 쪽 수를 아는 가짜 묶음 (PDFium 없이) - 연 것 · 닫은 것을 적어 둔다 +class _Opener { + final opened = []; + final disposed = []; + Completer? renderHold; + + Future call(String path) async { + opened.add(p.basename(path)); + final parts = File(path).readAsStringSync().split('-').map(int.parse).toList(); + return _Chunk(this, p.basename(path), parts[1] - parts[0]); + } +} + +class _Chunk implements HwpChunk { + final _Opener o; + final String name; + @override + final int pageCount; + _Chunk(this.o, this.name, this.pageCount); + + @override + Future render(int index, {required int maxWidth}) async { + expect(index, lessThan(pageCount)); + final h = o.renderHold; + if (h != null) await h.future; + return const ReaderImage(); + } + + @override + Future dispose() async => o.disposed.add(name); +} + +/// 관리자 지시 (10/20, HWP 2단계): 보기 원본 HwpSource 의 흐름 - 묶음 나누기 · 미리 만들기 · 열어 두는 묶음 상한 (메모리) · +/// 그리는 묶음은 닫지 않음 · 닫은 묶음은 다시 바꾸지 않고 파일만 · 시간 제한 → 이유 · 다시 시도 · 닫기 +void main() { + late Directory dir; + late File doc; + setUp(() { + dir = Directory.systemTemp.createTempSync('jj_hwpunit_'); + doc = File(p.join(dir.path, 'a.hwp'))..writeAsStringSync('x'); + }); + tearDown(() { + if (dir.existsSync()) dir.deleteSync(recursive: true); + }); + + Future settle() => Future.delayed(const Duration(milliseconds: 50)); + + test('보는 쪽의 묶음 + 다음 묶음만 · 열어 둔 묶음은 3개까지 (오래된 것부터 닫음) · 다시 보면 파일만 연다', () async { + final e = _Engine(40), o = _Opener(); + final s = await HwpSource.open(doc.path, engine: e, tempDir: dir.path, chunk: 5, maxOpenChunks: 3, opener: o.call); + expect(s.length, 40); + await s.load(0, maxWidth: 100); + await settle(); + expect(e.calls, [(0, 5), (5, 10)]); + expect(s.openChunks, [0, 1]); + await s.load(12, maxWidth: 100); // 묶음 2 + 미리 3 → 4개 → 0 을 닫음 + await settle(); + expect(e.calls, [(0, 5), (5, 10), (10, 15), (15, 20)]); + expect(s.openChunks, [1, 2, 3]); + expect(o.disposed, ['p0.pdf']); + // 닫은 묶음 0 을 다시: 바꾸지 않고 (calls 그대로) 파일만 연다 + await s.load(1, maxWidth: 100); + await settle(); + expect(e.calls.where((c) => c == (0, 5)), hasLength(1)); + expect(o.opened.where((n) => n == 'p0.pdf'), hasLength(2)); + expect(s.openChunks.length, lessThanOrEqualTo(3)); + expect(s.madeChunks, containsAll([0, 1, 2, 3])); + // 끝 묶음은 남은 쪽만 + await s.load(39, maxWidth: 100); + expect(e.calls.last, (35, 40)); + await s.dispose(); + expect(e.closed, isTrue); + expect(s.work.existsSync(), isFalse, reason: '닫으면 만든 PDF 들을 지움'); + }); + + test('그리고 있는 묶음은 상한을 넘어도 닫지 않고, 다 그린 뒤에 닫는다', () async { + final e = _Engine(40), o = _Opener(); + final s = await HwpSource.open(doc.path, engine: e, tempDir: dir.path, chunk: 5, maxOpenChunks: 2, opener: o.call); + final hold = Completer(); + o.renderHold = hold; + final drawing = s.load(2, maxWidth: 100); // 묶음 0 을 그리는 중 + await settle(); + o.renderHold = null; + await s.load(20, maxWidth: 100); // 묶음 4 (+ 5) + await s.load(30, maxWidth: 100); // 묶음 6 (+ 7) + await settle(); + expect(o.disposed, isNot(contains('p0.pdf')), reason: '그리는 중'); + expect(s.openChunks, contains(0)); + hold.complete(); + await drawing; + await s.load(35, maxWidth: 100); + await settle(); + expect(o.disposed, contains('p0.pdf'), reason: '다 그린 뒤에는 상한에 맞춰 닫힘'); + expect(s.openChunks.length, lessThanOrEqualTo(2)); + await s.dispose(); + }); + + test('시간 제한을 넘기면 쪽 범위 · 초가 든 이유, 다시 부르면 다시 만든다', () async { + final e = _Engine(8)..hold = Completer(); + final o = _Opener(); + final s = await HwpSource.open(doc.path, engine: e, tempDir: dir.path, chunk: 5, timeout: const Duration(milliseconds: 200), + opener: o.call); + Object? err; + try { + await s.load(0, maxWidth: 100); + } catch (x) { + err = x; + } + expect(err, isA()); + expect('$err', allOf(contains('1~5'), contains('0초'))); + expect(s.openChunks, isNot(contains(0)), reason: '실패한 묶음은 기억하지 않음 - [다시 시도] 가 다시 만든다'); + e.hold!.complete(); + e.hold = null; + await s.load(0, maxWidth: 100); + expect(e.calls.where((c) => c == (0, 5)).length, greaterThanOrEqualTo(2)); + await s.dispose(); + }); + + test('닫으면 만드는 중인 묶음은 끝난 뒤 스스로 닫히고 더 만들지 않는다', () async { + final e = _Engine(10)..hold = Completer(); + final o = _Opener(); + 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(); // 기다리지 않고 바로 끝난다 + e.hold!.complete(); + final r = await pending; + expect(r, isA(), reason: '닫힌 문서'); + expect(o.opened, isEmpty, reason: '닫은 뒤에는 PDF 를 열지 않음'); + }); +}