222 lines
8.0 KiB
Dart
222 lines
8.0 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'];
|
|
|
|
/// 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] 는 글꼴을 찾을 폴더들
|
|
Future<void> exportPdf(int from, int to, String out, {List<String> fontDirs = const []});
|
|
Future<void> close();
|
|
}
|
|
|
|
class HwpPdfException implements Exception {
|
|
final String message;
|
|
const HwpPdfException(this.message);
|
|
@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});
|
|
|
|
/// 문서를 연다 (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;
|
|
final open = [..._open.values];
|
|
_open.clear();
|
|
// 만드는 중인 묶음은 기다리지 않는다 (몇 분 걸릴 수 있음) - 끝나면 닫힌다 ([_openChunk] 가 _disposed 를 본다)
|
|
for (final f in open) {
|
|
unawaited(f.then((c) => c.dispose(), onError: (Object _) {}));
|
|
}
|
|
await doc.close(); // 바꾸는 중이면 그것이 끝난 뒤에 닫힌다 (엔진이 한 번에 하나씩)
|
|
try {
|
|
if (work.existsSync()) work.deleteSync(recursive: true);
|
|
} catch (_) {}
|
|
}
|
|
}
|