diff --git a/README.md b/README.md index f74c101e8..54ac0313d 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/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6cc84ccf8..5cd601a26 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. Renderers check only the original source for referenced fonts. `ConversionStep.MissingFonts` reports any missing families to `FileResult.MissingFonts`; the toast card can offer one trusted source, while the CLI and watch jobs @@ -136,7 +140,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 bd9f6a0ba..e80f3c9e7 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -7,12 +7,12 @@ 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) | +| 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) | +| 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 | @@ -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) | @@ -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 @@ -325,7 +325,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 @@ -398,7 +399,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 | @@ -700,6 +701,31 @@ 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 + +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`); 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 + 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 + 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`), 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 `Office/OoxmlConverter.cs` converts between the variants of a family (Word, Excel, PowerPoint) by copying the @@ -762,7 +788,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 @@ -781,10 +807,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 @@ -796,6 +823,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 c51a675d7..f67efc60f 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.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 قراءته (صورة Motion Photo من Android، أو Live Photo من iPhone مع ملف MOV بجانبها)", "error.live_photo_start": "يبدأ المقطع بعد نهاية هذا الفيديو. اختر «بداية المقطع» أبكر", @@ -129,7 +130,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.", @@ -217,6 +218,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", @@ -481,6 +490,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 2b6e8a5aa..fabe56e31 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -86,6 +86,7 @@ "error.cannot_turn": "Rotate and flip work on images only", "error.no_transparency": "This format has no transparency, so there's nothing to trim", "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\"", @@ -136,7 +137,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.", @@ -227,6 +228,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", @@ -496,6 +505,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 f095d775c..f0615151a 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -80,6 +80,7 @@ "error.cannot_turn": "Rotar y voltear solo funcionan con imágenes", "error.no_transparency": "Este formato no tiene transparencia, así que no hay nada que recortar", "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", @@ -129,7 +130,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í.", @@ -217,6 +218,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", @@ -481,6 +490,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/fr.json b/src/Filee.App/Assets/i18n/fr.json index 6564f6e8e..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", @@ -136,7 +137,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.", @@ -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/hi.json b/src/Filee.App/Assets/i18n/hi.json index 4a83f984b..ab3f57652 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.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": "क्लिप इस वीडियो के ख़त्म होने के बाद शुरू होती है। \"क्लिप की शुरुआत\" पहले चुनें", @@ -129,7 +130,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 एक-दूसरे में।", @@ -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": "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 7a8b128f9..ea3cbcbd6 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -80,6 +80,7 @@ "error.cannot_turn": "回転と反転は画像でのみ使えます", "error.no_transparency": "この形式には透明部分がないため、切り取るものがありません", "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": "クリップの開始位置が動画の終わりより後です。「クリップの開始」をもっと前にしてください", @@ -129,7 +130,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)を相互変換します。", @@ -217,6 +218,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", @@ -481,6 +490,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 9f80aca39..4047b5f70 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.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": "이 사진에서 읽을 수 있는 동영상을 찾지 못했어요 (안드로이드 모션 포토, 또는 옆에 MOV가 있는 아이폰 라이브 포토)", "error.live_photo_start": "클립 시작이 동영상 끝보다 뒤예요. \"클립 시작\"을 더 앞으로 정해 주세요", @@ -136,7 +137,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를 서로 변환해요.", @@ -227,6 +228,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": "이미지", @@ -496,6 +505,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/ne.json b/src/Filee.App/Assets/i18n/ne.json index c1b2578ab..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": "क्लिप यस भिडियो सकिएपछि सुरु हुन्छ। पहिलेको \"क्लिप सुरु\" समय रोज्नुहोस्", @@ -129,7 +130,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 फन्टहरू एकअर्कामा रूपान्तरण गर्दछ।", @@ -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/pl.json b/src/Filee.App/Assets/i18n/pl.json index c5e5ef3db..3311cdd34 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -86,6 +86,7 @@ "error.cannot_turn": "Obracanie i odbicie działają tylko na obrazach", "error.no_transparency": "Ten format nie ma przezroczystości, więc nie ma czego przycinać", "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”", @@ -136,7 +137,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ą.", @@ -227,6 +228,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", @@ -496,6 +505,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/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 3720b1f2b..8d20abed2 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -86,6 +86,7 @@ "error.cannot_turn": "Girar e espelhar só funcionam em imagens", "error.no_transparency": "Este formato não tem transparência, então não há nada para cortar", "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", @@ -136,7 +137,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.", @@ -227,6 +228,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", @@ -496,6 +505,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", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index eb4678a06..6d5f33abf 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.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": "В этом фото нет видео, которое Filee может прочитать (Motion Photo с Android или Live Photo с iPhone с файлом MOV рядом)", "error.live_photo_start": "Клип начинается после конца этого видео. Выберите более раннее «Начало клипа»", @@ -136,7 +137,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 друг в друга.", @@ -227,6 +228,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": "Изображение", @@ -496,6 +505,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 4e5aea7f0..855d8f244 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.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": "这张照片中没有可读取的视频(安卓动态照片,或旁边有 MOV 的 iPhone 实况照片)", "error.live_photo_start": "片段开始位置在视频结束之后。请把“片段开始”设得更早", @@ -136,7 +137,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 互相转换。", @@ -227,6 +228,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": "图片", @@ -496,6 +505,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/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 675176a5a..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": "片段開始位置在影片結束之後。請把「片段開始」設得更早", @@ -136,7 +137,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 互相轉換。", @@ -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.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/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 bf1225c4f..c3f9a77fa 100644 --- a/src/Filee.App/Services/PresetAvailability.cs +++ b/src/Filee.App/Services/PresetAvailability.cs @@ -110,6 +110,22 @@ private PresetJudgement Evaluate(Preset preset, IReadOnlyList format if (preset.Image.TrimTransparent && formats.All(f => !Filee.Engines.Magick.ImageEncoder.HoldsAlpha(preset.TargetFormat == BuiltInData.SameAsSource ? f.Id : preset.TargetFormat))) return new(PresetUse.Unusable, _loc["error.no_transparency"]); + // "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; 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 37779f981..88c6fd318 100644 --- a/src/Filee.Core/Conversion/IConverter.cs +++ b/src/Filee.Core/Conversion/IConverter.cs @@ -222,6 +222,26 @@ 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 +{ + /// + /// 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 935dfdc5d..a9ccbd8a3 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,144 @@ 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 + /// 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); + // 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", $"{pictures} > {MediaOptions.MaxSlides}"); + return; + } + 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", + ceiling: SlidesReadyShare); + 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 allocator = CreateFinalAllocator(job, first.SourcePath, 1, oneFile: true); + var output = allocator.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) + { + 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); + } + 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) { @@ -479,11 +624,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)) @@ -547,6 +693,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; @@ -572,7 +719,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; @@ -609,6 +756,10 @@ private async Task> RunFileAsync( inputs = outputs; } + // 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) inputs = PackZip(job, file, index, inputs, ct); // One output, and nothing else handed out for it (a Markdown file's picture folder goes with it). @@ -624,7 +775,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; } @@ -713,6 +864,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 scan; one that fails (an unreadable file) finds nothing, and the conversion reports the file. private TextScripts? ScanScripts(ITextShapingCheck check, string path, string format) { diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs index a08ee2ce8..2542fed9b 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), @@ -134,8 +135,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. @@ -165,6 +167,7 @@ public static class FormatRegistry new("vcf", "vCard", FormatCategory.Data, ["vcf", "vcard"]), // A browser's recorded network requests (HTTP Archive): JSON, read as one table row per request (issue #328). new("har", "HAR", FormatCategory.Data, ["har"], Writable: false), + 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 11d5a54a0..6c58997da 100644 --- a/src/Filee.Core/Presets/BuiltInData.cs +++ b/src/Filee.Core/Presets/BuiltInData.cs @@ -76,6 +76,7 @@ .. PdfSlidesPresets(), .. FramePresets(), .. MotionPhotoPresets(), .. LivePhotoPresets(), + .. MediaToolPresets(), new() { Id = "to-docx", Name = "DOCX", TargetFormat = "docx" }, new() { Id = "to-hwpx", Name = "HWPX", TargetFormat = "hwpx" }, .. HwpPresets(), @@ -125,6 +126,62 @@ public static List TrimPresets() => }, ]; + /// + /// 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. + /// + 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", 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", OnlyIfSmaller = true }, + }, + 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", OnlyIfSmaller = true }, + }, + 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. @@ -353,7 +410,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() => [ @@ -363,6 +420,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" }, ]; /// @@ -377,15 +435,16 @@ 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", "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() { 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() { @@ -434,7 +493,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). @@ -475,14 +534,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() { @@ -516,7 +575,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/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 cae156fc7..324a971b1 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; } @@ -409,6 +429,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. @@ -551,6 +577,57 @@ 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 1080p video, each shown for , fitted in with + /// black bars. + /// + 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; + + /// 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 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 + /// 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 ? 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 3df95d287..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 = 33; + 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 faad6cfe5..a6b226651 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -772,6 +772,96 @@ 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; + } + + /// 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"]), + ("video", ["compress-video", "merge-videos", "extract-audio"]), + ("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 Version37Offers) + { + 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 + || presets.All(p => p.Id != id)) + continue; + InsertBefore(back, id, "zip-all"); + changed = true; + } + } return changed; } diff --git a/src/Filee.Engines/Data/DataConverter.cs b/src/Filee.Engines/Data/DataConverter.cs index 9053186e1..5ded238d4 100644 --- a/src/Filee.Engines/Data/DataConverter.cs +++ b/src/Filee.Engines/Data/DataConverter.cs @@ -1,9 +1,9 @@ -// 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. HAR network -// logs are read as JSON, and become a table of their requests (HarData). +// Spreadsheets reach JSON and the others through CSV (XLSX → CSV → JSON), contacts and calendars through CSV as well. HAR +// network logs are read as JSON, and become a table of their requests (HarData). using System.Text; using Filee.Core.Conversion; @@ -13,7 +13,7 @@ namespace Filee.Engines.Data; -/// JSON, JSON Lines, YAML, TOML, XML and CSV into each other; vCard ↔ CSV; HAR → all of them. +/// JSON, JSON Lines, YAML, TOML, XML and CSV into each other; vCard and iCalendar ↔ CSV; HAR → all of them. public sealed class DataConverter : IConverter { private static readonly string[] Trees = ["json", "jsonl", "yaml", "toml", "xml"]; @@ -30,10 +30,12 @@ public sealed class DataConverter : IConverter new("vcf", "csv"), new("csv", "vcf"), .. Trees.Append("csv").Select(to => new ConversionEdge("har", to)), + 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))}"); @@ -65,6 +67,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) + { + 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; + if (!address.Contains(':')) + address = "mailto:" + address; + if (display.Length == 0) + return name + ":" + address; + display = display.Replace("\"", ""); + 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!; + var zoneId = cells[5].IndexOfAny(['\r', '\n', ':', ';', '"']) < 0 ? cells[5] : ""; + if (zoneId.Length == 0) + continue; + try + { + 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(zoneId, out var previous) + || hasStart && !previous.HasStart || hasStart == previous.HasStart && instant < previous.Instant)) + zones[zoneId] = (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!; + var zoneId = cells[5].IndexOfAny(['\r', '\n', ':', ';', '"']) < 0 ? cells[5] : ""; + TimeZoneInfo? zone = null; + if (zones.TryGetValue(zoneId, out var definition)) + zone = definition.Zone; + Line(output, "BEGIN:" + component.Type); + 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], zoneId, zone); + if (start.Length > 0) + Line(output, start); + var end = DateLine(component.Type == "VTODO" ? "DUE" : "DTEND", cells[3], zoneId, 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 && cells[10].IndexOfAny(['\r', '\n']) < 0) + Line(output, "RRULE:" + cells[10]); + 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"); + 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/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/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/Magick/ImageEncoder.cs b/src/Filee.Engines/Magick/ImageEncoder.cs index 3296b6646..e41924451 100644 --- a/src/Filee.Engines/Magick/ImageEncoder.cs +++ b/src/Filee.Engines/Magick/ImageEncoder.cs @@ -316,8 +316,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": @@ -334,6 +338,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..27d808a3d 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,102 @@ 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) + { + 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; + + List args; + TimeSpan duration; + double start; + 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)) + { + 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, start, 1, cancellationToken); + if (!File.Exists(outputPath) || new FileInfo(outputPath).Length == 0) + throw new InvalidOperationException("FFmpeg did not write any output."); + progress?.Report(1); + } + + /// 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) + { + // 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, + MediaJoining.SlideshowWidth, MediaJoining.SlideshowHeight), null, null, 0, 0, cancellationToken); + slides.Add(slide); + progress?.Report(0.2 * (i + 1) / pictures.Count); + } + return slides; + } + + /// 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 98f514012..f088ab3a2 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,76 @@ 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"]); + // 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; + } + /// /// 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 +828,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 +901,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 +910,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/MediaInfo.cs b/src/Filee.Engines/Media/MediaInfo.cs index 3f5fdde23..862469f37 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 @@ -75,8 +79,9 @@ internal sealed record AudioStreamInfo(int Channels, int SampleRate, string? Cod /// A bare H.264 / H.265 stream with no container (a camera's .h264 / .h265): its frames carry no timestamps, so it is /// always encoded, never copied into a container. /// +/// The file carries attachments (an MKV's subtitle fonts). internal sealed record MediaInfo(TimeSpan? Duration, VideoStreamInfo? Video, AudioStreamInfo? Audio, int? CoverStreamIndex, - IReadOnlyList? SubtitleStreams = null, bool RawStream = false) + IReadOnlyList? SubtitleStreams = null, bool RawStream = false, bool HasAttachments = false) { /// Every subtitle stream, in file order. public IReadOnlyList Subtitles => SubtitleStreams ?? []; @@ -87,7 +92,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) { @@ -107,6 +112,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) { @@ -129,12 +135,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)); @@ -148,7 +158,7 @@ public static MediaInfo Parse(string json) seconds = longestStream; var raw = root.TryGetProperty("format", out var container) && Text(container, "format_name") is "h264" or "hevc"; return new MediaInfo(seconds > 0 ? TimeSpan.FromSeconds(seconds) : null, video, audio, cover, - subtitles.Count > 0 ? subtitles : null, raw); + subtitles.Count > 0 ? subtitles : null, raw, 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 new file mode 100644 index 000000000..fd174c21c --- /dev/null +++ b/src/Filee.Engines/Media/MediaJoining.cs @@ -0,0 +1,283 @@ +// 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 +{ + /// 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 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) + { + 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 + && 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) + 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 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 + ? 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) = (SlideshowWidth, SlideshowHeight); + 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/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/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/src/Filee.Engines/Vector/HpglPlot.cs b/src/Filee.Engines/Vector/HpglPlot.cs new file mode 100644 index 000000000..3d38d5714 --- /dev/null +++ b/src/Filee.Engines/Vector/HpglPlot.cs @@ -0,0 +1,394 @@ +// HP-GL and HP-GL/2 plot files (.plt, issue #410): what AutoCAD and other programs "plot to file" for pen plotters +// and large-format printers, also cutting plotters. The pen moves become SVG polylines at the plot's real size (40 +// plotter units per millimetre), and the vector engine renders PDF, PNG, … from there. +// +// 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), 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; + +namespace Filee.Engines.Vector; + +internal static class HpglPlot +{ + /// Plotter 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 (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 = 0; + while (i < s.Length) + { + var c = s[i]; + if (c == 27) + { + // 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)) + { + 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 = 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; + } + } + + /// + /// 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 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) from , 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.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 84d53830e..52134aa6a 100644 --- a/tests/Filee.App.Tests/CropOverlayTests.cs +++ b/tests/Filee.App.Tests/CropOverlayTests.cs @@ -112,6 +112,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() { diff --git a/tests/Filee.App.Tests/DonutFlipTests.cs b/tests/Filee.App.Tests/DonutFlipTests.cs index ca80dffc1..fc27b259c 100644 --- a/tests/Filee.App.Tests/DonutFlipTests.cs +++ b/tests/Filee.App.Tests/DonutFlipTests.cs @@ -682,9 +682,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 37): 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 d881886aa..bc5f5ad57 100644 --- a/tests/Filee.Core.Tests/FakeConverter.cs +++ b/tests/Filee.Core.Tests/FakeConverter.cs @@ -166,6 +166,28 @@ 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; } + + /// 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); + 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..b8ba6db9e --- /dev/null +++ b/tests/Filee.Core.Tests/MediaToolTests.cs @@ -0,0 +1,399 @@ +// 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; +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 = 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); + 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_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 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. + 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(36, 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); + // "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(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() + { + 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(36, 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() + { + 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_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 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() + { + 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.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() + { + 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() + { + 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.Core.Tests/PltFormatTests.cs b/tests/Filee.Core.Tests/PltFormatTests.cs new file mode 100644 index 000000000..28061dabb --- /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_34_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(34, presets, profiles)); + + Assert.Contains("plt", profiles.Single(p => p.Id == "vector").Extensions); + } +} diff --git a/tests/Filee.Core.Tests/ScriptTextTests.cs b/tests/Filee.Core.Tests/ScriptTextTests.cs new file mode 100644 index 000000000..ad482f631 --- /dev/null +++ b/tests/Filee.Core.Tests/ScriptTextTests.cs @@ -0,0 +1,33 @@ +// Lua scripts and CSS style sheets are plain text (issues #388, #368): recognised, and learnt by existing text donuts. + +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Core.Profiles; +using Filee.Core.Settings; + +namespace Filee.Core.Tests; + +public class ScriptTextTests +{ + [Theory] + [InlineData("init.lua")] + [InlineData("site.css")] + public void Scripts_and_style_sheets_are_text(string name) => Assert.Equal("txt", FormatRegistry.Detect(name)?.Id); + + [Fact] + public void Version_33_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(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(33, presets, profiles)); + } +} diff --git a/tests/Filee.Core.Tests/UserDataStoreTests.cs b/tests/Filee.Core.Tests/UserDataStoreTests.cs index 142da41e2..4b06239b4 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_35_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 = 35; + 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 = 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"); + 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 = 35; + 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/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/HpglPlotTests.cs b/tests/Filee.Engines.Tests/HpglPlotTests.cs new file mode 100644 index 000000000..1db8e766c --- /dev/null +++ b/tests/Filee.Engines.Tests/HpglPlotTests.cs @@ -0,0 +1,106 @@ +// 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 independently of Filee's decoder, so the test is not circular. + +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/ICalendarTests.cs b/tests/Filee.Engines.Tests/ICalendarTests.cs new file mode 100644 index 000000000..dc6a6e188 --- /dev/null +++ b/tests/Filee.Engines.Tests/ICalendarTests.cs @@ -0,0 +1,582 @@ +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 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() + { + 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", "", "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:+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:+0400\r\nTZOFFSETTO:+0400", output); + var back = await ReadCalendar(output); + Assert.EndsWith("+04:00", 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); + } +} diff --git a/tests/Filee.Engines.Tests/MediaJoiningTests.cs b/tests/Filee.Engines.Tests/MediaJoiningTests.cs new file mode 100644 index 000000000..9bd359853 --- /dev/null +++ b/tests/Filee.Engines.Tests/MediaJoiningTests.cs @@ -0,0 +1,221 @@ +// 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", HeaderHash: "MD5:1"); + + 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 })); + // 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] + 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 }; + + 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")); + } + + [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")); + 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); + 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..dec78b719 --- /dev/null +++ b/tests/Filee.Engines.Tests/MediaToolRealTests.cs @@ -0,0 +1,493 @@ +// 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(); + } + + /// 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] + 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); + // 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 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() + { + 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", TestContext.Current.CancellationToken); + 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, TestContext.Current.CancellationToken)); + 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_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() + { + 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, TestContext.Current.CancellationToken))); + 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, TestContext.Current.CancellationToken)); + 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, TestContext.Current.CancellationToken)); + 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)); + } +} 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() 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); + } +} 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