From c6ff54a85478fc8653960a0886e7f1a71bd1c467 Mon Sep 17 00:00:00 2001 From: Perricheno Date: Thu, 22 Oct 2026 14:47:19 +0500 Subject: [PATCH 1/5] Add PDF editing tools to the donut --- src/Filee.App/Assets/i18n/ar.json | 19 ++ src/Filee.App/Assets/i18n/en.json | 19 ++ src/Filee.App/Assets/i18n/es.json | 19 ++ src/Filee.App/Assets/i18n/fr.json | 19 ++ src/Filee.App/Assets/i18n/hi.json | 19 ++ src/Filee.App/Assets/i18n/ja.json | 19 ++ src/Filee.App/Assets/i18n/ko.json | 19 ++ src/Filee.App/Assets/i18n/pl.json | 19 ++ src/Filee.App/Assets/i18n/pt-BR.json | 19 ++ src/Filee.App/Assets/i18n/ru.json | 19 ++ src/Filee.App/Assets/i18n/zh-CN.json | 19 ++ src/Filee.App/Assets/i18n/zh-TW.json | 19 ++ src/Filee.App/Services/CropService.cs | 54 +++- src/Filee.App/Services/PresetAvailability.cs | 4 + src/Filee.App/Services/RadialController.cs | 20 +- src/Filee.App/Services/WindowService.cs | 24 ++ .../ViewModels/CropOverlayViewModel.cs | 3 +- .../ViewModels/GroupEditorViewModel.cs | 6 +- .../ViewModels/PdfPageEditorViewModel.cs | 277 ++++++++++++++++++ src/Filee.App/Views/PdfPageEditorWindow.axaml | 84 ++++++ .../Views/PdfPageEditorWindow.axaml.cs | 53 ++++ src/Filee.Core/Presets/BuiltInData.cs | 33 ++- src/Filee.Core/Presets/Preset.cs | 13 + src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 28 +- src/Filee.Engines/Pdf/PdfPageTools.cs | 133 +++++++++ src/Filee.Engines/Pdf/PdfSharpConverter.cs | 24 +- tests/Filee.App.Tests/CropServiceTests.cs | 45 +++ tests/Filee.App.Tests/DonutGroupTests.cs | 28 +- tests/Filee.App.Tests/GroupEditorTests.cs | 15 + tests/Filee.App.Tests/PdfPageEditorTests.cs | 103 +++++++ tests/Filee.Core.Tests/BackFaceTests.cs | 36 +++ .../Filee.Engines.Tests/PdfPageToolsTests.cs | 90 ++++++ 33 files changed, 1273 insertions(+), 30 deletions(-) create mode 100644 src/Filee.App/ViewModels/PdfPageEditorViewModel.cs create mode 100644 src/Filee.App/Views/PdfPageEditorWindow.axaml create mode 100644 src/Filee.App/Views/PdfPageEditorWindow.axaml.cs create mode 100644 src/Filee.Engines/Pdf/PdfPageTools.cs create mode 100644 tests/Filee.App.Tests/PdfPageEditorTests.cs create mode 100644 tests/Filee.Engines.Tests/PdfPageToolsTests.cs diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 7ee866f47..83332f2f0 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -198,6 +198,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.long_image": "صورة طويلة", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index d6024c303..0dcbdefdb 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -208,6 +208,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": "Extract", + "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.long_image": "Long image", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 89007a3ee..4f790ae7e 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -198,6 +198,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": "Extraer", + "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.long_image": "Imagen larga", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 321978517..a99e378e3 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -208,6 +208,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": "Extraire", + "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.long_image": "Image longue", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index dde786072..f82d920dc 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -198,6 +198,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.long_image": "लंबी इमेज", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index afc5f5d33..c9787978d 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -198,6 +198,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.long_image": "縦連結", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 115bfc7e1..4753c6932 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -208,6 +208,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.long_image": "긴 이미지", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 2e2da36b6..31d4bd6f3 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -208,6 +208,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": "Wyodrębnij", + "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.long_image": "Długi obraz", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 0b7cd7cd0..577b6eda1 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -208,6 +208,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": "Extrair", + "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.long_image": "Imagem longa", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 3eb526c74..0d2581dd9 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -208,6 +208,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.long_image": "Длинная картинка", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 6247a2b0a..a00764d15 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -208,6 +208,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.long_image": "长图", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index d1a197a11..82392e535 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -208,6 +208,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.long_image": "長圖", 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 55ee80f69..643544229 100644 --- a/src/Filee.App/Services/PresetAvailability.cs +++ b/src/Filee.App/Services/PresetAvailability.cs @@ -90,6 +90,10 @@ private PresetJudgement Evaluate(Preset preset, IReadOnlyList format { if (preset.IsTool) { + 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 (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/RadialController.cs b/src/Filee.App/Services/RadialController.cs index 16e0bdd86..d4231b20b 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -321,7 +321,7 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w var near = _window?.Position; Close(animate: false); if (web is null) - windows.ShowCropOverlay(sources, preset, near); + ShowTool(sources, preset, near); else _ = ShowToolForBrowserFilesAsync(web, preset, near); return; @@ -373,7 +373,23 @@ private async Task ShowToolForBrowserFilesAsync(WebDrop web, Filee.Core.Presets. var files = await conversions.SaveBrowserFilesAsync(web, (placeholder, ex) => log.LogWarning(ex, "Saving {File} from a browser for {Preset} failed", Path.GetFileName(placeholder), preset.Id)); if (files.Count > 0) - windows.ShowCropOverlay(files, preset, near); + ShowTool(files, preset, near); + } + + private void ShowTool(IReadOnlyList files, Preset preset, PixelPoint? near) + { + switch (preset.Id) + { + case "tool-pdf-pages": + windows.ShowPdfPageEditor(files, preset); + break; + case "tool-pdf-add": + windows.ShowPdfPageEditor(files, preset, addPages: true); + break; + default: + windows.ShowCropOverlay(files, preset, near); + break; + } } /// diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index ac4347e7f..ace7d8582 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -31,8 +31,10 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem private CropOverlayWindow? _cropOverlay; private MergeOrderWindow? _mergeOrder; + private PdfPageEditorWindow? _pdfPageEditor; internal MergeOrderWindow? MergeOrder => _mergeOrder; + internal PdfPageEditorWindow? PdfPageEditor => _pdfPageEditor; /// Reviews the order of a PDF merge. Closing or cancelling starts no conversion. public async Task?> ChooseMergeOrderAsync(IReadOnlyList files) @@ -75,6 +77,28 @@ 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 }; + win.Closed += (_, _) => + { + if (_pdfPageEditor == win) + _pdfPageEditor = null; + }; + if (addPages) + win.Opened += async (_, _) => await win.PickAndAddAsync(); + _pdfPageEditor = win; + win.Show(); + win.Activate(); + } + /// /// Shows (or brings to front) the main window, optionally on a page. Going to the tray closes it /// (): a new one opens where and on the page the last one was. 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..d0369e2f7 --- /dev/null +++ b/src/Filee.App/ViewModels/PdfPageEditorViewModel.cs @@ -0,0 +1,277 @@ +// 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 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(string path, int pageIndex, ILocalizer loc) : ObservableObject +{ + public string Path { get; } = 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))] + private int _rotation; + + public string RotationText + { + get + { + var signed = Rotation > 180 ? Rotation - 360 : Rotation; + return signed == 0 ? "" : loc.Format("pdf_editor.rotation", signed); + } + } + public PdfPageReference Reference => new(Path, PageIndex, Rotation); +} + +public sealed partial class PdfPageEditorViewModel : ObservableObject +{ + private readonly Preset _preset; + private readonly ILocalizer _loc; + private readonly UserDataStore _store; + private readonly ILogger? _log; + private readonly string _primarySource; + + public PdfPageEditorViewModel(IReadOnlyList files, Preset preset, ILocalizer loc, UserDataStore store, + ILogger? log = null) + { + _preset = preset; + _loc = loc; + _store = store; + _log = log; + _primarySource = files.First(); + AddFiles(files); + SelectedPage = Pages.FirstOrDefault(); + } + + public ObservableCollection Pages { get; } = []; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(MoveUpCommand))] + [NotifyCanExecuteChangedFor(nameof(MoveDownCommand))] + [NotifyCanExecuteChangedFor(nameof(DeleteCommand))] + [NotifyCanExecuteChangedFor(nameof(RotateLeftCommand))] + [NotifyCanExecuteChangedFor(nameof(RotateRightCommand))] + private PdfPageItem? _selectedPage; + + [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 async Task AddFilesAsync(IReadOnlyList paths) + { + if (paths.Count == 0 || IsBusy) + return; + IsBusy = true; + Status = _loc["pdf_editor.loading"]; + try + { + var counts = await Task.Run(() => paths.Select(path => + { + try { return (Path: path, Count: PdfPageTools.PageCount(path), Error: (Exception?)null); } + catch (Exception ex) when (ex is not OutOfMemoryException) { return (Path: path, Count: 0, Error: ex); } + }).ToList()); + var failed = false; + foreach (var (path, count, error) in counts) + { + if (error is not null) + { + failed = true; + _log?.LogError(error, "Could not add {Path} to the PDF editor", path); + continue; + } + for (var page = 0; page < count; page++) + Pages.Add(new PdfPageItem(path, page, _loc)); + } + 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; + } + } + + private void AddFiles(IEnumerable paths) + { + foreach (var path in paths) + { + try + { + for (var page = 0; page < PdfPageTools.PageCount(path); page++) + Pages.Add(new PdfPageItem(path, page, _loc)); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _log?.LogError(ex, "Could not open {Path} in the PDF editor", path); + Status = _loc["error.conversion_failed"]; + } + } + NotifyCollectionChanged(); + } + + 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); + 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; + } + + 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(); + } +} diff --git a/src/Filee.App/Views/PdfPageEditorWindow.axaml b/src/Filee.App/Views/PdfPageEditorWindow.axaml new file mode 100644 index 000000000..0bdac5fa3 --- /dev/null +++ b/src/Filee.App/Views/PdfPageEditorWindow.axaml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + +