From 0d22734704673f61e38370189eda519ba8ba45f5 Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:44:36 +0530 Subject: [PATCH 01/20] Add everyday media tools: compress, merge, slideshow, extract audio, remove metadata New built-in presets (settings schema 33), offered on the back faces of the images, PDF, video and audio donuts where they have room: - Compress image: same format, quality 75 (never above a JPEG's own), zlib level 9 for PNG, ZIP for TIFF. - Compress video: H.264 MP4 at the Small quality, at most 1080p. - Video from images: pictures as a 1080p MP4, 3 s each, with 1 s crossfades (seconds and crossfade on/off in the preset editor). - Extract audio: a video's sound as M4A, copied when the codec allows (MediaOptions.CopyAudioWhenPossible). - Merge videos / Merge audio: clips joined in the order picked in the merge order window; same-codec clips are copied, others encoded to match the first clip; audio is decoded and joined without gaps. - Remove metadata: same format; images drop EXIF/XMP/ICC (JPEG kept at its own quality), videos and audio copy their streams without tags, chapters or data tracks, PDFs drop document info and XMP. JobQueue joins files through a new IMediaJoiner (FFmpeg); MediaJoining holds the pure command lines. Strings in all nine languages. Unit tests for the presets, migration, queue and command lines, and real FFmpeg tests for every tool. Co-Authored-By: Claude Opus 5.5 --- src/Filee.App/Assets/i18n/ar.json | 12 + src/Filee.App/Assets/i18n/en.json | 12 + src/Filee.App/Assets/i18n/es.json | 12 + src/Filee.App/Assets/i18n/hi.json | 12 + src/Filee.App/Assets/i18n/ja.json | 12 + src/Filee.App/Assets/i18n/ko.json | 12 + src/Filee.App/Assets/i18n/pl.json | 12 + src/Filee.App/Assets/i18n/ru.json | 12 + src/Filee.App/Assets/i18n/zh-CN.json | 12 + src/Filee.App/Services/AppHost.cs | 3 +- src/Filee.App/Services/PresetAvailability.cs | 17 + src/Filee.App/Services/RadialController.cs | 3 +- .../ViewModels/PresetEditorViewModel.cs | 17 +- src/Filee.App/Views/PresetEditorView.axaml | 8 + src/Filee.Cli/CliHost.cs | 2 +- src/Filee.Core/Conversion/IConverter.cs | 23 + src/Filee.Core/Conversion/JobQueue.cs | 132 +++++- src/Filee.Core/Presets/BuiltInData.cs | 66 ++- src/Filee.Core/Presets/Preset.cs | 80 ++++ src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 42 ++ src/Filee.Engines/EngineRegistry.cs | 4 + src/Filee.Engines/Magick/ImageEncoder.cs | 13 +- src/Filee.Engines/Media/FfmpegConverter.cs | 120 +++++- src/Filee.Engines/Media/MediaEncoding.cs | 77 +++- src/Filee.Engines/Media/MediaJoining.cs | 286 ++++++++++++ src/Filee.Engines/Pdf/PdfSharpConverter.cs | 42 +- tests/Filee.App.Tests/DonutFlipTests.cs | 3 +- tests/Filee.Core.Tests/FakeConverter.cs | 20 + tests/Filee.Core.Tests/MediaToolTests.cs | 183 ++++++++ tests/Filee.Engines.Tests/EngineFixture.cs | 3 +- .../Filee.Engines.Tests/MediaJoiningTests.cs | 211 +++++++++ .../Filee.Engines.Tests/MediaToolRealTests.cs | 407 ++++++++++++++++++ 33 files changed, 1843 insertions(+), 29 deletions(-) create mode 100644 src/Filee.Engines/Media/MediaJoining.cs create mode 100644 tests/Filee.Core.Tests/MediaToolTests.cs create mode 100644 tests/Filee.Engines.Tests/MediaJoiningTests.cs create mode 100644 tests/Filee.Engines.Tests/MediaToolRealTests.cs diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 7ee866f47..69a9198ad 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -76,6 +76,7 @@ "error.no_route": "لا يوجد محرك يستطيع إجراء هذا التحويل", "error.cannot_turn": "التدوير والانعكاس متاحان للصور فقط", "error.too_many_frames": "عدد الإطارات كبير جدًا لحفظها كصور. زِد قيمة «كل إطار رقم N»", + "error.too_many_pictures": "عدد الصور كبير جدًا لفيديو واحد، قسّمها على عدة فيديوهات", "error.not_enough_space": "لا توجد مساحة كافية على القرص لحفظ الصور", "error.no_motion_photo": "لا تحتوي هذه الصورة على فيديو يستطيع Filee قراءته (صورة Motion Photo من Android، أو Live Photo من iPhone مع ملف MOV بجانبها)", "error.live_photo_start": "يبدأ المقطع بعد نهاية هذا الفيديو. اختر «بداية المقطع» أبكر", @@ -212,6 +213,14 @@ "preset.extract_motion": "استخراج فيديو Live Photo", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", + "preset.compress_image": "ضغط الصورة", + "preset.compress_video": "ضغط الفيديو", + "preset.video_from_images": "فيديو من الصور", + "preset.extract_audio": "استخراج الصوت", + "preset.merge_videos": "دمج الفيديوهات", + "preset.compress_audio": "ضغط الصوت", + "preset.merge_audio": "دمج الصوت", + "preset.remove_metadata": "إزالة البيانات الوصفية", "format.folder": "مجلد", "format.category.Image": "صورة", "format.category.Pdf": "PDF", @@ -476,6 +485,9 @@ "presets.frame_estimate": "تنتج دقيقة من فيديو بمعدل 30 إطارًا/ث {0} صورة، بحد أقصى {1} لكل فيديو.", "presets.extract_motion_photo": "استخراج فيديو Live/Motion Photo", "presets.extract_motion_photo_hint": "تتحول الصورة إلى الفيديو المحفوظ معها: المضمّن في Motion Photo من Android، أو ملف MOV بجانب Live Photo من iPhone.\nتتحول الصور الأخرى إلى فيديو للصورة الثابتة.", + "presets.slide_seconds": "ثوانٍ لكل صورة", + "presets.crossfade": "تلاشٍ متداخل بين الصور", + "presets.crossfade_hint": "تتلاشى كل صورة في التالية خلال ثانية بدلًا من الانتقال المفاجئ.", "presets.group.live_photo": "Live / Motion Photo", "presets.live_photo": "يصبح الفيديو", "presets.live_photo.None": "إطارًا ثابتًا واحدًا", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index d6024c303..ccd8d77d1 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -82,6 +82,7 @@ "error.no_route": "No engine can do this conversion", "error.cannot_turn": "Rotate and flip work on images only", "error.too_many_frames": "Too many frames to save as pictures, raise \"Every Nth frame\"", + "error.too_many_pictures": "Too many pictures for one video, split them into several", "error.not_enough_space": "Not enough free disk space for the pictures", "error.no_motion_photo": "This photo has no video Filee can read (an Android Motion Photo, or an iPhone Live Photo with its MOV next to it)", "error.live_photo_start": "The clip starts after the end of this video. Choose an earlier \"Clip start\"", @@ -222,6 +223,14 @@ "preset.extract_motion": "Extract Live Photo video", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", + "preset.compress_image": "Compress image", + "preset.compress_video": "Compress video", + "preset.video_from_images": "Video from images", + "preset.extract_audio": "Extract audio", + "preset.merge_videos": "Merge videos", + "preset.compress_audio": "Compress audio", + "preset.merge_audio": "Merge audio", + "preset.remove_metadata": "Remove metadata", "format.folder": "Folder", "format.category.Image": "Image", @@ -491,6 +500,9 @@ "presets.frame_estimate": "One minute of 30 fps video gives {0} pictures, at most {1} per video.", "presets.extract_motion_photo": "Extract Live/Motion Photo video", "presets.extract_motion_photo_hint": "A photo becomes the video saved with it: the one inside an Android Motion Photo, or the MOV next to an iPhone Live Photo.\nOther pictures become a video of the still picture.", + "presets.slide_seconds": "Seconds per picture", + "presets.crossfade": "Crossfade between pictures", + "presets.crossfade_hint": "Each picture fades into the next over a second instead of cutting to it.", "presets.group.live_photo": "Live / Motion Photo", "presets.live_photo": "A video becomes", "presets.live_photo.None": "One still frame", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 89007a3ee..921e10362 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -76,6 +76,7 @@ "error.no_route": "Ningún motor puede hacer esta conversión", "error.cannot_turn": "Rotar y voltear solo funcionan con imágenes", "error.too_many_frames": "Demasiados fotogramas para guardar como imágenes; aumenta «Cada N fotogramas»", + "error.too_many_pictures": "Demasiadas imágenes para un vídeo; divídelas en varios", "error.not_enough_space": "Espacio libre en disco insuficiente para las imágenes", "error.no_motion_photo": "Esta foto no tiene un vídeo que Filee pueda leer (una Motion Photo de Android o una Live Photo de iPhone con su MOV al lado)", "error.live_photo_start": "El clip empieza después del final de este vídeo. Elige un «Inicio del clip» anterior", @@ -212,6 +213,14 @@ "preset.extract_motion": "Extraer vídeo de Live Photo", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", + "preset.compress_image": "Comprimir imagen", + "preset.compress_video": "Comprimir vídeo", + "preset.video_from_images": "Vídeo de imágenes", + "preset.extract_audio": "Extraer audio", + "preset.merge_videos": "Unir vídeos", + "preset.compress_audio": "Comprimir audio", + "preset.merge_audio": "Unir audio", + "preset.remove_metadata": "Quitar metadatos", "format.folder": "Carpeta", "format.category.Image": "Imagen", "format.category.Pdf": "PDF", @@ -476,6 +485,9 @@ "presets.frame_estimate": "Un minuto de vídeo a 30 fps genera {0} imágenes, como máximo {1} por vídeo.", "presets.extract_motion_photo": "Extraer vídeo de Live/Motion Photo", "presets.extract_motion_photo_hint": "La foto se convierte en el vídeo guardado con ella: el que lleva dentro una Motion Photo de Android o el MOV junto a una Live Photo de iPhone.\nLas demás imágenes se convierten en un vídeo de la imagen fija.", + "presets.slide_seconds": "Segundos por imagen", + "presets.crossfade": "Fundido entre imágenes", + "presets.crossfade_hint": "Cada imagen se funde con la siguiente durante un segundo en lugar de cortar.", "presets.group.live_photo": "Live Photo / Motion Photo", "presets.live_photo": "Un vídeo se convierte en", "presets.live_photo.None": "Un fotograma fijo", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index dde786072..8ef98e60c 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -76,6 +76,7 @@ "error.no_route": "कोई भी इंजन यह कन्वर्ज़न नहीं कर सकता", "error.cannot_turn": "घुमाना और पलटना केवल इमेज पर काम करता है", "error.too_many_frames": "तस्वीरों के रूप में सहेजने के लिए बहुत ज़्यादा फ़्रेम हैं, \"हर Nवाँ फ़्रेम\" बढ़ाएँ", + "error.too_many_pictures": "एक वीडियो के लिए तस्वीरें बहुत ज़्यादा हैं, इन्हें कई हिस्सों में बाँटें", "error.not_enough_space": "तस्वीरों के लिए डिस्क पर पर्याप्त खाली जगह नहीं है", "error.no_motion_photo": "इस फ़ोटो में ऐसा कोई वीडियो नहीं है जिसे Filee पढ़ सके (Android मोशन फ़ोटो, या पास में अपनी MOV फ़ाइल वाली iPhone लाइव फ़ोटो)", "error.live_photo_start": "क्लिप इस वीडियो के ख़त्म होने के बाद शुरू होती है। \"क्लिप की शुरुआत\" पहले चुनें", @@ -212,6 +213,14 @@ "preset.extract_motion": "लाइव फ़ोटो वीडियो निकालें", "preset.live_photo_iphone": "लाइव फ़ोटो (iPhone)", "preset.motion_photo_android": "मोशन फ़ोटो (Android)", + "preset.compress_image": "इमेज कंप्रेस करें", + "preset.compress_video": "वीडियो कंप्रेस करें", + "preset.video_from_images": "तस्वीरों से वीडियो", + "preset.extract_audio": "ऑडियो निकालें", + "preset.merge_videos": "वीडियो जोड़ें", + "preset.compress_audio": "ऑडियो कंप्रेस करें", + "preset.merge_audio": "ऑडियो जोड़ें", + "preset.remove_metadata": "मेटाडेटा हटाएँ", "format.folder": "फ़ोल्डर", "format.category.Image": "इमेज", "format.category.Pdf": "PDF", @@ -476,6 +485,9 @@ "presets.frame_estimate": "30 fps के एक मिनट के वीडियो से {0} तस्वीरें बनती हैं, प्रति वीडियो अधिकतम {1}।", "presets.extract_motion_photo": "लाइव/मोशन फ़ोटो का वीडियो निकालें", "presets.extract_motion_photo_hint": "फ़ोटो उसके साथ सहेजा गया वीडियो बन जाती है: Android मोशन फ़ोटो के अंदर वाला वीडियो, या iPhone लाइव फ़ोटो के पास रखी उसी नाम की MOV फ़ाइल।\nबाकी तस्वीरें स्थिर तस्वीर का वीडियो बनती हैं।", + "presets.slide_seconds": "हर तस्वीर के सेकंड", + "presets.crossfade": "तस्वीरों के बीच क्रॉसफ़ेड", + "presets.crossfade_hint": "हर तस्वीर सीधे बदलने के बजाय एक सेकंड में अगली तस्वीर में घुल जाती है।", "presets.group.live_photo": "लाइव / मोशन फ़ोटो", "presets.live_photo": "वीडियो बनता है", "presets.live_photo.None": "एक स्थिर फ़्रेम", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index afc5f5d33..59894213d 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -76,6 +76,7 @@ "error.no_route": "この変換に対応するエンジンがありません", "error.cannot_turn": "回転と反転は画像でのみ使えます", "error.too_many_frames": "画像として保存するフレーム数が多すぎます。「Nフレームごと」の値を大きくしてください", + "error.too_many_pictures": "1本の動画にするには画像が多すぎます。いくつかに分けてください", "error.not_enough_space": "画像を保存するための空き容量が不足しています", "error.no_motion_photo": "この写真には、Fileeが読み取れる動画がありません(Androidのモーションフォト、または隣にMOVがあるiPhoneのLive Photo)", "error.live_photo_start": "クリップの開始位置が動画の終わりより後です。「クリップの開始」をもっと前にしてください", @@ -212,6 +213,14 @@ "preset.extract_motion": "Live Photoの動画を抽出", "preset.live_photo_iphone": "Live Photo(iPhone)", "preset.motion_photo_android": "モーションフォト(Android)", + "preset.compress_image": "画像を圧縮", + "preset.compress_video": "動画を圧縮", + "preset.video_from_images": "写真から動画", + "preset.extract_audio": "音声を抽出", + "preset.merge_videos": "動画を結合", + "preset.compress_audio": "音声を圧縮", + "preset.merge_audio": "音声を結合", + "preset.remove_metadata": "メタデータを削除", "format.folder": "フォルダー", "format.category.Image": "画像", "format.category.Pdf": "PDF", @@ -476,6 +485,9 @@ "presets.frame_estimate": "30 fpsの動画は、1分あたり{0}枚の画像になります(動画1本につき最大{1}枚)。", "presets.extract_motion_photo": "Live Photo・モーションフォトの動画を抽出", "presets.extract_motion_photo_hint": "写真と一緒に保存された動画を取り出します。Androidのモーションフォトは写真の中の動画、iPhoneのLive Photoは隣にある同じ名前のMOVを使います。\nほかの画像は静止画の動画になります。", + "presets.slide_seconds": "1枚あたりの秒数", + "presets.crossfade": "写真の間をクロスフェード", + "presets.crossfade_hint": "次の写真へ切り替えず、1秒かけて徐々に移り変わります。", "presets.group.live_photo": "Live Photo/モーションフォト", "presets.live_photo": "動画の保存方法", "presets.live_photo.None": "静止画1枚", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 115bfc7e1..7d2bd40a5 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -82,6 +82,7 @@ "error.no_route": "이 변환을 할 수 있는 엔진이 없어요", "error.cannot_turn": "회전·반전은 이미지에만 할 수 있어요", "error.too_many_frames": "프레임이 너무 많아요. 'N번째 프레임마다' 값을 늘려 주세요", + "error.too_many_pictures": "한 동영상에 넣기엔 사진이 너무 많아요. 여러 번에 나눠 주세요", "error.not_enough_space": "이미지를 저장할 디스크 공간이 부족해요", "error.no_motion_photo": "이 사진에서 읽을 수 있는 동영상을 찾지 못했어요 (안드로이드 모션 포토, 또는 옆에 MOV가 있는 아이폰 라이브 포토)", "error.live_photo_start": "클립 시작이 동영상 끝보다 뒤예요. \"클립 시작\"을 더 앞으로 정해 주세요", @@ -222,6 +223,14 @@ "preset.extract_motion": "라이브 포토 동영상 추출", "preset.live_photo_iphone": "라이브 포토 (아이폰)", "preset.motion_photo_android": "모션 포토 (안드로이드)", + "preset.compress_image": "이미지 압축", + "preset.compress_video": "동영상 압축", + "preset.video_from_images": "사진으로 동영상", + "preset.extract_audio": "오디오 추출", + "preset.merge_videos": "동영상 합치기", + "preset.compress_audio": "오디오 압축", + "preset.merge_audio": "오디오 합치기", + "preset.remove_metadata": "메타데이터 제거", "format.folder": "폴더", "format.category.Image": "이미지", @@ -491,6 +500,9 @@ "presets.frame_estimate": "30fps 동영상 1분이면 이미지 {0}장이 생겨요. 동영상 하나에 최대 {1}장까지예요.", "presets.extract_motion_photo": "라이브/모션 포토 동영상 추출", "presets.extract_motion_photo_hint": "사진과 함께 저장된 동영상을 꺼내요. 안드로이드 모션 포토는 사진 안의 동영상을, 아이폰 라이브 포토는 옆에 있는 같은 이름의 MOV를 써요.\n다른 이미지는 정지 화면 동영상이 돼요.", + "presets.slide_seconds": "사진당 초", + "presets.crossfade": "사진 사이 크로스페이드", + "presets.crossfade_hint": "다음 사진으로 바로 넘어가지 않고 1초 동안 서서히 바뀌어요.", "presets.group.live_photo": "라이브 / 모션 포토", "presets.live_photo": "동영상을 이렇게 저장", "presets.live_photo.None": "정지 화면 한 장", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 2e2da36b6..227d60f95 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -82,6 +82,7 @@ "error.no_route": "Żaden silnik nie wykona tej konwersji", "error.cannot_turn": "Obracanie i odbicie działają tylko na obrazach", "error.too_many_frames": "Za dużo klatek do zapisania jako obrazy, zwiększ „Co N-ta klatka”", + "error.too_many_pictures": "Za dużo zdjęć na jedno wideo, podziel je na kilka", "error.not_enough_space": "Za mało wolnego miejsca na dysku na obrazy", "error.no_motion_photo": "To zdjęcie nie ma wideo, które Filee może odczytać (Motion Photo z Androida lub Live Photo z iPhone'a z plikiem MOV obok)", "error.live_photo_start": "Klip zaczyna się po końcu tego wideo. Wybierz wcześniejszy „Początek klipu”", @@ -222,6 +223,14 @@ "preset.extract_motion": "Wyodrębnij wideo z Live Photo", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", + "preset.compress_image": "Kompresuj obraz", + "preset.compress_video": "Kompresuj wideo", + "preset.video_from_images": "Wideo ze zdjęć", + "preset.extract_audio": "Wyodrębnij dźwięk", + "preset.merge_videos": "Połącz wideo", + "preset.compress_audio": "Kompresuj dźwięk", + "preset.merge_audio": "Połącz dźwięk", + "preset.remove_metadata": "Usuń metadane", "format.folder": "Folder", "format.category.Image": "Obraz", @@ -491,6 +500,9 @@ "presets.frame_estimate": "Minuta wideo w 30 kl./s daje {0} obrazów, najwyżej {1} na wideo.", "presets.extract_motion_photo": "Wyodrębnij wideo z Live/Motion Photo", "presets.extract_motion_photo_hint": "Zdjęcie staje się zapisanym z nim wideo: tym wewnątrz Motion Photo z Androida lub plikiem MOV obok Live Photo z iPhone'a.\nInne obrazy stają się wideo z nieruchomym obrazem.", + "presets.slide_seconds": "Sekundy na zdjęcie", + "presets.crossfade": "Przenikanie między zdjęciami", + "presets.crossfade_hint": "Każde zdjęcie przez sekundę przechodzi w następne zamiast nagłego cięcia.", "presets.group.live_photo": "Live Photo / Motion Photo", "presets.live_photo": "Wideo staje się", "presets.live_photo.None": "Jedną nieruchomą klatką", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 3eb526c74..bbdb49b6d 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -82,6 +82,7 @@ "error.no_route": "Нет движка для такой конвертации", "error.cannot_turn": "Поворот и отражение работают только с изображениями", "error.too_many_frames": "Слишком много кадров для сохранения картинками — увеличьте «Каждый N-й кадр»", + "error.too_many_pictures": "Слишком много картинок для одного видео — разделите их на несколько", "error.not_enough_space": "Не хватает места на диске для картинок", "error.no_motion_photo": "В этом фото нет видео, которое Filee может прочитать (Motion Photo с Android или Live Photo с iPhone с файлом MOV рядом)", "error.live_photo_start": "Клип начинается после конца этого видео. Выберите более раннее «Начало клипа»", @@ -222,6 +223,14 @@ "preset.extract_motion": "Видео из Live Photo", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", + "preset.compress_image": "Сжать изображение", + "preset.compress_video": "Сжать видео", + "preset.video_from_images": "Видео из фото", + "preset.extract_audio": "Извлечь звук", + "preset.merge_videos": "Объединить видео", + "preset.compress_audio": "Сжать аудио", + "preset.merge_audio": "Объединить аудио", + "preset.remove_metadata": "Удалить метаданные", "format.folder": "Папка", "format.category.Image": "Изображение", @@ -491,6 +500,9 @@ "presets.frame_estimate": "Минута видео с 30 кадрами в секунду даст картинок: {0}, не больше {1} на одно видео.", "presets.extract_motion_photo": "Извлекать видео из Live/Motion Photo", "presets.extract_motion_photo_hint": "Фото превращается в видео, сохранённое вместе с ним: встроенное в Motion Photo с Android или файл MOV рядом с Live Photo с iPhone.\nДругие картинки превращаются в видео из неподвижного кадра.", + "presets.slide_seconds": "Секунд на картинку", + "presets.crossfade": "Плавный переход между картинками", + "presets.crossfade_hint": "Каждая картинка за секунду плавно сменяется следующей, без резкой склейки.", "presets.group.live_photo": "Live Photo / Motion Photo", "presets.live_photo": "Видео становится", "presets.live_photo.None": "Одним неподвижным кадром", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 6247a2b0a..f3f8f9cc4 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -82,6 +82,7 @@ "error.no_route": "没有引擎可以完成此转换", "error.cannot_turn": "只能旋转和翻转图片", "error.too_many_frames": "帧数太多,请调大“每 N 帧”", + "error.too_many_pictures": "图片太多,无法放进一个视频,请分成几次", "error.not_enough_space": "磁盘空间不足,无法保存这些图片", "error.no_motion_photo": "这张照片中没有可读取的视频(安卓动态照片,或旁边有 MOV 的 iPhone 实况照片)", "error.live_photo_start": "片段开始位置在视频结束之后。请把“片段开始”设得更早", @@ -222,6 +223,14 @@ "preset.extract_motion": "提取实况/动态照片视频", "preset.live_photo_iphone": "实况照片(iPhone)", "preset.motion_photo_android": "动态照片(安卓)", + "preset.compress_image": "压缩图片", + "preset.compress_video": "压缩视频", + "preset.video_from_images": "图片生成视频", + "preset.extract_audio": "提取音频", + "preset.merge_videos": "合并视频", + "preset.compress_audio": "压缩音频", + "preset.merge_audio": "合并音频", + "preset.remove_metadata": "移除元数据", "format.folder": "文件夹", "format.category.Image": "图片", @@ -491,6 +500,9 @@ "presets.frame_estimate": "30 fps 的视频每分钟生成 {0} 张图片,每个视频最多 {1} 张。", "presets.extract_motion_photo": "提取实况/动态照片中的视频", "presets.extract_motion_photo_hint": "取出与照片一起保存的视频。安卓动态照片取其中内嵌的视频,iPhone 实况照片取旁边同名的 MOV。\n其他图片仍转换为静态画面的视频。", + "presets.slide_seconds": "每张图片秒数", + "presets.crossfade": "图片之间交叉淡化", + "presets.crossfade_hint": "每张图片在一秒内渐变到下一张,而不是直接切换。", "presets.group.live_photo": "实况/动态照片", "presets.live_photo": "视频保存为", "presets.live_photo.None": "一张静态画面", diff --git a/src/Filee.App/Services/AppHost.cs b/src/Filee.App/Services/AppHost.cs index f06ce0dc7..bb6164d70 100644 --- a/src/Filee.App/Services/AppHost.cs +++ b/src/Filee.App/Services/AppHost.cs @@ -56,7 +56,8 @@ public static IServiceProvider Build(string? dataDirectory = null, UserDataStore EngineRegistry.FindPdfMerger(sp.GetRequiredService>()), sp.GetRequiredService>(), EngineRegistry.FindFileCombiner(sp.GetRequiredService>()), - EngineRegistry.FindTiffMerger(sp.GetRequiredService>()))); + EngineRegistry.FindTiffMerger(sp.GetRequiredService>()), + EngineRegistry.FindMediaJoiner(sp.GetRequiredService>()))); // Files dragged from a browser with only an address are downloaded without the browser's cookies. services.AddSingleton(_ => new WebDownloader(userAgent: $"Filee/{typeof(AppHost).Assembly.GetName().Version?.ToString(3)}")); diff --git a/src/Filee.App/Services/PresetAvailability.cs b/src/Filee.App/Services/PresetAvailability.cs index 55ee80f69..1955cb6eb 100644 --- a/src/Filee.App/Services/PresetAvailability.cs +++ b/src/Filee.App/Services/PresetAvailability.cs @@ -106,6 +106,23 @@ private PresetJudgement Evaluate(Preset preset, IReadOnlyList format if (formats.Count == 0) return new(PresetUse.Unusable, _loc["error.unsupported_source"]); + // "Merge videos" / "Merge audio" / "Video from images": FFmpeg joins the files, so a route into the target (or, + // for pictures, into PNG and from PNG into a video) says whether it is installed. + if (preset.JoinsIntoOneMedia) + { + var target = preset.TargetFormat; + bool Joinable(RoutePlanner planner) => formats.Any(f => preset.Media.Slideshow + ? f.Category is FormatCategory.Image or FormatCategory.Vector + && (f.Id == "png" || planner.CanConvert(f.Id, "png")) && planner.CanConvert("png", target) + : (f.Category == FormatCategory.Video || f.Category == FormatCategory.Audio && target == "m4a") + && planner.CanConvert(f.Id, target)); + if (Joinable(installedPlanner())) + return new(PresetUse.Usable, null); + return packageEnabling(Joinable) is { } joiner + ? new(PresetUse.NeedsPackage, _loc.Format("engine.reason.needs_package", joiner.Name)) + : new(PresetUse.Unusable, _loc["error.no_route"]); + } + var merge = preset.Pdf.MergeIntoSingle && preset.TargetFormat == "pdf"; var turns = preset.Image.TurnsOrMirrors; string TargetFor(FileFormat f) => preset.TargetFormat == BuiltInData.SameAsSource ? f.Id : preset.TargetFormat; diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index 16e0bdd86..7682481f7 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -327,7 +327,8 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w return; } - if (preset.TargetFormat == "pdf" && preset.Pdf.MergeIntoSingle && sources.Count > 1) + // Merged PDFs, joined clips and slideshows follow the order the user picks. + if ((preset.TargetFormat == "pdf" && preset.Pdf.MergeIntoSingle || preset.JoinsIntoOneMedia) && sources.Count > 1) { Close(animate: false); _ = MergeInOrderAsync(sources, preset.Clone(), web); diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index d76f638b3..5758e1c0c 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -127,6 +127,9 @@ .. FormatRegistry.Known _livePhotoLength = _edit.Media.LivePhotoLength; _livePhotoCover = _edit.Media.LivePhotoCover; _frameInterval = _edit.Media.FrameInterval > 0 ? _edit.Media.FrameInterval : 1; + _isSlideshow = _edit.Media.Slideshow; + _slideSeconds = _edit.Media.SlideDuration; + _crossfade = _edit.Media.Crossfade; _traceMode = TraceModes.First(m => m.Value == _edit.Trace.Mode); _animationSpeed = AnimationSpeeds.First(s => s.Value == _edit.Image.PlaybackSpeed); _traceColors = Math.Clamp(_edit.Trace.Colors, TracingOptions.MinColors, TracingOptions.MaxColors); @@ -205,7 +208,7 @@ .. FormatRegistry.Known nameof(ShowAudioChannels), nameof(ShowWav), nameof(VideoCodecs), nameof(VideoCodecChoice), nameof(ShowVideoCodec), nameof(ShowHardwareEncoding), nameof(AudioCodecs), nameof(AudioCodecChoice), nameof(ShowAudioCodec), nameof(ShowArchive), nameof(ShowArchiveOptions), nameof(ShowExtractNote), - nameof(ShowSaveEveryFrame), nameof(ShowAnimationSpeed), nameof(ShowTrace), nameof(ShowExtractMotionPhoto), + nameof(ShowSaveEveryFrame), nameof(ShowAnimationSpeed), nameof(ShowTrace), nameof(ShowExtractMotionPhoto), nameof(ShowSlideshow), nameof(ShowLivePhotoChoice), nameof(LivePhotoKinds), nameof(LivePhotoChoice), nameof(IsLivePhoto), nameof(LivePhotoHint))] private Choice _target; @@ -419,6 +422,11 @@ public bool ColorProfileChecked [ObservableProperty, NotifyPropertyChangedFor(nameof(FrameEstimate))] private int _frameInterval = 1; + /// "Video from images" (MediaOptions.Slideshow): how long each picture shows, and fades between them. + private readonly bool _isSlideshow; + [ObservableProperty] private double _slideSeconds; + [ObservableProperty] private bool _crossfade; + /// How many pictures a minute of 30 fps video gives at the chosen interval, and the limit per video. public string FrameEstimate => _loc.Format("presets.frame_estimate", (int)Math.Ceiling(1800.0 / Math.Clamp(FrameInterval, 1, 1000)), MediaOptions.MaxFrames.ToString("N0", System.Globalization.CultureInfo.InvariantCulture)); @@ -518,6 +526,8 @@ partial void OnAnimationSpeedChanged(Choice? oldValue, Choice ne public bool ShowExtractNote => TargetId == FormatRegistry.Folder; /// "Save every frame" of a video: PNG and JPG (format ids, ). public bool ShowSaveEveryFrame => !IsTool && !IsLivePhoto && MediaOptions.FrameTargets.Contains(TargetId); + /// The slideshow options, for a preset that makes a video of pictures (MP4). + public bool ShowSlideshow => _isSlideshow && MediaOptions.JoinTargets.Contains(TargetId) && TargetIsVideo; /// The video of a Live / Motion Photo: MP4 and MOV (). public bool ShowExtractMotionPhoto => !IsTool && MediaOptions.MotionPhotoTargets.Contains(TargetId); /// @@ -617,6 +627,11 @@ public bool Apply() media.SaveEveryFrame = ShowSaveEveryFrame && SaveEveryFrame; media.FrameInterval = media.SaveEveryFrame ? Math.Clamp(FrameInterval, 1, 1000) : 1; media.ExtractMotionPhoto = ShowExtractMotionPhoto && ExtractMotionPhoto; + if (ShowSlideshow) + { + media.SlideSeconds = double.IsFinite(SlideSeconds) ? Math.Clamp(SlideSeconds, MediaOptions.MinSlideSeconds, MediaOptions.MaxSlideSeconds) : 3; + media.Crossfade = Crossfade; + } media.LivePhoto = IsLivePhoto ? _livePhotoValue : LivePhotoKind.None; static double Seconds(double value, double fallback, double min, double max) => double.IsFinite(value) ? Math.Clamp(value, min, max) : fallback; diff --git a/src/Filee.App/Views/PresetEditorView.axaml b/src/Filee.App/Views/PresetEditorView.axaml index 8c81df875..717ccbcc8 100644 --- a/src/Filee.App/Views/PresetEditorView.axaml +++ b/src/Filee.App/Views/PresetEditorView.axaml @@ -193,6 +193,14 @@ + + + + + + + diff --git a/src/Filee.Cli/CliHost.cs b/src/Filee.Cli/CliHost.cs index 14bb8b43e..6dff13a28 100644 --- a/src/Filee.Cli/CliHost.cs +++ b/src/Filee.Cli/CliHost.cs @@ -36,7 +36,7 @@ public static CliHost Create(string? dataDirectory = null) var converters = EngineRegistry.CreateAll(new EngineEnvironment(store.Directory)); var catalog = new ConverterCatalog(converters) { Priority = store.Settings.EnginePriority }; var queue = new JobQueue(catalog, EngineRegistry.FindPdfMerger(converters), combiner: EngineRegistry.FindFileCombiner(converters), - tiffMerger: EngineRegistry.FindTiffMerger(converters)); + tiffMerger: EngineRegistry.FindTiffMerger(converters), mediaJoiner: EngineRegistry.FindMediaJoiner(converters)); return new CliHost(store, catalog, queue); } diff --git a/src/Filee.Core/Conversion/IConverter.cs b/src/Filee.Core/Conversion/IConverter.cs index 3a84b9d86..11b521b99 100644 --- a/src/Filee.Core/Conversion/IConverter.cs +++ b/src/Filee.Core/Conversion/IConverter.cs @@ -196,6 +196,29 @@ public interface ITiffMerger Task MergeAsync(IReadOnlyList inputPaths, string outputPath, Presets.ImageOptions options, CancellationToken cancellationToken); } +/// +/// Joins several videos or audio files into one, one after the other, or turns pictures into one video ("Merge +/// videos", "Merge audio", "Video from images"). Implemented by the FFmpeg engine; uses it when +/// a preset joins files (). +/// +public interface IMediaJoiner +{ + /// True if files can be joined into now (FFmpeg is installed). + bool CanJoinInto(string format); + + /// + /// Writes every file of , in order, into : video or audio + /// clips back to back, or pictures shown one after the other (). + /// + /// Videos, audio files, or pictures (PNG, as the image engines wrote them). + /// File to create; removed again when the join fails. + /// Target format id, "mp4" or "m4a". + /// Options, e.g. . + /// Scratch directory for intermediate files; deleted after the job. + Task JoinAsync(IReadOnlyList inputPaths, string outputPath, string format, Preset preset, + string workDirectory, IProgress? progress, CancellationToken cancellationToken); +} + /// /// Packs several files, unchanged, into one archive ("Compress into one archive"). Implemented by the archive engine; /// uses it when a preset sets . diff --git a/src/Filee.Core/Conversion/JobQueue.cs b/src/Filee.Core/Conversion/JobQueue.cs index 9cf06228e..dd04a7eca 100644 --- a/src/Filee.Core/Conversion/JobQueue.cs +++ b/src/Filee.Core/Conversion/JobQueue.cs @@ -17,6 +17,7 @@ public sealed class JobQueue : IAsyncDisposable private readonly IPdfMerger? _pdfMerger; private readonly IFileCombiner? _combiner; private readonly ITiffMerger? _tiffMerger; + private readonly IMediaJoiner? _mediaJoiner; private readonly ILogger _log; private readonly Channel _channel = Channel.CreateUnbounded(); private readonly ConcurrentDictionary _engineGates = new(); @@ -35,13 +36,15 @@ public sealed class JobQueue : IAsyncDisposable /// Optional logger. /// Used by "Compress into one archive" presets (). /// Used by multi-page TIFF presets (). + /// Used by "Merge videos", "Merge audio" and "Video from images" (). public JobQueue(ConverterCatalog catalog, IPdfMerger? pdfMerger = null, ILogger? log = null, IFileCombiner? combiner = null, - ITiffMerger? tiffMerger = null) + ITiffMerger? tiffMerger = null, IMediaJoiner? mediaJoiner = null) { _catalog = catalog; _pdfMerger = pdfMerger; _combiner = combiner; _tiffMerger = tiffMerger; + _mediaJoiner = mediaJoiner; _log = log ?? (ILogger)NullLogger.Instance; _worker = Task.Run(WorkLoopAsync); } @@ -102,8 +105,12 @@ private async Task RunJobAsync(ConversionJob job) // One file is enough: a 10-page PDF becomes one 10-page TIFF. var multiPageTiff = job.Preset.Image.MultiPageTiff && job.Preset.TargetFormat == "tiff" && _tiffMerger is not null; + // Clips back to back, or pictures one after the other, in the order they were dropped. + var join = job.Preset.JoinsIntoOneMedia && _mediaJoiner is not null; - if (merge) + if (join) + await RunJoinedMediaAsync(job, planner, workDir); + else if (merge) await RunMergedAsync(job, planner, workDir); else if (multiPageTiff) await RunMultiPageTiffAsync(job, planner, workDir); @@ -333,6 +340,127 @@ await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), Notify(job); } + /// + /// "Merge videos" / "Merge audio": the videos or audio files go into one file as they are. "Video from images": + /// every picture becomes PNG in the work directory first (a PNG is taken as it is), with its orientation applied by + /// the image engines, then the pictures become one video. The output is named after the first file. + /// + private async Task RunJoinedMediaAsync(ConversionJob job, RoutePlanner planner, string workDir) + { + var slideshow = job.Preset.Media.Slideshow; + var target = FormatRegistry.Get(job.Preset.TargetFormat); + var parts = new IReadOnlyList?[job.Files.Count]; + await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), + new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, + async (i, _) => + { + var file = job.Files[i]; + if (Stopped(file)) + return; + var source = FormatRegistry.Detect(file.SourcePath); + if (source is null) + { + FailFile(job, file, "error.unsupported_source", "." + FormatRegistry.ExtensionOf(file.SourcePath)); + return; + } + if (IsEmptyFile(file.SourcePath)) + { + FailFile(job, file, "error.empty_file", null); + return; + } + if (slideshow) + { + parts[i] = source.Id == "png" && !job.Preset.Image.TurnsOrMirrors + ? [file.SourcePath] + : await RunFileAsync(job, file, i + 1, planner, workDir, finalOutput: false, intermediate: "png"); + return; + } + // A video's sound can join an audio file; a picture or a document joins nothing. + var joins = source.Category == FormatCategory.Video + || source.Category == FormatCategory.Audio && target.Category == FormatCategory.Audio; + if (!joins) + { + FailFile(job, file, "error.no_route", $"{source.Id} → {target.Id}"); + return; + } + parts[i] = [file.SourcePath]; + }); + + var joined = job.Files.Where((f, i) => parts[i] is { Count: > 0 } && f.State != FileState.Failed).ToList(); + var inputs = parts.Where(p => p is not null).SelectMany(p => p!).ToList(); + if (inputs.Count == 0 || job.CancellationToken.IsCancellationRequested) + { + Notify(job); + return; + } + + var first = joined[0]; + var output = CreateFinalAllocator(job, first.SourcePath, 1).Allocate(target.PrimaryExtension); + if (output is null) + { + foreach (var f in joined) + { + f.State = FileState.Skipped; + f.Progress = 1; + } + Notify(job); + return; + } + + foreach (var f in joined) + f.State = FileState.Running; + Notify(job); + var floor = joined.Min(f => f.Progress); + var progress = new Progress(p => + { + // Reports arrive on the thread pool, possibly out of order: progress only moves forward. + var value = floor + (1 - floor) * Math.Clamp(p, 0, 1); + if (value <= first.Progress) + return; + foreach (var f in joined) + f.Progress = value; + Notify(job); + }); + + try + { + await _mediaJoiner!.JoinAsync(inputs, output, target.Id, job.Preset, workDir, progress, job.CancellationToken); + first.Outputs.Add(output); + foreach (var f in joined) + { + f.State = FileState.Done; + f.Progress = 1; + } + } + catch (OperationCanceledException) + { + foreach (var f in joined) + f.State = FileState.Cancelled; + } + catch (ConversionRefusedException ex) + { + _log.LogInformation("Joining {Count} file(s) into {Output} was refused: {Reason}", inputs.Count, output, ex.Message); + foreach (var f in joined) + FailFile(job, f, ex.ErrorKey, ex.Detail); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Joining {Count} file(s) into {Output} failed", inputs.Count, output); + foreach (var f in joined) + FailFile(job, f, "error.conversion_failed", ex.Message); + } + Notify(job); + } + + private void FailFile(ConversionJob job, FileResult file, string key, string? detail) + { + file.State = FileState.Failed; + file.ErrorKey = key; + file.ErrorDetail = detail; + file.Progress = 1; + Notify(job); + } + /// A file that would be used as it is although the preset turns or mirrors it. private void FailUnturned(ConversionJob job, FileResult file, string detail) { diff --git a/src/Filee.Core/Presets/BuiltInData.cs b/src/Filee.Core/Presets/BuiltInData.cs index 4a3c96c03..d93420143 100644 --- a/src/Filee.Core/Presets/BuiltInData.cs +++ b/src/Filee.Core/Presets/BuiltInData.cs @@ -75,6 +75,7 @@ .. PdfSlidesPresets(), .. FramePresets(), .. MotionPhotoPresets(), .. LivePhotoPresets(), + .. MediaToolPresets(), new() { Id = "to-docx", Name = "DOCX", TargetFormat = "docx" }, new() { Id = "to-hwpx", Name = "HWPX", TargetFormat = "hwpx" }, .. HwpPresets(), @@ -111,6 +112,62 @@ public static List LivePhotoPresets() => new() { Id = "motion-photo-android", NameKey = "preset.motion_photo_android", TargetFormat = "jpg", Media = { LivePhoto = LivePhotoKind.Android } }, ]; + /// + /// Everyday tools (added in settings schema 33): smaller copies of images, videos and audio, a video of photos, + /// a video's sound as M4A, videos or audio files joined into one, and a copy without metadata. On the back faces + /// of the images, PDF, video and audio donuts where they have room. + /// + public static List MediaToolPresets() => + [ + new() + { + // Quality 75 like most photo apps' "smaller"; a JPEG saved below it stays at its own quality. + Id = "compress-image", NameKey = "preset.compress_image", TargetFormat = SameAsSource, + Image = { Quality = 75, MaxCompression = true, TiffCompression = TiffCompression.Zip }, + Output = { FileNamePattern = "{name}_compressed" }, + }, + new() + { + // H.264 at the Small quality, at most 1080p: plays everywhere, a fraction of a phone video's size. + Id = "compress-video", NameKey = "preset.compress_video", TargetFormat = "mp4", + Media = { Quality = MediaQuality.Small, MaxHeight = 1080 }, Output = { FileNamePattern = "{name}_compressed" }, + }, + new() + { + Id = "video-from-images", NameKey = "preset.video_from_images", TargetFormat = "mp4", + Media = { Slideshow = true }, Output = { FileNamePattern = "{name}_video" }, + }, + new() + { + Id = "extract-audio", NameKey = "preset.extract_audio", TargetFormat = "m4a", + Media = { CopyAudioWhenPossible = true }, + }, + new() + { + Id = "merge-videos", NameKey = "preset.merge_videos", TargetFormat = "mp4", + Media = { JoinIntoOne = true }, Output = { FileNamePattern = "{name}_merged" }, + }, + new() + { + // AAC at the Small quality: 128 kbit/s for stereo. + Id = "compress-audio", NameKey = "preset.compress_audio", TargetFormat = "m4a", + Media = { Quality = MediaQuality.Small }, Output = { FileNamePattern = "{name}_compressed" }, + }, + new() + { + Id = "merge-audio", NameKey = "preset.merge_audio", TargetFormat = "m4a", + Media = { JoinIntoOne = true }, Output = { FileNamePattern = "{name}_merged" }, + }, + new() + { + // Everything goes, the colour profile too; the preset editor can keep it. File dates are new ones. + Id = "remove-metadata", NameKey = "preset.remove_metadata", TargetFormat = SameAsSource, + Image = { Quality = 90, KeepMetadata = false, KeepColorProfile = false, KeepSourceQuality = true }, + Pdf = { RemoveMetadata = true }, Media = { RemoveMetadata = true }, + Output = { FileNamePattern = "{name}_clean" }, + }, + ]; + /// /// "Compress PDF": pictures at 150 dpi, the rest compressed losslessly (added in settings schema 20). On no default /// donut, the PDF donut is full: users add it in Settings → Toolbar. @@ -363,14 +420,15 @@ public static List CreateProfiles() => Id = "images", NameKey = "profile.images", Extensions = ExtensionsOf(FormatCategory.Image), PresetIds = ["to-png", "to-jpg", "to-webp", "to-pdf", "trace-svg", "trace-line-art", "to-tiff", "to-bmp", "to-ico", "half-size"], - BackPresetIds = ["tool-crop", "rotate", "flip", "remove-background", "grayscale", "extract-motion", "to-avif", "to-gif", "merge-pdf", "zip-all"], + BackPresetIds = ["tool-crop", "rotate", "flip", "remove-background", "grayscale", "extract-motion", "to-avif", "to-gif", "merge-pdf", + "compress-image", "remove-metadata", "zip-all"], }, new() { Id = "pdf", NameKey = "profile.pdf", Extensions = ["pdf"], PresetIds = [.. PdfDonut], - BackPresetIds = ["compress-pdf", "to-webp", "to-tiff", "to-html", "to-odt", "to-rtf", "to-azw3", "to-cbz", "zip-all"], + BackPresetIds = ["compress-pdf", "to-webp", "to-tiff", "to-html", "to-odt", "to-rtf", "to-azw3", "to-cbz", "remove-metadata", "zip-all"], }, new() { @@ -460,14 +518,14 @@ public static List CatalogProfiles() => // Every frame as pictures is on no front (a long video gives thousands of files): behind a flip, a drop that // misses its slice doesn't start it. Live / Motion Photos of a clip are there too. BackPresetIds = ["mp4-av1", "to-webp", "extract-frames-png", "extract-frames-jpg", "live-photo-iphone", "motion-photo-android", - "to-m4a", "to-wav", "to-flac", "zip-all"], + "extract-audio", "to-wav", "to-flac", "compress-video", "merge-videos", "zip-all"], }, new() { Id = "audio", NameKey = "profile.audio", Extensions = ExtensionsOf(FormatCategory.Audio), PresetIds = ["to-mp3", "mp3-128", "to-m4a", "to-wav", "to-flac", "to-ogg", "to-opus", "to-aac"], - BackPresetIds = ["to-alac", "zip-all"], + BackPresetIds = ["to-alac", "compress-audio", "merge-audio", "remove-metadata", "zip-all"], }, new() { diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs index ed6315102..09a8b2053 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -164,6 +164,14 @@ public bool MakesLivePhotoOf(string sourceFormat) => MediaOptions.LivePhotoTargets(Media.LivePhoto).Contains(TargetFormat) && Formats.FormatRegistry.FindById(sourceFormat)?.Category == Formats.FormatCategory.Video; + /// + /// True for "Merge videos", "Merge audio" and "Video from images": every dropped file goes, in order, into one + /// video or audio file (, ). + /// + [JsonIgnore] + public bool JoinsIntoOneMedia => + (Media.JoinIntoOne || Media.Slideshow) && MediaOptions.JoinTargets.Contains(TargetFormat); + /// True for a . [JsonIgnore] public bool IsTool => Kind == PresetKind.Tool; @@ -273,6 +281,18 @@ public sealed class ImageOptions /// public bool KeepColorProfile { get; set; } = true; + /// + /// "Compress image": PNG with zlib's best compression, and a JPEG never saved above the quality it had (a photo + /// saved at 75 would otherwise come back at the preset's quality, bigger and no better). + /// + public bool MaxCompression { get; set; } + + /// + /// JPEG → JPEG at the quality the photo had (ImageMagick's estimate), not at : "Remove + /// metadata" re-encodes the picture as little as it can. Other targets use . + /// + public bool KeepSourceQuality { get; set; } + /// Convert to grayscale. public bool Grayscale { get; set; } @@ -403,6 +423,12 @@ public sealed class PdfOptions [JsonConverter(typeof(LenientEnumJsonConverter))] public PdfCompression Compression { get; set; } + /// + /// For PDF → PDF: drop the document information (title, author, dates, creator) and every XMP packet. Pages, + /// links, bookmarks and form fields stay as they are. + /// + public bool RemoveMetadata { get; set; } + /// /// With : also let Ghostscript rewrite the PDF when it is installed, and keep whichever /// result is smaller. Off by default: it rewrites the whole file (fonts, structure), not only the pictures. @@ -545,6 +571,60 @@ public sealed class MediaOptions /// public const long MaxFrames = 100_000; + /// Targets the dropped files can be joined into (, ). + public static readonly IReadOnlyList JoinTargets = ["mp4", "m4a"]; + + /// + /// MP4 or M4A: every dropped video (or audio file) goes, in order and without gaps, into one file named after the + /// first. Clips that share codecs and picture size are copied; others are encoded to match the first. + /// + public bool JoinIntoOne { get; set; } + + /// + /// MP4: the dropped pictures become one video, each shown for , fitted into + /// × with black bars. + /// + public bool Slideshow { get; set; } + + /// Shortest and longest time one picture of a is shown, in seconds. + public const double MinSlideSeconds = 0.5, MaxSlideSeconds = 60; + + /// With : how long each picture is shown, in seconds. + public double SlideSeconds { get; set; } = 3; + + /// With : fade from one picture into the next instead of cutting. + public bool Crossfade { get; set; } = true; + + /// With : how long a fade takes, in seconds (at most half a picture's time). + public double CrossfadeSeconds { get; set; } = 1; + + /// Frame size of a (1080p unless the preset says otherwise). + public int SlideshowWidth { get; set; } = 1920; + + /// + public int SlideshowHeight { get; set; } = 1080; + + /// + /// Audio target: copy the sound as it is when the target takes its codec (AAC from a phone video into M4A), with + /// no bitrate, channel or Small quality asking for an encode. Off: always encoded, as before the option existed. + /// + public bool CopyAudioWhenPossible { get; set; } + + /// + /// Video and audio: drop the tags (title, artist, dates, location, camera, encoder) and chapters, copying the + /// streams as they are. Only when the file keeps its format. + /// + public bool RemoveMetadata { get; set; } + + /// within its limits (a hand-edited preset file may hold anything). + [JsonIgnore] + public double SlideDuration => double.IsFinite(SlideSeconds) ? Math.Clamp(SlideSeconds, MinSlideSeconds, MaxSlideSeconds) : 3; + + /// The fade between two pictures in seconds: 0 without , never over half a picture's time. + [JsonIgnore] + public double FadeDuration => Crossfade && double.IsFinite(CrossfadeSeconds) && CrossfadeSeconds > 0 + ? Math.Min(CrossfadeSeconds, SlideDuration / 2) + : 0; public MediaOptions Clone() => (MediaOptions)MemberwiseClone(); } diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 4f488dcba..3df95d287 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -112,7 +112,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest public sealed class AppSettings { /// Current schema version. Increase when the format changes and add a migration. - public const int CurrentSchemaVersion = 32; + public const int CurrentSchemaVersion = 33; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 7aff0c842..15e65f5d5 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -750,6 +750,48 @@ public static bool ApplyToLibrary(int from, List presets, ListWhere schema 33 offers its tools: (donut, presets for its back face). + private static readonly (string Profile, string[] Presets)[] Version33Offers = + [ + ("images", ["compress-image", "remove-metadata"]), + ("pdf", ["remove-metadata"]), + ("video", ["compress-video", "merge-videos"]), + ("audio", ["compress-audio", "merge-audio", "remove-metadata"]), + ]; + + private static bool AddMediaTools(List presets, List profiles) + { + var changed = false; + var at = presets.FindLastIndex(p => p.Id is "motion-photo-android" or "live-photo-iphone" or "extract-motion"); + at = at < 0 ? presets.Count : at + 1; + foreach (var tool in BuiltInData.MediaToolPresets()) + { + if (presets.Any(p => p.Id == tool.Id)) + continue; + presets.Insert(Math.Min(at++, presets.Count), tool); + changed = true; + } + foreach (var (profileId, ids) in Version33Offers) + { + if (profiles.FirstOrDefault(p => p.Id == profileId) is not { BackPresetIds: { } back } profile) + continue; + foreach (var id in ids) + { + if (back.Contains(id) || profile.PresetIds.Contains(id) || back.Count >= ToolbarProfile.MaxSlices + || presets.All(p => p.Id != id)) + continue; + InsertBefore(back, id, "zip-all"); + changed = true; + } + } return changed; } diff --git a/src/Filee.Engines/EngineRegistry.cs b/src/Filee.Engines/EngineRegistry.cs index b9c0f0102..dc9a98ede 100644 --- a/src/Filee.Engines/EngineRegistry.cs +++ b/src/Filee.Engines/EngineRegistry.cs @@ -81,4 +81,8 @@ public static IReadOnlyList CreateAll(EngineEnvironment env, ILogger /// Finds the engine that packs several files into one archive ("Compress into one archive"). public static IFileCombiner? FindFileCombiner(IEnumerable converters) => converters.OfType().FirstOrDefault(); + + /// The engine that joins videos, audio files and pictures into one (FFmpeg). + public static IMediaJoiner? FindMediaJoiner(IEnumerable converters) => + converters.OfType().FirstOrDefault(); } diff --git a/src/Filee.Engines/Magick/ImageEncoder.cs b/src/Filee.Engines/Magick/ImageEncoder.cs index 1679e1a90..5fbb84694 100644 --- a/src/Filee.Engines/Magick/ImageEncoder.cs +++ b/src/Filee.Engines/Magick/ImageEncoder.cs @@ -283,8 +283,12 @@ public static void ApplyOptions(IMagickImage image, ImageOptions options, var quality = (uint)Math.Clamp(options.Quality, 1, 100); // Turning a JPEG has to re-encode it: at the photo's own quality (Magick's estimate when it read the file), not // above it, or a phone photo saved at 75 comes back at 95, bigger and no better. - if (options.TurnsOrMirrors && targetFormat == "jpg" && image.Format == MagickFormat.Jpeg && image.Quality is > 0 and <= 100) - quality = Math.Min(quality, image.Quality); + // "Compress image" likewise never raises a photo's quality, and "Remove metadata" keeps it as it was. + var sourceQuality = targetFormat == "jpg" && image.Format == MagickFormat.Jpeg && image.Quality is > 0 and <= 100 ? image.Quality : 0; + if ((options.TurnsOrMirrors || options.MaxCompression) && sourceQuality > 0) + quality = Math.Min(quality, sourceQuality); + if (options.KeepSourceQuality && sourceQuality > 0) + quality = sourceQuality; switch (targetFormat) { case "jpg": @@ -301,6 +305,11 @@ public static void ApplyOptions(IMagickImage image, ImageOptions options, if (options.WebpLossless) image.Settings.SetDefine(MagickFormat.WebP, "lossless", true); break; + case "png" when options.MaxCompression: + // zlib level 9 with adaptive row filters: the smallest PNG ImageMagick writes, the same pixels. + image.Settings.SetDefine(MagickFormat.Png, "compression-level", "9"); + image.Settings.SetDefine(MagickFormat.Png, "compression-filter", "5"); + break; case "tiff": image.Settings.Compression = options.TiffCompression switch { diff --git a/src/Filee.Engines/Media/FfmpegConverter.cs b/src/Filee.Engines/Media/FfmpegConverter.cs index dd7f8a266..94be8ec1c 100644 --- a/src/Filee.Engines/Media/FfmpegConverter.cs +++ b/src/Filee.Engines/Media/FfmpegConverter.cs @@ -5,7 +5,8 @@ // writer). Container and codec choices live in MediaEncoding, the process handling in FfmpegRunner, hardware encoders // in HardwareVideo (options) and HardwareProbe (which one works here). // Live / Motion Photos (Android, iPhone) give their video when a preset asks for it (LivePhotoSource, MotionPhoto), and -// a clip of a video becomes one (LivePhotoWriter). +// a clip of a video becomes one (LivePhotoWriter). Clips and pictures are joined into one file by IMediaJoiner +// (MediaJoining), and "Remove metadata" copies a file's streams without its tags. using System.Collections.Concurrent; using System.Text.RegularExpressions; @@ -22,7 +23,7 @@ namespace Filee.Engines.Media; /// Converts video and audio files with Filee's own copy of FFmpeg. /// Receives which hardware encoders work and why a hardware encode was redone in software. -public sealed partial class FfmpegConverter(ILogger? logger = null) : IConverter, IImageTransformer +public sealed partial class FfmpegConverter(ILogger? logger = null) : IConverter, IImageTransformer, IMediaJoiner { private static readonly ConcurrentDictionary Versions = new(); @@ -146,6 +147,24 @@ public async Task> ConvertAsync(ConversionStep step, IProg : null; var media = step.Preset.Media; + // "Remove metadata": the streams as they are, in the same format, without tags. + if (media.RemoveMetadata && step.From == step.To && !still + && (MediaEncoding.VideoTargets.ContainsKey(step.To) || MediaEncoding.AudioTargets.ContainsKey(step.To))) + { + try + { + await RunPassesAsync(ffmpeg, [new FfmpegPass(MediaEncoding.WithoutMetadata(input, output, step.To, info), 1)], output, + info.Duration, progress, cancellationToken); + } + catch + { + TryDelete(output); + throw; + } + progress?.Report(1); + return [output]; + } + var encoders = still ? null : EncodersOf(ffmpeg); // Hardware encoding (preset option): the first GPU encoder that passes its test encode, if any. HardwareEncoder? hardware = null; @@ -182,6 +201,103 @@ await RunWithFallbackAsync(passes, hardware, Plan, return [output]; } + public bool CanJoinInto(string format) => MediaOptions.JoinTargets.Contains(format) && (_tools ?? Locate()) is not null; + + public async Task JoinAsync(IReadOnlyList inputPaths, string outputPath, string format, Preset preset, + string workDirectory, IProgress? progress, CancellationToken cancellationToken) + { + var (ffmpeg, ffprobe) = _tools ?? Locate() ?? throw new InvalidOperationException("FFmpeg was not found."); + var scratch = Path.Combine(workDirectory, "join-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(scratch); + var media = preset.Media; + try + { + if (media.Slideshow) + { + await SlideshowAsync(ffmpeg, inputPaths, outputPath, media, scratch, progress, cancellationToken); + } + else + { + // Probing takes the first few percent. + var clips = new List<(string Path, MediaInfo Info)>(); + for (var i = 0; i < inputPaths.Count; i++) + { + var path = Path.GetFullPath(inputPaths[i]); + clips.Add((path, await MediaInfo.ProbeAsync(ffprobe, path, cancellationToken))); + progress?.Report(0.05 * (i + 1) / inputPaths.Count); + } + var duration = TimeSpan.FromSeconds(clips.Sum(c => c.Info.Duration?.TotalSeconds ?? 0)); + List args; + if (format == "m4a") + { + if (clips.FirstOrDefault(c => c.Info.Audio is null && c.Info.Video is null) is { Path: { } silent }) + throw new InvalidOperationException($"{Path.GetFileName(silent)} has no sound."); + args = MediaJoining.EncodeAudio(clips, outputPath, media, scratch); + } + else if (MediaJoining.CanCopyVideos([.. clips.Select(c => c.Info)], media)) + { + var list = Path.Combine(scratch, "clips.txt"); + await File.WriteAllTextAsync(list, MediaJoining.ConcatList(clips.Select(c => c.Path)), cancellationToken); + args = MediaJoining.CopyVideos(list, outputPath, clips[0].Info); + } + else + { + var encoder = MediaEncoding.VideoEncoder("mp4", "libx264", media.VideoCodec, EncodersOf(ffmpeg)); + args = MediaJoining.EncodeVideos(clips, outputPath, media, encoder, scratch); + } + await RunJoinPassAsync(ffmpeg, args, duration, progress, 0.05, 1, cancellationToken); + } + if (!File.Exists(outputPath) || new FileInfo(outputPath).Length == 0) + throw new InvalidOperationException("FFmpeg did not write any output."); + } + catch + { + TryDelete(outputPath); + throw; + } + progress?.Report(1); + } + + /// + /// Pictures → one video: each picture is fitted into the frame as a small JPEG first (a fifth of the progress), + /// then the slides are encoded with their fades. + /// + private async Task SlideshowAsync(string ffmpeg, IReadOnlyList pictures, string output, MediaOptions media, string scratch, + IProgress? progress, CancellationToken cancellationToken) + { + if (pictures.Count > MediaJoining.MaxSlides) + throw new ConversionRefusedException("error.too_many_pictures", $"{pictures.Count} > {MediaJoining.MaxSlides}"); + var (width, height) = MediaJoining.SlideshowFrame(media); + var slides = new List(); + for (var i = 0; i < pictures.Count; i++) + { + var slide = Path.Combine(scratch, $"slide-{i + 1:D4}.jpg"); + await RunJoinPassAsync(ffmpeg, MediaJoining.PrepareSlide(Path.GetFullPath(pictures[i]), slide, width, height), null, + null, 0, 0, cancellationToken); + slides.Add(slide); + progress?.Report(0.2 * (i + 1) / pictures.Count); + } + var encoder = MediaEncoding.VideoEncoder("mp4", "libx264", media.VideoCodec, EncodersOf(ffmpeg)); + await RunJoinPassAsync(ffmpeg, MediaJoining.Slideshow(slides, output, media, encoder, scratch), + MediaJoining.SlideshowDuration(slides.Count, media), progress, 0.2, 1, cancellationToken); + } + + /// Runs one join pass, mapping FFmpeg's position onto … of the progress. + private static async Task RunJoinPassAsync(string ffmpeg, IReadOnlyList arguments, TimeSpan? duration, IProgress? progress, + double from, double to, CancellationToken cancellationToken) + { + var total = duration?.TotalSeconds ?? 0; + Action? onPosition = progress is null || total <= 0 + ? null + : seconds => progress.Report(from + (to - from) * Math.Clamp(seconds / total, 0, 1)); + var result = await FfmpegRunner.RunAsync(ffmpeg, arguments, onPosition, cancellationToken); + if (result.ExitCode != 0) + { + var reason = FfmpegRunner.Summarize(result.StandardError); + throw new InvalidOperationException($"FFmpeg failed (exit code {result.ExitCode}){(reason.Length > 0 ? ": " + reason : ".")}"); + } + } + /// /// The video of a Live / Motion Photo as : copied out of the photo (or from next to /// it) into the work directory, converted there like any video and moved into place once finished, so a failure diff --git a/src/Filee.Engines/Media/MediaEncoding.cs b/src/Filee.Engines/Media/MediaEncoding.cs index be71f1a51..361f139a2 100644 --- a/src/Filee.Engines/Media/MediaEncoding.cs +++ b/src/Filee.Engines/Media/MediaEncoding.cs @@ -278,7 +278,7 @@ private static List Video(string input, string output, string target, Me /// with AAC in MOV) become MP4 this way. Only codecs the target's usual players handle are listed: MP4/MOV keep to /// what Apple devices and browsers play, WebM to what the WebM specification allows. /// - private static readonly Dictionary Video, HashSet Audio)> Remuxable = new() + internal static readonly Dictionary Video, HashSet Audio)> Remuxable = new() { ["mp4"] = (["h264", "hevc"], ["aac", "alac", "ac3", "eac3", "mp3"]), ["m4v"] = (["h264", "hevc"], ["aac", "alac", "ac3", "eac3", "mp3"]), @@ -373,7 +373,7 @@ internal static string DvdOrDvFilter(VideoStreamInfo video, bool pal) /// PAL (25 fps) for 25/50 fps sources, NTSC (29.97 fps) for everything else. internal static bool IsPal(double fps) => Math.Abs(fps - 25) < 0.5 || Math.Abs(fps - 50) < 1 || Math.Abs(fps - 12.5) < 0.3; - private static IEnumerable VideoCodecArguments(string target, string codec, MediaQuality quality, VideoStreamInfo video, bool pal) + internal static IEnumerable VideoCodecArguments(string target, string codec, MediaQuality quality, VideoStreamInfo video, bool pal) { // Constant quantizer for the MPEG-style codecs: 2 is the best useful value, 4 good, 7 visibly compressed. var quantizer = Text(Choose(quality, 2, 4, 7)); @@ -485,7 +485,7 @@ internal static string BroadcastRate(double fps) return den == 1 ? num.ToString(CultureInfo.InvariantCulture) : $"{num}/{den}"; } - private static IEnumerable ContainerArguments(string target) => target switch + internal static IEnumerable ContainerArguments(string target) => target switch { // Moves the index to the front so playback (and web streaming) can start before the file is loaded. "mp4" or "m4v" or "mov" or "3gp" or "3g2" or "m4a" or "m4b" => ["-movflags", "+faststart"], @@ -500,7 +500,7 @@ internal static string BroadcastRate(double fps) _ => [], }; - private static IEnumerable MetadataArguments(int from = 0) + internal static IEnumerable MetadataArguments(int from = 0) { // Keep title, artist, dates, … (chapters are kept by default); an empty value removes a tag. yield return "-map_metadata"; @@ -527,15 +527,72 @@ private static List Audio(string input, string output, string target, Me args.AddRange(["-map", $"0:{cover}", "-c:v", "copy", "-disposition:v:0", "attached_pic"]); if (extras is { AttachmentFile: { } picture, AttachmentMime: { } mime }) args.AddRange(["-attach", picture, "-metadata:s:t", "mimetype=" + mime, "-metadata:s:t", "filename=" + Path.GetFileName(picture)]); - var codec = AudioEncoder(target, spec.AudioCodec, options); - args.AddRange(AudioCodecArguments(target, codec, options, audio, inVideo: false)); - args.AddRange(ChannelAndRateArguments(target, codec, options, audio.SampleRate)); + if (CanCopyIntoAudio(target, audio, options)) + { + // "Extract audio": the phone video's AAC goes into the M4A as it is, without a second encode. + args.AddRange(["-c:a", "copy"]); + } + else + { + var codec = AudioEncoder(target, spec.AudioCodec, options); + args.AddRange(AudioCodecArguments(target, codec, options, audio, inVideo: false)); + args.AddRange(ChannelAndRateArguments(target, codec, options, audio.SampleRate)); + } args.AddRange(MetadataArguments(extras?.MetadataFile is null ? 0 : 1)); args.AddRange(ContainerArguments(target)); args.AddRange(["-f", spec.Muxer, "-progress", "pipe:1", output]); return args; } + /// Audio codecs an audio target takes as they are (). + private static readonly Dictionary> CopyableAudio = new() + { + ["m4a"] = ["aac", "alac"], + ["m4b"] = ["aac", "alac"], + ["aac"] = ["aac"], + ["mp3"] = ["mp3"], + ["flac"] = ["flac"], + ["opus"] = ["opus"], + ["ogg"] = ["vorbis"], + }; + + /// + /// True when the preset copies sound it can () and this sound can be + /// copied into : the target takes its codec (only ALAC when the preset asks for Apple + /// Lossless), and no bitrate, channel count or Small quality asks for an encode. + /// + internal static bool CanCopyIntoAudio(string target, AudioStreamInfo audio, MediaOptions options) + { + if (!options.CopyAudioWhenPossible || options.Quality == MediaQuality.Small || options.AudioBitrateKbps > 0 + || options.AudioChannels is 1 or 2 && options.AudioChannels != audio.Channels + || audio.Codec is not { } codec || !CopyableAudio.TryGetValue(target, out var codecs) || !codecs.Contains(codec)) + return false; + return MediaCodecs.Effective(target, options.AudioCodec) != Core.Presets.AudioCodec.Alac || codec == "alac"; + } + + // ───────────────────────── Without metadata ───────────────────────── + + /// + /// "Remove metadata": the file's video, audio and subtitle streams copied as they are into the same format, with no + /// tags (title, artist, dates, location, camera, encoder), per-stream tags or chapters. Data tracks (an iPhone's + /// timed location and camera metadata) are left out; album art stays. + /// + public static List WithoutMetadata(string input, string output, string target, MediaInfo info) + { + var spec = VideoTargets.TryGetValue(target, out var video) ? video + : AudioTargets.TryGetValue(target, out var audio) ? audio + : throw new NotSupportedException($"FFmpeg cannot write '{target}'."); + List args = [.. Common, "-i", input]; + // A video's pictures without cover art (0:V); an audio file's cover art (0:v) stays with it. + args.AddRange(["-map", info.Video is not null ? "0:V?" : "0:v?", "-map", "0:a?", "-map", "0:s?", "-c", "copy"]); + if (info.Video?.Codec == "hevc" && target is "mp4" or "m4v" or "mov") + args.AddRange(["-tag:v", "hvc1"]); + args.AddRange(["-map_metadata", "-1", "-map_metadata:s", "-1", "-map_chapters", "-1", "-fflags", "+bitexact"]); + args.AddRange(ContainerArguments(target)); + args.AddRange(["-max_muxing_queue_size", "4096", "-f", spec.Muxer, "-progress", "pipe:1", output]); + return args; + } + /// /// The audio encoder of a target: the PCM format the preset picks for WAV and W64, ALAC for M4A / M4B when the /// preset asks for Apple Lossless, else the target's usual one. @@ -767,7 +824,7 @@ private static string GifFrames(VideoStreamInfo video, int maxHeight, int fps, i } /// Images have no pixel aspect ratio: anamorphic video (DV, DVD) is stretched to square pixels first. - private static string SquarePixels(VideoStreamInfo video) => + internal static string SquarePixels(VideoStreamInfo video) => Math.Abs(video.SampleAspect - 1) > 0.01 ? "scale=w='trunc(iw*sar/2)*2':h=ih,setsar=1," : ""; // ───────────────────────── Still frame ───────────────────────── @@ -840,7 +897,7 @@ internal static string StillScale(VideoStreamInfo picture, int maxHeight) // ───────────────────────── Helpers ───────────────────────── - private static T Choose(MediaQuality quality, T high, T balanced, T small) => quality switch + internal static T Choose(MediaQuality quality, T high, T balanced, T small) => quality switch { MediaQuality.High => high, MediaQuality.Small => small, @@ -849,7 +906,7 @@ internal static string StillScale(VideoStreamInfo picture, int maxHeight) private static string Text(int value) => value.ToString(CultureInfo.InvariantCulture); - private static int Even(double value) => Math.Max(2, (int)Math.Round(value / 2) * 2); + internal static int Even(double value) => Math.Max(2, (int)Math.Round(value / 2) * 2); /// The even number at or below (a tiny rounding margin keeps 1080.0000001 at 1080). private static int EvenDown(double value) => Math.Max(2, (int)Math.Floor(value / 2 + 1e-6) * 2); diff --git a/src/Filee.Engines/Media/MediaJoining.cs b/src/Filee.Engines/Media/MediaJoining.cs new file mode 100644 index 000000000..bca79430b --- /dev/null +++ b/src/Filee.Engines/Media/MediaJoining.cs @@ -0,0 +1,286 @@ +// Several files into one with FFmpeg: video clips or audio files back to back ("Merge videos", "Merge audio") and +// pictures shown one after the other, with crossfades if the preset asks ("Video from images"). Pure apart from the +// filter graph file of a long graph, like MediaEncoding: the command lines are unit tested without FFmpeg. +// FfmpegConverter runs them. + +using System.Globalization; +using Filee.Core.Presets; + +namespace Filee.Engines.Media; + +/// Command lines that join files. +internal static class MediaJoining +{ + /// Most pictures one slideshow takes: every picture is an FFmpeg input, held while the video is encoded. + public const int MaxSlides = 300; + + /// Frames per second of a slideshow: smooth fades, and what every player plays. + public const int SlideshowFps = 30; + + /// The rate every clip's sound is brought to before the clips are joined (what video uses most). + private const int JoinSampleRate = 48000; + + /// + /// A filter graph longer than this goes into a file (-/filter_complex): Windows limits a whole command line + /// to 32,767 characters, and every clip adds a chain to the graph. + /// + internal const int MaxInlineGraph = 8000; + + // ───────────────────────── Videos ───────────────────────── + + /// + /// True when the clips can be joined without an encode: every one is a video MP4 takes as it is, all with the same + /// codec, picture size, pixel format, turn and pixel shape, and the same kind of sound (or none at all). Videos from + /// one phone usually are. + /// + public static bool CanCopyVideos(IReadOnlyList clips, MediaOptions options) + { + if (clips.Count == 0 || clips[0].Video is not { } first) + return false; + var sameVideo = clips.All(c => c.Video is { } v && MediaEncoding.CanCopyVideo("mp4", v, options) + && v.Codec == first.Codec && v.Width == first.Width && v.Height == first.Height && v.PixelFormat == first.PixelFormat + && v.Rotation == first.Rotation && Math.Abs(v.SampleAspect - first.SampleAspect) < 0.01); + if (!sameVideo) + return false; + if (clips[0].Audio is not { } sound) + return clips.All(c => c.Audio is null); + return clips.All(c => c.Audio is { } a && MediaEncoding.CanCopyAudio("mp4", a, options) + && a.Codec == sound.Codec && a.SampleRate == sound.SampleRate && a.Channels == sound.Channels); + } + + /// The list of the concat demuxer: one file '…' line per clip, quotes escaped. + public static string ConcatList(IEnumerable paths) => + string.Concat(paths.Select(path => "file '" + Path.GetFullPath(path).Replace("'", @"'\''", StringComparison.Ordinal) + "'\n")); + + /// Clips that share codecs and size, copied one after the other into one MP4 (). + public static List CopyVideos(string listFile, string output, MediaInfo first) + { + List args = [.. MediaEncoding.Common, "-f", "concat", "-safe", "0", "-i", listFile, "-map", "0:V:0"]; + if (first.Audio is not null) + args.AddRange(["-map", "0:a:0"]); + args.AddRange(["-c", "copy"]); + if (first.Video?.Codec == "hevc") + args.AddRange(["-tag:v", "hvc1"]); + args.AddRange(MediaEncoding.MetadataArguments()); + args.AddRange(MediaEncoding.ContainerArguments("mp4")); + args.AddRange(["-max_muxing_queue_size", "4096", "-f", "mp4", "-progress", "pipe:1", output]); + return args; + } + + /// + /// The frame every clip is fitted into: the first clip's picture as it is shown (turned, square pixels), even, and + /// no taller than . + /// + internal static (int Width, int Height) JoinFrame(VideoStreamInfo first, int maxHeight) + { + var height = (double)Math.Max(2, first.DisplayHeight); + if (maxHeight > 0) + height = Math.Min(height, Math.Max(16, maxHeight)); + return (MediaEncoding.Even(height * first.DisplayAspect), MediaEncoding.Even(height)); + } + + /// + /// Clips of any codec and size encoded into one MP4 (H.264, or the preset's codec, and AAC): each is fitted into the + /// first clip's frame with black bars, at its frame rate. A clip without sound is silent for its length when + /// others have sound. + /// + /// The video encoder (). + /// Where a long filter graph is written (). + /// A clip has no video, or FFmpeg can't tell how long a silent one is. + public static List EncodeVideos(IReadOnlyList<(string Path, MediaInfo Info)> clips, string output, MediaOptions options, + string encoder, string workDirectory) + { + var first = clips[0].Info.Video ?? throw new InvalidOperationException($"{Path.GetFileName(clips[0].Path)} has no video stream."); + var (width, height) = JoinFrame(first, options.MaxHeight); + var fps = MediaEncoding.NearestRate(first.FrameRate) ?? "30"; + var withSound = clips.Any(c => c.Info.Audio is not null); + + var graph = new List(); + var segments = ""; + for (var i = 0; i < clips.Count; i++) + { + var (path, info) = clips[i]; + var video = info.Video ?? throw new InvalidOperationException($"{Path.GetFileName(path)} has no video stream."); + graph.Add($"[{i}:V:0]{MediaEncoding.SquarePixels(video)}{Fit(width, height)},fps={fps},format=yuv420p[v{i}]"); + segments += $"[v{i}]"; + if (!withSound) + continue; + graph.Add(info.Audio is not null ? $"[{i}:a:0]{Resample("stereo")}[a{i}]" : $"{Silence(path, info, "stereo")}[a{i}]"); + segments += $"[a{i}]"; + } + graph.Add($"{segments}concat=n={clips.Count}:v=1:a={(withSound ? 1 : 0)}[v]{(withSound ? "[a]" : "")}"); + + List args = [.. MediaEncoding.Common]; + foreach (var (path, _) in clips) + args.AddRange(["-i", path]); + args.AddRange(GraphArguments(string.Join(';', graph), workDirectory)); + args.AddRange(["-map", "[v]"]); + args.AddRange(MediaEncoding.VideoCodecArguments("mp4", encoder, options.Quality, first, pal: false)); + if (withSound) + args.AddRange(["-map", "[a]", "-c:a", "aac", "-b:a", Text(AacKbps(options, 2)) + "k"]); + else + args.Add("-an"); + args.AddRange(MediaEncoding.MetadataArguments()); + args.AddRange(MediaEncoding.ContainerArguments("mp4")); + args.AddRange(["-max_muxing_queue_size", "4096", "-f", "mp4", "-progress", "pipe:1", output]); + return args; + } + + // ───────────────────────── Audio ───────────────────────── + + /// + /// Audio files (or the sound of videos) decoded and joined into one M4A, sample after sample: no gap and no + /// encoder padding between them, which copying the clips' AAC would leave. Mono when every clip is mono, else + /// stereo; AAC at the preset's quality or bitrate, or Apple Lossless when the preset asks for it. + /// + /// A file has no sound and FFmpeg can't tell how long it is. + public static List EncodeAudio(IReadOnlyList<(string Path, MediaInfo Info)> clips, string output, MediaOptions options, + string workDirectory) + { + var mono = clips.All(c => c.Info.Audio is { Channels: 1 }); + var layout = mono ? "mono" : "stereo"; + var graph = new List(); + var segments = ""; + for (var i = 0; i < clips.Count; i++) + { + var (path, info) = clips[i]; + graph.Add(info.Audio is not null ? $"[{i}:a:0]{Resample(layout)}[a{i}]" : $"{Silence(path, info, layout)}[a{i}]"); + segments += $"[a{i}]"; + } + graph.Add($"{segments}concat=n={clips.Count}:v=0:a=1[a]"); + + List args = [.. MediaEncoding.Common]; + foreach (var (path, _) in clips) + args.AddRange(["-i", path]); + args.AddRange(GraphArguments(string.Join(';', graph), workDirectory)); + args.AddRange(["-map", "[a]", "-vn"]); + if (MediaCodecs.Effective("m4a", options.AudioCodec) == AudioCodec.Alac) + args.AddRange(["-c:a", "alac"]); + else + args.AddRange(["-c:a", "aac", "-b:a", Text(AacKbps(options, mono ? 1 : 2)) + "k"]); + args.AddRange(MediaEncoding.MetadataArguments()); + args.AddRange(MediaEncoding.ContainerArguments("m4a")); + args.AddRange(["-f", "ipod", "-progress", "pipe:1", output]); + return args; + } + + /// The preset's AAC bitrate, else 256 / 192 / 128 kbit/s per stereo pair by quality (half for mono). + private static int AacKbps(MediaOptions options, int channels) => + options.AudioBitrateKbps > 0 + ? Math.Clamp(options.AudioBitrateKbps, 16, 512) + : MediaEncoding.Choose(options.Quality, 256, 192, 128) * channels / 2; + + private static string Resample(string layout) => + $"aresample={JoinSampleRate},aformat=sample_fmts=fltp:sample_rates={JoinSampleRate}:channel_layouts={layout}"; + + /// Silence as long as a clip without sound, so the sound of the clips after it stays in step. + private static string Silence(string path, MediaInfo info, string layout) + { + var seconds = info.Duration?.TotalSeconds ?? 0; + if (seconds <= 0) + throw new InvalidOperationException($"{Path.GetFileName(path)} has no sound, and FFmpeg can't tell how long it is."); + return $"aevalsrc=0:c={layout}:s={JoinSampleRate}:d={Seconds(seconds)},{Resample(layout)}"; + } + + // ───────────────────────── Pictures ───────────────────────── + + /// The slideshow frame of a preset: even, between 16 and 8K, 1080p by default. + public static (int Width, int Height) SlideshowFrame(MediaOptions options) => + (MediaEncoding.Even(Math.Clamp(options.SlideshowWidth, 16, 7680)), MediaEncoding.Even(Math.Clamp(options.SlideshowHeight, 16, 4320))); + + /// How long a slideshow of pictures plays: the fades overlap the pictures. + public static TimeSpan SlideshowDuration(int count, MediaOptions options) => count <= 0 + ? TimeSpan.Zero + : TimeSpan.FromSeconds(count * options.SlideDuration - (count - 1) * options.FadeDuration); + + /// + /// One picture fitted into the slideshow frame (black bars, transparency on black) as a JPEG at FFmpeg's best + /// quality: the slideshow then holds a small 1080p picture per slide instead of a full-size photo. + /// + public static List PrepareSlide(string picture, string output, int width, int height) => + [ + .. MediaEncoding.Common, "-i", picture, "-map", "0:V:0", + "-vf", $"premultiply=inplace=1,{Fit(width, height, ":flags=lanczos")}", + "-frames:v", "1", "-update", "1", "-c:v", "mjpeg", "-q:v", "2", "-f", "image2", output, + ]; + + /// + /// The prepared slides () as one MP4: each shown for , + /// fading into the next over (a cut without crossfades). BT.709 in TV range, + /// 30 frames per second, no sound. + /// + /// The video encoder (). + /// Where a long filter graph is written (). + public static List Slideshow(IReadOnlyList slides, string output, MediaOptions options, string encoder, string workDirectory) + { + var (width, height) = SlideshowFrame(options); + var show = Seconds(options.SlideDuration); + var fade = options.FadeDuration; + var graph = new List(); + for (var i = 0; i < slides.Count; i++) + { + // One decoded frame per picture, repeated for its time: no picture is read more than once. + graph.Add($"[{i}:v]scale=out_color_matrix=bt709:out_range=tv,format=yuv420p,setsar=1,fps={SlideshowFps}," + + $"tpad=stop_mode=clone:stop_duration={show},trim=duration={show},setpts=PTS-STARTPTS[s{i}]"); + } + if (slides.Count == 1) + { + graph.Add("[s0]null[out]"); + } + else if (fade > 0) + { + // Each fade starts where the previous output ends minus the fade: k × (show − fade). + var previous = "s0"; + for (var k = 1; k < slides.Count; k++) + { + var label = k == slides.Count - 1 ? "out" : $"x{k}"; + graph.Add($"[{previous}][s{k}]xfade=transition=fade:duration={Seconds(fade)}:offset={Seconds(k * (options.SlideDuration - fade))}[{label}]"); + previous = label; + } + } + else + { + graph.Add($"{string.Concat(Enumerable.Range(0, slides.Count).Select(i => $"[s{i}]"))}concat=n={slides.Count}:v=1:a=0[out]"); + } + + List args = [.. MediaEncoding.Common]; + foreach (var slide in slides) + args.AddRange(["-i", slide]); + args.AddRange(GraphArguments(string.Join(';', graph), workDirectory)); + args.AddRange(["-map", "[out]"]); + // Pictures cost an encoder little: one step above the preset's quality, as a single picture's video does. + var quality = options.Quality == MediaQuality.Small ? MediaQuality.Balanced : MediaQuality.High; + var frame = new VideoStreamInfo(width, height, 1, 0, SlideshowFps, PixelFormat: "yuv420p"); + args.AddRange(MediaEncoding.VideoCodecArguments("mp4", encoder, quality, frame, pal: false)); + args.AddRange(["-r", Text(SlideshowFps), "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-an"]); + args.AddRange(MediaEncoding.ContainerArguments("mp4")); + args.AddRange(["-f", "mp4", "-progress", "pipe:1", output]); + return args; + } + + // ───────────────────────── Helpers ───────────────────────── + + /// Fits a picture into × , centred, with black bars. + /// More options of the scale filter, e.g. ":flags=lanczos". + private static string Fit(int width, int height, string scaleOptions = "") => + $"scale={width}:{height}:force_original_aspect_ratio=decrease:force_divisible_by=2{scaleOptions}," + + $"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1"; + + /// + /// -filter_complex with the graph, or -/filter_complex with a file holding it when it is long + /// (). + /// + internal static string[] GraphArguments(string graph, string workDirectory) + { + if (graph.Length <= MaxInlineGraph) + return ["-filter_complex", graph]; + Directory.CreateDirectory(workDirectory); + var file = Path.Combine(workDirectory, $"graph-{Guid.NewGuid():N}.txt"); + File.WriteAllText(file, graph); + return ["-/filter_complex", file]; + } + + private static string Seconds(double seconds) => seconds.ToString("0.###", CultureInfo.InvariantCulture); + + private static string Text(int value) => value.ToString(CultureInfo.InvariantCulture); +} diff --git a/src/Filee.Engines/Pdf/PdfSharpConverter.cs b/src/Filee.Engines/Pdf/PdfSharpConverter.cs index 4f64ac75c..97b09639d 100644 --- a/src/Filee.Engines/Pdf/PdfSharpConverter.cs +++ b/src/Filee.Engines/Pdf/PdfSharpConverter.cs @@ -1,5 +1,5 @@ -// Builds PDFs from images, splits / extracts PDF pages, merges PDFs and makes PDFs smaller (PDFsharp, MIT; the -// compression itself is in PdfCompressor, with Ghostscript as an optional second try). +// Builds PDFs from images, splits / extracts PDF pages, merges PDFs, makes PDFs smaller (PDFsharp, MIT; the +// compression itself is in PdfCompressor, with Ghostscript as an optional second try) and removes their metadata. using Filee.Core.Conversion; using Filee.Core.Presets; @@ -36,6 +36,8 @@ public sealed class PdfSharpConverter : IConverter, IPdfMerger, IImageTransforme public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) => step.From != "pdf" ? Task.Run(() => ImageToPdf(step, progress, cancellationToken), cancellationToken) + : step.Preset.Pdf is { Compression: PdfCompression.None, RemoveMetadata: true, SplitPages: false, PageRange: "" } + ? Task.Run(() => WithoutMetadata(step, progress, cancellationToken), cancellationToken) : step.Preset.Pdf.Compression == PdfCompression.None ? Task.Run(() => SplitOrExtract(step, progress, cancellationToken), cancellationToken) : Task.Run(() => CompressAsync(step, progress, cancellationToken), cancellationToken); @@ -105,6 +107,42 @@ private static IReadOnlyList ImageToPdf(ConversionStep step, IProgress + /// "Remove metadata": the whole PDF, opened for changes so links, bookmarks and form fields stay, without its + /// document information (title, author, subject, keywords, creator, dates) and XMP packets. + /// + private static IReadOnlyList WithoutMetadata(ConversionStep step, IProgress? progress, CancellationToken ct) + { + using var document = OpenForChanges(step.InputPath); + ct.ThrowIfCancellationRequested(); + StripMetadata(document); + progress?.Report(0.5); + var output = step.Output.Allocate("pdf"); + if (output is null) + return []; + document.Save(output); + return [output]; + } + + /// + /// Drops the document information and every XMP packet (document, pages, pictures, forms) and the private data of + /// the apps that made the file. PDFsharp writes a new XMP packet with only its own name and the save date. + /// + internal static void StripMetadata(PdfDocument document) + { + foreach (var key in document.Info.Elements.Keys.ToList()) + document.Info.Elements.Remove(key); + document.Internals.Catalog.Elements.Remove("/Metadata"); + document.Internals.Catalog.Elements.Remove("/PieceInfo"); + foreach (var page in document.Pages) + page.Elements.Remove("/PieceInfo"); + foreach (var item in document.Internals.GetAllObjects()) + { + if (item is PdfDictionary dictionary && dictionary.Elements.ContainsKey("/Metadata")) + dictionary.Elements.Remove("/Metadata"); + } + } + /// PDF → PDF: split into single pages, or extract the configured page range. private static IReadOnlyList SplitOrExtract(ConversionStep step, IProgress? progress, CancellationToken ct) { diff --git a/tests/Filee.App.Tests/DonutFlipTests.cs b/tests/Filee.App.Tests/DonutFlipTests.cs index d98555a29..01d11cec7 100644 --- a/tests/Filee.App.Tests/DonutFlipTests.cs +++ b/tests/Filee.App.Tests/DonutFlipTests.cs @@ -681,9 +681,10 @@ public void The_editor_switches_between_the_front_and_the_back(string language, RenderTests.Save(window, $"toolbar-back-face-{language}{(dark ? "-dark" : "")}.png"); var back = ImageBack; + // The built-in back face is full (settings schema 33): a slice goes before another comes in. + page.RemoveAt(page.SliceCount - 1); Assert.True(page.Insert("to-psd", 0)); page.Move(0, 2); - page.RemoveAt(page.SliceCount - 1); List edited = [.. back[..2], "to-psd", .. back[2..^1]]; Assert.Equal(edited, Images.BackPresetIds); Assert.Equal(front, Images.PresetIds); // the front as it was diff --git a/tests/Filee.Core.Tests/FakeConverter.cs b/tests/Filee.Core.Tests/FakeConverter.cs index c0706262e..d500fddfb 100644 --- a/tests/Filee.Core.Tests/FakeConverter.cs +++ b/tests/Filee.Core.Tests/FakeConverter.cs @@ -158,6 +158,26 @@ public Task CombineAsync(IReadOnlyList inputPaths, string outputPath, st } } +/// Test double for the FFmpeg engine's joining: records what it was given and writes their names. +internal sealed class FakeJoiner : IMediaJoiner +{ + public List Inputs { get; } = []; + + public bool Throw { get; set; } + + public bool CanJoinInto(string format) => true; + + public Task JoinAsync(IReadOnlyList inputPaths, string outputPath, string format, Filee.Core.Presets.Preset preset, + string workDirectory, IProgress? progress, CancellationToken cancellationToken) + { + if (Throw) + throw new InvalidOperationException("boom"); + Inputs.AddRange(inputPaths); + File.WriteAllText(outputPath, string.Join("|", inputPaths.Select(Path.GetFileName))); + return Task.CompletedTask; + } +} + /// Creates and removes a unique temp directory. internal sealed class TempDir : IDisposable { diff --git a/tests/Filee.Core.Tests/MediaToolTests.cs b/tests/Filee.Core.Tests/MediaToolTests.cs new file mode 100644 index 000000000..6fb8d43f6 --- /dev/null +++ b/tests/Filee.Core.Tests/MediaToolTests.cs @@ -0,0 +1,183 @@ +// The everyday media tools of settings schema 33: their presets, how existing libraries get them, and the queue +// joining videos, audio files and pictures into one file. + +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Core.Profiles; +using Filee.Core.Settings; + +namespace Filee.Core.Tests; + +public class MediaToolTests +{ + private static readonly string[] ToolIds = + ["compress-image", "compress-video", "video-from-images", "extract-audio", "merge-videos", "compress-audio", "merge-audio", "remove-metadata"]; + + private static Preset BuiltIn(string id) => BuiltInData.CreatePresets().Single(p => p.Id == id); + + private static async Task Run(JobQueue queue, IReadOnlyList sources, Preset preset) + { + var job = new ConversionJob(sources, preset, "Test"); + await queue.RunAsync(job); + return job; + } + + [Fact] + public void A_new_library_has_every_tool_with_a_name_to_show() + { + var presets = BuiltInData.CreatePresets(); + foreach (var id in ToolIds) + Assert.StartsWith("preset.", Assert.Single(presets, p => p.Id == id).NameKey); + } + + [Fact] + public void Only_the_merge_and_slideshow_tools_join_files() + { + Assert.Equal(["video-from-images", "merge-videos", "merge-audio"], + BuiltInData.CreatePresets().Where(p => p.JoinsIntoOneMedia).Select(p => p.Id)); + // A join option on a target that can't hold it converts file by file, as before. + Assert.False(new Preset { TargetFormat = "webm", Media = { JoinIntoOne = true } }.JoinsIntoOneMedia); + } + + [Fact] + public void Remove_metadata_takes_everything_and_keeps_the_format() + { + var clean = BuiltIn("remove-metadata"); + Assert.Equal(BuiltInData.SameAsSource, clean.TargetFormat); + Assert.False(clean.Image.KeepMetadata); + Assert.False(clean.Image.KeepColorProfile); + Assert.True(clean.Image.KeepSourceQuality); + Assert.True(clean.Pdf.RemoveMetadata); + Assert.True(clean.Media.RemoveMetadata); + Assert.False(clean.Output.KeepDates); + } + + [Fact] + public void Slides_and_fades_stay_within_their_limits() + { + var media = new MediaOptions { SlideSeconds = 2, CrossfadeSeconds = 5 }; + Assert.Equal(1, media.FadeDuration); // at most half of the time a picture is shown + media.Crossfade = false; + Assert.Equal(0, media.FadeDuration); + Assert.Equal(MediaOptions.MaxSlideSeconds, new MediaOptions { SlideSeconds = 1e9 }.SlideDuration); + Assert.Equal(3, new MediaOptions { SlideSeconds = double.NaN }.SlideDuration); + } + + [Fact] + public void Options_survive_a_copy() + { + var copy = BuiltIn("video-from-images").Clone(); + Assert.True(copy.Media.Slideshow); + Assert.True(copy.Media.Crossfade); + Assert.True(BuiltIn("compress-image").Clone().Image.MaxCompression); + } + + [Fact] + public void Version_32_libraries_get_the_tools_and_back_faces_with_room_offer_them() + { + var presets = BuiltInData.CreatePresets().Where(p => !ToolIds.Contains(p.Id)).ToList(); + var profiles = BuiltInData.CreateProfiles(); + foreach (var profile in profiles) + profile.BackPresetIds!.RemoveAll(ToolIds.Contains); + // A back face the user filled up stays as it is. + var pdf = profiles.Single(p => p.Id == "pdf"); + while (pdf.BackPresetIds!.Count < ToolbarProfile.MaxSlices) + pdf.BackPresetIds.Insert(0, presets.First(p => !pdf.BackPresetIds.Contains(p.Id) && !pdf.PresetIds.Contains(p.Id)).Id); + var full = pdf.BackPresetIds.ToList(); + + Assert.True(SettingsMigrations.ApplyToLibrary(32, presets, profiles)); + + Assert.All(ToolIds, id => Assert.Single(presets, p => p.Id == id)); + var audio = profiles.Single(p => p.Id == "audio").BackPresetIds!; + Assert.Equal(["to-alac", "compress-audio", "merge-audio", "remove-metadata", "zip-all"], audio); + var video = profiles.Single(p => p.Id == "video").BackPresetIds!; + Assert.Equal(["compress-video", "merge-videos", "zip-all"], video.TakeLast(3)); + Assert.Equal(full, pdf.BackPresetIds); + Assert.False(SettingsMigrations.ApplyToLibrary(32, presets, profiles)); // once only + Assert.False(SettingsMigrations.ApplyToLibrary(AppSettings.CurrentSchemaVersion, presets, profiles)); + } + + [Fact] + public async Task Merge_videos_joins_the_clips_in_order_into_one_file_named_after_the_first() + { + using var dir = new TempDir(); + var second = dir.File("b.mp4"); + var first = dir.File("a.mov"); + var joiner = new FakeJoiner(); + await using var queue = new JobQueue(new ConverterCatalog([]), mediaJoiner: joiner); + + var job = await Run(queue, [first, second], BuiltIn("merge-videos")); + + Assert.Equal(JobState.Completed, job.State); + Assert.Equal([first, second], joiner.Inputs); + Assert.Equal(Path.Combine(dir.Path, "a_merged.mp4"), job.Outputs.Single()); + } + + [Fact] + public async Task A_file_that_is_no_video_fails_and_the_other_clips_still_join() + { + using var dir = new TempDir(); + var clip = dir.File("a.mp4"); + var song = dir.File("b.mp3"); + var joiner = new FakeJoiner(); + await using var queue = new JobQueue(new ConverterCatalog([]), mediaJoiner: joiner); + + var job = await Run(queue, [clip, song], BuiltIn("merge-videos")); + + Assert.Equal(JobState.CompletedWithErrors, job.State); + Assert.Equal([clip], joiner.Inputs); + Assert.Equal("error.no_route", job.Files[1].ErrorKey); + } + + [Fact] + public async Task Merge_audio_takes_audio_files_and_the_sound_of_videos() + { + using var dir = new TempDir(); + var song = dir.File("a.mp3"); + var clip = dir.File("b.mp4"); + var joiner = new FakeJoiner(); + await using var queue = new JobQueue(new ConverterCatalog([]), mediaJoiner: joiner); + + var job = await Run(queue, [song, clip], BuiltIn("merge-audio")); + + Assert.Equal(JobState.Completed, job.State); + Assert.Equal([song, clip], joiner.Inputs); + Assert.Equal(Path.Combine(dir.Path, "a_merged.m4a"), job.Outputs.Single()); + } + + [Fact] + public async Task Video_from_images_makes_pngs_first_and_takes_a_png_as_it_is() + { + using var dir = new TempDir(); + var photo = dir.File("a.jpg"); + var drawing = dir.File("b.png"); + var joiner = new FakeJoiner(); + await using var queue = new JobQueue(new ConverterCatalog([new FakeConverter("magick", new ConversionEdge("jpg", "png"))]), + mediaJoiner: joiner); + + var job = await Run(queue, [photo, drawing], BuiltIn("video-from-images")); + + Assert.Equal(JobState.Completed, job.State); + Assert.Equal(2, joiner.Inputs.Count); + Assert.EndsWith(".png", joiner.Inputs[0]); + Assert.NotEqual(photo, joiner.Inputs[0]); // converted in the work directory + Assert.Equal(drawing, joiner.Inputs[1]); + Assert.Equal(Path.Combine(dir.Path, "a_video.mp4"), job.Outputs.Single()); + Assert.False(File.Exists(Path.Combine(dir.Path, "a.png"))); // no picture left beside the source + } + + [Fact] + public async Task A_failed_join_fails_every_file_and_leaves_no_output() + { + using var dir = new TempDir(); + var a = dir.File("a.mp4"); + var b = dir.File("b.mp4"); + await using var queue = new JobQueue(new ConverterCatalog([]), mediaJoiner: new FakeJoiner { Throw = true }); + + var job = await Run(queue, [a, b], BuiltIn("merge-videos")); + + Assert.Equal(JobState.Failed, job.State); + Assert.All(job.Files, f => Assert.Equal("error.conversion_failed", f.ErrorKey)); + Assert.Empty(job.Outputs); + } +} diff --git a/tests/Filee.Engines.Tests/EngineFixture.cs b/tests/Filee.Engines.Tests/EngineFixture.cs index 60518af0f..75c3ed501 100644 --- a/tests/Filee.Engines.Tests/EngineFixture.cs +++ b/tests/Filee.Engines.Tests/EngineFixture.cs @@ -67,7 +67,8 @@ public static string MakeMultiPageTiff(string folder, int pages) public async Task ConvertAsync(IReadOnlyList sources, Preset preset) { await using var queue = new JobQueue(Catalog, EngineRegistry.FindPdfMerger(Converters), - combiner: EngineRegistry.FindFileCombiner(Converters), tiffMerger: EngineRegistry.FindTiffMerger(Converters)); + combiner: EngineRegistry.FindFileCombiner(Converters), tiffMerger: EngineRegistry.FindTiffMerger(Converters), + mediaJoiner: EngineRegistry.FindMediaJoiner(Converters)); var job = new ConversionJob(sources, preset, preset.Name); await queue.RunAsync(job); return job; diff --git a/tests/Filee.Engines.Tests/MediaJoiningTests.cs b/tests/Filee.Engines.Tests/MediaJoiningTests.cs new file mode 100644 index 000000000..03ba9efe7 --- /dev/null +++ b/tests/Filee.Engines.Tests/MediaJoiningTests.cs @@ -0,0 +1,211 @@ +// Command lines of the everyday media tools (no FFmpeg needed): videos and audio files joined into one, pictures as a +// slideshow, a video's sound copied into M4A, and a copy of a file without its tags. + +using Filee.Core.Presets; +using Filee.Engines.Media; + +namespace Filee.Engines.Tests; + +public class MediaJoiningTests +{ + private static readonly VideoStreamInfo PhoneVideo = new(1920, 1080, 1, 0, 30, "h264", "yuv420p"); + + private static MediaInfo Clip(VideoStreamInfo? video = null, AudioStreamInfo? audio = null, double seconds = 10) => + new(TimeSpan.FromSeconds(seconds), video, audio, null); + + private static string After(IReadOnlyList args, string option) => args[args.ToList().IndexOf(option) + 1]; + + [Fact] + public void Clips_from_one_phone_are_copied() + { + var clip = Clip(PhoneVideo, new AudioStreamInfo(2, 48000, "aac")); + Assert.True(MediaJoining.CanCopyVideos([clip, clip], new MediaOptions())); + + var args = MediaJoining.CopyVideos("clips.txt", "out.mp4", clip); + Assert.Equal("concat", After(args, "-f")); + Assert.Equal("copy", After(args, "-c")); + Assert.Contains("0:a:0", args); + } + + [Fact] + public void Clips_of_another_size_codec_or_sound_are_encoded() + { + var clip = Clip(PhoneVideo, new AudioStreamInfo(2, 48000, "aac")); + var options = new MediaOptions(); + Assert.False(MediaJoining.CanCopyVideos([clip, clip with { Video = PhoneVideo with { Width = 1280, Height = 720 } }], options)); + Assert.False(MediaJoining.CanCopyVideos([clip, clip with { Video = PhoneVideo with { Codec = "hevc" } }], options)); + Assert.False(MediaJoining.CanCopyVideos([clip, clip with { Audio = new AudioStreamInfo(2, 44100, "aac") }], options)); + Assert.False(MediaJoining.CanCopyVideos([clip, clip with { Audio = null }], options)); + Assert.False(MediaJoining.CanCopyVideos([clip, clip], new MediaOptions { Quality = MediaQuality.Small })); + } + + [Fact] + public void Encoded_clips_fit_the_first_ones_frame_and_a_silent_one_gets_silence() + { + var portrait = PhoneVideo with { Rotation = -90 }; + var withSound = Clip(portrait, new AudioStreamInfo(2, 44100, "aac")); + var silent = Clip(PhoneVideo, seconds: 4.5); + + var args = MediaJoining.EncodeVideos([("a.mov", withSound), ("b.mp4", silent)], "out.mp4", new MediaOptions(), "libx264", "work"); + + var graph = After(args, "-filter_complex"); + Assert.Contains("[0:V:0]scale=1080:1920:force_original_aspect_ratio=decrease", graph); // the first clip stands upright + Assert.Contains("pad=1080:1920", graph); + Assert.Contains("aevalsrc=0:c=stereo:s=48000:d=4.5", graph); + Assert.Contains("concat=n=2:v=1:a=1[v][a]", graph); + Assert.Equal("libx264", After(args, "-c:v")); + Assert.Equal("aac", After(args, "-c:a")); + Assert.Equal("out.mp4", args[^1]); + } + + [Fact] + public void Clips_without_any_sound_join_without_an_audio_track() + { + var args = MediaJoining.EncodeVideos([("a.mp4", Clip(PhoneVideo)), ("b.mp4", Clip(PhoneVideo))], "out.mp4", new MediaOptions(), + "libx264", "work"); + + Assert.Contains("concat=n=2:v=1:a=0[v]", After(args, "-filter_complex")); + Assert.Contains("-an", args); + } + + [Fact] + public void A_frame_never_gets_taller_than_the_preset_allows() + { + var uhd = PhoneVideo with { Width = 3840, Height = 2160 }; + Assert.Equal((1920, 1080), MediaJoining.JoinFrame(uhd, 1080)); + Assert.Equal((3840, 2160), MediaJoining.JoinFrame(uhd, 0)); + } + + [Fact] + public void Audio_is_decoded_and_joined_without_gaps() + { + var song = Clip(audio: new AudioStreamInfo(2, 44100, "mp3")); + var memo = Clip(audio: new AudioStreamInfo(1, 16000, "aac")); + + var args = MediaJoining.EncodeAudio([("a.mp3", song), ("b.m4a", memo)], "out.m4a", new MediaOptions(), "work"); + + var graph = After(args, "-filter_complex"); + Assert.Contains("[0:a:0]aresample=48000", graph); + Assert.Contains("channel_layouts=stereo", graph); + Assert.Contains("concat=n=2:v=0:a=1[a]", graph); + Assert.Equal("aac", After(args, "-c:a")); + Assert.Equal("192k", After(args, "-b:a")); + Assert.Equal("ipod", After(args, "-f")); + } + + [Fact] + public void Mono_recordings_stay_mono_and_apple_lossless_is_kept() + { + var memo = Clip(audio: new AudioStreamInfo(1, 16000, "aac")); + var mono = MediaJoining.EncodeAudio([("a.m4a", memo), ("b.m4a", memo)], "out.m4a", new MediaOptions(), "work"); + Assert.Contains("channel_layouts=mono", After(mono, "-filter_complex")); + Assert.Equal("96k", After(mono, "-b:a")); + + var alac = MediaJoining.EncodeAudio([("a.m4a", memo)], "out.m4a", new MediaOptions { AudioCodec = AudioCodec.Alac }, "work"); + Assert.Equal("alac", After(alac, "-c:a")); + } + + [Fact] + public void Slides_fade_into_each_other_at_the_right_times() + { + var options = new MediaOptions { Slideshow = true, SlideSeconds = 3, CrossfadeSeconds = 1 }; + + var args = MediaJoining.Slideshow(["1.jpg", "2.jpg", "3.jpg"], "out.mp4", options, "libx264", "work"); + + var graph = After(args, "-filter_complex"); + Assert.Contains("tpad=stop_mode=clone:stop_duration=3,trim=duration=3", graph); + Assert.Contains("[s0][s1]xfade=transition=fade:duration=1:offset=2[x1]", graph); + Assert.Contains("[x1][s2]xfade=transition=fade:duration=1:offset=4[out]", graph); + Assert.Equal(TimeSpan.FromSeconds(7), MediaJoining.SlideshowDuration(3, options)); + Assert.Contains("-an", args); + } + + [Fact] + public void Without_crossfades_the_slides_are_cut() + { + var options = new MediaOptions { Slideshow = true, SlideSeconds = 2, Crossfade = false }; + + var graph = After(MediaJoining.Slideshow(["1.jpg", "2.jpg"], "out.mp4", options, "libx264", "work"), "-filter_complex"); + + Assert.Contains("[s0][s1]concat=n=2:v=1:a=0[out]", graph); + Assert.DoesNotContain("xfade", graph); + Assert.Equal(TimeSpan.FromSeconds(4), MediaJoining.SlideshowDuration(2, options)); + } + + [Fact] + public void A_picture_is_fitted_into_the_1080p_frame() + { + var args = MediaJoining.PrepareSlide("photo.png", "slide.jpg", 1920, 1080); + + Assert.Contains("scale=1920:1080:force_original_aspect_ratio=decrease:force_divisible_by=2:flags=lanczos,pad=1920:1080", + After(args, "-vf")); + Assert.Equal("mjpeg", After(args, "-c:v")); + Assert.Equal((1920, 1080), MediaJoining.SlideshowFrame(new MediaOptions())); + } + + [Fact] + public void A_long_graph_goes_into_a_file() + { + var work = Path.Combine(Path.GetTempPath(), "filee-tests", Guid.NewGuid().ToString("N")[..8]); + try + { + var slides = Enumerable.Range(1, 100).Select(i => $"{i}.jpg").ToList(); + var args = MediaJoining.Slideshow(slides, "out.mp4", new MediaOptions { Slideshow = true }, "libx264", work); + + Assert.DoesNotContain("-filter_complex", args); + var file = After(args, "-/filter_complex"); + Assert.Contains("[x98][s99]xfade", File.ReadAllText(file)); + } + finally + { + if (Directory.Exists(work)) + Directory.Delete(work, recursive: true); + } + } + + [Fact] + public void Concat_lists_quote_every_path() + { + var list = MediaJoining.ConcatList(["it's.mp4"]); + Assert.StartsWith("file '", list); + Assert.EndsWith(@"it'\''s.mp4'" + "\n", list); + } + + [Fact] + public void Extract_audio_copies_aac_into_m4a_and_encodes_what_it_cannot_copy() + { + var copy = new MediaOptions { CopyAudioWhenPossible = true }; + var aac = new AudioStreamInfo(2, 48000, "aac"); + Assert.True(MediaEncoding.CanCopyIntoAudio("m4a", aac, copy)); + Assert.False(MediaEncoding.CanCopyIntoAudio("m4a", new AudioStreamInfo(2, 48000, "opus"), copy)); + Assert.False(MediaEncoding.CanCopyIntoAudio("m4a", aac, new MediaOptions())); // only when the preset asks + Assert.False(MediaEncoding.CanCopyIntoAudio("m4a", aac, new MediaOptions { CopyAudioWhenPossible = true, AudioBitrateKbps = 96 })); + Assert.False(MediaEncoding.CanCopyIntoAudio("m4a", aac, new MediaOptions { CopyAudioWhenPossible = true, AudioCodec = AudioCodec.Alac })); + + var phone = new MediaInfo(TimeSpan.FromSeconds(10), PhoneVideo, aac, null); + var args = MediaEncoding.Plan("in.mov", "out.m4a", "m4a", copy, phone, "work").Single().Arguments; + Assert.Equal("copy", After(args, "-c:a")); + } + + [Fact] + public void Remove_metadata_copies_the_streams_without_tags_chapters_or_data_tracks() + { + var phone = new MediaInfo(TimeSpan.FromSeconds(10), PhoneVideo with { Codec = "hevc" }, new AudioStreamInfo(2, 48000, "aac"), null); + + var args = MediaEncoding.WithoutMetadata("in.mov", "out.mov", "mov", phone); + + Assert.Equal("copy", After(args, "-c")); + Assert.Equal("-1", After(args, "-map_metadata")); + Assert.Equal("-1", After(args, "-map_metadata:s")); + Assert.Equal("-1", After(args, "-map_chapters")); + Assert.Contains("0:V?", args); + Assert.DoesNotContain("0", args.Where((a, i) => i > 0 && args[i - 1] == "-map")); // never every stream (data tracks) + Assert.Equal("hvc1", After(args, "-tag:v")); + Assert.Equal("mov", After(args, "-f")); + + var song = new MediaInfo(TimeSpan.FromSeconds(10), null, new AudioStreamInfo(2, 44100, "mp3"), 1); + var audio = MediaEncoding.WithoutMetadata("in.mp3", "out.mp3", "mp3", song); + Assert.Contains("0:v?", audio); // the album art stays + Assert.Equal("mp3", After(audio, "-f")); + } +} diff --git a/tests/Filee.Engines.Tests/MediaToolRealTests.cs b/tests/Filee.Engines.Tests/MediaToolRealTests.cs new file mode 100644 index 000000000..44f0c1954 --- /dev/null +++ b/tests/Filee.Engines.Tests/MediaToolRealTests.cs @@ -0,0 +1,407 @@ +// The everyday media tools run for real: videos and audio files merged, pictures made into a slideshow, a video's +// sound extracted, and copies without metadata (videos and audio need Filee's FFmpeg, `pwsh build/fetch-engines.ps1 +// -Only ffmpeg`; pictures and PDFs always run). Inputs are generated; every output is checked with ffprobe or by +// reading it back. + +using System.Globalization; +using System.Text; +using System.Text.Json; +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Engines.Infrastructure; +using Filee.Engines.Media; +using ImageMagick; +using PdfSharp.Pdf; +using PdfSharp.Pdf.IO; + +namespace Filee.Engines.Tests; + +[Collection(nameof(FfmpegCollection))] +public class MediaToolRealTests(EngineFixture fx) : IClassFixture +{ + private static readonly (string Ffmpeg, string Ffprobe)? Tools = FfmpegConverter.Locate(); + + private static void SkipUnlessInstalled() => + Assert.SkipUnless(Tools is not null, "FFmpeg is not installed (pwsh build/fetch-engines.ps1 -Only ffmpeg)"); + + private static Preset BuiltIn(string id) => BuiltInData.CreatePresets().Single(p => p.Id == id); + + private async Task RunAsync(IReadOnlyList inputs, Preset preset) + { + var job = await fx.ConvertAsync(inputs, preset); + Assert.True(job.State == JobState.Completed, $"{job.State}: " + string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + return Assert.Single(job.Outputs); + } + + // ───────────────────────── Inputs ───────────────────────── + + private static string Text(double value) => value.ToString(CultureInfo.InvariantCulture); + + /// A test pattern clip, with a 440 Hz tone unless is false. + private static async Task MakeClipAsync(string folder, string name, double seconds = 2, string size = "320x240", + int fps = 25, bool audio = true, params string[] extra) + { + var path = Path.Combine(folder, name); + List args = ["-hide_banner", "-nostdin", "-v", "error", "-y", + "-f", "lavfi", "-i", $"testsrc2=size={size}:rate={fps}:duration={Text(seconds)}"]; + if (audio) + args.AddRange(["-f", "lavfi", "-i", $"sine=frequency=440:sample_rate=48000:duration={Text(seconds)}", "-ac", "2"]); + args.AddRange(extra); + args.Add(path); + await FfmpegAsync(args); + return path; + } + + /// A tone of channels, in the format the name's extension says. + private static async Task MakeToneAsync(string folder, string name, double seconds, int channels = 2, int rate = 44100, + params string[] extra) + { + var path = Path.Combine(folder, name); + await FfmpegAsync(["-hide_banner", "-nostdin", "-v", "error", "-y", "-f", "lavfi", + "-i", $"sine=frequency=330:sample_rate={rate}:duration={Text(seconds)}", "-ac", channels.ToString(CultureInfo.InvariantCulture), + .. extra, path]); + return path; + } + + private static async Task FfmpegAsync(IEnumerable args) + { + var result = await ProcessRunner.RunAsync(Tools!.Value.Ffmpeg, args, TimeSpan.FromMinutes(2), CancellationToken.None); + Assert.True(result.ExitCode == 0, result.StandardError); + return result; + } + + // ───────────────────────── What ffprobe sees ───────────────────────── + + private sealed record Track(string Type, string Codec, int Width, int Height, int Channels, Dictionary Tags); + + private sealed record Probed(double Duration, List Streams, Dictionary Tags, int Chapters) + { + public Track? Video => Streams.FirstOrDefault(s => s.Type == "video"); + public Track? Audio => Streams.FirstOrDefault(s => s.Type == "audio"); + } + + private static async Task ProbeAsync(string path) + { + var result = await ProcessRunner.RunAsync(Tools!.Value.Ffprobe, + ["-v", "error", "-print_format", "json", "-show_format", "-show_streams", "-show_chapters", path], + TimeSpan.FromMinutes(1), CancellationToken.None); + Assert.True(result.ExitCode == 0, $"ffprobe {path}: {result.StandardError}"); + using var json = JsonDocument.Parse(result.StandardOutput); + static string S(JsonElement e, string name) => e.TryGetProperty(name, out var v) ? v.ToString() : ""; + static int N(JsonElement e, string name) => int.TryParse(S(e, name), out var n) ? n : 0; + static Dictionary Tags(JsonElement e) => e.TryGetProperty("tags", out var tags) + ? tags.EnumerateObject().ToDictionary(t => t.Name.ToLowerInvariant(), t => t.Value.ToString()) + : []; + var root = json.RootElement; + var streams = root.GetProperty("streams").EnumerateArray() + .Select(s => new Track(S(s, "codec_type"), S(s, "codec_name"), N(s, "width"), N(s, "height"), N(s, "channels"), Tags(s))) + .ToList(); + var format = root.GetProperty("format"); + var duration = double.Parse(S(format, "duration"), CultureInfo.InvariantCulture); + var chapters = root.TryGetProperty("chapters", out var c) ? c.GetArrayLength() : 0; + return new Probed(duration, streams, Tags(format), chapters); + } + + /// A hash of the packets of a file's first stream of ("a" or "v"): equal when copied. + private static async Task StreamHashAsync(string path, string type) + { + var result = await FfmpegAsync(["-hide_banner", "-nostdin", "-v", "error", "-i", path, "-map", $"0:{type}:0", "-c", "copy", + "-f", "streamhash", "-hash", "md5", "-"]); + return result.StandardOutput.Split('=', 2)[^1].Trim(); + } + + // ───────────────────────── Merge videos ───────────────────────── + + [Fact] + public async Task Clips_from_one_camera_are_merged_without_an_encode() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + string[] h264 = ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac"]; + var first = await MakeClipAsync(folder, "a.mp4", 2, extra: h264); + var second = await MakeClipAsync(folder, "b.mp4", 1.5, extra: h264); + + var output = await RunAsync([first, second], BuiltIn("merge-videos")); + + Assert.Equal(Path.Combine(folder, "a_merged.mp4"), output); + var probed = await ProbeAsync(output); + Assert.InRange(probed.Duration, 3.3, 3.7); + Assert.Equal(("h264", 320, 240), (probed.Video!.Codec, probed.Video.Width, probed.Video.Height)); + Assert.Equal("aac", probed.Audio!.Codec); + } + + [Fact] + public async Task Clips_of_other_sizes_and_without_sound_are_fitted_into_the_first_ones_frame() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + var first = await MakeClipAsync(folder, "a.mp4", 2, extra: ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac"]); + var wide = await MakeClipAsync(folder, "b.mkv", 1, size: "640x360", fps: 30, audio: false, extra: ["-c:v", "libx264"]); + var webm = await MakeClipAsync(folder, "c.webm", 1, size: "160x120", extra: ["-c:v", "libvpx-vp9", "-c:a", "libopus"]); + + var output = await RunAsync([first, wide, webm], BuiltIn("merge-videos")); + + var probed = await ProbeAsync(output); + Assert.InRange(probed.Duration, 3.8, 4.3); + Assert.Equal(("h264", 320, 240), (probed.Video!.Codec, probed.Video.Width, probed.Video.Height)); + Assert.Equal("aac", probed.Audio!.Codec); + } + + // ───────────────────────── Merge audio ───────────────────────── + + [Fact] + public async Task Audio_files_are_joined_into_one_m4a_with_no_gap() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + var wav = await MakeToneAsync(folder, "a.wav", 2); + var mp3 = await MakeToneAsync(folder, "b.mp3", 1, channels: 1, rate: 22050); + var clip = await MakeClipAsync(folder, "c.mp4", 1, extra: ["-c:v", "libx264", "-c:a", "aac"]); + + var output = await RunAsync([wav, mp3, clip], BuiltIn("merge-audio")); + + Assert.Equal(Path.Combine(folder, "a_merged.m4a"), output); + var probed = await ProbeAsync(output); + Assert.InRange(probed.Duration, 3.95, 4.08); // 2 + 1 + 1 seconds, nothing in between + Assert.Null(probed.Video); + Assert.Equal(("aac", 2), (probed.Audio!.Codec, probed.Audio.Channels)); + } + + // ───────────────────────── Video from images ───────────────────────── + + [Fact] + public async Task Pictures_become_a_1080p_video_with_crossfades() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + var pictures = new[] + { + EngineFixture.MakeImage(folder, "1.png", MagickFormat.Png, 320, 200), + EngineFixture.MakeImage(folder, "2.jpg", MagickFormat.Jpeg, 200, 320), + EngineFixture.MakeImage(folder, "3.webp", MagickFormat.WebP, 1000, 1000), + }; + + var output = await RunAsync(pictures, BuiltIn("video-from-images")); + + Assert.Equal(Path.Combine(folder, "1_video.mp4"), output); + var probed = await ProbeAsync(output); + Assert.InRange(probed.Duration, 6.9, 7.1); // 3 × 3 s, minus two 1 s fades + Assert.Equal(("h264", 1920, 1080), (probed.Video!.Codec, probed.Video.Width, probed.Video.Height)); + Assert.Null(probed.Audio); + } + + [Fact] + public async Task Without_crossfades_each_picture_shows_for_its_whole_time() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + var preset = BuiltIn("video-from-images"); + preset.Media.Crossfade = false; + preset.Media.SlideSeconds = 1.5; + + var output = await RunAsync( + [EngineFixture.MakeImage(folder, "1.png", MagickFormat.Png), EngineFixture.MakeImage(folder, "2.png", MagickFormat.Png)], preset); + + Assert.InRange((await ProbeAsync(output)).Duration, 2.9, 3.1); + } + + // ───────────────────────── Extract audio ───────────────────────── + + [Fact] + public async Task A_phone_videos_aac_sound_is_copied_into_m4a() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + var clip = await MakeClipAsync(folder, "IMG_0001.mov", 2, extra: ["-c:v", "libx264", "-c:a", "aac", "-b:a", "96k"]); + + var output = await RunAsync([clip], BuiltIn("extract-audio")); + + Assert.Equal(Path.Combine(folder, "IMG_0001.m4a"), output); + var probed = await ProbeAsync(output); + Assert.Null(probed.Video); + Assert.Equal("aac", probed.Audio!.Codec); + Assert.Equal(await StreamHashAsync(clip, "a"), await StreamHashAsync(output, "a")); // the same packets + } + + [Fact] + public async Task Sound_m4a_cannot_hold_is_encoded_to_aac() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + var clip = await MakeClipAsync(folder, "clip.webm", 1, extra: ["-c:v", "libvpx-vp9", "-c:a", "libopus"]); + + var probed = await ProbeAsync(await RunAsync([clip], BuiltIn("extract-audio"))); + + Assert.Equal("aac", probed.Audio!.Codec); + } + + // ───────────────────────── Remove metadata ───────────────────────── + + [Fact] + public async Task A_video_loses_its_tags_and_chapters_and_keeps_its_streams() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + var chapters = Path.Combine(folder, "chapters.txt"); + await File.WriteAllTextAsync(chapters, ";FFMETADATA1\ntitle=Secret trip\nartist=Jane Doe\n" + + "[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000\ntitle=Arrival\n"); + var clip = Path.Combine(folder, "trip.mp4"); + await FfmpegAsync( + [ + "-hide_banner", "-nostdin", "-v", "error", "-y", + "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=25:duration=2", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=2", + "-f", "ffmetadata", "-i", chapters, "-map", "0", "-map", "1", "-map_metadata", "2", "-map_chapters", "2", + "-c:v", "libx264", "-c:a", "aac", "-metadata", "creation_time=2020-05-01T10:00:00Z", + "-metadata", "location=+37.5665+126.9780/", "-metadata:s:a:0", "handler_name=Jane's phone", clip, + ]); + var before = await ProbeAsync(clip); + Assert.Equal("Secret trip", before.Tags["title"]); + Assert.Equal(1, before.Chapters); + + var output = await RunAsync([clip], BuiltIn("remove-metadata")); + + Assert.Equal(Path.Combine(folder, "trip_clean.mp4"), output); + var after = await ProbeAsync(output); + foreach (var tag in new[] { "title", "artist", "creation_time", "location", "comment" }) + Assert.False(after.Tags.ContainsKey(tag), tag); + Assert.DoesNotContain(after.Streams, s => s.Tags.Values.Any(v => v.Contains("Jane", StringComparison.Ordinal))); + Assert.Equal(0, after.Chapters); + var bytes = Encoding.Latin1.GetString(await File.ReadAllBytesAsync(output)); + Assert.DoesNotContain("Secret trip", bytes); + Assert.DoesNotContain("+37.5665", bytes); + Assert.Equal(await StreamHashAsync(clip, "v"), await StreamHashAsync(output, "v")); + Assert.Equal(await StreamHashAsync(clip, "a"), await StreamHashAsync(output, "a")); + } + + [Fact] + public async Task A_song_loses_its_tags_and_stays_an_mp3() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + var song = await MakeToneAsync(folder, "song.mp3", 2, extra: + ["-metadata", "title=Private song", "-metadata", "artist=Jane Doe", "-metadata", "date=2019"]); + + var output = await RunAsync([song], BuiltIn("remove-metadata")); + + Assert.Equal(".mp3", Path.GetExtension(output)); + var after = await ProbeAsync(output); + Assert.Equal("mp3", after.Audio!.Codec); + Assert.False(after.Tags.ContainsKey("title")); + Assert.False(after.Tags.ContainsKey("artist")); + Assert.DoesNotContain("Jane Doe", Encoding.Latin1.GetString(await File.ReadAllBytesAsync(output))); + Assert.Equal(await StreamHashAsync(song, "a"), await StreamHashAsync(output, "a")); + } + + [Fact] + public async Task A_photo_loses_its_exif_and_keeps_its_pixels_and_quality() + { + var folder = fx.NewFolder(); + var photo = Path.Combine(folder, "IMG_0002.jpg"); + using (var image = new MagickImage("gradient:#7F77DD-#1D9E75", 400, 300)) + { + var exif = new ExifProfile(); + exif.SetValue(ExifTag.Make, "Filee Camera"); + exif.SetValue(ExifTag.DateTimeOriginal, "2020:05:01 10:00:00"); + exif.SetValue(ExifTag.GPSLatitudeRef, "N"); + image.SetProfile(exif); + image.Comment = "Jane's holiday"; + image.Quality = 80; + image.Write(photo, MagickFormat.Jpeg); + } + + var output = await RunAsync([photo], BuiltIn("remove-metadata")); + + Assert.Equal(Path.Combine(folder, "IMG_0002_clean.jpg"), output); + using var clean = new MagickImage(output); + Assert.Null(clean.GetExifProfile()); + Assert.Null(clean.GetXmpProfile()); + Assert.True(string.IsNullOrEmpty(clean.Comment)); + Assert.Equal((400u, 300u), (clean.Width, clean.Height)); + Assert.InRange(clean.Quality, 78u, 82u); // saved at the photo's own quality, not above it + var bytes = Encoding.Latin1.GetString(await File.ReadAllBytesAsync(output)); + Assert.DoesNotContain("Filee Camera", bytes); + Assert.DoesNotContain("Jane", bytes); + } + + [Fact] + public async Task A_pdf_loses_its_author_and_title_and_keeps_its_pages() + { + var folder = fx.NewFolder(); + var path = Path.Combine(folder, "contract.pdf"); + using (var document = new PdfDocument()) + { + document.Info.Author = "Jane Doe"; + document.Info.Title = "Secret contract"; + document.Info.Keywords = "confidential"; + document.AddPage(); + document.AddPage(); + document.Save(path); + } + + var output = await RunAsync([path], BuiltIn("remove-metadata")); + + Assert.Equal(Path.Combine(folder, "contract_clean.pdf"), output); + using (var clean = PdfReader.Open(output, PdfDocumentOpenMode.Import)) + { + Assert.Equal(2, clean.PageCount); + Assert.True(string.IsNullOrEmpty(clean.Info.Author)); + Assert.True(string.IsNullOrEmpty(clean.Info.Title)); + } + var bytes = Encoding.Latin1.GetString(await File.ReadAllBytesAsync(output)); + Assert.DoesNotContain("Jane Doe", bytes); + Assert.DoesNotContain("Secret contract", bytes); + Assert.DoesNotContain("confidential", bytes); + } + + // ───────────────────────── Compress ───────────────────────── + + [Fact] + public async Task Compress_audio_saves_a_smaller_aac_copy() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + var wav = await MakeToneAsync(folder, "talk.wav", 5); + + var output = await RunAsync([wav], BuiltIn("compress-audio")); + + Assert.Equal(Path.Combine(folder, "talk_compressed.m4a"), output); + Assert.Equal("aac", (await ProbeAsync(output)).Audio!.Codec); + Assert.True(new FileInfo(output).Length < new FileInfo(wav).Length / 5); + } + + [Fact] + public async Task Compress_video_saves_a_1080p_h264_mp4() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + var clip = await MakeClipAsync(folder, "big.mov", 1, size: "2560x1440", extra: ["-c:v", "libx264", "-crf", "10", "-c:a", "aac"]); + + var output = await RunAsync([clip], BuiltIn("compress-video")); + + Assert.Equal(Path.Combine(folder, "big_compressed.mp4"), output); + var probed = await ProbeAsync(output); + Assert.Equal(("h264", 1920, 1080), (probed.Video!.Codec, probed.Video.Width, probed.Video.Height)); + Assert.True(new FileInfo(output).Length < new FileInfo(clip).Length); + } + + [Fact] + public async Task Compress_image_saves_a_smaller_png_with_the_same_pixels() + { + var folder = fx.NewFolder(); + var png = Path.Combine(folder, "screen.png"); + using (var image = new MagickImage("plasma:fractal", 300, 200)) + { + image.Settings.SetDefine(MagickFormat.Png, "compression-level", "1"); + image.Write(png, MagickFormat.Png); + } + + var output = await RunAsync([png], BuiltIn("compress-image")); + + Assert.Equal(Path.Combine(folder, "screen_compressed.png"), output); + Assert.True(new FileInfo(output).Length < new FileInfo(png).Length); + using var original = new MagickImage(png); + using var compressed = new MagickImage(output); + Assert.Equal(0, original.Compare(compressed, ErrorMetric.Absolute)); + } +} From 94ec0a77a8bfd3399ee5e7f3bba8bfc1d6766adf Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:57:36 +0530 Subject: [PATCH 02/20] Put Extract audio on the video donut's back face when upgrading Schema 33 replaces the plain M4A slice of the video donut's back face with Extract audio (the same M4A, the sound copied where it can be), or adds it with the other tools when there is room. Real FFmpeg tests pass the test's cancellation token to file calls (xUnit1051, dotnet format). Co-Authored-By: Claude Opus 5.5 --- src/Filee.Core/Settings/UserDataStore.cs | 15 ++++++++++++++- tests/Filee.Core.Tests/MediaToolTests.cs | 19 ++++++++++++++++++- .../Filee.Engines.Tests/MediaToolRealTests.cs | 10 +++++----- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 15e65f5d5..1dd656452 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -753,6 +753,7 @@ public static bool ApplyToLibrary(int from, List presets, List presets, List pro { if (profiles.FirstOrDefault(p => p.Id == profileId) is not { BackPresetIds: { } back } profile) continue; + // "Extract audio" writes the same M4A as the plain M4A slice, copying the sound where it can: it takes that + // slice's place on the video donut's back, or comes in with the other tools when there is room. + if (profileId == "video" && !back.Contains("extract-audio") && !profile.PresetIds.Contains("extract-audio") + && presets.Any(p => p.Id == "extract-audio")) + { + var m4a = back.IndexOf("to-m4a"); + if (m4a >= 0) + { + back[m4a] = "extract-audio"; + changed = true; + } + } foreach (var id in ids) { if (back.Contains(id) || profile.PresetIds.Contains(id) || back.Count >= ToolbarProfile.MaxSlices diff --git a/tests/Filee.Core.Tests/MediaToolTests.cs b/tests/Filee.Core.Tests/MediaToolTests.cs index 6fb8d43f6..e48fe0332 100644 --- a/tests/Filee.Core.Tests/MediaToolTests.cs +++ b/tests/Filee.Core.Tests/MediaToolTests.cs @@ -79,6 +79,9 @@ public void Version_32_libraries_get_the_tools_and_back_faces_with_room_offer_th var profiles = BuiltInData.CreateProfiles(); foreach (var profile in profiles) profile.BackPresetIds!.RemoveAll(ToolIds.Contains); + // The video donut's back face of schema 32, with the plain M4A slice. + profiles.Single(p => p.Id == "video").BackPresetIds = ["mp4-av1", "to-webp", "extract-frames-png", "extract-frames-jpg", + "live-photo-iphone", "motion-photo-android", "to-m4a", "to-wav", "to-flac", "zip-all"]; // A back face the user filled up stays as it is. var pdf = profiles.Single(p => p.Id == "pdf"); while (pdf.BackPresetIds!.Count < ToolbarProfile.MaxSlices) @@ -90,13 +93,27 @@ public void Version_32_libraries_get_the_tools_and_back_faces_with_room_offer_th Assert.All(ToolIds, id => Assert.Single(presets, p => p.Id == id)); var audio = profiles.Single(p => p.Id == "audio").BackPresetIds!; Assert.Equal(["to-alac", "compress-audio", "merge-audio", "remove-metadata", "zip-all"], audio); + // "Extract audio" takes the M4A slice's place; the new library's back face is what an upgraded one gets. var video = profiles.Single(p => p.Id == "video").BackPresetIds!; - Assert.Equal(["compress-video", "merge-videos", "zip-all"], video.TakeLast(3)); + Assert.Equal(BuiltInData.CreateProfiles().Single(p => p.Id == "video").BackPresetIds, video); Assert.Equal(full, pdf.BackPresetIds); Assert.False(SettingsMigrations.ApplyToLibrary(32, presets, profiles)); // once only Assert.False(SettingsMigrations.ApplyToLibrary(AppSettings.CurrentSchemaVersion, presets, profiles)); } + [Fact] + public void Extract_audio_comes_onto_a_video_back_face_without_the_m4a_slice_when_there_is_room() + { + var presets = BuiltInData.CreatePresets().Where(p => !ToolIds.Contains(p.Id)).ToList(); + var profiles = BuiltInData.CreateProfiles(); + var video = profiles.Single(p => p.Id == "video"); + video.BackPresetIds = ["mp4-av1", "to-wav", "zip-all"]; + + Assert.True(SettingsMigrations.ApplyToLibrary(32, presets, profiles)); + + Assert.Equal(["mp4-av1", "to-wav", "compress-video", "merge-videos", "extract-audio", "zip-all"], video.BackPresetIds); + } + [Fact] public async Task Merge_videos_joins_the_clips_in_order_into_one_file_named_after_the_first() { diff --git a/tests/Filee.Engines.Tests/MediaToolRealTests.cs b/tests/Filee.Engines.Tests/MediaToolRealTests.cs index 44f0c1954..d8c3d7efe 100644 --- a/tests/Filee.Engines.Tests/MediaToolRealTests.cs +++ b/tests/Filee.Engines.Tests/MediaToolRealTests.cs @@ -244,7 +244,7 @@ public async Task A_video_loses_its_tags_and_chapters_and_keeps_its_streams() var folder = fx.NewFolder(); var chapters = Path.Combine(folder, "chapters.txt"); await File.WriteAllTextAsync(chapters, ";FFMETADATA1\ntitle=Secret trip\nartist=Jane Doe\n" + - "[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000\ntitle=Arrival\n"); + "[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000\ntitle=Arrival\n", TestContext.Current.CancellationToken); var clip = Path.Combine(folder, "trip.mp4"); await FfmpegAsync( [ @@ -267,7 +267,7 @@ await FfmpegAsync( Assert.False(after.Tags.ContainsKey(tag), tag); Assert.DoesNotContain(after.Streams, s => s.Tags.Values.Any(v => v.Contains("Jane", StringComparison.Ordinal))); Assert.Equal(0, after.Chapters); - var bytes = Encoding.Latin1.GetString(await File.ReadAllBytesAsync(output)); + var bytes = Encoding.Latin1.GetString(await File.ReadAllBytesAsync(output, TestContext.Current.CancellationToken)); Assert.DoesNotContain("Secret trip", bytes); Assert.DoesNotContain("+37.5665", bytes); Assert.Equal(await StreamHashAsync(clip, "v"), await StreamHashAsync(output, "v")); @@ -289,7 +289,7 @@ public async Task A_song_loses_its_tags_and_stays_an_mp3() Assert.Equal("mp3", after.Audio!.Codec); Assert.False(after.Tags.ContainsKey("title")); Assert.False(after.Tags.ContainsKey("artist")); - Assert.DoesNotContain("Jane Doe", Encoding.Latin1.GetString(await File.ReadAllBytesAsync(output))); + Assert.DoesNotContain("Jane Doe", Encoding.Latin1.GetString(await File.ReadAllBytesAsync(output, TestContext.Current.CancellationToken))); Assert.Equal(await StreamHashAsync(song, "a"), await StreamHashAsync(output, "a")); } @@ -319,7 +319,7 @@ public async Task A_photo_loses_its_exif_and_keeps_its_pixels_and_quality() Assert.True(string.IsNullOrEmpty(clean.Comment)); Assert.Equal((400u, 300u), (clean.Width, clean.Height)); Assert.InRange(clean.Quality, 78u, 82u); // saved at the photo's own quality, not above it - var bytes = Encoding.Latin1.GetString(await File.ReadAllBytesAsync(output)); + var bytes = Encoding.Latin1.GetString(await File.ReadAllBytesAsync(output, TestContext.Current.CancellationToken)); Assert.DoesNotContain("Filee Camera", bytes); Assert.DoesNotContain("Jane", bytes); } @@ -348,7 +348,7 @@ public async Task A_pdf_loses_its_author_and_title_and_keeps_its_pages() Assert.True(string.IsNullOrEmpty(clean.Info.Author)); Assert.True(string.IsNullOrEmpty(clean.Info.Title)); } - var bytes = Encoding.Latin1.GetString(await File.ReadAllBytesAsync(output)); + var bytes = Encoding.Latin1.GetString(await File.ReadAllBytesAsync(output, TestContext.Current.CancellationToken)); Assert.DoesNotContain("Jane Doe", bytes); Assert.DoesNotContain("Secret contract", bytes); Assert.DoesNotContain("confidential", bytes); From 756f0a0a3c3e6c7166a971e5005005e258788616 Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:10:39 +0530 Subject: [PATCH 03/20] Translate the media tools into Brazilian Portuguese The pt-BR translation merged since gets the new preset names, the slideshow options and the error for too many pictures. Co-Authored-By: Claude Opus 5.5 --- src/Filee.App/Assets/i18n/pt-BR.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 0b7cd7cd0..7584f2663 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -82,6 +82,7 @@ "error.no_route": "Nenhum motor faz esta conversão", "error.cannot_turn": "Girar e espelhar só funcionam em imagens", "error.too_many_frames": "Quadros demais para salvar como imagens. Aumente “A cada N quadros”", + "error.too_many_pictures": "Imagens demais para um vídeo; divida-as em vários", "error.not_enough_space": "Não há espaço livre suficiente em disco para as imagens", "error.no_motion_photo": "Esta foto não tem um vídeo que o Filee consiga ler (uma Motion Photo do Android, ou uma Live Photo do iPhone com o MOV ao lado)", "error.live_photo_start": "O clipe começa depois do fim deste vídeo. Escolha um “Início do clipe” mais cedo", @@ -222,6 +223,14 @@ "preset.extract_motion": "Extrair vídeo da Live Photo", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", + "preset.compress_image": "Comprimir imagem", + "preset.compress_video": "Comprimir vídeo", + "preset.video_from_images": "Vídeo de imagens", + "preset.extract_audio": "Extrair áudio", + "preset.merge_videos": "Juntar vídeos", + "preset.compress_audio": "Comprimir áudio", + "preset.merge_audio": "Juntar áudio", + "preset.remove_metadata": "Remover metadados", "format.folder": "Pasta", "format.category.Image": "Imagem", @@ -491,6 +500,9 @@ "presets.frame_estimate": "Um minuto de vídeo a 30 fps gera {0} imagens, no máximo {1} por vídeo.", "presets.extract_motion_photo": "Extrair o vídeo da Live/Motion Photo", "presets.extract_motion_photo_hint": "Uma foto vira o vídeo salvo com ela: o que está dentro de uma Motion Photo do Android, ou o MOV ao lado de uma Live Photo do iPhone.\nOutras imagens viram um vídeo da imagem parada.", + "presets.slide_seconds": "Segundos por imagem", + "presets.crossfade": "Transição suave entre imagens", + "presets.crossfade_hint": "Cada imagem se funde com a próxima em um segundo, em vez de um corte seco.", "presets.group.live_photo": "Live / Motion Photo", "presets.live_photo": "Um vídeo vira", "presets.live_photo.None": "Um quadro parado", From 1c11b1e0f175801f627e494a309826e19c560ff7 Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:01:08 +0530 Subject: [PATCH 04/20] Harden the media tools: safe merge copies, slideshow progress, compress never bigger - Merge videos copies clips only when their codec setup (ffprobe's extradata hash), frame rate and size match; others are encoded. FFmpeg puts a changed header in-band, but the MP4 keeps one sample description, which stricter players may not follow. - Video from images: the PNG step reports into the first 30% only, so late progress reports no longer show the job as done while the video is made. - Compress image / video / audio keep the source when the copy in the same format isn't smaller (OutputRule.OnlyIfSmaller), with the notice Compress PDF already shows. - Remove metadata keeps an MKV's attachments (subtitle fonts) with their file name and type tags; MKV refuses an attachment without them. - Removed IMediaJoiner.CanJoinInto (never called) and the slideshow size and fade length options nobody could set (now constants). Tests: stricter copy check, progress at join time, keep-if-not-smaller, and real FFmpeg runs for mixed-profile merges, MKV fonts and a portrait video. Co-Authored-By: Claude Opus 5.5 --- src/Filee.Core/Conversion/IConverter.cs | 3 - src/Filee.Core/Conversion/JobQueue.cs | 31 +++++++-- src/Filee.Core/Presets/BuiltInData.cs | 6 +- src/Filee.Core/Presets/OutputRule.cs | 6 ++ src/Filee.Core/Presets/Preset.cs | 16 ++--- src/Filee.Engines/Media/FfmpegConverter.cs | 4 +- src/Filee.Engines/Media/MediaEncoding.cs | 4 ++ src/Filee.Engines/Media/MediaInfo.cs | 20 ++++-- src/Filee.Engines/Media/MediaJoining.cs | 14 ++-- tests/Filee.Core.Tests/FakeConverter.cs | 4 +- tests/Filee.Core.Tests/MediaToolTests.cs | 41 +++++++++++- .../Filee.Engines.Tests/MediaJoiningTests.cs | 16 ++++- .../Filee.Engines.Tests/MediaToolRealTests.cs | 67 +++++++++++++++++++ 13 files changed, 188 insertions(+), 44 deletions(-) diff --git a/src/Filee.Core/Conversion/IConverter.cs b/src/Filee.Core/Conversion/IConverter.cs index 11b521b99..402ef8d89 100644 --- a/src/Filee.Core/Conversion/IConverter.cs +++ b/src/Filee.Core/Conversion/IConverter.cs @@ -203,9 +203,6 @@ public interface ITiffMerger /// public interface IMediaJoiner { - /// True if files can be joined into now (FFmpeg is installed). - bool CanJoinInto(string format); - /// /// Writes every file of , in order, into : video or audio /// clips back to back, or pictures shown one after the other (). diff --git a/src/Filee.Core/Conversion/JobQueue.cs b/src/Filee.Core/Conversion/JobQueue.cs index dd04a7eca..a476e43d1 100644 --- a/src/Filee.Core/Conversion/JobQueue.cs +++ b/src/Filee.Core/Conversion/JobQueue.cs @@ -340,6 +340,9 @@ await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), Notify(job); } + /// Share of a slideshow's progress that preparing its pictures takes; encoding the video fills the rest. + private const double SlidesReadyShare = 0.3; + /// /// "Merge videos" / "Merge audio": the videos or audio files go into one file as they are. "Video from images": /// every picture becomes PNG in the work directory first (a PNG is taken as it is), with its orientation applied by @@ -372,7 +375,8 @@ await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), { parts[i] = source.Id == "png" && !job.Preset.Image.TurnsOrMirrors ? [file.SourcePath] - : await RunFileAsync(job, file, i + 1, planner, workDir, finalOutput: false, intermediate: "png"); + : await RunFileAsync(job, file, i + 1, planner, workDir, finalOutput: false, intermediate: "png", + ceiling: SlidesReadyShare); return; } // A video's sound can join an audio file; a picture or a document joins nothing. @@ -597,11 +601,12 @@ private void CopyDates(string source, IEnumerable outputs) /// /// Converts one source file along its planned route. /// With false, results stay in the work directory as - /// files (PDF for merging, TIFF pages for a multi-page TIFF). + /// files (PDF for merging, TIFF pages for a multi-page TIFF). is the file's progress once + /// the route is done (a slideshow's pictures leave the rest to the video). /// private async Task> RunFileAsync( ConversionJob job, FileResult file, int index, RoutePlanner planner, string workDir, bool finalOutput, - string intermediate = "pdf") + string intermediate = "pdf", double ceiling = 1) { var ct = job.CancellationToken; if (Stopped(file)) @@ -652,6 +657,7 @@ private async Task> RunFileAsync( Notify(job); // A file saved from a browser first has part of its progress already. var floor = file.Progress; + ceiling = Math.Max(ceiling, floor); // The last step's allocator: how many paths it handed out tells a single output from several, and what a // failed or cancelled conversion started there is undone through it. FinalAllocator? final = null; @@ -677,7 +683,7 @@ private async Task> RunFileAsync( var progress = new Progress(p => { // Reports arrive on the thread pool, possibly out of order: progress only moves forward. - var value = floor + (1 - floor) * (stepIndex + Math.Clamp(p, 0, 1)) / route.Steps.Count; + var value = floor + (ceiling - floor) * (stepIndex + Math.Clamp(p, 0, 1)) / route.Steps.Count; if (value <= file.Progress) return; file.Progress = value; @@ -709,6 +715,8 @@ private async Task> RunFileAsync( if (zipResult && inputs.Count > 0) inputs = PackZip(job, file, index, inputs, ct); + else if (finalOutput && job.Preset.Output.OnlyIfSmaller && inputs.Count == 1 && CopyIfNotSmaller(file.SourcePath, inputs[0])) + file.NoticeKey = "notice.pdf_not_smaller"; // One output, and nothing else handed out for it (a Markdown file's picture folder goes with it). else if (finalOutput && inputs.Count == 1 && final is { Handed: 1 }) inputs = [NextToSource(job, file, index, inputs[0], final)]; @@ -722,7 +730,7 @@ private async Task> RunFileAsync( CopyDates(file.SourcePath, inputs); } file.State = inputs.Count == 0 && finalOutput ? FileState.Skipped : FileState.Done; - file.Progress = 1; + file.Progress = ceiling; Notify(job); return inputs; } @@ -811,6 +819,19 @@ private IReadOnlyList PackZip(ConversionJob job, FileResult file, int in return [zipPath]; } + /// + /// Puts the source in place of when both have the same format and the output is not + /// smaller (). True when it did. + /// + private static bool CopyIfNotSmaller(string source, string output) + { + if (!string.Equals(FormatRegistry.Detect(source)?.Id, FormatRegistry.Detect(output)?.Id, StringComparison.Ordinal) + || !File.Exists(output) || new FileInfo(output).Length < new FileInfo(source).Length) + return false; + File.Copy(source, output, overwrite: true); + return true; + } + /// The engine's check; a check that fails (an unreadable file) finds nothing, and the conversion reports the file. private string? ScriptNeedingShaping(ITextShapingCheck check, string path, string format) { diff --git a/src/Filee.Core/Presets/BuiltInData.cs b/src/Filee.Core/Presets/BuiltInData.cs index d93420143..81ab6be02 100644 --- a/src/Filee.Core/Presets/BuiltInData.cs +++ b/src/Filee.Core/Presets/BuiltInData.cs @@ -124,13 +124,13 @@ public static List MediaToolPresets() => // Quality 75 like most photo apps' "smaller"; a JPEG saved below it stays at its own quality. Id = "compress-image", NameKey = "preset.compress_image", TargetFormat = SameAsSource, Image = { Quality = 75, MaxCompression = true, TiffCompression = TiffCompression.Zip }, - Output = { FileNamePattern = "{name}_compressed" }, + Output = { FileNamePattern = "{name}_compressed", OnlyIfSmaller = true }, }, new() { // H.264 at the Small quality, at most 1080p: plays everywhere, a fraction of a phone video's size. Id = "compress-video", NameKey = "preset.compress_video", TargetFormat = "mp4", - Media = { Quality = MediaQuality.Small, MaxHeight = 1080 }, Output = { FileNamePattern = "{name}_compressed" }, + Media = { Quality = MediaQuality.Small, MaxHeight = 1080 }, Output = { FileNamePattern = "{name}_compressed", OnlyIfSmaller = true }, }, new() { @@ -151,7 +151,7 @@ public static List MediaToolPresets() => { // AAC at the Small quality: 128 kbit/s for stereo. Id = "compress-audio", NameKey = "preset.compress_audio", TargetFormat = "m4a", - Media = { Quality = MediaQuality.Small }, Output = { FileNamePattern = "{name}_compressed" }, + Media = { Quality = MediaQuality.Small }, Output = { FileNamePattern = "{name}_compressed", OnlyIfSmaller = true }, }, new() { diff --git a/src/Filee.Core/Presets/OutputRule.cs b/src/Filee.Core/Presets/OutputRule.cs index 157df1ede..5fa4930a3 100644 --- a/src/Filee.Core/Presets/OutputRule.cs +++ b/src/Filee.Core/Presets/OutputRule.cs @@ -85,6 +85,12 @@ public sealed class OutputRule /// public bool ZipResult { get; set; } + /// + /// "Compress" presets: when a file that keeps its format doesn't get smaller, the output is a copy of the source + /// instead, with a notice. A result in another format is always kept. + /// + public bool OnlyIfSmaller { get; set; } + public OutputRule Clone() { var clone = (OutputRule)MemberwiseClone(); diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs index 09a8b2053..3a7a92db1 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -581,8 +581,8 @@ public sealed class MediaOptions public bool JoinIntoOne { get; set; } /// - /// MP4: the dropped pictures become one video, each shown for , fitted into - /// × with black bars. + /// MP4: the dropped pictures become one 1080p video, each shown for , fitted in with + /// black bars. /// public bool Slideshow { get; set; } @@ -596,13 +596,7 @@ public sealed class MediaOptions public bool Crossfade { get; set; } = true; /// With : how long a fade takes, in seconds (at most half a picture's time). - public double CrossfadeSeconds { get; set; } = 1; - - /// Frame size of a (1080p unless the preset says otherwise). - public int SlideshowWidth { get; set; } = 1920; - - /// - public int SlideshowHeight { get; set; } = 1080; + public const double CrossfadeSeconds = 1; /// /// Audio target: copy the sound as it is when the target takes its codec (AAC from a phone video into M4A), with @@ -622,9 +616,7 @@ public sealed class MediaOptions /// The fade between two pictures in seconds: 0 without , never over half a picture's time. [JsonIgnore] - public double FadeDuration => Crossfade && double.IsFinite(CrossfadeSeconds) && CrossfadeSeconds > 0 - ? Math.Min(CrossfadeSeconds, SlideDuration / 2) - : 0; + public double FadeDuration => Crossfade ? Math.Min(CrossfadeSeconds, SlideDuration / 2) : 0; public MediaOptions Clone() => (MediaOptions)MemberwiseClone(); } diff --git a/src/Filee.Engines/Media/FfmpegConverter.cs b/src/Filee.Engines/Media/FfmpegConverter.cs index 94be8ec1c..6c6b9938d 100644 --- a/src/Filee.Engines/Media/FfmpegConverter.cs +++ b/src/Filee.Engines/Media/FfmpegConverter.cs @@ -201,8 +201,6 @@ await RunWithFallbackAsync(passes, hardware, Plan, return [output]; } - public bool CanJoinInto(string format) => MediaOptions.JoinTargets.Contains(format) && (_tools ?? Locate()) is not null; - public async Task JoinAsync(IReadOnlyList inputPaths, string outputPath, string format, Preset preset, string workDirectory, IProgress? progress, CancellationToken cancellationToken) { @@ -267,7 +265,7 @@ private async Task SlideshowAsync(string ffmpeg, IReadOnlyList pictures, { if (pictures.Count > MediaJoining.MaxSlides) throw new ConversionRefusedException("error.too_many_pictures", $"{pictures.Count} > {MediaJoining.MaxSlides}"); - var (width, height) = MediaJoining.SlideshowFrame(media); + var (width, height) = (MediaJoining.SlideshowWidth, MediaJoining.SlideshowHeight); var slides = new List(); for (var i = 0; i < pictures.Count; i++) { diff --git a/src/Filee.Engines/Media/MediaEncoding.cs b/src/Filee.Engines/Media/MediaEncoding.cs index 361f139a2..50170f95c 100644 --- a/src/Filee.Engines/Media/MediaEncoding.cs +++ b/src/Filee.Engines/Media/MediaEncoding.cs @@ -588,6 +588,10 @@ public static List WithoutMetadata(string input, string output, string t if (info.Video?.Codec == "hevc" && target is "mp4" or "m4v" or "mov") args.AddRange(["-tag:v", "hvc1"]); args.AddRange(["-map_metadata", "-1", "-map_metadata:s", "-1", "-map_chapters", "-1", "-fflags", "+bitexact"]); + // Attachments are an MKV's subtitle fonts (without them styled subtitles fall back to another font); each needs + // its own file name and type tags. Only when there are any: FFmpeg refuses a specifier that matches nothing. + if (info.HasAttachments) + args.AddRange(["-map", "0:t", "-map_metadata:s:t", "0:s:t"]); args.AddRange(ContainerArguments(target)); args.AddRange(["-max_muxing_queue_size", "4096", "-f", spec.Muxer, "-progress", "pipe:1", output]); return args; diff --git a/src/Filee.Engines/Media/MediaInfo.cs b/src/Filee.Engines/Media/MediaInfo.cs index e058fbac5..695397b87 100644 --- a/src/Filee.Engines/Media/MediaInfo.cs +++ b/src/Filee.Engines/Media/MediaInfo.cs @@ -16,8 +16,12 @@ namespace Filee.Engines.Media; /// FFmpeg's codec name (h264, hevc, …), null when unknown. /// FFmpeg's pixel format (yuv420p, yuv420p10le, …), null when unknown. /// Colour primaries, transfer and matrix as FFmpeg names them (bt2020, smpte2084, …). +/// +/// A hash of the codec's setup data (H.264 / HEVC parameter sets), equal only for streams a decoder reads with the same +/// setup; null when the file has none. +/// internal sealed record VideoStreamInfo(int Width, int Height, double SampleAspect, int Rotation, double FrameRate, - string? Codec = null, string? PixelFormat = null, VideoColor? Color = null) + string? Codec = null, string? PixelFormat = null, VideoColor? Color = null, string? HeaderHash = null) { /// /// True for more than 8 bits per sample (yuv420p10le, p010le, yuv422p12le, …): phones' HDR video, ProRes, 10-bit @@ -71,8 +75,9 @@ internal sealed record AudioStreamInfo(int Channels, int SampleRate, string? Cod /// First audio stream, if any. /// Index of an embedded cover picture (JPEG or PNG) such as album art, if any. /// Every subtitle stream in file order, null when there is none. +/// The file carries attachments (an MKV's subtitle fonts). internal sealed record MediaInfo(TimeSpan? Duration, VideoStreamInfo? Video, AudioStreamInfo? Audio, int? CoverStreamIndex, - IReadOnlyList? SubtitleStreams = null) + IReadOnlyList? SubtitleStreams = null, bool HasAttachments = false) { /// Every subtitle stream, in file order. public IReadOnlyList Subtitles => SubtitleStreams ?? []; @@ -83,7 +88,7 @@ internal sealed record MediaInfo(TimeSpan? Duration, VideoStreamInfo? Video, Aud public static async Task ProbeAsync(string ffprobe, string input, CancellationToken cancellationToken) { var result = await ProcessRunner.RunAsync(ffprobe, - ["-hide_banner", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", input], + ["-hide_banner", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", "-show_data_hash", "MD5", input], ProbeTimeout, cancellationToken); if (result.ExitCode != 0) { @@ -103,6 +108,7 @@ public static MediaInfo Parse(string json) AudioStreamInfo? audio = null; int? cover = null; List subtitles = []; + var attachments = false; double longestStream = 0; if (root.TryGetProperty("streams", out var streams) && streams.ValueKind == JsonValueKind.Array) { @@ -125,12 +131,16 @@ public static MediaInfo Parse(string json) FrameRateOf(stream), Text(stream, "codec_name"), Text(stream, "pix_fmt"), - ColorOf(stream)); + ColorOf(stream), + Text(stream, "extradata_hash")); break; case "audio" when audio is null: audio = new AudioStreamInfo((int)Number(stream, "channels"), (int)Number(stream, "sample_rate"), Text(stream, "codec_name")); break; + case "attachment": + attachments = true; + break; case "subtitle": subtitles.Add(new SubtitleStreamInfo((int)Number(stream, "index"), Text(stream, "codec_name"), stream.TryGetProperty("tags", out var tags) ? Text(tags, "language") : null)); @@ -143,7 +153,7 @@ public static MediaInfo Parse(string json) if (seconds <= 0) seconds = longestStream; return new MediaInfo(seconds > 0 ? TimeSpan.FromSeconds(seconds) : null, video, audio, cover, - subtitles.Count > 0 ? subtitles : null); + subtitles.Count > 0 ? subtitles : null, attachments); } /// The colour tags of a video stream; null when it has none ("unknown" counts as none). diff --git a/src/Filee.Engines/Media/MediaJoining.cs b/src/Filee.Engines/Media/MediaJoining.cs index bca79430b..2928c6f1a 100644 --- a/src/Filee.Engines/Media/MediaJoining.cs +++ b/src/Filee.Engines/Media/MediaJoining.cs @@ -30,8 +30,8 @@ internal static class MediaJoining /// /// True when the clips can be joined without an encode: every one is a video MP4 takes as it is, all with the same - /// codec, picture size, pixel format, turn and pixel shape, and the same kind of sound (or none at all). Videos from - /// one phone usually are. + /// codec setup (parameter sets: the MP4 keeps only the first clip's), picture size, pixel format, frame rate, turn + /// and pixel shape, and the same kind of sound (or none at all). Videos from one phone usually are. /// public static bool CanCopyVideos(IReadOnlyList clips, MediaOptions options) { @@ -39,7 +39,8 @@ public static bool CanCopyVideos(IReadOnlyList clips, MediaOptions op return false; var sameVideo = clips.All(c => c.Video is { } v && MediaEncoding.CanCopyVideo("mp4", v, options) && v.Codec == first.Codec && v.Width == first.Width && v.Height == first.Height && v.PixelFormat == first.PixelFormat - && v.Rotation == first.Rotation && Math.Abs(v.SampleAspect - first.SampleAspect) < 0.01); + && v.Rotation == first.Rotation && Math.Abs(v.SampleAspect - first.SampleAspect) < 0.01 + && Math.Abs(v.FrameRate - first.FrameRate) < 0.01 && v.HeaderHash is not null && v.HeaderHash == first.HeaderHash); if (!sameVideo) return false; if (clips[0].Audio is not { } sound) @@ -184,9 +185,8 @@ private static string Silence(string path, MediaInfo info, string layout) // ───────────────────────── Pictures ───────────────────────── - /// The slideshow frame of a preset: even, between 16 and 8K, 1080p by default. - public static (int Width, int Height) SlideshowFrame(MediaOptions options) => - (MediaEncoding.Even(Math.Clamp(options.SlideshowWidth, 16, 7680)), MediaEncoding.Even(Math.Clamp(options.SlideshowHeight, 16, 4320))); + /// The frame every slideshow picture is fitted into: 1080p. + public const int SlideshowWidth = 1920, SlideshowHeight = 1080; /// How long a slideshow of pictures plays: the fades overlap the pictures. public static TimeSpan SlideshowDuration(int count, MediaOptions options) => count <= 0 @@ -213,7 +213,7 @@ public static List PrepareSlide(string picture, string output, int width /// Where a long filter graph is written (). public static List Slideshow(IReadOnlyList slides, string output, MediaOptions options, string encoder, string workDirectory) { - var (width, height) = SlideshowFrame(options); + var (width, height) = (SlideshowWidth, SlideshowHeight); var show = Seconds(options.SlideDuration); var fade = options.FadeDuration; var graph = new List(); diff --git a/tests/Filee.Core.Tests/FakeConverter.cs b/tests/Filee.Core.Tests/FakeConverter.cs index d500fddfb..cf85eadb8 100644 --- a/tests/Filee.Core.Tests/FakeConverter.cs +++ b/tests/Filee.Core.Tests/FakeConverter.cs @@ -165,11 +165,13 @@ internal sealed class FakeJoiner : IMediaJoiner public bool Throw { get; set; } - public bool CanJoinInto(string format) => true; + /// Called when the join starts, before anything is written. + public Action? Joining { get; set; } public Task JoinAsync(IReadOnlyList inputPaths, string outputPath, string format, Filee.Core.Presets.Preset preset, string workDirectory, IProgress? progress, CancellationToken cancellationToken) { + Joining?.Invoke(); if (Throw) throw new InvalidOperationException("boom"); Inputs.AddRange(inputPaths); diff --git a/tests/Filee.Core.Tests/MediaToolTests.cs b/tests/Filee.Core.Tests/MediaToolTests.cs index e48fe0332..324725c11 100644 --- a/tests/Filee.Core.Tests/MediaToolTests.cs +++ b/tests/Filee.Core.Tests/MediaToolTests.cs @@ -55,8 +55,9 @@ public void Remove_metadata_takes_everything_and_keeps_the_format() [Fact] public void Slides_and_fades_stay_within_their_limits() { - var media = new MediaOptions { SlideSeconds = 2, CrossfadeSeconds = 5 }; - Assert.Equal(1, media.FadeDuration); // at most half of the time a picture is shown + var media = new MediaOptions { SlideSeconds = 1.5 }; + Assert.Equal(0.75, media.FadeDuration); // at most half of the time a picture is shown + Assert.Equal(1, new MediaOptions { SlideSeconds = 3 }.FadeDuration); media.Crossfade = false; Assert.Equal(0, media.FadeDuration); Assert.Equal(MediaOptions.MaxSlideSeconds, new MediaOptions { SlideSeconds = 1e9 }.SlideDuration); @@ -183,6 +184,42 @@ public async Task Video_from_images_makes_pngs_first_and_takes_a_png_as_it_is() Assert.False(File.Exists(Path.Combine(dir.Path, "a.png"))); // no picture left beside the source } + [Fact] + public async Task A_slideshow_is_not_shown_as_done_while_its_video_is_made() + { + using var dir = new TempDir(); + var photos = new[] { dir.File("a.jpg"), dir.File("b.jpg") }; + var job = new ConversionJob(photos, BuiltIn("video-from-images"), "Test"); + double atJoin = -1; + var joiner = new FakeJoiner { Joining = () => atJoin = job.Progress }; + await using var queue = new JobQueue(new ConverterCatalog([new FakeConverter("magick", new ConversionEdge("jpg", "png"))]), + mediaJoiner: joiner); + + await queue.RunAsync(job); + + Assert.Equal(JobState.Completed, job.State); + Assert.InRange(atJoin, 0.01, 0.5); // the pictures are ready, the video isn't + Assert.Equal(1, job.Progress); + } + + [Fact] + public async Task Compressing_keeps_the_original_when_the_copy_would_not_be_smaller() + { + using var dir = new TempDir(); + // The fake engine writes a short line: smaller than the big file, bigger than the tiny one. + var big = dir.File("big.jpg", new string('x', 10_000)); + var tiny = dir.File("tiny.jpg", "x"); + await using var queue = new JobQueue(new ConverterCatalog([new FakeConverter("magick", new ConversionEdge("jpg", "jpg"))])); + + var job = await Run(queue, [big, tiny], BuiltIn("compress-image")); + + Assert.Equal(JobState.Completed, job.State); + Assert.Null(job.Files[0].NoticeKey); + Assert.True(new FileInfo(Path.Combine(dir.Path, "big_compressed.jpg")).Length < 10_000); + Assert.Equal("notice.pdf_not_smaller", job.Files[1].NoticeKey); + Assert.Equal("x", File.ReadAllText(Path.Combine(dir.Path, "tiny_compressed.jpg"))); // the original, copied + } + [Fact] public async Task A_failed_join_fails_every_file_and_leaves_no_output() { diff --git a/tests/Filee.Engines.Tests/MediaJoiningTests.cs b/tests/Filee.Engines.Tests/MediaJoiningTests.cs index 03ba9efe7..9bd359853 100644 --- a/tests/Filee.Engines.Tests/MediaJoiningTests.cs +++ b/tests/Filee.Engines.Tests/MediaJoiningTests.cs @@ -8,7 +8,7 @@ namespace Filee.Engines.Tests; public class MediaJoiningTests { - private static readonly VideoStreamInfo PhoneVideo = new(1920, 1080, 1, 0, 30, "h264", "yuv420p"); + private static readonly VideoStreamInfo PhoneVideo = new(1920, 1080, 1, 0, 30, "h264", "yuv420p", HeaderHash: "MD5:1"); private static MediaInfo Clip(VideoStreamInfo? video = null, AudioStreamInfo? audio = null, double seconds = 10) => new(TimeSpan.FromSeconds(seconds), video, audio, null); @@ -37,6 +37,11 @@ public void Clips_of_another_size_codec_or_sound_are_encoded() Assert.False(MediaJoining.CanCopyVideos([clip, clip with { Audio = new AudioStreamInfo(2, 44100, "aac") }], options)); Assert.False(MediaJoining.CanCopyVideos([clip, clip with { Audio = null }], options)); Assert.False(MediaJoining.CanCopyVideos([clip, clip], new MediaOptions { Quality = MediaQuality.Small })); + // Same size and codec from another encoder (other parameter sets), another frame rate, or no setup data to compare. + Assert.False(MediaJoining.CanCopyVideos([clip, clip with { Video = PhoneVideo with { HeaderHash = "MD5:2" } }], options)); + Assert.False(MediaJoining.CanCopyVideos([clip, clip with { Video = PhoneVideo with { FrameRate = 60 } }], options)); + var unknown = clip with { Video = PhoneVideo with { HeaderHash = null } }; + Assert.False(MediaJoining.CanCopyVideos([unknown, unknown], options)); } [Fact] @@ -108,7 +113,7 @@ public void Mono_recordings_stay_mono_and_apple_lossless_is_kept() [Fact] public void Slides_fade_into_each_other_at_the_right_times() { - var options = new MediaOptions { Slideshow = true, SlideSeconds = 3, CrossfadeSeconds = 1 }; + var options = new MediaOptions { Slideshow = true, SlideSeconds = 3 }; var args = MediaJoining.Slideshow(["1.jpg", "2.jpg", "3.jpg"], "out.mp4", options, "libx264", "work"); @@ -140,7 +145,6 @@ public void A_picture_is_fitted_into_the_1080p_frame() Assert.Contains("scale=1920:1080:force_original_aspect_ratio=decrease:force_divisible_by=2:flags=lanczos,pad=1920:1080", After(args, "-vf")); Assert.Equal("mjpeg", After(args, "-c:v")); - Assert.Equal((1920, 1080), MediaJoining.SlideshowFrame(new MediaOptions())); } [Fact] @@ -202,6 +206,12 @@ public void Remove_metadata_copies_the_streams_without_tags_chapters_or_data_tra Assert.DoesNotContain("0", args.Where((a, i) => i > 0 && args[i - 1] == "-map")); // never every stream (data tracks) Assert.Equal("hvc1", After(args, "-tag:v")); Assert.Equal("mov", After(args, "-f")); + Assert.DoesNotContain("0:t", args); // FFmpeg refuses an attachment specifier that matches nothing + + var episode = new MediaInfo(TimeSpan.FromSeconds(10), PhoneVideo, null, null, HasAttachments: true); + var mkv = MediaEncoding.WithoutMetadata("in.mkv", "out.mkv", "mkv", episode); + Assert.Contains("0:t", mkv); // subtitle fonts stay, with their file name and type + Assert.Equal("0:s:t", After(mkv, "-map_metadata:s:t")); var song = new MediaInfo(TimeSpan.FromSeconds(10), null, new AudioStreamInfo(2, 44100, "mp3"), 1); var audio = MediaEncoding.WithoutMetadata("in.mp3", "out.mp3", "mp3", song); diff --git a/tests/Filee.Engines.Tests/MediaToolRealTests.cs b/tests/Filee.Engines.Tests/MediaToolRealTests.cs index d8c3d7efe..f4667b67b 100644 --- a/tests/Filee.Engines.Tests/MediaToolRealTests.cs +++ b/tests/Filee.Engines.Tests/MediaToolRealTests.cs @@ -110,6 +110,13 @@ private static async Task StreamHashAsync(string path, string type) return result.StandardOutput.Split('=', 2)[^1].Trim(); } + /// The MD5 of every video packet of a file, in order. + private static async Task> PacketHashesAsync(string path) + { + var result = await FfmpegAsync(["-hide_banner", "-nostdin", "-v", "error", "-i", path, "-map", "0:v:0", "-c", "copy", "-f", "framemd5", "-"]); + return [.. result.StandardOutput.Split('\n').Where(l => l.Length > 0 && l[0] != '#').Select(l => l.Split(',')[^1].Trim())]; + } + // ───────────────────────── Merge videos ───────────────────────── [Fact] @@ -126,10 +133,34 @@ public async Task Clips_from_one_camera_are_merged_without_an_encode() Assert.Equal(Path.Combine(folder, "a_merged.mp4"), output); var probed = await ProbeAsync(output); Assert.InRange(probed.Duration, 3.3, 3.7); + // Copied, not encoded again: the merged file starts with the first clip's packets, byte for byte (FFmpeg puts + // the stream's parameter sets into each clip's first packet, so that one is skipped). + var firstPackets = await PacketHashesAsync(first); + Assert.Equal(firstPackets.Skip(1), (await PacketHashesAsync(output)).Take(firstPackets.Count).Skip(1)); Assert.Equal(("h264", 320, 240), (probed.Video!.Codec, probed.Video.Width, probed.Video.Height)); Assert.Equal("aac", probed.Audio!.Codec); } + [Fact] + public async Task Clips_of_one_size_but_other_encoder_settings_still_play_after_merging() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + // Same codec, size and pixel format; another profile and header, as from two different phones. + var first = await MakeClipAsync(folder, "a.mp4", 2, extra: ["-c:v", "libx264", "-profile:v", "high", "-pix_fmt", "yuv420p", "-c:a", "aac"]); + var second = await MakeClipAsync(folder, "b.mp4", 2, extra: + ["-c:v", "libx264", "-profile:v", "baseline", "-pix_fmt", "yuv420p", "-c:a", "aac"]); + + var output = await RunAsync([first, second], BuiltIn("merge-videos")); + + var decode = await ProcessRunner.RunAsync(Tools!.Value.Ffmpeg, + ["-hide_banner", "-nostdin", "-v", "error", "-i", output, "-f", "null", "-"], TimeSpan.FromMinutes(2), CancellationToken.None); + Assert.True(decode.ExitCode == 0 && decode.StandardError.Trim().Length == 0, decode.StandardError); + Assert.InRange((await ProbeAsync(output)).Duration, 3.8, 4.2); + // Not copied: the first clip's packets are encoded again. + Assert.NotEqual((await PacketHashesAsync(first)).Skip(1).Take(10), (await PacketHashesAsync(output)).Skip(1).Take(10)); + } + [Fact] public async Task Clips_of_other_sizes_and_without_sound_are_fitted_into_the_first_ones_frame() { @@ -274,6 +305,42 @@ await FfmpegAsync( Assert.Equal(await StreamHashAsync(clip, "a"), await StreamHashAsync(output, "a")); } + [Fact] + public async Task A_portrait_phone_video_stays_upright() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + var plain = await MakeClipAsync(folder, "plain.mov", 1, audio: false, extra: ["-c:v", "libx264"]); + var clip = Path.Combine(folder, "IMG_0003.mov"); + // Phones store portrait video landscape, with a display matrix that turns it 90° (set on a copy: an encode would + // turn the pixels instead). + await FfmpegAsync(["-hide_banner", "-nostdin", "-v", "error", "-y", "-display_rotation", "90", "-i", plain, "-c", "copy", + "-metadata", "location=+37.5+127.0/", clip]); + Assert.Equal(90, Math.Abs((await MediaInfo.ProbeAsync(Tools!.Value.Ffprobe, clip, CancellationToken.None)).Video!.Rotation)); + + var output = await RunAsync([clip], BuiltIn("remove-metadata")); + + Assert.Equal(90, Math.Abs((await MediaInfo.ProbeAsync(Tools!.Value.Ffprobe, output, CancellationToken.None)).Video!.Rotation)); + Assert.False((await ProbeAsync(output)).Tags.ContainsKey("location")); + } + + [Fact] + public async Task An_mkv_keeps_its_subtitle_fonts() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + var font = Path.Combine(folder, "Sub Font.ttf"); + await File.WriteAllBytesAsync(font, Enumerable.Range(0, 2048).Select(i => (byte)i).ToArray(), TestContext.Current.CancellationToken); + var clip = await MakeClipAsync(folder, "episode.mkv", 1, extra: + ["-c:v", "libx264", "-c:a", "aac", "-metadata", "title=Episode 1", "-attach", font, + "-metadata:s:t", "mimetype=application/x-truetype-font"]); + + var after = await ProbeAsync(await RunAsync([clip], BuiltIn("remove-metadata"))); + + Assert.EndsWith("Sub Font.ttf", Assert.Single(after.Streams, s => s.Type == "attachment").Tags["filename"]); + Assert.False(after.Tags.ContainsKey("title")); + } + [Fact] public async Task A_song_loses_its_tags_and_stays_an_mp3() { From cf3bedac9a980f2395e22de11a8c82faab398de3 Mon Sep 17 00:00:00 2001 From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:45:59 +0900 Subject: [PATCH 05/20] Add iCalendar (.ics) to CSV conversion and back - Read VEVENT and VTODO into 14 CSV columns: ISO 8601 times with the original offset, all-day as dates, TZID in its own column, RRULE, EXDATE and RECURRENCE-ID kept raw; JSON and other data formats via CSV. - Resolve offsets from a same-calendar VTIMEZONE without RRULE first, then the system time-zone database, else keep floating time. - Write CSV back to iCalendar (VEVENT/VTODO, VTIMEZONE built from the zone's real transitions, 75-octet folding, no BOM). - Register ics/ical on the data donut, add an "iCalendar" preset on the back of the spreadsheets donut (settings schema 33), update the data engine description in every UI language and the docs. Closes #340 --- README.md | 2 +- docs/ENGINES.md | 25 +- src/Filee.App/Assets/i18n/ar.json | 2 +- src/Filee.App/Assets/i18n/en.json | 2 +- src/Filee.App/Assets/i18n/es.json | 2 +- src/Filee.App/Assets/i18n/hi.json | 2 +- src/Filee.App/Assets/i18n/ja.json | 2 +- src/Filee.App/Assets/i18n/ko.json | 2 +- src/Filee.App/Assets/i18n/pl.json | 2 +- src/Filee.App/Assets/i18n/pt-BR.json | 2 +- src/Filee.App/Assets/i18n/ru.json | 2 +- src/Filee.App/Assets/i18n/zh-CN.json | 2 +- src/Filee.Core/Formats/FormatRegistry.cs | 1 + src/Filee.Core/Presets/BuiltInData.cs | 7 +- src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 29 + src/Filee.Engines/Data/DataConverter.cs | 34 +- src/Filee.Engines/Data/ICalendar.cs | 597 +++++++++++++++++++ tests/Filee.Core.Tests/UserDataStoreTests.cs | 71 +++ tests/Filee.Engines.Tests/ICalendarTests.cs | 464 ++++++++++++++ 20 files changed, 1227 insertions(+), 25 deletions(-) create mode 100644 src/Filee.Engines/Data/ICalendar.cs create mode 100644 tests/Filee.Engines.Tests/ICalendarTests.cs diff --git a/README.md b/README.md index 937fa768d..d651b1167 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ slides show what's new. | **Archives**: ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | extract, ZIP, 7Z, TAR, TAR.GZ; any files → one ZIP | | **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF | | **Fonts**: TTF, OTF, WOFF, WOFF2, EOT | each other | -| **Data**: JSON, JSON Lines, YAML, TOML, XML · vCard | each other, CSV and XLSX (tables); vCard ↔ CSV | +| **Data**: JSON, JSON Lines, YAML, TOML, XML · vCard, iCalendar | each other, CSV and XLSX (tables); vCard, iCalendar ↔ CSV | | **3D models**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY; FBX read | each other, PNG preview | | **Geospatial vectors**: Shapefile, GeoJSON, KML, KMZ, GPX · CSV with coordinates | each other and CSV / XLSX / JSON; projected data (UTM, Web Mercator, Lambert, Albers) comes out as WGS 84 longitude / latitude | diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 3ba038b0c..59e4e3930 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -21,7 +21,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | PDF text | built in (PdfPig) | **PDF → TXT, Markdown** (reading order, also two columns; Markdown keeps headings, emphasis, links and the pictures; no OCR) | | PDF slides | built in (PDFium, PdfPig) | **PDF → PPTX**: a picture of each page on its own slide (looks like the PDF, not editable), the page's text in the speaker notes | | E-mail | built in (MimeKit, MsgReader) | **EML, Outlook MSG → HTML** (headers + body, inline pictures), **TXT**, **ZIP** (the attachments); **MSG → EML**; **MHTML → HTML** (the saved page in one file, pictures inlined; on to PDF, DOCX, HWPX, TXT, Markdown, EPUB from there) | -| Data | built in (System.Text.Json, YamlDotNet, Tomlyn, System.Xml) | **JSON, JSON Lines, YAML, TOML, XML, CSV ↔ each other** (CSV for tables); **vCard ↔ CSV**; spreadsheets and contacts reach JSON / YAML / TOML / XML through CSV | +| Data | built in (System.Text.Json, YamlDotNet, Tomlyn, System.Xml) | **JSON, JSON Lines, YAML, TOML, XML, CSV ↔ each other** (CSV for tables); **vCard, iCalendar ↔ CSV**; spreadsheets, contacts and events reach JSON / YAML / TOML / XML through CSV | | **E-books** | built in | **EPUB, MOBI/AZW/AZW3/PRC, FB2, HTMLZ, TXTZ → EPUB, HWPX (→ PDF), TXT, HTML, Markdown, FB2, HTMLZ, TXTZ**; HTML, Markdown, TXT, DOCX → EPUB/FB2/HTMLZ/TXTZ; HTML → TXT; comics CBZ/CBR/CB7/CBT/CBC → PDF, EPUB, CBZ; PDF → CBZ; AZW4 → PDF; **EPUB ↔ KEPUB** (Kobo) | | CAD (ACadSharp + built-in renderer) | built in (library) | **DWG ↔ DXF**; DWG/DXF → **PDF** (vector), **SVG**, PNG, JPG, WEBP, TIFF, BMP, GIF, ICO, AVIF | | **Subtitles** | built in | **SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC → SRT, ASS, WebVTT, SAMI, LRC, TTML, MicroDVD**, always UTF-8 (Korean CP949 / EUC-KR files read correctly) | @@ -748,7 +748,7 @@ saved as octet-stream, and scripts, frames, remote pictures, fonts and style she (windows-1252, `file:///C:/` locations, VML fallbacks), a `cid:`-only page in EUC-KR, a picture on disk named by a `file:` URI that must not be read, and the routes to HTML, TXT, DOCX, HWPX and PDF. -## Data (JSON, YAML, TOML, XML, CSV, vCard) +## Data (JSON, YAML, TOML, XML, CSV, vCard, iCalendar) `Data/DataConverter.cs` (engine id `data`) reads every format into one tree (`Data/DataTree.cs`: objects with their keys in file order, arrays, text, true / false, integers of any size, floats, TOML dates and null) and writes it @@ -766,10 +766,11 @@ options: the output is always pretty-printed. | CSV (`.csv`) | Filee (`Office/Sheets/CsvFormat.cs`) | first row = keys, one object per row; plain numbers (not `007`) and `true` / `false` typed, the rest text; delimiter and encoding detected as for spreadsheets | only tables: an array of objects (columns = every key, first seen first; an object inside a row becomes `parent.child` columns, one level only), an object of arrays (columns), an array of arrays (rows) or one flat object (one row); objects that only wrap the array (`{"users": […]}`, `…`) are looked through. Anything else stops with a message saying what is not a table | **`.csv` and `.xml` ownership.** CSV stays a spreadsheet format: the spreadsheets donut, XLSX / ODS routes and -"Merge into PDF" are unchanged, and the data engine only adds edges `csv ↔ json / jsonl / yaml / toml / xml`. Routes -combine: XLSX → CSV → JSON, YAML → CSV → XLSX, vCard → CSV → JSON. The spreadsheets donut gets "JSON". `.xml` had no -owner before; XML-based formats Filee converts have their own extensions (`.svg`, `.fb2`, `.dbk`, `.opml` …), so a -plain `.xml` file is treated as data and opens the data donut. +"Merge into PDF" are unchanged. The data engine adds `csv ↔ json / jsonl / yaml / toml / xml` edges, and vCard and +iCalendar each convert to and from CSV. Routes combine: XLSX → CSV → JSON, YAML → CSV → XLSX, vCard → CSV → JSON, +and ICS → CSV → JSON. The spreadsheets donut gets "JSON". `.xml` had no owner before; XML-based formats Filee +converts have their own extensions (`.svg`, `.fb2`, `.dbk`, `.opml` …), so a plain `.xml` file is treated as data +and opens the data donut. **vCard** (`Data/VCard.cs`, own parser): vCard 2.1, 3.0 and 4.0 → CSV with the columns Full name, Given name, Family name, Organization, Title, Email, Phone, Address, Birthday, Note (one row per contact; several values joined with @@ -781,6 +782,18 @@ a missing full name is made from the name parts (홍길동, "Ada Lovelace"). CSV `Name`, `Company`, `Mobile`, `E-mail` … and Korean / Chinese headers such as 이름, 전화번호, 이메일, 姓名, 电话) and writes vCard 3.0 in UTF-8, lines folded at 75 bytes; an address goes into the street field. +**iCalendar** (`Data/ICalendar.cs`, own parser): VEVENTs and VTODOs ↔ CSV; one component per row, with these 14 columns +in order: `Type`, `Title`, `Start`, `End`, `All day`, `Time zone`, `Location`, `Description`, `Organizer`, +`Attendees`, `RRULE`, `EXDATE`, `Recurrence ID`, `UID`. Times use ISO 8601, retaining the original offset; +all-day values are dates only, and a DTSTART TZID is kept in its own `Time zone` column. If a TZID cannot be +resolved, the local time stays floating without an offset. A VTIMEZONE in the same calendar without RRULE takes +precedence; RRULE-based zones are resolved through the system time-zone database instead, so historical years are +not guaranteed to match. CSV → ICS writes the normalized columns as VEVENTs or VTODOs. A numeric offset without a +`Time zone` is written in UTC. Each VTIMEZONE is built from the system rule in force at that zone's earliest timed +Start (or End when no Start); offsets in other years, including past years, may therefore be lost. The original +VTIMEZONE definition is not preserved, and only these columns are supported rather than arbitrary calendar data. +VALARM, X-properties, VJOURNAL, VFREEBUSY, binary attachments and other unsupported properties are lost. + ## CAD drawings (DWG, DXF) Built in, no AutoCAD or ODA software (`Filee.Engines/Cad`). [ACadSharp](https://github.com/DomCR/ACadSharp) reads diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 7ee866f47..b5ba7f250 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -125,7 +125,7 @@ "engine.pdf-text.description": "استخراج نصوص PDF بترتيب القراءة، بما في ذلك الصفحات ذات العمودين (دون تعرّف ضوئي OCR للمستندات الممسوحة).", "engine.pdf-slides.description": "تحويل PDF إلى شرائح PowerPoint: تصبح كل صفحة صورة على شريحة مستقلة (بنفس مظهر PDF وغير قابلة للتحرير)، مع نص الصفحة في ملاحظات المتحدث.", "engine.email.description": "تحويل رسائل البريد (EML وOutlook MSG) إلى HTML ونص مع المرسل والمستلمين والتاريخ والصور، وتجميع المرفقات في ZIP.\nتحويل MSG إلى EML، وصفحات الويب المحفوظة (MHTML) إلى ملف HTML واحد مع صورها.", - "engine.data.description": "التحويل بين JSON وJSON Lines وYAML وTOML وXML وCSV.\nتحويل جهات اتصال vCard إلى CSV والعكس.\nتُحذف التعليقات. تدعم CSV البيانات الجدولية فقط.", + "engine.data.description": "التحويل بين JSON وJSON Lines وYAML وTOML وXML وCSV.\nتحويل جهات اتصال vCard وأحداث iCalendar إلى CSV والعكس.\nتُحذف التعليقات. تدعم CSV البيانات الجدولية فقط.", "engine.vector.description": "تحويل SVG وSVGZ إلى PDF متجهي وصور وDXF؛ وتتبع الصور إلى SVG؛ وقراءة ملفات Illustrator المحفوظة بمحتوى PDF.", "engine.icns.description": "تحويل أيقونات تطبيقات macOS ‏(ICNS) من الصور وإليها.", "engine.font.description": "التحويل بين الخطوط: TTF وOTF وWOFF وWOFF2 وEOT.", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index d6024c303..29286d6f4 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -132,7 +132,7 @@ "engine.pdf-text.description": "Extracts the text of PDFs in reading order, also from two-column pages (no OCR for scans).", "engine.pdf-slides.description": "Turns PDFs into PowerPoint slides: each page becomes a picture on its own slide (looks like the PDF, not editable), with the page text in the speaker notes.", "engine.email.description": "E-mails (EML, Outlook MSG) to HTML and text with sender, recipients and date, pictures included; attachments into a ZIP.\nMSG to EML. Saved web pages (MHTML) to one HTML file with their pictures.", - "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML and CSV into each other.\nvCard contacts to CSV and back.\nComments are dropped. CSV holds tables only.", + "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML and CSV into each other.\nvCard contacts and iCalendar events to CSV and back.\nComments are dropped. CSV holds tables only.", "engine.vector.description": "SVG and SVGZ to vector PDF, images and DXF; pictures traced into SVG; Illustrator files saved with PDF content.", "engine.icns.description": "macOS app icons (ICNS) from and to images.", "engine.font.description": "Fonts: TTF, OTF, WOFF, WOFF2 and EOT into each other.", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 89007a3ee..3b276693f 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -125,7 +125,7 @@ "engine.pdf-text.description": "Extrae el texto de los PDF en orden de lectura, también de páginas en dos columnas (sin OCR para escaneos).", "engine.pdf-slides.description": "Convierte PDF en diapositivas de PowerPoint: cada página pasa a ser una imagen en su propia diapositiva (con el aspecto del PDF, no editable), con el texto de la página en las notas del orador.", "engine.email.description": "Correos electrónicos (EML, Outlook MSG) a HTML y texto con remitente, destinatarios y fecha, imágenes incluidas; los adjuntos van a un ZIP.\nMSG a EML. Páginas web guardadas (MHTML) a un solo archivo HTML con sus imágenes.", - "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML y CSV entre sí.\nContactos vCard a CSV y viceversa.\nLos comentarios se descartan. CSV solo admite tablas.", + "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML y CSV entre sí.\nContactos vCard y eventos de iCalendar a CSV y viceversa.\nLos comentarios se descartan. CSV solo admite tablas.", "engine.vector.description": "SVG y SVGZ a PDF vectorial, imágenes y DXF; imágenes trazadas a SVG; archivos de Illustrator guardados con contenido PDF.", "engine.icns.description": "Iconos de apps de macOS (ICNS) desde y hacia imágenes.", "engine.font.description": "Tipografías: TTF, OTF, WOFF, WOFF2 y EOT entre sí.", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index dde786072..0f46f06f5 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -125,7 +125,7 @@ "engine.pdf-text.description": "PDF का टेक्स्ट पढ़ने के क्रम में निकालता है, दो-कॉलम वाले पेजों से भी (स्कैन के लिए OCR नहीं)।", "engine.pdf-slides.description": "PDF को PowerPoint स्लाइड में बदलता है: हर पेज अपनी स्लाइड पर एक तस्वीर बन जाता है (PDF जैसा दिखता है, संपादन योग्य नहीं), और पेज का टेक्स्ट स्पीकर नोट्स में रहता है।", "engine.email.description": "ई-मेल (EML, Outlook MSG) को भेजने वाले, प्राप्तकर्ताओं और तारीख के साथ HTML और टेक्स्ट में बदलता है, तस्वीरों सहित; अटैचमेंट ZIP में।\nMSG से EML। सहेजे गए वेब पेज (MHTML) उनकी तस्वीरों सहित एक HTML फ़ाइल में।", - "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML और CSV एक-दूसरे में।\nvCard संपर्क CSV में और वापस।\nकमेंट हटा दिए जाते हैं। CSV में केवल टेबल रहती हैं।", + "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML और CSV एक-दूसरे में।\nvCard संपर्क और iCalendar इवेंट CSV में और वापस।\nकमेंट हटा दिए जाते हैं। CSV में केवल टेबल रहती हैं।", "engine.vector.description": "SVG और SVGZ से वेक्टर PDF, इमेज और DXF; तस्वीरों को SVG में ट्रेस करना; Illustrator फ़ाइलें PDF सामग्री के साथ सहेजना।", "engine.icns.description": "इमेज से और इमेज में macOS ऐप आइकन (ICNS)।", "engine.font.description": "फ़ॉन्ट: TTF, OTF, WOFF, WOFF2 और EOT एक-दूसरे में।", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index afc5f5d33..2229a6b6c 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -125,7 +125,7 @@ "engine.pdf-text.description": "PDFのテキストを読み順に抽出します。2段組みのページにも対応します(スキャン画像のOCRには非対応)。", "engine.pdf-slides.description": "PDFをPowerPointのスライドに変換します。各ページを画像にし、1ページずつ別のスライドに配置します(見た目はPDFと同じですが、編集はできません)。ページ内のテキストは発表者ノートに入ります。", "engine.email.description": "メール(EML、Outlook MSG)を、差出人、宛先、日付、画像を含めてHTMLとテキストに変換します。添付ファイルはZIPにまとめます。\nMSGからEMLへの変換にも対応します。画像を含む保存済みのWebページ(MHTML)を、1つのHTMLファイルに変換します。", - "engine.data.description": "JSON、JSON Lines、YAML、TOML、XML、CSVを相互変換します。\nvCardの連絡先とCSVも相互変換できます。\nコメントは削除されます。CSVで扱えるのは表形式のデータだけです。", + "engine.data.description": "JSON、JSON Lines、YAML、TOML、XML、CSVを相互変換します。\nvCardの連絡先とiCalendarの予定をCSVと相互変換できます。\nコメントは削除されます。CSVで扱えるのは表形式のデータだけです。", "engine.vector.description": "SVGとSVGZをベクターPDF、画像、DXFに変換します。画像をトレースしてSVGを作成し、PDF互換で保存されたIllustratorファイルにも対応します。", "engine.icns.description": "macOSアプリのアイコン(ICNS)を画像と相互変換します。", "engine.font.description": "フォント形式(TTF、OTF、WOFF、WOFF2、EOT)を相互変換します。", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 115bfc7e1..0e833dd02 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -132,7 +132,7 @@ "engine.pdf-text.description": "PDF의 글자를 읽는 순서대로 뽑아내요.\n2단 편집도 순서대로 읽어요(스캔 이미지 글자 인식은 안 해요).", "engine.pdf-slides.description": "PDF를 파워포인트 슬라이드로 바꿔요. 페이지마다 그림 한 장으로 슬라이드에 넣어서 PDF와 똑같이 보여요(편집은 안 돼요).\n페이지 글자는 슬라이드 노트에 넣어요.", "engine.email.description": "이메일(EML, Outlook MSG)을 보낸 사람·받는 사람·날짜와 본문 그림까지 담아 HTML·텍스트로 바꾸고, 첨부 파일은 ZIP으로 모아요.\nMSG는 EML로도 바꾸고, 저장한 웹 페이지(MHTML)는 그림까지 담긴 HTML 파일 하나로 바꿔요.", - "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML, CSV를 서로 바꿔요.\nvCard 연락처를 CSV로, CSV를 vCard로 바꿔요.\n주석은 빠져요. CSV는 표 모양 데이터만 담아요.", + "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML, CSV를 서로 바꿔요.\nvCard 연락처와 iCalendar 일정을 CSV로, CSV를 각각의 형식으로 바꿔요.\n주석은 빠져요. CSV는 표 모양 데이터만 담아요.", "engine.vector.description": "SVG·SVGZ를 벡터 PDF, 이미지, DXF로 바꾸고 그림을 SVG로 벡터화해요. PDF 내용이 들어 있는 일러스트레이터 파일도 변환해요.", "engine.icns.description": "macOS 앱 아이콘(ICNS)을 이미지로, 이미지를 ICNS로.", "engine.font.description": "글꼴: TTF, OTF, WOFF, WOFF2, EOT를 서로 변환해요.", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 2e2da36b6..396e274f0 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -132,7 +132,7 @@ "engine.pdf-text.description": "Wyodrębnia tekst z PDF w kolejności czytania, także ze stron dwukolumnowych (bez OCR skanów).", "engine.pdf-slides.description": "Zamienia PDF na slajdy PowerPoint: każda strona staje się obrazem na osobnym slajdzie (wygląda jak PDF, bez edycji), a tekst strony trafia do notatek prelegenta.", "engine.email.description": "E-maile (EML, Outlook MSG) do HTML i tekstu z nadawcą, odbiorcami i datą, razem z obrazami; załączniki do ZIP.\nMSG do EML. Zapisane strony internetowe (MHTML) do jednego pliku HTML razem z obrazami.", - "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML i CSV między sobą.\nKontakty vCard do CSV i z powrotem.\nKomentarze są pomijane. CSV przechowuje tylko tabele.", + "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML i CSV między sobą.\nKontakty vCard i wydarzenia iCalendar do CSV i z powrotem.\nKomentarze są pomijane. CSV przechowuje tylko tabele.", "engine.vector.description": "SVG i SVGZ do wektorowego PDF, obrazów i DXF; obrazy wektoryzowane do SVG; pliki Illustratora zapisane z zawartością PDF.", "engine.icns.description": "Ikony aplikacji macOS (ICNS) z obrazów i do obrazów.", "engine.font.description": "Czcionki: TTF, OTF, WOFF, WOFF2 i EOT między sobą.", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 0b7cd7cd0..84fd14425 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -132,7 +132,7 @@ "engine.pdf-text.description": "Extrai o texto de PDFs na ordem de leitura, também de páginas em duas colunas (sem OCR para digitalizações).", "engine.pdf-slides.description": "Transforma PDFs em slides do PowerPoint: cada página vira uma imagem no próprio slide (parece o PDF, não é editável), com o texto da página nas anotações do apresentador.", "engine.email.description": "E-mails (EML, MSG do Outlook) para HTML e texto, com remetente, destinatários e data, imagens incluídas; anexos num ZIP.\nMSG para EML. Páginas da web salvas (MHTML) para um único arquivo HTML com as imagens.", - "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML e CSV entre si.\nContatos vCard para CSV e de volta.\nComentários são descartados. CSV guarda só tabelas.", + "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML e CSV entre si.\nContatos vCard e eventos iCalendar para CSV e de volta.\nComentários são descartados. CSV guarda só tabelas.", "engine.vector.description": "SVG e SVGZ para PDF vetorial, imagens e DXF; imagens vetorizadas em SVG; arquivos do Illustrator salvos com conteúdo PDF.", "engine.icns.description": "Ícones de apps do macOS (ICNS) a partir de imagens e para imagens.", "engine.font.description": "Fontes: TTF, OTF, WOFF, WOFF2 e EOT entre si.", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 3eb526c74..833252fd1 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -132,7 +132,7 @@ "engine.pdf-text.description": "Достаёт текст из PDF в порядке чтения, в том числе со страниц в две колонки (сканы не распознаются).", "engine.pdf-slides.description": "Превращает PDF в слайды PowerPoint: каждая страница становится картинкой на своём слайде (выглядит как PDF, но не редактируется), текст страницы — в заметках докладчика.", "engine.email.description": "Письма (EML, Outlook MSG) — в HTML и текст с отправителем, получателями и датой, вместе с картинками; вложения — в ZIP.\nMSG в EML. Сохранённые веб-страницы (MHTML) — в один HTML-файл с картинками.", - "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML и CSV друг в друга.\nКонтакты vCard в CSV и обратно.\nКомментарии отбрасываются. CSV хранит только таблицы.", + "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML и CSV друг в друга.\nКонтакты vCard и события iCalendar в CSV и обратно.\nКомментарии отбрасываются. CSV хранит только таблицы.", "engine.vector.description": "SVG и SVGZ — в векторный PDF, картинки и DXF; векторизация картинок в SVG; файлы Illustrator, сохранённые с PDF-содержимым.", "engine.icns.description": "Значки приложений macOS (ICNS) из картинок и в картинки.", "engine.font.description": "Шрифты: TTF, OTF, WOFF, WOFF2 и EOT друг в друга.", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 6247a2b0a..bda80bac7 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -132,7 +132,7 @@ "engine.pdf-text.description": "按阅读顺序提取 PDF 中的文字,双栏页面也能正确排序(不对扫描件做文字识别)。", "engine.pdf-slides.description": "将 PDF 转换为 PowerPoint 幻灯片。每页作为一张图片放在单独的幻灯片上,外观与 PDF 相同(不可编辑),页面文字放入演讲者备注。", "engine.email.description": "将电子邮件(EML、Outlook MSG)连同发件人、收件人、日期和图片转为 HTML 和文本,附件打包为 ZIP。\nMSG 也可转为 EML。保存的网页(MHTML)可连同图片转为单个 HTML 文件。", - "engine.data.description": "JSON、JSON Lines、YAML、TOML、XML、CSV 互相转换。\nvCard 联系人与 CSV 互转。\n注释不保留。CSV 仅支持表格数据。", + "engine.data.description": "JSON、JSON Lines、YAML、TOML、XML、CSV 互相转换。\nvCard 联系人和 iCalendar 日程与 CSV 互转。\n注释不保留。CSV 仅支持表格数据。", "engine.vector.description": "将 SVG、SVGZ 转为矢量 PDF、图片和 DXF,并可将图片描摹为 SVG。也支持包含 PDF 内容的 Illustrator 文件。", "engine.icns.description": "macOS 应用图标(ICNS)与图片互转。", "engine.font.description": "字体:TTF、OTF、WOFF、WOFF2 和 EOT 互相转换。", diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs index 1ec467361..ac0549dfa 100644 --- a/src/Filee.Core/Formats/FormatRegistry.cs +++ b/src/Filee.Core/Formats/FormatRegistry.cs @@ -163,6 +163,7 @@ public static class FormatRegistry new("toml", "TOML", FormatCategory.Data, ["toml"]), new("xml", "XML", FormatCategory.Data, ["xml"]), new("vcf", "vCard", FormatCategory.Data, ["vcf", "vcard"]), + new("ics", "iCalendar", FormatCategory.Data, ["ics", "ical"]), // E-books new("epub", "EPUB", FormatCategory.Ebook, ["epub"]), diff --git a/src/Filee.Core/Presets/BuiltInData.cs b/src/Filee.Core/Presets/BuiltInData.cs index 4a3c96c03..cf04e3a13 100644 --- a/src/Filee.Core/Presets/BuiltInData.cs +++ b/src/Filee.Core/Presets/BuiltInData.cs @@ -339,7 +339,7 @@ private static Preset Tool(string id, string nameKey, string pattern, Action /// Structured data and contacts targets (added in settings schema 14, see SettingsMigrations). EML is for Outlook - /// MSG files, vCard for contact lists in CSV; neither is on a donut. + /// MSG files; vCard and iCalendar are on the back of the spreadsheets donut. /// public static List DataPresets() => [ @@ -349,6 +349,7 @@ public static List DataPresets() => new() { Id = "to-xml", Name = "XML", TargetFormat = "xml" }, new() { Id = "to-eml", Name = "EML", TargetFormat = "eml" }, new() { Id = "to-vcf", Name = "vCard", TargetFormat = "vcf" }, + new() { Id = "to-ics", Name = "iCalendar", TargetFormat = "ics" }, ]; /// @@ -419,7 +420,7 @@ .. WithSubtitleProfile(CatalogProfiles()), NameKey = "profile.spreadsheets", Extensions = ExtensionsOf(FormatCategory.Spreadsheet), PresetIds = ["to-pdf", "to-xlsx", "to-csv", "to-json", "to-ods", "to-hwpx", "to-hwp", "to-html", "merge-pdf"], - BackPresetIds = ["to-docx", "to-md", "to-txt", "to-yaml", "to-xml", "to-vcf", "to-png", "zip-all"], + BackPresetIds = ["to-docx", "to-md", "to-txt", "to-yaml", "to-xml", "to-vcf", "to-ics", "to-png", "zip-all"], }; /// PowerPoint and OpenDocument slides (split from "office" in settings schema 5). @@ -501,7 +502,7 @@ public static List CatalogProfiles() => }, ]; - /// JSON, YAML, TOML, XML and vCard files (added in settings schema 14, after "fonts"). + /// JSON, YAML, TOML, XML, vCard and iCalendar files (added in settings schema 14, after "fonts"). public static ToolbarProfile DataProfile() => new() { Id = "data", diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 4f488dcba..3df95d287 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -112,7 +112,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest public sealed class AppSettings { /// Current schema version. Increase when the format changes and add a migration. - public const int CurrentSchemaVersion = 32; + public const int CurrentSchemaVersion = 33; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 7aff0c842..69a4c9094 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -750,6 +750,35 @@ public static bool ApplyToLibrary(int from, List presets, List presets, List profiles) + { + var changed = false; + if (presets.All(p => p.Id != "to-ics")) + { + var preset = BuiltInData.DataPresets().Single(p => p.Id == "to-ics"); + var after = presets.FindIndex(p => p.Id == "to-vcf"); + presets.Insert(after < 0 ? presets.Count : after + 1, preset); + changed = true; + } + + if (profiles.FirstOrDefault(p => p.Id == "spreadsheets") is { BackPresetIds: { } back } + && !back.Contains("to-ics") && back.Count < ToolbarProfile.MaxSlices) + { + var after = back.IndexOf("to-vcf"); + var beforeZip = back.IndexOf("zip-all"); + back.Insert(after >= 0 ? after + 1 : beforeZip >= 0 ? beforeZip : back.Count, "to-ics"); + changed = true; + } return changed; } diff --git a/src/Filee.Engines/Data/DataConverter.cs b/src/Filee.Engines/Data/DataConverter.cs index 258b2f31e..c0b81dba3 100644 --- a/src/Filee.Engines/Data/DataConverter.cs +++ b/src/Filee.Engines/Data/DataConverter.cs @@ -1,8 +1,8 @@ -// Structured data between JSON, JSON Lines, YAML, TOML and XML (and CSV for tables), plus vCard contacts ↔ CSV, +// Structured data between JSON, JSON Lines, YAML, TOML and XML (and CSV for tables), plus vCard contacts and iCalendar ↔ CSV, // in-process. Every format is read into one tree (DataTree) and written from it, so structure and types carry over as // far as the target can hold them; see the format files for what each one loses (TOML has no null, XML no types, // CSV only tables). Output is UTF-8 without BOM (CSV: with BOM, like the spreadsheet engine), indented by two spaces. -// Spreadsheets reach JSON and the others through CSV (XLSX → CSV → JSON), contacts through CSV as well. +// Spreadsheets reach JSON and the others through CSV (XLSX → CSV → JSON), contacts and calendars through CSV as well. using System.Text; using Filee.Core.Conversion; @@ -12,7 +12,7 @@ namespace Filee.Engines.Data; -/// JSON, JSON Lines, YAML, TOML, XML and CSV into each other; vCard ↔ CSV. +/// JSON, JSON Lines, YAML, TOML, XML and CSV into each other; vCard and iCalendar ↔ CSV. public sealed class DataConverter : IConverter { private static readonly string[] Trees = ["json", "jsonl", "yaml", "toml", "xml"]; @@ -28,10 +28,12 @@ public sealed class DataConverter : IConverter .. Trees.Select(to => new ConversionEdge("csv", to)), new("vcf", "csv"), new("csv", "vcf"), + new("ics", "csv"), + new("csv", "ics"), ]; public EngineStatus GetStatus() => - EngineStatus.Available("JSON, JSON Lines, YAML, TOML, XML, CSV ↔ each other; vCard ↔ CSV", + EngineStatus.Available("JSON, JSON Lines, YAML, TOML, XML, CSV ↔ each other; vCard and iCalendar ↔ CSV", $"{EngineVersions.BuiltIn} · {EngineVersions.Library("YamlDotNet", typeof(YamlDotNet.RepresentationModel.YamlStream))}" + $" · {EngineVersions.Library("Tomlyn", typeof(Tomlyn.TomlSerializer))}"); @@ -63,6 +65,30 @@ public Task> ConvertAsync(ConversionStep step, IProgress Parameters) + { + public string Parameter(string name) => Parameters.GetValueOrDefault(name, ""); + } + + internal sealed class Component(string type, Dictionary? zones = null) + { + public string Type { get; } = type; + public List Properties { get; } = []; + public Dictionary Zones { get; } = zones ?? new(StringComparer.OrdinalIgnoreCase); + public string[]? Cells { get; init; } + + public Property? First(string name) => Properties.FirstOrDefault(p => p.Name == name); + public IEnumerable All(string name) => Properties.Where(p => p.Name == name); + } + + internal sealed class ZoneDefinition + { + public bool HasRule { get; set; } + public List<(DateTime Local, TimeSpan From, TimeSpan To)> Transitions { get; } = []; + } + + private static Property? Parse(string line) + { + var quoted = false; + var colon = -1; + for (var i = 0; i < line.Length; i++) + { + if (line[i] == '"') + quoted = !quoted; + else if (line[i] == ':' && !quoted) + { + colon = i; + break; + } + } + if (colon <= 0) + return null; + var parts = new List(); + var start = 0; + quoted = false; + for (var i = 0; i < colon; i++) + { + if (line[i] == '"') + quoted = !quoted; + if (line[i] == ';' && !quoted) + { + parts.Add(line[start..i]); + start = i + 1; + } + } + parts.Add(line[start..colon]); + if (parts[0].Length == 0 || parts[0].Any(char.IsWhiteSpace)) + return null; + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var part in parts.Skip(1)) + { + var equals = part.IndexOf('='); + if (equals > 0) + parameters[part[..equals].ToUpperInvariant()] = part[(equals + 1)..].Trim('"'); + } + return new Property(parts[0].ToUpperInvariant(), line[(colon + 1)..], line, parameters); + } + + private static IEnumerable Unfold(string text) + { + var current = new StringBuilder(); + var started = false; + foreach (var line in text.Split(["\r\n", "\n", "\r"], StringSplitOptions.None)) + { + if (started && line.Length > 0 && line[0] is ' ' or '\t') + { + current.Append(line.AsSpan(1)); + continue; + } + if (started) + yield return current.ToString(); + current.Clear().Append(line); + started = true; + } + if (started) + yield return current.ToString(); + } + + public static List Read(byte[] bytes) + { + var result = new List(); + var stack = new Stack(); + Dictionary? zones = null; + foreach (var line in Unfold(DocumentReaders.DecodeText(bytes))) + { + var property = Parse(line); + if (property is null) + continue; + if (property.Name == "BEGIN") + { + var type = property.Value.ToUpperInvariant(); + if (type == "VCALENDAR" && stack.Count == 0) + zones = new Dictionary(StringComparer.OrdinalIgnoreCase); + stack.Push(new Component(type, zones)); + } + else if (property.Name == "END") + { + if (stack.Count == 0 || stack.Peek().Type != property.Value.ToUpperInvariant()) + continue; + var closed = stack.Pop(); + if (closed.Type is "STANDARD" or "DAYLIGHT" && stack.Count > 0 && stack.Peek().Type == "VTIMEZONE") + { + var from = closed.First("TZOFFSETFROM")?.Value; + var to = closed.First("TZOFFSETTO")?.Value; + var dates = closed.All("DTSTART").Concat(closed.All("RDATE")).SelectMany(p => p.Value.Split(',')); + if (TryOffset(from, out var oldOffset) && TryOffset(to, out var newOffset)) + stack.Peek().Properties.Add(new Property("X-FILEE-OBSERVANCE", + $"{string.Join(',', dates)}|{oldOffset:c}|{newOffset:c}|{(closed.First("RRULE") is null ? "0" : "1")}", + "", new Dictionary())); + } + if (stack.Count == 1 && stack.Peek().Type == "VCALENDAR") + { + if (closed.Type is "VEVENT" or "VTODO") + result.Add(closed); + else if (closed.Type == "VTIMEZONE") + AddZone(closed); + } + if (closed.Type == "VCALENDAR") + zones = null; + } + else if (stack.Count > 0) + stack.Peek().Properties.Add(property); + } + return result; + } + + private static void AddZone(Component component) + { + var id = component.First("TZID")?.Value; + if (string.IsNullOrEmpty(id)) + return; + var definition = new ZoneDefinition(); + // Observances are accumulated while reading, without exposing nested properties on the parent. + foreach (var entry in component.Properties.Where(p => p.Name == "X-FILEE-OBSERVANCE")) + { + var parts = entry.Value.Split('|'); + if (parts.Length < 3 || !TimeSpan.TryParse(parts[1], CultureInfo.InvariantCulture, out var from) + || !TimeSpan.TryParse(parts[2], CultureInfo.InvariantCulture, out var to)) + continue; + definition.HasRule |= parts.Length > 3 && parts[3] == "1"; + foreach (var date in parts[0].Split(',')) + if (DateTime.TryParseExact(date, "yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var local)) + definition.Transitions.Add((local, from, to)); + } + component.Zones[id] = definition; + } + + private static bool TryOffset(string? value, out TimeSpan offset) + { + offset = default; + if (value is not { Length: 5 or 7 } || value[0] is not ('+' or '-') + || !int.TryParse(value.AsSpan(1, 2), out var hours) + || !int.TryParse(value.AsSpan(3, 2), out var minutes)) + return false; + var seconds = 0; + if (value.Length == 7 && !int.TryParse(value.AsSpan(5, 2), out seconds)) + return false; + offset = new TimeSpan(hours, minutes, seconds) * (value[0] == '-' ? -1 : 1); + return true; + } + + private static string Unescape(string value) + { + var result = new StringBuilder(value.Length); + for (var i = 0; i < value.Length; i++) + { + if (value[i] == '\\' && i + 1 < value.Length) + result.Append(value[++i] is 'n' or 'N' ? '\n' : value[i]); + else + result.Append(value[i]); + } + return result.ToString(); + } + + private static string Person(Property property) + { + var address = property.Value.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) + ? property.Value[7..] : property.Value; + var name = property.Parameter("CN"); + return name.Length == 0 ? address : $"{name} <{address}>"; + } + + private static readonly string[] DateFormats = ["yyyyMMdd", "yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmmss'Z'"]; + + private static bool ParseIcsDate(Property property, out DateTime value, out bool date, out bool utc) + { + date = property.Parameter("VALUE").Equals("DATE", StringComparison.OrdinalIgnoreCase) || property.Value.Length == 8; + utc = property.Value.EndsWith('Z'); + return DateTime.TryParseExact(property.Value, DateFormats, CultureInfo.InvariantCulture, + DateTimeStyles.None, out value); + } + + private static TimeSpan? Offset(string id, DateTime local, Dictionary zones) + { + if (zones.TryGetValue(id, out var definition) && !definition.HasRule && definition.Transitions.Count > 0) + { + var ordered = definition.Transitions.OrderBy(t => t.Local).ToList(); + var found = ordered.LastOrDefault(t => t.Local <= local); + return found == default ? ordered[0].From : found.To; + } + try { return TimeZoneInfo.FindSystemTimeZoneById(id).GetUtcOffset(local); } + catch (TimeZoneNotFoundException) { return null; } + catch (InvalidTimeZoneException) { return null; } + } + + private static string DateCell(Property? property, Dictionary zones) + { + if (property is null) + return ""; + if (!ParseIcsDate(property, out var value, out var date, out var utc)) + return property.Value; + if (date) + return value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + var text = value.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture); + if (utc) + return text + "Z"; + var id = property.Parameter("TZID"); + var offset = id.Length == 0 ? null : Offset(id, value, zones); + return offset is null ? text : new DateTimeOffset(value, offset.Value).ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture); + } + + private static bool Duration(string text, out TimeSpan duration) + { + duration = default; + var sign = 1; + if (text.StartsWith('-') || text.StartsWith('+')) + { + sign = text[0] == '-' ? -1 : 1; + text = text[1..]; + } + if (!text.StartsWith('P')) + return false; + text = text[1..]; + var time = false; + var digits = ""; + var any = false; + foreach (var ch in text) + { + if (ch == 'T' && !time) + { + time = true; + continue; + } + if (char.IsAsciiDigit(ch)) + { + digits += ch; + continue; + } + if (digits.Length == 0 || !int.TryParse(digits, out var number)) + return false; + duration += ch switch + { + 'W' when !time => TimeSpan.FromDays(number * 7d), + 'D' when !time => TimeSpan.FromDays(number), + 'H' when time => TimeSpan.FromHours(number), + 'M' when time => TimeSpan.FromMinutes(number), + 'S' when time => TimeSpan.FromSeconds(number), + _ => TimeSpan.MinValue, + }; + if (duration < TimeSpan.Zero) + return false; + digits = ""; + any = true; + } + duration *= sign; + return any && digits.Length == 0; + } + + public static List> ToRows(List components) + { + var rows = new List> { Columns.ToList() }; + foreach (var component in components) + { + var start = component.First("DTSTART"); + var end = component.First(component.Type == "VTODO" ? "DUE" : "DTEND"); + var endCell = DateCell(end, component.Zones); + if (end is null && start is not null && ParseIcsDate(start, out var startDate, out var date, out var utc) + && component.First("DURATION") is { } span && Duration(span.Value, out var duration)) + { + var computed = startDate + duration; + var synthetic = new Property(start.Name, computed.ToString(date ? "yyyyMMdd" : "yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture) + + (utc && !date ? "Z" : ""), "", start.Parameters); + endCell = DateCell(synthetic, component.Zones); + } + rows.Add( + [ + component.Type == "VTODO" ? "todo" : "event", + Unescape(component.First("SUMMARY")?.Value ?? ""), + DateCell(start, component.Zones), + endCell, + start?.Parameter("VALUE").Equals("DATE", StringComparison.OrdinalIgnoreCase) == true ? "true" : "false", + start is not null && start.Parameter("VALUE") != "DATE" && start.Value.Length != 8 ? start.Parameter("TZID") : "", + Unescape(component.First("LOCATION")?.Value ?? ""), + Unescape(component.First("DESCRIPTION")?.Value ?? ""), + component.First("ORGANIZER") is { } organizer ? Person(organizer) : "", + string.Join("\n", component.All("ATTENDEE").Select(Person)), + component.First("RRULE")?.Value ?? "", + string.Join("\n", component.All("EXDATE").Select(p => p.Raw)), + component.First("RECURRENCE-ID")?.Raw ?? "", + component.First("UID")?.Value ?? "", + ]); + } + return rows; + } + + private static int Field(string name) + { + for (var i = 0; i < Columns.Length; i++) + if (name.Equals(Columns[i], StringComparison.OrdinalIgnoreCase)) + return i; + return name.ToLowerInvariant() switch + { + "summary" or "subject" or "제목" or "标题" => 1, + "begin" or "시작" or "开始" => 2, + "finish" or "종료" or "结束" => 3, + "장소" or "地点" => 6, + "notes" or "설명" or "描述" => 7, + _ => -1, + }; + } + + public static List FromRows(List> rows) + { + if (rows.Count == 0) + throw new InvalidDataException("The table has no iCalendar events or to-dos."); + var fields = rows[0].Select(Field).ToArray(); + if (!fields.Contains(1) && !fields.Contains(2)) + throw new InvalidDataException("The table has no iCalendar events or to-dos."); + var result = new List(); + foreach (var row in rows.Skip(1)) + { + var cells = new string[Columns.Length]; + Array.Fill(cells, ""); + for (var i = 0; i < Math.Min(fields.Length, row.Count); i++) + if (fields[i] >= 0 && cells[fields[i]].Length == 0 && !rows[0].Take(i).Any(h => Field(h) == fields[i])) + cells[fields[i]] = row[i]; + var todo = cells[0].Equals("todo", StringComparison.OrdinalIgnoreCase); + if (!todo && !TryCsvDate(cells[2], out _, out _)) + continue; + result.Add(new Component(todo ? "VTODO" : "VEVENT") { Cells = cells }); + } + return result; + } + + private static bool TryCsvDate(string text, out DateTimeOffset instant, out bool date) + { + date = text.Length == 10 && DateOnly.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _); + if (date) + { + instant = new DateTimeOffset(DateTime.ParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture), TimeSpan.Zero); + return true; + } + return DateTimeOffset.TryParseExact(text, ["yyyy-MM-dd'T'HH:mm:ss'Z'", "yyyy-MM-dd'T'HH:mm:sszzz", "yyyy-MM-dd'T'HH:mm:ss"], + CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out instant); + } + + private static string Escape(string text) => + text.Replace("\\", "\\\\").Replace("\r\n", "\n").Replace("\r", "\n") + .Replace("\n", "\\n").Replace(";", "\\;").Replace(",", "\\,"); + + private static void Line(StringBuilder output, string text) + { + var bytes = 0; + foreach (var rune in text.EnumerateRunes()) + { + var size = rune.Utf8SequenceLength; + if (bytes + size > 75) + { + output.Append("\r\n "); + bytes = 1; + } + output.Append(rune.ToString()); + bytes += size; + } + output.Append("\r\n"); + } + + private static string DateLine(string name, string text, string zone, TimeZoneInfo? systemZone) + { + if (!TryCsvDate(text, out var instant, out var date)) + return ""; + if (date) + return $"{name};VALUE=DATE:{instant:yyyyMMdd}"; + var raw = DateTime.ParseExact(text[..19], "yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture); + if (systemZone is not null && zone.Length > 0) + { + var local = text.Length == 19 || (!text.EndsWith('Z') && instant.Offset == systemZone.GetUtcOffset(raw)) + ? raw : TimeZoneInfo.ConvertTime(instant, systemZone).DateTime; + return $"{name};TZID={zone}:{local:yyyyMMdd'T'HHmmss}"; + } + if (text.Length == 19) + return $"{name}:{raw:yyyyMMdd'T'HHmmss}"; + return $"{name}:{instant.ToUniversalTime():yyyyMMdd'T'HHmmss'Z'}"; + } + + private static string PersonLine(string name, string cell) + { + var marker = cell.LastIndexOf(" <", StringComparison.Ordinal); + var display = marker >= 0 && cell.EndsWith('>') ? cell[..marker] : ""; + var address = display.Length > 0 ? cell[(marker + 2)..^1] : cell; + if (!address.Contains(':')) + address = "mailto:" + address; + if (display.Length == 0) + return name + ":" + address; + if (display.IndexOfAny([';', ':', ',']) >= 0) + display = "\"" + display + "\""; + return $"{name};CN={display}:{address}"; + } + + public static string Write(List components) + { + var output = new StringBuilder(); + Line(output, "BEGIN:VCALENDAR"); + Line(output, "VERSION:2.0"); + Line(output, "PRODID:-//Filee//Filee//EN"); + var zones = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var component in components) + { + var cells = component.Cells!; + if (cells[5].Length == 0) + continue; + try + { + var zone = TimeZoneInfo.FindSystemTimeZoneById(cells[5]); + var parsedStart = TryCsvDate(cells[2], out var start, out var startDate); + var hasStart = cells[2].Length > 0 && parsedStart && !startDate; + var candidate = hasStart + ? start : TryCsvDate(cells[3], out var end, out var endDate) && !endDate ? end : (DateTimeOffset?)null; + if (candidate is { } instant && (!zones.TryGetValue(cells[5], out var previous) + || hasStart && !previous.HasStart || hasStart == previous.HasStart && instant < previous.Instant)) + zones[cells[5]] = (zone, instant, hasStart); + } + catch (TimeZoneNotFoundException) { } + catch (InvalidTimeZoneException) { } + } + foreach (var (id, definition) in zones) + foreach (var line in BuildVTimeZone(definition.Zone, definition.Instant, id).Split("\r\n", StringSplitOptions.RemoveEmptyEntries)) + Line(output, line); + + foreach (var component in components) + { + var cells = component.Cells!; + TimeZoneInfo? zone = null; + if (zones.TryGetValue(cells[5], out var definition)) + zone = definition.Zone; + Line(output, "BEGIN:" + component.Type); + var uid = cells[13].Length == 0 + ? Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\0', cells))))[..16] + "@filee" + : cells[13]; + Line(output, "UID:" + uid); + Line(output, "DTSTAMP:" + DateTime.UtcNow.ToString("yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture)); + if (cells[1].Length > 0) + Line(output, "SUMMARY:" + Escape(cells[1])); + var start = DateLine("DTSTART", cells[2], cells[5], zone); + if (start.Length > 0) + Line(output, start); + var end = DateLine(component.Type == "VTODO" ? "DUE" : "DTEND", cells[3], cells[5], zone); + if (end.Length > 0) + Line(output, end); + if (cells[6].Length > 0) + Line(output, "LOCATION:" + Escape(cells[6])); + if (cells[7].Length > 0) + Line(output, "DESCRIPTION:" + Escape(cells[7])); + if (cells[8].Length > 0) + Line(output, PersonLine("ORGANIZER", cells[8])); + foreach (var attendee in cells[9].Split('\n', StringSplitOptions.RemoveEmptyEntries)) + Line(output, PersonLine("ATTENDEE", attendee)); + if (cells[10].Length > 0) + Line(output, "RRULE:" + cells[10]); + foreach (var line in cells[11].Split('\n', StringSplitOptions.RemoveEmptyEntries)) + Line(output, line); + foreach (var line in cells[12].Split('\n', StringSplitOptions.RemoveEmptyEntries)) + Line(output, line); + Line(output, "END:" + component.Type); + } + Line(output, "END:VCALENDAR"); + return output.ToString(); + } + + private sealed record Transition(DateTimeOffset Instant, DateTime Local, TimeSpan From, TimeSpan To) + { + public bool Daylight => To > From; + } + + private static List Transitions(TimeZoneInfo zone, int year) + { + var found = new List(); + var until = new DateTimeOffset(year + 1, 1, 1, 0, 0, 0, TimeSpan.Zero); + var previous = new DateTimeOffset(year, 1, 1, 0, 0, 0, TimeSpan.Zero); + var offset = zone.GetUtcOffset(previous); + for (var next = previous.AddHours(1); next <= until; next = next.AddHours(1)) + { + var current = zone.GetUtcOffset(next); + if (current != offset) + { + var lower = previous; + var upper = next; + while ((upper - lower).TotalMinutes > 1) + { + var middle = lower.AddMinutes(Math.Floor((upper - lower).TotalMinutes / 2)); + if (zone.GetUtcOffset(middle) == offset) + lower = middle; + else + upper = middle; + } + found.Add(new Transition(upper, upper.UtcDateTime + offset, offset, current)); + } + previous = next; + offset = current; + } + return found; + } + + private static string Rule(Transition transition, TimeZoneInfo zone, int year) + { + var day = transition.Local; + var weekday = day.DayOfWeek.ToString()[..2].ToUpperInvariant(); + var nth = (day.Day - 1) / 7 + 1; + var candidates = new[] + { + $"BYDAY=-1{weekday}", + $"BYDAY={nth}{weekday}", + $"BYMONTHDAY={day.Day}", + }; + var matches = Enumerable.Range(year, 3).SelectMany(y => Transitions(zone, y)) + .Where(t => t.Daylight == transition.Daylight && t.Local.Month == day.Month).ToList(); + foreach (var candidate in candidates) + { + if (matches.Count != 3) + break; + if (matches.All(t => candidate == $"BYMONTHDAY={t.Local.Day}" || + candidate == $"BYDAY={(t.Local.AddDays(7).Month != t.Local.Month ? "-1" : (t.Local.Day - 1) / 7 + 1)}{t.Local.DayOfWeek.ToString()[..2].ToUpperInvariant()}")) + return $"FREQ=YEARLY;BYMONTH={day.Month};{candidate}"; + } + return $"FREQ=YEARLY;BYMONTH={day.Month};BYDAY={nth}{weekday}"; + } + + private static string OffsetText(TimeSpan offset) => + $"{(offset < TimeSpan.Zero ? "-" : "+")}{Math.Abs((int)offset.TotalHours):00}{Math.Abs(offset.Minutes):00}"; + + /// Builds observances from actual UTC transitions, rather than adjustment-rule metadata. + internal static string BuildVTimeZone(TimeZoneInfo zone, DateTimeOffset instant, string? tzid = null) + { + var year = instant.Year; + var transitions = Transitions(zone, year); + var output = new StringBuilder(); + Line(output, "BEGIN:VTIMEZONE"); + Line(output, "TZID:" + (tzid ?? zone.Id)); + if (transitions.Count < 2) + { + var offset = OffsetText(zone.GetUtcOffset(instant)); + Line(output, "BEGIN:STANDARD"); + Line(output, "DTSTART:19700101T000000"); + Line(output, "TZOFFSETFROM:" + offset); + Line(output, "TZOFFSETTO:" + offset); + Line(output, "END:STANDARD"); + } + else + { + foreach (var transition in transitions) + { + var type = transition.Daylight ? "DAYLIGHT" : "STANDARD"; + Line(output, "BEGIN:" + type); + Line(output, "DTSTART:" + transition.Local.ToString("yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture)); + Line(output, "TZOFFSETFROM:" + OffsetText(transition.From)); + Line(output, "TZOFFSETTO:" + OffsetText(transition.To)); + Line(output, "RRULE:" + Rule(transition, zone, year)); + Line(output, "END:" + type); + } + } + Line(output, "END:VTIMEZONE"); + return output.ToString(); + } +} diff --git a/tests/Filee.Core.Tests/UserDataStoreTests.cs b/tests/Filee.Core.Tests/UserDataStoreTests.cs index 142da41e2..18a8e1bad 100644 --- a/tests/Filee.Core.Tests/UserDataStoreTests.cs +++ b/tests/Filee.Core.Tests/UserDataStoreTests.cs @@ -1044,6 +1044,77 @@ public void Version_30_libraries_get_the_live_photo_presets_after_extract_live_v Assert.Single(presets, p => p.Id == "live-photo-iphone"); } + [Fact] + public void Version_32_libraries_learn_icalendar_extensions_and_preset_without_overriding_user_choices() + { + using var fullDir = new TempDir(); + var full = new UserDataStore(fullDir.Path); + full.Load(); + full.Settings.SchemaVersion = 32; + var data = full.Profiles.Single(p => p.Id == "data"); + data.Extensions.RemoveAll(e => e is "ics" or "ical"); + full.Profiles.Insert(0, new ToolbarProfile { Id = "my-calendar", Name = "My calendar", Extensions = ["ics"] }); + var fullBack = full.Profiles.Single(p => p.Id == "spreadsheets").BackPresetIds!; + fullBack.Remove("to-ics"); + fullBack.AddRange(full.Presets.Select(p => p.Id).Where(id => !fullBack.Contains(id)) + .Take(ToolbarProfile.MaxSlices - fullBack.Count)); + var fullBackBeforeMigration = fullBack.ToList(); + full.Presets.RemoveAll(p => p.Id == "to-ics"); + full.SaveSettings(); + full.SaveLibrary(); + + var upgradedFull = new UserDataStore(fullDir.Path); + upgradedFull.Load(); + Assert.Contains("ical", upgradedFull.Profiles.Single(p => p.Id == "data").Extensions); + Assert.DoesNotContain("ics", upgradedFull.Profiles.Single(p => p.Id == "data").Extensions); + Assert.Contains("ics", upgradedFull.Profiles.Single(p => p.Id == "my-calendar").Extensions); + Assert.Equal(fullBackBeforeMigration, upgradedFull.Profiles.Single(p => p.Id == "spreadsheets").BackPresetIds); + Assert.Single(upgradedFull.Presets, p => p.Id == "to-ics"); + + using var roomDir = new TempDir(); + var room = new UserDataStore(roomDir.Path); + room.Load(); + room.Settings.SchemaVersion = 32; + foreach (var profile in room.Profiles) + profile.Extensions.RemoveAll(e => e is "ics" or "ical"); + room.Profiles.Single(p => p.Id == "spreadsheets").BackPresetIds!.Remove("to-ics"); + room.Presets.RemoveAll(p => p.Id == "to-ics"); + room.SaveSettings(); + room.SaveLibrary(); + + var upgradedRoom = new UserDataStore(roomDir.Path); + upgradedRoom.Load(); + var sheets = upgradedRoom.Profiles.Single(p => p.Id == "spreadsheets").BackPresetIds!; + Assert.Equal(sheets.IndexOf("to-vcf") + 1, sheets.IndexOf("to-ics")); + Assert.Single(upgradedRoom.Presets, p => p.Id == "to-ics"); + upgradedRoom.Profiles.Single(p => p.Id == "data").Extensions.Remove("ics"); + upgradedRoom.SaveLibrary(); + + var reloadedRoom = new UserDataStore(roomDir.Path); + reloadedRoom.Load(); + Assert.DoesNotContain("ics", reloadedRoom.Profiles.Single(p => p.Id == "data").Extensions); + Assert.Equal(1, reloadedRoom.Profiles.Single(p => p.Id == "spreadsheets").BackPresetIds!.Count(id => id == "to-ics")); + Assert.Single(reloadedRoom.Presets, p => p.Id == "to-ics"); + + using var deletedDir = new TempDir(); + var deleted = new UserDataStore(deletedDir.Path); + deleted.Load(); + deleted.Settings.SchemaVersion = 32; + deleted.Profiles.RemoveAll(p => p.Id == "data"); + foreach (var profile in deleted.Profiles) + profile.Extensions.RemoveAll(e => e is "ics" or "ical"); + deleted.Profiles.Single(p => p.Id == "spreadsheets").BackPresetIds!.Remove("to-ics"); + deleted.Presets.RemoveAll(p => p.Id == "to-ics"); + deleted.SaveSettings(); + deleted.SaveLibrary(); + + var upgradedDeleted = new UserDataStore(deletedDir.Path); + upgradedDeleted.Load(); + Assert.DoesNotContain(upgradedDeleted.Profiles, p => p.Id == "data"); + Assert.DoesNotContain(upgradedDeleted.Profiles.SelectMany(p => p.Extensions), e => e is "ics" or "ical"); + Assert.Single(upgradedDeleted.Presets, p => p.Id == "to-ics"); + } + [Fact] public void Live_photo_options_survive_saving_and_unknown_kinds_read_as_none() { diff --git a/tests/Filee.Engines.Tests/ICalendarTests.cs b/tests/Filee.Engines.Tests/ICalendarTests.cs new file mode 100644 index 000000000..cac202dd0 --- /dev/null +++ b/tests/Filee.Engines.Tests/ICalendarTests.cs @@ -0,0 +1,464 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using Filee.Core.Conversion; +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Core.Settings; +using Filee.Engines.Data; +using Filee.Engines.Office.Sheets; + +namespace Filee.Engines.Tests; + +public class ICalendarTests(EngineFixture fx) : IClassFixture +{ + private static string Calendar(params string[] lines) => + string.Join("\r\n", ["BEGIN:VCALENDAR", "VERSION:2.0", .. lines, "END:VCALENDAR", ""]); + + private static string Event(string uid, params string[] lines) => + string.Join("\r\n", ["BEGIN:VEVENT", "UID:" + uid, .. lines, "END:VEVENT"]); + + private static List> Rows(string text) => CsvFormat.Parse(text.TrimStart('\uFEFF'), ','); + + private async Task Convert(string text, string extension, string target) + { + var path = Path.Combine(fx.NewFolder(), "source." + extension); + await File.WriteAllTextAsync(path, text, TestContext.Current.CancellationToken); + var job = await fx.ConvertAsync([path], new Preset { TargetFormat = target }); + Assert.Equal(JobState.Completed, job.State); + return job.Outputs.Single(); + } + + private async Task>> ReadCalendar(string text) => + Rows(await File.ReadAllTextAsync(await Convert(text, "ics", "csv"), TestContext.Current.CancellationToken)); + + private static string Cell(List> rows, int row, string name) => rows[row][rows[0].IndexOf(name)]; + + private static string Csv(params string[][] rows) => + string.Join("\r\n", rows.Select(row => string.Join(",", row.Select(value => + value.IndexOfAny([',', '"', '\n', '\r']) < 0 ? value : "\"" + value.Replace("\"", "\"\"") + "\"")))) + "\r\n"; + + [Fact] + public async Task Google_export_preserves_columns_recurrence_attendees_folding_and_skips_alarm() + { + var zone = string.Join("\r\n", "BEGIN:VTIMEZONE", "TZID:Asia/Seoul", "BEGIN:STANDARD", + "DTSTART:19700101T000000", "TZOFFSETFROM:+0900", "TZOFFSETTO:+0900", "END:STANDARD", "END:VTIMEZONE"); + var rows = await ReadCalendar(Calendar(zone, Event("007", + "SUMMARY:123", "DTSTART:20261021T000000Z", "DTEND:20261021T010000Z", "LOCATION:true", + "DESCRIPTION:긴 설명이 접힌 줄의 전반부 ", "\t후반부\\n둘째 줄", + "RRULE:FREQ=WEEKLY;COUNT=3", "EXDATE;TZID=Asia/Seoul:20261028T090000,20261104T090000", + "EXDATE;TZID=Asia/Seoul:20261111T090000", + "ATTENDEE;CN=\"Kim, A\":mailto:a@example.com", "ATTENDEE:mailto:b@example.com", + "BEGIN:VALARM", "DESCRIPTION:do not replace event description", "END:VALARM"), + Event("seoul", "DTSTART;TZID=Asia/Seoul:20261021T090000"))); + Assert.Equal(ICalendar.Columns, rows[0]); + Assert.Equal(3, rows.Count); + Assert.Equal("2026-10-21T00:00:00Z", Cell(rows, 1, "Start")); + Assert.Equal("2026-10-21T09:00:00+09:00", Cell(rows, 2, "Start")); + Assert.Equal("123", Cell(rows, 1, "Title")); + Assert.Equal("true", Cell(rows, 1, "Location")); + Assert.Equal("007", Cell(rows, 1, "UID")); + Assert.Equal("긴 설명이 접힌 줄의 전반부 후반부\n둘째 줄", Cell(rows, 1, "Description")); + Assert.Equal("FREQ=WEEKLY;COUNT=3", Cell(rows, 1, "RRULE")); + Assert.Equal("EXDATE;TZID=Asia/Seoul:20261028T090000,20261104T090000\nEXDATE;TZID=Asia/Seoul:20261111T090000", Cell(rows, 1, "EXDATE")); + Assert.Equal("Kim, A \nb@example.com", Cell(rows, 1, "Attendees")); + } + + [Fact] + public async Task Apple_all_day_exclusive_end_duration_and_unknown_properties() + { + var rows = await ReadCalendar(Calendar( + Event("all", "DTSTART;VALUE=DATE:20261021", "DTEND;VALUE=DATE:20261022", "X-APPLE-STRUCTURED-LOCATION:ignored"), + Event("duration", "DTSTART:20261021T090000Z", "DURATION:PT1H30M"), + Event("weeks", "DTSTART;VALUE=DATE:20261021", "DURATION:P2W"))); + Assert.Equal("2026-10-22", Cell(rows, 1, "End")); + Assert.Equal("true", Cell(rows, 1, "All day")); + Assert.Equal("", Cell(rows, 1, "Time zone")); + Assert.Equal("2026-10-21T10:30:00Z", Cell(rows, 2, "End")); + Assert.Equal("2026-11-04", Cell(rows, 3, "End")); + } + + [Fact] + public async Task Outlook_windows_zone_custom_zone_unknown_rules_and_recurrence_instance() + { + var fixedZone = string.Join("\r\n", "BEGIN:VTIMEZONE", "TZID:Customized Time Zone", "BEGIN:STANDARD", + "DTSTART:19700101T000000", "TZOFFSETFROM:+0530", "TZOFFSETTO:+0530", "END:STANDARD", "END:VTIMEZONE"); + var unknown = string.Join("\r\n", "BEGIN:VTIMEZONE", "TZID:Unknown/Rule", "BEGIN:STANDARD", + "DTSTART:19700101T000000", "TZOFFSETFROM:+0100", "TZOFFSETTO:+0200", + "RRULE:FREQ=YEARLY;BYMONTH=1;BYDAY=1SU", "END:STANDARD", "END:VTIMEZONE"); + var rows = await ReadCalendar(Calendar(fixedZone, unknown, + Event("windows", "DTSTART;TZID=Korea Standard Time:20261021T090000"), + Event("custom", "DTSTART;TZID=Customized Time Zone:20261021T090000"), + Event("unknown", "DTSTART;TZID=Unknown/Rule:20261021T090000"), + Event("exception", "DTSTART:20261021T100000Z", "RECURRENCE-ID;TZID=Asia/Seoul:20261021T090000"))); + Assert.Equal("2026-10-21T09:00:00+09:00", Cell(rows, 1, "Start")); + Assert.Equal("2026-10-21T09:00:00+05:30", Cell(rows, 2, "Start")); + Assert.Equal("2026-10-21T09:00:00", Cell(rows, 3, "Start")); + Assert.Equal("Unknown/Rule", Cell(rows, 3, "Time zone")); + Assert.Equal("RECURRENCE-ID;TZID=Asia/Seoul:20261021T090000", Cell(rows, 4, "Recurrence ID")); + } + + [Fact] + public async Task Calendar_order_is_event_todo_event_across_two_calendars() + { + var text = Calendar(Event("A", "DTSTART:20261021T090000Z"), "BEGIN:VTODO", "UID:B", "END:VTODO") + + Calendar(Event("C", "DTSTART:20261021T090000Z")); + var rows = await ReadCalendar(text); + Assert.Equal([("event", "A"), ("todo", "B"), ("event", "C")], + rows.Skip(1).Select(row => (row[0], row[13])).ToArray()); + } + + [Fact] + public async Task File_timezone_wins_and_transitions_are_scoped_to_each_calendar() + { + static string Zone(string id, string from, string to) => + string.Join("\r\n", "BEGIN:VTIMEZONE", "TZID:" + id, "BEGIN:STANDARD", + "DTSTART:20261021T100000", "TZOFFSETFROM:" + from, "TZOFFSETTO:" + to, + "END:STANDARD", "END:VTIMEZONE"); + var text = Calendar(Zone("America/New_York", "+0100", "+0100"), + Event("file", "DTSTART;TZID=America/New_York:20261021T090000")) + + Calendar(Zone("Custom/Change", "+0100", "+0200"), + Event("before", "DTSTART;TZID=Custom/Change:20261021T090000"), + Event("after", "DTSTART;TZID=Custom/Change:20261021T110000")) + + Calendar(Zone("America/New_York", "+0300", "+0300"), + Event("second", "DTSTART;TZID=America/New_York:20261021T090000")); + var rows = await ReadCalendar(text); + Assert.EndsWith("+01:00", Cell(rows, 1, "Start")); + Assert.EndsWith("+01:00", Cell(rows, 2, "Start")); + Assert.EndsWith("+02:00", Cell(rows, 3, "Start")); + Assert.EndsWith("+03:00", Cell(rows, 4, "Start")); + } + + [Fact] + public async Task Rdate_transitions_before_between_after_and_tab_fold_with_escapes() + { + var zone = string.Join("\n", "BEGIN:VTIMEZONE", "TZID:Custom/Rdate", "BEGIN:STANDARD", + "DTSTART:20261021T100000", "RDATE:20261121T100000", + "TZOFFSETFROM:+0100", "TZOFFSETTO:+0200", "END:STANDARD", + "BEGIN:DAYLIGHT", "DTSTART:20261221T100000", "RDATE:20270121T100000", + "TZOFFSETFROM:+0200", "TZOFFSETTO:+0300", "END:DAYLIGHT", "END:VTIMEZONE"); + var rows = await ReadCalendar(Calendar(zone, + Event("first", "DTSTART;TZID=Custom/Rdate:20261020T090000", @"SUMMARY:a\\b\;c\,d\ne\Nlast"), + Event("middle", "DTSTART;TZID=Custom/Rdate:20261101T090000"), + Event("last", "DTSTART;TZID=Custom/Rdate:20270122T090000")).Replace("\r\n", "\n")); + Assert.EndsWith("+01:00", Cell(rows, 1, "Start")); + Assert.EndsWith("+02:00", Cell(rows, 2, "Start")); + Assert.EndsWith("+03:00", Cell(rows, 3, "Start")); + Assert.Equal("a\\b;c,d\ne\nlast", Cell(rows, 1, "Title")); + } + + [Fact] + public async Task Todo_only_due_duration_without_dates_and_mixed_end_zones() + { + var rows = await ReadCalendar(Calendar( + "BEGIN:VTODO", "UID:due", "DUE;VALUE=DATE:20261030", "END:VTODO", + "BEGIN:VTODO", "UID:duration", "DTSTART:20261021T090000Z", "DURATION:PT1H", "END:VTODO", + "BEGIN:VTODO", "UID:empty", "END:VTODO", + Event("mixed", "DTSTART;TZID=Asia/Seoul:20261021T090000", + "DTEND;TZID=America/New_York:20261021T090000"))); + Assert.Equal(5, rows.Count); + Assert.Equal("", Cell(rows, 1, "Start")); + Assert.Equal("2026-10-30", Cell(rows, 1, "End")); + Assert.Equal("false", Cell(rows, 1, "All day")); + Assert.Equal("2026-10-21T10:00:00Z", Cell(rows, 2, "End")); + Assert.Equal("", Cell(rows, 3, "End")); + Assert.Equal("Asia/Seoul", Cell(rows, 4, "Time zone")); + Assert.EndsWith("-04:00", Cell(rows, 4, "End")); + var output = await File.ReadAllTextAsync(await Convert(Csv(ICalendar.Columns, + rows[1].ToArray()), "csv", "ics"), TestContext.Current.CancellationToken); + Assert.DoesNotContain("DTSTART", output); + Assert.Contains("DUE;VALUE=DATE:20261030", output); + } + + [Fact] + public async Task Todo_only_calendar_converts_to_one_todo_row() + { + var rows = await ReadCalendar(Calendar( + "BEGIN:VTODO", "UID:only", "DUE;VALUE=DATE:20261030", "END:VTODO")); + Assert.Equal(2, rows.Count); + Assert.Equal("todo", Cell(rows, 1, "Type")); + } + + [Fact] + public async Task Malformed_input_keeps_only_closed_components_and_raw_unparseable_dates() + { + var rows = await ReadCalendar(Calendar( + "garbage", "NO-COLON", + Event("closed", "DTSTART:tomorrow", "DTEND:tomorrow", "EXDATE:abc"), + Event("bad-duration", "DTSTART:20261021T090000Z", "DURATION:abc"), + Event("missing-start", "DTEND:20261021T090000Z")) + + "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:unclosed"); + Assert.Equal(4, rows.Count); + Assert.Equal("tomorrow", Cell(rows, 1, "Start")); + Assert.Equal("tomorrow", Cell(rows, 1, "End")); + Assert.Equal("EXDATE:abc", Cell(rows, 1, "EXDATE")); + Assert.Equal("", Cell(rows, 2, "End")); + Assert.Equal("", Cell(rows, 3, "Start")); + Assert.Equal("2026-10-21T09:00:00Z", Cell(rows, 3, "End")); + } + + [Fact] + public async Task Ambiguous_new_york_time_uses_standard_offset() + { + var rows = await ReadCalendar(Calendar(Event("dst", "DTSTART;TZID=America/New_York:20261101T013000"))); + Assert.Equal("2026-11-01T01:30:00-05:00", Cell(rows, 1, "Start")); + } + + [Fact] + public async Task Empty_calendar_and_invalid_table_fail_without_output() + { + var ics = Path.Combine(fx.NewFolder(), "empty.ics"); + await File.WriteAllTextAsync(ics, Calendar("BEGIN:VJOURNAL", "END:VJOURNAL"), TestContext.Current.CancellationToken); + var csv = Path.Combine(fx.NewFolder(), "empty.csv"); + await File.WriteAllTextAsync(csv, "Title,Start\r\nA,\r\nB,10/21/2026\r\n", TestContext.Current.CancellationToken); + foreach (var (path, target) in new[] { (ics, "csv"), (csv, "ics") }) + { + var job = await fx.ConvertAsync([path], new Preset { TargetFormat = target }); + Assert.Equal(JobState.Failed, job.State); + Assert.Equal("error.conversion_failed", job.Files.Single().ErrorKey); + Assert.Contains("no iCalendar events", job.Files.Single().ErrorDetail); + Assert.Empty(job.Outputs); + Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(path)!, "*." + target)); + } + var missing = Path.Combine(fx.NewFolder(), "unknown.csv"); + await File.WriteAllTextAsync(missing, "Unknown,Other\r\nA,B\r\n", TestContext.Current.CancellationToken); + Assert.Equal(JobState.Failed, (await fx.ConvertAsync([missing], new Preset { TargetFormat = "ics" })).State); + } + + [Fact] + public async Task Json_route_has_two_data_steps_and_uses_csv_type_inference() + { + var planner = fx.Catalog.CreatePlanner(); + var route = planner.Plan("ics", "json"); + Assert.NotNull(route); + Assert.Equal(2, route.Steps.Count); + Assert.All(route.Steps, step => Assert.Equal("data", step.Converter.Id)); + var path = await Convert(Calendar(Event("007", "SUMMARY:123", "LOCATION:true", + "DTSTART;VALUE=DATE:20261021")), "ics", "json"); + using var document = JsonDocument.Parse(await File.ReadAllTextAsync(path, TestContext.Current.CancellationToken)); + var row = document.RootElement.EnumerateArray().Single(); + Assert.Equal(ICalendar.Columns, row.EnumerateObject().Select(p => p.Name).ToArray()); + Assert.True(row.GetProperty("All day").GetBoolean()); + Assert.True(row.GetProperty("Location").GetBoolean()); + Assert.Equal(123, row.GetProperty("Title").GetInt32()); + Assert.Equal("007", row.GetProperty("UID").GetString()); + } + + [Fact] + public async Task Csv_writer_skips_invalid_events_end_and_unknown_columns_and_uses_leftmost_alias() + { + var text = await File.ReadAllTextAsync(await Convert( + Csv(["Title", "Summary", "Start", "End", "Unknown"], + ["A", "B", "2026-10-21", "soon", "secret"], + ["skip", "x", "", "", "secret"], + ["skip2", "y", "10/21/2026", "", "secret"]), + "csv", "ics"), TestContext.Current.CancellationToken); + Assert.Equal(1, text.Split("BEGIN:VEVENT").Length - 1); + Assert.Contains("SUMMARY:A", text); + Assert.DoesNotContain("SUMMARY:B", text); + Assert.DoesNotContain("DTEND", text); + Assert.DoesNotContain("secret", text); + } + + [Theory] + [InlineData("Summary", "Begin", "Finish")] + [InlineData("Subject", "시작", "종료")] + [InlineData("제목", "开始", "结束")] + [InlineData("标题", "Begin", "Finish")] + [InlineData("title", "START", "End")] + public async Task Accepted_aliases_and_case_insensitive_headers(string title, string start, string end) + { + var output = await File.ReadAllTextAsync(await Convert(Csv([title, start, end], + ["alias", "2026-10-21", "2026-10-22"]), "csv", "ics"), TestContext.Current.CancellationToken); + Assert.Contains("SUMMARY:alias", output); + Assert.Contains("DTSTART;VALUE=DATE:20261021", output); + Assert.Contains("DTEND;VALUE=DATE:20261022", output); + } + + [Theory] + [InlineData("Location", "장소")] + [InlineData("Location", "地点")] + [InlineData("Description", "Notes")] + [InlineData("Description", "설명")] + [InlineData("Description", "描述")] + public async Task Location_and_description_aliases(string field, string alias) + { + var output = await File.ReadAllTextAsync(await Convert(Csv(["Title", "Start", alias], + ["A", "2026-10-21", "sample"]), "csv", "ics"), TestContext.Current.CancellationToken); + Assert.Contains(field.ToUpperInvariant() + ":sample", output); + } + + [Fact] + public async Task Output_contract_uid_stamp_bom_folding_timezone_and_text_escaping() + { + var row = new[] { "event", "제목, 쉼표; 역슬래시\\ 줄\n둘", "2026-07-21T09:00:00+09:00", + "2026-07-21T10:00:00+09:00", "false", "Asia/Seoul", "", new string('가', 55), + "Kim, A ", "Kim; A ", "FREQ=DAILY;COUNT=2", + "EXDATE;TZID=Asia/Seoul:20260722T090000", "RECURRENCE-ID;TZID=Asia/Seoul:20260720T090000", "" }; + var csv = Csv(ICalendar.Columns, row); + var before = DateTime.UtcNow.AddSeconds(-1); + var first = await Convert(csv, "csv", "ics"); + var bytes = await File.ReadAllBytesAsync(first, TestContext.Current.CancellationToken); + var after = DateTime.UtcNow.AddSeconds(1); + Assert.False(bytes is [0xEF, 0xBB, 0xBF, ..]); + var text = Encoding.UTF8.GetString(bytes); + Assert.StartsWith("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Filee//Filee//EN\r\n", text); + Assert.Contains("BEGIN:VTIMEZONE\r\nTZID:Asia/Seoul\r\n", text); + Assert.Contains("TZOFFSETFROM:+0900\r\nTZOFFSETTO:+0900", text); + Assert.Contains("SUMMARY:제목\\, 쉼표\\; 역슬래시\\\\ 줄\\n둘", text); + Assert.Contains("ORGANIZER;CN=\"Kim, A\":mailto:a@example.com", text); + Assert.Contains("ATTENDEE;CN=\"Kim; A\":mailto:b@example.com", text); + Assert.Contains("EXDATE;TZID=Asia/Seoul:20260722T090000", text); + Assert.Contains("RECURRENCE-ID;TZID=Asia/Seoul:20260720T090000", text); + Assert.DoesNotContain("\n", text.Replace("\r\n", "")); + Assert.All(text.Split("\r\n"), line => Assert.True(Encoding.UTF8.GetByteCount(line) <= 75, line)); + var unfolded = text.Replace("\r\n ", ""); + var stamp = unfolded.Split("\r\n").Single(line => line.StartsWith("DTSTAMP:"))[8..]; + var parsed = DateTime.ParseExact(stamp, "yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); + Assert.InRange(parsed, before.AddSeconds(-1), after); + var uid = unfolded.Split("\r\n").Single(line => line.StartsWith("UID:")); + var second = await File.ReadAllTextAsync(await Convert(csv, "csv", "ics"), TestContext.Current.CancellationToken); + Assert.Contains(uid, second.Replace("\r\n ", "")); + var roundtrip = await ReadCalendar(text); + Assert.Equal("Kim, A ", Cell(roundtrip, 1, "Organizer")); + Assert.Equal("Kim; A ", Cell(roundtrip, 1, "Attendees")); + } + + [Fact] + public async Task Long_combining_sequence_folds_within_seventy_five_octets_and_roundtrips() + { + var title = "e" + new string('\u0301', 40); + var text = await File.ReadAllTextAsync(await Convert( + Csv(["Title", "Start"], [title, "2026-10-21"]), "csv", "ics"), TestContext.Current.CancellationToken); + Assert.All(text.Split("\r\n"), line => Assert.True(Encoding.UTF8.GetByteCount(line) <= 75, line)); + var rows = await ReadCalendar(text); + Assert.Equal(title, Cell(rows, 1, "Title")); + } + + [Fact] + public async Task Calendar_defaults_type_ignores_all_day_and_preserves_due_only_todo() + { + var rows = new[] + { + new[] { "", "blank", "2026-10-21T09:00:00Z", "", "true", "", "", "", "", "", "", "", "", "one" }, + new[] { "meeting", "other", "2026-10-21", "", "", "", "", "", "", "", "", "", "", "two" }, + new[] { "todo", "due", "", "2026-10-21T09:00:00+09:00", "false", "Asia/Seoul", "", "", "", "", "", "", "", "three" }, + }; + var text = await File.ReadAllTextAsync(await Convert(Csv([ICalendar.Columns, .. rows]), "csv", "ics"), TestContext.Current.CancellationToken); + Assert.Equal(2, text.Split("BEGIN:VEVENT").Length - 1); + Assert.Contains("DTSTART:20261021T090000Z", text); + Assert.Contains("DTSTART;VALUE=DATE:20261021", text); + Assert.Contains("BEGIN:VTODO", text); + var back = await ReadCalendar(text); + Assert.Equal("", Cell(back, 3, "Start")); + Assert.Equal("2026-10-21T09:00:00+09:00", Cell(back, 3, "End")); + Assert.Equal("", Cell(back, 3, "Time zone")); + } + + [Fact] + public async Task Canonical_rows_roundtrip_all_fourteen_columns() + { + var rows = new[] + { + new[] { "event", "all", "2026-10-21", "2026-10-22", "true", "", "", "", "", "", "", "", "", "all" }, + new[] { "event", "seoul", "2026-10-21T09:00:00+09:00", "2026-10-21T10:00:00+09:00", "false", "Asia/Seoul", "", "", "", "", "", "", "", "seoul" }, + new[] { "event", "summer", "2026-07-21T09:00:00-04:00", "2026-07-21T10:00:00-04:00", "false", "America/New_York", "", "", "", "", "", "", "", "summer" }, + new[] { "event", "winter", "2026-12-21T09:00:00-05:00", "2026-12-21T10:00:00-05:00", "false", "America/New_York", "", "", "", "", "", "", "", "winter" }, + new[] { "event", "overlap", "2026-11-01T01:30:00-05:00", "", "false", "America/New_York", "", "", "", "", "", "", "", "overlap" }, + new[] { "event", "gap", "2026-03-08T02:30:00-05:00", "", "false", "America/New_York", "", "", "", "", "", "", "", "gap" }, + new[] { "event", "utc", "2026-10-21T00:00:00Z", "", "false", "", "", "", "", "", "", "", "", "utc" }, + new[] { "event", "float", "2026-10-21T09:00:00", "", "false", "", "", "", "", "", "", "", "", "float" }, + new[] { "todo", "task", "", "2026-10-21T09:00:00", "false", "", "", "", "", "", "", "", "", "todo" }, + new[] { "event", "a,b;c\\d\nsecond", "2026-10-21T09:00:00Z", "", "false", "", "", "x,y;z\\q\nagain", + "name ", "one \ntwo ", "FREQ=WEEKLY;COUNT=2", + "EXDATE:20261028T090000Z\nEXDATE:20261104T090000Z", "RECURRENCE-ID:20261021T090000Z", "details" }, + new[] { "event", "uri", "2026-10-21T09:00:00Z", "", "false", "", "", "", "urn:organizer:abc", "", "", "", "", "uri" }, + }; + var original = Csv([ICalendar.Columns, .. rows]); + var output = await Convert(original, "csv", "ics"); + var back = Rows(await File.ReadAllTextAsync(await Convert(await File.ReadAllTextAsync(output, TestContext.Current.CancellationToken), + "ics", "csv"), TestContext.Current.CancellationToken)); + Assert.Equal(rows.Length + 1, back.Count); + for (var i = 0; i < rows.Length; i++) + Assert.Equal(rows[i], back[i + 1]); + } + + [Fact] + public async Task Noncanonical_rows_follow_loss_and_offset_rules() + { + var rows = new[] + { + new[] { "event", "generated", "2026-10-21T09:00:00Z", "", "false", "", "", "", "", "", "", "", "", "" }, + new[] { "event", "bad end", "2026-10-21T09:00:00Z", "tomorrow", "false", "", "", "", "", "", "", "", "", "bad" }, + new[] { "event", "floating end", "2026-10-21T09:00:00+09:00", "2026-10-21T10:00:00", "false", "Asia/Seoul", "", "", "", "", "", "", "", "floating" }, + new[] { "event", "overlap front", "2026-11-01T01:30:00-04:00", "", "false", "America/New_York", "", "", "", "", "", "", "", "front" }, + new[] { "event", "unknown", "2026-10-21T09:00:00+09:00", "", "false", "Unknown/Area", "", "", "", "", "", "", "", "unknown" }, + new[] { "event", "numeric", "2026-10-21T09:00:00+09:00", "", "false", "", "", "", "", "", "", "", "", "numeric" }, + new[] { "event", "invalid exdates", "2026-10-21T09:00:00Z", "", "false", "", "", "", "", "", + "", "EXDATEX:20261028T090000\nEXDATE :20261029T090000", "", "exdates" }, + new[] { "event", "missing start", "", "2026-10-21T09:00:00Z", "false", "", "", "", "", "", "", "", "", "missing" }, + }; + var path = await Convert(Csv([ICalendar.Columns, .. rows]), "csv", "ics"); + var back = await ReadCalendar(await File.ReadAllTextAsync(path, TestContext.Current.CancellationToken)); + Assert.Equal(8, back.Count); + Assert.EndsWith("@filee", Cell(back, 1, "UID")); + Assert.Equal("", Cell(back, 2, "End")); + Assert.Equal("2026-10-21T10:00:00+09:00", Cell(back, 3, "End")); + Assert.Equal("2026-11-01T01:30:00-05:00", Cell(back, 4, "Start")); + Assert.Equal("", Cell(back, 5, "Time zone")); + Assert.Equal("2026-10-21T00:00:00Z", Cell(back, 5, "Start")); + Assert.Equal("2026-10-21T00:00:00Z", Cell(back, 6, "Start")); + Assert.Equal("", Cell(back, 7, "EXDATE")); + } + + [Fact] + public async Task Fixed_zone_uses_earliest_start_or_todo_end_and_reverts_historical_offsets() + { + var row1 = new[] { "todo", "old", "", "1985-01-01T09:00:00+05:30", "false", "Asia/Kathmandu", "", "", "", "", "", "", "", "old" }; + var row2 = new[] { "event", "new", "2026-01-01T09:00:00+05:45", "", "false", "Asia/Kathmandu", "", "", "", "", "", "", "", "new" }; + var solo = await File.ReadAllTextAsync(await Convert(Csv(ICalendar.Columns, row1), + "csv", "ics"), TestContext.Current.CancellationToken); + Assert.Contains("TZOFFSETFROM:+0530\r\nTZOFFSETTO:+0530", solo); + var oldEvent = (string[])row1.Clone(); + oldEvent[0] = "event"; + oldEvent[2] = oldEvent[3]; + var output = await File.ReadAllTextAsync(await Convert(Csv(ICalendar.Columns, oldEvent, row2), + "csv", "ics"), TestContext.Current.CancellationToken); + Assert.Contains("TZOFFSETFROM:+0530\r\nTZOFFSETTO:+0530", output); + var back = await ReadCalendar(output); + Assert.EndsWith("+05:30", Cell(back, 2, "Start")); + } + + [Fact] + public void Timezone_builder_observes_transition_wall_times_and_validates_rules() + { + var utc = new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero); + var newYork = ICalendar.BuildVTimeZone(TimeZoneInfo.FindSystemTimeZoneById("America/New_York"), utc); + Assert.Contains("BEGIN:DAYLIGHT\r\nDTSTART:20260308T020000\r\nTZOFFSETFROM:-0500\r\nTZOFFSETTO:-0400\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU", newYork); + Assert.Contains("BEGIN:STANDARD\r\nDTSTART:20261101T020000\r\nTZOFFSETFROM:-0400\r\nTZOFFSETTO:-0500\r\nRRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU", newYork); + var berlin = ICalendar.BuildVTimeZone(TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"), utc); + Assert.Contains("BEGIN:DAYLIGHT\r\nDTSTART:20260329T020000\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU", berlin); + Assert.Contains("BEGIN:STANDARD\r\nDTSTART:20261025T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU", berlin); + var rules = new[] + { + TimeZoneInfo.AdjustmentRule.CreateAdjustmentRule( + new DateTime(2020, 1, 1), new DateTime(2030, 12, 31), TimeSpan.FromHours(1), + TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, 2, 0, 0), 3, 21), + TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, 3, 0, 0), 9, 21)), + }; + var custom = TimeZoneInfo.CreateCustomTimeZone("Fixed/Date", TimeSpan.FromHours(1), "Fixed/Date", "Standard", "Daylight", rules); + var fixedRule = ICalendar.BuildVTimeZone(custom, utc); + Assert.Contains("BYMONTH=3;BYMONTHDAY=21", fixedRule); + Assert.Contains("BYMONTH=9;BYMONTHDAY=21", fixedRule); + } + + [Fact] + public void Format_registration_and_new_data_donut_include_ics_and_ical() + { + Assert.Equal("ics", FormatRegistry.Detect("x.ics")?.Id); + Assert.Equal("ics", FormatRegistry.Detect("x.ICAL")?.Id); + var profile = BuiltInData.CreateProfiles().Single(p => p.Id == "data"); + Assert.Contains("ics", profile.Extensions); + Assert.Contains("ical", profile.Extensions); + } +} From d8813c8bacb99e8305cccf60ee7ba888c7353e40 Mon Sep 17 00:00:00 2001 From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:19:37 +0900 Subject: [PATCH 06/20] Use Moscow in the historical-offset iCalendar test Windows has no history for Nepal Standard Time (always +05:45), so the 1985 +05:30 case failed there. Moscow (+04:00 in 2013, +03:00 now) has the same history on Windows and in the IANA database. --- tests/Filee.Engines.Tests/ICalendarTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Filee.Engines.Tests/ICalendarTests.cs b/tests/Filee.Engines.Tests/ICalendarTests.cs index cac202dd0..40321b933 100644 --- a/tests/Filee.Engines.Tests/ICalendarTests.cs +++ b/tests/Filee.Engines.Tests/ICalendarTests.cs @@ -414,19 +414,19 @@ public async Task Noncanonical_rows_follow_loss_and_offset_rules() [Fact] public async Task Fixed_zone_uses_earliest_start_or_todo_end_and_reverts_historical_offsets() { - var row1 = new[] { "todo", "old", "", "1985-01-01T09:00:00+05:30", "false", "Asia/Kathmandu", "", "", "", "", "", "", "", "old" }; - var row2 = new[] { "event", "new", "2026-01-01T09:00:00+05:45", "", "false", "Asia/Kathmandu", "", "", "", "", "", "", "", "new" }; + var row1 = new[] { "todo", "old", "", "2013-01-01T09:00:00+04:00", "false", "Europe/Moscow", "", "", "", "", "", "", "", "old" }; + var row2 = new[] { "event", "new", "2026-01-01T09:00:00+03:00", "", "false", "Europe/Moscow", "", "", "", "", "", "", "", "new" }; var solo = await File.ReadAllTextAsync(await Convert(Csv(ICalendar.Columns, row1), "csv", "ics"), TestContext.Current.CancellationToken); - Assert.Contains("TZOFFSETFROM:+0530\r\nTZOFFSETTO:+0530", solo); + Assert.Contains("TZOFFSETFROM:+0400\r\nTZOFFSETTO:+0400", solo); var oldEvent = (string[])row1.Clone(); oldEvent[0] = "event"; oldEvent[2] = oldEvent[3]; var output = await File.ReadAllTextAsync(await Convert(Csv(ICalendar.Columns, oldEvent, row2), "csv", "ics"), TestContext.Current.CancellationToken); - Assert.Contains("TZOFFSETFROM:+0530\r\nTZOFFSETTO:+0530", output); + Assert.Contains("TZOFFSETFROM:+0400\r\nTZOFFSETTO:+0400", output); var back = await ReadCalendar(output); - Assert.EndsWith("+05:30", Cell(back, 2, "Start")); + Assert.EndsWith("+04:00", Cell(back, 2, "Start")); } [Fact] From 476c0aa425af9b489ba68594cbc60f663e7df7a7 Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:07:12 +0530 Subject: [PATCH 07/20] Read .lua scripts and .css style sheets as plain text (#388, #368) Like the scripts and logs 2.0.0 added: the TXT format takes .lua and .css, so they convert to PDF, Markdown, DOCX and the other text targets, read and never run. Settings schema 33 teaches existing text donuts the extensions unless a donut of the user already claims them. Co-Authored-By: Claude Opus 5.5 --- src/Filee.Core/Formats/FormatRegistry.cs | 5 +-- src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 3 ++ tests/Filee.Core.Tests/ScriptTextTests.cs | 33 ++++++++++++++++++++ tests/Filee.Engines.Tests/ScriptTextTests.cs | 27 ++++++++++++++++ 5 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 tests/Filee.Core.Tests/ScriptTextTests.cs create mode 100644 tests/Filee.Engines.Tests/ScriptTextTests.cs diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs index 1ec467361..14bb5bcee 100644 --- a/src/Filee.Core/Formats/FormatRegistry.cs +++ b/src/Filee.Core/Formats/FormatRegistry.cs @@ -134,8 +134,9 @@ public static class FormatRegistry new("sda", "SDA", FormatCategory.Presentation, ["sda", "sdd"], Writable: false), // Plain text / markup - // Text logs and source/proxy scripts are read as plain text, never executed (issues #273, #284, #254). - new("txt", "TXT", FormatCategory.Text, ["txt", "log", "py", "pac", "bat", "command"]), + // Text logs, source/proxy scripts and style sheets are read as plain text, never executed (issues #273, #284, #254, + // #388, #368). + new("txt", "TXT", FormatCategory.Text, ["txt", "log", "py", "pac", "bat", "command", "lua", "css"]), new("md", "Markdown", FormatCategory.Text, ["md", "markdown"]), new("html", "HTML", FormatCategory.Text, ["html", "htm", "xhtml"]), // A web page saved with its pictures in one MIME file (Chrome, Edge, Word); read as the page it holds. diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 4f488dcba..3df95d287 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -112,7 +112,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest public sealed class AppSettings { /// Current schema version. Increase when the format changes and add a migration. - public const int CurrentSchemaVersion = 32; + public const int CurrentSchemaVersion = 33; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 7aff0c842..1dbddd840 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -750,6 +750,9 @@ public static bool ApplyToLibrary(int from, List presets, List Assert.Equal("txt", FormatRegistry.Detect(name)?.Id); + + [Fact] + public void Version_32_text_donuts_learn_them_without_taking_custom_claims() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + foreach (var profile in profiles) + profile.Extensions.RemoveAll(e => e is "lua" or "css"); + profiles.Insert(0, new ToolbarProfile { Id = "my-web", Name = "Web", Extensions = ["css"] }); + + Assert.True(SettingsMigrations.ApplyToLibrary(32, presets, profiles)); + + var text = profiles.Single(p => p.Id == "text").Extensions; + Assert.Contains("lua", text); + Assert.DoesNotContain("css", text); // the user's own donut has it + Assert.False(SettingsMigrations.ApplyToLibrary(32, presets, profiles)); + } +} diff --git a/tests/Filee.Engines.Tests/ScriptTextTests.cs b/tests/Filee.Engines.Tests/ScriptTextTests.cs new file mode 100644 index 000000000..80c651c17 --- /dev/null +++ b/tests/Filee.Engines.Tests/ScriptTextTests.cs @@ -0,0 +1,27 @@ +// Lua scripts and CSS style sheets (issues #388, #368) become a PDF of their text, never run. + +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Engines.Pdf; + +namespace Filee.Engines.Tests; + +public class ScriptTextTests(EngineFixture fx) : IClassFixture +{ + [Theory] + [InlineData("site.css", "body { color: #1d9e75; }")] + [InlineData("init.lua", "local greeting = \"hello\" print(greeting)")] + public async Task The_code_becomes_a_pdf_of_its_text(string name, string code) + { + var available = fx.Converters.Where(c => fx.Catalog.StatusOf(c).IsAvailable).Select(c => c.Id).ToHashSet(); + Assert.SkipUnless(available.Contains("rhwp") || available.Contains("libreoffice"), "neither rhwp nor LibreOffice is available"); + var path = Path.Combine(fx.NewFolder(), name); + await File.WriteAllTextAsync(path, code, TestContext.Current.CancellationToken); + + var job = await fx.ConvertAsync([path], new Preset { TargetFormat = "pdf" }); + + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + var text = PdfDocumentReader.PlainText(PdfDocumentReader.ReadText(Assert.Single(job.Outputs))); + Assert.Contains(code.Split(' ')[1], text); + } +} From e9bf17399c953fe9f537536af9a5187bef3f3b7f Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:11:21 +0530 Subject: [PATCH 08/20] Snap the crop frame to the picture's centre lines while moving it (#369) When the moved frame's centre comes within 8 screen pixels of the picture's vertical or horizontal centre line, it snaps onto it and a dashed accent guide shows that line across the picture until the drag ends. A frame as wide (or tall) as the picture never counts as snapped. Keyboard nudges stay pixel-exact and don't snap. Co-Authored-By: Claude Opus 5.5 --- src/Filee.App/Controls/CropCanvas.cs | 21 ++++++++++++++++++ src/Filee.App/Controls/CropMath.cs | 16 ++++++++++++++ tests/Filee.App.Tests/CropMathTests.cs | 22 +++++++++++++++++-- tests/Filee.App.Tests/CropOverlayTests.cs | 26 +++++++++++++++++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/Filee.App/Controls/CropCanvas.cs b/src/Filee.App/Controls/CropCanvas.cs index 86cc920a8..c51728da5 100644 --- a/src/Filee.App/Controls/CropCanvas.cs +++ b/src/Filee.App/Controls/CropCanvas.cs @@ -103,6 +103,9 @@ public double AspectRatio private CropHandle _hoverHandle = CropHandle.None; private Point _dragStartPoint; private Rect _dragStartCrop; + + // The frame being moved sits on the picture's vertical / horizontal centre line: its guide is drawn. + private bool _snappedX, _snappedY; private const double MinSizePx = 24.0; // ───── Motion: tweens evaluated against the clock at each render, so a lost frame never leaves one half done ───── @@ -409,6 +412,7 @@ public override void Render(DrawingContext context) context.DrawImage(SourceImage, source, imgBounds); DrawGrid(context, crop, _grid.Value(now)); + DrawCentreGuides(context, imgBounds); DrawFrame(context, crop, now); DrawSizeChip(context, crop, imgBounds, bounds); } @@ -460,6 +464,20 @@ private static void DrawGrid(DrawingContext context, Rect crop, double opacity) } } + /// The picture's centre line the moved frame snapped to, across the whole picture. + private void DrawCentreGuides(DrawingContext context, Rect picture) + { + if (_activeHandle != CropHandle.Body || !(_snappedX || _snappedY)) + return; + var pen = new Pen(AccentBrush ?? Brushes.MediumPurple, 1, DashStyle.Dash); + var x = Math.Round(picture.Center.X) + 0.5; + var y = Math.Round(picture.Center.Y) + 0.5; + if (_snappedX) + context.DrawLine(pen, new Point(x, picture.Top), new Point(x, picture.Bottom)); + if (_snappedY) + context.DrawLine(pen, new Point(picture.Left, y), new Point(picture.Right, y)); + } + private void DrawFrame(DrawingContext context, Rect crop, long now) { var accent = AccentBrush ?? Brushes.MediumPurple; @@ -738,6 +756,8 @@ protected override void OnPointerMoved(PointerEventArgs e) var newX = SafeClamp(_dragStartCrop.X + deltaX, imgBounds.Left, Math.Max(imgBounds.Left, imgBounds.Right - _dragStartCrop.Width)); var newY = SafeClamp(_dragStartCrop.Y + deltaY, imgBounds.Top, Math.Max(imgBounds.Top, imgBounds.Bottom - _dragStartCrop.Height)); + (newX, _snappedX) = CropMath.SnapToCentre(newX, _dragStartCrop.Width, imgBounds.Left, imgBounds.Right); + (newY, _snappedY) = CropMath.SnapToCentre(newY, _dragStartCrop.Height, imgBounds.Top, imgBounds.Bottom); SetCropFromScreen(new Rect(newX, newY, _dragStartCrop.Width, _dragStartCrop.Height), imgBounds); return; @@ -897,6 +917,7 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) { _dragStartPoint = pt; _dragStartCrop = crop; + _snappedX = _snappedY = false; e.Pointer.Capture(this); e.Handled = true; PopHandle(_activeHandle); diff --git a/src/Filee.App/Controls/CropMath.cs b/src/Filee.App/Controls/CropMath.cs index f14cb460e..14f060644 100644 --- a/src/Filee.App/Controls/CropMath.cs +++ b/src/Filee.App/Controls/CropMath.cs @@ -60,6 +60,22 @@ public static Rect Nudge(Rect normalized, int dx, int dy, int width, int height) return new Rect((double)x / width, (double)y / height, normalized.Width, normalized.Height); } + /// How close (screen pixels) the frame's centre has to come to the picture's centre line to snap to it. + public const double SnapDistance = 8; + + /// + /// One side of a frame being dragged (issue #369): its start moved so its centre sits on the centre of + /// … when it comes within of it. A frame as + /// wide as the picture has nowhere to go and never counts as snapped. + /// + public static (double Start, bool Snapped) SnapToCentre(double start, double length, double min, double max) + { + var centre = (min + max) / 2; + if (length >= max - min - 0.5 || Math.Abs(start + length / 2 - centre) > SnapDistance) + return (start, false); + return (centre - length / 2, true); + } + /// Space between the frame and the size chip. public const double ChipGap = 10; diff --git a/tests/Filee.App.Tests/CropMathTests.cs b/tests/Filee.App.Tests/CropMathTests.cs index 707b83620..426d2ad0f 100644 --- a/tests/Filee.App.Tests/CropMathTests.cs +++ b/tests/Filee.App.Tests/CropMathTests.cs @@ -1,5 +1,5 @@ -// The crop overlay's arithmetic: ratios and their portrait forms, keyboard nudges, the frame in pixels and where the -// size chip goes. +// The crop overlay's arithmetic: ratios and their portrait forms, keyboard nudges, snapping to the centre, the frame +// in pixels and where the size chip goes. using Avalonia; using Filee.App.Controls; @@ -22,6 +22,24 @@ public void Ratios_and_their_portrait_forms(string mode, bool portrait, double e Assert.Equal(expected, CropMath.Ratio(mode, portrait, 1600, 1000), 6); } + [Theory] + // A 100 px wide frame over a 0…1000 px picture: centred at 500 when its start is 450. + [InlineData(450, 450, true)] // already centred + [InlineData(443, 450, true)] // 7 px off: pulled in + [InlineData(458, 450, true)] // 8 px off: still pulled in + [InlineData(459, 459, false)] // 9 px off: left alone + [InlineData(100, 100, false)] // far away + public void A_moved_frame_snaps_to_the_centre_line(double start, double expected, bool snapped) + { + Assert.Equal((expected, snapped), CropMath.SnapToCentre(start, 100, 0, 1000)); + } + + [Fact] + public void A_frame_as_wide_as_the_picture_never_counts_as_snapped() + { + Assert.Equal((0.0, false), CropMath.SnapToCentre(0, 1000, 0, 1000)); + } + [Fact] public void Only_fixed_ratios_that_arent_square_swap() { diff --git a/tests/Filee.App.Tests/CropOverlayTests.cs b/tests/Filee.App.Tests/CropOverlayTests.cs index 90d47a32c..7afe5eb75 100644 --- a/tests/Filee.App.Tests/CropOverlayTests.cs +++ b/tests/Filee.App.Tests/CropOverlayTests.cs @@ -111,6 +111,32 @@ public void Dragging_shows_the_grid_and_the_size_chip_follows_the_frame() }); } + [AvaloniaFact] + public void A_frame_moved_near_the_middle_snaps_to_the_centre_lines() + { + WithOverlay("en", false, ["harbour.jpg"], (window, vm, _suffix) => + { + var canvas = window.CropCanvas; + canvas.NormalizedCropRect = new Rect(0.1, 0.1, 0.5, 0.5); // centre at 35 %, 35 % + Settle(window); + var image = canvas.CalculateImageBounds(); + var origin = canvas.TranslatePoint(default, window)!.Value; + var start = canvas.GetScreenCropRect(image).Center + (Vector)origin; + // To 3 screen pixels short of the picture's centre on both axes: inside the snap distance. + var target = image.Center + (Vector)origin - new Vector(3, 3); + window.MouseDown(start, MouseButton.Left); + window.MouseMove(start + (target - start) / 2); + window.MouseMove(target); + Pump(); + Save(window, "crop-snap-en.png"); + window.MouseUp(target, MouseButton.Left); + + var frame = canvas.NormalizedCropRect; + Assert.Equal(0.5, frame.X + frame.Width / 2, 3); + Assert.Equal(0.5, frame.Y + frame.Height / 2, 3); + }); + } + [AvaloniaFact] public void Keys_nudge_the_frame_open_the_dial_reset_and_close() { From a54bbfa814f814a313006295775344339aecb4cb Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:21:54 +0530 Subject: [PATCH 09/20] Document .lua / .css as text and crop centre snapping ENGINES.md lists LUA and CSS with the plain-text formats; ARCHITECTURE.md describes the snap to the picture's centre lines in the crop overlay. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 4 +++- docs/ENGINES.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4a7c9e40f..757eaf0b6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -133,7 +133,9 @@ multi-frame pictures refused, the EXIF thumbnail dropped), one history entry for skipped. Every file starts free and whole, its portrait/landscape choice following the picture. - `Controls/CropCanvas` is the stage: the picture inside `ImagePadding`, the part outside the frame frosted (a copy at most 40 px across, drawn large) under a veil of the page colour, the frame in the accent colour with round handles, - a rule-of-thirds grid while dragging and a size chip that follows the frame. The preview is downscaled once by + a rule-of-thirds grid while dragging and a size chip that follows the frame. A frame being moved snaps onto the + picture's vertical or horizontal centre line within `CropMath.SnapDistance` (8 px) and shows that line as a dashed + guide until the drag ends. The preview is downscaled once by `CropService.LoadPreviewAsync`; nothing is decoded again while dragging. Programmatic changes (reset, a ratio, a new picture) ease in with tweens read against the clock, so a lost frame never leaves one half done; dragging and arrow-key nudges follow at once, and `PlayAnimations` (from `Motion.Enabled`) turns all of it off. diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 3ba038b0c..6c6a58049 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -12,7 +12,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | PDFsharp | built in (library) | images → PDF, merge, split, page ranges, **compression** (smaller PDFs, see below) | | 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 → UTF-8 TXT**, preserving source text without running scripts (and → PDF / DOCX through the HWPX writer, rhwp or LibreOffice) | +| Text & Markdown (Markdig) | built in (library) | **Markdown → HTML, TXT**; **TXT, LOG, PY, PAC, BAT, COMMAND, LUA, CSS → UTF-8 TXT**, preserving source text without running scripts (and → PDF / DOCX through the HWPX writer, rhwp or LibreOffice) | | Spreadsheets (ExcelDataReader for XLS) | built in (library) | **XLSX, XLS, ODS, CSV, TSV → XLSX, ODS, CSV, TSV** (one CSV / TSV per sheet) | | Office Open XML | built in | **DOCM / DOTX / DOTM ↔ DOCX, XLSM / XLTX ↔ XLSX, PPTM / POTX / POTM / PPSX ↔ PPTX** (macros removed for macro-free types) | | Fonts | built in | **TTF, OTF, WOFF, WOFF2, EOT ↔ each other**; CFF (PostScript) outlines become TrueType for TTF and EOT | From b3a663a659be32683cc7c19a781a86f0e9ef182c Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:31:10 +0530 Subject: [PATCH 10/20] Address review: safe failed joins, one-file joins, kept originals, early picture limit 1. FfmpegConverter.JoinAsync no longer deletes the output: probing, the picture limit and preparing the slides happen before FFmpeg writes, and a join that fails or is cancelled is undone by the queue's allocator, which deletes only paths that were the job's own. With "Overwrite", a probe failure left the user's existing a_merged.mp4 deleted before. 2. RunJoinedMediaAsync uses a one-file allocator (oneFile: true, like the PDF merge) and discards it on failure or cancel: the name is free again (no "a_merged (2)" on a retry), a subfolder it made goes, and "subfolder only for several files" puts the merged file next to the source. 3. OnlyIfSmaller is its own step before ZIP / next-to-source, so a kept original moves next to its source like a compressed copy. 4. The 300-picture limit (now MediaOptions.MaxSlides) is checked before any picture is converted; the engine keeps it as a backstop for multi-page pictures. Tests for each case, and docs: ARCHITECTURE.md for IMediaJoiner, ENGINES.md for the FFmpeg and PDFsharp parts. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 4 + docs/ENGINES.md | 25 ++++- src/Filee.Core/Conversion/JobQueue.cs | 18 ++- src/Filee.Core/Presets/Preset.cs | 5 + src/Filee.Engines/Media/FfmpegConverter.cs | 103 +++++++++--------- src/Filee.Engines/Media/MediaJoining.cs | 3 - tests/Filee.Core.Tests/MediaToolTests.cs | 73 +++++++++++++ .../Filee.Engines.Tests/MediaToolRealTests.cs | 19 ++++ 8 files changed, 191 insertions(+), 59 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4a7c9e40f..e198a180d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -50,6 +50,10 @@ same name ("Overwrite"). It frees its names and removes an output folder it made if that is left empty. "Merge into one PDF" converts each file to PDF and merges with PDFsharp. "Compress into one archive" packs the dropped files, unconverted, with the archive engine (`IFileCombiner`). + "Merge videos", "Merge audio" and "Video from images" join the files, in the order picked in the merge order + window, into one file with FFmpeg (`IMediaJoiner`); pictures become PNG through the image engines first. Like the + other one-file jobs, the output follows "subfolder only for several files", and a join that fails or is cancelled + is undone by the job's allocator, which deletes only paths that were the job's own. 6. **Report** – progress and results appear in the toast window and in Home → Recent conversions. ## Files dragged from a web browser diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 3ba038b0c..c439ec852 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 | | ICNS | built in | images → ICNS (16–1024 px), ICNS → images | -| PDFsharp | built in (library) | images → PDF, merge, split, page ranges, **compression** (smaller PDFs, see below) | +| PDFsharp | built in (library) | images → PDF, merge, split, page ranges, **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 → UTF-8 TXT**, preserving source text without running scripts (and → PDF / DOCX through the HWPX writer, rhwp or LibreOffice) | @@ -32,7 +32,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | LibreOffice + H2Orestart + Java | **optional download** (~420 MB, 1.3 GB on disk) | older and rare formats only: DOC, XLS, PPT, RTF and OpenDocument → PDF and to each other, output as DOCX/ODT/ODS/ODP; HWP/HWPX → DOCX/ODT; read-only formats (see below) → PDF and their kind's editable formats; FODT, OpenDocument templates (OTT, OTS, OTP) and XLT out; SVG → EMF/WMF/ODG, a PDF page → SVG | | Pandoc | **optional download** (~42 MB, 240 MB on disk) | Markdown ↔ DOCX/ODT/RTF, DOCX/ODT/HTML/RTF → Markdown, HTML ↔ DOCX/ODT, markup languages (reStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter, Textile, man, OPML) ↔ Markdown/HTML/DOCX/ODT and each other (and → RTF, EPUB, TXT), Markdown and markup → PPTX slides | | Ghostscript | **optional download** (~20 MB, 31 MB on disk) | EPS/PS → PDF, PDF → EPS/PS, PostScript-based AI → PDF (images through PDF and PDFium); smaller PDFs when a preset asks for it | -| FFmpeg | **optional download** (~100 MB, 272 MB on disk) | **all video and audio**: video ↔ video (H.264, HEVC, AV1), video → animated GIF / APNG, a still frame or every frame, audio and subtitle extraction, audio ↔ audio (also APE, WavPack, TTA, DSD, DTS, ALAC, …), GIF, APNG and photos → MP4/WEBM/MOV, Live / Motion Photos → their video, a clip of a video → an iPhone Live Photo or Android Motion Photo (experimental), images → HEIC | +| FFmpeg | **optional download** (~100 MB, 272 MB on disk) | **all video and audio**: video ↔ video (H.264, HEVC, AV1), video → animated GIF / APNG, a still frame or every frame, audio and subtitle extraction, audio ↔ audio (also APE, WavPack, TTA, DSD, DTS, ALAC, …), GIF, APNG and photos → MP4/WEBM/MOV, Live / Motion Photos → their video, a clip of a video → an iPhone Live Photo or Android Motion Photo (experimental), images → HEIC, clips or audio files joined into one, pictures → a slideshow MP4, copies without metadata (see "Media tools") | | Calibre | **optional download** (~216 MB, 660 MB on disk) | rare e-book formats: LIT, LRF, CHM, PDB, PML, RB, SNB, TCR, OEB → EPUB; EPUB → MOBI, AZW3, LIT, LRF, PDB, PML, RB, SNB, TCR; e-books → PDF (ranked above the HWPX writer, see below) | DOCX, XLSX, XLS, ODS and PPTX → PDF need neither Microsoft Office nor LibreOffice: they are read in-process, written as HWPX @@ -686,6 +686,27 @@ PDFsharp and Magick.NET; nothing has to be installed. - **Not done**: object streams (PDFsharp writes none), font subsetting or removal of unused glyphs, converting colour to gray, flattening transparency or form fields, recompressing JBIG2/CCITT scans. +## Media tools + +The everyday presets on the back faces of the images, PDF, video and audio donuts. Video and audio need FFmpeg. + +- **Compress image / video / audio** re-encode smaller (quality 75, never above a JPEG's own and PNG at zlib level 9; + H.264 at the Small quality, at most 1080p; AAC at the Small quality). A copy in the same format that isn't smaller is + replaced by the source, with the same notice as Compress PDF (`OutputRule.OnlyIfSmaller`). +- **Extract audio** copies AAC or ALAC into M4A without an encode (`MediaOptions.CopyAudioWhenPossible`); other sound + is encoded to AAC. +- **Merge videos** copies the clips with the concat demuxer only when all share the codec setup (ffprobe's extradata + hash), size, pixel format, frame rate, turn and kind of sound; otherwise every clip is fitted into the first clip's + frame and encoded, a clip without sound silent for its length. **Merge audio** always decodes and joins the sound + into one M4A, so no encoder padding or gap is left between files. Both are in `Media/MediaJoining.cs`. +- **Video from images**: each picture is fitted into 1920 × 1080 as a JPEG, then shown for the preset's seconds with + 1 s crossfades (`xfade`) or cuts. At most `MediaOptions.MaxSlides` (300) pictures; the queue refuses more before any + is converted. A filter graph too long for a command line goes into a `-/filter_complex` file. +- **Remove metadata** keeps the format. Images lose EXIF, XMP, IPTC, comments and the ICC profile, and a JPEG keeps + its own quality. Video and audio are copied without global and per-stream tags, chapters and data tracks; an MKV's + attachments (subtitle fonts) stay, with their file name and type. PDFs lose their document information and every + XMP packet (`PdfSharpConverter.StripMetadata`); pages, links, bookmarks and form fields stay. + ## Office Open XML variants `Office/OoxmlConverter.cs` converts between the variants of a family (Word, Excel, PowerPoint) by copying the diff --git a/src/Filee.Core/Conversion/JobQueue.cs b/src/Filee.Core/Conversion/JobQueue.cs index a476e43d1..21c402385 100644 --- a/src/Filee.Core/Conversion/JobQueue.cs +++ b/src/Filee.Core/Conversion/JobQueue.cs @@ -352,6 +352,13 @@ private async Task RunJoinedMediaAsync(ConversionJob job, RoutePlanner planner, { var slideshow = job.Preset.Media.Slideshow; var target = FormatRegistry.Get(job.Preset.TargetFormat); + // Before any picture is converted: too many fail at once instead of after minutes of PNG steps. + if (slideshow && job.Files.Count(f => !Stopped(f)) > MediaOptions.MaxSlides) + { + foreach (var f in job.Files.Where(f => !Stopped(f))) + FailFile(job, f, "error.too_many_pictures", $"{job.Files.Count} > {MediaOptions.MaxSlides}"); + return; + } var parts = new IReadOnlyList?[job.Files.Count]; await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, @@ -399,7 +406,8 @@ await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), } var first = joined[0]; - var output = CreateFinalAllocator(job, first.SourcePath, 1).Allocate(target.PrimaryExtension); + var allocator = CreateFinalAllocator(job, first.SourcePath, 1, oneFile: true); + var output = allocator.Allocate(target.PrimaryExtension); if (output is null) { foreach (var f in joined) @@ -438,17 +446,20 @@ await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), } catch (OperationCanceledException) { + allocator.Discard(); foreach (var f in joined) f.State = FileState.Cancelled; } catch (ConversionRefusedException ex) { + allocator.Discard(); _log.LogInformation("Joining {Count} file(s) into {Output} was refused: {Reason}", inputs.Count, output, ex.Message); foreach (var f in joined) FailFile(job, f, ex.ErrorKey, ex.Detail); } catch (Exception ex) { + allocator.Discard(); _log.LogWarning(ex, "Joining {Count} file(s) into {Output} failed", inputs.Count, output); foreach (var f in joined) FailFile(job, f, "error.conversion_failed", ex.Message); @@ -713,10 +724,11 @@ private async Task> RunFileAsync( inputs = outputs; } + if (!zipResult && finalOutput && job.Preset.Output.OnlyIfSmaller && inputs.Count == 1 && CopyIfNotSmaller(file.SourcePath, inputs[0])) + file.NoticeKey = "notice.pdf_not_smaller"; + if (zipResult && inputs.Count > 0) inputs = PackZip(job, file, index, inputs, ct); - else if (finalOutput && job.Preset.Output.OnlyIfSmaller && inputs.Count == 1 && CopyIfNotSmaller(file.SourcePath, inputs[0])) - file.NoticeKey = "notice.pdf_not_smaller"; // One output, and nothing else handed out for it (a Markdown file's picture folder goes with it). else if (finalOutput && inputs.Count == 1 && final is { Handed: 1 }) inputs = [NextToSource(job, file, index, inputs[0], final)]; diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs index 3a7a92db1..0b15002fb 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -586,6 +586,11 @@ public sealed class MediaOptions /// public bool Slideshow { get; set; } + /// + /// Most pictures one takes: every picture is an FFmpeg input, held while the video is encoded. + /// + public const int MaxSlides = 300; + /// Shortest and longest time one picture of a is shown, in seconds. public const double MinSlideSeconds = 0.5, MaxSlideSeconds = 60; diff --git a/src/Filee.Engines/Media/FfmpegConverter.cs b/src/Filee.Engines/Media/FfmpegConverter.cs index 6c6b9938d..27d808a3d 100644 --- a/src/Filee.Engines/Media/FfmpegConverter.cs +++ b/src/Filee.Engines/Media/FfmpegConverter.cs @@ -201,6 +201,11 @@ await RunWithFallbackAsync(passes, hardware, Plan, return [output]; } + /// + /// Everything that can fail before FFmpeg writes (probing, the picture limit, preparing the slides) happens first, and + /// nothing here deletes : with "Overwrite" it may be the user's own file. A join that + /// fails or is cancelled is undone by the queue, which deletes only paths that were the job's own. + /// public async Task JoinAsync(IReadOnlyList inputPaths, string outputPath, string format, Preset preset, string workDirectory, IProgress? progress, CancellationToken cancellationToken) { @@ -208,76 +213,72 @@ public async Task JoinAsync(IReadOnlyList inputPaths, string outputPath, var scratch = Path.Combine(workDirectory, "join-" + Guid.NewGuid().ToString("N")[..8]); Directory.CreateDirectory(scratch); var media = preset.Media; - try + + List args; + TimeSpan duration; + double start; + if (media.Slideshow) { - if (media.Slideshow) + var slides = await PrepareSlidesAsync(ffmpeg, inputPaths, scratch, progress, cancellationToken); + var encoder = MediaEncoding.VideoEncoder("mp4", "libx264", media.VideoCodec, EncodersOf(ffmpeg)); + args = MediaJoining.Slideshow(slides, outputPath, media, encoder, scratch); + duration = MediaJoining.SlideshowDuration(slides.Count, media); + start = 0.2; + } + else + { + // Probing takes the first few percent. + var clips = new List<(string Path, MediaInfo Info)>(); + for (var i = 0; i < inputPaths.Count; i++) + { + var path = Path.GetFullPath(inputPaths[i]); + clips.Add((path, await MediaInfo.ProbeAsync(ffprobe, path, cancellationToken))); + progress?.Report(0.05 * (i + 1) / inputPaths.Count); + } + duration = TimeSpan.FromSeconds(clips.Sum(c => c.Info.Duration?.TotalSeconds ?? 0)); + start = 0.05; + if (format == "m4a") + { + if (clips.FirstOrDefault(c => c.Info.Audio is null && c.Info.Video is null) is { Path: { } silent }) + throw new InvalidOperationException($"{Path.GetFileName(silent)} has no sound."); + args = MediaJoining.EncodeAudio(clips, outputPath, media, scratch); + } + else if (MediaJoining.CanCopyVideos([.. clips.Select(c => c.Info)], media)) { - await SlideshowAsync(ffmpeg, inputPaths, outputPath, media, scratch, progress, cancellationToken); + var list = Path.Combine(scratch, "clips.txt"); + await File.WriteAllTextAsync(list, MediaJoining.ConcatList(clips.Select(c => c.Path)), cancellationToken); + args = MediaJoining.CopyVideos(list, outputPath, clips[0].Info); } else { - // Probing takes the first few percent. - var clips = new List<(string Path, MediaInfo Info)>(); - for (var i = 0; i < inputPaths.Count; i++) - { - var path = Path.GetFullPath(inputPaths[i]); - clips.Add((path, await MediaInfo.ProbeAsync(ffprobe, path, cancellationToken))); - progress?.Report(0.05 * (i + 1) / inputPaths.Count); - } - var duration = TimeSpan.FromSeconds(clips.Sum(c => c.Info.Duration?.TotalSeconds ?? 0)); - List args; - if (format == "m4a") - { - if (clips.FirstOrDefault(c => c.Info.Audio is null && c.Info.Video is null) is { Path: { } silent }) - throw new InvalidOperationException($"{Path.GetFileName(silent)} has no sound."); - args = MediaJoining.EncodeAudio(clips, outputPath, media, scratch); - } - else if (MediaJoining.CanCopyVideos([.. clips.Select(c => c.Info)], media)) - { - var list = Path.Combine(scratch, "clips.txt"); - await File.WriteAllTextAsync(list, MediaJoining.ConcatList(clips.Select(c => c.Path)), cancellationToken); - args = MediaJoining.CopyVideos(list, outputPath, clips[0].Info); - } - else - { - var encoder = MediaEncoding.VideoEncoder("mp4", "libx264", media.VideoCodec, EncodersOf(ffmpeg)); - args = MediaJoining.EncodeVideos(clips, outputPath, media, encoder, scratch); - } - await RunJoinPassAsync(ffmpeg, args, duration, progress, 0.05, 1, cancellationToken); + var encoder = MediaEncoding.VideoEncoder("mp4", "libx264", media.VideoCodec, EncodersOf(ffmpeg)); + args = MediaJoining.EncodeVideos(clips, outputPath, media, encoder, scratch); } - if (!File.Exists(outputPath) || new FileInfo(outputPath).Length == 0) - throw new InvalidOperationException("FFmpeg did not write any output."); - } - catch - { - TryDelete(outputPath); - throw; } + + await RunJoinPassAsync(ffmpeg, args, duration, progress, start, 1, cancellationToken); + if (!File.Exists(outputPath) || new FileInfo(outputPath).Length == 0) + throw new InvalidOperationException("FFmpeg did not write any output."); progress?.Report(1); } - /// - /// Pictures → one video: each picture is fitted into the frame as a small JPEG first (a fifth of the progress), - /// then the slides are encoded with their fades. - /// - private async Task SlideshowAsync(string ffmpeg, IReadOnlyList pictures, string output, MediaOptions media, string scratch, + /// Every picture fitted into the slideshow frame as a small JPEG in the work folder (a fifth of the progress). + private static async Task> PrepareSlidesAsync(string ffmpeg, IReadOnlyList pictures, string scratch, IProgress? progress, CancellationToken cancellationToken) { - if (pictures.Count > MediaJoining.MaxSlides) - throw new ConversionRefusedException("error.too_many_pictures", $"{pictures.Count} > {MediaJoining.MaxSlides}"); - var (width, height) = (MediaJoining.SlideshowWidth, MediaJoining.SlideshowHeight); + // The queue checks the number of files; a multi-page picture can still give more slides than that. + if (pictures.Count > MediaOptions.MaxSlides) + throw new ConversionRefusedException("error.too_many_pictures", $"{pictures.Count} > {MediaOptions.MaxSlides}"); var slides = new List(); for (var i = 0; i < pictures.Count; i++) { var slide = Path.Combine(scratch, $"slide-{i + 1:D4}.jpg"); - await RunJoinPassAsync(ffmpeg, MediaJoining.PrepareSlide(Path.GetFullPath(pictures[i]), slide, width, height), null, - null, 0, 0, cancellationToken); + await RunJoinPassAsync(ffmpeg, MediaJoining.PrepareSlide(Path.GetFullPath(pictures[i]), slide, + MediaJoining.SlideshowWidth, MediaJoining.SlideshowHeight), null, null, 0, 0, cancellationToken); slides.Add(slide); progress?.Report(0.2 * (i + 1) / pictures.Count); } - var encoder = MediaEncoding.VideoEncoder("mp4", "libx264", media.VideoCodec, EncodersOf(ffmpeg)); - await RunJoinPassAsync(ffmpeg, MediaJoining.Slideshow(slides, output, media, encoder, scratch), - MediaJoining.SlideshowDuration(slides.Count, media), progress, 0.2, 1, cancellationToken); + return slides; } /// Runs one join pass, mapping FFmpeg's position onto … of the progress. diff --git a/src/Filee.Engines/Media/MediaJoining.cs b/src/Filee.Engines/Media/MediaJoining.cs index 2928c6f1a..fd174c21c 100644 --- a/src/Filee.Engines/Media/MediaJoining.cs +++ b/src/Filee.Engines/Media/MediaJoining.cs @@ -11,9 +11,6 @@ namespace Filee.Engines.Media; /// Command lines that join files. internal static class MediaJoining { - /// Most pictures one slideshow takes: every picture is an FFmpeg input, held while the video is encoded. - public const int MaxSlides = 300; - /// Frames per second of a slideshow: smooth fades, and what every player plays. public const int SlideshowFps = 30; diff --git a/tests/Filee.Core.Tests/MediaToolTests.cs b/tests/Filee.Core.Tests/MediaToolTests.cs index 324725c11..5f78c6c6c 100644 --- a/tests/Filee.Core.Tests/MediaToolTests.cs +++ b/tests/Filee.Core.Tests/MediaToolTests.cs @@ -220,6 +220,79 @@ public async Task Compressing_keeps_the_original_when_the_copy_would_not_be_smal Assert.Equal("x", File.ReadAllText(Path.Combine(dir.Path, "tiny_compressed.jpg"))); // the original, copied } + [Fact] + public async Task A_failed_join_frees_its_name_and_removes_the_subfolder_it_made() + { + using var dir = new TempDir(); + var a = dir.File("a.mp4"); + var b = dir.File("b.mp4"); + var preset = BuiltIn("merge-videos").Clone(); + preset.Output.Location = OutputLocation.Subfolder; + preset.Output.SubfolderName = "joined"; + var joiner = new FakeJoiner { Throw = true }; + await using var queue = new JobQueue(new ConverterCatalog([]), mediaJoiner: joiner); + + Assert.Equal(JobState.Failed, (await Run(queue, [a, b], preset)).State); + Assert.False(Directory.Exists(Path.Combine(dir.Path, "joined"))); + + joiner.Throw = false; + var again = await Run(queue, [a, b], preset); + Assert.Equal(Path.Combine(dir.Path, "joined", "a_merged.mp4"), again.Outputs.Single()); // not "a_merged (2)" + } + + [Fact] + public async Task A_joined_file_is_one_file_for_subfolder_only_for_several_files() + { + using var dir = new TempDir(); + var preset = BuiltIn("merge-videos").Clone(); + preset.Output.Location = OutputLocation.Subfolder; + preset.Output.SubfolderName = "{name}"; + preset.Output.SubfolderOnlyForSeveralFiles = true; + await using var queue = new JobQueue(new ConverterCatalog([]), mediaJoiner: new FakeJoiner()); + + var job = await Run(queue, [dir.File("a.mp4"), dir.File("b.mp4")], preset); + + Assert.Equal(Path.Combine(dir.Path, "a_merged.mp4"), job.Outputs.Single()); + Assert.False(Directory.Exists(Path.Combine(dir.Path, "a"))); + } + + [Fact] + public async Task Too_many_pictures_fail_before_any_of_them_is_converted() + { + using var dir = new TempDir(); + var photos = Enumerable.Range(1, MediaOptions.MaxSlides + 1).Select(i => dir.File($"p{i:D3}.jpg")).ToList(); + // A conversion that ran would fail as "conversion failed", not as "too many pictures". + var converter = new FakeConverter("magick", new ConversionEdge("jpg", "png")) { Throw = true }; + var joiner = new FakeJoiner(); + await using var queue = new JobQueue(new ConverterCatalog([converter]), mediaJoiner: joiner); + + var job = await Run(queue, photos, BuiltIn("video-from-images")); + + Assert.Equal(JobState.Failed, job.State); + Assert.All(job.Files, f => Assert.Equal("error.too_many_pictures", f.ErrorKey)); + Assert.Empty(joiner.Inputs); + } + + [Fact] + public async Task A_kept_original_goes_next_to_its_source_like_a_compressed_copy() + { + using var dir = new TempDir(); + var tiny = dir.File("tiny.jpg", "x"); + var preset = BuiltIn("compress-image").Clone(); + preset.Output.Location = OutputLocation.Subfolder; + preset.Output.SubfolderName = "{name}"; + preset.Output.SubfolderOnlyForSeveralFiles = true; + await using var queue = new JobQueue(new ConverterCatalog([new FakeConverter("magick", new ConversionEdge("jpg", "jpg"))])); + + var job = await Run(queue, [tiny], preset); + + var output = Path.Combine(dir.Path, "tiny_compressed.jpg"); + Assert.Equal(output, job.Outputs.Single()); + Assert.Equal("x", File.ReadAllText(output)); // the original, copied + Assert.Equal("notice.pdf_not_smaller", job.Files[0].NoticeKey); + Assert.False(Directory.Exists(Path.Combine(dir.Path, "tiny"))); + } + [Fact] public async Task A_failed_join_fails_every_file_and_leaves_no_output() { diff --git a/tests/Filee.Engines.Tests/MediaToolRealTests.cs b/tests/Filee.Engines.Tests/MediaToolRealTests.cs index f4667b67b..dec78b719 100644 --- a/tests/Filee.Engines.Tests/MediaToolRealTests.cs +++ b/tests/Filee.Engines.Tests/MediaToolRealTests.cs @@ -161,6 +161,25 @@ public async Task Clips_of_one_size_but_other_encoder_settings_still_play_after_ Assert.NotEqual((await PacketHashesAsync(first)).Skip(1).Take(10), (await PacketHashesAsync(output)).Skip(1).Take(10)); } + [Fact] + public async Task A_join_that_fails_before_writing_keeps_the_file_it_was_to_overwrite() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + var first = await MakeClipAsync(folder, "a.mp4", 1, extra: ["-c:v", "libx264", "-c:a", "aac"]); + var broken = Path.Combine(folder, "b.mp4"); + await File.WriteAllTextAsync(broken, "not a video", TestContext.Current.CancellationToken); + var existing = Path.Combine(folder, "a_merged.mp4"); + await File.WriteAllTextAsync(existing, "the user's own file", TestContext.Current.CancellationToken); + var preset = BuiltIn("merge-videos").Clone(); + preset.Output.Conflict = ConflictPolicy.Overwrite; + + var job = await fx.ConvertAsync([first, broken], preset); + + Assert.Equal(JobState.Failed, job.State); // FFmpeg can't read b.mp4: the join stops while probing + Assert.Equal("the user's own file", await File.ReadAllTextAsync(existing, TestContext.Current.CancellationToken)); + } + [Fact] public async Task Clips_of_other_sizes_and_without_sound_are_fitted_into_the_first_ones_frame() { From 1c064631c41f0d5fb1b000ac77d63a2c60288966 Mon Sep 17 00:00:00 2001 From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:48:30 +0900 Subject: [PATCH 11/20] Keep CSV cells from adding their own iCalendar lines A CSV cell could break out of its property when converting CSV to ICS: a line break in UID or RRULE, or extra lines in the EXDATE and Recurrence ID columns, became content lines of their own (an extra VEVENT, an unclosed VALARM). - UID, RRULE and Time zone with CR or LF are dropped (UID falls back to the generated one); a time zone with ':', ';' or '"' is dropped too. - EXDATE and Recurrence ID columns keep only EXDATE / RECURRENCE-ID lines. - Organizer and attendee cells lose CR/LF, and quotes are removed from the CN display name. Filee's own ICS -> CSV output writes those three cells on one line, so round trips are unchanged. --- src/Filee.Engines/Data/ICalendar.cs | 31 ++--- tests/Filee.Engines.Tests/ICalendarTests.cs | 118 ++++++++++++++++++++ 2 files changed, 136 insertions(+), 13 deletions(-) diff --git a/src/Filee.Engines/Data/ICalendar.cs b/src/Filee.Engines/Data/ICalendar.cs index 9bdebe857..55b984857 100644 --- a/src/Filee.Engines/Data/ICalendar.cs +++ b/src/Filee.Engines/Data/ICalendar.cs @@ -418,6 +418,7 @@ private static string DateLine(string name, string text, string zone, TimeZoneIn private static string PersonLine(string name, string cell) { + cell = cell.Replace("\r", "").Replace("\n", ""); var marker = cell.LastIndexOf(" <", StringComparison.Ordinal); var display = marker >= 0 && cell.EndsWith('>') ? cell[..marker] : ""; var address = display.Length > 0 ? cell[(marker + 2)..^1] : cell; @@ -425,6 +426,7 @@ private static string PersonLine(string name, string cell) address = "mailto:" + address; if (display.Length == 0) return name + ":" + address; + display = display.Replace("\"", ""); if (display.IndexOfAny([';', ':', ',']) >= 0) display = "\"" + display + "\""; return $"{name};CN={display}:{address}"; @@ -440,18 +442,19 @@ public static string Write(List components) foreach (var component in components) { var cells = component.Cells!; - if (cells[5].Length == 0) + var zoneId = cells[5].IndexOfAny(['\r', '\n', ':', ';', '"']) < 0 ? cells[5] : ""; + if (zoneId.Length == 0) continue; try { - var zone = TimeZoneInfo.FindSystemTimeZoneById(cells[5]); + var zone = TimeZoneInfo.FindSystemTimeZoneById(zoneId); var parsedStart = TryCsvDate(cells[2], out var start, out var startDate); var hasStart = cells[2].Length > 0 && parsedStart && !startDate; var candidate = hasStart ? start : TryCsvDate(cells[3], out var end, out var endDate) && !endDate ? end : (DateTimeOffset?)null; - if (candidate is { } instant && (!zones.TryGetValue(cells[5], out var previous) + if (candidate is { } instant && (!zones.TryGetValue(zoneId, out var previous) || hasStart && !previous.HasStart || hasStart == previous.HasStart && instant < previous.Instant)) - zones[cells[5]] = (zone, instant, hasStart); + zones[zoneId] = (zone, instant, hasStart); } catch (TimeZoneNotFoundException) { } catch (InvalidTimeZoneException) { } @@ -463,21 +466,22 @@ public static string Write(List components) foreach (var component in components) { var cells = component.Cells!; + var zoneId = cells[5].IndexOfAny(['\r', '\n', ':', ';', '"']) < 0 ? cells[5] : ""; TimeZoneInfo? zone = null; - if (zones.TryGetValue(cells[5], out var definition)) + if (zones.TryGetValue(zoneId, out var definition)) zone = definition.Zone; Line(output, "BEGIN:" + component.Type); - var uid = cells[13].Length == 0 + var uid = cells[13].IndexOfAny(['\r', '\n']) >= 0 || cells[13].Length == 0 ? Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\0', cells))))[..16] + "@filee" : cells[13]; Line(output, "UID:" + uid); Line(output, "DTSTAMP:" + DateTime.UtcNow.ToString("yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture)); if (cells[1].Length > 0) Line(output, "SUMMARY:" + Escape(cells[1])); - var start = DateLine("DTSTART", cells[2], cells[5], zone); + var start = DateLine("DTSTART", cells[2], zoneId, zone); if (start.Length > 0) Line(output, start); - var end = DateLine(component.Type == "VTODO" ? "DUE" : "DTEND", cells[3], cells[5], zone); + var end = DateLine(component.Type == "VTODO" ? "DUE" : "DTEND", cells[3], zoneId, zone); if (end.Length > 0) Line(output, end); if (cells[6].Length > 0) @@ -488,12 +492,13 @@ public static string Write(List components) Line(output, PersonLine("ORGANIZER", cells[8])); foreach (var attendee in cells[9].Split('\n', StringSplitOptions.RemoveEmptyEntries)) Line(output, PersonLine("ATTENDEE", attendee)); - if (cells[10].Length > 0) + if (cells[10].Length > 0 && cells[10].IndexOfAny(['\r', '\n']) < 0) Line(output, "RRULE:" + cells[10]); - foreach (var line in cells[11].Split('\n', StringSplitOptions.RemoveEmptyEntries)) - Line(output, line); - foreach (var line in cells[12].Split('\n', StringSplitOptions.RemoveEmptyEntries)) - Line(output, line); + foreach (var (index, name) in new[] { (11, "EXDATE"), (12, "RECURRENCE-ID") }) + foreach (var line in cells[index].Split(["\r\n", "\n", "\r"], StringSplitOptions.RemoveEmptyEntries)) + if (line.StartsWith(name + ":", StringComparison.OrdinalIgnoreCase) + || line.StartsWith(name + ";", StringComparison.OrdinalIgnoreCase)) + Line(output, line); Line(output, "END:" + component.Type); } Line(output, "END:VCALENDAR"); diff --git a/tests/Filee.Engines.Tests/ICalendarTests.cs b/tests/Filee.Engines.Tests/ICalendarTests.cs index 40321b933..dc6a6e188 100644 --- a/tests/Filee.Engines.Tests/ICalendarTests.cs +++ b/tests/Filee.Engines.Tests/ICalendarTests.cs @@ -324,6 +324,124 @@ public async Task Output_contract_uid_stamp_bom_folding_timezone_and_text_escapi Assert.Equal("Kim; A ", Cell(roundtrip, 1, "Attendees")); } + [Fact] + public async Task Injected_rrule_and_exdate_cannot_create_events_or_alarms() + { + var row = Enumerable.Repeat("", ICalendar.Columns.Length).ToArray(); + row[1] = "Safe"; + row[2] = "2026-10-22T09:00:00Z"; + row[10] = "FREQ=DAILY\nBEGIN:VALARM"; + row[11] = "EXDATE:20261022T090000Z\r\nEND:VEVENT\nBEGIN:VEVENT\rSUMMARY:Injected" + + "\nexdate;VALUE=DATE:20261023\rEXDATEX:20261024T090000Z"; + var second = (string[])row.Clone(); + second[1] = "Second"; + second[10] = "FREQ=WEEKLY\rX-INJECT:1"; + second[11] = ""; + var text = (await File.ReadAllTextAsync(await Convert(Csv(ICalendar.Columns, row, second), + "csv", "ics"), TestContext.Current.CancellationToken)).Replace("\r\n ", ""); + var lines = text.Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(2, lines.Count(line => line == "BEGIN:VEVENT")); + Assert.DoesNotContain("VALARM", text); + Assert.DoesNotContain("Injected", text); + Assert.DoesNotContain("RRULE:", text); + Assert.Contains("EXDATE:20261022T090000Z", lines); + Assert.Contains("exdate;VALUE=DATE:20261023", lines); + Assert.DoesNotContain("EXDATEX:20261024T090000Z", lines); + var stack = new Stack(); + foreach (var line in lines) + { + if (line.StartsWith("BEGIN:", StringComparison.Ordinal)) + stack.Push(line[6..]); + if (line.StartsWith("END:", StringComparison.Ordinal)) + Assert.Equal(stack.Pop(), line[4..]); + } + Assert.Empty(stack); + var names = new HashSet(StringComparer.Ordinal) + { + "BEGIN", "END", "VERSION", "PRODID", "UID", "DTSTAMP", "SUMMARY", + "DTSTART", "DTEND", "DUE", "LOCATION", "DESCRIPTION", "ORGANIZER", + "ATTENDEE", "RRULE", "EXDATE", "RECURRENCE-ID", "TZID", + "TZOFFSETFROM", "TZOFFSETTO", "TZNAME", "RDATE", + }; + foreach (var line in lines) + { + Assert.Matches(@"^[A-Za-z-]+(?:;[^:\r\n]+)?:[^\r\n]*$", line); + Assert.Contains(line.Split([';', ':'], 2)[0].ToUpperInvariant(), names); + } + } + + [Theory] + [InlineData("bad\nBEGIN:VEVENT")] + [InlineData("bad\rEND:VEVENT")] + public async Task Uid_with_line_break_uses_generated_uid(string uid) + { + var text = (await File.ReadAllTextAsync(await Convert(Csv(["Title", "Start", "UID"], + ["Safe", "2026-10-21", uid]), "csv", "ics"), TestContext.Current.CancellationToken)) + .Replace("\r\n ", ""); + Assert.Matches(@"(?m)^UID:[0-9a-f]{16}@filee\r?$", text); + Assert.Contains("SUMMARY:Safe\r\nDTSTART;VALUE=DATE:20261021", text); + Assert.Equal(1, text.Split("BEGIN:VEVENT").Length - 1); + } + + [Theory] + [InlineData("Asia/Seoul\nX-INJECT:1")] + [InlineData("Asia/Seoul\rX-INJECT:1")] + [InlineData("Asia/Seoul:X")] + [InlineData("Asia/Seoul;X")] + [InlineData("Asia/Seoul\"X")] + public async Task Invalid_timezone_cell_is_discarded_for_dates_and_definitions(string timezone) + { + var text = await File.ReadAllTextAsync(await Convert(Csv(["Title", "Start", "Time zone"], + ["Safe", "2026-10-21T09:00:00+09:00", timezone]), "csv", "ics"), + TestContext.Current.CancellationToken); + Assert.DoesNotContain("TZID", text); + Assert.DoesNotContain("VTIMEZONE", text); + Assert.Contains("SUMMARY:Safe\r\nDTSTART:20261021T000000Z", text); + Assert.Equal(1, text.Split("BEGIN:VEVENT").Length - 1); + } + + [Fact] + public async Task Invalid_timezone_cannot_borrow_another_events_valid_zone() + { + var text = (await File.ReadAllTextAsync(await Convert(Csv( + ["Title", "Start", "Time zone"], + ["Valid", "2026-10-21T09:00:00+09:00", "Asia/Seoul"], + ["Invalid", "2026-10-21T09:00:00+09:00", "Asia/Seoul;X"]), + "csv", "ics"), TestContext.Current.CancellationToken)).Replace("\r\n ", ""); + Assert.Contains("BEGIN:VTIMEZONE\r\nTZID:Asia/Seoul", text); + Assert.Contains("SUMMARY:Valid\r\nDTSTART;TZID=Asia/Seoul:20261021T090000", text); + Assert.Contains("SUMMARY:Invalid\r\nDTSTART:20261021T000000Z", text); + } + + [Fact] + public async Task Recurrence_id_accepts_only_its_own_content_lines() + { + var text = (await File.ReadAllTextAsync(await Convert(Csv(["Title", "Start", "Recurrence ID"], + ["Safe", "2026-10-21", "RECURRENCE-ID;TZID=Asia/Seoul:20261021T090000\nX-INJECT:1" + + "\rrecurrence-id:20261022T090000Z\r\nRECURRENCE-IDX:20261023T090000Z"]), + "csv", "ics"), TestContext.Current.CancellationToken)).Replace("\r\n ", ""); + Assert.Contains("RECURRENCE-ID;TZID=Asia/Seoul:20261021T090000\r\n", text); + Assert.Contains("recurrence-id:20261022T090000Z\r\n", text); + Assert.DoesNotContain("X-INJECT", text); + Assert.DoesNotContain("RECURRENCE-IDX", text); + } + + [Fact] + public async Task People_remove_quotes_and_line_breaks_without_new_content_lines() + { + var text = (await File.ReadAllTextAsync(await Convert(Csv( + ["Title", "Start", "Organizer", "Attendees"], + ["Safe", "2026-10-21", "Lee\r\nX-INJECT:1, B ", + "Kim \"A\", B \r\nPark\rInjected, C "]), + "csv", "ics"), TestContext.Current.CancellationToken)).Replace("\r\n ", ""); + Assert.Contains("ORGANIZER;CN=\"LeeX-INJECT:1, B\":mailto:o@example.com\r\n", text); + Assert.Contains("ATTENDEE;CN=\"Kim A, B\":mailto:a@example.com\r\n", text); + Assert.Contains("ATTENDEE;CN=\"ParkInjected, C\":mailto:b@example.com\r\n", text); + Assert.DoesNotContain("\r\nX-INJECT:", text); + Assert.DoesNotContain("\r\nInjected,", text); + Assert.Equal(2, text.Split("ATTENDEE;").Length - 1); + } + [Fact] public async Task Long_combining_sequence_folds_within_seventy_five_octets_and_roundtrips() { From eda4b53ecf0a5687117ec36814ca5529370e0bfd Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:27:41 +0530 Subject: [PATCH 12/20] Repeat an Excel sheet's print title rows on every PDF page (#407) XLSX keeps its rows to repeat at the top (the _xlnm.Print_Titles name) and the HWPX tables start every page with them, as frozen rows did. Rows above the titles come once, before them. --- docs/ENGINES.md | 5 ++- src/Filee.Engines/Hwp/Hwpx/SheetDocument.cs | 19 ++++++---- src/Filee.Engines/Office/Sheets/Workbook.cs | 6 +++ src/Filee.Engines/Office/Sheets/XlsxReader.cs | 35 +++++++++++++++++- tests/Filee.Engines.Tests/OfficeBuilders.cs | 5 ++- tests/Filee.Engines.Tests/OfficeTests.cs | 37 +++++++++++++++++++ 6 files changed, 96 insertions(+), 11 deletions(-) diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 3ba038b0c..482c302e2 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -311,7 +311,8 @@ Readers turn the source into a small document model (`Hwp/Hwpx/HwpxModel.cs`) an - Word's document grid ("lines"), which 한글 does not have, as an "at least" line spacing. - **XLSX, XLS, ODS, CSV and TSV** (`Office/Sheets`, see [Spreadsheets](#spreadsheets)): every visible sheet becomes a section with one table. Cells show what Excel shows (number formats via ExcelNumberFormat, dates, percentages, cached formula results), with fonts, fills, - borders, alignment and merged cells; frozen top rows repeat on every page. The page is A4 with narrow margins, + borders, alignment and merged cells; an XLSX's rows to repeat at the top (Print Titles, #407), else frozen top + rows, repeat on every page, and rows above the print titles come once before them. The page is A4 with narrow margins, landscape when the columns are much wider than portrait, and the table is scaled to the page width like Excel's "Fit all columns on one page". CSV is read with its delimiter (comma, semicolon, tab) and encoding (UTF-8 / UTF-16 with BOM, else CP949 as saved by Korean Excel). Not converted: charts, pictures, conditional @@ -384,7 +385,7 @@ the text the spreadsheet shows, the value behind it and its Excel number format. | Format | Read | Written | |---|---|---| -| XLSX | own reader: values, number formats, fonts, fills, borders, alignment, merges, widths, heights, hidden rows / columns / sheets, frozen rows | every sheet, numbers and dates as numbers with their formats, booleans, styles, merges, widths, heights, hidden rows / columns, frozen rows | +| XLSX | own reader: values, number formats, fonts, fills, borders, alignment, merges, widths, heights, hidden rows / columns / sheets, frozen rows, print titles (rows) | every sheet, numbers and dates as numbers with their formats, booleans, styles, merges, widths, heights, hidden rows / columns, frozen rows | | XLS (Excel 97–2003, also Excel 5/95) | ExcelDataReader (MIT): values, number formats, merges, horizontal / vertical alignment; widths, heights and hidden rows / columns from the BIFF records (`XlsLayout.cs`) | no (LibreOffice) | | ODS | own reader (`content.xml`, `styles.xml`, `settings.xml`): repeated rows / columns (the empty repeats up to the end of the sheet are skipped), spans, every value type, the text Calc shows (`text:p`), data styles as Excel codes, cell styles, widths, heights, hidden rows / columns / sheets, frozen and header rows | every sheet, typed cells (float, percentage, currency, date, time, boolean) with data styles and the shown text, styles, merges, widths, heights, hidden rows / columns, frozen rows | | CSV / TSV | delimiter (CSV: comma, semicolon or tab; TSV: tab) and encoding (UTF-8 / UTF-16 with BOM, else CP949) | UTF-8 with BOM, CRLF, one file per sheet | diff --git a/src/Filee.Engines/Hwp/Hwpx/SheetDocument.cs b/src/Filee.Engines/Hwp/Hwpx/SheetDocument.cs index 179ee136b..8f437a7d8 100644 --- a/src/Filee.Engines/Hwp/Hwpx/SheetDocument.cs +++ b/src/Filee.Engines/Hwp/Hwpx/SheetDocument.cs @@ -3,7 +3,7 @@ // // Layout choices, since a sheet has no page of its own: A4 with narrow margins, landscape when the columns are much // wider than a portrait page, columns (and text) scaled down to fit the width like "Fit all columns on one page", -// light grey grid lines where cells have no border, frozen top rows repeated on every page. +// light grey grid lines where cells have no border, the rows to repeat at the top (or frozen top rows) on every page. using Filee.Engines.Office.Sheets; @@ -66,10 +66,15 @@ private static HSection Section(Worksheet sheet, bool withTitle) section.Blocks.Add(title); } - // Page breaks set in Excel start a new table on a new page; frozen header rows repeat at its top. - var headerRows = sheet.FrozenRows is > 0 and <= 5 ? sheet.FrozenRows : 0; - var header = rows.Where(r => r < headerRows).ToList(); - var body = rows.Where(r => r >= headerRows).ToList(); + // Page breaks set in Excel start a new table on a new page; the rows to repeat at the top (Print Titles, else + // frozen header rows) repeat at its top. Rows above print titles (a document title) come once, before them. + var titles = sheet.TitleRows ?? (sheet.FrozenRows is > 0 and <= 5 ? (0, sheet.FrozenRows - 1) : null); + var headerRows = titles is { } t ? rows.Where(r => r >= t.First && r <= t.Last).ToHashSet() : []; + var lead = titles is { } above ? rows.Where(r => r < above.First).ToList() : []; + var header = rows.Where(headerRows.Contains).ToList(); + var body = rows.Where(r => !headerRows.Contains(r) && (titles is not { } u || r > u.Last)).ToList(); + if (lead.Count > 0) + section.Blocks.Add(Table(sheet, lead, columns, [], fontScale, widths.Select(w => Math.Max(300, (int)(w * scale))).ToArray())); var pages = new List> { new(header) }; var previous = -1; foreach (var r in body) @@ -99,7 +104,7 @@ private static HSection Section(Worksheet sheet, bool withTitle) } /// One table of the given rows (one printed page or more), merged areas cut at its first and last row. - private static HTable Table(Worksheet sheet, List rows, List columns, int headerRows, double fontScale, int[] widths) + private static HTable Table(Worksheet sheet, List rows, List columns, HashSet headerRows, double fontScale, int[] widths) { var table = new HTable { @@ -130,7 +135,7 @@ private static HTable Table(Worksheet sheet, List rows, List columns, { var row = new HRow { - Header = r < headerRows, + Header = headerRows.Contains(r), Height = sheet.RowHeights.TryGetValue(r, out var points) ? (int)(points * 100 * fontScale) : null, }; foreach (var c in columns) diff --git a/src/Filee.Engines/Office/Sheets/Workbook.cs b/src/Filee.Engines/Office/Sheets/Workbook.cs index b00d0689e..6a579d048 100644 --- a/src/Filee.Engines/Office/Sheets/Workbook.cs +++ b/src/Filee.Engines/Office/Sheets/Workbook.cs @@ -34,6 +34,12 @@ internal sealed class Worksheet(string name) /// Rows frozen at the top (usually headers): repeated on every printed page. public int FrozenRows { get; set; } + /// + /// 0-based rows to repeat at the top of every printed page (Page Layout → Print Titles), or null. They win over + /// and need not start at the top. + /// + public (int First, int Last)? TitleRows { get; set; } + /// 0-based rows that start a new printed page (page breaks set in Excel: Page Layout → Breaks). public SortedSet RowBreaks { get; } = []; diff --git a/src/Filee.Engines/Office/Sheets/XlsxReader.cs b/src/Filee.Engines/Office/Sheets/XlsxReader.cs index 713ae38d6..cd6631477 100644 --- a/src/Filee.Engines/Office/Sheets/XlsxReader.cs +++ b/src/Filee.Engines/Office/Sheets/XlsxReader.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Text; +using System.Text.RegularExpressions; using System.Xml.Linq; using Filee.Engines.Office.Ooxml; @@ -46,19 +47,51 @@ private Workbook ReadWorkbook() if (_package.TargetOfType(workbookPath, "/styles") is { } styles) ReadStyles(_package.Part(styles), colors); + // Print titles are a defined name per sheet, by the sheet's position (hidden sheets count). + var titles = new Dictionary(); + foreach (var name in workbook.Element(S + "definedNames")?.Elements(S + "definedName") ?? []) + { + if ((string?)name.Attribute("name") == "_xlnm.Print_Titles" && int.TryParse((string?)name.Attribute("localSheetId"), out var id) + && PrintTitleRows(name.Value) is { } rows) + titles[id] = rows; + } + var result = new Workbook(); + var index = -1; foreach (var sheet in workbook.Element(S + "sheets")?.Elements(S + "sheet") ?? []) { + index++; if ((string?)sheet.Attribute("state") is "hidden" or "veryHidden") continue; var target = _package.Target(workbookPath, (string?)sheet.Attribute(R + "id")); if (target is null || _package.Part(target)?.Root is not { } xml || xml.Name != S + "worksheet") continue; // chart sheets and dialog sheets have no cells - result.Sheets.Add(ReadSheet((string?)sheet.Attribute("name") ?? "Sheet", xml)); + var read = ReadSheet((string?)sheet.Attribute("name") ?? "Sheet", xml); + if (titles.TryGetValue(index, out var titleRows)) + read.TitleRows = titleRows; + result.Sheets.Add(read); } return result; } + /// + /// The 0-based rows of a print titles reference: 'Bill of quantities'!$3:$4, also next to repeated columns + /// (Sheet1!$A:$B,Sheet1!$1:$2); null when it repeats columns only. + /// + internal static (int First, int Last)? PrintTitleRows(string reference) + { + foreach (var part in reference.Split(',')) + { + var match = PrintTitleRowsPattern.Match(part[(part.LastIndexOf('!') + 1)..].Trim()); + if (match.Success && int.TryParse(match.Groups[1].Value, out var first) && int.TryParse(match.Groups[2].Value, out var last) + && first >= 1 && last >= first) + return (first - 1, last - 1); + } + return null; + } + + private static readonly Regex PrintTitleRowsPattern = new(@"^\$?(\d+):\$?(\d+)$", RegexOptions.CultureInvariant); + private void ReadSharedStrings(XDocument? xml) { foreach (var item in xml?.Root?.Elements(S + "si") ?? []) diff --git a/tests/Filee.Engines.Tests/OfficeBuilders.cs b/tests/Filee.Engines.Tests/OfficeBuilders.cs index 60fcb11a8..fe5d8591f 100644 --- a/tests/Filee.Engines.Tests/OfficeBuilders.cs +++ b/tests/Filee.Engines.Tests/OfficeBuilders.cs @@ -103,6 +103,9 @@ public XlsxBuilder Sheet(string name, string rows, string before = "", string af return this; } + /// A definedNames element for the workbook (print titles, print areas), or empty. + public string DefinedNames { get; set; } = ""; + public string Save(string path) { var parts = new Dictionary @@ -110,7 +113,7 @@ public string Save(string path) ["_rels/.rels"] = OpcWriter.Relationships([("rId1", "officeDocument", "xl/workbook.xml")]), ["xl/workbook.xml"] = $"" + string.Concat(_sheets.Select((s, i) => $"")) + - "", + "" + DefinedNames + "", ["xl/_rels/workbook.xml.rels"] = OpcWriter.Relationships( _sheets.Select((_, i) => ($"rId{i + 1}", "worksheet", $"worksheets/sheet{i + 1}.xml")) .Append(("rIdS", "sharedStrings", "sharedStrings.xml")) diff --git a/tests/Filee.Engines.Tests/OfficeTests.cs b/tests/Filee.Engines.Tests/OfficeTests.cs index 28b7981be..5ff535cb8 100644 --- a/tests/Filee.Engines.Tests/OfficeTests.cs +++ b/tests/Filee.Engines.Tests/OfficeTests.cs @@ -6,6 +6,7 @@ using System.Xml.Linq; using Filee.Core.Conversion; using Filee.Core.Presets; +using Filee.Engines.Hwp.Hwpx; using Filee.Engines.Office.Sheets; using ImageMagick; using static Filee.Engines.Tests.HwpxAssert; @@ -168,6 +169,42 @@ public async Task Xlsx_to_hwpx_makes_a_table_per_sheet() RenderWithRhwp(fx, hwpx, "xlsx-sales"); } + [Fact] + public void Print_titles_name_the_rows_they_repeat() + { + Assert.Equal((2, 3), XlsxReader.PrintTitleRows("'공사 내역서'!$3:$4")); + Assert.Equal((0, 1), XlsxReader.PrintTitleRows("Sheet1!$A:$B,Sheet1!$1:$2")); + Assert.Null(XlsxReader.PrintTitleRows("Sheet1!$A:$B")); // columns only + } + + [Fact] + public async Task Rows_to_repeat_at_the_top_repeat_on_every_page_of_a_pdf_bound_table() + { + // Issue #407: a bill of quantities with a title, then two heading rows set as "Rows to repeat at top" and a + // page break. The title comes once; the headings start every page's table. + var xlsx = new XlsxBuilder(); + xlsx.Sheet("숨김", Row(1, xlsx.Text("A1", "x")), hidden: true); // hidden sheets still count in localSheetId + var rows = Row(1, xlsx.Text("A1", "공사 내역서", Header)) + Row(2, xlsx.Text("A2", "현장: 서울")) + + Row(3, xlsx.Text("A3", "품명", Header), xlsx.Text("B3", "금액", Header)) + + Row(4, xlsx.Text("A4", "규격", Header), xlsx.Text("B4", "원", Header)) + + string.Concat(Enumerable.Range(5, 40).Select(r => Row(r, xlsx.Text($"A{r}", $"자재 {r}"), Number($"B{r}", r * 1000)))); + xlsx.Sheet("내역", rows, after: ""); + xlsx.DefinedNames = "내역!$3:$4"; + var path = xlsx.Save(Path.Combine(fx.NewFolder(), "내역서.xlsx")); + + Assert.Equal((2, 3), Assert.Single(XlsxReader.Read(path).Sheets).TitleRows); + var tables = SheetDocument.Build(XlsxReader.Read(path)).Sections.Single().Blocks.OfType().ToList(); + + Assert.Equal(3, tables.Count); // the title, then one table per printed page + Assert.Equal(2, tables[0].Rows.Count); + Assert.DoesNotContain(tables[0].Rows, r => r.Header); + foreach (var page in tables.Skip(1)) + Assert.Equal([true, true, false], page.Rows.Take(3).Select(r => r.Header)); + Assert.Equal(2 + 20, tables[1].Rows.Count); // rows 5-24, then the break + Assert.Equal(2 + 20, tables[2].Rows.Count); // rows 25-44 + Assert.True(tables[2].PageBreakBefore); + } + // ───────────────────────── Presentations ───────────────────────── private static byte[] Png() From 25a8ca1da7803723df741d637a7774314e60dbf6 Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:32:36 +0530 Subject: [PATCH 13/20] Read HP-GL plot files (.plt, #410) HpglPlot reads HP-GL and HP-GL/2 (also the packed PE polylines printer drivers write, inside PJL / PCL) and draws the pen moves as SVG at real size; the vector engine renders PDF and images from there. Settings schema 33 teaches the vector donut .plt, .hpgl, .hpg and .hgl. --- docs/ENGINES.md | 2 +- src/Filee.Core/Formats/FormatRegistry.cs | 1 + src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 3 + src/Filee.Engines/Vector/HpglPlot.cs | 384 ++++++++++++++++++++ src/Filee.Engines/Vector/VectorConverter.cs | 13 + tests/Filee.Core.Tests/PltFormatTests.cs | 32 ++ tests/Filee.Engines.Tests/HpglPlotTests.cs | 93 +++++ tests/Filee.Engines.Tests/TracingTests.cs | 3 +- 9 files changed, 530 insertions(+), 3 deletions(-) create mode 100644 src/Filee.Engines/Vector/HpglPlot.cs create mode 100644 tests/Filee.Core.Tests/PltFormatTests.cs create mode 100644 tests/Filee.Engines.Tests/HpglPlotTests.cs diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 482c302e2..d10eed97b 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -7,7 +7,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, |---|---|---| | ImageMagick (Magick.NET) | built in (library) | JPG, PNG, WEBP, TIFF, BMP, GIF, ICO, AVIF, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, MNG, QOI, DDS, PCX, CUR, XPM, XBM, WBMP, PAM, PFM, SGI, Sun Raster, MIFF ↔ each other, and **APNG** (Filee's own reader and writer); HEIC (written with FFmpeg), XCF, camera RAW (LibRaw), DICOM (uncompressed ones by Filee's own reader), Photo CD, OpenEXR, Radiance HDR, EMF/WMF (Windows) and the photo of an iPhone LIVP read | | 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 | +| 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) | | ICNS | built in | images → ICNS (16–1024 px), ICNS → images | | PDFsharp | built in (library) | images → PDF, merge, split, page ranges, **compression** (smaller PDFs, see below) | | PDFium | built in (library) | PDF → images (multi-page TIFF/GIF supported) | diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs index 1ec467361..0f7545054 100644 --- a/src/Filee.Core/Formats/FormatRegistry.cs +++ b/src/Filee.Core/Formats/FormatRegistry.cs @@ -66,6 +66,7 @@ public static class FormatRegistry new("wmf", "WMF", FormatCategory.Vector, ["wmf"]), new("cdr", "CDR", FormatCategory.Vector, ["cdr"], Writable: false), new("cgm", "CGM", FormatCategory.Vector, ["cgm"], Writable: false), + new("plt", "PLT", FormatCategory.Vector, ["plt", "hpgl", "hpg", "hgl"], Writable: false), new("vsd", "VSD", FormatCategory.Vector, ["vsd", "vsdx"], Writable: false), new("odg", "ODG", FormatCategory.Vector, ["odg"]), new("fodg", "FODG", FormatCategory.Vector, ["fodg"], Writable: false), diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 4f488dcba..3df95d287 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -112,7 +112,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest public sealed class AppSettings { /// Current schema version. Increase when the format changes and add a migration. - public const int CurrentSchemaVersion = 32; + public const int CurrentSchemaVersion = 33; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 7aff0c842..e1e891850 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -750,6 +750,9 @@ public static bool ApplyToLibrary(int from, List presets, ListPlotter units per millimetre. + private const double UnitsPerMm = 40; + + /// HP-GL/2's default pen colours: 0 white (paper), 1 black, 2 red, 3 green, 4 yellow, 5 blue, 6 magenta, 7 cyan. + private static readonly string[] DefaultColors = ["#FFFFFF", "#000000", "#FF0000", "#00A000", "#E0C000", "#0000FF", "#FF00FF", "#00B0B0"]; + + /// A line or a filled polygon, in plotter units, Y up. + internal sealed record Stroke(string Color, double Width, List<(double X, double Y)> Points, bool Filled); + + /// Reads the drawing of a plot file. Fails with a reason when it holds no HP-GL. + internal static List Read(byte[] data) + { + var plotter = new Plotter(); + foreach (var (name, args) in Commands(data)) + plotter.Run(name, args); + plotter.EndLine(); + if (plotter.Strokes.Count == 0) + throw new InvalidDataException("No HP-GL drawing found: the plot file has no pen moves (it may be PostScript or PCL raster output)."); + return plotter.Strokes; + } + + /// The drawing as SVG at its real size, with a small margin and white paper. + internal static string ToSvg(byte[] data) + { + var strokes = Read(data); + var points = strokes.SelectMany(s => s.Points).ToList(); + var margin = 2 * UnitsPerMm; + double left = points.Min(p => p.X) - margin, right = points.Max(p => p.X) + margin; + double bottom = points.Min(p => p.Y) - margin, top = points.Max(p => p.Y) + margin; + double width = right - left, height = top - bottom; + string N(double v) => v.ToString("0.##", CultureInfo.InvariantCulture); + + var svg = new StringBuilder(); + svg.Append(CultureInfo.InvariantCulture, + $""""""); + svg.Append(CultureInfo.InvariantCulture, $""""""); + foreach (var stroke in strokes) + { + var path = string.Join(' ', stroke.Points.Select(p => $"{N(p.X - left)},{N(top - p.Y)}")); + svg.Append(stroke.Filled + ? $"""""" + : $""""""); + } + svg.Append(""); + return svg.ToString(); + } + + // ───────────────────────── Commands ───────────────────────── + + /// + /// Splits the plot into commands: two letters and their arguments up to ';' or the next command. PE's arguments + /// are its encoded bytes; LB's run to the label terminator (ETX). + /// + internal static IEnumerable<(string Name, string Args)> Commands(byte[] s) + { + var i = StartsWithHpgl(s) ? 0 : EntryPoint(s, 0); + while (i < s.Length) + { + var c = s[i]; + if (c == 27) + { + // An escape sequence leaves HP-GL/2 (PCL, PJL): go on where the plot enters it again. + i = EntryPoint(s, i + 1); + continue; + } + if (c <= 32 || c == ';' || !char.IsAsciiLetterUpper((char)c) && !char.IsAsciiLetterLower((char)c)) + { + i++; + continue; + } + if (i + 1 >= s.Length) + yield break; + var name = Encoding.ASCII.GetString(s, i, 2).ToUpperInvariant(); + var start = i + 2; + var end = start; + if (name == "PE") + while (end < s.Length && s[end] != ';' && s[end] != 27) end++; + else if (name == "LB") + while (end < s.Length && s[end] != 3) end++; + else + while (end < s.Length && s[end] != ';' && s[end] != 27 && !char.IsAsciiLetter((char)s[end])) end++; + yield return (name, Encoding.Latin1.GetString(s, start, end - start)); + i = end < s.Length && s[end] is (byte)';' or 3 ? end + 1 : end; + } + } + + private static bool StartsWithHpgl(byte[] s) + { + var head = Encoding.ASCII.GetString(s, 0, Math.Min(s.Length, 16)).TrimStart(); + return head.StartsWith("IN", StringComparison.OrdinalIgnoreCase) || head.StartsWith("BP", StringComparison.Ordinal) + || head.StartsWith("SP", StringComparison.Ordinal) || head.StartsWith("PU", StringComparison.Ordinal) + || head.StartsWith("PA", StringComparison.Ordinal) || head.StartsWith("DF", StringComparison.Ordinal); + } + + /// Index after the next "enter HP-GL/2 mode" (ESC % 0-3 B, also ESC % -1 B), or the end. + private static int EntryPoint(byte[] s, int from) + { + for (var i = from; i + 3 < s.Length; i++) + { + if (s[i] != 27 || s[i + 1] != '%') + continue; + var j = s[i + 2] == '-' ? i + 3 : i + 2; + if (j + 1 < s.Length && s[j] is >= (byte)'0' and <= (byte)'3' && s[j + 1] == 'B') + return j + 2; + } + return s.Length; + } + + private static List Numbers(string args) => + [.. args.Split([',', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(a => double.TryParse(a, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : double.NaN) + .Where(v => !double.IsNaN(v))]; + + /// + /// The numbers of a PE command from until a flag or the end. Base 64 (or base 32 in + /// 7-bit mode): digits 63.. are followed by a terminating digit (191.. or 95..), least significant first; the + /// lowest bit is the sign. + /// + internal static List DecodeNumbers(string data, ref int start, int fractionBits, int numberBase) + { + var terminator = numberBase == 64 ? 191 : 95; + var values = new List(); + long value = 0, scale = 1; + for (; start < data.Length; start++) + { + int c = data[start]; + if (c < 63) + break; + if (c >= terminator) + { + value += (c - terminator) * scale; + var signed = (value & 1) == 1 ? -(value >> 1) : value >> 1; + values.Add(signed / (double)(1L << fractionBits)); + value = 0; + scale = 1; + } + else + { + value += (c - 63) * scale; + scale *= numberBase; + } + } + return values; + } + + // ───────────────────────── Plotter ───────────────────────── + + private sealed class Plotter + { + private readonly Dictionary _colors = []; + private readonly Dictionary _widths = []; + private List<(double X, double Y)>? _line; + private bool _penDown; + private bool _relative; + private int _pen = 1; + private (double X, double Y) _at; + private List>? _polygon; + private bool _inPolygon; + + public List Strokes { get; } = []; + + /// The pen's colour; pens past the default eight draw black. + private string Color => _colors.TryGetValue(_pen, out var c) ? c : DefaultColors[_pen is > 0 and < 8 ? _pen : 1]; + + /// Pen width in plotter units (default 0.35 mm). + private double Width => _widths.TryGetValue(_pen, out var w) ? w : 0.35 * UnitsPerMm; + + public void Run(string name, string args) + { + switch (name) + { + case "IN": + EndLine(); + _penDown = _relative = false; + _pen = 1; + _at = (0, 0); + break; + case "SP": + EndLine(); + _pen = Numbers(args) is [var pen, ..] ? (int)pen : 0; + break; + case "PC": + if (Numbers(args) is [var p, var r, var g, var b, ..]) + _colors[(int)p] = $"#{Channel(r)}{Channel(g)}{Channel(b)}"; + break; + case "PW": + // Width in millimetres, for one pen or all of them. + var width = Numbers(args); + if (width is [var mm, var forPen, ..]) + _widths[(int)forPen] = Math.Max(0.05, mm) * UnitsPerMm; + else if (width is [var all]) + foreach (var any in Enumerable.Range(0, 256)) + _widths[any] = Math.Max(0.05, all) * UnitsPerMm; + EndLine(); + break; + case "PU" or "PD": + EndLine(keepPolygon: true); + _penDown = name == "PD"; + MoveAll(Numbers(args)); + break; + case "PA" or "PR": + _relative = name == "PR"; + MoveAll(Numbers(args)); + break; + case "CI": + if (Numbers(args) is [var radius, ..]) + Circle(radius); + break; + case "AA" or "AR": + if (Numbers(args) is [var cx, var cy, var sweep, ..]) + Arc(name == "AR" ? (_at.X + cx, _at.Y + cy) : (cx, cy), sweep); + break; + case "PE": + Encoded(args); + break; + case "PM": + // PM0 starts a polygon (its buffer is cleared), PM1 closes an outline of it, PM2 ends it. + EndLine(keepPolygon: true); + var mode = Numbers(args) is [var m, ..] ? (int)m : 0; + if (mode == 0) + _polygon = []; + _polygon ??= []; + _inPolygon = mode != 2; + break; + case "EP" or "FP": + EndLine(); + foreach (var outline in _polygon ?? []) + if (outline.Count > 1) + Strokes.Add(new Stroke(Color, Width, outline, Filled: name == "FP")); + break; + } + } + + private static string Channel(double value) => ((int)Math.Clamp(value, 0, 255)).ToString("X2", CultureInfo.InvariantCulture); + + private void MoveAll(List coordinates) + { + for (var i = 0; i + 1 < coordinates.Count; i += 2) + MoveTo(_relative ? (_at.X + coordinates[i], _at.Y + coordinates[i + 1]) : (coordinates[i], coordinates[i + 1]), _penDown); + } + + private void MoveTo((double X, double Y) to, bool draw) + { + if (draw) + { + _line ??= [_at]; + _line.Add(to); + } + else + { + EndLine(keepPolygon: true); + } + _at = to; + } + + /// + /// Ends the line being drawn. In polygon mode it becomes an outline of the polygon (drawn by EP / FP), else a + /// stroke. + /// + public void EndLine(bool keepPolygon = false) + { + if (_line is { Count: > 1 } line) + { + if (_inPolygon && keepPolygon) + _polygon!.Add(line); + else if (_pen != 0) + Strokes.Add(new Stroke(Color, Width, line, Filled: false)); + } + _line = null; + } + + private void Circle(double radius) + { + EndLine(keepPolygon: true); + var center = _at; + var points = Enumerable.Range(0, 73) + .Select(i => (center.X + radius * Math.Cos(i * Math.PI / 36), center.Y + radius * Math.Sin(i * Math.PI / 36))).ToList(); + if (_inPolygon) + _polygon!.Add(points); + else if (_pen != 0) + Strokes.Add(new Stroke(Color, Width, points, Filled: false)); + } + + private void Arc((double X, double Y) center, double sweepDegrees) + { + var radius = Math.Sqrt(Math.Pow(_at.X - center.X, 2) + Math.Pow(_at.Y - center.Y, 2)); + var start = Math.Atan2(_at.Y - center.Y, _at.X - center.X); + var steps = Math.Max(2, (int)Math.Ceiling(Math.Abs(sweepDegrees) / 5)); + for (var i = 1; i <= steps; i++) + { + var angle = start + sweepDegrees * Math.PI / 180 * i / steps; + MoveTo((center.X + radius * Math.Cos(angle), center.Y + radius * Math.Sin(angle)), _penDown); + } + } + + /// + /// PE: flags ':' (select pen), '<' (the next point is a move), '>' (fraction bits), '=' (the next point is + /// absolute), '7' (7-bit mode, base 32); points are relative and drawn otherwise. The pen stays down after it. + /// + private void Encoded(string data) + { + var penUp = false; + var absolute = false; + var fractionBits = 0; + var numberBase = 64; + var queue = new List(); + var i = 0; + while (i < data.Length) + { + var flag = data[i]; + if (flag is ':' or '<' or '>' or '=' or '7') + { + i++; + switch (flag) + { + case ':': + var pen = DecodeNumbers(data, ref i, 0, numberBase); + if (pen.Count > 0) + { + EndLine(); + _pen = (int)pen[0]; + queue.AddRange(pen.Skip(1)); + } + break; + case '<': + penUp = true; + break; + case '>': + var bits = DecodeNumbers(data, ref i, 0, numberBase); + if (bits.Count > 0) + { + fractionBits = (int)bits[0]; + queue.AddRange(bits.Skip(1)); + } + break; + case '=': + absolute = true; + break; + case '7': + numberBase = 32; + break; + } + } + else if (data[i] < 63) + { + i++; // not part of PE + } + else + { + queue.AddRange(DecodeNumbers(data, ref i, fractionBits, numberBase)); + } + + for (var k = 0; k + 1 < queue.Count; k += 2) + { + var to = absolute ? (queue[k], queue[k + 1]) : (_at.X + queue[k], _at.Y + queue[k + 1]); + MoveTo(to, draw: !penUp); + penUp = absolute = false; + } + if (queue.Count % 2 == 0) + queue.Clear(); + else + queue.RemoveRange(0, queue.Count - 1); + } + _penDown = true; + } + } +} diff --git a/src/Filee.Engines/Vector/VectorConverter.cs b/src/Filee.Engines/Vector/VectorConverter.cs index d23f0fdb6..d7ab9dbe2 100644 --- a/src/Filee.Engines/Vector/VectorConverter.cs +++ b/src/Filee.Engines/Vector/VectorConverter.cs @@ -41,6 +41,8 @@ .. ImageEncoder.Writable.SelectMany(target => new[] { ImageEncoder.Edge("svg", t new("svgz", "dxf"), // Illustrator files are PDF inside (or PostScript, see ConvertIllustratorAsync). new("ai", "pdf"), + // HP-GL plot files: their pen moves as lines (HpglPlot), PDF / PNG / ... from there. + new("plt", "svg"), // Raster images are traced (other raster formats through PNG, e.g. ICNS; never vector files or documents). .. ImageEncoder.Readable.Select(source => new ConversionEdge(source, "svg", TraceCost, ImageSourcesOnly: true)), ]; @@ -54,6 +56,7 @@ public async Task> ConvertAsync(ConversionStep step, IProg ("ai", "pdf") => await ConvertIllustratorAsync(step, progress, cancellationToken), ("svg", "svgz") => await Task.Run(() => Compress(step), cancellationToken), ("svgz", "svg") => await Task.Run(() => Decompress(step), cancellationToken), + ("plt", "svg") => await Task.Run(() => Plot(step), cancellationToken), (_, "svg") => await Task.Run(() => RasterTracing.Run(step, progress, cancellationToken), cancellationToken), (_, "dxf") => await Task.Run(() => ToDxf(step, cancellationToken), cancellationToken), (_, "pdf") => await Task.Run(() => ToPdf(step, cancellationToken), cancellationToken), @@ -63,6 +66,16 @@ public async Task> ConvertAsync(ConversionStep step, IProg return written; } + private static IReadOnlyList Plot(ConversionStep step) + { + var svg = HpglPlot.ToSvg(File.ReadAllBytes(step.InputPath)); + var output = step.Output.Allocate("svg"); + if (output is null) + return []; + File.WriteAllText(output, svg, new System.Text.UTF8Encoding(false)); + return [output]; + } + private static IReadOnlyList ToPdf(ConversionStep step, CancellationToken ct) { using var drawing = SvgRenderer.Load(step.InputPath); diff --git a/tests/Filee.Core.Tests/PltFormatTests.cs b/tests/Filee.Core.Tests/PltFormatTests.cs new file mode 100644 index 000000000..13601c2d1 --- /dev/null +++ b/tests/Filee.Core.Tests/PltFormatTests.cs @@ -0,0 +1,32 @@ +// HP-GL plot files (.plt, issue #410) are a read-only vector format, learnt by existing vector donuts. + +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Core.Settings; + +namespace Filee.Core.Tests; + +public class PltFormatTests +{ + [Theory] + [InlineData("floor.plt")] + [InlineData("cut.hpgl")] + public void Plot_files_are_a_read_only_vector_format(string name) + { + var format = FormatRegistry.Detect(name)!; + Assert.Equal(("plt", FormatCategory.Vector, false), (format.Id, format.Category, format.Writable)); + } + + [Fact] + public void Version_32_vector_donuts_learn_them() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + foreach (var profile in profiles) + profile.Extensions.RemoveAll(e => e is "plt" or "hpgl" or "hpg" or "hgl"); + + Assert.True(SettingsMigrations.ApplyToLibrary(32, presets, profiles)); + + Assert.Contains("plt", profiles.Single(p => p.Id == "vector").Extensions); + } +} diff --git a/tests/Filee.Engines.Tests/HpglPlotTests.cs b/tests/Filee.Engines.Tests/HpglPlotTests.cs new file mode 100644 index 000000000..887b13e56 --- /dev/null +++ b/tests/Filee.Engines.Tests/HpglPlotTests.cs @@ -0,0 +1,93 @@ +// HP-GL plot files (.plt, issue #410): pen moves read as lines, drawn as SVG and rendered through the vector engine. +// The packed HP-GL/2 sample (PE) was encoded with ezdxf's hpgl2 add-on, not with Filee's own decoder. + +using System.Text; +using Filee.Core.Conversion; +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Engines.Vector; +using ImageMagick; + +namespace Filee.Engines.Tests; + +public class HpglPlotTests(EngineFixture fx) : IClassFixture +{ + /// + /// Pen 1: a square of 400 units (10 mm) from (400, 400), drawn relative; pen 2: a move of (800, 0), then a line + /// of (-120, -120). Wrapped in PJL / PCL like a printer driver's plot, with pen 2 at 0.5 mm. + /// + private static byte[] DriverPlot() => Encoding.Latin1.GetBytes( + "\u001B%-12345X@PJL ENTER LANGUAGE=HPGL2\r\n\u001BE\u001B%0BIN;PW0.5,2;PE" + + ":Á<=_Ë_Ë_Ë¿¿_Ë`Ë¿¿`Ë:Ã Assert.Equal(200, Math.Sqrt(Math.Pow(p.X - 1000, 2) + Math.Pow(p.Y - 1000, 2)), 6)); + Assert.Equal(400, strokes[1].Points[^1].X, 6); // a quarter turn around (0, 400) ends at (400, 400) + Assert.Equal(400, strokes[1].Points[^1].Y, 6); + Assert.True(strokes[2].Filled); + Assert.Equal("#0080FF", strokes[2].Color); + } + + [Fact] + public async Task A_plot_becomes_an_svg_a_png_and_a_pdf() + { + var path = Path.Combine(fx.NewFolder(), "floor.plt"); + await File.WriteAllBytesAsync(path, DriverPlot(), TestContext.Current.CancellationToken); + Assert.Equal("plt", FormatRegistry.Detect(path)?.Id); + + var svg = await fx.ConvertAsync([path], new Preset { TargetFormat = "svg" }); + Assert.True(svg.State == JobState.Completed, string.Join("; ", svg.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + var text = await File.ReadAllTextAsync(svg.Outputs.Single(), TestContext.Current.CancellationToken); + Assert.Contains("width=\"24mm\"", text); // 20 mm of drawing (400 → 1200) and 2 mm on each side + + var png = await fx.ConvertAsync([path], new Preset { TargetFormat = "png" }); + Assert.True(png.State == JobState.Completed, string.Join("; ", png.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + using (var image = new MagickImage(png.Outputs.Single())) + Assert.True(image.Histogram().Count > 2, "the lines are drawn"); + + var pdf = await fx.ConvertAsync([path], new Preset { TargetFormat = "pdf" }); + Assert.Equal(JobState.Completed, pdf.State); + } + + [Fact] + public async Task A_plot_file_without_hpgl_fails_with_a_reason() + { + var path = Path.Combine(fx.NewFolder(), "postscript.plt"); + await File.WriteAllTextAsync(path, "%!PS-Adobe-3.0\nnewpath 0 0 moveto showpage\n", TestContext.Current.CancellationToken); + + var job = await fx.ConvertAsync([path], new Preset { TargetFormat = "png" }); + + Assert.Equal(JobState.Failed, job.State); + Assert.Contains("HP-GL", job.Files[0].ErrorDetail); + } +} diff --git a/tests/Filee.Engines.Tests/TracingTests.cs b/tests/Filee.Engines.Tests/TracingTests.cs index 831e64f01..82d265b1a 100644 --- a/tests/Filee.Engines.Tests/TracingTests.cs +++ b/tests/Filee.Engines.Tests/TracingTests.cs @@ -465,7 +465,8 @@ public void Tracing_is_never_a_step_towards_a_format_that_has_another_route() Assert.True(checkedRoutes > 1000); } - private static bool IsTracing(RouteStep step) => step.Converter is VectorConverter && step.To == "svg" && step.From is not "svgz"; + // SVGZ is unpacked and a plot file's pen moves are drawn (HpglPlot): neither is tracing. + private static bool IsTracing(RouteStep step) => step.Converter is VectorConverter && step.To == "svg" && step.From is not ("svgz" or "plt"); /// The vector engine without its tracing edges. private sealed class WithoutTracing(IConverter inner) : IConverter From b3a8ce76f0ad3165ab073b40b8502e6a1e8d2299 Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:54:20 +0530 Subject: [PATCH 14/20] Read HP-GL/1 plots that start with any command (#432 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device-control sequences (ESC . …) are skipped, a CO comment's quoted text stays its argument, and any command may come first, so plots opening with ESC .(, IP / SC, ETX or a comment are read. The header no longer names the project only read to check PE. --- src/Filee.Engines/Vector/HpglPlot.cs | 40 ++++++++++++++-------- tests/Filee.Engines.Tests/HpglPlotTests.cs | 15 +++++++- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/Filee.Engines/Vector/HpglPlot.cs b/src/Filee.Engines/Vector/HpglPlot.cs index 7808aab44..3d38d5714 100644 --- a/src/Filee.Engines/Vector/HpglPlot.cs +++ b/src/Filee.Engines/Vector/HpglPlot.cs @@ -4,9 +4,9 @@ // // Read: IN, SP / PC / NP / PW (pens and their colours and widths), PU / PD / PA / PR, CI, AA / AR (arcs as short // lines), PE (HP-GL/2 encoded polylines, the bulk of a printer driver's plot), PM / EP / FP (polygons, filled with -// FP). A PJL / PCL wrapper around HP-GL/2 is skipped up to "enter HP-GL/2 mode" (ESC % … B). Not drawn: text (LB), -// fill patterns, raster data; user scaling (SC) is ignored, so a scaled plot keeps its shape but not its size. -// The PE decoding follows ezdxf's hpgl2 add-on (MIT). +// FP). A PJL / PCL wrapper around HP-GL/2 is skipped up to "enter HP-GL/2 mode" (ESC % … B), and the device-control +// sequences of HP-GL/1 plotters (ESC . …) are skipped. Not drawn: text (LB), fill patterns, raster data; user scaling +// (SC) is ignored, so a scaled plot keeps its shape but not its size. using System.Globalization; using System.Text; @@ -65,19 +65,21 @@ internal static string ToSvg(byte[] data) // ───────────────────────── Commands ───────────────────────── /// - /// Splits the plot into commands: two letters and their arguments up to ';' or the next command. PE's arguments - /// are its encoded bytes; LB's run to the label terminator (ETX). + /// Splits the plot into commands: two letters and their arguments up to ';' or the next command (quoted text such + /// as a CO comment included). PE's arguments are its encoded bytes; LB's run to the label terminator (ETX). Any + /// command may come first. /// internal static IEnumerable<(string Name, string Args)> Commands(byte[] s) { - var i = StartsWithHpgl(s) ? 0 : EntryPoint(s, 0); + var i = 0; while (i < s.Length) { var c = s[i]; if (c == 27) { - // An escape sequence leaves HP-GL/2 (PCL, PJL): go on where the plot enters it again. - i = EntryPoint(s, i + 1); + // ESC . is an HP-GL/1 device-control sequence; any other escape (PCL, PJL) leaves HP-GL/2, which goes on + // where the plot enters it again (ESC % … B). + i = i + 1 < s.Length && s[i + 1] == '.' ? DeviceControlEnd(s, i) : EntryPoint(s, i); continue; } if (c <= 32 || c == ';' || !char.IsAsciiLetterUpper((char)c) && !char.IsAsciiLetterLower((char)c)) @@ -95,21 +97,29 @@ internal static string ToSvg(byte[] data) else if (name == "LB") while (end < s.Length && s[end] != 3) end++; else - while (end < s.Length && s[end] != ';' && s[end] != 27 && !char.IsAsciiLetter((char)s[end])) end++; + while (end < s.Length && s[end] != ';' && s[end] != 27 && !char.IsAsciiLetter((char)s[end])) + end = s[end] == '"' ? Math.Min(s.Length, Array.IndexOf(s, (byte)'"', end + 1) is var close and >= 0 ? close + 1 : s.Length) : end + 1; yield return (name, Encoding.Latin1.GetString(s, start, end - start)); i = end < s.Length && s[end] is (byte)';' or 3 ? end + 1 : end; } } - private static bool StartsWithHpgl(byte[] s) + /// + /// Index after a device-control sequence at : ESC . and one character (ESC . ( turns the + /// plotter on), with its numeric parameters when they end in ':' (ESC . I 81;;17:, ESC . N ;19:). + /// + private static int DeviceControlEnd(byte[] s, int at) { - var head = Encoding.ASCII.GetString(s, 0, Math.Min(s.Length, 16)).TrimStart(); - return head.StartsWith("IN", StringComparison.OrdinalIgnoreCase) || head.StartsWith("BP", StringComparison.Ordinal) - || head.StartsWith("SP", StringComparison.Ordinal) || head.StartsWith("PU", StringComparison.Ordinal) - || head.StartsWith("PA", StringComparison.Ordinal) || head.StartsWith("DF", StringComparison.Ordinal); + var next = at + 3; + if (next > s.Length) + return s.Length; + var end = next; + while (end < s.Length && (char.IsAsciiDigit((char)s[end]) || s[end] is (byte)';' or (byte)'-' or (byte)'.')) + end++; + return end < s.Length && s[end] == ':' ? end + 1 : next; } - /// Index after the next "enter HP-GL/2 mode" (ESC % 0-3 B, also ESC % -1 B), or the end. + /// Index after the next "enter HP-GL/2 mode" (ESC % 0-3 B, also ESC % -1 B) from , or the end. private static int EntryPoint(byte[] s, int from) { for (var i = from; i + 3 < s.Length; i++) diff --git a/tests/Filee.Engines.Tests/HpglPlotTests.cs b/tests/Filee.Engines.Tests/HpglPlotTests.cs index 887b13e56..1db8e766c 100644 --- a/tests/Filee.Engines.Tests/HpglPlotTests.cs +++ b/tests/Filee.Engines.Tests/HpglPlotTests.cs @@ -1,5 +1,5 @@ // HP-GL plot files (.plt, issue #410): pen moves read as lines, drawn as SVG and rendered through the vector engine. -// The packed HP-GL/2 sample (PE) was encoded with ezdxf's hpgl2 add-on, not with Filee's own decoder. +// The packed HP-GL/2 sample (PE) was encoded independently of Filee's decoder, so the test is not circular. using System.Text; using Filee.Core.Conversion; @@ -33,6 +33,19 @@ public void Plain_hpgl_pen_moves_become_lines() Assert.Equal("#FF0000", strokes[1].Color); } + [Theory] + [InlineData("\u001B.(;\u001B.I81;;17:\u001B.N;19:IN;SP1;PU0,0;PD400,0,400,400;")] // device control first + [InlineData("IP0,0,4000,4000;SC;SP1;PU0,0;PD400,0,400,400;")] // scaling points first + [InlineData("\u0003IN;SP1;PU0,0;PD400,0,400,400;")] // a leading ETX + [InlineData("CO\"made by x; PD 9,9\";IN;SP1;PU0,0;PD400,0,400,400;")] // a comment first, with a command in it + [InlineData("in;sp1;pu0,0;pd400,0,400,400;")] // lower case + public void Hpgl1_plots_starting_with_any_command_are_read(string plot) + { + var stroke = Assert.Single(HpglPlot.Read(Encoding.Latin1.GetBytes(plot))); + + Assert.Equal([(0, 0), (400, 0), (400, 400)], stroke.Points); + } + [Fact] public void Encoded_polylines_of_a_printer_driver_are_read() { From 0cd5f63e1ad610defbc503401163fd79bc501f98 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 23 Oct 2026 09:35:14 +0900 Subject: [PATCH 15/20] Renumber #393's migration to 34; no new strings Main took settings schema 33 for #351, so the Lua / CSS text-donut step becomes v34 after it, AppSettings.CurrentSchemaVersion goes to 34 and the migration test starts from 33. #393 adds no UI strings; all 13 language files already match en.json. Co-Authored-By: Claude Opus 5.5 --- src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 4 ++-- tests/Filee.Core.Tests/ScriptTextTests.cs | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 3df95d287..508c929c6 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -112,7 +112,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest public sealed class AppSettings { /// Current schema version. Increase when the format changes and add a migration. - public const int CurrentSchemaVersion = 33; + public const int CurrentSchemaVersion = 34; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index d35ee1489..df42d095e 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -772,8 +772,8 @@ public static bool ApplyToLibrary(int from, List presets, List Assert.Equal("txt", FormatRegistry.Detect(name)?.Id); [Fact] - public void Version_32_text_donuts_learn_them_without_taking_custom_claims() + public void Version_33_text_donuts_learn_them_without_taking_custom_claims() { var presets = BuiltInData.CreatePresets(); var profiles = BuiltInData.CreateProfiles(); @@ -23,11 +23,11 @@ public void Version_32_text_donuts_learn_them_without_taking_custom_claims() profile.Extensions.RemoveAll(e => e is "lua" or "css"); profiles.Insert(0, new ToolbarProfile { Id = "my-web", Name = "Web", Extensions = ["css"] }); - Assert.True(SettingsMigrations.ApplyToLibrary(32, presets, profiles)); + Assert.True(SettingsMigrations.ApplyToLibrary(33, presets, profiles)); var text = profiles.Single(p => p.Id == "text").Extensions; Assert.Contains("lua", text); Assert.DoesNotContain("css", text); // the user's own donut has it - Assert.False(SettingsMigrations.ApplyToLibrary(32, presets, profiles)); + Assert.False(SettingsMigrations.ApplyToLibrary(33, presets, profiles)); } } From 08499ba2ae8ce5b38e2ce18170537a7fc6c3054c Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 23 Oct 2026 09:35:44 +0900 Subject: [PATCH 16/20] Renumber #432's migration to 35; no new strings The HP-GL plot-file step becomes v35 after #393's v34, AppSettings.CurrentSchemaVersion goes to 35 and the migration test starts from 34. #432 adds no UI strings; all 13 language files already match en.json. Co-Authored-By: Claude Opus 5.5 --- src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 4 ++-- tests/Filee.Core.Tests/PltFormatTests.cs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 508c929c6..21f65e98e 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -112,7 +112,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest public sealed class AppSettings { /// Current schema version. Increase when the format changes and add a migration. - public const int CurrentSchemaVersion = 34; + public const int CurrentSchemaVersion = 35; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index a52da8f63..25d2b604f 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -775,8 +775,8 @@ public static bool ApplyToLibrary(int from, List presets, List e is "plt" or "hpgl" or "hpg" or "hgl"); - Assert.True(SettingsMigrations.ApplyToLibrary(32, presets, profiles)); + Assert.True(SettingsMigrations.ApplyToLibrary(34, presets, profiles)); Assert.Contains("plt", profiles.Single(p => p.Id == "vector").Extensions); } From b5648aaad9bdeff29f90882cc82caea6d6c9f957 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 23 Oct 2026 09:37:04 +0900 Subject: [PATCH 17/20] Renumber #381's migration to 36; iCalendar in the data engine text for fr, zh-TW, ne The iCalendar step becomes v36 after #432's v35, AppSettings.CurrentSchemaVersion goes to 36 and the iCalendar migration test starts from 35. engine.data.description now names iCalendar events in English; French and Traditional Chinese still said vCard only, and Nepali had an older one-line text. All three now say what the English one says. Co-Authored-By: Claude Opus 5.5 --- src/Filee.App/Assets/i18n/fr.json | 2 +- src/Filee.App/Assets/i18n/ne.json | 2 +- src/Filee.App/Assets/i18n/zh-TW.json | 2 +- src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 4 ++-- tests/Filee.Core.Tests/UserDataStoreTests.cs | 8 ++++---- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 6564f6e8e..5790e61d9 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -136,7 +136,7 @@ "engine.pdf-text.description": "Extrait le texte des PDF dans l'ordre de lecture, y compris sur deux colonnes (pas d'OCR pour les numérisations).", "engine.pdf-slides.description": "Convertit les PDF en diapositives PowerPoint : chaque page devient une image sur sa propre diapositive (fidèle au PDF, non modifiable), avec le texte de la page dans les notes du présentateur.", "engine.email.description": "E-mails (EML, MSG Outlook) en HTML et texte avec expéditeur, destinataires et date, images comprises ; pièces jointes dans un ZIP.\nMSG en EML. Pages web enregistrées (MHTML) en un seul fichier HTML avec leurs images.", - "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML et CSV entre eux.\nContacts vCard en CSV et inversement.\nLes commentaires sont supprimés. Le CSV ne contient que des tableaux.", + "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML et CSV entre eux.\nContacts vCard et événements iCalendar en CSV et inversement.\nLes commentaires sont supprimés. Le CSV ne contient que des tableaux.", "engine.vector.description": "SVG et SVGZ en PDF vectoriel, images et DXF ; images vectorisées en SVG ; fichiers Illustrator enregistrés avec contenu PDF.", "engine.icns.description": "Icônes d'apps macOS (ICNS) depuis et vers des images.", "engine.font.description": "Polices : TTF, OTF, WOFF, WOFF2 et EOT entre eux.", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index c1b2578ab..6fb452e5e 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -129,7 +129,7 @@ "engine.pdf-text.description": "पढ्ने क्रममा PDF को पाठ निकाल्छ, दुई-स्तम्भ पृष्ठहरूबाट पनि (स्क्यानका लागि OCR होइन)।", "engine.pdf-slides.description": "PDF लाई PowerPoint स्लाइडमा रूपान्तरण गर्दछ: प्रत्येक पृष्ठ आफ्नै स्लाइडमा तस्बिर बन्छ (PDF जस्तै देखिन्छ, सम्पादन गर्न मिल्दैन), र पृष्ठको पाठ वक्ता नोटमा रहन्छ।", "engine.email.description": "इमेलहरू (EML, Outlook MSG) लाई प्रेषक, प्राप्तकर्ता र मितिसहित HTML र पाठमा रूपान्तरण गर्दछ, तस्बिरहरूसहित; संलग्नकहरू ZIP मा।\nMSG लाई EML मा। सुरक्षित वेब पृष्ठहरू (MHTML) लाई तिनका तस्बिरहरूसहित एउटै HTML फाइलमा।", - "engine.data.description": "JSON, YAML, XML, TOML र CSV बीच डाटा रूपान्तरण गर्दछ।", + "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML र CSV एकअर्कामा।\nvCard सम्पर्कहरू र iCalendar घटनाहरू CSV मा, र CSV बाट तिनीहरूमा।\nटिप्पणीहरू हटाइन्छन्। CSV मा तालिका मात्र रहन्छ।", "engine.vector.description": "SVG र SVGZ बाट भेक्टर PDF, छवि र DXF; तस्बिरहरूलाई SVG मा ट्रेस गर्ने; PDF सामग्रीसहित सुरक्षित गरिएका Illustrator फाइलहरू।", "engine.icns.description": "छविहरूबाट र छविहरूमा macOS एप आइकन (ICNS)।", "engine.font.description": "TTF, OTF, WOFF, WOFF2 फन्टहरू एकअर्कामा रूपान्तरण गर्दछ।", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 675176a5a..377f5a37f 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -136,7 +136,7 @@ "engine.pdf-text.description": "按閱讀順序擷取 PDF 中的文字,雙欄頁面也能正確排序(不對掃描檔做文字辨識)。", "engine.pdf-slides.description": "將 PDF 轉換為 PowerPoint 投影片。每頁作為一張圖片放在單獨的投影片上,外觀與 PDF 相同(不可編輯),頁面文字放入講者備忘稿。", "engine.email.description": "將電子郵件(EML、Outlook MSG)連同發件人、收件人、日期和圖片轉為 HTML 和文字,附件打包為 ZIP。\nMSG 也可轉為 EML。儲存的網頁(MHTML)可連同圖片轉為單個 HTML 檔案。", - "engine.data.description": "JSON、JSON Lines、YAML、TOML、XML、CSV 互相轉換。\nvCard 聯絡人與 CSV 互轉。\n註釋不保留。CSV 僅支援表格資料。", + "engine.data.description": "JSON、JSON Lines、YAML、TOML、XML、CSV 互相轉換。\nvCard 聯絡人和 iCalendar 行程與 CSV 互轉。\n註釋不保留。CSV 僅支援表格資料。", "engine.vector.description": "將 SVG、SVGZ 轉為向量 PDF、圖片和 DXF,並可將圖片描摹為 SVG。也支援包含 PDF 內容的 Illustrator 檔案。", "engine.icns.description": "macOS 應用程式圖示(ICNS)與圖片互轉。", "engine.font.description": "字型:TTF、OTF、WOFF、WOFF2 和 EOT 互相轉換。", diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 21f65e98e..da7d09fda 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -112,7 +112,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest public sealed class AppSettings { /// Current schema version. Increase when the format changes and add a migration. - public const int CurrentSchemaVersion = 35; + public const int CurrentSchemaVersion = 36; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index d9a1016b5..1102a5c18 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -778,9 +778,9 @@ public static bool ApplyToLibrary(int from, List presets, List p.Id == "data"); data.Extensions.RemoveAll(e => e is "ics" or "ical"); full.Profiles.Insert(0, new ToolbarProfile { Id = "my-calendar", Name = "My calendar", Extensions = ["ics"] }); @@ -1074,7 +1074,7 @@ public void Version_32_libraries_learn_icalendar_extensions_and_preset_without_o using var roomDir = new TempDir(); var room = new UserDataStore(roomDir.Path); room.Load(); - room.Settings.SchemaVersion = 32; + room.Settings.SchemaVersion = 35; foreach (var profile in room.Profiles) profile.Extensions.RemoveAll(e => e is "ics" or "ical"); room.Profiles.Single(p => p.Id == "spreadsheets").BackPresetIds!.Remove("to-ics"); @@ -1099,7 +1099,7 @@ public void Version_32_libraries_learn_icalendar_extensions_and_preset_without_o using var deletedDir = new TempDir(); var deleted = new UserDataStore(deletedDir.Path); deleted.Load(); - deleted.Settings.SchemaVersion = 32; + deleted.Settings.SchemaVersion = 35; deleted.Profiles.RemoveAll(p => p.Id == "data"); foreach (var profile in deleted.Profiles) profile.Extensions.RemoveAll(e => e is "ics" or "ical"); From 352fe377bbba7fa66c358d3ab895d37721eda42e Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 23 Oct 2026 09:41:41 +0900 Subject: [PATCH 18/20] Renumber #349's migration to 37; media tool strings for fr, zh-TW, ne The everyday-tools step becomes v37 after #381's v36, AppSettings.CurrentSchemaVersion goes to 37, its offers table is Version37Offers, and the migration tests start from 36. French, Traditional Chinese and Nepali get the twelve new strings (the media tool preset names, the slideshow options and the too-many-pictures error) in en.json's order, using each file's existing terms. Co-Authored-By: Claude Opus 5.5 --- src/Filee.App/Assets/i18n/fr.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/ne.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/zh-TW.json | 12 ++++++++++++ src/Filee.Core/Presets/BuiltInData.cs | 2 +- src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 10 +++++----- tests/Filee.App.Tests/DonutFlipTests.cs | 2 +- tests/Filee.Core.Tests/MediaToolTests.cs | 12 ++++++------ 8 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 5790e61d9..4e6bf7073 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -86,6 +86,7 @@ "error.cannot_turn": "La rotation et le retournement ne s'appliquent qu'aux images", "error.no_transparency": "Ce format n'a pas de transparence : il n'y a rien à rogner", "error.too_many_frames": "Trop d'images à enregistrer, augmentez « Une image sur N »", + "error.too_many_pictures": "Trop d'images pour une seule vidéo, répartissez-les en plusieurs", "error.not_enough_space": "Pas assez d'espace disque libre pour les images", "error.no_motion_photo": "Cette photo ne contient aucune vidéo lisible par Filee (une Motion Photo Android, ou une Live Photo d'iPhone avec son MOV à côté)", "error.live_photo_start": "L'extrait commence après la fin de cette vidéo. Choisissez un « Début de l'extrait » plus tôt", @@ -227,6 +228,14 @@ "preset.extract_motion": "Vidéo Live", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", + "preset.compress_image": "Compresser l'image", + "preset.compress_video": "Compresser la vidéo", + "preset.video_from_images": "Vidéo à partir d'images", + "preset.extract_audio": "Extraire l'audio", + "preset.merge_videos": "Fusionner les vidéos", + "preset.compress_audio": "Compresser l'audio", + "preset.merge_audio": "Fusionner l'audio", + "preset.remove_metadata": "Supprimer les métadonnées", "format.folder": "Dossier", "format.category.Image": "Image", @@ -496,6 +505,9 @@ "presets.frame_estimate": "Une minute de vidéo à 30 i/s donne {0} images, au plus {1} par vidéo.", "presets.extract_motion_photo": "Extraire la vidéo Live/Motion Photo", "presets.extract_motion_photo_hint": "Une photo devient la vidéo enregistrée avec elle : celle contenue dans une Motion Photo Android, ou le MOV à côté d'une Live Photo d'iPhone.\nLes autres images deviennent une vidéo de l'image fixe.", + "presets.slide_seconds": "Secondes par image", + "presets.crossfade": "Fondu enchaîné entre les images", + "presets.crossfade_hint": "Chaque image se fond dans la suivante en une seconde au lieu d'y passer d'un coup.", "presets.group.live_photo": "Live / Motion Photo", "presets.live_photo": "Une vidéo devient", "presets.live_photo.None": "Une image fixe", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 6fb452e5e..7d4e72a67 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -80,6 +80,7 @@ "error.cannot_turn": "घुमाउने र उल्ट्याउने कार्य केवल छविहरूमा मात्र काम गर्छ", "error.no_transparency": "यो ढाँचामा पारदर्शिता हुँदैन, त्यसैले काट्नुपर्ने केही छैन", "error.too_many_frames": "छविका रूपमा सुरक्षित गर्न धेरै फ्रेमहरू छन्, \"प्रत्येक N-औं फ्रेम\" बढाउनुहोस्", + "error.too_many_pictures": "एउटा भिडियोका लागि धेरै तस्बिरहरू छन्, तिनलाई केही भागमा बाँड्नुहोस्", "error.not_enough_space": "छविहरूका लागि डिस्कमा पर्याप्त खाली ठाउँ छैन", "error.no_motion_photo": "यस तस्बिरमा Filee ले पढ्न सक्ने कुनै भिडियो छैन (Android मोशन फोटो, वा छेउमा MOV भएको iPhone लाइभ फोटो)", "error.live_photo_start": "क्लिप यस भिडियो सकिएपछि सुरु हुन्छ। पहिलेको \"क्लिप सुरु\" समय रोज्नुहोस्", @@ -217,6 +218,14 @@ "preset.extract_motion": "लाइभ फोटो भिडियो निकाल्नुहोस्", "preset.live_photo_iphone": "लाइभ फोटो (iPhone)", "preset.motion_photo_android": "मोशन फोटो (Android)", + "preset.compress_image": "छवि कम्प्रेस गर्नुहोस्", + "preset.compress_video": "भिडियो कम्प्रेस गर्नुहोस्", + "preset.video_from_images": "छविहरूबाट भिडियो", + "preset.extract_audio": "अडियो निकाल्नुहोस्", + "preset.merge_videos": "भिडियोहरू मर्ज गर्नुहोस्", + "preset.compress_audio": "अडियो कम्प्रेस गर्नुहोस्", + "preset.merge_audio": "अडियो मर्ज गर्नुहोस्", + "preset.remove_metadata": "मेटाडाटा हटाउनुहोस्", "format.folder": "फोल्डर", "format.category.Image": "छवि", "format.category.Pdf": "PDF", @@ -481,6 +490,9 @@ "presets.frame_estimate": "३० fps को एक मिनेटको भिडियोबाट {0} तस्बिरहरू बन्छन्, प्रति भिडियो बढीमा {1}।", "presets.extract_motion_photo": "लाइभ/मोशन फोटो भिडियो निकाल्नुहोस्", "presets.extract_motion_photo_hint": "फोटो त्यसैसँग सुरक्षित गरिएको भिडियो बन्दछ: Android मोशन फोटो भित्रको, वा iPhone लाइभ फोटोको छेउमा रहेको MOV फाइल।\nअन्य तस्बिरहरू स्थिर तस्बिरको भिडियो बन्छन्।", + "presets.slide_seconds": "प्रति तस्बिर सेकेन्ड", + "presets.crossfade": "तस्बिरहरूबीच क्रसफेड", + "presets.crossfade_hint": "प्रत्येक तस्बिर सिधै काटिनुको सट्टा एक सेकेन्डमा बिस्तारै अर्कोमा बदलिन्छ।", "presets.group.live_photo": "लाइभ / मोशन फोटो", "presets.live_photo": "भिडियो के बन्छ", "presets.live_photo.None": "एउटा स्थिर फ्रेम", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 377f5a37f..7546206aa 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -86,6 +86,7 @@ "error.cannot_turn": "只能旋轉和翻轉圖片", "error.no_transparency": "此格式沒有透明部分,無需裁切", "error.too_many_frames": "影格數太多,請調大「每 N 個影格」", + "error.too_many_pictures": "圖片太多,無法放進同一部影片,請分成幾次", "error.not_enough_space": "磁碟空間不足,無法儲存這些圖片", "error.no_motion_photo": "這張照片中沒有可讀取的影片(Android 動態相片,或旁邊有 MOV 的 iPhone 原況照片)", "error.live_photo_start": "片段開始位置在影片結束之後。請把「片段開始」設得更早", @@ -227,6 +228,14 @@ "preset.extract_motion": "擷取原況/動態相片影片", "preset.live_photo_iphone": "原況照片(iPhone)", "preset.motion_photo_android": "動態相片(Android)", + "preset.compress_image": "壓縮圖片", + "preset.compress_video": "壓縮影片", + "preset.video_from_images": "圖片製成影片", + "preset.extract_audio": "擷取音訊", + "preset.merge_videos": "合併影片", + "preset.compress_audio": "壓縮音訊", + "preset.merge_audio": "合併音訊", + "preset.remove_metadata": "移除中繼資料", "format.folder": "資料夾", "format.category.Image": "圖片", @@ -496,6 +505,9 @@ "presets.frame_estimate": "30 fps 的影片每分鐘產生 {0} 張圖片,每個影片最多 {1} 張。", "presets.extract_motion_photo": "擷取原況/動態相片中的影片", "presets.extract_motion_photo_hint": "取出與照片一起儲存的影片。Android 動態相片取其中內嵌的影片,iPhone 原況照片取旁邊同名的 MOV。\n其他圖片仍轉換為靜態畫面的影片。", + "presets.slide_seconds": "每張圖片秒數", + "presets.crossfade": "圖片之間交叉淡化", + "presets.crossfade_hint": "每張圖片在一秒內漸變到下一張,而不是直接切換。", "presets.group.live_photo": "原況/動態相片", "presets.live_photo": "影片儲存為", "presets.live_photo.None": "一張靜態畫面", diff --git a/src/Filee.Core/Presets/BuiltInData.cs b/src/Filee.Core/Presets/BuiltInData.cs index 45ac163ff..539d8d56a 100644 --- a/src/Filee.Core/Presets/BuiltInData.cs +++ b/src/Filee.Core/Presets/BuiltInData.cs @@ -127,7 +127,7 @@ public static List TrimPresets() => ]; /// - /// Everyday tools (added in settings schema 33): smaller copies of images, videos and audio, a video of photos, + /// Everyday tools (added in settings schema 37): smaller copies of images, videos and audio, a video of photos, /// a video's sound as M4A, videos or audio files joined into one, and a copy without metadata. On the back faces /// of the images, PDF, video and audio donuts where they have room. /// diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index da7d09fda..abbde4383 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -112,7 +112,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest public sealed class AppSettings { /// Current schema version. Increase when the format changes and add a migration. - public const int CurrentSchemaVersion = 36; + public const int CurrentSchemaVersion = 37; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 182f48143..a6b226651 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -785,11 +785,11 @@ public static bool ApplyToLibrary(int from, List presets, List presets, List return changed; } - /// Where schema 33 offers its tools: (donut, presets for its back face). - private static readonly (string Profile, string[] Presets)[] Version33Offers = + /// Where schema 37 offers its tools: (donut, presets for its back face). + private static readonly (string Profile, string[] Presets)[] Version37Offers = [ ("images", ["compress-image", "remove-metadata"]), ("pdf", ["remove-metadata"]), @@ -837,7 +837,7 @@ private static bool AddMediaTools(List presets, List pro presets.Insert(Math.Min(at++, presets.Count), tool); changed = true; } - foreach (var (profileId, ids) in Version33Offers) + foreach (var (profileId, ids) in Version37Offers) { if (profiles.FirstOrDefault(p => p.Id == profileId) is not { BackPresetIds: { } back } profile) continue; diff --git a/tests/Filee.App.Tests/DonutFlipTests.cs b/tests/Filee.App.Tests/DonutFlipTests.cs index 5f906bb9e..fc27b259c 100644 --- a/tests/Filee.App.Tests/DonutFlipTests.cs +++ b/tests/Filee.App.Tests/DonutFlipTests.cs @@ -682,7 +682,7 @@ public void The_editor_switches_between_the_front_and_the_back(string language, RenderTests.Save(window, $"toolbar-back-face-{language}{(dark ? "-dark" : "")}.png"); var back = ImageBack; - // The built-in back face is full (settings schema 33): a slice goes before another comes in. + // The built-in back face is full (settings schema 37): a slice goes before another comes in. page.RemoveAt(page.SliceCount - 1); Assert.True(page.Insert("to-psd", 0)); page.Move(0, 2); diff --git a/tests/Filee.Core.Tests/MediaToolTests.cs b/tests/Filee.Core.Tests/MediaToolTests.cs index 5f78c6c6c..6bcbab38f 100644 --- a/tests/Filee.Core.Tests/MediaToolTests.cs +++ b/tests/Filee.Core.Tests/MediaToolTests.cs @@ -1,4 +1,4 @@ -// The everyday media tools of settings schema 33: their presets, how existing libraries get them, and the queue +// The everyday media tools of settings schema 37: their presets, how existing libraries get them, and the queue // joining videos, audio files and pictures into one file. using Filee.Core.Conversion; @@ -74,13 +74,13 @@ public void Options_survive_a_copy() } [Fact] - public void Version_32_libraries_get_the_tools_and_back_faces_with_room_offer_them() + public void Version_36_libraries_get_the_tools_and_back_faces_with_room_offer_them() { var presets = BuiltInData.CreatePresets().Where(p => !ToolIds.Contains(p.Id)).ToList(); var profiles = BuiltInData.CreateProfiles(); foreach (var profile in profiles) profile.BackPresetIds!.RemoveAll(ToolIds.Contains); - // The video donut's back face of schema 32, with the plain M4A slice. + // The video donut's back face of schema 36, with the plain M4A slice. profiles.Single(p => p.Id == "video").BackPresetIds = ["mp4-av1", "to-webp", "extract-frames-png", "extract-frames-jpg", "live-photo-iphone", "motion-photo-android", "to-m4a", "to-wav", "to-flac", "zip-all"]; // A back face the user filled up stays as it is. @@ -89,7 +89,7 @@ public void Version_32_libraries_get_the_tools_and_back_faces_with_room_offer_th pdf.BackPresetIds.Insert(0, presets.First(p => !pdf.BackPresetIds.Contains(p.Id) && !pdf.PresetIds.Contains(p.Id)).Id); var full = pdf.BackPresetIds.ToList(); - Assert.True(SettingsMigrations.ApplyToLibrary(32, presets, profiles)); + Assert.True(SettingsMigrations.ApplyToLibrary(36, presets, profiles)); Assert.All(ToolIds, id => Assert.Single(presets, p => p.Id == id)); var audio = profiles.Single(p => p.Id == "audio").BackPresetIds!; @@ -98,7 +98,7 @@ public void Version_32_libraries_get_the_tools_and_back_faces_with_room_offer_th var video = profiles.Single(p => p.Id == "video").BackPresetIds!; Assert.Equal(BuiltInData.CreateProfiles().Single(p => p.Id == "video").BackPresetIds, video); Assert.Equal(full, pdf.BackPresetIds); - Assert.False(SettingsMigrations.ApplyToLibrary(32, presets, profiles)); // once only + Assert.False(SettingsMigrations.ApplyToLibrary(36, presets, profiles)); // once only Assert.False(SettingsMigrations.ApplyToLibrary(AppSettings.CurrentSchemaVersion, presets, profiles)); } @@ -110,7 +110,7 @@ public void Extract_audio_comes_onto_a_video_back_face_without_the_m4a_slice_whe var video = profiles.Single(p => p.Id == "video"); video.BackPresetIds = ["mp4-av1", "to-wav", "zip-all"]; - Assert.True(SettingsMigrations.ApplyToLibrary(32, presets, profiles)); + Assert.True(SettingsMigrations.ApplyToLibrary(36, presets, profiles)); Assert.Equal(["mp4-av1", "to-wav", "compress-video", "merge-videos", "extract-audio", "zip-all"], video.BackPresetIds); } From c1315c86083e8aef5a3b3fed05886c9889bbea5d Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 23 Oct 2026 09:52:25 +0900 Subject: [PATCH 19/20] Fit #349's images back face, compress into ZIPs, count saved pictures - The images donut's built-in back face holds 12 slices (ToolbarProfile.MaxSlices) again: with main's "Trim transparent borders" on it, #349's two tools made 13. "Extract Live Photo video" leaves it and stays in the preset library, as its doc and docs/ENGINES.md already said. The v37 step already adds a tool only while a back face has room; new tests check a schema-36 images face (it takes "Compress image" and stays at 12) and that an upgraded library's face is the new built-in one. - OnlyIfSmaller also applies under "Save as ZIP": the output there is still in the work folder, so the source is copied over it and goes into the ZIP. - "Too many pictures" shows the number of pictures it counted (downloads that failed are left out), not every dropped file. - docs/ENGINES.md: Video from images is in the library only, crossfades are at most half a picture's time, PDFsharp writes its own XMP packet after the metadata is removed, and the ZIP case above. CHANGELOG's back-face line no longer lists Live Photo video on the image donut. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- docs/ENGINES.md | 14 ++-- src/Filee.Core/Conversion/JobQueue.cs | 11 +-- src/Filee.Core/Presets/BuiltInData.cs | 5 +- tests/Filee.Core.Tests/MediaToolTests.cs | 89 ++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5a1cee08..d35943e72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,8 +144,8 @@ pt-BR: Correções de segurança para e-books, SVG, modelos 3D e e-mails, e muit run. Attachments dragged out of Outlook and files dragged out of a ZIP in Explorer open the donut too. - **The donut's back face** (issue #195). Every donut has a second set of up to 12 slices. While the donut is open, press **Tab** or turn the **mouse wheel** to flip it; the hub shows the hint when the back has something for your - files. Built-in donuts come with a back face: Crop, Rotate, Flip, Remove background and Live Photo video on the image - donut, Compress PDF on the PDF donut, frame extraction and Live / Motion Photos on the video donut, One ZIP on most. + files. Built-in donuts come with a back face: Crop, Rotate, Flip and Remove background on the image donut, + Compress PDF on the PDF donut, frame extraction and Live / Motion Photos on the video donut, One ZIP on most. The Front / Back switch in Settings → Donut toolbar edits it, and Settings → Shortcuts → "Flip the donut" changes the key or turns the wheel off. - **Crop** (by @smjrifle). The Crop slice (on the back of the image donut) opens your pictures one after another in a diff --git a/docs/ENGINES.md b/docs/ENGINES.md index b35dd57d7..e80f3c9e7 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -703,11 +703,13 @@ PDFsharp and Magick.NET; nothing has to be installed. ## Media tools -The everyday presets on the back faces of the images, PDF, video and audio donuts. Video and audio need FFmpeg. +Everyday presets, on the back faces of the images, PDF, video and audio donuts where they fit ("Video from images" is +in the preset library only). Video and audio need FFmpeg. - **Compress image / video / audio** re-encode smaller (quality 75, never above a JPEG's own and PNG at zlib level 9; H.264 at the Small quality, at most 1080p; AAC at the Small quality). A copy in the same format that isn't smaller is - replaced by the source, with the same notice as Compress PDF (`OutputRule.OnlyIfSmaller`). + replaced by the source, with the same notice as Compress PDF (`OutputRule.OnlyIfSmaller`); under "Save as ZIP" the + source goes into the ZIP instead. - **Extract audio** copies AAC or ALAC into M4A without an encode (`MediaOptions.CopyAudioWhenPossible`); other sound is encoded to AAC. - **Merge videos** copies the clips with the concat demuxer only when all share the codec setup (ffprobe's extradata @@ -715,12 +717,14 @@ The everyday presets on the back faces of the images, PDF, video and audio donut frame and encoded, a clip without sound silent for its length. **Merge audio** always decodes and joins the sound into one M4A, so no encoder padding or gap is left between files. Both are in `Media/MediaJoining.cs`. - **Video from images**: each picture is fitted into 1920 × 1080 as a JPEG, then shown for the preset's seconds with - 1 s crossfades (`xfade`) or cuts. At most `MediaOptions.MaxSlides` (300) pictures; the queue refuses more before any - is converted. A filter graph too long for a command line goes into a `-/filter_complex` file. + crossfades (`xfade`) of 1 s, at most half a picture's time, or cuts. At most `MediaOptions.MaxSlides` (300) + pictures, not counting downloads that failed; the queue refuses more before any is converted. A filter graph too + long for a command line goes into a `-/filter_complex` file. - **Remove metadata** keeps the format. Images lose EXIF, XMP, IPTC, comments and the ICC profile, and a JPEG keeps its own quality. Video and audio are copied without global and per-stream tags, chapters and data tracks; an MKV's attachments (subtitle fonts) stay, with their file name and type. PDFs lose their document information and every - XMP packet (`PdfSharpConverter.StripMetadata`); pages, links, bookmarks and form fields stay. + XMP packet (`PdfSharpConverter.StripMetadata`), and PDFsharp writes a new one holding only its own name and the save + date; pages, links, bookmarks and form fields stay. ## Office Open XML variants diff --git a/src/Filee.Core/Conversion/JobQueue.cs b/src/Filee.Core/Conversion/JobQueue.cs index 05a22fd2b..a9ccbd8a3 100644 --- a/src/Filee.Core/Conversion/JobQueue.cs +++ b/src/Filee.Core/Conversion/JobQueue.cs @@ -352,11 +352,13 @@ private async Task RunJoinedMediaAsync(ConversionJob job, RoutePlanner planner, { var slideshow = job.Preset.Media.Slideshow; var target = FormatRegistry.Get(job.Preset.TargetFormat); - // Before any picture is converted: too many fail at once instead of after minutes of PNG steps. - if (slideshow && job.Files.Count(f => !Stopped(f)) > MediaOptions.MaxSlides) + // Before any picture is converted: too many fail at once instead of after minutes of PNG steps. Files whose + // download already failed don't count. + var pictures = job.Files.Count(f => !Stopped(f)); + if (slideshow && pictures > MediaOptions.MaxSlides) { foreach (var f in job.Files.Where(f => !Stopped(f))) - FailFile(job, f, "error.too_many_pictures", $"{job.Files.Count} > {MediaOptions.MaxSlides}"); + FailFile(job, f, "error.too_many_pictures", $"{pictures} > {MediaOptions.MaxSlides}"); return; } var parts = new IReadOnlyList?[job.Files.Count]; @@ -754,7 +756,8 @@ private async Task> RunFileAsync( inputs = outputs; } - if (!zipResult && finalOutput && job.Preset.Output.OnlyIfSmaller && inputs.Count == 1 && CopyIfNotSmaller(file.SourcePath, inputs[0])) + // Under "Save as ZIP" the output is still in the work folder: the source copied over it goes into the ZIP. + if (finalOutput && job.Preset.Output.OnlyIfSmaller && inputs.Count == 1 && CopyIfNotSmaller(file.SourcePath, inputs[0])) file.NoticeKey = "notice.pdf_not_smaller"; if (zipResult && inputs.Count > 0) diff --git a/src/Filee.Core/Presets/BuiltInData.cs b/src/Filee.Core/Presets/BuiltInData.cs index 539d8d56a..6c58997da 100644 --- a/src/Filee.Core/Presets/BuiltInData.cs +++ b/src/Filee.Core/Presets/BuiltInData.cs @@ -435,8 +435,9 @@ public static List CreateProfiles() => Id = "images", NameKey = "profile.images", Extensions = ExtensionsOf(FormatCategory.Image), PresetIds = ["to-png", "to-jpg", "to-webp", "to-pdf", "trace-svg", "trace-line-art", "to-tiff", "to-bmp", "to-ico", "half-size"], - BackPresetIds = ["tool-crop", "rotate", "flip", "remove-background", "trim-transparent", "grayscale", "extract-motion", "to-avif", "to-gif", - "merge-pdf", "compress-image", "remove-metadata", "zip-all"], + // Full (ToolbarProfile.MaxSlices): "Extract Live Photo video" stays in the library, on no donut. + BackPresetIds = ["tool-crop", "rotate", "flip", "remove-background", "trim-transparent", "grayscale", "to-avif", "to-gif", "merge-pdf", + "compress-image", "remove-metadata", "zip-all"], }, new() { diff --git a/tests/Filee.Core.Tests/MediaToolTests.cs b/tests/Filee.Core.Tests/MediaToolTests.cs index 6bcbab38f..b8ba6db9e 100644 --- a/tests/Filee.Core.Tests/MediaToolTests.cs +++ b/tests/Filee.Core.Tests/MediaToolTests.cs @@ -98,10 +98,43 @@ public void Version_36_libraries_get_the_tools_and_back_faces_with_room_offer_th var video = profiles.Single(p => p.Id == "video").BackPresetIds!; Assert.Equal(BuiltInData.CreateProfiles().Single(p => p.Id == "video").BackPresetIds, video); Assert.Equal(full, pdf.BackPresetIds); + Assert.Equal(BuiltInData.CreateProfiles().Single(p => p.Id == "images").BackPresetIds, + profiles.Single(p => p.Id == "images").BackPresetIds); Assert.False(SettingsMigrations.ApplyToLibrary(36, presets, profiles)); // once only Assert.False(SettingsMigrations.ApplyToLibrary(AppSettings.CurrentSchemaVersion, presets, profiles)); } + [Fact] + public void A_new_library_images_back_face_is_full_without_the_live_photo_video() + { + var presets = BuiltInData.CreatePresets(); + var images = BuiltInData.CreateProfiles().Single(p => p.Id == "images").BackPresetIds!; + Assert.Equal(ToolbarProfile.MaxSlices, images.Count); + Assert.Contains("compress-image", images); + Assert.Contains("remove-metadata", images); + Assert.DoesNotContain("extract-motion", images); + Assert.Single(presets, p => p.Id == "extract-motion"); // still in the library + } + + [Fact] + public void A_schema_36_images_back_face_takes_what_fits_and_never_more_than_the_donut_holds() + { + var presets = BuiltInData.CreatePresets().Where(p => !ToolIds.Contains(p.Id)).ToList(); + var profiles = BuiltInData.CreateProfiles(); + // The images back face as schema 36 wrote it: eleven slices, "Extract Live Photo video" among them. + var images = profiles.Single(p => p.Id == "images"); + images.BackPresetIds = ["tool-crop", "rotate", "flip", "remove-background", "trim-transparent", "grayscale", "extract-motion", + "to-avif", "to-gif", "merge-pdf", "zip-all"]; + + Assert.True(SettingsMigrations.ApplyToLibrary(36, presets, profiles)); + + Assert.Equal(ToolbarProfile.MaxSlices, images.BackPresetIds.Count); + Assert.Equal(["merge-pdf", "compress-image", "zip-all"], images.BackPresetIds[^3..]); + Assert.DoesNotContain("remove-metadata", images.BackPresetIds); // no room left, still in the library + Assert.Single(presets, p => p.Id == "remove-metadata"); + Assert.All(profiles, p => Assert.InRange(p.BackPresetIds!.Count, 0, ToolbarProfile.MaxSlices)); + } + [Fact] public void Extract_audio_comes_onto_a_video_back_face_without_the_m4a_slice_when_there_is_room() { @@ -220,6 +253,33 @@ public async Task Compressing_keeps_the_original_when_the_copy_would_not_be_smal Assert.Equal("x", File.ReadAllText(Path.Combine(dir.Path, "tiny_compressed.jpg"))); // the original, copied } + [Fact] + public async Task Compressing_into_a_zip_keeps_the_original_when_the_copy_would_not_be_smaller() + { + using var dir = new TempDir(); + var big = dir.File("big.jpg", new string('x', 10_000)); + var tiny = dir.File("tiny.jpg", "x"); + var preset = BuiltIn("compress-image").Clone(); + preset.Output.ZipResult = true; + await using var queue = new JobQueue(new ConverterCatalog([new FakeConverter("magick", new ConversionEdge("jpg", "jpg"))])); + + var job = await Run(queue, [big, tiny], preset); + + Assert.Equal(JobState.Completed, job.State); + Assert.Null(job.Files[0].NoticeKey); + Assert.True(ZipEntry(job.Files[0].Outputs.Single()).Length < 10_000); + Assert.Equal("notice.pdf_not_smaller", job.Files[1].NoticeKey); + Assert.Equal("x", ZipEntry(job.Files[1].Outputs.Single())); // the original, packed + Assert.Equal("x", File.ReadAllText(tiny)); + + static string ZipEntry(string zip) + { + using var archive = System.IO.Compression.ZipFile.OpenRead(zip); + using var reader = new StreamReader(Assert.Single(archive.Entries).Open()); + return reader.ReadToEnd(); + } + } + [Fact] public async Task A_failed_join_frees_its_name_and_removes_the_subfolder_it_made() { @@ -270,9 +330,38 @@ public async Task Too_many_pictures_fail_before_any_of_them_is_converted() Assert.Equal(JobState.Failed, job.State); Assert.All(job.Files, f => Assert.Equal("error.too_many_pictures", f.ErrorKey)); + Assert.All(job.Files, f => Assert.Equal($"{MediaOptions.MaxSlides + 1} > {MediaOptions.MaxSlides}", f.ErrorDetail)); Assert.Empty(joiner.Inputs); } + [Fact] + public async Task Too_many_pictures_counts_only_the_ones_that_were_saved() + { + using var dir = new TempDir(); + var photos = Enumerable.Range(1, MediaOptions.MaxSlides + 1).Select(i => dir.File($"p{i:D3}.jpg")).ToList(); + var converter = new FakeConverter("magick", new ConversionEdge("jpg", "png")) { Throw = true }; + await using var queue = new JobQueue(new ConverterCatalog([converter]), mediaJoiner: new FakeJoiner()); + var job = new ConversionJob([.. photos, "web/lost.jpg"], BuiltIn("video-from-images"), "Test", new FailingFetcher()); + + await queue.RunAsync(job); + + Assert.Equal("error.web_download_failed", job.Files[^1].ErrorKey); + Assert.All(job.Files.SkipLast(1), f => Assert.Equal($"{MediaOptions.MaxSlides + 1} > {MediaOptions.MaxSlides}", f.ErrorDetail)); + } + + /// A browser download that never arrives. + private sealed class FailingFetcher : ISourceFetcher + { + public bool Fetches(string source) => source.StartsWith("web/", StringComparison.Ordinal); + + public Task FetchAsync(string source, IProgress progress, CancellationToken cancellationToken) => + throw new IOException("gone"); + + public void Release(ConversionJob job) + { + } + } + [Fact] public async Task A_kept_original_goes_next_to_its_source_like_a_compressed_copy() { From 781e61867d3200342caaeb9dfee53acf16a7f2d0 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 23 Oct 2026 09:55:53 +0900 Subject: [PATCH 20/20] Keep the released 2.0.0 CHANGELOG section as it shipped Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d35943e72..d5a1cee08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,8 +144,8 @@ pt-BR: Correções de segurança para e-books, SVG, modelos 3D e e-mails, e muit run. Attachments dragged out of Outlook and files dragged out of a ZIP in Explorer open the donut too. - **The donut's back face** (issue #195). Every donut has a second set of up to 12 slices. While the donut is open, press **Tab** or turn the **mouse wheel** to flip it; the hub shows the hint when the back has something for your - files. Built-in donuts come with a back face: Crop, Rotate, Flip and Remove background on the image donut, - Compress PDF on the PDF donut, frame extraction and Live / Motion Photos on the video donut, One ZIP on most. + files. Built-in donuts come with a back face: Crop, Rotate, Flip, Remove background and Live Photo video on the image + donut, Compress PDF on the PDF donut, frame extraction and Live / Motion Photos on the video donut, One ZIP on most. The Front / Back switch in Settings → Donut toolbar edits it, and Settings → Shortcuts → "Flip the donut" changes the key or turns the wheel off. - **Crop** (by @smjrifle). The Crop slice (on the back of the image donut) opens your pictures one after another in a