diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b03a45f51..abb52f53e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -139,11 +139,19 @@ the drag donut shows files and flips on `FlipKeyPressed` / `FlipWheelTurned`: th can't hold an event back; Explorer and Finder ignore Tab during a drag) and reports one flip per key press. The editor's Front / Back switch (`ToolbarPageViewModel.EditingBack`) edits either list with the same drag and drop. -## Crop overlay +A group is one slice that opens up to six non-group actions. Its members may be conversions or interactive tools +(for example the PDF Edit group contains Crop and the page editor); groups cannot contain another group. The group +editor offers both kinds, and `UserDataStore.EnsureConsistency` removes missing, duplicate, nested or excess members +without excluding tools. -The Crop tool (`tool-crop`) opens `Views/CropOverlayWindow` instead of running a job: the files one after another, +## Crop overlay and PDF page editor + +The image Crop tool (`tool-crop`) and PDF Crop (`tool-pdf-crop`) open `Views/CropOverlayWindow` instead of running a job: +the files one after another, each cropped by hand and saved by `CropService` (the preset's output rule, the source's quality kept, animated or -multi-frame pictures refused, the EXIF thumbnail dropped), one history entry for the run. +multi-frame pictures refused, the EXIF thumbnail dropped), one history entry for the run. A PDF preview is rendered +with PDFium and the chosen rectangle changes every page's crop box in a copied document opened in modify mode, so +text, vectors, outlines, forms, page labels and document information remain PDF objects and stay attached. - `CropOverlayViewModel` walks the queue (Enter saves and opens the next file, Skip, Esc closes), keeps a `CropQueueItem` per file for the strip of thumbnails (waiting, current, done, skipped, failed; a thumbnail is a @@ -166,6 +174,14 @@ multi-frame pictures refused, the EXIF thumbnail dropped), one history entry for - `Controls/CropMath` holds the arithmetic (ratios and their portrait forms, nudging by whole pixels, the frame in pixels, where the size chip goes, where the dial opens) and is unit tested. +`tool-pdf-pages` opens `Views/PdfPageEditorWindow`: it previews, reorders, rotates, deletes, adds and extracts pages. +Each source PDF's bytes and page sizes are cached once after the window has appeared; thumbnails and the large preview +render off the UI thread. A whole-document edit keeps the original catalogue and changes `/Rotate` in modify mode. +An actual reorder, deletion, addition or extraction builds a new PDF from selected page objects. A PDF that opens +without a password but requires an owner password for modify mode uses the same page-copy fallback as compression; +its outlines and form fields are not preserved by that fallback. Generated copies are writable even when the source +has the read-only attribute; the source's bytes and attributes are left alone. + ## Clip window The Cut a clip tool (`tool-clip`, first on the back of the video donut) opens `Views/ClipWindow` instead of running a diff --git a/docs/ENGINES.md b/docs/ENGINES.md index da2ea4fd3..d2da97b53 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -9,7 +9,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | Windows Imaging Component | part of Windows (Windows only) | **JPEG XR** (JXR, WDP, HDP; HDR screenshots of Windows) → the images ImageMagick writes, and PDF, ICNS, HEIC and SVG through PNG; HDR tone-mapped to SDR | | Vector graphics | built in (Svg.Skia, SkiaSharp, ACadSharp) | **SVG/SVGZ → PDF (vector) and images**, SVG ↔ SVGZ, **SVG → DXF** (→ DWG with the CAD engine), **raster images → SVG (tracing)**, PDF-compatible AI → PDF, **HP-GL / HP-GL/2 plot files (PLT) → SVG**, PDF and images (pen moves, packed PE polylines, arcs and filled polygons; text labels are not drawn), **Tajima DST embroidery → SVG** (stitches as lines, a palette colour per thread; → PNG, JPG, PDF from there) | | ICNS | built in | images → ICNS (16–1024 px), ICNS → images | -| PDFsharp | built in (library) | images → PDF, merge, split, page ranges, spreadsheet print-studio assembly, **compression** (smaller PDFs, see below), PDFs without their metadata | +| PDFsharp | built in (library) | images → PDF, merge, split, page ranges, page organising, lossless rotate and crop-box edits, spreadsheet print-studio assembly, **compression** (smaller PDFs, see below), PDFs without their metadata | | PDFium | built in (library) | PDF → images (multi-page TIFF/GIF supported) | | Unhwp | built in (library) | HWP/HWPX → TXT, Markdown, HTML (pictures in a `_files` folder next to the Markdown, embedded in the HTML) | | Text & Markdown (Markdig) | built in (library) | **Markdown → HTML, TXT**; **TXT, LOG, PY, PAC, BAT, COMMAND, LUA, CSS, PS1 → UTF-8 TXT**, preserving source text without running scripts (and → PDF / DOCX through the HWPX writer, rhwp or LibreOffice) | diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index df65290f7..e05285961 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -284,6 +284,25 @@ "preset.merge_pdf": "دمج PDF", "merge.order_title": "اختيار ترتيب الدمج", "merge.order_hint": "حدّد ملفًا وانقله إلى أعلى أو أسفل. تُدمج الملفات بالترتيب من الأعلى إلى الأسفل. (‎Alt+↑ / Alt+↓‎)", + "preset.group_pdf_edit": "تحرير", + "preset.pdf_crop": "اقتصاص PDF", + "preset.pdf_crop.short": "اقتصاص", + "preset.pdf_pages": "ترتيب / حذف الصفحات", + "preset.pdf_pages.short": "الصفحات", + "preset.pdf_add": "إضافة صفحات", + "preset.pdf_add.short": "إضافة", + "preset.pdf_extract": "استخراج صفحة", + "preset.pdf_extract.short": "استخراج", + "preset.split_pdf.short": "تقسيم", + "pdf_editor.title": "تحرير صفحات PDF", + "pdf_editor.hint": "أعد ترتيب الصفحات أو أدرها أو احذفها. أضف صفحات من PDF آخر أو استخرج الصفحة المحددة.", + "pdf_editor.add": "إضافة PDF", + "pdf_editor.delete": "حذف", + "pdf_editor.page": "الصفحة {0}", + "pdf_editor.rotation": "تدوير {0}°", + "pdf_editor.loading": "جارٍ تحميل الصفحات…", + "pdf_editor.saving": "جارٍ حفظ PDF…", + "pdf_editor.saved": "تم الحفظ: {0}", "preset.split_pdf": "تقسيم PDF", "preset.compress_pdf": "ضغط PDF", "preset.searchable_pdf": "PDF قابل للبحث", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 48abe0c6a..2369443b6 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -294,6 +294,25 @@ "preset.merge_pdf": "Merge PDF", "merge.order_title": "Choose merge order", "merge.order_hint": "Select a file and move it up or down. Files are merged from top to bottom. (Alt+↑ / Alt+↓)", + "preset.group_pdf_edit": "Edit", + "preset.pdf_crop": "Crop PDF", + "preset.pdf_crop.short": "Crop", + "preset.pdf_pages": "Reorder / delete pages", + "preset.pdf_pages.short": "Pages", + "preset.pdf_add": "Add pages", + "preset.pdf_add.short": "Add", + "preset.pdf_extract": "Extract page", + "preset.pdf_extract.short": "Extract", + "preset.split_pdf.short": "Split", + "pdf_editor.title": "Edit PDF pages", + "pdf_editor.hint": "Reorder, rotate or delete pages. Add pages from another PDF, or extract the selected page as its own file.", + "pdf_editor.add": "Add PDF", + "pdf_editor.delete": "Delete", + "pdf_editor.page": "Page {0}", + "pdf_editor.rotation": "Rotated {0}°", + "pdf_editor.loading": "Loading pages…", + "pdf_editor.saving": "Saving PDF…", + "pdf_editor.saved": "Saved {0}", "preset.split_pdf": "Split PDF", "preset.compress_pdf": "Compress PDF", "preset.searchable_pdf": "Searchable PDF", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 847a0fd81..b1c23f91c 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -284,6 +284,25 @@ "preset.merge_pdf": "Combinar PDF", "merge.order_title": "Elegir el orden de combinación", "merge.order_hint": "Selecciona un archivo y muévelo arriba o abajo. Los archivos se combinan de arriba abajo. (Alt+↑ / Alt+↓)", + "preset.group_pdf_edit": "Editar", + "preset.pdf_crop": "Recortar PDF", + "preset.pdf_crop.short": "Recortar", + "preset.pdf_pages": "Reordenar / eliminar páginas", + "preset.pdf_pages.short": "Páginas", + "preset.pdf_add": "Añadir páginas", + "preset.pdf_add.short": "Añadir", + "preset.pdf_extract": "Extraer página", + "preset.pdf_extract.short": "Extraer", + "preset.split_pdf.short": "Dividir", + "pdf_editor.title": "Editar páginas PDF", + "pdf_editor.hint": "Reordena, gira o elimina páginas. Añade páginas de otro PDF o extrae la seleccionada como archivo independiente.", + "pdf_editor.add": "Añadir PDF", + "pdf_editor.delete": "Eliminar", + "pdf_editor.page": "Página {0}", + "pdf_editor.rotation": "Girada {0}°", + "pdf_editor.loading": "Cargando páginas…", + "pdf_editor.saving": "Guardando PDF…", + "pdf_editor.saved": "Guardado: {0}", "preset.split_pdf": "Dividir PDF", "preset.compress_pdf": "Comprimir PDF", "preset.searchable_pdf": "PDF con búsqueda", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index ce4c0312d..24847973a 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -294,6 +294,25 @@ "preset.merge_pdf": "Fusionner les PDF", "merge.order_title": "Choisir l'ordre de fusion", "merge.order_hint": "Sélectionnez un fichier et déplacez-le vers le haut ou le bas. Les fichiers sont fusionnés de haut en bas. (Alt+↑ / Alt+↓)", + "preset.group_pdf_edit": "Modifier", + "preset.pdf_crop": "Rogner le PDF", + "preset.pdf_crop.short": "Rogner", + "preset.pdf_pages": "Réorganiser / supprimer", + "preset.pdf_pages.short": "Pages", + "preset.pdf_add": "Ajouter des pages", + "preset.pdf_add.short": "Ajouter", + "preset.pdf_extract": "Extraire la page", + "preset.pdf_extract.short": "Extraire", + "preset.split_pdf.short": "Scinder", + "pdf_editor.title": "Modifier les pages PDF", + "pdf_editor.hint": "Réorganisez, faites pivoter ou supprimez des pages. Ajoutez des pages d’un autre PDF ou extrayez la page sélectionnée.", + "pdf_editor.add": "Ajouter un PDF", + "pdf_editor.delete": "Supprimer", + "pdf_editor.page": "Page {0}", + "pdf_editor.rotation": "Rotation {0}°", + "pdf_editor.loading": "Chargement des pages…", + "pdf_editor.saving": "Enregistrement du PDF…", + "pdf_editor.saved": "Enregistré : {0}", "preset.split_pdf": "Diviser le PDF", "preset.compress_pdf": "Compresser le PDF", "preset.searchable_pdf": "PDF consultable", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index faf6e8126..0e2fa4a35 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -284,6 +284,25 @@ "preset.merge_pdf": "PDF मर्ज करें", "merge.order_title": "मर्ज करने का क्रम चुनें", "merge.order_hint": "फ़ाइल चुनकर उसे ऊपर या नीचे ले जाएँ। फ़ाइलें ऊपर से नीचे के क्रम में मर्ज होंगी। (Alt+↑ / Alt+↓)", + "preset.group_pdf_edit": "संपादित करें", + "preset.pdf_crop": "PDF क्रॉप करें", + "preset.pdf_crop.short": "क्रॉप", + "preset.pdf_pages": "पुनः क्रम / पृष्ठ हटाएँ", + "preset.pdf_pages.short": "पृष्ठ", + "preset.pdf_add": "पृष्ठ जोड़ें", + "preset.pdf_add.short": "जोड़ें", + "preset.pdf_extract": "पृष्ठ निकालें", + "preset.pdf_extract.short": "निकालें", + "preset.split_pdf.short": "विभाजित करें", + "pdf_editor.title": "PDF पृष्ठ संपादित करें", + "pdf_editor.hint": "पृष्ठों का क्रम बदलें, घुमाएँ या हटाएँ। दूसरे PDF से पृष्ठ जोड़ें या चुना हुआ पृष्ठ निकालें।", + "pdf_editor.add": "PDF जोड़ें", + "pdf_editor.delete": "हटाएँ", + "pdf_editor.page": "पृष्ठ {0}", + "pdf_editor.rotation": "{0}° घुमाया", + "pdf_editor.loading": "पृष्ठ लोड हो रहे हैं…", + "pdf_editor.saving": "PDF सहेजा जा रहा है…", + "pdf_editor.saved": "सहेजा गया: {0}", "preset.split_pdf": "PDF बाँटें", "preset.compress_pdf": "PDF कंप्रेस करें", "preset.searchable_pdf": "खोजने योग्य PDF", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 9de6257ce..8920ca43d 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -284,6 +284,25 @@ "preset.merge_pdf": "結合", "merge.order_title": "結合順序を選択", "merge.order_hint": "ファイルを選択して上下に移動します。上から順に結合されます。(Alt+↑ / Alt+↓)", + "preset.group_pdf_edit": "編集", + "preset.pdf_crop": "PDF をトリミング", + "preset.pdf_crop.short": "トリミング", + "preset.pdf_pages": "並べ替え / 削除", + "preset.pdf_pages.short": "ページ", + "preset.pdf_add": "ページを追加", + "preset.pdf_add.short": "追加", + "preset.pdf_extract": "ページを抽出", + "preset.pdf_extract.short": "抽出", + "preset.split_pdf.short": "分割", + "pdf_editor.title": "PDF ページを編集", + "pdf_editor.hint": "ページの並べ替え、回転、削除ができます。別の PDF から追加するか、選択したページを抽出できます。", + "pdf_editor.add": "PDF を追加", + "pdf_editor.delete": "削除", + "pdf_editor.page": "{0} ページ", + "pdf_editor.rotation": "{0}° 回転", + "pdf_editor.loading": "ページを読み込み中…", + "pdf_editor.saving": "PDF を保存中…", + "pdf_editor.saved": "保存しました: {0}", "preset.split_pdf": "分割", "preset.compress_pdf": "圧縮", "preset.searchable_pdf": "検索可能なPDF", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 79a14b732..457fdf889 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -294,6 +294,25 @@ "preset.merge_pdf": "PDF 병합", "merge.order_title": "병합 순서 선택", "merge.order_hint": "파일을 골라 위아래로 옮기세요. 위에서부터 순서대로 병합돼요. (Alt+↑ / Alt+↓)", + "preset.group_pdf_edit": "편집", + "preset.pdf_crop": "PDF 자르기", + "preset.pdf_crop.short": "자르기", + "preset.pdf_pages": "페이지 순서 / 삭제", + "preset.pdf_pages.short": "페이지", + "preset.pdf_add": "페이지 추가", + "preset.pdf_add.short": "추가", + "preset.pdf_extract": "페이지 추출", + "preset.pdf_extract.short": "추출", + "preset.split_pdf.short": "분할", + "pdf_editor.title": "PDF 페이지 편집", + "pdf_editor.hint": "페이지 순서를 바꾸거나 회전·삭제하세요. 다른 PDF에서 페이지를 추가하거나 선택한 페이지를 추출할 수 있어요.", + "pdf_editor.add": "PDF 추가", + "pdf_editor.delete": "삭제", + "pdf_editor.page": "{0}페이지", + "pdf_editor.rotation": "{0}° 회전", + "pdf_editor.loading": "페이지 불러오는 중…", + "pdf_editor.saving": "PDF 저장 중…", + "pdf_editor.saved": "저장됨: {0}", "preset.split_pdf": "PDF 분할", "preset.compress_pdf": "PDF 압축", "preset.searchable_pdf": "검색 가능한 PDF", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 1546a0a2c..35a092357 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -284,6 +284,25 @@ "preset.merge_pdf": "PDF मर्ज गर्नुहोस्", "merge.order_title": "मर्ज क्रम मिलाउनुहोस्", "merge.order_hint": "फाइलहरूलाई इच्छित क्रममा मिलाउन तान्नुहोस् र छाड्नुहोस्", + "preset.group_pdf_edit": "सम्पादन", + "preset.pdf_crop": "PDF क्रप गर्नुहोस्", + "preset.pdf_crop.short": "क्रप", + "preset.pdf_pages": "पृष्ठहरू क्रम मिलाउनुहोस् / मेटाउनुहोस्", + "preset.pdf_pages.short": "पृष्ठहरू", + "preset.pdf_add": "पृष्ठहरू थप्नुहोस्", + "preset.pdf_add.short": "थप्नुहोस्", + "preset.pdf_extract": "पृष्ठ निकाल्नुहोस्", + "preset.pdf_extract.short": "निकाल्नुहोस्", + "preset.split_pdf.short": "विभाजन", + "pdf_editor.title": "PDF पृष्ठहरू सम्पादन गर्नुहोस्", + "pdf_editor.hint": "पृष्ठहरूको क्रम मिलाउनुहोस्, घुमाउनुहोस् वा मेटाउनुहोस्। अर्को PDF बाट पृष्ठहरू थप्नुहोस्, वा छानिएको पृष्ठलाई छुट्टै फाइलका रूपमा निकाल्नुहोस्।", + "pdf_editor.add": "PDF थप्नुहोस्", + "pdf_editor.delete": "मेटाउनुहोस्", + "pdf_editor.page": "पृष्ठ {0}", + "pdf_editor.rotation": "{0}° घुमाइयो", + "pdf_editor.loading": "पृष्ठहरू लोड हुँदैछन्…", + "pdf_editor.saving": "PDF सेभ हुँदैछ…", + "pdf_editor.saved": "सेभ भयो: {0}", "preset.split_pdf": "PDF विभाजन गर्नुहोस्", "preset.compress_pdf": "PDF कम्प्रेस गर्नुहोस्", "preset.searchable_pdf": "खोज्न मिल्ने PDF", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index c2adac993..1c91592be 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -294,6 +294,25 @@ "preset.merge_pdf": "Połącz PDF", "merge.order_title": "Wybierz kolejność scalania", "merge.order_hint": "Wybierz plik i przesuń go w górę lub w dół. Pliki są scalane od góry do dołu. (Alt+↑ / Alt+↓)", + "preset.group_pdf_edit": "Edytuj", + "preset.pdf_crop": "Przytnij PDF", + "preset.pdf_crop.short": "Przytnij", + "preset.pdf_pages": "Zmień kolejność / usuń", + "preset.pdf_pages.short": "Strony", + "preset.pdf_add": "Dodaj strony", + "preset.pdf_add.short": "Dodaj", + "preset.pdf_extract": "Wyodrębnij stronę", + "preset.pdf_extract.short": "Wyodrębnij", + "preset.split_pdf.short": "Podziel", + "pdf_editor.title": "Edytuj strony PDF", + "pdf_editor.hint": "Zmieniaj kolejność, obracaj lub usuwaj strony. Dodaj strony z innego PDF albo wyodrębnij wybraną stronę.", + "pdf_editor.add": "Dodaj PDF", + "pdf_editor.delete": "Usuń", + "pdf_editor.page": "Strona {0}", + "pdf_editor.rotation": "Obrót {0}°", + "pdf_editor.loading": "Wczytywanie stron…", + "pdf_editor.saving": "Zapisywanie PDF…", + "pdf_editor.saved": "Zapisano: {0}", "preset.split_pdf": "Podziel PDF", "preset.compress_pdf": "Kompresuj PDF", "preset.searchable_pdf": "Przeszukiwalny PDF", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 42c64df23..4240e8cc6 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -294,6 +294,25 @@ "preset.merge_pdf": "Mesclar PDF", "merge.order_title": "Escolha a ordem da mesclagem", "merge.order_hint": "Selecione um arquivo e mova para cima ou para baixo. Os arquivos são mesclados de cima para baixo. (Alt+↑ / Alt+↓)", + "preset.group_pdf_edit": "Editar", + "preset.pdf_crop": "Recortar PDF", + "preset.pdf_crop.short": "Recortar", + "preset.pdf_pages": "Reordenar / excluir páginas", + "preset.pdf_pages.short": "Páginas", + "preset.pdf_add": "Adicionar páginas", + "preset.pdf_add.short": "Adicionar", + "preset.pdf_extract": "Extrair página", + "preset.pdf_extract.short": "Extrair", + "preset.split_pdf.short": "Dividir", + "pdf_editor.title": "Editar páginas do PDF", + "pdf_editor.hint": "Reordene, gire ou exclua páginas. Adicione páginas de outro PDF ou extraia a página selecionada.", + "pdf_editor.add": "Adicionar PDF", + "pdf_editor.delete": "Excluir", + "pdf_editor.page": "Página {0}", + "pdf_editor.rotation": "Girada {0}°", + "pdf_editor.loading": "Carregando páginas…", + "pdf_editor.saving": "Salvando PDF…", + "pdf_editor.saved": "Salvo: {0}", "preset.split_pdf": "Dividir PDF", "preset.compress_pdf": "Comprimir PDF", "preset.searchable_pdf": "PDF pesquisável", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index b48880eb6..e0d8fa5fa 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -294,6 +294,25 @@ "preset.merge_pdf": "Объединить PDF", "merge.order_title": "Выберите порядок объединения", "merge.order_hint": "Выберите файл и переместите его вверх или вниз. Файлы объединяются сверху вниз. (Alt+↑ / Alt+↓)", + "preset.group_pdf_edit": "Изменить", + "preset.pdf_crop": "Обрезать PDF", + "preset.pdf_crop.short": "Обрезать", + "preset.pdf_pages": "Порядок / удаление", + "preset.pdf_pages.short": "Страницы", + "preset.pdf_add": "Добавить страницы", + "preset.pdf_add.short": "Добавить", + "preset.pdf_extract": "Извлечь страницу", + "preset.pdf_extract.short": "Извлечь", + "preset.split_pdf.short": "Разделить", + "pdf_editor.title": "Изменение страниц PDF", + "pdf_editor.hint": "Меняйте порядок, поворачивайте и удаляйте страницы. Добавляйте их из другого PDF или извлекайте выбранную страницу.", + "pdf_editor.add": "Добавить PDF", + "pdf_editor.delete": "Удалить", + "pdf_editor.page": "Страница {0}", + "pdf_editor.rotation": "Поворот {0}°", + "pdf_editor.loading": "Загрузка страниц…", + "pdf_editor.saving": "Сохранение PDF…", + "pdf_editor.saved": "Сохранено: {0}", "preset.split_pdf": "Разделить PDF", "preset.compress_pdf": "Сжать PDF", "preset.searchable_pdf": "PDF с поиском", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index c17f3e505..da0aab994 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -284,6 +284,25 @@ "preset.merge_pdf": "รวมไฟล์ PDF", "merge.order_title": "เลือกลำดับการรวมไฟล์", "merge.order_hint": "เลือกไฟล์แล้วเลื่อนขึ้นหรือลง ไฟล์จะรวมจากบนลงล่าง (Alt+↑ / Alt+↓)", + "preset.group_pdf_edit": "แก้ไข", + "preset.pdf_crop": "ครอบตัด PDF", + "preset.pdf_crop.short": "ครอบตัด", + "preset.pdf_pages": "จัดลำดับ / ลบหน้า", + "preset.pdf_pages.short": "หน้า", + "preset.pdf_add": "เพิ่มหน้า", + "preset.pdf_add.short": "เพิ่ม", + "preset.pdf_extract": "แยกหน้าออกมา", + "preset.pdf_extract.short": "แยกออก", + "preset.split_pdf.short": "แยก", + "pdf_editor.title": "แก้ไขหน้า PDF", + "pdf_editor.hint": "จัดลำดับ หมุน หรือลบหน้า เพิ่มหน้าจาก PDF อื่น หรือแยกหน้าที่เลือกออกมาเป็นไฟล์ของตัวเอง", + "pdf_editor.add": "เพิ่ม PDF", + "pdf_editor.delete": "ลบ", + "pdf_editor.page": "หน้า {0}", + "pdf_editor.rotation": "หมุน {0}°", + "pdf_editor.loading": "กำลังโหลดหน้า…", + "pdf_editor.saving": "กำลังบันทึก PDF…", + "pdf_editor.saved": "บันทึกแล้ว {0}", "preset.split_pdf": "แยกหน้า PDF", "preset.compress_pdf": "บีบอัด PDF", "preset.searchable_pdf": "PDF ที่ค้นหาได้", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 1bde238f0..8108eabf0 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -284,6 +284,25 @@ "preset.merge_pdf": "PDF birleştir", "merge.order_title": "Birleştirme sırasını seçin", "merge.order_hint": "Bir dosya seçip yukarı veya aşağı taşıyın. Dosyalar yukarıdan aşağıya birleştirilir. (Alt+↑ / Alt+↓)", + "preset.group_pdf_edit": "Düzenle", + "preset.pdf_crop": "PDF kırp", + "preset.pdf_crop.short": "Kırp", + "preset.pdf_pages": "Sayfaları sırala / sil", + "preset.pdf_pages.short": "Sayfalar", + "preset.pdf_add": "Sayfa ekle", + "preset.pdf_add.short": "Ekle", + "preset.pdf_extract": "Sayfayı çıkar", + "preset.pdf_extract.short": "Çıkar", + "preset.split_pdf.short": "Böl", + "pdf_editor.title": "PDF sayfalarını düzenle", + "pdf_editor.hint": "Sayfaları yeniden sıralayın, döndürün veya silin. Başka bir PDF'den sayfa ekleyin ya da seçili sayfayı ayrı bir dosya olarak çıkarın.", + "pdf_editor.add": "PDF ekle", + "pdf_editor.delete": "Sil", + "pdf_editor.page": "Sayfa {0}", + "pdf_editor.rotation": "{0}° döndürüldü", + "pdf_editor.loading": "Sayfalar yükleniyor…", + "pdf_editor.saving": "PDF kaydediliyor…", + "pdf_editor.saved": "Kaydedildi: {0}", "preset.split_pdf": "PDF böl", "preset.compress_pdf": "PDF sıkıştır", "preset.searchable_pdf": "Aranabilir PDF", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 6f1808b85..13cd23809 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -294,6 +294,25 @@ "preset.merge_pdf": "合并 PDF", "merge.order_title": "选择合并顺序", "merge.order_hint": "选择文件并上下移动。文件将按从上到下的顺序合并。(Alt+↑ / Alt+↓)", + "preset.group_pdf_edit": "编辑", + "preset.pdf_crop": "裁剪 PDF", + "preset.pdf_crop.short": "裁剪", + "preset.pdf_pages": "排序 / 删除页面", + "preset.pdf_pages.short": "页面", + "preset.pdf_add": "添加页面", + "preset.pdf_add.short": "添加", + "preset.pdf_extract": "提取页面", + "preset.pdf_extract.short": "提取", + "preset.split_pdf.short": "拆分", + "pdf_editor.title": "编辑 PDF 页面", + "pdf_editor.hint": "可重新排序、旋转或删除页面,也可从其他 PDF 添加页面或提取所选页面。", + "pdf_editor.add": "添加 PDF", + "pdf_editor.delete": "删除", + "pdf_editor.page": "第 {0} 页", + "pdf_editor.rotation": "已旋转 {0}°", + "pdf_editor.loading": "正在加载页面…", + "pdf_editor.saving": "正在保存 PDF…", + "pdf_editor.saved": "已保存:{0}", "preset.split_pdf": "拆分 PDF", "preset.compress_pdf": "压缩 PDF", "preset.searchable_pdf": "可搜索的 PDF", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index a8656dc5a..aef9945a1 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -294,6 +294,25 @@ "preset.merge_pdf": "合併 PDF", "merge.order_title": "選擇合併順序", "merge.order_hint": "選擇檔案並上下移動。檔案將按從上到下的順序合併。(Alt+↑ / Alt+↓)", + "preset.group_pdf_edit": "編輯", + "preset.pdf_crop": "裁剪 PDF", + "preset.pdf_crop.short": "裁剪", + "preset.pdf_pages": "排序 / 刪除頁面", + "preset.pdf_pages.short": "頁面", + "preset.pdf_add": "新增頁面", + "preset.pdf_add.short": "新增", + "preset.pdf_extract": "擷取頁面", + "preset.pdf_extract.short": "擷取", + "preset.split_pdf.short": "拆分", + "pdf_editor.title": "編輯 PDF 頁面", + "pdf_editor.hint": "可重新排序、旋轉或刪除頁面,也可從其他 PDF 新增頁面或擷取所選頁面。", + "pdf_editor.add": "新增 PDF", + "pdf_editor.delete": "刪除", + "pdf_editor.page": "第 {0} 頁", + "pdf_editor.rotation": "已旋轉 {0}°", + "pdf_editor.loading": "正在載入頁面…", + "pdf_editor.saving": "正在儲存 PDF…", + "pdf_editor.saved": "已儲存:{0}", "preset.split_pdf": "拆分 PDF", "preset.compress_pdf": "壓縮 PDF", "preset.searchable_pdf": "可搜尋的 PDF", diff --git a/src/Filee.App/Services/CropService.cs b/src/Filee.App/Services/CropService.cs index e3a72ce12..a09994de6 100644 --- a/src/Filee.App/Services/CropService.cs +++ b/src/Filee.App/Services/CropService.cs @@ -6,6 +6,7 @@ using Filee.Core.Presets; using Filee.Core.Settings; using Filee.Engines.Magick; +using Filee.Engines.Pdf; using ImageMagick; using Microsoft.Extensions.Logging; @@ -33,6 +34,8 @@ private static MagickReadSettings CreateReadSettings(string inputPath) private static void EnsureNotAnimated(string inputPath) { + if (inputPath.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)) + return; if (inputPath.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && Apng.IsAnimated(inputPath)) throw new NotSupportedException("Animated PNG files cannot be cropped interactively."); @@ -53,6 +56,12 @@ private static void EnsureNotAnimated(string inputPath) { return await Task.Run(() => { + if (inputPath.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)) + { + var preview = PdfPageTools.Preview(inputPath); + using var previewStream = new MemoryStream(preview.Png); + return (new Bitmap(previewStream), preview.Width, preview.Height); + } EnsureNotAnimated(inputPath); var readSettings = CreateReadSettings(inputPath); @@ -90,7 +99,9 @@ public static async Task CropFileAsync( Preset preset, UserDataStore store, ILocalizer loc, - ILogger? log = null) + ILogger? log = null, + int? originalWidth = null, + int? originalHeight = null) { return await Task.Run(() => { @@ -112,6 +123,19 @@ public static async Task CropFileAsync( if (!string.IsNullOrEmpty(outDir)) Directory.CreateDirectory(outDir); + if (ext.Equals("pdf", StringComparison.OrdinalIgnoreCase)) + { + var fullWidth = Math.Max(1, originalWidth ?? x + width); + var fullHeight = Math.Max(1, originalHeight ?? y + height); + PdfPageTools.CropAll(inputPath, outputPath, + (double)x / fullWidth, + (double)y / fullHeight, + (double)width / fullWidth, + (double)height / fullHeight); + PreserveDates(inputPath, outputPath, appliedPreset, store, log); + return new CropResult(CropStatus.Success, outputPath, null, null); + } + var readSettings = CreateReadSettings(inputPath); using var image = readSettings.Format != MagickFormat.Unknown ? new MagickImage(inputPath, readSettings) @@ -136,18 +160,7 @@ public static async Task CropFileAsync( image.Write(outputPath); - if (appliedPreset.Output.KeepDates || store.Settings.KeepFileDates) - { - try - { - File.SetCreationTimeUtc(outputPath, File.GetCreationTimeUtc(inputPath)); - File.SetLastWriteTimeUtc(outputPath, File.GetLastWriteTimeUtc(inputPath)); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - log?.LogDebug(ex, "Could not preserve file timestamps for {Path}", outputPath); - } - } + PreserveDates(inputPath, outputPath, appliedPreset, store, log); return new CropResult(CropStatus.Success, outputPath, null, null); } @@ -163,4 +176,19 @@ public static async Task CropFileAsync( } }); } + + private static void PreserveDates(string inputPath, string outputPath, Preset preset, UserDataStore store, ILogger? log) + { + if (!preset.Output.KeepDates && !store.Settings.KeepFileDates) + return; + try + { + File.SetCreationTimeUtc(outputPath, File.GetCreationTimeUtc(inputPath)); + File.SetLastWriteTimeUtc(outputPath, File.GetLastWriteTimeUtc(inputPath)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + log?.LogDebug(ex, "Could not preserve file timestamps for {Path}", outputPath); + } + } } diff --git a/src/Filee.App/Services/PresetAvailability.cs b/src/Filee.App/Services/PresetAvailability.cs index 07d28c00f..b45b0825f 100644 --- a/src/Filee.App/Services/PresetAvailability.cs +++ b/src/Filee.App/Services/PresetAvailability.cs @@ -119,6 +119,10 @@ private PresetJudgement Evaluate(Preset preset, IReadOnlyList format } return new(PresetUse.Unusable, _loc["error.unsupported_source"]); } + if (preset.Id.StartsWith("tool-pdf-", StringComparison.Ordinal)) + return formats.Count > 0 && formats.All(f => f.Id == "pdf") + ? new(PresetUse.Usable, null) + : new(PresetUse.Unusable, _loc["error.unsupported_source"]); if (preset.IsCropTool && formats.Count > 0 && formats.All(f => WritableSingleFrameRasterFormats.Contains(f.Id))) return new(PresetUse.Usable, null); return new(PresetUse.Unusable, _loc["error.unsupported_source"]); diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 40c0b75a5..20ed7e986 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -41,8 +41,10 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem internal IReadOnlyCollection SpreadsheetStudios => _spreadsheetStudios; private MergeOrderWindow? _mergeOrder; + private PdfPageEditorWindow? _pdfPageEditor; internal MergeOrderWindow? MergeOrder => _mergeOrder; + internal PdfPageEditorWindow? PdfPageEditor => _pdfPageEditor; private void PrepareWindow(Window window) => services.GetService()?.ApplyWindowClass(window); @@ -68,19 +70,24 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem public MainWindow? Main => _main; /// - /// Opens the window of a tool preset (crop, cut a clip, the spreadsheet print studio: one per workbook) on the files. - /// A tool this version doesn't know opens nothing. + /// Opens the window of a tool preset (crop, cut a clip, the spreadsheet print studio: one per workbook, the PDF page + /// editor) on the files. A tool this version doesn't know opens nothing. /// public void ShowTool(IReadOnlyList files, Preset preset, PixelPoint? near = null) { if (preset.IsClipTool) ShowClipWindow(files, preset, near); - // Before the crop: the print studio has no Tool of its own, which reads as a crop. + // Before the crop: the print studio and the PDF page tools have no Tool of their own, which reads as a crop + // ("Crop PDF" is one and opens the crop overlay). else if (preset.Id == "tool-sheet-studio") { foreach (var workbook in files.Where(file => Path.GetExtension(file).Equals(".xlsx", StringComparison.OrdinalIgnoreCase))) ShowSpreadsheetPrintStudio(workbook); } + else if (preset.Id == "tool-pdf-pages") + ShowPdfPageEditor(files, preset); + else if (preset.Id == "tool-pdf-add") + ShowPdfPageEditor(files, preset, addPages: true); else if (preset.IsCropTool) ShowCropOverlay(files, preset, near); } @@ -137,6 +144,29 @@ public void ShowCropOverlay(IReadOnlyList files, Preset preset, PixelPoi win.Activate(); } + /// Opens the lossless PDF page organiser. The Add-pages action starts with its file picker open. + public void ShowPdfPageEditor(IReadOnlyList files, Preset preset, bool addPages = false) + { + if (files.Count == 0) + return; + _pdfPageEditor?.Close(); + var store = services.GetRequiredService(); + var log = services.GetService>(); + var vm = new PdfPageEditorViewModel(files, preset, loc, store, log); + var win = new PdfPageEditorWindow { DataContext = vm }; + PrepareWindow(win); + win.Closed += (_, _) => + { + if (_pdfPageEditor == win) + _pdfPageEditor = null; + }; + if (addPages) + win.Opened += async (_, _) => await win.PickAndAddAsync(); + _pdfPageEditor = win; + win.Show(); + win.Activate(); + } + /// Opens the non-destructive XLSX print studio. One workbook owns one window and print plan. public void ShowSpreadsheetPrintStudio(string file) { diff --git a/src/Filee.App/ViewModels/CropOverlayViewModel.cs b/src/Filee.App/ViewModels/CropOverlayViewModel.cs index 54e33a096..cd2f8d7e2 100644 --- a/src/Filee.App/ViewModels/CropOverlayViewModel.cs +++ b/src/Filee.App/ViewModels/CropOverlayViewModel.cs @@ -245,7 +245,8 @@ public async Task CropAndSaveAsync() try { - var result = await CropService.CropFileAsync(path, x, y, w, h, _preset, _store, _loc, _log); + var result = await CropService.CropFileAsync(path, x, y, w, h, _preset, _store, _loc, _log, + OriginalWidth, OriginalHeight); if (result.Status == CropStatus.Success && result.OutputPath is not null) { _outputs.Add(result.OutputPath); diff --git a/src/Filee.App/ViewModels/GroupEditorViewModel.cs b/src/Filee.App/ViewModels/GroupEditorViewModel.cs index d570c2a54..aceaa3fc0 100644 --- a/src/Filee.App/ViewModels/GroupEditorViewModel.cs +++ b/src/Filee.App/ViewModels/GroupEditorViewModel.cs @@ -30,7 +30,7 @@ public GroupEditorViewModel(Preset group, UserDataStore store, ILocalizer loc, b _name = loc.DisplayName(group); foreach (var id in group.SubPresetIds) { - if (store.FindPreset(id) is { IsGroup: false, IsTool: false } preset) + if (store.FindPreset(id) is { IsGroup: false } preset) Members.Add(MemberOf(preset)); } Members.CollectionChanged += (_, _) => Refresh(); @@ -48,7 +48,7 @@ public GroupEditorViewModel(Preset group, UserDataStore store, ILocalizer loc, b /// The presets the group fans out, in order. public ObservableCollection Members { get; } = []; - /// Conversions that can still be added (not groups, not already in it). + /// Actions that can still be added (not groups, not already in it). public ObservableCollection> Available { get; } = []; [ObservableProperty] @@ -140,7 +140,7 @@ private void Refresh() { var taken = Members.Select(m => m.PresetId).ToHashSet(); Available.Clear(); - foreach (var preset in _store.Presets.Where(p => !p.IsGroup && !p.IsTool && !taken.Contains(p.Id))) + foreach (var preset in _store.Presets.Where(p => !p.IsGroup && !taken.Contains(p.Id))) Available.Add(new Choice(preset.Id, _loc.DisplayName(preset), CaptionOf(preset))); OnPropertyChanged(nameof(IsFull)); OnPropertyChanged(nameof(IsEmpty)); diff --git a/src/Filee.App/ViewModels/PdfPageEditorViewModel.cs b/src/Filee.App/ViewModels/PdfPageEditorViewModel.cs new file mode 100644 index 000000000..40a7ad463 --- /dev/null +++ b/src/Filee.App/ViewModels/PdfPageEditorViewModel.cs @@ -0,0 +1,418 @@ +// Lightweight PDF page organiser opened from the PDF donut's Edit group. Pages stay as PDF objects: saving copies +// them in the chosen order and only changes /Rotate where requested. + +using System.Collections.ObjectModel; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Filee.App.Services; +using Filee.Core.Conversion; +using Filee.Core.History; +using Filee.Core.Localization; +using Filee.Core.Platform; +using Filee.Core.Presets; +using Filee.Core.Settings; +using Filee.Engines.Pdf; +using Microsoft.Extensions.Logging; + +namespace Filee.App.ViewModels; + +public sealed partial class PdfPageItem(PdfPreviewSource previewSource, int pageIndex, ILocalizer loc) : ObservableObject, IDisposable +{ + public PdfPreviewSource PreviewSource { get; } = previewSource; + public string Path => PreviewSource.Path; + public int PageIndex { get; } = pageIndex; + public int PageNumber => PageIndex + 1; + public string PageText => loc.Format("pdf_editor.page", PageIndex + 1); + public string SourceName => System.IO.Path.GetFileName(Path); + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(RotationText))] + [NotifyPropertyChangedFor(nameof(PreviewRotation))] + private int _rotation; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasThumbnail))] + private Bitmap? _thumbnail; + + [ObservableProperty] + private bool _isThumbnailLoading; + + public string RotationText + { + get + { + var signed = Rotation > 180 ? Rotation - 360 : Rotation; + return signed == 0 ? "" : loc.Format("pdf_editor.rotation", signed); + } + } + + public double PreviewRotation => Rotation > 180 ? Rotation - 360 : Rotation; + public bool HasThumbnail => Thumbnail is not null; + public PdfPageReference Reference => new(Path, PageIndex, Rotation); + + internal void SetThumbnail(byte[] png) + { + using var stream = new MemoryStream(png); + var previous = Thumbnail; + Thumbnail = new Bitmap(stream); + previous?.Dispose(); + } + + public void Dispose() + { + Thumbnail?.Dispose(); + Thumbnail = null; + } +} + +public sealed partial class PdfPageEditorViewModel : ObservableObject, IDisposable +{ + private readonly Preset _preset; + private readonly ILocalizer _loc; + private readonly UserDataStore _store; + private readonly ILogger? _log; + private readonly string _primarySource; + private readonly IReadOnlyList _initialFiles; + private readonly Dictionary _previewSources = new(FileSystemPaths.Comparer); + private readonly SemaphoreSlim _thumbnailGate = new(1, 1); + private Task? _initialLoad; + private int _previewRequest; + private bool _closed; + + public PdfPageEditorViewModel(IReadOnlyList files, Preset preset, ILocalizer loc, UserDataStore store, + ILogger? log = null) + { + _preset = preset; + _loc = loc; + _store = store; + _log = log; + _primarySource = files.First(); + _initialFiles = files; + } + + public ObservableCollection Pages { get; } = []; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(MoveUpCommand))] + [NotifyCanExecuteChangedFor(nameof(MoveDownCommand))] + [NotifyCanExecuteChangedFor(nameof(DeleteCommand))] + [NotifyCanExecuteChangedFor(nameof(RotateLeftCommand))] + [NotifyCanExecuteChangedFor(nameof(RotateRightCommand))] + [NotifyPropertyChangedFor(nameof(SelectedPreviewRotation))] + private PdfPageItem? _selectedPage; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasSelectedPreview))] + private Bitmap? _selectedPreview; + + [ObservableProperty] + private bool _isPreviewLoading; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanSave))] + [NotifyCanExecuteChangedFor(nameof(MoveUpCommand))] + [NotifyCanExecuteChangedFor(nameof(MoveDownCommand))] + [NotifyCanExecuteChangedFor(nameof(DeleteCommand))] + [NotifyCanExecuteChangedFor(nameof(RotateLeftCommand))] + [NotifyCanExecuteChangedFor(nameof(RotateRightCommand))] + private bool _isBusy; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasStatus))] + private string _status = ""; + + public bool HasStatus => !string.IsNullOrWhiteSpace(Status); + public bool CanSave => !IsBusy && Pages.Count > 0; + public bool HasSelectedPreview => SelectedPreview is not null; + public double SelectedPreviewRotation => SelectedPage?.PreviewRotation ?? 0; + + partial void OnSelectedPageChanged(PdfPageItem? value) => _ = LoadSelectedPreviewAsync(value); + + /// Opens the PDFs and renders the page strip after the window is visible. + public async Task LoadThumbnailsAsync() + { + await EnsureInitialFilesAsync(); + await LoadThumbnailsAsync(Pages.ToList()); + } + + private async Task LoadThumbnailsAsync(IReadOnlyList items) + { + await _thumbnailGate.WaitAsync(); + try + { + foreach (var item in items.Where(item => item.Thumbnail is null)) + { + if (_closed || !Pages.Contains(item)) + return; + item.IsThumbnailLoading = true; + try + { + var preview = await Task.Run(() => item.PreviewSource.Preview(item.PageIndex, 260)); + if (!_closed && Pages.Contains(item)) + item.SetThumbnail(preview.Png); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _log?.LogDebug(ex, "Could not render thumbnail for page {Page} of {Path}", item.PageNumber, item.Path); + } + finally + { + item.IsThumbnailLoading = false; + } + } + } + finally + { + _thumbnailGate.Release(); + } + } + + private async Task LoadSelectedPreviewAsync(PdfPageItem? item) + { + var request = Interlocked.Increment(ref _previewRequest); + IsPreviewLoading = item is not null; + SelectedPreview?.Dispose(); + SelectedPreview = null; + if (item is null) + return; + try + { + var preview = await Task.Run(() => item.PreviewSource.Preview(item.PageIndex, 1400)); + using var stream = new MemoryStream(preview.Png); + var bitmap = new Bitmap(stream); + if (_closed || request != _previewRequest || !ReferenceEquals(item, SelectedPage)) + { + bitmap.Dispose(); + return; + } + SelectedPreview = bitmap; + if (item.Thumbnail is null) + item.SetThumbnail(preview.Png); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _log?.LogDebug(ex, "Could not render preview for page {Page} of {Path}", item.PageNumber, item.Path); + } + finally + { + if (request == _previewRequest) + IsPreviewLoading = false; + } + } + + public async Task AddFilesAsync(IReadOnlyList paths) + { + await EnsureInitialFilesAsync(); + if (paths.Count == 0 || IsBusy || _closed) + return; + var added = await AddFilesCoreAsync(paths); + _ = LoadThumbnailsAsync(added); + } + + private Task EnsureInitialFilesAsync() => _initialLoad ??= AddFilesCoreAsync(_initialFiles); + + private async Task> AddFilesCoreAsync(IReadOnlyList paths) + { + if (paths.Count == 0 || _closed) + return []; + IsBusy = true; + Status = _loc["pdf_editor.loading"]; + var added = new List(); + try + { + var missing = paths.Distinct(FileSystemPaths.Comparer) + .Where(path => !_previewSources.ContainsKey(path)).ToList(); + var opened = await Task.Run(() => missing.Select(path => + { + try { return (Path: path, Source: PdfPreviewSource.Open(path), Error: (Exception?)null); } + catch (Exception ex) when (ex is not OutOfMemoryException) { return (Path: path, Source: (PdfPreviewSource?)null, Error: ex); } + }).ToList()); + var failed = false; + foreach (var (path, source, error) in opened) + { + if (error is not null) + { + failed = true; + _log?.LogError(error, "Could not add {Path} to the PDF editor", path); + continue; + } + _previewSources[path] = source!; + } + foreach (var path in paths) + { + if (!_previewSources.TryGetValue(path, out var source)) + continue; + for (var page = 0; page < source.PageCount; page++) + { + var item = new PdfPageItem(source, page, _loc); + Pages.Add(item); + added.Add(item); + } + } + SelectedPage ??= Pages.FirstOrDefault(); + Status = failed ? _loc["error.conversion_failed"] : ""; + NotifyCollectionChanged(); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _log?.LogError(ex, "Could not add pages to the PDF editor"); + Status = _loc["error.conversion_failed"]; + } + finally + { + IsBusy = false; + } + return added; + } + + private bool CanMoveUp() => !IsBusy && SelectedPage is not null && Pages.IndexOf(SelectedPage) > 0; + private bool CanMoveDown() => !IsBusy && SelectedPage is not null && Pages.IndexOf(SelectedPage) is >= 0 and var index && index < Pages.Count - 1; + private bool CanChangePage() => !IsBusy && SelectedPage is not null; + private bool CanDelete() => CanChangePage() && Pages.Count > 1; + + [RelayCommand(CanExecute = nameof(CanMoveUp))] + private void MoveUp() => Move(-1); + + [RelayCommand(CanExecute = nameof(CanMoveDown))] + private void MoveDown() => Move(1); + + private void Move(int direction) + { + if (SelectedPage is not { } selected) + return; + var index = Pages.IndexOf(selected); + var target = index + direction; + if (index < 0 || target < 0 || target >= Pages.Count) + return; + Pages.Move(index, target); + SelectedPage = selected; + NotifyCollectionChanged(); + } + + [RelayCommand(CanExecute = nameof(CanDelete))] + private void Delete() + { + if (SelectedPage is not { } selected || Pages.Count <= 1) + return; + var index = Pages.IndexOf(selected); + Pages.RemoveAt(index); + selected.Dispose(); + SelectedPage = Pages[Math.Min(index, Pages.Count - 1)]; + NotifyCollectionChanged(); + } + + [RelayCommand(CanExecute = nameof(CanChangePage))] + private void RotateLeft() => Rotate(-90); + + [RelayCommand(CanExecute = nameof(CanChangePage))] + private void RotateRight() => Rotate(90); + + private void Rotate(int degrees) + { + if (SelectedPage is not { } selected) + return; + selected.Rotation = ((selected.Rotation + degrees) % 360 + 360) % 360; + OnPropertyChanged(nameof(SelectedPreviewRotation)); + } + + public Task SaveAsync() => WriteAsync(Pages, _preset, closeWhenDone: true); + + public Task ExtractSelectedAsync() + { + if (SelectedPage is not { } selected) + return Task.FromResult(false); + var extract = _preset.Clone(); + extract.Id = "pdf-extract-page"; + extract.NameKey = "preset.pdf_extract"; + extract.Output.FileNamePattern = "{name}_extracted"; + return WriteAsync([selected], extract, closeWhenDone: false); + } + + private async Task WriteAsync(IEnumerable items, Preset preset, bool closeWhenDone) + { + if (IsBusy) + return false; + var pages = items.Select(item => item.Reference).ToList(); + if (pages.Count == 0) + return false; + var applied = _store.Settings.WithDefaultOutput(preset); + var tokens = new OutputPathResolver.Tokens(_primarySource, _loc.DisplayName(applied), 1, DateTime.Now); + var output = OutputPathResolver.Resolve(applied.Output, tokens, "pdf", File.Exists); + if (output is null) + { + Status = _loc["toast.skipped"]; + return false; + } + + IsBusy = true; + Status = _loc["pdf_editor.saving"]; + try + { + var folder = System.IO.Path.GetDirectoryName(output); + if (!string.IsNullOrEmpty(folder)) + Directory.CreateDirectory(folder); + await Task.Run(() => PdfPageTools.Save(pages, output)); + PreserveDates(output, applied); + _store.AddHistory(new HistoryEntry + { + JobId = Guid.NewGuid().ToString("N")[..10], + FinishedAt = DateTime.Now, + PresetId = preset.Id, + PresetName = _loc.DisplayName(preset), + State = JobState.Completed, + Sources = pages.Select(p => p.Path).Distinct(FileSystemPaths.Comparer).ToList(), + Outputs = [output], + }); + Status = _loc.Format("pdf_editor.saved", System.IO.Path.GetFileName(output)); + return closeWhenDone; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _log?.LogError(ex, "Could not save edited PDF"); + Status = _loc["error.conversion_failed"]; + return false; + } + finally + { + IsBusy = false; + } + } + + private void PreserveDates(string output, Preset preset) + { + if (!preset.Output.KeepDates && !_store.Settings.KeepFileDates) + return; + try + { + File.SetCreationTimeUtc(output, File.GetCreationTimeUtc(_primarySource)); + File.SetLastWriteTimeUtc(output, File.GetLastWriteTimeUtc(_primarySource)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _log?.LogDebug(ex, "Could not preserve file timestamps for {Path}", output); + } + } + + private void NotifyCollectionChanged() + { + OnPropertyChanged(nameof(CanSave)); + MoveUpCommand.NotifyCanExecuteChanged(); + MoveDownCommand.NotifyCanExecuteChanged(); + DeleteCommand.NotifyCanExecuteChanged(); + RotateLeftCommand.NotifyCanExecuteChanged(); + RotateRightCommand.NotifyCanExecuteChanged(); + } + + public void Dispose() + { + if (_closed) + return; + _closed = true; + Interlocked.Increment(ref _previewRequest); + SelectedPreview?.Dispose(); + SelectedPreview = null; + foreach (var page in Pages) + page.Dispose(); + } +} diff --git a/src/Filee.App/Views/PdfPageEditorWindow.axaml b/src/Filee.App/Views/PdfPageEditorWindow.axaml new file mode 100644 index 000000000..7cb4708b3 --- /dev/null +++ b/src/Filee.App/Views/PdfPageEditorWindow.axaml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + +