Compare commits

...

9 Commits

Author SHA1 Message Date
RyuJongJin 56c632d372 next10: hwp_pdf (ced0ae9) + rsync_narrow (dfed3fa: 412 Rsync 화면 오른쪽 창 · 옵션 이름 줄바꿈) - 시험판 986
합침 (d3): copy_sync_settings 의 import 한 줄 (optionProblemText · keepOptionsTogether 둘 다).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:54:41 +09:00
RyuJongJin ced0ae9e5d WIP Android HWP · HWPX 보기 1단계 (관리자 지시 10/20): rhwp v0.8.7 (MIT, 커밋 1a76570 고정) 를 얇은 C ABI (tool/rhwp_ffi) 와 함께 NDK 로 빌드 (tool/build_rhwp_android.sh - git sparse 받기 · Rust 1.93.1 · Cargo.lock --locked · 16KB 정렬 · 경로 /jj 로 바꿈) → APK 의 libjj_rhwp.so 를 dart:ffi 로 · 보기 화면 HwpSource: 보는 쪽의 5쪽 묶음만 PDF 로 바꾸고 다음 묶음을 미리, 묶음마다 3분 제한, 실패한 묶음은 그 쪽에 이유 · 다시 보면 다시 · 닫으면 멈춤 · 파일 탐색기: 보기 컴포넌트를 켰을 때 앱 안에서, 열지 못하면 이유를 알리고 다른 앱으로 · 문서의 가로 쪽은 자동으로 반 나누지 않음 (내 판단) · 라이선스 화면 · Android 고지 · 시험 hwp_source_test (가짜 엔진, Windows 통합) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:43:20 +09:00
RyuJongJin 4b450e2e5c WIP 시험: Tor · CEF · DNS 실제 연결 시험의 데이터 폴더는 heldByApp (tor · WebView 가 앱이 끝날 때까지 잡아 %TEMP% 에 jj_tor_real_ 이 남았음 - test_temp_check 가 실행 폴더와 함께 지움, 관리자 지시 10/20) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:39:17 +09:00
RyuJongJin f947935c19 next9: + msys_args 끝 (634fcbc · 849c830: 목록 · 기록 · 배치 파일의 상대 경로 막기) - 시험판 985
합침 (d3): 상대 경로 막기를 optionProblems 의 새 종류 relativePath 로 (안내 끝 문구 "드라이브 (C:) 나 / 로 시작하는 경로로", 내 판단).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:14:23 +09:00
RyuJongJin 849c83031d 기록 · 배치 파일 (--log-file · --write-batch · --read-batch · --only-write-batch) 의 상대 경로도 막음 (검토 대기)
관리자 지시 (10/20, 28 확인): 상대 경로면 응답 파일 임시 폴더에 생겼다가 함께 지워져 사용자가 기록을 찾지 못함 - "기록 · 배치 파일은 전체 경로로 적어 주세요". --backup-dir · --partial-dir · --temp-dir · --compare-dest 류는 받는 쪽 기준이라 그대로 (시험에 둠). 번역 en · ja · zh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:10:55 +09:00
RyuJongJin 634fcbc920 목록 파일 (--exclude-from · --include-from · --files-from · ". 파일" · merge) 의 상대 경로는 막음 - "목록 파일은 전체 경로로 적어 주세요" (검토 대기)
관리자 판단 (10/20, 7160577 에 이어): rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의 작업 폴더
기준으로 상대 경로를 읽어 서로 다른 목록 파일을 볼 수 있다. unreadableOptions 가 막는다 (옵션 칸 · 확인 창 · 모니터링 빨갛게, 실행 ·
실시간 동기화 막음).
- 대상: --exclude-from · --include-from · --files-from (= 와 다음 낱말), --filter / -f (붙여 · 다음 낱말 · 짧은 묶음 -avf) 의
  ". 파일" · "merge[,고치개] 파일". 전체 경로 = C:\ · C:/ · \서버 · / (/cygdrive/… · Android).
- 빼는 것: ": 이름" · dir-merge · -F (폴더마다 읽는 규칙 파일 이름 - 원래 상대), --exclude 의 값 (패턴).
시험: rsync_msys_args_test (막는 것 10 · 막지 않는 것 11). dismiss_test · live_sync_scene_test 의 merge 규칙을 전체 경로로.
거르기 · 맞추기 · 실시간 · 복사 시험 25개 파일 382개 통과.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:03:29 +09:00
RyuJongJin bd0801ce66 next9: + msys_args (7160577: Windows rsync 인수를 응답 파일로 · MSYS=noglob - ' 가 인수를 합쳐 거르기가 사라지던 것) - 시험판 985
합침 (d3): unreadableOptions 는 optionProblems 를 거친다 (b1a43c3 의 종류별 안내). 15 의 windows 매개변수 · 큰따옴표 막기를
optionProblems 로 옮기고, 안내는 새 종류 doubleQuote "큰따옴표 (\") 를 빼 주세요" (내 판단).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:59:12 +09:00
RyuJongJin 716057734a P0 Windows rsync: 인수를 응답 파일 (@) 로 넘기고 MSYS=noglob - ' 짝짓기 · 글로브 펼치기로 거르기가 사라져 지킬 파일이 지워지던 것 (검토 대기)
관리자 지시 (10/20, 28 재현 - 데이터, 정식 판에도 있음): Windows 의 rsync (MSYS2 런타임, 3.5.1) 는 Windows 프로그램이 넘긴
명령 줄을 스스로 다시 읽는다. 앱의 인수는 맞는데:
- ' 를 따옴표로 짝지어 인수를 합침: -a --delete --exclude="it's.txt" --exclude="Bob's.txt" 가 한 인수가 되어 거르기가 사라지고
  it's.txt · Bob's.txt 가 지워짐 (미리 보기는 "남음"). ' 하나면 rsync 가 실패, ' 가 든 경로 (한쪽 · 양쪽) 는 합쳐져 실패.
- * ? [ ] { } 를 작업 폴더의 파일로 펼침: --exclude *.log 가 작업 폴더의 x.log y.log 로 바뀌어 거르기가 빗나감.
실험 (실제 rsync): MSYS=noglob 은 글로브만 막고 ' 짝짓기는 그대로 (CYGWIN=noglob 은 소용없음). Dart 는 빈칸 · 큰따옴표가 없는
인수를 따옴표로 감싸지 않고, 감쌀 때의 \" 를 MSYS 는 다르게 읽어 명령 줄로는 고칠 수 없음. 응답 파일에 인수마다 큰따옴표로 감싸
넣고 (안의 \ 는 그대로) "@args.txt" 로 넘기면 ' · * ? [ ] { } · 빈칸 · 한글 · \ · @ 가 모두 그대로 감. 응답 파일 경로에 빈칸 · ' 가
있으면 @ 가 먹히지 않아 그 폴더를 작업 폴더로 하고 상대 이름으로 넘김.
- lib/core/rsync_launch.dart: prepareRsyncLaunch (Windows 만 - Android 는 그대로) · 끝나면 지움. 기존 MSYS 값에 noglob 을 더함.
- 띄우는 곳 둘 다: TransferJob (복사 · Rsync 화면 · 모니터링 [실행] · 최종 정리 - robocopy 는 그대로) · LiveSync (실시간 동기화).
  미리 보기는 rsync 를 쓰지 않는다.
- 큰따옴표 (") 가 든 인수는 응답 파일로도 그대로 못 넘김 (Windows 파일 이름에는 없음): 옵션 칸 · 확인 창 · 모니터링에 빨갛게
  (unreadableOptions, Windows 만) · 띄울 때도 막고 이유를 보임.
- 알아 둘 것: rsync 의 작업 폴더가 응답 파일 폴더가 되어, 옵션의 상대 경로 (--exclude-from=목록.txt) 는 그 폴더 기준 (예전에도
  앱의 작업 폴더라 정해지지 않았음 - 절대 경로를 쓰는 것이 맞음).
시험 rsync_msys_args_test (실제 rsync): ' 두 개 · 하나의 --exclude, ' 가 든 원본 경로 · 원본 · 대상 경로 · 경로 + exclude, 따로 쓴 *
· ? · [ ] · { }, [ ] · { } · 빈칸 · 한글 경로, 실시간 동기화 쌍, 응답 파일 지우기, 도우미 · 큰따옴표 막기. 고치기 전에는 7개 실패.
거르기 · 맞추기 · 실시간 · 복사 시험 25개 파일 · rsync_page_test (Windows 통합) 통과.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:52:26 +09:00
RyuJongJin b1a43c36c5 WIP 옵션 칸 보충 (관리자 지시 10/20, 28 지적): --cc · --zc · --zl · --zt 등 값 받는 별칭 (오탐) · -–delete 처럼 하이픈과 대시가 섞인 것도 고침 · 안내 끝 문구를 문제 종류에 맞게 (따옴표 닫기 · - 로 시작 · 값 적기 · 곧은 글자로) · 옵션 낱말은 줄바꿈 없이 · 캡처 시험 windows_option_problem_shot_test (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:46:57 +09:00
33 changed files with 3438 additions and 36 deletions

View File

@ -67,9 +67,12 @@ android {
sourceSets["main"].jniLibs.srcDir("../../third_party/tor/android")
// 86: 7-Zip 25.01 (lib7zz.so 라는 이름의 실행 파일 - 7z · RAR 풀기): tool/build_7zz_android.sh (공식 소스, SHA-256 고정)
sourceSets["main"].jniLibs.srcDir("../../third_party/7zip/android")
// 관리자 지시 (10/20): HWP · HWPX → PDF (libjj_rhwp.so - dart:ffi 로 읽는 라이브러리): tool/build_rhwp_android.sh (rhwp v0.8.7, 커밋 고정)
sourceSets["main"].jniLibs.srcDir("../../third_party/rhwp/android")
// 195: third_party 의 실행 파일은 third_party_libs.txt 에 적은 것만 (공유 폴더에 다른 판이 있어도 섞이지 않게)
val thirdPartyLibs = file("third_party_libs.txt").readLines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }.toSet()
listOf("../../third_party/rsync/android", "../../third_party/sdcpp/android", "../../third_party/tor/android", "../../third_party/7zip/android").forEach { dir ->
listOf("../../third_party/rsync/android", "../../third_party/sdcpp/android", "../../third_party/tor/android", "../../third_party/7zip/android",
"../../third_party/rhwp/android").forEach { dir ->
fileTree(dir).matching { include("**/*.so") }.forEach { so ->
if (so.name !in thirdPartyLibs) packaging.jniLibs.excludes += "**/${so.name}"
}

View File

@ -7,3 +7,4 @@ libsdcli.so
libsdcli_v80.so
libtor.so
lib7zz.so
libjj_rhwp.so

View File

@ -545,6 +545,7 @@
"{0}: 작업 중이라 옮기지 않았습니다": "{0}: not moved because it is being processed",
"{0}: 저장하지 않았습니다": "{0}: not saved",
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: moves the {1} checked videos to {2}",
"{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다": "Couldn't convert pages {0}-{1} within {2} s",
"{0}개": "{0}",
"{0}개 ZIP 으로 묶기": "Compress {0} items to ZIP",
"{0}개 · {1}": "{0} · {1}",
@ -657,6 +658,7 @@
"감지한 언어: {0} (자동 감지)": "Detected language: {0} (auto-detect)",
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "Detected language: {0} - if it is wrong, make them again with another language",
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "Downloaded only if the detected language differs from the subtitle language {0}",
"값을 적어 주세요 (예: --exclude=*.tmp)": "Add a value (e.g. --exclude=*.tmp)",
"강하게 (0.4)": "Strong (0.4)",
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "Stronger refines more but may drift from the original",
"같습니다 ✔": "Match ✔",
@ -751,6 +753,7 @@
"고치기": "Edit",
"고친 옵션을 저장하지 않았습니다": "The edited options aren't saved",
"고화질": "High quality",
"곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "Retype with straight quotes and hyphens (-)",
"골라서 시작": "Choose and start",
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "Cannot roll back: shared storage (Download) is not available.",
"공용 폴더에 받은 파일 (동영상 · 그림 등)": "Files downloaded to shared folders (videos, images, etc.)",
@ -820,6 +823,7 @@
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "To draw on this device, download the model first ({0})",
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "On some devices a list of apps opens instead - find JJ_MKVMaker in the list and turn it on.",
"기능 버튼 줄": "Button bar",
"기록 · 배치 파일은 전체 경로로 적어 주세요: {0}": "Please write the full path of the log or batch file: {0}",
"기본": "Default",
"기본 DNS": "Primary DNS",
"기본 앱으로 열기": "Open with default app",
@ -1021,6 +1025,7 @@
"단축키 - Total Commander: F5 복사 · F6 이동 · F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침. 그 밖: Ctrl+C · X · V (복사 · 잘라내기 · 붙여넣기) · Ctrl+A 모두 표시 · F5 새로 고침": "Shortcuts - Total Commander: F5 copy · F6 move · F7 new folder · F8 delete · Insert/Space mark · Ctrl+R refresh. Others: Ctrl+C · X · V (copy · cut · paste) · Ctrl+A mark all · F5 refresh",
"단축키 해제": "Unregister hotkey",
"닫기": "Close",
"닫힌 문서입니다": "The document is closed",
"닫힘 (실행 안 함)": "Closed (not run)",
"담아 두고 붙여넣기 (기본)": "Hold and paste (default)",
"답이 없습니다": "No answer",
@ -1124,6 +1129,7 @@
"뒤로": "Back",
"뒤로 보내고 계속": "Keep running in background",
"뒤에서 계속": "Continue in background",
"드라이브 (C:) 나 / 로 시작하는 경로로": "Use a path that starts with a drive (C:) or /",
"드라이브 경로": "Drive path",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "The top folder of a drive can't be deleted",
"등록": "Register",
@ -1131,6 +1137,7 @@
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "Registered: \"Play with JJ_MKVMaker\" · \"Make subtitles with JJ_MKVMaker\" (Windows 11: Show more options)",
"따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "Not covered: the Tor screen (names are looked up inside Tor), IP addresses, home-network names (NAS / PC names without a dot, *.local, Tailscale *.ts.net etc. use the device DNS)",
"따옴표가 닫히지 않았습니다: {0}": "Unclosed quote: {0}",
"따옴표를 닫아 주세요": "Close the quote",
"또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "Or scan the QR code with the other device's camera (it includes the code)",
"라오어": "Lao",
"라이선스 원문 보기": "View full license text",
@ -1238,6 +1245,7 @@
"목록": "list",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos",
"목록 보기": "View list",
"목록 파일은 전체 경로로 적어 주세요: {0}": "Please write the full path of the list file: {0}",
"목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "If JJ_MKVMaker is not in the list, use [Allow another app] to pick JJ_MKVMaker.exe and check \"Private\". Turn sharing off and on again afterwards.",
"목록에서 동영상을 누르면 여기에 자세히 보입니다": "Tap a video in the list to see its details here",
"목록에서 빼기": "Remove from list",
@ -1780,6 +1788,7 @@
"앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "Deletes the in-app browser's cookies (logins), site data, cache and history, plus the cookie file exported for downloads. You'll need to log in to sites again. Bookmarks are kept.",
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "In-app web browser · bookmarks · video downloads",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "In-app web browser: presses [Skip] for you, and mutes and fast-forwards ads that can't be skipped (\"Skipping ad…\" shows at the top while skipping)",
"앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "Couldn't open it in the app, opening with another app: {0}",
"앱 지우기": "Uninstall app",
"앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "Kept in the app trash for {0} days, then deleted (restore from [Trash] in the explorer).",
"앱에 들어 있는 것 (기본)": "Built into the app (default)",
@ -1819,6 +1828,7 @@
"엔진": "Engine",
"여기까지 범위 고르기": "Select range up to here",
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "Drop here to add to the Make MKV video list",
"여는 중: {0}": "Opening: {0}",
"여러 개를 골랐을 때": "When several are selected",
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "How many to process at once when making several videos into MKV (re-encoding uses a lot of CPU)",
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "Too many wrong tries. You can try again in {0} s",
@ -1892,8 +1902,8 @@
"옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to move (tap, or mark with the circle on the right).",
"옮길까요?": "move?",
"옵션": "Options",
"옵션을 알아볼 수 없습니다: {0}": "Unrecognized option: {0}",
"옵션을 알아볼 수 없습니다: {0} - 곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "Unrecognized option: {0} - retype it with straight quotes and hyphens (-)",
"옵션은 - 나 -- 로 시작합니다": "Options start with - or --",
"옵션을 알아볼 수 없습니다: {0} - {1}": "Unrecognized option: {0} - {1}",
"옵션을 저장했습니다": "Options saved",
"완료": "Done",
"완료 시 자동 동영상 추가": "Auto-add videos when done",
@ -2598,6 +2608,9 @@
"큰 파일": "Large files",
"큰 파일이 없습니다": "No large files",
"큰 폴더": "Large folders",
"큰따옴표 (\") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}": "Options containing a double quote (\") cannot be passed to rsync on Windows: {0}",
"큰따옴표 (\") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}": "Arguments containing a double quote (\") cannot be passed as-is to rsync on Windows, so it was not run: {0}",
"큰따옴표 (\") 를 빼 주세요": "Remove the double quote (\")",
"키 있음": "Key saved",
"타갈로그어": "Tagalog",
"타밀어": "Tamil",

View File

@ -545,6 +545,7 @@
"{0}: 작업 중이라 옮기지 않았습니다": "{0}: 作業中のため移動しませんでした",
"{0}: 저장하지 않았습니다": "{0}: 保存しませんでした",
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: チェックした動画 {1}個を {2} に移動します",
"{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다": "{0}~{1} ページを {2} 秒以内に変換できませんでした",
"{0}개": "{0}個",
"{0}개 ZIP 으로 묶기": "{0} 件を ZIP にまとめる",
"{0}개 · {1}": "{0}個・{1}",
@ -657,6 +658,7 @@
"감지한 언어: {0} (자동 감지)": "検出した言語: {0} (自動検出)",
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "検出した言語: {0} - 違っていれば別の言語で作り直してください",
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}",
"값을 적어 주세요 (예: --exclude=*.tmp)": "値を入力してください (例: --exclude=*.tmp)",
"강하게 (0.4)": "強め (0.4)",
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "強いほど大きく整えますが、元と違ってくることがあります",
"같습니다 ✔": "一致します ✔",
@ -751,6 +753,7 @@
"고치기": "編集",
"고친 옵션을 저장하지 않았습니다": "編集したオプションが保存されていません",
"고화질": "高画質",
"곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "半角の引用符とハイフン (-) で入力し直してください",
"골라서 시작": "選んで開始",
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "共有ストレージ (Download) を使えないため戻せません。",
"공용 폴더에 받은 파일 (동영상 · 그림 등)": "共有フォルダーにダウンロードしたファイル (動画・画像など)",
@ -820,6 +823,7 @@
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "この端末で描くにはモデルのダウンロードが必要です ({0})",
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "機器によってはアプリ一覧が開きます。その場合は一覧から JJ_MKVMaker を探してオンにしてください。",
"기능 버튼 줄": "機能ボタンの列",
"기록 · 배치 파일은 전체 경로로 적어 주세요: {0}": "ログ・バッチファイルはフルパスで書いてください: {0}",
"기본": "既定",
"기본 DNS": "優先 DNS",
"기본 앱으로 열기": "既定のアプリで開く",
@ -1021,6 +1025,7 @@
"단축키 - Total Commander: F5 복사 · F6 이동 · F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침. 그 밖: Ctrl+C · X · V (복사 · 잘라내기 · 붙여넣기) · Ctrl+A 모두 표시 · F5 새로 고침": "ショートカット - Total Commander: F5 コピー · F6 移動 · F7 新しいフォルダー · F8 削除 · Insert/Space マーク · Ctrl+R 更新。その他: Ctrl+C · X · V (コピー · 切り取り · 貼り付け) · Ctrl+A すべてマーク · F5 更新",
"단축키 해제": "ショートカット解除",
"닫기": "閉じる",
"닫힌 문서입니다": "文書は閉じられています",
"닫힘 (실행 안 함)": "閉じた (実行しない)",
"담아 두고 붙여넣기 (기본)": "保持して貼り付け (既定)",
"답이 없습니다": "応答がありません",
@ -1124,6 +1129,7 @@
"뒤로": "戻る",
"뒤로 보내고 계속": "裏で続ける",
"뒤에서 계속": "バックグラウンドで続ける",
"드라이브 (C:) 나 / 로 시작하는 경로로": "ドライブ (C:) か / で始まるパスで",
"드라이브 경로": "ドライブのパス",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "ドライブの最上位フォルダーは削除できません",
"등록": "登録",
@ -1131,6 +1137,7 @@
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "登録済み:「JJ_MKVMaker で再生」・「JJ_MKVMaker で字幕を作成」(Windows 11: その他のオプションを表示)",
"따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "対象外: Tor 画面 (名前も Tor 内で解決)、IP アドレス、家庭内ネットワークの名前 (ドットのない NAS・PC 名、*.local、Tailscale の *.ts.net などは端末の DNS)",
"따옴표가 닫히지 않았습니다: {0}": "引用符が閉じていません: {0}",
"따옴표를 닫아 주세요": "引用符を閉じてください",
"또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "または、もう一方の端末のカメラで QR コードを読み取ってください (コード入り)",
"라오어": "ラオ語",
"라이선스 원문 보기": "ライセンス原文を見る",
@ -1238,6 +1245,7 @@
"목록": "一覧",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します",
"목록 보기": "リストを見る",
"목록 파일은 전체 경로로 적어 주세요: {0}": "リストファイルはフルパスで書いてください: {0}",
"목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "一覧に JJ_MKVMaker がなければ [別のアプリの許可] で JJ_MKVMaker.exe を選び「プライベート」にチェックします。変更後は共有をオフにして再びオンにしてください。",
"목록에서 동영상을 누르면 여기에 자세히 보입니다": "リストの動画をタップすると、ここに詳細が表示されます",
"목록에서 빼기": "リストから外す",
@ -1780,6 +1788,7 @@
"앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "アプリ内ブラウザの Cookie (ログイン)・サイトデータ・キャッシュ・閲覧履歴と、ダウンロード用に書き出した Cookie ファイルを削除します。サイトへの再ログインが必要です。お気に入りはそのままです。",
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "アプリ内 Web ブラウザー・ブックマーク・動画ダウンロード",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)",
"앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "アプリ内で開けないため、他のアプリで開きます: {0}",
"앱 지우기": "アプリを削除",
"앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "アプリのゴミ箱に {0} 日間置いてから削除します (エクスプローラーの [ゴミ箱] から復元できます)。",
"앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)",
@ -1819,6 +1828,7 @@
"엔진": "エンジン",
"여기까지 범위 고르기": "ここまで範囲選択",
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "ここにドロップすると MKV 作成の動画リストに追加します",
"여는 중: {0}": "開いています: {0}",
"여러 개를 골랐을 때": "複数選んだとき",
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)",
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "何度も間違えたため {0} 秒後に再入力できます",
@ -1892,8 +1902,8 @@
"옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "移動する項目を選んでください (タップするか右の丸で印を付ける)。",
"옮길까요?": "移動しますか?",
"옵션": "オプション",
"옵션을 알아볼 수 없습니다: {0}": "オプションを認識できません: {0}",
"옵션을 알아볼 수 없습니다: {0} - 곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "オプションを認識できません: {0} - 半角の引用符とハイフン (-) で入力し直してください",
"옵션은 - 나 -- 로 시작합니다": "オプションは - か -- で始まります",
"옵션을 알아볼 수 없습니다: {0} - {1}": "オプションを認識できません: {0} - {1}",
"옵션을 저장했습니다": "オプションを保存しました",
"완료": "完了",
"완료 시 자동 동영상 추가": "完了時に動画を自動追加",
@ -2598,6 +2608,9 @@
"큰 파일": "大きいファイル",
"큰 파일이 없습니다": "大きいファイルはありません",
"큰 폴더": "大きいフォルダー",
"큰따옴표 (\") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}": "ダブルクォート (\") を含むオプションは Windows の rsync に渡せません: {0}",
"큰따옴표 (\") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}": "ダブルクォート (\") を含む引数は Windows の rsync にそのまま渡せないため、実行しませんでした: {0}",
"큰따옴표 (\") 를 빼 주세요": "二重引用符 (\") を外してください",
"키 있음": "キーあり",
"타갈로그어": "タガログ語",
"타밀어": "タミル語",

View File

@ -545,6 +545,7 @@
"{0}: 작업 중이라 옮기지 않았습니다": "{0}: 正在处理,未移动",
"{0}: 저장하지 않았습니다": "{0}:未保存",
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: 将勾选的 {1} 个视频移到 {2}",
"{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다": "无法在 {2} 秒内转换第 {0}~{1} 页",
"{0}개": "{0} 个",
"{0}개 ZIP 으로 묶기": "将 {0} 项压缩为 ZIP",
"{0}개 · {1}": "{0} 个 · {1}",
@ -657,6 +658,7 @@
"감지한 언어: {0} (자동 감지)": "检测到的语言:{0}(自动检测)",
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "检测到的语言:{0} - 如有误,请用其他语言重新生成",
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}",
"값을 적어 주세요 (예: --exclude=*.tmp)": "请填写值 (例: --exclude=*.tmp)",
"강하게 (0.4)": "强 (0.4)",
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "越强优化越多,但可能与原图不同",
"같습니다 ✔": "相同 ✔",
@ -751,6 +753,7 @@
"고치기": "编辑",
"고친 옵션을 저장하지 않았습니다": "修改的选项尚未保存",
"고화질": "高画质",
"곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "请用直引号和连字符 (-) 重新输入",
"골라서 시작": "选择后开始",
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "无法使用公共存储 (Download),因此无法回退。",
"공용 폴더에 받은 파일 (동영상 · 그림 등)": "下载到公共文件夹的文件 (视频、图片等)",
@ -820,6 +823,7 @@
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "要在本设备上绘制,需要先下载模型 ({0})",
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "在某些设备上会打开应用列表,请在列表中找到 JJ_MKVMaker 并开启。",
"기능 버튼 줄": "功能按钮栏",
"기록 · 배치 파일은 전체 경로로 적어 주세요: {0}": "请填写日志或批处理文件的完整路径: {0}",
"기본": "默认",
"기본 DNS": "首选 DNS",
"기본 앱으로 열기": "用默认应用打开",
@ -1021,6 +1025,7 @@
"단축키 - Total Commander: F5 복사 · F6 이동 · F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침. 그 밖: Ctrl+C · X · V (복사 · 잘라내기 · 붙여넣기) · Ctrl+A 모두 표시 · F5 새로 고침": "快捷键 - Total Commander: F5 复制 · F6 移动 · F7 新建文件夹 · F8 删除 · Insert/Space 标记 · Ctrl+R 刷新。其他: Ctrl+C · X · V (复制 · 剪切 · 粘贴) · Ctrl+A 全部标记 · F5 刷新",
"단축키 해제": "注销快捷键",
"닫기": "关闭",
"닫힌 문서입니다": "文档已关闭",
"닫힘 (실행 안 함)": "已关闭 (未执行)",
"담아 두고 붙여넣기 (기본)": "暂存后粘贴 (默认)",
"답이 없습니다": "没有应答",
@ -1124,6 +1129,7 @@
"뒤로": "后退",
"뒤로 보내고 계속": "转到后台继续",
"뒤에서 계속": "在后台继续",
"드라이브 (C:) 나 / 로 시작하는 경로로": "请使用以盘符 (C:) 或 / 开头的路径",
"드라이브 경로": "驱动器路径",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "无法删除驱动器的根文件夹",
"등록": "注册",
@ -1131,6 +1137,7 @@
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "已注册: “用 JJ_MKVMaker 播放” · “用 JJ_MKVMaker 生成字幕”(Windows 11: 显示更多选项)",
"따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "不适用: Tor 画面 (名称也在 Tor 内解析)、IP 地址、家庭网络内的名称 (不带点的 NAS · PC 名称、*.local、Tailscale 的 *.ts.net 等使用设备 DNS)",
"따옴표가 닫히지 않았습니다: {0}": "引号未闭合: {0}",
"따옴표를 닫아 주세요": "请闭合引号",
"또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "或用另一台设备的相机扫描二维码 (已包含代码)",
"라오어": "老挝语",
"라이선스 원문 보기": "查看许可证原文",
@ -1238,6 +1245,7 @@
"목록": "列表",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域",
"목록 보기": "查看列表",
"목록 파일은 전체 경로로 적어 주세요: {0}": "请填写列表文件的完整路径: {0}",
"목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "如果列表中没有 JJ_MKVMaker,请用 [允许其他应用] 选择 JJ_MKVMaker.exe 并勾选“专用”。更改后请关闭共享再重新开启。",
"목록에서 동영상을 누르면 여기에 자세히 보입니다": "点按列表中的视频即可在此查看详情",
"목록에서 빼기": "从列表中移除",
@ -1780,6 +1788,7 @@
"앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "删除应用内浏览器的 Cookie (登录) · 网站数据 · 缓存 · 浏览记录,以及为下载导出的 Cookie 文件。需要重新登录网站。收藏保持不变。",
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "应用内浏览器 · 书签 · 视频下载",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)",
"앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "无法在应用内打开,改用其他应用打开: {0}",
"앱 지우기": "卸载应用",
"앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "在应用回收站保留 {0} 天后删除 (可在资源管理器的 [回收站] 中还原)。",
"앱에 들어 있는 것 (기본)": "应用内置 (默认)",
@ -1819,6 +1828,7 @@
"엔진": "引擎",
"여기까지 범위 고르기": "选择到此处的范围",
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "拖放到此处即可加入制作 MKV 的视频列表",
"여는 중: {0}": "正在打开: {0}",
"여러 개를 골랐을 때": "选择多个时",
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)",
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "错误次数过多,{0} 秒后可再次输入",
@ -1892,8 +1902,8 @@
"옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要移动的项目 (点按或用右侧圆圈标记)。",
"옮길까요?": "移动吗?",
"옵션": "选项",
"옵션을 알아볼 수 없습니다: {0}": "无法识别的选项: {0}",
"옵션을 알아볼 수 없습니다: {0} - 곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "无法识别的选项: {0} - 请用直引号和连字符 (-) 重新输入",
"옵션은 - 나 -- 로 시작합니다": "选项以 - 或 -- 开头",
"옵션을 알아볼 수 없습니다: {0} - {1}": "无法识别的选项: {0} - {1}",
"옵션을 저장했습니다": "已保存选项",
"완료": "完成",
"완료 시 자동 동영상 추가": "完成后自动添加视频",
@ -2598,6 +2608,9 @@
"큰 파일": "大文件",
"큰 파일이 없습니다": "没有大文件",
"큰 폴더": "大文件夹",
"큰따옴표 (\") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}": "含有双引号 (\") 的选项无法传给 Windows 上的 rsync: {0}",
"큰따옴표 (\") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}": "含有双引号 (\") 的参数无法原样传给 Windows 上的 rsync,因此未执行: {0}",
"큰따옴표 (\") 를 빼 주세요": "请去掉双引号 (\")",
"키 있음": "已有密钥",
"타갈로그어": "他加禄语",
"타밀어": "泰米尔语",

View File

@ -0,0 +1,278 @@
rhwp v0.8.7 (https://github.com/edwardkim/rhwp, commit 1a76570e833917d15817415a53c09ad61ab3203f)
Android: libjj_rhwp.so (HWP / HWPX -> PDF), built by tool/build_rhwp_android.sh with tool/rhwp_ffi
MIT License
Copyright (c) 2025-2026 Edward Kim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---------------------------------------------------------------------
rhwp THIRD_PARTY_LICENSES.md
---------------------------------------------------------------------
# Third-Party Licenses
rhwp 프로젝트가 사용하는 서드파티 라이브러리 및 리소스의 라이선스 목록이다.
기준 파일:
- `Cargo.toml` / `Cargo.lock` (`rhwp` v0.8.7)
- `rhwp-studio/package.json` / `rhwp-studio/package-lock.json`
- `rhwp-chrome/package.json` / `rhwp-chrome/package-lock.json`
- `rhwp-firefox/package.json` / `rhwp-firefox/package-lock.json`
- `rhwp-vscode/package.json` / `rhwp-vscode/package-lock.json`
- `assets/fonts/FONTS.md`
---
## Rust 크레이트
### 직접 의존성
| 크레이트 | 버전 | 라이선스 | 저장소 | 비고 |
|---------|------|---------|--------|------|
| aes | 0.9.2 | MIT OR Apache-2.0 | RustCrypto/block-ciphers | HWPX 암호화 |
| base64 | 0.23.1 | MIT OR Apache-2.0 | marshallpierce/rust-base64 | Base64 인코딩 |
| blake3 | 1.8.6 | CC0-1.0 OR Apache-2.0 OR Apache-2.0 WITH LLVM-exception | BLAKE3-team/BLAKE3 | 해시/진단 |
| byteorder | 1.5.0 | Unlicense OR MIT | BurntSushi/byteorder | 바이너리 endian 처리 |
| cbc | 0.2.1 | MIT OR Apache-2.0 | RustCrypto/block-modes | HWPX 암호화 모드 |
| cfb | 0.14.0 | MIT | mdsteele/rust-cfb | OLE Compound File |
| cipher | 0.5.2 | MIT OR Apache-2.0 | RustCrypto/traits | 암호 trait |
| codepage | 0.1.2 | Apache-2.0 OR MIT | hsivonen/codepage | 코드페이지 처리 |
| console_error_panic_hook | 0.1.7 | Apache-2.0/MIT | rustwasm/console_error_panic_hook | WASM panic hook |
| crc32fast | 1.5.0 | MIT OR Apache-2.0 | srijs/rust-crc32fast | HWP3 암호 키 검증 |
| des | 0.9.0 | MIT OR Apache-2.0 | RustCrypto/block-ciphers | HWP3 암호화 |
| ed25519-dalek | 2.2.0 | BSD-3-Clause | dalek-cryptography/curve25519-dalek | capsule 서명 |
| embedded-io | 0.7.1 | MIT OR Apache-2.0 | rust-embedded/embedded-hal | IO trait |
| encoding_rs | 0.8.35 | (Apache-2.0 OR MIT) AND BSD-3-Clause | hsivonen/encoding_rs | 문자 인코딩 |
| flate2 | 1.1.9 | MIT OR Apache-2.0 | rust-lang/flate2-rs | 압축/해제 |
| getrandom | 0.4.3 | MIT OR Apache-2.0 | rust-random/getrandom | 서명 키 난수 생성 |
| hmac | 0.13.0 | MIT OR Apache-2.0 | RustCrypto/MACs | HWPX 무결성 |
| image | 0.25.10 | MIT OR Apache-2.0 | image-rs/image | BMP/JPEG/PNG 디코딩 |
| js-sys | 0.3.102 | MIT OR Apache-2.0 | rustwasm/wasm-bindgen | WASM JS interop |
| paste | 1.0.15 | MIT OR Apache-2.0 | dtolnay/paste | 매크로 보조 |
| pbkdf2 | 0.13.0 | MIT OR Apache-2.0 | RustCrypto/password-hashes | HWPX 키 파생 |
| pcx | 0.2.5 | MIT OR Apache-2.0 OR WTFPL | kryptan/pcx | PCX 이미지 디코딩 |
| pdf-writer | 0.12.1 | MIT OR Apache-2.0 | typst/pdf-writer | PDF 출력 |
| quick-xml | 0.41.0 | MIT | tafia/quick-xml | HWPX XML 파싱 |
| resvg | 0.47.0 | Apache-2.0 OR MIT | linebender/resvg | native-skia SVG raster (optional) |
| roxmltree | 0.21.1 | MIT OR Apache-2.0 | RazrFalcon/roxmltree | XML 검증·정규화 |
| serde | 1.0.229 | MIT OR Apache-2.0 | serde-rs/serde | 직렬화 |
| serde_json | 1.0.151 | MIT OR Apache-2.0 | serde-rs/json | JSON 직렬화 |
| sha1 | 0.11.0 | MIT OR Apache-2.0 | RustCrypto/hashes | HWPX 암호 키 파생 |
| sha2 | 0.11.0 | MIT OR Apache-2.0 | RustCrypto/hashes | 서명·CAS 해시 |
| skia-safe | 0.99.0 | MIT | rust-skia/rust-skia | native-skia PNG/PDF backend (optional) |
| snafu | 0.9.2 | MIT OR Apache-2.0 | shepmaster/snafu | 에러 처리 |
| strum | 0.28.0 | MIT | Peternator7/strum | enum derive |
| subsecond | 0.7.10 | MIT OR Apache-2.0 | DioxusLabs/dioxus | 개발 핫패치 (optional) |
| subsetter | 0.2.6 | MIT OR Apache-2.0 | typst/subsetter | 폰트 subset |
| svg2pdf | 0.13.0 | MIT OR Apache-2.0 | typst/svg2pdf | SVG → PDF |
| svgtypes | 0.16.1 | Apache-2.0 OR MIT | linebender/svgtypes | SVG 값 파싱 |
| ttf-parser | 0.25.1 | MIT OR Apache-2.0 | harfbuzz/ttf-parser | 폰트 파싱 |
| unicode-properties | 0.1.4 | MIT OR Apache-2.0 | unicode-rs/unicode-properties | 유니코드 문자 분류 |
| unicode-segmentation | 1.13.3 | MIT OR Apache-2.0 | unicode-rs/unicode-segmentation | grapheme/word segmentation |
| unicode-width | 0.2.2 | MIT OR Apache-2.0 | unicode-rs/unicode-width | 문자 폭 계산 |
| usvg | 0.45.1 | Apache-2.0 OR MIT | linebender/resvg | SVG 파싱 |
| wasm-bindgen | 0.2.125 | MIT OR Apache-2.0 | rustwasm/wasm-bindgen | WASM binding |
| wasm-bindgen-test | 0.3.75 | MIT OR Apache-2.0 | rustwasm/wasm-bindgen | WASM 테스트 |
| web-sys | 0.3.102 | MIT OR Apache-2.0 | rustwasm/wasm-bindgen | Web API binding |
| zip | 8.6.0 | MIT | zip-rs/zip2 | HWPX ZIP 처리 |
### 전체 Rust 의존성 요약
`cargo metadata --locked` 기준 package entry는 247개이며, 그중 registry 또는 Git
source를 가진 외부 Rust 크레이트는 243개다. 나머지 4개는 현재 workspace 패키지다.
주요 라이선스군:
- MIT / Apache-2.0 dual license 계열
- MIT 단독
- Apache-2.0 OR MIT
- BSD-2-Clause / BSD-3-Clause
- Zlib
- Unlicense
- 0BSD
- ISC
- Unicode-DFS-2016
- CC0-1.0 / Apache-2.0 WITH LLVM-exception (`blake3`)
- WTFPL 대체 선택지 포함 (`pcx`, MIT/Apache 선택 가능)
> 모든 Rust 의존성은 MIT, Apache-2.0, BSD, Zlib, Unlicense, 0BSD, ISC 등 permissive license 계열이거나
> permissive option을 포함하며, rhwp의 MIT 라이선스와 호환된다.
---
## npm 패키지
### npm 배포 패키지
| 패키지 | 버전 | 라이선스 | 용도 |
|--------|------|---------|------|
| @rhwp/core | 0.8.6 | MIT | WASM parser/renderer API |
| @rhwp/editor | 0.8.6 | MIT | iframe 기반 웹 에디터 wrapper |
### rhwp-studio
| 패키지 | Lock 버전 | 라이선스 | 용도 |
|--------|-----------|---------|------|
| canvaskit-wasm | 0.42.0 | BSD-3-Clause | CanvasKit 렌더링 backend |
| @noble/hashes | 2.4.0 | MIT | 렌더 리소스 키와 문서 digest |
| @types/chrome | 0.2.5 | MIT | Chrome API 타입 |
| pixelmatch | 7.2.0 | ISC | 시각 diff 픽셀 비교 |
| pngjs | 7.0.0 | MIT | 시각 diff PNG 처리 |
| puppeteer-core | 25.5.0 | Apache-2.0 | E2E 테스트 / CDP 연결 |
| typescript | 7.0.2 | Apache-2.0 | TypeScript 컴파일 |
| vite | 8.2.1 | MIT | 개발 서버 + 빌드 |
| vite-plugin-pwa | 1.3.0 | MIT | PWA 빌드 보조 |
| workbox-window | 7.4.1 | MIT | Service worker runtime 보조 |
### rhwp-chrome / rhwp-firefox
| 패키지 | Lock 버전 | 라이선스 | 용도 |
|--------|-----------|---------|------|
| typescript | 7.0.2 | Apache-2.0 | TypeScript 컴파일 |
| vite | 8.2.1 | MIT | 확장 번들 빌드 |
### rhwp-vscode
| 패키지 | Lock 버전 | 라이선스 | 용도 |
|--------|-----------|---------|------|
| @types/node | 18.15.13 | MIT | Node.js 타입 |
| @types/vscode | 1.82.0 | MIT | VS Code API 타입 |
| @typescript/native | 7.0.2 | Apache-2.0 | TypeScript native preview 컴파일러 |
| copy-webpack-plugin | 14.0.0 | MIT | Webpack 파일 복사 |
| null-loader | 4.0.1 | MIT | Webpack 로더 |
| ts-loader | 9.6.2 | MIT | Webpack TypeScript 로더 |
| typescript | 6.0.2 | Apache-2.0 | TypeScript 컴파일 |
| webpack | 5.109.2 | MIT | 번들러 |
| webpack-cli | 7.2.2 | MIT | Webpack CLI |
| @noble/hashes | 2.4.0 | MIT | 문서 digest |
| canvaskit-wasm | 0.42.0 | BSD-3-Clause | 자동 선택 CanvasKit backend |
### rhwp-shared
`rhwp-shared`는 현재 별도 외부 npm 의존성이 없는 shared source package다.
---
## 웹 폰트 및 폰트 리소스
`assets/fonts/`에 포함된 canonical 폰트 목록이다. 저작권 보호가 필요한 한컴/Microsoft 폰트는 Git에
포함하지 않으며, 세부 파일 목록과 폴백 관계는 `assets/fonts/FONTS.md`를 참조한다.
| 폰트/리소스 | 라이선스 | 출처 | 비고 |
|-------------|---------|------|------|
| Pretendard (9종) | SIL Open Font License 1.1 | github.com/orioncactus/pretendard | Sans-serif fallback |
| Noto Serif KR (2종) | SIL Open Font License 1.1 | Google Fonts | Serif fallback |
| Noto Sans KR (3종) | SIL Open Font License 1.1 | Google Fonts | Sans-serif fallback |
| Nanum Myeongjo (3종) | SIL Open Font License 1.1 | Google Fonts | Serif fallback |
| Nanum Gothic (3종) | SIL Open Font License 1.1 | Google Fonts | Sans-serif fallback |
| Nanum Gothic Coding (2종) | SIL Open Font License 1.1 | Google Fonts | Monospace fallback |
| Gowun Batang (2종) | SIL Open Font License 1.1 | Google Fonts | Serif fallback |
| Gowun Dodum | SIL Open Font License 1.1 | Google Fonts | Sans-serif fallback |
| D2 Coding (2종) | SIL Open Font License 1.1 | NAVER | Monospace fallback |
| Spoqa Han Sans | SIL Open Font License 1.1 | github.com/spoqa/spoqa-han-sans | Sans-serif fallback |
| Source Han Serif K Old Hangul subset | SIL Open Font License 1.1 | Adobe / Google | 옛한글 PUA fallback, `SourceHanSerifK-OFL.txt` 동봉 |
| Latin Modern Math | GUST Font License | GUST / TeX ecosystem | 수식 fallback |
| Cafe24 써라운드 | 카페24 무료 배포 | fonts.cafe24.com | 장식체 |
| Cafe24 슈퍼매직 | 카페24 무료 배포 | fonts.cafe24.com | 장식체 |
| 행복고딕 (Happiness Sans, 4종) | 무료 배포 | 행복나눔재단 | Sans-serif fallback |
---
## 도구
| 도구 | 라이선스 | 용도 |
|------|---------|------|
| Docker | Apache-2.0 | WASM 빌드 환경 |
| wasm-pack | MIT OR Apache-2.0 | WASM 패키징 |
| Chrome DevTools Protocol | BSD-3-Clause | E2E 테스트 / 브라우저 자동화 |
| GitHub Actions | GitHub Terms | CI/CD |
| npm Trusted Publishing / OIDC | npm Terms | npm 배포 |
---
## 직접 포팅한 알고리즘
### Volexity hwp-extract
- 출처: [volexity/hwp-extract](https://github.com/volexity/hwp-extract), commit
`e5f8b5e1590dee973630666e687e919fa70da2e2`
- 참조 파일: `src/hwp_extract/encrypt.py`
- 적용 위치: `src/parser/crypto.rs`의 HWP5 EncryptVersion 4 키 파생 및 비트 단위
AES-CFB 복호화
- 라이선스: BSD-3-Clause
Modified BSD License
_Copyright © `2024`, `Volexity, Inc`_
_All rights reserved._
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the `Volexity, Inc` nor the
names of its contributors may be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL `Volexity, Inc` BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
---
## 참조한 오픈소스 프로젝트 (스펙·설계 참조)
rhwp는 아래 프로젝트들의 **코드를 직접 복사하지 않으며**, 공개된 스펙 정보(enum 값·속성 기본값·태그 이름·검증 규칙 등)만 참조한다.
Apache-2.0 라이선스 고지 의무는 본 문서, 관련 기술 문서, 그리고 각 참조 파일의 헤더 주석으로 충족한다.
| 프로젝트 | 라이선스 | 참조 범위 | rhwp 위치 |
|---------|---------|----------|-----------|
| [hancom-io/hwpx-owpml-model](https://github.com/hancom-io/hwpx-owpml-model) | Apache-2.0 © 2022 Hancom Inc. | HWPX enum 정의, 속성 기본값, 태그 전체 집합, canonical 속성·자식 순서 | `src/serializer/hwpx/canonical_defaults.rs`, `src/serializer/hwpx/{header,table,shape,picture}.rs`, `mydocs/tech/hwpx_hancom_reference.md` |
| [hancom-io/dvc](https://github.com/hancom-io/dvc) | Apache-2.0 © 2022 Hancom Inc. | HWPX 검증 규칙 JSON 스키마, errorCode 체계 | `mydocs/tech/hwpx_dvc_reference.md` |
---
## 라이선스 호환성
rhwp는 **MIT 라이선스**로 배포된다.
- MIT, Apache-2.0, BSD, Zlib, Unlicense, 0BSD, ISC, CC0 — MIT와 호환되는 permissive license 계열
- `encoding_rs`의 BSD-3-Clause 조항은 고지 의무를 요구하며, 이 문서로 충족
- `pcx`는 WTFPL 대체 선택지를 포함하지만 MIT/Apache-2.0 선택지가 있어 rhwp MIT 배포와 호환
- 오픈 폰트는 SIL OFL 1.1 또는 GUST Font License 등 재배포 가능한 라이선스만 Git에 포함
- 저작권 보호 대상 폰트(한컴, Microsoft 등)는 Git에 포함하지 않으며 `ttfs/FONTS.md`, `assets/fonts/FONTS.md`에서 목록과 폴백만 관리
---
*이 문서는 `Cargo.toml`, `Cargo.lock`, 각 `package.json`/`package-lock.json`, `assets/fonts/FONTS.md` 기준으로 현행화되었으며, 의존성 업데이트 시 함께 갱신해야 한다.*

View File

@ -58,6 +58,10 @@ JJ_MKVMaker (Android) - 포함된 오픈 소스 구성 요소 및 라이선스
(이 앱은 RAR 을 만들지 않습니다). 공식 소스 (https://www.7-zip.org/a/7z2501-src.7z) 를 고치지 않고 Android NDK 로 빌드
(빌드 방법: 소스의 tool/build_7zz_android.sh). 따로 실행하는 프로그램이며, 대응 소스는 위 주소와 이 앱 판의 태그에 있습니다.
원문: 앱 안 "오픈 소스 라이선스" 의 7-Zip (7ZIP_LICENSE.txt) · GNU LGPL 2.1
- rhwp v0.8.7 (libjj_rhwp.so - HWP · HWPX 를 PDF 로 바꿔 보기) MIT https://github.com/edwardkim/rhwp
Copyright (c) 2025-2026 Edward Kim. 커밋 1a76570 을 고치지 않고, 얇은 C 함수 묶음 (소스의 tool/rhwp_ffi) 과 함께 Android NDK 로
빌드 (빌드 방법: 소스의 tool/build_rhwp_android.sh). 함께 묶인 Rust crate 들 (MIT · Apache-2.0 등) 의 목록과 원문:
앱 안 "오픈 소스 라이선스" 의 rhwp (RHWP_LICENSES.txt)
- stable-diffusion.cpp · ggml (libsdcli.so 라는 이름의 실행 파일) MIT https://github.com/leejet/stable-diffusion.cpp
커밋 228c707 을 고치지 않고 Android NDK 로 빌드 (AI 그림 · 해상도 올리기, 빌드 방법: 소스의 tool/build_sdcpp_android.sh)
함께 묶인 구성 요소: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json ·

View File

@ -34,7 +34,7 @@ void main() {
}
CefRuntime.rememberStartState();
expect(CefRuntime.readyThisRun, isTrue);
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_cef_real_').path;
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_cef_real_', heldByApp: true).path;
Directory(data).createSync(recursive: true);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings

View File

@ -21,7 +21,7 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('250: 닿지 않는 DNS → 브라우저에 앱 덮개 (Edge 실제)', (tester) async {
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_dns_block_').path;
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_dns_block_', heldByApp: true).path;
Directory(data).createSync(recursive: true);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings

View File

@ -0,0 +1,134 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:integration_test/integration_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;
import 'package:pdfrx/pdfrx.dart';
import 'support/temp_dir.dart';
/// 쪽마다 그림 한 장인 진짜 PDF 를 만드는 가짜 엔진 (rhwp 대신) - 부른 범위를 적어 둔다
class _FakeEngine implements HwpPdfEngine {
final int pages;
final String tempDir;
final calls = <(int, int)>[];
var failNext = 0;
Completer<void>? hold;
_FakeEngine(this.pages, this.tempDir);
@override
Future<HwpPdfDoc> open(String path, {String? password}) async {
if (password == 'bad') throw const HwpPdfException('암호가 틀림');
return _FakeDoc(this);
}
}
class _FakeDoc implements HwpPdfDoc {
final _FakeEngine e;
var closed = false;
_FakeDoc(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));
// 끝나지 않는 변환 (시간 제한 시험): 풀려도 아무것도 쓰지 않는다 (시험이 끝난 뒤 폴더에 쓰지 않게)
if (e.hold != null) {
await e.hold!.future;
throw const HwpPdfException('늦게 끝남');
}
if (e.failNext > 0) {
e.failNext--;
throw const HwpPdfException('렌더 실패');
}
final docs = <PdfDocument>[];
for (var i = from; i < to; i++) {
final png = p.join(e.tempDir, 'pg$i.png');
// 쪽마다 폭이 달라 (100 + 쪽 번호) 어느 쪽이 그려졌는지 크기로 안다
File(png).writeAsBytesSync(img.encodePng(img.Image(width: 100 + i, height: 140)));
docs.add(await imagePageDocument(png, tempDir: e.tempDir, sourceName: 'jj_fake_$i'));
}
final outDoc = await PdfDocument.createNew(sourceName: 'jj_fake_out_$from');
outDoc.pages = [for (final d in docs) d.pages.first];
File(out).writeAsBytesSync(await outDoc.encodePdf());
await outDoc.dispose();
for (final d in docs) {
await d.dispose();
}
}
@override
Future<void> close() async => closed = true;
}
/// 관리자 지시 (10/20): HWP · HWPX 보기 - 보는 쪽의 묶음만 PDF 로 바꾸고 다음 묶음을 미리 만든다 · 시간 제한 · 실패하면 다시 · 닫기.
/// (rhwp 대신 가짜 엔진, PDF 를 그리는 PDFium 이 필요해 Windows 통합 시험)
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('보는 쪽의 묶음 (5쪽) 만 바꾸고 다음 묶음을 미리 · 끝 묶음은 남은 쪽만', (tester) async {
final dir = testTempDir('jj_hwpsrc_');
final doc = File(p.join(dir.path, 'a.hwp'))..writeAsStringSync('x');
final engine = _FakeEngine(12, dir.path);
final src = await tester.runAsync(() => HwpSource.open(doc.path, engine: engine, tempDir: dir.path, chunk: 5));
expect(src!.length, 12);
expect(src.pageName(0), '1 / 12');
final first = await tester.runAsync(() => src.load(0, maxWidth: 200));
expect(first!.image, isNotNull);
first.dispose();
await tester.runAsync(() => Future<void>.delayed(const Duration(seconds: 2)));
expect(engine.calls, [(0, 5), (5, 10)], reason: '첫 묶음 + 다음 묶음 미리');
// 끝 쪽: 남은 2쪽만 (11번째 = 폭 110)
final last = await tester.runAsync(() => src.load(11, maxWidth: 200));
expect(last!.image, isNotNull);
last.dispose();
expect(engine.calls.last, (10, 12));
// 이미 만든 묶음은 다시 만들지 않는다
final again = await tester.runAsync(() => src.load(3, maxWidth: 200));
again!.dispose();
expect(engine.calls.where((c) => c == (0, 5)), hasLength(1));
await tester.runAsync(src.dispose);
expect(Directory(src.work.path).existsSync(), isFalse, reason: '닫으면 임시 PDF 를 지움');
});
testWidgets('묶음이 실패하면 그 쪽에 이유, 다시 보면 다시 만든다 · 시간 제한 · 열기 실패', (tester) async {
final dir = testTempDir('jj_hwpsrc_');
final doc = File(p.join(dir.path, 'b.hwpx'))..writeAsStringSync('x');
final engine = _FakeEngine(3, dir.path)..failNext = 1;
final src = await tester.runAsync(() => HwpSource.open(doc.path, engine: engine, tempDir: dir.path, chunk: 5,
timeout: const Duration(seconds: 2)));
Object? err;
await tester.runAsync(() => src!.load(0, maxWidth: 200).then((r) => r.dispose(), onError: (Object e) {
err = e;
}));
expect('$err', contains('렌더 실패'));
final ok = await tester.runAsync(() => src!.load(0, maxWidth: 200));
expect(ok!.image, isNotNull, reason: '실패한 묶음은 다시');
ok.dispose();
await tester.runAsync(src!.dispose);
// 시간 제한: 끝나지 않는 변환
final slow = _FakeEngine(3, dir.path)..hold = Completer<void>();
final s2 = await tester.runAsync(() => HwpSource.open(doc.path, engine: slow, tempDir: dir.path, timeout: const Duration(seconds: 1)));
Object? err2;
await tester.runAsync(() => s2!.load(1, maxWidth: 200).then((r) => r.dispose(), onError: (Object e) {
err2 = e;
}));
expect(err2, isA<HwpPdfException>());
expect('$err2', contains('1~3'));
slow.hold!.complete();
await tester.runAsync(s2!.dispose);
// 열기 실패 (암호 등) 는 그대로 알린다 - 파일 탐색기는 다른 앱으로 연다
Object? err3;
await tester.runAsync(() => HwpSource.open(doc.path, engine: engine, tempDir: dir.path, password: 'bad').then((_) {}, onError: (Object e) {
err3 = e;
}));
expect('$err3', contains('암호'));
});
}

View File

@ -27,7 +27,7 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('DNS 강제 · Tor 화면 실제 연결', (tester) async {
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_tor_real_').path;
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_tor_real_', heldByApp: true).path;
Directory(data).createSync(recursive: true);
ComponentStore.dataDirectory = data;
ComponentStore.shared = ComponentStore(data);

View File

@ -0,0 +1,79 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/i18n_controller.dart';
import 'package:jj_mkvmaker/core/sync_tools.dart';
import 'package:jj_mkvmaker/ui/sync_preview_view.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
/// 관리자 지시 (10/20, 28 지적): 알아볼 수 없는 옵션 안내 - 끝 문구가 문제 종류에 맞는지, 412 에서 넘치지 않는지.
/// 확인 창의 빨간 글 (OptionProblemNote) 과 설정 칸 아래 글 (errorText, 3줄까지) 을 크기 412 · 960, 언어 ko · en · ja 로 찍는다.
/// 실행: flutter test integration_test\windows_option_problem_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_optproblem');
const cases = [
'-a --delete --exclude="a.tmp --exclude=keep.txt', // 닫히지 않은 따옴표
'-a delete', // 경로로 읽힘
'-a --delete --exclude', // 값 없음
'-a ---delete «x»', // 이상한 글자
'-av delete --exclude', // 여러 종류
];
for (final lang in ['ko', 'en', 'ja']) {
for (final (w, hh) in [(412, 900), (960, 600)]) {
testWidgets('옵션 안내 $lang ${w}x$hh', (t) async {
t.view.physicalSize = Size(w.toDouble(), hh.toDouble());
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final shotKey = GlobalKey();
await t.pumpWidget(RepaintBoundary(
key: shotKey,
child: MaterialApp(
theme: buildTheme(),
debugShowCheckedModeBanner: false,
home: Scaffold(
body: ListView(padding: const EdgeInsets.all(16), children: [
for (final o in cases) ...[
Text(o, style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
OptionProblemNote(optionProblems(o)),
const SizedBox(height: 8),
],
// 설정 칸 (copy_sync_settings 의 rsync 옵션 칸과 같은 폭 · 글 줄 수)
SizedBox(
width: 380,
child: TextField(
controller: TextEditingController(text: cases.last),
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
errorText: optionProblemText(optionProblems(cases.last)),
errorMaxLines: 3,
),
),
),
]),
),
),
));
for (var i = 0; i < 4; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
await t.pump(const Duration(milliseconds: 60));
}
expect(t.takeException(), isNull, reason: '넘침');
expect(find.byType(OptionProblemNote), findsNWidgets(cases.length));
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final img = await t.runAsync(() => b.toImage());
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true);
File(p.join(out, 'optproblem_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
});
}
}
}

View File

@ -17,6 +17,7 @@ import '../platform/windows/rsync_installer.dart';
import 'app_controller.dart';
import 'settings.dart';
import '../core/rsync_filter.dart';
import '../core/rsync_launch.dart';
/// 앱에 들어 있는 rsync (없으면 null). 시작할 때 [findBundledRsync] 로 정한다.
/// - Windows: 실행 파일 옆 rsync/rsync.exe (tool/fetch_rsync.ps1 로 준비해 빌드에 넣음)
@ -511,13 +512,13 @@ class LiveSync extends ChangeNotifier {
}
/// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 rsync 옵션 (있으면 맞추지 않는다 - 카드에 빨갛게)
List<String> unreadableOf(LiveSyncPair x) => unreadableOptions(_optionsOf(x));
List<OptionProblem> unreadableOf(LiveSyncPair x) => optionProblems(_optionsOf(x));
Future<void> syncNow(LiveSyncPair x) async {
final k = keyOf(x);
final bad = unreadableOf(x);
final nest = bad.isNotEmpty
? trf('옵션을 알아볼 수 없습니다: {0}', [bad.join(' · ')])
? optionProblemMessage(bad)
: nestingProblem(x, allowInnerToOuter: c.settings.allowInnerToOuter);
if (nest != null) {
status[k] = (DateTime.now(), nest);
@ -630,7 +631,15 @@ class LiveSync extends ChangeNotifier {
if (exe == null) throw StateError(tr('rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)'));
final args = rsyncArgsFor(x, s, windows: Platform.isWindows);
await Directory(x.target).create(recursive: true);
final r = await Process.run(exe, args, stdoutEncoding: utf8, stderrEncoding: utf8);
// P0 (10/20): Windows 의 rsync 는 응답 파일 · MSYS=noglob 으로 (' · * 를 런타임이 다시 읽지 않게 - [prepareRsyncLaunch])
final launch = await prepareRsyncLaunch(args);
final ProcessResult r;
try {
r = await Process.run(exe, launch.args,
workingDirectory: launch.workingDirectory, environment: launch.environment, stdoutEncoding: utf8, stderrEncoding: utf8);
} finally {
launch.dispose();
}
if (!rsyncOk(r.exitCode)) throw ProcessException(exe, const [], '${r.stderr}'.trim(), r.exitCode);
return RsyncOutput().feed('${r.stdout}\n').length;
}

View File

@ -11,6 +11,7 @@ import '../core/sync_tools.dart';
import '../core/vfs.dart';
import '../l10n/tr.dart';
import '../core/rsync_filter.dart';
import '../core/rsync_launch.dart';
/// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다:
/// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률
@ -270,8 +271,8 @@ class TransferJob extends ChangeNotifier {
try {
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션이 남으면 실행하지 않는다 (rsync 가 원본 경로로 읽어 옵션이 몰래 빠짐)
if (method == CopyMethod.rsync) {
final bad = unreadableOptions(options);
if (bad.isNotEmpty) throw StateError(trf('옵션을 알아볼 수 없습니다: {0}', [bad.join(' · ')]));
final bad = optionProblems(options);
if (bad.isNotEmpty) throw StateError(optionProblemMessage(bad));
}
for (var i = 0; i < sources.length; i++) {
final s = sources[i];
@ -401,8 +402,20 @@ class TransferJob extends ChangeNotifier {
if (log.length > 20) log.removeAt(0);
}
Future<int> _start(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut) async {
_proc = await Process.start(exe, args, runInShell: false);
/// [rsync]: Windows 의 rsync 는 응답 파일 · MSYS=noglob 으로 띄운다 (P0 10/20 - ' · * 를 런타임이 다시 읽지 않게, [prepareRsyncLaunch]).
/// robocopy 는 Windows 프로그램이라 그대로
Future<int> _start(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut, {bool rsync = false}) async {
final launch = rsync ? await prepareRsyncLaunch(args) : null;
try {
return await _startRaw(exe, launch?.args ?? args, enc, onOut, workingDirectory: launch?.workingDirectory, environment: launch?.environment);
} finally {
launch?.dispose();
}
}
Future<int> _startRaw(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut,
{String? workingDirectory, Map<String, String>? environment}) async {
_proc = await Process.start(exe, args, runInShell: false, workingDirectory: workingDirectory, environment: environment);
final a = _proc!.stdout.transform(enc.decoder).listen(onOut);
final b = _proc!.stderr.transform(enc.decoder).listen((t) {
for (final l in t.split(RegExp(r'[\r\n]'))) {
@ -459,6 +472,7 @@ class TransferJob extends ChangeNotifier {
}
_tick();
},
rsync: true,
);
if (cancelled) return;
if (!rsyncOk(code)) throw ProcessException(exe, const [], rsyncFailureText(code, errLog, log), code);

131
lib/core/hwp_source.dart Normal file
View File

@ -0,0 +1,131 @@
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;
}
/// 관리자 지시 (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);
}
@override
int get length => doc.pageCount;
@override
String pageName(int i) => '${i + 1} / $length';
/// [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('닫힌 문서입니다'));
await pdfrxFlutterInitialize();
final pdf = await PdfDocument.openFile(out);
if (_disposed) {
await pdf.dispose();
throw StateError(tr('닫힌 문서입니다'));
}
_opened.add(pdf);
return pdf;
}
@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];
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() async {
_disposed = true;
for (final d in _opened) {
await d.dispose();
}
_opened.clear();
await doc.close(); // 바꾸는 중이면 그것이 끝난 뒤에 닫힌다 (엔진이 한 번에 하나씩)
try {
if (work.existsSync()) work.deleteSync(recursive: true);
} catch (_) {}
}
}

View File

@ -0,0 +1,44 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import '../l10n/tr.dart';
/// 관리자 지시 (10/20, 28 재현 - P0 데이터): Windows 의 rsync (MSYS2 · Cygwin 런타임) 는 Windows 프로그램이 넘긴 명령 줄을
/// 스스로 다시 읽는다 - ' 를 따옴표로 짝지어 (--exclude=it's.txt --exclude=Bob's.txt 가 한 인수로 합쳐져 거르기가 사라지고
/// 지킬 파일이 지워짐, ' 가 든 경로는 합쳐져 실패) * ? [ ] { } 를 작업 폴더의 파일로 펼친다 (--exclude *.log 가 x.log y.log 로).
/// 앱이 만든 인수를 응답 파일 (@파일) 에 큰따옴표로 감싸 넣고 MSYS=noglob 으로 띄우면 그대로 간다 (실제 rsync 3.5.1 로 확인 -
/// MSYS=noglob 만으로는 ' 를 막지 못하고, CYGWIN=noglob 은 소용없다).
/// 응답 파일의 경로에 빈칸 · ' 가 있으면 @ 가 먹히지 않아, 그 파일이 든 폴더를 작업 폴더로 하고 "@args.txt" 로 넘긴다.
/// 큰따옴표 (") 가 든 인수는 이 방법으로도 그대로 넘기지 못한다 - 실행하지 않고 이유를 알린다 (Windows 파일 이름에는 " 가 없다).
class RsyncLaunch {
final List<String> args;
final String? workingDirectory;
final Map<String, String>? environment;
final Directory? _dir;
RsyncLaunch._(this.args, this.workingDirectory, this.environment, this._dir);
/// 다 쓴 응답 파일을 지운다 (rsync 가 끝난 뒤)
void dispose() {
try {
_dir?.deleteSync(recursive: true);
} catch (_) {}
}
}
/// Windows rsync 에 그대로 넘길 수 없는 인수 (큰따옴표가 듦)
List<String> msysUnsafeArgs(List<String> args) => [for (final a in args) if (a.contains('"')) a];
/// [args] 를 rsync 에 그대로 넘기도록 준비한다 (Windows 만 응답 파일 · MSYS=noglob, 그 밖은 그대로). 끝나면 [RsyncLaunch.dispose]
Future<RsyncLaunch> prepareRsyncLaunch(List<String> args, {bool? windows}) async {
if (!(windows ?? Platform.isWindows)) return RsyncLaunch._(args, null, null, null);
final bad = msysUnsafeArgs(args);
if (bad.isNotEmpty) {
throw StateError(trf('큰따옴표 (") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}', [bad.join(' · ')]));
}
final dir = await Directory.systemTemp.createTemp('jj_rsargs_');
await File(p.join(dir.path, 'args.txt')).writeAsString(args.map((a) => '"$a"').join(' '), encoding: utf8);
final msys = Platform.environment['MSYS'];
return RsyncLaunch._(['@args.txt'], dir.path, {'MSYS': msys == null || msys.trim().isEmpty ? 'noglob' : '$msys noglob'}, dir);
}

View File

@ -1,3 +1,5 @@
import 'dart:io' show Platform;
import 'package:path/path.dart' as p;
import 'file_ops.dart' show appTempRsyncExcludes;
@ -157,7 +159,8 @@ List<String> _normalizeWords(List<_Word> raw) {
value = '';
continue;
}
if (!w.quoted && t.isNotEmpty && _dashLike.contains(t[0])) {
// 28 확인 (10/20): 곧은 하이픈과 섞인 것 (-–delete) 도 맨 앞 둘 중 하나라도 대시면 같은 규칙으로
if (!w.quoted && t.isNotEmpty && (_dashLike.contains(t[0]) || t.length > 1 && t[0] == '-' && _dashLike.contains(t[1]))) {
final n = _dashAt(t, 1) ? 2 : 1;
final rest = t.substring(n);
final name = rest.split('=').first;
@ -190,25 +193,71 @@ const _rsyncLongWithValue = {
'--block-size', '--checksum-choice', '--compress-choice', '--compress-level', '--skip-compress', '--info', '--debug',
'--sockopts', '--port', '--address', '--outbuf', '--remote-option', '--max-alloc', '--stop-after', '--stop-at',
'--write-batch', '--only-write-batch', '--read-batch', '--protocol', '--checksum-seed', '--copy-as', '--early-input',
// 28 확인 (10/20): rsync --help 의 짧은 별칭 (aka --cc · --zc · --zl · --zt) 과 값 받는 옵션 - 빠져 있어 값이 "경로로 읽힙니다" 로 막혔다
'--cc', '--zc', '--zl', '--zt', '--compress-threads', '--confine-root',
};
/// 관리자 지시 (10/20, 28 · 15 확인): [splitOptions] 로 고쳐 읽은 뒤에도 rsync 가 다르게 읽을 것 - 있으면 확인 창 · 실시간 동기화에
/// 빨갛게 알리고 실행을 막는다: 닫히지 않은 따옴표, 옵션 자리의 - 로 시작하지 않는 낱말 (rsync 는 원본 경로로 읽어 옵션이 몰래 빠짐),
/// 하이픈 셋 이상, 끝에 값 없이 남은 값 받는 옵션. 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다
List<String> unreadableOptions(String options) {
List<String> unreadableOptions(String options, {bool? windows}) => [for (final x in optionProblems(options, windows: windows)) x.text];
/// 옵션 칸의 문제 종류 - 안내의 끝 문구 (고칠 방법) 를 고른다 (관리자 지시 10/20, 28 지적)
enum OptionProblemKind { unclosed, path, noValue, odd, doubleQuote, relativePath }
/// [unreadableOptions] 의 한 줄과 그 종류
class OptionProblem {
final OptionProblemKind kind;
final String text;
/// [text] 안의 옵션 낱말 (화면이 줄바꿈 없이 묶는다)
final String token;
const OptionProblem(this.kind, this.text, [this.token = '']);
@override
String toString() => text;
}
/// 문제 종류에 맞는 고칠 방법 (여러 종류면 · 로 잇는다)
String optionProblemHint(Iterable<OptionProblem> problems) {
final kinds = {for (final x in problems) x.kind};
return [
if (kinds.contains(OptionProblemKind.unclosed)) tr('따옴표를 닫아 주세요'),
if (kinds.contains(OptionProblemKind.path)) tr('옵션은 - 나 -- 로 시작합니다'),
if (kinds.contains(OptionProblemKind.noValue)) tr('값을 적어 주세요 (예: --exclude=*.tmp)'),
if (kinds.contains(OptionProblemKind.odd)) tr('곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'),
if (kinds.contains(OptionProblemKind.doubleQuote)) tr('큰따옴표 (") 를 빼 주세요'),
if (kinds.contains(OptionProblemKind.relativePath)) tr('드라이브 (C:) 나 / 로 시작하는 경로로'),
].join(' · ');
}
/// "옵션을 알아볼 수 없습니다: … - 고칠 방법" (작업 기록 · 실시간 동기화 카드 · 설정 칸)
String optionProblemMessage(List<OptionProblem> problems) =>
trf('옵션을 알아볼 수 없습니다: {0} - {1}', [problems.join(' · '), optionProblemHint(problems)]);
/// [unreadableOptions] 의 몸통 (종류와 함께)
List<OptionProblem> optionProblems(String options, {bool? windows}) {
windows ??= Platform.isWindows;
final parsed = _parseOptions(options);
// 15 확인 F1: 닫히지 않은 따옴표 - 뒤의 옵션이 다르게 읽힐 수 있다
final bad = [for (final u in parsed.unclosed) trf('따옴표가 닫히지 않았습니다: {0}', [u])];
final bad = [for (final u in parsed.unclosed) OptionProblem(OptionProblemKind.unclosed, trf('따옴표가 닫히지 않았습니다: {0}', [u]), u)];
var value = false;
var last = '';
for (final t in parsed.words) {
// P0 (10/20): Windows 의 rsync (MSYS2) 에는 큰따옴표가 든 인수를 그대로 넘길 수 없다 (prepareRsyncLaunch) - 값 자리도.
// 닫히지 않은 따옴표로 이미 막혔으면 (그 따옴표가 낱말에 남음) 한 번만 알린다
if (windows && parsed.unclosed.isEmpty && t.contains('"')) {
bad.add(OptionProblem(OptionProblemKind.doubleQuote, trf('큰따옴표 (") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}', [t]), t));
value = false;
continue;
}
if (value) {
value = false;
continue;
}
if (t.startsWith('---')) {
// 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다
bad.add(t);
bad.add(OptionProblem(OptionProblemKind.odd, t, t));
} else if (t.startsWith('--')) {
value = !t.contains('=') && _rsyncLongWithValue.contains(t);
last = t;
@ -220,14 +269,72 @@ List<String> unreadableOptions(String options) {
} else {
// 관리자 판단 (10/20, 28 확인): 옵션 칸에는 경로가 들어갈 자리가 없다 (원본 · 대상은 앱이 넣음) - 값 자리가 아닌데
// - 로 시작하지 않는 낱말 (-a delete 의 delete, 혼자 쓴 -) 은 rsync 가 원본 경로로 읽어 옵션이 몰래 빠진다
bad.add(trf('경로로 읽힙니다: {0}', [t]));
// ASCII 밖 글자로 시작하면 (« … 등) 붙여 넣은 이상한 글자 - 안내는 곧은 글자로 다시 적기
bad.add(OptionProblem(t.codeUnitAt(0) > 0x7F ? OptionProblemKind.odd : OptionProblemKind.path, trf('경로로 읽힙니다: {0}', [t]), t));
}
}
// 값이 필요한 옵션이 끝에 값 없이 (--exclude) - rsync 가 원본 경로를 그 값으로 먹는다
if (value) bad.add(trf('{0} 의 값이 없습니다', [last]));
if (value) bad.add(OptionProblem(OptionProblemKind.noValue, trf('{0} 의 값이 없습니다', [last]), last));
// 관리자 판단 (10/20): 목록 파일의 상대 경로 - rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의
// 작업 폴더 기준으로 읽어 서로 다른 파일을 볼 수 있다. 전체 경로만 받는다
for (final f in listFileValues(parsed.words)) {
if (!_absolutePath(f)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('목록 파일은 전체 경로로 적어 주세요: {0}', [f]), f));
}
// 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 - 상대 경로면 응답 파일 임시 폴더에 생겼다가 함께 지워져 찾을 수 없다.
// --backup-dir · --partial-dir · --temp-dir · --compare-dest 류는 받는 쪽 기준이라 그대로
for (final f in _optionValues(parsed.words, const {'--log-file', '--write-batch', '--read-batch', '--only-write-batch'})) {
if (!_absolutePath(f)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('기록 · 배치 파일은 전체 경로로 적어 주세요: {0}', [f]), f));
}
return bad;
}
/// 긴 옵션 [names] 의 값 (--이름=값 · --이름 값)
List<String> _optionValues(List<String> words, Set<String> names) => [
for (var i = 0; i < words.length; i++)
if (words[i].contains('=') && names.contains(words[i].substring(0, words[i].indexOf('='))))
words[i].substring(words[i].indexOf('=') + 1)
else if (names.contains(words[i]) && i + 1 < words.length)
words[i + 1],
];
/// 파일을 받는 옵션의 값 (--exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일").
/// ": 이름" · dir-merge 는 폴더마다 읽는 파일 이름이라 (원래 상대) 빼고
List<String> listFileValues(List<String> words) {
const fileOpts = {'--exclude-from', '--include-from', '--files-from'};
final out = <String>[];
String? mergeFile(String rule) => RegExp(r'^(?:\.|merge)(?:,[^\s]*)?\s+(.+)$').firstMatch(rule.trim())?.group(1);
for (var i = 0; i < words.length; i++) {
final w = words[i];
final next = i + 1 < words.length ? words[i + 1] : null;
final eq = w.indexOf('=');
if (w.startsWith('--') && eq > 0 && fileOpts.contains(w.substring(0, eq))) {
out.add(w.substring(eq + 1));
} else if (fileOpts.contains(w) && next != null) {
out.add(next);
i++;
} else if (w.startsWith('--filter=')) {
final f = mergeFile(w.substring(9));
if (f != null) out.add(f);
} else if (w == '--filter' && next != null) {
final f = mergeFile(next);
if (f != null) out.add(f);
i++;
} else if (!w.startsWith('--') && w.length > 1 && w.startsWith('-')) {
// 짧은 옵션 묶음의 f: 붙은 값 (-f". 목록") 또는 다음 낱말 (-avf ". 목록")
final k = w.indexOf(RegExp('[BefMT@]'), 1);
if (k < 0 || w[k] != 'f') continue;
final rule = k == w.length - 1 ? next : w.substring(k + 1);
if (k == w.length - 1) i++;
final f = rule == null ? null : mergeFile(rule);
if (f != null) out.add(f);
}
}
return out;
}
/// 전체 경로 (C:\ · C:/ · \\서버 · / 로 시작 - /cygdrive/c/… 와 Android 경로 포함)
bool _absolutePath(String f) => RegExp(r'^(?:[A-Za-z]:[\\/]|[\\/])').hasMatch(f);
/// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x).
/// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다.
String toCygwinPath(String path, {bool windows = true}) {

View File

@ -0,0 +1,126 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:isolate';
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 에서 (화면을 막지 않게), 한 문서는 한 번에 하나씩.
class RhwpEngine implements HwpPdfEngine {
static const libName = 'libjj_rhwp.so';
/// 글꼴을 찾을 폴더 (Android 는 rhwp 가 시스템 글꼴을 스스로 찾지 않는다 - 한글은 Noto Sans/Serif CJK KR 로 대신 그림).
/// 관리자 판단 (10/20): 한글 글꼴 컴포넌트를 받으면 그 폴더를 앞에 더한다 (아직 없음)
static const fontDirs = ['/system/fonts'];
/// 라이브러리를 읽을 수 있는지 (없으면 다른 앱으로 연다)
static bool get available {
try {
DynamicLibrary.open(libName);
return true;
} catch (_) {
return false;
}
}
@override
Future<HwpPdfDoc> open(String path, {String? password}) async {
final (handle, err) = await Isolate.run(() => _open(path, password ?? ''));
if (handle == 0) throw HwpPdfException(err ?? 'rhwp');
final pages = await Isolate.run(() => _pageCount(handle));
return _RhwpDoc(handle, pages);
}
}
class _RhwpDoc implements HwpPdfDoc {
final int _handle;
@override
final int pageCount;
var _closed = false;
/// 한 번에 하나씩 (rhwp 문서는 여러 스레드에서 함께 쓰지 않는다)
Future<void> _queue = Future.value();
_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;
}
@override
Future<void> exportPdf(int from, int to, String out, {List<String> fontDirs = const []}) => _serial(() async {
if (_closed) throw const HwpPdfException('closed');
final h = _handle, dirs = fontDirs.join('\n');
final err = await Isolate.run(() => _export(h, from, to, dirs, out));
if (err != null) throw HwpPdfException(err);
});
@override
Future<void> close() => _serial(() async {
if (_closed) return;
_closed = true;
_close(_handle);
});
}
// ───────── C 함수 (isolate 안에서 부른다 - 핸들은 주소 숫자로 넘긴다) ─────────
typedef _OpenC = Pointer<Void> Function(Pointer<Utf8>, Pointer<Utf8>, 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<Pointer<Utf8>>);
typedef _ExportD = int Function(Pointer<Void>, int, int, Pointer<Utf8>, Pointer<Utf8>, Pointer<Pointer<Utf8>>);
typedef _CloseC = Void Function(Pointer<Void>);
typedef _CloseD = void Function(Pointer<Void>);
typedef _FreeC = Void Function(Pointer<Utf8>);
typedef _FreeD = void Function(Pointer<Utf8>);
DynamicLibrary _lib() => DynamicLibrary.open(RhwpEngine.libName);
/// 돌려받은 오류 글을 읽고 푼다
String? _takeError(DynamicLibrary lib, Pointer<Pointer<Utf8>> err) {
final p = err.value;
if (p == nullptr) return null;
final s = p.toDartString();
lib.lookupFunction<_FreeC, _FreeD>('jj_rhwp_free')(p);
return s;
}
(int, String?) _open(String path, String password) {
final lib = _lib();
final open = lib.lookupFunction<_OpenC, _OpenC>('jj_rhwp_open');
final a = path.toNativeUtf8(), b = password.toNativeUtf8();
final err = calloc<Pointer<Utf8>>();
try {
final doc = open(a, b, err);
return (doc.address, _takeError(lib, err));
} finally {
calloc.free(a);
calloc.free(b);
calloc.free(err);
}
}
int _pageCount(int handle) => _lib().lookupFunction<_CountC, _CountD>('jj_rhwp_page_count')(Pointer<Void>.fromAddress(handle));
String? _export(int handle, 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 err = calloc<Pointer<Utf8>>();
try {
final r = export(Pointer<Void>.fromAddress(handle), from, to, d, o, err);
final e = _takeError(lib, err);
return r == 0 ? null : (e ?? 'rhwp');
} finally {
calloc.free(d);
calloc.free(o);
calloc.free(err);
}
}
void _close(int handle) => _lib().lookupFunction<_CloseC, _CloseD>('jj_rhwp_close')(Pointer<Void>.fromAddress(handle));

View File

@ -19,7 +19,7 @@ import 'rsync_setup.dart';
import 'schedule_editor.dart';
import 'theme.dart';
import 'setting_tile.dart';
import 'sync_preview_view.dart' show OptionTextFormatter, keepOptionsTogether;
import 'sync_preview_view.dart' show OptionTextFormatter, keepOptionsTogether, optionProblemText;
/// 환경 설정의 복사 · 동기화 부분.
/// - 파일 탐색기 ([rsync] false): 파일 / 폴더 복사 방법 (현재 방식 · robocopy), robocopy 옵션, 속도 제한
@ -79,7 +79,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set, {bool rsync = false}) {
final ctl = _optionText.putIfAbsent(title, () => TextEditingController(text: value));
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 rsync 옵션은 칸 아래에 빨갛게 (실행 · 실시간 동기화는 막힘)
final bad = rsync ? unreadableOptions(ctl.text) : const <String>[];
final bad = rsync ? optionProblems(ctl.text) : const <OptionProblem>[];
return SettingTile(
title: Text(title),
subtitle: Text(hint),
@ -93,7 +93,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
errorText: bad.isEmpty ? null : trf('옵션을 알아볼 수 없습니다: {0}', [bad.join(' · ')]),
errorText: bad.isEmpty ? null : optionProblemText(bad),
errorMaxLines: 3,
suffixIcon: IconButton(
tooltip: trf('기본값으로 ({0})', [def]),

View File

@ -25,6 +25,7 @@ import '../core/ftp.dart' show FtpCertException, FtpRegistry;
import '../core/smb.dart' show SmbRegistry;
import '../core/webdav.dart' show DavRegistry;
import '../platform/android/android_storage.dart';
import '../platform/android/hwp_pdf_ffi.dart' show RhwpEngine;
import 'app_actions.dart';
import 'explorer_look.dart';
import 'key_hint.dart';
@ -50,6 +51,7 @@ import '../app/component_store.dart';
import '../app/components.dart';
import 'zip_page.dart';
import '../core/reader_sources.dart';
import '../core/hwp_source.dart';
import '../l10n/tr.dart';
import 'explorer_bookmarks.dart';
import 'explorer_search.dart';
@ -1686,6 +1688,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (ext == 'pdf') return _viewPdf(path);
if (zipExtensions.contains(ext)) return _openZip(path, pane);
}
// 관리자 지시 (10/20): Android 의 HWP · HWPX 는 앱 안에서 PDF 로 바꿔 본다 (rhwp - APK 에 들어 있음, 보기 컴포넌트를 켰을 때).
// 열지 못하면 (암호 · 깨진 파일 · 라이브러리 없음) 이유를 알리고 예전처럼 다른 앱으로
if (Platform.isAndroid && c.settings.components.contains('viewer') && hwpExtensions.contains(extOf(path)) && RhwpEngine.available) {
if (await _viewHwp(path)) return;
}
// 문서 미리보기 (Windows: 설치한 변환기로 PDF 로 바꿔 봄)
if (c.settings.components.contains('docs') && Platform.isWindows &&
AppComponent.docExtensions.contains(extOf(path))) {
@ -1797,6 +1804,42 @@ class _ExplorerPageState extends State<ExplorerPage> {
await openReader(context, c, src);
}
/// 관리자 지시 (10/20): HWP · HWPX → 보기 화면 (보는 쪽부터 몇 쪽씩 PDF 로 바꿈 - [HwpSource]). 열지 못하면 false
Future<bool> _viewHwp(String path) async {
final temp = await c.services.storage.tempDirectory();
if (!mounted) return true;
final dialog = showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
content: Row(children: [
const CircularProgressIndicator(),
const SizedBox(width: 16),
Expanded(child: Text(trf('여는 중: {0}', [vBasename(path)]))),
]),
),
);
HwpSource? src;
Object? error;
try {
src = await HwpSource.open(path, engine: RhwpEngine(), tempDir: temp, fontDirs: RhwpEngine.fontDirs);
} catch (e) {
error = e;
}
if (mounted) Navigator.of(context).pop();
await dialog;
if (src == null) {
_snack(trf('앱 안에서 열지 못해 다른 앱으로 엽니다: {0}', [error]));
return false;
}
if (!mounted) {
await src.dispose();
return true;
}
await openReader(context, c, src);
return true;
}
Future<void> _viewPdf(String path) async {
final temp = await c.services.storage.tempDirectory();
PdfSource src;
@ -3433,7 +3476,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 같은 거르기를 따르므로 미리 볼 수 없는 규칙도 똑같이 알린다
List<String> filterLimits() => {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList();
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션이 남으면 [실행] 을 막는다
List<String> badOptions() => {for (final t in tasks) ...unreadableOptions(opts(t))}.toList();
List<OptionProblem> badOptions() {
final seen = <String>{};
return [for (final t in tasks) ...optionProblems(opts(t)).where((x) => seen.add(x.text))];
}
// 93: 지우는 실행 (--delete 등. 100: WebDAV 도 따른다) 은 비교가 끝나 지울 목록이 보인 뒤에만
bool deletes() => tasks.any((t) => optionsDelete(opts(t)));
// 105: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다

View File

@ -40,6 +40,8 @@ List<(List<String>, String)> get _licenseAssets => [
(['smbj 0.15.0 · asn-one · Bouncy Castle · SLF4J · MBassador (Android SMB)'], 'assets/licenses/SMBJ_LICENSES.txt'),
// Tor 브라우저: tor 와 함께 묶인 Libevent · OpenSSL · zlib · zstd (공식 번들의 docs)
(['Tor 0.4.9.13 (Libevent · OpenSSL · zlib · Zstandard)'], 'assets/licenses/TOR_LICENSES.txt'),
// 관리자 지시 (10/20): Android 의 HWP · HWPX 보기 (libjj_rhwp.so - rhwp MIT 와 함께 묶인 Rust crate 들)
if (Platform.isAndroid) (['rhwp 0.8.7 (HWP · HWPX → PDF)'], 'assets/licenses/RHWP_LICENSES.txt'),
// 175: 함께 빌드한 네이티브 라이브러리 (Dart 쪽 패키지 라이선스와 따로)
([tr('whisper.cpp · ggml (whisper_ggml 안)')], 'assets/licenses/WHISPER_CPP_LICENSE.txt'),
(['ONNX Runtime 1.15.1 (Microsoft)'], 'assets/licenses/ONNXRUNTIME_LICENSES.txt'),

View File

@ -663,7 +663,7 @@ class _CopyCardState extends State<_CopyCard> {
style: TextStyle(fontSize: 12, color: t.move ? Colors.orangeAccent : JjColors.textDim),
),
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 (실행은 작업이 막는다)
if (_method == 'rsync' && unreadableOptions(_options.text).isNotEmpty) OptionProblemNote(unreadableOptions(_options.text)),
if (_method == 'rsync' && optionProblems(_options.text).isNotEmpty) OptionProblemNote(optionProblems(_options.text)),
if (!t.move && optionsRemoveSource(_options.text))
Text(tr('복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요'),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),

View File

@ -7,6 +7,7 @@ import 'package:wakelock_plus/wakelock_plus.dart';
import '../app/app_controller.dart';
import '../core/reader_sources.dart';
import '../core/hwp_source.dart' show HwpSource;
import '../core/vfs.dart';
import '../l10n/tr.dart';
import 'ai_image_page.dart';
@ -159,7 +160,8 @@ class _ReaderPageState extends State<ReaderPage> {
if (d == null) return false;
Size turned(Size s) => _turns.isOdd ? Size(s.height, s.width) : s;
return autoSplitsPage(turned(d), [for (final e in _dims.entries) if (e.key != page) turned(e.value)],
bundle: src is! ImageFilesSource);
// 관리자 지시 (10/20) · 내 판단: HWP · HWPX 문서의 가로 쪽 (가로 표 등) 은 만화 두 쪽 스캔이 아니라 나누지 않는다
bundle: src is! ImageFilesSource && src is! HwpSource);
}
/// 223: 나눠 본 장에서 바로 한 장으로 (설정을 "끄기" 로)

View File

@ -228,7 +228,7 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
// 177: rsync 로 맞출 때만 옵션의 거르기 (--exclude 등) 를 비교에도 (앱이 직접 · robocopy 는 이 규칙을 쓰지 않음)
// 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 rsync 를 고른 작업이면 같은 거르기를 따른다 (현재 방식 · robocopy 는 이 규칙을 쓰지 않음)
final filter = t.method == 'rsync' ? RsyncFilter.parse(t.options) : RsyncFilter.none;
final bad = t.method == 'rsync' ? unreadableOptions(t.options) : const <String>[];
final bad = t.method == 'rsync' ? optionProblems(t.options) : const <OptionProblem>[];
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
@ -341,15 +341,26 @@ class OptionTextFormatter extends TextInputFormatter {
}
/// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 - 빨갛게 알리고 [실행] 을 막는다 (rsync 가 원본 경로로 읽어 옵션이 몰래 빠짐)
/// 알아볼 수 없는 옵션 안내 글 (확인 창 · 설정 칸): 옵션 낱말은 줄바꿈 없이 묶고 (28 지적: "--" 와 "exclude" 로 갈림),
/// 고칠 방법의 - · -- 뒤에서도 줄을 바꾸지 않는다 (나머지 글은 보통처럼 줄을 바꾼다)
String optionProblemText(List<OptionProblem> bad) {
final wj = String.fromCharCode(0x2060);
return trf('옵션을 알아볼 수 없습니다: {0} - {1}', [
bad.map((x) => x.token.isEmpty ? x.text : x.text.replaceFirst(x.token, unbreakableOption(x.token))).join(' · '),
optionProblemHint(bad).replaceAll('-', '-$wj'),
]);
}
class OptionProblemNote extends StatelessWidget {
final List<String> bad;
final List<OptionProblem> bad;
const OptionProblemNote(this.bad, {super.key});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
trf('옵션을 알아볼 수 없습니다: {0} - 곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요', [bad.map(unbreakableOption).join(' · ')]),
// 28 지적 (10/20): 끝 문구는 문제 종류에 맞게 (따옴표 닫기 · - 로 시작 · 값 적기 · 곧은 글자로)
optionProblemText(bad),
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
);
}

View File

@ -229,11 +229,11 @@ void main() {
..writeAsStringSync('o');
bool? r;
// 관리자 지시 (10/20): H 는 이제 미리 볼 수 있다 - 아직 따르지 못하는 merge 규칙으로
final t = CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete --filter="merge rules.txt"');
final t = CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete --filter="merge C:/jj_rules/rules.txt"'); // 목록 파일은 전체 경로 (관리자 판단 10/20)
await host(tester, (ctx) async => r = await confirmDeletingRun(ctx, t));
await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 200);
expect(find.textContaining('미리 볼 수 없습니다'), findsOneWidget);
expect(find.textContaining('merge rules.txt'), findsWidgets);
expect(find.textContaining('merge C:/jj_rules/rules.txt'), findsWidgets);
FilledButton run() => tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행'));
expect(run().onPressed, isNull, reason: '체크 전에는 실행할 수 없음');
await tester.tap(find.text('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)'));

View File

@ -312,7 +312,7 @@ void main() {
testWidgets('[지금 지우기 (확인)] 창: 빨간 "미리 볼 수 없음" · 체크해야 [지우고 맞추기] · [지우지 않기] 는 없음', (tester) async {
if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
c.settings.rsyncOptions = '-a --filter="merge rules.txt"'; // 아직 따르지 못하는 규칙 (H 는 이제 따름)
c.settings.rsyncOptions = '-a --filter="merge C:/jj_rules/rules.txt"'; // 아직 따르지 못하는 규칙 (H 는 이제 따름) · 목록 파일은 전체 경로
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true);
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);

View File

@ -105,11 +105,28 @@ void main() {
expect(unreadableOptions('-a --exclude *.log --delete'), isEmpty, reason: '값이 있음');
expect(unreadableOptions('-a --exclude=*.log'), isEmpty);
expect(unreadableOptions(''), isEmpty);
// 28 확인 (10/20): rsync 의 짧은 별칭 (--cc · --zc · --zl) 도 값을 받는다 (오탐이던 것)
expect(unreadableOptions('-a --cc md5 --zc zlib --zl 3 --zt 2'), isEmpty);
// 곧은 하이픈과 대시가 섞인 것 (-\u2013delete)
expect(splitOptions('-a -\u2013delete \u2013-del'), ['-a', '--delete', '--del']);
expect(unreadableOptions('-a -\u2013delete'), isEmpty);
});
test('28 지적 (10/20): 안내 끝 문구는 문제 종류에 맞게', () {
String hint(String o) => optionProblemHint(optionProblems(o));
expect(hint('-a --exclude="a.tmp'), '따옴표를 닫아 주세요');
expect(hint('-a delete'), '옵션은 - 나 -- 로 시작합니다');
expect(hint('-a --exclude'), '값을 적어 주세요 (예: --exclude=*.tmp)');
expect(hint('-a ---delete'), '곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요');
expect(hint('-a \u00ABx\u00BB'), '곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요');
expect(hint('-a delete --exclude'), '옵션은 - 나 -- 로 시작합니다 · 값을 적어 주세요 (예: --exclude=*.tmp)');
expect(optionProblemMessage(optionProblems('-a delete')), '옵션을 알아볼 수 없습니다: 경로로 읽힙니다: delete - 옵션은 - 나 -- 로 시작합니다');
});
testWidgets('알아볼 수 없는 옵션 알림 (빨간 글, 옵션 글은 줄바꿈 없이)', (tester) async {
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OptionProblemNote(['\u00ABdelete\u00BB']))));
await tester.pumpWidget(MaterialApp(home: Scaffold(body: OptionProblemNote(optionProblems('-a \u00ABdelete\u00BB')))));
expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsOneWidget);
expect(find.textContaining('곧은 따옴표와 하이픈'), findsOneWidget);
});
// 28 확인 (10/20): 막기 전 실제 rsync 는 이런 낱말을 원본 경로로 먹는다 - 그래서 [unreadableOptions] 로 막는다

View File

@ -0,0 +1,170 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/live_sync.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/app/transfer_job.dart';
import 'package:jj_mkvmaker/core/rsync_launch.dart';
import 'package:jj_mkvmaker/core/sync_tools.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:path/path.dart' as p;
import 'support/rsync_scenes.dart' show sceneRsync, treeOf;
/// P0 (관리자 지시 10/20, 28 재현 - 데이터): Windows 의 rsync (MSYS2 런타임) 가 명령 줄을 다시 읽어 ' 를 짝짓고 (거르기가 사라져
/// 지킬 파일이 지워짐 · ' 경로가 합쳐져 실패) * ? [ ] { } 를 작업 폴더의 파일로 펼치던 것 - 응답 파일 + MSYS=noglob 으로 그대로 넘긴다.
/// 앱이 rsync 를 띄우는 두 길 (복사 · Rsync 화면 · 모니터링 · 최종 정리 = TransferJob, 실시간 동기화 = LiveSync) 을 실제 rsync 로.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('도우미: Windows 는 응답 파일 · MSYS=noglob, 그 밖은 그대로 · 큰따옴표가 든 인수는 막음', () async {
final plain = await prepareRsyncLaunch(['-a', "it's"], windows: false);
expect(plain.args, ['-a', "it's"]);
expect(plain.environment, isNull);
final w = await prepareRsyncLaunch(['-a', "--exclude=it's.txt", 'a b'], windows: true);
addTearDown(w.dispose);
expect(w.args, ['@args.txt']);
expect(w.environment!['MSYS'], contains('noglob'));
expect(File(p.join(w.workingDirectory!, 'args.txt')).readAsStringSync(), '"-a" "--exclude=it\'s.txt" "a b"');
await expectLater(prepareRsyncLaunch(['--exclude=a"b'], windows: true), throwsA(isA<StateError>()));
// 옵션 칸에서도 미리 빨갛게 (Windows 만)
expect(unreadableOptions('-a --exclude=\'a"b\'', windows: true), hasLength(1));
expect(unreadableOptions('-a --exclude=\'a"b\'', windows: false), isEmpty);
expect(unreadableOptions('-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"', windows: true), isEmpty);
});
test('관리자 판단 (10/20): 목록 파일의 상대 경로는 막는다 (rsync 와 미리 보기가 다른 작업 폴더 기준으로 읽음) - 전체 경로 · 폴더마다 읽는 규칙은 그대로', () {
for (final o in [
'-a --exclude-from=list.txt',
'-a --exclude-from list.txt',
'-a --include-from=./a.txt',
'-a --files-from=sub/files.txt',
'-a --filter=". rules.txt"',
'-a --filter="merge,- rules.txt"',
'-a --filter ". w.txt"',
'-a -f". y.txt"',
'-avf ". z.txt"',
'-a --exclude-from=~/list.txt',
]) {
expect(unreadableOptions(o, windows: true), [contains('목록 파일은 전체 경로로 적어 주세요')], reason: o);
}
for (final o in [
r'-a --exclude-from=C:\lists\l.txt',
'-a --exclude-from=C:/lists/l.txt',
'-a --include-from=/cygdrive/c/l.txt',
r'-a --files-from=\\nas\share\l.txt',
'-a --exclude-from /sdcard/l.txt',
'-a --filter=". /cygdrive/c/rules.txt"',
r'-a -f". C:\r.txt"',
'-a --filter=": .rsync-filter"',
'-a --filter="dir-merge .x"',
'-a -F',
'-a --exclude=list.txt',
// 받는 쪽 기준이라 그대로 (28 확인)
'-a --backup-dir=old --partial-dir=.part --temp-dir=tmp --compare-dest=../base',
r'-a --log-file=C:\logs\r.log --write-batch=/cygdrive/c/b/batch',
]) {
expect(unreadableOptions(o, windows: true), isEmpty, reason: o);
}
// 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 전체 경로만 (상대면 응답 파일 임시 폴더에 생겼다가 함께 지워짐)
for (final o in ['-a --log-file=r.log', '-a --log-file r.log', '-a --write-batch=b', '-a --only-write-batch b', '-a --read-batch=./b']) {
expect(unreadableOptions(o, windows: true), [contains('기록 · 배치 파일은 전체 경로로 적어 주세요')], reason: o);
}
});
group('실제 rsync', () {
final rsync = sceneRsync;
late Directory tmp;
setUp(() => tmp = Directory.systemTemp.createTempSync('jj_msysargs_'));
tearDown(() => tmp.deleteSync(recursive: true));
/// 원본 · 대상 폴더를 만들고 파일을 넣는다 (내용 = 쪽:이름)
(String, String) scene(String sn, String dn, List<String> sf, List<String> df) {
final s = Directory(p.join(tmp.path, sn))..createSync(recursive: true);
final d = Directory(p.join(tmp.path, dn))..createSync(recursive: true);
for (final f in sf) {
File(p.join(s.path, f))
..parent.createSync(recursive: true)
..writeAsStringSync('s:$f');
}
for (final f in df) {
File(p.join(d.path, f))
..parent.createSync(recursive: true)
..writeAsStringSync('d:$f');
}
return (s.path, d.path);
}
// (이름, 옵션, 원본 폴더, 대상 폴더, 원본 파일, 대상 파일, 남아야 할 것 (대상), 없어야 할 것 (대상))
final cases = <(String, String, String, String, List<String>, List<String>, List<String>, List<String>)>[
("28 재현: ' 두 개의 --exclude", '-a --delete --exclude="it\'s.txt" --exclude="Bob\'s.txt"', 's', 'd', ['a.txt'],
["it's.txt", "Bob's.txt", 'old.txt'], ["it's.txt", "Bob's.txt", 'a.txt'], ['old.txt']),
("' 하나의 --exclude", "-a --delete --exclude=\"it's.txt\"", 's', 'd', ['a.txt'], ["it's.txt", 'old.txt'], ["it's.txt", 'a.txt'], ['old.txt']),
("' 가 든 원본 경로", '-a --delete', "s'1", 'd', ["a'b.txt"], ['old.txt'], ["a'b.txt"], ['old.txt']),
("' 가 든 원본 · 대상 경로", '-a --delete', "s'1", "d'2", ['a.txt', "c'd/e'f.txt"], ['old.txt'], ['a.txt', "c'd/e'f.txt"], ['old.txt']),
("' 경로 + ' --exclude", "-a --delete --exclude=\"it's.txt\"", "s'1", "d'2", ['a.txt'], ["it's.txt", 'old.txt'], ["it's.txt", 'a.txt'],
['old.txt']),
('따로 쓴 * (작업 폴더에 맞는 파일이 있어도)', '-a --delete --exclude *.txt', 's', 'd', ['a.txt', 'n.log'], ['keep.txt', 'old.log'],
['keep.txt', 'n.log'], ['old.log', 'a.txt']),
('따로 쓴 ? · [ ] · { }', '-a --delete --exclude b?.txt --exclude [x]* --exclude {q,r}.txt', 's', 'd', ['a.txt'],
['b9.txt', 'xk.txt', '{q,r}.txt', 'q.txt', 'old.txt'], ['b9.txt', 'xk.txt', '{q,r}.txt', 'a.txt'], ['q.txt', 'old.txt']),
('[ ] · { } · 빈칸 · 한글이 든 경로', '-a --delete', '원본 [1] {a}', "받는 쪽 [2] {b}'s", ['가 나.txt'], ['old.txt'], ['가 나.txt'], ['old.txt']),
];
for (final (name, opts, sn, dn, sf, df, keep, gone) in cases) {
test('복사 작업 (TransferJob): $name', () async {
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
final (s, d) = scene(sn, dn, sf, df);
// 작업 폴더에 글로브에 맞을 파일을 둔다 (예전에는 따로 쓴 * 가 이것들로 펼쳐졌다)
final cwd = Directory.current;
final job = TransferJob(sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: opts, rsyncExe: rsync, contents: true);
await job.run();
expect(job.error, isNull, reason: '${job.error}');
final t = treeOf(d);
for (final k in keep) {
expect(t.containsKey(k), isTrue, reason: '$name: $k 가 있어야 함 - $t');
}
for (final g in gone) {
expect(t.containsKey(g), isFalse, reason: '$name: $g 는 없어야 함 - $t');
}
expect(Directory.current.path, cwd.path);
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
}
test("실시간 동기화 (LiveSync rsync 쌍): ' 두 개의 --exclude · ' 가 든 경로", () async {
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
final (s, d) = scene("s'1", "d'2", ['a.txt'], ["it's.txt", "Bob's.txt", 'old.txt']);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = rsync
..rsyncOptions = '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"'
..liveSyncPairs = [LiveSyncPair(s, d, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"')];
final live = LiveSync(c);
addTearDown(live.dispose);
final pair = c.settings.liveSyncPairs.single;
await live.syncNow(pair);
expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패')));
final t = treeOf(d);
expect(t.keys.toSet(), {"it's.txt", "Bob's.txt", 'a.txt'}, reason: '거르기를 지키고 old.txt 만 지움');
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
test('응답 파일은 끝나면 지운다', () async {
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
final (s, d) = scene('s', 'd', ['a.txt'], []);
final job = TransferJob(sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: '-a', rsyncExe: rsync, contents: true);
await job.run();
expect(job.error, isNull);
// 이 작업의 응답 파일 (이 시험의 경로가 든 것) 이 남지 않았는지 - 함께 도는 다른 시험의 것은 세지 않는다
final mine = [
for (final e in Directory.systemTemp.listSync())
if (p.basename(e.path).startsWith('jj_rsargs_') && File(p.join(e.path, 'args.txt')).existsSync())
if (File(p.join(e.path, 'args.txt')).readAsStringSync().contains(toCygwinPath(s))) e.path,
];
expect(mine, isEmpty);
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
});
}

View File

@ -0,0 +1,95 @@
#!/bin/sh
# 관리자 지시 (10/20): Android (arm64) 에서 HWP · HWPX 를 PDF 로 바꿔 앱의 보기 화면으로 연다 - rhwp (Rust, MIT) 를
# tool/rhwp_ffi (얇은 C ABI) 와 함께 NDK 로 빌드해 third_party/rhwp/android/arm64-v8a/libjj_rhwp.so 로 둔다 (rsync · 7zz 와 같은 방식 -
# APK 의 jniLibs 로 들어가 앱이 dart:ffi 로 읽는다).
# 소스: https://github.com/edwardkim/rhwp v0.8.7 = 커밋 1a76570 (고정 - git 커밋 해시가 내용의 SHA-1 지문이다, 고치지 않음).
# 저장소가 크므로 (예제 문서 · PDF 로 4GB 넘음) 빌드에 쓰는 폴더만 받는다 (git sparse, 깊이 1).
# 라이선스: rhwp MIT (LICENSE) · 의존 crate 들 (THIRD_PARTY_LICENSES.md) - 둘 다 third_party/rhwp 에 함께 둔다.
# 도구: Rust 1.93.1 (rhwp 의 rust-toolchain.toml 과 같음, tools/rust - RUSTUP_HOME · CARGO_HOME) + Android NDK clang (링커).
# Windows 에서는 호스트 쪽 (빌드 스크립트) 을 MSVC 로 링크하므로 tools/VSBuildTools 의 vcvars64 환경을 읽어 온다.
# 16KB 쪽 기기 (Android 15+) 에서도 읽히게 -z max-page-size=16384, 다시 만들어도 같은 파일이 되게 경로를 /jj 로 바꿔 적는다.
# 의존 crate 판은 tool/rhwp_ffi/Cargo.lock 으로 고정 (--locked).
# 사용법 (Git Bash): sh tool/build_rhwp_android.sh - Android NDK 가 필요 (ANDROID_NDK 또는 tools/android-sdk/ndk/*)
# 다시 만들어지는지 보기: JJ_RHWP_CHECK=1 sh tool/build_rhwp_android.sh (깨끗한 target 으로 두 번 빌드해 해시를 견줌)
set -e
APP=$(cd "$(dirname "$0")/.." && pwd)
TOOLS=$(cd "$APP/.." && pwd)/tools
VER=0.8.7
REV=1a76570e833917d15817415a53c09ad61ab3203f
NDK_ROOT=${ANDROID_NDK:-$(ls -d "$TOOLS"/android-sdk/ndk/* | sort | tail -1)}
case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) HOST=windows-x86_64; EXE=.exe; CMD=.cmd ;; Darwin) HOST=darwin-x86_64; EXE=; CMD= ;; *) HOST=linux-x86_64; EXE=; CMD= ;; esac
TC=$NDK_ROOT/toolchains/llvm/prebuilt/$HOST/bin
export RUSTUP_HOME=${RUSTUP_HOME:-$TOOLS/rust/rustup}
export CARGO_HOME=${CARGO_HOME:-$TOOLS/rust/cargo}
export PATH="$CARGO_HOME/bin:$PATH"
# Windows: 호스트 링커 (MSVC) 환경
if [ "$HOST" = windows-x86_64 ] && [ -z "$VCINSTALLDIR" ]; then
VC=$(cygpath -w "$TOOLS/VSBuildTools/VC/Auxiliary/Build/vcvars64.bat")
ENVF=$(mktemp)
# cmd 에 따옴표를 넘기면 Git Bash 가 바꿔 버려, 부를 것을 .cmd 파일로 적어 그것을 돌린다
BAT=$(mktemp --suffix=.cmd)
printf '@call "%s" >nul\r\n@set\r\n' "$VC" > "$BAT"
cmd //c "$(cygpath -w "$BAT")" | tr -d '\r' > "$ENVF"
rm -f "$BAT"
while IFS='=' read -r k v; do
case "$k" in
Path|PATH) PATH="$(cygpath -u -p "$v"):$PATH" ;;
LIB|LIBPATH|INCLUDE|VCINSTALLDIR|VCToolsInstallDir|WindowsSdkDir|WindowsSDKVersion|UCRTVersion|UniversalCRTSdkDir) export "$k=$v" ;;
esac
done < "$ENVF"
rm -f "$ENVF"
export PATH
[ -n "$VCINSTALLDIR" ] || { echo "vcvars64 환경을 읽지 못함: $VC"; exit 1; }
fi
# 작업 폴더: tools 아래 (빌드 캐시가 수 GB - 시스템 임시 폴더 (C:) 가 모자라 시험이 실패했다, 10/20). 다시 빌드할 때 이어 쓴다
WORK=${JJ_RHWP_WORK:-$TOOLS/src/rhwp/build}
mkdir -p "$WORK"
SRC=$WORK/rhwp
if [ "$(git -C "$SRC" rev-parse HEAD 2>/dev/null)" != "$REV" ]; then
rm -rf "$SRC"
git init -q "$SRC"
git -C "$SRC" remote add origin https://github.com/edwardkim/rhwp.git
git -C "$SRC" sparse-checkout init --no-cone
printf '%s\n' '/*' '!/samples/' '!/pdf/' '!/mydocs/' '!/saved/' '!/tests/' '!/gym/' '!/rhwp-*/' '!/npm/' '!/typescript/' \
'!/tools/' '!/fuzz/' '!/scripts/' '/saved/blank2010.hwp' '/mydocs/manual/' '/mydocs/tech/' > "$SRC/.git/info/sparse-checkout"
git -C "$SRC" fetch -q --depth 1 --filter=blob:none origin "$REV"
git -C "$SRC" checkout -q FETCH_HEAD
fi
[ "$(git -C "$SRC" rev-parse HEAD)" = "$REV" ] || { echo "rhwp 커밋이 다름"; exit 1; }
[ -z "$(git -C "$SRC" status --porcelain)" ] || { echo "rhwp 소스가 바뀌어 있음"; exit 1; }
rm -rf "$WORK/ffi"
cp -r "$APP/tool/rhwp_ffi" "$WORK/ffi"
# 처음 한 번: 의존 판을 rhwp 의 Cargo.lock 그대로 이어받아 tool/rhwp_ffi/Cargo.lock 을 만든다 (그 뒤로는 --locked)
LOCKED=--locked
if [ ! -f "$APP/tool/rhwp_ffi/Cargo.lock" ]; then
cp "$SRC/Cargo.lock" "$WORK/ffi/Cargo.lock"
LOCKED=
fi
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang$CMD"
export CC_aarch64_linux_android="$TC/aarch64-linux-android24-clang$CMD"
export AR_aarch64_linux_android="$TC/llvm-ar$EXE"
W=$WORK
[ "$HOST" = windows-x86_64 ] && W=$(cygpath -m "$WORK")
export RUSTFLAGS="-C link-arg=-Wl,-z,max-page-size=16384 -C link-arg=-Wl,--build-id=none --remap-path-prefix=$W=/jj --remap-path-prefix=$(cygpath -m "$CARGO_HOME" 2>/dev/null || echo "$CARGO_HOME")=/cargo"
export JJ_RHWP_VERSION=$VER
build() {
cargo build -q --release $LOCKED --target aarch64-linux-android --manifest-path "$WORK/ffi/Cargo.toml" --target-dir "$1"
"$TC/llvm-strip$EXE" -o "$2" "$1/aarch64-linux-android/release/libjj_rhwp.so"
}
OUT="$APP/third_party/rhwp/android/arm64-v8a"
mkdir -p "$OUT"
build "$WORK/target" "$OUT/libjj_rhwp.so"
[ -n "$LOCKED" ] || cp "$WORK/ffi/Cargo.lock" "$APP/tool/rhwp_ffi/Cargo.lock"
cp "$SRC/LICENSE" "$OUT/../LICENSE.txt"
cp "$SRC/THIRD_PARTY_LICENSES.md" "$OUT/../THIRD_PARTY_LICENSES.md"
if [ -n "$JJ_RHWP_CHECK" ]; then
rm -rf "$WORK/target2"
build "$WORK/target2" "$WORK/again.so"
A=$(sha256sum < "$OUT/libjj_rhwp.so"); B=$(sha256sum < "$WORK/again.so")
[ "$A" = "$B" ] && echo "다시 만들어도 같음" || { echo "다시 만들면 다름: $A / $B"; exit 1; }
fi
ls -l "$OUT/libjj_rhwp.so"
sha256sum "$OUT/libjj_rhwp.so"

1928
tool/rhwp_ffi/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

20
tool/rhwp_ffi/Cargo.toml Normal file
View File

@ -0,0 +1,20 @@
# JJ_MKVMaker: HWP · HWPX → PDF (rhwp) 의 C ABI - tool/build_rhwp_android.sh 가 rhwp 소스 (커밋 고정) 옆에 두고 빌드한다
[package]
name = "jj_rhwp"
version = "0.1.0"
edition = "2021"
license = "MIT"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
# 빌드 스크립트가 ../rhwp 에 rhwp v0.8.7 (커밋 1a76570) 을 받아 둔다
rhwp = { path = "../rhwp", default-features = false }
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = true

View File

@ -0,0 +1,5 @@
# rhwp v0.8.7 과 같은 판 (rhwp 의 rust-toolchain.toml)
[toolchain]
channel = "1.93.1"
targets = ["aarch64-linux-android"]
profile = "minimal"

137
tool/rhwp_ffi/src/lib.rs Normal file
View File

@ -0,0 +1,137 @@
//! JJ_MKVMaker: HWP · HWPX 를 PDF 로 바꾸는 얇은 C ABI (rhwp, MIT - https://github.com/edwardkim/rhwp).
//! 관리자 지시 (10/20): Android 에서 HWP · HWPX 를 앱의 보기 화면 (PDF) 으로 연다 - 문서를 한 번 열고, 쪽 범위마다 PDF 를 만든다
//! (큰 문서는 앞쪽부터 몇 쪽씩 보이면서 뒤를 이어 만든다). Dart 는 dart:ffi 로 부른다 (lib/platform/android/hwp_pdf.dart).
//! 오류 글 · 돌려준 글은 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 rhwp::document_core::DocumentCore;
use rhwp::renderer::pdf::PdfExportOptions;
/// 연 문서
pub struct JjRhwpDoc {
core: DocumentCore,
}
fn text(p: *const c_char) -> Option<String> {
if p.is_null() {
return None;
}
Some(unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned())
}
fn set_err(err: *mut *mut c_char, msg: &str) {
if err.is_null() {
return;
}
let c = CString::new(msg.replace('\0', " ")).unwrap_or_default();
unsafe { *err = c.into_raw() };
}
fn guarded<T>(err: *mut *mut c_char, fail: T, f: impl FnOnce() -> Result<T, String>) -> T {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(v)) => v,
Ok(Err(e)) => {
set_err(err, &e);
fail
}
Err(p) => {
let msg = p
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| p.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".to_string());
set_err(err, &format!("rhwp panic: {msg}"));
fail
}
}
}
/// 판 (예: "0.8.7") - 라이선스 화면 · 작업 기록용. jj_rhwp_free 로 푼다
#[no_mangle]
pub extern "C" fn jj_rhwp_version() -> *mut c_char {
CString::new(option_env!("JJ_RHWP_VERSION").unwrap_or("?")).unwrap_or_default().into_raw()
}
/// 문서 열기 (password 는 null 이나 "" 이면 없음). 실패하면 null 과 err
#[no_mangle]
pub extern "C" fn jj_rhwp_open(path: *const c_char, password: *const c_char, err: *mut *mut c_char) -> *mut JjRhwpDoc {
guarded(err, null_mut(), || {
let path = text(path).ok_or("경로 없음")?;
let data = std::fs::read(&path).map_err(|e| e.to_string())?;
let core = match text(password).filter(|p| !p.is_empty()) {
Some(p) => DocumentCore::from_bytes_with_password(&data, p.as_bytes()),
None => DocumentCore::from_bytes(&data),
}
.map_err(|e| e.to_string())?;
Ok(Box::into_raw(Box::new(JjRhwpDoc { core })))
})
}
/// 쪽 수 (doc 이 null 이면 -1)
#[no_mangle]
pub extern "C" fn jj_rhwp_page_count(doc: *const JjRhwpDoc) -> c_int {
if doc.is_null() {
return -1;
}
let doc = unsafe { &*doc };
catch_unwind(AssertUnwindSafe(|| doc.core.page_count() as c_int)).unwrap_or(-1)
}
/// [from, to) 쪽 (0 부터) 을 PDF 파일 out 으로. font_dirs 는 줄바꿈 (\n) 으로 나눈 글꼴 폴더들 (Android 는 시스템 글꼴을
/// 스스로 찾지 않으므로 /system/fonts 와 앱이 받은 한글 글꼴 폴더를 넘긴다). 성공 0, 실패 -1 과 err
#[no_mangle]
pub extern "C" fn jj_rhwp_export_pdf(
doc: *const JjRhwpDoc,
from: c_int,
to: c_int,
font_dirs: *const c_char,
out: *const c_char,
err: *mut *mut c_char,
) -> c_int {
guarded(err, -1, || {
if doc.is_null() {
return Err("문서 없음".into());
}
let doc = unsafe { &*doc };
let count = doc.core.page_count() as c_int;
if from < 0 || to > count || from >= to {
return Err(format!("쪽 범위가 맞지 않음: {from}..{to} / {count}"));
}
let out = text(out).ok_or("저장할 곳 없음")?;
let mut options = PdfExportOptions::default();
options.font_paths = text(font_dirs)
.unwrap_or_default()
.split('\n')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.collect();
let pages: Vec<u32> = (from as u32..to as u32).collect();
let bytes = doc.core.render_pages_pdf_native_with_options(&pages, &options).map_err(|e| e.to_string())?;
// 다 쓴 뒤에 이름을 바꾼다 (보기 화면이 반쪽 파일을 읽지 않게)
let part = format!("{out}.part");
std::fs::write(&part, &bytes).map_err(|e| e.to_string())?;
std::fs::rename(&part, &out).map_err(|e| e.to_string())?;
Ok(0)
})
}
/// 문서 닫기
#[no_mangle]
pub extern "C" fn jj_rhwp_close(doc: *mut JjRhwpDoc) {
if !doc.is_null() {
let _ = catch_unwind(AssertUnwindSafe(|| drop(unsafe { Box::from_raw(doc) })));
}
}
/// 이 라이브러리가 돌려준 글 (err · 판) 풀기
#[no_mangle]
pub extern "C" fn jj_rhwp_free(s: *mut c_char) {
if !s.is_null() {
drop(unsafe { CString::from_raw(s) });
}
}