From 90403b5e3c1135cbf319313ebbb737f9db5d34aa Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Wed, 4 Nov 2026 13:49:20 +0900 Subject: [PATCH] Read signed .p7m files as the file inside them (#1010) A .p7m is a CMS / PKCS #7 SignedData envelope (Italian e-invoices *.xml.p7m, signed PDFs *.pdf.p7m). The new built-in "p7m" engine takes the signed file out with SignedCms (DER, BER, Base64 or PEM; envelopes signed again are opened again, at most 8 deep; at most 512 MB). The signature is not verified or trusted: no chain, no revocation, no network. The format inside comes from the inner name (invoice.xml.p7m) or the first bytes. A new IEnvelopeOpener lets JobQueue ask for it before planning, and RoutePlanner.PlanThrough opens the envelope first and plans on from there, so P7M -> PDF -> PNG and P7M -> XML -> JSON are one conversion. Outputs are named after the file inside (invoice.pdf.p7m -> invoice.pdf). p7m is a read-only document input graded general; the documents donut learns it (schema 66). New strings are in all 20 languages. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 6 +- docs/ENGINES.md | 26 ++ src/Filee.App/Assets/i18n/ar.json | 7 + src/Filee.App/Assets/i18n/de.json | 7 + src/Filee.App/Assets/i18n/en.json | 7 + src/Filee.App/Assets/i18n/es.json | 7 + src/Filee.App/Assets/i18n/fr.json | 7 + src/Filee.App/Assets/i18n/hi.json | 7 + src/Filee.App/Assets/i18n/id.json | 7 + src/Filee.App/Assets/i18n/it.json | 7 + src/Filee.App/Assets/i18n/ja.json | 7 + src/Filee.App/Assets/i18n/ko.json | 7 + src/Filee.App/Assets/i18n/ne.json | 7 + src/Filee.App/Assets/i18n/pl.json | 7 + src/Filee.App/Assets/i18n/pt-BR.json | 7 + src/Filee.App/Assets/i18n/pt-PT.json | 7 + src/Filee.App/Assets/i18n/ru.json | 7 + src/Filee.App/Assets/i18n/th.json | 7 + src/Filee.App/Assets/i18n/tr.json | 7 + src/Filee.App/Assets/i18n/vi.json | 7 + src/Filee.App/Assets/i18n/zh-CN.json | 7 + src/Filee.App/Assets/i18n/zh-TW.json | 7 + src/Filee.Core/Conversion/IConverter.cs | 18 ++ src/Filee.Core/Conversion/JobQueue.cs | 51 ++-- src/Filee.Core/Conversion/RoutePlanner.cs | 26 ++ src/Filee.Core/Formats/FormatFields.cs | 2 +- src/Filee.Core/Formats/FormatRegistry.cs | 16 +- src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 4 + src/Filee.Engines/EngineRegistry.cs | 2 + src/Filee.Engines/Signed/P7mConverter.cs | 266 ++++++++++++++++++++ tests/Filee.Core.Tests/EnvelopeTests.cs | 128 ++++++++++ tests/Filee.Core.Tests/FormatFieldsTests.cs | 2 +- tests/Filee.Engines.Tests/FonTests.cs | 3 +- tests/Filee.Engines.Tests/P7mTests.cs | 198 +++++++++++++++ 35 files changed, 867 insertions(+), 23 deletions(-) create mode 100644 src/Filee.Engines/Signed/P7mConverter.cs create mode 100644 tests/Filee.Core.Tests/EnvelopeTests.cs create mode 100644 tests/Filee.Engines.Tests/P7mTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9f29e4c34..82927b479 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -22,7 +22,7 @@ │ Built in: Magick (images, RAW) · PdfSharp · Pdfium · PdfPig · Svg.Skia · ICNS │ │ HwpxWriter + DocxWriter over DocumentReaders (DOCX, XLSX, XLS, ODS, PPTX, │ │ PDF, HTML, EPUB, MOBI, FB2, HWPX, Markdown) · Spreadsheets · OOXML variants │ - │ · E-mail · Data · E-books · CAD · Fonts · Subtitles · 3D models │ + │ · E-mail · Signed files · Data · E-books · CAD · Fonts · Subtitles · 3D models │ │ Bundled: rhwp (HWP→PDF, HWP↔HWPX) · 7-Zip (archives) · Unhwp · Markdig │ │ Optional: FFmpeg · LibreOffice + H2Orestart · calibre · Ghostscript · Pandoc │ └────────────────────────────────────────────────────────────────────────────────────┘ @@ -42,7 +42,9 @@ 4. **Plan** – for every file, `RoutePlanner` finds the cheapest chain of converter edges from the source format to the target (at most 3 hops). Engine priority (Settings → Engines) breaks ties. Some edges are limited: tracing only starts from raster images (`ImageSourcesOnly`), and PDF → PPTX picture slides are only ever a route of their - own (`DirectOnly`), never a step of a longer one. + own (`DirectOnly`), never a step of a longer one. An envelope (a signed `.p7m`) is planned from the file inside + it: its engine (`IEnvelopeOpener`) names that file's format, and the route opens it first, then goes on from there + (`RoutePlanner.PlanThrough`: P7M → PDF → PNG). 5. **Run** – `JobQueue` runs steps with per-engine concurrency limits. Intermediate files live in a temp folder; final outputs are named by `OutputPathResolver` (pattern, folder, conflict policy; the source is never overwritten). Each name stays reserved while a conversion holds it. A conversion that fails or is cancelled deletes what it began diff --git a/docs/ENGINES.md b/docs/ENGINES.md index ea19f378e..4d54ffcce 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -22,6 +22,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | 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 | | PDF tables | built in (PdfPig) | **PDF → XLSX, ODS, CSV** (#267): each table drawn with lines on its own sheet, numbers and dates as real values, merged cells and column widths kept | | 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) | +| Signed files | built in (System.Security.Cryptography.Pkcs) | **P7M → the signed file inside** (CMS / PKCS #7, CAdES: Italian e-invoices `*.xml.p7m`, signed PDFs `*.pdf.p7m`), then on from its format (P7M → PDF → JPG); the signature isn't checked | | 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**; FreeMASTER oscilloscope TXT → 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 | @@ -961,6 +962,31 @@ 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. +## Signed files (.p7m) + +A `.p7m` (#1010) is a CMS / PKCS #7 SignedData envelope (RFC 5652; CAdES as Italian e-invoices and signed PDFs are +made): the signed file with its signature around it. `Signed/P7mConverter.cs` takes the file out with .NET's +`SignedCms` (System.Security.Cryptography.Pkcs): DER or BER, or the same saved as Base64 or PEM text. An envelope +signed again is opened again, up to 8 deep. Envelopes over 512 MB are refused. + +- **The signature is not checked**, and nothing in it is trusted: no certificate chain, no revocation list, no + time-stamp server, no network. The result is the plain file without the proof the signature gives; the `.p7m` is + left as it was. +- **The format inside** comes from the envelope's name (`invoice.xml.p7m` → XML), else from the first bytes: PDF and + pictures (as for files from a browser), XML, HTML, RTF, a MIME message (a signed e-mail, `smime.p7m`), ZIP and the + Word, Excel, PowerPoint, OpenDocument and EPUB files in one, plain UTF-8 text. Anything else is refused + (`error.p7m_unknown_content`). +- **The route** goes on from that format. The converter is an `IEnvelopeOpener`: `JobQueue` asks it for the format + inside before planning, and `RoutePlanner.PlanThrough` puts the step to it first (P7M → PDF → PNG); "same format", + OCR and the other options apply to the file inside. The converter has an edge from P7M to every format, so the donut + offers what any file inside could become, and a file inside that can't become the target fails with its own + format's "no route" (`xml → mp4`). +- **Names**: outputs are named after the file inside (`FormatRegistry.NameWithoutExtension`: `invoice.pdf.p7m` → + `invoice.pdf`, `invoice.png`). +- **Refused**: a detached signature (`error.p7m_detached`: the signed file is kept elsewhere), an encrypted S/MIME + envelope (`error.p7m_encrypted`: EnvelopedData, only the recipient's key opens it) and anything that isn't a + SignedData (`error.p7m_unreadable`). + ## 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 diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index e65e9cb7c..f9bd81852 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -98,6 +98,11 @@ "download.badge_files": "{0} · {1} من أصل {2} من الملفات", "error.unsupported_source": "نوع الملف هذا غير مدعوم", "error.empty_file": "الملف فارغ (0 بايت). يبقى الملف المُنشأ باستخدام «جديد» من قائمة النقر بالزر الأيمن فارغًا حتى تفتحه في تطبيقه وتحفظه", + "error.p7m_unreadable": "هذا ليس ملفًا موقّعًا (.p7m) يستطيع Filee فتحه، أو أنه تالف", + "error.p7m_detached": "لا يحتوي ملف .p7m هذا إلا على التوقيع، لا على الملف الموقّع. حوّل الملف الذي يوقّعه بدلًا منه", + "error.p7m_encrypted": "ملف .p7m هذا مشفّر وليس موقّعًا فقط: لا يفتحه إلا مفتاح المستلم", + "error.p7m_unknown_content": "الملف داخل ملف .p7m هذا من نوع لا يحوّله Filee", + "error.p7m_too_large": "الملف الموقّع أكبر من أن يُفتح", "format_request.request": "طلب دعم هذه الصيغة", "format_request.notice_title": "لا يستطيع Filee تحويل ملفات {0} بعد", "format_request.notice_text": "أخبرنا بالصيغة التي تحتاجها.\nتساعد الطلبات في تحديد الصيغ التي سيدعمها Filee لاحقًا.\nلا يمكن تحويل الملفات المشفّرة أو المحمية من النسخ (DRM)، مهما كانت صيغتها.", @@ -211,6 +216,8 @@ "engine.pdf-tables.description": "يحوّل الجداول المرسومة بخطوط في ملف PDF إلى مصنف Excel، لكل جدول ورقة، مع الأرقام والتواريخ كقيم حقيقية.", "engine.email.name": "E-mail (built-in)", "engine.email.description": "تحويل رسائل البريد (EML وOutlook MSG) إلى HTML ونص مع المرسل والمستلمين والتاريخ والصور، وتجميع المرفقات في ZIP.\nتحويل MSG إلى EML، وصفحات الويب المحفوظة (MHTML) إلى ملف HTML واحد مع صورها.", + "engine.p7m.name": "Signed files (built-in)", + "engine.p7m.description": "يستخرج الملف الموقّع من ملف .p7m (CMS / PKCS #7: الفواتير الإلكترونية الإيطالية وملفات PDF الموقّعة) ثم يحوّله. لا يُتحقَّق من التوقيع ولا يبقى في الناتج: احتفظ بملف .p7m كإثبات.", "engine.data.name": "Data (built-in)", "engine.data.description": "التحويل بين JSON وJSON Lines وYAML وTOML وXML وCSV.\nتحويل جهات اتصال vCard وأحداث iCalendar إلى CSV والعكس.\nتُحذف التعليقات. تدعم CSV البيانات الجدولية فقط.", "engine.vector.name": "Vector graphics (built-in)", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 23d4dd594..54ad399a3 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -98,6 +98,11 @@ "download.badge_files": "{0} · {1} von {2} Dateien", "error.unsupported_source": "Dieser Dateityp wird nicht unterstützt", "error.empty_file": "Die Datei ist leer (0 Bytes). Eine über „Neu“ im Kontextmenü erstellte Datei bleibt leer, bis du sie in ihrem Programm öffnest und speicherst", + "error.p7m_unreadable": "Das ist keine signierte .p7m-Datei, die Filee öffnen kann, oder sie ist beschädigt", + "error.p7m_detached": "Diese .p7m enthält nur die Signatur, nicht die signierte Datei. Konvertiere stattdessen die Datei, die sie signiert", + "error.p7m_encrypted": "Diese .p7m ist verschlüsselt, nicht nur signiert: Nur der Schlüssel des Empfängers öffnet sie", + "error.p7m_unknown_content": "Die Datei in dieser .p7m hat ein Format, das Filee nicht konvertiert", + "error.p7m_too_large": "Die signierte Datei ist zu groß zum Öffnen", "format_request.request": "Dieses Format vorschlagen", "format_request.notice_title": "Filee kann {0}-Dateien noch nicht konvertieren", "format_request.notice_text": "Sag uns, welches Format du brauchst.\nDie Anfragen bestimmen, welche Formate Filee als Nächstes unterstützt.\nVerschlüsselte oder kopiergeschützte Dateien (DRM) lassen sich unabhängig vom Format nicht konvertieren.", @@ -211,6 +216,8 @@ "engine.pdf-tables.description": "Überträgt Tabellen mit eingezeichneten Linien aus einer PDF in eine Excel-Arbeitsmappe, jeweils auf ein eigenes Blatt. Zahlen und Datumsangaben bleiben echte Werte.", "engine.email.name": "E-Mail (integriert)", "engine.email.description": "E-Mails (EML, Outlook MSG) in HTML und Text mit Absender, Empfängern, Datum und Bildern; Anhänge in eine ZIP-Datei.\nMSG in EML. Gespeicherte Webseiten (MHTML) in eine HTML-Datei mit ihren Bildern.", + "engine.p7m.name": "Signierte Dateien (integriert)", + "engine.p7m.description": "Holt die signierte Datei aus einer .p7m (CMS / PKCS #7: italienische E-Rechnungen, signierte PDFs) und konvertiert sie weiter. Die Signatur wird weder geprüft noch übernommen: Behalte die .p7m als Nachweis.", "engine.data.name": "Daten (integriert)", "engine.data.description": "Konvertiert JSON, JSON Lines, YAML, TOML, XML und CSV untereinander.\nvCard-Kontakte und iCalendar-Termine in CSV und zurück.\nKommentare werden entfernt. CSV speichert nur Tabellen.", "engine.vector.name": "Vektorgrafiken (integriert)", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 43c91786d..445a2cae9 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -104,6 +104,11 @@ "error.unsupported_source": "This file type isn't supported", "error.empty_file": "The file is empty (0 bytes). A file made with “New” in the right-click menu stays empty until you open it in its app and save it", + "error.p7m_unreadable": "This isn't a signed .p7m file Filee can open, or it is damaged", + "error.p7m_detached": "This .p7m holds only the signature, not the signed file. Convert the file it signs instead", + "error.p7m_encrypted": "This .p7m is encrypted, not only signed: only the recipient's key opens it", + "error.p7m_unknown_content": "The file inside this .p7m is of a type Filee doesn't convert", + "error.p7m_too_large": "The signed file is too large to open", "format_request.request": "Request this format", "format_request.notice_title": "Filee can't convert {0} files yet", "format_request.notice_text": "Tell us which format you need.\nRequests decide what Filee supports next.\nEncrypted or copy-protected (DRM) files can't be converted, whatever the format.", @@ -218,6 +223,8 @@ "engine.pdf-tables.description": "Turns the tables drawn with lines in a PDF into an Excel workbook, one sheet each, with numbers and dates as real values.", "engine.email.name": "E-mail (built-in)", "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.p7m.name": "Signed files (built-in)", + "engine.p7m.description": "Takes the signed file out of a .p7m (CMS / PKCS #7: Italian e-invoices, signed PDFs) and converts it on. The signature isn't checked or kept: keep the .p7m as proof.", "engine.data.name": "Data (built-in)", "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.name": "Vector graphics (built-in)", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 280629611..d0615329e 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -98,6 +98,11 @@ "download.badge_files": "{0} · {1} de {2} archivos", "error.unsupported_source": "Este tipo de archivo no es compatible", "error.empty_file": "El archivo está vacío (0 bytes). Un archivo creado con «Nuevo» en el menú contextual sigue vacío hasta que lo abres en su aplicación y lo guardas", + "error.p7m_unreadable": "No es un archivo firmado .p7m que Filee pueda abrir, o está dañado", + "error.p7m_detached": "Este .p7m solo contiene la firma, no el archivo firmado. Convierte el archivo que firma", + "error.p7m_encrypted": "Este .p7m está cifrado, no solo firmado: solo lo abre la clave del destinatario", + "error.p7m_unknown_content": "El archivo dentro de este .p7m es de un tipo que Filee no convierte", + "error.p7m_too_large": "El archivo firmado es demasiado grande para abrirlo", "format_request.request": "Solicitar este formato", "format_request.notice_title": "Filee aún no puede convertir archivos {0}", "format_request.notice_text": "Cuéntanos qué formato necesitas.\nLas solicitudes determinan qué formatos admitirá Filee a continuación.\nLos archivos cifrados o con protección de copia (DRM) no se pueden convertir, sea cual sea su formato.", @@ -211,6 +216,8 @@ "engine.pdf-tables.description": "Convierte las tablas dibujadas con líneas de un PDF en un libro de Excel, una hoja por tabla, con números y fechas como valores reales.", "engine.email.name": "E-mail (built-in)", "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.p7m.name": "Signed files (built-in)", + "engine.p7m.description": "Saca el archivo firmado de un .p7m (CMS / PKCS #7: facturas electrónicas italianas, PDF firmados) y sigue la conversión. La firma no se comprueba ni se conserva: guarda el .p7m como prueba.", "engine.data.name": "Data (built-in)", "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.name": "Vector graphics (built-in)", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index d691ebbaf..2b4758103 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -104,6 +104,11 @@ "error.unsupported_source": "Ce type de fichier n'est pas pris en charge", "error.empty_file": "Le fichier est vide (0 octet). Un fichier créé avec « Nouveau » dans le menu contextuel reste vide tant que vous ne l'avez pas ouvert et enregistré dans son application", + "error.p7m_unreadable": "Ce n'est pas un fichier signé .p7m que Filee peut ouvrir, ou il est endommagé", + "error.p7m_detached": "Ce .p7m ne contient que la signature, pas le fichier signé. Convertissez plutôt le fichier qu'il signe", + "error.p7m_encrypted": "Ce .p7m est chiffré, pas seulement signé : seule la clé du destinataire peut l'ouvrir", + "error.p7m_unknown_content": "Le fichier contenu dans ce .p7m est d'un type que Filee ne convertit pas", + "error.p7m_too_large": "Le fichier signé est trop volumineux pour être ouvert", "format_request.request": "Demander ce format", "format_request.notice_title": "Filee ne sait pas encore convertir les fichiers {0}", "format_request.notice_text": "Dites-nous de quel format vous avez besoin.\nLes demandes décident de ce que Filee prendra en charge ensuite.\nLes fichiers chiffrés ou protégés contre la copie (DRM) ne peuvent pas être convertis, quel que soit le format.", @@ -218,6 +223,8 @@ "engine.pdf-tables.description": "Transforme les tableaux tracés avec des lignes d'un PDF en classeur Excel, une feuille par tableau, avec les nombres et les dates en vraies valeurs.", "engine.email.name": "E-mail (built-in)", "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.p7m.name": "Signed files (built-in)", + "engine.p7m.description": "Extrait le fichier signé d'un .p7m (CMS / PKCS #7 : factures électroniques italiennes, PDF signés) puis le convertit. La signature n'est ni vérifiée ni conservée : gardez le .p7m comme preuve.", "engine.data.name": "Data (built-in)", "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.name": "Vector graphics (built-in)", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 91f9b7aac..df9cbda82 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -98,6 +98,11 @@ "download.badge_files": "{0} · {2} में से {1} फ़ाइल", "error.unsupported_source": "यह फ़ाइल प्रकार समर्थित नहीं है", "error.empty_file": "फ़ाइल खाली है (0 बाइट)। राइट-क्लिक मेनू के “नया” से बनाई गई फ़ाइल तब तक खाली रहती है, जब तक आप उसे उसके ऐप में खोलकर सेव न करें", + "error.p7m_unreadable": "यह ऐसी साइन की गई .p7m फ़ाइल नहीं है जिसे Filee खोल सके, या यह खराब है", + "error.p7m_detached": "इस .p7m में सिर्फ़ सिग्नेचर है, साइन की गई फ़ाइल नहीं। इसकी जगह वह फ़ाइल बदलें जिस पर यह साइन है", + "error.p7m_encrypted": "यह .p7m सिर्फ़ साइन नहीं, एन्क्रिप्ट भी है: इसे सिर्फ़ प्राप्तकर्ता की कुंजी खोल सकती है", + "error.p7m_unknown_content": "इस .p7m के अंदर की फ़ाइल ऐसे प्रकार की है जिसे Filee नहीं बदलता", + "error.p7m_too_large": "साइन की गई फ़ाइल खोलने के लिए बहुत बड़ी है", "format_request.request": "इस फ़ॉर्मैट का अनुरोध करें", "format_request.notice_title": "Filee अभी {0} फ़ाइलें कन्वर्ट नहीं कर सकता", "format_request.notice_text": "हमें बताएँ कि आपको कौन-सा फ़ॉर्मैट चाहिए।\nअनुरोधों से तय होता है कि Filee आगे क्या समर्थन करेगा।\nएन्क्रिप्टेड या कॉपी-सुरक्षित (DRM) फ़ाइलें किसी भी फ़ॉर्मैट में कन्वर्ट नहीं की जा सकतीं।", @@ -211,6 +216,8 @@ "engine.pdf-tables.description": "PDF में रेखाओं से बनी तालिकाओं को Excel वर्कबुक में बदलता है, हर तालिका एक शीट, संख्याओं और तारीखों को असली मानों के रूप में।", "engine.email.name": "E-mail (built-in)", "engine.email.description": "ई-मेल (EML, Outlook MSG) को भेजने वाले, प्राप्तकर्ताओं और तारीख के साथ HTML और टेक्स्ट में बदलता है, तस्वीरों सहित; अटैचमेंट ZIP में।\nMSG से EML। सहेजे गए वेब पेज (MHTML) उनकी तस्वीरों सहित एक HTML फ़ाइल में।", + "engine.p7m.name": "Signed files (built-in)", + "engine.p7m.description": ".p7m (CMS / PKCS #7: इटली के ई-इनवॉइस, साइन किए गए PDF) से साइन की गई फ़ाइल निकालता है और आगे बदलता है। सिग्नेचर की जाँच नहीं होती और वह नतीजे में नहीं रहता: सबूत के लिए .p7m रखें।", "engine.data.name": "Data (built-in)", "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML और CSV एक-दूसरे में।\nvCard संपर्क और iCalendar इवेंट CSV में और वापस।\nकमेंट हटा दिए जाते हैं। CSV में केवल टेबल रहती हैं।", "engine.vector.name": "Vector graphics (built-in)", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 279df415d..4249c7ecf 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -98,6 +98,11 @@ "download.badge_files": "{0} · {1} dari {2} file", "error.unsupported_source": "Jenis file ini tidak didukung", "error.empty_file": "File kosong (0 bita). File yang dibuat dengan opsi “Baru” di menu klik kanan tetap kosong sampai Anda membukanya di aplikasinya dan menyimpannya", + "error.p7m_unreadable": "Ini bukan file .p7m bertanda tangan yang bisa dibuka Filee, atau file rusak", + "error.p7m_detached": ".p7m ini hanya berisi tanda tangan, bukan file yang ditandatangani. Konversikan file yang ditandatanganinya", + "error.p7m_encrypted": ".p7m ini dienkripsi, bukan hanya ditandatangani: hanya kunci penerima yang bisa membukanya", + "error.p7m_unknown_content": "File di dalam .p7m ini berjenis yang tidak dikonversi Filee", + "error.p7m_too_large": "File bertanda tangan terlalu besar untuk dibuka", "format_request.request": "Minta format ini", "format_request.notice_title": "Filee belum dapat mengonversi file {0}", "format_request.notice_text": "Beri tahu kami format apa yang Anda butuhkan.\nPermintaan menentukan format apa yang akan didukung Filee selanjutnya.\nFile yang dienkripsi atau dilindungi DRM tidak dapat dikonversi, apa pun formatnya.", @@ -211,6 +216,8 @@ "engine.pdf-tables.description": "Mengubah tabel bergaris dalam PDF menjadi buku kerja Excel, masing-masing tabel satu lembar, dengan angka dan tanggal sebagai nilai data asli.", "engine.email.name": "Email (bawaan)", "engine.email.description": "Email (EML, Outlook MSG) ke HTML dan teks dengan pengirim, penerima, tanggal, serta gambar terlampir; lampiran ke file ZIP.\nMSG ke EML. Halaman web tersimpan (MHTML) menjadi satu file HTML beserta gambarnya.", + "engine.p7m.name": "File bertanda tangan (bawaan)", + "engine.p7m.description": "Mengambil file bertanda tangan dari .p7m (CMS / PKCS #7: faktur elektronik Italia, PDF bertanda tangan) lalu mengonversinya. Tanda tangan tidak diperiksa dan tidak ikut ke hasil: simpan .p7m sebagai bukti.", "engine.data.name": "Data (bawaan)", "engine.data.description": "Saling konversi antara JSON, JSON Lines, YAML, TOML, XML, dan CSV.\nKontak vCard dan kalender iCalendar ke CSV dan sebaliknya.\nKomentar diabaikan. CSV hanya menampung tabel data.", "engine.vector.name": "Grafik vektor (bawaan)", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index 824b1cf8a..d485c3946 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -97,6 +97,11 @@ "download.badge_files": "{0} · {1} di {2} file", "error.unsupported_source": "Questo tipo di file non è supportato", "error.empty_file": "Il file è vuoto (0 byte). Un file creato con “Nuovo” nel menu del clic destro resta vuoto finché non lo apri nella sua app e lo salvi", +"error.p7m_unreadable": "Non è un file firmato .p7m che Filee riesce ad aprire, oppure è danneggiato", +"error.p7m_detached": "Questo .p7m contiene solo la firma, non il file firmato. Converti invece il file che firma", +"error.p7m_encrypted": "Questo .p7m è cifrato, non solo firmato: solo la chiave del destinatario può aprirlo", +"error.p7m_unknown_content": "Il file dentro questo .p7m è di un tipo che Filee non converte", +"error.p7m_too_large": "Il file firmato è troppo grande da aprire", "format_request.request": "Richiedi questo formato", "format_request.notice_title": "Filee non può ancora convertire i file {0}", "format_request.notice_text": "Dicci di quale formato hai bisogno.\nLe richieste decidono cosa supporterà Filee in futuro.\nI file cifrati o protetti da copia (DRM) non si possono convertire, qualunque sia il formato.", @@ -210,6 +215,8 @@ "engine.pdf-tables.description": "Trasforma le tabelle disegnate con linee in un PDF in una cartella di lavoro Excel, un foglio ciascuna, con numeri e date come valori veri.", "engine.email.name": "E-mail (integrato)", "engine.email.description": "E-mail (EML, Outlook MSG) in HTML e testo con mittente, destinatari e data, immagini incluse; gli allegati in un ZIP.\nMSG in EML. Pagine web salvate (MHTML) in un unico file HTML con le loro immagini.", +"engine.p7m.name": "File firmati (integrato)", +"engine.p7m.description": "Estrae il file firmato da un .p7m (CMS / PKCS #7: fatture elettroniche, PDF firmati) e lo converte. La firma non viene verificata né conservata: tieni il .p7m come prova.", "engine.data.name": "Dati (integrato)", "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML e CSV convertibili tra loro.\nContatti vCard ed eventi iCalendar in CSV e viceversa.\nI commenti vengono eliminati. Il CSV contiene solo tabelle.", "engine.vector.name": "Grafica vettoriale (integrato)", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 3cb7e576e..303ca0ac8 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -98,6 +98,11 @@ "download.badge_files": "{0} · {2}件中{1}件目", "error.unsupported_source": "このファイル形式には対応していません", "error.empty_file": "ファイルが空です(0バイト)。右クリックメニューの「新規作成」で作ったファイルは、対応アプリで開いて保存するまで空のままです", + "error.p7m_unreadable": "Filee で開ける署名付きファイル(.p7m)ではないか、ファイルが壊れています", + "error.p7m_detached": "この .p7m には署名だけが入っていて、署名されたファイルはありません。署名対象のファイルを変換してください", + "error.p7m_encrypted": "この .p7m は署名だけでなく暗号化されています。受信者の鍵でしか開けません", + "error.p7m_unknown_content": "この .p7m の中のファイルは、Filee が変換できない形式です", + "error.p7m_too_large": "署名付きファイルが大きすぎて開けません", "format_request.request": "この形式への対応をリクエスト", "format_request.notice_title": "Fileeはまだ{0}ファイルを変換できません", "format_request.notice_text": "必要な形式を教えてください。\nリクエストをもとに、今後Fileeが対応する形式を決めます。\n暗号化またはコピー保護(DRM)されたファイルは、形式にかかわらず変換できません。", @@ -211,6 +216,8 @@ "engine.pdf-tables.description": "PDF の罫線で描かれた表を Excel ブックに変換します。表ごとに 1 シートで、数値と日付は実際の値になります。", "engine.email.name": "E-mail (built-in)", "engine.email.description": "メール(EML、Outlook MSG)を、差出人、宛先、日付、画像を含めてHTMLとテキストに変換します。添付ファイルはZIPにまとめます。\nMSGからEMLへの変換にも対応します。画像を含む保存済みのWebページ(MHTML)を、1つのHTMLファイルに変換します。", + "engine.p7m.name": "Signed files (built-in)", + "engine.p7m.description": ".p7m(CMS / PKCS #7:イタリアの電子請求書、署名付き PDF など)から署名されたファイルを取り出して変換します。署名は検証せず、結果にも残りません。証明が必要な場合は .p7m を保管してください。", "engine.data.name": "Data (built-in)", "engine.data.description": "JSON、JSON Lines、YAML、TOML、XML、CSVを相互変換します。\nvCardの連絡先とiCalendarの予定をCSVと相互変換できます。\nコメントは削除されます。CSVで扱えるのは表形式のデータだけです。", "engine.vector.name": "Vector graphics (built-in)", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 59b4bf87c..d349b1228 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -104,6 +104,11 @@ "error.unsupported_source": "지원하지 않는 파일 형식이에요", "error.empty_file": "빈 파일이에요(0바이트). 우클릭 메뉴의 “새로 만들기”로 만든 파일은 앱에서 열어 저장해야 내용이 생겨요", + "error.p7m_unreadable": "Filee가 열 수 있는 서명 파일(.p7m)이 아니거나 손상됐어요", + "error.p7m_detached": "이 .p7m에는 서명만 있고 서명된 파일은 없어요. 서명 대상 파일을 변환해 주세요", + "error.p7m_encrypted": "이 .p7m은 서명만이 아니라 암호화돼 있어요. 받는 사람의 키로만 열 수 있어요", + "error.p7m_unknown_content": "이 .p7m 안의 파일은 Filee가 변환하지 않는 형식이에요", + "error.p7m_too_large": "서명 파일이 너무 커서 열 수 없어요", "format_request.request": "이 형식 요청하기", "format_request.notice_title": "아직 {0} 파일은 변환할 수 없어요", "format_request.notice_text": "필요한 형식을 알려주세요.\n요청을 보고 다음에 지원할 형식을 정해요.\n암호화되거나 DRM이 걸린 파일은 변환할 수 없어요.", @@ -218,6 +223,8 @@ "engine.pdf-tables.description": "PDF에서 선으로 그린 표를 Excel 통합 문서로 바꿔요. 표마다 시트 하나씩이고, 숫자와 날짜는 실제 값이 돼요.", "engine.email.name": "E-mail (built-in)", "engine.email.description": "이메일(EML, Outlook MSG)을 보낸 사람·받는 사람·날짜와 본문 그림까지 담아 HTML·텍스트로 바꾸고, 첨부 파일은 ZIP으로 모아요.\nMSG는 EML로도 바꾸고, 저장한 웹 페이지(MHTML)는 그림까지 담긴 HTML 파일 하나로 바꿔요.", + "engine.p7m.name": "Signed files (built-in)", + "engine.p7m.description": ".p7m(CMS / PKCS #7: 이탈리아 전자 세금계산서, 서명된 PDF 등)에서 서명된 파일을 꺼내 이어서 변환해요. 서명은 확인하지 않고 결과에도 남지 않으니, 증명이 필요하면 .p7m을 보관해 주세요.", "engine.data.name": "Data (built-in)", "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML, CSV를 서로 바꿔요.\nvCard 연락처와 iCalendar 일정을 CSV로, CSV를 각각의 형식으로 바꿔요.\n주석은 빠져요. CSV는 표 모양 데이터만 담아요.", "engine.vector.name": "Vector graphics (built-in)", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 656c8010c..0a5b51644 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -98,6 +98,11 @@ "download.badge_files": "{0} · {2} मध्ये {1} फाइलहरू", "error.unsupported_source": "यो फाइल प्रकार समर्थित छैन", "error.empty_file": "फाइल खाली छ (० बाइट)। दायाँ-क्लिक मेनुमा “नयाँ” बाट सिर्जना गरिएको फाइल सम्बन्धित एपमा खोलेर सुरक्षित नगरेसम्म खाली रहन्छ", + "error.p7m_unreadable": "यो Filee ले खोल्न सक्ने हस्ताक्षरित .p7m फाइल होइन, वा बिग्रिएको छ", + "error.p7m_detached": "यो .p7m मा हस्ताक्षर मात्र छ, हस्ताक्षरित फाइल छैन। बरु यसले हस्ताक्षर गरेको फाइल रूपान्तरण गर्नुहोस्", + "error.p7m_encrypted": "यो .p7m हस्ताक्षरित मात्र होइन, इन्क्रिप्ट गरिएको छ: प्रापकको कुञ्जीले मात्र खोल्छ", + "error.p7m_unknown_content": "यो .p7m भित्रको फाइल Filee ले रूपान्तरण नगर्ने प्रकारको हो", + "error.p7m_too_large": "हस्ताक्षरित फाइल खोल्न धेरै ठूलो छ", "format_request.request": "नयाँ ढाँचा अनुरोध गर्नुहोस्", "format_request.notice_title": "Filee ले अहिलेसम्म {0} फाइलहरू रूपान्तरण गर्न सक्दैन", "format_request.notice_text": "तपाईंले चाहेको ढाँचा समर्थित छैन? हामीलाई अनुरोध पठाउनुहोस् र हामी यसलाई थप्ने प्रयास गर्नेछौं।", @@ -211,6 +216,8 @@ "engine.pdf-tables.description": "PDF मा रेखाले बनाइएका तालिकाहरूलाई Excel वर्कबुकमा बदल्छ, हरेक तालिका एउटा शिट, अङ्क र मितिहरू वास्तविक मानका रूपमा।", "engine.email.name": "E-mail (built-in)", "engine.email.description": "इमेलहरू (EML, Outlook MSG) लाई प्रेषक, प्राप्तकर्ता र मितिसहित HTML र पाठमा रूपान्तरण गर्दछ, तस्बिरहरूसहित; संलग्नकहरू ZIP मा।\nMSG लाई EML मा। सुरक्षित वेब पृष्ठहरू (MHTML) लाई तिनका तस्बिरहरूसहित एउटै HTML फाइलमा।", + "engine.p7m.name": "Signed files (built-in)", + "engine.p7m.description": ".p7m (CMS / PKCS #7: इटालियन ई-इनभ्वाइस, हस्ताक्षरित PDF) बाट हस्ताक्षरित फाइल निकाल्छ र अगाडि रूपान्तरण गर्छ। हस्ताक्षर जाँच गरिँदैन र नतिजामा रहँदैन: प्रमाणका लागि .p7m राख्नुहोस्।", "engine.data.name": "Data (built-in)", "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML र CSV एकअर्कामा।\nvCard सम्पर्कहरू र iCalendar घटनाहरू CSV मा, र CSV बाट तिनीहरूमा।\nटिप्पणीहरू हटाइन्छन्। CSV मा तालिका मात्र रहन्छ।", "engine.vector.name": "Vector graphics (built-in)", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 2412a143f..829a97919 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -104,6 +104,11 @@ "error.unsupported_source": "Ten typ pliku nie jest obsługiwany", "error.empty_file": "Plik jest pusty (0 bajtów). Plik utworzony przez „Nowy” w menu kontekstowym pozostaje pusty, dopóki nie otworzysz go w jego aplikacji i nie zapiszesz", + "error.p7m_unreadable": "To nie jest podpisany plik .p7m, który Filee może otworzyć, albo jest uszkodzony", + "error.p7m_detached": "Ten .p7m zawiera tylko podpis, a nie podpisany plik. Przekonwertuj plik, który podpisuje", + "error.p7m_encrypted": "Ten .p7m jest zaszyfrowany, a nie tylko podpisany: otworzy go tylko klucz odbiorcy", + "error.p7m_unknown_content": "Plik w tym .p7m jest typu, którego Filee nie konwertuje", + "error.p7m_too_large": "Podpisany plik jest za duży, by go otworzyć", "format_request.request": "Poproś o ten format", "format_request.notice_title": "Filee nie potrafi jeszcze konwertować plików {0}", "format_request.notice_text": "Napisz, jakiego formatu potrzebujesz.\nNa podstawie próśb wybieramy kolejne obsługiwane formaty.\nPlików zaszyfrowanych lub chronionych przed kopiowaniem (DRM) nie da się konwertować, niezależnie od formatu.", @@ -218,6 +223,8 @@ "engine.pdf-tables.description": "Zamienia tabele narysowane liniami w PDF na skoroszyt Excel, każdą na osobnym arkuszu, z liczbami i datami jako prawdziwymi wartościami.", "engine.email.name": "E-mail (built-in)", "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.p7m.name": "Signed files (built-in)", + "engine.p7m.description": "Wyjmuje podpisany plik z .p7m (CMS / PKCS #7: włoskie e-faktury, podpisane PDF) i konwertuje go dalej. Podpis nie jest sprawdzany ani zachowywany: zachowaj .p7m jako dowód.", "engine.data.name": "Data (built-in)", "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.name": "Vector graphics (built-in)", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index f5bdbb9fa..aaa01a3d7 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -104,6 +104,11 @@ "error.unsupported_source": "Este tipo de arquivo não é compatível", "error.empty_file": "O arquivo está vazio (0 bytes). Um arquivo criado com “Novo” no menu de contexto continua vazio até você abri-lo no aplicativo e salvá-lo", + "error.p7m_unreadable": "Este não é um arquivo assinado .p7m que o Filee consiga abrir, ou está danificado", + "error.p7m_detached": "Este .p7m contém só a assinatura, não o arquivo assinado. Converta o arquivo que ele assina", + "error.p7m_encrypted": "Este .p7m está criptografado, não só assinado: só a chave do destinatário o abre", + "error.p7m_unknown_content": "O arquivo dentro deste .p7m é de um tipo que o Filee não converte", + "error.p7m_too_large": "O arquivo assinado é grande demais para abrir", "format_request.request": "Pedir este formato", "format_request.notice_title": "O Filee ainda não converte arquivos {0}", "format_request.notice_text": "Diga qual formato você precisa.\nOs pedidos decidem o que o Filee passa a aceitar.\nArquivos criptografados ou protegidos contra cópia (DRM) não podem ser convertidos, seja qual for o formato.", @@ -218,6 +223,8 @@ "engine.pdf-tables.description": "Transforma as tabelas desenhadas com linhas de um PDF em uma pasta de trabalho do Excel, uma planilha por tabela, com números e datas como valores reais.", "engine.email.name": "E-mail (mecanismo integrado)", "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.p7m.name": "Arquivos assinados (mecanismo integrado)", + "engine.p7m.description": "Tira o arquivo assinado de um .p7m (CMS / PKCS #7: notas fiscais eletrônicas italianas, PDFs assinados) e continua a conversão. A assinatura não é verificada nem mantida: guarde o .p7m como prova.", "engine.data.name": "Dados (mecanismo integrado)", "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.name": "Gráficos vetoriais (mecanismo integrado)", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 56fb31491..4617f856a 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -104,6 +104,11 @@ "error.unsupported_source": "Este tipo de ficheiro não é suportado", "error.empty_file": "O ficheiro está vazio (0 bytes). Um ficheiro criado com «Novo» no menu de contexto fica vazio até o abrires na respetiva aplicação e o guardares", + "error.p7m_unreadable": "Este não é um ficheiro assinado .p7m que o Filee consiga abrir, ou está danificado", + "error.p7m_detached": "Este .p7m contém só a assinatura, não o ficheiro assinado. Converte o ficheiro que ele assina", + "error.p7m_encrypted": "Este .p7m está encriptado, não só assinado: só a chave do destinatário o abre", + "error.p7m_unknown_content": "O ficheiro dentro deste .p7m é de um tipo que o Filee não converte", + "error.p7m_too_large": "O ficheiro assinado é demasiado grande para abrir", "format_request.request": "Pedir este formato", "format_request.notice_title": "O Filee ainda não consegue converter ficheiros {0}", "format_request.notice_text": "Diz-nos de que formato precisas.\nOs pedidos ajudam a decidir os próximos formatos suportados pelo Filee.\nNão é possível converter ficheiros encriptados ou protegidos contra cópia (DRM), seja qual for o formato.", @@ -218,6 +223,8 @@ "engine.pdf-tables.description": "Converte as tabelas delimitadas por linhas num PDF num livro do Excel, com uma folha por tabela e valores numéricos e de data.", "engine.email.name": "E-mail (motor integrado)", "engine.email.description": "E-mails (EML, MSG do Outlook) para HTML e texto com remetente, destinatários, data e imagens; anexos para ZIP.\nMSG para EML. Páginas Web guardadas (MHTML) para um ficheiro HTML com as respetivas imagens.", + "engine.p7m.name": "Ficheiros assinados (motor integrado)", + "engine.p7m.description": "Tira o ficheiro assinado de um .p7m (CMS / PKCS #7: faturas eletrónicas italianas, PDF assinados) e continua a conversão. A assinatura não é verificada nem mantida: guarda o .p7m como prova.", "engine.data.name": "Dados (motor integrado)", "engine.data.description": "Converte entre JSON, JSON Lines, YAML, TOML, XML e CSV.\nContactos vCard e eventos iCalendar para CSV e vice-versa.\nOs comentários são removidos. O CSV só guarda tabelas.", "engine.vector.name": "Gráficos vetoriais (motor integrado)", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 2c701edb3..d39ae378f 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -104,6 +104,11 @@ "error.unsupported_source": "Этот тип файлов не поддерживается", "error.empty_file": "Файл пустой (0 байт). Файл, созданный через «Создать» в контекстном меню, остаётся пустым, пока его не откроют в программе и не сохранят", + "error.p7m_unreadable": "Это не подписанный файл .p7m, который может открыть Filee, или он повреждён", + "error.p7m_detached": "В этом .p7m только подпись, без подписанного файла. Конвертируйте сам подписанный файл", + "error.p7m_encrypted": "Этот .p7m зашифрован, а не только подписан: открыть его может только ключ получателя", + "error.p7m_unknown_content": "Файл внутри этого .p7m имеет тип, который Filee не конвертирует", + "error.p7m_too_large": "Подписанный файл слишком большой, чтобы его открыть", "format_request.request": "Запросить этот формат", "format_request.notice_title": "Filee пока не умеет конвертировать файлы {0}", "format_request.notice_text": "Напишите, какой формат вам нужен.\nОт запросов зависит, что Filee будет поддерживать дальше.\nЗашифрованные и защищённые от копирования (DRM) файлы сконвертировать нельзя, в каком бы формате они ни были.", @@ -218,6 +223,8 @@ "engine.pdf-tables.description": "Превращает таблицы PDF, нарисованные линиями, в книгу Excel — по листу на таблицу, с числами и датами как настоящими значениями.", "engine.email.name": "E-mail (built-in)", "engine.email.description": "Письма (EML, Outlook MSG) — в HTML и текст с отправителем, получателями и датой, вместе с картинками; вложения — в ZIP.\nMSG в EML. Сохранённые веб-страницы (MHTML) — в один HTML-файл с картинками.", + "engine.p7m.name": "Signed files (built-in)", + "engine.p7m.description": "Извлекает подписанный файл из .p7m (CMS / PKCS #7: итальянские электронные счета, подписанные PDF) и конвертирует его дальше. Подпись не проверяется и не сохраняется: храните .p7m как доказательство.", "engine.data.name": "Data (built-in)", "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML и CSV друг в друга.\nКонтакты vCard и события iCalendar в CSV и обратно.\nКомментарии отбрасываются. CSV хранит только таблицы.", "engine.vector.name": "Vector graphics (built-in)", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index c16961f73..cf79987e2 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -98,6 +98,11 @@ "download.badge_files": "{0} · {1} จาก {2} ไฟล์", "error.unsupported_source": "ไม่รองรับไฟล์ประเภทนี้", "error.empty_file": "ไฟล์ว่างเปล่า (0 ไบต์) ไฟล์ที่สร้างขึ้นจากเมนูคลิกขวา 'สร้างใหม่' จะว่างเปล่าจนกว่าคุณจะเปิดในแอปและบันทึก", + "error.p7m_unreadable": "ไม่ใช่ไฟล์ .p7m ที่ลงนามซึ่ง Filee เปิดได้ หรือไฟล์เสียหาย", + "error.p7m_detached": ".p7m นี้มีแค่ลายเซ็น ไม่มีไฟล์ที่ลงนาม ให้แปลงไฟล์ที่ลายเซ็นนี้ลงนามแทน", + "error.p7m_encrypted": ".p7m นี้ถูกเข้ารหัส ไม่ใช่แค่ลงนาม: เปิดได้ด้วยกุญแจของผู้รับเท่านั้น", + "error.p7m_unknown_content": "ไฟล์ใน .p7m นี้เป็นชนิดที่ Filee ไม่แปลง", + "error.p7m_too_large": "ไฟล์ที่ลงนามใหญ่เกินกว่าจะเปิดได้", "format_request.request": "ขอให้รองรับฟอร์แมตนี้", "format_request.notice_title": "Filee ยังไม่สามารถแปลงไฟล์ {0} ได้", "format_request.notice_text": "แจ้งให้เราทราบว่าคุณต้องการฟอร์แมตใด\nคำขอของคุณจะช่วยกำหนดฟอร์แมตที่ Filee จะพัฒนาต่อไป\nไฟล์ที่เข้ารหัสหรือติดการคุ้มครองลิขสิทธิ์ (DRM) จะไม่สามารถแปลงได้ไม่ว่าจะฟอร์แมตใดก็ตาม", @@ -211,6 +216,8 @@ "engine.pdf-tables.description": "แปลงตารางที่วาดด้วยเส้นใน PDF เป็นสมุดงาน Excel ตารางละหนึ่งชีต โดยตัวเลขและวันที่เป็นค่าจริง", "engine.email.name": "E-mail (built-in)", "engine.email.description": "อีเมล (EML, Outlook MSG) เป็น HTML และข้อความ พร้อมผู้ส่ง ผู้รับ วันที่ และรูปภาพ แนบไฟล์ลงใน ZIP\nMSG เป็น EML บันทึกหน้าเว็บ (MHTML) เป็นไฟล์ HTML ไฟล์เดียวพร้อมรูปภาพ", + "engine.p7m.name": "Signed files (built-in)", + "engine.p7m.description": "ดึงไฟล์ที่ลงนามออกจาก .p7m (CMS / PKCS #7: ใบแจ้งหนี้อิเล็กทรอนิกส์ของอิตาลี, PDF ที่ลงนาม) แล้วแปลงต่อ ไม่มีการตรวจสอบลายเซ็นและลายเซ็นจะไม่อยู่ในผลลัพธ์: เก็บ .p7m ไว้เป็นหลักฐาน", "engine.data.name": "Data (built-in)", "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML และ CSV สลับกันได้\nรายชื่อติดต่อ vCard และกิจกรรม iCalendar เป็น CSV และกลับกัน\nความคิดเห็นจะถูกตัดออก CSV เก็บเฉพาะตารางเท่านั้น", "engine.vector.name": "Vector graphics (built-in)", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 85be7052d..e51b6cd51 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -98,6 +98,11 @@ "download.badge_files": "{0} · {2} dosyadan {1}", "error.unsupported_source": "Bu dosya türü desteklenmiyor", "error.empty_file": "Dosya boş (0 bayt). Sağ tık menüsündeki “Yeni” ile oluşturulan dosya, uygulamasında açıp kaydedene kadar boş kalır", + "error.p7m_unreadable": "Bu, Filee'nin açabileceği imzalı bir .p7m dosyası değil ya da dosya bozuk", + "error.p7m_detached": "Bu .p7m yalnızca imzayı içeriyor, imzalanan dosyayı değil. Bunun yerine imzaladığı dosyayı dönüştürün", + "error.p7m_encrypted": "Bu .p7m yalnızca imzalı değil, şifreli: yalnızca alıcının anahtarı açabilir", + "error.p7m_unknown_content": "Bu .p7m içindeki dosya, Filee'nin dönüştürmediği bir türde", + "error.p7m_too_large": "İmzalı dosya açılamayacak kadar büyük", "format_request.request": "Bu biçimi talep et", "format_request.notice_title": "Filee henüz {0} dosyalarını dönüştüremiyor", "format_request.notice_text": "İhtiyacınız olan biçimi bize bildirin.\nFilee'nin sırada hangi biçimleri destekleyeceğini talepler belirler.\nŞifreli veya kopya korumalı (DRM) dosyalar, biçimi ne olursa olsun dönüştürülemez.", @@ -211,6 +216,8 @@ "engine.pdf-tables.description": "PDF'deki çizgilerle oluşturulmuş tabloları, her biri ayrı çalışma sayfasında olacak şekilde Excel'e dönüştürür; sayı ve tarihler gerçek değerler olarak tutulur.", "engine.email.name": "E-posta (yerleşik)", "engine.email.description": "E-postaları (EML, Outlook MSG) gönderen, alıcı, tarih ve görselleriyle HTML ve metne; ekleri ZIP'e dönüştürür.\nMSG → EML. Kaydedilmiş web sayfalarını (MHTML) görselleriyle tek bir HTML dosyasına dönüştürür.", + "engine.p7m.name": "İmzalı dosyalar (yerleşik)", + "engine.p7m.description": ".p7m dosyasından (CMS / PKCS #7: İtalyan e-faturaları, imzalı PDF'ler) imzalı dosyayı çıkarır ve dönüştürmeye devam eder. İmza doğrulanmaz ve sonuçta kalmaz: kanıt olarak .p7m'yi saklayın.", "engine.data.name": "Veri (yerleşik)", "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML ve CSV arasında dönüştürme.\nvCard kişileri ve iCalendar etkinlikleri → CSV ve tersi.\nYorumlar çıkarılır. CSV yalnızca tablo tutar.", "engine.vector.name": "Vektör grafikler (yerleşik)", diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json index 7cdafa4c8..31fd032d1 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -104,6 +104,11 @@ "error.unsupported_source": "Loại tệp này không được hỗ trợ", "error.empty_file": "Tệp trống (0 byte). Tệp tạo bằng “Mới” trong menu chuột phải sẽ trống cho đến khi bạn mở nó trong ứng dụng tương ứng và lưu lại", + "error.p7m_unreadable": "Đây không phải tệp .p7m đã ký mà Filee mở được, hoặc tệp bị hỏng", + "error.p7m_detached": "Tệp .p7m này chỉ chứa chữ ký, không chứa tệp đã ký. Hãy chuyển đổi tệp mà nó ký", + "error.p7m_encrypted": "Tệp .p7m này được mã hóa chứ không chỉ được ký: chỉ khóa của người nhận mới mở được", + "error.p7m_unknown_content": "Tệp bên trong .p7m này thuộc loại Filee không chuyển đổi", + "error.p7m_too_large": "Tệp đã ký quá lớn để mở", "format_request.request": "Yêu cầu định dạng này", "format_request.notice_title": "Filee chưa thể chuyển đổi tệp {0}", "format_request.notice_text": "Hãy cho chúng tôi biết bạn cần định dạng nào.\nCác yêu cầu sẽ quyết định Filee hỗ trợ gì tiếp theo.\nTệp đã mã hóa hoặc có bảo vệ bản quyền (DRM) không thể chuyển đổi, dù ở định dạng nào.", @@ -218,6 +223,8 @@ "engine.pdf-tables.description": "Chuyển các bảng kẻ bằng đường kẻ trong PDF thành sổ làm việc Excel, mỗi bảng một trang tính, số và ngày là giá trị thực.", "engine.email.name": "Email (tích hợp)", "engine.email.description": "Email (EML, Outlook MSG) sang HTML và văn bản kèm người gửi, người nhận và ngày, có cả ảnh; tệp đính kèm vào một tệp ZIP.\nMSG sang EML. Trang web đã lưu (MHTML) sang một tệp HTML kèm ảnh.", + "engine.p7m.name": "Tệp đã ký (tích hợp)", + "engine.p7m.description": "Lấy tệp đã ký ra khỏi .p7m (CMS / PKCS #7: hóa đơn điện tử Ý, PDF đã ký) rồi chuyển đổi tiếp. Chữ ký không được kiểm tra và không có trong kết quả: hãy giữ tệp .p7m làm bằng chứng.", "engine.data.name": "Dữ liệu (tích hợp)", "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML và CSV chuyển đổi qua lại.\nDanh bạ vCard và sự kiện iCalendar sang CSV và ngược lại.\nChú thích bị bỏ đi. CSV chỉ chứa bảng.", "engine.vector.name": "Đồ họa vector (tích hợp)", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 3575d4b2e..ba2b5fec8 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -104,6 +104,11 @@ "error.unsupported_source": "不支持此文件类型", "error.empty_file": "文件是空的(0 字节)。用右键菜单“新建”创建的文件,要在对应的应用中打开并保存后才有内容", + "error.p7m_unreadable": "这不是 Filee 能打开的已签名 .p7m 文件,或文件已损坏", + "error.p7m_detached": "这个 .p7m 只包含签名,没有被签名的文件。请转换它所签名的文件", + "error.p7m_encrypted": "这个 .p7m 已加密,而不只是签名:只有收件人的密钥才能打开", + "error.p7m_unknown_content": "这个 .p7m 中的文件是 Filee 不转换的类型", + "error.p7m_too_large": "已签名的文件太大,无法打开", "format_request.request": "请求支持此格式", "format_request.notice_title": "Filee 暂时无法转换 {0} 文件", "format_request.notice_text": "告诉我们你需要的格式。\n我们会根据请求决定接下来支持哪些格式。\n加密或有复制保护 (DRM) 的文件,无论什么格式都无法转换。", @@ -218,6 +223,8 @@ "engine.pdf-tables.description": "把 PDF 中用线条画出的表格转换为 Excel 工作簿,每个表格一个工作表,数字和日期为真正的数值。", "engine.email.name": "E-mail (built-in)", "engine.email.description": "将电子邮件(EML、Outlook MSG)连同发件人、收件人、日期和图片转为 HTML 和文本,附件打包为 ZIP。\nMSG 也可转为 EML。保存的网页(MHTML)可连同图片转为单个 HTML 文件。", + "engine.p7m.name": "Signed files (built-in)", + "engine.p7m.description": "从 .p7m(CMS / PKCS #7:意大利电子发票、已签名的 PDF)中取出已签名的文件并继续转换。不验证签名,结果中也不保留签名:如需证明请保留 .p7m。", "engine.data.name": "Data (built-in)", "engine.data.description": "JSON、JSON Lines、YAML、TOML、XML、CSV 互相转换。\nvCard 联系人和 iCalendar 日程与 CSV 互转。\n注释不保留。CSV 仅支持表格数据。", "engine.vector.name": "Vector graphics (built-in)", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index b907b11de..b9787b155 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -104,6 +104,11 @@ "error.unsupported_source": "不支援此檔案類型", "error.empty_file": "檔案是空的(0 位元組)。用右鍵選單「新增」建立的檔案,要在對應的應用程式中開啟並儲存後才有內容", + "error.p7m_unreadable": "這不是 Filee 能開啟的已簽署 .p7m 檔案,或檔案已損壞", + "error.p7m_detached": "這個 .p7m 只包含簽章,沒有被簽署的檔案。請轉換它所簽署的檔案", + "error.p7m_encrypted": "這個 .p7m 已加密,而不只是簽署:只有收件人的金鑰才能開啟", + "error.p7m_unknown_content": "這個 .p7m 中的檔案是 Filee 不轉換的類型", + "error.p7m_too_large": "已簽署的檔案太大,無法開啟", "format_request.request": "請求支援此格式", "format_request.notice_title": "Filee 暫時無法轉換 {0} 檔案", "format_request.notice_text": "告訴我們你需要的格式。\n我們會根據請求決定接下來支援哪些格式。\n加密或有複製保護 (DRM) 的檔案,無論什麼格式都無法轉換。", @@ -218,6 +223,8 @@ "engine.pdf-tables.description": "把 PDF 中以線條繪製的表格轉成 Excel 活頁簿,每個表格一個工作表,數字與日期為真正的數值。", "engine.email.name": "E-mail (built-in)", "engine.email.description": "將電子郵件(EML、Outlook MSG)連同發件人、收件人、日期和圖片轉為 HTML 和文字,附件打包為 ZIP。\nMSG 也可轉為 EML。儲存的網頁(MHTML)可連同圖片轉為單個 HTML 檔案。", + "engine.p7m.name": "Signed files (built-in)", + "engine.p7m.description": "從 .p7m(CMS / PKCS #7:義大利電子發票、已簽署的 PDF)中取出已簽署的檔案並繼續轉換。不驗證簽章,結果中也不保留簽章:如需證明請保留 .p7m。", "engine.data.name": "Data (built-in)", "engine.data.description": "JSON、JSON Lines、YAML、TOML、XML、CSV 互相轉換。\nvCard 聯絡人和 iCalendar 行程與 CSV 互轉。\n註釋不保留。CSV 僅支援表格資料。", "engine.vector.name": "Vector graphics (built-in)", diff --git a/src/Filee.Core/Conversion/IConverter.cs b/src/Filee.Core/Conversion/IConverter.cs index 7594e6276..26a88a31c 100644 --- a/src/Filee.Core/Conversion/IConverter.cs +++ b/src/Filee.Core/Conversion/IConverter.cs @@ -219,6 +219,24 @@ public interface ITextShapingCheck TextScripts ScanScripts(string sourcePath, string sourceFormat); } +/// +/// An engine that opens envelopes: files that wrap one file of another format, such as a signed .p7m around a PDF or +/// an XML invoice. Which format is inside is known only from the file, so asks before planning +/// and plans the route from that format, with the engine's step to it first (). +/// The engine has an edge from the envelope to every format it can find inside. +/// +public interface IEnvelopeOpener +{ + /// True for an envelope format this engine opens. + bool Opens(string format); + + /// + /// The format id of the file inside (an envelope of ). Throws + /// when it can't be opened or holds nothing Filee converts. + /// + string InnerFormat(string path, string format); +} + /// Merges several PDF files into one. Implemented by the PDF engine. public interface IPdfMerger { diff --git a/src/Filee.Core/Conversion/JobQueue.cs b/src/Filee.Core/Conversion/JobQueue.cs index 70e6543b6..85e5849b1 100644 --- a/src/Filee.Core/Conversion/JobQueue.cs +++ b/src/Filee.Core/Conversion/JobQueue.cs @@ -771,33 +771,53 @@ private async Task> RunFileAsync( if (IsEmptyFile(file.SourcePath)) return Fail(file, "error.empty_file", null); + // An envelope (a signed .p7m) is converted as the file inside it: the route opens it first, and everything + // below that depends on the format (same format, frames, OCR, …) asks about the file inside. + var from = source; + var opener = _catalog.All.OfType().FirstOrDefault(o => o.Opens(source.Id)); + if (opener is not null) + { + try + { + from = FormatRegistry.Get(opener.InnerFormat(file.SourcePath, source.Id)); + } + catch (ConversionRefusedException ex) + { + _log.LogInformation("Opening {File} was refused: {Reason}", file.SourcePath, ex.Message); + return Fail(file, ex.ErrorKey, ex.Detail); + } + } + var target = !finalOutput ? intermediate - : runWith.TargetFormat == BuiltInData.SameAsSource ? runWith.CropsVideo ? VideoCrop.TargetFor(source.Id) : source.Id + : runWith.TargetFormat == BuiltInData.SameAsSource ? runWith.CropsVideo ? VideoCrop.TargetFor(from.Id) : from.Id : runWith.TargetFormat; // "Save every frame": a video becomes a folder of pictures in one step of its own; "Extract Live/Motion Photo // video" likewise takes a photo's video, and "Live / Motion Photo" makes one of a video. Never for an // intermediate file, and never on the way to another format (video → JPG is otherwise one still frame, photo → // MP4 a video of the still picture). - var frames = finalOutput && runWith.SavesFramesOf(source.Id); - var motionPhoto = finalOutput && runWith.ExtractsMotionOf(source.Id); - var livePhoto = finalOutput && runWith.MakesLivePhotoOf(source.Id); - var caption = finalOutput && runWith.CaptionsVideo(source.Id); + var frames = finalOutput && runWith.SavesFramesOf(from.Id); + var motionPhoto = finalOutput && runWith.ExtractsMotionOf(from.Id); + var livePhoto = finalOutput && runWith.MakesLivePhotoOf(from.Id); + var caption = finalOutput && runWith.CaptionsVideo(from.Id); var translate = finalOutput && runWith.Translate.Enabled; - if (translate && !runWith.TranslatesDocument(source.Id)) - return Fail(file, "error.no_route", $"{source.Id} → {target}", new RouteRequest(source.Id, target, Translate: true)); + if (translate && !runWith.TranslatesDocument(from.Id)) + return Fail(file, "error.no_route", $"{from.Id} → {target}", new RouteRequest(from.Id, target, Translate: true)); // "Recognize text": Tesseract writes a searchable PDF first, read on into DOCX, TXT, … from there. Also for the // PDFs that are merged into one. - var ocr = (finalOutput || target == "pdf") && runWith.RecognizesTextOf(source.Id); - var route = planner.Plan(source.Id, target, frames, motionPhoto: motionPhoto, livePhoto: livePhoto, ocr: ocr, caption: caption, translate: translate); + var ocr = (finalOutput || target == "pdf") && runWith.RecognizesTextOf(from.Id); + var route = opener is null + ? planner.Plan(source.Id, target, frames, motionPhoto: motionPhoto, livePhoto: livePhoto, ocr: ocr, caption: caption, translate: translate) + : planner.PlanThrough(source.Id, from.Id, target, frames, motionPhoto: motionPhoto, livePhoto: livePhoto, ocr: ocr, caption: caption, translate: translate); if (route is null) - return Fail(file, "error.no_route", $"{source.Id} → {target}", - new RouteRequest(source.Id, target, Frames: frames, MotionPhoto: motionPhoto, LivePhoto: livePhoto, Ocr: ocr, Translate: translate)); + return Fail(file, "error.no_route", $"{from.Id} → {target}", + new RouteRequest(from.Id, target, Frames: frames, MotionPhoto: motionPhoto, LivePhoto: livePhoto, Ocr: ocr, Translate: translate)); // A step that draws text without shaping (rhwp) would break Arabic, Hebrew, Indic or Thai text: such a file // takes a route without it (LibreOffice), or is refused with the reason rather than written broken. One - // scan of the file's text answers this and the widths question below. - var scripts = route.Steps.FirstOrDefault(s => s.Unshaped)?.Converter is ITextShapingCheck check + // scan of the file's text answers this and the widths question below. Not for an envelope, whose own bytes + // aren't the document's. + var scripts = opener is null && route.Steps.FirstOrDefault(s => s.Unshaped)?.Converter is ITextShapingCheck check ? ScanScripts(check, file.SourcePath, source.Id) : null; if (scripts?.NeedsShaping is { } script) @@ -899,8 +919,9 @@ private async Task> RunFileAsync( } // Under "Save as ZIP" the output is still in the work folder: the source copied over it goes into the ZIP. - // Never with hidden parts: the source copied over the result would show them again. - var copiedSource = finalOutput && runWith.Output.OnlyIfSmaller && !runWith.Image.HidesAreas && inputs.Count == 1 + // Never with hidden parts: the source copied over the result would show them again. Never for an envelope, + // which isn't a file of the result's format. + var copiedSource = finalOutput && runWith.Output.OnlyIfSmaller && !runWith.Image.HidesAreas && opener is null && inputs.Count == 1 && CopyIfNotSmaller(file.SourcePath, inputs[0]); if (copiedSource) file.NoticeKey = "notice.pdf_not_smaller"; diff --git a/src/Filee.Core/Conversion/RoutePlanner.cs b/src/Filee.Core/Conversion/RoutePlanner.cs index baec5a990..4cfe9ba3d 100644 --- a/src/Filee.Core/Conversion/RoutePlanner.cs +++ b/src/Filee.Core/Conversion/RoutePlanner.cs @@ -177,6 +177,32 @@ public RoutePlanner(IEnumerable converters, Func? p public bool CanConvert(string from, string to, bool frames = false, bool motionPhoto = false, bool livePhoto = false, bool ocr = false, bool caption = false, bool translate = false) => Plan(from, to, frames, motionPhoto: motionPhoto, livePhoto: livePhoto, ocr: ocr, caption: caption, translate: translate) is not null; + /// + /// The route for an envelope (): the cheapest edge from to the + /// format inside it, , then the route from there to as + /// finds it with the same options. Null when the envelope can't be opened to that format or the + /// file inside can't become . + /// + public Route? PlanThrough(string from, string through, string to, bool frames = false, bool shaping = false, bool motionPhoto = false, + bool livePhoto = false, bool ocr = false, bool caption = false, bool translate = false) + { + from = from.ToLowerInvariant(); + through = through.ToLowerInvariant(); + to = to.ToLowerInvariant(); + var open = EdgesFrom(from) + .Where(e => e.Edge.To == through && e.Edge.To != from) + .OrderBy(e => e.Cost) + .FirstOrDefault(); + if (open.Converter is null) + return null; + var step = new RouteStep(open.Converter, from, through); + if (through == to && !(frames || motionPhoto || livePhoto || ocr || caption || translate)) + return new Route([step], open.Cost); + return Plan(through, to, frames, shaping, motionPhoto, livePhoto, ocr, caption, translate) is { } rest + ? new Route([step, .. rest.Steps], open.Cost + rest.Cost) + : null; + } + private Route? PlanWithTranslate(string from, string to, bool shaping) { var direct = EdgesFrom(from) diff --git a/src/Filee.Core/Formats/FormatFields.cs b/src/Filee.Core/Formats/FormatFields.cs index 209336a95..23c4a2f5b 100644 --- a/src/Filee.Core/Formats/FormatFields.cs +++ b/src/Filee.Core/Formats/FormatFields.cs @@ -66,7 +66,7 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow [ "tiff", "bmp", "ico", "avif", "jxl", "tga", "apng", "jxr", "livp", "svg", "svgz", "emf", "vsd", "odg", "docm", "dotx", "dotm", "doc", "odt", "ott", "rtf", "wps", "ofd", "abw", "pages", "pub", "eml", "msg", - "xlsm", "xltx", "xls", "ods", "ots", "tsv", "et", "numbers", "pptm", "potx", "potm", "ppsx", "ppt", + "p7m", "xlsm", "xltx", "xls", "ods", "ots", "tsv", "et", "numbers", "pptm", "potx", "potm", "ppsx", "ppt", "odp", "otp", "key", "dps", "md", "html", "mhtml", "opml", "json", "xml", "vcf", "ics", "epub", "kepub", "azw3", "fb2", "cbz", "cbr", "cb7", "cbt", "cbc", "m4v", "mkv", "webm", "avi", "wmv", "mpg", "ts", "m2ts", "ogv", "m4b", "aac", "flac", "ogg", "opus", "wma", "aiff", "weba", "mka", "qta", "7z", "rar", diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs index f6578503a..df946fbd9 100644 --- a/src/Filee.Core/Formats/FormatRegistry.cs +++ b/src/Filee.Core/Formats/FormatRegistry.cs @@ -108,6 +108,9 @@ public static class FormatRegistry new("pub", "PUB", FormatCategory.Document, ["pub"], Writable: false), new("eml", "EML", FormatCategory.Document, ["eml"]), new("msg", "MSG", FormatCategory.Document, ["msg"], Writable: false), + // A signed envelope (CMS / PKCS #7, CAdES; #1010): Italian e-invoices (invoice.xml.p7m) and signed PDFs. It is + // converted as the file inside it (IEnvelopeOpener), and named after it: invoice.pdf.p7m → invoice.pdf. + new("p7m", "P7M", FormatCategory.Document, ["p7m"], Writable: false), // Korean word processor (Hancom) new("hwpx", "HWPX", FormatCategory.Hwp, ["hwpx"]), @@ -402,11 +405,20 @@ public static string ExtensionOf(string path) return Path.GetExtension(name).TrimStart('.'); } - /// The file name without its (possibly two-part) extension: "backup.tar.gz" → "backup". + /// + /// The file name without its (possibly two-part) extension: "backup.tar.gz" → "backup". An envelope's name loses + /// the extension of the file inside too, when it has one Filee knows: "invoice.pdf.p7m" → "invoice". + /// public static string NameWithoutExtension(string path) { var name = Path.GetFileName(path); var extension = ExtensionOf(name); - return extension.Length == 0 ? name : name[..^(extension.Length + 1)]; + if (extension.Length == 0) + return name; + var stem = name[..^(extension.Length + 1)]; + return Envelopes.Contains(extension) && Detect(stem) is not null ? NameWithoutExtension(stem) : stem; } + + /// Extensions of envelopes, which are named after the file inside (). + private static readonly HashSet Envelopes = new(["p7m"], StringComparer.OrdinalIgnoreCase); } diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index b19cd74ee..9f16ee405 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -123,7 +123,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 = 65; + public const int CurrentSchemaVersion = 66; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index c6f54dfc6..0d6db4046 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -1012,6 +1012,10 @@ public static bool ApplyToLibrary(int from, List presets, List CreateAll(EngineEnvironment env, ILogger new PdfSlidesConverter(), new PdfTablesConverter(), new EmlConverter(), + new P7mConverter(), new DataConverter(), new SubtitleConverter(), new WicImageConverter(), diff --git a/src/Filee.Engines/Signed/P7mConverter.cs b/src/Filee.Engines/Signed/P7mConverter.cs new file mode 100644 index 000000000..929508cbd --- /dev/null +++ b/src/Filee.Engines/Signed/P7mConverter.cs @@ -0,0 +1,266 @@ +// Signed files (.p7m, #1010): a CMS / PKCS #7 SignedData envelope (RFC 5652; CAdES, as Italian e-invoices and signed +// PDFs are made) with the signed file inside. Filee takes that file out as it is, with .NET's SignedCms +// (System.Security.Cryptography.Pkcs): DER or BER, or the same as Base64 or PEM text, as some signing tools and mail +// programs save it. An envelope signed again (a second signature around the first) is opened again, up to MaxDepth. +// +// The signature is NOT checked, and nothing in it is trusted: no certificate chain is built, no revocation list or +// time-stamp server is asked, nothing goes over the network. Only the content is extracted, so the result is the plain +// file without the proof the signature gives (the .p7m stays as it was, for that). +// +// The file inside is named after the envelope (invoice.xml.p7m → invoice.xml) and its format comes from that name, else +// from its first bytes (PDF, pictures, XML, RTF, a MIME message, ZIP and the Office and OpenDocument files in one, plain +// text). JobQueue asks for that format (IEnvelopeOpener) and plans on from it, so P7M → PDF → JPG is one conversion. + +using System.Buffers; +using System.Buffers.Text; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Security.Cryptography.Pkcs; +using System.Text; +using System.Text.Unicode; +using Filee.Core.Conversion; +using Filee.Core.Formats; +using Filee.Core.Web; +using Filee.Engines.Infrastructure; + +namespace Filee.Engines.Signed; + +/// Signed envelopes (.p7m) → the file inside them. +public sealed class P7mConverter : IConverter, IEnvelopeOpener +{ + /// + /// The largest envelope opened: it is read whole, and the file inside is a second copy in memory. Signed scans and + /// e-invoices with attachments are tens of megabytes. + /// + internal const long MaxSize = 512L * 1024 * 1024; + + /// Envelopes inside envelopes opened at most (each signature of a file signed again adds one). + internal const int MaxDepth = 8; + + private const string SignedData = "1.2.840.113549.1.7.2"; + private const string EnvelopedData = "1.2.840.113549.1.7.3"; + + public string Id => "p7m"; + public string DisplayName => "Signed files (P7M)"; + public int MaxParallelism => 0; + + /// The file inside can be of any format Filee reads: which one is known only from the file (). + public IReadOnlyList Edges { get; } = + [.. FormatRegistry.Known.Where(f => f.Id != "p7m").Select(f => new ConversionEdge("p7m", f.Id))]; + + public EngineStatus GetStatus() => EngineStatus.Available("P7M → the signed file inside", + $"{EngineVersions.BuiltIn} · {EngineVersions.Library("System.Security.Cryptography.Pkcs", typeof(SignedCms))}"); + + public bool Opens(string format) => format == "p7m"; + + public string InnerFormat(string path, string format) => + FormatOf(InnerName(path), Open(path)) ?? throw new ConversionRefusedException("error.p7m_unknown_content"); + + public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) => + Task.Run>(() => + { + var content = Open(step.InputPath); + var name = InnerName(step.OriginalSourcePath ?? step.InputPath); + if (FormatOf(name, content) != step.To) + throw new ConversionRefusedException("error.p7m_unknown_content"); + progress?.Report(0.5); + cancellationToken.ThrowIfCancellationRequested(); + // The file's own spelling of its extension (".jpeg") when it has one of the format's. + var extension = FormatRegistry.ExtensionOf(name); + if (FormatRegistry.FindByExtension(extension)?.Id != step.To) + extension = FormatRegistry.Get(step.To).PrimaryExtension; + if (step.Output.Allocate(extension) is not { } output) + return []; + File.WriteAllBytes(output, content); + progress?.Report(1); + return [output]; + }, cancellationToken); + + /// The name of the file inside: the envelope's without ".p7m" (each of them, for one signed again). + internal static string InnerName(string path) + { + var name = Path.GetFileName(path); + while (name.Length > 4 && name.EndsWith(".p7m", StringComparison.OrdinalIgnoreCase)) + name = name[..^4]; + return name; + } + + /// + /// The signed file inside the envelope at , out of every envelope around it. The signatures + /// are not checked (see the top of the file). + /// + internal static byte[] Open(string path) + { + if (new FileInfo(path).Length > MaxSize) + throw new ConversionRefusedException("error.p7m_too_large", $"{MaxSize / (1024 * 1024)} MB"); + var content = Unwrap(File.ReadAllBytes(path), outer: true)!; + for (var depth = 1; depth < MaxDepth && Unwrap(content, outer: false) is { } inner; depth++) + content = inner; + return content; + } + + /// + /// The content of the SignedData in (binary, Base64 or PEM). For the + /// envelope anything else is refused with the reason; inside it, null means + /// is the signed file itself. + /// + private static byte[]? Unwrap(byte[] data, bool outer) + { + var encoded = data is [0x30, ..] ? data : FromText(data); + string? type = null; + if (encoded is not null) + { + try + { + type = ContentInfo.GetContentType(encoded).Value; + } + catch (CryptographicException) + { + } + } + // S/MIME mail encrypted for someone (smime.p7m) is EnvelopedData: only the recipient's key opens it. + if (type == EnvelopedData) + throw new ConversionRefusedException("error.p7m_encrypted"); + if (type != SignedData || encoded is null) + return outer ? throw new ConversionRefusedException("error.p7m_unreadable") : null; + + var cms = new SignedCms(); + try + { + cms.Decode(encoded); + } + catch (CryptographicException) + { + return outer ? throw new ConversionRefusedException("error.p7m_unreadable") : null; + } + // A detached signature (.p7s, or a .p7m made so) signs a file kept elsewhere and holds none. + if (cms.ContentInfo.Content.Length == 0) + return outer ? throw new ConversionRefusedException("error.p7m_detached") : null; + return cms.ContentInfo.Content; + } + + /// + /// The DER bytes of an envelope saved as Base64 text, with or without PEM's "-----BEGIN …-----" lines, or null + /// when is no such text or doesn't hold DER. + /// + private static byte[]? FromText(byte[] data) + { + ReadOnlySpan text = data; + if (text.StartsWith((ReadOnlySpan)[0xEF, 0xBB, 0xBF])) + text = text[3..]; + var begin = text.IndexOf("-----BEGIN"u8); + if (begin >= 0) + { + var line = text[begin..].IndexOf((byte)'\n'); + if (line < 0) + return null; + text = text[(begin + line + 1)..]; + var end = text.IndexOf("-----END"u8); + if (end < 0) + return null; + text = text[..end]; + } + var base64 = new byte[text.Length]; + var length = 0; + foreach (var b in text) + { + if (b is (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n') + continue; + if (!(b is >= (byte)'A' and <= (byte)'Z' or >= (byte)'a' and <= (byte)'z' or >= (byte)'0' and <= (byte)'9' + or (byte)'+' or (byte)'/' or (byte)'=')) + return null; // a binary file or other text: stops at its first byte that isn't Base64 + base64[length++] = b; + } + if (length == 0 || Base64.DecodeFromUtf8InPlace(base64.AsSpan(0, length), out var written) != OperationStatus.Done + || written == 0 || base64[0] != 0x30) + return null; + return base64[..written]; + } + + /// + /// The format id of the signed file: its name's when that is a format Filee knows, else the one its first bytes + /// show; null when neither says. + /// + internal static string? FormatOf(string name, ReadOnlySpan content) + { + if (FormatRegistry.Detect(name) is { } named && named.Id != "p7m") + return named.Id; + return Sniff(content); + } + + /// A format told by the first bytes alone, or null. + internal static string? Sniff(ReadOnlySpan content) + { + var head = content[..Math.Min(content.Length, 4096)]; + if (WebFormats.Sniff(head) is { } extension) + return FormatRegistry.FindByExtension(extension)?.Id; + if (head.StartsWith("PK\x03\x04"u8)) + return ZipKind(content); + var text = Encoding.UTF8.GetString(head).TrimStart('', ' ', '\t', '\r', '\n'); + if (text.StartsWith("A ZIP's format: Word, Excel and PowerPoint files and OpenDocument and EPUB by their parts, else ZIP. + private static string ZipKind(ReadOnlySpan content) + { + try + { + using var zip = new ZipArchive(new MemoryStream(content.ToArray(), writable: false), ZipArchiveMode.Read); + if (zip.GetEntry("mimetype") is { Length: < 256 } entry) + { + using var reader = new StreamReader(entry.Open(), Encoding.ASCII); + switch (reader.ReadToEnd().Trim()) + { + case "application/vnd.oasis.opendocument.text": + return "odt"; + case "application/vnd.oasis.opendocument.spreadsheet": + return "ods"; + case "application/vnd.oasis.opendocument.presentation": + return "odp"; + case "application/epub+zip": + return "epub"; + } + } + if (zip.GetEntry("[Content_Types].xml") is not null) + { + if (zip.GetEntry("word/document.xml") is not null) + return "docx"; + if (zip.GetEntry("xl/workbook.xml") is not null) + return "xlsx"; + if (zip.GetEntry("ppt/presentation.xml") is not null) + return "pptx"; + } + } + catch (InvalidDataException) + { + // Not a ZIP Filee reads after all: the archive engine says what is wrong with it. + } + return "zip"; + } + + /// + /// True for UTF-8 text without control characters other than tabs, line breaks and form feeds. Unless + /// , is the start of the file and may end inside a character. + /// + private static bool IsText(ReadOnlySpan head, bool whole) + { + if (head.IsEmpty) + return false; + foreach (var b in head) + { + if (b < 0x20 && b is not ((byte)'\t' or (byte)'\n' or (byte)'\r' or 0x0C)) + return false; + } + var status = Utf8.ToUtf16(head, new char[head.Length], out _, out _, replaceInvalidSequences: false, isFinalBlock: whole); + return status is OperationStatus.Done or OperationStatus.NeedMoreData; + } +} diff --git a/tests/Filee.Core.Tests/EnvelopeTests.cs b/tests/Filee.Core.Tests/EnvelopeTests.cs new file mode 100644 index 000000000..632df0842 --- /dev/null +++ b/tests/Filee.Core.Tests/EnvelopeTests.cs @@ -0,0 +1,128 @@ +// Signed envelopes (.p7m, #1010): a read-only document format converted as the file inside it. The route opens the +// envelope to the format the engine finds in it (IEnvelopeOpener) and goes on from there; the outputs are named after +// the file inside. + +using Filee.Core.Conversion; +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Core.Profiles; +using Filee.Core.Settings; + +namespace Filee.Core.Tests; + +public class EnvelopeTests +{ + /// Opens .p7m files to the format its test says is inside, like the real engine writes the inner file. + private sealed class FakeOpener(string inner, params string[] formats) : IConverter, IEnvelopeOpener + { + private readonly FakeConverter _writer = new("p7m", [.. formats.Select(f => new ConversionEdge("p7m", f))]); + public string Id => "p7m"; + public string DisplayName => "p7m"; + public IReadOnlyList Edges => _writer.Edges; + public int MaxParallelism => 0; + public EngineStatus GetStatus() => EngineStatus.Available(); + public bool Opens(string format) => format == "p7m"; + + public string InnerFormat(string path, string format) => + inner == "refuse" ? throw new ConversionRefusedException("error.p7m_detached") : inner; + + public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) => + _writer.ConvertAsync(step, progress, cancellationToken); + } + + [Theory] + [InlineData("invoice.pdf.p7m", "invoice")] + [InlineData("IT01234567890_FPA01.xml.P7M", "IT01234567890_FPA01")] + [InlineData("contract.pdf.p7m.p7m", "contract")] + [InlineData("signed.p7m", "signed")] + [InlineData("report.final.p7m", "report.final")] + [InlineData("backup.tar.gz", "backup")] + [InlineData("photo.jpg", "photo")] + public void An_envelope_is_named_after_the_file_inside(string file, string name) => + Assert.Equal(name, FormatRegistry.NameWithoutExtension(file)); + + [Fact] + public void P7m_is_a_read_only_document_format_on_the_documents_donut() + { + var format = FormatRegistry.Detect("invoice.pdf.p7m"); + + Assert.NotNull(format); + Assert.Equal(("p7m", FormatCategory.Document, false), (format.Id, format.Category, format.Writable)); + Assert.Equal("office", ProfileSelector.Select(BuiltInData.CreateProfiles(), ["invoice.xml.p7m"])?.Id); + } + + [Fact] + public void Version_66_libraries_learn_p7m_once() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + var office = profiles.Single(p => p.Id == "office"); + office.Extensions.Remove("p7m"); + + Assert.True(SettingsMigrations.ApplyToLibrary(65, presets, profiles)); + + Assert.Contains("p7m", office.Extensions); + Assert.False(SettingsMigrations.ApplyToLibrary(65, presets, profiles)); + } + + [Fact] + public void The_route_opens_the_envelope_to_the_format_inside_then_goes_on() + { + var opener = new FakeOpener("pdf", "pdf", "xml", "png"); + var pdfium = new FakeConverter("pdfium", new ConversionEdge("pdf", "png")); + var planner = new RoutePlanner([opener, pdfium]); + + var route = planner.PlanThrough("p7m", "pdf", "png"); + + Assert.NotNull(route); + Assert.Equal([("p7m", "pdf"), ("pdf", "png")], route.Steps.Select(s => (s.From, s.To))); + Assert.Equal(["p7m", "pdfium"], route.Steps.Select(s => s.Converter.Id)); + // The direct p7m → png edge is for a picture inside, not a PDF. + Assert.Equal([("p7m", "pdf")], planner.PlanThrough("p7m", "pdf", "pdf")!.Steps.Select(s => (s.From, s.To))); + Assert.Null(planner.PlanThrough("p7m", "xml", "png")); + Assert.Null(planner.PlanThrough("p7m", "docx", "pdf")); + } + + [Fact] + public async Task A_signed_pdf_becomes_a_picture_named_after_the_pdf() + { + using var dir = new TempDir(); + var source = dir.File("invoice.pdf.p7m"); + var pdfium = new FakeConverter("pdfium", new ConversionEdge("pdf", "png")); + await using var queue = new JobQueue(new ConverterCatalog([new FakeOpener("pdf", "pdf", "png"), pdfium])); + + var job = new ConversionJob([source], new Preset { TargetFormat = "png" }, "Test"); + await queue.RunAsync(job); + + Assert.Equal(JobState.Completed, job.State); + Assert.Equal(Path.Combine(dir.Path, "invoice.png"), job.Outputs.Single()); + Assert.Contains("→png by pdfium", await File.ReadAllTextAsync(job.Outputs.Single(), TestContext.Current.CancellationToken)); + Assert.True(File.Exists(source)); + } + + [Fact] + public async Task An_envelope_the_engine_refuses_fails_with_its_reason() + { + using var dir = new TempDir(); + await using var queue = new JobQueue(new ConverterCatalog([new FakeOpener("refuse", "pdf")])); + + var job = new ConversionJob([dir.File("signature.p7m")], new Preset { TargetFormat = "pdf" }, "Test"); + await queue.RunAsync(job); + + Assert.Equal(JobState.Failed, job.State); + Assert.Equal("error.p7m_detached", job.Files.Single().ErrorKey); + } + + [Fact] + public async Task A_file_inside_that_cant_become_the_target_has_no_route_from_its_own_format() + { + using var dir = new TempDir(); + await using var queue = new JobQueue(new ConverterCatalog([new FakeOpener("xml", "xml", "png")])); + + var job = new ConversionJob([dir.File("invoice.xml.p7m")], new Preset { TargetFormat = "png" }, "Test"); + await queue.RunAsync(job); + + Assert.Equal("error.no_route", job.Files.Single().ErrorKey); + Assert.Equal("xml → png", job.Files.Single().ErrorDetail); + } +} diff --git a/tests/Filee.Core.Tests/FormatFieldsTests.cs b/tests/Filee.Core.Tests/FormatFieldsTests.cs index fcf50c1af..fe09817f9 100644 --- a/tests/Filee.Core.Tests/FormatFieldsTests.cs +++ b/tests/Filee.Core.Tests/FormatFieldsTests.cs @@ -144,7 +144,7 @@ public void Grade_counts_match_the_published_table() new Dictionary { [FormatGrade.Everyday] = 19, - [FormatGrade.General] = 94, + [FormatGrade.General] = 95, [FormatGrade.Specialist] = 113, [FormatGrade.Legacy] = 50, }, diff --git a/tests/Filee.Engines.Tests/FonTests.cs b/tests/Filee.Engines.Tests/FonTests.cs index 133e17134..80182418b 100644 --- a/tests/Filee.Engines.Tests/FonTests.cs +++ b/tests/Filee.Engines.Tests/FonTests.cs @@ -1267,7 +1267,8 @@ public void Fon_edges_only_leave_existing_font_routes_intact() { var converter = fx.Converters.OfType().Single(); Assert.Equal(Formats.Select(t => ("fon", t)).Order(), converter.Edges.Where(e => e.From == "fon").Select(e => (e.From, e.To)).Order()); - Assert.DoesNotContain(fx.Converters.SelectMany(c => c.Edges), e => e.To == "fon"); + // Nothing makes FON; a signed envelope (.p7m) only opens to one signed as it was. + Assert.DoesNotContain(fx.Converters.Where(c => c is not IEnvelopeOpener).SelectMany(c => c.Edges), e => e.To == "fon"); foreach (var from in Formats) foreach (var to in Formats.Where(t => t != from)) Assert.Contains(new ConversionEdge(from, to), converter.Edges); diff --git a/tests/Filee.Engines.Tests/P7mTests.cs b/tests/Filee.Engines.Tests/P7mTests.cs new file mode 100644 index 000000000..89c4b0679 --- /dev/null +++ b/tests/Filee.Engines.Tests/P7mTests.cs @@ -0,0 +1,198 @@ +// Signed files (.p7m, #1010): the signed file comes out of the CMS envelope as it was signed and converts on from its +// own format. The envelopes are signed here with a self-signed certificate made for the test; the signature is never +// checked, so nothing about the certificate matters. + +using System.IO.Compression; +using System.Security.Cryptography; +using System.Security.Cryptography.Pkcs; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Engines.Signed; +using PdfSharp.Pdf; + +namespace Filee.Engines.Tests; + +public sealed class P7mTests(EngineFixture fx) : IClassFixture, IDisposable +{ + private readonly X509Certificate2 _certificate = SelfSigned(); + + private const string Invoice = """ + + + + + 00042 + + + + """; + + private static X509Certificate2 SelfSigned() + { + using var key = RSA.Create(2048); + var request = new CertificateRequest("CN=Filee test signer", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + } + + public void Dispose() => _certificate.Dispose(); + + /// A CAdES-like attached signature: inside a CMS SignedData. + private byte[] Sign(byte[] content, bool detached = false) + { + var cms = new SignedCms(new ContentInfo(content), detached); + cms.ComputeSignature(new CmsSigner(_certificate) { IncludeOption = X509IncludeOption.EndCertOnly }); + return cms.Encode(); + } + + private static byte[] Pdf() + { + using var document = new PdfDocument(); + document.AddPage(); + using var stream = new MemoryStream(); + document.Save(stream); + return stream.ToArray(); + } + + private string Write(string name, byte[] bytes) + { + var path = Path.Combine(fx.NewFolder(), name); + File.WriteAllBytes(path, bytes); + return path; + } + + private static void AssertDone(ConversionJob job) => + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + + private async Task ErrorOf(string path, string target = "pdf") + { + var job = await fx.ConvertAsync([path], new Preset { TargetFormat = target }); + Assert.Equal(JobState.Failed, job.State); + return job.Files.Single().ErrorKey!; + } + + [Fact] + public async Task A_signed_pdf_comes_out_as_it_was_signed_and_named_after_it() + { + var pdf = Pdf(); + var envelope = Write("invoice.pdf.p7m", Sign(pdf)); + + var job = await fx.ConvertAsync([envelope], new Preset { TargetFormat = "pdf" }); + + AssertDone(job); + var output = job.Outputs.Single(); + Assert.Equal(Path.Combine(Path.GetDirectoryName(envelope)!, "invoice.pdf"), output); + Assert.Equal(pdf, await File.ReadAllBytesAsync(output, TestContext.Current.CancellationToken)); + Assert.True(File.Exists(envelope)); + } + + [Fact] + public async Task A_signed_pdf_goes_on_to_a_picture() + { + var envelope = Write("invoice.pdf.p7m", Sign(Pdf())); + + var job = await fx.ConvertAsync([envelope], new Preset { TargetFormat = "png" }); + + AssertDone(job); + var output = job.Outputs.Single(); + Assert.Equal("invoice.png", Path.GetFileName(output)); + Assert.Equal([0x89, (byte)'P', (byte)'N', (byte)'G'], (await File.ReadAllBytesAsync(output, TestContext.Current.CancellationToken))[..4]); + } + + [Fact] + public async Task An_e_invoice_saved_as_base64_becomes_its_xml_and_goes_on_to_json() + { + var xml = Encoding.UTF8.GetBytes(Invoice); + var base64 = Convert.ToBase64String(Sign(xml), Base64FormattingOptions.InsertLineBreaks); + var envelope = Write("IT01234567890_FPA01.xml.p7m", Encoding.ASCII.GetBytes(base64)); + + var asXml = await fx.ConvertAsync([envelope], new Preset { TargetFormat = "xml" }); + var asJson = await fx.ConvertAsync([envelope], new Preset { TargetFormat = "json" }); + + AssertDone(asXml); + Assert.Equal("IT01234567890_FPA01.xml", Path.GetFileName(asXml.Outputs.Single())); + Assert.Equal(xml, await File.ReadAllBytesAsync(asXml.Outputs.Single(), TestContext.Current.CancellationToken)); + AssertDone(asJson); + Assert.Contains("00042", await File.ReadAllTextAsync(asJson.Outputs.Single(), TestContext.Current.CancellationToken)); + } + + [Fact] + public void Pem_text_and_a_second_signature_around_the_first_are_opened_too() + { + var pdf = Pdf(); + var pem = $"-----BEGIN PKCS7-----\n{Convert.ToBase64String(Sign(pdf), Base64FormattingOptions.InsertLineBreaks)}\n-----END PKCS7-----\n"; + + Assert.Equal(pdf, P7mConverter.Open(Write("a.pdf.p7m", Encoding.ASCII.GetBytes(pem)))); + Assert.Equal(pdf, P7mConverter.Open(Write("b.pdf.p7m.p7m", Sign(Sign(pdf))))); + Assert.Equal(pdf, P7mConverter.Open(Write("c.pdf.p7m", Sign(Encoding.ASCII.GetBytes(Convert.ToBase64String(Sign(pdf))))))); + } + + [Fact] + public async Task Without_a_name_inside_the_first_bytes_decide() + { + var envelope = Write("document.p7m", Sign(Pdf())); + + var job = await fx.ConvertAsync([envelope], new Preset { TargetFormat = "pdf" }); + + AssertDone(job); + Assert.Equal("document.pdf", Path.GetFileName(job.Outputs.Single())); + } + + [Fact] + public void The_first_bytes_tell_office_files_xml_mail_and_text_apart() + { + Assert.Equal("docx", P7mConverter.Sniff(Zip(("[Content_Types].xml", ""), ("word/document.xml", "")))); + Assert.Equal("xlsx", P7mConverter.Sniff(Zip(("[Content_Types].xml", ""), ("xl/workbook.xml", "")))); + Assert.Equal("odt", P7mConverter.Sniff(Zip(("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", "")))); + Assert.Equal("zip", P7mConverter.Sniff(Zip(("readme.txt", "hello")))); + Assert.Equal("xml", P7mConverter.Sniff("\uFEFF "u8.ToArray())); + Assert.Equal("pdf", P7mConverter.Sniff(Pdf())); + Assert.Equal("eml", P7mConverter.Sniff("MIME-Version: 1.0\r\nContent-Type: text/plain\r\n\r\nHi"u8.ToArray())); + Assert.Equal("rtf", P7mConverter.Sniff(@"{\rtf1\ansi Hello}"u8.ToArray())); + Assert.Equal("txt", P7mConverter.Sniff("Grazie per l'ordine.\nTotale: 12,50 €\n"u8.ToArray())); + Assert.Null(P7mConverter.Sniff([0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 0, 0])); + // The name of the file inside comes first. + Assert.Equal("xml", P7mConverter.FormatOf("invoice.xml", Pdf())); + Assert.Equal("invoice.xml", P7mConverter.InnerName(Path.Combine("Fatture", "invoice.xml.P7M"))); + Assert.Equal("contract.pdf", P7mConverter.InnerName("contract.pdf.p7m.p7m")); + } + + private static byte[] Zip(params (string Name, string Text)[] entries) + { + using var stream = new MemoryStream(); + using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, text) in entries) + { + using var writer = new StreamWriter(zip.CreateEntry(name).Open()); + writer.Write(text); + } + } + return stream.ToArray(); + } + + [Fact] + public async Task A_detached_signature_a_damaged_file_and_an_encrypted_one_are_refused_with_the_reason() + { + var encrypted = new EnvelopedCms(new ContentInfo(Pdf())); + encrypted.Encrypt(new CmsRecipient(_certificate)); + + Assert.Equal("error.p7m_detached", await ErrorOf(Write("invoice.pdf.p7m", Sign(Pdf(), detached: true)))); + Assert.Equal("error.p7m_unreadable", await ErrorOf(Write("broken.pdf.p7m", [0x30, 0x82, 0x01, 0x00, 0x06, 0x09, 1, 2, 3]))); + Assert.Equal("error.p7m_unreadable", await ErrorOf(Write("plain.pdf.p7m", Pdf()))); + Assert.Equal("error.p7m_encrypted", await ErrorOf(Write("smime.p7m", encrypted.Encode()))); + Assert.Equal("error.p7m_unknown_content", await ErrorOf(Write("blob.p7m", Sign([0x00, 0xFF, 0x13, 0x37, 0x00, 0x01])))); + } + + [Fact] + public async Task A_file_inside_that_cant_become_the_target_says_its_own_format() + { + var envelope = Write("IT01234567890_FPA01.xml.p7m", Sign(Encoding.UTF8.GetBytes(Invoice))); + + var job = await fx.ConvertAsync([envelope], new Preset { TargetFormat = "mp4" }); + + Assert.Equal("error.no_route", job.Files.Single().ErrorKey); + Assert.Equal("xml → mp4", job.Files.Single().ErrorDetail); + } +}