JJ_MKVMaker/lib/core/hwp_source.dart

364 lines
15 KiB
Dart

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<String> removeStaleHwpWork(String tempDir, {Duration minAge = const Duration(minutes: 1), DateTime? now}) {
final before = (now ?? DateTime.now()).subtract(minAge);
final removed = <String>[];
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<HwpPdfDoc> open(String path, {String? password});
}
/// 연 문서: 쪽 수와 쪽 범위 → PDF
abstract class HwpPdfDoc {
int get pageCount;
/// [from] 부터 [to] 앞까지 (0 부터) 를 PDF 파일 [out] 으로. [fontDirs] 는 글꼴을 찾을 폴더들.
/// [timeout]: 바꾸기가 실제로 시작된 때부터 재고, 넘으면 그 요청만 멈추고 [HwpErrorKind.timeout]
Future<void> exportPdf(int from, int to, String out, {List<String> fontDirs = const [], Duration? timeout});
/// 닫는다: 도는 바꾸기는 멈추고, 줄에 선 것은 시작하지 않는다 ([HwpErrorKind.closed])
Future<void> 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<void> 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<void> _queue = Future.value();
Future<void>? _closing;
@override
Future<void> exportPdf(int from, int to, String out, {List<String> fontDirs = const [], Duration? timeout}) {
if (_closed) return Future.error(const HwpPdfException(HwpErrorKind.closed));
final req = ++_seq;
final result = Completer<void>();
_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<void> 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 }
/// 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<Object> 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<ReaderImage> render(int index, {required int maxWidth});
Future<void> dispose();
}
/// PDF 파일을 묶음으로 연다
typedef HwpChunkOpener = Future<HwpChunk> Function(String pdfPath);
/// 기본: pdfrx 로 연다
Future<HwpChunk> 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<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);
if (image == null) return const ReaderImage();
try {
return ReaderImage(image: await image.createImage());
} finally {
image.dispose();
}
}
@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, this.log});
/// 실패의 원문을 남길 곳 (작업 기록 - 파일 탐색기가 AppController.note 를 준다)
final void Function(String line)? log;
/// 문서를 연다 (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,
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<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 {
// 시간 제한은 문서 ([QueuedHwpDoc]) 가 바꾸기가 시작된 때부터 재고, 넘으면 그 요청만 멈춘다
await doc.exportPdf(from, to, out, fontDirs: fontDirs, timeout: timeout);
} 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<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;
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<void>.delayed(const Duration(milliseconds: 200))]);
}
await doc.close(); // 도는 바꾸기는 다음 쪽에서 멈추고, 줄에 선 것은 시작하지 않는다
try {
if (work.existsSync()) work.deleteSync(recursive: true);
} catch (_) {}
}
}