WIP HWP 2단계 (관리자 지시 10/20): 열어 두는 PDF 묶음은 최근 3개까지 (몇백 쪽도 메모리가 늘지 않게, 그리는 묶음은 닫지 않음, 닫은 묶음은 파일만 다시 열기) · 보기 화면의 "열 수 없습니다" 에 [다시 시도] (그 장의 기억을 지우고 다시 부름 - 모든 보기 원본) · 닫을 때 만드는 중인 묶음을 기다리지 않음 · 묶음 여는 부분을 바꿔 끼울 수 있게 (HwpChunkOpener) 해서 단위 시험 hwp_source_unit_test (PDFium 없이 4개) (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hwp2
RyuJongJin 2026-10-20 18:46:30 +09:00
parent 65098287e8
commit 34efa34193
3 changed files with 345 additions and 73 deletions

View File

@ -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<String> fontDirs;
final int chunk;
final Duration timeout;
@override
final String title;
final _chunks = <int, Future<PdfDocument>>{};
final _opened = <PdfDocument>[];
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<HwpSource> open(String path,
{required HwpPdfEngine engine,
required String tempDir,
String? password,
List<String> 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);
/// 바꾼 PDF 한 묶음 (몇 쪽). 기본은 pdfrx ([pdfChunkOpener]) - 시험은 PDFium 없이 가짜로
abstract class HwpChunk {
int get pageCount;
Future<ReaderImage> render(int index, {required int maxWidth});
Future<void> dispose();
}
@override
int get length => doc.pageCount;
@override
String pageName(int i) => '${i + 1} / $length';
/// PDF 파일을 묶음으로 연다
typedef HwpChunkOpener = Future<HwpChunk> Function(String pdfPath);
/// [k] 번째 묶음의 PDF (한 번만 만든다 - 실패하면 다음에 다시)
Future<PdfDocument> _chunk(int k) => _chunks[k] ??= _make(k).onError<Object>((e, st) {
_chunks.remove(k);
Error.throwWithStackTrace(e, st);
});
Future<PdfDocument> _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('닫힌 문서입니다'));
/// 기본: pdfrx 로 연다
Future<HwpChunk> pdfChunkOpener(String pdfPath) async {
await pdfrxFlutterInitialize();
final pdf = await PdfDocument.openFile(out);
if (_disposed) {
await pdf.dispose();
throw StateError(tr('닫힌 문서입니다'));
}
_opened.add(pdf);
return pdf;
return _PdfChunk(await PdfDocument.openFile(pdfPath));
}
class _PdfChunk implements HwpChunk {
final PdfDocument doc;
_PdfChunk(this.doc);
@override
Future<ReaderImage> 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];
int get pageCount => doc.pages.length;
@override
Future<ReaderImage> 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<void> 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<String> fontDirs;
final int chunk;
final Duration timeout;
final int maxOpenChunks;
final HwpChunkOpener opener;
@override
final String title;
/// 열어 둔 묶음 (넣은 순서 = 최근에 쓴 순서, 앞이 오래된 것)
final _open = <int, Future<HwpChunk>>{};
/// PDF 파일을 다 만든 묶음
final _made = <int>{};
/// 지금 그리고 있는 묶음 (닫지 않는다)
final _busy = <int, int>{};
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<HwpSource> open(String path,
{required HwpPdfEngine engine,
required String tempDir,
String? password,
List<String> 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<int> get openChunks => _open.keys.toList();
/// PDF 파일을 만든 묶음 번호 (시험용)
Set<int> get madeChunks => Set.unmodifiable(_made);
/// [k] 번째 묶음 (실패하면 지워서 다음에 다시)
Future<HwpChunk> _chunk(int k) {
final had = _open.remove(k);
if (had != null) {
_open[k] = had; // 최근에 쓴 것으로
return had;
}
late final Future<HwpChunk> f;
f = _openChunk(k).onError<Object>((e, st) {
if (identical(_open[k], f)) _open.remove(k);
Error.throwWithStackTrace(e, st);
});
_open[k] = f;
_evict();
return f;
}
Future<HwpChunk> _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<ReaderImage> 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<void> 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);

View File

@ -441,10 +441,23 @@ class _ReaderPageState extends State<ReaderPage> {
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<ReaderImage>(
key: ValueKey('$_version:${slot.page}:${slot.part}'),

View File

@ -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<void>? hold;
var closed = false;
_Engine(this.pages);
@override
Future<HwpPdfDoc> 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<void> exportPdf(int from, int to, String out, {List<String> 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<void> close() async => e.closed = true;
}
/// 파일을 읽어 쪽 수를 아는 가짜 묶음 (PDFium 없이) - 연 것 · 닫은 것을 적어 둔다
class _Opener {
final opened = <String>[];
final disposed = <String>[];
Completer<void>? renderHold;
Future<HwpChunk> 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<ReaderImage> 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<void> 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<void> settle() => Future<void>.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<void>();
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<void>();
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<HwpPdfException>());
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<void>();
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<Object?>((_) => null, onError: (Object x) => x);
await settle();
await s.dispose(); // 기다리지 않고 바로 끝난다
e.hold!.complete();
final r = await pending;
expect(r, isA<StateError>(), reason: '닫힌 문서');
expect(o.opened, isEmpty, reason: '닫은 뒤에는 PDF 를 열지 않음');
});
}