WIP HWP 고침 (관리자 지시 10/20, 28 검토): ① 앱 시작의 HWP 작업 폴더 정리를 기다리지 않음 (queue_test 가 가짜 시간 안에서 10분 걸리던 회귀) ② 시간 제한은 바꾸기가 실제로 시작된 때부터 · 멈춤은 요청마다 (QueuedHwpDoc 로 줄 세우기 · 시간 · 멈춤을 FFI 에서 떼어 시험할 수 있게), Rust ABI 3: export 에 요청 번호 · jj_rhwp_cancel(번호) · jj_rhwp_cancel_all (시작할 때 깃발을 지우지 않음), 닫으면 줄에 선 것은 시작하지 않음 · check_apk 필요 함수 8개 ③ 진행 창 close 는 그 창이 맨 위일 때만 · 시험: 줄 세우기 3개 · ABI 판 3 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>hwp2
parent
76fd2f3cbf
commit
7bc452e54c
|
|
@ -23,28 +23,38 @@ class _FakeEngine implements HwpPdfEngine {
|
|||
@override
|
||||
Future<HwpPdfDoc> 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<void> exportPdf(int from, int to, String out, {List<String> 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 = <PdfDocument>[];
|
||||
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<void> close() async => closed = true;
|
||||
}
|
||||
|
|
@ -124,7 +132,7 @@ void main() {
|
|||
}));
|
||||
expect(err2, isA<HwpPdfException>());
|
||||
expect('$err2', contains('1~3'));
|
||||
slow.hold!.complete();
|
||||
expect(slow.hold!.isCompleted, isTrue, reason: '시간 제한이 변환을 멈춤 (기다림만 끊지 않음)');
|
||||
await tester.runAsync(s2!.dispose);
|
||||
|
||||
// 열기 실패 (암호 등) 는 그대로 알린다 - 파일 탐색기는 다른 앱으로 연다
|
||||
|
|
|
|||
|
|
@ -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 만들기를 사용할 수 없습니다.'));
|
||||
|
|
|
|||
|
|
@ -41,15 +41,93 @@ abstract class HwpPdfEngine {
|
|||
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 []});
|
||||
/// [from] 부터 [to] 앞까지 (0 부터) 를 PDF 파일 [out] 으로. [fontDirs] 는 글꼴을 찾을 폴더들.
|
||||
/// [timeout]: 바꾸기가 실제로 시작된 때부터 재고, 넘으면 그 요청만 멈추고 [HwpErrorKind.timeout]
|
||||
Future<void> exportPdf(int from, int to, String out, {List<String> fontDirs = const [], Duration? timeout});
|
||||
|
||||
/// 지금 바꾸는 것을 멈춘다 (시간 제한을 넘겼을 때 · 닫을 때 - 관리자 지시 10/20: 기다림만 끊으면 뒤에서 계속 돌아
|
||||
/// 다음 묶음이 줄을 서서 연달아 실패했다). 멈춘 바꾸기는 [HwpErrorKind.cancelled] 로 끝난다
|
||||
void cancel();
|
||||
/// 닫는다: 도는 바꾸기는 멈추고, 줄에 선 것은 시작하지 않는다 ([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 }
|
||||
|
|
@ -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<void>.delayed(const Duration(milliseconds: 200))]);
|
||||
}
|
||||
doc.cancel(); // 바꾸는 중이면 다음 쪽에서 멈춘다
|
||||
await doc.close(); // 멈춘 바꾸기가 끝난 뒤에 닫힌다 (엔진이 한 번에 하나씩)
|
||||
await doc.close(); // 도는 바꾸기는 다음 쪽에서 멈추고, 줄에 선 것은 시작하지 않는다
|
||||
try {
|
||||
if (work.existsSync()) work.deleteSync(recursive: true);
|
||||
} catch (_) {}
|
||||
|
|
|
|||
|
|
@ -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<void> _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<T> _serial<T>(Future<T> 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<void> exportPdf(int from, int to, String out, {List<String> 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<void> close() => _serial(() async {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
_close(_handle);
|
||||
});
|
||||
Future<void> close() async {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
_close(_handle);
|
||||
}
|
||||
}
|
||||
|
||||
// ───────── C 함수 (isolate 안에서 부른다 - 핸들은 주소 숫자로 넘긴다) ─────────
|
||||
|
|
@ -101,8 +97,10 @@ class _RhwpDoc implements HwpPdfDoc {
|
|||
typedef _OpenC = Pointer<Void> Function(Pointer<Utf8>, Pointer<Utf8>, Pointer<Int32>, Pointer<Pointer<Utf8>>);
|
||||
typedef _CountC = Int32 Function(Pointer<Void>);
|
||||
typedef _CountD = int Function(Pointer<Void>);
|
||||
typedef _ExportC = Int32 Function(Pointer<Void>, Int32, Int32, Pointer<Utf8>, Pointer<Utf8>, Pointer<Int32>, Pointer<Pointer<Utf8>>);
|
||||
typedef _ExportD = int Function(Pointer<Void>, int, int, Pointer<Utf8>, Pointer<Utf8>, Pointer<Int32>, Pointer<Pointer<Utf8>>);
|
||||
typedef _ExportC = Int32 Function(Pointer<Void>, Uint64, Int32, Int32, Pointer<Utf8>, Pointer<Utf8>, Pointer<Int32>, Pointer<Pointer<Utf8>>);
|
||||
typedef _ExportD = int Function(Pointer<Void>, int, int, int, Pointer<Utf8>, Pointer<Utf8>, Pointer<Int32>, Pointer<Pointer<Utf8>>);
|
||||
typedef _CancelC = Void Function(Pointer<Void>, Uint64);
|
||||
typedef _CancelD = void Function(Pointer<Void>, int);
|
||||
typedef _VoidC = Void Function(Pointer<Void>);
|
||||
typedef _VoidD = void Function(Pointer<Void>);
|
||||
typedef _FreeC = Void Function(Pointer<Utf8>);
|
||||
|
|
@ -138,14 +136,14 @@ String? _takeError(DynamicLibrary lib, Pointer<Pointer<Utf8>> err) {
|
|||
|
||||
int _pageCount(int handle) => _lib().lookupFunction<_CountC, _CountD>('jj_rhwp_page_count')(Pointer<Void>.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<Int32>();
|
||||
final err = calloc<Pointer<Utf8>>();
|
||||
try {
|
||||
final r = export(Pointer<Void>.fromAddress(handle), from, to, d, o, code, err);
|
||||
final r = export(Pointer<Void>.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<Void>.fromAddress(handle));
|
||||
void _cancel(int handle, int req) =>
|
||||
_lib().lookupFunction<_CancelC, _CancelD>('jj_rhwp_cancel')(Pointer<Void>.fromAddress(handle), req);
|
||||
|
||||
void _cancelAll(int handle) => _lib().lookupFunction<_VoidC, _VoidD>('jj_rhwp_cancel_all')(Pointer<Void>.fromAddress(handle));
|
||||
|
||||
void _close(int handle) => _lib().lookupFunction<_VoidC, _VoidD>('jj_rhwp_close')(Pointer<Void>.fromAddress(handle));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 에 모두', () {
|
||||
|
|
|
|||
|
|
@ -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<void>? hold;
|
||||
var closed = false;
|
||||
var cancels = 0;
|
||||
final cancelled = <int>{};
|
||||
var cancelledAll = false;
|
||||
_Engine(this.pages);
|
||||
|
||||
@override
|
||||
Future<HwpPdfDoc> open(String path, {String? password}) async => _Doc(this);
|
||||
Future<HwpPdfDoc> 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<void> exportPdf(int from, int to, String out, {List<String> 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<void> close() async => e.closed = true;
|
||||
|
|
@ -151,10 +171,9 @@ void main() {
|
|||
expect(err, isA<HwpPdfException>());
|
||||
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<Object?>((_) => 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<StateError>(), 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<void>.delayed(const Duration(milliseconds: 700)); // 뒤 묶음의 제한보다 오래 줄에서 기다림
|
||||
expect(n.started, [1]);
|
||||
n.release(1);
|
||||
await a;
|
||||
await Future<void>.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<HwpPdfException>().having((e) => e.kind, 'kind', HwpErrorKind.timeout));
|
||||
expect(n.cancelled, {1}, reason: '다른 요청 (2) 은 멈추지 않음');
|
||||
await Future<void>.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<Object?>((_) => null, onError: (Object e) => e);
|
||||
final b = d.exportPdf(5, 10, 'b').then<Object?>((_) => null, onError: (Object e) => e);
|
||||
await Future<void>.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<HwpPdfException>().having((e) => e.kind, 'kind', HwpErrorKind.closed));
|
||||
expect(await b, isA<HwpPdfException>().having((e) => e.kind, 'kind', HwpErrorKind.closed));
|
||||
expect(() => d.exportPdf(0, 1, 'c'), throwsA(isA<HwpPdfException>()), reason: '닫은 뒤의 요청');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 요청마다 문이 있는 가짜 C 함수 - 멈춤 · 닫기를 받으면 문이 열리고 (cancelled) 로 끝난다
|
||||
class _Gated implements HwpNative {
|
||||
final started = <int>[];
|
||||
final gates = <int, Completer<void>>{};
|
||||
final cancelled = <int>{};
|
||||
var all = false;
|
||||
var closed = false;
|
||||
@override
|
||||
int get pageCount => 10;
|
||||
|
||||
Completer<void> _gate(int req) => gates.putIfAbsent(req, Completer<void>.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<void> close() async => closed = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)개 있음" }
|
||||
|
|
|
|||
|
|
@ -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<HashSet<u64>>,
|
||||
/// 닫는 중 - 모든 요청을 멈춘다 (되돌리지 않는다)
|
||||
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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue