Compare commits
9 Commits
기능추가1
...
v2026.10.1
| Author | SHA1 | Date |
|---|---|---|
|
|
56c632d372 | |
|
|
ced0ae9e5d | |
|
|
4b450e2e5c | |
|
|
f947935c19 | |
|
|
849c83031d | |
|
|
634fcbc920 | |
|
|
bd0801ce66 | |
|
|
716057734a | |
|
|
b1a43c36c5 |
|
|
@ -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}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,3 +7,4 @@ libsdcli.so
|
|||
libsdcli_v80.so
|
||||
libtor.so
|
||||
lib7zz.so
|
||||
libjj_rhwp.so
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
"큰따옴표 (\") 를 빼 주세요": "二重引用符 (\") を外してください",
|
||||
"키 있음": "キーあり",
|
||||
"타갈로그어": "タガログ語",
|
||||
"타밀어": "タミル語",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
"큰따옴표 (\") 를 빼 주세요": "请去掉双引号 (\")",
|
||||
"키 있음": "已有密钥",
|
||||
"타갈로그어": "他加禄语",
|
||||
"타밀어": "泰米尔语",
|
||||
|
|
|
|||
|
|
@ -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` 기준으로 현행화되었으며, 의존성 업데이트 시 함께 갱신해야 한다.*
|
||||
|
|
@ -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 ·
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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('암호'));
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (_) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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}) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
@ -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]),
|
||||
|
|
|
|||
|
|
@ -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: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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: 나눠 본 장에서 바로 한 장으로 (설정을 "끄기" 로)
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)'));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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] 로 막는다
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
});
|
||||
}
|
||||
|
|
@ -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"
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# rhwp v0.8.7 과 같은 판 (rhwp 의 rust-toolchain.toml)
|
||||
[toolchain]
|
||||
channel = "1.93.1"
|
||||
targets = ["aarch64-linux-android"]
|
||||
profile = "minimal"
|
||||
|
|
@ -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) });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue