` / ``, ` ` with or without `;`; of several languages (KRCC, ENCC, …) the one with the most lines | one class from the text's language (KRCC for Korean), ` ` where the screen goes blank, UTF-8 with BOM |
| LRC | `[mm:ss.xx]` (several per line), `[ti:]`, `[offset:]`, enhanced word times dropped; a line lasts until the next one | plain text, lines joined, an empty time line where the screen goes blank |
+| KRC (`.krc`, read only, #1296) | KuGou Music lyrics: after the `krc1` header, zlib data behind the fixed XOR layer every KuGou file shares (packing, no key or account), unpacked to at most 16 MB of text. One cue per `[start,duration]` line (milliseconds), so it lasts as long as the file says; the word times (``) are dropped. `[ti:]` and `[offset:]` as in LRC; other tags, and the translations in `[language:]`, are skipped. A file without the header, or whose packed text doesn't unpack, fails with a clear message | — |
| TTML / DFXP | TTML 1 and the 2006 DFXP namespace; times relative to their parents; clock, frames (`ttp:frameRate`), ticks and offset times; spans and referenced styles | TTML 1 with a bottom region and tts: spans |
| MicroDVD (.sub) | `{frame}{frame}text` with the rate of a `{1}{1}25` first line (else 23.976), `\|` between lines, `{y:i}` / `{c:$BBGGRR}`; VobSub `.sub` (pictures) and SubViewer fail with a clear message | 23.976 fps, first line states it, plain text, `{Y:i}` for all-italic cues |
| SCC | CEA-608 caption channel 1: pop-on, paint-on and roll-up captions, row preambles, italics, special and extended characters, doubled control codes, drop-frame time codes | — |
@@ -3705,16 +3919,32 @@ Dahua DVR / NVR recordings (`.dav`, read only, #594) are read by FFmpeg's `dhav`
the target and preset allow it (`CanCopyVideo`), like any other container's; G.711 sound is encoded for targets that
can't hold it. The chunks carry the camera's wall-clock time, so FFmpeg reports the recording date as the start time;
the output starts at zero.
+Every action that hands FFmpeg a file (convert, caption, Live / Motion Photo, Merge videos, the Cut a clip editor, size
+estimates) asks `Media/MediaInput.cs` what to give it: the path, or for the two cases below a `concat:` or `subfile`
+input. Merge videos' concat list allows those protocols (`-protocol_whitelist`), which its demuxer refuses by default.
Hikvision camera and NVR recordings are MPEG program streams with H.264 / H.265 video and G.711 sound, whatever their
extension; FFmpeg's mpeg demuxer reads them by their content, so they go to every video and audio target as their
format (usually MP4) is read. Exports from iVMS-4200 and the NVR (`.mp4`, sometimes `.264`) start with a 40-byte
`IMKH` header, which makes FFmpeg read the stream map's type 0x91 as G.711 mu-law. The recordings on a camera's SD
card or a NAS (`hiv00000.mp4`, indexed by `index00.bin`) are the same stream without the header: FFmpeg then takes
-mu-law sound for MP2 and fails. When a file starts with a pack header and a stream map in its first 256 KB lists
-mu-law sound (`Media/HikvisionStream.cs`), Filee gives FFmpeg `concat:|`, a 40-byte `IMKH` header
-written to the job's work folder followed by the recording, which is not copied. A damaged stream map leaves the file
-to FFmpeg as it is. Old `4HKH` `.264` files (Hikvision's own frame format) and recordings made with stream encryption
-on can't be read and fail with FFmpeg's error.
+mu-law sound for MP2 and fails. When a video file starts with a pack header and a stream map in its first 256 KB lists
+mu-law sound (`Media/HikvisionStream.cs`, which reads only four bytes of any other file), Filee gives FFmpeg
+`concat:|`, a 40-byte `IMKH` header written to the job's work folder (to a `media` folder in Filee's
+work folder, `WorkFolders.Root`, for the Cut a clip editor) followed by the recording, which is not copied. Sound
+files and pictures aren't looked into. A damaged stream map leaves the file to FFmpeg as it is. Old `4HKH` `.264`
+files (Hikvision's own frame format) and recordings made with stream encryption on can't be read and fail with
+FFmpeg's error.
+Wisenet WAVE / Nx Witness exports (`.nov`, read only) are layout files written by Network Optix's client: named
+streams (a NUL-terminated name, then the data) and an index of where each starts, after them with a 20-byte footer
+(version 2) or before them (version 1), as `nx_open`'s `layout_file_info.h` (MPL-2.0) describes. Each camera's
+recording is a Matroska stream named `_.mkv`; `Media/NovExport.cs` reads the index and gives
+FFmpeg the largest recording through the `subfile` protocol, so nothing is copied, and it goes to every video and audio
+target. An export of several cameras, or of one camera with gaps, converts only that largest recording and says so
+(`notice.nov_recordings_left_out`). A
+password-protected export is encrypted (AES-256, keyed by the password) and refused (`error.nov_password`), as is one
+without a recording (`error.nov_no_video`); a damaged index throws `InvalidDataException`, checked against the file's
+size before anything is allocated. The `.exe` export is the client's player with the same streams appended: it stays a
+Windows program (`.exe` is read for its icon) and is never run.
Bink videos (`.bik`, read only, #751), the cutscene format of many games, are read by FFmpeg's `bink` demuxer and
decoder and go to every video and audio target. FFmpeg has no Bink encoder, so nothing writes `.bik`.
FLIC animations (`.flc` and `.fli`, read only, #940), from Autodesk Animator and Animator Pro and the cutscenes of DOS
@@ -3752,7 +3982,9 @@ format, and FFmpeg reads what the file holds, so a `.dat` with no video in it fa
| WAV · W64 · AIFF · AU · CAF · VOC · FLAC · WV · TTA | 16-bit PCM (WAV and W64: 8/16/24/32-bit or 32-bit float; WAV switches to RF64 over 4 GB, Sony Wave64 has no limit) · FLAC · WavPack (lossless) · TTA |
| SRT · ASS · VTT (from video) | the video's first text subtitle track, copied when it already is that format, else converted by FFmpeg |
- **M4R (iPhone ringtone, #1291).** AAC in the M4A container (`-f ipod`, `+faststart`) with the `.m4r` extension, which is what iOS looks for. It is always encoded, never stream-copied, so the cut is exact. The ringtone window is the preset's `RingtoneStart` and `RingtoneLength` (the editor's *Ringtone start* and *Ringtone length*; defaults 0 s and 30 s): `-ss` before the input and `-t` after it, at most 40 s (`MediaOptions.RingtoneWindow`). A length that goes past the end of the sound is cut there, and a start past the end moves back so the ringtone keeps its length and ends where the sound ends. When the ringtone is only a part of the sound, the result carries the note `notice.ringtone_cut`. The built-in *iPhone ringtone* preset is in the palette only (no donut slot).
+ **M4R (iPhone ringtone, #1291).** AAC in the M4A container (`-f ipod`, `+faststart`) with the `.m4r` extension, which is what iOS looks for. It is always encoded, never stream-copied, so the cut is exact. The ringtone window is the preset's `RingtoneStart` and `RingtoneLength` (the editor's *Ringtone start* and *Ringtone length*; defaults 0 s and 30 s): `-ss` before the input and `-t` after it, at most 40 s (`MediaOptions.RingtoneWindow`). A length that goes past the end of the sound is cut there, and a start past the end moves back so the ringtone keeps its length and ends where the sound ends. When the ringtone is only a part of the sound, the result carries the note `notice.ringtone_cut`. The built-in *iPhone ringtone* preset is in the palette only (no donut slot). With `MediaOptions.AskRingtonePart` (on in the built-in preset; the editor's *Ask for the part every time*), dropping sounds on the preset opens the Cut a clip window in ringtone mode (`AskedRingtone`, `WindowService.ShowRingtoneWindow`): its Save starts the ordinary M4R conversion with `RingtoneStart` and `RingtoneLength` set to the chosen part (a copy of the preset; the saved one is left alone).
+
+ **Cut a clip on sounds (#1291).** The clip tool also takes sound files (`ClipTool.CanCut`: video or audio whose clip is one FFmpeg step). A sound's clip keeps its format where FFmpeg writes it, else MP3. `FfmpegConverter.CutSoundClipAsync` runs `MediaEncoding.ClipSoundArguments`: the same graph as a video clip with `video:false` (pieces, volume, speed, fades, music), always encoded, 48 kHz stereo, cover art kept for formats that carry it. `VideoFrames.ProbeAsync` reports a sound as a `VideoSummary` with no picture (`HasPicture` false), and `ClipPreview.PlayAsync` plays it with `onFrame([], 0, 0, seconds)`.
Read only (FFmpeg's decoders): APE (Monkey's Audio), Musepack (MPC), TAK, DSF and DFF (DSD), DTS, Speex (SPX) and the
formats above. No encoder for them is in the build, or (DTS) only an experimental one.
diff --git a/docs/FORMAT-RULES.md b/docs/FORMAT-RULES.md
index e6b6256d1..83ff4771d 100644
--- a/docs/FORMAT-RULES.md
+++ b/docs/FORMAT-RULES.md
@@ -97,8 +97,9 @@ image it is a plain PNG or JPG conversion.
### 8. Don't block inputs that pass as an ordinary conversion
- Such inputs keep working, so existing toolbars and presets don't stop working.
-- Where the result differs a lot from what the name says, the user is told so, for example in the slot's tooltip:
+- Where the result differs a lot from what the name says, the user is told so in the donut centre text and the Available presets tooltip:
*Extract frames* (`extract-frames-png`, `extract-frames-jpg`) and *Extract Live Photo video* (`extract-motion`).
+- The list of presets that need this notice lives in `PresetAvailability.OrdinaryConversionFor`; add new presets there.
- An input is blocked only where the result would mislead. That is decided per feature, not by one rule for all.
### 9. When inputs grow, recheck the features
diff --git a/docs/RENDER-QUALITY.md b/docs/RENDER-QUALITY.md
index 474b1dc94..6b57906d2 100644
--- a/docs/RENDER-QUALITY.md
+++ b/docs/RENDER-QUALITY.md
@@ -2,7 +2,7 @@
How Filee measures a document renderer: GitHub Project 4, "Filee: built-in engines", item *Layout engine: render
quality harness*. It measures Filee's own layout engine, the Filee renderer (the default document → PDF engine since
-settings schema 91), against rhwp, the renderer it replaced and Filee still bundles for files the renderer leaves to it
+settings schema 97), against rhwp, the renderer it replaced and Filee still bundles for files the renderer leaves to it
and for users who turn it off. The numbers behind the switch (pages, lines and SSIM on the HWPX and HWP corpora) are in
discussion #891. No input or reference file is in
the repository: every document is written in code at test time, and real documents stay in a private corpus outside
diff --git a/installer/macos/Info.plist b/installer/macos/Info.plist
index e02a80c3d..187c0afcd 100644
--- a/installer/macos/Info.plist
+++ b/installer/macos/Info.plist
@@ -3,7 +3,7 @@
CFBundleDevelopmentRegion en
- CFBundleLocalizations en ko zh-Hans zh-Hant pt-PT pt-BR de it id vi fa
+ CFBundleLocalizations en mn ko zh-Hans zh-Hant pt-PT pt-BR de it id vi fa
CFBundleIdentifier com.filee.app
CFBundleName Filee
CFBundleDisplayName Filee
diff --git a/installer/macos/dmg/requirements.txt b/installer/macos/dmg/requirements.txt
new file mode 100644
index 000000000..17a984e8f
--- /dev/null
+++ b/installer/macos/dmg/requirements.txt
@@ -0,0 +1,4 @@
+# The disk image tool (build/build-portable.ps1) and its dependencies, pinned so release builds don't change.
+dmgbuild==1.6.7
+ds_store==1.3.3
+mac_alias==2.2.3
diff --git a/installer/macos/mn.lproj/InfoPlist.strings b/installer/macos/mn.lproj/InfoPlist.strings
new file mode 100644
index 000000000..bd9eaa959
--- /dev/null
+++ b/installer/macos/mn.lproj/InfoPlist.strings
@@ -0,0 +1,2 @@
+"NSAppleEventsUsageDescription" = "Filee хөрвүүлэх товчлол ашиглах үед Finder-д сонгосон файлуудыг уншина.";
+"NSInputMonitoringUsageDescription" = "Filee хөрвүүлэх цэсийг харуулахын тулд товчлол болон чирэх үйлдлийг танина.";
diff --git a/installer/macos/mn.lproj/ServicesMenu.strings b/installer/macos/mn.lproj/ServicesMenu.strings
new file mode 100644
index 000000000..110008c3a
--- /dev/null
+++ b/installer/macos/mn.lproj/ServicesMenu.strings
@@ -0,0 +1 @@
+"Convert with Filee" = "Filee-ээр хөрвүүлэх";
diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs
index d39b7b56f..1c8a421d0 100644
--- a/src/Filee.App/App.axaml.cs
+++ b/src/Filee.App/App.axaml.cs
@@ -231,6 +231,7 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop)
desktop.Exit += (_, _) =>
{
UiZoom.Flush();
+ DonutLookSave.Flush();
watchdog.Dispose();
AppHost.Get().Dispose();
AppHost.Get().Dispose();
diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json
index 19511f10c..793f88207 100644
--- a/src/Filee.App/Assets/i18n/ar.json
+++ b/src/Filee.App/Assets/i18n/ar.json
@@ -74,6 +74,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "عدد الملفات: {0}",
"donut.drop_to": "أفلت للتحويل إلى {0}",
+ "donut.ordinary_conversion.single": "يُحوَّل هذا الملف تحويلاً عادياً",
+ "donut.ordinary_conversion.all": "تحويل عادي لكل ملف قابل للتحويل",
+ "donut.ordinary_conversion.some": "تحويل عادي لبعض الملفات",
"donut.pick_in_group": "حرّك المؤشر إلى الخارج للاختيار",
"donut.group_empty": "لم تعد هناك إعدادات مسبقة في هذه المجموعة. عدّلها من الإعدادات ← شريط الأدوات الدائري",
"donut.click_to": "انقر للتحويل إلى {0}",
@@ -127,6 +130,8 @@
"error.split_unknown_length": "مدة هذا الفيديو غير معروفة، لذا لا يمكن تقسيمه إلى أجزاء متساوية",
"error.split_needs_copy": "التقسيم ينسخ الفيديو كما هو، وهذه الصيغة لا يمكنها احتواؤه. اختر صيغة الفيديو نفسها أو MKV",
"error.merged_image_too_large": "ستكون الصورة المدمجة كبيرة جدًا، ادمج صورًا أقل أو أصغر",
+ "error.merge_paper_geometry": "تحتوي الصفحة على حجم أو تدوير أو وحدات غير صالحة، ولا يمكن ملاءمتها للورق المحدد",
+ "error.merge_paper_limit": "تتجاوز الصفحة حد المعالجة اللازم لتغيير حجم الورق",
"error.over_size_limit": "لا يمكن تصغيره بما يكفي ليناسب حد الحجم",
"error.not_enough_space": "لا توجد مساحة كافية على القرص لحفظ الصور",
"error.no_motion_photo": "لا تحتوي هذه الصورة على فيديو يستطيع Filee قراءته (صورة Motion Photo من Android، أو Live Photo من iPhone مع ملف MOV بجانبها)",
@@ -166,6 +171,9 @@
"error.fit_damaged": "ملف FIT تالف أو مبتور",
"error.fit_chained": "الملفات المتعددة من نوع FIT المتصلة بعضها ببعض غير مدعومة بعد",
"error.fit_too_large": "ملف FIT كبير جدًا بحيث لا يمكن تحويله",
+ "error.canvas_not_canvas": "هذا ليس لوحة Obsidian (JSON Canvas)",
+ "error.canvas_empty": "لا تحتوي اللوحة على بطاقات لرسمها",
+ "error.canvas_too_large": "اللوحة كبيرة جدًا بحيث لا يمكن تحويلها",
"error.fit_no_file_id": "لا يحدد ملف FIT نوع الملف",
"error.fit_not_activity": "ملف FIT هذا ليس نشاطًا (يتم تحويل الأنشطة فقط)",
"error.fit_no_positions": "لا يحتوي النشاط على مواقع مسجلة: حوّله إلى CSV أو XLSX أو JSON",
@@ -177,12 +185,16 @@
"error.fon_damaged": "ملف .fon تالف أو غير مكتمل",
"error.document_too_complex": "المستند كبير جدًا أو معقّد جدًا بحيث لا يمكن قراءته بأمان",
"error.m4s_needs_init": "ملف .m4s هذا جزء من فيديو متدفّق، ولا يمكن قراءته دون مقطع البداية (init) المنفصل الخاص به",
+ "error.nov_password": "هذا التصدير محمي بكلمة مرور، لذا فإن الفيديو الخاص به مشفّر: صدّره مرة أخرى من Wisenet WAVE / Nx Witness بدون كلمة مرور",
+ "error.nov_no_video": "لا يحتوي هذا التصدير على أي تسجيل من الكاميرا",
"error.exe_unreadable": "هذا ليس برنامج Windows أو مكتبة يستطيع Filee قراءتها، أو أن الملف تالف",
"error.exe_no_icon": "لا يحتوي هذا الملف على أيقونة خاصة به (يعرض Windows له أيقونة قياسية)",
"notice.pdf_not_smaller": "تعذّر تقليل الحجم، لذا نُسخ الملف كما هو",
+ "notice.nov_recordings_left_out": "يحتوي هذا التصدير على عدة تسجيلات (كاميرات أخرى أو أجزاء تفصل بينها فجوات): تم تحويل الأكبر فقط",
"notice.under_min_size": "تم الحفظ، لكنه أصغر من الحد الأدنى لحجم الملف",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "حُذفت كائنات لا يدعمها إصدار AutoCAD المختار (أسهم توضيحية متعددة، شبكات …)",
+ "notice.gcode_no_thumbnail": "لا يحتوي ملف G-code على معاينة من برنامج التقطيع، لذا رُسم مسار الأداة من الأعلى",
"notice.sheet_objects_left_out": "حُذفت كائنات لا يستطيع Filee طباعتها بعد (مخططات، نص داخل الأشكال …): ثبّت LibreOffice من الإعدادات ← المحركات للإبقاء عليها",
"notice.cmyk_needs_ghostscript": "هذا الملف بنظام CMYK لكنه حُفظ بنظام RGB: ثبّت Ghostscript من الإعدادات ← المحركات للإبقاء على CMYK",
"notice.pdfa_needs_ghostscript": "هذا الملف ليس PDF/A: ثبّت Ghostscript من الإعدادات ← المحركات لإنشاء PDF/A",
@@ -275,6 +287,8 @@
"engine.font.description": "تحويل TTF وOTF وWOFF وWOFF2 وEOT فيما بينها، وتحويل خطوط Windows النقطية (FON) إلى هذه الصيغ أيضًا.",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "رسومات AutoCAD: التحويل بين DWG وDXF، وتحويل كليهما إلى PDF وSVG وPNG.",
+ "engine.gcode.name": "G-code (built-in)",
+ "engine.gcode.description": "مسارات الطابعات ثلاثية الأبعاد وآلات CNC (G-code) إلى PDF وSVG وصور، أو معاينة برنامج التقطيع.",
"engine.drawing.name": "Drawings (built-in)",
"engine.drawing.description": "تحويل رسومات Visio (VSDX) ورسومات OpenDocument (ODG وFODG) إلى PDF وSVG وPNG دون Visio أو LibreOffice.\nلا تزال ملفات Visio الثنائية القديمة (.vsd) تحتاج إلى LibreOffice.",
"engine.model3d.name": "3D models (built-in)",
@@ -297,6 +311,7 @@
"engine.tesseract.description": "يتعرّف على نص الصور والمستندات الممسوحة (OCR): PDF قابل للبحث وDOCX وTXT.",
"engine.vision.description": "يتعرّف على نص الصور والمستندات الممسوحة بأداة التعرّف على النص المدمجة في macOS (OCR): PDF قابل للبحث وDOCX وTXT. لا حاجة إلى أي تنزيل.",
"engine.calibre.description": "صيغ الكتب الإلكترونية النادرة (LIT وLRF…) والحفظ بصيغ قديمة (LIT وPDB وRB…).\nعند إيقاف مُصيِّر Filee ووضع Calibre فوق محرك كتابة HWPX، يُنشئ أيضًا PDF من الكتب الإلكترونية (بسرعة أكبر بكثير للكتب الطويلة).",
+ "engine.weasyprint.description": "ينشئ ملفات PDF من صفحات HTML وMarkdown والبريد الإلكتروني كما يطبعها المتصفح: تظهر العناوين والجداول والخط العريض والنص العربي أو الهندي بشكل صحيح.\nلا يحمّل سوى الصور الموجودة بجانب المستند، ولا شيء من الإنترنت.",
"engine.libreoffice.description": "قراءة الصيغ القديمة والأقل شيوعًا: DOC من Word 6.0/95، وPPT من PowerPoint 95، وPages وKeynote وNumbers وWordPerfect وClarisWorks وPublisher وملفات Visio الثنائية (VSD)…\nوتحويل ملفات ODP وODG التي تحتوي على مخططات أو كائنات مضمّنة أو عناصر ثلاثية الأبعاد، والحفظ بصيغ DOC وXLS وPPT وODT وقوالب OpenDocument وEMF.",
"engine.translator.description": "يقوم بترجمة المستندات والكتب الإلكترونية محلياً باستخدام نماذج Ollama دون اتصال بالإنترنت.\nيترجم أيضاً ملفات الترجمة بصيغتي SRT وWebVTT: يُرسَل نص المقاطع فقط إلى النموذج، وتبقى أرقام المقاطع وتوقيتاتها كما هي.",
"engine.exiftool.description": "يقرأ كل حقول البيانات الوصفية للصور وملفات PDF والفيديو والصوت، ويعدّلها في معظم أنواع الملفات دون المساس بمحتوى الملف.\nتستخدمه أداة البيانات الوصفية؛ ومن دونه تُعرض الحقول للقراءة فقط.",
@@ -395,6 +410,7 @@
"clip.save": "حفظ المقطع",
"clip.save_next": "حفظ والتالي",
"clip.seconds": "{0} ث",
+ "clip.ringtone_limit": "نغمة رنين iPhone لا تتجاوز {0} ثانية",
"clip.length_hint": "مقطع بهذه المدة من البداية",
"clip.fast": "سريع، دون إعادة ترميز",
"clip.fast_hint": "ينسخ الفيديو كما هو: ينتهي في لحظة دون فقدان الجودة، لكن المقطع يبدأ من الإطار المفتاحي الذي يسبق البداية (حتى بضع ثوانٍ قبلها). أوقفه للقص بدقة عند الإطارات المختارة (يعيد الترميز ويستغرق وقتًا أطول).",
@@ -793,9 +809,15 @@
"toolbar.hint_remove": "اسحب قطاعًا إلى هنا لإزالته",
"toolbar.hint_reorder": "اسحب القطاعات فوق بعضها لتغيير ترتيبها",
"toolbar.hint_edit": "انقر بالزر الأيمن على قطاع أو استخدم ✎ لتعديله",
+ "toolbar.group_hint": "اسحب الإعدادات المسبقة من القائمة، واسحب الشرائح لإعادة ترتيبها، واسحب شريحة إلى القائمة لإزالتها.",
+ "toolbar.group_back": "العودة إلى {0}",
"toolbar.max_reached": "يتسع الشريط الدائري لما يصل إلى {0} إعدادات مسبقة",
- "toolbar.appearance": "المظهر",
"toolbar.size": "الحجم",
+ "toolbar.edit_size": "تعديل حجم الشريط الدائري",
+ "toolbar.look_done": "تم",
+ "toolbar.look_hint": "الشريط الدائري معروض على الشاشة بحجمه الحقيقي. اسحب منتصفه لنقله، وانقر على مجموعة لتبقى مفتوحة.",
+ "toolbar.look_all": "كل الأشرطة الدائرية",
+ "toolbar.look_this": "هذا الشريط الدائري: {0}",
"toolbar.hole": "الفتحة الوسطى",
"toolbar.opacity": "العتامة",
"toolbar.captions": "إظهار صيغة الناتج",
@@ -803,6 +825,9 @@
"toolbar.label_scale": "حجم التسميات",
"toolbar.ring_size": "حجم حلقة المجموعة",
"toolbar.ring_spread": "اتساع حلقة المجموعة",
+ "toolbar.group_shown": "المجموعة المعروضة",
+ "toolbar.example_group": "مجموعة كمثال",
+ "toolbar.example_none": "لا توجد مجموعات في هذا الشريط الدائري",
"labels.auto": "تلقائي",
"labels.icon_text": "أيقونة ونص",
"labels.text_only": "نص فقط",
@@ -825,6 +850,7 @@
"group.add": "إضافة",
"group.add_placeholder": "اختر إعدادًا مسبقًا",
"group.full": "تتسع المجموعة لما يصل إلى {0} إعدادات مسبقة.",
+ "group.copy_front": "لا يمكن وضع «النسخ إلى مجلد» في هذه المجموعة: فهي موجودة على وجه أمامي أيضًا.",
"group.move_up": "نقل لأعلى",
"group.move_down": "نقل لأسفل",
"group.remove": "إزالة من المجموعة",
@@ -851,6 +877,9 @@
"toolbar.mixed_others": "تفتح التحديدات المختلطة الأخرى «{0}».",
"toolbar.mixed_others_self": "لا يوجد ملف تعريف مختلط بلا امتدادات محددة، لذا يُستخدم هذا الملف أيضًا لكل مزيج آخر.",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "تُحوَّل صيغة الإدخال هذه تحويلاً عادياً",
+ "toolbar.chip_tip.ordinary_conversion.all": "تُحوَّل جميع صيغ الإدخال القابلة للتحويل تحويلاً عادياً",
+ "toolbar.chip_tip.ordinary_conversion.some": "تُحوَّل بعض صيغ الإدخال تحويلاً عادياً",
"toolbar.chip_tip.makes_same": "→ التنسيق نفسه",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "الصيغ المقبولة: {0}",
@@ -902,6 +931,11 @@
"presets.cad_colors.BlackAndWhite": "أبيض وأسود",
"presets.cad_colors.Grayscale": "تدرّج رمادي",
"presets.cad_colors_hint": "يُستخدم عند تحويل رسم DWG أو DXF إلى PDF أو SVG أو صورة. يرسم «أبيض وأسود» كل الخطوط والنصوص بالأسود مثل الطباعة أحادية اللون، وتصبح المساحات المملوءة رمادية بحسب سطوعها حتى لا يتحول الملء الفاتح إلى كتلة سوداء. أما «تدرّج رمادي» فيحوّل كل لون إلى رمادي ويُغمّق الخطوط الفاتحة، كالأصفر، بما يكفي لقراءتها.",
+ "presets.gcode_view": "العرض",
+ "presets.gcode_view.Top": "من الأعلى",
+ "presets.gcode_view.Isometric": "متساوي القياس",
+ "presets.gcode_view.Thumbnail": "معاينة برنامج التقطيع",
+ "presets.gcode_view_hint": "يُستخدم عندما يتحول ملف G-code من برنامج تقطيع أو برنامج CAM إلى PDF أو SVG أو صورة. يُلوَّن البثق والقطع حسب الارتفاع، من الداكن (منخفض) إلى الفاتح (مرتفع)؛ وحركات الانتقال خطوط زرقاء رفيعة (حمراء للحركات السريعة في CNC). معاينة برنامج التقطيع هي الصورة التي حفظها PrusaSlicer أو Cura أو OrcaSlicer أو Bambu Studio في الملف؛ أما SVG وPDF والملفات التي لا تحتوي عليها فتعرض المسار من الأعلى.",
"presets.group.image": "الصورة",
"presets.quality": "الجودة",
"presets.resize": "تغيير الحجم",
@@ -981,10 +1015,15 @@
"presets.ringtone_start": "بداية النغمة (ث)",
"presets.ringtone_length": "مدة النغمة (ث، 40 كحد أقصى)",
"presets.ringtone_hint": "نغمات iPhone ملفات AAC لا تزيد مدتها عن 40 ثانية. تبدأ النغمة من الثانية التي تحددها وتُقص إلى المدة التي تحددها (لا تتجاوز 40 ثانية).",
+ "presets.ask_ringtone": "اسأل عن الجزء في كل مرة (يفتح قص مقطع)",
"presets.ico_sizes": "أحجام الأيقونة",
"presets.group.pdf": "PDF",
"presets.merge": "دمج كل الملفات في PDF واحد",
"presets.source_bookmarks": "إضافة إشارة مرجعية لكل ملف أصلي",
+ "presets.normalize_merge_paper": "استخدام حجم ورق واحد لكل الصفحات المدمجة",
+ "presets.merge_paper": "ورق ملف PDF المدمج",
+ "presets.merge_paper_hint": "يُلاءم كل صفحة مع الورق المحدد ويضعها في الوسط دون قص. تُطبّق إعدادات صفحات الصور قبل الدمج.",
+ "presets.merge_paper_auto_hint": "يُختار الاتجاه التلقائي مرة واحدة استنادًا إلى الصفحة الأولى. تُكبّر الصفحات الأصغر لتلائم الورق.",
"presets.page_size": "حجم الصفحة",
"presets.page_size.FitImage": "ملاءمة الصورة",
"presets.page_size.A4": "A4",
@@ -1141,6 +1180,7 @@
"presets.extract_note": "يُستخرج كل أرشيف إلى مجلد مستقل. يُحدَّد موقعه ضمن الإخراج.",
"presets.group.trace": "التتبّع",
"presets.group.cad": "رسم CAD",
+ "presets.group.gcode": "G-code (طباعة ثلاثية الأبعاد، CNC)",
"presets.trace_note": "يُستخدم عند تتبّع صورة (PNG أو JPG أو غيرها) إلى أشكال متجهية.",
"presets.trace_mode": "الوضع",
"presets.trace_mode.Color": "ملوّن",
@@ -1367,6 +1407,8 @@
"engines.package.whisper.description": "تحويل الكلام في الفيديو والصوت إلى ملفات ترجمة SRT. يعمل بدون اتصال بالإنترنت.",
"engines.package.calibre.name": "Calibre (صيغ الكتب الإلكترونية النادرة)",
"engines.package.calibre.description": "يحوّل صيغ الكتب الإلكترونية النادرة (LIT وLRF…) ويحفظ بصيغ قديمة (LIT وPDB وRB…).\nتُقرأ EPUB وMOBI وAZW3 وFB2 والقصص المصورة، وتُحفظ كتب Kindle، بدونه.",
+ "engines.package.weasyprint.name": "WeasyPrint (من HTML وMarkdown والبريد إلى PDF)",
+ "engines.package.weasyprint.description": "ينشئ ملفات PDF أفضل من صفحات HTML وMarkdown والبريد الإلكتروني (EML وMSG وMHTML): العناوين والجداول والخط العريض والكتابات من اليمين إلى اليسار أو الهندية كما يطبعها المتصفح.\nوبدونه تصبح أيضًا ملفات PDF عبر المسار المدمج.",
"engines.package.7zip.name": "7-Zip (أنواع الأرشيف النادرة)",
"engines.package.7zip.description": "يفك الأنواع النادرة من الأرشيفات التي لا تقرؤها قارئات Filee المدمجة: 7Z بطريقة PPMd وطرق ضغط أخرى غير شائعة، وCAB بضغط Quantum، وARJ متعدد الأجزاء، وصور الأقراص بنظام NTFS أو exFAT أو APFS…\nتعمل ZIP و7Z وRAR وTAR وCAB وISO وDMG وكل الأرشيفات الشائعة الأخرى بدونه.",
"engines.package.size": "التنزيل {0} · على القرص {1}",
@@ -1393,7 +1435,17 @@
"engines.remove": "إزالة",
"engines.remove_confirm": "هل تريد إزالة {0}؟ يمكنك تنزيله مجددًا لاحقًا.",
"engines.state.own_copy": "نسختك",
- "engines.state.system_copy": "موجود على هذا الحاسوب",
+ "engines.source.download": "تنزيل Filee",
+ "engines.source.system": "على هذا الحاسوب",
+ "engines.source.version": "الإصدار {0}",
+ "engines.source.on_disk": "على القرص {0}",
+ "engines.other_copy_title": "موجود أيضًا على هذا الحاسوب",
+ "engines.other_copy_detail": "{0} (ستُزال نسخة Filee المنزّلة)",
+ "engines.use_other_copy": "استخدام هذه بدلًا منها",
+ "engines.use_other_copy_confirm": "هل تريد استخدام النسخة الموجودة في {0} وإزالة نسخة {1} التي نزّلها Filee؟ يمكنك تنزيلها مجددًا لاحقًا.",
+ "engines.other_copy_gone": "لم يعد من الممكن العثور على تلك النسخة أو أنها لا تعمل. تم الإبقاء على نسخة Filee المنزّلة.",
+ "engines.install_filee_copy": "تثبيت نسخة Filee",
+ "engines.use_another": "استخدام نسخة أخرى…",
"engines.update_recommended": "يوصى بالتحديث: هذه النسخة هي {0}، وقد اختُبر Filee مع {1}.\nتظل معظم التحويلات تعمل.",
"engines.use_own": "استخدام نسختي…",
"engines.use_own_hint": "هل لديك نسخة بالفعل؟ اختر برنامجها ليستخدمه Filee بدلًا من تنزيل نسخته الخاصة.",
diff --git a/src/Filee.App/Assets/i18n/bn-BD.json b/src/Filee.App/Assets/i18n/bn-BD.json
index a897ff0a4..55a32ec02 100644
--- a/src/Filee.App/Assets/i18n/bn-BD.json
+++ b/src/Filee.App/Assets/i18n/bn-BD.json
@@ -88,6 +88,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "{0}টি ফাইল",
"donut.drop_to": "ছেড়ে দিন → {0}",
+ "donut.ordinary_conversion.single": "এই ফাইলটি সাধারণ রূপান্তর হিসেবে চলে",
+ "donut.ordinary_conversion.all": "রূপান্তরযোগ্য সব ফাইল সাধারণ রূপান্তর হিসেবে চলে",
+ "donut.ordinary_conversion.some": "কিছু ফাইল সাধারণ রূপান্তর হিসেবে চলে",
"donut.pick_in_group": "নির্বাচন করতে বাইরের দিকে সরান",
"donut.group_empty": "এই গ্রুপে আর কোনো প্রিসেট অবশিষ্ট নেই। সেটিংস → ডোনাট টুলবারে এটি সম্পাদনা করুন",
"donut.click_to": "ক্লিক করুন → {0}",
@@ -141,6 +144,8 @@
"error.split_unknown_length": "এই ভিডিওর দৈর্ঘ্য জানা নেই, তাই এটিকে সমান ভাগে কাটা যাবে না",
"error.split_needs_copy": "ভাগ করার সময় ভিডিও যেমন আছে তেমন কপি হয়, আর এই ফরম্যাট সেটি রাখতে পারে না। ভিডিওর নিজের ফরম্যাট বা MKV বেছে নিন",
"error.merged_image_too_large": "একত্রিত ছবিটি অতিরিক্ত বড় হবে, কম বা ছোট ছবি একত্রিত করুন",
+ "error.merge_paper_geometry": "পৃষ্ঠার আকার, ঘূর্ণন বা একক অবৈধ, তাই নির্বাচিত কাগজে মানিয়ে নেওয়া যাবে না",
+ "error.merge_paper_limit": "কাগজের আকার পরিবর্তনের প্রক্রিয়াকরণ সীমা পৃষ্ঠাটি অতিক্রম করেছে",
"error.over_size_limit": "ফাইলের আকারের সীমার মধ্যে এটিকে যথেষ্ট ছোট করা যাচ্ছে না",
"error.not_enough_space": "ছবিগুলোর জন্য ডিস্কে পর্যাপ্ত গ্যাপা জায়গা নেই",
"error.no_motion_photo": "এই ছবিতে ফাইলি পড়তে পারে এমন কোনো ভিডিও নেই (একটি Android Motion Photo, অথবা পাশে MOV ফাইলসহ iPhone Live Photo)",
@@ -180,6 +185,9 @@
"error.fit_damaged": "FIT ফাইলটি ক্ষতিগ্রস্ত বা অসম্পূর্ণ",
"error.fit_chained": "ফাইলটিতে একের পর এক একাধিক FIT ফাইল রয়েছে, যা এখনও সমর্থিত নয়",
"error.fit_too_large": "রূপান্তরের জন্য FIT ফাইলটি অতিরিক্ত বড়",
+ "error.canvas_not_canvas": "এটি Obsidian ক্যানভাস (JSON Canvas) নয়",
+ "error.canvas_empty": "ক্যানভাসে আঁকার মতো কোনো কার্ড নেই",
+ "error.canvas_too_large": "রূপান্তরের জন্য ক্যানভাসটি অতিরিক্ত বড়",
"error.fit_no_file_id": "FIT ফাইলে এটি কোন ধরনের ফাইল তা উল্লেখ নেই",
"error.fit_not_activity": "এই FIT ফাইলটি কোনো অ্যাক্টিভিটি নয় (কেবল অ্যাক্টিভিটিই রূপান্তর করা হয়)",
"error.fit_no_positions": "অ্যাক্টিভিটিতে কোনো রেকর্ডকৃত অবস্থান নেই: এটিকে CSV, XLSX বা JSON-এ রূপান্তর করুন",
@@ -191,12 +199,16 @@
"error.fon_damaged": ".fon ফাইলটি ক্ষতিগ্রস্ত বা অসম্পূর্ণ",
"error.document_too_complex": "নথিটি নিরাপদে পড়ার জন্য অনেক বড় বা অনেক জটিল",
"error.m4s_needs_init": "এই .m4s ফাইলটি স্ট্রিম করা ভিডিওর একটি অংশ: এর আলাদা সূচনা (init) অংশ ছাড়া এটি পড়া যাবে না",
+ "error.nov_password": "এই এক্সপোর্টটি পাসওয়ার্ড দিয়ে সুরক্ষিত, তাই এর ভিডিও এনক্রিপ্ট করা: Wisenet WAVE / Nx Witness থেকে পাসওয়ার্ড ছাড়া আবার এক্সপোর্ট করুন",
+ "error.nov_no_video": "এই এক্সপোর্টে ক্যামেরার কোনো রেকর্ডিং নেই",
"error.exe_unreadable": "এটি এমন কোনো Windows প্রোগ্রাম বা লাইব্রেরি নয় যা Filee পড়তে পারে, অথবা ফাইলটি ক্ষতিগ্রস্ত",
"error.exe_no_icon": "এই ফাইলের নিজস্ব কোনো আইকন নেই (Windows এর জন্য একটি সাধারণ আইকন দেখায়)",
"notice.pdf_not_smaller": "আকার আরও ছোট করা যায়নি, তাই এটি অপরিবর্তিত অবস্থায় কপি করা হয়েছে",
+ "notice.nov_recordings_left_out": "এই এক্সপোর্টে কয়েকটি রেকর্ডিং আছে (অন্য ক্যামেরা বা বিরতিতে ভাগ হওয়া অংশ): শুধু সবচেয়ে বড়টি রূপান্তর করা হয়েছে",
"notice.under_min_size": "সংরক্ষণ করা হয়েছে, তবে ফাইলের সর্বনিম্ন আকারের চেয়ে ছোট",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "নির্বাচিত AutoCAD সংস্করণে নেই এমন অবজেক্টগুলো (মাল্টিলিডার, মেশ ইত্যাদি) বাদ দেওয়া হয়েছে",
+ "notice.gcode_no_thumbnail": "G-code ফাইলে স্লাইসারের প্রিভিউ নেই, তাই টুলপাথ উপর থেকে আঁকা হয়েছে",
"notice.sheet_objects_left_out": "Filee এখনও যেসব অবজেক্ট প্রিন্ট করতে পারে না (চার্ট, আকৃতির ভেতরের লেখা ইত্যাদি) সেগুলো বাদ দেওয়া হয়েছে: রাখতে সেটিংস → ইঞ্জিন-এ LibreOffice ইনস্টল করুন",
"notice.cmyk_needs_ghostscript": "এই ফাইলটি CMYK হলেও RGB হিসেবে সংরক্ষণ করা হয়েছে: CMYK রাখতে সেটিংস → ইঞ্জিন-এ Ghostscript ইনস্টল করুন",
"notice.pdfa_needs_ghostscript": "এই পিডিএফটি PDF/A নয়: PDF/A বানাতে সেটিংস → ইঞ্জিন-এ Ghostscript ইনস্টল করুন",
@@ -289,6 +301,8 @@
"engine.font.description": "ফন্ট: TTF, OTF, WOFF, WOFF2 এবং EOT একে অপরে রূপান্তর; এগুলোতে Windows বিটম্যাপ ফন্ট (FON) এ রূপান্তর।",
"engine.cad.name": "CAD (বিল্ট-ইন)",
"engine.cad.description": "AutoCAD ড্রয়িং: DWG ↔ DXF, এবং দুটো থেকেই পিডিএফ, SVG ও PNG।",
+ "engine.gcode.name": "G-code (বিল্ট-ইন)",
+ "engine.gcode.description": "3D প্রিন্টার ও CNC মেশিনের টুলপাথ (G-code) PDF, SVG ও ছবিতে, অথবা স্লাইসারের নিজের প্রিভিউ।",
"engine.drawing.name": "ড্রয়িং (বিল্ট-ইন)",
"engine.drawing.description": "Visio ড্রয়িং (VSDX) ও OpenDocument ড্রয়িং (ODG, FODG) থেকে পিডিএফ, SVG ও PNG, Visio বা LibreOffice ছাড়াই।\nপুরোনো বাইনারি Visio ফাইলের (.vsd) জন্য এখনও LibreOffice লাগে।",
"engine.model3d.name": "3D মডেল (বিল্ট-ইন)",
@@ -311,6 +325,7 @@
"engine.tesseract.description": "স্ক্যান এবং ছবির টেক্সট শনাক্ত করে (OCR): অনুসন্ধানযোগ্য পিডিএফ, DOCX, TXT।",
"engine.vision.description": "macOS-এর নিজস্ব টেক্সট শনাক্তকরণ (OCR) দিয়ে স্ক্যান এবং ছবির টেক্সট শনাক্ত করে: অনুসন্ধানযোগ্য পিডিএফ, DOCX, TXT। কিছু ডাউনলোড করার প্রয়োজন নেই।",
"engine.calibre.description": "বিরল ই-বুক ফরম্যাট (LIT, LRF, CHM, PDB, RB…) এবং Kindle আউটপুট (MOBI, AZW3)।\nFilee রেন্ডারার বন্ধ থাকলে এবং এটি HWPX রাইটারের ওপরে থাকলে এটি ই-বুকের পিডিএফ-ও তৈরি করে (বড় বইয়ের জন্য অনেক দ্রুত)।",
+ "engine.weasyprint.description": "HTML পেজ, Markdown ও ই-মেইলের PDF ব্রাউজার যেভাবে প্রিন্ট করে সেভাবে তৈরি করে: শিরোনাম, টেবিল, বোল্ড এবং আরবি বা হিন্দি লেখা ঠিকভাবে আসে।\nএটি শুধু ডকুমেন্টের পাশের ছবিগুলো লোড করে, ইন্টারনেট থেকে কিছুই নয়।",
"engine.libreoffice.description": "পুরোনো এবং কম প্রচলিত ফরম্যাট পড়ে: Word 6.0/95 DOC, PowerPoint 95 PPT, Pages, Keynote, Numbers, ওয়ার্ডপার্ফেক্ট, ClarisWorks, Publisher, বাইনারি Visio (VSD)…\nচার্ট, এমবেড করা অবজেক্ট বা 3D থাকা ODP ও ODG ফাইলও রূপান্তর করে, এবং DOC, XLS, PPT, ODT, OpenDocument টেমপ্লেট ও EMF হিসেবে সংরক্ষণ করে।",
"engine.translator.description": "অফলাইন Ollama মডেল ব্যবহার করে স্থানীয়ভাবে ডকুমেন্ট এবং ই-বুক অনুবাদ করে।\nSRT এবং WebVTT সাবটাইটেলও অনুবাদ করে: কেবল কিউ টেক্সট মডেলে পাঠানো হয়, কিউ নম্বর ও টাইমস্ট্যাম্প অপরিবর্তিত থাকে।",
"engine.exiftool.description": "ছবি, PDF, ভিডিও ও শব্দের প্রতিটি মেটাডেটা ফিল্ড পড়ে এবং ফাইলের বিষয়বস্তু না ছুঁয়ে বেশিরভাগ ধরনের ফাইলে সেগুলো সম্পাদনা করে।\nমেটাডেটা টুল এটি ব্যবহার করে; এটি ছাড়া ফিল্ডগুলো শুধু পড়ার জন্য দেখানো হয়।",
@@ -409,6 +424,7 @@
"clip.save": "ক্লিপ সংরক্ষণ করুন",
"clip.save_next": "সংরক্ষণ ও পরবর্তী",
"clip.seconds": "{0} সেকেন্ড",
+ "clip.ringtone_limit": "iPhone রিংটোন সর্বোচ্চ {0} সেকেন্ডের হয়",
"clip.length_hint": "শুরু থেকে এত সেকেন্ডের একটি ক্লিপ",
"clip.fast": "দ্রুত, পুনঃএনকোডিং ছাড়া",
"clip.fast_hint": "ভিডিওটি যেমন আছে তেমনই কপি করে: পলকের মধ্যে শেষ হয় এবং গুণমান নষ্ট হয় না, তবে শুরুর ঠিক আগের কী-ফ্রেম থেকে ক্লিপটি শুরু হয় (কয়েক সেকেন্ড আগে পর্যন্ত)। সুনির্দিষ্ট ফ্রেমে কাটতে এটি বন্ধ করুন (পুনরায় এনকোড করে, বেশি সময় নেয়)।",
@@ -792,9 +808,15 @@
"toolbar.hint_remove": "সরাতে একটি স্লাইস এখানে টেনে আনুন",
"toolbar.hint_reorder": "ক্রম সাজাতে স্লাইসগুলো একটির ওপর আরেকটি টেনে আনুন",
"toolbar.hint_edit": "সম্পাদনা করতে স্লাইসে রাইট-ক্লিক করুন বা ✎ ব্যবহার করুন",
+ "toolbar.group_hint": "তালিকা থেকে প্রিসেট টেনে আনুন, ক্রম সাজাতে স্লাইস টানুন, আর বের করে দিতে একটি স্লাইস তালিকায় টেনে আনুন।",
+ "toolbar.group_back": "{0}-এ ফিরে যান",
"toolbar.max_reached": "একটি ডোনাটে সর্বোচ্চ {0}টি প্রিসেট রাখা যায়",
- "toolbar.appearance": "অবয়ব",
"toolbar.size": "আকার",
+ "toolbar.edit_size": "ডোনাটের আকার সম্পাদনা",
+ "toolbar.look_done": "সম্পন্ন",
+ "toolbar.look_hint": "আপনার ডোনাট আসল আকারে স্ক্রিনে আছে। সরাতে এর মাঝখান টেনে আনুন, আর কোনো গ্রুপ খোলা রাখতে তাতে ক্লিক করুন।",
+ "toolbar.look_all": "সব ডোনাট",
+ "toolbar.look_this": "এই ডোনাট: {0}",
"toolbar.hole": "ছিদ্র",
"toolbar.opacity": "অস্বচ্ছতা",
"toolbar.captions": "আউটপুট ফরম্যাট দেখান",
@@ -802,6 +824,9 @@
"toolbar.label_scale": "লেবেলের আকার",
"toolbar.ring_size": "গ্রুপ রিংয়ের আকার",
"toolbar.ring_spread": "গ্রুপ রিংয়ের বিস্তার",
+ "toolbar.group_shown": "দেখানো গ্রুপ",
+ "toolbar.example_group": "উদাহরণ গ্রুপ",
+ "toolbar.example_none": "এই ডোনাটে কোনো গ্রুপ নেই",
"labels.auto": "স্বয়ংক্রিয়",
"labels.icon_text": "আইকন ও টেক্সট",
"labels.text_only": "কেবল টেক্সট",
@@ -824,6 +849,7 @@
"group.add": "যোগ করুন",
"group.add_placeholder": "একটি প্রিসেট নির্বাচন করুন",
"group.full": "একটি গ্রুপে সর্বোচ্চ {0}টি প্রিসেট রাখা যায়।",
+ "group.copy_front": "ফোল্ডারে কপি করা এই গ্রুপে রাখা যায় না: গ্রুপটি সামনের পিঠেও আছে।",
"group.move_up": "ওপরে তুলুন",
"group.move_down": "নিচে নামান",
"group.remove": "গ্রুপ থেকে সরান",
@@ -850,6 +876,9 @@
"toolbar.mixed_others": "অন্যান্য মিশ্র ফাইলের জন্য “{0}” খুলবে।",
"toolbar.mixed_others_self": "চিহ্নিত না থাকা কোনো মিশ্র প্রোফাইল অবশিষ্ট নেই, তাই এটি অন্য সব মিশ্রণের জন্যও ব্যবহৃত হবে।",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "এই ইনপুট ফরম্যাটটি সাধারণ রূপান্তর হিসেবে চলে",
+ "toolbar.chip_tip.ordinary_conversion.all": "রূপান্তরযোগ্য সব ইনপুট ফরম্যাট সাধারণ রূপান্তর হিসেবে চলে",
+ "toolbar.chip_tip.ordinary_conversion.some": "কিছু ইনপুট ফরম্যাট সাধারণ রূপান্তর হিসেবে চলে",
"toolbar.chip_tip.makes_same": "→ একই ফরম্যাট",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "গৃহীত ফরম্যাট: {0}",
@@ -901,6 +930,11 @@
"presets.cad_colors.BlackAndWhite": "সাদা-কালো",
"presets.cad_colors.Grayscale": "গ্রেস্কেল",
"presets.cad_colors_hint": "DWG বা DXF ড্রয়িং যখন পিডিএফ, SVG বা ছবিতে রূপান্তরিত হয় তখন ব্যবহৃত হয়। সাদা-কালো মোড প্রতিটি রেখা ও টেক্সটকে একরঙা প্লটের মতো কালো রঙে আঁকে; ভরাট অঞ্চলগুলো তাদের উজ্জ্বলতা অনুযায়ী ধূসর রঙে পরিণত হয়, ফলে হালকা ভরাট অংশ কালো ব্লকে পরিণত হয় না। গ্রেস্কেল প্রতিটি রঙকে ধূসরে রূপান্তর করে এবং হলুদ রঙের মতো হালকা রেখাগুলোকে পড়ার উপযোগী করে গাঢ় করে।",
+ "presets.gcode_view": "দৃশ্য",
+ "presets.gcode_view.Top": "উপর থেকে",
+ "presets.gcode_view.Isometric": "আইসোমেট্রিক",
+ "presets.gcode_view.Thumbnail": "স্লাইসারের প্রিভিউ",
+ "presets.gcode_view_hint": "স্লাইসার বা CAM প্রোগ্রামের G-code ফাইল PDF, SVG বা ছবি হলে ব্যবহৃত হয়। এক্সট্রুশন ও কাট উচ্চতা অনুযায়ী রঙ পায়, গাঢ় (নিচে) থেকে হালকা (উপরে); খালি চলাচল সরু নীল রেখা (CNC-এর দ্রুত চলাচলের জন্য লাল)। স্লাইসারের প্রিভিউ হলো সেই ছবি যা PrusaSlicer, Cura, OrcaSlicer বা Bambu Studio ফাইলে সংরক্ষণ করেছে; SVG, PDF ও প্রিভিউ ছাড়া ফাইল টুলপাথ উপর থেকে দেখায়।",
"presets.group.image": "ছবি",
"presets.quality": "গুণমান",
"presets.resize": "আকার পরিবর্তন",
@@ -980,10 +1014,15 @@
"presets.ringtone_start": "রিংটোনের শুরু (সেকেন্ড)",
"presets.ringtone_length": "রিংটোনের দৈর্ঘ্য (সেকেন্ড, সর্বোচ্চ ৪০)",
"presets.ringtone_hint": "iPhone রিংটোন হলো সর্বোচ্চ ৪০ সেকেন্ডের AAC ফাইল। আপনি যে সেকেন্ড দেবেন সেখান থেকে রিংটোন শুরু হয় এবং আপনার দেওয়া দৈর্ঘ্যে কাটা হয় (৪০ সেকেন্ডের বেশি নয়)।",
+ "presets.ask_ringtone": "প্রতিবার অংশটি জিজ্ঞাসা করুন (ক্লিপ কাটা খুলবে)",
"presets.ico_sizes": "আইকন আকার",
"presets.group.pdf": "পিডিএফ",
"presets.merge": "সব ফাইল একটি একক পিডিএফ-এ একত্রিত করুন",
"presets.source_bookmarks": "প্রতিটি মূল ফাইলের জন্য একটি বুকমার্ক যোগ করুন",
+ "presets.normalize_merge_paper": "একত্রিত সব পৃষ্ঠার জন্য একই কাগজের আকার ব্যবহার করুন",
+ "presets.merge_paper": "একত্রিত পিডিএফের কাগজ",
+ "presets.merge_paper_hint": "প্রতিটি পৃষ্ঠা নির্বাচিত কাগজে মানিয়ে মাঝখানে বসায়, কোনো অংশ কাটে না। একত্রিত করার আগে ছবির পৃষ্ঠার সেটিংস প্রয়োগ করা হয়।",
+ "presets.merge_paper_auto_hint": "প্রথম পৃষ্ঠা দেখে স্বয়ংক্রিয় দিক একবার নির্ধারণ করা হয়। ছোট পৃষ্ঠাগুলো মানিয়ে নিতে বড় করা হয়।",
"presets.page_size": "পৃষ্ঠার আকার",
"presets.page_size.FitImage": "ছবির সাথে মানানসই",
"presets.page_size.A4": "A4",
@@ -1140,6 +1179,7 @@
"presets.extract_note": "প্রতিটি আর্কাইভ নিজস্ব একটি ফোল্ডারে আনপ্যাক করা হয়। এটি কোথায় যাবে তা ‘আউটপুট’-এর অধীনে নির্ধারণ করা হয়।",
"presets.group.trace": "ট্রেসিং",
"presets.group.cad": "CAD ড্রয়িং",
+ "presets.group.gcode": "G-code (3D প্রিন্টিং, CNC)",
"presets.trace_note": "ছবি (PNG, JPG, …) ভেক্টর আকারে ট্রেস করার সময় ব্যবহৃত হয়।",
"presets.trace_mode": "মোড",
"presets.trace_mode.Color": "রঙিন",
@@ -1366,6 +1406,8 @@
"engines.package.whisper.description": "ভিডিও এবং অডিওর কণ্ঠস্বর থেকে SRT সাবটাইটেল ফাইলে রূপান্তর করে। ইন্টারনেট সংযোগ ছাড়াই অফলাইনে চলে।",
"engines.package.calibre.name": "Calibre (Kindle এবং বিরল ই-বুক ফরম্যাট)",
"engines.package.calibre.description": "Kindle-এর জন্য MOBI ও AZW3 সংরক্ষণ করে এবং বিরল ই-বুক ফরম্যাট (LIT, LRF, PDB, CHM…) রূপান্তর করে।\nEPUB, MOBI, AZW3, FB2 এবং কমিকস এটি ছাড়াই পড়া যায়।",
+ "engines.package.weasyprint.name": "WeasyPrint (HTML, Markdown ও ই-মেইল থেকে PDF)",
+ "engines.package.weasyprint.description": "HTML পেজ, Markdown ও ই-মেইলের (EML, MSG, MHTML) আরও ভালো PDF তৈরি করে: শিরোনাম, টেবিল, বোল্ড এবং ডান থেকে বাম বা ভারতীয় লিপি ব্রাউজারের প্রিন্টের মতো।\nএটি ছাড়াও বিল্ট-ইন পথে সেগুলো PDF হয়।",
"engines.package.7zip.name": "7-Zip (বিরল আর্কাইভ ধরন)",
"engines.package.7zip.description": "Filee-এর নিজস্ব রিডার যে বিরল আর্কাইভ ধরনগুলো খোলে না সেগুলো আনপ্যাক করে: PPMd ও অন্যান্য অপ্রচলিত পদ্ধতির 7Z, Quantum CAB, মাল্টি-ভলিউম ARJ, NTFS, exFAT বা APFS ডিস্ক ইমেজ…\nZIP, 7Z, RAR, TAR, CAB, ISO, DMG এবং অন্য সব সাধারণ আর্কাইভ এটি ছাড়াই কাজ করে।",
"engines.package.size": "ডাউনলোড {0} · ডিস্কে {1}",
@@ -1392,7 +1434,17 @@
"engines.remove": "সরান",
"engines.remove_confirm": "{0} কি সরিয়ে ফেলবেন? আপনি পরে এটি আবার ডাউনলোড করতে পারবেন।",
"engines.state.own_copy": "আপনার কপি",
- "engines.state.system_copy": "এই পিসিতে পাওয়া গেছে",
+ "engines.source.download": "ফাইলির ডাউনলোড",
+ "engines.source.system": "এই কম্পিউটারে",
+ "engines.source.version": "সংস্করণ {0}",
+ "engines.source.on_disk": "ডিস্কে {0}",
+ "engines.other_copy_title": "এই কম্পিউটারেও আছে",
+ "engines.other_copy_detail": "{0} (ফাইলির ডাউনলোড সরিয়ে ফেলা হবে)",
+ "engines.use_other_copy": "এর পরিবর্তে এটি ব্যবহার করুন",
+ "engines.use_other_copy_confirm": "{0}-এর কপিটি ব্যবহার করে {1}-এর ফাইলির ডাউনলোড সরিয়ে ফেলবেন? আপনি পরে এটি আবার ডাউনলোড করতে পারবেন।",
+ "engines.other_copy_gone": "সেই কপিটি আর পাওয়া যাচ্ছে না বা চালু হচ্ছে না। ফাইলির ডাউনলোড রেখে দেওয়া হয়েছে।",
+ "engines.install_filee_copy": "ফাইলির কপি ইনস্টল করুন",
+ "engines.use_another": "অন্য কপি ব্যবহার করুন…",
"engines.update_recommended": "আপডেট প্রস্তাবিত: এই কপিটি {0}, ফাইলি পরীক্ষিত হয়েছে {1}-এর সাথে।\nবেশিরভাগ রূপান্তর এখনও কাজ করে।",
"engines.use_own": "আমার কপি ব্যবহার করুন…",
"engines.use_own_hint": "ইতিমধ্যে এটি আছে? এর প্রোগ্রামটি বেছে নিন এবং ফাইলি নিজেরটি ডাউনলোড করার পরিবর্তে সেটি ব্যবহার করবে।",
diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json
index b9dd69e7f..ad69350d3 100644
--- a/src/Filee.App/Assets/i18n/de.json
+++ b/src/Filee.App/Assets/i18n/de.json
@@ -74,6 +74,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "{0} Dateien",
"donut.drop_to": "Ablegen → {0}",
+ "donut.ordinary_conversion.single": "Diese Datei wird normal konvertiert",
+ "donut.ordinary_conversion.all": "Alle konvertierbaren Dateien werden normal konvertiert",
+ "donut.ordinary_conversion.some": "Einige Dateien werden normal konvertiert",
"donut.pick_in_group": "Zum Auswählen nach außen bewegen",
"donut.group_empty": "Diese Gruppe enthält keine Vorlagen mehr. Bearbeite sie unter Einstellungen → Ringmenü",
"donut.click_to": "Klicken → {0}",
@@ -127,6 +130,8 @@
"error.split_unknown_length": "Die Länge dieses Videos ist unbekannt, daher lässt es sich nicht in gleiche Teile schneiden",
"error.split_needs_copy": "Beim Teilen wird das Video unverändert kopiert, und dieses Format kann es nicht aufnehmen. Wähle das Format des Videos oder MKV",
"error.merged_image_too_large": "Das zusammengeführte Bild wäre zu groß. Führe weniger oder kleinere Bilder zusammen",
+ "error.merge_paper_geometry": "Eine Seite hat eine ungültige Größe, Drehung oder Einheit und kann nicht an das ausgewählte Papier angepasst werden",
+ "error.merge_paper_limit": "Eine Seite überschreitet das Verarbeitungslimit zum Ändern der Papiergröße",
"error.over_size_limit": "Es lässt sich nicht klein genug für das Größenlimit machen",
"error.not_enough_space": "Nicht genügend freier Speicherplatz für die Bilder",
"error.no_motion_photo": "Dieses Foto enthält kein für Filee lesbares Video (Android Motion Photo oder iPhone Live Photo mit zugehöriger MOV-Datei im selben Ordner)",
@@ -166,6 +171,9 @@
"error.fit_damaged": "Die FIT-Datei ist beschädigt oder unvollständig",
"error.fit_chained": "Die Datei enthält mehrere FIT-Dateien hintereinander. Das wird noch nicht unterstützt",
"error.fit_too_large": "Die FIT-Datei ist zu groß zum Konvertieren",
+ "error.canvas_not_canvas": "Dies ist kein Obsidian-Canvas (JSON Canvas)",
+ "error.canvas_empty": "Das Canvas enthält keine Karten zum Zeichnen",
+ "error.canvas_too_large": "Das Canvas ist zu groß zum Konvertieren",
"error.fit_no_file_id": "Die FIT-Datei gibt nicht an, welche Art von Datei sie ist",
"error.fit_not_activity": "Diese FIT-Datei ist keine Aktivität (nur Aktivitäten werden konvertiert)",
"error.fit_no_positions": "Die Aktivität enthält keine aufgezeichneten Positionen. Konvertiere sie in CSV, XLSX oder JSON",
@@ -177,12 +185,16 @@
"error.fon_damaged": "Die .fon-Datei ist beschädigt oder unvollständig",
"error.document_too_complex": "Das Dokument ist zu groß oder zu komplex, um es sicher zu lesen",
"error.m4s_needs_init": "Diese .m4s-Datei ist ein Stück eines gestreamten Videos und lässt sich ohne ihr separates Startsegment (init) nicht lesen",
+ "error.nov_password": "Dieser Export ist mit einem Passwort geschützt, sein Video ist daher verschlüsselt: Exportieren Sie ihn in Wisenet WAVE / Nx Witness erneut ohne Passwort",
+ "error.nov_no_video": "Dieser Export enthält keine Kameraaufnahme",
"error.exe_unreadable": "Das ist kein Windows-Programm und keine Bibliothek, die Filee lesen kann, oder die Datei ist beschädigt",
"error.exe_no_icon": "Diese Datei hat kein eigenes Symbol (Windows zeigt für sie ein Standardsymbol)",
"notice.pdf_not_smaller": "Die Datei ließ sich nicht verkleinern und wurde unverändert kopiert",
+ "notice.nov_recordings_left_out": "Dieser Export enthält mehrere Aufnahmen (andere Kameras oder durch Lücken getrennte Teile): nur die größte wurde umgewandelt",
"notice.under_min_size": "Gespeichert, aber kleiner als die Mindestdateigröße",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "Objekte, die die gewählte AutoCAD-Version nicht unterstützt (Multiführungslinien, Netze …), wurden weggelassen",
+ "notice.gcode_no_thumbnail": "Die G-Code-Datei hat keine Slicer-Vorschau, daher wurde ihr Werkzeugweg von oben gezeichnet",
"notice.sheet_objects_left_out": "Objekte, die Filee noch nicht drucken kann (Diagramme, Text in Formen …), wurden weggelassen: Installiere LibreOffice unter Einstellungen → Module, um sie zu behalten",
"notice.cmyk_needs_ghostscript": "Diese Datei ist CMYK, wurde aber als RGB gespeichert: Installiere Ghostscript unter Einstellungen → Module, um CMYK zu behalten",
"notice.pdfa_needs_ghostscript": "Diese PDF ist kein PDF/A: Installiere Ghostscript unter Einstellungen → Module, um PDF/A zu erstellen",
@@ -275,6 +287,8 @@
"engine.font.description": "TTF, OTF, WOFF, WOFF2 und EOT untereinander konvertieren; Windows-Bitmap-Schriftarten (FON) in diese Formate umwandeln.",
"engine.cad.name": "CAD (integriert)",
"engine.cad.description": "AutoCAD-Zeichnungen: DWG ↔ DXF und beide in PDF, SVG und PNG.",
+ "engine.gcode.name": "G-code (integriert)",
+ "engine.gcode.description": "Werkzeugwege von 3D-Druckern und CNC-Maschinen (G-Code) als PDF, SVG und Bild, oder die Vorschau des Slicers.",
"engine.drawing.name": "Zeichnungen (integriert)",
"engine.drawing.description": "Visio-Zeichnungen (VSDX) und OpenDocument-Zeichnungen (ODG, FODG) in PDF, SVG und PNG, ohne Visio oder LibreOffice.\nFür alte binäre Visio-Dateien (.vsd) ist weiterhin LibreOffice nötig.",
"engine.model3d.name": "3D-Modelle (integriert)",
@@ -297,6 +311,7 @@
"engine.tesseract.description": "Erkennt Text in Scans und Bildern (OCR): durchsuchbare PDF, DOCX, TXT.",
"engine.vision.description": "Erkennt Text in Scans und Bildern mit der Texterkennung von macOS (OCR): durchsuchbare PDF, DOCX, TXT. Kein Download nötig.",
"engine.calibre.description": "Seltene E-Book-Formate (LIT, LRF…) und Ausgabe in ältere Formate (LIT, PDB, RB…).\nMit ausgeschaltetem Filee-Renderer und oberhalb des HWPX-Writers erstellt es auch PDFs aus E-Books (bei langen Büchern deutlich schneller).",
+ "engine.weasyprint.description": "Erstellt PDFs von HTML-Seiten, Markdown und E-Mails so, wie ein Browser sie druckt: Überschriften, Tabellen, Fettschrift und arabischer oder Hindi-Text stimmen.\nEs lädt nur die Bilder neben dem Dokument, nichts aus dem Internet.",
"engine.libreoffice.description": "Liest ältere und weniger verbreitete Formate: DOC aus Word 6.0/95, PPT aus PowerPoint 95, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, binäres Visio (VSD)…\nKonvertiert auch ODP- und ODG-Dateien mit Diagrammen, eingebetteten Objekten oder 3D und speichert als DOC, XLS, PPT, ODT, OpenDocument-Vorlagen und EMF.",
"engine.translator.description": "Übersetzt Dokumente und E-Books lokal mit Offline-Ollama-Modellen.\nÜbersetzt auch SRT- und WebVTT-Untertitel: Nur der Text der Einträge wird an das Modell gesendet, Nummern und Zeitstempel der Einträge bleiben erhalten.",
"engine.exiftool.description": "Liest alle Metadatenfelder von Bildern, PDFs, Video und Audio und bearbeitet sie in den meisten Dateiarten, ohne den Inhalt der Datei zu verändern.\nVom Metadaten-Werkzeug genutzt; ohne es sind die Felder schreibgeschützt.",
@@ -395,6 +410,7 @@
"clip.save": "Clip speichern",
"clip.save_next": "Speichern & Weiter",
"clip.seconds": "{0} s",
+ "clip.ringtone_limit": "Ein iPhone-Klingelton ist höchstens {0} Sekunden lang",
"clip.length_hint": "Clip mit dieser Dauer in Sekunden ab dem Beginn",
"clip.fast": "Schnell, ohne erneutes Kodieren",
"clip.fast_hint": "Kopiert das Video unverändert: schnell und verlustfrei. Der Clip beginnt jedoch am Schlüsselbild vor dem gewählten Beginn (bis zu wenige Sekunden früher). Deaktivieren, um genau an den gewählten Bildern zu schneiden (kodiert erneut und dauert länger).",
@@ -793,9 +809,15 @@
"toolbar.hint_remove": "Segment zum Entfernen hierher zurückziehen",
"toolbar.hint_reorder": "Segmente aufeinanderziehen, um sie umzuordnen",
"toolbar.hint_edit": "Segment mit Rechtsklick oder ✎ bearbeiten",
+ "toolbar.group_hint": "Vorlagen aus der Liste hineinziehen, Segmente zum Umsortieren ziehen und eins auf die Liste ziehen, um es herauszunehmen.",
+ "toolbar.group_back": "Zurück zu {0}",
"toolbar.max_reached": "Ein Ringmenü fasst bis zu {0} Vorlagen",
- "toolbar.appearance": "Darstellung",
"toolbar.size": "Größe",
+ "toolbar.edit_size": "Ringmenü-Größe bearbeiten",
+ "toolbar.look_done": "Fertig",
+ "toolbar.look_hint": "Dein Ringmenü ist in echter Größe auf dem Bildschirm. Zieh an der Mitte, um es zu verschieben, und klick auf eine Gruppe, damit sie offen bleibt.",
+ "toolbar.look_all": "Alle Ringmenüs",
+ "toolbar.look_this": "Dieses Ringmenü: {0}",
"toolbar.hole": "Innenöffnung",
"toolbar.opacity": "Deckkraft",
"toolbar.captions": "Ausgabeformat anzeigen",
@@ -803,6 +825,9 @@
"toolbar.label_scale": "Beschriftungsgröße",
"toolbar.ring_size": "Größe des Gruppenrings",
"toolbar.ring_spread": "Breite des Gruppenrings",
+ "toolbar.group_shown": "Angezeigte Gruppe",
+ "toolbar.example_group": "Beispielgruppe",
+ "toolbar.example_none": "Dieses Ringmenü hat keine Gruppen",
"labels.auto": "Automatisch",
"labels.icon_text": "Symbol und Text",
"labels.text_only": "Nur Text",
@@ -825,6 +850,7 @@
"group.add": "Hinzufügen",
"group.add_placeholder": "Vorlage auswählen",
"group.full": "Eine Gruppe fasst bis zu {0} Vorlagen.",
+ "group.copy_front": "„In Ordner kopieren“ kann nicht in diese Gruppe: Sie liegt auch auf einer Vorderseite.",
"group.move_up": "Nach oben",
"group.move_down": "Nach unten",
"group.remove": "Aus der Gruppe entfernen",
@@ -851,6 +877,9 @@
"toolbar.mixed_others": "Andere Dateimischungen öffnen „{0}“.",
"toolbar.mixed_others_self": "Kein Profil für gemischte Auswahlen ist ohne Auswahl geblieben. Dieses Profil wird daher auch für alle anderen Dateimischungen verwendet.",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "Dieses Eingabeformat wird normal konvertiert",
+ "toolbar.chip_tip.ordinary_conversion.all": "Alle konvertierbaren Eingabeformate werden normal konvertiert",
+ "toolbar.chip_tip.ordinary_conversion.some": "Einige Eingabeformate werden normal konvertiert",
"toolbar.chip_tip.makes_same": "→ Gleiches Format",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "Formate: {0}",
@@ -902,6 +931,11 @@
"presets.cad_colors.BlackAndWhite": "Schwarzweiß",
"presets.cad_colors.Grayscale": "Graustufen",
"presets.cad_colors_hint": "Gilt, wenn eine DWG- oder DXF-Zeichnung zu PDF, SVG oder einem Bild wird. Schwarzweiß zeichnet alle Linien und Texte schwarz wie ein monochromer Plot; gefüllte Flächen werden zum Grau ihrer Helligkeit, damit eine helle Füllung kein schwarzer Block wird. Graustufen macht jede Farbe zu einem Grau und dunkelt helle Linien wie Gelb so weit ab, dass sie lesbar bleiben.",
+ "presets.gcode_view": "Ansicht",
+ "presets.gcode_view.Top": "Von oben",
+ "presets.gcode_view.Isometric": "Isometrisch",
+ "presets.gcode_view.Thumbnail": "Slicer-Vorschau",
+ "presets.gcode_view_hint": "Gilt, wenn eine G-Code-Datei eines Slicers oder CAM-Programms zu PDF, SVG oder Bild wird. Extrusion und Schnitte sind nach Höhe gefärbt, von dunkel (unten) bis hell (oben); Leerfahrten sind dünne blaue Linien (rot für Eilgänge einer CNC). Die Slicer-Vorschau ist das Bild, das PrusaSlicer, Cura, OrcaSlicer oder Bambu Studio in der Datei gespeichert hat; SVG, PDF und Dateien ohne Vorschau zeigen den Werkzeugweg von oben.",
"presets.group.image": "Bild",
"presets.quality": "Qualität",
"presets.resize": "Größe ändern",
@@ -981,10 +1015,15 @@
"presets.ringtone_start": "Klingelton-Start (s)",
"presets.ringtone_length": "Länge des Klingeltons (s, max. 40)",
"presets.ringtone_hint": "iPhone-Klingeltöne sind AAC-Dateien von höchstens 40 Sekunden. Der Klingelton beginnt bei der angegebenen Sekunde und wird auf die angegebene Länge gekürzt (nie über 40 s).",
+ "presets.ask_ringtone": "Jedes Mal nach dem Ausschnitt fragen (öffnet „Clip schneiden“)",
"presets.ico_sizes": "Symbolgrößen",
"presets.group.pdf": "PDF",
"presets.merge": "Alle Dateien in einer PDF zusammenführen",
"presets.source_bookmarks": "Für jede ursprüngliche Datei ein Lesezeichen hinzufügen",
+ "presets.normalize_merge_paper": "Für alle zusammengeführten Seiten dasselbe Papierformat verwenden",
+ "presets.merge_paper": "Papier für die zusammengeführte PDF",
+ "presets.merge_paper_hint": "Passt jede Seite ohne Beschneiden an das ausgewählte Papier an und zentriert sie. Einstellungen für Bildseiten werden vor dem Zusammenführen angewendet.",
+ "presets.merge_paper_auto_hint": "Die automatische Ausrichtung wird einmal anhand der ersten Seite gewählt. Kleinere Seiten werden zum Einpassen vergrößert.",
"presets.page_size": "Seitengröße",
"presets.page_size.FitImage": "An Bild anpassen",
"presets.page_size.A4": "A4",
@@ -1141,6 +1180,7 @@
"presets.extract_note": "Jedes Archiv wird in einen eigenen Ordner entpackt. Der Speicherort wird unter Ausgabe festgelegt.",
"presets.group.trace": "Vektorisierung",
"presets.group.cad": "CAD-Zeichnung",
+ "presets.group.gcode": "G-Code (3D-Druck, CNC)",
"presets.trace_note": "Wird verwendet, wenn ein Bild (PNG, JPG, …) in Vektorformen umgewandelt wird.",
"presets.trace_mode": "Modus",
"presets.trace_mode.Color": "Farbe",
@@ -1367,6 +1407,8 @@
"engines.package.whisper.description": "Transkribiert Sprache in Videos und Audio in SRT-Untertiteldateien. Funktioniert offline ohne Internetverbindung.",
"engines.package.calibre.name": "Calibre (seltene E-Book-Formate)",
"engines.package.calibre.description": "Konvertiert seltene E-Book-Formate (LIT, LRF…) und speichert ältere (LIT, PDB, RB…).\nEPUB, MOBI, AZW3, FB2 und Comics werden ohne Calibre gelesen, Kindle-Bücher ohne Calibre gespeichert.",
+ "engines.package.weasyprint.name": "WeasyPrint (HTML, Markdown und E-Mail zu PDF)",
+ "engines.package.weasyprint.description": "Erstellt bessere PDFs von HTML-Seiten, Markdown und E-Mails (EML, MSG, MHTML): Überschriften, Tabellen, Fettschrift sowie rechtsläufige und indische Schriften wie im Browserdruck.\nOhne es werden sie auch zu PDFs, über den eingebauten Weg.",
"engines.package.7zip.name": "7-Zip (seltene Archivvarianten)",
"engines.package.7zip.description": "Entpackt die seltenen Archivvarianten, die Filees eigene Leser auslassen: 7Z mit PPMd und anderen ungewöhnlichen Methoden, Quantum-CAB, mehrteilige ARJ, NTFS-, exFAT- oder APFS-Abbilder…\nZIP, 7Z, RAR, TAR, CAB, ISO, DMG und alle anderen gängigen Archive funktionieren ohne 7-Zip.",
"engines.package.size": "Download {0} · Platzbedarf {1}",
@@ -1393,7 +1435,17 @@
"engines.remove": "Entfernen",
"engines.remove_confirm": "{0} entfernen? Du kannst es später erneut herunterladen.",
"engines.state.own_copy": "Eigene Installation",
- "engines.state.system_copy": "Auf diesem PC gefunden",
+ "engines.source.download": "Download von Filee",
+ "engines.source.system": "Auf diesem Computer",
+ "engines.source.version": "Version {0}",
+ "engines.source.on_disk": "Platzbedarf {0}",
+ "engines.other_copy_title": "Auch auf diesem Computer",
+ "engines.other_copy_detail": "{0} (der Download von Filee würde entfernt)",
+ "engines.use_other_copy": "Stattdessen diese verwenden",
+ "engines.use_other_copy_confirm": "Die Installation in {0} verwenden und den Download von {1} durch Filee entfernen? Du kannst es später erneut herunterladen.",
+ "engines.other_copy_gone": "Diese Installation ist nicht mehr zu finden oder startet nicht. Der Download von Filee wurde behalten.",
+ "engines.install_filee_copy": "Version von Filee installieren",
+ "engines.use_another": "Andere Version…",
"engines.update_recommended": "Update empfohlen: Diese Installation hat Version {0}; Filee wurde mit {1} getestet.\nDie meisten Konvertierungen funktionieren trotzdem.",
"engines.use_own": "Eigene Version…",
"engines.use_own_hint": "Bereits installiert? Wähle das Programm aus. Filee verwendet es, statt eine eigene Version herunterzuladen.",
diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json
index 4e5835783..b563c5d4b 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -78,6 +78,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "{0} files",
"donut.drop_to": "Drop → {0}",
+ "donut.ordinary_conversion.single": "This file runs as an ordinary conversion",
+ "donut.ordinary_conversion.all": "All convertible files run as ordinary conversions",
+ "donut.ordinary_conversion.some": "Some files run as ordinary conversions",
"donut.pick_in_group": "Move outward to choose",
"donut.group_empty": "No presets left in this group. Edit it in Settings → Donut toolbar",
"donut.click_to": "Click → {0}",
@@ -133,6 +136,8 @@
"error.split_unknown_length": "This video's length is unknown, so it can't be cut into equal parts",
"error.split_needs_copy": "Splitting copies the video as it is, and this format can't hold it. Choose the video's own format or MKV",
"error.merged_image_too_large": "The merged picture would be too large, merge fewer or smaller pictures",
+ "error.merge_paper_geometry": "A page has invalid size, rotation, or units and cannot be fitted to the selected paper",
+ "error.merge_paper_limit": "This PDF is too complex to fit to one paper size",
"error.over_size_limit": "It can't be made small enough for the size limit",
"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)",
@@ -172,6 +177,9 @@
"error.fit_damaged": "The FIT file is damaged or cut short",
"error.fit_chained": "The file holds several FIT files one after another, which isn't supported yet",
"error.fit_too_large": "The FIT file is too large to convert",
+ "error.canvas_not_canvas": "This isn't an Obsidian canvas (JSON Canvas)",
+ "error.canvas_empty": "The canvas has no cards to draw",
+ "error.canvas_too_large": "The canvas is too large to convert",
"error.fit_no_file_id": "The FIT file doesn't say what kind of file it is",
"error.fit_not_activity": "This FIT file isn't an activity (only activities are converted)",
"error.fit_no_positions": "The activity has no recorded positions: convert it to CSV, XLSX or JSON",
@@ -183,12 +191,16 @@
"error.fon_damaged": "The .fon file is damaged or cut short",
"error.document_too_complex": "The document is too large or too complex to read safely",
"error.m4s_needs_init": "This .m4s is one piece of a streamed video: it can't be read without its separate start (init) segment",
+ "error.nov_password": "This export is protected with a password, so its video is encrypted: export it again from Wisenet WAVE / Nx Witness without a password",
+ "error.nov_no_video": "This export holds no camera recording",
"error.exe_unreadable": "This isn't a Windows program or library Filee can read, or it is damaged",
"error.exe_no_icon": "This file has no icon of its own (Windows shows a standard one for it)",
"notice.pdf_not_smaller": "Couldn't make it smaller, so it was copied as it is",
+ "notice.nov_recordings_left_out": "This export holds several recordings (other cameras or pieces with gaps): only the largest was converted",
"notice.under_min_size": "Saved, but smaller than the minimum file size",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "Objects the chosen AutoCAD version doesn't have (multileaders, meshes …) were left out",
+ "notice.gcode_no_thumbnail": "The G-code file has no slicer preview, so its toolpath was drawn from above",
"notice.sheet_objects_left_out": "Objects Filee can't print yet (charts, text in shapes …) were left out: install LibreOffice in Settings → Engines to keep them",
"notice.cmyk_needs_ghostscript": "This file is CMYK but was saved as RGB: install Ghostscript in Settings → Engines to keep CMYK",
"notice.pdfa_needs_ghostscript": "This PDF isn't PDF/A: install Ghostscript in Settings → Engines to make it PDF/A",
@@ -282,6 +294,8 @@
"engine.font.description": "Fonts: TTF, OTF, WOFF, WOFF2 and EOT into each other; Windows bitmap fonts (FON) into them.",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "AutoCAD drawings: DWG ↔ DXF, and both to PDF, SVG and PNG.",
+ "engine.gcode.name": "G-code (built-in)",
+ "engine.gcode.description": "Toolpaths of 3D printers and CNC machines (G-code) to PDF, SVG and pictures, or the slicer's own preview.",
"engine.drawing.name": "Drawings (built-in)",
"engine.drawing.description": "Visio drawings (VSDX) and OpenDocument drawings (ODG, FODG) to PDF, SVG and PNG, without Visio or LibreOffice.\nOld binary Visio files (.vsd) still need LibreOffice.",
"engine.model3d.name": "3D models (built-in)",
@@ -304,6 +318,7 @@
"engine.tesseract.description": "Recognizes the text of scans and pictures (OCR): searchable PDF, DOCX, TXT.",
"engine.vision.description": "Recognizes the text of scans and pictures with macOS's own text recognition (OCR): searchable PDF, DOCX, TXT. Nothing to download.",
"engine.calibre.description": "Rare e-book formats (LIT, LRF…) and output in older formats (LIT, PDB, RB…).\nWith the Filee renderer off and Calibre above the HWPX writer, it also makes the PDFs of e-books (much faster for long books).",
+ "engine.weasyprint.description": "Makes PDFs of HTML pages, Markdown and e-mails as a browser prints them: headings, tables, bold and Arabic or Hindi text come out right.\nIt loads only the pictures beside the document, nothing from the internet.",
"engine.libreoffice.description": "Reads older and less common formats: Word 6.0/95 DOC, PowerPoint 95 PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, binary Visio (VSD)…\nAlso converts ODP and ODG files with charts, embedded objects or 3D, and saves as DOC, XLS, PPT, ODT, OpenDocument templates and EMF.",
"engine.translator.description": "Translates documents and e-books locally using offline Ollama models.\nAlso translates SRT and WebVTT subtitles: only the cue text is sent to the model, cue numbers and timestamps are kept.",
"engine.exiftool.description": "Reads every metadata field of pictures, PDFs, video and sound, and edits them in most kinds of file without touching the file's content.\nUsed by the Metadata tool; without it the fields are shown read-only.",
@@ -404,6 +419,7 @@
"clip.save": "Save clip",
"clip.save_next": "Save & next",
"clip.seconds": "{0} s",
+ "clip.ringtone_limit": "An iPhone ringtone is at most {0} seconds",
"clip.length_hint": "A clip of this many seconds from the start",
"clip.fast": "Fast, no re-encoding",
"clip.fast_hint": "Copies the video as it is: done in a moment and nothing is lost, but the clip begins at the key frame before the start (up to a few seconds early). Turn off to cut exactly at the chosen frames (encodes again, takes longer).",
@@ -807,9 +823,15 @@
"toolbar.hint_remove": "Drag a slice back here to remove it",
"toolbar.hint_reorder": "Drag slices onto each other to reorder",
"toolbar.hint_edit": "Right-click a slice or use ✎ to edit",
+ "toolbar.group_hint": "Drag presets in from the list, drag slices to reorder them, and drag one onto the list to take it out.",
+ "toolbar.group_back": "Back to {0}",
"toolbar.max_reached": "A donut holds up to {0} presets",
- "toolbar.appearance": "Appearance",
"toolbar.size": "Size",
+ "toolbar.edit_size": "Edit Donut Size",
+ "toolbar.look_done": "Done",
+ "toolbar.look_hint": "Your donut is on screen at its real size. Drag its middle to move it, and click a group to keep it open.",
+ "toolbar.look_all": "All donuts",
+ "toolbar.look_this": "This donut: {0}",
"toolbar.hole": "Hole",
"toolbar.opacity": "Opacity",
"toolbar.captions": "Show output format",
@@ -817,6 +839,9 @@
"toolbar.label_scale": "Label scale",
"toolbar.ring_size": "Group ring size",
"toolbar.ring_spread": "Group ring spread",
+ "toolbar.group_shown": "Group shown",
+ "toolbar.example_group": "Example group",
+ "toolbar.example_none": "This donut has no groups",
"labels.auto": "Auto",
"labels.icon_text": "Icon & text",
"labels.text_only": "Text only",
@@ -839,6 +864,7 @@
"group.add": "Add",
"group.add_placeholder": "Choose a preset",
"group.full": "A group holds up to {0} presets.",
+ "group.copy_front": "Copy to folder can't go into this group: it is also on a front face.",
"group.move_up": "Move up",
"group.move_down": "Move down",
"group.remove": "Remove from the group",
@@ -865,6 +891,9 @@
"toolbar.mixed_others": "Other mixes of files open “{0}”.",
"toolbar.mixed_others_self": "No mixed profile is left with nothing checked, so this one is also used for every other mix.",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "This input format runs as an ordinary conversion",
+ "toolbar.chip_tip.ordinary_conversion.all": "All convertible input formats run as ordinary conversions",
+ "toolbar.chip_tip.ordinary_conversion.some": "Some input formats run as ordinary conversions",
"toolbar.chip_tip.makes_same": "→ Same format",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "Accepted formats: {0}",
@@ -917,6 +946,11 @@
"presets.cad_colors.BlackAndWhite": "Black and white",
"presets.cad_colors.Grayscale": "Grayscale",
"presets.cad_colors_hint": "Used when a DWG or DXF drawing becomes a PDF, SVG or picture. Black and white draws every line and text in black, like a monochrome plot; filled areas turn the gray of their brightness, so a light fill doesn't become a black block. Grayscale turns each colour into a gray and darkens light lines, such as yellow, enough to read.",
+ "presets.gcode_view": "View",
+ "presets.gcode_view.Top": "From above",
+ "presets.gcode_view.Isometric": "Isometric",
+ "presets.gcode_view.Thumbnail": "Slicer's preview",
+ "presets.gcode_view_hint": "Used when a G-code file from a slicer or CAM program becomes a PDF, SVG or picture. Extrusion and cuts are coloured by height, from dark (low) to light (high); travel moves are thin blue lines (red for CNC rapids). The slicer's preview is the picture PrusaSlicer, Cura, OrcaSlicer or Bambu Studio saved in the file; SVG, PDF and files without one show the toolpath from above.",
"presets.group.image": "Image",
"presets.quality": "Quality",
"presets.resize": "Resize",
@@ -996,10 +1030,15 @@
"presets.ringtone_start": "Ringtone start (s)",
"presets.ringtone_length": "Ringtone length (s, max 40)",
"presets.ringtone_hint": "iPhone ringtones are AAC files of at most 40 seconds. The ringtone starts at the second you set and is cut to the length you set (never over 40 s).",
+ "presets.ask_ringtone": "Ask for the part every time (opens Cut a clip)",
"presets.ico_sizes": "Icon sizes",
"presets.group.pdf": "PDF",
"presets.merge": "Merge all files into one PDF",
"presets.source_bookmarks": "Add a bookmark per source file",
+ "presets.normalize_merge_paper": "Use one paper size for all merged pages",
+ "presets.merge_paper": "Paper for the merged PDF",
+ "presets.merge_paper_hint": "Fits each page to the selected paper and centres it without cropping. Image page settings are applied before merging.",
+ "presets.merge_paper_auto_hint": "Automatic orientation is chosen once from the first page. Smaller pages are enlarged to fit.",
"presets.page_size": "Page size",
"presets.page_size.FitImage": "Fit to image",
"presets.page_size.A4": "A4",
@@ -1156,6 +1195,7 @@
"presets.extract_note": "Each archive is extracted into a folder of its own. Where it goes is set under Output.",
"presets.group.trace": "Tracing",
"presets.group.cad": "CAD drawing",
+ "presets.group.gcode": "G-code (3D printing, CNC)",
"presets.trace_note": "Used when a picture (PNG, JPG, …) is traced into vector shapes.",
"presets.trace_mode": "Mode",
"presets.trace_mode.Color": "Colour",
@@ -1384,6 +1424,8 @@
"engines.package.whisper.description": "Transcribes speech in video and audio to SRT subtitle files. Runs offline without an internet connection.",
"engines.package.calibre.name": "Calibre (rare e-book formats)",
"engines.package.calibre.description": "Converts rare e-book formats (LIT, LRF…) and saves older ones (LIT, PDB, RB…).\nEPUB, MOBI, AZW3, FB2 and comics are read, and Kindle books saved, without it.",
+ "engines.package.weasyprint.name": "WeasyPrint (HTML, Markdown and e-mail to PDF)",
+ "engines.package.weasyprint.description": "Makes better PDFs of HTML pages, Markdown and e-mails (EML, MSG, MHTML): headings, tables, bold and right-to-left or Indic scripts as a browser prints them.\nThey become PDFs without it too, through the built-in route.",
"engines.package.7zip.name": "7-Zip (rare archive variants)",
"engines.package.7zip.description": "Unpacks the rare archive variants Filee's own readers leave out: 7Z with PPMd and other uncommon methods, Quantum CAB, multi-volume ARJ, NTFS, exFAT or APFS disk images…\nZIP, 7Z, RAR, TAR, CAB, ISO, DMG and every other common archive work without it.",
"engines.package.size": "Download {0} · {1} on disk",
@@ -1410,7 +1452,17 @@
"engines.remove": "Remove",
"engines.remove_confirm": "Remove {0}? You can download it again later.",
"engines.state.own_copy": "Your copy",
- "engines.state.system_copy": "Found on this PC",
+ "engines.source.download": "Filee's download",
+ "engines.source.system": "On this computer",
+ "engines.source.version": "version {0}",
+ "engines.source.on_disk": "{0} on disk",
+ "engines.other_copy_title": "Also on this computer",
+ "engines.other_copy_detail": "{0} (Filee's download would be removed)",
+ "engines.use_other_copy": "Use this one instead",
+ "engines.use_other_copy_confirm": "Use the copy in {0} and remove Filee's download of {1}? You can download it again later.",
+ "engines.other_copy_gone": "That copy can no longer be found or doesn't start. Filee's download was kept.",
+ "engines.install_filee_copy": "Install Filee's copy",
+ "engines.use_another": "Use another copy…",
"engines.update_recommended": "Update recommended: this copy is {0}, Filee is tested with {1}.\nMost conversions still work.",
"engines.use_own": "Use my copy…",
"engines.use_own_hint": "Already have it? Pick its program and Filee uses that instead of downloading its own.",
diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json
index b821cddcf..124ebfeef 100644
--- a/src/Filee.App/Assets/i18n/es.json
+++ b/src/Filee.App/Assets/i18n/es.json
@@ -74,6 +74,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "{0} archivos",
"donut.drop_to": "Suelta → {0}",
+ "donut.ordinary_conversion.single": "Este archivo usa una conversión normal",
+ "donut.ordinary_conversion.all": "Todos los archivos convertibles: conversión normal",
+ "donut.ordinary_conversion.some": "Algunos archivos usan una conversión normal",
"donut.pick_in_group": "Muévete hacia fuera para elegir",
"donut.group_empty": "No quedan ajustes preestablecidos en este grupo. Edítalo en Ajustes → Menú circular",
"donut.click_to": "Clic → {0}",
@@ -127,6 +130,8 @@
"error.split_unknown_length": "No se conoce la duración de este vídeo, así que no se puede cortar en partes iguales",
"error.split_needs_copy": "Al dividir, el vídeo se copia tal cual y este formato no puede contenerlo. Elige el formato del propio vídeo o MKV",
"error.merged_image_too_large": "La imagen unida sería demasiado grande; une menos imágenes o imágenes más pequeñas",
+ "error.merge_paper_geometry": "Una página tiene un tamaño, una rotación o unas unidades no válidos y no se puede ajustar al papel seleccionado",
+ "error.merge_paper_limit": "Una página supera el límite de procesamiento para cambiar el tamaño del papel",
"error.over_size_limit": "No se puede reducir lo suficiente para el límite de tamaño",
"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)",
@@ -166,6 +171,9 @@
"error.fit_damaged": "El archivo FIT está dañado o incompleto",
"error.fit_chained": "Aún no se admiten varios archivos FIT unidos uno tras otro",
"error.fit_too_large": "El archivo FIT es demasiado grande para convertirlo",
+ "error.canvas_not_canvas": "Esto no es un lienzo de Obsidian (JSON Canvas)",
+ "error.canvas_empty": "El lienzo no tiene tarjetas que dibujar",
+ "error.canvas_too_large": "El lienzo es demasiado grande para convertirlo",
"error.fit_no_file_id": "El archivo FIT no indica qué tipo de archivo es",
"error.fit_not_activity": "Este archivo FIT no es una actividad (solo se convierten actividades)",
"error.fit_no_positions": "La actividad no tiene posiciones registradas: conviértela a CSV, XLSX o JSON",
@@ -177,12 +185,16 @@
"error.fon_damaged": "El archivo .fon está dañado o incompleto",
"error.document_too_complex": "El documento es demasiado grande o complejo para leerlo de forma segura",
"error.m4s_needs_init": "Este .m4s es un fragmento de un vídeo en streaming y no se puede leer sin su segmento de inicio (init) aparte",
+ "error.nov_password": "Esta exportación está protegida con contraseña, así que su vídeo está cifrado: vuelve a exportarla desde Wisenet WAVE / Nx Witness sin contraseña",
+ "error.nov_no_video": "Esta exportación no contiene ninguna grabación de cámara",
"error.exe_unreadable": "No es un programa ni una biblioteca de Windows que Filee pueda leer, o el archivo está dañado",
"error.exe_no_icon": "Este archivo no tiene icono propio (Windows le muestra uno genérico)",
"notice.pdf_not_smaller": "No se pudo reducir su tamaño, por lo que se copió tal cual",
+ "notice.nov_recordings_left_out": "Esta exportación contiene varias grabaciones (otras cámaras o partes separadas por cortes): solo se convirtió la más grande",
"notice.under_min_size": "Guardado, pero más pequeño que el tamaño mínimo de archivo",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "Se omitieron objetos que la versión de AutoCAD elegida no tiene (directrices múltiples, mallas…)",
+ "notice.gcode_no_thumbnail": "El archivo G-code no tiene vista previa del laminador, así que su trayectoria se dibujó vista desde arriba",
"notice.sheet_objects_left_out": "Se omitieron objetos que Filee aún no puede imprimir (gráficos, texto en formas…): instala LibreOffice en Ajustes → Motores para conservarlos",
"notice.cmyk_needs_ghostscript": "Este archivo es CMYK, pero se guardó en RGB: instala Ghostscript en Ajustes → Motores para mantener CMYK",
"notice.pdfa_needs_ghostscript": "Este PDF no es PDF/A: instala Ghostscript en Ajustes → Motores para crear PDF/A",
@@ -275,6 +287,8 @@
"engine.font.description": "Convierte TTF, OTF, WOFF, WOFF2 y EOT entre sí; convierte fuentes de mapa de bits de Windows (FON) a estos formatos.",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "Planos de AutoCAD: DWG ↔ DXF, y ambos a PDF, SVG y PNG.",
+ "engine.gcode.name": "G-code (built-in)",
+ "engine.gcode.description": "Trayectorias de impresoras 3D y máquinas CNC (G-code) a PDF, SVG e imágenes, o la vista previa del laminador.",
"engine.drawing.name": "Drawings (built-in)",
"engine.drawing.description": "Dibujos de Visio (VSDX) y de OpenDocument (ODG, FODG) a PDF, SVG y PNG, sin Visio ni LibreOffice.\nLos archivos binarios antiguos de Visio (.vsd) siguen necesitando LibreOffice.",
"engine.model3d.name": "3D models (built-in)",
@@ -297,6 +311,7 @@
"engine.tesseract.description": "Reconoce el texto de escaneos e imágenes (OCR): PDF con búsqueda, DOCX, TXT.",
"engine.vision.description": "Reconoce el texto de escaneos e imágenes con el reconocimiento de texto propio de macOS (OCR): PDF con búsqueda, DOCX, TXT. No hay nada que descargar.",
"engine.calibre.description": "Formatos raros de libros electrónicos (LIT, LRF…) y salida en formatos antiguos (LIT, PDB, RB…).\nCon el renderizador de Filee desactivado y por encima del escritor HWPX, también crea los PDF de libros electrónicos (mucho más rápido para libros largos).",
+ "engine.weasyprint.description": "Crea los PDF de páginas HTML, Markdown y correos como los imprime un navegador: títulos, tablas, negrita y texto árabe o hindi salen bien.\nSolo carga las imágenes junto al documento, nada de Internet.",
"engine.libreoffice.description": "Lee formatos antiguos y poco habituales: DOC de Word 6.0/95, PPT de PowerPoint 95, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio binario (VSD)…\nTambién convierte archivos ODP y ODG con gráficos, objetos incrustados o 3D, y guarda como DOC, XLS, PPT, ODT, plantillas OpenDocument y EMF.",
"engine.translator.description": "Traduce documentos y libros electrónicos localmente usando modelos de Ollama fuera de línea.\nTambién traduce subtítulos SRT y WebVTT: solo se envía al modelo el texto de cada entrada; los números de entrada y las marcas de tiempo se conservan.",
"engine.exiftool.description": "Lee todos los campos de metadatos de imágenes, PDF, vídeo y audio, y los edita en la mayoría de tipos de archivo sin tocar su contenido.\nLo usa la herramienta Metadatos; sin él, los campos se muestran de solo lectura.",
@@ -395,6 +410,7 @@
"clip.save": "Guardar clip",
"clip.save_next": "Guardar y siguiente",
"clip.seconds": "{0} s",
+ "clip.ringtone_limit": "Un tono de llamada del iPhone dura como máximo {0} segundos",
"clip.length_hint": "Un clip de estos segundos desde el inicio",
"clip.fast": "Rápido, sin recodificar",
"clip.fast_hint": "Copia el vídeo tal cual: listo al instante y sin pérdida, pero el clip empieza en el fotograma clave anterior al inicio (hasta unos segundos antes). Desactívalo para cortar exactamente en los fotogramas elegidos (vuelve a codificar, tarda más).",
@@ -793,9 +809,15 @@
"toolbar.hint_remove": "Arrastra una porción aquí para quitarla",
"toolbar.hint_reorder": "Arrastra una porción sobre otra para reordenar",
"toolbar.hint_edit": "Haz clic derecho en una porción o usa ✎ para editarla",
+ "toolbar.group_hint": "Arrastra ajustes preestablecidos desde la lista, arrastra las porciones para reordenarlas y arrastra una a la lista para quitarla.",
+ "toolbar.group_back": "Volver a {0}",
"toolbar.max_reached": "Un menú circular admite hasta {0} ajustes preestablecidos",
- "toolbar.appearance": "Aspecto",
"toolbar.size": "Tamaño",
+ "toolbar.edit_size": "Editar tamaño del menú circular",
+ "toolbar.look_done": "Listo",
+ "toolbar.look_hint": "Tu menú circular está en pantalla a su tamaño real. Arrastra el centro para moverlo y haz clic en un grupo para dejarlo abierto.",
+ "toolbar.look_all": "Todos los menús circulares",
+ "toolbar.look_this": "Este menú circular: {0}",
"toolbar.hole": "Hueco",
"toolbar.opacity": "Opacidad",
"toolbar.captions": "Mostrar formato de salida",
@@ -803,6 +825,9 @@
"toolbar.label_scale": "Tamaño de las etiquetas",
"toolbar.ring_size": "Tamaño del anillo de grupo",
"toolbar.ring_spread": "Amplitud del anillo de grupo",
+ "toolbar.group_shown": "Grupo mostrado",
+ "toolbar.example_group": "Grupo de ejemplo",
+ "toolbar.example_none": "Este menú circular no tiene grupos",
"labels.auto": "Automático",
"labels.icon_text": "Icono y texto",
"labels.text_only": "Solo texto",
@@ -825,6 +850,7 @@
"group.add": "Añadir",
"group.add_placeholder": "Elige un ajuste preestablecido",
"group.full": "Un grupo admite hasta {0} ajustes preestablecidos.",
+ "group.copy_front": "«Copiar a carpeta» no puede ir en este grupo: también está en un anverso.",
"group.move_up": "Mover arriba",
"group.move_down": "Mover abajo",
"group.remove": "Quitar del grupo",
@@ -851,6 +877,9 @@
"toolbar.mixed_others": "Las demás combinaciones de archivos abren «{0}».",
"toolbar.mixed_others_self": "Ningún perfil mixto quedó sin nada marcado, así que este también se usa para todas las demás combinaciones.",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "Este formato de entrada usa una conversión normal",
+ "toolbar.chip_tip.ordinary_conversion.all": "Todos los formatos de entrada convertibles usan una conversión normal",
+ "toolbar.chip_tip.ordinary_conversion.some": "Algunos formatos de entrada usan una conversión normal",
"toolbar.chip_tip.makes_same": "→ Mismo formato",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "Formatos admitidos: {0}",
@@ -902,6 +931,11 @@
"presets.cad_colors.BlackAndWhite": "Blanco y negro",
"presets.cad_colors.Grayscale": "Escala de grises",
"presets.cad_colors_hint": "Se usa cuando un dibujo DWG o DXF se convierte en PDF, SVG o imagen. Blanco y negro dibuja todas las líneas y textos en negro, como un trazado monocromo; las áreas rellenas pasan al gris de su luminosidad, para que un relleno claro no se convierta en un bloque negro. Escala de grises convierte cada color en un gris y oscurece las líneas claras, como el amarillo, lo bastante para leerlas.",
+ "presets.gcode_view": "Vista",
+ "presets.gcode_view.Top": "Desde arriba",
+ "presets.gcode_view.Isometric": "Isométrica",
+ "presets.gcode_view.Thumbnail": "Vista previa del laminador",
+ "presets.gcode_view_hint": "Se usa cuando un archivo G-code de un laminador o programa CAM se convierte en PDF, SVG o imagen. La extrusión y los cortes se colorean por altura, de oscuro (abajo) a claro (arriba); los desplazamientos en vacío son líneas azules finas (rojas para los rápidos de una CNC). La vista previa del laminador es la imagen que PrusaSlicer, Cura, OrcaSlicer o Bambu Studio guardó en el archivo; SVG, PDF y los archivos sin ella muestran la trayectoria desde arriba.",
"presets.group.image": "Imagen",
"presets.quality": "Calidad",
"presets.resize": "Cambiar tamaño",
@@ -981,10 +1015,15 @@
"presets.ringtone_start": "Inicio del tono (s)",
"presets.ringtone_length": "Duración del tono (s, máx. 40)",
"presets.ringtone_hint": "Los tonos de llamada del iPhone son archivos AAC de 40 segundos como máximo. El tono empieza en el segundo que indiques y se recorta a la duración que fijes (nunca más de 40 s).",
+ "presets.ask_ringtone": "Preguntar la parte cada vez (abre Cortar un clip)",
"presets.ico_sizes": "Tamaños de icono",
"presets.group.pdf": "PDF",
"presets.merge": "Combinar todos los archivos en un PDF",
"presets.source_bookmarks": "Añadir un marcador por cada archivo original",
+ "presets.normalize_merge_paper": "Usar un mismo tamaño de papel para todas las páginas combinadas",
+ "presets.merge_paper": "Papel para el PDF combinado",
+ "presets.merge_paper_hint": "Ajusta cada página al papel seleccionado y la centra sin recortarla. Los ajustes de las páginas de imagen se aplican antes de combinarlas.",
+ "presets.merge_paper_auto_hint": "La orientación automática se elige una sola vez según la primera página. Las páginas más pequeñas se amplían para ajustarlas.",
"presets.page_size": "Tamaño de página",
"presets.page_size.FitImage": "Ajustar a la imagen",
"presets.page_size.A4": "A4",
@@ -1141,6 +1180,7 @@
"presets.extract_note": "Cada archivo comprimido se extrae en una carpeta propia. El destino se define en Salida.",
"presets.group.trace": "Trazado",
"presets.group.cad": "Dibujo CAD",
+ "presets.group.gcode": "G-code (impresión 3D, CNC)",
"presets.trace_note": "Se usa cuando una imagen (PNG, JPG, …) se traza a formas vectoriales.",
"presets.trace_mode": "Modo",
"presets.trace_mode.Color": "Color",
@@ -1367,6 +1407,8 @@
"engines.package.whisper.description": "Transcribe la voz de vídeos y audios a archivos de subtítulos SRT. Funciona sin conexión a internet.",
"engines.package.calibre.name": "Calibre (libros electrónicos poco habituales)",
"engines.package.calibre.description": "Convierte formatos de libro electrónico poco habituales (LIT, LRF…) y guarda en formatos antiguos (LIT, PDB, RB…).\nEPUB, MOBI, AZW3, FB2 y cómics se leen, y los libros para Kindle se guardan, sin él.",
+ "engines.package.weasyprint.name": "WeasyPrint (HTML, Markdown y correo a PDF)",
+ "engines.package.weasyprint.description": "Crea mejores PDF de páginas HTML, Markdown y correos (EML, MSG, MHTML): títulos, tablas, negrita y escrituras de derecha a izquierda o índicas como las imprime un navegador.\nSin él también se convierten en PDF, por la ruta integrada.",
"engines.package.7zip.name": "7-Zip (variantes de archivo poco comunes)",
"engines.package.7zip.description": "Extrae las variantes poco comunes que los lectores propios de Filee no abren: 7Z con PPMd y otros métodos inusuales, CAB Quantum, ARJ multivolumen, imágenes de disco NTFS, exFAT o APFS…\nZIP, 7Z, RAR, TAR, CAB, ISO, DMG y todos los demás archivos comunes funcionan sin él.",
"engines.package.size": "Descargar {0} · {1} en disco",
@@ -1393,7 +1435,17 @@
"engines.remove": "Quitar",
"engines.remove_confirm": "¿Quitar {0}? Podrás descargarlo de nuevo más tarde.",
"engines.state.own_copy": "Tu copia",
- "engines.state.system_copy": "Encontrado en este equipo",
+ "engines.source.download": "Descarga de Filee",
+ "engines.source.system": "En este equipo",
+ "engines.source.version": "versión {0}",
+ "engines.source.on_disk": "{0} en disco",
+ "engines.other_copy_title": "También en este equipo",
+ "engines.other_copy_detail": "{0} (se quitaría la descarga de Filee)",
+ "engines.use_other_copy": "Usar esta en su lugar",
+ "engines.use_other_copy_confirm": "¿Usar la copia de {0} y quitar la descarga de {1} de Filee? Podrás descargarlo de nuevo más tarde.",
+ "engines.other_copy_gone": "Esa copia ya no se encuentra o no se inicia. Se ha conservado la descarga de Filee.",
+ "engines.install_filee_copy": "Instalar la copia de Filee",
+ "engines.use_another": "Usar otra copia…",
"engines.update_recommended": "Actualización recomendada: esta copia es la {0} y Filee está probado con la {1}.\nLa mayoría de las conversiones seguirán funcionando.",
"engines.use_own": "Usar mi copia…",
"engines.use_own_hint": "¿Ya lo tienes? Elige su programa y Filee usará ese en lugar de descargar el suyo.",
diff --git a/src/Filee.App/Assets/i18n/fa.json b/src/Filee.App/Assets/i18n/fa.json
index f3d49ab5b..fd7b3ff8d 100644
--- a/src/Filee.App/Assets/i18n/fa.json
+++ b/src/Filee.App/Assets/i18n/fa.json
@@ -88,6 +88,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "{0} فایل",
"donut.drop_to": "رها کردن ← {0}",
+ "donut.ordinary_conversion.single": "این فایل بهصورت عادی تبدیل میشود",
+ "donut.ordinary_conversion.all": "تبدیل عادی همهٔ فایلهای قابل تبدیل",
+ "donut.ordinary_conversion.some": "تبدیل عادی بعضی فایلها",
"donut.pick_in_group": "حرکت به سمت بیرون برای انتخاب",
"donut.group_empty": "پیشتنظیمی در این گروه باقی نمانده است. آن را در تنظیمات ← نوار ابزار دونات ویرایش کنید",
"donut.click_to": "کلیک ← {0}",
@@ -141,6 +144,8 @@
"error.split_unknown_length": "طول این ویدیو معلوم نیست، پس نمیتوان آن را به بخشهای مساوی تقسیم کرد",
"error.split_needs_copy": "تقسیم، ویدیو را همانطور که هست کپی میکند و این قالب نمیتواند آن را نگه دارد. قالب خود ویدیو یا MKV را انتخاب کنید",
"error.merged_image_too_large": "تصویر ادغامشده بیش از حد بزرگ خواهد شد، تصاویر کمتر یا کوچکتری را ادغام کنید",
+ "error.merge_paper_geometry": "اندازه، چرخش یا واحدهای صفحه نامعتبر است و نمیتوان آن را با کاغذ انتخابشده هماهنگ کرد",
+ "error.merge_paper_limit": "صفحه از حد پردازش برای تغییر اندازه کاغذ فراتر میرود",
"error.over_size_limit": "امکان کاهش حجم به اندازه کافی برای رسیدن به حد نصاب وجود ندارد",
"error.not_enough_space": "فضای دیسک خالی کافی برای تصاویر وجود ندارد",
"error.no_motion_photo": "این عکس ویدیویی ندارد که Filee بتواند بخواند (یک Motion Photo اندروید یا یک Live Photo آیفون به همراه فایل MOV در کنار آن)",
@@ -180,6 +185,9 @@
"error.fit_damaged": "فایل FIT آسیب دیده یا ناقص است",
"error.fit_chained": "فایل شامل چند فایل FIT پشت سر هم است که هنوز پشتیبانی نمیشود",
"error.fit_too_large": "فایل FIT برای تبدیل بیش از حد بزرگ است",
+ "error.canvas_not_canvas": "این یک بوم Obsidian (JSON Canvas) نیست",
+ "error.canvas_empty": "بوم هیچ کارتی برای رسم ندارد",
+ "error.canvas_too_large": "بوم برای تبدیل بیش از حد بزرگ است",
"error.fit_no_file_id": "فایل FIT مشخص نمیکند چه نوع فایلی است",
"error.fit_not_activity": "این فایل FIT یک فعالیت ورزشی نیست (تنها فعالیتها تبدیل میشوند)",
"error.fit_no_positions": "این فعالیت هیچ موقعیت ثبتشدهای ندارد: آن را به CSV، XLSX یا JSON تبدیل کنید",
@@ -191,12 +199,16 @@
"error.fon_damaged": "فایل .fon آسیب دیده یا ناقص است",
"error.document_too_complex": "سند برای خواندن ایمن بیش از حد بزرگ یا پیچیده است",
"error.m4s_needs_init": "این فایل .m4s بخشی از یک ویدیوی استریم است: بدون بخش شروع مجزای آن (init) قابل خواندن نیست",
+ "error.nov_password": "این خروجی با گذرواژه محافظت شده و ویدئوی آن رمزگذاری شده است: آن را دوباره بدون گذرواژه از Wisenet WAVE / Nx Witness خروجی بگیرید",
+ "error.nov_no_video": "این خروجی هیچ ضبطی از دوربین ندارد",
"error.exe_unreadable": "این برنامه یا کتابخانهٔ Windows نیست که Filee بتواند آن را بخواند، یا فایل آسیب دیده است",
"error.exe_no_icon": "این فایل آیکون مخصوص به خود ندارد (Windows برای آن یک آیکون استاندارد نشان میدهد)",
"notice.pdf_not_smaller": "امکان کوچکتر کردن فایل نبود، بنابراین همانطور که بود کپی شد",
+ "notice.nov_recordings_left_out": "این خروجی چند ضبط دارد (دوربینهای دیگر یا تکههایی با فاصله): فقط بزرگترین آنها تبدیل شد",
"notice.under_min_size": "ذخیره شد، اما از حداقل اندازه فایل کوچکتر است",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "اشیاء ناموجود در نسخه انتخابی اتوکد (multileaders، مشها…) نادیده گرفته شدند",
+ "notice.gcode_no_thumbnail": "فایل G-code پیشنمایش اسلایسر ندارد، پس مسیر ابزار از بالا کشیده شد",
"notice.sheet_objects_left_out": "اشیائی که Filee هنوز نمیتواند چاپ کند (نمودارها، متن داخل شکلها…) نادیده گرفته شدند: برای حفظ آنها، LibreOffice را در تنظیمات ← موتورها نصب کنید",
"notice.cmyk_needs_ghostscript": "این فایل CMYK است اما به صورت RGB ذخیره شد: برای حفظ CMYK، نرمافزار Ghostscript را در تنظیمات ← موتورها نصب کنید",
"notice.pdfa_needs_ghostscript": "این PDF از نوع PDF/A نیست: برای ساخت PDF/A، نرمافزار Ghostscript را در تنظیمات ← موتورها نصب کنید",
@@ -289,6 +301,8 @@
"engine.font.description": "تبدیل فونتها: TTF، OTF، WOFF، WOFF2 و EOT به یکدیگر؛ فونتهای بیتی ویندوز (FON) به آنها.",
"engine.cad.name": "CAD (داخلی)",
"engine.cad.description": "نقشههای اتوکد: DWG ↔ DXF، و هر دو به PDF، SVG و PNG.",
+ "engine.gcode.name": "G-code (داخلی)",
+ "engine.gcode.description": "مسیرهای چاپگرهای سهبعدی و دستگاههای CNC (G-code) به PDF، SVG و تصویر، یا پیشنمایش خود اسلایسر.",
"engine.drawing.name": "نمودارها و نقشهها (داخلی)",
"engine.drawing.description": "نمودارهای Visio (VSDX) و نقشههای OpenDocument (ODG، FODG) به PDF، SVG و PNG، بدون Visio یا LibreOffice.\nفایلهای باینری قدیمی Visio (.vsd) همچنان به LibreOffice نیاز دارند.",
"engine.model3d.name": "مدلهای سهبعدی (داخلی)",
@@ -311,6 +325,7 @@
"engine.tesseract.description": "متن را در بیش از ۱۰۰ زبان با استفاده از Tesseract OCR تشخیص میدهد.",
"engine.vision.description": "متن اسکنها و تصاویر را با تشخیص متن خود macOS (OCR) شناسایی میکند: PDF قابل جستجو، DOCX، TXT. بدون نیاز به دانلود چیزی.",
"engine.calibre.description": "کتابهای الکترونیکی را تبدیل و ایجاد میکند، جلد و فراداده را تنظیم میکند و با فرمتهای MOBI و AZW3 کار مینماید.",
+ "engine.weasyprint.description": "از صفحههای HTML، Markdown و ایمیلها همانطور که مرورگر چاپ میکند PDF میسازد: عنوانها، جدولها، متن پررنگ و متن عربی یا هندی درست درمیآیند.\nفقط تصویرهای کنار سند را بارگیری میکند، هیچ چیز از اینترنت.",
"engine.libreoffice.description": "قالبهای قدیمیتر و کمکاربرد را میخواند: DOC نسخهٔ Word 6.0/95، PPT نسخهٔ PowerPoint 95، Pages، Keynote، Numbers، WordPerfect، ClarisWorks، Publisher، Visio باینری (VSD)…\nفایلهای ODP و ODG دارای نمودار، شیء جاسازیشده یا سهبعدی را هم تبدیل میکند و با قالبهای DOC، XLS، PPT، ODT، الگوهای OpenDocument و EMF ذخیره میکند.",
"engine.translator.description": "اسناد، کتابهای الکترونیکی و زیرنویسها را به زبانهای دیگر ترجمه میکند. کاملاً آفلاین و روی رایانه شما اجرا میشود.",
"engine.exiftool.description": "همه فیلدهای متادیتای تصویر، PDF، ویدیو و صدا را میخواند و بدون دست زدن به محتوای فایل، آنها را در بیشتر انواع فایل ویرایش میکند.\nابزار متادیتا از آن استفاده میکند؛ بدون آن فیلدها فقطخواندنی نمایش داده میشوند.",
@@ -409,6 +424,7 @@
"clip.save": "ذخیره کلیپ",
"clip.save_next": "ذخیره و بعدی",
"clip.seconds": "{0} ثانیه",
+ "clip.ringtone_limit": "زنگ آیفون حداکثر {0} ثانیه است",
"clip.length_hint": "کلیپی به این تعداد ثانیه از نقطه شروع",
"clip.fast": "سریع، بدون انکود مجدد",
"clip.fast_hint": "ویدیو را همانطور که هست کپی میکند: در یک لحظه انجام میشود و هیچ کیفیتی از دست نمیرود، اما کلیپ از فریم کلیدی قبل از شروع آغاز میشود (تا چند ثانیه زودتر). این گزینه را خاموش کنید تا دقیقاً از فریمهای انتخابی برش داده شود (نیاز به انکود مجدد دارد و بیشتر طول میکشد).",
@@ -792,9 +808,15 @@
"toolbar.hint_remove": "برای حذف، یک برش را به اینجا برگردانید",
"toolbar.hint_reorder": "برای تغییر ترتیب، برشها را روی یکدیگر بکشید",
"toolbar.hint_edit": "روی یک برش راستکلیک کنید یا از ✎ برای ویرایش استفاده نمایید",
+ "toolbar.group_hint": "پیشتنظیمها را از فهرست به داخل بکشید، برای تغییر ترتیب برشها را بکشید، و برای بیرون آوردن یک برش، آن را روی فهرست بکشید.",
+ "toolbar.group_back": "بازگشت به {0}",
"toolbar.max_reached": "یک دونات حداکثر {0} پیشتنظیم را نگه میدارد",
- "toolbar.appearance": "ظاهر",
"toolbar.size": "اندازه",
+ "toolbar.edit_size": "ویرایش اندازه دونات",
+ "toolbar.look_done": "تمام",
+ "toolbar.look_hint": "دونات شما با اندازه واقعی روی صفحه است. برای جابهجا کردن، وسط آن را بکشید و برای باز نگه داشتن یک گروه، روی آن کلیک کنید.",
+ "toolbar.look_all": "همه دوناتها",
+ "toolbar.look_this": "این دونات: {0}",
"toolbar.hole": "حفره میانی",
"toolbar.opacity": "شفافیت",
"toolbar.captions": "نمایش فرمت خروجی",
@@ -802,6 +824,9 @@
"toolbar.label_scale": "اندازه برچسبها",
"toolbar.ring_size": "اندازه حلقه گروه",
"toolbar.ring_spread": "گستردگی حلقه گروه",
+ "toolbar.group_shown": "گروه نمایشدادهشده",
+ "toolbar.example_group": "گروه نمونه",
+ "toolbar.example_none": "این دونات گروهی ندارد",
"labels.auto": "خودکار",
"labels.icon_text": "آیکون و متن",
"labels.text_only": "فقط متن",
@@ -824,6 +849,7 @@
"group.add": "افزودن",
"group.add_placeholder": "انتخاب یک پیشتنظیم",
"group.full": "یک گروه حداکثر {0} پیشتنظیم را نگه میدارد.",
+ "group.copy_front": "قابلیت کپی به پوشه نمیتواند در این گروه قرار بگیرد: این گروه در نمای رو هم هست.",
"group.move_up": "انتقال به بالا",
"group.move_down": "انتقال به پایین",
"group.remove": "حذف از گروه",
@@ -850,6 +876,9 @@
"toolbar.mixed_others": "سایر ترکیبهای فایلی «{0}» را باز میکنند.",
"toolbar.mixed_others_self": "هیچ نمایه ترکیبی بدون علامت باقی نمانده است، بنابراین این نمایه برای هر ترکیب دیگری نیز استفاده میشود.",
"toolbar.chip_tip.makes": "← {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "این فرمت ورودی بهصورت عادی تبدیل میشود",
+ "toolbar.chip_tip.ordinary_conversion.all": "همهٔ فرمتهای ورودی قابل تبدیل بهصورت عادی تبدیل میشوند",
+ "toolbar.chip_tip.ordinary_conversion.some": "بعضی فرمتهای ورودی بهصورت عادی تبدیل میشوند",
"toolbar.chip_tip.makes_same": "← همان فرمت",
"toolbar.chip_tip.makes_name": "← {0}",
"toolbar.chip_tip.works_with": "فرمتهای پذیرفتهشده: {0}",
@@ -901,6 +930,11 @@
"presets.cad_colors.BlackAndWhite": "سیاه و سفید",
"presets.cad_colors.Grayscale": "طیف خاکستری",
"presets.cad_colors_hint": "هنگامی که یک نقشه DWG یا DXF به PDF، SVG یا تصویر تبدیل میشود استفاده میگردد. حالت سیاه و سفید همه خطوط و متون را مانند یک پلات تکرنگ به رنگ سیاه رسم میکند؛ نواحی پرشده به خاکستری متناسب با روشنایی خود تبدیل میشوند تا یک پرکردگی روشن به یک بلوک سیاه تبدیل نشود. طیف خاکستری هر رنگ را به یک سایه خاکستری تبدیل میکند و خطوط روشن مانند زرد را برای خوانایی تیرهتر میسازد.",
+ "presets.gcode_view": "نما",
+ "presets.gcode_view.Top": "از بالا",
+ "presets.gcode_view.Isometric": "ایزومتریک",
+ "presets.gcode_view.Thumbnail": "پیشنمایش اسلایسر",
+ "presets.gcode_view_hint": "وقتی فایل G-code یک اسلایسر یا برنامه CAM به PDF، SVG یا تصویر تبدیل میشود به کار میرود. اکستروژن و برشها بر اساس ارتفاع رنگ میگیرند، از تیره (پایین) تا روشن (بالا)؛ حرکتهای بیکار خطوط آبی نازکاند (قرمز برای حرکتهای سریع CNC). پیشنمایش اسلایسر تصویری است که PrusaSlicer، Cura، OrcaSlicer یا Bambu Studio در فایل ذخیره کرده است؛ SVG، PDF و فایلهای بدون آن مسیر را از بالا نشان میدهند.",
"presets.group.image": "تصویر",
"presets.quality": "کیفیت",
"presets.resize": "تغییر اندازه",
@@ -980,10 +1014,15 @@
"presets.ringtone_start": "شروع زنگ (ثانیه)",
"presets.ringtone_length": "مدت زنگ (ثانیه، حداکثر ۴۰)",
"presets.ringtone_hint": "زنگهای iPhone فایلهای AAC حداکثر ۴۰ ثانیهای هستند. زنگ از ثانیهای که تعیین میکنید شروع میشود و به مدتی که تعیین میکنید بریده میشود (هرگز بیش از ۴۰ ثانیه نمیشود).",
+ "presets.ask_ringtone": "هر بار بخش را بپرس (برش کلیپ باز میشود)",
"presets.ico_sizes": "اندازههای آیکون",
"presets.group.pdf": "PDF",
"presets.merge": "ادغام همه فایلها در یک PDF",
"presets.source_bookmarks": "افزودن نشانک برای هر فایل اصلی",
+ "presets.normalize_merge_paper": "استفاده از یک اندازه کاغذ برای همه صفحههای ادغامشده",
+ "presets.merge_paper": "کاغذ برای PDF ادغامشده",
+ "presets.merge_paper_hint": "هر صفحه را بدون برش با کاغذ انتخابشده متناسب و در مرکز قرار میدهد. تنظیمات صفحههای تصویری پیش از ادغام اعمال میشوند.",
+ "presets.merge_paper_auto_hint": "جهت خودکار یکبار بر اساس صفحه نخست انتخاب میشود. صفحههای کوچکتر برای تناسب بزرگ میشوند.",
"presets.page_size": "اندازه صفحه",
"presets.page_size.FitImage": "هماندازه تصویر",
"presets.page_size.A4": "A4",
@@ -1140,6 +1179,7 @@
"presets.extract_note": "هر فایل آرشیو در پوشه مخصوص به خود استخراج میشود. محل ذخیره آن در بخش خروجی تنظیم میگردد.",
"presets.group.trace": "تبدیل به برداری (Tracing)",
"presets.group.cad": "نقشه CAD",
+ "presets.group.gcode": "G-code (چاپ سهبعدی، CNC)",
"presets.trace_note": "زمانی استفاده میشود که یک تصویر پیکسلی (PNG، JPG، …) به اشکال برداری تبدیل شود.",
"presets.trace_mode": "حالت",
"presets.trace_mode.Color": "رنگی",
@@ -1366,6 +1406,8 @@
"engines.package.whisper.description": "گفتار در فایلهای صوتی و ویدیویی را به زیرنویس متنی تبدیل میکند.",
"engines.package.calibre.name": "Calibre (فرمتهای نادر کتاب الکترونیکی)",
"engines.package.calibre.description": "کتابها را در فرمتهای MOBI و AZW3 برای Kindle ذخیره میکند و فرمتهای نادر (LIT، LRF، PDB، CHM…) را تبدیل مینماید.\nفرمتهای EPUB، MOBI، AZW3، FB2 و کمیکها بدون آن نیز خوانده میشوند.",
+ "engines.package.weasyprint.name": "WeasyPrint (از HTML، Markdown و ایمیل به PDF)",
+ "engines.package.weasyprint.description": "از صفحههای HTML، Markdown و ایمیلها (EML، MSG، MHTML) PDF بهتری میسازد: عنوانها، جدولها، متن پررنگ و خطهای راستبهچپ یا هندی همانطور که مرورگر چاپ میکند.\nبدون آن هم از راه داخلی به PDF تبدیل میشوند.",
"engines.package.7zip.name": "7-Zip (گونههای کمیاب آرشیو)",
"engines.package.7zip.description": "گونههای کمیاب آرشیو را که خوانندههای داخلی Filee باز نمیکنند استخراج میکند: 7Z با PPMd و روشهای نامعمول دیگر، CAB با فشردهسازی Quantum، ARJ چندبخشی، تصویرهای دیسک NTFS، exFAT یا APFS…\nZIP، 7Z، RAR، TAR، CAB، ISO، DMG و همهٔ آرشیوهای رایج دیگر بدون آن کار میکنند.",
"engines.package.size": "دانلود {0} · {1} روی دیسک",
@@ -1392,7 +1434,17 @@
"engines.remove": "حذف",
"engines.remove_confirm": "{0} حذف شود؟ میتوانید بعداً دوباره آن را دانلود کنید.",
"engines.state.own_copy": "نسخه شما",
- "engines.state.system_copy": "یافتشده روی این رایانه",
+ "engines.source.download": "دانلود Filee",
+ "engines.source.system": "روی این رایانه",
+ "engines.source.version": "نسخه {0}",
+ "engines.source.on_disk": "{0} روی دیسک",
+ "engines.other_copy_title": "همچنین روی این رایانه",
+ "engines.other_copy_detail": "{0} (دانلود Filee حذف میشود)",
+ "engines.use_other_copy": "به جای آن از این استفاده شود",
+ "engines.use_other_copy_confirm": "از نسخه موجود در {0} استفاده شود و دانلود {1} توسط Filee حذف شود؟ میتوانید بعداً دوباره آن را دانلود کنید.",
+ "engines.other_copy_gone": "آن نسخه دیگر پیدا نمیشود یا اجرا نمیشود. دانلود Filee نگه داشته شد.",
+ "engines.install_filee_copy": "نصب نسخه Filee",
+ "engines.use_another": "استفاده از نسخه دیگر…",
"engines.update_recommended": "بهروزرسانی توصیه میشود: این نسخه {0} است، در حالی که Filee با نسخه {1} آزمایش شده است.\nبیشتر تبدیلها همچنان کار میکنند.",
"engines.use_own": "استفاده از نسخه من…",
"engines.use_own_hint": "از قبل آن را دارید؟ برنامه آن را انتخاب کنید تا Filee به جای دانلود مجدد از آن استفاده کند.",
diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json
index aab758622..a01d20532 100644
--- a/src/Filee.App/Assets/i18n/fr.json
+++ b/src/Filee.App/Assets/i18n/fr.json
@@ -78,6 +78,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "{0} fichiers",
"donut.drop_to": "Déposer → {0}",
+ "donut.ordinary_conversion.single": "Ce fichier utilise une conversion normale",
+ "donut.ordinary_conversion.all": "Conversion normale de tous les fichiers convertibles",
+ "donut.ordinary_conversion.some": "Certains fichiers utilisent une conversion normale",
"donut.pick_in_group": "Éloignez-vous pour choisir",
"donut.group_empty": "Plus aucun préréglage dans ce groupe. Modifiez-le dans Paramètres → Menu circulaire",
"donut.click_to": "Clic → {0}",
@@ -133,6 +136,8 @@
"error.split_unknown_length": "La durée de cette vidéo est inconnue, elle ne peut donc pas être coupée en parties égales",
"error.split_needs_copy": "Le découpage copie la vidéo telle quelle, et ce format ne peut pas la contenir. Choisissez le format de la vidéo ou MKV",
"error.merged_image_too_large": "L'image fusionnée serait trop grande, fusionnez moins d'images ou des images plus petites",
+ "error.merge_paper_geometry": "Une page a une taille, une rotation ou une unité non valide et ne peut pas être adaptée au format de papier choisi",
+ "error.merge_paper_limit": "Une page dépasse la limite de traitement pour changer le format de papier",
"error.over_size_limit": "Impossible de le rendre assez léger pour la taille maximale",
"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é)",
@@ -172,6 +177,9 @@
"error.fit_damaged": "Le fichier FIT est endommagé ou tronqué",
"error.fit_chained": "Les fichiers FIT enchaînés ne sont pas encore pris en charge",
"error.fit_too_large": "Le fichier FIT est trop volumineux pour être converti",
+ "error.canvas_not_canvas": "Ce fichier n'est pas un canevas Obsidian (JSON Canvas)",
+ "error.canvas_empty": "Le canevas n'a aucune carte à dessiner",
+ "error.canvas_too_large": "Le canevas est trop volumineux pour être converti",
"error.fit_no_file_id": "Le fichier FIT n'indique pas son type",
"error.fit_not_activity": "Ce fichier FIT n'est pas une activité (seules les activités sont converties)",
"error.fit_no_positions": "L'activité ne contient aucune position enregistrée : convertissez-la en CSV, XLSX ou JSON",
@@ -183,12 +191,16 @@
"error.fon_damaged": "Le fichier .fon est endommagé ou incomplet",
"error.document_too_complex": "Le document est trop volumineux ou trop complexe pour être lu en toute sécurité",
"error.m4s_needs_init": "Ce .m4s est un morceau d'une vidéo diffusée en streaming : il ne peut pas être lu sans son segment de départ (init) séparé",
+ "error.nov_password": "Cet export est protégé par un mot de passe, sa vidéo est donc chiffrée : exportez-le à nouveau depuis Wisenet WAVE / Nx Witness sans mot de passe",
+ "error.nov_no_video": "Cet export ne contient aucun enregistrement de caméra",
"error.exe_unreadable": "Ce n'est pas un programme ni une bibliothèque Windows que Filee peut lire, ou le fichier est endommagé",
"error.exe_no_icon": "Ce fichier n'a pas d'icône propre (Windows lui affiche une icône standard)",
"notice.pdf_not_smaller": "Impossible de réduire sa taille : le fichier a été copié tel quel",
+ "notice.nov_recordings_left_out": "Cet export contient plusieurs enregistrements (d'autres caméras ou des morceaux séparés par des coupures) : seul le plus gros a été converti",
"notice.under_min_size": "Enregistré, mais plus petit que la taille de fichier minimale",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "Des objets que la version d'AutoCAD choisie n'a pas (multilignes de repère, maillages…) ont été omis",
+ "notice.gcode_no_thumbnail": "Le fichier G-code n'a pas d'aperçu du slicer : son parcours a été dessiné vu de dessus",
"notice.sheet_objects_left_out": "Des objets que Filee ne sait pas encore imprimer (graphiques, texte dans les formes…) ont été omis : installez LibreOffice dans Paramètres → Moteurs pour les garder",
"notice.cmyk_needs_ghostscript": "Ce fichier est en CMJN mais a été enregistré en RVB : installez Ghostscript dans Paramètres → Moteurs pour garder le CMJN",
"notice.pdfa_needs_ghostscript": "Ce PDF n'est pas un PDF/A : installez Ghostscript dans Paramètres → Moteurs pour créer du PDF/A",
@@ -282,6 +294,8 @@
"engine.font.description": "Convertit TTF, OTF, WOFF, WOFF2 et EOT entre eux ; convertit les polices bitmap Windows (FON) vers ces formats.",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "Dessins AutoCAD : DWG ↔ DXF, et les deux en PDF, SVG et PNG.",
+ "engine.gcode.name": "G-code (built-in)",
+ "engine.gcode.description": "Parcours d'imprimantes 3D et de machines CNC (G-code) en PDF, SVG et images, ou l'aperçu du slicer.",
"engine.drawing.name": "Drawings (built-in)",
"engine.drawing.description": "Dessins Visio (VSDX) et OpenDocument (ODG, FODG) en PDF, SVG et PNG, sans Visio ni LibreOffice.\nLes anciens fichiers Visio binaires (.vsd) nécessitent toujours LibreOffice.",
"engine.model3d.name": "3D models (built-in)",
@@ -304,6 +318,7 @@
"engine.tesseract.description": "Reconnaît le texte des numérisations et des images (OCR) : PDF consultable, DOCX, TXT.",
"engine.vision.description": "Reconnaît le texte des numérisations et des images avec la reconnaissance de texte intégrée à macOS (OCR) : PDF consultable, DOCX, TXT. Rien à télécharger.",
"engine.calibre.description": "Formats de livres numériques rares (LIT, LRF…) et sortie dans d'anciens formats (LIT, PDB, RB…).\nPlacé au-dessus du rédacteur HWPX, avec le moteur de rendu Filee désactivé, il produit aussi les PDF des livres numériques (bien plus vite pour les longs livres).",
+ "engine.weasyprint.description": "Crée les PDF des pages HTML, du Markdown et des e-mails comme un navigateur les imprime : titres, tableaux, gras et textes arabes ou hindis sont corrects.\nIl ne charge que les images à côté du document, rien depuis Internet.",
"engine.libreoffice.description": "Lit les formats anciens et moins courants : DOC de Word 6.0/95, PPT de PowerPoint 95, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio binaire (VSD)…\nConvertit aussi les fichiers ODP et ODG contenant des graphiques, des objets incorporés ou de la 3D, et enregistre en DOC, XLS, PPT, ODT, modèles OpenDocument et EMF.",
"engine.translator.description": "Traduit les documents et livres électroniques localement à l'aide de modèles Ollama hors ligne.\nTraduit aussi les sous-titres SRT et WebVTT : seul le texte des répliques est envoyé au modèle, les numéros et les horodatages sont conservés.",
"engine.exiftool.description": "Lit tous les champs de métadonnées des images, PDF, vidéos et sons, et les modifie dans la plupart des types de fichiers sans toucher au contenu.\nUtilisé par l'outil Métadonnées ; sans lui, les champs sont en lecture seule.",
@@ -404,6 +419,7 @@
"clip.save": "Enregistrer l'extrait",
"clip.save_next": "Enregistrer et suivant",
"clip.seconds": "{0} s",
+ "clip.ringtone_limit": "Une sonnerie d’iPhone dure au plus {0} secondes",
"clip.length_hint": "Un extrait de cette durée depuis le début",
"clip.fast": "Rapide, sans réencodage",
"clip.fast_hint": "Copie la vidéo telle quelle : c'est instantané et sans perte, mais l'extrait commence à l'image clé avant le début (jusqu'à quelques secondes plus tôt). Désactivez pour couper exactement aux images choisies (réencode, plus long).",
@@ -508,6 +524,10 @@
"merge.same_width": "Même largeur",
"merge.same_height": "Même hauteur",
"merge.same_size_tip": "Met les images à l'échelle pour aligner leurs bords. Désactivé : chacune garde sa taille, centrée.",
+ "presets.normalize_merge_paper": "Utiliser le même format de papier pour toutes les pages fusionnées",
+ "presets.merge_paper": "Format de papier du PDF fusionné",
+ "presets.merge_paper_hint": "Adapte chaque page au format de papier choisi et la centre sans la rogner. Les réglages des pages image sont appliqués avant la fusion.",
+ "presets.merge_paper_auto_hint": "L'orientation automatique est choisie une seule fois, selon la première page. Les pages plus petites sont agrandies pour s'adapter.",
"merge.gap": "Espacement (px)",
"merge.background": "Arrière-plan",
"merge.transparent": "Transparent",
@@ -807,9 +827,15 @@
"toolbar.hint_remove": "Glissez une tranche ici pour la retirer",
"toolbar.hint_reorder": "Glissez les tranches l'une sur l'autre pour les réordonner",
"toolbar.hint_edit": "Clic droit sur une tranche ou ✎ pour la modifier",
+ "toolbar.group_hint": "Glissez des préréglages depuis la liste, glissez les parts pour les réordonner, et glissez-en une sur la liste pour la retirer.",
+ "toolbar.group_back": "Revenir à {0}",
"toolbar.max_reached": "Un menu circulaire contient au plus {0} préréglages",
- "toolbar.appearance": "Apparence",
"toolbar.size": "Taille",
+ "toolbar.edit_size": "Modifier la taille du menu circulaire",
+ "toolbar.look_done": "Terminé",
+ "toolbar.look_hint": "Votre menu circulaire est à l'écran à sa taille réelle. Faites glisser son centre pour le déplacer, et cliquez sur un groupe pour le garder ouvert.",
+ "toolbar.look_all": "Tous les menus circulaires",
+ "toolbar.look_this": "Ce menu circulaire : {0}",
"toolbar.hole": "Trou",
"toolbar.opacity": "Opacité",
"toolbar.captions": "Afficher le format de sortie",
@@ -817,6 +843,9 @@
"toolbar.label_scale": "Taille des libellés",
"toolbar.ring_size": "Taille de l'anneau de groupe",
"toolbar.ring_spread": "Largeur de l'anneau de groupe",
+ "toolbar.group_shown": "Groupe affiché",
+ "toolbar.example_group": "Exemple de groupe",
+ "toolbar.example_none": "Ce menu circulaire n'a pas de groupe",
"labels.auto": "Auto",
"labels.icon_text": "Icône et texte",
"labels.text_only": "Texte seul",
@@ -839,6 +868,7 @@
"group.add": "Ajouter",
"group.add_placeholder": "Choisir un préréglage",
"group.full": "Un groupe contient au plus {0} préréglages.",
+ "group.copy_front": "« Copier dans un dossier » ne peut pas aller dans ce groupe : il est aussi sur un recto.",
"group.move_up": "Monter",
"group.move_down": "Descendre",
"group.remove": "Retirer du groupe",
@@ -865,6 +895,9 @@
"toolbar.mixed_others": "Les autres mélanges de fichiers ouvrent « {0} ».",
"toolbar.mixed_others_self": "Il ne reste aucun profil mixte sans case cochée : celui-ci sert donc aussi pour tous les autres mélanges.",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "Ce format d’entrée utilise une conversion normale",
+ "toolbar.chip_tip.ordinary_conversion.all": "Tous les formats d’entrée convertibles utilisent une conversion normale",
+ "toolbar.chip_tip.ordinary_conversion.some": "Certains formats d’entrée utilisent une conversion normale",
"toolbar.chip_tip.makes_same": "→ Même format",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "Formats acceptés : {0}",
@@ -917,6 +950,11 @@
"presets.cad_colors.BlackAndWhite": "Noir et blanc",
"presets.cad_colors.Grayscale": "Niveaux de gris",
"presets.cad_colors_hint": "Utilisé quand un dessin DWG ou DXF devient un PDF, un SVG ou une image. Noir et blanc trace toutes les lignes et tous les textes en noir, comme un tracé monochrome ; les zones remplies prennent le gris de leur luminosité, pour qu'un remplissage clair ne devienne pas un bloc noir. Niveaux de gris change chaque couleur en gris et fonce les lignes claires, comme le jaune, assez pour qu'elles se lisent.",
+ "presets.gcode_view": "Vue",
+ "presets.gcode_view.Top": "De dessus",
+ "presets.gcode_view.Isometric": "Isométrique",
+ "presets.gcode_view.Thumbnail": "Aperçu du slicer",
+ "presets.gcode_view_hint": "Utilisé quand un fichier G-code d'un slicer ou d'un logiciel de FAO devient un PDF, un SVG ou une image. L'extrusion et les coupes sont colorées selon la hauteur, du foncé (bas) au clair (haut) ; les déplacements à vide sont de fines lignes bleues (rouges pour les rapides d'une CNC). L'aperçu du slicer est l'image que PrusaSlicer, Cura, OrcaSlicer ou Bambu Studio a enregistrée dans le fichier ; le SVG, le PDF et les fichiers qui n'en ont pas montrent le parcours vu de dessus.",
"presets.group.image": "Image",
"presets.quality": "Qualité",
"presets.resize": "Redimensionner",
@@ -996,6 +1034,7 @@
"presets.ringtone_start": "Début de la sonnerie (s)",
"presets.ringtone_length": "Durée de la sonnerie (s, 40 max.)",
"presets.ringtone_hint": "Les sonneries de l'iPhone sont des fichiers AAC de 40 secondes au plus. La sonnerie commence à la seconde indiquée et est coupée à la durée choisie (jamais plus de 40 s).",
+ "presets.ask_ringtone": "Demander l’extrait à chaque fois (ouvre Couper un clip)",
"presets.ico_sizes": "Tailles d'icône",
"presets.group.pdf": "PDF",
"presets.merge": "Fusionner tous les fichiers en un seul PDF",
@@ -1156,6 +1195,7 @@
"presets.extract_note": "Chaque archive est extraite dans son propre dossier. Son emplacement se règle dans Sortie.",
"presets.group.trace": "Vectorisation",
"presets.group.cad": "Dessin CAO",
+ "presets.group.gcode": "G-code (impression 3D, CNC)",
"presets.trace_note": "Utilisé quand une image (PNG, JPG, …) est vectorisée en formes vectorielles.",
"presets.trace_mode": "Mode",
"presets.trace_mode.Color": "Couleur",
@@ -1384,6 +1424,8 @@
"engines.package.whisper.description": "Transcrit la parole des vidéos et audios en fichiers de sous-titres SRT. Fonctionne hors ligne sans connexion Internet.",
"engines.package.calibre.name": "Calibre (formats de livres numériques rares)",
"engines.package.calibre.description": "Convertit les formats de livres numériques rares (LIT, LRF…) et enregistre dans d'anciens formats (LIT, PDB, RB…).\nEPUB, MOBI, AZW3, FB2 et bandes dessinées sont lus, et les livres Kindle enregistrés, sans lui.",
+ "engines.package.weasyprint.name": "WeasyPrint (HTML, Markdown et e-mails en PDF)",
+ "engines.package.weasyprint.description": "Crée de meilleurs PDF des pages HTML, du Markdown et des e-mails (EML, MSG, MHTML) : titres, tableaux, gras et écritures de droite à gauche ou indiennes comme un navigateur les imprime.\nSans lui, ils deviennent aussi des PDF par la route intégrée.",
"engines.package.7zip.name": "7-Zip (variantes d'archives rares)",
"engines.package.7zip.description": "Extrait les variantes d'archives rares que les lecteurs de Filee laissent de côté : 7Z en PPMd et autres méthodes peu courantes, CAB Quantum, ARJ multivolume, images disque NTFS, exFAT ou APFS…\nZIP, 7Z, RAR, TAR, CAB, ISO, DMG et toutes les autres archives courantes fonctionnent sans lui.",
"engines.package.size": "Téléchargement {0} · {1} sur le disque",
@@ -1410,7 +1452,17 @@
"engines.remove": "Supprimer",
"engines.remove_confirm": "Supprimer {0} ? Vous pourrez le télécharger de nouveau plus tard.",
"engines.state.own_copy": "Votre copie",
- "engines.state.system_copy": "Trouvé sur ce PC",
+ "engines.source.download": "Téléchargement de Filee",
+ "engines.source.system": "Sur cet ordinateur",
+ "engines.source.version": "version {0}",
+ "engines.source.on_disk": "{0} sur le disque",
+ "engines.other_copy_title": "Aussi sur cet ordinateur",
+ "engines.other_copy_detail": "{0} (le téléchargement de Filee serait supprimé)",
+ "engines.use_other_copy": "Utiliser celle-ci à la place",
+ "engines.use_other_copy_confirm": "Utiliser la copie de {0} et supprimer {1} téléchargé par Filee ? Vous pourrez le télécharger de nouveau plus tard.",
+ "engines.other_copy_gone": "Cette copie est introuvable ou ne démarre pas. Le téléchargement de Filee a été conservé.",
+ "engines.install_filee_copy": "Installer la copie de Filee",
+ "engines.use_another": "Utiliser une autre copie…",
"engines.update_recommended": "Mise à jour recommandée : cette copie est en version {0}, Filee est testé avec la {1}.\nLa plupart des conversions fonctionnent quand même.",
"engines.use_own": "Utiliser ma copie…",
"engines.use_own_hint": "Vous l'avez déjà ? Choisissez son programme et Filee l'utilisera au lieu de télécharger le sien.",
diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json
index 539d021eb..3af67c209 100644
--- a/src/Filee.App/Assets/i18n/hi.json
+++ b/src/Filee.App/Assets/i18n/hi.json
@@ -74,6 +74,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "{0} फ़ाइलें",
"donut.drop_to": "छोड़ें → {0}",
+ "donut.ordinary_conversion.single": "यह फ़ाइल सामान्य कन्वर्ज़न के रूप में चलती है",
+ "donut.ordinary_conversion.all": "कन्वर्ट हो सकने वाली सभी फ़ाइलें सामान्य कन्वर्ज़न के रूप में चलती हैं",
+ "donut.ordinary_conversion.some": "कुछ फ़ाइलें सामान्य कन्वर्ज़न के रूप में चलती हैं",
"donut.pick_in_group": "चुनने के लिए बाहर की ओर ले जाएँ",
"donut.group_empty": "इस समूह में कोई प्रीसेट नहीं बचा है। इसे सेटिंग्स → डोनट टूलबार में बदलें",
"donut.click_to": "क्लिक करें → {0}",
@@ -127,6 +130,8 @@
"error.split_unknown_length": "इस वीडियो की लंबाई पता नहीं है, इसलिए इसे बराबर हिस्सों में नहीं काटा जा सकता",
"error.split_needs_copy": "बाँटने पर वीडियो जैसा है वैसा कॉपी होता है, और यह फ़ॉर्मैट उसे नहीं रख सकता। वीडियो का अपना फ़ॉर्मैट या MKV चुनें",
"error.merged_image_too_large": "जुड़ी हुई तस्वीर बहुत बड़ी होगी, कम या छोटी तस्वीरें जोड़ें",
+ "error.merge_paper_geometry": "किसी पेज का आकार, घुमाव या इकाई अमान्य है, इसलिए उसे चुने हुए कागज़ के आकार में नहीं ढाला जा सकता",
+ "error.merge_paper_limit": "एक पेज कागज़ का आकार बदलने की प्रोसेसिंग सीमा से अधिक है",
"error.over_size_limit": "इसे आकार सीमा जितना छोटा नहीं किया जा सकता",
"error.not_enough_space": "तस्वीरों के लिए डिस्क पर पर्याप्त खाली जगह नहीं है",
"error.no_motion_photo": "इस फ़ोटो में ऐसा कोई वीडियो नहीं है जिसे Filee पढ़ सके (Android मोशन फ़ोटो, या पास में अपनी MOV फ़ाइल वाली iPhone लाइव फ़ोटो)",
@@ -166,6 +171,9 @@
"error.fit_damaged": "FIT फ़ाइल खराब है या अधूरी है",
"error.fit_chained": "एक के बाद एक जुड़ी कई FIT फ़ाइलें अभी समर्थित नहीं हैं",
"error.fit_too_large": "FIT फ़ाइल कन्वर्ट करने के लिए बहुत बड़ी है",
+ "error.canvas_not_canvas": "यह Obsidian कैनवस (JSON Canvas) नहीं है",
+ "error.canvas_empty": "कैनवस में बनाने के लिए कोई कार्ड नहीं है",
+ "error.canvas_too_large": "कैनवस कन्वर्ट करने के लिए बहुत बड़ा है",
"error.fit_no_file_id": "FIT फ़ाइल में फ़ाइल का प्रकार नहीं बताया गया है",
"error.fit_not_activity": "यह FIT फ़ाइल गतिविधि नहीं है (सिर्फ़ गतिविधियाँ कन्वर्ट होती हैं)",
"error.fit_no_positions": "गतिविधि में दर्ज स्थान नहीं हैं: इसे CSV, XLSX या JSON में बदलें",
@@ -177,12 +185,16 @@
"error.fon_damaged": ".fon फ़ाइल खराब है या अधूरी है",
"error.document_too_complex": "दस्तावेज़ इतना बड़ा या जटिल है कि उसे सुरक्षित रूप से पढ़ा नहीं जा सकता",
"error.m4s_needs_init": "यह .m4s स्ट्रीम किए गए वीडियो का एक हिस्सा है: इसके अलग शुरुआती (init) सेगमेंट के बिना इसे पढ़ा नहीं जा सकता",
+ "error.nov_password": "यह एक्सपोर्ट पासवर्ड से सुरक्षित है, इसलिए इसका वीडियो एन्क्रिप्टेड है: इसे Wisenet WAVE / Nx Witness से बिना पासवर्ड के फिर से एक्सपोर्ट करें",
+ "error.nov_no_video": "इस एक्सपोर्ट में कैमरे की कोई रिकॉर्डिंग नहीं है",
"error.exe_unreadable": "यह कोई ऐसा Windows प्रोग्राम या लाइब्रेरी नहीं है जिसे Filee पढ़ सके, या फ़ाइल खराब है",
"error.exe_no_icon": "इस फ़ाइल का अपना कोई आइकन नहीं है (Windows इसके लिए एक मानक आइकन दिखाता है)",
"notice.pdf_not_smaller": "इसे छोटा नहीं किया जा सका, इसलिए जैसा है वैसा ही कॉपी कर दिया गया",
+ "notice.nov_recordings_left_out": "इस एक्सपोर्ट में कई रिकॉर्डिंग हैं (दूसरे कैमरे या बीच में रुकावट वाले हिस्से): केवल सबसे बड़ी रिकॉर्डिंग बदली गई",
"notice.under_min_size": "सहेजा गया, लेकिन न्यूनतम फ़ाइल आकार से छोटा है",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "चुने गए AutoCAD संस्करण में न होने वाले ऑब्जेक्ट (मल्टीलीडर, मेश …) छोड़ दिए गए",
+ "notice.gcode_no_thumbnail": "G-code फ़ाइल में स्लाइसर का प्रीव्यू नहीं है, इसलिए टूलपाथ ऊपर से देखा हुआ बनाया गया",
"notice.sheet_objects_left_out": "जिन ऑब्जेक्ट को Filee अभी प्रिंट नहीं कर सकता (चार्ट, आकृतियों में टेक्स्ट …), वे छोड़ दिए गए: उन्हें रखने के लिए सेटिंग्स → इंजन में LibreOffice इंस्टॉल करें",
"notice.cmyk_needs_ghostscript": "यह फ़ाइल CMYK है, लेकिन RGB में सेव हुई: CMYK बनाए रखने के लिए सेटिंग्स → इंजन में Ghostscript इंस्टॉल करें",
"notice.pdfa_needs_ghostscript": "यह PDF, PDF/A नहीं है: PDF/A बनाने के लिए सेटिंग्स → इंजन में Ghostscript इंस्टॉल करें",
@@ -275,6 +287,8 @@
"engine.font.description": "TTF, OTF, WOFF, WOFF2 और EOT को आपस में बदलें; Windows बिटमैप फ़ॉन्ट (FON) को भी इन फ़ॉर्मैट में बदलें।",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "AutoCAD ड्रॉइंग: DWG ↔ DXF, और दोनों से PDF, SVG और PNG।",
+ "engine.gcode.name": "G-code (built-in)",
+ "engine.gcode.description": "3D प्रिंटर और CNC मशीनों के टूलपाथ (G-code) को PDF, SVG और चित्र में, या स्लाइसर का अपना प्रीव्यू।",
"engine.drawing.name": "Drawings (built-in)",
"engine.drawing.description": "Visio ड्रॉइंग (VSDX) और OpenDocument ड्रॉइंग (ODG, FODG) से PDF, SVG और PNG, बिना Visio या LibreOffice के।\nपुरानी बाइनरी Visio फ़ाइलों (.vsd) के लिए अब भी LibreOffice चाहिए।",
"engine.model3d.name": "3D models (built-in)",
@@ -297,6 +311,7 @@
"engine.tesseract.description": "स्कैन और तस्वीरों का टेक्स्ट पहचानता है (OCR): खोजने योग्य PDF, DOCX, TXT।",
"engine.vision.description": "macOS की अपनी टेक्स्ट पहचान से स्कैन और तस्वीरों का टेक्स्ट पहचानता है (OCR): खोजने योग्य PDF, DOCX, TXT। कुछ डाउनलोड नहीं करना पड़ता।",
"engine.calibre.description": "दुर्लभ ई-बुक फ़ॉर्मैट (LIT, LRF…) और पुराने फ़ॉर्मैट में आउटपुट (LIT, PDB, RB…)।\nFilee रेंडरर बंद हो और यह HWPX writer के ऊपर हो, तो यह ई-बुक के PDF भी बनाता है (लंबी किताबों के लिए बहुत तेज़)।",
+ "engine.weasyprint.description": "HTML पेज, Markdown और ईमेल के PDF वैसे बनाता है जैसे ब्राउज़र उन्हें प्रिंट करता है: शीर्षक, तालिकाएँ, बोल्ड और अरबी या हिंदी पाठ सही आते हैं।\nयह केवल दस्तावेज़ के पास की तस्वीरें लोड करता है, इंटरनेट से कुछ नहीं।",
"engine.libreoffice.description": "पुराने और कम प्रचलित फ़ॉर्मैट पढ़ता है: Word 6.0/95 DOC, PowerPoint 95 PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, बाइनरी Visio (VSD)…\nचार्ट, एम्बेड किए गए ऑब्जेक्ट या 3D वाली ODP और ODG फ़ाइलें भी कन्वर्ट करता है, और DOC, XLS, PPT, ODT, OpenDocument टेम्पलेट और EMF के रूप में सहेजता है।",
"engine.translator.description": "ऑफ़लाइन ओलामा मॉडल का उपयोग करके दस्तावेज़ों और ई-पुस्तकों का स्थानीय अनुवाद करता है।\nSRT और WebVTT सबटाइटल का भी अनुवाद करता है: मॉडल को केवल क्यू का टेक्स्ट भेजा जाता है, क्यू नंबर और टाइमस्टैम्प वैसे ही रहते हैं।",
"engine.exiftool.description": "चित्रों, PDF, वीडियो और ऑडियो के सभी मेटाडेटा फ़ील्ड पढ़ता है, और ज़्यादातर तरह की फ़ाइलों में फ़ाइल की सामग्री छुए बिना उन्हें बदलता है।\nमेटाडेटा टूल इसका उपयोग करता है; इसके बिना फ़ील्ड केवल पढ़ने के लिए दिखते हैं।",
@@ -395,6 +410,7 @@
"clip.save": "क्लिप सहेजें",
"clip.save_next": "सहेजें और अगला",
"clip.seconds": "{0} से.",
+ "clip.ringtone_limit": "iPhone रिंगटोन अधिकतम {0} सेकंड की होती है",
"clip.length_hint": "शुरुआत से इतने सेकंड की क्लिप",
"clip.fast": "तेज़, बिना री-एन्कोड",
"clip.fast_hint": "वीडियो को जैसा है वैसा कॉपी करता है: पल भर में और बिना गुणवत्ता खोए, पर क्लिप शुरुआत से पहले वाले की फ़्रेम से शुरू होती है (कुछ सेकंड पहले तक)। चुने गए फ़्रेम पर ठीक-ठीक काटने के लिए बंद करें (दोबारा एन्कोड होता है, ज़्यादा समय लगता है)।",
@@ -498,6 +514,10 @@
"merge.same_width": "एक जैसी चौड़ाई",
"merge.same_height": "एक जैसी ऊँचाई",
"merge.same_size_tip": "तस्वीरों का आकार बदलता है ताकि उनके किनारे मिल जाएँ। बंद होने पर हर तस्वीर अपने आकार में बीच में रहती है।",
+ "presets.normalize_merge_paper": "मर्ज किए गए सभी पेजों के लिए एक ही कागज़ का आकार रखें",
+ "presets.merge_paper": "मर्ज की गई PDF के लिए कागज़",
+ "presets.merge_paper_hint": "हर पेज को चुने हुए कागज़ के आकार में फिट करके बीच में रखता है, बिना काटे। तस्वीर वाले पेजों की सेटिंग मर्ज करने से पहले लागू होती हैं।",
+ "presets.merge_paper_auto_hint": "ऑटो ओरिएंटेशन पहले पेज के आधार पर एक बार चुना जाता है। छोटे पेज फिट होने के लिए बड़े किए जाते हैं।",
"merge.gap": "अंतर (px)",
"merge.background": "बैकग्राउंड",
"merge.transparent": "पारदर्शी",
@@ -793,9 +813,15 @@
"toolbar.hint_remove": "हटाने के लिए स्लाइस को वापस यहाँ खींचें",
"toolbar.hint_reorder": "क्रम बदलने के लिए स्लाइस को एक-दूसरे पर खींचें",
"toolbar.hint_edit": "संपादित करने के लिए स्लाइस पर राइट-क्लिक करें या ✎ इस्तेमाल करें",
+ "toolbar.group_hint": "सूची से प्रीसेट खींचकर डालें, क्रम बदलने के लिए स्लाइस खींचें, और हटाने के लिए किसी स्लाइस को सूची पर खींचें।",
+ "toolbar.group_back": "{0} पर वापस जाएँ",
"toolbar.max_reached": "एक डोनट में अधिकतम {0} प्रीसेट आ सकते हैं",
- "toolbar.appearance": "रूप-रंग",
"toolbar.size": "आकार",
+ "toolbar.edit_size": "डोनट का आकार बदलें",
+ "toolbar.look_done": "हो गया",
+ "toolbar.look_hint": "आपका डोनट स्क्रीन पर असली आकार में है। इसे हिलाने के लिए बीच का हिस्सा खींचें, और किसी समूह को खुला रखने के लिए उस पर क्लिक करें।",
+ "toolbar.look_all": "सभी डोनट",
+ "toolbar.look_this": "यह डोनट: {0}",
"toolbar.hole": "छेद",
"toolbar.opacity": "अपारदर्शिता",
"toolbar.captions": "आउटपुट फ़ॉर्मैट दिखाएँ",
@@ -803,6 +829,9 @@
"toolbar.label_scale": "लेबल का आकार",
"toolbar.ring_size": "समूह रिंग का आकार",
"toolbar.ring_spread": "समूह रिंग का फैलाव",
+ "toolbar.group_shown": "दिखाया गया समूह",
+ "toolbar.example_group": "उदाहरण समूह",
+ "toolbar.example_none": "इस डोनट में कोई समूह नहीं है",
"labels.auto": "अपने आप",
"labels.icon_text": "आइकन और टेक्स्ट",
"labels.text_only": "सिर्फ़ टेक्स्ट",
@@ -825,6 +854,7 @@
"group.add": "जोड़ें",
"group.add_placeholder": "प्रीसेट चुनें",
"group.full": "एक समूह में अधिकतम {0} प्रीसेट आ सकते हैं।",
+ "group.copy_front": "\"फ़ोल्डर में कॉपी करें\" इस समूह में नहीं जा सकता: यह समूह आगे वाले हिस्से पर भी है।",
"group.move_up": "ऊपर ले जाएँ",
"group.move_down": "नीचे ले जाएँ",
"group.remove": "समूह से हटाएँ",
@@ -851,6 +881,9 @@
"toolbar.mixed_others": "फ़ाइलों के अन्य मिश्रण “{0}” खोलते हैं।",
"toolbar.mixed_others_self": "कोई मिश्रित प्रोफ़ाइल बिना चयन के नहीं बची है, इसलिए यह प्रोफ़ाइल हर अन्य मिश्रण के लिए भी इस्तेमाल होती है।",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "यह इनपुट फ़ॉर्मैट सामान्य कन्वर्ज़न के रूप में चलता है",
+ "toolbar.chip_tip.ordinary_conversion.all": "कन्वर्ट हो सकने वाले सभी इनपुट फ़ॉर्मैट सामान्य कन्वर्ज़न के रूप में चलते हैं",
+ "toolbar.chip_tip.ordinary_conversion.some": "कुछ इनपुट फ़ॉर्मैट सामान्य कन्वर्ज़न के रूप में चलते हैं",
"toolbar.chip_tip.makes_same": "→ वही फ़ॉर्मैट",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "स्वीकार्य फ़ॉर्मैट: {0}",
@@ -902,6 +935,11 @@
"presets.cad_colors.BlackAndWhite": "श्वेत-श्याम",
"presets.cad_colors.Grayscale": "ग्रेस्केल",
"presets.cad_colors_hint": "जब कोई DWG या DXF ड्रॉइंग PDF, SVG या चित्र बनती है, तब लागू होता है। श्वेत-श्याम हर रेखा और टेक्स्ट को काले रंग में बनाता है, जैसे मोनोक्रोम प्लॉट; भरे हुए क्षेत्र अपनी चमक के अनुसार धूसर हो जाते हैं, ताकि हल्का भराव काला ब्लॉक न बने। ग्रेस्केल हर रंग को धूसर में बदलता है और पीले जैसी हल्की रेखाओं को पढ़ने लायक गहरा कर देता है।",
+ "presets.gcode_view": "दृश्य",
+ "presets.gcode_view.Top": "ऊपर से",
+ "presets.gcode_view.Isometric": "आइसोमेट्रिक",
+ "presets.gcode_view.Thumbnail": "स्लाइसर का प्रीव्यू",
+ "presets.gcode_view_hint": "जब किसी स्लाइसर या CAM प्रोग्राम की G-code फ़ाइल PDF, SVG या चित्र बनती है, तब इस्तेमाल होता है। एक्सट्रूज़न और कट ऊँचाई के अनुसार रंगे जाते हैं, गहरे (नीचे) से हल्के (ऊपर) तक; खाली चालें पतली नीली रेखाएँ हैं (CNC की तेज़ चालों के लिए लाल)। स्लाइसर का प्रीव्यू वह चित्र है जो PrusaSlicer, Cura, OrcaSlicer या Bambu Studio ने फ़ाइल में सहेजा है; SVG, PDF और बिना प्रीव्यू वाली फ़ाइलें टूलपाथ ऊपर से दिखाती हैं।",
"presets.group.image": "इमेज",
"presets.quality": "क्वालिटी",
"presets.resize": "आकार बदलें",
@@ -981,6 +1019,7 @@
"presets.ringtone_start": "रिंगटोन की शुरुआत (से.)",
"presets.ringtone_length": "रिंगटोन की अवधि (से., अधिकतम 40)",
"presets.ringtone_hint": "iPhone रिंगटोन अधिकतम 40 सेकंड की AAC फ़ाइलें होती हैं। रिंगटोन आपके बताए सेकंड से शुरू होती है और आपकी तय की गई अवधि तक काटी जाती है (40 सेकंड से अधिक कभी नहीं)।",
+ "presets.ask_ringtone": "हर बार हिस्सा पूछें (क्लिप काटें खुलेगा)",
"presets.ico_sizes": "आइकन आकार",
"presets.group.pdf": "PDF",
"presets.merge": "सभी फ़ाइलों को एक PDF में मर्ज करें",
@@ -1141,6 +1180,7 @@
"presets.extract_note": "हर आर्काइव अपने अलग फ़ोल्डर में निकाला जाता है। वह कहाँ जाएगा यह आउटपुट में तय होता है।",
"presets.group.trace": "ट्रेसिंग",
"presets.group.cad": "CAD ड्रॉइंग",
+ "presets.group.gcode": "G-code (3D प्रिंटिंग, CNC)",
"presets.trace_note": "तब इस्तेमाल होता है जब किसी तस्वीर (PNG, JPG, …) को वेक्टर आकृतियों में ट्रेस किया जाता है।",
"presets.trace_mode": "मोड",
"presets.trace_mode.Color": "रंगीन",
@@ -1367,6 +1407,8 @@
"engines.package.whisper.description": "वीडियो और ऑडियो के भाषण को SRT उपशीर्षक फ़ाइलों में ट्रांसक्राइब करता है। इंटरनेट कनेक्शन के बिना ऑफ़लाइन काम करता है।",
"engines.package.calibre.name": "Calibre (दुर्लभ ई-बुक फ़ॉर्मैट)",
"engines.package.calibre.description": "दुर्लभ ई-बुक फ़ॉर्मैट (LIT, LRF…) कन्वर्ट करता है और पुराने फ़ॉर्मैट (LIT, PDB, RB…) में सहेजता है।\nEPUB, MOBI, AZW3, FB2 और कॉमिक्स इसके बिना पढ़े जाते हैं, और Kindle किताबें इसके बिना सहेजी जाती हैं।",
+ "engines.package.weasyprint.name": "WeasyPrint (HTML, Markdown और ईमेल से PDF)",
+ "engines.package.weasyprint.description": "HTML पेज, Markdown और ईमेल (EML, MSG, MHTML) के बेहतर PDF बनाता है: शीर्षक, तालिकाएँ, बोल्ड और दाएँ से बाएँ या भारतीय लिपियाँ वैसे ही जैसे ब्राउज़र प्रिंट करता है।\nइसके बिना भी वे अंतर्निहित तरीके से PDF बनते हैं।",
"engines.package.7zip.name": "7-Zip (दुर्लभ आर्काइव प्रकार)",
"engines.package.7zip.description": "उन दुर्लभ आर्काइव प्रकारों को खोलता है जिन्हें Filee के अपने रीडर छोड़ देते हैं: PPMd और अन्य असामान्य विधियों वाले 7Z, Quantum CAB, मल्टी-वॉल्यूम ARJ, NTFS, exFAT या APFS डिस्क इमेज…\nZIP, 7Z, RAR, TAR, CAB, ISO, DMG और बाकी सभी सामान्य आर्काइव इसके बिना काम करते हैं।",
"engines.package.size": "डाउनलोड {0} · डिस्क पर {1}",
@@ -1393,7 +1435,17 @@
"engines.remove": "हटाएँ",
"engines.remove_confirm": "{0} हटाएँ? आप इसे बाद में दोबारा डाउनलोड कर सकते हैं।",
"engines.state.own_copy": "आपकी कॉपी",
- "engines.state.system_copy": "इस PC पर मिला",
+ "engines.source.download": "Filee का डाउनलोड",
+ "engines.source.system": "इस कंप्यूटर पर",
+ "engines.source.version": "संस्करण {0}",
+ "engines.source.on_disk": "डिस्क पर {0}",
+ "engines.other_copy_title": "इस कंप्यूटर पर भी",
+ "engines.other_copy_detail": "{0} (Filee का डाउनलोड हटा दिया जाएगा)",
+ "engines.use_other_copy": "इसके बजाय यह इस्तेमाल करें",
+ "engines.use_other_copy_confirm": "{0} वाली कॉपी इस्तेमाल करें और Filee का डाउनलोड किया {1} हटाएँ? आप इसे बाद में दोबारा डाउनलोड कर सकते हैं।",
+ "engines.other_copy_gone": "वह कॉपी अब नहीं मिल रही या शुरू नहीं होती। Filee का डाउनलोड रखा गया है।",
+ "engines.install_filee_copy": "Filee की कॉपी इंस्टॉल करें",
+ "engines.use_another": "दूसरी कॉपी इस्तेमाल करें…",
"engines.update_recommended": "अपडेट अनुशंसित: यह कॉपी {0} है, Filee का परीक्षण {1} के साथ हुआ है।\nज़्यादातर कन्वर्ज़न फिर भी काम करते हैं।",
"engines.use_own": "मेरी कॉपी इस्तेमाल करें…",
"engines.use_own_hint": "पहले से है? उसका प्रोग्राम चुनें और Filee अपना डाउनलोड करने की जगह उसी को इस्तेमाल करेगा।",
diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json
index 6d71bfd4e..210f582bf 100644
--- a/src/Filee.App/Assets/i18n/id.json
+++ b/src/Filee.App/Assets/i18n/id.json
@@ -74,6 +74,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "{0} file",
"donut.drop_to": "Lepas → {0}",
+ "donut.ordinary_conversion.single": "File ini menjalankan konversi biasa",
+ "donut.ordinary_conversion.all": "Semua file yang bisa dikonversi: konversi biasa",
+ "donut.ordinary_conversion.some": "Sebagian file menjalankan konversi biasa",
"donut.pick_in_group": "Arahkan keluar untuk memilih",
"donut.group_empty": "Tidak ada preset tersisa di grup ini. Edit di Pengaturan → Toolbar donut",
"donut.click_to": "Klik → {0}",
@@ -127,6 +130,8 @@
"error.split_unknown_length": "Durasi video ini tidak diketahui, jadi tidak bisa dipotong menjadi bagian sama besar",
"error.split_needs_copy": "Pemotongan menyalin video apa adanya, dan format ini tidak bisa memuatnya. Pilih format video itu sendiri atau MKV",
"error.merged_image_too_large": "Gambar hasil penggabungan akan terlalu besar, gabungkan lebih sedikit gambar atau gambar berukuran lebih kecil",
+ "error.merge_paper_geometry": "Sebuah halaman memiliki ukuran, rotasi, atau satuan yang tidak valid sehingga tidak dapat disesuaikan ke ukuran kertas yang dipilih",
+ "error.merge_paper_limit": "Sebuah halaman melampaui batas pemrosesan untuk mengubah ukuran kertas",
"error.over_size_limit": "Ukuran file tidak dapat diperkecil agar muat dalam batas ukuran",
"error.not_enough_space": "Ruang disk tidak mencukupi untuk memproses gambar",
"error.no_motion_photo": "Foto ini tidak memiliki video yang dapat dibaca Filee (Motion Photo Android, atau Live Photo iPhone dengan file MOV di sampingnya)",
@@ -166,6 +171,9 @@
"error.fit_damaged": "File FIT rusak atau terpotong",
"error.fit_chained": "File ini memuat beberapa file FIT berturut-turut, yang belum didukung saat ini",
"error.fit_too_large": "File FIT terlalu besar untuk dikonversi",
+ "error.canvas_not_canvas": "Ini bukan kanvas Obsidian (JSON Canvas)",
+ "error.canvas_empty": "Kanvas tidak memiliki kartu untuk digambar",
+ "error.canvas_too_large": "Kanvas terlalu besar untuk dikonversi",
"error.fit_no_file_id": "File FIT tidak menyebutkan jenis filenya",
"error.fit_not_activity": "File FIT ini bukan sebuah aktivitas (hanya aktivitas yang dapat dikonversi)",
"error.fit_no_positions": "Aktivitas tidak memiliki catatan koordinat posisi: konversikan ke CSV, XLSX, atau JSON",
@@ -177,12 +185,16 @@
"error.fon_damaged": "File .fon rusak atau terpotong",
"error.document_too_complex": "Dokumen terlalu besar atau terlalu rumit untuk dibaca dengan aman",
"error.m4s_needs_init": "File .m4s ini adalah salah satu pecahan video streaming: tidak dapat dibaca tanpa segmen awal (init) terpisahnya",
+ "error.nov_password": "Ekspor ini dilindungi kata sandi, jadi videonya terenkripsi: ekspor ulang dari Wisenet WAVE / Nx Witness tanpa kata sandi",
+ "error.nov_no_video": "Ekspor ini tidak berisi rekaman kamera",
"error.exe_unreadable": "Ini bukan program atau pustaka Windows yang dapat dibaca Filee, atau file rusak",
"error.exe_no_icon": "File ini tidak punya ikon sendiri (Windows menampilkan ikon standar untuknya)",
"notice.pdf_not_smaller": "Ukuran tidak dapat diperkecil lagi, sehingga disalin apa adanya",
+ "notice.nov_recordings_left_out": "Ekspor ini berisi beberapa rekaman (kamera lain atau potongan yang terputus): hanya yang terbesar yang dikonversi",
"notice.under_min_size": "Disimpan, tetapi lebih kecil dari ukuran file minimum",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "Objek yang tidak dimiliki versi AutoCAD yang dipilih (multileader, mesh …) tidak disertakan",
+ "notice.gcode_no_thumbnail": "Berkas G-code tidak punya pratinjau slicer, jadi jalurnya digambar dari atas",
"notice.sheet_objects_left_out": "Objek yang belum bisa dicetak Filee (diagram, teks dalam bentuk …) tidak disertakan: pasang LibreOffice di Pengaturan → Engine untuk menyertakannya",
"notice.cmyk_needs_ghostscript": "File ini CMYK, tetapi disimpan sebagai RGB: pasang Ghostscript di Pengaturan → Engine agar tetap CMYK",
"notice.pdfa_needs_ghostscript": "PDF ini bukan PDF/A: pasang Ghostscript di Pengaturan → Engine untuk membuat PDF/A",
@@ -275,6 +287,8 @@
"engine.font.description": "Font: TTF, OTF, WOFF, WOFF2, dan EOT saling konversi; font bitmap Windows (FON) ke format tersebut.",
"engine.cad.name": "CAD (bawaan)",
"engine.cad.description": "Gambar AutoCAD: DWG ↔ DXF, dan keduanya ke PDF, SVG, serta PNG.",
+ "engine.gcode.name": "G-code (bawaan)",
+ "engine.gcode.description": "Jalur printer 3D dan mesin CNC (G-code) ke PDF, SVG, dan gambar, atau pratinjau dari slicer.",
"engine.drawing.name": "Diagram (bawaan)",
"engine.drawing.description": "Diagram Visio (VSDX) dan gambar OpenDocument (ODG, FODG) ke PDF, SVG, dan PNG, tanpa Visio atau LibreOffice.\nFile Visio biner lama (.vsd) masih memerlukan LibreOffice.",
"engine.model3d.name": "Model 3D (bawaan)",
@@ -297,6 +311,7 @@
"engine.tesseract.description": "Mengenali teks dari hasil pindai dan gambar (OCR): PDF yang dapat dicari, DOCX, TXT.",
"engine.vision.description": "Mengenali teks dari hasil pindai dan gambar dengan pengenal teks bawaan macOS (OCR): PDF yang dapat dicari, DOCX, TXT. Tanpa perlu mengunduh apa pun.",
"engine.calibre.description": "Format e-book langka (LIT, LRF…) dan output ke format lama (LIT, PDB, RB…).\nBila perender Filee mati dan Calibre diposisikan di atas pembuat HWPX, Calibre juga membuat PDF dari e-book (jauh lebih cepat untuk buku tebal).",
+ "engine.weasyprint.description": "Membuat PDF dari halaman HTML, Markdown, dan email seperti yang dicetak browser: judul, tabel, huruf tebal, serta teks Arab atau Hindi tampil benar.\nHanya memuat gambar di samping dokumen, tidak ada dari internet.",
"engine.libreoffice.description": "Membaca format lama dan jarang digunakan: DOC Word 6.0/95, PPT PowerPoint 95, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio biner (VSD)…\nJuga mengonversi file ODP dan ODG yang berisi bagan, objek tersemat, atau 3D, serta menyimpan sebagai DOC, XLS, PPT, ODT, templat OpenDocument, dan EMF.",
"engine.translator.description": "Menerjemahkan dokumen dan e-book secara lokal menggunakan model Ollama offline.\nJuga menerjemahkan subtitle SRT dan WebVTT: hanya teks cue yang dikirim ke model, nomor cue dan stempel waktu tetap dipertahankan.",
"engine.exiftool.description": "Membaca semua bidang metadata gambar, PDF, video, dan audio, serta mengeditnya di sebagian besar jenis file tanpa menyentuh isi file.\nDigunakan oleh alat Metadata; tanpanya, bidang hanya dapat dibaca.",
@@ -395,6 +410,7 @@
"clip.save": "Simpan klip",
"clip.save_next": "Simpan & berikutnya",
"clip.seconds": "{0} d",
+ "clip.ringtone_limit": "Nada dering iPhone maksimal {0} detik",
"clip.length_hint": "Klip sepanjang sekian detik ini dari awal",
"clip.fast": "Cepat, tanpa pengodean ulang",
"clip.fast_hint": "Menyalin video apa adanya: selesai dalam sekejap tanpa penurunan kualitas, tetapi klip dimulai dari keyframe sebelum titik awal (bisa lebih awal beberapa detik). Matikan untuk memotong tepat di bingkai yang dipilih (mengodekan ulang, butuh waktu lebih lama).",
@@ -498,6 +514,10 @@
"merge.same_width": "Samakan lebar",
"merge.same_height": "Samakan tinggi",
"merge.same_size_tip": "Menyesuaikan skala gambar sehingga tepinya sejajar. Nonaktif: masing-masing mempertahankan ukuran aslinya di posisi tengah.",
+ "presets.normalize_merge_paper": "Gunakan satu ukuran kertas untuk semua halaman yang digabung",
+ "presets.merge_paper": "Kertas untuk PDF gabungan",
+ "presets.merge_paper_hint": "Menyesuaikan setiap halaman ke ukuran kertas yang dipilih dan menempatkannya di tengah tanpa memotong. Pengaturan halaman gambar diterapkan sebelum penggabungan.",
+ "presets.merge_paper_auto_hint": "Orientasi otomatis dipilih sekali berdasarkan halaman pertama. Halaman yang lebih kecil diperbesar agar pas.",
"merge.gap": "Jarak celah (px)",
"merge.background": "Latar belakang",
"merge.transparent": "Transparan",
@@ -793,9 +813,15 @@
"toolbar.hint_remove": "Seret irisan kembali ke sini untuk menghapusnya",
"toolbar.hint_reorder": "Seret irisan satu ke yang lain untuk mengubah urutan",
"toolbar.hint_edit": "Klik kanan irisan atau gunakan ✎ untuk mengedit",
+ "toolbar.group_hint": "Seret preset dari daftar, seret irisan untuk mengurutkannya, dan seret satu ke daftar untuk mengeluarkannya.",
+ "toolbar.group_back": "Kembali ke {0}",
"toolbar.max_reached": "Satu menu donut menampung hingga {0} preset",
- "toolbar.appearance": "Tampilan",
"toolbar.size": "Ukuran",
+ "toolbar.edit_size": "Ubah Ukuran Menu Donut",
+ "toolbar.look_done": "Selesai",
+ "toolbar.look_hint": "Menu donut Anda tampil di layar dalam ukuran sebenarnya. Seret bagian tengahnya untuk memindahkannya, dan klik grup agar tetap terbuka.",
+ "toolbar.look_all": "Semua menu donut",
+ "toolbar.look_this": "Menu donut ini: {0}",
"toolbar.hole": "Lubang tengah",
"toolbar.opacity": "Transparansi",
"toolbar.captions": "Tampilkan format keluaran",
@@ -803,6 +829,9 @@
"toolbar.label_scale": "Skala label",
"toolbar.ring_size": "Ukuran cincin grup",
"toolbar.ring_spread": "Sebaran cincin grup",
+ "toolbar.group_shown": "Grup yang ditampilkan",
+ "toolbar.example_group": "Contoh grup",
+ "toolbar.example_none": "Menu donut ini tidak punya grup",
"labels.auto": "Otomatis",
"labels.icon_text": "Ikon & teks",
"labels.text_only": "Hanya teks",
@@ -825,6 +854,7 @@
"group.add": "Tambah",
"group.add_placeholder": "Pilih preset",
"group.full": "Satu grup dapat menampung hingga {0} preset.",
+ "group.copy_front": "\"Salin ke folder\" tidak bisa masuk ke grup ini: grup ini juga ada di sisi depan.",
"group.move_up": "Pindahkan ke atas",
"group.move_down": "Pindahkan ke bawah",
"group.remove": "Hapus dari grup",
@@ -851,6 +881,9 @@
"toolbar.mixed_others": "Kombinasi file lainnya membuka “{0}”.",
"toolbar.mixed_others_self": "Tidak ada profil campuran yang dibiarkan tanpa centang, sehingga profil ini juga digunakan untuk setiap kombinasi lainnya.",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "Format input ini menjalankan konversi biasa",
+ "toolbar.chip_tip.ordinary_conversion.all": "Semua format input yang dapat dikonversi menjalankan konversi biasa",
+ "toolbar.chip_tip.ordinary_conversion.some": "Sebagian format input menjalankan konversi biasa",
"toolbar.chip_tip.makes_same": "→ Format sama",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "Format yang didukung: {0}",
@@ -902,6 +935,11 @@
"presets.cad_colors.BlackAndWhite": "Hitam putih",
"presets.cad_colors.Grayscale": "Skala abu-abu",
"presets.cad_colors_hint": "Digunakan saat gambar DWG atau DXF diubah menjadi PDF, SVG, atau gambar. Hitam putih menggambar setiap garis dan teks dengan warna hitam, seperti plot monokrom; area terisi berubah menjadi abu-abu sesuai kecerahannya, sehingga isian terang tidak menjadi blok hitam pekat. Grayscale mengubah setiap warna menjadi abu-abu dan menggelapkan garis terang, seperti kuning, agar mudah dibaca.",
+ "presets.gcode_view": "Tampilan",
+ "presets.gcode_view.Top": "Dari atas",
+ "presets.gcode_view.Isometric": "Isometrik",
+ "presets.gcode_view.Thumbnail": "Pratinjau slicer",
+ "presets.gcode_view_hint": "Dipakai saat berkas G-code dari slicer atau program CAM menjadi PDF, SVG, atau gambar. Ekstrusi dan potongan diwarnai menurut ketinggian, dari gelap (bawah) ke terang (atas); gerakan kosong berupa garis biru tipis (merah untuk gerakan cepat CNC). Pratinjau slicer adalah gambar yang disimpan PrusaSlicer, Cura, OrcaSlicer, atau Bambu Studio di dalam berkas; SVG, PDF, dan berkas tanpa pratinjau menampilkan jalur dari atas.",
"presets.group.image": "Gambar",
"presets.quality": "Kualitas",
"presets.resize": "Ubah ukuran",
@@ -981,6 +1019,7 @@
"presets.ringtone_start": "Awal nada dering (dtk)",
"presets.ringtone_length": "Panjang nada dering (dtk, maks. 40)",
"presets.ringtone_hint": "Nada dering iPhone adalah file AAC berdurasi paling lama 40 detik. Nada dering dimulai dari detik yang Anda tentukan dan dipotong sesuai panjang yang Anda atur (tidak pernah lebih dari 40 dtk).",
+ "presets.ask_ringtone": "Tanyakan bagiannya setiap kali (membuka Potong klip)",
"presets.ico_sizes": "Ukuran ikon",
"presets.group.pdf": "PDF",
"presets.merge": "Gabungkan semua file menjadi satu PDF",
@@ -1141,6 +1180,7 @@
"presets.extract_note": "Setiap arsip diekstrak ke foldernya masing-masing. Lokasinya diatur di bawah bagian Penyimpanan.",
"presets.group.trace": "Vektorisasi",
"presets.group.cad": "Gambar CAD",
+ "presets.group.gcode": "G-code (cetak 3D, CNC)",
"presets.trace_note": "Digunakan saat sebuah gambar (PNG, JPG, …) divektorisasi menjadi bentuk vektor.",
"presets.trace_mode": "Mode",
"presets.trace_mode.Color": "Warna",
@@ -1367,6 +1407,8 @@
"engines.package.whisper.description": "Mentranskripsikan ucapan dalam video dan audio menjadi file subtitle SRT. Berjalan offline tanpa koneksi internet.",
"engines.package.calibre.name": "Calibre (format e-book langka)",
"engines.package.calibre.description": "Mengonversi format e-book langka (LIT, LRF…) dan menyimpan ke format lama (LIT, PDB, RB…).\nEPUB, MOBI, AZW3, FB2, dan komik dibaca, serta buku Kindle disimpan, tanpanya.",
+ "engines.package.weasyprint.name": "WeasyPrint (HTML, Markdown, dan email ke PDF)",
+ "engines.package.weasyprint.description": "Membuat PDF yang lebih baik dari halaman HTML, Markdown, dan email (EML, MSG, MHTML): judul, tabel, huruf tebal, serta aksara kanan-ke-kiri atau India seperti cetakan browser.\nTanpanya, file tetap menjadi PDF lewat rute bawaan.",
"engines.package.7zip.name": "7-Zip (varian arsip langka)",
"engines.package.7zip.description": "Mengekstrak varian arsip langka yang tidak dibaca oleh pembaca bawaan Filee: 7Z dengan PPMd dan metode tak umum lainnya, CAB Quantum, ARJ multi-volume, image disk NTFS, exFAT, atau APFS…\nZIP, 7Z, RAR, TAR, CAB, ISO, DMG, dan semua arsip umum lainnya berfungsi tanpanya.",
"engines.package.size": "Unduhan {0} · {1} di disk",
@@ -1393,7 +1435,17 @@
"engines.remove": "Hapus",
"engines.remove_confirm": "Hapus {0}? Anda dapat mengunduhnya kembali nanti.",
"engines.state.own_copy": "Salinan Anda",
- "engines.state.system_copy": "Ditemukan di PC ini",
+ "engines.source.download": "Unduhan Filee",
+ "engines.source.system": "Di komputer ini",
+ "engines.source.version": "versi {0}",
+ "engines.source.on_disk": "{0} di disk",
+ "engines.other_copy_title": "Juga ada di komputer ini",
+ "engines.other_copy_detail": "{0} (unduhan Filee akan dihapus)",
+ "engines.use_other_copy": "Gunakan yang ini saja",
+ "engines.use_other_copy_confirm": "Gunakan salinan di {0} dan hapus unduhan {1} dari Filee? Anda dapat mengunduhnya lagi nanti.",
+ "engines.other_copy_gone": "Salinan itu tidak ditemukan lagi atau tidak bisa dijalankan. Unduhan Filee tetap disimpan.",
+ "engines.install_filee_copy": "Pasang salinan Filee",
+ "engines.use_another": "Gunakan salinan lain…",
"engines.update_recommended": "Pembaruan disarankan: salinan ini versi {0}, Filee diuji dengan versi {1}.\nSebagian besar konversi tetap dapat berjalan.",
"engines.use_own": "Gunakan salinan saya…",
"engines.use_own_hint": "Sudah memilikinya? Tentukan programnya dan Filee akan menggunakannya alih-alih mengunduh versinya sendiri.",
diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json
index 0fb52369d..0f2f2b49d 100644
--- a/src/Filee.App/Assets/i18n/it.json
+++ b/src/Filee.App/Assets/i18n/it.json
@@ -73,6 +73,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "{0} file",
"donut.drop_to": "Rilascia → {0}",
+"donut.ordinary_conversion.single": "Questo file usa una conversione normale",
+"donut.ordinary_conversion.all": "Tutti i file convertibili usano una conversione normale",
+"donut.ordinary_conversion.some": "Alcuni file usano una conversione normale",
"donut.pick_in_group": "Spostati verso l'esterno per scegliere",
"donut.group_empty": "Nessun preset rimasto in questo gruppo. Modificalo in Impostazioni → Barra donut",
"donut.click_to": "Clic → {0}",
@@ -126,6 +129,8 @@
"error.split_unknown_length": "La durata di questo video è sconosciuta, quindi non si può dividere in parti uguali",
"error.split_needs_copy": "La divisione copia il video così com'è e questo formato non può contenerlo. Scegli il formato del video o MKV",
"error.merged_image_too_large": "L'immagine unita sarebbe troppo grande; unisci meno immagini o immagini più piccole",
+"error.merge_paper_geometry": "Una pagina ha dimensioni, rotazione o unità non valide e non può essere adattata al formato carta scelto",
+"error.merge_paper_limit": "Una pagina supera il limite di elaborazione per la modifica del formato carta",
"error.over_size_limit": "Non si può ridurre abbastanza da rispettare il limite di dimensione",
"error.not_enough_space": "Spazio libero su disco insufficiente per le immagini",
"error.no_motion_photo": "Questa foto non contiene un video che Filee possa leggere (una Motion Photo di Android, o una Live Photo di iPhone con il suo MOV accanto)",
@@ -165,6 +170,9 @@
"error.fit_damaged": "Il file FIT è danneggiato o troncato",
"error.fit_chained": "Il file contiene più file FIT uno dopo l'altro, cosa non ancora supportata",
"error.fit_too_large": "Il file FIT è troppo grande per essere convertito",
+"error.canvas_not_canvas": "Questo non è un canvas di Obsidian (JSON Canvas)",
+"error.canvas_empty": "Il canvas non ha schede da disegnare",
+"error.canvas_too_large": "Il canvas è troppo grande per essere convertito",
"error.fit_no_file_id": "Il file FIT non indica che tipo di file sia",
"error.fit_not_activity": "Questo file FIT non è un'attività (si convertono solo le attività)",
"error.fit_no_positions": "L'attività non ha posizioni registrate: convertila in CSV, XLSX o JSON",
@@ -176,12 +184,16 @@
"error.fon_damaged": "Il file .fon è danneggiato o troncato",
"error.document_too_complex": "Il documento è troppo grande o troppo complesso per essere letto in sicurezza",
"error.m4s_needs_init": "Questo .m4s è un frammento di un video in streaming: non può essere letto senza il suo segmento iniziale (init) separato",
+"error.nov_password": "Questa esportazione è protetta da password, quindi il video è cifrato: esportala di nuovo da Wisenet WAVE / Nx Witness senza password",
+"error.nov_no_video": "Questa esportazione non contiene registrazioni di telecamere",
"error.exe_unreadable": "Non è un programma o una libreria di Windows che Filee può leggere, oppure il file è danneggiato",
"error.exe_no_icon": "Questo file non ha un'icona propria (Windows gli mostra un'icona standard)",
"notice.pdf_not_smaller": "Impossibile ridurlo, quindi è stato copiato così com'è",
+"notice.nov_recordings_left_out": "Questa esportazione contiene più registrazioni (altre telecamere o parti separate da interruzioni): è stata convertita solo la più grande",
"notice.under_min_size": "Salvato, ma più piccolo della dimensione minima del file",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "Gli oggetti che la versione AutoCAD scelta non supporta (multilinee guida, mesh …) sono stati omessi",
+"notice.gcode_no_thumbnail": "Il file G-code non ha un'anteprima dello slicer, quindi il percorso è stato disegnato visto dall'alto",
"notice.sheet_objects_left_out": "Gli oggetti che Filee non sa ancora stampare (grafici, testo nelle forme …) sono stati omessi: installa LibreOffice in Impostazioni → Motori per mantenerli",
"notice.cmyk_needs_ghostscript": "Questo file è CMYK ma è stato salvato in RGB: installa Ghostscript in Impostazioni → Motori per mantenere il CMYK",
"notice.pdfa_needs_ghostscript": "Questo PDF non è PDF/A: installa Ghostscript in Impostazioni → Motori per creare PDF/A",
@@ -274,6 +286,8 @@
"engine.font.description": "Font: TTF, OTF, WOFF, WOFF2 ed EOT convertibili tra loro; font bitmap di Windows (FON) in questi formati.",
"engine.cad.name": "CAD (integrato)",
"engine.cad.description": "Disegni AutoCAD: DWG ↔ DXF, ed entrambi in PDF, SVG e PNG.",
+"engine.gcode.name": "G-code (integrato)",
+"engine.gcode.description": "Percorsi di stampanti 3D e macchine CNC (G-code) in PDF, SVG e immagini, o l'anteprima dello slicer.",
"engine.drawing.name": "Disegni (integrato)",
"engine.drawing.description": "Disegni Visio (VSDX) e OpenDocument (ODG, FODG) in PDF, SVG e PNG, senza Visio né LibreOffice.\nI vecchi file binari di Visio (.vsd) richiedono ancora LibreOffice.",
"engine.model3d.name": "Modelli 3D (integrato)",
@@ -296,6 +310,7 @@
"engine.tesseract.description": "Riconosce il testo di scansioni e immagini (OCR): PDF ricercabile, DOCX, TXT.",
"engine.vision.description": "Riconosce il testo di scansioni e immagini con il riconoscimento testo di macOS (OCR): PDF ricercabile, DOCX, TXT. Non c'è nulla da scaricare.",
"engine.calibre.description": "Formati di e-book rari (LIT, LRF…) e output in formati vecchi (LIT, PDB, RB…).\nCon il renderer di Filee disattivato e Calibre sopra il writer HWPX, crea anche i PDF degli e-book (molto più veloce per i libri lunghi).",
+ "engine.weasyprint.description": "Crea i PDF di pagine HTML, Markdown ed e-mail come li stampa un browser: titoli, tabelle, grassetto e testo arabo o hindi vengono corretti.\nCarica solo le immagini accanto al documento, niente da Internet.",
"engine.libreoffice.description": "Legge formati vecchi e meno comuni: DOC di Word 6.0/95, PPT di PowerPoint 95, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio binario (VSD)…\nConverte anche file ODP e ODG con grafici, oggetti incorporati o 3D, e salva come DOC, XLS, PPT, ODT, modelli OpenDocument ed EMF.",
"engine.translator.description": "Traduce documenti ed e-book localmente utilizzando modelli Ollama offline.\nTraduce anche i sottotitoli SRT e WebVTT: al modello viene inviato solo il testo delle battute, numerazione e tempi restano invariati.",
"engine.exiftool.description": "Legge tutti i campi dei metadati di immagini, PDF, video e audio, e li modifica nella maggior parte dei tipi di file senza toccarne il contenuto.\nUsato dallo strumento Metadati; senza, i campi sono di sola lettura.",
@@ -394,6 +409,7 @@
"clip.save": "Salva clip",
"clip.save_next": "Salva e avanti",
"clip.seconds": "{0} s",
+ "clip.ringtone_limit": "Una suoneria dell’iPhone dura al massimo {0} secondi",
"clip.length_hint": "Una clip di questi secondi a partire dall'inizio",
"clip.fast": "Veloce, senza ricodifica",
"clip.fast_hint": "Copia il video così com'è: fatto in un attimo e senza perdite, ma la clip inizia dal keyframe precedente all'inizio (fino a qualche secondo prima). Disattiva per tagliare esattamente sui fotogrammi scelti (ricodifica, richiede più tempo).",
@@ -497,6 +513,10 @@
"merge.same_width": "Stessa larghezza",
"merge.same_height": "Stessa altezza",
"merge.same_size_tip": "Scalerà le immagini in modo che i bordi siano allineati. Disattivato: ciascuna mantiene le proprie dimensioni, centrata.",
+"presets.normalize_merge_paper": "Usa un unico formato carta per tutte le pagine unite",
+"presets.merge_paper": "Formato carta del PDF unito",
+"presets.merge_paper_hint": "Adatta ogni pagina al formato carta scelto e la centra senza ritagliarla. Le impostazioni delle pagine immagine vengono applicate prima dell'unione.",
+"presets.merge_paper_auto_hint": "L'orientamento automatico viene scelto una sola volta in base alla prima pagina. Le pagine più piccole vengono ingrandite per adattarsi.",
"merge.gap": "Spazio (px)",
"merge.background": "Sfondo",
"merge.transparent": "Trasparente",
@@ -792,9 +812,15 @@
"toolbar.hint_remove": "Trascina una fetta qui per rimuoverla",
"toolbar.hint_reorder": "Trascina le fette l'una sull'altra per riordinarle",
"toolbar.hint_edit": "Clic destro su una fetta o usa ✎ per modificare",
+"toolbar.group_hint": "Trascina i preset dall'elenco, trascina le fette per riordinarle e trascinane una sull'elenco per toglierla.",
+"toolbar.group_back": "Torna a {0}",
"toolbar.max_reached": "Un donut contiene fino a {0} preset",
-"toolbar.appearance": "Aspetto",
"toolbar.size": "Dimensione",
+"toolbar.edit_size": "Modifica dimensione del donut",
+"toolbar.look_done": "Fatto",
+"toolbar.look_hint": "Il tuo donut è sullo schermo a grandezza reale. Trascinane il centro per spostarlo e fai clic su un gruppo per tenerlo aperto.",
+"toolbar.look_all": "Tutti i donut",
+"toolbar.look_this": "Questo donut: {0}",
"toolbar.hole": "Foro",
"toolbar.opacity": "Opacità",
"toolbar.captions": "Mostra formato di output",
@@ -802,6 +828,9 @@
"toolbar.label_scale": "Dimensione etichette",
"toolbar.ring_size": "Dimensione anello del gruppo",
"toolbar.ring_spread": "Ampiezza anello del gruppo",
+"toolbar.group_shown": "Gruppo mostrato",
+"toolbar.example_group": "Gruppo di esempio",
+"toolbar.example_none": "Questo donut non ha gruppi",
"labels.auto": "Auto",
"labels.icon_text": "Icona e testo",
"labels.text_only": "Solo testo",
@@ -824,6 +853,7 @@
"group.add": "Aggiungi",
"group.add_placeholder": "Scegli un preset",
"group.full": "Un gruppo contiene fino a {0} preset.",
+"group.copy_front": "«Copia nella cartella» non può andare in questo gruppo: è anche su un fronte.",
"group.move_up": "Sposta in su",
"group.move_down": "Sposta in giù",
"group.remove": "Rimuovi dal gruppo",
@@ -850,6 +880,9 @@
"toolbar.mixed_others": "Le altre combinazioni di file aprono “{0}”.",
"toolbar.mixed_others_self": "Non è rimasto nessun profilo misto senza selezioni, quindi questo viene usato anche per ogni altra combinazione.",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+"toolbar.chip_tip.ordinary_conversion.single": "Questo formato di ingresso usa una conversione normale",
+"toolbar.chip_tip.ordinary_conversion.all": "Tutti i formati di ingresso convertibili usano una conversione normale",
+"toolbar.chip_tip.ordinary_conversion.some": "Alcuni formati di ingresso usano una conversione normale",
"toolbar.chip_tip.makes_same": "→ Stesso formato",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "Formati accettati: {0}",
@@ -901,6 +934,11 @@
"presets.cad_colors.BlackAndWhite": "Bianco e nero",
"presets.cad_colors.Grayscale": "Scala di grigi",
"presets.cad_colors_hint": "Usato quando un disegno DWG o DXF diventa un PDF, SVG o un'immagine. Bianco e nero disegna ogni linea e testo in nero, come un tracciato monocromatico; le aree riempite assumono il grigio della loro luminosità, in modo che un riempimento chiaro non diventi un blocco nero. Scala di grigi trasforma ogni colore in un grigio e scurisce le linee chiare, come il giallo, quanto basta per renderle leggibili.",
+"presets.gcode_view": "Vista",
+"presets.gcode_view.Top": "Dall'alto",
+"presets.gcode_view.Isometric": "Isometrica",
+"presets.gcode_view.Thumbnail": "Anteprima dello slicer",
+"presets.gcode_view_hint": "Usato quando un file G-code di uno slicer o di un programma CAM diventa un PDF, un SVG o un'immagine. Estrusione e tagli sono colorati in base all'altezza, da scuro (in basso) a chiaro (in alto); gli spostamenti a vuoto sono sottili linee blu (rosse per i rapidi di una CNC). L'anteprima dello slicer è l'immagine che PrusaSlicer, Cura, OrcaSlicer o Bambu Studio ha salvato nel file; SVG, PDF e i file senza anteprima mostrano il percorso dall'alto.",
"presets.group.image": "Immagine",
"presets.quality": "Qualità",
"presets.resize": "Ridimensiona",
@@ -980,6 +1018,7 @@
"presets.ringtone_start": "Inizio della suoneria (s)",
"presets.ringtone_length": "Durata della suoneria (s, max 40)",
"presets.ringtone_hint": "Le suonerie dell'iPhone sono file AAC di al massimo 40 secondi. La suoneria parte dal secondo indicato ed è tagliata alla durata impostata (mai oltre 40 s).",
+ "presets.ask_ringtone": "Chiedi la parte ogni volta (apre Taglia una clip)",
"presets.ico_sizes": "Dimensioni dell'icona",
"presets.group.pdf": "PDF",
"presets.merge": "Unisci tutti i file in un unico PDF",
@@ -1140,6 +1179,7 @@
"presets.extract_note": "Ogni archivio viene estratto in una cartella a sé. La destinazione si imposta in Output.",
"presets.group.trace": "Vettorializzazione",
"presets.group.cad": "Disegno CAD",
+"presets.group.gcode": "G-code (stampa 3D, CNC)",
"presets.trace_note": "Usato quando un'immagine (PNG, JPG, …) viene vettorializzata in forme vettoriali.",
"presets.trace_mode": "Modalità",
"presets.trace_mode.Color": "Colore",
@@ -1366,6 +1406,8 @@
"engines.package.whisper.description": "Trascrive la voce in video e audio in file di sottotitoli SRT. Funziona offline senza connessione Internet.",
"engines.package.calibre.name": "Calibre (formati di e-book rari)",
"engines.package.calibre.description": "Converte formati di e-book rari (LIT, LRF…) e salva in formati vecchi (LIT, PDB, RB…).\nEPUB, MOBI, AZW3, FB2 e fumetti si leggono, e i libri per Kindle si salvano, senza.",
+ "engines.package.weasyprint.name": "WeasyPrint (HTML, Markdown ed e-mail in PDF)",
+ "engines.package.weasyprint.description": "Crea PDF migliori di pagine HTML, Markdown ed e-mail (EML, MSG, MHTML): titoli, tabelle, grassetto e scritture da destra a sinistra o indiane come le stampa un browser.\nAnche senza di esso diventano PDF, con il percorso integrato.",
"engines.package.7zip.name": "7-Zip (varianti rare di archivi)",
"engines.package.7zip.description": "Estrae le varianti rare di archivi che i lettori di Filee non gestiscono: 7Z con PPMd e altri metodi insoliti, CAB Quantum, ARJ multivolume, immagini disco NTFS, exFAT o APFS…\nZIP, 7Z, RAR, TAR, CAB, ISO, DMG e tutti gli altri archivi comuni funzionano senza.",
"engines.package.size": "Download {0} · {1} su disco",
@@ -1392,7 +1434,17 @@
"engines.remove": "Rimuovi",
"engines.remove_confirm": "Rimuovere {0}? Potrai scaricarlo di nuovo più tardi.",
"engines.state.own_copy": "La tua copia",
-"engines.state.system_copy": "Trovato su questo PC",
+"engines.source.download": "Download di Filee",
+"engines.source.system": "Su questo computer",
+"engines.source.version": "versione {0}",
+"engines.source.on_disk": "{0} su disco",
+"engines.other_copy_title": "Anche su questo computer",
+"engines.other_copy_detail": "{0} (il download di Filee verrebbe rimosso)",
+"engines.use_other_copy": "Usa questa invece",
+"engines.use_other_copy_confirm": "Usare la copia in {0} e rimuovere il download di {1} di Filee? Puoi scaricarlo di nuovo più tardi.",
+"engines.other_copy_gone": "Quella copia non si trova più o non si avvia. Il download di Filee è stato mantenuto.",
+"engines.install_filee_copy": "Installa la copia di Filee",
+"engines.use_another": "Usa un'altra copia…",
"engines.update_recommended": "Aggiornamento consigliato: questa copia è {0}, Filee è testato con {1}.\nLa maggior parte delle conversioni funziona comunque.",
"engines.use_own": "Usa la mia copia…",
"engines.use_own_hint": "Ce l'hai già? Scegli il suo programma e Filee userà quello invece di scaricarne uno proprio.",
diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json
index 52aaf85a9..a00eb06cb 100644
--- a/src/Filee.App/Assets/i18n/ja.json
+++ b/src/Filee.App/Assets/i18n/ja.json
@@ -74,6 +74,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "{0}ファイル",
"donut.drop_to": "ドロップ → {0}",
+ "donut.ordinary_conversion.single": "このファイルは通常の変換として実行されます",
+ "donut.ordinary_conversion.all": "変換可能な全ファイル:通常の変換",
+ "donut.ordinary_conversion.some": "一部のファイルは通常の変換として実行されます",
"donut.pick_in_group": "外側に動かして選択",
"donut.group_empty": "このグループにプリセットがありません。「設定」→「ドーナツメニュー」で編集できます",
"donut.click_to": "クリック → {0}",
@@ -127,6 +130,8 @@
"error.split_unknown_length": "この動画の長さがわからないため、等分できません",
"error.split_needs_copy": "分割は動画をそのままコピーしますが、この形式には入れられません。動画と同じ形式か MKV を選んでください",
"error.merged_image_too_large": "結合した画像が大きすぎます。枚数を減らすか、小さい画像で結合してください",
+ "error.merge_paper_geometry": "ページのサイズ、回転、または単位が無効なため、選択した用紙サイズに合わせられません",
+ "error.merge_paper_limit": "用紙サイズ変更の処理上限を超えるページがあります",
"error.over_size_limit": "サイズ上限に収まるまで小さくできません",
"error.not_enough_space": "画像を保存するための空き容量が不足しています",
"error.no_motion_photo": "この写真には、Fileeが読み取れる動画がありません(Androidのモーションフォト、または隣にMOVがあるiPhoneのLive Photo)",
@@ -166,6 +171,9 @@
"error.fit_damaged": "FITファイルが破損しているか、途中で切れています",
"error.fit_chained": "複数のFITファイルを連結した形式にはまだ対応していません",
"error.fit_too_large": "FITファイルが大きすぎて変換できません",
+ "error.canvas_not_canvas": "Obsidian のキャンバス (JSON Canvas) ではありません",
+ "error.canvas_empty": "キャンバスに描画するカードがありません",
+ "error.canvas_too_large": "キャンバスが大きすぎて変換できません",
"error.fit_no_file_id": "FITファイルにファイルの種類が記録されていません",
"error.fit_not_activity": "このFITファイルはアクティビティではありません(アクティビティのみ変換できます)",
"error.fit_no_positions": "アクティビティに位置情報が記録されていません。CSV、XLSX、JSONに変換してください",
@@ -177,12 +185,16 @@
"error.fon_damaged": ".fonファイルが破損しているか、途中で切れています",
"error.document_too_complex": "文書が大きすぎるか複雑すぎるため、安全に読み込めません",
"error.m4s_needs_init": "この .m4s はストリーミング動画の一部で、別にある開始(init)セグメントがないと読み込めません",
+ "error.nov_password": "このエクスポートはパスワードで保護されているため、動画が暗号化されています。Wisenet WAVE / Nx Witness からパスワードなしで書き出し直してください",
+ "error.nov_no_video": "このエクスポートにはカメラの録画が含まれていません",
"error.exe_unreadable": "Filee で読み込める Windows のプログラムやライブラリではないか、ファイルが壊れています",
"error.exe_no_icon": "このファイルには独自のアイコンがありません(Windows では標準のアイコンが表示されます)",
"notice.pdf_not_smaller": "サイズを小さくできなかったため、元のままコピーしました",
+ "notice.nov_recordings_left_out": "このエクスポートには複数の録画(別のカメラや途切れた部分)が含まれています。最も大きいものだけを変換しました",
"notice.under_min_size": "保存しましたが、最小ファイルサイズより小さいです",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "選んだ AutoCAD のバージョンにないオブジェクト(マルチ引出線、メッシュ …)を省きました",
+ "notice.gcode_no_thumbnail": "G-code ファイルにスライサーのプレビューがないため、ツールパスを上から描きました",
"notice.sheet_objects_left_out": "まだ印刷できないオブジェクト(グラフ、図形内の文字 …)を省きました。残すには「設定」→「変換エンジン」で LibreOffice をインストールしてください",
"notice.cmyk_needs_ghostscript": "CMYK のファイルですが RGB で保存しました。CMYK のまま保存するには「設定」→「変換エンジン」で Ghostscript をインストールしてください",
"notice.pdfa_needs_ghostscript": "この PDF は PDF/A ではありません。PDF/A にするには「設定」→「変換エンジン」で Ghostscript をインストールしてください",
@@ -275,6 +287,8 @@
"engine.font.description": "TTF、OTF、WOFF、WOFF2、EOTを相互変換し、Windowsビットマップフォント(FON)もこれらの形式に変換します。",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "AutoCAD図面に対応します。DWGとDXFの相互変換、および両形式からPDF、SVG、PNGへの変換ができます。",
+ "engine.gcode.name": "G-code (built-in)",
+ "engine.gcode.description": "3D プリンターと CNC マシンのツールパス (G-code) を PDF、SVG、画像に。スライサーのプレビューも使えます。",
"engine.drawing.name": "Drawings (built-in)",
"engine.drawing.description": "Visio図面(VSDX)とOpenDocument図面(ODG、FODG)を、VisioやLibreOfficeなしでPDF、SVG、PNGに変換します。\n古いバイナリ形式のVisioファイル(.vsd)には引き続きLibreOfficeが必要です。",
"engine.model3d.name": "3D models (built-in)",
@@ -297,6 +311,7 @@
"engine.tesseract.description": "スキャンや画像の文字を認識します(OCR):検索可能な PDF、DOCX、TXT。",
"engine.vision.description": "macOS 標準の文字認識で、スキャンや画像の文字を認識します(OCR):検索可能な PDF、DOCX、TXT。ダウンロードは不要です。",
"engine.calibre.description": "一般的でない電子書籍形式(LIT、LRFなど)を変換し、古い形式(LIT、PDB、RBなど)で保存できます。\nFileeレンダラーをオフにして「HWPX writer」より上に置くと、電子書籍のPDF作成もCalibreが担当します(長い本では大幅に速くなります)。",
+ "engine.weasyprint.description": "HTML ページ、Markdown、メールをブラウザーで印刷したとおりに PDF にします。見出し、表、太字、アラビア語やヒンディー語の文字も正しく出ます。\n読み込むのは文書の横にある画像だけで、インターネットからは何も読み込みません。",
"engine.libreoffice.description": "古い形式や一般的でない形式(Word 6.0/95のDOC、PowerPoint 95のPPT、Pages、Keynote、Numbers、WordPerfect、ClarisWorks、Publisher、バイナリ形式のVisio(VSD)など)を読み込みます。\nグラフ、埋め込みオブジェクト、3Dを含むODPとODGの変換や、DOC、XLS、PPT、ODT、OpenDocumentテンプレート、EMF形式での保存にも対応します。",
"engine.translator.description": "オフラインのOllamaモデルを使用して、ローカルでドキュメントや電子書籍を翻訳します。\nSRT・WebVTT字幕も翻訳します。モデルに送るのは字幕のテキストだけで、字幕番号とタイムスタンプはそのまま保持されます。",
"engine.exiftool.description": "画像、PDF、動画、音声のすべてのメタデータ項目を読み取り、ほとんどの種類のファイルでは内容に触れずに編集します。\nメタデータツールが使います。ない場合、項目は読み取り専用で表示されます。",
@@ -395,6 +410,7 @@
"clip.save": "クリップを保存",
"clip.save_next": "保存して次へ",
"clip.seconds": "{0} 秒",
+ "clip.ringtone_limit": "iPhoneの着信音は最長{0}秒です",
"clip.length_hint": "開始位置からこの長さのクリップ",
"clip.fast": "高速(再エンコードなし)",
"clip.fast_hint": "動画をそのままコピーします。すぐに終わり画質も落ちませんが、クリップは開始位置の前のキーフレームから始まります(最大数秒早く)。オフにすると選んだフレームで正確に切り出します(再エンコードするため時間がかかります)。",
@@ -498,6 +514,10 @@
"merge.same_width": "幅をそろえる",
"merge.same_height": "高さをそろえる",
"merge.same_size_tip": "端がそろうように画像を拡大・縮小します。オフにすると元のサイズのまま中央に配置します。",
+ "presets.normalize_merge_paper": "結合するすべてのページで用紙サイズを統一",
+ "presets.merge_paper": "結合後の PDF の用紙",
+ "presets.merge_paper_hint": "各ページを選択した用紙サイズに合わせて、切り取らずに中央に配置します。画像ページの設定は結合前に適用されます。",
+ "presets.merge_paper_auto_hint": "自動方向は最初のページを基準に一度だけ選びます。小さいページは用紙に収まるよう拡大します。",
"merge.gap": "間隔 (px)",
"merge.background": "背景",
"merge.transparent": "透明",
@@ -793,9 +813,15 @@
"toolbar.hint_remove": "項目をここにドラッグして削除",
"toolbar.hint_reorder": "項目を重ねるようにドラッグして並べ替え",
"toolbar.hint_edit": "項目を右クリックするか、✎を使って編集",
+ "toolbar.group_hint": "リストからプリセットをドラッグして追加し、スライスをドラッグして並べ替え、リストにドラッグすると外せます。",
+ "toolbar.group_back": "{0} に戻る",
"toolbar.max_reached": "ドーナツメニューには最大{0}個のプリセットを登録できます",
- "toolbar.appearance": "外観",
"toolbar.size": "サイズ",
+ "toolbar.edit_size": "ドーナツメニューのサイズを編集",
+ "toolbar.look_done": "完了",
+ "toolbar.look_hint": "ドーナツメニューが実際のサイズで画面に表示されます。中央をドラッグすると移動でき、グループをクリックすると開いたままになります。",
+ "toolbar.look_all": "すべてのドーナツメニュー",
+ "toolbar.look_this": "このドーナツメニュー: {0}",
"toolbar.hole": "中央の穴",
"toolbar.opacity": "不透明度",
"toolbar.captions": "出力形式を表示",
@@ -803,6 +829,9 @@
"toolbar.label_scale": "ラベルの大きさ",
"toolbar.ring_size": "グループリングの大きさ",
"toolbar.ring_spread": "グループリングの広がり",
+ "toolbar.group_shown": "表示するグループ",
+ "toolbar.example_group": "グループの例",
+ "toolbar.example_none": "このドーナツメニューにはグループがありません",
"labels.auto": "自動",
"labels.icon_text": "アイコンとテキスト",
"labels.text_only": "テキストのみ",
@@ -825,6 +854,7 @@
"group.add": "追加",
"group.add_placeholder": "プリセットを選択",
"group.full": "グループには最大{0}個のプリセットを登録できます。",
+ "group.copy_front": "「フォルダーにコピー」はこのグループに入れられません。このグループは表面にもあります。",
"group.move_up": "上へ移動",
"group.move_down": "下へ移動",
"group.remove": "グループから削除",
@@ -851,6 +881,9 @@
"toolbar.mixed_others": "ほかの形式の組み合わせには「{0}」が使われます。",
"toolbar.mixed_others_self": "何も選択されていない複数形式用プロファイルがほかにないため、このプロファイルが残りすべての組み合わせにも使われます。",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "この入力形式は通常の変換として実行されます",
+ "toolbar.chip_tip.ordinary_conversion.all": "変換可能な入力形式はすべて通常の変換として実行されます",
+ "toolbar.chip_tip.ordinary_conversion.some": "一部の入力形式は通常の変換として実行されます",
"toolbar.chip_tip.makes_same": "→ 同じ形式",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "対応形式: {0}",
@@ -902,6 +935,11 @@
"presets.cad_colors.BlackAndWhite": "白黒",
"presets.cad_colors.Grayscale": "グレースケール",
"presets.cad_colors_hint": "DWG・DXF 図面を PDF、SVG、画像にするときに使います。白黒はモノクロ印刷のように線と文字をすべて黒で描き、塗りつぶしは明るさに応じたグレーにするので、明るい塗りが黒い塊になりません。グレースケールは各色をグレーにし、黄色などの明るい線は読める濃さまで暗くします。",
+ "presets.gcode_view": "表示",
+ "presets.gcode_view.Top": "上から",
+ "presets.gcode_view.Isometric": "アイソメトリック",
+ "presets.gcode_view.Thumbnail": "スライサーのプレビュー",
+ "presets.gcode_view_hint": "スライサーや CAM ソフトの G-code ファイルを PDF、SVG、画像にするときに使います。押し出しと切削は高さで色分けされ、低いほど暗く、高いほど明るくなります。空送りは細い青線 (CNC の早送りは赤) です。スライサーのプレビューは PrusaSlicer、Cura、OrcaSlicer、Bambu Studio がファイルに保存した画像です。SVG、PDF、プレビューのないファイルはツールパスを上から描きます。",
"presets.group.image": "画像",
"presets.quality": "品質",
"presets.resize": "サイズ変更",
@@ -981,6 +1019,7 @@
"presets.ringtone_start": "着信音の開始位置(秒)",
"presets.ringtone_length": "着信音の長さ(秒、最大40)",
"presets.ringtone_hint": "iPhone の着信音は 40 秒までの AAC ファイルです。指定した秒から始まり、指定した長さで切り取られます(40 秒を超えることはありません)。",
+ "presets.ask_ringtone": "毎回、使う部分を指定する(「クリップを切り出す」を開きます)",
"presets.ico_sizes": "アイコンサイズ",
"presets.group.pdf": "PDF",
"presets.merge": "すべてのファイルを1つのPDFに結合",
@@ -1141,6 +1180,7 @@
"presets.extract_note": "各アーカイブは個別のフォルダーに展開されます。展開先は「出力」で設定できます。",
"presets.group.trace": "トレース",
"presets.group.cad": "CAD 図面",
+ "presets.group.gcode": "G-code (3D プリント、CNC)",
"presets.trace_note": "画像(PNG、JPGなど)をベクター図形にトレースするときに使います。",
"presets.trace_mode": "モード",
"presets.trace_mode.Color": "カラー",
@@ -1367,6 +1407,8 @@
"engines.package.whisper.description": "動画や音声の音声をSRT字幕ファイルに書き起こします。インターネット接続なしでオフラインで動作します。",
"engines.package.calibre.name": "Calibre(一般的でない電子書籍形式)",
"engines.package.calibre.description": "一般的でない電子書籍形式(LIT、LRFなど)を変換し、古い形式(LIT、PDB、RBなど)で保存できます。\nEPUB、MOBI、AZW3、FB2、コミックの読み込みとKindle用の保存には、このエンジンは必要ありません。",
+ "engines.package.weasyprint.name": "WeasyPrint(HTML・Markdown・メールを PDF に)",
+ "engines.package.weasyprint.description": "HTML ページ、Markdown、メール(EML、MSG、MHTML)をよりきれいな PDF にします。見出し、表、太字、右から左に書く文字やインドの文字もブラウザーで印刷したとおりです。\nなくても、組み込みの方法で PDF になります。",
"engines.package.7zip.name": "7-Zip(まれなアーカイブ形式)",
"engines.package.7zip.description": "Filee内蔵の読み込み機能が扱わない、まれなアーカイブを展開します:PPMdなど珍しい方式の7Z、Quantum圧縮のCAB、分割ARJ、NTFS・exFAT・APFSのディスクイメージ…\nZIP、7Z、RAR、TAR、CAB、ISO、DMGなど一般的なアーカイブは、インストールしなくても扱えます。",
"engines.package.size": "ダウンロード:{0} · ディスク使用量:{1}",
@@ -1393,7 +1435,17 @@
"engines.remove": "削除",
"engines.remove_confirm": "{0}を削除しますか?後から再ダウンロードできます。",
"engines.state.own_copy": "指定したプログラム",
- "engines.state.system_copy": "このPCで検出",
+ "engines.source.download": "Fileeでダウンロード",
+ "engines.source.system": "このPC上",
+ "engines.source.version": "バージョン{0}",
+ "engines.source.on_disk": "ディスク使用量:{0}",
+ "engines.other_copy_title": "このPCにもあります",
+ "engines.other_copy_detail": "{0}(Fileeでダウンロードしたものは削除されます)",
+ "engines.use_other_copy": "こちらを使う",
+ "engines.use_other_copy_confirm": "{0}のプログラムを使い、Fileeでダウンロードした{1}を削除しますか?後から再ダウンロードできます。",
+ "engines.other_copy_gone": "そのプログラムが見つからないか、起動しません。Fileeでダウンロードしたものはそのまま残しました。",
+ "engines.install_filee_copy": "Fileeのものをインストール",
+ "engines.use_another": "別のプログラムを使う…",
"engines.update_recommended": "更新をおすすめします。現在のバージョンは{0}ですが、Fileeは{1}で動作確認しています。\nほとんどの変換は引き続き実行できます。",
"engines.use_own": "手元のプログラムを使う…",
"engines.use_own_hint": "すでにお持ちの場合は、プログラムを指定してください。Fileeがダウンロードする代わりに、そのプログラムを使います。",
diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json
index baaf19140..f48b7e9f6 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -78,6 +78,9 @@
"donut.files_same": "{1} {0}개",
"donut.files_mixed": "파일 {0}개",
"donut.drop_to": "놓으면 {0}(으)로",
+ "donut.ordinary_conversion.single": "이 파일은 일반 변환으로 실행돼요",
+ "donut.ordinary_conversion.all": "변환 가능한 파일은 모두 일반 변환으로 실행돼요",
+ "donut.ordinary_conversion.some": "일부 파일은 일반 변환으로 실행돼요",
"donut.pick_in_group": "바깥으로 옮겨 고르세요",
"donut.group_empty": "묶음에 남은 프리셋 없음 (설정 → 원형 툴바에서 편집)",
"donut.click_to": "클릭하면 {0}(으)로",
@@ -133,6 +136,8 @@
"error.split_unknown_length": "이 동영상의 길이를 알 수 없어서 등분할 수 없어요",
"error.split_needs_copy": "나누기는 동영상을 그대로 복사하는데, 이 형식에는 담을 수 없어요. 원래 동영상 형식이나 MKV를 골라 주세요",
"error.merged_image_too_large": "합친 이미지가 너무 커요. 사진 수를 줄이거나 더 작은 사진으로 합쳐 주세요",
+ "error.merge_paper_geometry": "페이지 크기, 회전 또는 단위 정보가 올바르지 않아 선택한 용지에 맞출 수 없어요",
+ "error.merge_paper_limit": "용지 크기를 바꾸기 위한 페이지 처리 한도를 넘었어요",
"error.over_size_limit": "크기 제한에 맞을 만큼 줄일 수 없어요",
"error.not_enough_space": "이미지를 저장할 디스크 공간이 부족해요",
"error.no_motion_photo": "이 사진에서 읽을 수 있는 동영상을 찾지 못했어요 (안드로이드 모션 포토, 또는 옆에 MOV가 있는 아이폰 라이브 포토)",
@@ -172,6 +177,9 @@
"error.fit_damaged": "FIT 파일이 손상됐거나 잘렸어요",
"error.fit_chained": "여러 FIT 파일이 이어 붙은 파일은 아직 지원하지 않아요",
"error.fit_too_large": "FIT 파일이 너무 커서 변환할 수 없어요",
+ "error.canvas_not_canvas": "Obsidian 캔버스(JSON Canvas)가 아니에요",
+ "error.canvas_empty": "캔버스에 그릴 카드가 없어요",
+ "error.canvas_too_large": "캔버스가 너무 커서 변환할 수 없어요",
"error.fit_no_file_id": "FIT 파일에 파일 종류 정보가 없어요",
"error.fit_not_activity": "활동 기록이 아닌 FIT 파일이에요 (활동만 변환해요)",
"error.fit_no_positions": "위치 기록이 없는 활동이에요. CSV, XLSX, JSON으로 변환하세요",
@@ -183,12 +191,16 @@
"error.fon_damaged": ".fon 파일이 손상되었거나 잘렸어요",
"error.document_too_complex": "문서가 너무 크거나 복잡해서 안전하게 읽을 수 없어요",
"error.m4s_needs_init": "스트리밍 동영상의 한 조각인 .m4s 파일이에요. 따로 있는 시작(init) 세그먼트 없이는 읽을 수 없어요",
+ "error.nov_password": "이 내보내기 파일은 비밀번호로 보호되어 영상이 암호화되어 있어요. Wisenet WAVE / Nx Witness에서 비밀번호 없이 다시 내보내 주세요",
+ "error.nov_no_video": "이 내보내기 파일에는 카메라 녹화가 없어요",
"error.exe_unreadable": "Filee가 읽을 수 있는 Windows 프로그램이나 라이브러리가 아니거나 파일이 손상되었어요",
"error.exe_no_icon": "이 파일에는 자체 아이콘이 없어요 (Windows가 기본 아이콘을 보여 줘요)",
"notice.pdf_not_smaller": "더 작게 만들 수 없어서 그대로 복사했어요",
+ "notice.nov_recordings_left_out": "이 내보내기 파일에는 녹화가 여러 개(다른 카메라 또는 끊긴 구간) 있어요. 가장 큰 녹화만 변환했어요",
"notice.under_min_size": "최소 파일 크기보다 작지만 저장했어요",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "선택한 AutoCAD 버전에 없는 개체(다중 지시선, 메시 …)는 빼고 저장했어요",
+ "notice.gcode_no_thumbnail": "G-code 파일에 슬라이서 미리보기가 없어서 툴패스를 위에서 본 모습으로 그렸어요",
"notice.sheet_objects_left_out": "아직 인쇄할 수 없는 개체(차트, 도형 안의 글자 …)는 빼고 저장했어요. 함께 받으려면 설정 → 변환 엔진에서 LibreOffice를 설치하세요",
"notice.cmyk_needs_ghostscript": "CMYK 파일인데 RGB로 저장했어요. CMYK로 받으려면 설정 → 변환 엔진에서 Ghostscript를 설치하세요",
"notice.pdfa_needs_ghostscript": "이 PDF는 PDF/A가 아니에요. PDF/A로 받으려면 설정 → 변환 엔진에서 Ghostscript를 설치하세요",
@@ -282,6 +294,8 @@
"engine.font.description": "TTF, OTF, WOFF, WOFF2, EOT를 서로 변환하고, Windows 비트맵 글꼴(FON)도 이 형식으로 바꿔요.",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "오토캐드 도면: DWG ↔ DXF, 그리고 둘 다 PDF, SVG, PNG로.",
+ "engine.gcode.name": "G-code (built-in)",
+ "engine.gcode.description": "3D 프린터와 CNC 기계의 툴패스(G-code)를 PDF, SVG, 그림으로 바꿔요. 슬라이서가 저장한 미리보기도 쓸 수 있어요.",
"engine.drawing.name": "Drawings (built-in)",
"engine.drawing.description": "Visio 도면(VSDX)과 오픈도큐먼트 도면(ODG, FODG)을 Visio나 LibreOffice 없이 PDF, SVG, PNG로 바꿔요.\n오래된 바이너리 Visio 파일(.vsd)은 여전히 LibreOffice가 필요해요.",
"engine.model3d.name": "3D models (built-in)",
@@ -304,6 +318,7 @@
"engine.tesseract.description": "스캔본과 사진의 글자를 인식해요(OCR): 검색 가능한 PDF, DOCX, TXT.",
"engine.vision.description": "macOS에 들어 있는 문자 인식으로 스캔본과 사진의 글자를 인식해요(OCR): 검색 가능한 PDF, DOCX, TXT. 따로 내려받을 게 없어요.",
"engine.calibre.description": "드문 전자책 형식(LIT, LRF…)과 오래된 형식으로 저장(LIT, PDB, RB…).\nFilee 렌더러를 끄고 HWPX writer보다 위에 두면 전자책 PDF도 만들어요(긴 책은 훨씬 빨라요).",
+ "engine.weasyprint.description": "HTML 페이지, Markdown, 이메일을 브라우저에서 인쇄한 그대로 PDF로 만들어요. 제목, 표, 굵은 글씨, 아랍어·힌디어 텍스트도 올바르게 나와요.\n문서 옆에 있는 그림만 불러오고 인터넷에서는 아무것도 불러오지 않아요.",
"engine.libreoffice.description": "Word 6.0/95 DOC, PowerPoint 95 PPT, Pages, Keynote, Numbers, 워드퍼펙트, ClarisWorks, Publisher, 바이너리 Visio(VSD) 같은 오래되었거나 드문 형식을 읽어요.\n차트, 포함된 개체, 3D가 든 ODP·ODG 파일도 변환하고, DOC·XLS·PPT·ODT, 오픈도큐먼트 서식 파일, EMF로 저장도 해요.",
"engine.translator.description": "오프라인 Ollama 모델을 사용해 문서와 전자책을 컴퓨터에서 번역해요.\nSRT·WebVTT 자막도 번역해요. 모델에는 대사 텍스트만 보내고, 자막 번호와 시간은 그대로 둬요.",
"engine.exiftool.description": "사진, PDF, 동영상, 소리의 모든 메타데이터 항목을 읽고, 대부분의 파일 종류에서 파일 내용은 건드리지 않고 편집해요.\n메타데이터 도구가 사용해요. 없으면 항목을 읽기 전용으로 보여 줘요.",
@@ -404,6 +419,7 @@
"clip.save": "클립 저장",
"clip.save_next": "저장 후 다음",
"clip.seconds": "{0}초",
+ "clip.ringtone_limit": "iPhone 벨소리는 최대 {0}초입니다",
"clip.length_hint": "시작 지점부터 이 길이만큼의 클립",
"clip.fast": "빠르게, 재인코딩 없이",
"clip.fast_hint": "영상을 그대로 복사합니다. 금방 끝나고 화질 손실이 없지만, 클립은 시작 지점 앞의 키 프레임에서 시작합니다(최대 몇 초 일찍). 끄면 고른 프레임에서 정확히 자릅니다(다시 인코딩하므로 더 오래 걸림).",
@@ -807,9 +823,15 @@
"toolbar.hint_remove": "세그먼트를 여기로 끌면 빼기",
"toolbar.hint_reorder": "세그먼트끼리 끌면 순서 변경",
"toolbar.hint_edit": "우클릭 또는 ✎로 프리셋 편집",
+ "toolbar.group_hint": "목록에서 프리셋을 끌어 넣고, 세그먼트를 끌어 순서를 바꾸고, 목록으로 끌어다 놓으면 빠져요.",
+ "toolbar.group_back": "{0}(으)로 돌아가기",
"toolbar.max_reached": "도넛에는 최대 {0}개까지 넣을 수 있어요",
- "toolbar.appearance": "모양",
"toolbar.size": "크기",
+ "toolbar.edit_size": "도넛 크기 편집",
+ "toolbar.look_done": "완료",
+ "toolbar.look_hint": "도넛이 실제 크기로 화면에 표시돼요. 가운데를 끌어 옮기고, 묶음을 클릭하면 열린 채로 유지돼요.",
+ "toolbar.look_all": "모든 도넛",
+ "toolbar.look_this": "이 도넛: {0}",
"toolbar.hole": "구멍",
"toolbar.opacity": "불투명도",
"toolbar.captions": "출력 형식 표시",
@@ -817,6 +839,9 @@
"toolbar.label_scale": "레이블 크기",
"toolbar.ring_size": "묶음 링 크기",
"toolbar.ring_spread": "묶음 링 펼침 폭",
+ "toolbar.group_shown": "표시할 묶음",
+ "toolbar.example_group": "예시 묶음",
+ "toolbar.example_none": "이 도넛에는 묶음이 없어요",
"labels.auto": "자동",
"labels.icon_text": "아이콘과 텍스트",
"labels.text_only": "텍스트만",
@@ -839,6 +864,7 @@
"group.add": "추가",
"group.add_placeholder": "프리셋 선택",
"group.full": "묶음에는 프리셋을 {0}개까지 넣을 수 있어요.",
+ "group.copy_front": "\"폴더로 복사\"는 이 묶음에 넣을 수 없어요. 이 묶음은 앞면에도 있어요.",
"group.move_up": "위로",
"group.move_down": "아래로",
"group.remove": "묶음에서 빼기",
@@ -865,6 +891,9 @@
"toolbar.mixed_others": "그 밖의 혼합 선택에는 “{0}”이(가) 열려요.",
"toolbar.mixed_others_self": "아무것도 체크하지 않은 혼합 프로필이 없어서 그 밖의 혼합 선택에도 이 프로필이 쓰여요.",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "이 입력 형식은 일반 변환으로 실행돼요",
+ "toolbar.chip_tip.ordinary_conversion.all": "변환 가능한 입력 형식은 모두 일반 변환으로 실행돼요",
+ "toolbar.chip_tip.ordinary_conversion.some": "일부 입력 형식은 일반 변환으로 실행돼요",
"toolbar.chip_tip.makes_same": "→ 같은 형식",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "받을 수 있는 형식 {0}개",
@@ -917,6 +946,11 @@
"presets.cad_colors.BlackAndWhite": "흑백",
"presets.cad_colors.Grayscale": "회색조",
"presets.cad_colors_hint": "DWG·DXF 도면을 PDF, SVG, 그림으로 만들 때 쓰여요. 흑백은 모노크롬 출력처럼 모든 선과 글자를 검은색으로 그리고, 채워진 영역은 밝기에 맞는 회색으로 칠해서 밝은 채우기가 검은 덩어리가 되지 않아요. 회색조는 모든 색을 회색으로 바꾸고, 노란색처럼 밝은 선은 읽을 수 있을 만큼 진하게 해요.",
+ "presets.gcode_view": "보기",
+ "presets.gcode_view.Top": "위에서",
+ "presets.gcode_view.Isometric": "아이소메트릭",
+ "presets.gcode_view.Thumbnail": "슬라이서 미리보기",
+ "presets.gcode_view_hint": "슬라이서나 CAM 프로그램의 G-code 파일을 PDF, SVG, 그림으로 만들 때 쓰여요. 압출과 절삭은 높이에 따라 어두운 색(낮음)에서 밝은 색(높음)으로 칠하고, 이동은 가는 파란 선(CNC 급속 이동은 빨간 선)으로 그려요. 슬라이서 미리보기는 PrusaSlicer, Cura, OrcaSlicer, Bambu Studio가 파일에 저장한 그림이에요. SVG, PDF와 미리보기가 없는 파일은 툴패스를 위에서 본 모습으로 보여 줘요.",
"presets.group.image": "이미지",
"presets.quality": "품질",
"presets.resize": "크기 조정",
@@ -996,10 +1030,15 @@
"presets.ringtone_start": "벨소리 시작 (초)",
"presets.ringtone_length": "벨소리 길이 (초, 최대 40)",
"presets.ringtone_hint": "iPhone 벨소리는 최대 40초의 AAC 파일이에요. 지정한 초부터 시작해 정한 길이로 잘려요(40초를 넘지 않아요).",
+ "presets.ask_ringtone": "매번 구간 묻기 (클립 자르기가 열립니다)",
"presets.ico_sizes": "아이콘 크기",
"presets.group.pdf": "PDF",
"presets.merge": "모든 파일을 하나의 PDF로 합치기",
"presets.source_bookmarks": "원본 파일마다 책갈피 추가",
+ "presets.normalize_merge_paper": "합친 PDF의 모든 페이지를 같은 용지 크기로 맞추기",
+ "presets.merge_paper": "합친 PDF의 용지",
+ "presets.merge_paper_hint": "각 페이지를 자르지 않고 선택한 용지에 맞춰 가운데 배치해요. 이미지의 페이지 크기와 여백은 합치기 전에 적용돼요.",
+ "presets.merge_paper_auto_hint": "자동 방향은 첫 페이지를 기준으로 한 번 정해요. 작은 페이지도 용지에 맞게 확대해요.",
"presets.page_size": "페이지 크기",
"presets.page_size.FitImage": "이미지에 맞춤",
"presets.page_size.A4": "A4",
@@ -1156,6 +1195,7 @@
"presets.extract_note": "압축 파일마다 따로 폴더를 만들어 풀어요. 저장 위치는 아래 출력에서 정해요.",
"presets.group.trace": "벡터화",
"presets.group.cad": "CAD 도면",
+ "presets.group.gcode": "G-code (3D 프린팅, CNC)",
"presets.trace_note": "그림(PNG, JPG 등)을 벡터 도형으로 바꿀 때 쓰여요.",
"presets.trace_mode": "방식",
"presets.trace_mode.Color": "컬러",
@@ -1384,6 +1424,8 @@
"engines.package.whisper.description": "동영상과 오디오의 말소리를 SRT 자막 파일로 바꿔요. 인터넷 연결 없이 작동해요.",
"engines.package.calibre.name": "Calibre (드문 전자책 형식)",
"engines.package.calibre.description": "드문 전자책 형식(LIT, LRF 등)을 변환하고, 오래된 형식(LIT, PDB, RB 등)으로 저장해요.\nEPUB·MOBI·AZW3·FB2와 만화책은 없어도 읽고, 킨들용 책도 없어도 만들어요.",
+ "engines.package.weasyprint.name": "WeasyPrint (HTML, Markdown, 이메일 → PDF)",
+ "engines.package.weasyprint.description": "HTML 페이지, Markdown, 이메일(EML, MSG, MHTML)을 더 나은 PDF로 만들어요. 제목, 표, 굵은 글씨, 오른쪽에서 왼쪽으로 쓰는 문자와 인도 문자도 브라우저 인쇄처럼 나와요.\n없어도 내장 경로로 PDF가 돼요.",
"engines.package.7zip.name": "7-Zip (드문 압축 파일 변형)",
"engines.package.7zip.description": "Filee 자체 리더가 풀지 않는 드문 압축 파일을 풀어요: PPMd 등 흔치 않은 방식의 7Z, Quantum CAB, 분할 ARJ, NTFS·exFAT·APFS 디스크 이미지…\nZIP·7Z·RAR·TAR·CAB·ISO·DMG 등 흔한 압축 파일은 없어도 돼요.",
"engines.package.size": "다운로드 {0} · 설치 후 {1}",
@@ -1410,7 +1452,17 @@
"engines.remove": "제거",
"engines.remove_confirm": "{0}을(를) 제거할까요? 나중에 다시 내려받을 수 있어요.",
"engines.state.own_copy": "내 프로그램 사용 중",
- "engines.state.system_copy": "PC에 있는 프로그램 사용 중",
+ "engines.source.download": "Filee가 내려받은 프로그램",
+ "engines.source.system": "PC에 있는 프로그램",
+ "engines.source.version": "버전 {0}",
+ "engines.source.on_disk": "디스크 사용 {0}",
+ "engines.other_copy_title": "PC에도 있어요",
+ "engines.other_copy_detail": "{0} (Filee가 내려받은 프로그램은 제거돼요)",
+ "engines.use_other_copy": "이 프로그램 쓰기",
+ "engines.use_other_copy_confirm": "{0}에 있는 프로그램을 쓰고 Filee가 내려받은 {1}을(를) 제거할까요? 나중에 다시 내려받을 수 있어요.",
+ "engines.other_copy_gone": "그 프로그램을 더 이상 찾을 수 없거나 실행되지 않아요. Filee가 내려받은 것은 그대로 두었어요.",
+ "engines.install_filee_copy": "Filee 버전 설치",
+ "engines.use_another": "다른 프로그램 쓰기…",
"engines.update_recommended": "업데이트 권장: 이 프로그램은 {0} 버전이고 Filee는 {1} 버전으로 테스트했어요.\n대부분의 변환은 그대로 돼요.",
"engines.use_own": "내 프로그램 쓰기…",
"engines.use_own_hint": "이미 설치해 둔 게 있다면 실행 파일을 고르세요.\nFilee가 따로 내려받지 않고 그걸 써요.",
diff --git a/src/Filee.App/Assets/i18n/mn.json b/src/Filee.App/Assets/i18n/mn.json
new file mode 100644
index 000000000..4c821e579
--- /dev/null
+++ b/src/Filee.App/Assets/i18n/mn.json
@@ -0,0 +1,1877 @@
+{
+ "app.name": "Filee",
+ "app.tagline": "Чирээд, тавь, хөрвүүлэгдлээ.",
+ "fields.title": "Салбарууд",
+ "fields.description": "Ажилладаггүй салбаруудаа унтраана уу. Зөвхөн унтраасан салбарт хамаарах форматууд цагираг хэлбэрийн самбарын хэсгүүд, Тохиргоо → Цагираг самбар доторх “Боломжит урьдчилсан тохиргоо”, Тохиргоо → Урьдчилсан тохиргоо жагсаалт болон “Хөрвүүлэх формат” жагсаалтаас хасагдана. Filee тэдгээрийн файлыг таньсан хэвээр байх ба командын мөрөөр хөрвүүлэх, мөн чирж оруулсан файл өөрийн салбарын форматуудыг санал болгох нь хэвээр үлдэнэ.",
+ "fields.formats": "Форматууд: {0}",
+ "fields.always_shown": "Үргэлж харагдах: {0}",
+ "fields.name.cad": "CAD",
+ "fields.name.model3d": "3D загварууд",
+ "fields.name.geo": "Газрын зураг",
+ "fields.name.font": "Фонтууд",
+ "fields.name.game": "Тоглоом",
+ "fields.name.design": "Дизайн ба хэвлэл",
+ "fields.name.science": "Шинжлэх ухаан ба анагаах",
+ "fields.name.developer": "Хөгжүүлэгч",
+ "fields.name.writing": "Техникийн бичвэр ба хэвлэн нийтлэл",
+ "fields.name.video-pro": "Видео үйлдвэрлэл ба өргөн нэвтрүүлэг",
+ "fields.name.audio-pro": "Мэргэжлийн аудио",
+ "fields.name.legacy": "Хуучин форматууд",
+ "nav.home": "Нүүр",
+ "nav.general": "Ерөнхий",
+ "nav.triggers": "Товчлолууд",
+ "nav.toolbar": "Цагираг самбар",
+ "nav.presets": "Урьдчилсан тохиргоо",
+ "nav.fields": "Салбарууд",
+ "nav.watch": "Хянах хавтаснууд",
+ "nav.theme": "Загвар",
+ "nav.engines": "Хөдөлгүүрүүд",
+ "nav.about": "Тухай",
+ "nav.collapse": "Хажуугийн самбарыг хураах",
+ "nav.expand": "Хажуугийн самбарыг дэлгэх",
+ "nav.page_error": "Энэ хуудсыг нээж чадсангүй. Дэлгэрэнгүй мэдээлэл бүртгэл (log) дотор байна.",
+ "tray.open": "Filee-г нээх",
+ "tray.pause": "Товчлолуудыг түр зогсоох",
+ "tray.resume": "Товчлолуудыг үргэлжлүүлэх",
+ "tray.quit": "Гарах",
+ "tray.update": "{0} хувилбар руу шинэчлэх",
+ "tray.tooltip": "Filee: {0} товчийг удаан дарж файлуудаа чирнэ үү",
+ "close.title": "Filee-г ажилласан хэвээр үлдээх үү?",
+ "close.text": "Системийн тавиурт (tray) байх үед цагираг самбар болон хянах хавтаснууд хэвийн ажиллана.\nТа үүнийг дараа нь Тохиргоо → Ерөнхий хэсгээс өөрчлөх боломжтой.",
+ "close.tray": "Тавиурт үлдээх",
+ "close.quit": "Гарах",
+ "close.dont_ask": "Дахин бүү асуу",
+ "home.title": "Нүүр",
+ "home.drop_title": "Файлуудаа энд чирж тавина уу",
+ "home.drop_select": "Файл сонгох бол давхар товшино уу",
+ "home.drop_hint": "Эсвэл Explorer дотор файл чирэхдээ {0} товчийг удаан дарна уу. Таны хулганы заагч дээр цагираг самбар нээгдэнэ.",
+ "home.drop_hint_macos": "Эсвэл Finder дотор файл чирэхдээ {0} товчийг удаан дарна уу. Таны хулганы заагч дээр цагираг самбар нээгдэнэ.",
+ "home.drop_hint_linux": "Эсвэл файлын менежер дотор файл чирэхдээ {0} товчийг удаан дарна уу. Таны хулганы заагч дээр цагираг самбар нээгдэнэ.",
+ "home.accessibility_needed": "⌥ Option товч дарж чирэх үед цагираг самбарыг нээхийн тулд Accessibility (Хүртээмж) хэсэгт Filee-д зөвшөөрөл олгоно уу.\nАсаамагц шууд ажиллана. Хэрэв Filee жагсаалтад аль хэдийн асаалттай байвал − товчоор хасаад + товчоор дахин нэмнэ үү: macOS нь шинэчлэгдсэн эсвэл дахин суулгасан Filee-д өмнөх зөвшөөрлийг автоматаар шилжүүлдэггүй.",
+ "home.elevated": "Filee администраторын эрхээр ажиллаж байгаа тул Explorer дээрээс файл чирж тавих эсвэл хулганы баруун товчны цэсийг ашиглах боломжгүй.\nМөн товчлол нь Explorer дээр сонгосон файлыг уншиж чадахгүй.\nҮүнийг засахын тулд Filee-г администраторын эрхгүйгээр дахин эхлүүлнэ үү.",
+ "home.elevated_restart": "Администраторын эрхгүйгээр дахин эхлүүлэх",
+ "home.elevated_restart_failed": "Filee өөрийгөө дахин эхлүүлж чадсангүй.\nСистемийн тавиураас Filee-г хаагаад дахин нээнэ үү.",
+ "elevation.notice_title": "Filee администраторын эрхээр ажиллаж байна",
+ "elevation.notice_text": "Ийм горимоор ажиллаж байх үед Explorer нь Filee рүү файл чирж тавих болон цэсийг нь ашиглах боломжгүй.\nАдминистраторын эрхгүйгээр дахин эхлүүлнэ үү.",
+ "startup.elevated_other": "Өөр нэг Filee администраторын эрхээр ажиллаж байна.\nExplorer түүн рүү файл дамжуулж чадахгүй.\nСистемийн тавиураас Filee-г хаагаад дахин эхлүүлнэ үү.",
+ "startup.no_answer": "Filee ажиллаж байгаа ч хариу өгсөнгүй.\nХэрэв администраторын эрхээр ажиллаж байвал тавиураас хаагаад дахин эхлүүлнэ үү.",
+ "home.recent": "Сүүлийн хөрвүүлэлтүүд",
+ "home.recent_empty": "Таны хөрвүүлсэн файлууд энд харагдана.",
+ "home.no_matches": "Хайлтад тохирох сүүлийн хөрвүүлэлт олдсонгүй.",
+ "home.open_folder": "Хавтас нээх",
+ "home.open_file": "Файл нээх",
+ "home.open_with": "Програмаар нээх",
+ "home.clear_history": "Цэвэрлэх",
+ "home.files": "{0} файл",
+ "home.failed_files": "{0} амжилтгүй",
+ "home.undo": "Буцаах",
+ "home.undo_hint": "Үр дүнгийн файлуудыг Хогийн сав руу зөөх",
+ "home.undone": "Буцаалаа: үр дүнгийн файлууд Хогийн сав руу орлоо",
+ "home.undone_original": "Буцаалаа: үр дүнгийн файлууд болон тэдгээрийн орлуулсан эх файлууд Хогийн сав руу орлоо",
+ "home.undo_failed": "{0}-г Хогийн сав руу зөөж чадсангүй: {1}",
+ "donut.center_drag": "Файлуудаа энд авчирна уу",
+ "donut.files_same": "{0} × {1}",
+ "donut.files_mixed": "{0} файл",
+ "donut.drop_to": "Тавих → {0}",
+ "donut.ordinary_conversion.single": "Энэ файл энгийн хөрвүүлэлтээр явна",
+ "donut.ordinary_conversion.all": "Хөрвүүлэх боломжтой бүх файл энгийн хөрвүүлэлтээр явна",
+ "donut.ordinary_conversion.some": "Зарим файл энгийн хөрвүүлэлтээр явна",
+ "donut.pick_in_group": "Сонгохын тулд гадагш чиглүүлнэ үү",
+ "donut.group_empty": "Энэ бүлэгт урьдчилсан тохиргоо үлдсэнгүй. Тохиргоо → Цагираг самбар хэсгээс засна уу",
+ "donut.click_to": "Товших → {0}",
+ "donut.cancel": "Цуцлах бол энд тавина уу",
+ "donut.close_hint": "Хаах бол Esc",
+ "donut.edit": "Тохиргоог засах",
+ "donut.empty": "Урьдчилсан тохиргоог энд чирж оруулна уу",
+ "donut.same_format": "Ижил формат",
+ "donut.group_caption": "{0}-н бүлэг",
+ "donut.flip_to_back": "{0} ар тал",
+ "donut.flip_to_front": "{0} нүүр тал",
+ "toast.converting": "{0} → {1}",
+ "toast.done": "Дууслаа",
+ "toast.done_errors": "{1}-ээс {0} нь амжилтгүй боллоо",
+ "toast.failed": "Хөрвүүлэлт амжилтгүй боллоо",
+ "toast.cancelled": "Цуцлагдлаа",
+ "toast.skipped": "Алгаслаа (файл аль хэдийн байна)",
+ "toast.open_folder": "Харуулах",
+ "toast.cancel": "Цуцлах",
+ "toast.copy": "Дэлгэрэнгүйг хуулах",
+ "toast.copied": "Хуулагдлаа",
+ "toast.more": "дахин {0}",
+ "toast.missing_fonts": "Дутуу баримтын фонтууд: {0}",
+ "toast.get_fonts": "Фонт татах",
+ "download.badge": "{0}/с · {1}",
+ "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": "Гарын үсэгтэй файл нээхэд хэт том байна",
+ "error.document_too_large": "Энэ баримт нь задлахад хэт их өгөгдөлтэй эсвэл аюулгүй нээхэд хэт гүн давхарлагдсан байна",
+ "format_request.request": "Энэ форматыг хүсэх",
+ "format_request.notice_title": "Filee одоогоор {0} файлыг хөрвүүлж чадахгүй байна",
+ "format_request.notice_text": "Танд ямар формат хэрэгтэй байгааг бидэнд хэлнэ үү.\nХэрэглэгчдийн хүсэлтээр Filee дараагийн дэмжих форматуудыг шийддэг.\nЯмар ч формат байсан шифрлэгдсэн эсвэл хуулбарлахаас хамгаалагдсан (DRM) файлыг хөрвүүлэх боломжгүй.",
+ "error.no_route": "Энэ хөрвүүлэлтийг хийх хөдөлгүүр олдсонгүй",
+ "error.msi_busy": "Өөр суулгац явагдаж байна. Дууссаны дараа дахин оролдоно уу",
+ "error.msi_link": "Windows Installer багцыг нээж чадсангүй: замд нь байгаа хавтас өөр диск рүү холбосон холбоос (link) байж болзошгүй",
+ "error.cannot_turn": "Эргүүлэх болон толин тусгал болгох үйлдэл зөвхөн зураг дээр ажиллана",
+ "error.images_only": "Зөвхөн зураг дээр ажиллана",
+ "error.no_transparency": "Энэ форматад тунгалаг хэсэг байхгүй тул тайрах зүйл алга",
+ "error.no_black_bars": "Хар хүрээ олдсонгүй тул тайрах зүйл алга",
+ "error.crop_same_frame": "Видео аль хэдийн ийм харьцаатай байгаа тул тайрах зүйл алга",
+ "error.ocr_language_download": "OCR хэлийг татаж чадсангүй",
+ "error.ocr_needs_tesseract": "Apple Vision энэ хэлийг уншиж чадахгүй. Тохиргоо → Хөдөлгүүрүүд хэсгээс Tesseract суулгана уу",
+ "error.too_many_frames": "Зураг болгон хадгалахад кадр хэт олон байна, \"N кадр тутамд\" утгыг ихэсгэнэ үү",
+ "error.too_many_pictures": "Нэг видеонд зураг хэт олон байна, хэд хэдэн хэсэгт хуваана уу",
+ "error.too_many_parts": "Нэг видеонд хуваах хэсэг хэт олон байна, илүү урт хугацаа сонгоно уу",
+ "error.split_unknown_length": "Энэ видеоны урт тодорхойгүй байгаа тул тэнцүү хэсгүүдэд хуваах боломжгүй",
+ "error.split_needs_copy": "Хуваах үйлдэл нь видеог байгаагаар нь хуулдаг бөгөөд энэ формат түүнийг агуулж чадахгүй. Видеоны өөрийнх нь формат эсвэл MKV сонгоно уу",
+ "error.merged_image_too_large": "Нэгтгэсэн зураг хэт том болох тул цөөн эсвэл жижиг зураг нэгтгэнэ үү",
+ "error.merge_paper_geometry": "Нэг хуудасны хэмжээ, эргэлт эсвэл нэгж буруу тул сонгосон цаасанд тааруулах боломжгүй",
+ "error.merge_paper_limit": "Энэ PDF нэг цаасны хэмжээнд тааруулахад хэт нарийн бүтэцтэй байна",
+ "error.over_size_limit": "Хэмжээний хязгаарт багтаахаар жижигрүүлэх боломжгүй байна",
+ "error.not_enough_space": "Зургуудыг хадгалахад дискний сул зай хүрэлцэхгүй байна",
+ "error.no_motion_photo": "Энэ зурагт Filee унших боломжтой видео алга (Android Motion Photo, эсвэл хажуудаа MOV файлтай iPhone Live Photo байх шаардлагатай)",
+ "error.live_photo_start": "Хэсгийн эхлэл энэ видеоны төгсгөлөөс хойш байна. Илүү эрт \"Эхлэх хугацаа\" сонгоно уу",
+ "error.live_photo_hdr": "Энэ FFmpeg-д zscale шүүлтүүр байхгүй тул энэ HDR видеог Live / Motion Photo болгох боломжгүй. Filee-ийн татдаг FFmpeg-д энэ шүүлтүүр бий",
+ "error.tracker_needs_openmpt": "Энэ FFmpeg нь tracker модулиудыг (XM, IT, S3M) уншиж чадахгүй: libopenmpt-гүй бүтээгдсэн байна. Windows болон Linux-д зориулсан Filee-ийн FFmpeg-д энэ нь бий",
+ "error.rhwp_timeout": "rhwp энэ урт баримтыг хугацаанд нь дуусгаж чадсангүй",
+ "error.rhwp_timeout_calibre": "rhwp энэ урт баримтыг хугацаанд нь дуусгаж чадсангүй. Цахим номын хувьд Тохиргоо → Хөдөлгүүрүүд хэсэгт Calibre-г HWPX бичигчийн дээр байрлуулбал Calibre PDF-ийг хамаагүй хурдан үүсгэнэ",
+ "error.engine_stalled": "Хөдөлгүүр хариу өгөхөө больсон: удаан хугацаанд ахиц гараагүй тул зогсоолоо",
+ "error.engine_busy": "Өөр нэг Filee (програм, командын мөр эсвэл хоёр дахь хувилбар) энэ хөдөлгүүрийг суулгаж эсвэл устгаж байна. Дууссаны дараа дахин оролдоно уу.",
+ "error.engine_files_missing": "Хөдөлгүүрийн бүх файлыг сонгоогүй эсвэл зарим нь Filee-ийн өөр хувилбарт зориулагдсан байна. Эдгээр файл шаардлагатай",
+ "error.engine_file_mismatch": "Энэ файл нь Filee-ийн энэ хөдөлгүүрт зориулж татдаг файл биш байна: гэмтсэн эсвэл өөр хувилбар байна",
+ "error.needs_shaping": "Энэ бичгийг зөв хөрвүүлэхэд Filee буулгагч эсвэл LibreOffice шаардлагатай. Тохиргоо → Хөдөлгүүрүүд хэсгээс буулгагчийг асаах эсвэл LibreOffice суулгана уу",
+ "error.vsd_needs_libreoffice": "Хуучин хоёртын Visio зургийг (.vsd) LibreOffice хөрвүүлдэг. Тохиргоо → Хөдөлгүүрүүд хэсгээс суулгах эсвэл Visio дээр зургийг VSDX болгон хадгална уу",
+ "error.old_ppt_needs_libreoffice": "PowerPoint 95 болон түүнээс хуучин үзүүлэнг LibreOffice хөрвүүлдэг. Тохиргоо → Хөдөлгүүрүүд хэсгээс суулгах эсвэл файлыг шинэ PowerPoint дээр дахин хадгална уу",
+ "error.archive_needs_7zip": "Filee-ийн суурилагдсан уншигч энэ төрлийн архивыг задалж чадахгүй. Задлахын тулд Тохиргоо → Хөдөлгүүрүүд хэсгээс 7-Zip суулгана уу",
+ "error.runtime_missing": "Энэ хөдөлгүүрт шаардлагатай Microsoft Visual C++ runtime файл дутуу эсвэл хуучирсан байна. Filee-г шинэчлэх буюу дахин суулгах, эсвэл Microsoft Visual C++ Redistributable (x64) суулгана уу",
+ "error.conversion_failed": "Хөдөлгүүр алдаа заалаа",
+ "error.pdf_no_tables": "Энэ PDF дотор зураасаар зурсан хүснэгт байхгүй тул хүснэгтэн файлд оруулах зүйл алга. Сканнердсан PDF-д ямар ч текст байдаггүй.",
+ "error.document_no_tables": "Энэ файлд хүснэгт байхгүй тул хүснэгтэн файлд оруулах зүйл алга. Tab эсвэл хоосон зайгаар зэрэгцүүлсэн багануудыг хүснэгт гэж уншихгүй.",
+ "error.pdf_password_gone": "Урьдчилсан тохиргооны PDF нууц үг түүхэнд хадгалагддаггүй бөгөөд одоо тэр тохиргоонд нууц үг алга байна. Нууц үгтэй тохиргоогоор файлуудыг дахин хөрвүүлнэ үү.",
+ "error.web_download_failed": "Файлыг татаж чадсангүй",
+ "error.web_page": "Холбоос нь файл бус вэб хуудас нээж байна. Нэвтэрч орох шаардлагатай файлыг Filee татаж чадахгүй: эхлээд хөтөч дээрээсээ хадгална уу",
+ "error.web_too_large": "Файл 2 ГБ-аас том байна",
+ "error.web_blocked": "Програм, суулгац болон скриптүүдийг хадгалахгүй",
+ "error.web_timeout": "Сервер хариу өгөхөө больсон",
+ "error.web_local_address": "Холбоос нь энэ компьютер эсвэл таны дотоод сүлжээ рүү чиглэж байна. Файлыг эхлээд хөтчөөсөө хадгалах эсвэл Тохиргоо → Ерөнхий хэсэгт “Энэ компьютер болон дотоод сүлжээний холбоосыг зөвшөөрөх”-ийг асаана уу",
+ "error.geo_empty": "Файлд гео-объект алга",
+ "error.geo_unknown_crs": "Координатууд нь уртраг, өргөрөг биш бөгөөд файлд координатын систем нь заагдаагүй байна (Shapefile-ийн хажууд .prj файлыг нь байлгана уу)",
+ "error.geo_unsupported_crs": "Энэ координатын системийг одоогоор дэмжихгүй",
+ "error.geo_bad_coordinates": "Координатууд энэ координатын системд тохирохгүй байна",
+ "error.geo_csv_no_coordinates": "CSV файлд уртраг/өргөргийн багана болон WKT багана алга",
+ "error.geo_csv_no_locations": "CSV файлын аль ч мөрөнд ашиглах боломжтой байршил алга",
+ "error.geo_unsupported_shape": "Энэ төрлийн дүрсийг дэмжихгүй",
+ "error.geo_too_large": "Shapefile нь форматын 2 ГБ-ын хязгаараас давах гэж байна",
+ "error.fit_not_fit": "Энэ нь Garmin FIT файл биш байна",
+ "error.fit_damaged": "FIT файл гэмтсэн эсвэл дутуу байна",
+ "error.fit_chained": "Файлд хэд хэдэн FIT файл дараалан орсон байна, үүнийг одоогоор дэмжихгүй",
+ "error.fit_too_large": "FIT файл хөрвүүлэхэд хэт том байна",
+ "error.canvas_not_canvas": "Энэ нь Obsidian canvas (JSON Canvas) биш байна",
+ "error.canvas_empty": "Canvas дээр зурах карт алга",
+ "error.canvas_too_large": "Canvas хөрвүүлэхэд хэт том байна",
+ "error.fit_no_file_id": "FIT файл ямар төрлийн файл болох нь тодорхойгүй байна",
+ "error.fit_not_activity": "Энэ FIT файл нь дасгал хөдөлгөөний бүртгэл биш байна (зөвхөн дасгалын бүртгэлийг хөрвүүлнэ)",
+ "error.fit_no_positions": "Бүртгэлд байршлын мэдээлэл алга: үүнийг CSV, XLSX эсвэл JSON руу хөрвүүлнэ үү",
+ "error.fit_no_records": "Бүртгэлд бичигдсэн өгөгдөл алга",
+ "error.osm_too_large": "OpenStreetMap файл хөрвүүлэхэд хэт том байна",
+ "error.fon_vector": "Энэ нь вектор фонт байна; зөвхөн цэгэн (bitmap) .fon фонтыг хөрвүүлэх боломжтой",
+ "error.fon_unsupported": "Энэ төрлийн .fon фонтыг дэмжихгүй",
+ "error.fon_too_large": ".fon фонт хөрвүүлэхэд хэт том байна",
+ "error.fon_damaged": ".fon файл гэмтсэн эсвэл дутуу байна",
+ "error.document_too_complex": "Баримт аюулгүй уншихад хэт том эсвэл хэт нарийн бүтэцтэй байна",
+ "error.m4s_needs_init": "Энэ .m4s нь урсгал видеоны нэг хэсэг: тусдаа эхлэл (init) хэсэггүйгээр унших боломжгүй",
+ "error.nov_password": "Энэ экспорт нууц үгээр хамгаалагдсан тул видео нь шифрлэгдсэн байна: Wisenet WAVE / Nx Witness-ээс нууц үггүйгээр дахин экспортлоно уу",
+ "error.nov_no_video": "Энэ экспортод камерын бичлэг алга",
+ "error.exe_unreadable": "Энэ нь Filee уншиж чадах Windows програм эсвэл сан биш, эсвэл гэмтсэн байна",
+ "error.exe_no_icon": "Энэ файлд өөрийн дүрс (icon) байхгүй байна (Windows түүнд стандарт дүрс харуулдаг)",
+ "notice.pdf_not_smaller": "Жижигрүүлж чадсангүй тул байгаагаар нь хууллаа",
+ "notice.nov_recordings_left_out": "Энэ экспортод хэд хэдэн бичлэг байна (бусад камер эсвэл тасалдалтай хэсгүүд): зөвхөн хамгийн томыг нь хөрвүүлсэн",
+ "notice.under_min_size": "Хадгалагдсан боловч файлын хамгийн бага хэмжээнээс жижиг байна",
+ "notice.with_under_min_size": "{0} · {1}",
+ "notice.cad_newer_left_out": "Сонгосон AutoCAD хувилбарт байхгүй объектууд (multileader, mesh …) орхигдлоо",
+ "notice.gcode_no_thumbnail": "G-code файлд слайсерын урьдчилан харах зураг байхгүй тул замыг дээрээс нь зурсан",
+ "notice.sheet_objects_left_out": "Filee одоогоор хэвлэж чадахгүй объектууд (график, дүрс доторх текст …) орхигдлоо: хадгалахын тулд Тохиргоо → Хөдөлгүүрүүд хэсгээс LibreOffice суулгана уу",
+ "notice.cmyk_needs_ghostscript": "Энэ файл CMYK боловч RGB хэлбэрээр хадгалагдлаа: CMYK хэвээр хадгалахын тулд Тохиргоо → Хөдөлгүүрүүд хэсгээс Ghostscript суулгана уу",
+ "notice.pdfa_needs_ghostscript": "Энэ PDF нь PDF/A биш байна: PDF/A болгохын тулд Тохиргоо → Хөдөлгүүрүүд хэсгээс Ghostscript суулгана уу",
+ "notice.cmyk_render_failed": "Энэ файл CMYK боловч Ghostscript зурж чадсангүй тул RGB хэлбэрээр хадгаллаа",
+ "notice.original_kept": "Эх файлыг хэвээр үлдээж, үр дүнг хуулбар болгон хадгаллаа",
+ "notice.original_in_trash": "Эх файл Хогийн саванд орж, үр дүнгийн файл нэрийг нь авлаа",
+ "notice.original_unchanged": "Жижигрүүлж чадсангүй тул эх файлыг хэвээр үлдээлээ",
+ "notice.move_original_kept": "Хуулагдсан боловч эх файлыг устгаж чадсангүй",
+ "trash.no_recycle_bin": "энэ дискэнд Хогийн сав байхгүй",
+ "trash.in_use": "файл өөр програм дээр нээлттэй байна",
+ "trash.read_only": "файл зөвхөн унших горимтой байна",
+ "trash.other_drive": "Хогийн сав өөр диск дээр байна",
+ "trash.no_folder": "Хогийн савны хавтас байхгүй байна",
+ "trash.failed": "Хогийн сав руу зөөж чадсангүй",
+ "trash.cannot_place": "үр дүнг эх файлын хавтаст хадгалж чадсангүй",
+ "trash.changed": "хөрвүүлсний дараа өөрчлөгдсөн байна",
+ "notice.empty_spreadsheet_pages_removed": "Хүснэгтийн хоосон хуудсууд устгагдлаа",
+ "notice.macros_not_run": "Баримт макро агуулж байна. Тэдгээрийг ажиллуулсангүй",
+ "notice.ocr_has_text": "Бүх хуудас аль хэдийн тексттэй байсан тул байгаагаар нь хууллаа",
+ "notice.ps_pages_as_pictures": "PostScript-д багтах боломжгүй тунгалаг хэсэгтэй хуудсуудыг зураг болгон хадгаллаа",
+ "notice.live_photo_pair": "Ижил нэртэй зураг болон MOV файл болгон хадгаллаа. Live Photo болгохын тулд хоёуланг нь хамт импортолно уу (iCloud Photos, AirDrop эсвэл Mac дээрх Photos)",
+ "notice.ringtone_cut": "Хонхны ая нь дууны зөвхөн нэг хэсэг: iPhone хамгийн ихдээ 40 секунд хүлээн авдаг",
+ "notice.picture_subtitles_left_out": "Зургийн хадмал (Blu-ray, DVD) энэ форматад орох боломжгүй тул орхигдлоо. Хадгалах бол MKV руу хөрвүүлнэ үү",
+ "notice.subtitles_not_kept": "Хадмал орчуулгын замыг хадгалах боломжгүй байсан тул видеог хадмалгүйгээр хөрвүүллээ",
+ "notice.geo_datum_assumed": "Датум нь WGS 84 биш байна; байршлуудыг WGS 84 мэтээр хөрвүүлсэн тул хэдэн зуун метр хүртэл зөрөх боломжтой",
+ "notice.geo_gpx_lossy": "GPX нь зөвхөн цөөн талбар бүхий цэг, маршрут, замыг хадгалдаг: бусад шинж чанарууд орхигдож, олон өнцөгтүүд зам болж хувирлаа",
+ "notice.geo_text_cut": "Зарим текст Shapefile талбарын зөвшөөрөгдөх хэмжээнээс (254 байт) урт байсан тул тайрагдлаа",
+ "notice.geo_columns_dropped": "Shapefile хамгийн ихдээ 255 багана агуулдаг: үлдсэн нь орхигдлоо",
+ "notice.geo_rows_without_location": "Зарим мөрөнд ашиглах боломжтой байршил байхгүй тул дүрсгүйгээр хадгаллаа",
+ "notice.geo_field_names": "Shapefile талбарын нэр хамгийн ихдээ 10 тэмдэгт байдаг: заримыг нь богиносголоо",
+ "notice.geo_shapefile_split": "Shapefile нь зөвхөн нэг төрлийн дүрс агуулдаг тул цэг, шугам, олон өнцөгтийг тусдаа файл болгон хадгаллаа",
+ "notice.geo_heights_dropped": "Зарим цэгт өндрийн утга байхгүй бөгөөд Shapefile нэгийг нь ч орхиж чаддаггүй тул бүх өндрийн утгыг хаслаа",
+ "notice.osm_incomplete": "Зарим шугам болон талбай нь файлд байхгүй цэгүүдийг (хайчилж авсан хэсэг) ашигласан тул орхигдлоо",
+ "engine.reason.not_installed": "Суулгаагүй",
+ "engine.reason.error": "Шалгах үед алдаа гарлаа",
+ "engine.reason.unsupported_platform": "Энэ платформ дээр боломжгүй",
+ "engine.reason.needs_package": "{0} шаардлагатай. Тохиргоо → Хөдөлгүүрүүд хэсгээс суулгана уу",
+ "engine.reason.preview_off": "Унтраалттай. Тохиргоо → Хөдөлгүүрүүд хэсгээс асаана уу",
+ "engine.reason.needs_perl": "Энэ компьютерт байхгүй байгаа Perl шаардлагатай",
+ "engine.magick.description": "JPG, PNG, WEBP, AVIF, HEIC, TIFF, GIF, APNG, PSD, камерын RAW болон бусад.\nЧанар, хэмжээ болон DPI нь таны урьдчилсан тохиргоог дагана.",
+ "engine.wic.description": "Windows-ийн HDR дэлгэцийн зураг зэрэг JPEG XR зургуудыг (.jxr, .wdp, .hdp) Windows-ийн өөрийн кодекоор PNG, JPG болон бусад зураг руу хөрвүүлнэ.\nХурц гэрэлтэй хэсгийн нарийн ширийнийг хадгалахын тулд HDR-ийг өнгөний зураглалд (tone-map) оруулна.",
+ "engine.pdfium.description": "PDF хуудсуудыг урьдчилсан тохиргооны нягтаршлаар PNG, JPG болон TIFF (мөн олон хуудастай TIFF болон GIF) болгон буулгана.",
+ "engine.pdfsharp.description": "Зургуудаас PDF үүсгэж, PDF-үүдийг нэгтгэх, хуваах эсвэл хуудасны мужийг тасалж авна.\nМөн доторх зургуудыг нь жижигрүүлж PDF-ийн хэмжээг багасгана.",
+ "engine.rhwp.description": "HWP ↔ HWPX хооронд хөрвүүлж, Filee буулгагч унтраалттай эсвэл файлыг одоогоор зурж чадахгүй үед HWP/HWPX-ийг PDF болгон буулгана.\nТэгэхэд HWPX-ээр дамжуулан PDF болдог баримтуудыг (DOCX, PPTX, цахим ном…) мөн зурна.",
+ "engine.hwp-text.name": "HWP текст (суурилагдсан)",
+ "engine.hwp-text.description": "HWP болон HWPX-ээс текст, Markdown болон HTML-ийг гаргаж авна.",
+ "engine.markdown.name": "Markdown (суурилагдсан)",
+ "engine.markdown.description": "Markdown → HTML болон энгийн текст.\nТекст болон скриптийн эх файлууд → скриптийг ажиллуулахгүйгээр UTF-8 TXT болгоно.",
+ "engine.spreadsheet.name": "Хүснэгт (суурилагдсан)",
+ "engine.spreadsheet.description": "XLSX, XLS, ODS, CSV болон TSV хоорондоо хөрвүүлнэ (XLS зөвхөн уншигдана).\nХуудас бүрийг нэг CSV болгоно.",
+ "engine.sheet-print.name": "Хүснэгт хэвлэх (суурилагдсан)",
+ "engine.sheet-print.description": "XLSX, XLS болон ODS-ийг Excel эсвэл LibreOffice-гүйгээр Excel шиг PDF болгон хэвлэнэ:\nхуудасны тохиргоо, хэвлэх муж ба гарчиг, хуудас таслалт, толгой ба хөл хэсэг.",
+ "engine.ooxml.name": "Office Open XML (суурилагдсан)",
+ "engine.ooxml.description": "Макро идэвхжүүлсэн файл болон загварууд (DOCM, XLSM, PPTM, DOTX…) ↔ DOCX, XLSX, PPTX.\nМакрог устгана.",
+ "engine.hwpx-writer.name": "HWPX бичигч (суурилагдсан)",
+ "engine.hwpx-writer.description": "Word, Excel, PowerPoint, PDF, OFD, HTML, цахим ном болон Markdown-оос HWPX бичнэ.\nOffice ашиглахгүйгээр баримтыг PDF болгох эхний алхам.",
+ "engine.layout.name": "Filee буулгагч",
+ "engine.layout.description": "Filee-ийн өөрийн байрлуулах хөдөлгүүр: HWP, HWPX, Word баримт, цахим ном болон слайдыг (PPTX, PPT, ODP) бусад програмгүйгээр PDF болгоно.\nАнхдагчаар асаалттай; Тохиргоо → Хөдөлгүүрүүд хэсгээс унтрааж болно.",
+ "engine.docx-writer.name": "DOCX бичигч (суурилагдсан)",
+ "engine.docx-writer.description": "PDF, OFD, HWP/HWPX, Markdown, HTML, хүснэгт, слайд болон цахим номноос Word баримт (DOCX) бичнэ.",
+ "engine.odt-writer.name": "ODT бичигч (суурилагдсан)",
+ "engine.odt-writer.description": "Word баримт, PDF, OFD, HWP/HWPX, Markdown, HTML, хүснэгт, слайд болон цахим номноос OpenDocument текст (ODT, LibreOffice Writer) бичнэ.",
+ "engine.markup.name": "Тэмдэглэгээний хэлүүд (суурилагдсан)",
+ "engine.markup.description": "Jupyter notebook, OPML, Textile, MediaWiki, reStructuredText, Org болон DocBook-ийг Pandoc-гүйгээр.\nБаримтыг эдгээр формат руу, эдгээрийг Markdown, HTML, текст, DOCX, HWPX болон PDF руу.",
+ "engine.pptx-writer.name": "PPTX бичигч (суурилагдсан)",
+ "engine.pptx-writer.description": "Markdown болон тэмдэглэгээний файл, Word баримт, ODT, HWP/HWPX, HTML, хүснэгт болон цахим номноос PowerPoint слайд (PPTX) үүсгэнэ: гарчиг бүрт нэг слайд, жагсаалт, хүснэгт, зураг болон кодтой.",
+ "engine.odp-writer.name": "ODP бичигч (суурилагдсан)",
+ "engine.odp-writer.description": "LibreOffice-гүйгээр OpenDocument үзүүлэн (ODP) үүсгэнэ: PowerPoint үзүүлэнг (PPTX, PPT) дүрс, зураг, хүснэгт болон илтгэгчийн тэмдэглэлтэй нь, мөн Markdown болон тэмдэглэгээний файл, Word баримт, ODT, HWP/HWPX, HTML, хүснэгт болон цахим номыг гарчиг бүрт нэг слайдтайгаар.",
+ "engine.pdf-text.name": "PDF текст (суурилагдсан)",
+ "engine.pdf-text.description": "PDF-ийн текстийг унших дарааллаар нь, мөн хоёр баганатай хуудаснаас гаргаж авна (сканнердсан файлд OCR хийхгүй).",
+ "engine.pdf-slides.name": "PDF слайд (суурилагдсан)",
+ "engine.pdf-slides.description": "PDF-ийг PowerPoint слайд болгоно: хуудас бүр тусдаа слайд дээр зураг болж орно (PDF шигээ харагдах боловч засах боломжгүй), хуудасны текст нь илтгэгчийн тэмдэглэлд орно.",
+ "engine.pdf-tables.name": "PDF хүснэгт (суурилагдсан)",
+ "engine.pdf-tables.description": "PDF дотор зураасаар зурсан хүснэгтүүдийг тоо болон огноог бодит утгаар нь хадгалан тус бүр нь нэг хуудастай Excel файл болгоно.",
+ "engine.pdf-ps.name": "PostScript бичигч (суурилагдсан)",
+ "engine.pdf-ps.description": "PDF-ийг Ghostscript-гүйгээр PostScript болон EPS болгоно: текст фонтоо хадгалж, зураас вектороороо үлдэнэ. Тунгалаг хэсэгтэй хуудсууд зураг болно.",
+ "engine.document-tables.name": "Баримтын хүснэгт (суурилагдсан)",
+ "engine.document-tables.description": "Word баримт (DOCX) болон вэб хуудасны (HTML, MHTML) хүснэгтүүдийг тоо болон огноог бодит утгаар нь хадгалан тус бүр нь нэг хуудастай Excel файл болгоно.",
+ "engine.email.name": "Имэйл (суурилагдсан)",
+ "engine.email.description": "Имэйлүүдийг (EML, Outlook MSG) илгээгч, хүлээн авагч, огноо, зургуудын хамт HTML болон текст рүү, хавсралтуудыг ZIP рүү хөрвүүлнэ.\nMSG-г EML рүү. Хадгалсан вэб хуудсыг (MHTML) зургуудынх нь хамт нэг HTML файл болгоно.",
+ "engine.p7m.name": "Гарын үсэгтэй файл (суурилагдсан)",
+ "engine.p7m.description": ".p7m (CMS / PKCS #7: Италийн цахим нэхэмжлэх, гарын үсэгтэй PDF) дотроос гарын үсэг зурсан файлыг гаргаж цааш хөрвүүлнэ. Гарын үсгийг шалгахгүй, хадгалахгүй тул баталгаа болгож .p7m файлаа хадгална уу.",
+ "engine.data.name": "Өгөгдөл (суурилагдсан)",
+ "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML болон CSV хоорондоо хөрвүүлнэ.\nvCard харилцагчид болон iCalendar үйл явдлуудыг CSV рүү болон буцааж хөрвүүлнэ.\nТайлбарууд (comments) хасагдана. CSV зөвхөн хүснэгт агуулна.",
+ "engine.vector.name": "Вектор график (суурилагдсан)",
+ "engine.vector.description": "SVG болон SVGZ-г вектор PDF, зураг болон DXF рүү; зургийг мөшгөж SVG рүү; PDF агуулгатай хадгалсан Illustrator файлуудыг хөрвүүлнэ.",
+ "engine.icns.name": "ICNS (суурилагдсан)",
+ "engine.icns.description": "macOS програмын дүрсүүдийг (ICNS) зургаас болон зураг руу хөрвүүлнэ.",
+ "engine.font.name": "Фонт (суурилагдсан)",
+ "engine.font.description": "Фонтууд: TTF, OTF, WOFF, WOFF2 болон EOT хоорондоо; Windows-ийн цэгэн фонтуудыг (FON) эдгээр рүү хөрвүүлнэ.",
+ "engine.cad.name": "CAD (суурилагдсан)",
+ "engine.cad.description": "AutoCAD зураг: DWG ↔ DXF, мөн хоёуланг нь PDF, SVG болон PNG рүү хөрвүүлнэ.",
+ "engine.gcode.name": "G-code (суурилагдсан)",
+ "engine.gcode.description": "3D принтер болон CNC машины замыг (G-code) PDF, SVG, зураг руу, эсвэл слайсерын өөрийн урьдчилан харах зураг руу.",
+ "engine.drawing.name": "Зураг төсөл (суурилагдсан)",
+ "engine.drawing.description": "Visio зураг (VSDX) болон OpenDocument зургийг (ODG, FODG) Visio эсвэл LibreOffice-гүйгээр PDF, SVG болон PNG болгоно.\nХуучин хоёртын Visio файлд (.vsd) LibreOffice хэрэгтэй хэвээр.",
+ "engine.model3d.name": "3D загвар (суурилагдсан)",
+ "engine.model3d.description": "3D загварууд: GLB, glTF, STL, OBJ, 3MF болон PLY хоорондоо, FBX-ийг эдгээрийн аль руу ч хөрвүүлэх ба PNG урьдчилж харах зураг үүсгэнэ.",
+ "engine.image3d.name": "Зургийг 3D рельеф болгох (суурилагдсан)",
+ "engine.image3d.description": "Зургийг хэвлэх эсвэл харахад зориулсан 3D рельеф загвар болгоно: цайвар хэсгүүд нь хавтгай суурин дээр илүү өндөр товойж гарна. GLB, glTF, STL, OBJ, 3MF болон PLY.",
+ "engine.geo.name": "Гео-орон зайн вектор өгөгдөл (суурилагдсан)",
+ "engine.geo.description": "Гео-орон зайн вектор өгөгдөл: GeoJSON, KML, KMZ, GPX, Shapefile болон CSV (уртраг/өргөрөг эсвэл WKT) хоорондоо хөрвүүлнэ.\nҮр дүн нь WGS 84 уртраг/өргөрөг байх ба проекцлогдсон Shapefile-уудыг (UTM, Web Mercator, Lambert, Albers) хөрвүүлнэ.",
+ "engine.archive.name": "Архив (суурилагдсан)",
+ "engine.archive.description": "ZIP, 7Z (solid ч гэсэн), RAR, TAR, XZ, LZMA, CAB, LHA, ARJ, ISO, UDF, IMG, DMG болон бусдыг задалж, ZIP, 7Z, TAR.GZ, TAR.BZ2, TAR.XZ, BZ2 болон XZ-ийг өөрөө шахна.\n7-Zip-тэй бол ховор хувилбаруудыг ч (Quantum CAB, олон боть ARJ, PPMd зэрэг 7Z арга, NTFS эсвэл APFS дискний дүрс…) боловсруулна.",
+ "engine.ebook.name": "Цахим ном (суурилагдсан)",
+ "engine.ebook.description": "EPUB, MOBI, AZW3, FB2 болон комик номыг (CBZ, CBR) PDF, EPUB, AZW3, MOBI, DOCX болон текст рүү хөрвүүлнэ.\nМөн баримтыг EPUB руу, EPUB-ийг Kobo-д зориулсан KEPUB руу, PDF-ийг CBZ руу хөрвүүлнэ.",
+ "engine.subtitle.name": "Хадмал орчуулга (суурилагдсан)",
+ "engine.subtitle.description": "Хадмал орчуулга: SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD болон SCC хоорондоо хөрвүүлж UTF-8 хэлбэрээр хадгална.\nCP949 / EUC-KR кодчилолтой солонгос SMI болон SRT файлуудыг зөв уншина.",
+ "engine.ffmpeg.description": "Видео болон аудио: MP4, MOV, MKV, WEBM, MP3, M4A, WAV, FLAC болон бусад.\nМөн дуу болон хадмал орчуулгыг салгаж авах, GIF болон APNG хөдөлгөөнт зураг хийх, зургийг HEIC хэлбэрээр хадгалах боломжтой.",
+ "engine.whisper.name": "Whisper",
+ "engine.whisper.description": "OpenAI Whisper дээр суурилсан яриаг текст болгох хөрвүүлэгч. Видео болон аудио доторх ярианаас SRT хадмал орчуулгыг автоматаар үүсгэнэ.",
+ "engine.pandoc.description": "Markdown болон HTML ↔ ODT болон RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter болон бусад ↔ Markdown, HTML, DOCX болон ODT\nMarkdown болон Org → PPTX слайд",
+ "engine.ghostscript.description": "EPS, PostScript болон хуучин Illustrator файлуудыг PDF болон зураг руу; PDF-ийг EPS болон PS рүү зөвхөн суурилагдсан PostScript бичигчийн дээр байрлуулсан үед хөрвүүлнэ.",
+ "engine.tesseract.description": "Сканнердсан баримт болон зургийн текстийг танина (OCR): хайлт хийх боломжтой PDF, DOCX, TXT.",
+ "engine.vision.description": "macOS-ийн өөрийн текст таних системээр (OCR) скан болон зургийн текстийг танина: хайлт хийх боломжтой PDF, DOCX, TXT. Нэмж юу ч татах шаардлагагүй.",
+ "engine.calibre.description": "Ховор цахим номын форматууд (LIT, LRF…) болон хуучин форматын гаралт (LIT, PDB, RB…).\nFilee буулгагч унтраалттай, Calibre HWPX бичигчийн дээр байвал цахим номын PDF-ийг мөн үүсгэнэ (урт номонд хамаагүй хурдан).",
+ "engine.weasyprint.description": "HTML хуудас, Markdown болон и-мэйлийг хөтөч хэвлэдэг шиг PDF болгоно: гарчиг, хүснэгт, тод бичвэр, араб болон хинди бичвэр зөв гарна.\nЗөвхөн баримтын хажууд байгаа зургийг ачаална, интернэтээс юу ч ачаалахгүй.",
+ "engine.libreoffice.description": "Хуучин болон ховор форматуудыг уншина: Word 6.0/95 DOC, PowerPoint 95 PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, хоёртын Visio (VSD)…\nМөн график, суулгасан объект эсвэл 3D агуулсан ODP болон ODG файлыг хөрвүүлж, DOC, XLS, PPT, ODT, OpenDocument загвар болон EMF хэлбэрээр хадгална.",
+ "engine.translator.description": "Офлайн Ollama загваруудыг ашиглан баримт болон цахим номыг таны компьютер дээр орчуулна.\nМөн SRT болон WebVTT хадмал орчуулгыг орчуулна: загвар руу зөвхөн хадмалын текстийг илгээж, дугаар болон цагийн тэмдэглэгээг хэвээр үлдээнэ.",
+ "engine.exiftool.description": "Зураг, PDF, видео болон дууны бүх мета өгөгдлийн талбарыг уншиж, файлын үндсэн агуулгад хүрэлгүйгээр ихэнх төрлийн файлд засварлана.\nМета өгөгдөл хэрэгсэлд ашиглагдана; үүнгүйгээр талбарууд зөвхөн унших горимоор харагдана.",
+ "profile.images": "Зураг",
+ "profile.pdf": "PDF",
+ "profile.office": "Баримт бичиг",
+ "profile.spreadsheets": "Хүснэгт",
+ "profile.presentations": "Үзүүлэн (слайд)",
+ "profile.hwp": "HWP / HWPX",
+ "profile.text": "Текст ба Markdown",
+ "profile.ebooks": "Цахим ном",
+ "profile.video": "Видео",
+ "profile.audio": "Аудио",
+ "profile.subtitles": "Хадмал орчуулга",
+ "profile.vector": "Вектор график",
+ "profile.archives": "Архив",
+ "profile.cad": "CAD зураг",
+ "profile.fonts": "Фонт",
+ "profile.data": "Өгөгдөл",
+ "profile.models": "3D загвар",
+ "profile.geo": "Гео-орон зайн өгөгдөл",
+ "profile.mixed": "Холимог файлууд",
+ "preset.crop": "Тайрах",
+ "crop.save": "Тайраад хадгалах",
+ "crop.reset": "Сэргээх",
+ "crop.skip": "Алгасах",
+ "crop.aspect_title": "Харьцаа:",
+ "crop.aspect_free": "Чөлөөт",
+ "crop.aspect_original": "Эх хувь",
+ "crop.aspect_custom": "Өөрийн харьцаа (харьцааны товчны хажууд Ө болон Өн утгыг бичнэ үү)",
+ "crop.custom_width": "Өргөн (W)",
+ "crop.custom_height": "Өндөр (H)",
+ "crop.page_counter": "{1}-ээс {0}",
+ "crop.save_next": "Тайраад дараагийнх",
+ "crop.swap": "Босоо ба хэвтээг солих (X)",
+ "crop.portrait": "Босоо",
+ "crop.landscape": "Хэвтээ",
+ "crop.hint_keys": "Сумтай товчоор хүрээг хөдөлгөнө, Shift × 10 · Esc хаана",
+ "crop.close_hint": "Хаах (Esc)",
+ "crop.maximize": "Томруулах",
+ "crop.restore": "Хэвийн болгох",
+ "crop.state_current": "Одоо нээлттэй",
+ "crop.state_waiting": "Хүлээж буй",
+ "crop.state_done": "Тайрагдсан",
+ "crop.state_skipped": "Алгассан",
+ "crop.state_failed": "Амжилтгүй",
+ "crop.perspective": "Перспектив",
+ "crop.perspective_tip": "Перспектив: хазайсан зургийг тэгшлэхийн тулд булан тус бүрийг тусад нь хөдөлгөнө үү",
+ "crop.perspective_pdf": "Перспектив зөвхөн зураг дээр ажиллана, PDF хуудас дээр ажиллахгүй",
+ "crop.hint_keys_perspective": "Tab товчоор булан сонгоно, сумтай товчоор хөдөлгөнө, Shift × 10 · Esc хаана",
+ "crop.corner_top_left": "Зүүн дээд булан",
+ "crop.corner_top_right": "Баруун дээд булан",
+ "crop.corner_bottom_right": "Баруун доод булан",
+ "crop.corner_bottom_left": "Зүүн доод булан",
+ "preset.hide": "Пикселжүүлэх / бүрсийлгэх",
+ "preset.hide.donut": "Пикселжүүлэх",
+ "error.cannot_hide": "Зөвхөн зургийг пикселжүүлэх эсвэл бүрсийлгэх боломжтой",
+ "error.background_picture": "Ард нь тавих зургийг унших боломжгүй байна",
+ "error.background_picture_changed": "Ард нь тавих зураг харуулсны дараа өөрчлөгдсөн байна. Арын зургийг дахин нэмнэ үү.",
+ "error.metadata_refused": "Мета өгөгдлийн зарим өөрчлөлтийг хүссэний дагуу бичих боломжгүй байлаа",
+ "hide.save": "Нуугаад хадгалах",
+ "hide.save_next": "Нуугаад дараагийнх",
+ "hide.pixelate": "Пикселжүүлэх",
+ "hide.pixelate_hint": "Дундаж өнгөтэй дөрвөлжнүүдээр бүрхэх (P)",
+ "hide.blur": "Бүрсийлгэх",
+ "hide.blur_hint": "Хүчтэй бүрсийлтээр бүрхэх (B)",
+ "hide.box": "Хар дөрвөлжин",
+ "hide.box_hint": "Цул хар дөрвөлжнөөр бүрхэх (X)",
+ "hide.strength": "Хүч",
+ "hide.strength_value": "10-аас {0} хүч (− / +)",
+ "hide.remove": "Хүрээг устгах",
+ "hide.remove_hint": "Сонгосон хүрээг устгах (Del)",
+ "hide.clear": "Бүгдийг цэвэрлэх",
+ "hide.empty_hint": "Нуухыг хүссэн хэсэг дээгүүр чирнэ үү",
+ "hide.hint_keys": "Tab хүрээ сонгоно, сумтай товч хөдөлгөнө, Shift × 10 · Del устгана · Ctrl+Z буцаана · Esc хаана",
+ "hide.state_done": "Дууссан",
+ "preset.clip": "Хэсэг таслах",
+ "preset.group_crop_video": "Видео тайрах",
+ "preset.group_crop_video.donut": "Видео тайрах",
+ "preset.crop_video_auto": "Хар хүрээг арилгах",
+ "preset.crop_video_auto.short": "Авто",
+ "preset.crop_video_16_9": "16:9 болгож тайрах",
+ "preset.crop_video_16_9.short": "16:9",
+ "preset.crop_video_9_16": "9:16 болгож тайрах",
+ "preset.crop_video_9_16.short": "9:16",
+ "preset.crop_video_4_3": "4:3 болгож тайрах",
+ "preset.crop_video_4_3.short": "4:3",
+ "preset.crop_video_1_1": "1:1 болгож тайрах",
+ "preset.crop_video_1_1.short": "1:1",
+ "preset.crop_video_21_9": "21:9 болгож тайрах",
+ "preset.crop_video_21_9.short": "21:9",
+ "clip.start": "Эхлэл",
+ "clip.end": "Төгсгөл",
+ "clip.length": "Урт",
+ "clip.timeline": "Цагийн шугам",
+ "clip.save": "Хэсгийг хадгалах",
+ "clip.save_next": "Хадгалаад дараагийнх",
+ "clip.seconds": "{0} с",
+ "clip.ringtone_limit": "iPhone хонхны ая хамгийн ихдээ {0} секунд байна",
+ "clip.length_hint": "Эхлэлээс хойш ийм хэдэн секундийн урттай хэсэг",
+ "clip.fast": "Хурдан, дахин кодлохгүй",
+ "clip.fast_hint": "Видеог байгаагаар нь хуулна: хормын дотор дуусаж чанар алдахгүй, гэхдээ хэсэг нь эхлэлийн өмнөх тулгуур кадраас (хэдэн секундийн өмнөөс) эхэлнэ. Яг сонгосон кадраар таслах бол унтраана уу (дахин кодлох тул удаан болно).",
+ "clip.hint_keys": "Space тоглуулна · C зүснэ · Del хасна (зай үлдээнэ), Shift+Del зайг арилгана · хэсгүүдийг чирэх эсвэл Ctrl / Shift+товшиж олноор сонгоно, Ctrl+A бүгдийг · ирмэгээс чирж тайрна · ← → 0.1 с-ээр, Shift 1 с-ээр · S / E / P эхлэл, төгсгөл эсвэл заагч · Ctrl+Z буцаана · Z томруулна · Enter хадгална · Esc хаана",
+ "clip.playhead": "Тоглуулах заагч",
+ "clip.play": "Дуутай тоглуулах (Space)",
+ "clip.pause": "Түр зогсоох (Space)",
+ "clip.fast_needs_encode": "Зүсэлт, хурд, дууны түвшин эсвэл уусах эффект орсон тул хэсгийг дахин кодлох шаардлагатай; байгаагаар нь хуулах боломжгүй.",
+ "clip.cut": "Зүсэх",
+ "clip.cut_hint": "Тоглуулах заагч дээр хэсгийг хоёр хуваах (C)",
+ "clip.delete": "Устгах",
+ "clip.restore": "Буцааж тавих",
+ "clip.delete_hint": "Сонгосон хэсгийг хасаж, зайг арилгах товчтой хоосон зай үлдээнэ (Delete); Shift+Delete зайг шууд арилгана. A1 дээр тухайн хэсгийн дууг хаана, A2 дээр хөгжмийг устгана",
+ "clip.undo": "Буцаах (Ctrl+Z)",
+ "clip.redo": "Дахин хийх (Ctrl+Y)",
+ "clip.magnet": "Соронз",
+ "clip.magnet_hint": "Соронз (M): тоглуулах заагч, хэсгийн ирмэг болон хөгжим нь зүсэлт болон заагч руу татагдаж наалдана",
+ "clip.zoom": "Томруулах",
+ "clip.zoom_hint": "Цагийн шугамыг хэсэг рүү ойртуулах эсвэл бүтэн видеог харахаар холдуулах (Z)",
+ "clip.sequence": "Дараалал: дүрс, дуу болон хөгжим",
+ "clip.close_gap": "Зайг арилгах",
+ "clip.no_music_yet": "A2: файлаас хөгжим оруулах (Хөгжим…)",
+ "clip.remove_music": "Хөгжмийг устгах",
+ "clip.mute": "Дууг хаах",
+ "clip.unmute": "Дууг нээх",
+ "clip.mute_hint": "Сонгосон хэсгийн дууг хаана; дүрс нь үргэлжлэн тоглоно (A1 дээр Delete дарахтай ижил)",
+ "clip.music": "Хөгжим",
+ "clip.music_hint": "Хэсгийн доор файлаас хөгжим оруулах (A2): чирж байрлуулна, шугамыг нь чирж дууны түвшинг тохируулна",
+ "clip.music_pick": "Хэсэгт зориулсан хөгжим",
+ "clip.music_files": "Дуу болон видео файлууд",
+ "clip.no_music": "Энэ файлд хөгжим болгон ашиглах дуу алга",
+ "clip.preparing": "Бэлтгэж байна…",
+ "clip.finding_silence": "Чимээгүй хэсгийг хайж байна…",
+ "clip.loading_music": "Хөгжмийг уншиж байна…",
+ "clip.selected_count": "{0} сонгогдсон",
+ "clip.volume": "Дууны түвшин",
+ "clip.volume_hint": "Сонгосон хэсгийн дууны түвшин (0–200 %)",
+ "clip.speed": "Хурд",
+ "clip.speed_hint": "Сонгосон хэсгийн хурд; дууны өнгө хэвээр хадгалагдана",
+ "clip.remove_silence": "Чимээгүй хэсгийг хасах",
+ "clip.remove_silence_hint": "Хэсгийн чимээгүй завсруудыг олж хасна (Ctrl+Z буцаана)",
+ "clip.close_gaps": "Зайнуудыг арилгах",
+ "clip.close_gaps_hint": "Хэсгүүдийг хассаны дараа үлдсэн бүх хоосон зайг арилгана",
+ "clip.fade_in": "Аажмаар тодрох",
+ "clip.fade_in_hint": "1 секундийн турш хар дэлгэц болон чимээгүй байдлаас аажмаар тодорч эхэлнэ",
+ "clip.fade_out": "Аажмаар бүдгэрэх",
+ "clip.fade_out_hint": "1 секундийн турш хар дэлгэц болон чимээгүй байдал руу аажмаар уусаж дуусна",
+ "clip.cannot_cut": "Тоглуулах заагчийг яг зүсэлт дээр бус, хэсгийн дотор байрлуулна уу",
+ "clip.last_piece": "Тоглож буй сүүлийн хэсгийг хасах боломжгүй",
+ "clip.silence_removed": "Чимээгүй хэсгийг хаслаа: {0}",
+ "clip.no_silence": "Чимээгүй хэсэг олдсонгүй",
+ "clip.cannot_play": "Хэсгийг тоглуулах боломжгүй",
+ "preset.half_size": "Тал хэмжээ",
+ "preset.half_size.donut": "Тал хэмжээ",
+ "preset.image_edit": "Зураг засах",
+ "preset.image_edit.donut": "Зураг засах",
+ "image_edit.count": "{0} зураг",
+ "image_edit.hint": "Эдгээр зургийн хэмжээ, формат болон эргүүлэлтийг сонгоно уу. Filee таны сонголтыг дараагийн удаад хадгална.",
+ "image_edit.convert": "Хөрвүүлэх",
+ "image_edit.width": "Өргөн",
+ "image_edit.height": "Өндөр",
+ "image_edit.unit": "Нэгж",
+ "image_edit.unit.Pixels": "пиксел",
+ "image_edit.unit.Centimetres": "см",
+ "image_edit.unit.Millimetres": "мм",
+ "image_edit.unit.Inches": "инч",
+ "preset.grayscale": "Саарал өнгөтэй",
+ "preset.remove_background": "Арын дэвсгэр арилгах",
+ "preset.remove_background.donut": "Дэвсгэр авах",
+ "preset.trim_transparent": "Тунгалаг захыг тайрах",
+ "preset.group_rotate": "Эргүүлэх",
+ "preset.rotate_left": "Зүүн тийш эргүүлэх",
+ "preset.rotate_left.short": "Зүүн",
+ "preset.rotate_right": "Баруун тийш эргүүлэх",
+ "preset.rotate_right.short": "Баруун",
+ "preset.rotate_180": "180° эргүүлэх",
+ "preset.rotate_180.short": "180°",
+ "preset.group_flip": "Толин тусгал",
+ "preset.annotate": "Тэмдэглэгээ хийх",
+ "preset.background": "Арын дэвсгэр нэмэх",
+ "preset.metadata": "Мета өгөгдөл",
+ "preset.flip_horizontal": "Хэвтээгээр эргүүлэх",
+ "preset.flip_horizontal.short": "Хэвтээ",
+ "preset.flip_vertical": "Босоогоор эргүүлэх",
+ "preset.flip_vertical.short": "Босоо",
+ "preset.merge_pdf": "PDF нэгтгэх",
+ "preset.merge_pdf.donut": "PDF нэгтгэх",
+ "merge.order_title": "Нэгтгэх дарааллыг сонгох",
+ "merge.order_hint": "Картуудын хооронд шилжихдээ сумтай товчийг ашиглана уу. Дарааллыг өөрчлөхийн тулд картыг чирэх эсвэл Alt + ↑ / Alt + ↓ дарна уу. Файлууд дээрээс доош дарааллаар нэгдэнэ. Delete товч сонгосон файлыг хасна.",
+ "merge.sort_by": "Эрэмбэлэх",
+ "merge.sort_name_asc": "Нэрээр (А-аас Я)",
+ "merge.sort_name_desc": "Нэрээр (Я-аас А)",
+ "merge.sort_modified_asc": "Өөрчилсөн огноо (хуучнаас нь)",
+ "merge.sort_modified_desc": "Өөрчилсөн огноо (шинээс нь)",
+ "merge.sort_created_asc": "Үүсгэсэн огноо (хуучнаас нь)",
+ "merge.sort_created_desc": "Үүсгэсэн огноо (шинээс нь)",
+ "merge.sort_custom": "Өөрийн дарааллаар",
+ "merge.images_hint": "Зургуудын хооронд шилжихдээ сумтай товчийг ашиглана уу. Дарааллыг өөрчлөхийн тулд зургийг чирэх эсвэл Alt + ↑ / Alt + ↓ дарна уу. Эхнийх нь дээр эсвэл зэрэгцүүлэх үед зүүн талд байрлана. Delete товч сонгосон зургийг хасна.",
+ "merge.vertical": "Дээрээс доош",
+ "merge.horizontal": "Зэрэгцүүлэх",
+ "merge.same_width": "Ижил өргөнтэй",
+ "merge.same_height": "Ижил өндөртэй",
+ "merge.same_size_tip": "Ирмэгүүд нь тэгш таарахаар зургуудын хэмжээг тааруулна. Унтраасан үед: тус бүр өөрийн хэмжээгээр голлож байрлана.",
+ "merge.gap": "Зай (пиксел)",
+ "merge.background": "Арын дэвсгэр",
+ "merge.transparent": "Тунгалаг",
+ "merge.transparent_tip": "PNG, WEBP болон тунгалаг байдлыг хадгалдаг бусад форматад зориулагдсан. JPG формат тэр хэсэгт цагаан өнгөтэй болно.",
+ "merge.music_hint": "Жагсаалт дахь дуунууд энд байгаа дарааллаараа видеоны доор ар араасаа тоглоно.",
+ "merge.add_music": "Хөгжим нэмэх…",
+ "merge.music_pick": "Видеонд зориулсан хөгжим",
+ "merge.loop_music": "Хөгжмийг давтах",
+ "merge.loop_music_tip": "Видео дуустал дуунуудыг эхнээс нь дахин тоглуулж, төгсгөлд нь дууг аажмаар намсгана. Унтраасан үед: сүүлийн дуу дуусмагц хөгжим зогсоно.",
+ "preset.group_pdf_edit": "Засах",
+ "preset.group_pdf_nup": "Нэг хуудсанд багтаах",
+ "preset.pdf_nup_2": "Нэг хуудсанд 2 нүүр",
+ "preset.pdf_nup_2.short": "2",
+ "preset.pdf_nup_4": "Нэг хуудсанд 4 нүүр",
+ "preset.pdf_nup_4.short": "4",
+ "preset.pdf_nup_6": "Нэг хуудсанд 6 нүүр",
+ "preset.pdf_nup_6.short": "6",
+ "preset.pdf_nup_8": "Нэг хуудсанд 8 нүүр",
+ "preset.pdf_nup_8.short": "8",
+ "preset.pdf_nup_9": "Нэг хуудсанд 9 нүүр",
+ "preset.pdf_nup_9.short": "9",
+ "preset.pdf_crop": "PDF тайрах",
+ "preset.pdf_crop.short": "Тайрах",
+ "preset.pdf_pages": "Хуудас эрэмбэлэх / устгах",
+ "preset.pdf_pages.short": "Хуудас",
+ "preset.pdf_add": "Хуудас нэмэх",
+ "preset.pdf_add.short": "Нэмэх",
+ "preset.pdf_extract": "Хуудас салгах",
+ "preset.pdf_extract.short": "Салгах",
+ "preset.split_pdf.short": "Хуваах",
+ "pdf_editor.title": "PDF хуудас засах",
+ "pdf_editor.hint": "Хуудсуудыг чирж дарааллыг өөрчилнө; Ctrl эсвэл Shift + товшиж олноор сонгоно. Сонгосон хуудсуудыг эргүүлэх, устгах, өөр PDF-ээс хуудас нэмэх, оруулах, солих эсвэл сонгосон хуудсуудыг тусдаа файл болгон салгаж авна уу.",
+ "pdf_editor.add": "PDF нэмэх",
+ "pdf_editor.insert": "PDF оруулах",
+ "pdf_editor.insert_before": "Сонгосон хуудсуудын өмнө",
+ "pdf_editor.insert_after": "Сонгосон хуудсуудын дараа",
+ "pdf_editor.replace": "PDF-ээр солих",
+ "pdf_editor.delete": "Устгах",
+ "pdf_editor.page": "Хуудас {0}",
+ "pdf_editor.rotation": "{0}° эргүүлсэн",
+ "pdf_editor.loading": "Хуудсуудыг ачаалж байна…",
+ "pdf_editor.saving": "PDF-ийг хадгалж байна…",
+ "pdf_editor.saved": "{0}-г хадгаллаа",
+ "preset.split_pdf": "PDF хуваах",
+ "preset.compress_pdf": "PDF шахах",
+ "preset.compress_pdf.donut": "Шахах",
+ "preset.searchable_pdf": "Хайлт хийх боломжтой PDF",
+ "preset.pdf_a": "PDF/A (архивын)",
+ "preset.long_image": "Урт зураг",
+ "preset.pdf_slides": "PPTX (слайд)",
+ "preset.extract": "Задлах",
+ "preset.zip_all": "Нэг ZIP",
+ "preset.zip_all.donut": "Нэг ZIP",
+ "preset.extract_subtitles": "Хадмал салгах",
+ "preset.caption_video": "Хадмал үүсгэх",
+ "preset.caption_video.donut": "Хадмал",
+ "preset.trace_svg": "SVG болгож мөшгөх",
+ "preset.trace_svg.donut": "Мөшгөх",
+ "preset.trace_line_art": "Зураасан зургийг SVG болгох",
+ "preset.trace_line_art.donut": "Зураасан зураг",
+ "preset.extract_frames_png": "Кадруудыг салгах (PNG)",
+ "preset.extract_frames_png.donut": "Кадр салгах (PNG)",
+ "preset.extract_frames_jpg": "Кадруудыг салгах (JPG)",
+ "preset.extract_motion": "Live Photo видеог салгах",
+ "preset.live_photo_iphone": "Live Photo (iPhone)",
+ "preset.motion_photo_android": "Motion Photo (Android)",
+ "preset.compress_image": "Зураг шахах",
+ "preset.compress_image.donut": "Зураг шахах",
+ "preset.compress_video": "Видео шахах",
+ "preset.compress_video.donut": "Видео шахах",
+ "preset.for_email": "Имэйлд зориулсан видео (18 МБ-аас бага)",
+ "preset.for_discord": "Discord-д зориулсан видео (10 МБ-аас бага)",
+ "preset.for_whatsapp": "WhatsApp-д зориулсан видео (720p, 16 МБ-аас бага)",
+ "preset.for_instagram": "Instagram-д зориулсан зураг (JPG, 1080 пиксел өргөн)",
+ "preset.video_from_images": "Зургуудаас видео хийх",
+ "preset.extract_audio": "Дууг салгах",
+ "preset.merge_videos": "Видео нэгтгэх",
+ "preset.merge_videos.donut": "Видео нэгтгэх",
+ "preset.compress_audio": "Аудио шахах",
+ "preset.merge_audio": "Аудио нэгтгэх",
+ "preset.merge_audio.donut": "Аудио нэгтгэх",
+ "preset.iphone_ringtone": "iPhone хонхны ая",
+ "preset.merge_images": "Зураг нэгтгэх",
+ "preset.merge_images.donut": "Зураг нэгтгэх",
+ "preset.remove_metadata": "Мета өгөгдөл устгах",
+ "preset.remove_metadata.donut": "Мета өгөгдөл устгах",
+ "format.folder": "Хавтас",
+ "format.category.Image": "Зураг",
+ "format.category.Pdf": "PDF",
+ "format.category.Document": "Баримт",
+ "format.category.Spreadsheet": "Хүснэгт",
+ "format.category.Presentation": "Үзүүлэн",
+ "format.category.Hwp": "HWP",
+ "format.category.Text": "Текст",
+ "format.category.Video": "Видео",
+ "format.category.Audio": "Аудио",
+ "format.category.Ebook": "Цахим ном",
+ "format.category.Archive": "Архив",
+ "format.category.Vector": "Вектор",
+ "format.category.Cad": "CAD",
+ "format.category.Font": "Фонт",
+ "format.category.Subtitle": "Хадмал",
+ "format.category.Data": "Өгөгдөл",
+ "format.category.Model3D": "3D загвар",
+ "format.category.Geo": "Гео-орон зай",
+ "portable.import_existing": "Одоо байгаа өгөгдлийг оруулах",
+ "portable.start_fresh": "Шинээр эхлэх",
+ "portable.import_summary": "Өөрийн тохиргоо, урьдчилсан тохиргоо, түүх болон татсан хөдөлгүүрүүдийг энэ зөөврийн (portable) хавтас руу хуулна. Эх файлууд өөрчлөгдөхгүй болон устахгүй.",
+ "portable.import_next": "Хуулсны дараа Filee таны сонгосон өгөгдлөөр үргэлжилнэ. Хэрэв тохиргоог сонгоогүй бол үндсэн тохиргоотойгоор нээгдэнэ. Энэ програмд өмнөх автоматаар эхлэх эсвэл файлын менежерийн бүртгэл байгаа бол тэдгээрийг устгах эсэхийг тусад нь баталгаажуулж асууна. Бусад суулгац өөрчлөгдөхгүй.",
+ "portable.fresh_next": "Шинээр эхлэх нь хуулах үйлдлийг алгасаж анхдагч тохиргоотойгоор нээгдэнэ. Таны өмнөх өгөгдөл байгаа газраа хэвээр үлдэнэ.",
+ "portable.choose_settings": "Тохиргоо",
+ "portable.choose_presets": "Урьдчилсан тохиргоо",
+ "portable.choose_profiles": "Профайлууд",
+ "portable.choose_history": "Түүх",
+ "portable.profile_dependencies": "Өөрийн үүсгэсэн профайлуудад харгалзах урьдчилсан тохиргоо нь хэрэгтэй байж болно. Тусгай үйлдлүүдийг хадгалахын тулд хоёуланг нь оруулна уу. Татсан хөдөлгүүрүүд хамаарах сангуудынхаа хамт хадгалагдана.",
+ "portable.choose_engines": "Татсан хөдөлгүүрүүд",
+ "portable.import_prompt": "{0}-аас одоо байгаа тохиргоо, урьдчилсан тохиргоо, түүх болон татсан хөдөлгүүрүүдийг {1} руу хуулах уу? Эх хувиуд өөрчлөгдөхгүй хэвээр үлдэнэ. Анхдагч тохиргоо ашиглах бол Шинээр эхлэх-ийг сонгоно уу.",
+ "portable.cleanup_prompt": "Энэ програмд хамаарах өмнөх автоматаар эхлэх болон файлын менежерийн бүртгэлүүдийг устгах уу? Бусад суулгацын бүртгэл хэвээр үлдэнэ.",
+ "startup.portable_unwritable": "Filee зөөврийн өгөгдлийн хавтас руугаа бичиж чадахгүй байна:\n{0}\n\nFilee-г бичих эрхтэй хавтас руу зөөх эсвэл энэ хавтаст бичих эрх олгоод дахин оролдоно уу.",
+ "general.portable_mode": "Зөөврийн горим: тохиргоо, татсан хөдөлгүүрүүд болон бүртгэлүүд зөөврийн өгөгдлийн хавтаст хадгалагдана. Системтэй хамт эхлэх болон файлын менежерийн холболт идэвхгүй байна.",
+ "general.title": "Ерөнхий",
+ "general.language": "Хэл",
+ "general.language_auto": "Системийн анхдагч",
+ "general.start_with_system": "Системд нэвтрэх үед Filee-г эхлүүлэх",
+ "general.show_in_dock": "Filee-г Dock дээр харуулах",
+ "general.show_menu_bar_icon": "Filee-г цэсний мөрөнд харуулах",
+ "general.colored_menu_bar_icon": "Цэсний мөрийн дүрсийг өнгөтөөр харуулах",
+ "general.dock_failed": "Filee-ийн Dock дүрсийг өөрчилж чадсангүй.",
+ "general.context_menu": "Explorer-ийн хулганы баруун товчны цэсэнд “Filee-ээр хөрвүүлэх”-ийг харуулах",
+ "general.context_menu_label": "Filee-ээр хөрвүүлэх",
+ "general.context_menu_macos": "Finder дээр файл дээр баруун товших үед “Filee-ээр хөрвүүлэх”-ийг харуулах",
+ "general.context_menu_linux": "Файлын менежерт “Filee-ээр хөрвүүлэх”-ийг нэмэх",
+ "general.integration_macos": "Finder үүнийг баруун товчны цэсэнд харуулна (цэс урт үед Quick Actions эсвэл Services дотор).\nFilee-г Applications хавтаст байлгана уу.",
+ "general.integration_linux": "Thunar, Nautilus, Nemo болон Dolphin-д файлын үйлдэл нэмнэ.\nThunar-ийн товчлолыг өөрчлөхийн тулд эхлээд Thunar-ийг хаагаад дараа нь дахин нээнэ үү.",
+ "general.integration_failed": "Системийн холболтыг хэрэгжүүлж чадсангүй: {0}",
+ "general.startup_entry_missing": "Нэвтрэх үед эхлэх бүртгэл хадгалсны дараахан устгагдсан эсвэл өөрчлөгдсөн байна. Хамгаалалтын програм хааж байж болзошгүй.",
+ "general.modern_menu_hint": "Windows 11 дээр энэ сонголт “Бусад сонголтыг харуулах” (Show more options) дотор ордог.\nҮүнийг үндсэн цэс рүү гаргах боломжтой (Windows нэг удаа администраторын зөвшөөрөл асууна).",
+ "general.modern_menu_on": "“Filee-ээр хөрвүүлэх” Windows 11-ийн үндсэн цэсэнд байна.",
+ "general.modern_menu_outdated": "Үндсэн цэсний бүртгэл нь Filee-ийн хуучин эсвэл зөөгдсөн хувилбарт хамаарч байна.\nШинэчлэхийн тулд дахин нэмнэ үү.",
+ "general.modern_menu_add": "Үндсэн цэсэнд нэмэх",
+ "general.modern_menu_remove": "Үндсэн цэснээс хасах",
+ "general.modern_menu_busy": "Windows-ийг хүлээж байна…",
+ "general.modern_menu_cancelled": "Цуцлагдлаа. Энэ сонголт “Бусад сонголтыг харуулах” дотор хэвээр үлдэнэ.",
+ "general.modern_menu_failed": "Windows бүртгэлийг нэмж чадсангүй: {0}",
+ "general.modern_menu_remove_failed": "Windows бүртгэлийг хасаж чадсангүй: {0}",
+ "general.default_output": "Хадгалах үндсэн байршил",
+ "general.default_output_hint": "“Хадгалах үндсэн байршил” гэж тохируулсан урьдчилсан тохиргоонууд энд хадгалагдана.\nӨөрийн байршилтай тохиргоо нь тэр байршлаа хадгална.",
+ "general.keep_dates": "Бүх хөрвүүлэлтэд эх файлын огноог хадгалах",
+ "general.keep_dates_hint": "Хөрвүүлсэн файлууд бүх урьдчилсан тохиргоон дээр эх файлын үүсгэсэн болон өөрчилсөн огноог авна.\nУнтраасан үед: зөвхөн \"Эх файлын огноог хадгалах\" сонголттой тохиргоонууд үүнийг хийнэ.",
+ "general.browser_drops": "Хөтчөөс чирж оруулсан файлууд",
+ "general.browser_drops_hint": "Товчийг удаан дараад Chrome, Edge, Firefox эсвэл Safari-аас зураг, файлын холбоос эсвэл таталтыг чирж гаргана уу: өөрийн файлуудтай адилаар цагираг самбар нээгдэнэ. Файл энэ хавтаст хадгалагдах ба эх файлын хажууд хадгалдаг тохиргоонууд хөрвүүлсэн файлыг мөн энд тавина.\nFilee таны хөтчийн нэвтрэх эрхгүйгээр холбоосыг өөрөө татаж авна.",
+ "general.browser_drops_folder": "Хадгалах хавтас",
+ "general.browser_drops_only_converted": "Зөвхөн хөрвүүлсэн файлыг хадгалах",
+ "general.browser_drops_only_converted_hint": "Эх файл түр хавтаст хүлээж байгаад хөрвүүлж дуусмагц устгагдана.",
+ "general.browser_drops_local_network": "Энэ компьютер болон дотоод сүлжээний холбоосыг зөвшөөрөх",
+ "general.browser_drops_local_network_hint": "Filee-д интранет эсвэл NAS зэрэг энэ компьютер болон дотоод сүлжээний холбоосыг татах боломж олгоно. Зөвхөн итгэлтэй хуудсуудаас чирж байгаа үедээ асаана уу.",
+ "general.network": "Сүлжээ",
+ "general.proxy": "Filee-ийн таталтад зориулсан прокси",
+ "general.proxy_hint": "Хөдөлгүүр татах, шинэчлэлт шалгах болон хөтчөөс чирсэн холбоосууд энэ проксигоор дамжина; бусад програмд нөлөөлөхгүй. Системийн проксиг ашиглах бол хоосон үлдээнэ үү. Энэ компьютер болон дотоод сүлжээний хаягууд руу үргэлж шууд хандана.",
+ "general.proxy_invalid": "Энэ прокси хаяг биш байна. http://127.0.0.1:7890 (http, https, socks4 эсвэл socks5) хэлбэрээр бичнэ үү.",
+ "general.zip_geo": "Гео-орон зайн бүх үр дүнг ZIP хэлбэрээр хадгалах",
+ "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX болон бусад гео-орон зайн үр дүнгүүд бүх урьдчилсан тохиргоон дээр нэг файлыг нэг ZIP болгож багцална.\nУнтраасан үед: зөвхөн \"Үр дүнг ZIP болгож хадгалах\" сонголттой тохиргоонууд үүнийг хийнэ.",
+ "general.check_updates": "Шинэчлэлтийг автоматаар шалгах",
+ "general.close_action": "Цонхыг хаах үед",
+ "general.close.Ask": "Тухай бүр асуух",
+ "general.close.Tray": "Filee-г системийн тавиурт ажиллуулсан хэвээр үлдээх",
+ "general.close.Quit": "Filee-ээс гарах",
+ "general.history": "Хөрвүүлэлтийн түүх",
+ "general.no_history": "Сүүлийн хөрвүүлэлтүүдийн түүхийг хадгалахгүй",
+ "general.history_limit": "Түүхэнд хадгалах хөрвүүлэлтийн тоо",
+ "general.clear_history": "Түүхийг цэвэрлэх",
+ "general.history_count": "Түүхэнд {0} хөрвүүлэлт байна",
+ "general.history_cleared": "Түүх цэвэрлэгдлээ.",
+ "general.check_now": "Одоо шалгах",
+ "general.update_available": "{0} хувилбар бэлэн боллоо",
+ "general.update_none": "Хамгийн сүүлийн хувилбар байна ({0})",
+ "update.sidebar": "{0} хувилбар руу шинэчлэх",
+ "update.notice_title": "Filee {0} хувилбар гарлаа",
+ "update.notice_text": "Шинэчлэх товчийг дарахад Filee шинэ хувилбарыг татаж суулгана. Таны тохиргоо хэвээр хадгалагдана.",
+ "update.download": "Татах",
+ "update.later": "Дараа",
+ "update.install": "Шинэчлэх",
+ "update.notice_text_portable": "Энэ нь зөөврийн (portable) хувилбар байна.\nШинэ zip файлыг татаж аваад энэ хавтас дээр дарж задална уу. Таны тохиргоо хэвээр үлдэнэ.",
+ "update.downloading": "Шинэчлэлтийг татаж байна… {0}%",
+ "update.downloading_short": "Татаж байна… {0}%",
+ "update.installing_short": "Шинэчилж байна…",
+ "update.installing": "Суулгагч ажиллаж байна. Дуусмагц Filee дахин нээгдэнэ.",
+ "update.failed": "Автоматаар шинэчилж чадсангүй. Шинэ хувилбарыг filee.sh хаягаас татна уу.",
+ "update.failed_alert": "Шинэчлэлтийг татаж чадсангүй.\nOK дарж filee.sh хуудсыг нээгээд шинэ хувилбарыг тэндээс татна уу.",
+ "update.unverified_alert": "Шинэчлэлт Filee-ээс ирсэн эсэхийг баталгаажуулж чадсангүй тул суулгасангүй.\nOK дарж filee.sh хуудсыг нээгээд шинэ хувилбарыг тэндээс татна уу.",
+ "update.rolled_back_alert": "Шинэчлэлтийг суулгаж чадсангүй тул Filee таны өмнөх хувилбарыг хэвээр үлдээлээ.\nOK дарж filee.sh хуудсыг нээгээд шинэ хувилбарыг тэндээс татна уу.",
+ "update.rolled_back_log": "Дэлгэрэнгүй мэдээлэл бүртгэл (log) дотор байна: {0}",
+ "update.cancelled": "Шинэчлэлт цуцлагдлаа. Суулгахын тулд Шинэчлэх товчийг дахин дарна уу.",
+ "update.level.Required": "Заавал хийх шинэчлэлт",
+ "update.level.Recommended": "Санал болгох шинэчлэлт",
+ "update.level.Optional": "Нэмэлт шинэчлэлт",
+ "update.level_hint.Required": "Таны файлуудад нөлөөлөх алдааг зассан. Шинэчилнэ үү.",
+ "update.level_hint.Recommended": "Алдаануудыг зассан.",
+ "update.level_hint.Optional": "Шинэ боломжууд. Хүссэн үедээ шинэчилнэ үү.",
+ "update.pill.Required": "Заавал",
+ "update.pill.Optional": "Сонголтоор",
+ "update.more_changes": "… болон дахин {0}",
+ "general.data_folder": "Тохиргооны хавтас",
+ "general.open_data_folder": "Нээх",
+ "general.data_folder_hint": "Filee таны тохиргоо, урьдчилсан тохиргоо, самбарууд, хөрвүүлэлтийн түүх болон бүртгэлийн файлуудыг энд хадгалдаг. Нөөц хуулбар хийх эсвэл бүртгэл харах бол нээнэ үү.",
+ "general.library": "Урьдчилсан тохиргоо ба самбарууд",
+ "general.export": "Экспортлох…",
+ "general.import": "Импортлох…",
+ "general.reset_library": "Анхдагч байдалд оруулах",
+ "general.export_hint": "Нөөцлөх эсвэл бусадтай хуваалцахын тулд урьдчилсан тохиргоо болон самбаруудаа файл болгон хадгална уу.",
+ "general.import_hint": "Файлаас урьдчилсан тохиргоо болон самбаруудыг нэмнэ. Танд аль хэдийн байгаа хуулбарууд солигдоно.",
+ "general.reset_hint": "Таны бүх урьдчилсан тохиргоо болон самбаруудыг суурилагдсан хувилбараар солино.",
+ "general.library_note": "Таны бусад тохиргоо өөрчлөгдөхгүй.",
+ "general.reset_confirm": "Бүх урьдчилсан тохиргоо болон самбарын профайлуудыг анхдагч утгаар солих уу?",
+ "general.imported": "{0} зүйлийг импортлолоо",
+ "triggers.title": "Товчлолууд",
+ "triggers.description": "Цагираг самбар хэрхэн нээгдэхийг сонгоно уу. Та хүссэн хэмжээгээрээ дохио зангаа (gesture) нэмэх боломжтой.",
+ "triggers.macos_permissions": "System Settings > Privacy & Security > Accessibility хэсэгт Filee-д зөвшөөрөл олгоно уу.\nХэрэв Filee аль хэдийн асаалттай боловч дохио ажиллахгүй бол − товчоор хасаад + товчоор дахин нэмнэ үү.\nСонголтын товчлолыг анх удаа ашиглахад Finder мөн Automation зөвшөөрөл асууна.",
+ "triggers.linux_x11": "Чирэх дохионууд X11 дээр ажиллана.\nФайлын менежерийн холболт асаалттай үед эхний гарын товчлол Thunar-ийн Filee үйлдлийг ажиллуулна (өөрчилсний дараа Thunar-иг дахин нээнэ үү).\nБусад файлын менежерт удаан дарах дохио болон товчлол ажиллахгүй. Filee цэс эсвэл чирж тавих хэсгийг ашиглана уу.",
+ "triggers.linux_gnome_wayland": "GNOME Wayland: Extensions програмаас “Filee donut at the cursor”-ийг идэвхжүүлээд, Files дотор Ctrl товч дарж файлаа чирнэ үү.\nӨргөтгөлийг анх суулгасны эсвэл шинэчилсний дараа ачаалахгүй бол системээс гараад дахин нэвтэрнэ үү. Администраторын эрх эсвэл input бүлэг шаардлагагүй.\nЧирэх дохио, тусгай товч, Түр зогсоох болон хасагдсан програмууд дэмжигдэнэ. Глобал сонголтын товчлол, удаан дарах дохио болон чирэх үед Tab/дугуйгаар эргүүлэх боломж энэ холболтод байхгүй.",
+ "triggers.gnome_waiting": "Filee GNOME өргөтгөлийг хүлээж байна.\nExtensions дотроос “Filee donut at the cursor”-ийг идэвхжүүлнэ үү. Суулгасан эсвэл шинэчилсний дараа шаардлагатай бол гараад дахин нэвтэрнэ үү. Чирж тавих хэсэг болон файлын менежерийн үйлдлүүд ажилласан хэвээр байна.",
+ "triggers.gnome_version_mismatch": "Ажиллаж буй Filee GNOME өргөтгөл нь Filee-ийн өөр хувилбарт хамаарч байна.\nGNOME суулгасан өргөтгөлийг ачаалахын тулд системээс гараад дахин нэвтэрнэ үү. Хэрэв хэвээр байвал Filee-г дахин суулгана уу.\nФайлын менежерийн үйлдлүүд болон чирж тавих хэсэг ажилласан хэвээр байна.",
+ "triggers.linux_wayland": "Хулганы заагчийг дагадаг дохионууд Wayland дээр ажиллахгүй.\nX11 сесс ашиглах, эсвэл файлын менежерийн цэс болон чирж тавих хэсгээр хөрвүүлнэ үү.\nThunar дахин эхэлсний дараа эхний гарын товчлолыг ашиглах боломжтой.",
+ "triggers.linux_wayland_experimental": "Wayland дээрх дохионууд туршилтын горимд байгаа бөгөөд KDE Plasma болон Hyprland дээр ажиллана.\nFilee хулганы оролтыг /dev/input-аас уншдаг. KDE Plasma дээр KWin скрипт цагирагийг заагч дээр байрлуулна; Hyprland дээр hyprland.conf дахь цонхны дүрэм гүйцэтгэнэ (INSTALL.txt-г харна уу). Бусад програм дээр Ctrl+чирэх үйлдэл тасалдаж болзошгүй тул саад болбол Ctrl+Shift зэрэг өөр товч ашиглана уу.",
+ "triggers.open_accessibility": "Accessibility тохиргоог нээх",
+ "triggers.retry_hook": "Дохиог дахин оролдох",
+ "triggers.hook_running": "Глобал оролт танигч ажиллаж байна.",
+ "triggers.hook_stopped": "Глобал оролт танигч зогссон байна.\nФайлын менежерийн үйлдлүүд болон чирж тавих хэсэг ажилласан хэвээр байна.",
+ "triggers.hook_failed": "Глобал оролт танигч амжилтгүй боллоо: {0}",
+ "triggers.hook_linux_backend": "X11 оролтын сангууд ачаалаагүй тул дохионууд эхэлж чадсангүй.\nX11 сесс ашиглаж libxtst6 болон libxt6 (Ubuntu/Debian: sudo apt install libxtst6 libxt6) суулгаад дахин оролдоно уу.\nФайлын менежерийн үйлдлүүд болон чирж тавих хэсэг ажилласан хэвээр байна.",
+ "triggers.add": "Дохио нэмэх",
+ "triggers.remove": "Устгах",
+ "triggers.enabled": "Асаалттай",
+ "triggers.kind": "Дохио",
+ "triggers.kind.Drag": "Нэмэлт товч + файл чирэх",
+ "triggers.kind.Hold": "Нэмэлт товч + удаан дарах",
+ "triggers.kind.KeyChord": "Гарын товчлол (сонгосон файлууд)",
+ "triggers.modifiers": "Нэмэлт товчнууд",
+ "triggers.button": "Хулганы товч",
+ "triggers.button.None": "Байхгүй",
+ "triggers.button.Left": "Зүүн",
+ "triggers.button.Right": "Баруун",
+ "triggers.button.Middle": "Дунд",
+ "triggers.button.X1": "Буцах (X1)",
+ "triggers.button.X2": "Урагшлах (X2)",
+ "triggers.key": "Товч",
+ "triggers.record": "Бичих",
+ "triggers.recording": "Товчны хослол дарна уу…",
+ "triggers.clear": "Цэвэрлэх",
+ "triggers.clipboard": "Түр санах ой (clipboard) дахь зүйлийг хөрвүүлэх",
+ "triggers.clipboard_note": "Эхлээд хуулсан файлуудыг, үгүй бол хуулсан зургийг (Control дарж авсан дэлгэцийн зураг) авна. Үр дүн нь түр санах ой дээр орлуулж тавигдах ба буулгахад бэлэн болно; зургийн үр дүн хөтчөөс татсан файлууд хадгалагддаг газар хадгалагдана. Аль нь ч байхгүй бол юу ч болохгүй.",
+ "triggers.recording_key": "Товч дарна уу…",
+ "triggers.flip_title": "Цагирагийг эргүүлэх",
+ "triggers.flip_description": "Цагираг нээлттэй байх үед энэ товч эсвэл хулганы дугуйг эргүүлэхэд ар талыг нь харуулна. Цагираг бүр ар талын тохиргоогоо Цагираг самбар хэсгээс авна.",
+ "triggers.flip_wheel": "Хулганы дугуй цагирагийг эргүүлнэ",
+ "triggers.flip_reset": "Tab ашиглах",
+ "triggers.flip_key_taken": "{0} товч цагирагийг эргүүлж чадахгүй: цагираг эсвэл өөр товчлол үүнийг аль хэдийн ашиглаж байна.",
+ "triggers.flip_key_unknown": "Энэ товчийг ашиглах боломжгүй. Өөр товч оролдоно уу.",
+ "triggers.threshold": "Чирэх зай",
+ "triggers.threshold_system": "Системийн анхдагч",
+ "triggers.hold_ms": "Удаан дарах хугацаа",
+ "triggers.scope": "Идэвхтэй орчин",
+ "triggers.scope.FileManager": "Файлын менежер ба дэлгэц (desktop)",
+ "triggers.scope.FileManager_macos": "Finder ба дэлгэц (desktop)",
+ "triggers.scope.FileManager_linux": "Файлын менежер ба дэлгэц (desktop)",
+ "triggers.scope.Anywhere": "Хаана ч",
+ "triggers.face": "Нээгдэх тал",
+ "triggers.face.Front": "Цагирагийн нүүр тал",
+ "triggers.face.Back": "Цагирагийн ар тал",
+ "triggers.face_note": "Тал бүрийг шууд нээхийн тулд хоёр дохионд өөр өөр нэмэлт товч онооно уу. Хэрэв ар талд тухайн файлд тохирох зүйл байхгүй бол нүүр тал нь нээгдэнэ.",
+ "triggers.excluded": "Эдгээр програм дээр хэзээ ч бүү идэвхжүүл",
+ "triggers.excluded_hint": "Програмын нэр бичээд Enter дарна уу, жишээ нь photoshop",
+ "triggers.excluded_pick": "Ажиллаж буй програм сонгох",
+ "triggers.excluded_none_running": "Нээлттэй цонхтой өөр програм алга.",
+ "triggers.excluded_note": "Хулганы заагч эдгээр програм дээр байх үед дохио болон товчлолууд ажиллахгүй.",
+ "triggers.paused": "Товчлолууд түр зогссон байна",
+ "toolbar.title": "Цагираг самбар",
+ "toolbar.step_profile": "1. Профайл сонгох",
+ "toolbar.step_arrange": "2. Урьдчилсан тохиргоог байрлуулах",
+ "toolbar.add_profile": "Шинэ профайл",
+ "toolbar.profile_up": "Профайлыг дээшлүүлэх",
+ "toolbar.profile_down": "Профайлыг доошлуулах",
+ "toolbar.profile_order_hint": "Хоёр профайл ижил өргөтгөлтэй бол дээр байрласан нь нээгдэнэ. Alt+↑ / Alt+↓ товчоор сонгосон профайлыг зөөнө.",
+ "toolbar.profile_new": "Шинэ профайл",
+ "toolbar.delete_profile": "Профайлыг устгах",
+ "toolbar.reset_profile": "Анхдагч байдалд оруулах",
+ "toolbar.reset_profile_confirm": "“{0}”-г анхдагч урьдчилсан тохиргоо болон өргөтгөлүүд рүү нь буцаах уу? Таны бусад профайл болон тохиргоонууд хэвээр үлдэнэ.",
+ "toolbar.name": "Нэр",
+ "toolbar.extensions": "Эдгээр өргөтгөлд ашиглагдана",
+ "toolbar.extensions_hint": "Өргөтгөл нэмэх, жишээ нь jpg",
+ "toolbar.available": "Боломжит урьдчилсан тохиргоо",
+ "toolbar.hint_add": "Нэмэхийн тулд чипийг цагираг дээр чирж тавина уу",
+ "toolbar.hint_remove": "Хасахын тулд хэсгийг буцааж энд чирнэ үү",
+ "toolbar.hint_reorder": "Дарааллыг өөрчлөхийн тулд хэсгүүдийг бие бие рүү нь чирнэ үү",
+ "toolbar.hint_edit": "Засахын тулд хэсэг дээр баруун товших эсвэл ✎ ашиглана уу",
+ "toolbar.group_hint": "Урьдчилсан тохиргоог жагсаалтаас чирж оруулна, хэсгүүдийг чирж дарааллыг өөрчилнө, хэсгийг жагсаалт руу чирвэл хасагдана.",
+ "toolbar.group_back": "{0} руу буцах",
+ "toolbar.max_reached": "Нэг цагираг хамгийн ихдээ {0} урьдчилсан тохиргоо багтаана",
+ "toolbar.size": "Хэмжээ",
+ "toolbar.edit_size": "Цагирагийн хэмжээг засах",
+ "toolbar.look_done": "Дууслаа",
+ "toolbar.look_hint": "Таны цагираг дэлгэц дээр бодит хэмжээгээрээ байна. Голоос нь чирж зөөнө, бүлэг дээр дарж нээлттэй байлгана.",
+ "toolbar.look_all": "Бүх цагираг",
+ "toolbar.look_this": "Энэ цагираг: {0}",
+ "toolbar.hole": "Голын нүх",
+ "toolbar.opacity": "Тунгалагжилт",
+ "toolbar.captions": "Гаралтын форматыг харуулах",
+ "toolbar.slice_labels": "Хэсгийн шошго",
+ "toolbar.label_scale": "Шошгоны хэмжээ",
+ "toolbar.ring_size": "Бүлгийн цагирагийн хэмжээ",
+ "toolbar.ring_spread": "Бүлгийн цагирагийн тэлэлт",
+ "toolbar.group_shown": "Харуулж буй бүлэг",
+ "toolbar.example_group": "Жишээ бүлэг",
+ "toolbar.example_none": "Энэ цагирагт бүлэг байхгүй",
+ "labels.auto": "Авто",
+ "labels.icon_text": "Дүрс ба текст",
+ "labels.text_only": "Зөвхөн текст",
+ "labels.icon_only": "Зөвхөн дүрс",
+ "toolbar.new_preset": "Шинэ тохиргоо",
+ "toolbar.new_group": "Шинэ бүлэг",
+ "toolbar.face_front": "Нүүр тал",
+ "toolbar.face_back": "Ар тал",
+ "toolbar.face_hint": "Нээлттэй цагирагийг эргүүлэх: {0}",
+ "toolbar.face_hint_wheel": "хулганы дугуй",
+ "toolbar.face_hint_off": "Нээлттэй цагирагийг эргүүлэхийн тулд Товчлолууд хэсгээс товч эсвэл хулганы дугуйг сонгоно уу",
+ "toolbar.tab.all": "Бүгд",
+ "toolbar.tab.media": "Видео ба аудио",
+ "toolbar.tab.edit": "Засах",
+ "toolbar.tab.model3d_cad": "3D ба CAD",
+ "group.editor_title": "Бүлэг",
+ "group.hint": "Бүлэг нь хэд хэдэн урьдчилсан тохиргоог багтаасан нэг цагирагийн хэсэг юм: дээр нь заагчаа барих эсвэл гадагш чиглүүлэхэд тэдгээр нь тойрон дэлгэгдэнэ.",
+ "group.members": "Урьдчилсан тохиргоо ({0} / {1})",
+ "group.empty": "Одоогоор урьдчилсан тохиргоо алга. Доороос нэмнэ үү.",
+ "group.add": "Нэмэх",
+ "group.add_placeholder": "Урьдчилсан тохиргоо сонгох",
+ "group.full": "Нэг бүлэг хамгийн ихдээ {0} урьдчилсан тохиргоо багтаана.",
+ "group.copy_front": "«Хавтас руу хуулах» энэ бүлэгт орох боломжгүй: энэ нь нүүр тал дээр бас байгаа.",
+ "group.move_up": "Дээшлүүлэх",
+ "group.move_down": "Доошлуулах",
+ "group.remove": "Бүлгээс хасах",
+ "group.delete": "Бүлгийг устгах",
+ "group.delete_confirm": "“{0}” бүлгийг устгах уу? Доторх урьдчилсан тохиргоонууд нь хэвээр үлдэнэ.",
+ "toolbar.ext_none": "Одоогоор өргөтгөл алга. Өргөтгөл нэмсний дараа л энэ профайл ашиглагдана.",
+ "toolbar.ext_invalid": "“{0}” нь файлын өргөтгөл биш байна (жишээ нь jpg эсвэл tar.gz).",
+ "toolbar.ext_duplicate": "{0} жагсаалтад аль хэдийн байна.",
+ "toolbar.ext_also_in": "Мөн “{0}” дотор бий",
+ "toolbar.ext_in_profile": "{0} дотор",
+ "toolbar.ext_claimed_first": "{0}: жагсаалтад эхэнд орсон “{1}” дотор мөн байгаа тул эдгээр файл “{1}”-ийг нээсэн хэвээр байна. Энд нээхийн тулд энэ профайлыг “{1}”-ийн дээр байрлуулна уу.",
+ "toolbar.ext_claimed_later": "{0}: “{1}” дотор мөн бий.\nЭнэ профайл жагсаалтад эхэнд орсон тул эдгээр файл одоо үүнийг нээнэ.",
+ "toolbar.ext_unknown": "{0}: Filee энэ төрлийн файлыг танихгүй байна.\nТа үүнийг үлдээж болох ч эдгээр файлыг хөрвүүлэх боломжгүй.",
+ "toolbar.ext_unknown_tip": "Үл мэдэгдэх файлын төрөл",
+ "toolbar.mixed_toggle": "Холимог сонголтод ашиглах (өөр өөр төрлийн файлууд)",
+ "toolbar.mixed_locked": "Холимог сонголтод зориулж Filee-д дор хаяж нэг профайл хэрэгтэй тул энэ нь хэвээр үлдэнэ.",
+ "toolbar.mixed_badge": "Холимог сонголтод",
+ "toolbar.mixed_title": "Сонгосон бүх файл дараах төрлүүдийн аль нэг нь байх үед энэ самбарыг харуулах:",
+ "toolbar.mixed_expand": "Өргөтгөлүүдийг харуулах",
+ "toolbar.mixed_other_group": "Бусад өргөтгөлүүд",
+ "toolbar.mixed_catch_all": "Юу ч тэмдэглээгүй: өөр ямар ч профайл аваагүй аливаа холимог файлд ашиглагдана.",
+ "toolbar.mixed_shadowed": "Юу ч тэмдэглээгүй тул жагсаалтын өмнө байгаа “{0}” бүх холимог файлыг авна.\nЭнэ профайлыг ашиглахын тулд зарим өргөтгөлийг тэмдэглэнэ үү.",
+ "toolbar.mixed_covered": "“{0}” эхэнд орсон бөгөөд энд тэмдэглэсэн бүх өргөтгөлийг зөвшөөрдөг тул энэ профайл хэзээ ч ашиглагдахгүй.",
+ "toolbar.mixed_others": "Бусад холимог файлууд “{0}”-ийг нээнэ.",
+ "toolbar.mixed_others_self": "Юу ч тэмдэглээгүй өөр холимог профайл үлдээгүй тул энэ профайл бусад бүх холимогт мөн ашиглагдана.",
+ "toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "Энэ оролтын формат энгийн хөрвүүлэлтээр явна",
+ "toolbar.chip_tip.ordinary_conversion.all": "Хөрвүүлэх боломжтой бүх оролтын формат энгийн хөрвүүлэлтээр явна",
+ "toolbar.chip_tip.ordinary_conversion.some": "Зарим оролтын формат энгийн хөрвүүлэлтээр явна",
+ "toolbar.chip_tip.makes_same": "→ Ижил формат",
+ "toolbar.chip_tip.makes_name": "→ {0}",
+ "toolbar.chip_tip.works_with": "Зөвшөөрөгдөх форматууд: {0}",
+ "toolbar.chip_tip.kind": "{0} ({1})",
+ "toolbar.chip_tip.name_separator": ", ",
+ "toolbar.chip_tip.names_more": "{0} +{1}",
+ "toolbar.chip_tip.more_shift": "дахин +{0} · Бүгдийг харах бол Shift",
+ "toolbar.chip_tip.shift_all": "Бүгдийг харах бол Shift",
+ "toolbar.chip_tip.any_file": "Дурын файл",
+ "tags.remove": "Хасах",
+ "presets.title": "Урьдчилсан тохиргоо",
+ "presets.add": "Шинэ",
+ "presets.add_empty": "Хоосон тохиргоо",
+ "presets.add_ready": "Бэлэн загвар, файл хаашаа орохоос хамааран:",
+ "presets.duplicate": "Хувилах",
+ "presets.delete": "Устгах",
+ "presets.save": "Хадгалах",
+ "presets.revert": "Буцаах",
+ "presets.name": "Нэр",
+ "presets.icon": "Дүрс",
+ "presets.icon_default": "Анхдагч",
+ "presets.icon_none": "Байхгүй",
+ "presets.icon_search": "Дүрс хайх",
+ "presets.icon_no_match": "Хайлтад тохирох дүрс олдсонгүй.",
+ "presets.short_name": "Богино нэр",
+ "presets.short_name_hint": "Нэрийн оронд цагираг дээр харагдана",
+ "presets.show": "Хэсэг дээр харуулах",
+ "presets.show_donut": "Цагирагийн тохиргоог ашиглах",
+ "presets.direction": "Текстийн чиглэл",
+ "labels.dir_straight": "Шулуун",
+ "labels.dir_out": "Төвөөс гадагш",
+ "labels.dir_ring": "Цагирагийн дагуу",
+ "icons.cat.files_and_folders": "Файл ба хавтас",
+ "icons.cat.pictures": "Зураг",
+ "icons.cat.crop_and_transform": "Тайрах ба хувиргах",
+ "icons.cat.pdf_and_pages": "PDF ба хуудас",
+ "icons.cat.video_and_audio": "Видео ба аудио",
+ "icons.cat.text_and_data": "Текст ба өгөгдөл",
+ "icons.cat.share_and_move": "Хуваалцах ба зөөх",
+ "icons.cat.general": "Ерөнхий",
+ "presets.target": "Хөрвүүлэх формат",
+ "presets.target_hidden": "Одоогийн сонгосон формат ({0}) зөвхөн унтраасан салбарт хамаарах тул жагсаалтад харагдахгүй байна. Өөр формат сонгохгүй бол энэ хэвээр үлдэнэ.",
+ "presets.same_as_source": "Эх файлтай ижил формат",
+ "presets.cad_version": "AutoCAD хувилбар",
+ "presets.cad_version.same": "Эх файлтай ижил",
+ "presets.cad_version_hint": "Хуучин хувилбарууд хуучин AutoCAD болон CAD програмууд дээр нээгдэнэ. 2007 оноос өмнөх хувилбарт текстийг тохирох кодын хуудсаар (Солонгос, Хятад, Япон …) болон бусад тэмдэгтийг \\U+ кодоор хадгалдаг. Тухайн хувилбарт байхгүй multileader, mesh зэрэг объектууд орхигдоно. 2007 оны DWG нь 2004 хэлбэрээр хадгалагдах ба AutoCAD 2007 үүнийг нээдэг.",
+ "presets.cad_colors": "Өнгө",
+ "presets.cad_colors.AsDrawn": "Зурснаар нь",
+ "presets.cad_colors.BlackAndWhite": "Хар ба цагаан",
+ "presets.cad_colors.Grayscale": "Саарал өнгөтэй",
+ "presets.cad_colors_hint": "DWG эсвэл DXF зураг PDF, SVG эсвэл зураг болох үед ашиглагдана. Хар ба цагаан нь нэг өнгийн хэвлэл шиг бүх шугам, текстийг хараар зурна; дүүргэсэн хэсгүүд тод байдлаасаа хамаарч саарал өнгөтэй болох тул цайвар дүүргэлт хар дөрвөлжин болохгүй. Саарал өнгө нь өнгө бүрийг саарал болгож, шар зэрэг цайвар шугамуудыг уншигдахуйц болгож бараантуулна.",
+ "presets.gcode_view": "Харагдац",
+ "presets.gcode_view.Top": "Дээрээс",
+ "presets.gcode_view.Isometric": "Изометр",
+ "presets.gcode_view.Thumbnail": "Слайсерын урьдчилан харах зураг",
+ "presets.gcode_view_hint": "Слайсер эсвэл CAM программын G-code файлыг PDF, SVG эсвэл зураг болгоход хэрэглэнэ. Шахалт ба зүсэлтийг өндрөөр нь бараанаас (доод) цайвар (дээд) өнгөөр будна; шилжих хөдөлгөөн нь нимгэн цэнхэр зураас (CNC-ийн хурдан шилжилт улаан). Слайсерын урьдчилан харах зураг нь PrusaSlicer, Cura, OrcaSlicer эсвэл Bambu Studio-гийн файлд хадгалсан зураг; SVG, PDF болон ийм зураггүй файлууд замыг дээрээс нь харуулна.",
+ "presets.group.image": "Зураг",
+ "presets.quality": "Чанар",
+ "presets.resize": "Хэмжээ өөрчлөх",
+ "presets.resize.None": "Анхны хэмжээ",
+ "presets.resize.Percent": "Хувиар томруулах/багасгах",
+ "presets.resize.LongEdge": "Урт талыг хязгаарлах",
+ "presets.resize.Width": "Өргөнийг заах",
+ "presets.resize.Custom": "Өргөн ба өндрийг заах",
+ "presets.resize.MinShortEdge": "Богино талын доод хэмжээг заах",
+ "presets.rotation": "Эргүүлэх",
+ "presets.rotation.None": "Эргүүлэхгүй",
+ "presets.rotation.Right90": "Баруун тийш 90°",
+ "presets.rotation.Left90": "Зүүн тийш 90°",
+ "presets.rotation.Rotate180": "180°",
+ "presets.flip": "Толин тусгал",
+ "presets.flip.None": "Эргүүлэхгүй",
+ "presets.flip.Horizontal": "Зүүн ↔ баруун",
+ "presets.flip.Vertical": "Дээд ↔ доод",
+ "presets.percent": "Хувь",
+ "presets.long_edge": "Урт тал (пиксел)",
+ "presets.width": "Өргөн (пиксел)",
+ "presets.height": "Өндөр (пиксел)",
+ "presets.lock_aspect": "Харьцааг хадгалах",
+ "presets.never_enlarge": "Хэзээ ч бүү томруул",
+ "presets.never_enlarge_hint": "Заасан өргөн болон өндөрт аль хэдийн багтаж буй зургууд хэвээр үлдэнэ.",
+ "presets.custom_size_empty_hint": "Харьцааг хадгалсан үед зөвхөн нөгөө талыг хязгаарлахын тулд нэг нүдийг хоосон орхино уу.",
+ "presets.custom_size_missing": "Өргөн болон өндрийг оруулна уу, эсвэл харьцааг хадгалаад дор хаяж нэгийг нь оруулна уу.",
+ "presets.min_short_edge": "Богино талын доод хэмжээ (пиксел)",
+ "presets.min_short_edge_hint": "Жижиг зургуудыг богино тал нь энэ урттай болтол томруулна. Том зургууд хэвээр үлдэнэ.",
+ "presets.dpi": "DPI",
+ "presets.dpi_keep": "Хэвээр үлдээх",
+ "presets.keep_metadata": "Мета өгөгдөл хадгалах (EXIF)",
+ "presets.keep_color_profile": "Өнгөний профайл хадгалах (ICC)",
+ "presets.keep_color_profile_hint": "Мета өгөгдлийг устгасан үед боломжтой.\nҮүнгүйгээр өргөн өнгөний хүрээтэй зургууд (утасны Display P3) өнгө нь гандсан мэт харагдаж болно.",
+ "presets.grayscale": "Саарал өнгөтэй",
+ "presets.remove_background": "Арын дэвсгэр арилгах",
+ "presets.remove_background_hint": "Цул арын дэвсгэрийг тунгалаг болгоно: гарын үсэг эсвэл тамганы ар талын цаас, логоны дэвсгэр гэх мэт.\nИрмэгийн дагуух өнгийг арын дэвсгэр гэж тооцдог тул зургийн эргэн тойронд бага зэрэг цаас үлдээнэ үү.\nJPG нь тунгалаг байдалгүй тул доорх өнгийг авна. Хүний зурагт тохиромжгүй.",
+ "presets.background_tolerance": "Арын дэвсгэр таних хүлцэл",
+ "presets.background_tolerance_hint": "Өндөр байх тусам сүүдэртэй цаас эсвэл толбо зэргийг илүү их арилгана. Хэт өндөр байвал нарийн зураасыг нимгэлнэ.\nХүрээн доторх цайвар дүүргэлтийг хэвээр үлдээхийн тулд \"Хүрээлэгдсэн хэсгийг хадгалах\"-ийг асаана уу.",
+ "presets.keep_enclosed_background": "Хүрээлэгдсэн хэсгийг хадгалах",
+ "presets.keep_enclosed_background_hint": "Зөвхөн гадна ирмэгт хүрч буй арын дэвсгэрийг арилгах тул логоны дотор талын цагаан хэсэг хэвээр үлдэнэ.\nГар бичмэл дээр унтрааж орхино уу (\"о\" зэрэг үсгийн доторх цаасыг үлдээх болно).",
+ "presets.background": "Тунгалаг хэсгийн арын өнгө",
+ "presets.tiff_compression": "TIFF шахалт",
+ "presets.webp_lossless": "Чанар алдахгүй WEBP",
+ "presets.multipage_tiff": "Бүх файлыг нэг олон хуудастай TIFF болгох",
+ "presets.multipage_tiff_hint": "Зураг бүр болон PDF эсвэл TIFF-ийн хуудас бүр дарааллаараа нэг хуудас болно.\nОлон хуудастай TIFF үүсгэхэд нэг PDF хангалттай.",
+ "presets.fits_tiff": "FITS (одон орон) → TIFF",
+ "presets.fits_tiff.Linear": "Шугаман: өгөгдлийг байгаагаар нь",
+ "presets.fits_tiff.Stretched": "Сунгасан, урьдчилж харах зураг шиг",
+ "presets.fits_tiff_hint": "Телескопын камерын зурагт зориулагдсан. TIFF нь файлын битийн гүнийг хадгална: 8 болон 16 бит хэвээр үлдэж, 32-бит болон хөвөгч таслалтай өгөгдөл 32-бит хөвөгч таслалтай болно.",
+ "presets.fits_debayer": "Өнгөт камерын FITS-ийг Debayer хийх",
+ "presets.fits_debayer_hint": "FITS зургуудыг автоматаар сунгана. Өнгөт камерын түүхий кадр нь толгой мэдээлэл дэх Bayer загвараар өнгөт болно; унтраасан үед хар цагаан хэвээр үлдэнэ.",
+ "presets.animation_speed": "Тоглуулах хурд",
+ "presets.animation_speed_hint": "Хөдөлгөөнт зургийн кадр бүрийг богино эсвэл урт хугацаагаар харуулна.\nТоглуулагчид секундэд хамгийн ихдээ 50 кадр харуулдаг тул хэт хурдан хөдөлгөөнт зураг зарим кадраа алгасна.",
+ "presets.animation_speed.original": "1× (анхны)",
+ "presets.save_every_frame": "Видеоны кадр бүрийг хадгалах",
+ "presets.save_every_frame_hint": "Видео нэг тогтсон зургийн оронд кадр бүрийн зурагтай хавтас болно.\nУрт видео олон файл үүсгэнэ.",
+ "presets.frame_interval": "N кадр тутамд",
+ "presets.frame_estimate": "30 fps видеоны нэг минут нь {0} зураг болох ба нэг видеонд хамгийн ихдээ {1} байна.",
+ "presets.extract_motion_photo": "Live/Motion Photo видеог салгах",
+ "presets.extract_motion_photo_hint": "Зураг нь өөртэйгөө хамт хадгалагдсан видео болно: Android Motion Photo доторх видео эсвэл iPhone Live Photo-ийн хажууд байгаа MOV.\nБусад зургууд хөдөлгөөнгүй зургийн видео болно.",
+ "presets.slide_seconds": "Нэг зурагт ногдох секунд",
+ "presets.crossfade": "Зургуудын хооронд уусгаж шилжих",
+ "presets.crossfade_hint": "Зураг бүр огцом солигдохын оронд нэг секундийн турш дараагийн зураг руу уусаж шилжинэ.",
+ "presets.group.live_photo": "Live / Motion Photo",
+ "presets.live_photo": "Видео болох хэлбэр",
+ "presets.live_photo.None": "Нэг тогтсон кадр",
+ "presets.live_photo.Apple": "Live Photo (iPhone): зураг + MOV",
+ "presets.live_photo.Android": "Motion Photo (Android): нэг JPG",
+ "presets.live_photo_hint.apple": "Богино хэсэг нь ижил нэртэй, нэгдсэн Live Photo танигчтай зураг болон MOV файл болно. Хоёуланг нь хамт Photos руу импортолно уу. Видеоны богино тал нь хамгийн ихдээ 1440 пиксел байна. Туршилтынх: бүх iPhone дээр хараахан баталгаажаагүй.",
+ "presets.live_photo_hint.android": "Pixel утас Motion Photo хадгалдаг шиг видеог дотроо агуулсан нэг JPG (NAME.MP.jpg) болно; Google Photos болон үүнийг уншдаг бусад галерейд зориулагдсан. Видеоны богино тал нь хамгийн ихдээ 1440 пиксел байна. Туршилтынх: бүх утсан дээр хараахан баталгаажаагүй.",
+ "presets.live_photo_start": "Хэсгийн эхлэл (с)",
+ "presets.live_photo_length": "Хэсгийн урт (с)",
+ "presets.live_photo_cover": "Зураг авах цэг (с)",
+ "presets.live_photo_quality": "Зургийн чанар",
+ "presets.live_photo_video_quality": "Видеоны чанар",
+ "presets.ringtone_start": "Хонхны аяны эхлэл (с)",
+ "presets.ringtone_length": "Хонхны аяны урт (с, дээд тал нь 40)",
+ "presets.ringtone_hint": "iPhone хонхны ая нь хамгийн ихдээ 40 секундийн AAC файл байдаг. Хонхны ая таны тохируулсан секундээс эхэлж, заасан уртаар таслагдана (хэзээ ч 40 с-ээс хэтрэхгүй).",
+ "presets.ask_ringtone": "Хэсгийг тухай бүр асуух («Хэсэг таслах»-ыг нээнэ)",
+ "presets.ico_sizes": "Дүрсний хэмжээнүүд",
+ "presets.group.pdf": "PDF",
+ "presets.merge": "Бүх файлыг нэг PDF болгож нэгтгэх",
+ "presets.source_bookmarks": "Эх файл бүрт хавчуурга (bookmark) нэмэх",
+ "presets.normalize_merge_paper": "Нэгтгэсэн бүх хуудсанд нэг цаасны хэмжээ ашиглах",
+ "presets.merge_paper": "Нэгтгэсэн PDF-ийн цаас",
+ "presets.merge_paper_hint": "Хуудас бүрийг сонгосон цаасанд тааруулж, тайралгүйгээр голлуулна. Зургийн хуудасны тохиргоо нэгтгэхээс өмнө хэрэглэгдэнэ.",
+ "presets.merge_paper_auto_hint": "Автомат чиглэлийг эхний хуудаснаас нэг удаа сонгоно. Жижиг хуудсуудыг тааруулж томруулна.",
+ "presets.page_size": "Хуудасны хэмжээ",
+ "presets.page_size.FitImage": "Зурагт тааруулах",
+ "presets.page_size.A4": "A4",
+ "presets.page_size.Letter": "Letter",
+ "presets.margin": "Захын зай (мм)",
+ "presets.pages_per_sheet": "Нэг хуудсанд багтах нүүр",
+ "presets.sheet_paper": "Цаас",
+ "presets.sheet_paper.A4": "A4",
+ "presets.sheet_paper.Letter": "Letter",
+ "presets.sheet_paper.A3": "A3",
+ "presets.sheet_paper.SameAsFirstPage": "Эхний хуудастай ижил",
+ "presets.sheet_orientation": "Чиглэл",
+ "presets.sheet_orientation.Auto": "Автомат",
+ "presets.sheet_orientation.Portrait": "Босоо",
+ "presets.sheet_orientation.Landscape": "Хэвтээ",
+ "presets.sheet_order": "Хуудасны дараалал",
+ "presets.sheet_order.AcrossFirst": "Хөндлөн, дараа нь доош",
+ "presets.sheet_order.DownFirst": "Доош, дараа нь хөндлөн",
+ "presets.sheet_margin": "Цаасны захын зай (мм)",
+ "presets.sheet_gap": "Хуудас хоорондын зай (мм)",
+ "presets.sheet_borders": "Хуудас бүрийн эргэн тойронд нарийн хүрээ татах",
+ "presets.render_dpi": "Буулгах DPI (PDF → зураг)",
+ "presets.page_range": "Хуудсууд",
+ "presets.page_range_hint": "Бүх хуудас, эсвэл жишээ нь 1-3, 5",
+ "presets.split": "Хуудас бүрийг нэг файл болгох",
+ "presets.ask_pages": "Тухай бүр асуух",
+ "presets.ask_pages_hint": "PDF-ийг цагираг дээр тавихад хуудсыг сонгох жижиг цонх нээгдэнэ. Дээрх муж нь тухайн цонхны анхны утга болох ба хянах хавтас болон командын мөрөнд ашиглагдана.",
+ "presets.ask_compression": "Тухай бүр асуух",
+ "presets.ask_compression_hint": "Файлуудыг цагираг дээр тавихад ойролцоогоор хэр хэмжээтэй болохыг харуулсан шахалтын түвшинг сонгох жижиг цонх нээгдэнэ. Дээрх тохиргоо нь цонхны анхны утга болох ба хянах хавтас болон командын мөрөнд ашиглагдана.",
+ "compression.title": "Шахалтыг сонгох",
+ "compression.files": "{0} файл",
+ "compression.size_now": "Одоогийн хэмжээ: {0}",
+ "compression.estimate_label": "Тооцоолсон хэмжээ",
+ "compression.estimating": "Хэмжээг тооцоолж байна…",
+ "compression.estimate": "Ойролцоогоор {0} ({1}% жижиг)",
+ "compression.not_smaller": "Ойролцоогоор {0}, одоогийнхоос жижиг биш.",
+ "compression.not_smaller_kept": "Жижиг болохгүй байх магадлалтай: эх файлыг хэвээр үлдээнэ.",
+ "compression.unknown": "Filee эдгээр файлын хэмжээг тооцоолж чадахгүй байна.",
+ "compression.estimate_hint": "Урьдчилсан тооцоо: бодит хэмжээ нь файл дотор юу байгаагаас хамаарна.",
+ "compression.estimate_hint_video": "Урьдчилсан тооцоо: видеоны бодит хэмжээ нь дүрснээсээ ихээхэн хамаарна (хөдөлгөөн багатай дэлгэцийн бичлэг хамаагүй жижиг гарна).",
+ "compression.remember": "Энэ урьдчилсан тохиргоонд сануулах",
+ "pages.title": "Хуудас сонгох",
+ "pages.file_of": "{1}-ээс {0} дахь файл",
+ "pages.count": "Энэ PDF доторх хуудас: {0}",
+ "pages.count_unknown": "Filee энэ PDF-ийн хуудасны тоог тоолж чадсангүй.",
+ "pages.range_hint": "Бүх хуудас, эсвэл жишээ нь 13-55 эсвэл 1-3, 8, 12-15",
+ "pages.selected": "Сонгосон хуудас: {1}-ээс {0}",
+ "pages.invalid": "1-ээс эхлэн хуудасны дугаарыг бичнэ үү, жишээ нь 13-55 эсвэл 1-3, 8, 12-15.",
+ "pages.past_the_end": "Энэ PDF {0} дугаар хуудсаар дуусч байна.",
+ "pages.one_pdf": "Нэг PDF",
+ "pages.groups": "Нэг файлд хэд хэдэн хуудас",
+ "pages.per_file": "нэг файл дахь хуудас",
+ "pages.equal_parts": "тэнцүү хэсэг",
+ "presets.page_output": "Хуудсуудыг хадгалах хэлбэр",
+ "presets.video_output": "Видеог хадгалах хэлбэр",
+ "presets.video_one": "Нэг видео",
+ "presets.video_parts": "Хэд хэдэн хэсэг",
+ "presets.split_seconds": "нэг хэсгийн секунд",
+ "presets.split_minutes": "нэг хэсгийн минут",
+ "presets.video_parts_hint": "Дахин кодлохгүйгээр байгаагаар нь хуулах тул чанар, хэмжээ болон кодекийн тохиргоо үйлчлэхгүй. Хэсэг бүр тулгуур кадр дээр дуусдаг тул хүссэнээс хэдэн секундээр урт байж болно.",
+ "presets.pdf_tables_note": "PDF-ээс зураасаар зурсан хүснэгт бүр тусдаа ажлын хуудас болно (дараагийн хуудас руу үргэлжилсэн хүснэгт нэг хуудас хэвээр үлдэнэ), тоо болон огноо бодит утгаараа орно. Хүснэгтээс гадуурх текст орхигдоно.",
+ "presets.document_tables_note": "Word баримт (DOCX) эсвэл вэб хуудаснаас (HTML) хүснэгт бүр тусдаа ажлын хуудас болох ба тоо, огноо бодит утгаараа орно. Хүснэгтээс гадуурх текст орхигдоно. DOC файлыг эхлээд DOCX болгож хөрвүүлнэ үү.",
+ "presets.pdf_compression": "Шахах",
+ "presets.pdf_compression.None": "Шахахгүй",
+ "presets.pdf_compression.Screen": "Хамгийн жижиг (зургууд 72 dpi)",
+ "presets.pdf_compression.Ebook": "Тэнцвэртэй (зургууд 150 dpi)",
+ "presets.pdf_compression.Print": "Өндөр чанартай (зургууд 300 dpi)",
+ "presets.pdf_compression_hint": "Зургуудыг жижигрүүлж JPEG хэлбэрээр хадгална.\nТекст, фонт болон зураасан зургууд хэвээр үлдэнэ.\nХэрэв үр дүн нь жижиг болохгүй бол эх файлыг хэвээр үлдээнэ.",
+ "presets.pdf_ghostscript": "Мөн Ghostscript-ийг турших (суулгасан үед)",
+ "presets.pdf_ghostscript_hint": "Ихэвчлэн илүү жижиг болдог боловч файлыг бүхэлд нь дахин бичнэ.\nХамгийн жижиг үр дүнг нь хадгалж авна.",
+ "presets.ocr": "Текст таних (OCR)",
+ "presets.ocr_hint": "Скан болон зургууд хайлт хийх, засах боломжтой тексттэй болно. Tesseract шаардлагатай (Тохиргоо → Хөдөлгүүрүүд).\nАль хэдийн тексттэй хуудсууд хэвээр үлдэнэ.",
+ "presets.ocr_hint_macos": "macOS-ийн өөрийн текст таних системээр (Apple Vision) скан болон зургууд хайлт хийх, засах боломжтой тексттэй болно. Уншиж чадахгүй хэлэнд Tesseract шаардлагатай (Тохиргоо → Хөдөлгүүрүүд).\nАль хэдийн тексттэй хуудсууд хэвээр үлдэнэ.",
+ "presets.ocr_language": "Текстийн хэл",
+ "presets.ocr_language_size": "{0} татах",
+ "presets.ocr_more_languages": "Текст дэх бусад хэлүүд",
+ "presets.ocr_add_language": "Хэл нэмэх",
+ "presets.ocr_remove_language": "Энэ хэлийг хасах",
+ "presets.ocr_languages_hint": "Зөвхөн текст дотор үнэхээр байгаа хэлүүдийг нэмнэ үү: хэл нэмэх тусам текст таних нь удааширна.",
+ "presets.pdf_keep_metadata": "Хуудас болон зургийн мета өгөгдлийг хадгалах (XMP)",
+ "presets.watermark": "Усан тэмдэг",
+ "presets.watermark_hint": "жишээ нь НОРОГ",
+ "presets.pdf_password": "Нээх нууц үг",
+ "presets.pdf_password_hint": "Нууц үггүй бол хоосон орхино уу. Энэ нь урьдчилсан тохиргоотой хамт хадгалагддаг тул зөвхөн энэ төрлийн файлд ашиглана уу. PDF/A нууц үгтэй байх боломжгүй тул PDF/A сонгосон үед идэвхгүй байна.",
+ "presets.page_numbers": "Хуудасны дугаар (доод хэсэгт 1 / 5)",
+ "presets.combine_pages_hint": "Бүх хуудсыг нэг зургийн доор ар араас нь байрлуулна.\nХэт урт үр дүнг форматын дээд хязгаарт тааруулж жижигрүүлнэ (JPG 65,500 пиксел, WebP 16,383 пиксел).",
+ "presets.combine_pages": "Хуудсуудыг нэг урт зураг болгож давхарлах (PDF хөрвүүлэх үед)",
+ "presets.trim_page_margins": "Хуудас бүрийн дээд ба доод хоосон зайг тайрах",
+ "presets.split_tall_image": "Форматын хязгаараас хэт урт бол: хэд хэдэн зураг болгож хуваах",
+ "presets.split_tall_image_hint": "Унтраасан үед: урт зургийг форматын хязгаарт багтааж жижигрүүлнэ (JPG 65,500 пиксел, WebP 16,383 пиксел).\nАсаасан үед: хэмжээг нь хэвээр хадгалж хуудас хооронд нь хэд хэдэн файл болгож хуваана (name_1, name_2, …).",
+ "presets.group.document": "Баримт бичиг",
+ "presets.pdfa": "PDF/A (архивын)",
+ "presets.leave_out_pictures": "Зургуудыг орхих (зөвхөн текст)",
+ "presets.leave_out_pictures_hint": "Унтраасан үед: зургууд Markdown файлын хажууд \"name_files\" хавтаст хадгалагдаж түүнээс холбогдоно.\nАсаасан үед: зөвхөн текст хадгалагдаж хавтас үүсэхгүй.",
+ "presets.target_folder": "Хавтас (архив задлах)",
+ "presets.group.media": "Видео ба аудио",
+ "presets.media_quality": "Чанар",
+ "presets.media_quality_hint": "Шинэ формат нь кодекуудыг байгаагаар нь багтаах боломжтой үед (iPhone MOV → MP4) видеог зөвхөн дахин савлана (rewrap).\nЧанар алдахгүй, HDR хэвээр үлдэж, хэдхэн секундийн дотор дуусна.\nЖижиг сонголт нь файлыг багасгахын тулд үргэлж дахин кодлоно.",
+ "presets.media_quality.High": "Өндөр (том файл)",
+ "presets.media_quality.Balanced": "Тэнцвэртэй",
+ "presets.media_quality.Small": "Жижиг (хуваалцахад тохиромжтой)",
+ "presets.max_height": "Хамгийн их өндөр",
+ "presets.max_height.original": "Анхны",
+ "presets.crop_frame": "Кадрыг тайрах",
+ "presets.crop_frame.auto": "Хар хүрээг арилгах",
+ "presets.max_size_hint_image": "0 = хязгааргүй. Том зургийг хязгаарт багттал нь эхлээд бага чанартайгаар, дараа нь жижиг хэмжээтэйгээр дахин хадгална.",
+ "presets.max_size_hint_video": "0 = хязгааргүй. Том видеог уртад нь тохирох битрейтээр дахин кодлоно.",
+ "presets.size_range": "Файлын хэмжээ",
+ "presets.min_size": "Хамгийн бага",
+ "presets.max_size_short": "Хамгийн их",
+ "presets.size_unit": "Нэгж",
+ "presets.size_limit": "Хэмжээний хязгаар",
+ "presets.min_size_hint_image": "0 = доод хязгааргүй. Жижиг зургийг өндөр чанартайгаар дахин хадгална; чанарын тохиргоогүй форматуудыг (PNG, GIF…) томруулахгүй бөгөөд оронд нь мэдэгдэл өгнө.",
+ "presets.size_range_invalid": "Хамгийн бага утга нь хамгийн их утгаасаа их байна.",
+ "presets.keep_pixels": "Хэмжээг хэвээр үлдээж, зөвхөн чанарыг бууруулах",
+ "presets.keep_pixels_hint": "Хязгаарт багтаахын тулд зургийн хэмжээг хэзээ ч жижигрүүлэхгүй. Хамгийн бага чанартай үед ч багтахгүй бол хөрвүүлэхгүй.",
+ "presets.audio_bitrate": "Аудио битрейт",
+ "presets.audio_bitrate.auto": "Автомат",
+ "presets.remove_audio": "Дууг устгах",
+ "presets.audio_channels": "Суваг",
+ "presets.audio_channels.0": "Эх файлтай ижил",
+ "presets.audio_channels.1": "Моно",
+ "presets.audio_channels.2": "Стерео",
+ "presets.sample_rate": "Түүвэрлэлтийн давтамж",
+ "presets.sample_rate.source": "Эх файлтай ижил",
+ "presets.wav_format": "Битийн гүн",
+ "presets.wav_format.Pcm16": "16-бит",
+ "presets.wav_format.Pcm8": "8-бит",
+ "presets.wav_format.Pcm24": "24-бит",
+ "presets.wav_format.Pcm32": "32-бит",
+ "presets.wav_format.Float32": "32-бит float",
+ "presets.video_codec": "Видео кодек",
+ "presets.video_codec_hint": "H.264 хаана ч тоглоно.\nHEVC файлууд ойролцоогоор хоёр дахин жижиг бөгөөд ихэнх утас, зурагт болон сүүлийн үеийн компьютер дээр тоглоно.\nAV1 бүр ч жижиг боловч кодлоход удаан бөгөөд сүүлийн үеийн төхөөрөмж шаардана.\nСонгосон кодек дээр аль хэдийн байгаа видеог зөвхөн дахин савлана.",
+ "presets.video_codec.h264": "H.264 (хаана ч тоглоно)",
+ "presets.video_codec.vp9": "VP9",
+ "presets.video_codec.hevc": "HEVC / H.265 (илүү жижиг)",
+ "presets.video_codec.av1": "AV1 (хамгийн жижиг, удаан)",
+ "presets.hardware_encoding": "Техник хангамжийн хурдасгуур ашиглах (график чип)",
+ "presets.hardware_encoding_hint": "График чип дээр H.264, HEVC болон AV1-ийг кодлоно.\nХамаагүй хурдан бөгөөд бага эрчим хүч зарцуулна.\nИжил файлын хэмжээнд чанар нь ялимгүй бага байна.\nТохирох чипгүй бол (AV1-д сүүлийн үеийнх шаардлагатай) видеог ердийнхөөр нь кодлоно.",
+ "presets.caption_video": "Хадмал орчуулга үүсгэх (Whisper)",
+ "presets.caption_video_hint": "Whisper ашиглан видео болон аудио дахь яриаг буулгаж SRT хадмал орчуулгын файл болгоно. TXT руу хөрвүүлбэл цагийн тэмдэглэгээгүйгээр зөвхөн ярьсан текстийг өгнө.",
+ "presets.spoken_language": "Ярианы хэл",
+ "presets.spoken_language.auto": "Автомат",
+ "presets.audio_codec": "Аудио кодек",
+ "presets.audio_codec.aac": "AAC",
+ "presets.audio_codec.alac": "ALAC (чанар алдахгүй)",
+ "presets.group.archive": "Архив",
+ "presets.archive_level": "Шахалт",
+ "presets.archive_level.Store": "Байхгүй (зөвхөн хадгалах)",
+ "presets.archive_level.Fast": "Хурдан",
+ "presets.archive_level.Normal": "Хэвийн",
+ "presets.archive_level.Maximum": "Дээд зэргийн (удаан)",
+ "presets.combine": "Бүх файлыг нэг архивт хийх",
+ "presets.extract_note": "Архив бүрийг тусдаа хавтаст задална. Хаашаа орохыг Гаралт хэсэгт тохируулна.",
+ "presets.group.trace": "Мөшгөх",
+ "presets.group.cad": "CAD зураг",
+ "presets.group.gcode": "G-code (3D хэвлэл, CNC)",
+ "presets.trace_note": "Зургийг (PNG, JPG, …) вектор дүрс болгон мөшгөх үед ашиглагдана.",
+ "presets.trace_mode": "Горим",
+ "presets.trace_mode.Color": "Өнгөт",
+ "presets.trace_mode.BlackAndWhite": "Хар ба цагаан (зураасан зураг)",
+ "presets.trace_colors": "Өнгө",
+ "presets.trace_detail": "Нарийвчлал",
+ "presets.trace_detail_hint": "Бага утга нь хүрээг зөөлрүүлж жижиг цэгүүдийг арилгана.\nӨндөр утга нь пикселүүдийг нарийн дагаж жижиг хэсгүүдийг хадгална.",
+ "presets.group.output": "Гаралт",
+ "presets.location": "Хадгалах газар",
+ "presets.location.Default": "Хадгалах үндсэн байршил (Тохиргоо > Ерөнхий)",
+ "presets.location.SameFolder": "Эх файлтай ижил хавтаст",
+ "presets.location.Subfolder": "Дэд хавтаст",
+ "presets.location.CustomFolder": "Тогтсон хавтаст",
+ "presets.location.Ask": "Тухай бүр асуух",
+ "ask.save_title": "Үр дүнг хадгалах нэр",
+ "ask.folder_title": "Үр дүнг хаана хадгалахыг сонгоно уу",
+ "presets.subfolder": "Дэд хавтасны нэр",
+ "presets.subfolder_only_several": "Зөвхөн нэг файл хэд хэдэн файл болох үед",
+ "presets.subfolder_only_several_hint": "Нэг файл болж хөрвүүлэгдсэн файл эх файлынхаа хажууд хадгалагдана; зөвхөн олон файл болсон нь (хуудсаар хуваасан PDF гэх мэт) дэд хавтаст орно.",
+ "presets.custom_folder": "Хавтас",
+ "presets.browse": "Сонгох…",
+ "presets.pattern": "Файлын нэр",
+ "presets.pattern_hint": "{name} {ext} {preset} {date} {time} {index}",
+ "presets.keep_dates": "Эх файлын огноог хадгалах",
+ "presets.keep_dates_hint": "Хөрвүүлсэн файлууд эх файлын үүсгэсэн болон өөрчилсөн огноог авдаг тул зургууд огноогоороо эрэмбэлэгдсэн хэвээр байна.",
+ "presets.zip_result": "Үр дүнг ZIP болгож хадгалах",
+ "presets.zip_result_hint": "Үр дүнгийн файлууд (Shapefile-ийн .shp, .shx, .dbf, .prj болон .cpg) задгай файлуудын оронд эх файлын хажууд нэг ZIP болж багцлагдана.",
+ "presets.replace_original": "Эх файлыг орлуулах",
+ "presets.replace_original_hint": "Үр дүнгийн файл эх файлын нэр болон байршлыг авч, эх файл Хогийн сав руу орно. Зөвхөн үр дүн нь ижил форматтай үед ажиллах ба хянах хавтас эсвэл хөтчөөс чирсэн файлд хэзээ ч ажиллахгүй. Эх файл Хогийн сав руу орох боломжгүй үед хэвээр үлдэж үр дүнг хуулбар болгон хадгална. \"Хадгалах газар\" дээр юу гэж зааснаас үл хамааран эх файлын хавтаст хадгалагдана.",
+ "presets.conflict": "Хэрэв файл аль хэдийн байвал",
+ "presets.conflict.Rename": "Дугаар нэмэх",
+ "presets.conflict.Overwrite": "Дарж бичих",
+ "presets.conflict.Skip": "Алгасах",
+ "annotate.select": "Сонгох",
+ "annotate.select_hint": "Тэмдэглэгээг сонгох, зөөх эсвэл хэмжээг өөрчлөх (V)",
+ "annotate.pen": "Үзэг",
+ "annotate.pen_hint": "Чөлөөтэй зурах (P)",
+ "annotate.arrow": "Сум",
+ "annotate.arrow_hint": "Сүүлээс үзүүр хүртэл сум зурах (A)",
+ "annotate.box": "Дөрвөлжин",
+ "annotate.box_hint": "Хүрээ зурах (B)",
+ "annotate.highlight": "Тодруулагч",
+ "annotate.highlight_hint": "Нэвт харагдах өнгөөр тэмдэглэх (H)",
+ "annotate.text": "Текст",
+ "annotate.text_hint": "Товшоод шошго бичих (T)",
+ "annotate.number": "Дугаар",
+ "annotate.number_hint": "Дараагийн дугаарыг нэмэх бол товшино уу (N)",
+ "annotate.thin": "Нарийн",
+ "annotate.medium": "Дунд",
+ "annotate.thick": "Өргөн",
+ "annotate.red": "Улаан",
+ "annotate.orange": "Улбар шар",
+ "annotate.yellow": "Шар",
+ "annotate.green": "Ногоон",
+ "annotate.blue": "Цэнхэр",
+ "annotate.black": "Хар",
+ "annotate.undo": "Буцаах (Ctrl+Z)",
+ "annotate.redo": "Дахин хийх (Ctrl+Shift+Z)",
+ "annotate.remove": "Сонгосон тэмдэглэгээг устгах (Del)",
+ "annotate.keys": "V сонгоно · Tab тэмдэглэгээ сонгоно, сум хөдөлгөнө · P A B H T N хэрэгсэл сонгоно · Del устгана · Ctrl+Z буцаана · Esc хаана",
+ "annotate.save": "Хадгалах",
+ "annotate.save_next": "Хадгалаад дараагийнх",
+ "background.background": "Арын дэвсгэр",
+ "background.gradient": "Градиент",
+ "background.color": "Өнгө",
+ "background.picture": "Зураг",
+ "background.from": "Эхний өнгө",
+ "background.to": "Хоёр дахь өнгө",
+ "background.choose": "Сонгох…",
+ "background.no_picture": "Зураг сонгоогүй",
+ "background.padding": "Дотор зай",
+ "background.corners": "Булангууд",
+ "background.shadow": "Сүүдэр",
+ "background.shadow_none": "Байхгүй",
+ "background.shadow_light": "Хөнгөн",
+ "background.shadow_soft": "Зөөлөн",
+ "background.shadow_strong": "Хүчтэй",
+ "background.canvas": "Дэвсгэр талбай",
+ "background.auto": "Авто",
+ "background.square": "Дөрвөлжин",
+ "background.landscape": "Хэвтээ (16:9)",
+ "background.portrait": "Босоо (4:5)",
+ "background.same_look": "Бүх зурагт ижил загвар",
+ "background.save": "Хадгалах",
+ "background.save_next": "Хадгалаад дараагийнх",
+ "background.gradient_sunset": "Нар жаргах",
+ "background.gradient_sky": "Тэнгэр",
+ "background.gradient_mint": "Гаа",
+ "background.gradient_peach": "Тоор",
+ "background.gradient_ocean": "Далай",
+ "background.gradient_lilac": "Голт бор",
+ "background.color_white": "Цагаан",
+ "background.color_paper": "Цаас",
+ "background.color_charcoal": "Нүүрсэн хар",
+ "background.color_navy": "Гүн хөх",
+ "background.color_sand": "Элс",
+ "background.color_sage": "Саарал ногоон",
+ "background.picture_unreadable": "Энэ зургийг унших боломжгүй байна. Өөр зураг, уусалт эсвэл өнгө сонгоно уу.",
+ "background.keys": "Enter хадгална · Esc хаана · Tab загвараар шилжинэ · сум сонгох эсвэл гулсуурыг хөдөлгөнө",
+ "metadata.search": "Талбар болон утга хайх",
+ "metadata.fields": "{0} талбар",
+ "metadata.location_warning": "Энэ файлтай хэн ч хаана авсныг нь харах боломжтой.",
+ "metadata.unwritable": "Бүх талбарыг харуулж байна. Filee энэ төрлийн файлын мета өгөгдлийг өөрчилж чадахгүй.",
+ "metadata.needs_exiftool": "ExifTool-гүйгээр энэ файлын талбаруудыг унших боломжгүй. Тохиргоо › Хөдөлгүүрүүд хэсгээс суулгана уу.",
+ "metadata.embedded": "Энэ файл мөн өөрийн гэсэн мета өгөгдөлтэй видео эсвэл хоёр дахь зургийг агуулж байгаа бөгөөд түүнийг энд харуулах болон цэвэрлэх боломжгүй. “{0}” нь тэдгээргүй цэвэр хуулбар үүсгэнэ.",
+ "metadata.location_kept": "Байршил устгах нь энэ файлын бүх байршлын мэдээлэлд хүрч чадахгүй байна. Үлдэх зүйлс: {0}.",
+ "metadata.location_tracks": "Видео мөн замдаа (экшн камер, машины хар хайрцаг, дрон) байршлын мэдээлэл хадгалж болно. Filee үүнийг харах эсвэл устгах боломжгүй.",
+ "metadata.location_unread": "ExifTool нь {0}-ийн зөвхөн эхний 1,000 бичлэгийг уншсан тул Байршил устгах нь үлдсэн хэсэгт байршил байгаа эсэхийг шалгаж чадахгүй.",
+ "metadata.needs_perl": "Энэ файлын талбаруудыг уншихын тулд ExifTool-д Perl шаардлагатай бөгөөд энэ компьютерт байхгүй байна.",
+ "metadata.install": "ExifTool суулгах",
+ "metadata.remove_location": "Байршил устгах",
+ "metadata.remove_all": "Бүгдийг устгах",
+ "metadata.remove_all_hint": "Чиглэл болон өнгөний профайлыг хэвээр үлдээнэ. Түгжигдсэн талбарууд үлдэж болно.",
+ "metadata.changes_one": "1 өөрчлөлт",
+ "metadata.changes": "{0} өөрчлөлт",
+ "metadata.replace": "Эх файлыг орлуулах",
+ "metadata.replace_hint": "Хуулбар нь эх файлын байрыг эзэлж, эх файл Хогийн сав руу орно",
+ "metadata.save": "Хадгалах",
+ "metadata.save_copy": "Хуулбар хадгалах",
+ "metadata.save_next": "Хадгалаад дараагийнх",
+ "metadata.add_field": "Талбар нэмэх",
+ "metadata.one_per_line": "Мөр бүрт нэг",
+ "metadata.remove_field": "Талбар устгах",
+ "metadata.remove_named": "{0}-г устгах",
+ "metadata.undo": "Буцаах",
+ "metadata.undo_named": "{0}-г буцааж тавих",
+ "metadata.spoken_removed": "{0}, устгагдлаа",
+ "metadata.read_only": "Энд өөрчлөх боломжгүй",
+ "metadata.pdf_note": "Өмнөх утгууд PDF дотор сэргээгдэх боломжтой хэвээр үлдэнэ. Мета өгөгдөл устгах нь тэдгээрийг бүрмөсөн устгана.",
+ "metadata.keys": "Enter талбар засах эсвэл хадгална · Shift+Enter шинэ мөр эхлүүлнэ · Esc цуцлах эсвэл хаана · Ctrl+F хайна",
+ "metadata.empty": "Энэ файлд мета өгөгдөл олдсонгүй.",
+ "metadata.too_much": "Энд нээхэд хэт их мета өгөгдөл агуулж байна",
+ "metadata.too_long": "Энд засахад хэт урт байна. Устгах боломжтой хэвээр.",
+ "metadata.list_cut_short": "ExifTool нь {0}-ийн зөвхөн эхний 1,000 бичлэгийг уншсан тул үлдсэнийг нь алдахгүйн тулд энэ талбарыг энд засах боломжгүй. Устгах боломжтой хэвээр.",
+ "metadata.unreadable": "Мета өгөгдлийг нь унших боломжгүй байна",
+ "metadata.group.location": "Байршил",
+ "metadata.group.camera": "Камер",
+ "metadata.group.exposure": "Гэрэлтүүлэг (Exposure)",
+ "metadata.group.description": "Тайлбар",
+ "metadata.group.keywords": "Түлхүүр үгс",
+ "metadata.group.document": "Баримт бичиг",
+ "metadata.group.file": "Файл",
+ "presets.editor_title": "Урьдчилсан тохиргоо засах",
+ "presets.delete_confirm": "“{0}”-г устгах уу? Мөн бүх самбар болон бүлгээс хасагдах болно.",
+ "presets.name_required": "Нэр оруулна уу",
+ "presets.select": "Засахын тулд урьдчилсан тохиргоо сонгоно уу",
+ "theme.title": "Загвар",
+ "theme.style": "Загварын хэв маяг",
+ "theme.style.Soft": "Filee Зөөлөн",
+ "theme.style.MaterialYou": "Material You",
+ "theme.style.soft_hint": "Зөөлөн дугуйрсан булан болон цайвар гадаргуутай сонгодог Filee харагдац.",
+ "theme.style.material_you_hint": "Динамик өнгөний гадаргуу болон дасан зохицох онцлох өнгө бүхий Google Material 3 дизайн.",
+ "theme.style.m3_badge": "Material 3",
+ "theme.style.m3_tag": "M3",
+ "theme.mode": "Харагдац",
+ "theme.mode.System": "Систем",
+ "theme.mode.Light": "Цайвар",
+ "theme.mode.Dark": "Бараан",
+ "theme.accent": "Онцлох өнгө",
+ "theme.presets_pastel": "Цайвар өнгөний багц",
+ "theme.presets_vibrant": "Тод өнгөний загвар",
+ "theme.preset.monochrome": "Хар цагаан",
+ "theme.preset.lavender": "Лаванда",
+ "theme.preset.peach": "Тоор",
+ "theme.preset.mint": "Гаа",
+ "theme.preset.rose": "Сарнай",
+ "theme.preset.sky": "Тэнгэр",
+ "theme.preset.amber": "Хув",
+ "theme.preset.sage": "Саарал ногоон",
+ "theme.custom_color": "Өөрийн өнгө",
+ "theme.corner_radius": "Булангийн зөөлөн байдал",
+ "theme.donut_opacity": "Цагираг самбарын тунгалагжилт",
+ "theme.system_font": "Системийн фонт ашиглах",
+ "theme.system_font_hint": "Бусад програмын адил текстийг таны системийн өөрийн фонтоор харуулна. Дутуу бичгийг Filee-ийн суурилагдсан фонтууд зурсан хэвээр байна.",
+ "theme.reduce_motion": "Хөдөлгөөнт эффектийг багасгах",
+ "theme.reduce_motion_hint": "Хуучин компьютерт тохиромжтой. Шилжилт болон цагирагийн ойх хөдөлгөөнийг унтраана.",
+ "theme.window_size": "Цонхны хэмжээ",
+ "theme.window_size_smaller": "Жижиг",
+ "theme.window_size_larger": "Том",
+ "theme.window_size_reset": "Сэргээх",
+ "theme.window_size_hint": "Filee-ийн цонх дахь бүх зүйлийг томруулж эсвэл жижигрүүлнэ. Аль ч цонхонд {0} + хулганы дугуй болон {0} + нэмэх / хасах товч ижил үйлдэл хийх ба {0} + 0 нь 100% руу буцаана.",
+ "theme.preview": "Урьдчилж харах",
+ "theme.preview_toggle": "Жишээ унтраалга (зөвхөн харахад зориулагдсан, тохиргоо биш)",
+ "theme.preview_toggle_hint": "Энэ загварт унтраалгууд хэрхэн харагдахыг харуулна. Ямар ч тохиргоог өөрчлөхгүй.",
+ "engines.title": "Хөрвүүлэх хөдөлгүүрүүд",
+ "engines.description": "Filee хөрвүүлэлт бүрт хамгийн сайн боломжит хөдөлгүүрийг сонгодог.\nХэд хэдэн хөдөлгүүр ижил үйлдэл хийх боломжтой үед дээр байрласан нь давуу эрхтэй.",
+ "engines.available": "Бэлэн",
+ "engines.unavailable": "Боломжгүй",
+ "engines.move_up": "Дээшлүүлэх",
+ "engines.move_down": "Доошлуулах",
+ "engines.recheck": "Дахин шалгах",
+ "engines.conversions": "{0} хөрвүүлэлт",
+ "engines.optional_title": "Нэмэлт хөдөлгүүрүүд",
+ "engines.optional_description": "Том хэмжээтэй хөдөлгүүрүүдийг зөвхөн таныг хүссэн үед л татаж авна.\nFilee шинэчлэгдэхэд эдгээр нь хэвээр үлдэж, устгах үед хамт устгагдана.",
+ "engines.all_title": "Бүх хөдөлгүүр",
+ "engines.update": "Шинэчлэх",
+ "engines.state.update_available": "Шинэ хувилбар {0}",
+ "engines.update_notice_title": "Таны суулгасан хөдөлгүүрүүдэд шинэ хувилбар гарлаа",
+ "engines.settings": "Тохиргоо",
+ "engines.all_description": "Дарааллыг өөрчлөхийн тулд картыг чирэх (эсвэл сумтай товчийг ашиглах) боломжтой.",
+ "engines.lo_title": "LibreOffice суулгасан үед",
+ "engines.lo_description": "LibreOffice илүү нарийвчлалтай гүйцэтгэдэг хөрвүүлэлтүүдийг хариуцаж авах боломжтой.\nLibreOffice байхгүй үед эдгээр унтраалга юу ч өөрчлөхгүй.",
+ "engines.lo_slides": "Үзүүлэн (слайд) → PDF хөрвүүлэхэд LibreOffice ашиглах",
+ "engines.lo_slides_hint": "Filee буулгагчийн оронд LibreOffice Impress байрлуулна.\nУнтраасан үед: слайдыг Filee буулгагч зурна; одоогоор зурж чадахгүй зүйлтэй (график…) үзүүлэн хуучин суурилагдсан аргаар явна.",
+ "engines.lo_documents": "Word баримт → PDF хөрвүүлэхэд LibreOffice ашиглах",
+ "engines.lo_documents_hint": "Хятад болон Япон цэг таслалын дүрмийг багтаасан Word дээрх шиг загвараар бууна.\nУнтраасан үед: суурилагдсан аргаар хөрвүүлнэ.",
+ "engines.lo_sheets": "Хүснэгт → PDF хөрвүүлэхэд LibreOffice ашиглах",
+ "engines.lo_sheets_hint_print": "LibreOffice Calc өөрийн фонтоор хэвлэнэ.\nУнтраасан үед: Filee өөрөө Excel шиг хэвлэх муж, хуудасны тохиргоо болон хуудас таслалттайгаар хэвлэнэ.",
+ "engines.filee_renderer": "Filee буулгагчийг ашиглах",
+ "engines.filee_renderer_hint": "Filee-ийн өөрийн хөдөлгүүр HWP, HWPX, Word баримт, текст, Markdown, HTML, цахим ном болон PowerPoint слайдыг PDF болгон байрлуулна.\nУнтраасан үед: эдгээр нь HWPX бичигч болон rhwp-ээр дамжина; одоогоор зурж чадахгүй зүйлтэй файлууд (график, босоо текст…) ч мөн адил.",
+ "engines.package.libreoffice.name": "LibreOffice (хуучин болон ховор форматууд)",
+ "engines.package.libreoffice.description": "Хуучин эсвэл ховор форматуудад шаардлагатай: Word 6.0/95 DOC, PowerPoint 95 PPT, WordPerfect, ClarisWorks·MacWrite, Apple Pages·Numbers·Keynote, Publisher, хоёртын Visio (VSD) болон бусад.\nМөн DOC·XLS·PPT·ODT·EMF хэлбэрээр хадгалж, үзүүлэн → PDF хөрвүүлэлтийг илүү нарийвчлалтай болгоно.\nWord, Excel, PowerPoint, OpenDocument, Visio (VSDX), PDF болон HWP үүнгүйгээр хөрвүүлэгдэнэ.",
+ "engines.package.pandoc.name": "Pandoc (Markdown, LaTeX, Org болон бусад)",
+ "engines.package.pandoc.description": "Markdown болон HTML ↔ ODT болон RTF, DOCX → Markdown, мөн LaTeX, Org, AsciiDoc, Typst, Jupyter болон бусад тэмдэглэгээний хэлийг нэмнэ.\nМөн Markdown-оос PPTX слайд үүсгэнэ.\nMarkdown болон HTML → PDF, DOCX болон HWPX үүнгүйгээр ажиллана.",
+ "engines.package.ghostscript.name": "Ghostscript (EPS ба PostScript)",
+ "engines.package.ghostscript.description": "EPS, PS болон хуучин Illustrator файлууд → PDF ба зураг.\nPDF эсвэл SVG → EPS/PS, SVG болон PDF-тэй нийцтэй AI файлууд үүнгүйгээр хөрвүүлэгдэнэ.",
+ "engines.package.tesseract.name": "Tesseract (текст таних, OCR)",
+ "engines.package.tesseract.description": "Сканнердсан PDF болон зургийн текстийг уншина: хайлт хийх боломжтой PDF, DOCX, TXT.\nХэл бүрийг анх удаа ашиглах үед татаж авна.",
+ "engines.package.exiftool.name": "ExifTool (мета өгөгдөл)",
+ "engines.package.exiftool.description": "Зураг, PDF, видео болон дууны бүх мета өгөгдлийн талбарыг уншиж, файлын үндсэн агуулгад хүрэлгүйгээр ихэнх төрлийн файлд засварлана.\nМета өгөгдөл хэрэгсэлд ашиглагдана; үүнгүйгээр талбарууд зөвхөн унших горимоор харагдана.",
+ "engines.package.ffmpeg.name": "FFmpeg (видео ба аудио)",
+ "engines.package.ffmpeg.description": "Видео болон аудио хөрвүүлэлт, видео → GIF эсвэл тогтсон зураг, видеоноос дуу салгах болон GIF → MP4 үйлдлүүдийг нэмнэ.\nЗөвхөн видео болон аудионд шаардлагатай.",
+ "engines.package.whisper.name": "Whisper (AI хадмал орчуулга)",
+ "engines.package.whisper.description": "Видео болон аудио дахь яриаг буулгаж SRT хадмал орчуулгын файл болгоно. Интернэт холболтгүйгээр офлайн ажиллана.",
+ "engines.package.calibre.name": "Calibre (ховор цахим номын форматууд)",
+ "engines.package.calibre.description": "Ховор цахим номын форматуудыг (LIT, LRF…) хөрвүүлж, хуучин форматаар (LIT, PDB, RB…) хадгална.\nEPUB, MOBI, AZW3, FB2 болон комик номыг үүнгүйгээр уншиж, Kindle номыг хадгална.",
+ "engines.package.weasyprint.name": "WeasyPrint (HTML, Markdown болон и-мэйлээс PDF)",
+ "engines.package.weasyprint.description": "HTML хуудас, Markdown болон и-мэйлээс (EML, MSG, MHTML) илүү сайн PDF гаргана: гарчиг, хүснэгт, тод бичвэр, баруунаас зүүн тийш болон энэтхэг бичгүүдийг хөтөч хэвлэдэг шиг гаргана.\nҮүнгүйгээр ч гэсэн дотоод замаар PDF болно.",
+ "engines.package.7zip.name": "7-Zip (ховор архивын хувилбарууд)",
+ "engines.package.7zip.description": "Filee-ийн өөрийн уншигчдын орхидог ховор архивын хувилбаруудыг задална: PPMd болон бусад ховор аргатай 7Z, Quantum CAB, олон боть ARJ, NTFS, exFAT эсвэл APFS дискний дүрс…\nZIP, 7Z, RAR, TAR, CAB, ISO, DMG болон бусад түгээмэл архив үүнгүйгээр ажиллана.",
+ "engines.package.size": "Татах {0} · Диск дээр {1}",
+ "engines.package.system_required": "Энэ компьютерт суулгасан хувилбарыг ашиглах.",
+ "engines.guidance.macos": "Энэ Mac-д зориулж {0}-г суулгаад “Миний хувилбарыг ашиглах…”-ийг сонгож програм эсвэл .app хавтсыг нь заана уу.",
+ "engines.guidance.macos.ffmpeg": "Homebrew-ээр суулгаад (brew install ffmpeg) “Дахин шалгах”-ийг сонгоно уу.\nЭсвэл “Миний хувилбарыг ашиглах…”-ийг сонгож bin хавтсыг нь заана уу.",
+ "engines.guidance.macos.ghostscript": "Homebrew-ээр суулгаад (brew install ghostscript) “Дахин шалгах”-ийг сонгоно уу.\nЭсвэл “Миний хувилбарыг ашиглах…”-ийг сонгож bin хавтсыг нь заана уу.",
+ "engines.guidance.macos.tesseract": "Homebrew-ээр суулгаад (brew install tesseract) “Дахин шалгах”-ийг сонгоно уу.\nЭсвэл “Миний хувилбарыг ашиглах…”-ийг сонгож bin хавтсыг нь заана уу.",
+ "engines.guidance.macos.exiftool": "Homebrew-ээр суулгаад (brew install exiftool) “Дахин шалгах”-ийг сонгоно уу.\nЭсвэл “Миний хувилбарыг ашиглах…”-ийг сонгож хавтсыг нь заана уу.",
+ "engines.guidance.macos.calibre": "Filee-ийн татдаг calibre нь macOS 14 эсвэл түүнээс хойших хувилбарыг шаарддаг. Энэ macOS-д тохирох сүүлийн хувилбар болох calibre 7.26-г calibre-ebook.com хаягаас суулгаад “Миний хувилбарыг ашиглах…”-ийг сонгож .app хавтсыг нь заана уу.",
+ "engines.guidance.linux": "Өөрийн дистрибьюшний багцын менежерээр {0}-г суулгаад “Миний хувилбарыг ашиглах…”-ийг сонгож програмын хавтсыг нь заана уу.",
+ "engines.guidance.linux.libreoffice": "Багцын менежерээрээ суулгаад (Ubuntu/Debian: sudo apt install libreoffice) “Дахин шалгах”-ийг сонгоно уу.",
+ "engines.guidance.linux.ghostscript": "Багцын менежерээрээ суулгаад (Ubuntu/Debian: sudo apt install ghostscript) “Дахин шалгах”-ийг сонгоно уу.",
+ "engines.guidance.linux.tesseract": "Багцын менежерээрээ суулгаад (Ubuntu/Debian: sudo apt install tesseract-ocr) “Дахин шалгах”-ийг сонгоно уу.",
+ "engines.guidance.linux.exiftool": "Багцын менежерээрээ суулгаад (Ubuntu/Debian: sudo apt install libimage-exiftool-perl) “Дахин шалгах”-ийг сонгоно уу.",
+ "engines.guidance.windows": "Filee-д энэ компьютерт зориулсан {0} татах холбоос алга.\n“Миний хувилбарыг ашиглах…”-ийг сонгож суулгасан хувилбарыг заана уу.",
+ "engines.use_own_app_folder": "{0}.app агуулсан хавтсыг сонгоно уу",
+ "engines.progress_detail": "{1}-ээс {0} · {2}/с · {3}",
+ "engines.eta.estimating": "үлдсэн хугацааг тооцоолж байна…",
+ "engines.eta.seconds": "{0} с үлдлээ",
+ "engines.eta.minutes": "{0} мин үлдлээ",
+ "engines.install": "Суулгах",
+ "engines.cancel": "Цуцлах",
+ "engines.remove": "Устгах",
+ "engines.remove_confirm": "{0}-г устгах уу? Та дараа нь дахин татаж авах боломжтой.",
+ "engines.state.own_copy": "Таны хувилбар",
+ "engines.source.download": "Filee-ийн татсан хувилбар",
+ "engines.source.system": "Энэ компьютер дээр",
+ "engines.source.version": "хувилбар {0}",
+ "engines.source.on_disk": "Дискэн дээр {0}",
+ "engines.other_copy_title": "Энэ компьютер дээр мөн байгаа",
+ "engines.other_copy_detail": "{0} (Filee-ийн татсан хувилбар устгагдана)",
+ "engines.use_other_copy": "Оронд нь үүнийг ашиглах",
+ "engines.use_other_copy_confirm": "{0} дахь хувилбарыг ашиглаж, Filee-ийн татсан {1}-г устгах уу? Та дараа нь дахин татаж авах боломжтой.",
+ "engines.other_copy_gone": "Тэр хувилбар олдохгүй эсвэл эхлэхгүй байна. Filee-ийн татсан хувилбарыг хэвээр үлдээлээ.",
+ "engines.install_filee_copy": "Filee-ийн хувилбарыг суулгах",
+ "engines.use_another": "Өөр хувилбар ашиглах…",
+ "engines.update_recommended": "Шинэчлэхийг зөвлөж байна: энэ хувилбар нь {0}, Filee нь {1} хувилбартай шалгагдсан.\nИхэнх хөрвүүлэлт ажилласан хэвээр байна.",
+ "engines.use_own": "Миний хувилбарыг ашиглах…",
+ "engines.use_own_hint": "Танд аль хэдийн байгаа юу? Програмыг нь зааж өгвөл Filee өөрөө татахын оронд түүнийг ашиглана.",
+ "engines.use_own_title": "Өөрийн {0} програмыг сонгоно уу",
+ "engines.use_own_missing": "Тэр хавтаст Filee-д хэрэгтэй бүх зүйл алга: {0}.",
+ "engines.stop_own": "Ашиглахаа болих",
+ "engines.remove_failed": "Хөдөлгүүр ашиглагдаж байна. Одоо явагдаж буй хөрвүүлэлтүүд дууссаны дараа дахин оролдоно уу.",
+ "engines.state.not_installed": "Суулгаагүй",
+ "engines.state.queued": "Хүлээж байна…",
+ "engines.state.downloading": "Татаж байна {0}%",
+ "engines.state.unpacking": "Суулгаж байна…",
+ "engines.state.installed": "Суулгагдсан",
+ "engines.state.failed": "Амжилтгүй: {0}",
+ "engines.offline": "Офлайн",
+ "engines.offline_hint": "Энд интернэт алга уу? Өөр компьютер дээр татсан файлаас суулгах, эсвэл тэндээс энэ компьютерт зориулж файлуудыг хадгалж авна уу.",
+ "engines.install_from_files": "Татсан файлуудаас суулгах…",
+ "engines.install_from_files_title": "{0}-ийн татсан файлуудыг сонгоно уу",
+ "engines.save_files": "Өөр компьютерт зориулж файлуудыг хадгалах…",
+ "engines.save_files_title": "{0}-ийн файлуудыг хаана хадгалахыг сонгоно уу",
+ "engines.save_files_done": "{0}-ийн файлууд {1} дотор хадгалагдлаа.\nТэдгээрийг нөгөө компьютер руу хуулаад тэндээс Офлайн → Татсан файлуудаас суулгах-ийг сонгоно уу. Filee-ийн ижил хувилбар байх шаардлагатай.",
+ "engines.save_files_failed": "Файлуудыг хадгалж чадсангүй: {0}",
+ "engines.copy_links": "Татах холбоосуудыг хуулах",
+ "engines.links_copied": "Татах холбоосууд түр санах ойд хуулагдлаа. Интернэттэй компьютер дээр файлуудыг татаж аваад, энд Офлайн → Татсан файлуудаас суулгах-ийг сонгоно уу.",
+ "engines.state.copying": "Хуулж байна {0}%",
+ "about.title": "Тухай",
+ "about.updates": "Шинэчлэлтүүд",
+ "common.search": "Хайх",
+ "common.retry": "Дахин оролдох",
+ "about.version": "Хувилбар {0}",
+ "about.license": "Filee нь MIT лицензийн дагуух чөлөөт програм хангамж юм.",
+ "about.notices": "Гуравдагч талын мэдэгдлүүд",
+ "about.github": "GitHub сан",
+ "about.support_title": "Filee-г хөгжүүлэхэд туслах",
+ "about.support_text": "Filee нь үнэгүй бөгөөд нээлттэй эх сурвалжтай.\nGitHub дээр од дарах, алдаа мэдээлэх, санаа өгөх эсвэл ивээн тэтгэх зэрэг нь бүгд тус болно.",
+ "about.sponsor": "Ивээн тэтгэх",
+ "sponsor.title": "Filee-г ивээн тэтгэх",
+ "sponsor.lead": "Filee үнэгүй бөгөөд үнэгүй хэвээр байх болно. Хөгжүүлэлтийг нь дэмжихийг хүсвэл өөрт тохирох хэлбэрээ сонгоно уу.",
+ "sponsor.note.github": "GitHub-ээр дамжуулан картаар сар бүр эсвэл нэг удаа",
+ "sponsor.note.fairy": "Солонгос карт болон хялбар төлбөр",
+ "sponsor.note.patreon": "Сар бүрийн гишүүнчлэл",
+ "sponsor.note.kofi": "Нэг удаагийн кофе, бүртгэл шаардлагагүй",
+ "sponsor.note.paypal": "PayPal-аар дурын дүнгээр",
+ "sponsor.thanks": "Ивээн тэтгэгчдэдээ баярлалаа",
+ "sponsor.star": "GitHub дээр ⭐ дарах нь үнэгүй бөгөөд бусад хүмүүст Filee-г олоход тусалдаг.",
+ "feedback.notice_title": "Filee танд тус болж байна уу?",
+ "feedback.notice_text": "GitHub дээр од дарах нь бусад хүмүүст Filee-г олоход тусалдаг.\nАсуудал болон шинэ санаануудыг мөн баяртайгаар хүлээн авна.",
+ "feedback.star": "GitHub дээр од дарах",
+ "feedback.issue": "Асуудал эсвэл санаа мэдээлэх",
+ "common.ok": "OK",
+ "common.cancel": "Цуцлах",
+ "common.save": "Хадгалах",
+ "common.close": "Хаах",
+ "common.delete": "Устгах",
+ "common.yes": "Тийм",
+ "common.no": "Үгүй",
+ "common.on": "Асаалттай",
+ "common.off": "Унтраалттай",
+ "setup.title": "Хөрвүүлэх хөдөлгүүрүүдийг сонгох",
+ "setup.description": "Зураг, PDF, Office баримт, HWP, цахим ном, архив, фонт, CAD болон 3D загваруудыг одоо шууд хөрвүүлэх боломжтой.\nЗөвхөн видео, аудио эсвэл ховор форматуудад зориулж хөдөлгүүр татна уу.",
+ "setup.note": "Та Тохиргоо → Хөдөлгүүрүүд хэсгээс хүссэн үедээ хөдөлгүүр суулгах эсвэл устгах боломжтой.",
+ "setup.install": "Сонгосныг суулгах",
+ "setup.later": "Дараа",
+ "setup.background": "Арын горимд үргэлжлүүлэх",
+ "setup.done": "Дууслаа",
+ "setup.selection": "Татах хэмжээ: {0}",
+ "setup.nothing_selected": "Юу ч сонгоогүй",
+ "installer.title": "Filee Суулгац",
+ "installer.install": "Суулгах",
+ "installer.update": "Шинэчлэх",
+ "installer.welcome.fresh": "Суулгагч нь энэ компьютерт Filee {0}-г суулгана.",
+ "installer.welcome.update": "Filee {0} суулгагдсан байна.\nСуулгагч үүнийг {1} болгож шинэчилнэ; таны тохиргоо, урьдчилсан тохиргоо болон хөдөлгүүрүүд хэвээр үлдэнэ.",
+ "installer.welcome.license": "Filee нь үнэгүй бөгөөд нээлттэй эх сурвалжтай (MIT лиценз).",
+ "installer.language": "Хэл",
+ "installer.options.title": "Сонголтууд",
+ "installer.options.folder": "Суулгах хавтас",
+ "installer.options.browse": "Өөрчлөх…",
+ "installer.options.space": "Filee-д диск дээр ойролцоогоор {0} сул зай шаардлагатай.",
+ "installer.options.later": "Та эдгээрийг дараа нь Filee доторх Тохиргоо → Ерөнхий хэсгээс өөрчлөх боломжтой.",
+ "installer.options.update_note": "Filee бусад тохиргоогоо хэвээр хадгална. Хүссэн үедээ Filee доторх Тохиргоо → Ерөнхий хэсгээс өөрчилнө үү.",
+ "installer.task.modern_menu": "Мөн зөвхөн “Бусад сонголтыг харуулах” дотор бус, Windows 11-ийн үндсэн цэсний дээд хэсэгт харуулах",
+ "installer.task.cli": "“filee” командыг PATH-д нэмэх (командын мөрөөс хөрвүүлэх)",
+ "installer.task.desktop_icon": "Дэлгэц дээр товчлол үүсгэх",
+ "installer.engines.when": "Filee эхлэхдээ таны сонгосон хөдөлгүүрүүдийг албан ёсны эх сурвалжаас нь татаж авна.",
+ "installer.progress.install": "Filee-г суулгаж байна",
+ "installer.progress.update": "Filee-г шинэчилж байна",
+ "installer.status.verifying": "Суулгацын файлыг шалгаж байна…",
+ "installer.status.closing": "Filee-г хааж байна…",
+ "installer.status.removing_old": "Өмнөх хувилбарыг устгаж байна (тохиргоо болон хөдөлгүүрүүд хэвээр үлдэнэ)…",
+ "installer.status.copying": "Filee-ийн файлуудыг хуулж байна…",
+ "installer.status.explorer_menu": "File Explorer-ийн цэсэнд Filee-г нэмж байна…",
+ "installer.status.finishing": "Дуусгаж байна…",
+ "installer.status.rolling_back": "Өөрчлөлтүүдийг буцааж байна…",
+ "installer.status.removing": "Filee-г устгаж байна…",
+ "installer.done.title": "Filee бэлэн боллоо",
+ "installer.done.text": "Filee нь taskbar-ийн мэдэгдлийн хэсэгт (tray) ажиллана.\n\nФайлуудыг хөрвүүлэхийн тулд идэвхжүүлэх товчийг удаан дарж чирэх, эсвэл File Explorer дотор баруун товшино уу.",
+ "installer.done.updated": "Filee шинэчлэгдлээ. Таны тохиргоо, урьдчилсан тохиргоо болон хөдөлгүүрүүд өмнөх шигээ хэвээр байна.",
+ "installer.done.engines": "Таны сонгосон хөрвүүлэх хөдөлгүүрүүд Filee эхлэх үед татагдана.",
+ "installer.done.launch": "Filee-г нээх",
+ "installer.warning.explorer_menu": "Windows нь Filee-г File Explorer цэсний дээд хэсэгт нэмж чадсангүй:\n\n{0}\n\nFilee нь “Бусад сонголтыг харуулах” дотор харагдсан хэвээр байна. Та Filee доторх Тохиргоо → Ерөнхий хэсгээс дахин оролдох боломжтой.",
+ "installer.warning.runtime": "{0} ажиллах боломжгүй: үүнд энэ компьютер дээр байхгүй байгаа Microsoft Visual C++ runtime ({1}) шаардлагатай. Filee-ийн бусад бүх зүйл ажиллана. Засахын тулд Microsoft-ийн Visual C++ Redistributable (x64)-ийг суулгана уу:\n{2}",
+ "installer.error.title": "Filee суулгагдсангүй",
+ "installer.error.uninstall_title": "Filee бүрэн устгагдсангүй",
+ "installer.error.space": "{0} диск дээр хангалттай сул зай алга: Filee-д {1} шаардлагатай боловч зөвхөн {2} сул байна.",
+ "installer.error.damaged": "Энэ суулгацын файл гэмтсэн байна. filee.sh хаягаас дахин татна уу.",
+ "installer.error.incomplete": "Энэ суулгацын файл дутуу байна, таталт дуусаагүй байж болзошгүй. filee.sh хаягаас дахин татна уу.",
+ "installer.error.files": "Суулгагч Filee-ийн файлуудыг бичиж чадсангүй: {0}",
+ "installer.error.unexpected": "Алдаа гарлаа: {0}",
+ "installer.error.windows": "Filee-д 64-битийн Windows 10 (1809 эсвэл түүнээс хойших хувилбар) шаардлагатай.",
+ "installer.error.rolled_back": "Суулгагч бүх зүйлийг буцаалаа: өмнөх Filee ажилласан хэвээр байна.",
+ "installer.error.undone": "Суулгагч өөрчлөлтүүдээ буцаалаа.",
+ "installer.error.nothing_changed": "Энэ компьютер дээр юу ч өөрчлөгдсөнгүй.",
+ "installer.error.log": "Дэлгэрэнгүй мэдээлэл бүртгэл (log) дотор байна: {0}",
+ "installer.uninstall.title": "Filee-г устгах",
+ "installer.uninstall.text": "Энэ нь Filee {0}-г File Explorer цэсний бүртгэл, системтэй хамт эхлэх бүртгэл болон татсан хөрвүүлэх хөдөлгүүрүүдийн хамт энэ компьютерээс устгана.",
+ "installer.uninstall.delete_settings": "Миний тохиргоо, урьдчилсан тохиргоо болон самбаруудыг мөн устгах",
+ "installer.uninstall.settings_where": "Тэдгээр нь {0} дотор байдаг. Filee-г дахин суулгахдаа буцааж авахын тулд хэвээр үлдээнэ үү.",
+ "installer.uninstall.button": "Устгах",
+ "installer.uninstall.progress": "Filee-г устгаж байна",
+ "installer.uninstall.done_title": "Filee устгагдлаа",
+ "installer.uninstall.done": "Filee энэ компьютер дээр байхгүй боллоо.",
+ "installer.uninstall.restart": "Ашиглагдаж байсан цөөн хэдэн файл компьютерийг дахин ачаалах үед устана. Гараар устгах зүйл байхгүй.",
+ "installer.uninstall.settings_kept": "Таныг Filee-г дахин суулгахад зориулж тохиргоонууд тань хэвээр үлдлээ.",
+ "installer.uninstall.not_installed": "Filee нь {0} дотор суулгагдаагүй байна.",
+ "installer.uninstall.still_running": "Filee ажилласан хэвээр байгаа тул хаах боломжгүй байлаа, иймд файлууд нь хэвээр үлдлээ. Нэвтэрсэн бүх хэрэглэгчийн бүртгэл дээр системийн тавиур дахь дүрсээс Filee-г хаагаад дахин устгана уу.",
+ "installer.uninstall.files_left": "{0} доторх зарим файлыг устгаж чадаагүй тул Filee суулгасан програмуудын жагсаалтад үлдлээ. Хамгаалалтын програм хааж байж болзошгүй. Компьютерийг дахин ачаалаад Filee-г дахин устгана уу.",
+ "installer.uninstall.cleanup_failed": "Filee таны бүртгэлд бүртгэгдсэн бүх зүйлийг устгаж чадсангүй. Системтэй хамт эхлэх бүртгэл эсвэл File Explorer цэсний бүртгэл үлдэж болох ч ямар ч үйлдэл хийхгүй. Та Тохиргоо > Програмууд > Эхлүүлэх (Startup) хэсгээс автоматаар эхлэх бүртгэлийг унтрааж болно.",
+ "installer.uninstall.files_kept": "Ашиглагдаж байсан цөөн хэдэн файл {0} дотор .old төгсгөлтэй нэрээр үлдлээ. Тэдгээр нь ямар ч үйлдэл хийхгүй тул та устгаж болно.",
+ "installer.options.hide": "Нуух",
+ "installer.minimize": "Жижигрүүлэх",
+ "installer.error.copy": "Дэлгэрэнгүйг хуулах",
+ "installer.error.copied": "Хуулагдлаа",
+ "watch.title": "Хянах хавтаснууд",
+ "watch.description": "Filee ажиллаж байх үед эдгээр хавтаст орсон файлууд автоматаар хөрвүүлэгдэнэ.\nТатаж буй болон хуулж буй файлуудыг дуустал нь хүлээнэ.",
+ "watch.add": "Хавтас нэмэх",
+ "watch.empty": "Одоогоор хянах хавтас алга.",
+ "watch.enabled": "Асаалттай",
+ "watch.remove": "Устгах",
+ "watch.folder": "Хянах хавтас",
+ "watch.folder_placeholder": "Хавтас сонгох",
+ "watch.preset": "Хөрвүүлэх тохиргоо",
+ "watch.originals": "Хөрвүүлсний дараа",
+ "watch.originals_keep": "Эх файлыг байгаа газарт нь үлдээх",
+ "watch.originals_move": "Эх файлыг \"originals\" хавтас руу зөөх",
+ "watch.originals_hint": "Эх файлуудыг зөөх нь хавтсыг ирсэн зурвасын хайрцаг (inbox) шиг ажиллуулах ба Filee эхлэх үед тэнд хүлээж байсан файлуудыг мөн хөрвүүлнэ.",
+ "watch.output": "Хөрвүүлсэн файлуудыг хадгалах газар",
+ "watch.output_placeholder": "Хянаж буй хавтас доторх \"converted\" хавтас",
+ "watch.subfolders": "Дэд хавтаснуудыг хамруулах",
+ "watch.options": "Нэмэлт сонголтууд (заавал биш)",
+ "watch.include": "Хөрвүүлэх файлууд",
+ "watch.include_placeholder": "Өргөтгөл эсвэл нэр бичээд Enter дарна уу (жишээ нь heic, scan_*)",
+ "watch.include_hint": "Бүх файлыг хөрвүүлэх бол хоосон орхино уу.\n· heic → .heic өргөтгөлтэй файлууд\n· scan → нэрэндээ \"scan\" агуулсан файлууд\n· * дурын текстийг, ? нэг тэмдэгтийг орлоно (жишээ нь scan_*.jpg)",
+ "watch.name": "Үндсэн нэр",
+ "watch.name_placeholder": "Урьдчилсан тохиргоотой ижил: {0}",
+ "watch.name_hint": "Товчнууд нэрийн хэсгүүдийг нэмнэ. Урьдчилсан тохиргооны нэрийг ашиглах бол хоосон орхино уу.",
+ "watch.rename": "Хөрвүүлсэн файлуудын нэр",
+ "watch.rename_find": "Хайх, жишээ нь IMG_(\\d+)",
+ "watch.rename_replace": "Орлуулах, жишээ нь Photo_$1",
+ "watch.name_summary": "Нэр: {0}",
+ "watch.name_summary_preset": "Нэр: {0} (урьдчилсан тохиргоотой ижил)",
+ "watch.token.name": "Эх нэр",
+ "watch.token.ext": "Эх өргөтгөл",
+ "watch.token.date": "Огноо",
+ "watch.token.time": "Цаг",
+ "watch.token.index": "Дугаар",
+ "watch.token.preset": "Тохиргооны нэр",
+ "watch.kind.Replace": "Текст орлуулах",
+ "watch.kind.Remove": "Текст устгах",
+ "watch.kind.Prefix": "Эхэнд нь нэмэх",
+ "watch.kind.Suffix": "Төгсгөлд нь нэмэх",
+ "watch.kind.SpacesToUnderscores": "Хоосон зайг _ болгох",
+ "watch.kind.RemoveCopyNumber": "(2) гэх мэт хуулбарын дугаарыг устгах",
+ "watch.kind.RemoveBrackets": "Хаалт доторх текстийг устгах",
+ "watch.kind.RemoveLeadingNumber": "Эхэнд байгаа дугаарыг устгах",
+ "watch.kind.RemoveSymbols": "Тэмдэгтүүдийг устгах",
+ "watch.kind.Lowercase": "бүх үсгийг жижиг болгох",
+ "watch.kind.Uppercase": "БҮХ ҮСГИЙГ ТОМ БОЛГОХ",
+ "watch.kind.Regex": "Нарийвчилсан: энгийн илэрхийлэл (regex)",
+ "watch.ph.find": "Хайх текст, жишээ нь IMG_",
+ "watch.ph.replace": "Орлуулах текст, жишээ нь Photo_",
+ "watch.ph.remove": "Устгах текст, жишээ нь _copy",
+ "watch.ph.prefix": "Нэмэх текст, жишээ нь 2026_",
+ "watch.ph.suffix": "Нэмэх текст, жишээ нь _final",
+ "watch.advanced": "Нарийвчилсан",
+ "watch.recipe.camera_to_photo": "IMG_-г Photo_ болгож өөрчлөх",
+ "watch.recipe.lowercase": "Бүгдийг жижиг үсгээр",
+ "watch.rename_edit": "Нэрлэх дүрмийг засах",
+ "watch.rename_title": "Хөрвүүлсэн файлуудыг нэрлэх",
+ "watch.rename_intro": "Үндсэн нэрийг тохируулаад шаардлагатай бол дүрмээр хэсгүүдийг нь өөрчилнө үү.\nУрьдчилж харах хэсэгт файл бүрийн авах нэр харагдана.",
+ "watch.rename_steps": "Өөрчлөлтүүд (дээрээс доош дарааллаар хэрэгжинэ)",
+ "watch.rename_no_steps": "Өөрчлөлт алга. Зүүн талаас сонгох эсвэл шаардлагатай бол нэгийг нэмнэ үү.",
+ "watch.step_up": "Дээшлүүлэх (түрүүлж ажиллана)",
+ "watch.rename_try": "Нэр турших",
+ "watch.rename_try_placeholder": "Файлын нэр бичнэ үү, жишээ нь IMG_0412.jpg",
+ "watch.include_matches": "Хавтас дахь {0} файл одоо таарч байна",
+ "watch.add_step": "Дүрэм нэмэх",
+ "watch.remove_step": "Энэ дүрмийг устгах",
+ "watch.recipes": "Түгээмэл орлуулалтууд",
+ "watch.recipes_hint": "Дүрэм болгож нэмэхийн тулд давхар товшино уу (эсвэл + дарна уу), дараа нь хүсвэл өөрчилж болно.",
+ "watch.recipe_add": "Дүрэм болгож нэмэх",
+ "watch.recipe_removes": "(устгагдсан)",
+ "watch.recipe.drop_camera_prefix": "IMG_-г устгах",
+ "watch.recipe.spaces": "Хоосон зайг доогуур зураас болгох",
+ "watch.recipe.copy_number": "(2) гэх мэт хуулбарын дугаарыг устгах",
+ "watch.recipe.brackets": "Хаалт доторх текстийг устгах",
+ "watch.recipe.leading_number": "Эхэнд байгаа дугаарыг устгах",
+ "watch.recipe.date_dashes": "Огноонд зураас оруулах",
+ "watch.recipe.symbols": "Тэмдэгтүүдийг устгах",
+ "watch.recipe.prefix": "Эхэнд нь текст нэмэх",
+ "watch.recipe.suffix": "Төгсгөлд нь текст нэмэх",
+ "watch.invalid_regex": "Хүчинтэй энгийн илэрхийлэл (regex) биш байна: {0}",
+ "watch.preview": "Урьдчилж харах",
+ "watch.results": "Сүүлийн үр дүн",
+ "watch.results_ok": "{0} файл хөрвүүлэгдсэн, алдаа гараагүй",
+ "watch.results_failed": "{0} файл хөрвүүлэгдсэн · {1} амжилтгүй",
+ "watch.sample_file": "photo.jpg",
+ "watch.preview_keep": "Эх файл байгаа газартаа үлдэнэ",
+ "watch.preview_move": "Эх файл \"originals\" хавтас руу шилжинэ",
+ "watch.status_off": "Унтраалттай",
+ "watch.status_no_folder": "Эхлүүлэхийн тулд хавтас сонгоно уу",
+ "watch.status_watching": "Хянаж байна",
+ "watch.status_starting": "Эхэлж байна…",
+ "watch.status_error": "Энэ хавтсыг хянах боломжгүй: {0}",
+ "watch.cli_hint": "Мөн командын мөрөнд: filee watch <хавтас> --to <формат>",
+ "preset.sheet_studio": "PDF + Засах",
+ "sheet_studio.title": "Хүснэгт хэвлэх студи",
+ "sheet_studio.sheets": "Ажлын хуудас ба гаралтын дараалал",
+ "sheet_studio.page_setup": "Хуудасны тохиргоо",
+ "sheet_studio.print_area": "Хэвлэх муж",
+ "sheet_studio.use_content": "Агуулгыг ашиглах",
+ "sheet_studio.paper": "Цаас",
+ "sheet_studio.orientation": "Чиглэл",
+ "sheet_studio.scaling": "Хэмжээний харьцаа",
+ "sheet_studio.percent": "Хувь",
+ "sheet_studio.pages_wide": "Өргөнөөр багтах хуудас",
+ "sheet_studio.pages_tall": "Өндрөөр багтах хуудас (0 = авто)",
+ "sheet_studio.margins": "Захын зай (дээд, доод, зүүн, баруун)",
+ "sheet_studio.margin_top": "Дээд",
+ "sheet_studio.margin_bottom": "Доод",
+ "sheet_studio.margin_left": "Зүүн",
+ "sheet_studio.margin_right": "Баруун",
+ "sheet_studio.page_order": "Хуудасны дараалал",
+ "sheet_studio.include_hidden": "Нуусан мөр ба баганыг оруулах",
+ "sheet_studio.keep_breaks": "Гараар тавьсан хуудас таслалтыг хадгалах",
+ "sheet_studio.reset_sheet": "Энэ хуудсыг сэргээх",
+ "sheet_studio.rendering": "Яг таг PDF урьдчилсан харагдацыг бэлтгэж байна…",
+ "sheet_studio.export_pdf": "PDF экспортлох",
+ "sheet_studio.one_per_sheet": "Хуудас бүрийг тусдаа PDF болгох",
+ "sheet_studio.invalid_range": "Хүчинтэй муж оруулна уу, жишээ нь A1:S52.",
+ "sheet_studio.exporting": "Ажлын хуудсуудыг буулгаж нэгтгэж байна…",
+ "sheet_studio.exporting_each": "Ажлын хуудсуудыг буулгаж байна…",
+ "sheet_studio.exported": "{0}-г экспортлолоо",
+ "sheet_studio.exported_files": "{0} PDF файл экспортлолоо",
+ "sheet_studio.page_count": "{0} хуудас · урьдчилж харах нь экспорттой яг таарна",
+ "sheet_studio.from_workbook": "Ажлын номноос",
+ "sheet_studio.portrait": "Босоо",
+ "sheet_studio.landscape": "Хэвтээ",
+ "sheet_studio.fit_pages": "Хуудсанд багтаах",
+ "sheet_studio.exact_scale": "Яг хэмжээгээр",
+ "sheet_studio.down_over": "Доош, дараа нь баруун тийш",
+ "sheet_studio.over_down": "Баруун тийш, дараа нь доош",
+ "sheet_studio.warning_scale": "Ажлын ном зөвхөн {0}% хэмжээгээр шаардаж байна. Энэ нь ихэвчлэн хүснэгтийг хэт жижиг болгодог.",
+ "sheet_studio.warning_wide": "Хэт өргөн агуулгад хэвлэх муж тохируулаагүй байна. Агуулгыг ашиглах-ийг сонгоод өргөнөөр хэдэн хуудас болохыг заана уу.",
+ "sheet_studio.apply_fix": "Санал болгосон загварыг хэрэглэх",
+ "sheet_studio.add_region": "+ Өөр муж нэмэх",
+ "sheet_studio.region_name": "{0} · муж {1}",
+ "whats_new.title": "Filee {0}-ийн шинэ боломжууд",
+ "whats_new.previous": "Өмнөх",
+ "whats_new.next": "Дараах",
+ "whats_new.done": "Эхлэх",
+ "whats_new.about_link": "Filee {0}-ийн шинэ боломжууд",
+ "whats_new.2_0_0.browser.title": "Хөтчөөсөө шууд чирж оруулах",
+ "whats_new.2_0_0.browser.text": "Товчлолыг удаан дараад вэб хуудаснаас зураг эсвэл файлын холбоосыг чирж гаргана уу. Цагираг нээгдэж, файлыг хадгалан хөрвүүлнэ.",
+ "whats_new.2_0_0.flip.title": "Цагираг ар талтай боллоо",
+ "whats_new.2_0_0.flip.text": "Цагираг нээлттэй байх үед Tab дарах эсвэл хулганы дугуйг эргүүлж нэмэлт урьдчилсан тохиргоонууд руу эргүүлнэ үү.",
+ "whats_new.2_0_0.formats.title": "Илүү олон формат",
+ "whats_new.2_0_0.formats.text": "Вэб архив (MHTML), OFD цахим нэхэмжлэх, Shapefile, GeoJSON, KML болон GPX газрын зураг, JPEG XR, FBX загвар болон POTM загварууд.",
+ "whats_new.2_0_0.crop.title": "Хөрвүүлэхээсээ өмнө тайрах",
+ "whats_new.2_0_0.crop.text": "Зургуудыг Тайрах хэсэг дээр чирж тавина уу. Зураг дээр A дарж харьцааг шууд сонгоод, Тайраад дараагийнх товчийг дарна уу.",
+ "whats_new.2_0_0.theme.title": "Илүү зөөлөн, таны дуртай өнгөөр",
+ "whats_new.2_0_0.theme.text": "Дурын онцлох өнгийг сонгож, Булангийн зөөлөн байдлаар ирмэгийг дугуйруулж, цагирагийг нэвт харагддаг болгоно уу.",
+ "whats_new.2_0_0.languages.title": "Filee илүү олон хэлээр ярьдаг боллоо",
+ "whats_new.2_0_0.languages.text": "Англи, Солонгос, Хятад болон Польш хэл дээр нэмэгдээд Испани, Хинди, Япон, Орос, Араб болон Бразилын Португал хэлтэй боллоо.",
+ "whats_new.2_0_0.more.title": "Мөн бусад сайжруулалт",
+ "whats_new.2_0_0.more.text": "Нэг цагирагийг анхдагч байдалд оруулах, бүх түүхээс хайлт хийх, мөн LibreOffice-ээр дамжуулан Гужарати, Хинди эсвэл Араб баримтуудыг хөрвүүлэх боломжтой.",
+ "whats_new.2_3_0.annotate.title": "Зураг дээр тэмдэглэгээ хийх",
+ "whats_new.2_3_0.annotate.text": "Зураг дээр шууд сум, дөрвөлжин, тодруулагч, текст болон дугаар зурна уу. Тохиргоо → Цагираг самбар хэсгээс Тэмдэглэгээ хийх-ийг нэмнэ үү.",
+ "whats_new.2_3_0.crop_video.title": "Видео тайрах",
+ "whats_new.2_3_0.crop_video.text": "Хар хүрээг автоматаар арилгах, эсвэл 16:9, 9:16, 4:3, 1:1 болон 21:9 харьцаагаар тайрна уу. HDR видео HDR хэвээр үлдэнэ.",
+ "whats_new.2_3_0.sheets.title": "Нэг хуудсанд багтах нүүр",
+ "whats_new.2_3_0.sheets.text": "Нэг цаасан дээр 2, 4, 6, 8 эсвэл 9 PDF хуудас багтаана уу. Хуудсууд вектор хэвээр үлдэх тул текст тод хэвээр байна.",
+ "whats_new.2_3_0.fields.title": "Зөвхөн таны ашигладаг форматууд",
+ "whats_new.2_3_0.fields.text": "Тохиргоо → Салбарууд хэсгээс CAD, 3D эсвэл фонт зэрэг ажилладаггүй салбаруудаа унтраана уу. Тэдгээрийн форматууд цагираг болон жагсаалтаас хасагдана.",
+ "whats_new.2_3_0.translate.title": "Компьютер дээрээ хадмал үүсгэх ба орчуулах",
+ "whats_new.2_3_0.translate.text": "Видеоноос SRT хадмал үүсгэж, дотоод AI ашиглан баримт, цахим ном болон хадмал орчуулгыг орчуулна уу. Таны компьютерээс юу ч гадагш илгээгдэхгүй.",
+ "whats_new.2_3_0.more.title": "Мөн бусад сайжруулалт",
+ "whats_new.2_3_0.more.text": "Хавтас руу хуулах эсвэл зөөх, перспектив тайралт, CMYK хэвлэлийн файл, Krita болон Minecraft файлууд, мөн Итали, Индонез, Вьетнам хэл нэмэгдлээ.",
+ "preset.extract_motion.donut": "Видео салгах",
+ "presets.copy_to_folder": "Хавтас руу хуулах",
+ "presets.copy_hint": "Файл болон хавтсыг хөрвүүлэлгүйгээр хуулна. Эх файлууд байгаа газартаа үлдэх ба ижил нэртэй файлд дугаар нэмэгдэнэ.",
+ "presets.copy_choose_folder": "Очих хавтсыг сонгох, эсвэл Тухай бүр асуух-ийг сонгоно уу.",
+ "presets.copy_back": "Хавтас руу хуулах нь ар талд байрладаг. Нэмэхийн тулд Ар тал руу шилжинэ үү.",
+ "presets.copy_action": "Үйлдэл",
+ "presets.move_to_folder": "Хавтас руу зөөх",
+ "presets.move_hint": "Файл болон хавтсыг хөрвүүлэлгүйгээр зөөнө. Очих газартаа ормогц өмнөх газраасаа устах ба ижил нэртэй файлд дугаар нэмэгдэнэ.",
+ "preset.translate": "Орчуулах",
+ "preset.translate.donut": "Орчуулах",
+ "presets.group.translate": "Орчуулах",
+ "presets.translate_hint": "Баримт эсвэл номыг таны компьютер дээр зорилтот хэл рүү орчуулна.",
+ "presets.target_language": "Орчуулах хэл",
+ "presets.test_connection": "Холболтыг шалгах",
+ "engines.package.translator.no_model": "Ollama ажиллаж байгаа боловч загвар олдсонгүй. llama3.2 гэх мэт офлайн загвар татна уу.",
+ "engines.ollama.manage": "Ollama удирдах",
+ "engines.ollama.title": "Ollama дотоод AI удирдлага",
+ "engines.ollama.description": "Бүх загвар таны компьютер дээр 100% офлайн ажиллана. Баримтын нууцлалыг хамгаалах үүднээс үүлэн сүлжээ рүү дамжуулдаг загваруудыг (-cloud) хаасан.",
+ "engines.ollama.server_running": "Сервер ажиллаж байна",
+ "engines.ollama.server_stopped": "Сервер зогссон",
+ "engines.ollama.start_server": "Ollama сервер эхлүүлэх",
+ "engines.ollama.stop_server": "Ollama сервер зогсоох",
+ "engines.ollama.refresh": "Шинэчлэх",
+ "engines.ollama.pull_title": "Офлайн загвар суулгах / татах",
+ "engines.ollama.pull_button": "Загвар татах",
+ "engines.ollama.pull_placeholder": "Загварын шошго (жишээ нь llama3.2, qwen2.5:3b, mistral)",
+ "engines.ollama.cloud_warning": "Үүлэн загварууд (-cloud) баримтын текстийг интернэтээр дамжуулдаг тул хаагдсан байна. Дотоод офлайн загвар оруулна уу.",
+ "engines.ollama.installed_models": "Татсан загварууд",
+ "engines.ollama.no_models": "Ollama дотор загвар олдсонгүй. Дээр загварын шошго оруулаад Загвар татах товчийг дарна уу.",
+ "engines.ollama.delete_confirm": "{0}-г устгах уу? Та дараа нь дахин татаж авах боломжтой.",
+ "engines.ollama.delete_failed": "{0}-г устгаж чадсангүй.",
+ "engines.ollama.use_model": "Загварыг ашиглах",
+ "engines.ollama.using_model": "Идэвхтэй загвар (Одоо ашиглаж буй)",
+ "engines.ollama.cloud_blocked": "Үүлэн загвар (Хаагдсан)",
+ "engines.ollama.offline_badge": "Дотоод офлайн",
+ "engine.reason.download_translator": "Дотоод Ollama шаардлагатай (Тохиргоо → Хөдөлгүүрүүд хэсгийг харна уу)",
+ "error.translation_needs_engine": "Офлайн орчуулгад дотоод Ollama шаардлагатай. Тохируулах зааврыг Тохиргоо → Хөдөлгүүрүүд хэсгээс харна уу.",
+ "error.translation_cloud_model_forbidden": "Аюулгүй байдлын хориг: Баримтын текст таны компьютерээс хэзээ ч гадагш гарахгүй байхын тулд үүлэн загваруудыг (-cloud) хаасан.",
+ "error.translation_empty_response": "Ollama хоосон хариу буцаалаа.",
+ "error.translation_model_not_found": "Сонгосон загвар Ollama дотор олдсонгүй. Тохиргоо → Хөдөлгүүрүүд хэсгээс загвар сонгох эсвэл суулгана уу.",
+ "engines.ollama.testing": "Орчуулгыг шалгаж байна...",
+ "engines.ollama.starting": "Эхэлж байна...",
+ "engines.ollama.connecting": "Холбогдож байна...",
+ "engines.ollama.complete": "Дууслаа!",
+ "engines.ollama.failed": "Амжилтгүй:",
+ "engines.ollama.error": "Алдаа:",
+ "home.queue_error": "{0}-г хөрвүүлж чадсангүй. Жагсаалтад файлууд нь хэвээр үлдлээ.",
+ "home.queue_selected": "сонгогдсон",
+ "home.queue_remove_file": "{0}-г хасах",
+ "home.queue_title": "Хөрвүүлэх файлууд",
+ "home.queue_convert": "Үйлдэл сонгох",
+ "home.queue_clear": "Жагсаалтыг цэвэрлэх",
+ "home.queue_remove": "Хасах",
+ "general.collect_dropped": "Чирж оруулсан файлуудыг жагсаалтад цуглуулах",
+ "general.collect_dropped_hint": "Чирсэн болон сонгосон файлууд Нүүр хуудсан дээрх жагсаалтад хүлээх ба Үйлдэл сонгох нь тэдгээрийг харагдаж буй дарааллаар нь хамт хөрвүүлнэ. Унтраасан үед: өмнөх шигээ чирэх бүрт цагираг нээгдэнэ.",
+ "error.iso_root": "ISO үүсгэх нь диск эсвэл файлын системийн үндсэн хавтсыг дэмжихгүй. Оронд нь диск доторх хавтас сонгоно уу.",
+ "error.iso_link": "ISO үүсгэх нь симбол холбоос (symlink) эсвэл junction-ийг дагадаггүй. Холбоос бүртгэлгүй эх хавтас сонгоно уу.",
+ "error.iso_name": "Joliet нэр нь хамгийн ихдээ 62 BMP Unicode тэмдэгт агуулах ёстой бөгөөд удирдах тэмдэгт, цэгтэй таслал, урвуу ташуу зураас, *, : эсвэл ? агуулах ёсгүй. Харьцангуй зам хамгийн ихдээ 240 тэмдэгт байна.",
+ "error.iso_file_size": "ISO үүсгэхэд тус бүрийн файл 4 GiB-аас жижиг байх шаардлагатай.",
+ "error.iso_changed": "Бүх өгөгдлийг бичиж дуусахаас өмнө ISO үүсгэх үйлдэл зогслоо. Хөрвүүлэлтийн явцад эх файл өөрчлөгдсөн байж болзошгүй.",
+ "engine.iso.name": "ISO",
+ "engine.iso.description": "Unicode нэр болон хоосон хавтсуудыг хадгалан хавтас эсвэл архивын агуулгаас ISO дүрс үүсгэнэ.",
+ "preset.iso_image": "ISO дүрс",
+ "profile.folders": "Хавтас",
+ "preset.upscale": "Зургийн чанар сайжруулах",
+ "preset.upscale.donut": "Томруулах (AI)",
+ "engine.upscaler.name": "Зургийн чанарын загварууд",
+ "engine.upscaler.description": "Дотоод NCNN загваруудын тусламжтайгаар AI ашиглан зургийн нягтаршлыг 4× хүртэл өсгөх.",
+ "engines.upscaler.modal_description": "Зургийн нягтаршлыг 4× хүртэл өсгөхийн тулд дотоод AI загваруудыг татаж авна уу. Бүх боловсруулалт таны төхөөрөмж дээр 100% офлайн хийгдэнэ.",
+ "upscale.model": "Загвар",
+ "upscale.scale": "Өсгөх хэмжээ",
+ "upscale.output_format": "Гаралтын формат",
+ "upscale.format_same": "Эх файлтай ижил формат ({0})",
+ "upscale.resolution_label": "Нягтаршлын урьдчилсан харагдац",
+ "upscale.resolution_preview": "{0} × {1} пиксел ➜ {2} × {3} пиксел ({4}×)",
+ "upscale.button_start": "Одоо сайжруулах",
+ "error.upscale_failed": "Чанарыг сайжруулж чадсангүй: {0}",
+ "engines.package.upscayl-bin.name": "Upscayl (AI зураг томруулагч)",
+ "engines.package.upscayl-bin.description": "Дотоод NCNN загваруудаар зургийн чанарыг 4× хүртэл AI ашиглан өсгөнө.\nЗагваруудыг анх удаа ашиглах үед татаж авна.",
+ "error.upscale_needs_engine": "AI чанар сайжруулалтыг ашиглахын тулд Тохиргоо → Хөдөлгүүрүүд хэсгээс Upscayl суулгана уу.",
+ "error.upscale_needs_vulkan": "Нягтаршил өсгөхөд Vulkan дэмждэг график карт (GPU) шаардлагатай.",
+ "error.upscale_too_many_frames": "Чанар сайжруулахад кадр хэт олон байна",
+ "upscale.model_ready": "Бэлэн",
+ "upscale.model_download_size": "{0} МБ татах",
+ "upscale.downloading_model": "Загварыг татаж байна...",
+ "engines.model.delete_confirm": "{0} загварыг устгах уу? Та хүссэн үедээ дахин татаж авах боломжтой.",
+ "triggers.kind.Click": "Нэмэлт товч + хулганы товшилт (Filee Connect)",
+ "preset.filee_connect": "Filee Connect",
+ "connect.title": "Filee Connect",
+ "connect.caption": "Дотоод сүлжээ",
+ "connect.qr_instruction_network": "Хоёр төхөөрөмж хоёулаа ижил сүлжээнд холбогдсон эсэхийг шалгана уу.",
+ "connect.qr_instruction_scan": "Энэ QR кодыг өөрийн iOS/Android төхөөрөмжийн камер эсвэл сканнер програмаар уншуулна уу.",
+ "connect.qr_instruction_http": "Энэ нь дотоод сүлжээ бөгөөд \"http\" ашиглаж байгаа тул \"Гар утасны хөтөч\" нээхийг асууна; холболтыг зөвшөөрч төхөөрөмжид итгэнэ үү.",
+ "connect.tab_text": "Текст",
+ "connect.tab_shared_folder": "Хуваалцсан хавтас",
+ "connect.paste_clipboard": "Түр санах ойн текстийг буулгах",
+ "connect.send": "Илгээх",
+ "connect.end_session": "Холболтыг дуусгах",
+ "connect.no_messages": "Энэ сессэд одоогоор зурвас алга.",
+ "connect.no_files": "Одоогоор хуваалцсан файл алга. Файлуудаа энд байршуулах эсвэл чирж тавина уу.",
+ "connect.upload_files": "Файл байршуулах",
+ "connect.upload_folder": "Хавтас байршуулах",
+ "connect.refresh": "Шинэчлэх",
+ "connect.open_folder": "Хавтас нээх",
+ "triggers.connect_section_title": "Filee Connect серверийн товчлол",
+ "connect.waiting_device": "Төхөөрөмж холбогдохыг хүлээж байна…",
+ "connect.copy_link": "Холбоосыг хуулах",
+ "connect.disconnect_device": "Салгах",
+ "connect.input_placeholder": "Холбогдсон төхөөрөмжүүдтэй хуваалцах текст бичих эсвэл буулгах…",
+ "connect.messages_header": "Сессийн зурвасууд",
+ "connect.navigate_up": "Дээш",
+ "connect.view": "Урьдчилж харах",
+ "connect.stored_locally": "Файлууд Filee дотор дотооддоо хадгалагдах ба сесс дуусахад устана.",
+ "connect.sender_pc": "Компьютер",
+ "connect.sender_phone": "Утас",
+ "connect.copy": "Хуулах",
+ "connect.copied": "Хуулагдлаа!",
+ "connect.items_count": "{0} зүйл",
+ "connect.wifi_label": "Wi-Fi ({0}):",
+ "connect.firewall_hint": "Тэмдэглэл: Хэрэв таны утас холбогдохгүй бол Windows Defender Firewall дотор Filee-г зөвшөөрнө үү.",
+ "connect.pin_refresh_hint": "{0} с дараа шинэ ПИН код гарна",
+ "connect.connected_badge": "{0} холбогдсон",
+ "connect.connect_again": "Дахин холбогдох",
+ "connect.manual_url_label": "Хаяг:",
+ "connect.manual_instruction_pin": "Эсвэл утасныхаа хөтөч дээр дээрх хаягийг нээгээд ПИН кодыг оруулна уу.",
+ "connect.picker_files_title": "Байршуулах файлуудыг сонгох",
+ "connect.picker_folder_title": "Байршуулах хавтсыг сонгох",
+ "connect.upload_failed_one": "“{0}”-г байршуулж чадсангүй.",
+ "connect.upload_failed_many": "{0} файлыг байршуулж чадсангүй.",
+ "connect.pin_label": "ПИН:",
+ "connect.firewall_hint_generic": "Тэмдэглэл: Хэрэв таны утас холбогдохгүй бол системийн галт хана (firewall) дотор Filee-г зөвшөөрнө үү.",
+ "connect.no_network_message": "Filee Connect-ийг эхлүүлж чадсангүй. Энэ төхөөрөмж дотоод Wi-Fi эсвэл Ethernet сүлжээнд холбогдсон эсэхийг шалгана уу.",
+ "connect.save_as": "Өөрөөр хадгалах…"
+}
diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json
index faf6a58e5..e11e53f6c 100644
--- a/src/Filee.App/Assets/i18n/ne.json
+++ b/src/Filee.App/Assets/i18n/ne.json
@@ -74,6 +74,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "{0} फाइलहरू",
"donut.drop_to": "छाड्नुहोस् → {0}",
+ "donut.ordinary_conversion.single": "यो फाइल सामान्य रूपान्तरणको रूपमा चल्छ",
+ "donut.ordinary_conversion.all": "सबै रूपान्तरणयोग्य फाइलको सामान्य रूपान्तरण",
+ "donut.ordinary_conversion.some": "केही फाइल सामान्य रूपान्तरणको रूपमा चल्छन्",
"donut.pick_in_group": "छनोट गर्न बाहिरतर्फ लैजानुहोस्",
"donut.group_empty": "यस समूहमा कुनै प्रिसेट बाँकी छैन। यसलाई सेटिङहरू → डोनट टुलबारमा सम्पादन गर्नुहोस्",
"donut.click_to": "क्लिक गर्नुहोस् → {0}",
@@ -108,6 +111,8 @@
"error.p7m_unknown_content": "यो .p7m भित्रको फाइल Filee ले रूपान्तरण नगर्ने प्रकारको हो",
"error.p7m_too_large": "हस्ताक्षरित फाइल खोल्न धेरै ठूलो छ",
"error.document_too_large": "यो कागजात खोल्दा धेरै ठूलो डेटा बन्छ वा धेरै गहिरोसम्म नेस्ट गरिएको छ, त्यसैले सुरक्षित रूपमा खोल्न सकिँदैन",
+ "error.merge_paper_geometry": "पृष्ठको आकार, घुमाइ वा एकाइ मान्य छैन, त्यसैले यसलाई छानिएको कागजमा मिलाउन सकिँदैन",
+ "error.merge_paper_limit": "कागजको आकार बदल्न पृष्ठले प्रशोधन सीमा नाघ्यो",
"format_request.request": "नयाँ ढाँचा अनुरोध गर्नुहोस्",
"format_request.notice_title": "Filee ले अहिलेसम्म {0} फाइलहरू रूपान्तरण गर्न सक्दैन",
"format_request.notice_text": "तपाईंले चाहेको ढाँचा समर्थित छैन? हामीलाई अनुरोध पठाउनुहोस् र हामी यसलाई थप्ने प्रयास गर्नेछौं।",
@@ -166,6 +171,9 @@
"error.fit_damaged": "FIT फाइल बिग्रिएको वा अधुरो छ",
"error.fit_chained": "लगातार जोडिएका धेरै FIT फाइलहरू अहिले समर्थित छैनन्",
"error.fit_too_large": "FIT फाइल रूपान्तरण गर्न धेरै ठूलो छ",
+ "error.canvas_not_canvas": "यो Obsidian क्यानभास (JSON Canvas) होइन",
+ "error.canvas_empty": "क्यानभासमा कोर्ने कुनै कार्ड छैन",
+ "error.canvas_too_large": "क्यानभास रूपान्तरण गर्न धेरै ठूलो छ",
"error.fit_no_file_id": "FIT फाइलले फाइलको प्रकार बताउँदैन",
"error.fit_not_activity": "यो FIT फाइल गतिविधि होइन (गतिविधिहरू मात्र रूपान्तरण हुन्छन्)",
"error.fit_no_positions": "गतिविधिमा रेकर्ड गरिएका स्थान छैनन्: यसलाई CSV, XLSX वा JSON मा रूपान्तरण गर्नुहोस्",
@@ -177,12 +185,16 @@
"error.fon_damaged": ".fon फाइल बिग्रिएको वा अपूर्ण छ",
"error.document_too_complex": "कागजात सुरक्षित रूपमा पढ्न नसकिने गरी धेरै ठूलो वा जटिल छ",
"error.m4s_needs_init": "यो .m4s स्ट्रिम गरिएको भिडियोको एउटा टुक्रा हो: छुट्टै सुरुवाती (init) खण्डबिना यसलाई पढ्न सकिँदैन",
+ "error.nov_password": "यो निर्यात पासवर्डले सुरक्षित छ, त्यसैले यसको भिडियो इन्क्रिप्ट गरिएको छ: Wisenet WAVE / Nx Witness बाट पासवर्डबिना फेरि निर्यात गर्नुहोस्",
+ "error.nov_no_video": "यो निर्यातमा क्यामेराको कुनै रेकर्डिङ छैन",
"error.exe_unreadable": "यो Filee ले पढ्न सक्ने Windows प्रोग्राम वा लाइब्रेरी होइन, वा फाइल बिग्रिएको छ",
"error.exe_no_icon": "यो फाइलको आफ्नै आइकन छैन (Windows ले यसका लागि मानक आइकन देखाउँछ)",
"notice.pdf_not_smaller": "यसलाई सानो बनाउन सकिएन, त्यसैले जस्तो छ त्यस्तै प्रतिलिपि गरियो",
+ "notice.nov_recordings_left_out": "यो निर्यातमा धेरै रेकर्डिङ छन् (अरू क्यामेरा वा बीचमा रोकिएका भागहरू): सबैभन्दा ठूलो मात्र रूपान्तरण गरियो",
"notice.under_min_size": "सुरक्षित भयो, तर न्यूनतम फाइल आकारभन्दा सानो छ",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "छानिएको AutoCAD संस्करणमा नभएका वस्तुहरू (मल्टिलिडर, मेस …) छोडिए",
+ "notice.gcode_no_thumbnail": "G-code फाइलमा स्लाइसरको पूर्वावलोकन छैन, त्यसैले टूलपाथ माथिबाट कोरियो",
"notice.sheet_objects_left_out": "Filee ले अझै प्रिन्ट गर्न नसक्ने वस्तुहरू (चार्ट, आकारभित्रको पाठ …) छोडिए: तिनलाई राख्न सेटिङहरू → इन्जिनहरूमा LibreOffice स्थापना गर्नुहोस्",
"notice.cmyk_needs_ghostscript": "यो फाइल CMYK हो, तर RGB मा सेभ भयो: CMYK राख्न सेटिङहरू → इन्जिनहरूमा Ghostscript स्थापना गर्नुहोस्",
"notice.pdfa_needs_ghostscript": "यो PDF, PDF/A होइन: PDF/A बनाउन सेटिङहरू → इन्जिनहरूमा Ghostscript स्थापना गर्नुहोस्",
@@ -275,6 +287,8 @@
"engine.font.description": "TTF, OTF, WOFF, WOFF2 र EOT बीच रूपान्तरण; Windows बिटम्याप फन्ट (FON) लाई पनि यी ढाँचामा रूपान्तरण।",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "AutoCAD रेखाचित्रहरू: DWG ↔ DXF, र दुवैबाट PDF, SVG र PNG।",
+ "engine.gcode.name": "G-code (built-in)",
+ "engine.gcode.description": "3D प्रिन्टर र CNC मेसिनका टूलपाथ (G-code) लाई PDF, SVG र चित्रमा, वा स्लाइसरकै पूर्वावलोकन।",
"engine.drawing.name": "Drawings (built-in)",
"engine.drawing.description": "Visio रेखाचित्र (VSDX) र OpenDocument रेखाचित्र (ODG, FODG) बाट PDF, SVG र PNG, Visio वा LibreOffice बिना।\nपुराना बाइनरी Visio फाइलहरू (.vsd) का लागि अझै LibreOffice चाहिन्छ।",
"engine.model3d.name": "3D models (built-in)",
@@ -297,6 +311,7 @@
"engine.tesseract.description": "स्क्यान र तस्बिरको पाठ पहिचान गर्छ (OCR): खोज्न मिल्ने PDF, DOCX, TXT।",
"engine.vision.description": "macOS को आफ्नै पाठ पहिचानले स्क्यान र तस्बिरको पाठ पहिचान गर्छ (OCR): खोज्न मिल्ने PDF, DOCX, TXT। केही डाउनलोड गर्नु पर्दैन।",
"engine.calibre.description": "दुर्लभ इ-पुस्तक ढाँचाहरू (LIT, LRF…) र पुराना ढाँचामा आउटपुट (LIT, PDB, RB…)।\nFilee रेन्डरर बन्द हुँदा र यसलाई HWPX writer भन्दा माथि राख्दा, यसले इ-पुस्तकका PDF पनि बनाउँछ (लामो पुस्तकका लागि धेरै छिटो)।",
+ "engine.weasyprint.description": "HTML पृष्ठ, Markdown र इमेलका PDF ब्राउजरले छापेजस्तै बनाउँछ: शीर्षक, तालिका, बोल्ड र अरबी वा हिन्दी पाठ ठीकसँग आउँछन्।\nयसले कागजातको छेउका तस्बिरहरू मात्र लोड गर्छ, इन्टरनेटबाट केही पनि होइन।",
"engine.libreoffice.description": "पुराना र कम प्रचलित ढाँचाहरू पढ्छ: Word 6.0/95 DOC, PowerPoint 95 PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, बाइनरी Visio (VSD)…\nचार्ट, इम्बेड गरिएका वस्तु वा 3D भएका ODP र ODG फाइलहरू पनि रूपान्तरण गर्छ, र DOC, XLS, PPT, ODT, OpenDocument टेम्प्लेट र EMF को रूपमा सुरक्षित गर्छ।",
"engine.translator.description": "अफलाइन ओलामा मोडेलहरू प्रयोग गरेर कागजात र ई-पुस्तकहरू स्थानीय रूपमा अनुवाद गर्दछ।\nSRT र WebVTT उपशीर्षकहरू पनि अनुवाद गर्दछ: मोडेललाई क्यूको पाठ मात्र पठाइन्छ, क्यू नम्बर र टाइमस्ट्याम्प जस्ताको तस्तै राखिन्छ।",
"engine.exiftool.description": "चित्र, PDF, भिडियो र अडियोका सबै मेटाडाटा फिल्ड पढ्छ, र धेरैजसो प्रकारका फाइलमा फाइलको सामग्री नछोई तिनलाई सम्पादन गर्छ।\nमेटाडाटा उपकरणले यसलाई प्रयोग गर्छ; यसबिना फिल्डहरू पढ्न मात्र मिल्ने गरी देखिन्छन्।",
@@ -395,6 +410,7 @@
"clip.save": "क्लिप सेभ गर्नुहोस्",
"clip.save_next": "सेभ गरेर अर्को",
"clip.seconds": "{0} से",
+ "clip.ringtone_limit": "iPhone रिङटोन बढीमा {0} सेकेन्डको हुन्छ",
"clip.length_hint": "सुरुबाट यति सेकेन्डको क्लिप",
"clip.fast": "छिटो, फेरि इन्कोड नगरी",
"clip.fast_hint": "भिडियो जस्ताको तस्तै प्रतिलिपि गर्छ: एकै छिनमा हुन्छ र केही गुम्दैन, तर क्लिप सुरुभन्दा अघिको की फ्रेमबाट सुरु हुन्छ (केही सेकेन्डसम्म अघि)। छानिएकै फ्रेममा ठ्याक्कै काट्न यो बन्द गर्नुहोस् (फेरि इन्कोड हुन्छ, बढी समय लाग्छ)।",
@@ -793,9 +809,15 @@
"toolbar.hint_remove": "हटाउनको लागि स्लाइसलाई यहाँ फिर्ता तान्नुहोस्",
"toolbar.hint_reorder": "क्रम बदल्न स्लाइसहरूलाई एकअर्कामा तान्नुहोस्",
"toolbar.hint_edit": "सम्पादन गर्न स्लाइसमा दायाँ-क्लिक गर्नुहोस् वा ✎ प्रयोग गर्नुहोस्",
+ "toolbar.group_hint": "सूचीबाट प्रिसेट तानेर हाल्नुहोस्, क्रम मिलाउन स्लाइस तान्नुहोस्, र हटाउन कुनै स्लाइसलाई सूचीमा तान्नुहोस्।",
+ "toolbar.group_back": "{0} मा फर्कनुहोस्",
"toolbar.max_reached": "एउटा डोनटमा अधिकतम {0} सम्म प्रिसेटहरू अटाउँछन्",
- "toolbar.appearance": "स्वरूप",
"toolbar.size": "आकार",
+ "toolbar.edit_size": "डोनटको आकार सम्पादन",
+ "toolbar.look_done": "सकियो",
+ "toolbar.look_hint": "तपाईंको डोनट स्क्रिनमा वास्तविक आकारमा छ। सार्न बीचको भाग तान्नुहोस्, र समूहलाई खुला राख्न त्यसमा क्लिक गर्नुहोस्।",
+ "toolbar.look_all": "सबै डोनट",
+ "toolbar.look_this": "यो डोनट: {0}",
"toolbar.hole": "प्वाल (केन्द्र)",
"toolbar.opacity": "अपारदर्शिता",
"toolbar.captions": "आउटपुट ढाँचा देखाउनुहोस्",
@@ -803,6 +825,9 @@
"toolbar.label_scale": "लेबलको आकार",
"toolbar.ring_size": "समूह रिङको आकार",
"toolbar.ring_spread": "समूह रिङको फैलावट",
+ "toolbar.group_shown": "देखाइएको समूह",
+ "toolbar.example_group": "उदाहरण समूह",
+ "toolbar.example_none": "यो डोनटमा कुनै समूह छैन",
"labels.auto": "स्वचालित",
"labels.icon_text": "आइकन र पाठ",
"labels.text_only": "पाठ मात्र",
@@ -825,6 +850,7 @@
"group.add": "थप्नुहोस्",
"group.add_placeholder": "प्रिसेट छनोट गर्नुहोस्",
"group.full": "एउटा समूहमा अधिकतम {0} वटा प्रिसेटहरू अटाउँछन्।",
+ "group.copy_front": "\"फोल्डरमा प्रतिलिपि गर्नुहोस्\" यो समूहमा राख्न सकिँदैन: यो समूह अगाडिको भागमा पनि छ।",
"group.move_up": "माथि सार्नुहोस्",
"group.move_down": "तल सार्नुहोस्",
"group.remove": "समूहबाट हटाउनुहोस्",
@@ -851,6 +877,9 @@
"toolbar.mixed_others": "फाइलहरूको अन्य मिश्रणहरूले “{0}” खोल्छन्।",
"toolbar.mixed_others_self": "कुनै पनि अनचेक गरिएको मिश्रित प्रोफाइल बाँकी छैन, त्यसैले यो प्रोफाइल हरेक अन्य मिश्रणका लागि पनि प्रयोग हुन्छ।",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "यो इनपुट ढाँचा सामान्य रूपान्तरणको रूपमा चल्छ",
+ "toolbar.chip_tip.ordinary_conversion.all": "रूपान्तरण गर्न मिल्ने सबै इनपुट ढाँचा सामान्य रूपान्तरणको रूपमा चल्छन्",
+ "toolbar.chip_tip.ordinary_conversion.some": "केही इनपुट ढाँचा सामान्य रूपान्तरणको रूपमा चल्छन्",
"toolbar.chip_tip.makes_same": "→ उही ढाँचा",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "समर्थित ढाँचाहरू: {0}",
@@ -902,6 +931,11 @@
"presets.cad_colors.BlackAndWhite": "कालो र सेतो",
"presets.cad_colors.Grayscale": "ग्रेस्केल",
"presets.cad_colors_hint": "DWG वा DXF रेखाचित्र PDF, SVG वा चित्र बन्दा प्रयोग हुन्छ। कालो र सेतोले मोनोक्रोम प्लटजस्तै सबै रेखा र पाठ कालोमा बनाउँछ; भरिएका क्षेत्रहरू आफ्नो उज्यालोअनुसार खैरो हुन्छन्, ताकि हल्का भराइ कालो ब्लक नबनोस्। ग्रेस्केलले हरेक रङलाई खैरोमा बदल्छ र पहेँलोजस्ता हल्का रेखाहरूलाई पढ्न मिल्ने गरी गाढा बनाउँछ।",
+ "presets.gcode_view": "दृश्य",
+ "presets.gcode_view.Top": "माथिबाट",
+ "presets.gcode_view.Isometric": "आइसोमेट्रिक",
+ "presets.gcode_view.Thumbnail": "स्लाइसरको पूर्वावलोकन",
+ "presets.gcode_view_hint": "स्लाइसर वा CAM प्रोग्रामको G-code फाइल PDF, SVG वा चित्र बन्दा प्रयोग हुन्छ। एक्सट्रुजन र कटहरू उचाइअनुसार रङ्गिन्छन्, गाढा (तल) देखि हल्का (माथि) सम्म; खाली चालहरू पातला निला रेखा हुन् (CNC का छिटो चालका लागि रातो)। स्लाइसरको पूर्वावलोकन PrusaSlicer, Cura, OrcaSlicer वा Bambu Studio ले फाइलमा सुरक्षित गरेको चित्र हो; SVG, PDF र पूर्वावलोकन नभएका फाइलहरूले टूलपाथ माथिबाट देखाउँछन्।",
"presets.group.image": "छवि",
"presets.quality": "गुणस्तर",
"presets.resize": "आकार बदल्नुहोस्",
@@ -981,6 +1015,7 @@
"presets.ringtone_start": "रिङटोनको सुरु (सेकेन्ड)",
"presets.ringtone_length": "रिङटोनको लम्बाइ (सेकेन्ड, बढीमा ४०)",
"presets.ringtone_hint": "iPhone रिङटोन बढीमा ४० सेकेन्डको AAC फाइल हुन्छ। रिङटोन तपाईंले तोकेको सेकेन्डबाट सुरु हुन्छ र तपाईंले तोकेको लम्बाइमा काटिन्छ (४० सेकेन्डभन्दा बढी हुँदैन)।",
+ "presets.ask_ringtone": "हरेक पटक भाग सोध्नुहोस् (क्लिप काट्नुहोस् खुल्छ)",
"presets.ico_sizes": "आइकन आकारहरू (px)",
"presets.group.pdf": "PDF",
"presets.merge": "सबै फाइलहरू एउटै PDF मा मर्ज गर्नुहोस्",
@@ -1065,6 +1100,10 @@
"presets.ocr_remove_language": "यो भाषा हटाउनुहोस्",
"presets.ocr_languages_hint": "पाठमा साँच्चै भएका भाषा मात्र थप्नुहोस्: हरेक भाषाले पहिचान ढिलो बनाउँछ।",
"presets.pdf_keep_metadata": "पृष्ठ र तस्बिरको मेटाडाटा (XMP) राख्नुहोस्",
+ "presets.normalize_merge_paper": "गाभिएका सबै पृष्ठमा एउटै कागजको आकार प्रयोग गर्नुहोस्",
+ "presets.merge_paper": "गाभिएको PDF को कागज",
+ "presets.merge_paper_hint": "हरेक पृष्ठलाई नकाटी छानिएको कागजमा मिलाएर बीचमा राख्छ। गाभ्नुअघि तस्बिरका पृष्ठ सेटिङहरू लागू हुन्छन्।",
+ "presets.merge_paper_auto_hint": "स्वचालित दिशा पहिलो पृष्ठबाट एक पटक छानिन्छ। साना पृष्ठहरूलाई मिलाउन ठूलो गरिन्छ।",
"presets.watermark": "वाटरमार्क",
"presets.watermark_hint": "जस्तै: मस्यौदा",
"presets.pdf_password": "खोल्ने पासवर्ड",
@@ -1141,6 +1180,7 @@
"presets.extract_note": "प्रत्येक अभिलेख आफ्नै छुट्टै फोल्डरमा निकालिन्छ। यो कहाँ जान्छ भन्ने कुरा आउटपुटमा सेट गरिन्छ।",
"presets.group.trace": "ट्रेसिङ",
"presets.group.cad": "CAD रेखाचित्र",
+ "presets.group.gcode": "G-code (3D प्रिन्टिङ, CNC)",
"presets.trace_note": "जब तस्बिर (PNG, JPG, …) लाई भेक्टर आकारहरूमा ट्रेस गरिन्छ तब प्रयोग हुन्छ।",
"presets.trace_mode": "मोड",
"presets.trace_mode.Color": "रङ्गीन",
@@ -1367,6 +1407,8 @@
"engines.package.whisper.description": "भिडियो र अडियोमा भएको भाषणलाई SRT उपशीर्षक फाइलहरूमा ट्रान्सक्राइब गर्दछ। इन्टरनेट जडान बिना अफलाइन काम गर्दछ।",
"engines.package.calibre.name": "Calibre (दुर्लभ इ-पुस्तक ढाँचाहरू)",
"engines.package.calibre.description": "दुर्लभ इ-पुस्तक ढाँचाहरू (LIT, LRF…) रूपान्तरण गर्दछ र पुराना ढाँचाहरूमा (LIT, PDB, RB…) सुरक्षित गर्दछ।\nEPUB, MOBI, AZW3, FB2 र कमिक्स यस बिना पढ्न सकिन्छ, र Kindle पुस्तकहरू यस बिना सुरक्षित गर्न सकिन्छ।",
+ "engines.package.weasyprint.name": "WeasyPrint (HTML, Markdown र इमेलबाट PDF)",
+ "engines.package.weasyprint.description": "HTML पृष्ठ, Markdown र इमेल (EML, MSG, MHTML) का राम्रा PDF बनाउँछ: शीर्षक, तालिका, बोल्ड र दायाँबाट बायाँ वा भारतीय लिपिहरू ब्राउजरले छापेजस्तै।\nयसविना पनि तिनीहरू भित्री बाटोबाट PDF बन्छन्।",
"engines.package.7zip.name": "7-Zip (दुर्लभ आर्काइभ प्रकारहरू)",
"engines.package.7zip.description": "Filee का आफ्नै रिडरहरूले छोड्ने दुर्लभ आर्काइभ प्रकारहरू अनप्याक गर्छ: PPMd र अन्य असामान्य विधिका 7Z, Quantum CAB, बहु-भोल्युम ARJ, NTFS, exFAT वा APFS डिस्क इमेज…\nZIP, 7Z, RAR, TAR, CAB, ISO, DMG र अन्य सबै सामान्य आर्काइभहरू यस बिना पनि चल्छन्।",
"engines.package.size": "डाउनलोड {0} · डिस्कमा {1}",
@@ -1393,7 +1435,17 @@
"engines.remove": "हटाउनुहोस्",
"engines.remove_confirm": "{0} हटाउने? तपाईंले यसलाई पछि फेरि डाउनलोड गर्न सक्नुहुन्छ।",
"engines.state.own_copy": "तपाईंको प्रतिलिपि",
- "engines.state.system_copy": "यस कम्प्युटरमा फेला पर्यो",
+ "engines.source.download": "Filee को डाउनलोड",
+ "engines.source.system": "यस कम्प्युटरमा",
+ "engines.source.version": "संस्करण {0}",
+ "engines.source.on_disk": "डिस्कमा {0}",
+ "engines.other_copy_title": "यस कम्प्युटरमा पनि",
+ "engines.other_copy_detail": "{0} (Filee को डाउनलोड हटाइनेछ)",
+ "engines.use_other_copy": "यसको सट्टा यो प्रयोग गर्नुहोस्",
+ "engines.use_other_copy_confirm": "{0} मा रहेको प्रतिलिपि प्रयोग गरी Filee ले डाउनलोड गरेको {1} हटाउने? तपाईंले यसलाई पछि फेरि डाउनलोड गर्न सक्नुहुन्छ।",
+ "engines.other_copy_gone": "त्यो प्रतिलिपि अब भेटिँदैन वा सुरु हुँदैन। Filee को डाउनलोड राखिएको छ।",
+ "engines.install_filee_copy": "Filee को प्रतिलिपि स्थापना गर्नुहोस्",
+ "engines.use_another": "अर्को प्रतिलिपि प्रयोग गर्नुहोस्…",
"engines.update_recommended": "अपडेट सिफारिस गरिएको छ: यो प्रतिलिपि {0} हो, Filee को परीक्षण {1} सँग गरिएको छ।\nअधिकांश रूपान्तरणहरू अझै काम गर्छन्।",
"engines.use_own": "मेरो प्रतिलिपि प्रयोग गर्नुहोस्…",
"engines.use_own_hint": "पहिले नै छ? यसको प्रोग्राम छनोट गर्नुहोस् र Filee ले आफ्नै डाउनलोड गर्नुको सट्टा त्यसलाई प्रयोग गर्नेछ।",
diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json
index a5587541a..999bf34b6 100644
--- a/src/Filee.App/Assets/i18n/pl.json
+++ b/src/Filee.App/Assets/i18n/pl.json
@@ -78,6 +78,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "Pliki: {0}",
"donut.drop_to": "Upuść → {0}",
+ "donut.ordinary_conversion.single": "Ten plik jest konwertowany zwyczajnie",
+ "donut.ordinary_conversion.all": "Każdy konwertowalny plik: zwykła konwersja",
+ "donut.ordinary_conversion.some": "Niektóre pliki są konwertowane zwyczajnie",
"donut.pick_in_group": "Przesuń na zewnątrz, by wybrać",
"donut.group_empty": "W tej grupie nie ma już ustawień. Edytuj ją w Ustawienia → Pasek-pierścień",
"donut.click_to": "Kliknij → {0}",
@@ -114,6 +117,8 @@
"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ć",
"error.document_too_large": "Ten dokument rozpakowuje się do zbyt dużej ilości danych lub jest zagnieżdżony zbyt głęboko, by bezpiecznie go otworzyć",
+ "error.merge_paper_geometry": "Strona ma nieprawidłowy rozmiar, obrót lub jednostki i nie można jej dopasować do wybranego papieru",
+ "error.merge_paper_limit": "Strona przekracza limit przetwarzania przy zmianie rozmiaru papieru",
"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.",
@@ -172,6 +177,9 @@
"error.fit_damaged": "Plik FIT jest uszkodzony lub ucięty",
"error.fit_chained": "Pliki FIT połączone jeden za drugim nie są jeszcze obsługiwane",
"error.fit_too_large": "Plik FIT jest za duży do konwersji",
+ "error.canvas_not_canvas": "To nie jest kanwa Obsidiana (JSON Canvas)",
+ "error.canvas_empty": "Kanwa nie ma kart do narysowania",
+ "error.canvas_too_large": "Kanwa jest za duża do konwersji",
"error.fit_no_file_id": "Plik FIT nie podaje swojego rodzaju",
"error.fit_not_activity": "Ten plik FIT nie jest aktywnością (konwertowane są tylko aktywności)",
"error.fit_no_positions": "Aktywność nie zawiera zapisanych położeń: przekonwertuj ją do CSV, XLSX lub JSON",
@@ -183,12 +191,16 @@
"error.fon_damaged": "Plik .fon jest uszkodzony lub niekompletny",
"error.document_too_complex": "Dokument jest zbyt duży lub zbyt złożony, aby bezpiecznie go odczytać",
"error.m4s_needs_init": "Ten plik .m4s to fragment wideo strumieniowego i nie da się go odczytać bez osobnego segmentu początkowego (init)",
+ "error.nov_password": "Ten eksport jest chroniony hasłem, więc jego wideo jest zaszyfrowane: wyeksportuj go ponownie z Wisenet WAVE / Nx Witness bez hasła",
+ "error.nov_no_video": "Ten eksport nie zawiera nagrania z kamery",
"error.exe_unreadable": "To nie jest program ani biblioteka Windows, którą Filee potrafi odczytać, albo plik jest uszkodzony",
"error.exe_no_icon": "Ten plik nie ma własnej ikony (Windows pokazuje dla niego ikonę standardową)",
"notice.pdf_not_smaller": "Nie udało się zmniejszyć pliku, więc skopiowano go bez zmian",
+ "notice.nov_recordings_left_out": "Ten eksport zawiera kilka nagrań (z innych kamer lub części rozdzielonych przerwami): przekonwertowano tylko największe",
"notice.under_min_size": "Zapisano, ale plik jest mniejszy niż minimalny rozmiar",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "Pominięto obiekty, których wybrana wersja AutoCAD nie ma (multiodnośniki, siatki…)",
+ "notice.gcode_no_thumbnail": "Plik G-code nie ma podglądu ze slicera, więc jego ścieżkę narysowano z góry",
"notice.sheet_objects_left_out": "Pominięto obiekty, których Filee jeszcze nie drukuje (wykresy, tekst w kształtach…): zainstaluj LibreOffice w Ustawienia → Silniki, aby je zachować",
"notice.cmyk_needs_ghostscript": "Ten plik jest w CMYK, ale zapisano go w RGB: zainstaluj Ghostscript w Ustawienia → Silniki, aby zachować CMYK",
"notice.pdfa_needs_ghostscript": "Ten PDF nie jest w formacie PDF/A: zainstaluj Ghostscript w Ustawienia → Silniki, aby tworzyć PDF/A",
@@ -282,6 +294,8 @@
"engine.font.description": "Konwertuje między TTF, OTF, WOFF, WOFF2 i EOT; konwertuje bitmapowe czcionki Windows (FON) do tych formatów.",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "Rysunki AutoCAD: DWG ↔ DXF, oba także do PDF, SVG i PNG.",
+ "engine.gcode.name": "G-code (built-in)",
+ "engine.gcode.description": "Ścieżki drukarek 3D i maszyn CNC (G-code) do PDF, SVG i obrazów albo podgląd zapisany przez slicer.",
"engine.drawing.name": "Drawings (built-in)",
"engine.drawing.description": "Rysunki Visio (VSDX) i OpenDocument (ODG, FODG) do PDF, SVG i PNG, bez Visio i LibreOffice.\nStare binarne pliki Visio (.vsd) nadal wymagają LibreOffice.",
"engine.model3d.name": "3D models (built-in)",
@@ -304,6 +318,7 @@
"engine.tesseract.description": "Rozpoznaje tekst skanów i obrazów (OCR): przeszukiwalny PDF, DOCX, TXT.",
"engine.vision.description": "Rozpoznaje tekst skanów i obrazów wbudowanym rozpoznawaniem tekstu macOS (OCR): przeszukiwalny PDF, DOCX, TXT. Nic nie trzeba pobierać.",
"engine.calibre.description": "Rzadkie formaty e-booków (LIT, LRF…) i zapis w starszych formatach (LIT, PDB, RB…).\nGdy renderer Filee jest wyłączony, a Calibre stoi nad HWPX writer, tworzy też PDF-y e-booków (znacznie szybciej przy długich książkach).",
+ "engine.weasyprint.description": "Tworzy pliki PDF ze stron HTML, Markdown i e-maili tak, jak drukuje je przeglądarka: nagłówki, tabele, pogrubienia oraz tekst arabski lub hindi wychodzą poprawnie.\nWczytuje tylko obrazy obok dokumentu, nic z internetu.",
"engine.libreoffice.description": "Odczytuje starsze i rzadsze formaty: DOC z Worda 6.0/95, PPT z PowerPointa 95, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, binarne Visio (VSD)…\nKonwertuje też pliki ODP i ODG z wykresami, osadzonymi obiektami lub 3D oraz zapisuje jako DOC, XLS, PPT, ODT, szablony OpenDocument i EMF.",
"engine.translator.description": "Tłumaczy dokumenty i e-booki lokalnie za pomocą modeli offline Ollama.\nTłumaczy też napisy SRT i WebVTT: do modelu trafia tylko tekst napisów, a numery i znaczniki czasu pozostają bez zmian.",
"engine.exiftool.description": "Odczytuje wszystkie pola metadanych obrazów, PDF-ów, wideo i dźwięku oraz edytuje je w większości rodzajów plików, nie zmieniając ich zawartości.\nUżywa go narzędzie Metadane; bez niego pola są tylko do odczytu.",
@@ -404,6 +419,7 @@
"clip.save": "Zapisz klip",
"clip.save_next": "Zapisz i dalej",
"clip.seconds": "{0} s",
+ "clip.ringtone_limit": "Dzwonek iPhone’a trwa najwyżej {0} s",
"clip.length_hint": "Klip o tej długości od początku",
"clip.fast": "Szybko, bez ponownego kodowania",
"clip.fast_hint": "Kopiuje wideo bez zmian: gotowe w chwilę i bez utraty jakości, ale klip zaczyna się od klatki kluczowej przed początkiem (do kilku sekund wcześniej). Wyłącz, aby ciąć dokładnie w wybranych klatkach (koduje ponownie, trwa dłużej).",
@@ -492,6 +508,10 @@
"preset.flip_vertical.short": "Pionowo",
"preset.merge_pdf": "Połącz PDF",
"preset.merge_pdf.donut": "Połącz PDF",
+ "presets.normalize_merge_paper": "Użyj jednego rozmiaru papieru dla wszystkich połączonych stron",
+ "presets.merge_paper": "Papier dla połączonego PDF-a",
+ "presets.merge_paper_hint": "Dopasowuje każdą stronę do wybranego papieru i wyśrodkowuje ją bez przycinania. Ustawienia stron obrazów są stosowane przed łączeniem.",
+ "presets.merge_paper_auto_hint": "Orientacja automatyczna jest wybierana raz na podstawie pierwszej strony. Mniejsze strony są powiększane, aby pasowały.",
"merge.order_title": "Wybierz kolejność scalania",
"merge.order_hint": "Strzałkami przechodź między kartami. Aby zmienić kolejność, przeciągnij kartę lub użyj Alt + ↑ / Alt + ↓. Pliki są łączone od góry do dołu. Delete usuwa zaznaczony plik.",
"merge.sort_by": "Sortuj według",
@@ -807,9 +827,15 @@
"toolbar.hint_remove": "Przeciągnij wycinek tutaj, by go usunąć",
"toolbar.hint_reorder": "Przeciągaj wycinki na siebie, by zmienić kolejność",
"toolbar.hint_edit": "Kliknij wycinek prawym przyciskiem lub użyj ✎, by edytować",
+ "toolbar.group_hint": "Przeciągnij ustawienia z listy, przeciągaj fragmenty, aby zmienić kolejność, i przeciągnij fragment na listę, aby go usunąć.",
+ "toolbar.group_back": "Wróć do: {0}",
"toolbar.max_reached": "Pierścień mieści do {0} ustawień",
- "toolbar.appearance": "Wygląd",
"toolbar.size": "Rozmiar",
+ "toolbar.edit_size": "Edytuj rozmiar pierścienia",
+ "toolbar.look_done": "Gotowe",
+ "toolbar.look_hint": "Pierścień jest na ekranie w rzeczywistym rozmiarze. Przeciągnij jego środek, aby go przesunąć, i kliknij grupę, aby pozostała otwarta.",
+ "toolbar.look_all": "Wszystkie pierścienie",
+ "toolbar.look_this": "Ten pierścień: {0}",
"toolbar.hole": "Otwór",
"toolbar.opacity": "Krycie",
"toolbar.captions": "Pokazuj format wyjściowy",
@@ -817,6 +843,9 @@
"toolbar.label_scale": "Rozmiar etykiet",
"toolbar.ring_size": "Rozmiar pierścienia grupy",
"toolbar.ring_spread": "Rozpiętość pierścienia grupy",
+ "toolbar.group_shown": "Pokazana grupa",
+ "toolbar.example_group": "Przykładowa grupa",
+ "toolbar.example_none": "Ten pierścień nie ma grup",
"labels.auto": "Automatycznie",
"labels.icon_text": "Ikona i tekst",
"labels.text_only": "Tylko tekst",
@@ -839,6 +868,7 @@
"group.add": "Dodaj",
"group.add_placeholder": "Wybierz ustawienie",
"group.full": "Grupa mieści do {0} ustawień.",
+ "group.copy_front": "„Kopiuj do folderu” nie może trafić do tej grupy: jest ona też na przodzie.",
"group.move_up": "W górę",
"group.move_down": "W dół",
"group.remove": "Usuń z grupy",
@@ -865,6 +895,9 @@
"toolbar.mixed_others": "Inne mieszanki plików otwierają „{0}”.",
"toolbar.mixed_others_self": "Nie został żaden mieszany profil bez zaznaczeń, więc ten jest używany także dla każdej innej mieszanki.",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "Ten format wejściowy jest konwertowany zwyczajnie",
+ "toolbar.chip_tip.ordinary_conversion.all": "Wszystkie formaty wejściowe możliwe do konwersji są konwertowane zwyczajnie",
+ "toolbar.chip_tip.ordinary_conversion.some": "Niektóre formaty wejściowe są konwertowane zwyczajnie",
"toolbar.chip_tip.makes_same": "→ Ten sam format",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "Obsługiwane formaty: {0}",
@@ -917,6 +950,11 @@
"presets.cad_colors.BlackAndWhite": "Czarno-biały",
"presets.cad_colors.Grayscale": "Skala szarości",
"presets.cad_colors_hint": "Używane, gdy rysunek DWG lub DXF staje się plikiem PDF, SVG lub obrazem. Czarno-biały rysuje wszystkie linie i teksty na czarno, jak wydruk monochromatyczny; wypełnione obszary przyjmują szarość odpowiadającą ich jasności, więc jasne wypełnienie nie staje się czarnym blokiem. Skala szarości zamienia każdy kolor na szary i przyciemnia jasne linie, np. żółte, na tyle, by dało się je czytać.",
+ "presets.gcode_view": "Widok",
+ "presets.gcode_view.Top": "Z góry",
+ "presets.gcode_view.Isometric": "Izometryczny",
+ "presets.gcode_view.Thumbnail": "Podgląd ze slicera",
+ "presets.gcode_view_hint": "Używane, gdy plik G-code ze slicera lub programu CAM staje się PDF, SVG lub obrazem. Ekstruzja i cięcia są kolorowane według wysokości, od ciemnego (nisko) do jasnego (wysoko); ruchy jałowe to cienkie niebieskie linie (czerwone dla szybkich przejazdów CNC). Podgląd ze slicera to obraz, który PrusaSlicer, Cura, OrcaSlicer lub Bambu Studio zapisał w pliku; SVG, PDF i pliki bez podglądu pokazują ścieżkę z góry.",
"presets.group.image": "Obraz",
"presets.quality": "Jakość",
"presets.resize": "Zmień rozmiar",
@@ -996,6 +1034,7 @@
"presets.ringtone_start": "Początek dzwonka (s)",
"presets.ringtone_length": "Długość dzwonka (s, maks. 40)",
"presets.ringtone_hint": "Dzwonki iPhone'a to pliki AAC o długości najwyżej 40 sekund. Dzwonek zaczyna się od podanej sekundy i jest przycinany do ustawionej długości (nigdy powyżej 40 s).",
+ "presets.ask_ringtone": "Pytaj o fragment za każdym razem (otwiera Wytnij klip)",
"presets.ico_sizes": "Rozmiary ikon",
"presets.group.pdf": "PDF",
"presets.merge": "Połącz wszystkie pliki w jeden PDF",
@@ -1156,6 +1195,7 @@
"presets.extract_note": "Każde archiwum jest rozpakowywane do własnego folderu. Miejsce ustawisz w sekcji Wynik.",
"presets.group.trace": "Wektoryzacja",
"presets.group.cad": "Rysunek CAD",
+ "presets.group.gcode": "G-code (druk 3D, CNC)",
"presets.trace_note": "Używane, gdy obraz (PNG, JPG, …) jest zamieniany na kształty wektorowe.",
"presets.trace_mode": "Tryb",
"presets.trace_mode.Color": "Kolor",
@@ -1384,6 +1424,8 @@
"engines.package.whisper.description": "Transkrybuje mowę z filmów i nagrań audio do plików napisów SRT. Działa offline bez połączenia z internetem.",
"engines.package.calibre.name": "Calibre (rzadkie formaty e-booków)",
"engines.package.calibre.description": "Konwertuje rzadkie formaty e-booków (LIT, LRF…) i zapisuje w starszych formatach (LIT, PDB, RB…).\nEPUB, MOBI, AZW3, FB2 i komiksy są odczytywane, a książki dla Kindle zapisywane, bez niego.",
+ "engines.package.weasyprint.name": "WeasyPrint (HTML, Markdown i e-mail do PDF)",
+ "engines.package.weasyprint.description": "Tworzy lepsze pliki PDF ze stron HTML, Markdown i e-maili (EML, MSG, MHTML): nagłówki, tabele, pogrubienia oraz pisma od prawej do lewej i indyjskie tak, jak drukuje je przeglądarka.\nBez niego też stają się PDF-ami, wbudowaną drogą.",
"engines.package.7zip.name": "7-Zip (rzadkie odmiany archiwów)",
"engines.package.7zip.description": "Rozpakowuje rzadkie odmiany archiwów, których nie czytają wbudowane czytniki Filee: 7Z z PPMd i innymi nietypowymi metodami, CAB Quantum, wieloczęściowe ARJ, obrazy dysków NTFS, exFAT lub APFS…\nZIP, 7Z, RAR, TAR, CAB, ISO, DMG i wszystkie inne popularne archiwa działają bez niego.",
"engines.package.size": "Pobieranie {0} · {1} na dysku",
@@ -1410,7 +1452,17 @@
"engines.remove": "Usuń",
"engines.remove_confirm": "Usunąć {0}? Możesz pobrać go później ponownie.",
"engines.state.own_copy": "Twoja kopia",
- "engines.state.system_copy": "Znaleziono na tym komputerze",
+ "engines.source.download": "Pobrane przez Filee",
+ "engines.source.system": "Na tym komputerze",
+ "engines.source.version": "wersja {0}",
+ "engines.source.on_disk": "{0} na dysku",
+ "engines.other_copy_title": "Również na tym komputerze",
+ "engines.other_copy_detail": "{0} (kopia pobrana przez Filee zostanie usunięta)",
+ "engines.use_other_copy": "Użyj tej zamiast",
+ "engines.use_other_copy_confirm": "Użyć kopii z {0} i usunąć {1} pobrany przez Filee? Możesz pobrać go później ponownie.",
+ "engines.other_copy_gone": "Tej kopii nie można już znaleźć lub się nie uruchamia. Pobrany przez Filee program został zachowany.",
+ "engines.install_filee_copy": "Zainstaluj kopię Filee",
+ "engines.use_another": "Użyj innej kopii…",
"engines.update_recommended": "Zalecana aktualizacja: ta kopia to {0}, Filee jest testowane z {1}.\nWiększość konwersji nadal działa.",
"engines.use_own": "Użyj mojej kopii…",
"engines.use_own_hint": "Masz go już? Wskaż jego program, a Filee użyje go zamiast pobierać własny.",
diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json
index fed4c2b6d..c970d9431 100644
--- a/src/Filee.App/Assets/i18n/pt-BR.json
+++ b/src/Filee.App/Assets/i18n/pt-BR.json
@@ -78,6 +78,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "{0} arquivos",
"donut.drop_to": "Solte → {0}",
+ "donut.ordinary_conversion.single": "Este arquivo usa uma conversão normal",
+ "donut.ordinary_conversion.all": "Todos os arquivos convertíveis: conversão normal",
+ "donut.ordinary_conversion.some": "Alguns arquivos usam uma conversão normal",
"donut.pick_in_group": "Mova para fora para escolher",
"donut.group_empty": "Não restam predefinições neste grupo. Edite em Configurações → Menu circular",
"donut.click_to": "Clique → {0}",
@@ -114,6 +117,8 @@
"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",
"error.document_too_large": "Este documento se descompacta em dados demais ou está aninhado fundo demais para ser aberto com segurança",
+ "error.merge_paper_geometry": "A página tem tamanho, rotação ou unidades inválidos e não pode ser ajustada ao papel selecionado",
+ "error.merge_paper_limit": "A página excede o limite de processamento para alterar o tamanho do papel",
"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.",
@@ -172,6 +177,9 @@
"error.fit_damaged": "O arquivo FIT está danificado ou incompleto",
"error.fit_chained": "Arquivos FIT concatenados ainda não são compatíveis",
"error.fit_too_large": "O arquivo FIT é grande demais para converter",
+ "error.canvas_not_canvas": "Isto não é um canvas do Obsidian (JSON Canvas)",
+ "error.canvas_empty": "O canvas não tem cartões para desenhar",
+ "error.canvas_too_large": "O canvas é grande demais para converter",
"error.fit_no_file_id": "O arquivo FIT não informa seu tipo",
"error.fit_not_activity": "Este arquivo FIT não é uma atividade (só atividades são convertidas)",
"error.fit_no_positions": "A atividade não tem posições registradas: converta-a para CSV, XLSX ou JSON",
@@ -183,12 +191,16 @@
"error.fon_damaged": "O arquivo .fon está danificado ou incompleto",
"error.document_too_complex": "O documento é grande ou complexo demais para ser lido com segurança",
"error.m4s_needs_init": "Este .m4s é um pedaço de um vídeo transmitido por streaming e não pode ser lido sem o seu segmento inicial (init) separado",
+ "error.nov_password": "Esta exportação é protegida por senha, então o vídeo está criptografado: exporte de novo pelo Wisenet WAVE / Nx Witness sem senha",
+ "error.nov_no_video": "Esta exportação não contém nenhuma gravação de câmera",
"error.exe_unreadable": "Não é um programa ou biblioteca do Windows que o Filee consiga ler, ou o arquivo está danificado",
"error.exe_no_icon": "Este arquivo não tem ícone próprio (o Windows mostra um ícone padrão para ele)",
"notice.pdf_not_smaller": "Não deu para deixar menor, então foi copiado como está",
+ "notice.nov_recordings_left_out": "Esta exportação contém várias gravações (outras câmeras ou partes separadas por interrupções): só a maior foi convertida",
"notice.under_min_size": "Salvo, mas menor que o tamanho mínimo do arquivo",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "Objetos que a versão do AutoCAD escolhida não tem (multilinhas de chamada, malhas…) ficaram de fora",
+ "notice.gcode_no_thumbnail": "O arquivo G-code não tem prévia do fatiador, então o percurso foi desenhado visto de cima",
"notice.sheet_objects_left_out": "Objetos que o Filee ainda não imprime (gráficos, texto em formas…) ficaram de fora: instale o LibreOffice em Configurações → Motores para mantê-los",
"notice.cmyk_needs_ghostscript": "Este arquivo é CMYK, mas foi salvo em RGB: instale o Ghostscript em Configurações → Motores para manter o CMYK",
"notice.pdfa_needs_ghostscript": "Este PDF não é PDF/A: instale o Ghostscript em Configurações → Motores para criar PDF/A",
@@ -282,6 +294,8 @@
"engine.font.description": "Converte TTF, OTF, WOFF, WOFF2 e EOT entre si; converte fontes bitmap do Windows (FON) para esses formatos.",
"engine.cad.name": "CAD (mecanismo integrado)",
"engine.cad.description": "Desenhos do AutoCAD: DWG ↔ DXF, e os dois para PDF, SVG e PNG.",
+ "engine.gcode.name": "G-code (mecanismo integrado)",
+ "engine.gcode.description": "Percursos de impressoras 3D e máquinas CNC (G-code) para PDF, SVG e imagens, ou a prévia do fatiador.",
"engine.drawing.name": "Desenhos (mecanismo integrado)",
"engine.drawing.description": "Desenhos do Visio (VSDX) e do OpenDocument (ODG, FODG) para PDF, SVG e PNG, sem Visio nem LibreOffice.\nArquivos binários antigos do Visio (.vsd) ainda precisam do LibreOffice.",
"engine.model3d.name": "Modelos 3D (mecanismo integrado)",
@@ -304,6 +318,7 @@
"engine.tesseract.description": "Reconhece o texto de digitalizações e imagens (OCR): PDF pesquisável, DOCX, TXT.",
"engine.vision.description": "Reconhece o texto de digitalizações e imagens com o reconhecimento de texto do próprio macOS (OCR): PDF pesquisável, DOCX, TXT. Nada para baixar.",
"engine.calibre.description": "Formatos raros de e-book (LIT, LRF…) e saída em formatos antigos (LIT, PDB, RB…).\nCom o renderizador do Filee desligado e o Calibre acima do gravador de HWPX, também gera os PDFs de e-books (bem mais rápido em livros longos).",
+ "engine.weasyprint.description": "Cria PDFs de páginas HTML, Markdown e e-mails como um navegador os imprime: títulos, tabelas, negrito e texto árabe ou hindi saem certos.\nCarrega só as imagens ao lado do documento, nada da internet.",
"engine.libreoffice.description": "Lê formatos antigos e menos comuns: DOC do Word 6.0/95, PPT do PowerPoint 95, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio binário (VSD)…\nTambém converte arquivos ODP e ODG com gráficos, objetos incorporados ou 3D, e salva como DOC, XLS, PPT, ODT, modelos OpenDocument e EMF.",
"engine.translator.description": "Traduz documentos e e-books localmente usando modelos offline do Ollama.\nTambém traduz legendas SRT e WebVTT: só o texto de cada legenda é enviado ao modelo; os números e os tempos das legendas são mantidos.",
"engine.exiftool.description": "Lê todos os campos de metadados de imagens, PDFs, vídeo e áudio, e os edita na maioria dos tipos de arquivo sem mexer no conteúdo.\nUsado pela ferramenta Metadados; sem ele, os campos aparecem somente para leitura.",
@@ -404,6 +419,7 @@
"clip.save": "Salvar clipe",
"clip.save_next": "Salvar e próximo",
"clip.seconds": "{0} s",
+ "clip.ringtone_limit": "Um toque do iPhone tem no máximo {0} segundos",
"clip.length_hint": "Um clipe com esta quantidade de segundos a partir do início",
"clip.fast": "Rápido, sem recodificar",
"clip.fast_hint": "Copia o vídeo como está: pronto num instante e sem perda, mas o clipe começa no quadro-chave antes do início (até alguns segundos antes). Desative para cortar exatamente nos quadros escolhidos (recodifica, demora mais).",
@@ -492,6 +508,10 @@
"preset.flip_vertical.short": "Vertical",
"preset.merge_pdf": "Mesclar PDF",
"preset.merge_pdf.donut": "Mesclar PDF",
+ "presets.normalize_merge_paper": "Usar um tamanho de papel para todas as páginas mescladas",
+ "presets.merge_paper": "Papel do PDF mesclado",
+ "presets.merge_paper_hint": "Ajusta cada página ao papel selecionado e a centraliza sem cortar. As configurações de página das imagens são aplicadas antes da mesclagem.",
+ "presets.merge_paper_auto_hint": "A orientação automática é escolhida uma vez com base na primeira página. Páginas menores são ampliadas para caber.",
"merge.order_title": "Escolha a ordem da mesclagem",
"merge.order_hint": "Use as setas para navegar. Para mover a ordem, clique e arraste ou use Alt + ↑ / Alt + ↓. Os arquivos são mesclados de cima para baixo. Delete remove o arquivo selecionado.",
"merge.sort_by": "Ordenar por",
@@ -807,9 +827,15 @@
"toolbar.hint_remove": "Arraste uma fatia de volta para cá para removê-la",
"toolbar.hint_reorder": "Arraste fatias umas sobre as outras para reordenar",
"toolbar.hint_edit": "Clique com o botão direito numa fatia ou use ✎ para editar",
+ "toolbar.group_hint": "Arraste predefinições da lista, arraste as fatias para reordenar e arraste uma para a lista para tirá-la.",
+ "toolbar.group_back": "Voltar para {0}",
"toolbar.max_reached": "Um menu comporta até {0} predefinições",
- "toolbar.appearance": "Aparência",
"toolbar.size": "Tamanho",
+ "toolbar.edit_size": "Editar tamanho do menu",
+ "toolbar.look_done": "Concluir",
+ "toolbar.look_hint": "Seu menu está na tela no tamanho real. Arraste o centro para movê-lo e clique em um grupo para mantê-lo aberto.",
+ "toolbar.look_all": "Todos os menus",
+ "toolbar.look_this": "Este menu: {0}",
"toolbar.hole": "Furo",
"toolbar.opacity": "Opacidade",
"toolbar.captions": "Mostrar formato de saída",
@@ -817,6 +843,9 @@
"toolbar.label_scale": "Tamanho dos rótulos",
"toolbar.ring_size": "Tamanho do anel de grupo",
"toolbar.ring_spread": "Abertura do anel de grupo",
+ "toolbar.group_shown": "Grupo exibido",
+ "toolbar.example_group": "Grupo de exemplo",
+ "toolbar.example_none": "Este menu não tem grupos",
"labels.auto": "Automático",
"labels.icon_text": "Ícone e texto",
"labels.text_only": "Só texto",
@@ -839,6 +868,7 @@
"group.add": "Adicionar",
"group.add_placeholder": "Escolha uma predefinição",
"group.full": "Um grupo comporta até {0} predefinições.",
+ "group.copy_front": "\"Copiar para pasta\" não pode entrar neste grupo: ele também está em uma frente.",
"group.move_up": "Mover para cima",
"group.move_down": "Mover para baixo",
"group.remove": "Remover do grupo",
@@ -865,6 +895,9 @@
"toolbar.mixed_others": "Outras misturas de arquivos abrem “{0}”.",
"toolbar.mixed_others_self": "Não restou perfil misto sem nada marcado, então este também é usado para toda outra mistura.",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "Este formato de entrada usa uma conversão normal",
+ "toolbar.chip_tip.ordinary_conversion.all": "Todos os formatos de entrada que podem ser convertidos usam uma conversão normal",
+ "toolbar.chip_tip.ordinary_conversion.some": "Alguns formatos de entrada usam uma conversão normal",
"toolbar.chip_tip.makes_same": "→ Mesmo formato",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "Formatos aceitos: {0}",
@@ -917,6 +950,11 @@
"presets.cad_colors.BlackAndWhite": "Preto e branco",
"presets.cad_colors.Grayscale": "Tons de cinza",
"presets.cad_colors_hint": "Usado quando um desenho DWG ou DXF vira PDF, SVG ou imagem. Preto e branco desenha todas as linhas e textos em preto, como uma plotagem monocromática; áreas preenchidas ficam no cinza do seu brilho, para que um preenchimento claro não vire um bloco preto. Tons de cinza transforma cada cor em cinza e escurece linhas claras, como o amarelo, o suficiente para ler.",
+ "presets.gcode_view": "Vista",
+ "presets.gcode_view.Top": "De cima",
+ "presets.gcode_view.Isometric": "Isométrica",
+ "presets.gcode_view.Thumbnail": "Prévia do fatiador",
+ "presets.gcode_view_hint": "Usado quando um arquivo G-code de um fatiador ou programa CAM vira PDF, SVG ou imagem. Extrusão e cortes são coloridos pela altura, do escuro (baixo) ao claro (alto); os movimentos em vazio são linhas azuis finas (vermelhas para os rápidos de uma CNC). A prévia do fatiador é a imagem que o PrusaSlicer, Cura, OrcaSlicer ou Bambu Studio salvou no arquivo; SVG, PDF e arquivos sem ela mostram o percurso visto de cima.",
"presets.group.image": "Imagem",
"presets.quality": "Qualidade",
"presets.resize": "Redimensionar",
@@ -996,6 +1034,7 @@
"presets.ringtone_start": "Início do toque (s)",
"presets.ringtone_length": "Duração do toque (s, máx. 40)",
"presets.ringtone_hint": "Os toques do iPhone são arquivos AAC de no máximo 40 segundos. O toque começa no segundo que você definir e é cortado na duração escolhida (nunca mais de 40 s).",
+ "presets.ask_ringtone": "Perguntar o trecho sempre (abre Cortar um clipe)",
"presets.ico_sizes": "Tamanhos do ícone",
"presets.group.pdf": "PDF",
"presets.merge": "Mesclar todos os arquivos num PDF",
@@ -1156,6 +1195,7 @@
"presets.extract_note": "Cada arquivo compactado é extraído na própria pasta. O destino fica em Saída.",
"presets.group.trace": "Vetorização",
"presets.group.cad": "Desenho CAD",
+ "presets.group.gcode": "G-code (impressão 3D, CNC)",
"presets.trace_note": "Usado quando uma imagem (PNG, JPG, …) é vetorizada em formas.",
"presets.trace_mode": "Modo",
"presets.trace_mode.Color": "Cor",
@@ -1384,6 +1424,8 @@
"engines.package.whisper.description": "Transcreve a fala de vídeos e áudios para arquivos de legendas SRT. Funciona offline sem conexão com a internet.",
"engines.package.calibre.name": "Calibre (formatos raros de e-book)",
"engines.package.calibre.description": "Converte formatos raros de e-book (LIT, LRF…) e salva em formatos antigos (LIT, PDB, RB…).\nEPUB, MOBI, AZW3, FB2 e quadrinhos são lidos, e livros para Kindle são salvos, sem ele.",
+ "engines.package.weasyprint.name": "WeasyPrint (HTML, Markdown e e-mail para PDF)",
+ "engines.package.weasyprint.description": "Cria PDFs melhores de páginas HTML, Markdown e e-mails (EML, MSG, MHTML): títulos, tabelas, negrito e escritas da direita para a esquerda ou indianas como um navegador os imprime.\nSem ele, também viram PDF pela rota integrada.",
"engines.package.7zip.name": "7-Zip (variantes raras de arquivo compactado)",
"engines.package.7zip.description": "Extrai as variantes raras que os leitores próprios do Filee não abrem: 7Z com PPMd e outros métodos incomuns, CAB Quantum, ARJ em vários volumes, imagens de disco NTFS, exFAT ou APFS…\nZIP, 7Z, RAR, TAR, CAB, ISO, DMG e todos os outros arquivos compactados comuns funcionam sem ele.",
"engines.package.size": "Download de {0} · {1} em disco",
@@ -1410,7 +1452,17 @@
"engines.remove": "Remover",
"engines.remove_confirm": "Remover {0}? Você pode baixar de novo depois.",
"engines.state.own_copy": "Sua cópia",
- "engines.state.system_copy": "Encontrado neste PC",
+ "engines.source.download": "Download do Filee",
+ "engines.source.system": "Neste computador",
+ "engines.source.version": "versão {0}",
+ "engines.source.on_disk": "{0} em disco",
+ "engines.other_copy_title": "Também neste computador",
+ "engines.other_copy_detail": "{0} (o download do Filee seria removido)",
+ "engines.use_other_copy": "Usar esta no lugar",
+ "engines.use_other_copy_confirm": "Usar a cópia em {0} e remover o download do {1} feito pelo Filee? Você pode baixar de novo depois.",
+ "engines.other_copy_gone": "Essa cópia não foi mais encontrada ou não inicia. O download do Filee foi mantido.",
+ "engines.install_filee_copy": "Instalar a cópia do Filee",
+ "engines.use_another": "Usar outra cópia…",
"engines.update_recommended": "Atualização recomendada: esta cópia é {0}, o Filee foi testado com {1}.\nA maioria das conversões continua funcionando.",
"engines.use_own": "Usar minha cópia…",
"engines.use_own_hint": "Já tem? Escolha o programa e o Filee usa esse, em vez de baixar o próprio.",
diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json
index f98bf405e..66f521d73 100644
--- a/src/Filee.App/Assets/i18n/pt-PT.json
+++ b/src/Filee.App/Assets/i18n/pt-PT.json
@@ -78,6 +78,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "{0} ficheiros",
"donut.drop_to": "Largar → {0}",
+ "donut.ordinary_conversion.single": "Este ficheiro usa uma conversão normal",
+ "donut.ordinary_conversion.all": "Todos os ficheiros convertíveis: conversão normal",
+ "donut.ordinary_conversion.some": "Alguns ficheiros usam uma conversão normal",
"donut.pick_in_group": "Move o ponteiro para fora para escolher",
"donut.group_empty": "Este grupo já não tem predefinições. Edita-o em Definições → Barra circular",
"donut.click_to": "Clicar → {0}",
@@ -114,6 +117,8 @@
"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",
"error.document_too_large": "Este documento descomprime-se em demasiados dados ou está aninhado demasiado fundo para ser aberto em segurança",
+ "error.merge_paper_geometry": "A página tem um tamanho, uma rotação ou unidades inválidos e não pode ser ajustada ao papel selecionado",
+ "error.merge_paper_limit": "A página excede o limite de processamento para alterar o tamanho do papel",
"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.",
@@ -172,6 +177,9 @@
"error.fit_damaged": "O ficheiro FIT está danificado ou incompleto",
"error.fit_chained": "O ficheiro contém vários ficheiros FIT seguidos, o que ainda não é suportado",
"error.fit_too_large": "O ficheiro FIT é demasiado grande para converter",
+ "error.canvas_not_canvas": "Isto não é uma tela do Obsidian (JSON Canvas)",
+ "error.canvas_empty": "A tela não tem cartões para desenhar",
+ "error.canvas_too_large": "A tela é demasiado grande para converter",
"error.fit_no_file_id": "O ficheiro FIT não indica que tipo de ficheiro é",
"error.fit_not_activity": "Este ficheiro FIT não é uma atividade (só são convertidas atividades)",
"error.fit_no_positions": "A atividade não tem posições registadas: converte-a para CSV, XLSX ou JSON",
@@ -183,12 +191,16 @@
"error.fon_damaged": "O ficheiro .fon está danificado ou incompleto",
"error.document_too_complex": "O documento é demasiado grande ou complexo para ser lido em segurança",
"error.m4s_needs_init": "Este .m4s é um pedaço de um vídeo em streaming e não pode ser lido sem o respetivo segmento inicial (init) separado",
+ "error.nov_password": "Esta exportação está protegida por palavra-passe, por isso o vídeo está cifrado: exporte-a de novo no Wisenet WAVE / Nx Witness sem palavra-passe",
+ "error.nov_no_video": "Esta exportação não contém nenhuma gravação de câmara",
"error.exe_unreadable": "Não é um programa ou biblioteca do Windows que o Filee consiga ler, ou o ficheiro está danificado",
"error.exe_no_icon": "Este ficheiro não tem ícone próprio (o Windows mostra-lhe um ícone predefinido)",
"notice.pdf_not_smaller": "Não foi possível reduzir o tamanho, pelo que o ficheiro foi copiado sem alterações",
+ "notice.nov_recordings_left_out": "Esta exportação contém várias gravações (outras câmaras ou partes separadas por interrupções): só a maior foi convertida",
"notice.under_min_size": "Guardado, mas mais pequeno do que o tamanho mínimo do ficheiro",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "Os objetos que a versão de AutoCAD escolhida não suporta (linhas de chamada múltiplas, malhas…) foram omitidos",
+ "notice.gcode_no_thumbnail": "O ficheiro G-code não tem pré-visualização do fatiador, por isso o percurso foi desenhado visto de cima",
"notice.sheet_objects_left_out": "Os objetos que o Filee ainda não imprime (gráficos, texto em formas…) foram omitidos: instala o LibreOffice em Definições → Motores para os manter",
"notice.cmyk_needs_ghostscript": "Este ficheiro é CMYK, mas foi guardado em RGB: instala o Ghostscript em Definições → Motores para manter o CMYK",
"notice.pdfa_needs_ghostscript": "Este PDF não é PDF/A: instala o Ghostscript em Definições → Motores para criar PDF/A",
@@ -282,6 +294,8 @@
"engine.font.description": "Converte tipos de letra entre TTF, OTF, WOFF, WOFF2 e EOT; converte tipos de letra bitmap do Windows (FON) para estes formatos.",
"engine.cad.name": "CAD (motor integrado)",
"engine.cad.description": "Desenhos AutoCAD: DWG ↔ DXF e ambos para PDF, SVG e PNG.",
+ "engine.gcode.name": "G-code (motor integrado)",
+ "engine.gcode.description": "Percursos de impressoras 3D e máquinas CNC (G-code) para PDF, SVG e imagens, ou a pré-visualização do fatiador.",
"engine.drawing.name": "Desenhos (motor integrado)",
"engine.drawing.description": "Desenhos do Visio (VSDX) e do OpenDocument (ODG, FODG) para PDF, SVG e PNG, sem Visio nem LibreOffice.\nOs ficheiros binários antigos do Visio (.vsd) continuam a precisar do LibreOffice.",
"engine.model3d.name": "Modelos 3D (motor integrado)",
@@ -304,6 +318,7 @@
"engine.tesseract.description": "Reconhece o texto de digitalizações e imagens (OCR): PDF pesquisável, DOCX e TXT.",
"engine.vision.description": "Reconhece o texto de digitalizações e imagens com o reconhecimento de texto do macOS (OCR): PDF pesquisável, DOCX e TXT. Não é necessário descarregar nada.",
"engine.calibre.description": "Formatos raros de livros digitais (LIT, LRF…) e saída em formatos antigos (LIT, PDB, RB…).\nCom o renderizador do Filee desativado e o Calibre acima do gerador HWPX, também cria PDF de livros digitais (muito mais depressa para livros longos).",
+ "engine.weasyprint.description": "Cria PDF de páginas HTML, Markdown e e-mails como um navegador os imprime: títulos, tabelas, negrito e texto árabe ou hindi ficam corretos.\nCarrega apenas as imagens ao lado do documento, nada da Internet.",
"engine.libreoffice.description": "Lê formatos antigos e menos comuns: DOC do Word 6.0/95, PPT do PowerPoint 95, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio binário (VSD)…\nTambém converte ficheiros ODP e ODG com gráficos, objetos incorporados ou 3D, e guarda em DOC, XLS, PPT, ODT, modelos OpenDocument e EMF.",
"engine.translator.description": "Traduz documentos e e-books localmente através de modelos offline do Ollama.\nTambém traduz legendas SRT e WebVTT: apenas o texto de cada legenda é enviado ao modelo; os números e os tempos das legendas mantêm-se.",
"engine.exiftool.description": "Lê todos os campos de metadados de imagens, PDF, vídeo e áudio, e edita-os na maioria dos tipos de ficheiro sem mexer no conteúdo.\nUsado pela ferramenta Metadados; sem ele, os campos aparecem só para leitura.",
@@ -404,6 +419,7 @@
"clip.save": "Guardar excerto",
"clip.save_next": "Guardar e avançar",
"clip.seconds": "{0} s",
+ "clip.ringtone_limit": "Um toque do iPhone tem no máximo {0} segundos",
"clip.length_hint": "Um excerto com este número de segundos a partir do início",
"clip.fast": "Rápido, sem recodificar",
"clip.fast_hint": "Copia o vídeo tal como está: fica pronto num instante e sem perda de qualidade, mas o excerto começa no fotograma-chave anterior ao início (até alguns segundos antes). Desativa para cortar exatamente nos fotogramas escolhidos (volta a codificar e demora mais).",
@@ -492,6 +508,10 @@
"preset.flip_vertical.short": "Vertical",
"preset.merge_pdf": "Juntar PDF",
"preset.merge_pdf.donut": "Juntar PDF",
+ "presets.normalize_merge_paper": "Usar um só tamanho de papel para todas as páginas unidas",
+ "presets.merge_paper": "Papel do PDF unido",
+ "presets.merge_paper_hint": "Ajusta cada página ao papel selecionado e centra-a sem recortar. As definições de página das imagens são aplicadas antes de unir.",
+ "presets.merge_paper_auto_hint": "A orientação automática é escolhida uma vez com base na primeira página. As páginas mais pequenas são ampliadas para caber.",
"merge.order_title": "Escolher a ordem dos ficheiros",
"merge.order_hint": "Use as setas para navegar. Para mudar a ordem, clique e arraste ou use Alt + ↑ / Alt + ↓. Os ficheiros são unidos de cima para baixo. Delete remove o ficheiro selecionado.",
"merge.sort_by": "Ordenar por",
@@ -807,9 +827,15 @@
"toolbar.hint_remove": "Arrasta um segmento para aqui para o remover",
"toolbar.hint_reorder": "Arrasta os segmentos uns sobre os outros para os reordenar",
"toolbar.hint_edit": "Clica num segmento com o botão direito ou utiliza ✎ para editar",
+ "toolbar.group_hint": "Arraste predefinições da lista, arraste as fatias para reordenar e arraste uma para a lista para a retirar.",
+ "toolbar.group_back": "Voltar a {0}",
"toolbar.max_reached": "Uma barra circular admite até {0} predefinições",
- "toolbar.appearance": "Aspeto",
"toolbar.size": "Tamanho",
+ "toolbar.edit_size": "Editar tamanho da barra circular",
+ "toolbar.look_done": "Concluir",
+ "toolbar.look_hint": "A sua barra circular está no ecrã no tamanho real. Arraste o centro para a mover e clique num grupo para o manter aberto.",
+ "toolbar.look_all": "Todas as barras circulares",
+ "toolbar.look_this": "Esta barra circular: {0}",
"toolbar.hole": "Abertura central",
"toolbar.opacity": "Opacidade",
"toolbar.captions": "Mostrar formato de saída",
@@ -817,6 +843,9 @@
"toolbar.label_scale": "Tamanho dos rótulos",
"toolbar.ring_size": "Tamanho do anel de grupo",
"toolbar.ring_spread": "Abertura do anel de grupo",
+ "toolbar.group_shown": "Grupo mostrado",
+ "toolbar.example_group": "Grupo de exemplo",
+ "toolbar.example_none": "Esta barra circular não tem grupos",
"labels.auto": "Automático",
"labels.icon_text": "Ícone e texto",
"labels.text_only": "Só texto",
@@ -839,6 +868,7 @@
"group.add": "Adicionar",
"group.add_placeholder": "Escolhe uma predefinição",
"group.full": "Um grupo admite até {0} predefinições.",
+ "group.copy_front": "\"Copiar para pasta\" não pode entrar neste grupo: também está numa frente.",
"group.move_up": "Mover para cima",
"group.move_down": "Mover para baixo",
"group.remove": "Remover do grupo",
@@ -865,6 +895,9 @@
"toolbar.mixed_others": "As restantes misturas de ficheiros abrem «{0}».",
"toolbar.mixed_others_self": "Não resta nenhum perfil misto sem opções selecionadas, pelo que este também é utilizado para todas as outras misturas.",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "Este formato de entrada usa uma conversão normal",
+ "toolbar.chip_tip.ordinary_conversion.all": "Todos os formatos de entrada que podem ser convertidos usam uma conversão normal",
+ "toolbar.chip_tip.ordinary_conversion.some": "Alguns formatos de entrada usam uma conversão normal",
"toolbar.chip_tip.makes_same": "→ Mesmo formato",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "Formatos aceites: {0}",
@@ -917,6 +950,11 @@
"presets.cad_colors.BlackAndWhite": "Preto e branco",
"presets.cad_colors.Grayscale": "Escala de cinzentos",
"presets.cad_colors_hint": "Usado quando um desenho DWG ou DXF se torna PDF, SVG ou imagem. Preto e branco desenha todas as linhas e textos a preto, como uma impressão monocromática; as áreas preenchidas ficam no cinzento da sua luminosidade, para que um preenchimento claro não se torne um bloco preto. Escala de cinzentos transforma cada cor num cinzento e escurece as linhas claras, como o amarelo, o suficiente para se lerem.",
+ "presets.gcode_view": "Vista",
+ "presets.gcode_view.Top": "De cima",
+ "presets.gcode_view.Isometric": "Isométrica",
+ "presets.gcode_view.Thumbnail": "Pré-visualização do fatiador",
+ "presets.gcode_view_hint": "Usado quando um ficheiro G-code de um fatiador ou programa CAM passa a PDF, SVG ou imagem. A extrusão e os cortes são coloridos pela altura, do escuro (baixo) ao claro (alto); os movimentos em vazio são linhas azuis finas (vermelhas para os rápidos de uma CNC). A pré-visualização do fatiador é a imagem que o PrusaSlicer, Cura, OrcaSlicer ou Bambu Studio guardou no ficheiro; SVG, PDF e ficheiros sem ela mostram o percurso visto de cima.",
"presets.group.image": "Imagem",
"presets.quality": "Qualidade",
"presets.resize": "Redimensionar",
@@ -996,6 +1034,7 @@
"presets.ringtone_start": "Início do toque (s)",
"presets.ringtone_length": "Duração do toque (s, máx. 40)",
"presets.ringtone_hint": "Os toques do iPhone são ficheiros AAC com um máximo de 40 segundos. O toque começa no segundo que definir e é cortado na duração escolhida (nunca mais de 40 s).",
+ "presets.ask_ringtone": "Perguntar o excerto sempre (abre Cortar um clipe)",
"presets.ico_sizes": "Tamanhos dos ícones",
"presets.group.pdf": "PDF",
"presets.merge": "Juntar todos os ficheiros num PDF",
@@ -1156,6 +1195,7 @@
"presets.extract_note": "Cada ficheiro comprimido é extraído para uma pasta própria. O destino é definido em Saída.",
"presets.group.trace": "Vetorização",
"presets.group.cad": "Desenho CAD",
+ "presets.group.gcode": "G-code (impressão 3D, CNC)",
"presets.trace_note": "Utilizado ao vetorizar uma imagem (PNG, JPG, …).",
"presets.trace_mode": "Modo",
"presets.trace_mode.Color": "Cor",
@@ -1384,6 +1424,8 @@
"engines.package.whisper.description": "Transcreve a fala de vídeos e áudios para ficheiros de legendas SRT. Funciona offline sem ligação à Internet.",
"engines.package.calibre.name": "Calibre (formatos raros de livros digitais)",
"engines.package.calibre.description": "Converte formatos raros de livros digitais (LIT, LRF…) e guarda em formatos antigos (LIT, PDB, RB…).\nEPUB, MOBI, AZW3, FB2 e banda desenhada são lidos, e livros para Kindle são guardados, sem ele.",
+ "engines.package.weasyprint.name": "WeasyPrint (HTML, Markdown e e-mail para PDF)",
+ "engines.package.weasyprint.description": "Cria PDF melhores de páginas HTML, Markdown e e-mails (EML, MSG, MHTML): títulos, tabelas, negrito e escritas da direita para a esquerda ou indianas como um navegador os imprime.\nSem ele, também se tornam PDF pela via integrada.",
"engines.package.7zip.name": "7-Zip (variantes raras de arquivos)",
"engines.package.7zip.description": "Extrai as variantes raras de arquivos que os leitores do próprio Filee não abrem: 7Z com PPMd e outros métodos invulgares, CAB Quantum, ARJ em vários volumes, imagens de disco NTFS, exFAT ou APFS…\nZIP, 7Z, RAR, TAR, CAB, ISO, DMG e todos os outros arquivos comuns funcionam sem ele.",
"engines.package.size": "Transferência de {0} · {1} no disco",
@@ -1410,7 +1452,17 @@
"engines.remove": "Remover",
"engines.remove_confirm": "Remover {0}? Podes voltar a descarregá-lo mais tarde.",
"engines.state.own_copy": "A tua cópia",
- "engines.state.system_copy": "Encontrado neste computador",
+ "engines.source.download": "Transferência do Filee",
+ "engines.source.system": "Neste computador",
+ "engines.source.version": "versão {0}",
+ "engines.source.on_disk": "{0} no disco",
+ "engines.other_copy_title": "Também neste computador",
+ "engines.other_copy_detail": "{0} (a transferência do Filee seria removida)",
+ "engines.use_other_copy": "Utilizar esta em vez disso",
+ "engines.use_other_copy_confirm": "Utilizar a cópia em {0} e remover a transferência de {1} feita pelo Filee? Podes voltar a descarregá-lo mais tarde.",
+ "engines.other_copy_gone": "Essa cópia já não é encontrada ou não arranca. A transferência do Filee foi mantida.",
+ "engines.install_filee_copy": "Instalar a cópia do Filee",
+ "engines.use_another": "Utilizar outra cópia…",
"engines.update_recommended": "Atualização recomendada: esta cópia é a versão {0}; o Filee é testado com a {1}.\nA maioria das conversões continua a funcionar.",
"engines.use_own": "Utilizar a minha cópia…",
"engines.use_own_hint": "Já o tens? Seleciona o programa para o Filee o utilizar em vez de descarregar uma cópia própria.",
diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json
index 004021d31..49533c4c9 100644
--- a/src/Filee.App/Assets/i18n/ru.json
+++ b/src/Filee.App/Assets/i18n/ru.json
@@ -78,6 +78,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "Файлов: {0}",
"donut.drop_to": "Отпустите → {0}",
+ "donut.ordinary_conversion.single": "Этот файл обрабатывается как обычная конвертация",
+ "donut.ordinary_conversion.all": "Все преобразуемые файлы — обычная конвертация",
+ "donut.ordinary_conversion.some": "Некоторые файлы — обычная конвертация",
"donut.pick_in_group": "Ведите наружу, чтобы выбрать",
"donut.group_empty": "В этой группе не осталось пресетов. Настройте её в разделе «Настройки → Круговое меню»",
"donut.click_to": "Щёлкните → {0}",
@@ -114,6 +117,8 @@
"error.p7m_unknown_content": "Файл внутри этого .p7m имеет тип, который Filee не конвертирует",
"error.p7m_too_large": "Подписанный файл слишком большой, чтобы его открыть",
"error.document_too_large": "Этот документ распаковывается в слишком большой объём данных или вложен слишком глубоко, чтобы его можно было безопасно открыть",
+ "error.merge_paper_geometry": "У страницы неверный размер, поворот или единицы измерения — её нельзя вписать в выбранный формат бумаги",
+ "error.merge_paper_limit": "Страница превышает предел обработки при изменении формата бумаги",
"format_request.request": "Запросить этот формат",
"format_request.notice_title": "Filee пока не умеет конвертировать файлы {0}",
"format_request.notice_text": "Напишите, какой формат вам нужен.\nОт запросов зависит, что Filee будет поддерживать дальше.\nЗашифрованные и защищённые от копирования (DRM) файлы сконвертировать нельзя, в каком бы формате они ни были.",
@@ -172,6 +177,9 @@
"error.fit_damaged": "Файл FIT повреждён или обрезан",
"error.fit_chained": "Соединённые подряд файлы FIT пока не поддерживаются",
"error.fit_too_large": "Файл FIT слишком большой для конвертации",
+ "error.canvas_not_canvas": "Это не холст Obsidian (JSON Canvas)",
+ "error.canvas_empty": "На холсте нет карточек для отрисовки",
+ "error.canvas_too_large": "Холст слишком большой для конвертации",
"error.fit_no_file_id": "В файле FIT не указан его тип",
"error.fit_not_activity": "Этот файл FIT не является активностью (конвертируются только активности)",
"error.fit_no_positions": "В активности нет записанных координат: конвертируйте её в CSV, XLSX или JSON",
@@ -183,12 +191,16 @@
"error.fon_damaged": "Файл .fon повреждён или обрезан",
"error.document_too_complex": "Документ слишком большой или сложный, чтобы безопасно его прочитать",
"error.m4s_needs_init": "Этот .m4s — фрагмент потокового видео: без отдельного начального сегмента (init) его не прочитать",
+ "error.nov_password": "Этот экспорт защищён паролем, поэтому его видео зашифровано: экспортируйте его заново из Wisenet WAVE / Nx Witness без пароля",
+ "error.nov_no_video": "В этом экспорте нет записи с камеры",
"error.exe_unreadable": "Это не программа или библиотека Windows, которую Filee может прочитать, или файл повреждён",
"error.exe_no_icon": "У этого файла нет собственного значка (Windows показывает для него стандартный)",
"notice.pdf_not_smaller": "Уменьшить не получилось, файл скопирован как есть",
+ "notice.nov_recordings_left_out": "В этом экспорте несколько записей (другие камеры или части с разрывами): преобразована только самая большая",
"notice.under_min_size": "Сохранено, но меньше минимального размера файла",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "Объекты, которых нет в выбранной версии AutoCAD (мультивыноски, сети…), не сохранены",
+ "notice.gcode_no_thumbnail": "В файле G-code нет превью слайсера, поэтому траектория нарисована сверху",
"notice.sheet_objects_left_out": "Объекты, которые Filee пока не умеет печатать (диаграммы, текст в фигурах…), пропущены: чтобы сохранить их, установите LibreOffice в разделе Настройки → Движки",
"notice.cmyk_needs_ghostscript": "Это файл CMYK, но он сохранён в RGB: чтобы сохранить CMYK, установите Ghostscript в разделе Настройки → Движки",
"notice.pdfa_needs_ghostscript": "Этот PDF не в формате PDF/A: чтобы создавать PDF/A, установите Ghostscript в разделе Настройки → Движки",
@@ -282,6 +294,8 @@
"engine.font.description": "Преобразует TTF, OTF, WOFF, WOFF2 и EOT друг в друга; преобразует растровые шрифты Windows (FON) в эти форматы.",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "Чертежи AutoCAD: DWG ↔ DXF, и оба — в PDF, SVG и PNG.",
+ "engine.gcode.name": "G-code (built-in)",
+ "engine.gcode.description": "Траектории 3D-принтеров и станков с ЧПУ (G-code) в PDF, SVG и изображения или превью слайсера.",
"engine.drawing.name": "Drawings (built-in)",
"engine.drawing.description": "Схемы Visio (VSDX) и рисунки OpenDocument (ODG, FODG) — в PDF, SVG и PNG без Visio и LibreOffice.\nДля старых двоичных файлов Visio (.vsd) по-прежнему нужен LibreOffice.",
"engine.model3d.name": "3D models (built-in)",
@@ -304,6 +318,7 @@
"engine.tesseract.description": "Распознаёт текст сканов и изображений (OCR): PDF с поиском, DOCX, TXT.",
"engine.vision.description": "Распознаёт текст сканов и изображений встроенным в macOS распознаванием текста (OCR): PDF с поиском, DOCX, TXT. Ничего скачивать не нужно.",
"engine.calibre.description": "Редкие форматы электронных книг (LIT, LRF…) и сохранение в старые форматы (LIT, PDB, RB…).\nЕсли рендерер Filee выключен, а Calibre стоит выше HWPX writer, он же делает PDF из электронных книг (для длинных книг намного быстрее).",
+ "engine.weasyprint.description": "Создаёт PDF из HTML-страниц, Markdown и писем так, как их печатает браузер: заголовки, таблицы, жирный шрифт и арабский или хинди текст выглядят правильно.\nЗагружает только изображения рядом с документом, ничего из интернета.",
"engine.libreoffice.description": "Читает старые и редкие форматы: DOC из Word 6.0/95, PPT из PowerPoint 95, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, двоичные файлы Visio (VSD)…\nА ещё конвертирует файлы ODP и ODG с диаграммами, внедрёнными объектами или 3D и сохраняет в DOC, XLS, PPT, ODT, шаблоны OpenDocument и EMF.",
"engine.translator.description": "Переводит документы и электронные книги локально с помощью офлайн-моделей Ollama.\nТакже переводит субтитры SRT и WebVTT: в модель отправляется только текст реплик, номера и тайм-коды сохраняются.",
"engine.exiftool.description": "Читает все поля метаданных изображений, PDF, видео и звука и изменяет их в большинстве типов файлов, не трогая содержимое.\nИспользуется инструментом «Метаданные»; без него поля доступны только для чтения.",
@@ -404,6 +419,7 @@
"clip.save": "Сохранить клип",
"clip.save_next": "Сохранить и далее",
"clip.seconds": "{0} с",
+ "clip.ringtone_limit": "Рингтон iPhone — не более {0} секунд",
"clip.length_hint": "Клип такой длины от начала",
"clip.fast": "Быстро, без перекодирования",
"clip.fast_hint": "Копирует видео как есть: мгновенно и без потери качества, но клип начинается с ключевого кадра перед началом (до нескольких секунд раньше). Выключите, чтобы резать точно по выбранным кадрам (с перекодированием, дольше).",
@@ -492,6 +508,10 @@
"preset.flip_vertical.short": "По верт.",
"preset.merge_pdf": "Объединить PDF",
"preset.merge_pdf.donut": "Объединить PDF",
+ "presets.normalize_merge_paper": "Использовать один формат бумаги для всех страниц объединённого PDF",
+ "presets.merge_paper": "Бумага для объединённого PDF",
+ "presets.merge_paper_hint": "Вписывает каждую страницу в выбранную бумагу и выравнивает по центру без обрезки. Настройки страниц изображений применяются до объединения.",
+ "presets.merge_paper_auto_hint": "Ориентация выбирается автоматически один раз по первой странице. Маленькие страницы увеличиваются до нужного размера.",
"merge.order_title": "Выберите порядок объединения",
"merge.order_hint": "Стрелками переходите между карточками. Чтобы изменить порядок, перетащите карточку или нажмите Alt + ↑ / Alt + ↓. Файлы объединяются сверху вниз. Delete удаляет выбранный файл.",
"merge.sort_by": "Сортировка",
@@ -807,9 +827,15 @@
"toolbar.hint_remove": "Перетащите сектор сюда, чтобы убрать",
"toolbar.hint_reorder": "Перетаскивайте секторы друг на друга, чтобы поменять порядок",
"toolbar.hint_edit": "Правый щелчок по сектору или ✎ — изменить",
+ "toolbar.group_hint": "Перетащите пресеты из списка, перетаскивайте сегменты, чтобы изменить порядок, и перетащите сегмент в список, чтобы убрать его.",
+ "toolbar.group_back": "Назад: {0}",
"toolbar.max_reached": "В круге помещается не больше {0} пресетов",
- "toolbar.appearance": "Внешний вид",
"toolbar.size": "Размер",
+ "toolbar.edit_size": "Изменить размер кругового меню",
+ "toolbar.look_done": "Готово",
+ "toolbar.look_hint": "Круговое меню показано на экране в реальном размере. Перетащите его центр, чтобы переместить, и нажмите на группу, чтобы она оставалась открытой.",
+ "toolbar.look_all": "Все круговые меню",
+ "toolbar.look_this": "Это круговое меню: {0}",
"toolbar.hole": "Отверстие",
"toolbar.opacity": "Непрозрачность",
"toolbar.captions": "Показывать формат результата",
@@ -817,6 +843,9 @@
"toolbar.label_scale": "Размер подписей",
"toolbar.ring_size": "Размер кольца группы",
"toolbar.ring_spread": "Ширина кольца группы",
+ "toolbar.group_shown": "Показанная группа",
+ "toolbar.example_group": "Пример группы",
+ "toolbar.example_none": "В этом круговом меню нет групп",
"labels.auto": "Авто",
"labels.icon_text": "Значок и текст",
"labels.text_only": "Только текст",
@@ -839,6 +868,7 @@
"group.add": "Добавить",
"group.add_placeholder": "Выберите пресет",
"group.full": "В группе помещается не больше {0} пресетов.",
+ "group.copy_front": "«Копировать в папку» нельзя добавить в эту группу: она есть и на лицевой стороне.",
"group.move_up": "Выше",
"group.move_down": "Ниже",
"group.remove": "Убрать из группы",
@@ -865,6 +895,9 @@
"toolbar.mixed_others": "Остальные смеси файлов открывают «{0}».",
"toolbar.mixed_others_self": "Не осталось смешанного профиля без отметок, поэтому этот используется и для всех остальных смесей.",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "Этот входной формат обрабатывается как обычная конвертация",
+ "toolbar.chip_tip.ordinary_conversion.all": "Все конвертируемые входные форматы обрабатываются как обычная конвертация",
+ "toolbar.chip_tip.ordinary_conversion.some": "Некоторые входные форматы обрабатываются как обычная конвертация",
"toolbar.chip_tip.makes_same": "→ Тот же формат",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "Поддерживаемые форматы: {0}",
@@ -917,6 +950,11 @@
"presets.cad_colors.BlackAndWhite": "Чёрно-белый",
"presets.cad_colors.Grayscale": "Оттенки серого",
"presets.cad_colors_hint": "Применяется, когда чертёж DWG или DXF становится PDF, SVG или изображением. Чёрно-белый рисует все линии и тексты чёрным, как монохромная печать; заливки становятся серыми по своей яркости, чтобы светлая заливка не превратилась в чёрный блок. Оттенки серого переводят каждый цвет в серый и затемняют светлые линии, например жёлтые, настолько, чтобы их можно было прочитать.",
+ "presets.gcode_view": "Вид",
+ "presets.gcode_view.Top": "Сверху",
+ "presets.gcode_view.Isometric": "Изометрия",
+ "presets.gcode_view.Thumbnail": "Превью слайсера",
+ "presets.gcode_view_hint": "Используется, когда файл G-code из слайсера или CAM-программы становится PDF, SVG или изображением. Экструзия и резы окрашены по высоте, от тёмного (внизу) к светлому (вверху); холостые перемещения — тонкие синие линии (красные для ускоренных ходов ЧПУ). Превью слайсера — это картинка, которую PrusaSlicer, Cura, OrcaSlicer или Bambu Studio сохранил в файле; SVG, PDF и файлы без неё показывают траекторию сверху.",
"presets.group.image": "Изображение",
"presets.quality": "Качество",
"presets.resize": "Размер",
@@ -996,6 +1034,7 @@
"presets.ringtone_start": "Начало рингтона (с)",
"presets.ringtone_length": "Длина рингтона (с, не более 40)",
"presets.ringtone_hint": "Рингтоны iPhone — это файлы AAC длиной не более 40 секунд. Рингтон начинается с указанной секунды и обрезается до заданной длины (не более 40 с).",
+ "presets.ask_ringtone": "Спрашивать фрагмент каждый раз (откроется «Вырезать клип»)",
"presets.ico_sizes": "Размеры значка",
"presets.group.pdf": "PDF",
"presets.merge": "Объединить все файлы в один PDF",
@@ -1156,6 +1195,7 @@
"presets.extract_note": "Каждый архив распаковывается в свою папку. Куда она попадёт, задаётся в разделе «Сохранение».",
"presets.group.trace": "Векторизация",
"presets.group.cad": "Чертёж САПР",
+ "presets.group.gcode": "G-code (3D-печать, ЧПУ)",
"presets.trace_note": "Используется, когда картинка (PNG, JPG, …) превращается в векторные фигуры.",
"presets.trace_mode": "Режим",
"presets.trace_mode.Color": "Цветной",
@@ -1384,6 +1424,8 @@
"engines.package.whisper.description": "Преобразует речь из видео и аудио в файлы субтитров SRT. Работает автономно без подключения к Интернету.",
"engines.package.calibre.name": "Calibre (редкие форматы книг)",
"engines.package.calibre.description": "Конвертирует редкие форматы электронных книг (LIT, LRF…) и сохраняет в старые форматы (LIT, PDB, RB…).\nEPUB, MOBI, AZW3, FB2 и комиксы читаются, а книги для Kindle сохраняются и без него.",
+ "engines.package.weasyprint.name": "WeasyPrint (HTML, Markdown и письма в PDF)",
+ "engines.package.weasyprint.description": "Создаёт более качественные PDF из HTML-страниц, Markdown и писем (EML, MSG, MHTML): заголовки, таблицы, жирный шрифт и письменности справа налево или индийские, как при печати из браузера.\nБез него они тоже становятся PDF встроенным способом.",
"engines.package.7zip.name": "7-Zip (редкие варианты архивов)",
"engines.package.7zip.description": "Распаковывает редкие варианты архивов, которые не читают встроенные модули Filee: 7Z с PPMd и другими необычными методами, CAB с Quantum, многотомные ARJ, образы дисков NTFS, exFAT или APFS…\nZIP, 7Z, RAR, TAR, CAB, ISO, DMG и все остальные распространённые архивы работают и без него.",
"engines.package.size": "Загрузка: {0} · на диске: {1}",
@@ -1410,7 +1452,17 @@
"engines.remove": "Удалить",
"engines.remove_confirm": "Удалить {0}? Его можно будет скачать снова.",
"engines.state.own_copy": "Ваша копия",
- "engines.state.system_copy": "Найден на компьютере",
+ "engines.source.download": "Загружен Filee",
+ "engines.source.system": "На этом компьютере",
+ "engines.source.version": "версия {0}",
+ "engines.source.on_disk": "на диске: {0}",
+ "engines.other_copy_title": "Есть и на этом компьютере",
+ "engines.other_copy_detail": "{0} (копия, загруженная Filee, будет удалена)",
+ "engines.use_other_copy": "Использовать эту",
+ "engines.use_other_copy_confirm": "Использовать копию из {0} и удалить {1}, загруженный Filee? Его можно будет скачать снова.",
+ "engines.other_copy_gone": "Эта копия больше не найдена или не запускается. Копия, загруженная Filee, сохранена.",
+ "engines.install_filee_copy": "Установить копию Filee",
+ "engines.use_another": "Использовать другую копию…",
"engines.update_recommended": "Рекомендуется обновить: у этой копии версия {0}, а Filee проверен с {1}.\nБольшинство конвертаций всё равно работает.",
"engines.use_own": "Использовать свою копию…",
"engines.use_own_hint": "Уже есть? Укажите его программу, и Filee будет пользоваться ею, а не скачивать свою.",
diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json
index 7caeab924..b73fce55c 100644
--- a/src/Filee.App/Assets/i18n/th.json
+++ b/src/Filee.App/Assets/i18n/th.json
@@ -74,6 +74,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "{0} ไฟล์",
"donut.drop_to": "วาง → {0}",
+ "donut.ordinary_conversion.single": "ไฟล์นี้ใช้การแปลงตามปกติ",
+ "donut.ordinary_conversion.all": "ไฟล์ทั้งหมดที่แปลงได้ใช้การแปลงตามปกติ",
+ "donut.ordinary_conversion.some": "ไฟล์บางไฟล์ใช้การแปลงตามปกติ",
"donut.pick_in_group": "เลื่อนออกด้านนอกเพื่อเลือก",
"donut.group_empty": "ไม่มีพรีเซ็ตเหลือในกลุ่มนี้ แก้ไขได้ในการตั้งค่า → แถบโดนัท",
"donut.click_to": "คลิก → {0}",
@@ -127,6 +130,8 @@
"error.split_unknown_length": "ไม่ทราบความยาวของวิดีโอนี้ จึงตัดเป็นส่วนเท่า ๆ กันไม่ได้",
"error.split_needs_copy": "การแบ่งจะคัดลอกวิดีโอตามเดิม และรูปแบบนี้เก็บวิดีโอนี้ไม่ได้ ให้เลือกรูปแบบเดิมของวิดีโอหรือ MKV",
"error.merged_image_too_large": "รูปที่รวมแล้วจะใหญ่เกินไป ลองรวมรูปให้น้อยลงหรือใช้รูปที่เล็กลง",
+ "error.merge_paper_geometry": "หน้าเอกสารมีขนาด การหมุน หรือหน่วยที่ไม่ถูกต้อง จึงปรับให้พอดีกับกระดาษที่เลือกไม่ได้",
+ "error.merge_paper_limit": "หน้าเอกสารเกินขีดจำกัดการประมวลผลสำหรับการเปลี่ยนขนาดกระดาษ",
"error.over_size_limit": "ทำให้เล็กพอสำหรับขนาดสูงสุดไม่ได้",
"error.not_enough_space": "พื้นที่ว่างบนดิสก์ไม่เพียงพอสำหรับการบันทึกรูปภาพ",
"error.no_motion_photo": "รูปภาพนี้ไม่มีวิดีโอที่ Filee สามารถอ่านได้ (เช่น ภาพเคลื่อนไหวของ Android หรือ Live Photo ของ iPhone ที่มีไฟล์ MOV อยู่คู่กัน)",
@@ -166,6 +171,9 @@
"error.fit_damaged": "ไฟล์ FIT เสียหายหรือถูกตัดไม่ครบ",
"error.fit_chained": "ยังไม่รองรับไฟล์ FIT หลายไฟล์ที่นำมาต่อกัน",
"error.fit_too_large": "ไฟล์ FIT ใหญ่เกินกว่าจะแปลงได้",
+ "error.canvas_not_canvas": "ไฟล์นี้ไม่ใช่แคนวาสของ Obsidian (JSON Canvas)",
+ "error.canvas_empty": "แคนวาสไม่มีการ์ดให้วาด",
+ "error.canvas_too_large": "แคนวาสใหญ่เกินกว่าจะแปลงได้",
"error.fit_no_file_id": "ไฟล์ FIT ไม่ได้ระบุประเภทของไฟล์",
"error.fit_not_activity": "ไฟล์ FIT นี้ไม่ใช่กิจกรรม (แปลงได้เฉพาะกิจกรรมเท่านั้น)",
"error.fit_no_positions": "กิจกรรมนี้ไม่มีตำแหน่งที่บันทึกไว้ โปรดแปลงเป็น CSV, XLSX หรือ JSON",
@@ -177,12 +185,16 @@
"error.fon_damaged": "ไฟล์ .fon เสียหายหรือไม่สมบูรณ์",
"error.document_too_complex": "เอกสารใหญ่หรือซับซ้อนเกินกว่าจะอ่านได้อย่างปลอดภัย",
"error.m4s_needs_init": "ไฟล์ .m4s นี้เป็นชิ้นส่วนหนึ่งของวิดีโอสตรีมมิง และอ่านไม่ได้หากไม่มีเซกเมนต์เริ่มต้น (init) ที่แยกไว้",
+ "error.nov_password": "ไฟล์ส่งออกนี้ป้องกันด้วยรหัสผ่าน วิดีโอจึงถูกเข้ารหัส: ส่งออกอีกครั้งจาก Wisenet WAVE / Nx Witness โดยไม่ตั้งรหัสผ่าน",
+ "error.nov_no_video": "ไฟล์ส่งออกนี้ไม่มีการบันทึกจากกล้อง",
"error.exe_unreadable": "นี่ไม่ใช่โปรแกรมหรือไลบรารีของ Windows ที่ Filee อ่านได้ หรือไฟล์เสียหาย",
"error.exe_no_icon": "ไฟล์นี้ไม่มีไอคอนของตัวเอง (Windows จะแสดงไอคอนมาตรฐานให้)",
"notice.pdf_not_smaller": "ไม่สามารถบีบอัดให้เล็กลงได้ จึงคัดลอกไฟล์ตามเดิม",
+ "notice.nov_recordings_left_out": "ไฟล์ส่งออกนี้มีการบันทึกหลายรายการ (กล้องอื่นหรือช่วงที่ขาดตอน): แปลงเฉพาะรายการที่ใหญ่ที่สุด",
"notice.under_min_size": "บันทึกแล้ว แต่เล็กกว่าขนาดไฟล์ขั้นต่ำ",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "ละวัตถุที่ AutoCAD เวอร์ชันที่เลือกไม่มี (มัลติลีดเดอร์ เมช …) ไว้",
+ "notice.gcode_no_thumbnail": "ไฟล์ G-code ไม่มีภาพตัวอย่างจากสไลเซอร์ จึงวาดเส้นทางเครื่องมือจากมุมบน",
"notice.sheet_objects_left_out": "ละวัตถุที่ Filee ยังพิมพ์ไม่ได้ (แผนภูมิ ข้อความในรูปร่าง …) ไว้: ติดตั้ง LibreOffice ในการตั้งค่า → เครื่องมือแปลงไฟล์ เพื่อเก็บไว้",
"notice.cmyk_needs_ghostscript": "ไฟล์นี้เป็น CMYK แต่บันทึกเป็น RGB: ติดตั้ง Ghostscript ในการตั้งค่า → เครื่องมือแปลงไฟล์ เพื่อคง CMYK ไว้",
"notice.pdfa_needs_ghostscript": "PDF นี้ไม่ใช่ PDF/A: ติดตั้ง Ghostscript ในการตั้งค่า → เครื่องมือแปลงไฟล์ เพื่อสร้าง PDF/A",
@@ -275,6 +287,8 @@
"engine.font.description": "แปลง TTF, OTF, WOFF, WOFF2 และ EOT ระหว่างกัน; แปลงฟอนต์บิตแมปของ Windows (FON) เป็นรูปแบบเหล่านี้ด้วย",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "แบบแปลน AutoCAD: DWG ↔ DXF และแปลงทั้งสองเป็น PDF, SVG และ PNG",
+ "engine.gcode.name": "G-code (built-in)",
+ "engine.gcode.description": "เส้นทางของเครื่องพิมพ์ 3 มิติและเครื่อง CNC (G-code) เป็น PDF, SVG และรูปภาพ หรือภาพตัวอย่างของสไลเซอร์",
"engine.drawing.name": "Drawings (built-in)",
"engine.drawing.description": "แปลงไดอะแกรม Visio (VSDX) และภาพวาด OpenDocument (ODG, FODG) เป็น PDF, SVG และ PNG โดยไม่ต้องใช้ Visio หรือ LibreOffice\nไฟล์ Visio แบบไบนารีรุ่นเก่า (.vsd) ยังต้องใช้ LibreOffice",
"engine.model3d.name": "3D models (built-in)",
@@ -297,6 +311,7 @@
"engine.tesseract.description": "อ่านข้อความจากภาพสแกนและรูปภาพ (OCR): PDF ที่ค้นหาได้, DOCX, TXT",
"engine.vision.description": "อ่านข้อความจากภาพสแกนและรูปภาพด้วยระบบรู้จำข้อความในตัวของ macOS (OCR): PDF ที่ค้นหาได้, DOCX, TXT ไม่ต้องดาวน์โหลดอะไรเพิ่ม",
"engine.calibre.description": "ฟอร์แมตอีบุ๊กที่หาได้ยาก (LIT, LRF…) และบันทึกเป็นฟอร์แมตรุ่นเก่า (LIT, PDB, RB…)\nเมื่อปิดตัวเรนเดอร์ของ Filee และวาง Calibre ไว้เหนือตัวเขียน HWPX จะทำหน้าที่สร้าง PDF ของอีบุ๊กด้วย (เร็วกว่ามากสำหรับหนังสือเล่มหนา)",
+ "engine.weasyprint.description": "สร้าง PDF จากหน้า HTML, Markdown และอีเมลแบบที่เบราว์เซอร์พิมพ์: หัวข้อ ตาราง ตัวหนา และข้อความภาษาอาหรับหรือฮินดีออกมาถูกต้อง\nโหลดเฉพาะรูปภาพที่อยู่ข้างเอกสาร ไม่โหลดอะไรจากอินเทอร์เน็ต",
"engine.libreoffice.description": "อ่านฟอร์แมตรุ่นเก่าและฟอร์แมตที่พบน้อย: DOC ของ Word 6.0/95, PPT ของ PowerPoint 95, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio แบบไบนารี (VSD)…\nแปลงไฟล์ ODP และ ODG ที่มีแผนภูมิ ออบเจ็กต์ที่ฝังไว้ หรือ 3D และบันทึกเป็น DOC, XLS, PPT, ODT, แม่แบบ OpenDocument และ EMF",
"engine.translator.description": "แปลเอกสารและอีบุ๊กแบบออฟไลน์บนเครื่องโดยใช้โมเดล Ollama\nแปลคำบรรยาย SRT และ WebVTT ได้ด้วย โดยส่งเฉพาะข้อความคำบรรยายไปยังโมเดล ส่วนหมายเลขและเวลาของแต่ละช่วงจะคงเดิม",
"engine.exiftool.description": "อ่านทุกฟิลด์เมทาดาตาของรูปภาพ PDF วิดีโอ และเสียง และแก้ไขได้ในไฟล์ส่วนใหญ่โดยไม่แตะต้องเนื้อหาของไฟล์\nเครื่องมือเมทาดาตาใช้สิ่งนี้ หากไม่มี ฟิลด์จะแสดงแบบอ่านอย่างเดียว",
@@ -395,6 +410,7 @@
"clip.save": "บันทึกคลิป",
"clip.save_next": "บันทึกและต่อไป",
"clip.seconds": "{0} วินาที",
+ "clip.ringtone_limit": "เสียงเรียกเข้า iPhone ยาวไม่เกิน {0} วินาที",
"clip.length_hint": "คลิปยาวเท่านี้วินาทีนับจากจุดเริ่ม",
"clip.fast": "เร็ว ไม่เข้ารหัสใหม่",
"clip.fast_hint": "คัดลอกวิดีโอตามเดิม: เสร็จในพริบตาและไม่สูญเสียคุณภาพ แต่คลิปจะเริ่มที่คีย์เฟรมก่อนจุดเริ่ม (เร็วกว่าได้ถึงไม่กี่วินาที) ปิดตัวเลือกนี้เพื่อตัดตรงเฟรมที่เลือกพอดี (เข้ารหัสใหม่ ใช้เวลานานกว่า)",
@@ -793,9 +809,15 @@
"toolbar.hint_remove": "ลากชิ้นส่วนโดนัทกลับมาที่นี่เพื่อลบออก",
"toolbar.hint_reorder": "ลากชิ้นส่วนทับกันเพื่อสลับลำดับ",
"toolbar.hint_edit": "คลิกขวาที่ชิ้นส่วนหรือกด ✎ เพื่อแก้ไข",
+ "toolbar.group_hint": "ลากพรีเซ็ตเข้ามาจากรายการ ลากชิ้นเพื่อจัดลำดับ และลากชิ้นไปที่รายการเพื่อนำออก",
+ "toolbar.group_back": "กลับไปที่ {0}",
"toolbar.max_reached": "โดนัทบรรจุพรีเซ็ตได้สูงสุด {0} รายการ",
- "toolbar.appearance": "รูปลักษณ์",
"toolbar.size": "ขนาด",
+ "toolbar.edit_size": "แก้ไขขนาดโดนัท",
+ "toolbar.look_done": "เสร็จ",
+ "toolbar.look_hint": "โดนัทแสดงบนหน้าจอในขนาดจริง ลากตรงกลางเพื่อย้าย และคลิกกลุ่มเพื่อให้เปิดค้างไว้",
+ "toolbar.look_all": "โดนัททั้งหมด",
+ "toolbar.look_this": "โดนัทนี้: {0}",
"toolbar.hole": "ขนาดรูกลาง",
"toolbar.opacity": "ความทึบแสง",
"toolbar.captions": "แสดงฟอร์แมตผลลัพธ์",
@@ -803,6 +825,9 @@
"toolbar.label_scale": "ขนาดป้าย",
"toolbar.ring_size": "ขนาดวงแหวนกลุ่ม",
"toolbar.ring_spread": "ความกว้างวงแหวนกลุ่ม",
+ "toolbar.group_shown": "กลุ่มที่แสดง",
+ "toolbar.example_group": "กลุ่มตัวอย่าง",
+ "toolbar.example_none": "โดนัทนี้ไม่มีกลุ่ม",
"labels.auto": "อัตโนมัติ",
"labels.icon_text": "ไอคอนและข้อความ",
"labels.text_only": "ข้อความเท่านั้น",
@@ -825,6 +850,7 @@
"group.add": "เพิ่ม",
"group.add_placeholder": "เลือกพรีเซ็ต",
"group.full": "กลุ่มบรรจุได้สูงสุด {0} พรีเซ็ต",
+ "group.copy_front": "\"คัดลอกไปยังโฟลเดอร์\" ใส่ในกลุ่มนี้ไม่ได้ เพราะกลุ่มนี้อยู่ด้านหน้าด้วย",
"group.move_up": "เลื่อนขึ้น",
"group.move_down": "เลื่อนลง",
"group.remove": "ลบออกจากกลุ่ม",
@@ -851,6 +877,9 @@
"toolbar.mixed_others": "ไฟล์รวมประเภทแบบอื่นๆ จะเปิด “{0}”",
"toolbar.mixed_others_self": "ไม่มีโปรไฟล์ไฟล์รวมอันอื่นที่ไม่ได้เลือกอะไรไว้ ดังนั้นโปรไฟล์นี้จะถูกใช้กับไฟล์รวมแบบอื่นๆ ทั้งหมดด้วย",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "รูปแบบข้อมูลเข้านี้ใช้การแปลงตามปกติ",
+ "toolbar.chip_tip.ordinary_conversion.all": "รูปแบบข้อมูลเข้าทั้งหมดที่แปลงได้ใช้การแปลงตามปกติ",
+ "toolbar.chip_tip.ordinary_conversion.some": "รูปแบบข้อมูลเข้าบางรูปแบบใช้การแปลงตามปกติ",
"toolbar.chip_tip.makes_same": "→ รูปแบบเดียวกัน",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "รูปแบบที่รองรับ: {0}",
@@ -902,6 +931,11 @@
"presets.cad_colors.BlackAndWhite": "ขาวดำ",
"presets.cad_colors.Grayscale": "ระดับสีเทา",
"presets.cad_colors_hint": "ใช้เมื่อแปลงแบบ DWG หรือ DXF เป็น PDF, SVG หรือรูปภาพ ขาวดำจะวาดเส้นและข้อความทั้งหมดเป็นสีดำเหมือนการพล็อตแบบโมโนโครม ส่วนพื้นที่ที่ระบายทึบจะเป็นสีเทาตามความสว่างของสีเดิม เพื่อไม่ให้พื้นสีอ่อนกลายเป็นก้อนสีดำ ระดับสีเทาจะเปลี่ยนทุกสีเป็นสีเทา และทำให้เส้นสีอ่อนอย่างสีเหลืองเข้มขึ้นพอให้อ่านได้",
+ "presets.gcode_view": "มุมมอง",
+ "presets.gcode_view.Top": "จากด้านบน",
+ "presets.gcode_view.Isometric": "ไอโซเมตริก",
+ "presets.gcode_view.Thumbnail": "ภาพตัวอย่างจากสไลเซอร์",
+ "presets.gcode_view_hint": "ใช้เมื่อไฟล์ G-code จากสไลเซอร์หรือโปรแกรม CAM ถูกแปลงเป็น PDF, SVG หรือรูปภาพ การฉีดเส้นและการตัดจะมีสีตามความสูง จากเข้ม (ต่ำ) ไปอ่อน (สูง) ส่วนการเคลื่อนที่เปล่าเป็นเส้นสีน้ำเงินบาง (สีแดงสำหรับการเคลื่อนที่เร็วของ CNC) ภาพตัวอย่างจากสไลเซอร์คือภาพที่ PrusaSlicer, Cura, OrcaSlicer หรือ Bambu Studio บันทึกไว้ในไฟล์ ส่วน SVG, PDF และไฟล์ที่ไม่มีภาพนี้จะแสดงเส้นทางจากด้านบน",
"presets.group.image": "รูปภาพ",
"presets.quality": "คุณภาพ",
"presets.resize": "ปรับขนาด",
@@ -981,10 +1015,15 @@
"presets.ringtone_start": "จุดเริ่มต้นเสียงเรียกเข้า (วินาที)",
"presets.ringtone_length": "ความยาวเสียงเรียกเข้า (วินาที, สูงสุด 40)",
"presets.ringtone_hint": "เสียงเรียกเข้า iPhone เป็นไฟล์ AAC ยาวไม่เกิน 40 วินาที เสียงเรียกเข้าจะเริ่มที่วินาทีที่คุณกำหนดและตัดให้ได้ความยาวที่ตั้งไว้ (ไม่เกิน 40 วินาที)",
+ "presets.ask_ringtone": "ถามช่วงที่ใช้ทุกครั้ง (เปิดตัดคลิป)",
"presets.ico_sizes": "ขนาดไอคอน",
"presets.group.pdf": "PDF",
"presets.merge": "รวมทุกไฟล์เป็น PDF เดียว",
"presets.source_bookmarks": "เพิ่มบุ๊กมาร์กสำหรับไฟล์ต้นฉบับแต่ละไฟล์",
+ "presets.normalize_merge_paper": "ใช้ขนาดกระดาษเดียวกันกับทุกหน้าในไฟล์ที่รวม",
+ "presets.merge_paper": "กระดาษสำหรับ PDF ที่รวมแล้ว",
+ "presets.merge_paper_hint": "ปรับแต่ละหน้าให้พอดีกับกระดาษที่เลือกและจัดไว้ตรงกลางโดยไม่ตัดเนื้อหา การตั้งค่าหน้ากระดาษของรูปภาพจะใช้ก่อนรวม",
+ "presets.merge_paper_auto_hint": "กำหนดแนวกระดาษอัตโนมัติโดยดูจากหน้าแรกเพียงครั้งเดียว หน้าที่มีขนาดเล็กกว่าจะถูกขยายให้พอดี",
"presets.page_size": "ขนาดหน้ากระดาษ",
"presets.page_size.FitImage": "พอดีกับรูปภาพ",
"presets.page_size.A4": "A4",
@@ -1141,6 +1180,7 @@
"presets.extract_note": "ไฟล์บีบอัดแต่ละไฟล์จะแตกออกในโฟลเดอร์ของตัวเอง สามารถกำหนดตำแหน่งได้ที่ 'ผลลัพธ์'",
"presets.group.trace": "การแปลงเป็นเวกเตอร์",
"presets.group.cad": "แบบ CAD",
+ "presets.group.gcode": "G-code (พิมพ์ 3 มิติ, CNC)",
"presets.trace_note": "ใช้เมื่อแปลงรูปภาพบิตแมป (PNG, JPG, …) เป็นรูปทรงเวกเตอร์",
"presets.trace_mode": "โหมด",
"presets.trace_mode.Color": "สี",
@@ -1367,6 +1407,8 @@
"engines.package.whisper.description": "ถอดเสียงคำพูดในวิดีโอและเสียงเป็นไฟล์คำบรรยาย SRT ทำงานแบบออฟไลน์โดยไม่ต้องเชื่อมต่ออินเทอร์เน็ต",
"engines.package.calibre.name": "Calibre (อีบุ๊กหายาก)",
"engines.package.calibre.description": "แปลงฟอร์แมตอีบุ๊กหายาก (LIT, LRF…) และบันทึกเป็นฟอร์แมตรุ่นเก่า (LIT, PDB, RB…)\n(EPUB, MOBI, AZW3, FB2 และการ์ตูนอ่านได้ และบันทึกหนังสือสำหรับ Kindle ได้โดยไม่ต้องติดตั้ง)",
+ "engines.package.weasyprint.name": "WeasyPrint (HTML, Markdown และอีเมลเป็น PDF)",
+ "engines.package.weasyprint.description": "สร้าง PDF ที่ดีกว่าจากหน้า HTML, Markdown และอีเมล (EML, MSG, MHTML): หัวข้อ ตาราง ตัวหนา และอักษรที่เขียนจากขวาไปซ้ายหรืออักษรอินเดียแบบที่เบราว์เซอร์พิมพ์\nหากไม่มีก็ยังเป็น PDF ได้ผ่านเส้นทางในตัว",
"engines.package.7zip.name": "7-Zip (ไฟล์บีบอัดรูปแบบที่พบน้อย)",
"engines.package.7zip.description": "แตกไฟล์บีบอัดรูปแบบที่พบน้อยซึ่งตัวอ่านในตัวของ Filee ไม่รองรับ: 7Z แบบ PPMd และวิธีบีบอัดอื่นที่ไม่ค่อยใช้, CAB แบบ Quantum, ARJ หลายวอลุ่ม, อิมเมจดิสก์ NTFS, exFAT หรือ APFS…\nZIP, 7Z, RAR, TAR, CAB, ISO, DMG และไฟล์บีบอัดทั่วไปอื่นๆ ใช้งานได้โดยไม่ต้องติดตั้ง",
"engines.package.size": "ดาวน์โหลด {0} · ขนาดบนดิสก์ {1}",
@@ -1393,7 +1435,17 @@
"engines.remove": "ถอนการติดตั้ง",
"engines.remove_confirm": "ต้องการถอนการติดตั้ง {0} หรือไม่? คุณสามารถดาวน์โหลดใหม่ได้ในภายหลัง",
"engines.state.own_copy": "โปรแกรมของคุณเอง",
- "engines.state.system_copy": "พบในคอมพิวเตอร์เครื่องนี้",
+ "engines.source.download": "ดาวน์โหลดโดย Filee",
+ "engines.source.system": "ในคอมพิวเตอร์เครื่องนี้",
+ "engines.source.version": "เวอร์ชัน {0}",
+ "engines.source.on_disk": "ขนาดบนดิสก์ {0}",
+ "engines.other_copy_title": "มีในคอมพิวเตอร์เครื่องนี้ด้วย",
+ "engines.other_copy_detail": "{0} (โปรแกรมที่ Filee ดาวน์โหลดไว้จะถูกลบ)",
+ "engines.use_other_copy": "ใช้โปรแกรมนี้แทน",
+ "engines.use_other_copy_confirm": "ใช้โปรแกรมใน {0} และลบ {1} ที่ Filee ดาวน์โหลดไว้หรือไม่? คุณสามารถดาวน์โหลดใหม่ได้ในภายหลัง",
+ "engines.other_copy_gone": "ไม่พบโปรแกรมนั้นแล้วหรือเปิดไม่ได้ จึงเก็บสิ่งที่ Filee ดาวน์โหลดไว้ตามเดิม",
+ "engines.install_filee_copy": "ติดตั้งของ Filee",
+ "engines.use_another": "ใช้โปรแกรมอื่น…",
"engines.update_recommended": "แนะนำให้อัปเดต: เวอร์ชันที่คุณใช้อยู่คือ {0} แต่ Filee ได้รับการทดสอบกับ {1}\nการแปลงส่วนใหญ่จะยังคงทำงานได้",
"engines.use_own": "ใช้โปรแกรมในเครื่อง…",
"engines.use_own_hint": "มีโปรแกรมนี้อยู่แล้วใช่ไหม? เลือกไฟล์โปรแกรมแล้ว Filee จะนำมาใช้งานแทนการดาวน์โหลดใหม่",
diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json
index 2196f1713..4b8e09a44 100644
--- a/src/Filee.App/Assets/i18n/tr.json
+++ b/src/Filee.App/Assets/i18n/tr.json
@@ -74,6 +74,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "{0} dosya",
"donut.drop_to": "Bırak → {0}",
+ "donut.ordinary_conversion.single": "Bu dosya normal dönüştürme olarak çalışır",
+ "donut.ordinary_conversion.all": "Dönüşebilen tüm dosyalar normal dönüşümle işlenir",
+ "donut.ordinary_conversion.some": "Bazı dosyalar normal dönüştürme olarak çalışır",
"donut.pick_in_group": "Seçmek için dışa doğru ilerleyin",
"donut.group_empty": "Bu grupta ön ayar kalmadı. Ayarlar → Halka araç çubuğu bölümünden düzenleyin",
"donut.click_to": "Tıkla → {0}",
@@ -127,6 +130,8 @@
"error.split_unknown_length": "Bu videonun süresi bilinmiyor, bu yüzden eşit parçalara bölünemez",
"error.split_needs_copy": "Bölme işlemi videoyu olduğu gibi kopyalar ve bu biçim onu taşıyamaz. Videonun kendi biçimini ya da MKV'yi seçin",
"error.merged_image_too_large": "Birleştirilen görsel çok büyük olur; daha az veya daha küçük görsel birleştirin",
+ "error.merge_paper_geometry": "Sayfanın boyutu, döndürmesi veya birimi geçersiz olduğundan seçilen kâğıda sığdırılamıyor",
+ "error.merge_paper_limit": "Sayfa, kâğıt boyutunu değiştirme işlem sınırını aşıyor",
"error.over_size_limit": "Boyut sınırına sığacak kadar küçültülemiyor",
"error.not_enough_space": "Görseller için yeterli boş disk alanı yok",
"error.no_motion_photo": "Bu fotoğrafta Filee'nin okuyabileceği video yok (Android Motion Photo veya yanında MOV dosyası bulunan iPhone Live Photo)",
@@ -166,6 +171,9 @@
"error.fit_damaged": "FIT dosyası bozuk veya eksik",
"error.fit_chained": "Arka arkaya eklenmiş birden çok FIT dosyası henüz desteklenmiyor",
"error.fit_too_large": "FIT dosyası dönüştürülemeyecek kadar büyük",
+ "error.canvas_not_canvas": "Bu bir Obsidian tuvali (JSON Canvas) değil",
+ "error.canvas_empty": "Tuvalde çizilecek kart yok",
+ "error.canvas_too_large": "Tuval dönüştürülemeyecek kadar büyük",
"error.fit_no_file_id": "FIT dosyası türünü belirtmiyor",
"error.fit_not_activity": "Bu FIT dosyası bir etkinlik değil (yalnızca etkinlikler dönüştürülür)",
"error.fit_no_positions": "Etkinlikte kaydedilmiş konum yok: CSV, XLSX veya JSON'a dönüştürün",
@@ -177,12 +185,16 @@
"error.fon_damaged": ".fon dosyası hasarlı veya eksik",
"error.document_too_complex": "Belge güvenli şekilde okunamayacak kadar büyük veya karmaşık",
"error.m4s_needs_init": "Bu .m4s, akışla gelen bir videonun bir parçası: ayrı başlangıç (init) segmenti olmadan okunamaz",
+ "error.nov_password": "Bu dışa aktarma parolayla korunuyor, bu yüzden videosu şifreli: Wisenet WAVE / Nx Witness'tan parolasız olarak yeniden dışa aktarın",
+ "error.nov_no_video": "Bu dışa aktarmada kamera kaydı yok",
"error.exe_unreadable": "Bu, Filee'nin okuyabildiği bir Windows programı veya kitaplığı değil ya da dosya hasarlı",
"error.exe_no_icon": "Bu dosyanın kendi simgesi yok (Windows onun için standart bir simge gösterir)",
"notice.pdf_not_smaller": "Küçültülemediği için olduğu gibi kopyalandı",
+ "notice.nov_recordings_left_out": "Bu dışa aktarmada birden çok kayıt var (başka kameralar ya da boşluklarla ayrılmış parçalar): yalnızca en büyüğü dönüştürüldü",
"notice.under_min_size": "Kaydedildi, ancak en küçük dosya boyutundan küçük",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "Seçilen AutoCAD sürümünün desteklemediği nesneler (çoklu kılavuzlar, ağlar…) çıkarıldı",
+ "notice.gcode_no_thumbnail": "G-code dosyasında dilimleyici önizlemesi yok, bu yüzden takım yolu üstten çizildi",
"notice.sheet_objects_left_out": "Filee'nin henüz yazdıramadığı nesneler (grafikler, şekillerdeki metin…) çıkarıldı: korumak için Ayarlar → Dönüştürme motorları'ndan LibreOffice'i yükleyin",
"notice.cmyk_needs_ghostscript": "Bu dosya CMYK ama RGB olarak kaydedildi: CMYK'yı korumak için Ayarlar → Dönüştürme motorları'ndan Ghostscript'i yükleyin",
"notice.pdfa_needs_ghostscript": "Bu PDF, PDF/A değil: PDF/A oluşturmak için Ayarlar → Dönüştürme motorları'ndan Ghostscript'i yükleyin",
@@ -275,6 +287,8 @@
"engine.font.description": "TTF, OTF, WOFF, WOFF2 ve EOT arasında dönüştürür; Windows bitmap yazı tiplerini (FON) bu biçimlere dönüştürür.",
"engine.cad.name": "CAD (yerleşik)",
"engine.cad.description": "AutoCAD çizimleri: DWG ↔ DXF ve her ikisinden PDF, SVG ve PNG.",
+ "engine.gcode.name": "G-code (yerleşik)",
+ "engine.gcode.description": "3B yazıcıların ve CNC makinelerinin takım yolları (G-code) PDF, SVG ve resme, ya da dilimleyicinin kendi önizlemesi.",
"engine.drawing.name": "Çizimler (yerleşik)",
"engine.drawing.description": "Visio çizimleri (VSDX) ve OpenDocument çizimleri (ODG, FODG) → PDF, SVG ve PNG; Visio veya LibreOffice gerekmez.\nEski ikili Visio dosyaları (.vsd) için hâlâ LibreOffice gerekir.",
"engine.model3d.name": "3B modeller (yerleşik)",
@@ -297,6 +311,7 @@
"engine.tesseract.description": "Tarama ve görsellerdeki metni tanır (OCR): aranabilir PDF, DOCX, TXT.",
"engine.vision.description": "Tarama ve görsellerdeki metni macOS'un kendi metin tanıma özelliğiyle tanır (OCR): aranabilir PDF, DOCX, TXT. İndirme gerekmez.",
"engine.calibre.description": "Nadir e-kitap biçimleri (LIT, LRF…) ve eski biçimlere çıktı (LIT, PDB, RB…).\nFilee işleyicisi kapalıyken HWPX yazıcısının üstüne alındığında e-kitapları PDF'ye de dönüştürür (uzun kitaplarda çok daha hızlıdır).",
+ "engine.weasyprint.description": "HTML sayfalarının, Markdown'ın ve e-postaların PDF'lerini bir tarayıcının yazdırdığı gibi oluşturur: başlıklar, tablolar, kalın yazı ve Arapça ya da Hintçe metin doğru çıkar.\nYalnızca belgenin yanındaki resimleri yükler, internetten hiçbir şey yüklemez.",
"engine.libreoffice.description": "Eski ve az kullanılan biçimleri okur: Word 6.0/95 DOC, PowerPoint 95 PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, ikili Visio (VSD)…\nGrafik, gömülü nesne veya 3B içeren ODP ve ODG dosyalarını da dönüştürür; DOC, XLS, PPT, ODT, OpenDocument şablonları ve EMF olarak kaydeder.",
"engine.translator.description": "Çevrimdışı Ollama modellerini kullanarak belgeleri ve e-kitapları yerel olarak çevirir.\nSRT ve WebVTT altyazılarını da çevirir: modele yalnızca altyazı metni gönderilir, altyazı numaraları ve zaman damgaları korunur.",
"engine.exiftool.description": "Resimlerin, PDF'lerin, videonun ve sesin tüm üst veri alanlarını okur ve çoğu dosya türünde dosyanın içeriğine dokunmadan düzenler.\nÜst Veri aracı kullanır; o olmadan alanlar salt okunur gösterilir.",
@@ -395,6 +410,7 @@
"clip.save": "Klibi kaydet",
"clip.save_next": "Kaydet ve ilerle",
"clip.seconds": "{0} sn",
+ "clip.ringtone_limit": "iPhone zil sesi en fazla {0} saniye olabilir",
"clip.length_hint": "Başlangıçtan itibaren bu kadar saniyelik bir klip",
"clip.fast": "Hızlı, yeniden kodlama yok",
"clip.fast_hint": "Videoyu olduğu gibi kopyalar: bir anda biter ve hiçbir şey kaybolmaz, ancak klip başlangıçtan önceki anahtar karede başlar (birkaç saniyeye kadar erken). Tam seçilen karelerden kesmek için kapatın (yeniden kodlar, daha uzun sürer).",
@@ -793,9 +809,15 @@
"toolbar.hint_remove": "Kaldırmak için bir dilimi buraya sürükleyin",
"toolbar.hint_reorder": "Sıralamak için dilimleri birbirinin üzerine sürükleyin",
"toolbar.hint_edit": "Düzenlemek için dilime sağ tıklayın veya ✎ kullanın",
+ "toolbar.group_hint": "Listeden ön ayarları sürükleyip bırakın, sıralamak için dilimleri sürükleyin, çıkarmak için birini listeye sürükleyin.",
+ "toolbar.group_back": "{0} öğesine dön",
"toolbar.max_reached": "Bir halka en fazla {0} ön ayar tutar",
- "toolbar.appearance": "Görünüm",
"toolbar.size": "Boyut",
+ "toolbar.edit_size": "Halka boyutunu düzenle",
+ "toolbar.look_done": "Bitti",
+ "toolbar.look_hint": "Halka ekranda gerçek boyutunda. Taşımak için ortasını sürükleyin, açık kalması için bir gruba tıklayın.",
+ "toolbar.look_all": "Tüm halkalar",
+ "toolbar.look_this": "Bu halka: {0}",
"toolbar.hole": "İç boşluk",
"toolbar.opacity": "Opaklık",
"toolbar.captions": "Çıktı biçimini göster",
@@ -803,6 +825,9 @@
"toolbar.label_scale": "Etiket boyutu",
"toolbar.ring_size": "Grup halkasının boyutu",
"toolbar.ring_spread": "Grup halkasının genişliği",
+ "toolbar.group_shown": "Gösterilen grup",
+ "toolbar.example_group": "Örnek grup",
+ "toolbar.example_none": "Bu halkada grup yok",
"labels.auto": "Otomatik",
"labels.icon_text": "Simge ve metin",
"labels.text_only": "Yalnızca metin",
@@ -825,6 +850,7 @@
"group.add": "Ekle",
"group.add_placeholder": "Ön ayar seçin",
"group.full": "Bir grup en fazla {0} ön ayar tutar.",
+ "group.copy_front": "\"Klasöre kopyala\" bu gruba eklenemez: grup bir ön yüzde de var.",
"group.move_up": "Yukarı taşı",
"group.move_down": "Aşağı taşı",
"group.remove": "Gruptan çıkar",
@@ -851,6 +877,9 @@
"toolbar.mixed_others": "Diğer karışık dosya seçimleri “{0}” profilini açar.",
"toolbar.mixed_others_self": "Hiçbir karışık profil tamamen işaretsiz kalmadığı için bu profil diğer tüm karışık seçimlerde de kullanılır.",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "Bu girdi biçimi normal dönüştürme olarak çalışır",
+ "toolbar.chip_tip.ordinary_conversion.all": "Dönüştürülebilen tüm girdi biçimleri normal dönüştürme olarak çalışır",
+ "toolbar.chip_tip.ordinary_conversion.some": "Bazı girdi biçimleri normal dönüştürme olarak çalışır",
"toolbar.chip_tip.makes_same": "→ Aynı biçim",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "Desteklenen biçimler: {0}",
@@ -902,6 +931,11 @@
"presets.cad_colors.BlackAndWhite": "Siyah beyaz",
"presets.cad_colors.Grayscale": "Gri tonlama",
"presets.cad_colors_hint": "Bir DWG veya DXF çizimi PDF, SVG ya da resim olduğunda kullanılır. Siyah beyaz, tek renkli bir çizim çıktısı gibi tüm çizgileri ve yazıları siyah çizer; dolgulu alanlar parlaklıklarına göre griye döner, böylece açık renkli bir dolgu siyah bir bloğa dönüşmez. Gri tonlama her rengi griye çevirir ve sarı gibi açık çizgileri okunacak kadar koyulaştırır.",
+ "presets.gcode_view": "Görünüm",
+ "presets.gcode_view.Top": "Üstten",
+ "presets.gcode_view.Isometric": "İzometrik",
+ "presets.gcode_view.Thumbnail": "Dilimleyici önizlemesi",
+ "presets.gcode_view_hint": "Bir dilimleyiciden ya da CAM programından gelen G-code dosyası PDF, SVG veya resme dönüştürülürken kullanılır. Ekstrüzyon ve kesimler yüksekliğe göre koyudan (alt) açığa (üst) renklendirilir; boş hareketler ince mavi çizgilerdir (CNC hızlı hareketleri için kırmızı). Dilimleyici önizlemesi, PrusaSlicer, Cura, OrcaSlicer veya Bambu Studio'nun dosyaya kaydettiği resimdir; SVG, PDF ve önizlemesi olmayan dosyalar takım yolunu üstten gösterir.",
"presets.group.image": "Görsel",
"presets.quality": "Kalite",
"presets.resize": "Yeniden boyutlandır",
@@ -981,10 +1015,15 @@
"presets.ringtone_start": "Zil sesi başlangıcı (sn)",
"presets.ringtone_length": "Zil sesi uzunluğu (sn, en çok 40)",
"presets.ringtone_hint": "iPhone zil sesleri en fazla 40 saniyelik AAC dosyalarıdır. Zil sesi belirlediğiniz saniyeden başlar ve ayarladığınız uzunluğa kesilir (hiçbir zaman 40 sn'yi geçmez).",
+ "presets.ask_ringtone": "Bölümü her seferinde sor (Klip kes açılır)",
"presets.ico_sizes": "Simge boyutları",
"presets.group.pdf": "PDF",
"presets.merge": "Tüm dosyaları tek PDF'de birleştir",
"presets.source_bookmarks": "Her özgün dosya için bir yer imi ekle",
+ "presets.normalize_merge_paper": "Birleştirilen tüm sayfalarda aynı kâğıt boyutunu kullan",
+ "presets.merge_paper": "Birleştirilen PDF'nin kâğıdı",
+ "presets.merge_paper_hint": "Her sayfayı seçilen kâğıda sığdırıp kırpmadan ortalar. Görsel sayfası ayarları birleştirmeden önce uygulanır.",
+ "presets.merge_paper_auto_hint": "Otomatik yön, ilk sayfaya göre bir kez seçilir. Daha küçük sayfalar sığacak şekilde büyütülür.",
"presets.page_size": "Sayfa boyutu",
"presets.page_size.FitImage": "Görsele sığdır",
"presets.page_size.A4": "A4",
@@ -1141,6 +1180,7 @@
"presets.extract_note": "Her arşiv kendi klasörüne açılır. Konumu Çıktı bölümünde ayarlanır.",
"presets.group.trace": "Vektörleştirme",
"presets.group.cad": "CAD çizimi",
+ "presets.group.gcode": "G-code (3B baskı, CNC)",
"presets.trace_note": "Bir görsel (PNG, JPG, …) vektörel şekillere dönüştürülürken kullanılır.",
"presets.trace_mode": "Mod",
"presets.trace_mode.Color": "Renkli",
@@ -1367,6 +1407,8 @@
"engines.package.whisper.description": "Video ve sesteki konuşmaları SRT altyazı dosyalarına dönüştürür. İnternet bağlantısı olmadan çevrimdışı çalışır.",
"engines.package.calibre.name": "Calibre (nadir e-kitap biçimleri)",
"engines.package.calibre.description": "Nadir e-kitap biçimlerini dönüştürür (LIT, LRF…) ve eski biçimlerde kaydeder (LIT, PDB, RB…).\nEPUB, MOBI, AZW3, FB2 ve çizgi romanlar bu motor olmadan okunur, Kindle kitapları da onsuz kaydedilir.",
+ "engines.package.weasyprint.name": "WeasyPrint (HTML, Markdown ve e-postadan PDF'ye)",
+ "engines.package.weasyprint.description": "HTML sayfalarının, Markdown'ın ve e-postaların (EML, MSG, MHTML) daha iyi PDF'lerini oluşturur: başlıklar, tablolar, kalın yazı ve sağdan sola ya da Hint yazıları tarayıcı çıktısındaki gibi.\nO olmadan da yerleşik yolla PDF olurlar.",
"engines.package.7zip.name": "7-Zip (nadir arşiv türleri)",
"engines.package.7zip.description": "Filee'nin kendi okuyucularının açmadığı nadir arşiv türlerini açar: PPMd ve diğer alışılmadık yöntemlerle 7Z, Quantum CAB, çok parçalı ARJ, NTFS, exFAT veya APFS disk görüntüleri…\nZIP, 7Z, RAR, TAR, CAB, ISO, DMG ve diğer tüm yaygın arşivler bu motor olmadan çalışır.",
"engines.package.size": "İndirme {0} · Diskte {1}",
@@ -1393,7 +1435,17 @@
"engines.remove": "Kaldır",
"engines.remove_confirm": "{0} kaldırılsın mı? Daha sonra yeniden indirebilirsiniz.",
"engines.state.own_copy": "Kendi kopyanız",
- "engines.state.system_copy": "Bu bilgisayarda bulundu",
+ "engines.source.download": "Filee'nin indirdiği",
+ "engines.source.system": "Bu bilgisayarda",
+ "engines.source.version": "sürüm {0}",
+ "engines.source.on_disk": "Diskte {0}",
+ "engines.other_copy_title": "Bu bilgisayarda da var",
+ "engines.other_copy_detail": "{0} (Filee'nin indirdiği kopya kaldırılır)",
+ "engines.use_other_copy": "Bunun yerine bunu kullan",
+ "engines.use_other_copy_confirm": "{0} içindeki kopya kullanılsın ve Filee'nin indirdiği {1} kaldırılsın mı? Daha sonra yeniden indirebilirsiniz.",
+ "engines.other_copy_gone": "Bu kopya artık bulunamıyor veya başlamıyor. Filee'nin indirdiği kopya korundu.",
+ "engines.install_filee_copy": "Filee'nin kopyasını kur",
+ "engines.use_another": "Başka bir kopya kullan…",
"engines.update_recommended": "Güncelleme önerilir: bu kopya {0}, Filee ise {1} ile test edilmiştir.\nÇoğu dönüştürme yine de çalışır.",
"engines.use_own": "Kendi kopyamı kullan…",
"engines.use_own_hint": "Zaten kurulu mu? Programını seçin; Filee kendi kopyasını indirmek yerine onu kullansın.",
diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json
index f66f72083..94571fe78 100644
--- a/src/Filee.App/Assets/i18n/vi.json
+++ b/src/Filee.App/Assets/i18n/vi.json
@@ -78,6 +78,9 @@
"donut.files_same": "{0} × {1}",
"donut.files_mixed": "{0} tệp",
"donut.drop_to": "Thả → {0}",
+ "donut.ordinary_conversion.single": "Tệp này dùng chuyển đổi thông thường",
+ "donut.ordinary_conversion.all": "Tất cả tệp chuyển đổi được: chuyển đổi thông thường",
+ "donut.ordinary_conversion.some": "Một số tệp dùng chuyển đổi thông thường",
"donut.pick_in_group": "Di chuyển ra ngoài để chọn",
"donut.group_empty": "Nhóm này không còn cấu hình sẵn nào. Hãy chỉnh sửa trong Cài đặt → Thanh công cụ vòng tròn",
"donut.click_to": "Nhấp → {0}",
@@ -133,6 +136,8 @@
"error.split_unknown_length": "Không rõ độ dài của video này nên không thể cắt thành các phần bằng nhau",
"error.split_needs_copy": "Khi chia, video được sao chép nguyên trạng và định dạng này không chứa được nó. Hãy chọn định dạng gốc của video hoặc MKV",
"error.merged_image_too_large": "Ảnh ghép sẽ quá lớn, hãy ghép ít ảnh hơn hoặc ảnh nhỏ hơn",
+ "error.merge_paper_geometry": "Trang có kích thước, góc xoay hoặc đơn vị không hợp lệ nên không thể căn vừa giấy đã chọn",
+ "error.merge_paper_limit": "Trang vượt quá giới hạn xử lý khi đổi cỡ giấy",
"error.over_size_limit": "Không thể làm tệp đủ nhỏ để vừa giới hạn kích thước",
"error.not_enough_space": "Không đủ dung lượng đĩa trống cho các ảnh",
"error.no_motion_photo": "Ảnh này không có video mà Filee đọc được (ảnh Motion Photo của Android, hoặc Live Photo của iPhone kèm tệp MOV bên cạnh)",
@@ -172,6 +177,9 @@
"error.fit_damaged": "Tệp FIT bị hỏng hoặc bị cắt cụt",
"error.fit_chained": "Tệp chứa nhiều tệp FIT nối tiếp nhau, điều này chưa được hỗ trợ",
"error.fit_too_large": "Tệp FIT quá lớn để chuyển đổi",
+ "error.canvas_not_canvas": "Đây không phải canvas Obsidian (JSON Canvas)",
+ "error.canvas_empty": "Canvas không có thẻ nào để vẽ",
+ "error.canvas_too_large": "Canvas quá lớn để chuyển đổi",
"error.fit_no_file_id": "Tệp FIT không cho biết nó là loại tệp gì",
"error.fit_not_activity": "Tệp FIT này không phải một hoạt động (chỉ hoạt động mới được chuyển đổi)",
"error.fit_no_positions": "Hoạt động không có vị trí nào được ghi lại: hãy chuyển đổi sang CSV, XLSX hoặc JSON",
@@ -183,12 +191,16 @@
"error.fon_damaged": "Tệp .fon bị hỏng hoặc bị cắt cụt",
"error.document_too_complex": "Tài liệu quá lớn hoặc quá phức tạp để đọc an toàn",
"error.m4s_needs_init": "Tệp .m4s này là một phần của video phát trực tuyến: không thể đọc nếu thiếu đoạn đầu (init) riêng của nó",
+ "error.nov_password": "Bản xuất này được bảo vệ bằng mật khẩu nên video bị mã hóa: hãy xuất lại từ Wisenet WAVE / Nx Witness mà không đặt mật khẩu",
+ "error.nov_no_video": "Bản xuất này không có bản ghi camera nào",
"error.exe_unreadable": "Đây không phải chương trình hay thư viện Windows mà Filee đọc được, hoặc tệp bị hỏng",
"error.exe_no_icon": "Tệp này không có biểu tượng riêng (Windows hiển thị biểu tượng mặc định cho nó)",
"notice.pdf_not_smaller": "Không thể làm nhỏ hơn nên đã sao chép nguyên trạng",
+ "notice.nov_recordings_left_out": "Bản xuất này có nhiều bản ghi (camera khác hoặc các đoạn bị ngắt quãng): chỉ bản lớn nhất được chuyển đổi",
"notice.under_min_size": "Đã lưu, nhưng nhỏ hơn kích thước tệp tối thiểu",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "Các đối tượng mà phiên bản AutoCAD đã chọn không có (multileader, lưới …) đã bị bỏ qua",
+ "notice.gcode_no_thumbnail": "Tệp G-code không có ảnh xem trước của slicer nên đường chạy được vẽ nhìn từ trên xuống",
"notice.sheet_objects_left_out": "Các đối tượng Filee chưa in được (biểu đồ, chữ trong hình …) đã bị bỏ qua: hãy cài LibreOffice trong Cài đặt → Bộ xử lý để giữ chúng",
"notice.cmyk_needs_ghostscript": "Tệp này là CMYK nhưng đã được lưu dưới dạng RGB: hãy cài Ghostscript trong Cài đặt → Bộ xử lý để giữ CMYK",
"notice.pdfa_needs_ghostscript": "Tệp PDF này không phải PDF/A: hãy cài Ghostscript trong Cài đặt → Bộ xử lý để tạo PDF/A",
@@ -282,6 +294,8 @@
"engine.font.description": "Phông chữ: TTF, OTF, WOFF, WOFF2 và EOT chuyển đổi qua lại; phông chữ bitmap Windows (FON) sang các định dạng đó.",
"engine.cad.name": "CAD (tích hợp)",
"engine.cad.description": "Bản vẽ AutoCAD: DWG ↔ DXF, và cả hai sang PDF, SVG và PNG.",
+ "engine.gcode.name": "G-code (tích hợp)",
+ "engine.gcode.description": "Đường chạy của máy in 3D và máy CNC (G-code) sang PDF, SVG và ảnh, hoặc ảnh xem trước của slicer.",
"engine.drawing.name": "Bản vẽ (tích hợp)",
"engine.drawing.description": "Bản vẽ Visio (VSDX) và bản vẽ OpenDocument (ODG, FODG) sang PDF, SVG và PNG, không cần Visio hay LibreOffice.\nTệp Visio nhị phân cũ (.vsd) vẫn cần LibreOffice.",
"engine.model3d.name": "Mô hình 3D (tích hợp)",
@@ -304,6 +318,7 @@
"engine.tesseract.description": "Nhận dạng văn bản trong bản quét và ảnh (OCR): PDF có thể tìm kiếm, DOCX, TXT.",
"engine.vision.description": "Nhận dạng văn bản trong bản quét và ảnh bằng tính năng nhận dạng văn bản của chính macOS (OCR): PDF có thể tìm kiếm, DOCX, TXT. Không cần tải gì.",
"engine.calibre.description": "Định dạng sách điện tử hiếm (LIT, LRF, CHM, PDB, RB…) và đầu ra Kindle (MOBI, AZW3).\nKhi tắt trình kết xuất Filee và đặt trên trình ghi HWPX, Calibre cũng tạo PDF cho sách điện tử (nhanh hơn nhiều với sách dài).",
+ "engine.weasyprint.description": "Tạo PDF từ trang HTML, Markdown và email giống như trình duyệt in: tiêu đề, bảng, chữ đậm và văn bản tiếng Ả Rập hay tiếng Hindi hiển thị đúng.\nChỉ tải ảnh nằm cạnh tài liệu, không tải gì từ Internet.",
"engine.libreoffice.description": "Đọc các định dạng cũ hơn và ít phổ biến: DOC của Word 6.0/95, PPT của PowerPoint 95, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio nhị phân (VSD)…\nCũng chuyển đổi tệp ODP và ODG có biểu đồ, đối tượng nhúng hoặc 3D, và lưu dưới dạng DOC, XLS, PPT, ODT, mẫu OpenDocument và EMF.",
"engine.translator.description": "Dịch tài liệu và sách điện tử ngay trên máy bằng các mô hình Ollama ngoại tuyến.\nCũng dịch phụ đề SRT và WebVTT: chỉ văn bản của từng câu phụ đề được gửi tới mô hình, số thứ tự và mốc thời gian được giữ nguyên.",
"engine.exiftool.description": "Đọc mọi trường siêu dữ liệu của ảnh, PDF, video và âm thanh, và sửa chúng ở hầu hết các loại tệp mà không đụng đến nội dung tệp.\nĐược công cụ Siêu dữ liệu dùng; không có nó, các trường chỉ đọc.",
@@ -404,6 +419,7 @@
"clip.save": "Lưu đoạn",
"clip.save_next": "Lưu & tiếp",
"clip.seconds": "{0} giây",
+ "clip.ringtone_limit": "Nhạc chuông iPhone dài tối đa {0} giây",
"clip.length_hint": "Một đoạn dài bấy nhiêu giây tính từ điểm bắt đầu",
"clip.fast": "Nhanh, không mã hóa lại",
"clip.fast_hint": "Sao chép video nguyên trạng: xong ngay và không mất gì, nhưng đoạn sẽ bắt đầu ở khung hình chính trước điểm bắt đầu (sớm hơn tối đa vài giây). Tắt để cắt chính xác tại các khung hình đã chọn (mã hóa lại, mất nhiều thời gian hơn).",
@@ -807,9 +823,15 @@
"toolbar.hint_remove": "Kéo một phần trở lại đây để gỡ bỏ",
"toolbar.hint_reorder": "Kéo các phần chồng lên nhau để sắp xếp lại",
"toolbar.hint_edit": "Nhấp chuột phải vào một phần hoặc dùng ✎ để chỉnh sửa",
+ "toolbar.group_hint": "Kéo cấu hình sẵn từ danh sách vào, kéo các phần để sắp xếp lại, và kéo một phần vào danh sách để bỏ ra.",
+ "toolbar.group_back": "Quay lại {0}",
"toolbar.max_reached": "Một vòng tròn chứa tối đa {0} cấu hình sẵn",
- "toolbar.appearance": "Giao diện",
"toolbar.size": "Kích thước",
+ "toolbar.edit_size": "Sửa kích thước vòng tròn",
+ "toolbar.look_done": "Xong",
+ "toolbar.look_hint": "Vòng tròn của bạn đang hiện trên màn hình đúng kích thước thật. Kéo phần giữa để di chuyển, và nhấp vào một nhóm để giữ nó mở.",
+ "toolbar.look_all": "Tất cả vòng tròn",
+ "toolbar.look_this": "Vòng tròn này: {0}",
"toolbar.hole": "Lỗ giữa",
"toolbar.opacity": "Độ mờ đục",
"toolbar.captions": "Hiển thị định dạng đầu ra",
@@ -817,6 +839,9 @@
"toolbar.label_scale": "Cỡ nhãn",
"toolbar.ring_size": "Cỡ vòng nhóm",
"toolbar.ring_spread": "Độ trải của vòng nhóm",
+ "toolbar.group_shown": "Nhóm được hiển thị",
+ "toolbar.example_group": "Nhóm ví dụ",
+ "toolbar.example_none": "Vòng tròn này không có nhóm",
"labels.auto": "Tự động",
"labels.icon_text": "Biểu tượng và chữ",
"labels.text_only": "Chỉ chữ",
@@ -839,6 +864,7 @@
"group.add": "Thêm",
"group.add_placeholder": "Chọn một cấu hình sẵn",
"group.full": "Một nhóm chứa tối đa {0} cấu hình sẵn.",
+ "group.copy_front": "Không thể thêm Sao chép vào thư mục vào nhóm này: nhóm này cũng có ở mặt trước.",
"group.move_up": "Di chuyển lên",
"group.move_down": "Di chuyển xuống",
"group.remove": "Gỡ khỏi nhóm",
@@ -865,6 +891,9 @@
"toolbar.mixed_others": "Các tổ hợp tệp khác sẽ mở “{0}”.",
"toolbar.mixed_others_self": "Không còn hồ sơ hỗn hợp nào không chọn mục nào, nên hồ sơ này cũng được dùng cho mọi tổ hợp khác.",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "Định dạng đầu vào này dùng chuyển đổi thông thường",
+ "toolbar.chip_tip.ordinary_conversion.all": "Tất cả định dạng đầu vào có thể chuyển đổi đều dùng chuyển đổi thông thường",
+ "toolbar.chip_tip.ordinary_conversion.some": "Một số định dạng đầu vào dùng chuyển đổi thông thường",
"toolbar.chip_tip.makes_same": "→ Cùng định dạng",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "Định dạng được nhận: {0}",
@@ -917,6 +946,11 @@
"presets.cad_colors.BlackAndWhite": "Đen trắng",
"presets.cad_colors.Grayscale": "Thang độ xám",
"presets.cad_colors_hint": "Dùng khi bản vẽ DWG hoặc DXF thành PDF, SVG hoặc ảnh. Đen trắng vẽ mọi đường và văn bản bằng màu đen, như bản in đơn sắc; vùng tô chuyển sang màu xám theo độ sáng của chúng, nên vùng tô nhạt không thành khối đen. Thang độ xám chuyển mỗi màu thành một sắc xám và làm đậm các đường nhạt, như màu vàng, đủ để đọc.",
+ "presets.gcode_view": "Góc nhìn",
+ "presets.gcode_view.Top": "Từ trên xuống",
+ "presets.gcode_view.Isometric": "Đẳng cự",
+ "presets.gcode_view.Thumbnail": "Ảnh xem trước của slicer",
+ "presets.gcode_view_hint": "Dùng khi tệp G-code từ slicer hoặc phần mềm CAM được chuyển thành PDF, SVG hoặc ảnh. Đùn nhựa và đường cắt được tô màu theo độ cao, từ tối (thấp) đến sáng (cao); di chuyển không làm việc là các đường mảnh màu xanh (màu đỏ cho chạy nhanh của CNC). Ảnh xem trước của slicer là ảnh mà PrusaSlicer, Cura, OrcaSlicer hoặc Bambu Studio lưu trong tệp; SVG, PDF và tệp không có ảnh này hiển thị đường chạy từ trên xuống.",
"presets.group.image": "Ảnh",
"presets.quality": "Chất lượng",
"presets.resize": "Đổi kích thước",
@@ -996,10 +1030,15 @@
"presets.ringtone_start": "Bắt đầu nhạc chuông (giây)",
"presets.ringtone_length": "Độ dài nhạc chuông (giây, tối đa 40)",
"presets.ringtone_hint": "Nhạc chuông iPhone là tệp AAC dài tối đa 40 giây. Nhạc chuông bắt đầu từ giây bạn chọn và được cắt theo độ dài bạn đặt (không bao giờ quá 40 giây).",
+ "presets.ask_ringtone": "Hỏi đoạn cần lấy mỗi lần (mở Cắt clip)",
"presets.ico_sizes": "Kích thước biểu tượng",
"presets.group.pdf": "PDF",
"presets.merge": "Ghép mọi tệp thành một PDF",
"presets.source_bookmarks": "Thêm dấu trang cho mỗi tệp gốc",
+ "presets.normalize_merge_paper": "Dùng cùng một cỡ giấy cho mọi trang được ghép",
+ "presets.merge_paper": "Giấy cho PDF đã ghép",
+ "presets.merge_paper_hint": "Căn từng trang vừa với giấy đã chọn và đặt ở giữa mà không cắt xén. Cài đặt trang ảnh được áp dụng trước khi ghép.",
+ "presets.merge_paper_auto_hint": "Hướng giấy tự động được chọn một lần dựa trên trang đầu tiên. Các trang nhỏ hơn sẽ được phóng to cho vừa.",
"presets.page_size": "Khổ trang",
"presets.page_size.FitImage": "Vừa với ảnh",
"presets.page_size.A4": "A4",
@@ -1156,6 +1195,7 @@
"presets.extract_note": "Mỗi tệp nén được giải nén vào một thư mục riêng. Nơi lưu được đặt trong mục Đầu ra.",
"presets.group.trace": "Dò nét",
"presets.group.cad": "Bản vẽ CAD",
+ "presets.group.gcode": "G-code (in 3D, CNC)",
"presets.trace_note": "Dùng khi một ảnh (PNG, JPG, …) được dò nét thành các hình vector.",
"presets.trace_mode": "Chế độ",
"presets.trace_mode.Color": "Màu",
@@ -1384,6 +1424,8 @@
"engines.package.whisper.description": "Chuyển lời nói trong video và âm thanh thành tệp phụ đề SRT. Chạy ngoại tuyến, không cần kết nối Internet.",
"engines.package.calibre.name": "Calibre (Kindle và định dạng sách điện tử hiếm)",
"engines.package.calibre.description": "Lưu MOBI và AZW3 cho Kindle và chuyển đổi các định dạng sách điện tử hiếm (LIT, LRF, PDB, CHM…).\nEPUB, MOBI, AZW3, FB2 và truyện tranh được đọc mà không cần Calibre.",
+ "engines.package.weasyprint.name": "WeasyPrint (HTML, Markdown và email sang PDF)",
+ "engines.package.weasyprint.description": "Tạo PDF tốt hơn từ trang HTML, Markdown và email (EML, MSG, MHTML): tiêu đề, bảng, chữ đậm và chữ viết từ phải sang trái hay chữ Ấn Độ giống như trình duyệt in.\nKhông có nó, tệp vẫn thành PDF qua đường tích hợp.",
"engines.package.7zip.name": "7-Zip (các biến thể nén hiếm)",
"engines.package.7zip.description": "Giải nén các biến thể hiếm mà bộ đọc riêng của Filee bỏ qua: 7Z dùng PPMd và các phương thức ít gặp khác, CAB Quantum, ARJ nhiều phần, ảnh đĩa NTFS, exFAT hoặc APFS…\nZIP, 7Z, RAR, TAR, CAB, ISO, DMG và mọi định dạng nén phổ biến khác đều dùng được mà không cần 7-Zip.",
"engines.package.size": "Tải xuống {0} · {1} trên đĩa",
@@ -1410,7 +1452,17 @@
"engines.remove": "Gỡ bỏ",
"engines.remove_confirm": "Gỡ bỏ {0}? Bạn có thể tải xuống lại sau.",
"engines.state.own_copy": "Bản của bạn",
- "engines.state.system_copy": "Tìm thấy trên máy tính này",
+ "engines.source.download": "Bản tải xuống của Filee",
+ "engines.source.system": "Trên máy tính này",
+ "engines.source.version": "phiên bản {0}",
+ "engines.source.on_disk": "{0} trên đĩa",
+ "engines.other_copy_title": "Cũng có trên máy tính này",
+ "engines.other_copy_detail": "{0} (bản tải xuống của Filee sẽ bị gỡ bỏ)",
+ "engines.use_other_copy": "Dùng bản này thay thế",
+ "engines.use_other_copy_confirm": "Dùng bản trong {0} và gỡ bỏ bản tải xuống {1} của Filee? Bạn có thể tải xuống lại sau.",
+ "engines.other_copy_gone": "Không còn tìm thấy bản đó hoặc bản đó không chạy. Bản tải xuống của Filee được giữ lại.",
+ "engines.install_filee_copy": "Cài bản của Filee",
+ "engines.use_another": "Dùng bản khác…",
"engines.update_recommended": "Nên cập nhật: bản này là {0}, Filee được kiểm thử với {1}.\nHầu hết các chuyển đổi vẫn hoạt động.",
"engines.use_own": "Dùng bản của tôi…",
"engines.use_own_hint": "Đã có sẵn? Hãy chọn chương trình của nó, Filee sẽ dùng bản đó thay vì tự tải xuống.",
diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json
index b3d8d1319..6f84173f7 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -78,6 +78,9 @@
"donut.files_same": "{0} 个 {1}",
"donut.files_mixed": "{0} 个文件",
"donut.drop_to": "松开 → {0}",
+ "donut.ordinary_conversion.single": "此文件将进行普通转换",
+ "donut.ordinary_conversion.all": "所有可转换的文件都将进行普通转换",
+ "donut.ordinary_conversion.some": "部分文件将进行普通转换",
"donut.pick_in_group": "向外移动以选择",
"donut.group_empty": "此组中已没有预设,请在“设置 → 环形工具栏”中编辑",
"donut.click_to": "点击 → {0}",
@@ -133,6 +136,8 @@
"error.split_unknown_length": "无法得知这个视频的时长,所以不能等分",
"error.split_needs_copy": "分段会按原样复制视频,而这个格式装不下它。请选择视频原来的格式或 MKV",
"error.merged_image_too_large": "合并后的图片太大,请减少图片数量或使用更小的图片",
+ "error.merge_paper_geometry": "页面的尺寸、旋转角度或单位无效,无法适配所选纸张",
+ "error.merge_paper_limit": "页面超出更改纸张大小的处理上限",
"error.over_size_limit": "无法缩小到大小上限以内",
"error.not_enough_space": "磁盘空间不足,无法保存这些图片",
"error.no_motion_photo": "这张照片中没有可读取的视频(安卓动态照片,或旁边有 MOV 的 iPhone 实况照片)",
@@ -172,6 +177,9 @@
"error.fit_damaged": "FIT 文件已损坏或不完整",
"error.fit_chained": "暂不支持多个 FIT 文件首尾相连的情况",
"error.fit_too_large": "FIT 文件太大,无法转换",
+ "error.canvas_not_canvas": "这不是 Obsidian 白板 (JSON Canvas)",
+ "error.canvas_empty": "白板中没有可绘制的卡片",
+ "error.canvas_too_large": "白板太大,无法转换",
"error.fit_no_file_id": "FIT 文件未说明文件类型",
"error.fit_not_activity": "此 FIT 文件不是活动记录(只能转换活动记录)",
"error.fit_no_positions": "活动没有记录位置信息,请转换为 CSV、XLSX 或 JSON",
@@ -183,12 +191,16 @@
"error.fon_damaged": ".fon 文件已损坏或不完整",
"error.document_too_complex": "文档过大或过于复杂,无法安全读取",
"error.m4s_needs_init": "此 .m4s 是流媒体视频的一个片段,没有单独的起始(init)分段就无法读取",
+ "error.nov_password": "此导出文件受密码保护,视频已加密:请在 Wisenet WAVE / Nx Witness 中不设密码重新导出",
+ "error.nov_no_video": "此导出文件中没有摄像机录像",
"error.exe_unreadable": "这不是 Filee 能读取的 Windows 程序或库,或文件已损坏",
"error.exe_no_icon": "此文件没有自己的图标(Windows 会为它显示标准图标)",
"notice.pdf_not_smaller": "无法再缩小,已原样复制",
+ "notice.nov_recordings_left_out": "此导出文件包含多段录像(其他摄像机或中断分开的片段):只转换了最大的一段",
"notice.under_min_size": "已保存,但小于最小文件大小",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "已省略所选 AutoCAD 版本没有的对象(多重引线、网格……)",
+ "notice.gcode_no_thumbnail": "G-code 文件没有切片软件的预览图,因此按俯视图绘制了刀路",
"notice.sheet_objects_left_out": "已省略暂时无法打印的对象(图表、形状中的文字……):要保留它们,请在 设置 → 转换引擎 中安装 LibreOffice",
"notice.cmyk_needs_ghostscript": "这是 CMYK 文件,但保存成了 RGB:要保留 CMYK,请在 设置 → 转换引擎 中安装 Ghostscript",
"notice.pdfa_needs_ghostscript": "这个 PDF 不是 PDF/A:要生成 PDF/A,请在 设置 → 转换引擎 中安装 Ghostscript",
@@ -282,6 +294,8 @@
"engine.font.description": "TTF、OTF、WOFF、WOFF2 和 EOT 相互转换;将 Windows 位图字体(FON)转换为这些格式。",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "AutoCAD 图纸:DWG ↔ DXF,并可转为 PDF、SVG 和 PNG。",
+ "engine.gcode.name": "G-code (built-in)",
+ "engine.gcode.description": "将 3D 打印机和 CNC 机床的刀路 (G-code) 转为 PDF、SVG 和图片,或使用切片软件保存的预览图。",
"engine.drawing.name": "Drawings (built-in)",
"engine.drawing.description": "将 Visio 图表(VSDX)和 OpenDocument 绘图(ODG、FODG)转为 PDF、SVG 和 PNG,无需 Visio 或 LibreOffice。\n旧的二进制 Visio 文件(.vsd)仍需 LibreOffice。",
"engine.model3d.name": "3D models (built-in)",
@@ -304,6 +318,7 @@
"engine.tesseract.description": "识别扫描件和图片中的文字(OCR):可搜索的 PDF、DOCX、TXT。",
"engine.vision.description": "使用 macOS 自带的文字识别来识别扫描件和图片中的文字(OCR):可搜索的 PDF、DOCX、TXT。无需下载。",
"engine.calibre.description": "少见的电子书格式(LIT、LRF…)以及保存为旧格式(LIT、PDB、RB…)。\n关闭 Filee 渲染器、并把它排在 HWPX writer 之上时,也由它把电子书转为 PDF(长书快得多)。",
+ "engine.weasyprint.description": "像浏览器打印那样把 HTML 页面、Markdown 和电子邮件制作成 PDF:标题、表格、粗体以及阿拉伯语或印地语文本都能正确显示。\n只加载文档旁边的图片,不从互联网加载任何内容。",
"engine.libreoffice.description": "读取较旧或少见的格式,如 Word 6.0/95 的 DOC、PowerPoint 95 的 PPT、Pages、Keynote、Numbers、WordPerfect、ClarisWorks、Publisher、二进制 Visio(VSD)。\n也能转换含图表、嵌入对象或 3D 的 ODP 和 ODG 文件,并保存为 DOC、XLS、PPT、ODT、OpenDocument 模板或 EMF。",
"engine.translator.description": "使用离线 Ollama 模型在本地翻译文档和电子书。\n也能翻译 SRT 和 WebVTT 字幕:只把字幕文本发送给模型,字幕序号和时间轴保持不变。",
"engine.exiftool.description": "读取图片、PDF、视频和音频的所有元数据字段,并在大多数类型的文件中编辑它们,不改动文件内容。\n供“元数据”工具使用;没有它时,字段只能查看。",
@@ -404,6 +419,7 @@
"clip.save": "保存片段",
"clip.save_next": "保存并下一个",
"clip.seconds": "{0} 秒",
+ "clip.ringtone_limit": "iPhone 铃声最长 {0} 秒",
"clip.length_hint": "从开始位置起这么长的片段",
"clip.fast": "快速,不重新编码",
"clip.fast_hint": "按原样复制视频:瞬间完成且无画质损失,但片段从开始位置之前的关键帧开始(最多提前几秒)。关闭后会在所选帧处精确剪切(需要重新编码,耗时更长)。",
@@ -807,9 +823,15 @@
"toolbar.hint_remove": "将扇区拖回这里即可移除",
"toolbar.hint_reorder": "扇区之间互相拖动可调整顺序",
"toolbar.hint_edit": "右键扇区或点击 ✎ 编辑",
+ "toolbar.group_hint": "从列表拖入预设,拖动切片调整顺序,拖回列表即可移出。",
+ "toolbar.group_back": "返回{0}",
"toolbar.max_reached": "圆环最多容纳 {0} 个预设",
- "toolbar.appearance": "外观",
"toolbar.size": "大小",
+ "toolbar.edit_size": "编辑圆环大小",
+ "toolbar.look_done": "完成",
+ "toolbar.look_hint": "圆环以实际大小显示在屏幕上。拖动中间可以移动它,点击分组可让它保持展开。",
+ "toolbar.look_all": "所有圆环",
+ "toolbar.look_this": "此圆环:{0}",
"toolbar.hole": "内圈",
"toolbar.opacity": "不透明度",
"toolbar.captions": "显示输出格式",
@@ -817,6 +839,9 @@
"toolbar.label_scale": "标签大小",
"toolbar.ring_size": "分组圆环大小",
"toolbar.ring_spread": "分组圆环展开宽度",
+ "toolbar.group_shown": "显示的分组",
+ "toolbar.example_group": "示例分组",
+ "toolbar.example_none": "这个圆环没有分组",
"labels.auto": "自动",
"labels.icon_text": "图标和文字",
"labels.text_only": "仅文字",
@@ -839,6 +864,7 @@
"group.add": "添加",
"group.add_placeholder": "选择预设",
"group.full": "一个组最多可放 {0} 个预设。",
+ "group.copy_front": "无法将复制到文件夹放入此组:此组也在正面。",
"group.move_up": "上移",
"group.move_down": "下移",
"group.remove": "从组中移除",
@@ -865,6 +891,9 @@
"toolbar.mixed_others": "其他混合选择会打开“{0}”。",
"toolbar.mixed_others_self": "没有未勾选任何项的混合配置,因此其他混合选择也会使用此配置。",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "此输入格式将进行普通转换",
+ "toolbar.chip_tip.ordinary_conversion.all": "所有可转换的输入格式都将进行普通转换",
+ "toolbar.chip_tip.ordinary_conversion.some": "部分输入格式将进行普通转换",
"toolbar.chip_tip.makes_same": "→ 相同格式",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "支持的格式:{0}",
@@ -917,6 +946,11 @@
"presets.cad_colors.BlackAndWhite": "黑白",
"presets.cad_colors.Grayscale": "灰度",
"presets.cad_colors_hint": "DWG 或 DXF 图纸转换为 PDF、SVG 或图片时使用。黑白像单色打印一样把所有线条和文字画成黑色;填充区域按亮度变为灰色,浅色填充不会变成黑块。灰度把每种颜色转为灰色,并把黄色等浅色线条加深到能看清。",
+ "presets.gcode_view": "视图",
+ "presets.gcode_view.Top": "俯视",
+ "presets.gcode_view.Isometric": "等轴测",
+ "presets.gcode_view.Thumbnail": "切片软件预览图",
+ "presets.gcode_view_hint": "当切片软件或 CAM 程序生成的 G-code 文件转为 PDF、SVG 或图片时使用。挤出和切削按高度着色,从深(低)到浅(高);空走为细蓝线(CNC 快速移动为红线)。切片软件预览图是 PrusaSlicer、Cura、OrcaSlicer 或 Bambu Studio 保存在文件中的图片。SVG、PDF 以及没有预览图的文件显示俯视刀路。",
"presets.group.image": "图片",
"presets.quality": "质量",
"presets.resize": "调整大小",
@@ -996,10 +1030,15 @@
"presets.ringtone_start": "铃声起点(秒)",
"presets.ringtone_length": "铃声长度(秒,最长 40)",
"presets.ringtone_hint": "iPhone 铃声是不超过 40 秒的 AAC 文件。铃声从你设定的秒数开始,并按你设定的长度截取(不会超过 40 秒)。",
+ "presets.ask_ringtone": "每次都询问截取的部分(打开“剪辑片段”)",
"presets.ico_sizes": "图标尺寸",
"presets.group.pdf": "PDF",
"presets.merge": "将所有文件合并为一个 PDF",
"presets.source_bookmarks": "为每个原始文件添加书签",
+ "presets.normalize_merge_paper": "合并后的所有页面使用同一纸张尺寸",
+ "presets.merge_paper": "合并后 PDF 的纸张",
+ "presets.merge_paper_hint": "将每页适配到所选纸张并居中放置,不会裁切内容。图片页面设置会在合并前应用。",
+ "presets.merge_paper_auto_hint": "根据第一页仅选择一次自动方向。较小的页面会放大以适应纸张。",
"presets.page_size": "页面大小",
"presets.page_size.FitImage": "适应图片",
"presets.page_size.A4": "A4",
@@ -1156,6 +1195,7 @@
"presets.extract_note": "每个压缩文件解压到各自的文件夹。保存位置在下方“输出”中设置。",
"presets.group.trace": "描摹",
"presets.group.cad": "CAD 图纸",
+ "presets.group.gcode": "G-code (3D 打印、CNC)",
"presets.trace_note": "将图片(PNG、JPG 等)描摹为矢量图形时使用。",
"presets.trace_mode": "模式",
"presets.trace_mode.Color": "彩色",
@@ -1384,6 +1424,8 @@
"engines.package.whisper.description": "将视频和音频中的语音转录为 SRT 字幕文件。无需互联网连接即可离线运行。",
"engines.package.calibre.name": "Calibre(少见电子书格式)",
"engines.package.calibre.description": "转换少见的电子书格式(LIT、LRF 等),并可保存为旧格式(LIT、PDB、RB 等)。\nEPUB、MOBI、AZW3、FB2 和漫画无需它即可读取,Kindle 电子书也无需它即可保存。",
+ "engines.package.weasyprint.name": "WeasyPrint(HTML、Markdown 和邮件转 PDF)",
+ "engines.package.weasyprint.description": "为 HTML 页面、Markdown 和电子邮件(EML、MSG、MHTML)制作更好的 PDF:标题、表格、粗体以及从右到左书写的文字或印度文字都与浏览器打印一致。\n没有它,这些文件也能通过内置方式转为 PDF。",
"engines.package.7zip.name": "7-Zip(少见的压缩包变体)",
"engines.package.7zip.description": "解压 Filee 内置读取器不处理的少见压缩包:使用 PPMd 等少见压缩方法的 7Z、Quantum CAB、分卷 ARJ、NTFS、exFAT 或 APFS 磁盘映像…\nZIP、7Z、RAR、TAR、CAB、ISO、DMG 及其他常见压缩包无需它即可处理。",
"engines.package.size": "下载 {0} · 占用 {1}",
@@ -1410,7 +1452,17 @@
"engines.remove": "删除",
"engines.remove_confirm": "删除 {0}?以后可以重新下载。",
"engines.state.own_copy": "使用你的副本",
- "engines.state.system_copy": "使用电脑上已有的程序",
+ "engines.source.download": "Filee 下载的程序",
+ "engines.source.system": "电脑上已有的程序",
+ "engines.source.version": "版本 {0}",
+ "engines.source.on_disk": "占用 {0}",
+ "engines.other_copy_title": "电脑上也有",
+ "engines.other_copy_detail": "{0}(将删除 Filee 下载的程序)",
+ "engines.use_other_copy": "改用这个",
+ "engines.use_other_copy_confirm": "使用 {0} 中的程序并删除 Filee 下载的 {1}?以后可以重新下载。",
+ "engines.other_copy_gone": "找不到该程序或无法启动。已保留 Filee 下载的版本。",
+ "engines.install_filee_copy": "安装 Filee 的版本",
+ "engines.use_another": "使用其他程序…",
"engines.update_recommended": "建议更新:此程序为 {0} 版,Filee 使用 {1} 版测试。\n大多数转换仍可正常进行。",
"engines.use_own": "使用我的程序…",
"engines.use_own_hint": "已经装过了?选择它的程序,Filee 就直接使用,不再另外下载。",
diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json
index 0eab25da5..56b7e45ed 100644
--- a/src/Filee.App/Assets/i18n/zh-TW.json
+++ b/src/Filee.App/Assets/i18n/zh-TW.json
@@ -78,6 +78,9 @@
"donut.files_same": "{0} 個 {1}",
"donut.files_mixed": "{0} 個檔案",
"donut.drop_to": "放開 → {0}",
+ "donut.ordinary_conversion.single": "此檔案將執行一般轉換",
+ "donut.ordinary_conversion.all": "所有可轉換的檔案都將執行一般轉換",
+ "donut.ordinary_conversion.some": "部分檔案將執行一般轉換",
"donut.pick_in_group": "向外移動以選擇",
"donut.group_empty": "此群組中已沒有預設,請在「設定 → 環形工具列」中編輯",
"donut.click_to": "點擊 → {0}",
@@ -133,6 +136,8 @@
"error.split_unknown_length": "無法得知這部影片的長度,所以不能等分",
"error.split_needs_copy": "分段會照原樣複製影片,而這個格式裝不下它。請選擇影片原本的格式或 MKV",
"error.merged_image_too_large": "合併後的圖片太大,請減少圖片數量或使用較小的圖片",
+ "error.merge_paper_geometry": "頁面的尺寸、旋轉角度或單位無效,無法配合所選紙張",
+ "error.merge_paper_limit": "頁面超出變更紙張大小的處理上限",
"error.over_size_limit": "無法縮小到大小上限以內",
"error.not_enough_space": "磁碟空間不足,無法儲存這些圖片",
"error.no_motion_photo": "這張照片中沒有可讀取的影片(Android 動態相片,或旁邊有 MOV 的 iPhone 原況照片)",
@@ -172,6 +177,9 @@
"error.fit_damaged": "FIT 檔案已損毀或不完整",
"error.fit_chained": "暫不支援多個 FIT 檔案首尾相連的情況",
"error.fit_too_large": "FIT 檔案太大,無法轉換",
+ "error.canvas_not_canvas": "這不是 Obsidian 白板 (JSON Canvas)",
+ "error.canvas_empty": "白板中沒有可繪製的卡片",
+ "error.canvas_too_large": "白板太大,無法轉換",
"error.fit_no_file_id": "FIT 檔案未說明檔案類型",
"error.fit_not_activity": "此 FIT 檔案不是活動記錄(只能轉換活動記錄)",
"error.fit_no_positions": "活動沒有記錄位置資訊,請轉換為 CSV、XLSX 或 JSON",
@@ -183,12 +191,16 @@
"error.fon_damaged": ".fon 檔案已損壞或不完整",
"error.document_too_complex": "文件過大或過於複雜,無法安全讀取",
"error.m4s_needs_init": "此 .m4s 是串流影片的一個片段,沒有另外的起始(init)區段就無法讀取",
+ "error.nov_password": "此匯出檔受密碼保護,影片已加密:請在 Wisenet WAVE / Nx Witness 中不設密碼重新匯出",
+ "error.nov_no_video": "此匯出檔中沒有攝影機錄影",
"error.exe_unreadable": "這不是 Filee 能讀取的 Windows 程式或程式庫,或檔案已損壞",
"error.exe_no_icon": "此檔案沒有自己的圖示(Windows 會為它顯示標準圖示)",
"notice.pdf_not_smaller": "無法再縮小,已原樣複製",
+ "notice.nov_recordings_left_out": "此匯出檔包含多段錄影(其他攝影機或中斷分開的片段):只轉換了最大的一段",
"notice.under_min_size": "已儲存,但小於最小檔案大小",
"notice.with_under_min_size": "{0} · {1}",
"notice.cad_newer_left_out": "已略過所選 AutoCAD 版本沒有的物件(複線引線、網格……)",
+ "notice.gcode_no_thumbnail": "G-code 檔案沒有切片軟體的預覽圖,因此以俯視圖繪製刀具路徑",
"notice.sheet_objects_left_out": "已略過目前無法列印的物件(圖表、圖案中的文字……):要保留它們,請在「設定 → 轉換引擎」中安裝 LibreOffice",
"notice.cmyk_needs_ghostscript": "這是 CMYK 檔案,但儲存成了 RGB:要保留 CMYK,請在「設定 → 轉換引擎」中安裝 Ghostscript",
"notice.pdfa_needs_ghostscript": "這個 PDF 不是 PDF/A:要產生 PDF/A,請在「設定 → 轉換引擎」中安裝 Ghostscript",
@@ -282,6 +294,8 @@
"engine.font.description": "TTF、OTF、WOFF、WOFF2 和 EOT 相互轉換;將 Windows 點陣字型(FON)轉換為這些格式。",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "AutoCAD 圖紙:DWG ↔ DXF,並可轉為 PDF、SVG 和 PNG。",
+ "engine.gcode.name": "G-code (built-in)",
+ "engine.gcode.description": "將 3D 印表機和 CNC 機台的刀具路徑 (G-code) 轉為 PDF、SVG 和圖片,或使用切片軟體儲存的預覽圖。",
"engine.drawing.name": "Drawings (built-in)",
"engine.drawing.description": "將 Visio 圖表(VSDX)和 OpenDocument 繪圖(ODG、FODG)轉為 PDF、SVG 和 PNG,不需 Visio 或 LibreOffice。\n舊的二進位 Visio 檔案(.vsd)仍需 LibreOffice。",
"engine.model3d.name": "3D models (built-in)",
@@ -304,6 +318,7 @@
"engine.tesseract.description": "辨識掃描檔和圖片中的文字(OCR):可搜尋的 PDF、DOCX、TXT。",
"engine.vision.description": "使用 macOS 內建的文字辨識來辨識掃描檔和圖片中的文字(OCR):可搜尋的 PDF、DOCX、TXT。不需要下載。",
"engine.calibre.description": "少見的電子書格式(LIT、LRF…)以及儲存為舊格式(LIT、PDB、RB…)。\n關閉 Filee 轉譯器、並把它排在 HWPX writer 之上時,也由它把電子書轉為 PDF(長書快得多)。",
+ "engine.weasyprint.description": "像瀏覽器列印那樣把 HTML 頁面、Markdown 和電子郵件製作成 PDF:標題、表格、粗體以及阿拉伯文或印地文文字都能正確顯示。\n只載入文件旁邊的圖片,不從網際網路載入任何內容。",
"engine.libreoffice.description": "讀取較舊或少見的格式,如 Word 6.0/95 的 DOC、PowerPoint 95 的 PPT、Pages、Keynote、Numbers、WordPerfect、ClarisWorks、Publisher、二進位 Visio(VSD)。\n也能轉換含圖表、內嵌物件或 3D 的 ODP 和 ODG 檔案,並儲存為 DOC、XLS、PPT、ODT、OpenDocument 範本或 EMF。",
"engine.translator.description": "使用離線 Ollama 模型在本地翻譯文件與電子書。\n也能翻譯 SRT 和 WebVTT 字幕:只將字幕文字傳送給模型,字幕序號和時間軸保持不變。",
"engine.exiftool.description": "讀取圖片、PDF、影片和音訊的所有中繼資料欄位,並在大多數類型的檔案中編輯它們,不更動檔案內容。\n供「中繼資料」工具使用;沒有它時,欄位只能檢視。",
@@ -404,6 +419,7 @@
"clip.save": "儲存片段",
"clip.save_next": "儲存並下一個",
"clip.seconds": "{0} 秒",
+ "clip.ringtone_limit": "iPhone 鈴聲最長 {0} 秒",
"clip.length_hint": "從開始位置起這麼長的片段",
"clip.fast": "快速,不重新編碼",
"clip.fast_hint": "按原樣複製影片:瞬間完成且畫質無損,但片段會從開始位置之前的關鍵影格開始(最多提早幾秒)。關閉後會在所選影格精確剪輯(需要重新編碼,耗時較長)。",
@@ -807,9 +823,15 @@
"toolbar.hint_remove": "將扇形拖回這裡即可移除",
"toolbar.hint_reorder": "扇形之間互相拖曳可調整順序",
"toolbar.hint_edit": "右鍵扇形或點擊 ✎ 編輯",
+ "toolbar.group_hint": "從清單拖入預設,拖曳切片調整順序,拖回清單即可移出。",
+ "toolbar.group_back": "返回{0}",
"toolbar.max_reached": "圓環最多容納 {0} 個預設",
- "toolbar.appearance": "外觀",
"toolbar.size": "大小",
+ "toolbar.edit_size": "編輯圓環大小",
+ "toolbar.look_done": "完成",
+ "toolbar.look_hint": "圓環以實際大小顯示在螢幕上。拖曳中間可以移動它,點選群組可讓它保持展開。",
+ "toolbar.look_all": "所有圓環",
+ "toolbar.look_this": "此圓環:{0}",
"toolbar.hole": "內圈",
"toolbar.opacity": "不透明度",
"toolbar.captions": "顯示輸出格式",
@@ -817,6 +839,9 @@
"toolbar.label_scale": "標籤大小",
"toolbar.ring_size": "群組圓環大小",
"toolbar.ring_spread": "群組圓環展開寬度",
+ "toolbar.group_shown": "顯示的群組",
+ "toolbar.example_group": "範例群組",
+ "toolbar.example_none": "這個圓環沒有群組",
"labels.auto": "自動",
"labels.icon_text": "圖示和文字",
"labels.text_only": "僅文字",
@@ -839,6 +864,7 @@
"group.add": "新增",
"group.add_placeholder": "選擇預設",
"group.full": "一個群組最多可放 {0} 個預設。",
+ "group.copy_front": "無法將「複製到資料夾」放入此群組:此群組也在正面。",
"group.move_up": "上移",
"group.move_down": "下移",
"group.remove": "從群組中移除",
@@ -865,6 +891,9 @@
"toolbar.mixed_others": "其他混合選取會開啟「{0}」。",
"toolbar.mixed_others_self": "沒有未勾選任何項目的混合設定檔,因此其他混合選取也會使用此設定檔。",
"toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.ordinary_conversion.single": "此輸入格式將執行一般轉換",
+ "toolbar.chip_tip.ordinary_conversion.all": "所有可轉換的輸入格式都將執行一般轉換",
+ "toolbar.chip_tip.ordinary_conversion.some": "部分輸入格式將執行一般轉換",
"toolbar.chip_tip.makes_same": "→ 相同格式",
"toolbar.chip_tip.makes_name": "→ {0}",
"toolbar.chip_tip.works_with": "支援的格式:{0}",
@@ -917,6 +946,11 @@
"presets.cad_colors.BlackAndWhite": "黑白",
"presets.cad_colors.Grayscale": "灰階",
"presets.cad_colors_hint": "DWG 或 DXF 圖面轉成 PDF、SVG 或圖片時使用。黑白會像單色出圖一樣把所有線條和文字畫成黑色;填滿區域依亮度變成灰色,淺色填滿不會變成黑塊。灰階會把每種顏色轉成灰色,並把黃色等淺色線條加深到看得清楚。",
+ "presets.gcode_view": "檢視",
+ "presets.gcode_view.Top": "俯視",
+ "presets.gcode_view.Isometric": "等角",
+ "presets.gcode_view.Thumbnail": "切片軟體預覽圖",
+ "presets.gcode_view_hint": "當切片軟體或 CAM 程式產生的 G-code 檔案轉為 PDF、SVG 或圖片時使用。擠出和切削依高度著色,從深(低)到淺(高);空跑為細藍線(CNC 快速移動為紅線)。切片軟體預覽圖是 PrusaSlicer、Cura、OrcaSlicer 或 Bambu Studio 儲存在檔案中的圖片。SVG、PDF 以及沒有預覽圖的檔案顯示俯視的刀具路徑。",
"presets.group.image": "圖片",
"presets.quality": "品質",
"presets.resize": "調整大小",
@@ -996,10 +1030,15 @@
"presets.ringtone_start": "鈴聲起點(秒)",
"presets.ringtone_length": "鈴聲長度(秒,最長 40)",
"presets.ringtone_hint": "iPhone 鈴聲是不超過 40 秒的 AAC 檔案。鈴聲會從你設定的秒數開始,並依你設定的長度截取(不會超過 40 秒)。",
+ "presets.ask_ringtone": "每次都詢問擷取的部分(開啟「剪輯片段」)",
"presets.ico_sizes": "圖示尺寸",
"presets.group.pdf": "PDF",
"presets.merge": "將所有檔案合併為一個 PDF",
"presets.source_bookmarks": "為每個原始檔案新增書籤",
+ "presets.normalize_merge_paper": "合併後的所有頁面使用相同紙張尺寸",
+ "presets.merge_paper": "合併後 PDF 的紙張",
+ "presets.merge_paper_hint": "將每頁配合所選紙張調整並置中,不會裁切內容。圖片頁面設定會在合併前套用。",
+ "presets.merge_paper_auto_hint": "依第一頁只選擇一次自動方向。較小的頁面會放大以符合紙張。",
"presets.page_size": "頁面大小",
"presets.page_size.FitImage": "適應圖片",
"presets.page_size.A4": "A4",
@@ -1156,6 +1195,7 @@
"presets.extract_note": "每個壓縮檔解壓縮到各自的資料夾。儲存位置在下方「輸出」中設定。",
"presets.group.trace": "描摹",
"presets.group.cad": "CAD 圖面",
+ "presets.group.gcode": "G-code (3D 列印、CNC)",
"presets.trace_note": "將圖片(PNG、JPG 等)描摹為向量圖形時使用。",
"presets.trace_mode": "模式",
"presets.trace_mode.Color": "彩色",
@@ -1384,6 +1424,8 @@
"engines.package.whisper.description": "將影片與音訊中的語音轉錄為 SRT 字幕檔。無需網際網路連線即可離線執行。",
"engines.package.calibre.name": "Calibre(少見電子書格式)",
"engines.package.calibre.description": "轉換少見的電子書格式(LIT、LRF 等),並可儲存為舊格式(LIT、PDB、RB 等)。\nEPUB、MOBI、AZW3、FB2 和漫畫無需它即可讀取,Kindle 電子書也無需它即可儲存。",
+ "engines.package.weasyprint.name": "WeasyPrint(HTML、Markdown 和郵件轉 PDF)",
+ "engines.package.weasyprint.description": "為 HTML 頁面、Markdown 和電子郵件(EML、MSG、MHTML)製作更好的 PDF:標題、表格、粗體以及由右至左書寫的文字或印度文字都與瀏覽器列印一致。\n沒有它,這些檔案也能透過內建方式轉為 PDF。",
"engines.package.7zip.name": "7-Zip(少見的壓縮檔變體)",
"engines.package.7zip.description": "解壓縮 Filee 內建讀取器不處理的少見壓縮檔:使用 PPMd 等少見壓縮方式的 7Z、Quantum CAB、分割 ARJ、NTFS、exFAT 或 APFS 磁碟映像…\nZIP、7Z、RAR、TAR、CAB、ISO、DMG 及其他常見壓縮檔無需它即可處理。",
"engines.package.size": "下載 {0} · 佔用 {1}",
@@ -1410,7 +1452,17 @@
"engines.remove": "刪除",
"engines.remove_confirm": "刪除 {0}?以後可以重新下載。",
"engines.state.own_copy": "使用你的副本",
- "engines.state.system_copy": "使用電腦上已有的程式",
+ "engines.source.download": "Filee 下載的程式",
+ "engines.source.system": "電腦上已有的程式",
+ "engines.source.version": "版本 {0}",
+ "engines.source.on_disk": "佔用 {0}",
+ "engines.other_copy_title": "電腦上也有",
+ "engines.other_copy_detail": "{0}(將刪除 Filee 下載的程式)",
+ "engines.use_other_copy": "改用這個",
+ "engines.use_other_copy_confirm": "使用 {0} 中的程式並刪除 Filee 下載的 {1}?以後可以重新下載。",
+ "engines.other_copy_gone": "找不到該程式或無法啟動。已保留 Filee 下載的版本。",
+ "engines.install_filee_copy": "安裝 Filee 的版本",
+ "engines.use_another": "使用其他程式…",
"engines.update_recommended": "建議更新:此程式為 {0} 版,Filee 使用 {1} 版測試。\n大多數轉換仍可正常進行。",
"engines.use_own": "使用我的程式…",
"engines.use_own_hint": "已經裝過了?選擇它的程式,Filee 就直接使用,不再另外下載。",
diff --git a/src/Filee.App/Controls/ClipTimeline.cs b/src/Filee.App/Controls/ClipTimeline.cs
index 2a4c7c7c9..f66ff0a05 100644
--- a/src/Filee.App/Controls/ClipTimeline.cs
+++ b/src/Filee.App/Controls/ClipTimeline.cs
@@ -44,6 +44,10 @@ public sealed class ClipTimeline : Control
public static readonly StyledProperty?> LoudnessProperty =
AvaloniaProperty.Register?>(nameof(Loudness));
+ /// A sound alone: the loudness is the whole strip, drawn as a waveform in the accent colour.
+ public static readonly StyledProperty WaveformProperty =
+ AvaloniaProperty.Register(nameof(Waveform));
+
public static readonly StyledProperty FadeInProperty =
AvaloniaProperty.Register(nameof(FadeIn));
@@ -94,6 +98,7 @@ public sealed class ClipTimeline : Control
public IReadOnlyList? Pieces { get => GetValue(PiecesProperty); set => SetValue(PiecesProperty, value); }
public int SelectedIndex { get => GetValue(SelectedIndexProperty); set => SetValue(SelectedIndexProperty, value); }
public IReadOnlyList? Loudness { get => GetValue(LoudnessProperty); set => SetValue(LoudnessProperty, value); }
+ public bool Waveform { get => GetValue(WaveformProperty); set => SetValue(WaveformProperty, value); }
public bool FadeIn { get => GetValue(FadeInProperty); set => SetValue(FadeInProperty, value); }
public bool FadeOut { get => GetValue(FadeOutProperty); set => SetValue(FadeOutProperty, value); }
public bool Snap { get => GetValue(SnapProperty); set => SetValue(SnapProperty, value); }
@@ -149,7 +154,7 @@ private enum Drag { None, Start, End, Clip, Playhead, Pending }
static ClipTimeline()
{
AffectsRender(DurationProperty, StartProperty, EndProperty, PlayheadProperty, ActiveHandleProperty, PiecesProperty,
- SelectedIndexProperty, LoudnessProperty, FadeInProperty, FadeOutProperty, ViewStartProperty, ViewEndProperty, ThumbnailsProperty, AccentBrushProperty,
+ SelectedIndexProperty, LoudnessProperty, WaveformProperty, FadeInProperty, FadeOutProperty, ViewStartProperty, ViewEndProperty, ThumbnailsProperty, AccentBrushProperty,
OnAccentBrushProperty, TrackBrushProperty, StageBrushProperty, ForegroundProperty);
FocusableProperty.OverrideDefaultValue(false);
}
@@ -298,8 +303,8 @@ private void DrawLoudness(DrawingContext context, Rect strip)
{
if (Loudness is not { Count: > 0 } levels || Duration <= 0)
return;
- var area = strip.Height * 0.45;
- var bottom = strip.Bottom - 2;
+ var area = Waveform ? strip.Height * 0.8 : strip.Height * 0.45;
+ var bottom = Waveform ? strip.Bottom - strip.Height * 0.1 : strip.Bottom - 2;
var width = RawX(Duration / levels.Count) - RawX(0);
var bars = new StreamGeometry();
using (var g = bars.Open())
@@ -319,6 +324,12 @@ private void DrawLoudness(DrawingContext context, Rect strip)
g.EndFigure(true);
}
}
+ if (Waveform)
+ {
+ using (context.PushOpacity(0.85))
+ context.DrawGeometry(AccentBrush ?? Brushes.MediumPurple, null, bars);
+ return;
+ }
using (context.PushOpacity(0.35))
context.DrawGeometry(Brushes.Black, new Pen(Brushes.Black, 1.5), bars);
using (context.PushOpacity(0.85))
diff --git a/src/Filee.App/Controls/DonutMenu.Groups.cs b/src/Filee.App/Controls/DonutMenu.Groups.cs
index c0ec793cd..81cb90f52 100644
--- a/src/Filee.App/Controls/DonutMenu.Groups.cs
+++ b/src/Filee.App/Controls/DonutMenu.Groups.cs
@@ -4,6 +4,9 @@
// Pointer / drag → resting on a group's slice (ExpandDelay), or moving outward on it, opens its ring; the ring
// stays open on its way out to it (the gap, the neighbours' outer half) and folds on another slice,
// the hole, or going round the outside away from it.
+// Held → the donut on screen of Settings → Donut toolbar keeps one group open (HeldGroupId) while it is
+// edited or its ring adjusted: another group opens over it while the pointer is on it, and it comes
+// back after.
// Keyboard → arrows move, Enter opens a group or converts, Esc / Backspace fold a ring first, the flip key
// (Tab unless set otherwise) flips the donut (DonutMenu.Flip.cs).
// See-through → Windows and macOS let the pointer and drags through fully transparent pixels: a nearly
@@ -185,6 +188,7 @@ private void TrackGroups(DonutHit hit, Point? at)
ClearPending();
break;
}
+ OpenHeldGroup();
SetSub(sub);
// Redraw only when something changed: this runs on every pointer move and drag-over event.
if ((_expanded, _pendingGroup) != before)
@@ -229,9 +233,10 @@ private void SetSub(int sub)
///
/// Keyboard (click mode): arrows move around the donut, Enter converts or opens a group; in a group's ring the
- /// arrows move between its slices, Enter converts and Esc / Backspace go back to the donut. The flip key
- /// ( , Tab by default; with Shift or any other modifier too) flips the donut to its other face
- /// ( ). Returns false for keys left to the window (Esc on the donut closes it).
+ /// arrows move between its slices, Enter converts and Esc / Backspace go back to the donut (not from a ring held
+ /// open, : they are left to the window). The flip key ( , Tab by
+ /// default; with Shift or any other modifier too) flips the donut to its other face ( ). Returns
+ /// false for keys left to the window (Esc on the donut closes it).
///
public bool HandleKey(Key key, KeyModifiers modifiers = KeyModifiers.None)
{
@@ -263,8 +268,9 @@ private bool HandleKeyCore(Key key)
_ => 0,
};
- // Esc / Backspace fold an open ring first, however it was opened; only then does Esc close the donut.
- if (ExpandedChildren is not null && key is Key.Escape or Key.Back)
+ // Esc / Backspace fold an open ring first, however it was opened; only then does Esc close the donut. A held
+ // ring doesn't fold: Esc goes on to the window.
+ if (ExpandedChildren is not null && _expanded != HeldIndex() && key is Key.Escape or Key.Back)
{
var group = _expanded;
CollapseGroup();
@@ -337,16 +343,63 @@ public void ExternalDragLeft(Point? lastAt)
SetExternalHighlight(-1);
}
- /// Folds an open group away (the drag left the window, or files were dropped).
+ /// The group fanned out (-1 none).
+ internal int OpenedGroup => _expanded;
+
+ /// Opens group as resting on it would (for tests).
+ internal void OpenGroup(int index)
+ {
+ if (index >= 0 && index < Count && Items![index].CanExpand)
+ Expand(index);
+ }
+
+ /// Folds an open group away (the drag left the window, or files were dropped); a held one stays.
public void CollapseGroup()
{
_expanded = -1;
_ringFromKeys = false;
ClearPending();
+ OpenHeldGroup();
SetSub(-1);
StartAnimation();
}
+ ///
+ /// The group kept open, by its id ( ), or null: the Donut toolbar page's donut on screen
+ /// holds one open while it is edited or its ring adjusted. An id not on the face shown holds nothing (a group of
+ /// the other face), and the editor ( ) never holds one.
+ ///
+ public string? HeldGroupId { get => GetValue(HeldGroupIdProperty); set => SetValue(HeldGroupIdProperty, value); }
+
+ private int HeldIndex()
+ {
+ if (HeldGroupId is not { } id || IsEditMode || Items is not { } items)
+ return -1;
+ for (var i = 0; i < items.Count; i++)
+ if (items[i].Id == id && items[i].CanExpand)
+ return i;
+ return -1;
+ }
+
+ /// With no group open, opens the held one, if any (a group about to open on rest keeps waiting).
+ private void OpenHeldGroup()
+ {
+ if (_expanded >= 0 || HeldIndex() is not (>= 0 and var held))
+ return;
+ if (_shownExpanded != held)
+ _expand = default; // grows out of its own slice, not from another ring half folded
+ _expanded = _shownExpanded = held;
+ StartAnimation();
+ }
+
+ private void OnHeldGroupChanged()
+ {
+ if (HeldIndex() is >= 0 and var held)
+ Expand(held);
+ else
+ CollapseGroup();
+ }
+
///
/// Highlights what a drag is over from outside: a slice, a slice of a group's ring, or the hole.
/// (in this control) lets a group open at once when the drag heads outward on it.
diff --git a/src/Filee.App/Controls/DonutMenu.cs b/src/Filee.App/Controls/DonutMenu.cs
index 3a95641d2..555aada8b 100644
--- a/src/Filee.App/Controls/DonutMenu.cs
+++ b/src/Filee.App/Controls/DonutMenu.cs
@@ -10,6 +10,7 @@
// How each slice's name and icon fit inside it (the safe zone, one size and one style per ring): SliceLabelLayout.cs.
using System.Globalization;
+using System.Runtime.CompilerServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
@@ -44,6 +45,9 @@ public sealed class DonutItem
/// Tooltip shown for disabled slices.
public string? DisabledReason { get; init; }
+ /// A runnable slice's operation differs from its name, shown in the hub, not as an error.
+ public string? OrdinaryConversionNotice { get; init; }
+
///
/// A group slice: converts nothing itself; hovering it fans these out in a small ring outside the donut, where they
/// are clicked or dropped on like ordinary slices.
@@ -89,6 +93,8 @@ public sealed partial class DonutMenu : Control
AvaloniaProperty.Register(nameof(CenterTitle));
public static readonly StyledProperty CenterSubtitleProperty =
AvaloniaProperty.Register(nameof(CenterSubtitle));
+ public static readonly StyledProperty CenterNoticeProperty =
+ AvaloniaProperty.Register(nameof(CenterNotice));
public static readonly StyledProperty IsEditModeProperty =
AvaloniaProperty.Register(nameof(IsEditMode));
public static readonly StyledProperty AllowContextEditProperty =
@@ -105,6 +111,8 @@ public sealed partial class DonutMenu : Control
AvaloniaProperty.Register(nameof(GroupRingSize), 1);
public static readonly StyledProperty GroupRingSpreadProperty =
AvaloniaProperty.Register(nameof(GroupRingSpread), 1);
+ public static readonly StyledProperty HeldGroupIdProperty =
+ AvaloniaProperty.Register(nameof(HeldGroupId));
/// The theme's softness (Styles/Controls.axaml binds SoftRadius): rounds the corners of the slices.
public static readonly StyledProperty CornerRadiusProperty =
@@ -170,12 +178,13 @@ public sealed partial class DonutMenu : Control
static DonutMenu()
{
AffectsMeasure(OuterRadiusProperty, ReserveSubRingProperty, GroupRingSizeProperty);
- AffectsRender(HoleRatioProperty, SliceOpacityProperty, CenterTitleProperty, CenterSubtitleProperty,
+ AffectsRender(HoleRatioProperty, SliceOpacityProperty, CenterTitleProperty, CenterSubtitleProperty, CenterNoticeProperty,
IsEditModeProperty, SliceBrushProperty, SliceBorderBrushProperty, CenterBrushProperty, AccentBrushProperty,
OnAccentBrushProperty, ForegroundProperty, SecondaryForegroundProperty, SuccessBrushProperty, CornerRadiusProperty,
CenterHintProperty, CenterHintWheelProperty, ShowCaptionsProperty, LabelScaleProperty, DefaultShowProperty,
GroupRingSizeProperty, GroupRingSpreadProperty);
ItemsProperty.Changed.AddClassHandler((d, _) => d.OnItemsChanged());
+ HeldGroupIdProperty.Changed.AddClassHandler((d, _) => d.OnHeldGroupChanged());
}
public DonutMenu()
@@ -198,6 +207,7 @@ public DonutMenu()
public double SliceOpacity { get => GetValue(SliceOpacityProperty); set => SetValue(SliceOpacityProperty, value); }
public string? CenterTitle { get => GetValue(CenterTitleProperty); set => SetValue(CenterTitleProperty, value); }
public string? CenterSubtitle { get => GetValue(CenterSubtitleProperty); set => SetValue(CenterSubtitleProperty, value); }
+ public string? CenterNotice { get => GetValue(CenterNoticeProperty); set => SetValue(CenterNoticeProperty, value); }
/// Settings editor mode: shows ✎ on hover, raises drag and edit events instead of invoking slices.
public bool IsEditMode { get => GetValue(IsEditModeProperty); set => SetValue(IsEditModeProperty, value); }
@@ -212,10 +222,10 @@ public DonutMenu()
public bool ReserveSubRing { get => GetValue(ReserveSubRingProperty); set => SetValue(ReserveSubRingProperty, value); }
///
- /// Draw a slice's under its name (Settings → Donut toolbar → Appearance). Off, the
- /// name is centred alone, a group's "Group of N" included: its chevron ( ) still tells
- /// it from a preset. stays, and so does the line of a slice that shares its
- /// name with another on the face: nothing else would tell them apart.
+ /// Draw a slice's under its name (Settings → Donut toolbar → Edit Donut Size). Off,
+ /// the name is centred alone, a group's "Group of N" included: its chevron ( ) still
+ /// tells it from a preset. stays, and so does the line of a slice that shares
+ /// its name with another on the face: nothing else would tell them apart.
///
public bool ShowCaptions { get => GetValue(ShowCaptionsProperty); set => SetValue(ShowCaptionsProperty, value); }
@@ -265,6 +275,12 @@ public DonutMenu()
/// Right-click or ✎ on a slice.
public event EventHandler? SegmentContextRequested;
+ ///
+ /// A group slice was clicked (not its ✎): in the editor ( ), to open it and edit what it
+ /// fans out; on the live donut, after it opened at once.
+ ///
+ public event EventHandler? GroupOpenRequested;
+
/// Right-click on a slice of a group's ring (live toolbar, click mode): (group, slice in the group).
public event EventHandler<(int Group, int Sub)>? SubSegmentContextRequested;
@@ -488,6 +504,7 @@ private void OnItemsChanged()
_expanded = _shownExpanded = _subHover = -1;
ClearPending();
_expand = default;
+ OpenHeldGroup();
// New files (or none): a flip of the old ones is over. A flip's own swap keeps going (SwapFaces).
if (!_swapping)
CancelFlip();
@@ -631,11 +648,15 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e)
case DonutHitKind.EditButton:
SegmentContextRequested?.Invoke(this, hit.Index);
break;
+ case DonutHitKind.Segment when IsEditMode && Items![hit.Index].IsGroup:
+ GroupOpenRequested?.Invoke(this, hit.Index);
+ break;
case DonutHitKind.Segment when !IsEditMode && Items![hit.Index] is { IsEnabled: true, IsGroup: false }:
SegmentInvoked?.Invoke(this, hit.Index);
break;
case DonutHitKind.Segment when !IsEditMode && Items![hit.Index].Children is { Count: > 0 }:
Expand(hit.Index); // no waiting when clicked; a tap has no hover to rest on at all
+ GroupOpenRequested?.Invoke(this, hit.Index);
break;
case DonutHitKind.Outside when !IsEditMode && _expanded >= 0:
CollapseGroup(); // clicked past the ring
@@ -1057,46 +1078,257 @@ private void DrawCenter(DrawingContext context, Point center, double r1, Typefac
var scale = DonutScale;
using var scaled = context.PushTransform(Matrix.CreateTranslation(-center.X, -center.Y) * Matrix.CreateScale(scale, scale) *
Matrix.CreateTranslation(center.X, center.Y));
- var maxWidth = radius * 1.6 / scale;
- var title = CachedText(CenterTitle ?? "", bold, 14, Foreground, maxWidth, maxLines: 2);
- var subtitle = CachedText(CenterSubtitle ?? "", regular, 11.5, cancelHot ? DangerBrush : SecondaryForeground, maxWidth, maxLines: 2);
- // "⟳ Tab back": small, under the rest, a little narrower (the hub is round), with room for its icons.
- var hintIcons = FlipArrowSize + (CenterHintWheel ? MouseWidth + MouseGap : 0) + IconGap;
- var hint = string.IsNullOrEmpty(CenterHint) ? null : CachedText(CenterHint, regular, 10.5, SecondaryForeground, maxWidth * 0.85 - hintIcons);
- var total = title.Height + (string.IsNullOrEmpty(CenterSubtitle) ? 0 : subtitle.Height + 2) + (hint is null ? 0 : hint.Height + 5);
- var y = center.Y - total / 2;
- DrawCentered(context, title, center.X, y);
- y += title.Height;
+ var key = new CenterPlanKey(CenterNotice, CenterTitle, CenterSubtitle, CenterHint, CenterHintWheel,
+ radius, scale, bold, regular, Foreground, cancelHot ? DangerBrush : SecondaryForeground,
+ SecondaryForeground, CultureInfo.CurrentUICulture.Name, TextDirection);
+ if (!_centerPlans.TryGetValue(key, out var plan))
+ {
+ if (_centerPlans.Count >= MaxCachedPlans) _centerPlans.Clear();
+ _centerPlans[key] = plan = PlanCenter(key);
+ }
+ if (string.IsNullOrEmpty(CenterNotice))
+ {
+ // Preserve the original absolute-coordinate draw order, including subpixel icon placement.
+ var y = center.Y + plan.TitleTop;
+ DrawCentered(context, plan.Title!, center.X, y);
+ y += plan.Title!.Height;
+ if (!string.IsNullOrEmpty(CenterSubtitle))
+ {
+ DrawCentered(context, plan.Subtitle, center.X, y + 2);
+ y += plan.Subtitle.Height + 2;
+ }
+ if (plan.Hint is not null)
+ using (context.PushOpacity(0.85))
+ DrawCenterHint(context, plan.Hint, center.X, y + 5);
+ return;
+ }
+ if (plan.Title is not null) DrawCentered(context, plan.Title, center.X, center.Y + plan.TitleTop);
if (!string.IsNullOrEmpty(CenterSubtitle))
+ DrawCentered(context, plan.Subtitle, center.X, center.Y + plan.SubtitleTop);
+ if (plan.Hint is not null)
+ using (context.PushOpacity(0.85))
+ using (context.PushTransform(Matrix.CreateScale(plan.HintScale, plan.HintScale) *
+ Matrix.CreateTranslation(center.X, center.Y + plan.HintTop)))
+ DrawCenterHint(context, plan.Hint, 0, 0);
+ }
+
+ private readonly record struct CenterPlanKey(string? Notice, string? Title, string? Subtitle, string? Hint,
+ bool Wheel, double Radius, double Scale, Typeface Bold, Typeface Regular, IBrush? Foreground,
+ IBrush? ActionBrush, IBrush? HintBrush, string Culture, FlowDirection Direction);
+ private sealed record CenterPlan(FormattedText? Title, FormattedText Subtitle, FormattedText? Hint,
+ double TitleTop, double SubtitleTop, double HintTop, double HintScale);
+ private readonly Dictionary _centerPlans = new();
+
+ private CenterPlan PlanCenter(CenterPlanKey key)
+ {
+ var maxWidth = key.Radius * 1.6 / key.Scale;
+ var notice = !string.IsNullOrEmpty(key.Notice);
+ var title = notice
+ ? CachedText(key.Notice!, key.Regular, 11.5, key.Foreground, maxWidth, int.MaxValue)
+ : CachedText(key.Title ?? "", key.Bold, 14, key.Foreground, maxWidth, 2);
+ // This is deliberately identical to the no-notice action layout. The notice never takes its width.
+ var subtitle = CachedText(key.Subtitle ?? "", key.Regular, 11.5, key.ActionBrush, maxWidth, 2);
+ var hintIcons = FlipArrowSize + (key.Wheel ? MouseWidth + MouseGap : 0) + IconGap;
+ var hint = string.IsNullOrEmpty(key.Hint) ? null
+ : CachedText(key.Hint, key.Regular, 10.5, key.HintBrush, maxWidth * 0.85 - hintIcons);
+ var subtitleGap = 2.0;
+ var hintGap = 5.0;
+ var total = title.Height + (string.IsNullOrEmpty(key.Subtitle) ? 0 : subtitle.Height + subtitleGap)
+ + (hint is null ? 0 : hint.Height + hintGap);
+ var fitScale = 1.0;
+ var complete = true;
+ if (notice)
+ {
+ var width = Math.Max(Math.Max(title.Width, subtitle.Width), hint is null ? 0 : hint.Width + hintIcons);
+ fitScale = Math.Clamp(key.Radius * 1.9 / key.Scale / Math.Sqrt(width * width + total * total), 0.78, 1);
+ var line = CachedText(key.Notice!, key.Regular, 11.5 * fitScale, key.Foreground, maxWidth);
+ var otherHeight = total - title.Height;
+ var maxLines = Math.Max(1, (int)((key.Radius * 1.5 / key.Scale - otherHeight) / line.Height));
+ title = CachedText(key.Notice!, key.Regular, 11.5 * fitScale, key.Foreground, maxWidth, maxLines);
+ complete = title.Height >= CachedText(key.Notice!, key.Regular, 11.5 * fitScale,
+ key.Foreground, maxWidth, int.MaxValue).Height;
+ total = title.Height + otherHeight;
+ }
+ var titleTop = -total / 2;
+ var subtitleTop = titleTop + title.Height + subtitleGap;
+ var hintTop = (string.IsNullOrEmpty(key.Subtitle) ? titleTop + title.Height : subtitleTop + subtitle.Height) + hintGap;
+ // The no-notice path retains the original file count, metric spacing and unscaled hint.
+ // Containment and yielding apply only to the replacement notice.
+ if (!notice) return new(title, subtitle, hint, titleTop, subtitleTop, hintTop, 1);
+ var hintBounds = hint is null ? null : CenterHintBounds(hint);
+ var originalHintBounds = hintBounds;
+ var hintScale = 1.0;
+ if (complete && Place(keepPosition: true)) return Result();
+
+ // Search the circle's full diameter using actual ink. Let the secondary hint yield before
+ // truncating a complete notice; a platform's wider glyphs must not decide that priority.
+ for (var lines = int.MaxValue; lines >= 1; lines = lines == int.MaxValue ? 3 : lines - 1)
+ for (var hintStep = 10; hintStep >= (hint is not null && lines == int.MaxValue ? 5 : 10); hintStep--)
+ {
+ hintScale = hintStep / 10.0;
+ hintBounds = originalHintBounds?.Select(b =>
+ b.TransformToAABB(Matrix.CreateScale(hintScale, hintScale))).ToList();
+ for (var smaller = 0; smaller < 2; smaller++)
+ for (var step = 10; step >= 1; step--)
+ {
+ title = NoticeText(11.5 * (smaller == 0 ? fitScale : 0.78),
+ key.Radius * 2 / key.Scale * step / 10, lines);
+ Stack();
+ if (Place()) return Result();
+ }
+ }
+ title = null;
+ hintScale = 1;
+ hintBounds = originalHintBounds;
+ Stack();
+ CenterPlan? withoutNotice = null;
+ if (Place())
{
- DrawCentered(context, subtitle, center.X, y + 2);
- y += subtitle.Height + 2;
+ withoutNotice = Result();
+ if (hint is null) return withoutNotice;
}
+ if (hint is not null && withoutNotice is null)
+ for (var step = 9; step >= 1; step--)
+ {
+ hint = CachedText(key.Hint!, key.Regular, 10.5, key.HintBrush,
+ Math.Max(10, (maxWidth * 0.85 - hintIcons) * step / 10));
+ hintBounds = CenterHintBounds(hint);
+ Stack();
+ if (Place())
+ {
+ withoutNotice = Result();
+ break;
+ }
+ }
+ // A minimum hub can lack room even after the notice yields. Fit the secondary hint as a unit,
+ // including its strokes, rather than taking any glyphs or font size from the action.
if (hint is not null)
{
- using (context.PushOpacity(0.85))
+ var unscaled = CenterHintBounds(hint);
+ for (var step = 9; step >= 5; step--)
+ {
+ hintScale = step / 10.0;
+ hintBounds = unscaled.Select(b => b.TransformToAABB(Matrix.CreateScale(hintScale, hintScale))).ToList();
+ Stack();
+ if (Place())
+ {
+ for (var width = 10; width >= 1; width--)
+ {
+ title = NoticeText(11.5 * 0.78, maxWidth * width / 10, 1);
+ Stack();
+ if (Place()) return Result();
+ }
+ title = null;
+ Stack();
+ if (Place()) withoutNotice = Result();
+ }
+ }
+ }
+ // If even the unchanged guidance cannot fit, never return the last rejected candidate.
+ // Keep the original baseline positions and omit only the replacement notice.
+ return withoutNotice ?? PlanCenter(key with { Notice = null }) with { Title = null };
+
+ CenterPlan Result() => new(title, subtitle, hint, titleTop, subtitleTop, hintTop, hintScale);
+
+ FormattedText NoticeText(double size, double width, int lines) =>
+ CachedText(key.Notice!, key.Regular, size, key.Foreground,
+ Math.Max(Math.Ceiling(CachedText("…", key.Regular, size, key.Foreground, maxWidth).Width), width), lines);
+
+ void Stack()
+ {
+ // Pack actual ink, not the empty leading/trailing space of the font's metric boxes.
+ var bottom = 0.0;
+ titleTop = 0;
+ if (title is not null)
+ {
+ var bounds = TextBounds(title);
+ if (bounds.Count > 0)
+ {
+ titleTop = -bounds.Min(b => b.Top);
+ bottom = titleTop + bounds.Max(b => b.Bottom) + (string.IsNullOrEmpty(key.Subtitle) ? 0.5 : subtitleGap);
+ }
+ }
+ if (!string.IsNullOrEmpty(key.Subtitle))
{
- // The flip arrow is drawn, not typed: a font without "↻" showed a box in its place (#554). A small mouse
- // comes first when the wheel flips and no key does. The icons stand at the start of the line: on its
- // left, or on its right in a right-to-left language, in reading order there too.
- var left = center.X - (hintIcons + hint.Width) / 2;
- var rtl = TextDirection == FlowDirection.RightToLeft;
- var textLeft = rtl ? left : left + hintIcons;
- var middle = y + 5 + hint.Height / 2;
- var x = rtl ? left + hint.Width + IconGap : left;
- var arrowFirst = rtl && CenterHintWheel;
- if (CenterHintWheel && !arrowFirst)
+ subtitleTop = bottom - TextBounds(subtitle).Min(b => b.Top);
+ bottom = subtitleTop + TextBounds(subtitle).Max(b => b.Bottom) + 0.5;
+ }
+ hintTop = hintBounds is null ? bottom : bottom - hintBounds.Min(b => b.Top);
+ }
+
+ bool Place(bool keepPosition = false)
+ {
+ if (title is not null && !string.IsNullOrEmpty(key.Subtitle)
+ && subtitleTop + TextBounds(subtitle).Min(b => b.Top)
+ - titleTop - TextBounds(title).Max(b => b.Bottom) < subtitleGap - 1e-6)
+ return false;
+ var r = key.Radius / key.Scale;
+ var lower = double.NegativeInfinity;
+ var upper = double.PositiveInfinity;
+ bool Include(IEnumerable bounds, double y)
+ {
+ foreach (var b in bounds)
{
- DrawMouse(context, new Rect(x, middle - 5, MouseWidth, 10), SecondaryForeground);
- x += MouseWidth + MouseGap;
+ var edge = Math.Max(Math.Abs(b.Left), Math.Abs(b.Right));
+ if (edge >= r) return false;
+ var halfChord = Math.Sqrt(r * r - edge * edge);
+ lower = Math.Max(lower, -halfChord - b.Top - y);
+ upper = Math.Min(upper, halfChord - b.Bottom - y);
}
- DrawFlipArrow(context, new Point(x + FlipArrowSize / 2, middle), FlipArrowSize / 2, SecondaryForeground);
- x += FlipArrowSize + (arrowFirst ? MouseGap : IconGap);
- if (arrowFirst)
- DrawMouse(context, new Rect(x, middle - 5, MouseWidth, 10), SecondaryForeground);
- DrawCentered(context, hint, textLeft + hint.Width / 2, y + 5);
+ return lower <= upper;
+ }
+ if (title is not null && !Include(TextBounds(title), titleTop)) return false;
+ if (!string.IsNullOrEmpty(key.Subtitle) && !Include(TextBounds(subtitle), subtitleTop)) return false;
+ if (hintBounds is not null && !Include(hintBounds, hintTop)) return false;
+ if (keepPosition) return lower <= 0 && upper >= 0;
+ var move = (lower + upper) / 2;
+ titleTop += move;
+ subtitleTop += move;
+ hintTop += move;
+ return true;
+ }
+ }
+
+ private List CenterHintBounds(FormattedText hint)
+ {
+ var drawing = new DrawingGroup();
+ using (var context = drawing.Open()) DrawCenterHint(context, hint, 0, 0);
+ var bounds = new List();
+ void Walk(Drawing item, Matrix transform)
+ {
+ if (item is DrawingGroup group)
+ {
+ var within = (group.Transform?.Value ?? Matrix.Identity) * transform;
+ foreach (var child in group.Children) Walk(child, within);
}
+ else if (item is GlyphRunDrawing { GlyphRun: { } glyph })
+ bounds.Add(glyph.InkBounds.TransformToAABB(transform));
+ else if (item is GeometryDrawing { Geometry: { } geometry } shape)
+ bounds.Add((shape.Pen is { } pen ? geometry.GetRenderBounds(pen) : geometry.Bounds).TransformToAABB(transform));
+ }
+ Walk(drawing, Matrix.Identity);
+ return bounds;
+ }
+
+ private void DrawCenterHint(DrawingContext context, FormattedText hint, double centerX, double top)
+ {
+ var hintIcons = FlipArrowSize + (CenterHintWheel ? MouseWidth + MouseGap : 0) + IconGap;
+ var left = centerX - (hintIcons + hint.Width) / 2;
+ var rtl = TextDirection == FlowDirection.RightToLeft;
+ var textLeft = rtl ? left : left + hintIcons;
+ var middle = top + hint.Height / 2;
+ var x = rtl ? left + hint.Width + IconGap : left;
+ var arrowFirst = rtl && CenterHintWheel;
+ if (CenterHintWheel && !arrowFirst)
+ {
+ DrawMouse(context, new Rect(x, middle - 5, MouseWidth, 10), SecondaryForeground);
+ x += MouseWidth + MouseGap;
}
+ DrawFlipArrow(context, new Point(x + FlipArrowSize / 2, middle), FlipArrowSize / 2, SecondaryForeground);
+ x += FlipArrowSize + (arrowFirst ? MouseGap : IconGap);
+ if (arrowFirst)
+ DrawMouse(context, new Rect(x, middle - 5, MouseWidth, 10), SecondaryForeground);
+ DrawCentered(context, hint, textLeft + hint.Width / 2, top);
}
// The hub hint's icons: the flip arrow, the mouse (wheel only) and the room between them and before the text. The
@@ -1409,6 +1641,28 @@ internal static void DrawCentered(DrawingContext context, FormattedText text, do
// Text shaping is much more expensive than the slice transforms. Keep layouts across frames,
// with all layout/paint inputs in the key so language, theme and hover changes remain correct.
private readonly Dictionary<(string Text, Typeface Typeface, double Size, IBrush? Brush, double Width, int Lines, string Culture), FormattedText> _textLayouts = new();
+ private readonly ConditionalWeakTable> _textBounds = new();
+
+ private List TextBounds(FormattedText text) => _textBounds.GetValue(text, layout =>
+ {
+ var drawing = new DrawingGroup();
+ using (var context = drawing.Open())
+ DrawCentered(context, layout, 0, 0);
+ var bounds = new List();
+ void Walk(Drawing item, Matrix transform)
+ {
+ if (item is DrawingGroup group)
+ {
+ var within = (group.Transform?.Value ?? Matrix.Identity) * transform;
+ foreach (var child in group.Children)
+ Walk(child, within);
+ }
+ else if (item is GlyphRunDrawing { GlyphRun: { } glyph })
+ bounds.Add(glyph.InkBounds.TransformToAABB(transform));
+ }
+ Walk(drawing, Matrix.Identity);
+ return bounds;
+ });
private FormattedText CachedText(string text, Typeface typeface, double size, IBrush? brush, double maxWidth, int maxLines = 1)
{
@@ -1421,9 +1675,9 @@ private FormattedText CachedText(string text, Typeface typeface, double size, IB
private static FormattedText MakeText(string text, Typeface typeface, double size, IBrush? brush, double maxWidth, int maxLines = 1) =>
new(text, CultureInfo.CurrentUICulture, TextDirection, typeface, size, brush)
{
- MaxTextWidth = Math.Max(10, maxWidth),
+ MaxTextWidth = Math.Max(1, maxWidth),
MaxLineCount = maxLines,
- Trimming = TextTrimming.CharacterEllipsis,
+ Trimming = maxLines == int.MaxValue ? TextTrimming.None : TextTrimming.CharacterEllipsis,
TextAlignment = TextAlignment.Center,
};
diff --git a/src/Filee.App/Services/AppLanguages.cs b/src/Filee.App/Services/AppLanguages.cs
index 36577eec1..355f0eac8 100644
--- a/src/Filee.App/Services/AppLanguages.cs
+++ b/src/Filee.App/Services/AppLanguages.cs
@@ -30,6 +30,7 @@ internal static class AppLanguages
("id", "Bahasa Indonesia"),
("vi", "Tiếng Việt"),
("bn-BD", "বাংলা (বাংলাদেশ)"),
+ ("mn", "Монгол"),
];
/// Shared Portuguese region policy for the app and Setup, including legacy Inno language codes.
diff --git a/src/Filee.App/Services/DonutLookSave.cs b/src/Filee.App/Services/DonutLookSave.cs
new file mode 100644
index 000000000..478483b4f
--- /dev/null
+++ b/src/Filee.App/Services/DonutLookSave.cs
@@ -0,0 +1,47 @@
+// The donut's look as a slider moves (Settings → Donut toolbar → Edit Donut Size, Theme → donut opacity): the settings
+// in memory change at once, and the donut reads them each time it opens; settings.json is written once the slider
+// rests, as the window zoom is (UiZoom). Quietly: nothing that SettingsChanged applies again depends on the donut's
+// look.
+
+using Avalonia.Threading;
+using Filee.Core.Settings;
+
+namespace Filee.App.Services;
+
+internal static class DonutLookSave
+{
+ /// How long a slider rests before its value is written.
+ internal static readonly TimeSpan Delay = TimeSpan.FromMilliseconds(600);
+
+ private static DispatcherTimer? _timer;
+ private static UserDataStore? _store;
+
+ ///
+ /// Writes 's settings once no further change comes for .
+ ///
+ public static void Schedule(UserDataStore store)
+ {
+ if (_store is { } other && !ReferenceEquals(other, store))
+ Flush(); // another store's change still waiting (tests make several)
+ _store = store;
+ if (_timer is null)
+ {
+ _timer = new DispatcherTimer { Interval = Delay };
+ _timer.Tick += (_, _) => Flush();
+ }
+ _timer.Stop();
+ _timer.Start();
+ }
+
+ /// Writes a change still waiting for its moment (the page is left, Filee exits).
+ public static void Flush()
+ {
+ if (_timer is not { IsEnabled: true } timer)
+ return;
+ timer.Stop();
+ _store?.SaveDonutLook();
+ }
+
+ /// A change is waiting to be written.
+ internal static bool IsPending => _timer is { IsEnabled: true };
+}
diff --git a/src/Filee.App/Services/EngineDownloadService.cs b/src/Filee.App/Services/EngineDownloadService.cs
index 1d3c45f6b..0ffb2368c 100644
--- a/src/Filee.App/Services/EngineDownloadService.cs
+++ b/src/Filee.App/Services/EngineDownloadService.cs
@@ -51,13 +51,14 @@ public EnginePackageState(EnginePackage package, ILocalizer loc)
///
/// How to get an engine Filee doesn't download on this system (FFmpeg and Ghostscript from Homebrew on macOS,
- /// the distribution's packages on Linux), in the app's language; null when Filee can download it.
+ /// the distribution's packages on Linux), in the app's language; null when Filee can download it or the engine is
+ /// there already.
///
public string? InstallGuidance
{
get
{
- if (EngineDownloads.CanDownload(Package))
+ if (EngineDownloads.CanDownload(Package) || IsInstalled)
return null;
var system = OperatingSystem.IsMacOS() ? "macos" : OperatingSystem.IsLinux() ? "linux" : "windows";
var key = $"engines.guidance.{system}.{Package.Id}";
@@ -68,7 +69,9 @@ public string? InstallGuidance
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StatusText), nameof(IsInstalled), nameof(IsBusy), nameof(CanInstall), nameof(CanRemove),
- nameof(UsesOwnCopy), nameof(CanUseOwnCopy), nameof(ShowsCopyFolder), nameof(CanUpdate), nameof(HasOfflineMenu), nameof(CanInstallFromFiles))]
+ nameof(UsesOwnCopy), nameof(CanUseOwnCopy), nameof(CanUpdate), nameof(HasOfflineMenu), nameof(CanInstallFromFiles),
+ nameof(SourceText), nameof(UpdateText), nameof(InstallGuidance), nameof(HasOtherCopy), nameof(OtherCopyText), nameof(OffersInstall), nameof(InstallsOverCopy),
+ nameof(StatusIsOk), nameof(StatusIsDanger), nameof(StatusIsWarn))]
private EnginePackageStatus _status;
///
@@ -76,7 +79,8 @@ public string? InstallGuidance
/// update): offers the new one.
///
[ObservableProperty]
- [NotifyPropertyChangedFor(nameof(StatusText), nameof(CanUpdate), nameof(CanInstallFromFiles))]
+ [NotifyPropertyChangedFor(nameof(CanUpdate), nameof(CanInstallFromFiles), nameof(SourceText), nameof(UpdateText),
+ nameof(StatusIsOk), nameof(StatusIsWarn))]
private bool _isOutdated;
/// The version Filee downloads now, e.g. "8.0" for FFmpeg (the package's main component).
@@ -87,17 +91,66 @@ public string? InstallGuidance
/// Folder of the copy in use while the status is .
[ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(SourceText))]
private string? _ownCopyFolder;
/// The copy in use was found on the PC (PATH, Program Files), not chosen by the user.
[ObservableProperty]
- [NotifyPropertyChangedFor(nameof(StatusText), nameof(CanInstall), nameof(UsesOwnCopy), nameof(CanUseOwnCopy), nameof(CanInstallFromFiles))]
+ [NotifyPropertyChangedFor(nameof(CanInstall), nameof(UsesOwnCopy), nameof(CanUseOwnCopy), nameof(CanInstallFromFiles), nameof(SourceText),
+ nameof(OffersInstall), nameof(InstallsOverCopy))]
private bool _fromSystem;
+ ///
+ /// The version of the copy in use when Filee didn't download it ("9.0"), once its program has told it.
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(SourceText), nameof(OffersInstall), nameof(InstallsOverCopy))]
+ private string? _copyVersion;
+
+ ///
+ /// The version check of the copy in use has come back (with , or without when it can't be
+ /// told): until then nothing is offered over it, so the card doesn't show Install for a moment.
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(OffersInstall), nameof(InstallsOverCopy))]
+ private bool _copyVersionChecked;
+
+ ///
+ /// The copy found on the PC is older than the version Filee is tested with: Filee's download is offered.
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(OffersInstall), nameof(InstallsOverCopy), nameof(StatusIsOk), nameof(StatusIsWarn))]
+ private bool _isOlderCopy;
+
/// "Update recommended: 7.1 here, Filee is tested with 9.0" for an older copy, else null.
[ObservableProperty]
private string? _updateNote;
+ ///
+ /// A copy on the PC that isn't used because Filee's download is installed (it comes first): the card offers to use
+ /// it instead, which removes Filee's download ( ).
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasOtherCopy), nameof(OtherCopyText))]
+ private string? _otherCopyFolder;
+
+ /// The version of 's copy, once its program has told it.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(OtherCopyText))]
+ private string? _otherCopyVersion;
+
+ ///
+ /// 's program started and answered ( ).
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasOtherCopy), nameof(OtherCopyText))]
+ private bool _otherCopyRuns;
+
+ ///
+ /// Counts the reads of the card: a version check started before the latest read doesn't land on it.
+ ///
+ internal int VersionCheck { get; set; }
+
/// 0..100 while downloading or unpacking.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StatusText))]
@@ -120,7 +173,6 @@ public string? InstallGuidance
public bool CanRemove => Status == EnginePackageStatus.Installed;
/// The user chose this copy ("Stop using it" applies).
public bool UsesOwnCopy => Status == EnginePackageStatus.OwnCopy && !FromSystem;
- public bool ShowsCopyFolder => Status == EnginePackageStatus.OwnCopy;
public bool CanUseOwnCopy => Status is EnginePackageStatus.NotInstalled or EnginePackageStatus.Failed or EnginePackageStatus.Installed
|| (Status == EnginePackageStatus.OwnCopy && FromSystem);
public bool IsBusy => Status is EnginePackageStatus.Queued or EnginePackageStatus.Downloading or EnginePackageStatus.Copying
@@ -129,6 +181,27 @@ public string? InstallGuidance
public bool CanInstall => EngineDownloads.CanDownload(Package) && (Status is EnginePackageStatus.NotInstalled or EnginePackageStatus.Failed
|| (Status == EnginePackageStatus.OwnCopy && FromSystem));
+ ///
+ /// The Engines page offers Filee's download: when the engine is missing, or over a copy on the PC older than the
+ /// tested version or whose version can't be told (it may not start at all: a library missing, the wrong
+ /// architecture; an FFmpeg nightly named by date is offered it too). A copy on the PC that is up to date is simply
+ /// used; the first-run setup and the installer's choices still go by .
+ ///
+ public bool OffersInstall => CanInstall && (Status != EnginePackageStatus.OwnCopy || IsOlderCopy ||
+ (CopyVersionChecked && CopyVersion is null));
+
+ /// The offered download would take the place of the copy on the PC ("Install Filee's copy").
+ public bool InstallsOverCopy => OffersInstall && Status == EnginePackageStatus.OwnCopy;
+
+ ///
+ /// Filee's download is used and a copy on the PC could be used instead ( ): its program
+ /// starts ( ), and Filee's download is that program alone. A download with more parts
+ /// (Windows: LibreOffice's H2Orestart and Java, Whisper's model, a C++ runtime) would take parts with it that the
+ /// copy on the PC doesn't have.
+ ///
+ public bool HasOtherCopy => Status == EnginePackageStatus.Installed && OtherCopyFolder is not null &&
+ OtherCopyRuns && Package.Components.Count == 1;
+
///
/// The card's "Offline" menu (#447): install from files downloaded elsewhere, save the files for a computer without
/// internet, copy the download links. Only for packages Filee downloads here, and not while one is busy.
@@ -138,18 +211,63 @@ public string? InstallGuidance
/// The files can be installed where Install or Update would download them.
public bool CanInstallFromFiles => CanInstall || CanUpdate;
+ // Colour of the status tag on the Engines page (Border.status in Soft.axaml); none of them = neutral (queued,
+ // downloading...). Every engine that works is the same Ready; amber when its card recommends an update (Filee's
+ // download has a new version, or the copy in use is older than the one Filee is tested with).
+ public bool StatusIsOk => IsInstalled && !StatusIsWarn;
+ public bool StatusIsWarn => (Status == EnginePackageStatus.Installed && IsOutdated) || (Status == EnginePackageStatus.OwnCopy && IsOlderCopy);
+ public bool StatusIsDanger => Status == EnginePackageStatus.Failed;
+
+ ///
+ /// "Ready" for any engine that works, wherever it comes from ( says where).
+ ///
public string StatusText => Status switch
{
EnginePackageStatus.Queued => _loc["engines.state.queued"],
EnginePackageStatus.Downloading => _loc.Format("engines.state.downloading", (int)Progress),
EnginePackageStatus.Copying => _loc.Format("engines.state.copying", (int)Progress),
EnginePackageStatus.Unpacking => _loc["engines.state.unpacking"],
- EnginePackageStatus.Installed => IsOutdated ? _loc.Format("engines.state.update_available", Version ?? "") : _loc["engines.state.installed"],
- EnginePackageStatus.OwnCopy => _loc[FromSystem ? "engines.state.system_copy" : "engines.state.own_copy"],
+ EnginePackageStatus.Installed or EnginePackageStatus.OwnCopy => _loc["engines.available"],
EnginePackageStatus.Failed => _loc.Format("engines.state.failed", Error ?? ""),
_ => _loc["engines.state.not_installed"],
};
+ ///
+ /// Where the engine in use comes from: "Filee's download · version 3.12 · 236 MB on disk", "On this computer ·
+ /// /opt/homebrew/bin · version 8.0", "Your copy · …"; null while it isn't there.
+ ///
+ public string? SourceText => Status switch
+ {
+ EnginePackageStatus.Installed => Joined(_loc["engines.source.download"],
+ IsOutdated || Version is null ? null : _loc.Format("engines.source.version", Version),
+ _loc.Format("engines.source.on_disk", FormatBytes(Package.InstalledSize))),
+ EnginePackageStatus.OwnCopy when OwnCopyFolder is not null => Joined(
+ _loc[FromSystem ? "engines.source.system" : "engines.state.own_copy"], DisplayFolder(OwnCopyFolder),
+ CopyVersion is null ? null : _loc.Format("engines.source.version", CopyVersion)),
+ _ => null,
+ };
+
+ /// "New version 9.0" for an outdated download, else null.
+ public string? UpdateText => CanUpdate ? _loc.Format("engines.state.update_available", Version ?? "") : null;
+
+ ///
+ /// "/usr/local/bin · version 3.13 (Filee's download would be removed)" for the unused copy on the PC.
+ ///
+ public string? OtherCopyText => HasOtherCopy
+ ? _loc.Format("engines.other_copy_detail", Joined(DisplayFolder(OtherCopyFolder!),
+ OtherCopyVersion is null ? null : _loc.Format("engines.source.version", OtherCopyVersion)))
+ : null;
+
+ private static string Joined(params string?[] parts) => string.Join(" · ", parts.Where(p => !string.IsNullOrEmpty(p)));
+
+ /// A macOS app's program folder as the app itself: "/Applications/calibre.app".
+ internal static string DisplayFolder(string folder)
+ {
+ var trimmed = folder.TrimEnd('/', '\\');
+ const string inside = "/Contents/MacOS";
+ return trimmed.EndsWith(".app" + inside, StringComparison.OrdinalIgnoreCase) ? trimmed[..^inside.Length] : folder;
+ }
+
internal CancellationTokenSource? Cancellation { get; set; }
/// Re-reads localized texts after a language change.
@@ -202,15 +320,36 @@ public sealed class EngineDownloadService
/// Saves one package's downloads in a folder ( ); tests swap it.
internal Func, CancellationToken, Task> SavePackageFiles { get; set; }
+ ///
+ /// The version of a copy's program ( : package id, folder); tests swap it for
+ /// programs that can't run on every system.
+ ///
+ internal Func ReadVersion { get; set; }
+
+ /// A copy's program starts ( : package id, folder); tests swap it.
+ internal Func CopyRuns { get; set; }
+
public EngineDownloadService(ConverterCatalog catalog, ILocalizer loc, ILogger logger)
+ : this(catalog, loc, logger, new EngineInstaller(EngineInstaller.CreateHttpClient(Timeout.InfiniteTimeSpan), logger: logger))
+ {
+ }
+
+ ///
+ /// With its own installer, and optionally its own and : tests
+ /// install into and remove from a folder of their own.
+ ///
+ internal EngineDownloadService(ConverterCatalog catalog, ILocalizer loc, ILogger logger, EngineInstaller installer,
+ Func? readVersion = null, Func? copyRuns = null)
{
_catalog = catalog;
_logger = logger;
_loc = loc;
- _installer = new EngineInstaller(EngineInstaller.CreateHttpClient(Timeout.InfiniteTimeSpan), logger: logger);
+ _installer = installer;
InstallPackage = _installer.InstallAsync;
InstallPackageFromFiles = _installer.InstallFromFilesAsync;
SavePackageFiles = _installer.SaveFilesAsync;
+ ReadVersion = readVersion ?? CopyVersions.Installed;
+ CopyRuns = copyRuns ?? CopyVersions.Runs;
Packages = EngineDownloads.Packages.Select(p => new EnginePackageState(p, loc)).ToList();
if (loc is LocalizationService localization)
localization.LanguageChanged += (_, _) => Packages.ToList().ForEach(p => p.Relocalize());
@@ -236,18 +375,37 @@ public void RefreshStatus()
}
///
- /// The copy the engines use ( ). A copy Filee didn't download gets its
- /// version checked in the background ( ).
+ /// The copy the engines use ( ), and a copy on the PC that Filee's download
+ /// hides. A copy Filee didn't download gets its version read in the background
+ /// ( , and when it is older
+ /// than the tested one).
///
private EnginePackageStatus StatusOf(EnginePackageState state)
{
var copy = _installer.StatusOf(state.Package);
+ // The versions read for the same folders stay until they are read again, so the card doesn't blink on every
+ // refresh.
+ if (state.OwnCopyFolder != copy.Folder)
+ {
+ state.CopyVersion = null;
+ state.CopyVersionChecked = false;
+ state.IsOlderCopy = false;
+ state.UpdateNote = null;
+ }
+ if (state.OtherCopyFolder != copy.OtherFolder)
+ {
+ state.OtherCopyVersion = null;
+ state.OtherCopyRuns = false;
+ }
state.OwnCopyFolder = copy.Folder;
state.FromSystem = copy.FromSystem;
- state.UpdateNote = null;
+ state.OtherCopyFolder = copy.OtherFolder;
state.IsOutdated = copy.Outdated;
+ var check = ++state.VersionCheck;
if (copy.Folder is { } folder)
- _ = CheckVersionAsync(state, folder);
+ _ = CheckVersionAsync(state, check, folder, other: false);
+ if (copy.OtherFolder is { } otherFolder)
+ _ = CheckVersionAsync(state, check, otherFolder, other: true);
return copy.Status switch
{
EngineCopyStatus.OwnCopy => EnginePackageStatus.OwnCopy,
@@ -256,15 +414,53 @@ private EnginePackageStatus StatusOf(EnginePackageState state)
};
}
- /// Runs the copy's version check off the UI thread (it starts the program once) and notes an old one.
- private async Task CheckVersionAsync(EnginePackageState state, string folder)
+ ///
+ /// Runs a copy's version check off the UI thread (it starts the program once) and shows the version; for the copy
+ /// in use, also notes when it is older than the version Filee is tested with.
+ ///
+ private async Task CheckVersionAsync(EnginePackageState state, int check, string folder, bool other)
{
var id = state.Package.Id;
- var (installed, expected) = await Task.Run(() => (CopyVersions.Installed(id, folder), CopyVersions.Expected(id)));
- if (installed is null || expected is null || installed >= expected || state.OwnCopyFolder != folder)
- return;
+ var (read, runs) = (ReadVersion, CopyRuns);
+ // A hidden copy is started first (not cached: it may have stopped working); its version comes from that same
+ // start, and isn't asked for when it doesn't start (one that hangs costs one time-out, not two).
+ var (installed, started, expected) = await Task.Run(() =>
+ {
+ try
+ {
+ var starts = !other || runs(id, folder);
+ return (starts ? read(id, folder) : null, other && starts, CopyVersions.Expected(id));
+ }
+ catch (Exception ex)
+ {
+ // Something unforeseen (the folder unreadable, its file time out of reach): the version is unknown, the
+ // hidden copy isn't offered, and the card is still marked checked.
+ _logger.LogWarning(ex, "Reading the version of {Package} in {Folder} failed", id, folder);
+ return ((Version?)null, false, (Version?)null);
+ }
+ });
await Dispatcher.UIThread.InvokeAsync(() =>
- state.UpdateNote = _loc.Format("engines.update_recommended", installed.ToString(2), expected.ToString(2)));
+ {
+ // The card was read again since (Check again, an install): that read's own check lands instead.
+ if (state.VersionCheck != check)
+ return;
+ if (other)
+ {
+ if (state.OtherCopyFolder != folder)
+ return;
+ state.OtherCopyVersion = installed?.ToString(2);
+ state.OtherCopyRuns = started;
+ return;
+ }
+ if (state.OwnCopyFolder != folder)
+ return;
+ state.CopyVersion = installed?.ToString(2);
+ state.CopyVersionChecked = true;
+ state.IsOlderCopy = installed is not null && expected is not null && installed < expected;
+ state.UpdateNote = state.IsOlderCopy
+ ? _loc.Format("engines.update_recommended", installed!.ToString(2), expected!.ToString(2))
+ : null;
+ });
}
/// Filee's downloads that have a newer version now (only engines the user installed).
@@ -283,7 +479,9 @@ public async Task InstallAsync(EnginePackageState state, IReadOnlyList?
await RunQueuedAsync(state, (progress, token) => files is null
? InstallPackage(state.Package, progress, token)
: InstallPackageFromFiles(state.Package, files, progress, token));
- state.Status = EnginePackageStatus.Installed;
+ // Read from disk, not set: the copy that was in use and its notes give way, a copy on the PC now hidden is
+ // offered instead, and an update is no longer offered (#867).
+ state.Status = StatusOf(state);
_catalog.Refresh();
if (state.Package.ConverterIds.Contains("libreoffice"))
WarmUpInBackground(TimeSpan.Zero);
@@ -427,6 +625,59 @@ public void WarmUpInBackground(TimeSpan delay)
});
}
+ ///
+ /// Uses the copy on the PC in (the one the user confirmed) instead of Filee's download,
+ /// which is removed (#867). The copy is started again first (the search is cached until Check again, and the copy
+ /// may have been uninstalled or stopped working since): when it is gone, no longer starts, or is no longer the one
+ /// on the card, Filee's download is kept, the cards and converters are read again and the key of the reason is
+ /// returned.
+ ///
+ public async Task UseOtherCopyAsync(EnginePackageState state, string folder)
+ {
+ // A second click while the copy is being started again waits for nothing: the first one does the switch.
+ if (!_switching.Add(state))
+ return null;
+ try
+ {
+ return await SwitchAsync(state, folder);
+ }
+ finally
+ {
+ _switching.Remove(state);
+ }
+ }
+
+ private readonly HashSet _switching = [];
+
+ private async Task SwitchAsync(EnginePackageState state, string folder)
+ {
+ var id = state.Package.Id;
+ var runs = CopyRuns;
+ bool works;
+ try
+ {
+ works = state.HasOtherCopy && state.OtherCopyFolder == folder &&
+ await Task.Run(() => EngineEnvironment.FolderWithPrograms(id, folder) is not null && runs(id, folder));
+ }
+ catch (Exception ex)
+ {
+ // As in CheckVersionAsync: whatever went wrong, the copy isn't taken as working and the download is kept.
+ _logger.LogWarning(ex, "Checking the copy of {Package} in {Folder} failed", id, folder);
+ works = false;
+ }
+ // Filee's download was removed meanwhile (Remove on the same card): there is nothing left to switch from.
+ if (state.Status != EnginePackageStatus.Installed)
+ return null;
+ if (!works || state.OtherCopyFolder != folder)
+ {
+ EngineEnvironment.ForgetSystemCopies();
+ RefreshStatus();
+ _catalog.Refresh();
+ return "engines.other_copy_gone";
+ }
+ return Uninstall(state);
+ }
+
///
/// Removes a package. Returns null when it's gone, else the key of the reason: its files are in use (a conversion
/// is running), or another Filee is installing or removing it (#963).
@@ -438,7 +689,10 @@ public void WarmUpInBackground(TimeSpan delay)
try
{
_installer.Uninstall(state.Package);
- state.Status = EnginePackageStatus.NotInstalled;
+ // A copy on the PC takes over when there is one. Searched again: the one found while the download was there
+ // may have been the download itself, through a link that now leads nowhere.
+ EngineEnvironment.ForgetSystemCopy(state.Package.Id);
+ state.Status = StatusOf(state);
_catalog.Refresh();
return null;
}
diff --git a/src/Filee.App/Services/LocalizationService.cs b/src/Filee.App/Services/LocalizationService.cs
index be8ea0910..6c2a089e3 100644
--- a/src/Filee.App/Services/LocalizationService.cs
+++ b/src/Filee.App/Services/LocalizationService.cs
@@ -108,6 +108,11 @@ internal static string Resolve(string setting, Func osLanguage)
return Languages.First(l => l.Code.Equals(setting, StringComparison.OrdinalIgnoreCase)).Code;
var ui = osLanguage();
+ // Mongolian in Cyrillic ("mn", "mn-MN", "mn-Cyrl"); the traditional script ("mn-Mong", Inner Mongolia) reads
+ // differently, so it keeps the fallback.
+ if (ui.Equals("mn", StringComparison.OrdinalIgnoreCase)
+ || ui.StartsWith("mn-", StringComparison.OrdinalIgnoreCase) && !ui.Contains("Mong", StringComparison.OrdinalIgnoreCase))
+ return "mn";
if (ui.Equals("de", StringComparison.OrdinalIgnoreCase) || ui.StartsWith("de-", StringComparison.OrdinalIgnoreCase))
return "de";
// "it" and every region ("it-IT", "it-CH", …), but not Itelmen "itl".
diff --git a/src/Filee.App/Services/PresetAvailability.cs b/src/Filee.App/Services/PresetAvailability.cs
index 18734c0f0..f0c6f2195 100644
--- a/src/Filee.App/Services/PresetAvailability.cs
+++ b/src/Filee.App/Services/PresetAvailability.cs
@@ -9,6 +9,9 @@ namespace Filee.App.Services;
public enum PresetUse { Usable, NeedsPackage, Unusable }
+/// Whether the agreed extraction presets convert usable inputs without their named operation.
+public enum OrdinaryConversion { None, All, Some }
+
/// Why a preset can or cannot run; usable presets have no reason.
public readonly record struct PresetJudgement(PresetUse Use, string? Reason);
@@ -63,6 +66,24 @@ public IReadOnlyList UsableFormats(Preset preset, IReadOnlyListCounts only runnable inputs; an envelope's operation is unknown until its file is opened.
+ public OrdinaryConversion OrdinaryConversionFor(Preset preset, IReadOnlyList formats)
+ {
+ Func? asNamed = preset.Id switch
+ {
+ "extract-frames-png" or "extract-frames-jpg" => preset.SavesFramesOf,
+ "extract-motion" => preset.ExtractsMotionOf,
+ _ => null,
+ };
+ if (asNamed is null)
+ return OrdinaryConversion.None;
+ var usable = UsableFormats(preset, formats);
+ var ordinary = usable.Count(format => !asNamed(format.Id)
+ && !_catalog.All.OfType().Any(opener => opener.Opens(format.Id)));
+ return ordinary == 0 ? OrdinaryConversion.None
+ : ordinary == usable.Count ? OrdinaryConversion.All : OrdinaryConversion.Some;
+ }
+
private EnginePackageState? PackageEnabling(Func possible, Func plannerFor) =>
_packages.FirstOrDefault(p => !p.IsInstalled && possible(plannerFor(p)));
@@ -119,8 +140,8 @@ private PresetJudgement Evaluate(Preset preset, IReadOnlyList format
if (preset.IsClipTool)
{
- // Any video among the files: the others are left out when the clip window opens.
- var videos = formats.Where(f => f.Category == FormatCategory.Video).ToList();
+ // Any video or sound among the files: the others are left out when the clip window opens.
+ var videos = formats.Where(f => f.Category is FormatCategory.Video or FormatCategory.Audio).ToList();
if (videos.Count == 0)
return new(PresetUse.Unusable, _loc["error.unsupported_source"]);
bool CanCut(RoutePlanner planner) => videos.Any(f => ClipTool.CanCut(f.Id, planner));
diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs
index f108f1d26..35256df4f 100644
--- a/src/Filee.App/Services/RadialController.cs
+++ b/src/Filee.App/Services/RadialController.cs
@@ -466,6 +466,21 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w
return;
}
+ // "Ask every time" for the ringtone's part (#1291): the "Cut a clip" window opens on the sounds; other files convert
+ // from the start of the preset's part.
+ if (AskedRingtone.Asks(preset) && web is null && sources.Any(AskedRingtone.IsSound))
+ {
+ KeepKeyboard(); // for the clip window
+ var near = _window?.Position;
+ Close(animate: false);
+ var sounds = sources.Where(AskedRingtone.IsSound).ToList();
+ var rest = sources.Where(file => !AskedRingtone.IsSound(file)).ToList();
+ if (rest.Count > 0)
+ conversions.Start(rest, AskedRingtone.Apply(preset, preset.Media.RingtoneStart, preset.Media.RingtoneLength));
+ windows.ShowRingtoneWindow(sounds, preset, near);
+ return;
+ }
+
// "Ask every time" (#521): the save dialog first; the toast shows the job, the donut has closed for the dialog.
// shortcut: files from a browser aren't asked about, they save into the download folder as "next to the source".
if (preset.Output.AskEveryTime && (web is null || preset.IsCopyToFolder))
diff --git a/src/Filee.App/Services/SpreadsheetPrintService.cs b/src/Filee.App/Services/SpreadsheetPrintService.cs
index edbb20221..f65b8f67f 100644
--- a/src/Filee.App/Services/SpreadsheetPrintService.cs
+++ b/src/Filee.App/Services/SpreadsheetPrintService.cs
@@ -86,7 +86,7 @@ public async Task> ExportAsync(SpreadsheetWorkbookInfo wor
else
{
await _pdfMerger!.MergeAsync(rendered.Select(item => new PdfMergeInput(item.PdfPath, Path.GetFileName(item.PdfPath))).ToArray(),
- temporaryOutputs[0], sourceBookmarks: false, cancellationToken);
+ temporaryOutputs[0], sourceBookmarks: false, paper: null, cancellationToken);
}
// Without Overwrite, a file that appeared there since the names were chosen is never replaced.
for (var index = 0; index < targets.Count; index++)
diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs
index dc7c134ee..8c02df794 100644
--- a/src/Filee.App/Services/WindowService.cs
+++ b/src/Filee.App/Services/WindowService.cs
@@ -420,22 +420,35 @@ public void ShowTool(IReadOnlyList files, Preset preset, PixelPoint? nea
}
///
- /// Opens the "Cut a clip" window on the videos among the files. Each clip is written by an ordinary conversion,
+ /// Opens the "Cut a clip" window on the videos and sounds among the files. Each clip is written by an ordinary conversion,
/// shown in the toast.
///
- public void ShowClipWindow(IReadOnlyList files, Preset preset, PixelPoint? near = null)
+ public void ShowClipWindow(IReadOnlyList files, Preset preset, PixelPoint? near = null) =>
+ ShowClipWindow(files, preset, near, ringtone: false);
+
+ ///
+ /// Opens the "Cut a clip" window on the sounds among the files to choose the part that becomes an iPhone ringtone
+ /// ( , #1291). Its Save starts an ordinary M4R conversion of that part.
+ ///
+ public void ShowRingtoneWindow(IReadOnlyList files, Preset preset, PixelPoint? near = null) =>
+ ShowClipWindow(files, preset, near, ringtone: true);
+
+ private void ShowClipWindow(IReadOnlyList files, Preset preset, PixelPoint? near, bool ringtone)
{
- var videos = files.Where(f => FormatRegistry.Detect(f)?.Category == FormatCategory.Video).ToList();
+ var catalog = services.GetRequiredService();
+ var cutters = catalog.CreatePlanner();
+ var videos = files.Where(f => FormatRegistry.Detect(f) is { } format
+ && (ringtone ? format.Category == FormatCategory.Audio
+ : format.Category is FormatCategory.Video or FormatCategory.Audio && ClipTool.CanCut(format.Id, cutters))).ToList();
if (videos.Count == 0)
return;
_clipWindow?.Close();
var store = services.GetRequiredService();
- var catalog = services.GetRequiredService();
var conversions = services.GetRequiredService();
var log = services.GetService>();
var vm = new ClipWindowViewModel(videos, preset, loc, store, new FfmpegFrameSource(),
path => ClipTool.TargetFor(FormatRegistry.Detect(path)?.Id ?? ClipTool.FallbackTarget, catalog.CreatePlanner()),
- (path, conversion) => conversions.Start([path], conversion), log);
+ (path, conversion) => conversions.Start([path], conversion), log, ringtone);
var win = new ClipWindow { DataContext = vm };
win.PositionOnScreen(near);
win.Closed += (_, _) =>
@@ -1174,7 +1187,9 @@ internal sealed record MainWindowPlacement(PixelPoint? Position, Size? Size, boo
public static MainWindowPlacement Of(MainWindow window, MainWindowViewModel vm, MainWindowPlacement? last) =>
window.WindowState switch
{
- WindowState.Normal => new(window.Position, window.ClientSize, false, vm.SelectedItem.Key),
+ WindowState.Normal => new(window.PlaceToRemember,
+ window.RestPlace?.ClientSize ?? (window.WidthOwed is { } width ? window.ClientSize.WithWidth(width) : window.ClientSize),
+ false, vm.SelectedItem.Key),
WindowState.Maximized => (last ?? new(null, null, false, "")) with { Maximized = true, Page = vm.SelectedItem.Key },
_ => (last ?? new(null, null, false, "")) with { Page = vm.SelectedItem.Key },
};
diff --git a/src/Filee.App/Styles/Soft.axaml b/src/Filee.App/Styles/Soft.axaml
index a1978e37f..67f7a1dd6 100644
--- a/src/Filee.App/Styles/Soft.axaml
+++ b/src/Filee.App/Styles/Soft.axaml
@@ -21,7 +21,10 @@
+
+
+
0 1 3 0 #12000000, 0 6 18 0 #0D000000
@@ -48,7 +51,10 @@
+
+
+
0 1 3 0 #40000000, 0 8 24 0 #33000000
@@ -122,6 +128,9 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -285,7 +293,8 @@
-
+
@@ -297,18 +306,31 @@
-
+
+
+
+
+
+
+
+
+
+
-
+
-
+
-
+
@@ -317,7 +339,8 @@
+ Margin="12,6,12,0" Text="{Binding FaceHint}" IsVisible="{Binding !IsEditingGroup}" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -339,21 +479,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -452,47 +577,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -501,14 +585,16 @@
+ Command="{Binding NewPresetCommand}" ToolTip.Tip="{DynamicResource toolbar.new_preset}"
+ AutomationProperties.Name="{DynamicResource toolbar.new_preset}">
+ Command="{Binding NewGroupCommand}" ToolTip.Tip="{DynamicResource group.hint}"
+ AutomationProperties.Name="{DynamicResource toolbar.new_group}">
diff --git a/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs b/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs
index f0843dfbb..a435041d6 100644
--- a/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs
+++ b/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs
@@ -1,11 +1,20 @@
// Drag & drop for the donut editor.
//
-// Sources: a palette chip (add) or a donut slice (reorder / remove).
-// Targets: a donut slice (insert at / move to that position), the empty ring or the hole (append),
-// or the palette box (remove a slice).
-// A floating "ghost" chip follows the pointer, and the donut opens a gap where the drop would land, with a
-// translucent slice of the dragged preset in it (DonutMenu.SetDropPreview). The Front / Back switch shows the other
-// face of the profile, and the donut turns in to it as the open donut does on Tab.
+// Sources: a palette chip (add) or a donut slice (reorder / remove). Targets: a donut slice (insert at / move to that
+// position), the empty ring or the hole (append), or the palette box (remove a slice). A floating "ghost" chip follows
+// the pointer, and the donut opens a gap where the drop would land, with a translucent slice of the dragged preset in
+// it (DonutMenu.SetDropPreview). The Front / Back switch shows the other face of the profile, and the donut turns in to
+// it as the open donut does on Tab. A click on a group slice opens the group (ToolbarPageViewModel.OpenGroup): the
+// donut then shows its presets, and the same drags add, reorder and take them out of the group.
+//
+// The donut on screen: while Edit Donut Size is open, or a group is open in the editor, the donut is also shown at its
+// real size next to this window (DonutPreviewWindow), filled and styled as it opens for the selected profile's files.
+// It follows every change made here, and holds a group open (DonutMenu.HeldGroupId): the one open in the editor, or,
+// once a ring setting changes, the one picked (Group shown, or clicked on it; the first until then), or an example ring
+// on a donut with none. Where it goes: where it was last dragged (against this window), else beside this window (on a
+// screen as tall as the donut), else on another screen; with no room, this window slides over to make room and back
+// when the donut goes; with none at all (maximized, or a screen too small for both), over the start side of this
+// window, above it.
//
// Chip tooltips: an available chip's tip lists the formats it takes (PresetFormatTip), as a card. Holding either Shift
// shows the whole list while it is open; Tab or the arrow keys open a chip's tip as hover does, and pressing or
@@ -18,8 +27,11 @@
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using Filee.App.Controls;
+using Filee.App.Services;
using Filee.App.ViewModels;
using Filee.App.ViewModels.Pages;
+using Filee.Core.Localization;
+using Filee.Core.Settings;
namespace Filee.App.Views.Pages;
@@ -48,6 +60,28 @@ private sealed record DragState(Source Source, string? PresetId, int SliceIndex,
private bool ShiftDown => _leftShift || _rightShift;
+ // The donut on screen (see the top of this file), made the first time it is needed.
+ private DonutPreviewWindow? _preview;
+ private RadialViewModel? _previewModel;
+
+ /// The donut on screen, once made (null before).
+ internal DonutPreviewWindow? Preview => _preview;
+
+ // What changes the donut's look (the settings and this donut's labels), and what changes what is on it.
+ private static readonly HashSet LookProperties =
+ [
+ nameof(ToolbarPageViewModel.OuterRadius), nameof(ToolbarPageViewModel.HoleRatio), nameof(ToolbarPageViewModel.SliceOpacity),
+ nameof(ToolbarPageViewModel.ShowCaptions), nameof(ToolbarPageViewModel.GroupRingSize), nameof(ToolbarPageViewModel.GroupRingSpread),
+ nameof(ToolbarPageViewModel.LabelScale), nameof(ToolbarPageViewModel.SliceLabels),
+ ];
+
+ private static readonly HashSet ContentProperties =
+ [
+ nameof(ToolbarPageViewModel.IsAdjustingLook), nameof(ToolbarPageViewModel.OpenGroup), nameof(ToolbarPageViewModel.DonutItems),
+ nameof(ToolbarPageViewModel.SelectedProfile), nameof(ToolbarPageViewModel.EditingBack), nameof(ToolbarPageViewModel.ShowsRing),
+ nameof(ToolbarPageViewModel.ShownGroup), nameof(ToolbarPageViewModel.RingGroups),
+ ];
+
public ToolbarPage()
{
InitializeComponent();
@@ -60,6 +94,7 @@ public ToolbarPage()
// rebuild doesn't get here: the pressed chip has left the tree by then and its panel takes the capture, so the
// drag goes on.
Root.AddHandler(PointerCaptureLostEvent, OnRootCaptureLost, RoutingStrategies.Direct, handledEventsToo: true);
+ AddHandler(GotFocusEvent, (_, e) => _lastFocused = e.Source as Control, RoutingStrategies.Bubble, handledEventsToo: true);
// Before the list box, which doesn't move between the rows of its wrap panel.
PaletteTabs.AddHandler(KeyDownEvent, OnTabsKeyDown, RoutingStrategies.Tunnel);
@@ -78,6 +113,12 @@ public ToolbarPage()
if (Vm is not null && _drag is null)
await Vm.EditAsync(index);
};
+ // A click on a group slice opens it, as a folder: the editor then edits what it fans out.
+ Donut.GroupOpenRequested += (_, index) =>
+ {
+ if (_drag is null)
+ Vm?.OpenGroupAt(index);
+ };
DataContextChanged += (_, _) =>
{
if (_subscribed is not null)
@@ -85,6 +126,7 @@ public ToolbarPage()
_subscribed = Vm;
if (_subscribed is not null)
_subscribed.PropertyChanged += OnViewModelChanged;
+ UpdatePreview(reload: true);
};
}
@@ -106,6 +148,379 @@ private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e)
EndDrag();
break;
}
+ if (e.PropertyName == nameof(ToolbarPageViewModel.ProfileName))
+ RenamePreview();
+ if (e.PropertyName is nameof(ToolbarPageViewModel.OpenGroup) or nameof(ToolbarPageViewModel.IsAdjustingLook))
+ Avalonia.Threading.Dispatcher.UIThread.Post(KeepFocus, Avalonia.Threading.DispatcherPriority.Loaded);
+ // Not while the profile moves in the list: its selection is gone for a moment, the profile the same.
+ if (e.PropertyName is { } name && (ContentProperties.Contains(name) || LookProperties.Contains(name))
+ && !(name == nameof(ToolbarPageViewModel.SelectedProfile) && Vm is { IsRepositioning: true }))
+ UpdatePreview(reload: ContentProperties.Contains(name));
+ }
+
+ ///
+ /// The control that had the keyboard was hidden with its part of the page (the way back out of a group, Edit Donut
+ /// Size, Done): the one that took its place has it now, rather than nothing.
+ ///
+ private void KeepFocus()
+ {
+ // Hidden, the focused control lets go of the keyboard (nothing has it then): the one this page last gave it to.
+ var focused = _topLevel?.FocusManager?.GetFocusedElement() as Control;
+ if (Vm is not { } vm || (focused ?? _lastFocused) is not { IsEffectivelyVisible: false } hidden
+ || !hidden.GetVisualAncestors().Contains(this))
+ return;
+ Control next = vm.IsAdjustingLook ? LookDone : vm.IsEditingGroup ? GroupBack : EditSize;
+ next.Focus(NavigationMethod.Tab);
+ }
+
+ // The control of this page that last had the keyboard (see KeepFocus).
+ private Control? _lastFocused;
+
+ ///
+ /// The donut on screen while it's wanted: refilled when what is on it changed ( , and only
+ /// then: a search typed in the palette rebuilds the editor but changes nothing on it), else restyled; put away
+ /// otherwise, and this window back where it was.
+ ///
+ private void UpdatePreview(bool reload)
+ {
+ if (Vm is not { } vm || !(vm.IsAdjustingLook || vm.IsEditingGroup))
+ {
+ HidePreview();
+ SlideBack(animate: true);
+ return;
+ }
+ // No profile for now (a search in the profile list hid it): the donut goes until one is picked, the window
+ // stays where it made room.
+ if (vm.SelectedProfile?.Profile is not { } profile)
+ {
+ HidePreview();
+ return;
+ }
+ // Hidden or minimized: the donut goes with it, and comes back with it. The window stays where it slid to
+ // meanwhile (a minimized window reports no position to compare), and closing it remembers where the user had
+ // it.
+ if (_topLevel is not Window { IsVisible: true, WindowState: not WindowState.Minimized } main)
+ {
+ HidePreview();
+ return;
+ }
+ if (_preview is null || _previewModel is null)
+ {
+ _previewModel = new RadialViewModel(AppHost.Get(), AppHost.Get(), AppHost.Get());
+ _preview = new DonutPreviewWindow { DataContext = _previewModel };
+ _preview.EscapePressed += () =>
+ {
+ Vm?.DoneAdjustingLookCommand.Execute(null);
+ Vm?.CloseGroupCommand.Execute(null);
+ };
+ _preview.Moved += OnPreviewMoved;
+ _preview.DragFinished += CheckDrop;
+ // Grown off its screen or onto this window (Size or Group ring size moved up): placed again; over the
+ // window for want of room, it goes back beside it once it is small enough again.
+ _preview.SizeChanged += (_, _) =>
+ {
+ if (_preview is { IsVisible: true, IsDragging: false, Place: { } place } preview && _topLevel is Window window
+ && !_sliding && (place.Over || !place.Fits(preview.Box.Width, FrameOf(window), ScreensOf(window))))
+ PlacePreview(window, moveWindow: false);
+ };
+ // A group clicked on it is the one held open from then on.
+ _preview.DonutControl.GroupOpenRequested += (_, index) =>
+ {
+ if (_previewModel is { } model && index < model.Items.Count && model.Items[index].Id != RadialViewModel.ExampleGroupId)
+ Vm?.HoldGroup(model.Items[index].Id, model.IsBackFace);
+ };
+ _preview.Closed += (_, _) => (_preview, _previewModel, _previewContent) = (null, null, null);
+ }
+ var held = HeldGroup(vm, out var back);
+ var shown = _preview.IsVisible;
+ var content = ContentOf(vm, profile, held, back);
+ if (!shown || reload && content != _previewContent)
+ {
+ _previewModel.LoadPreview(profile, back);
+ if (held == RadialViewModel.ExampleGroupId)
+ _previewModel.ShowExampleGroup(Loc["toolbar.example_group"], Loc["toolbar.example_none"]);
+ _previewContent = content;
+ }
+ else
+ {
+ _previewModel.ApplyLook(profile);
+ _previewContent = content; // a look saved with the library (Label scale) isn't new content
+ }
+ _preview.DonutControl.HeldGroupId = held;
+ if (!shown)
+ {
+ // Back while this window slides back (or over): it stops there, as if it had landed there, and is placed
+ // from there; where it was before it moved over is still where Done takes it.
+ if (_sliding)
+ {
+ var at = SlidingAt(main);
+ StopSlide();
+ SlidTo = LandedAt = at;
+ _stopped = true; // not moved by the user: where X11 reports it may lag behind
+ }
+ PlacePreview(main, moveWindow: true);
+ _stopped = false;
+ }
+ }
+
+ // What the donut on screen shows, so a rebuild of the editor that changed none of it (a palette search) doesn't
+ // fill it again.
+ private string? _previewContent;
+
+ private string ContentOf(ToolbarPageViewModel vm, Filee.Core.Profiles.ToolbarProfile profile, string? held, bool back) =>
+ string.Join('|', profile.Id, held, back, vm.LibraryVersion, Loc.Language,
+ string.Join(',', AppHost.Get().Settings.TurnedOffFields));
+
+ private static ILocalizer Loc => AppHost.Get();
+
+ // Renamed: a donut filled for no file at all (the mixed profile that takes anything) has its name in the centre,
+ // which changes with it. Not filled again for every key typed.
+ private void RenamePreview()
+ {
+ if (_preview is { IsVisible: true } && _previewModel is { } model && Vm is { } vm && vm.SelectedProfile?.Profile is { } profile
+ && RadialViewModel.PreviewFormats(profile).Count == 0 && HeldGroup(vm, out _) != RadialViewModel.ExampleGroupId)
+ model.CenterTitle = Loc.DisplayName(profile);
+ }
+
+ ///
+ /// The group the donut on screen holds open, and the face it is on ( ): the one open in the
+ /// editor; once a ring setting changed, the one shown (or the example); else none, on the face being edited.
+ ///
+ private static string? HeldGroup(ToolbarPageViewModel vm, out bool back)
+ {
+ back = vm.EditingBack;
+ if (vm.OpenGroup is { } editing)
+ return editing.Id;
+ if (vm.ShowsExampleRing)
+ {
+ back = false;
+ return RadialViewModel.ExampleGroupId;
+ }
+ if (vm.IsAdjustingLook && vm.ShowsRing && vm.ShownGroup is { } group)
+ {
+ back = group.Back;
+ return group.Id;
+ }
+ return null;
+ }
+
+ private void HidePreview()
+ {
+ if (_preview is { IsVisible: true } preview)
+ preview.Hide();
+ }
+
+ ///
+ /// The donut was dragged: it opens there, against this window, from now on (written once it rests).
+ ///
+ private void OnPreviewMoved(PixelPoint center)
+ {
+ if (_topLevel is not Window main)
+ return;
+ var store = AppHost.Get();
+ store.Settings.DonutPreviewPlace = new WindowBounds { X = center.X - main.Position.X, Y = center.Y - main.Position.Y };
+ DonutLookSave.Schedule(store);
+ }
+
+ ///
+ /// The drag is over: dropped onto this window, where a click would bury it, the donut goes back beside it, and the
+ /// place isn't kept. Left across two screens, or grown off one meanwhile, it is placed again.
+ ///
+ private void CheckDrop()
+ {
+ if (_preview is not { IsVisible: true } preview || _topLevel is not Window main)
+ return;
+ var frame = FrameOf(main);
+ if (preview.Box.Intersects(frame))
+ {
+ var store = AppHost.Get();
+ store.Settings.DonutPreviewPlace = null;
+ DonutLookSave.Schedule(store);
+ PlacePreview(main, moveWindow: false);
+ }
+ else if (preview.Place is { } place && !place.Fits(preview.Box.Width, frame, ScreensOf(main)))
+ PlacePreview(main, moveWindow: false);
+ }
+
+ // macOS reports window positions and screens in points, the others in device pixels.
+ private static double ScalingOf(Avalonia.Platform.Screen? screen) => OperatingSystem.IsMacOS() ? 1 : screen?.Scaling ?? 1;
+
+ private static List ScreensOf(Window window) =>
+ window.Screens.All.Select(s => new PreviewScreen(s.WorkingArea, ScalingOf(s))).ToList();
+
+ private static PixelRect FrameOf(Window window) => new(window.Position,
+ PixelSize.FromSize(window.FrameSize ?? window.Bounds.Size, ScalingOf(window.Screens.ScreenFromWindow(window) ?? window.Screens.Primary)));
+
+ ///
+ /// Puts the donut on screen where says, moving this window over first when it
+ /// must ( : only as the donut first appears, never while the user works).
+ ///
+ private void PlacePreview(Window main, bool moveWindow)
+ {
+ if (_preview is not { } preview || _previewModel is not { } donut)
+ return;
+ var all = main.Screens.All.ToList();
+ var home = main.Screens.ScreenFromWindow(main) ?? main.Screens.Primary;
+ var screens = ScreensOf(main);
+ var index = Math.Max(0, home is null ? 0 : all.FindIndex(s => s.WorkingArea == home.WorkingArea));
+ var scaling = ScalingOf(home);
+ var frame = main.FrameSize ?? main.Bounds.Size;
+ var window = new PixelRect(main.Position, PixelSize.FromSize(frame, scaling));
+ var current = DonutMenu.SideFor(donut.OuterRadius, donut.ReserveSubRing, donut.GroupRingSize);
+ var dragged = AppHost.Get().Settings.DonutPreviewPlace is { X: { } dx, Y: { } dy } ? new PixelPoint(dx, dy) : (PixelPoint?)null;
+ // Not on native Wayland, where a window can't place itself: it would only be made narrower.
+ int? minWidth = moveWindow && main.WindowState == WindowState.Normal && !Filee.Platform.Linux.WaylandSupport.NativeWaylandRequested
+ ? (int)Math.Ceiling((main.MinWidth + frame.Width - main.ClientSize.Width) * scaling)
+ : null;
+ // With nothing else left, it goes over the navigation and the profile list, which neither Edit Donut Size nor
+ // an open group needs while the donut is tried out, and which are wider than Available presets: the panel's
+ // Done and its values stay clear. They are on the right in Arabic.
+ var overRight = FlowDirection == Avalonia.Media.FlowDirection.RightToLeft;
+ var place = DonutPreviewPlacement.Place(window, screens, index, current, dragged, minWidth, overRight);
+ if (place.MovedWindow is { } moved)
+ MoveMain(main, moved, scaling);
+ preview.ShowAt(place, (int)Math.Ceiling(current * scaling), main);
+ }
+
+ // This window moved over to make room for the donut: where it went (it goes back unless moved since), the slide
+ // under way, and the width it was made narrower to (it goes back to its own unless the user resized it since).
+ // Where it actually landed (a window manager may keep it off the menu bar) is the window's own, for closing to
+ // judge by too (MainWindow.Landed): null while it is on its way, over or back.
+ private bool _sliding;
+ private double? _narrowedTo;
+
+ // A slide was just stopped midway, the donut back on screen.
+ private bool _stopped;
+
+ private PixelPoint? SlidTo
+ {
+ get => (_topLevel as MainWindow)?.SlidTo;
+ set
+ {
+ if (_topLevel is MainWindow main)
+ main.SlidTo = value;
+ }
+ }
+
+ // Two widths a window was given and reports: the same to a device pixel (Windows truncates to whole pixels).
+ private static bool SameWidth(Window window, double a, double b) => Math.Abs(a - b) <= 1 / window.RenderScaling + 0.01;
+
+ private PixelPoint? LandedAt
+ {
+ get => (_topLevel as MainWindow)?.Landed;
+ set
+ {
+ if (_topLevel is MainWindow main)
+ main.Landed = value;
+ }
+ }
+
+ ///
+ /// Moves this window to (narrower if it must be), remembering where it was.
+ ///
+ private void MoveMain(Window main, PixelRect to, double scaling)
+ {
+ if (main is MainWindow mw)
+ {
+ // Moved by the user since it last moved over (while the donut was put away): there is where it goes back,
+ // as wide as it was unless the user resized it too.
+ if (mw.RestPlace is { } rest && !_sliding && !_stopped && (LandedAt ?? SlidTo) is not null && !mw.StillWhereItSlid)
+ {
+ var narrowed = _narrowedTo is { } width && SameWidth(main, main.ClientSize.Width, width);
+ mw.RestPlace = (main.Position, narrowed ? rest.ClientSize.WithHeight(main.ClientSize.Height) : main.ClientSize);
+ if (!narrowed)
+ _narrowedTo = null;
+ }
+ mw.RestPlace ??= (main.Position, main.ClientSize);
+ }
+ var narrower = (main.FrameSize?.Width ?? main.Bounds.Width) * scaling - to.Width;
+ if (narrower > 0)
+ {
+ // Recorded first: resizing reports it, and must not take it for the user's.
+ _narrowedTo = Math.Max(main.MinWidth, main.ClientSize.Width - narrower / scaling);
+ main.Width = _narrowedTo.Value;
+ }
+ SlidTo = to.Position;
+ LandedAt = null;
+ Slide(main, to.Position, () => LandedAt = main.Position);
+ }
+
+ ///
+ /// The donut went: this window goes back to where it was, and as wide, unless the user moved it since it moved
+ /// over. false puts it back at once (leaving the page, closing, hiding).
+ ///
+ private void SlideBack(bool animate)
+ {
+ // Maximized meanwhile: it goes back once it is a normal window again (see OnWindowPropertyChanged).
+ if (_topLevel is not MainWindow { WindowState: WindowState.Normal } main || main.RestPlace is not { } rest)
+ return;
+ var stillThere = _sliding || main.StillWhereItSlid;
+ // Its own width back, unless the user resized it since it was made narrower (or it never was).
+ var narrowed = _narrowedTo is { } width && SameWidth(main, main.ClientSize.Width, width);
+ SlidTo = LandedAt = null;
+ _narrowedTo = null;
+ if (narrowed)
+ main.Width = rest.ClientSize.Width;
+ if (!stillThere)
+ {
+ StopSlide();
+ main.RestPlace = null; // moved by the user: where it is now is theirs
+ return;
+ }
+ if (animate)
+ Slide(main, rest.Position, () => main.RestPlace = null);
+ else
+ {
+ StopSlide();
+ main.Position = rest.Position;
+ main.RestPlace = null;
+ }
+ }
+
+ private Avalonia.Threading.DispatcherTimer? _slide;
+
+ /// How long this window takes to slide over or back (tests make it longer, to catch it midway).
+ internal static TimeSpan SlideTime { get; set; } = TimeSpan.FromSeconds(0.25);
+
+ private void StopSlide()
+ {
+ _slide?.Stop();
+ _sliding = false;
+ _slideAt = null;
+ }
+
+ // Where the slide under way last put this window: an X11 window reports a new place only once the window manager
+ // has moved it, a frame or so later.
+ private PixelPoint? _slideAt;
+
+ private PixelPoint SlidingAt(Window main) => _sliding && _slideAt is { } at ? at : main.Position;
+
+ // Eased over a quarter of a second, or at once without animations.
+ private void Slide(Window main, PixelPoint to, Action done)
+ {
+ var from = SlidingAt(main);
+ StopSlide();
+ if (!Motion.Enabled || from == to)
+ {
+ main.Position = to;
+ done();
+ return;
+ }
+ var started = DateTime.UtcNow;
+ var duration = SlideTime.TotalSeconds;
+ _sliding = true;
+ _slide = new Avalonia.Threading.DispatcherTimer(TimeSpan.FromMilliseconds(16), Avalonia.Threading.DispatcherPriority.Render, (_, _) =>
+ {
+ var t = Math.Min(1, (DateTime.UtcNow - started).TotalSeconds / duration);
+ var eased = 1 - Math.Pow(1 - t, 3);
+ _slideAt = new PixelPoint((int)Math.Round(from.X + (to.X - from.X) * eased), (int)Math.Round(from.Y + (to.Y - from.Y) * eased));
+ main.Position = _slideAt.Value;
+ if (t < 1)
+ return;
+ StopSlide();
+ done();
+ });
+ _slide.Start();
}
private ToolbarPageViewModel? Vm => DataContext as ToolbarPageViewModel;
@@ -123,9 +538,87 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
window.Activated += OnWindowActivationChanged;
window.Deactivated += OnWindowActivationChanged;
+ window.PropertyChanged += OnWindowPropertyChanged;
+ window.PositionChanged += OnWindowPositionChanged;
}
+ UpdatePreview(reload: true);
+ }
+
+ ///
+ /// The window hidden (to the tray) or minimised puts the donut on screen away with it, and brings it back when
+ /// shown.
+ ///
+ private void OnWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Property == Visual.IsVisibleProperty || e.Property == Window.WindowStateProperty)
+ UpdatePreview(reload: true);
+ if (e.Property == Window.WindowStateProperty || e.Property == TopLevel.ClientSizeProperty)
+ KeepClearOfWindow();
+ if (e.Property == TopLevel.ClientSizeProperty)
+ NoteUserResize();
+ // A normal window again after Done (it was maximized meanwhile): it goes back, once its normal size and place
+ // are in (some systems report the state first).
+ if (e.Property == Window.WindowStateProperty)
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ if (_preview is not { IsVisible: true } && _topLevel is MainWindow { WindowState: WindowState.Normal, RestPlace: not null })
+ SlideBack(animate: false);
+ }, Avalonia.Threading.DispatcherPriority.Background);
+ }
+
+ private bool _userResizePosted;
+
+ ///
+ /// This window was resized while it made room: the size the user gave it is theirs, kept on Done and remembered on
+ /// close ( ). Judged once the change has settled, on a normal window: Windows
+ /// reports a maximized window's new size before its new state.
+ ///
+ private void NoteUserResize()
+ {
+ if (_userResizePosted)
+ return;
+ _userResizePosted = true;
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ _userResizePosted = false;
+ if (_sliding || _topLevel is not MainWindow { IsVisible: true, WindowState: WindowState.Normal, RestPlace: { } rest } main)
+ return;
+ // Its width the user's unless it is the one it was made narrower to; its height always.
+ var width = rest.ClientSize.Width;
+ if (!SameWidth(main, main.ClientSize.Width, _narrowedTo ?? width) && !SameWidth(main, main.ClientSize.Width, width))
+ {
+ _narrowedTo = null;
+ width = main.ClientSize.Width;
+ }
+ if (new Size(width, main.ClientSize.Height) is var size && size != rest.ClientSize)
+ main.RestPlace = (rest.Position, size);
+ }, Avalonia.Threading.DispatcherPriority.Background);
+ }
+
+ ///
+ /// This window was resized, maximized or moved while the donut is on screen: the donut is placed again, so it stays
+ /// beside the window (or where it was dragged, against it) and never ends up under it.
+ ///
+ private void KeepClearOfWindow()
+ {
+ // Once the window's new size and frame are laid out (they follow the size it was given), and once for a burst
+ // of moves.
+ if (_clearPosted)
+ return;
+ _clearPosted = true;
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ _clearPosted = false;
+ if (!_sliding && _topLevel is Window { IsVisible: true, WindowState: not WindowState.Minimized } main
+ && _preview is { IsVisible: true, IsDragging: false })
+ PlacePreview(main, moveWindow: false);
+ }, Avalonia.Threading.DispatcherPriority.Loaded);
}
+ private bool _clearPosted;
+
+ private void OnWindowPositionChanged(object? sender, PixelPointEventArgs e) => KeepClearOfWindow();
+
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
@@ -137,9 +630,28 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e
{
window.Activated -= OnWindowActivationChanged;
window.Deactivated -= OnWindowActivationChanged;
+ window.PropertyChanged -= OnWindowPropertyChanged;
+ window.PositionChanged -= OnWindowPositionChanged;
}
}
+ // Another page: the editor's modes end with the page, the donut on screen goes, and this window goes back.
+ SlideBack(animate: false);
+ if (_topLevel is MainWindow { RestPlace: not null } away)
+ {
+ // Maximized or minimized meanwhile: nothing is left to put it back once it is a normal window again, so
+ // where it moved to is where it is, and where closing remembers; made narrower, it gets its own width back
+ // then all the same (MainWindow.WidthOwed).
+ StopSlide();
+ if (_narrowedTo is not null)
+ away.WidthOwed = away.RestPlace.Value.ClientSize.Width;
+ away.RestPlace = null;
+ SlidTo = LandedAt = null;
+ _narrowedTo = null;
+ }
_topLevel = null;
+ Vm?.DoneAdjustingLookCommand.Execute(null);
+ Vm?.CloseGroupCommand.Execute(null);
+ _preview?.Close();
_leftShift = _rightShift = false;
}
diff --git a/src/Filee.App/Views/PresetEditorView.axaml b/src/Filee.App/Views/PresetEditorView.axaml
index 80223ec51..2e52942ea 100644
--- a/src/Filee.App/Views/PresetEditorView.axaml
+++ b/src/Filee.App/Views/PresetEditorView.axaml
@@ -436,6 +436,7 @@
+
@@ -543,6 +544,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -736,6 +757,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Filee.App/Views/RadialWindow.axaml b/src/Filee.App/Views/RadialWindow.axaml
index 022c558bf..cd14e4abc 100644
--- a/src/Filee.App/Views/RadialWindow.axaml
+++ b/src/Filee.App/Views/RadialWindow.axaml
@@ -44,6 +44,7 @@
Items="{Binding Items}"
CenterTitle="{Binding CenterTitle}"
CenterSubtitle="{Binding CenterSubtitle}"
+ CenterNotice="{Binding CenterNotice}"
OuterRadius="{Binding OuterRadius}"
HoleRatio="{Binding HoleRatio}"
SliceOpacity="{Binding SliceOpacity}"
diff --git a/src/Filee.Cli/EnginesCommand.cs b/src/Filee.Cli/EnginesCommand.cs
index e286868e0..a29f4b19a 100644
--- a/src/Filee.Cli/EnginesCommand.cs
+++ b/src/Filee.Cli/EnginesCommand.cs
@@ -156,8 +156,9 @@ private static void WriteEngine(Utf8JsonWriter json, EngineInstaller engines, En
// ───────────────────────── Install and remove ─────────────────────────
///
- /// As the Engines page offers it: a package not installed, Filee's download of an outdated one, and Filee's download
- /// over a copy found on the PC (it is the tested version). The user's own copy is left in use.
+ /// A package not installed, Filee's download of an outdated one, and Filee's download over a copy found on the PC
+ /// (it is the tested version), as the first-run setup installs it; the Engines page offers that last one only over
+ /// a copy that is older or whose version can't be read. The user's own copy is left in use.
///
private static async Task InstallAsync(EnginePackage package, CliOptions options, CliHost host, TextWriter errors,
CancellationToken cancellationToken)
diff --git a/src/Filee.Core/Conversion/IConverter.cs b/src/Filee.Core/Conversion/IConverter.cs
index e53c6f51b..889a5fa89 100644
--- a/src/Filee.Core/Conversion/IConverter.cs
+++ b/src/Filee.Core/Conversion/IConverter.cs
@@ -69,11 +69,16 @@ namespace Filee.Core.Conversion;
/// route it would have taken without the engine. Only a route's first step, where the file is the one the engine
/// looked at.
///
+///
+/// The edge is used only in routes that start from one of these formats (ids, lower case); null for every source.
+/// WeasyPrint's HTML → PDF serves pages, Markdown and e-mails, which become HTML first, but must not become a hop for
+/// a Word document or an e-book that the HTML writers could also turn into a page.
+///
public readonly record struct ConversionEdge(
string From, string To, int Cost = 10, bool ImageSourcesOnly = false, bool NotForImageSources = false,
bool DirectOnly = false, bool FrameSequence = false, bool Unshaped = false, bool MotionPhoto = false, bool LivePhoto = false,
bool Ocr = false, bool Caption = false, bool Translate = false, bool Metadata = false, bool Upscale = false,
- bool Checked = false);
+ bool Checked = false, IReadOnlyList? Sources = null);
///
/// A conversion an engine refuses with a reason the user can act on, shown in their language (the file's
@@ -267,10 +272,22 @@ public interface IEnvelopeOpener
/// The original source's basename, including its extension, without directories.
public sealed record PdfMergeInput(string PdfPath, string SourceName);
+/// The paper size and orientation shared by every page of a merged PDF.
+/// The target paper, or the visible physical size of the first imported page.
+/// The target orientation, chosen once from the first imported page when automatic.
+public readonly record struct PdfMergePaper(PdfSheetPaper Paper, PdfSheetOrientation Orientation);
+
/// Merges several PDF files into one. Implemented by the PDF engine.
public interface IPdfMerger
{
- Task MergeAsync(IReadOnlyList inputs, string outputPath, bool sourceBookmarks, CancellationToken cancellationToken);
+ /// Imports the input PDFs in order and writes their pages and supported structure into one PDF.
+ /// PDFs and original source names in the order to merge.
+ /// File to create.
+ /// Add a parent bookmark for each original source file.
+ /// Fit merged pages to this paper, or null to preserve their original geometry.
+ /// Cancels the merge without modifying the inputs.
+ Task MergeAsync(IReadOnlyList inputs, string outputPath, bool sourceBookmarks, PdfMergePaper? paper,
+ CancellationToken cancellationToken);
}
///
diff --git a/src/Filee.Core/Conversion/JobQueue.cs b/src/Filee.Core/Conversion/JobQueue.cs
index e23f1984c..250faa4b1 100644
--- a/src/Filee.Core/Conversion/JobQueue.cs
+++ b/src/Filee.Core/Conversion/JobQueue.cs
@@ -322,11 +322,22 @@ await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count),
try
{
- await _pdfMerger!.MergeAsync(inputs, output, job.Preset.Pdf.SourceBookmarks, job.CancellationToken);
+ var pdf = job.Preset.Pdf;
+ PdfMergePaper? paper = pdf.NormalizeMergePaper ? new(pdf.MergePaper, pdf.MergeOrientation) : null;
+ await _pdfMerger!.MergeAsync(inputs, output, pdf.SourceBookmarks, paper, job.CancellationToken);
// Once for the merged PDF, so its pages are numbered through. PDFsharp wrote it, so it is no PDF/A yet.
await FinishPdfsAsync(job.Preset, [output], job.CancellationToken);
await ArchivePdfsAsync(job.Preset, [output], first, workDir, changed: true, job.CancellationToken);
}
+ catch (ConversionRefusedException ex)
+ {
+ // A refusal (a page the paper size can't be fitted to, say) is told on every merged file, as joining does.
+ allocator.Discard();
+ _log.LogInformation("Merging {Count} file(s) into {Output} was refused: {Reason}", inputs.Count, output, ex.Message);
+ foreach (var f in job.Files.Where(f => !Stopped(f)))
+ FailFile(job, f, ex.ErrorKey, ex.Detail);
+ return;
+ }
catch
{
allocator.Discard();
diff --git a/src/Filee.Core/Conversion/RoutePlanner.cs b/src/Filee.Core/Conversion/RoutePlanner.cs
index d1aa182c6..4f4419fc9 100644
--- a/src/Filee.Core/Conversion/RoutePlanner.cs
+++ b/src/Filee.Core/Conversion/RoutePlanner.cs
@@ -189,6 +189,7 @@ public RoutePlanner(IEnumerable converters, Func? p
|| (edge.ImageSourcesOnly && !imageSource) || (edge.NotForImageSources && imageSource)
|| (edge.Unshaped && shaping)
|| (edge.Checked && (withoutChecked || node != from))
+ || (edge.Sources is { } sources && !sources.Contains(from))
|| (edge.DirectOnly && (node != from || edge.To != to)))
continue;
var next = cost + edgeCost;
diff --git a/src/Filee.Core/Formats/FormatFields.cs b/src/Filee.Core/Formats/FormatFields.cs
index c8acffd6e..500ed61c3 100644
--- a/src/Filee.Core/Formats/FormatFields.cs
+++ b/src/Filee.Core/Formats/FormatFields.cs
@@ -64,25 +64,26 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow
]),
(FormatGrade.General,
[
- "tiff", "bmp", "ico", "avif", "jxl", "tga", "apng", "jxr", "livp", "exe", "svg", "svgz", "emf", "vsd", "odg",
+ "tiff", "bmp", "ico", "avif", "jxl", "tga", "apng", "jxr", "livp", "exe", "svg", "svgz", "emf", "vsd", "odg", "canvas",
"docm", "dotx", "dotm", "doc", "odt", "ott", "rtf", "wps", "ofd", "abw", "pages", "pub", "eml", "msg",
"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", "m4r", "aac", "flac", "ogg", "opus", "wma", "aiff", "weba", "mka", "qta", "7z", "rar",
- "dmg", "iso", "img", "alz", "egg", "split", "dxf", "srt", "vtt", "smi", "lrc"
+ "dmg", "iso", "img", "alz", "egg", "split", "dxf", "srt", "vtt", "smi", "lrc",
+ "krc"
]),
(FormatGrade.Specialist,
[
"jp2", "psd", "psb", "ppm", "icns", "qoi", "dds", "cur", "xpm", "xbm", "pam", "pfm", "miff", "dcm",
"exr", "hdr", "fits", "xcf", "kra", "pdn", "raw", "eps", "ps", "ai", "cdr", "cgm", "plt", "dst", "fodg",
"fodt", "fods", "fodp", "rst", "tex", "org", "adoc", "typ", "mediawiki", "dbk", "ipynb", "textile", "man",
- "jsonl", "yaml", "toml", "har", "plist", "htmlz", "txtz", "chm", "dv", "mxf", "cavs", "usm", "m4s", "dav", "bik",
+ "jsonl", "yaml", "toml", "har", "plist", "htmlz", "txtz", "chm", "dv", "mxf", "cavs", "usm", "m4s", "dav", "nov", "bik",
"h265", "h264", "ac3", "caf", "mp2", "dss", "wv", "tta", "w64", "eac3", "ape", "tak", "dsf", "dff",
- "dts", "tar", "tgz", "tbz2", "txz", "tar7z", "tlz", "gz", "bz2", "xz", "lz", "lzma", "jar", "zxp", "mcpack", "mcaddon",
- "mcworld", "mctemplate", "mrpack", "cab",
+ "dts", "tar", "tgz", "tbz2", "txz", "tar7z", "tlz", "gz", "bz2", "xz", "lz", "lzma", "jar", "zxp", "nupkg", "apks",
+ "aab", "mcpack", "mcaddon", "mcworld", "mctemplate", "mrpack", "cab", "gerber",
"cpio", "deb", "rpm", "dwg", "ttf", "otf", "woff", "woff2", "ass", "ssa", "ttml", "scc", "glb", "gltf",
- "stl", "obj", "3mf", "ply", "fbx", "geojson", "kml", "kmz", "gpx", "shp", "fit", "osm", "xm", "it", "ani"
+ "stl", "obj", "3mf", "ply", "fbx", "gcode", "geojson", "kml", "kmz", "gpx", "shp", "fit", "osm", "xm", "it", "ani", "vtf"
]),
(FormatGrade.Legacy,
[
@@ -99,7 +100,7 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow
// Layout, print, photo post-production, signs and embroidery.
(["psd", "psb", "exr", "hdr", "xcf", "kra", "pdn", "raw", "eps", "ps", "ai", "cdr", "plt", "dst"], ["design"]),
// Game textures and cutscenes, Minecraft packs and modpacks.
- (["dds", "usm", "bik", "mcpack", "mcaddon", "mcworld", "mctemplate", "mrpack"], ["game"]),
+ (["dds", "vtf", "usm", "bik", "mcpack", "mcaddon", "mcworld", "mctemplate", "mrpack"], ["game"]),
// Medical imaging and astronomy.
(["dcm", "fits"], ["science"]),
// Software work: raw image formats of graphics tools, app icons and cursors, config and data files, flat XML
@@ -107,19 +108,21 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow
([
"ppm", "icns", "qoi", "cur", "ani", "xpm", "xbm", "pam", "pfm", "miff", "fodg", "fodt", "fods", "fodp", "man",
"jsonl", "yaml", "toml", "har", "plist", "m4s", "tar", "tgz", "tbz2", "txz", "tar7z", "tlz", "gz", "bz2", "xz",
- "lz", "lzma", "jar", "zxp", "cab", "cpio", "deb", "rpm"
+ "lz", "lzma", "jar", "zxp", "nupkg", "apks", "aab", "cab", "cpio", "deb", "rpm"
], ["developer"]),
// Markup sources, papers and outlines, maintenance-doc drawings (CGM), Calibre's ebook formats.
(["cgm", "rst", "tex", "org", "adoc", "typ", "mediawiki", "dbk", "textile", "htmlz", "txtz"], ["writing"]),
// Broadcast and editing masters, broadcast audio and captions, fansub and delivery subtitles, CCTV and
// raw streams.
([
- "dv", "mxf", "cavs", "dav", "h265", "h264", "ac3", "mp2", "eac3", "dts", "ass", "ssa", "ttml", "scc"
+ "dv", "mxf", "cavs", "dav", "nov", "h265", "h264", "ac3", "mp2", "eac3", "dts", "ass", "ssa", "ttml", "scc"
], ["video-pro"]),
// Lossless archiving and recorders.
(["dss", "wv", "tta", "w64", "ape", "tak", "dsf", "dff"], ["audio-pro"]),
// 3D printing exports from CAD designs (STL, 3MF) and CAD models handed to renderers (OBJ).
(["stl", "obj", "3mf"], ["model3d", "cad"]),
+ // G-code: what slicers make for 3D printers and CAM programs for CNC machines.
+ (["gcode"], ["model3d", "cad"]),
// JPEG 2000: digital cinema, archives and medical imaging.
(["jp2"], ["design", "science"]),
// Jupyter notebooks: development and data science.
diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs
index b7bb2b0ab..34c0c870e 100644
--- a/src/Filee.Core/Formats/FormatRegistry.cs
+++ b/src/Filee.Core/Formats/FormatRegistry.cs
@@ -61,12 +61,16 @@ public static class FormatRegistry
new("fits", "FITS", FormatCategory.Image, ["fits", "fts"], Writable: false),
// Read on Windows only, with its own codec (HDR screenshots of Windows are JPEG XR).
new("jxr", "JPEG XR", FormatCategory.Image, ["jxr", "wdp", "hdp"], Writable: false),
- new("xcf", "XCF", FormatCategory.Image, ["xcf"], Writable: false),
+ // GIMP's own file, read and written by Filee: layers merged for other pictures, kept between XCF, PDN and PSD.
+ new("xcf", "GIMP (XCF)", FormatCategory.Image, ["xcf"]),
// Krita's own file (#903): a ZIP whose merged picture (mergedimage.png) is read, as Krita shows it. ".kra~" is the
// backup Krita keeps of the previous save, the same file under another name (#1077).
new("kra", "Krita (KRA)", FormatCategory.Image, ["kra", "kra~"], Writable: false),
// Paint.NET's own file, read and written by Filee: layers merged for other pictures, kept between PDN and PSD.
new("pdn", "Paint.NET (PDN)", FormatCategory.Image, ["pdn"]),
+ // Valve's texture for the Source engine (Garry's Mod, Counter-Strike: Source, Team Fortress 2), read and written by
+ // Filee: DXT1 or DXT5 with every mipmap, the frames of an animated texture.
+ new("vtf", "Valve Texture (VTF)", FormatCategory.Image, ["vtf"]),
// iPhone Live Photo as one ZIP (iCloud downloads): the photo, or its video with FFmpeg.
new("livp", "Live Photo (LIVP)", FormatCategory.Image, ["livp"], Writable: false),
// A Windows program or library (#1188), read as its icon: the picture Explorer shows for the file.
@@ -90,6 +94,9 @@ public static class FormatRegistry
// Tajima embroidery designs: the stitches drawn as lines (issue #397).
new("dst", "DST", FormatCategory.Vector, ["dst"], Writable: false),
new("odg", "ODG", FormatCategory.Vector, ["odg"]),
+ // Obsidian's visual boards (JSON Canvas 1.0): cards, groups and the lines between them, read by Filee itself and
+ // drawn as SVG (then PDF and pictures), or their text as Markdown. A .canvas that isn't one is refused.
+ new("canvas", "Obsidian Canvas", FormatCategory.Vector, ["canvas"], Writable: false),
new("fodg", "FODG", FormatCategory.Vector, ["fodg"], Writable: false),
// PDF
@@ -259,6 +266,9 @@ public static class FormatRegistry
// Dahua and similar DVR / NVR recordings (#594): FFmpeg's dhav demuxer reads them (H.264 / H.265 video, often
// with G.711 or AAC sound).
new("dav", "DAV", FormatCategory.Video, ["dav"], Writable: false),
+ // Wisenet WAVE / Nx Witness exports: a layout file whose camera recordings are Matroska streams, read by FFmpeg
+ // from inside the file (NovExport). Password-protected exports are encrypted and refused.
+ new("nov", "NOV", FormatCategory.Video, ["nov"], Writable: false),
// Bink Video, the game cutscene format of RAD Game Tools (#751): FFmpeg's bink demuxer reads it. Never a target.
new("bik", "BIK", FormatCategory.Video, ["bik"], Writable: false),
// FLIC, the animations of Autodesk Animator (.fli) and Animator Pro (.flc), also in DOS games and pixel-art tools
@@ -332,6 +342,12 @@ public static class FormatRegistry
// Adobe extension packages for After Effects, Premiere Pro, Photoshop … (#1382): signed ZIPs. Never a target,
// since installing one needs the signature its developer makes with Adobe's ZXPSignCmd.
new("zxp", "ZXP (Adobe extension)", FormatCategory.Archive, ["zxp"], Writable: false),
+ // NuGet packages and their symbol packages (#1465), split-APK containers from bundletool, SAI, APKMirror and
+ // APKPure, and Android App Bundles (#1464): all ZIPs. Never a target, since NuGet, Android and the stores need
+ // a package signed (and laid out) by its developer's tools.
+ new("nupkg", "NUPKG (NuGet package)", FormatCategory.Archive, ["nupkg", "snupkg"], Writable: false),
+ new("apks", "APKS (Android split APKs)", FormatCategory.Archive, ["apks", "xapk", "apkm"], Writable: false),
+ new("aab", "AAB (Android App Bundle)", FormatCategory.Archive, ["aab"], Writable: false),
// Minecraft Bedrock packs, add-ons, worlds and world templates, and Modrinth modpacks (#824, #898): ZIP archives
// under their own extensions, written with their marker file (manifest.json, level.dat, modrinth.index.json) at
// the top.
@@ -357,6 +373,14 @@ public static class FormatRegistry
// CAD
new("dwg", "DWG", FormatCategory.Cad, ["dwg"]),
new("dxf", "DXF", FormatCategory.Cad, ["dxf"]),
+ // PCB layers (RS-274X / Gerber X2): generic, Protel-style per-layer and board-outline extensions. GIMP brushes
+ // also use .gbr; the reader tells them apart and says so.
+ new("gerber", "Gerber", FormatCategory.Cad,
+ ["gbr", "ger", "gtl", "gbl", "gto", "gbo", "gts", "gbs", "gtp", "gbp", "gko", "gm1"], Writable: false),
+ // Toolpaths of 3D printers (slicers) and CNC machines, read only and drawn as a picture. ".nc" stays out (also
+ // NetCDF, #562, #244), and so do ".tap" (also cassette images) and ".g" (also ANTLR and GAP; RepRapFirmware's
+ // macros seldom have a toolpath).
+ new("gcode", "G-code", FormatCategory.Cad, ["gcode", "gco", "ngc"], Writable: false),
// Fonts
new("ttf", "TTF", FormatCategory.Font, ["ttf"]),
@@ -373,6 +397,7 @@ public static class FormatRegistry
new("vtt", "WebVTT", FormatCategory.Subtitle, ["vtt"]),
new("smi", "SAMI", FormatCategory.Subtitle, ["smi", "sami"]),
new("lrc", "LRC", FormatCategory.Subtitle, ["lrc"]),
+ new("krc", "KRC", FormatCategory.Subtitle, ["krc"], Writable: false),
new("ttml", "TTML", FormatCategory.Subtitle, ["ttml", "dfxp"]),
new("sub", "MicroDVD", FormatCategory.Subtitle, ["sub"]),
new("scc", "SCC", FormatCategory.Subtitle, ["scc"], Writable: false),
diff --git a/src/Filee.Core/Presets/AskedRingtone.cs b/src/Filee.Core/Presets/AskedRingtone.cs
new file mode 100644
index 000000000..54a958daa
--- /dev/null
+++ b/src/Filee.Core/Presets/AskedRingtone.cs
@@ -0,0 +1,31 @@
+// "Ask every time" for the ringtone's part (#1291): which drops ask, and the preset as it runs once the user has chosen
+// the part in the "Cut a clip" window.
+
+using Filee.Core.Formats;
+
+namespace Filee.Core.Presets;
+
+public static class AskedRingtone
+{
+ /// The preset makes iPhone ringtones and asks for the part of each sound ( ).
+ public static bool Asks(Preset preset) =>
+ preset.Media.AskRingtonePart && preset.TargetFormat == "m4r" && !preset.IsGroup && !preset.IsTool;
+
+ /// True for a sound among the dropped files, whose part is asked for.
+ public static bool IsSound(string path) => FormatRegistry.Detect(path)?.Category == FormatCategory.Audio;
+
+ ///
+ /// The preset as it runs for one sound: the part from , seconds long
+ /// (within the limits of a ringtone), and no asking again. A copy; the saved preset is left alone.
+ ///
+ public static Preset Apply(Preset preset, double start, double length)
+ {
+ var copy = preset.Clone();
+ copy.Media.RingtoneStart = double.IsFinite(start) ? Math.Max(0, start) : 0;
+ copy.Media.RingtoneLength = double.IsFinite(length)
+ ? Math.Clamp(length, MediaOptions.MinRingtoneLength, MediaOptions.MaxRingtoneLength)
+ : MediaOptions.DefaultRingtoneLength;
+ copy.Media.AskRingtonePart = false;
+ return copy;
+ }
+}
diff --git a/src/Filee.Core/Presets/BuiltInData.cs b/src/Filee.Core/Presets/BuiltInData.cs
index d5b0c5a1e..7fe755d92 100644
--- a/src/Filee.Core/Presets/BuiltInData.cs
+++ b/src/Filee.Core/Presets/BuiltInData.cs
@@ -658,7 +658,7 @@ public static List CatalogPresets() =>
new()
{
Id = RingtoneId, NameKey = "preset.iphone_ringtone", TargetFormat = "m4r",
- Media = { RingtoneStart = 0, RingtoneLength = MediaOptions.DefaultRingtoneLength },
+ Media = { RingtoneStart = 0, RingtoneLength = MediaOptions.DefaultRingtoneLength, AskRingtonePart = true },
},
// Archives
diff --git a/src/Filee.Core/Presets/ClipTool.cs b/src/Filee.Core/Presets/ClipTool.cs
index e4d063cfd..b99d9f82e 100644
--- a/src/Filee.Core/Presets/ClipTool.cs
+++ b/src/Filee.Core/Presets/ClipTool.cs
@@ -1,6 +1,6 @@
-// "Cut a clip" runs as an ordinary conversion once the clip window has its part of the video: the tool preset becomes
-// a conversion of that part into the video's own format (or MP4), so the job queue, the toast, cancelling, the output
-// rules and the history work as for any other file.
+// "Cut a clip" runs as an ordinary conversion once the clip window has its part of the video (or sound): the tool preset
+// becomes a conversion of that part into the file's own format (or MP4 for a video, MP3 for a sound), so the job queue,
+// the toast, cancelling, the output rules and the history work as for any other file.
using Filee.Core.Conversion;
using Filee.Core.Formats;
@@ -13,17 +13,37 @@ public static class ClipTool
/// Where a clip goes when the video's own format can't be written: plays everywhere.
public const string FallbackTarget = "mp4";
+ /// Where a clip of a sound goes when the sound's own format can't be written: plays everywhere.
+ public const string SoundFallbackTarget = "mp3";
+
+ /// The id of the converter that writes clips (the engine the clip window's preview reads with too).
+ private const string ClipEngine = "ffmpeg";
+
+ /// True for a sound (an MP3, a recording): no picture, only a waveform to cut.
+ public static bool IsSound(string sourceFormat) => FormatRegistry.FindById(sourceFormat)?.Category == FormatCategory.Audio;
+
///
- /// The format a clip of a video is written in: its own when one engine writes it
- /// straight from the video (MP4 → MP4, MKV → MKV), else (RealVideo, WTV, …).
+ /// The format a clip of a file is written in: its own when one engine writes it
+ /// straight from the file (MP4 → MP4, MKV → MKV, MP3 → MP3), else for a video
+ /// (RealVideo, WTV, …) or for a sound.
///
public static string TargetFor(string sourceFormat, RoutePlanner planner) =>
- planner.Plan(sourceFormat, sourceFormat) is { Steps.Count: 1 } ? sourceFormat : FallbackTarget;
+ WritesInOneStep(sourceFormat, sourceFormat, planner) ? sourceFormat : IsSound(sourceFormat) ? SoundFallbackTarget : FallbackTarget;
- /// True when a clip can be cut of a file: it is a video that can be written.
+ ///
+ /// True when a clip can be cut of a file: it is a video, or a sound, that can be
+ /// written.
+ ///
public static bool CanCut(string sourceFormat, RoutePlanner planner) =>
- FormatRegistry.FindById(sourceFormat)?.Category == FormatCategory.Video
- && planner.Plan(sourceFormat, TargetFor(sourceFormat, planner)) is { Steps.Count: 1 };
+ (FormatRegistry.FindById(sourceFormat)?.Category is FormatCategory.Video or FormatCategory.Audio)
+ && WritesInOneStep(sourceFormat, TargetFor(sourceFormat, planner), planner);
+
+ ///
+ /// One step makes of . A sound's step must be FFmpeg's: other engines
+ /// convert sounds too (MIDI, tracker modules) and know nothing of clips.
+ ///
+ private static bool WritesInOneStep(string from, string to, RoutePlanner planner) =>
+ planner.Plan(from, to) is { Steps.Count: 1 } route && (!IsSound(from) || route.Steps[0].Converter.Id == ClipEngine);
/// The conversion that writes one stretch of a video, as recorded (see the other overload).
public static Preset ConversionOf(Preset tool, string targetFormat, ClipRange clip) =>
diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs
index 13613bb84..90dad9577 100644
--- a/src/Filee.Core/Presets/Preset.cs
+++ b/src/Filee.Core/Presets/Preset.cs
@@ -126,6 +126,9 @@ public sealed class Preset
/// Options used when a DWG or DXF is written or drawn.
public CadOptions Cad { get; set; } = new();
+ /// Options used when a G-code toolpath becomes a PDF, SVG or picture.
+ public GcodeOptions Gcode { get; set; } = new();
+
/// Options used for offline AI translation.
public TranslateOptions Translate { get; set; } = new();
@@ -380,10 +383,11 @@ public bool RecognizesTextOf(string sourceFormat) =>
///
/// True if converting a file cuts a clip of it ( ,
- /// set by the clip window on the conversion it starts): the file is a video. Other files convert as usual.
+ /// set by the clip window on the conversion it starts): the file is a video or a sound. Other files convert as usual.
///
public bool CutsClipOf(string sourceFormat) =>
- Media.Clip is { Parts.Count: > 0 } && Formats.FormatRegistry.FindById(sourceFormat)?.Category == Formats.FormatCategory.Video;
+ Media.Clip is { Parts.Count: > 0 }
+ && Formats.FormatRegistry.FindById(sourceFormat)?.Category is Formats.FormatCategory.Video or Formats.FormatCategory.Audio;
///
/// True if converting a file splits it into parts by stream copy
@@ -444,6 +448,7 @@ public Preset ForSharing()
Archive = Archive.Clone(),
Trace = Trace.Clone(),
Cad = Cad.Clone(),
+ Gcode = Gcode.Clone(),
Translate = Translate.Clone(),
Upscale = Upscale.Clone(),
Metadata = Metadata?.Clone(),
@@ -1206,7 +1211,7 @@ public enum PdfRotation
Rotate180,
}
-/// The paper "Pages per sheet" puts the pages on ( ).
+/// The paper for "Pages per sheet" or for fitting merged pages to one size.
public enum PdfSheetPaper
{
A4,
@@ -1216,7 +1221,7 @@ public enum PdfSheetPaper
SameAsFirstPage,
}
-/// Which way a sheet of "Pages per sheet" stands.
+/// Which way a sheet of "Pages per sheet" or a merged PDF stands.
public enum PdfSheetOrientation
{
/// Whichever fits the first page larger.
@@ -1243,6 +1248,17 @@ public sealed class PdfOptions
/// Add a bookmark for each original source file when merging several files into one PDF.
public bool SourceBookmarks { get; set; }
+ /// Fit every merged page to one paper size after input conversions and before PDF finishing.
+ public bool NormalizeMergePaper { get; set; }
+
+ /// With : the paper, independent of image and N-up page settings.
+ [JsonConverter(typeof(LenientEnumJsonConverter))]
+ public PdfSheetPaper MergePaper { get; set; } = PdfSheetPaper.A4;
+
+ /// With : the orientation, chosen once from the first imported page.
+ [JsonConverter(typeof(LenientEnumJsonConverter))]
+ public PdfSheetOrientation MergeOrientation { get; set; } = PdfSheetOrientation.Auto;
+
public PdfPageSize PageSize { get; set; } = PdfPageSize.FitImage;
/// Page margin in millimetres (ignored for ).
@@ -1566,6 +1582,12 @@ public sealed class MediaOptions
///
public double RingtoneLength { get; set; } = DefaultRingtoneLength;
+ ///
+ /// M4R (iPhone ringtone): ask for the part of each sound every time, in the "Cut a clip" window (#1291), instead
+ /// of using and .
+ ///
+ public bool AskRingtonePart { get; set; }
+
///
/// The part of a sound of (null when unknown) that becomes the ringtone: where it
/// starts and how long it is. A hand-edited preset file may hold anything, so both are brought within their limits:
@@ -1875,6 +1897,30 @@ public sealed class CadOptions
public CadOptions Clone() => (CadOptions)MemberwiseClone();
}
+/// What a G-code file (3D printer or CNC) shows when it becomes a picture, an SVG or a PDF.
+public enum GcodeView
+{
+ /// The toolpath seen from above.
+ Top,
+ /// The toolpath seen from the front left corner, above the bed, so the height of the print shows.
+ Isometric,
+ ///
+ /// The preview the slicer saved in the file (PrusaSlicer, Cura, OrcaSlicer, Bambu Studio …), for pictures; the
+ /// toolpath from above for SVG and PDF, and for a file without one.
+ ///
+ Thumbnail,
+}
+
+/// Options for G-code files drawn into a PDF, SVG or picture.
+public sealed class GcodeOptions
+{
+ /// A choice from a newer Filee reads as .
+ [JsonConverter(typeof(LenientEnumJsonConverter))]
+ public GcodeView View { get; set; }
+
+ public GcodeOptions Clone() => (GcodeOptions)MemberwiseClone();
+}
+
/// Options for offline AI document translation.
public sealed class TranslateOptions
{
diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs
index 45dd824bb..041d5b4aa 100644
--- a/src/Filee.Core/Settings/AppSettings.cs
+++ b/src/Filee.Core/Settings/AppSettings.cs
@@ -170,7 +170,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 = 91;
+ public const int CurrentSchemaVersion = 97;
public int SchemaVersion { get; set; } = CurrentSchemaVersion;
@@ -287,7 +287,7 @@ public sealed class AppSettings
///
/// With LibreOffice installed, presentations (PPTX, PPT, ODP) → PDF go through it instead of Filee's slide renderer.
- /// Off by default since schema 91, which turned it off once for everyone: the slide renderer keeps backgrounds and
+ /// Off by default since schema 97, which turned it off once for everyone: the slide renderer keeps backgrounds and
/// shape positions, which the route it used to replace (HWPX writer and rhwp) lost.
///
public bool LibreOfficeForSlides { get; set; }
@@ -296,7 +296,7 @@ public sealed class AppSettings
public string? PreferredOllamaModel { get; set; }
///
- /// Settings before schema 90 only: HWP / HWPX → PDF through LibreOffice and H2Orestart instead of rhwp. LibreOffice
+ /// Settings before schema 96 only: HWP / HWPX → PDF through LibreOffice and H2Orestart instead of rhwp. LibreOffice
/// no longer opens HWP; the migration turns on for whoever chose this (the other way
/// around rhwp) and clears it, so it is never written again.
///
@@ -305,7 +305,7 @@ public sealed class AppSettings
///
/// Filee's own renderer lays HWP, HWPX, documents read into Filee's document model and PowerPoint decks out to PDF
- /// (on by default; schema 91 turned it on once for everyone). Off, they go through the HWPX writer and rhwp. Files
+ /// (on by default; schema 97 turned it on once for everyone). Off, they go through the HWPX writer and rhwp. Files
/// with what it doesn't draw yet keep that route either way.
///
public bool FileeRenderer { get; set; } = true;
@@ -351,6 +351,14 @@ public sealed class AppSettings
///
public WindowBounds? MainWindow { get; set; }
+ ///
+ /// Where the Donut toolbar page's donut on screen was dragged to: its centre against the main window's top-left
+ /// corner, in screen pixels (points on macOS) ( , ), so it
+ /// opens there beside the window again. Null until it has been dragged, and again once it is dropped onto the
+ /// window. A Filee from before it skips the property, so it needs no schema step.
+ ///
+ public WindowBounds? DonutPreviewPlace { get; set; }
+
///
/// The fields turned off on the Fields page (ids of ): a format that belongs only to
/// them is left out of the donut and the preset lists. An empty list and a missing value both mean every field is on, so
diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs
index 4db7d42a6..85e258bd8 100644
--- a/src/Filee.Core/Settings/UserDataStore.cs
+++ b/src/Filee.Core/Settings/UserDataStore.cs
@@ -117,6 +117,14 @@ public void SaveUiScale(double scale)
Write(SettingsPath, Settings, FileeJsonContext.Default.AppSettings);
}
+ ///
+ /// Remembers the donut's look ( : size, hole, opacity, output format, group ring) and
+ /// where its preview was dragged ( ), quietly as
+ /// : the donut reads its look each time it opens, and the theme, triggers and integration
+ /// that applies again haven't changed.
+ ///
+ public void SaveDonutLook() => Write(SettingsPath, Settings, FileeJsonContext.Default.AppSettings);
+
///
/// Remembers where the main window was ( ), quietly as :
/// nothing else depends on it.
@@ -464,26 +472,26 @@ public static void Apply(AppSettings settings)
if (settings.SchemaVersion < 21)
InsertBefore(settings.EnginePriority, "wic", settings.EnginePriority.Contains("ffmpeg") ? "ffmpeg" : "pandoc");
- // v89: Unhwp is gone; the built-in "hwp-text" engine (HWP / HWPX → TXT, Markdown, HTML) takes its place in the
- // order, so every other engine keeps its rank. (Built-in engines branch; was schema 58 there before main
- // took 58–88. May be renumbered again if another change takes it first.)
- if (settings.SchemaVersion < 89)
+ // v95: Unhwp is gone; the built-in "hwp-text" engine (HWP / HWPX → TXT, Markdown, HTML) takes its place in the
+ // order, so every other engine keeps its rank. (Built-in engines branch; was schema 58, then 89, there
+ // before main took 58–94. May be renumbered again if another change takes it first.)
+ if (settings.SchemaVersion < 95)
ReplaceEngine(settings.EnginePriority, "unhwp", "hwp-text");
- // v90: LibreOffice no longer opens HWP / HWPX (no H2Orestart, no Java). Whoever sent HWP → PDF through it to get
- // around rhwp gets the other way around rhwp, Filee's renderer. (Built-in engines branch; was schema 59 there.
- // May be renumbered again if another change takes it first.)
- if (settings.SchemaVersion < 90 && settings.LibreOfficeForHwp == true)
+ // v96: LibreOffice no longer opens HWP / HWPX (no H2Orestart, no Java). Whoever sent HWP → PDF through it to get
+ // around rhwp gets the other way around rhwp, Filee's renderer. (Built-in engines branch; was schema 59, then
+ // 90, there. May be renumbered again if another change takes it first.)
+ if (settings.SchemaVersion < 96 && settings.LibreOfficeForHwp == true)
settings.FileeRenderer = true;
settings.LibreOfficeForHwp = null;
- // v91: Filee's renderer is the default document → PDF engine, and its slide renderer draws presentations. The
+ // v97: Filee's renderer is the default document → PDF engine, and its slide renderer draws presentations. The
// renderer is turned on for everyone (it was a preview of unreleased builds, so an "off" there was no
// choice against a default), and "Use LibreOffice for presentations", on by default since it beat the
// HWPX writer and rhwp, is turned off once so decks reach the slide renderer; whoever turns it back on
// keeps it. The other LibreOffice switches were off by default and stay as the user set them.
- // (Built-in engines branch. May be renumbered if another change takes it first.)
- if (settings.SchemaVersion < 91)
+ // (Built-in engines branch; was schema 91 there. May be renumbered if another change takes it first.)
+ if (settings.SchemaVersion < 97)
{
settings.FileeRenderer = true;
settings.LibreOfficeForSlides = false;
@@ -1162,6 +1170,30 @@ public static bool ApplyToLibrary(int from, List presets, List? s
/// Formats Filee reads (all in-process; 7-Zip, when installed, only as a fallback).
public static readonly IReadOnlyList BuiltInFormats =
[
- "zip", "jar", "zxp", "7z", "rar", "tar", "tgz", "tbz2", "txz", "tar7z", "tz", "tlz", "gz", "bz2", "xz", "lzma", "z",
- "lz", "cpio", "deb", "rpm", "iso", "alz", "egg", "split", "cab", "lha", "arj", "dmg", "img",
+ "zip", "jar", "zxp", "nupkg", "apks", "aab", "7z", "rar", "tar", "tgz", "tbz2", "txz", "tar7z", "tz", "tlz", "gz", "bz2",
+ "xz", "lzma", "z", "lz", "cpio", "deb", "rpm", "iso", "alz", "egg", "split", "cab", "lha", "arj", "dmg", "img",
"mcpack", "mcaddon", "mcworld", "mctemplate", "mrpack",
];
/// Formats 7-Zip can read too, so it may try again after an in-process reader gave up.
private static readonly HashSet SevenZipAlsoReads =
[
- "zip", "jar", "zxp", "7z", "rar", "tar", "tgz", "tbz2", "txz", "tar7z", "tz", "gz", "bz2", "xz", "lzma", "z", "cpio",
- "deb", "rpm", "iso", "split", "cab", "lha", "arj", "dmg", "img", "mcpack", "mcaddon", "mcworld", "mctemplate", "mrpack",
+ "zip", "jar", "zxp", "nupkg", "apks", "aab", "7z", "rar", "tar", "tgz", "tbz2", "txz", "tar7z", "tz", "gz", "bz2", "xz",
+ "lzma", "z", "cpio", "deb", "rpm", "iso", "split", "cab", "lha", "arj", "dmg", "img", "mcpack", "mcaddon", "mcworld",
+ "mctemplate", "mrpack",
];
/// A TAR inside a compressed stream (or a 7z): unpacking the outer layer yields one .tar file.
@@ -185,6 +186,9 @@ internal static void ExtractInProcess(string archive, string format, string fold
case "zip":
case "jar":
case "zxp":
+ case "nupkg":
+ case "apks":
+ case "aab":
case "mcpack":
case "mcaddon":
case "mcworld":
diff --git a/src/Filee.Engines/Archives/LibraryArchives.cs b/src/Filee.Engines/Archives/LibraryArchives.cs
index 05c6d3499..6f741374f 100644
--- a/src/Filee.Engines/Archives/LibraryArchives.cs
+++ b/src/Filee.Engines/Archives/LibraryArchives.cs
@@ -4,7 +4,7 @@
// 7-Zip's). When SharpCompress can't read such a 7z either, the method Filee's reader named is the error (only 7-Zip
// unpacks it, see ArchiveExtractor.NeedsSevenZip). The archive is listed first and checked by ArchiveSafety; encrypted
// archives and entries are refused, links are left out, sizes and (for ZIP and 7z) CRC-32s are checked as each file
-// is written.
+// is written. A ZIP whose directory isn't at its end (cut off) is refused as damaged before SharpCompress sees it.
using System.Globalization;
using System.Text;
@@ -32,6 +32,8 @@ public static void Extract(string archive, string folder, CancellationToken canc
if (type == ArchiveType.Zip && ZipReader.TryExtract(file, name, folder, cancellationToken)
|| TryExtractSevenZip(file, name, folder, cancellationToken, out unsupported))
return;
+ if (type == ArchiveType.Zip && !ZipReader.EndsWithDirectory(file))
+ throw CutOff(name);
}
// A file Filee opens itself: SharpCompress leaves the one it opens by path open when the archive can't be
// read (a 7z with encrypted names), and the folder could not be cleared afterwards.
@@ -63,6 +65,8 @@ public static void Extract(Stream stream, string name, string folder, Cancellati
throw unsupported ?? ArchiveSafety.NotAnArchive(name);
if (type == ArchiveType.Zip && ZipReader.TryExtract(stream, name, folder, cancellationToken))
return;
+ if (type == ArchiveType.Zip && !ZipReader.EndsWithDirectory(stream))
+ throw CutOff(name);
stream.Position = 0;
var options = Options(type);
options.LeaveStreamOpen = true;
@@ -77,6 +81,9 @@ public static void Extract(Stream stream, string name, string folder, Cancellati
}
}
+ /// A ZIP without its directory at the end ( ), as when cut off.
+ private static Exception CutOff(string name) => ArchiveSafety.Damaged(name, "its directory at the end is missing");
+
///
/// True when SharpCompress gave up on a 7z Filee's reader left to it: not for refusals (password, bombs, unsafe
/// paths), cancellation or a full or unwritable disk, which stay the error.
diff --git a/src/Filee.Engines/Archives/ZipReader.cs b/src/Filee.Engines/Archives/ZipReader.cs
index e74a91b79..e82e0e7b1 100644
--- a/src/Filee.Engines/Archives/ZipReader.cs
+++ b/src/Filee.Engines/Archives/ZipReader.cs
@@ -3,6 +3,7 @@
// (LZMA, BZip2, PPMd, Deflate64 … from 7-Zip or WinZip) is left to SharpCompress, before anything is written. Names
// without the UTF-8 flag are read as UTF-8 when valid, else with the system's OEM code page, as in LibraryArchives.
+using System.Buffers.Binary;
using System.IO.Compression;
using System.Text;
@@ -11,6 +12,31 @@ namespace Filee.Engines.Archives;
/// ZIP through System.IO.Compression.
internal static class ZipReader
{
+ ///
+ /// True when the ZIP in ends with its end-of-central-directory record and the comment
+ /// that record declares. A ZIP cut off before its end has no such record there; a search backwards would find the
+ /// record of a ZIP stored inside it (an APK inside an .xapk) instead, and SharpCompress would unpack that one.
+ ///
+ public static bool EndsWithDirectory(Stream stream)
+ {
+ const int recordSize = 22;
+ var length = stream.Length;
+ if (length < recordSize)
+ return false;
+ var span = (int)Math.Min(length, recordSize + ushort.MaxValue);
+ var tail = new byte[span];
+ stream.Position = length - span;
+ stream.ReadExactly(tail);
+ stream.Position = 0;
+ for (var i = span - recordSize; i >= 0; i--)
+ {
+ if (tail[i] == (byte)'P' && tail[i + 1] == (byte)'K' && tail[i + 2] == 5 && tail[i + 3] == 6
+ && i + recordSize + BinaryPrimitives.ReadUInt16LittleEndian(tail.AsSpan(i + 20)) == span)
+ return true;
+ }
+ return false;
+ }
+
///
/// Unpacks the ZIP in ; false (nothing written) when an entry needs SharpCompress or
/// .NET cannot read the archive at all.
diff --git a/src/Filee.Engines/CompressionEstimator.cs b/src/Filee.Engines/CompressionEstimator.cs
index 6169354a7..e5a2c2d05 100644
--- a/src/Filee.Engines/CompressionEstimator.cs
+++ b/src/Filee.Engines/CompressionEstimator.cs
@@ -91,7 +91,7 @@ public static Task ReadAsync(IReadOnlyList files,
if (!ffprobeLooked)
(ffprobe, ffprobeLooked) = (FfmpegConverter.Locate()?.Ffprobe, true);
if (ffprobe is not null)
- entry.Media = await MediaInfo.ProbeAsync(ffprobe, path, cancellationToken);
+ entry.Media = await MediaInfo.ProbeAsync(ffprobe, MediaInput.For(path), cancellationToken);
break;
}
}
diff --git a/src/Filee.Engines/EngineRegistry.cs b/src/Filee.Engines/EngineRegistry.cs
index 367298fb7..d10d7640f 100644
--- a/src/Filee.Engines/EngineRegistry.cs
+++ b/src/Filee.Engines/EngineRegistry.cs
@@ -82,6 +82,7 @@ public static IReadOnlyList CreateAll(EngineEnvironment env, ILogger
new FfmpegConverter(loggers?.CreateLogger()),
new CadConverter(),
new DrawingConverter(),
+ new Gcode.GcodeConverter(loggers?.CreateLogger()),
new ModelConverter(),
new ImageTo3DConverter(),
new GeoConverter(),
@@ -91,6 +92,7 @@ public static IReadOnlyList CreateAll(EngineEnvironment env, ILogger
new VisionConverter(), // before Tesseract: a Mac's OCR step (Tesseract reads the languages Vision doesn't)
new TesseractConverter(),
new CalibreConverter(),
+ new WeasyPrintConverter(),
new LibreOfficeConverter(env, loggers?.CreateLogger()),
]);
diff --git a/src/Filee.Engines/Gcode/GcodeConverter.cs b/src/Filee.Engines/Gcode/GcodeConverter.cs
new file mode 100644
index 000000000..d17065dff
--- /dev/null
+++ b/src/Filee.Engines/Gcode/GcodeConverter.cs
@@ -0,0 +1,148 @@
+// G-code (3D printers and CNC machines) → SVG, PDF and pictures, without a slicer: the toolpath is read
+// (GcodeToolpath) and drawn (GcodeDrawing) with SkiaSharp. For a picture, the preset can ask for the preview the slicer
+// saved in the file instead (GcodeThumbnail).
+
+using System.Text;
+using Filee.Core.Conversion;
+using Filee.Core.Formats;
+using Filee.Core.Presets;
+using Filee.Engines.Infrastructure;
+using Filee.Engines.Magick;
+using Filee.Engines.Vector;
+using ImageMagick;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using SkiaSharp;
+
+namespace Filee.Engines.Gcode;
+
+/// G-code → SVG, PDF, PNG, JPG, WEBP, TIFF, BMP, GIF, ICO, AVIF, …
+///
+/// The toolpath is drawn from above or in an isometric view ( ); with
+/// , pictures are the slicer's own preview when the file has one. PDF is direct (not
+/// through a picture), so it stays vector; DXF and DWG come from the SVG through the vector and CAD engines.
+///
+public sealed class GcodeConverter(ILogger? logger = null) : IConverter, IImageTransformer
+{
+ private readonly ILogger _logger = logger ?? NullLogger.Instance;
+
+ public string Id => "gcode";
+ public string DisplayName => "G-code (built-in)";
+
+ /// A large print's toolpath takes a few hundred MB while it is drawn, so only a few run at once.
+ public int MaxParallelism => Math.Clamp(Environment.ProcessorCount / 2, 1, 4);
+
+ /// Only when drawing to a picture (not to SVG or PDF).
+ public bool Transforms(string from, string to) => ImageEncoder.Writable.Contains(to);
+
+ public IReadOnlyList Edges { get; } =
+ [
+ new("gcode", "svg"),
+ new("gcode", "pdf"),
+ .. ImageEncoder.Writable.Select(target => ImageEncoder.Edge("gcode", target)),
+ ];
+
+ public EngineStatus GetStatus() => EngineStatus.Available(version: EngineVersions.BuiltIn);
+
+ public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) =>
+ Task.Run>(() =>
+ {
+ var view = step.Preset.Gcode.View;
+ if (view == GcodeView.Thumbnail && ImageEncoder.Writable.Contains(step.To))
+ {
+ if (Thumbnail(step, cancellationToken) is { } written)
+ return written;
+ step.Notice?.Invoke("notice.gcode_no_thumbnail");
+ }
+ progress?.Report(0.05);
+ var toolpath = GcodeToolpath.Read(step.InputPath, cancellationToken);
+ _logger.LogInformation("{File}: {Kind} toolpath, {Lines} line(s), {Points} point(s), {Layers} layer(s)", step.InputPath,
+ toolpath.IsPrinter ? "3D printer" : "CNC", toolpath.Lines.Count, toolpath.Lines.Sum(l => l.Points.Count), toolpath.Layers);
+ progress?.Report(0.5);
+ var scene = GcodeDrawing.Build(toolpath, view);
+ cancellationToken.ThrowIfCancellationRequested();
+ progress?.Report(0.7);
+ var result = step.To switch
+ {
+ "svg" => WriteSvg(step, scene),
+ "pdf" => WritePdf(step, scene),
+ _ => WritePicture(step, scene),
+ };
+ progress?.Report(1);
+ return result;
+ }, cancellationToken);
+
+ private static IReadOnlyList WriteSvg(ConversionStep step, GcodeDrawing.Scene scene)
+ {
+ if (step.Output.Allocate("svg") is not { } output)
+ return [];
+ using (var writer = new StreamWriter(output, false, new UTF8Encoding(false)))
+ GcodeDrawing.WriteSvg(scene, writer);
+ return [output];
+ }
+
+ private static IReadOnlyList WritePdf(ConversionStep step, GcodeDrawing.Scene scene)
+ {
+ if (step.Output.Allocate("pdf") is not { } output)
+ return [];
+ GcodeDrawing.WritePdf(scene, output, FormatRegistry.NameWithoutExtension(step.InputPath), step.Preset.Document.PdfA);
+ return [output];
+ }
+
+ ///
+ /// The drawing as a picture, rendered at the size it ends up at as the vector engine renders an SVG (the preset's
+ /// render DPI, resize or custom size; see ).
+ ///
+ private static IReadOnlyList WritePicture(ConversionStep step, GcodeDrawing.Scene scene)
+ {
+ const float pixelsPerMm = SvgRenderer.CssDpi / 25.4f;
+ var bounds = new SKRect(0, 0, scene.Width * pixelsPerMm, scene.Height * pixelsPerMm);
+ using var recorder = new SKPictureRecorder();
+ var canvas = recorder.BeginRecording(bounds);
+ canvas.Scale(pixelsPerMm);
+ GcodeDrawing.Draw(scene, canvas);
+ using var picture = recorder.EndRecording();
+ var scale = SvgRenderer.RasterScale(bounds, step.Preset.Image, step.Preset.Pdf);
+ using var image = SvgRenderer.Rasterize(picture, bounds, scale);
+ // Rendered at its final size: the preset's resize must not scale the bitmap again (as VectorConverter.ToRaster).
+ var options = step.Preset.Image.Clone();
+ options.Resize = ResizeMode.None;
+ options.MinShortEdge = null;
+ ImageEncoder.ApplyOptions(image, options, step.To);
+ return ImageEncoder.Write([image], step.To, step.Output, options);
+ }
+
+ /// The slicer's preview as the picture, or null when the file has none that reads.
+ private IReadOnlyList? Thumbnail(ConversionStep step, CancellationToken ct)
+ {
+ GcodeThumbnail.Image? thumbnail;
+ using (var stream = GcodeToolpath.Open(step.InputPath))
+ thumbnail = GcodeThumbnail.Largest(stream, ct);
+ if (thumbnail is null)
+ return null;
+ MagickImage image;
+ try
+ {
+ // The header first: a thumbnail that says it is small may still unpack to a huge picture.
+ var settings = new MagickReadSettings { Format = ImageEncoder.ToMagickFormat(thumbnail.Format) };
+ var info = new MagickImageInfo(thumbnail.Data, settings);
+ if (info.Width is 0 or > GcodeThumbnail.MaxSide || info.Height is 0 or > GcodeThumbnail.MaxSide)
+ {
+ _logger.LogWarning("{File}: the {Format} thumbnail is {Width}×{Height}, past {Max} pixels", step.InputPath, thumbnail.Format, info.Width, info.Height, GcodeThumbnail.MaxSide);
+ return null;
+ }
+ image = new MagickImage(thumbnail.Data, settings);
+ }
+ catch (MagickException ex)
+ {
+ _logger.LogWarning("{File}: the {Format} thumbnail doesn't read: {Message}", step.InputPath, thumbnail.Format, ex.Message);
+ return null;
+ }
+ using (image)
+ {
+ _logger.LogInformation("{File}: the slicer's {Width}×{Height} {Format} thumbnail", step.InputPath, image.Width, image.Height, thumbnail.Format);
+ ImageEncoder.ApplyOptions(image, step.Preset.Image, step.To);
+ return ImageEncoder.Write([image], step.To, step.Output, step.Preset.Image);
+ }
+ }
+}
diff --git a/src/Filee.Engines/Gcode/GcodeDrawing.cs b/src/Filee.Engines/Gcode/GcodeDrawing.cs
new file mode 100644
index 000000000..7b0c532bc
--- /dev/null
+++ b/src/Filee.Engines/Gcode/GcodeDrawing.cs
@@ -0,0 +1,266 @@
+// Draws a G-code toolpath (GcodeToolpath), from above or in an isometric view, at its real size in millimetres.
+// Extrusion (a printer's) or cuts (a CNC machine's) are drawn wide, coloured by height from dark (low) to light (high)
+// so the layers or depths read apart; travel moves (a CNC machine's rapids) are thin lines, blue for a printer and red
+// for a CNC machine, as slicer and CAM previews draw them. The drawing is written as SVG, or drawn with SkiaSharp
+// straight into a picture or a PDF: a large print's SVG runs to a hundred MB, too slow to read back.
+
+using System.Collections;
+using System.Globalization;
+using System.Numerics;
+using System.Text;
+using Filee.Core.Presets;
+using SkiaSharp;
+
+namespace Filee.Engines.Gcode;
+
+internal static class GcodeDrawing
+{
+ /// Colours from low to high: dark violet to orange (part of "magma"), all readable on white.
+ private static readonly Vector3[] Ramp =
+ [
+ new(0x2C, 0x11, 0x5F), new(0x72, 0x1F, 0x81), new(0xB7, 0x37, 0x79), new(0xF1, 0x60, 0x5D), new(0xFD, 0x95, 0x67),
+ ];
+
+ private static readonly SKColor TravelColor = SKColor.Parse("#1C7ED6");
+ private static readonly SKColor RapidColor = SKColor.Parse("#E03131");
+
+ /// Opacity of travel lines, so the part shows through them.
+ private const float TravelOpacity = 0.6f;
+
+ /// Height bands the colours are split in.
+ private const int Bands = 24;
+
+ ///
+ /// Points closer than this share of the drawing's long side are merged: about a pixel of a 6000-pixel picture, so
+ /// the drawing stays small without a visible change.
+ ///
+ private const float PixelShare = 1f / 6000;
+
+ private static readonly float Cos30 = MathF.Sqrt(3) / 2;
+
+ /// One colour of lines: polylines in millimetres from the drawing's top left corner.
+ internal sealed record Stroke(List> Polylines, SKColor Color, float Width, float Opacity);
+
+ /// A drawing: its size in millimetres and its strokes, in the order they are drawn.
+ internal sealed record Scene(float Width, float Height, List Strokes);
+
+ /// The toolpath seen from above (also for ) or isometric.
+ internal static Scene Build(GcodeToolpath.Toolpath toolpath, GcodeView view)
+ {
+ var iso = view == GcodeView.Isometric;
+ Vector2 Project(Vector3 p) => iso
+ ? new Vector2((p.X - p.Y) * Cos30, -((p.X + p.Y) * 0.5f + p.Z))
+ : new Vector2(p.X, -p.Y);
+
+ float left = float.MaxValue, top = float.MaxValue, right = float.MinValue, bottom = float.MinValue;
+ float low = float.MaxValue, high = float.MinValue;
+ foreach (var line in toolpath.Work)
+ foreach (var point in line.Points)
+ {
+ var p = Project(point);
+ left = MathF.Min(left, p.X);
+ right = MathF.Max(right, p.X);
+ top = MathF.Min(top, p.Y);
+ bottom = MathF.Max(bottom, p.Y);
+ low = MathF.Min(low, point.Z);
+ high = MathF.Max(high, point.Z);
+ }
+ var extent = MathF.Max(MathF.Max(right - left, bottom - top), 1);
+ var workWidth = toolpath.IsPrinter ? (toolpath.NozzleDiameter ?? 0.4f) * 1.1f : MathF.Max(extent / 500, 0.1f);
+ var margin = MathF.Max(extent * 0.03f, workWidth * 2);
+ left -= margin;
+ top -= margin;
+ float width = right - left + margin, height = bottom - top + margin;
+ var tolerance = extent * PixelShare;
+ var origin = new Vector2(left, top);
+
+ // Runs of points per kind of move and height band, projected and with points closer than the tolerance merged.
+ int BandOf(float z) => high - low < 1e-3f ? 0 : Math.Clamp((int)((z - low) / (high - low) * Bands), 0, Bands - 1);
+ var runs = new List>[2, Bands];
+ foreach (var line in toolpath.Lines)
+ {
+ var kind = (int)line.Kind;
+ List? run = null;
+ var band = -1;
+ var last = Project(line.Points[0]) - origin;
+ for (var i = 1; i < line.Points.Count; i++)
+ {
+ var b = BandOf(line.Points[i].Z);
+ var p = Project(line.Points[i]) - origin;
+ if (b == band && Vector2.DistanceSquared(p, last) < tolerance * tolerance && i < line.Points.Count - 1)
+ continue;
+ if (b != band)
+ {
+ // The next band goes on from where this one stopped.
+ band = b;
+ run = [last];
+ (runs[kind, b] ??= []).Add(run);
+ }
+ run!.Add(p);
+ last = p;
+ }
+ }
+
+ // Drawn from the lowest band up, work then travel in each: higher layers cover the travel under them, as on the
+ // printed part, and the travel of a layer shows over its own moves. A segment that a later one of the same kind
+ // covers exactly (to the tolerance) is left out: from above, the segments of a straight wall repeat on every
+ // layer of it, and only the top one shows. Found from the last drawn back.
+ const int Work = (int)GcodeToolpath.MoveKind.Work, Travel = (int)GcodeToolpath.MoveKind.Travel;
+ var hidden = new Dictionary, BitArray>(ReferenceEqualityComparer.Instance);
+ var drawn = new HashSet();
+ var grid = 1 / tolerance;
+ long Key(Vector2 a, Vector2 b, int kind)
+ {
+ long Cell(Vector2 p) => Math.Clamp((long)(p.X * grid), 0, 0x7FFF) << 15 | Math.Clamp((long)(p.Y * grid), 0, 0x7FFF);
+ long ca = Cell(a), cb = Cell(b);
+ return (long)kind << 60 | Math.Min(ca, cb) << 30 | Math.Max(ca, cb);
+ }
+ for (var b = Bands - 1; b >= 0; b--)
+ foreach (var kind in (int[])[Travel, Work])
+ {
+ if (runs[kind, b] is not { } kindRuns)
+ continue;
+ for (var r = kindRuns.Count - 1; r >= 0; r--)
+ {
+ var run = kindRuns[r];
+ BitArray? skip = null;
+ for (var i = run.Count - 2; i >= 0; i--)
+ if (!drawn.Add(Key(run[i], run[i + 1], kind)))
+ (skip ??= new BitArray(run.Count - 1))[i] = true;
+ if (skip is not null)
+ hidden[run] = skip;
+ }
+ }
+
+ var strokes = new List();
+ for (var b = 0; b < Bands; b++)
+ foreach (var kind in (int[])[Work, Travel])
+ {
+ if (runs[kind, b] is not { } kindRuns)
+ continue;
+ var polylines = new List>();
+ foreach (var run in kindRuns)
+ {
+ if (!hidden.TryGetValue(run, out var skip))
+ {
+ polylines.Add(run);
+ continue;
+ }
+ List? piece = null;
+ for (var i = 0; i + 1 < run.Count; i++)
+ {
+ if (skip[i])
+ {
+ piece = null;
+ continue;
+ }
+ if (piece is null)
+ polylines.Add(piece = [run[i]]);
+ piece.Add(run[i + 1]);
+ }
+ }
+ if (polylines.Count > 0)
+ strokes.Add(kind == Work
+ ? new Stroke(polylines, Color(b), workWidth, 1)
+ : new Stroke(polylines, toolpath.IsPrinter ? TravelColor : RapidColor, workWidth * 0.2f, TravelOpacity));
+ }
+ return new Scene(width, height, strokes);
+ }
+
+ /// Writes the drawing as SVG at its real size, on white, to .
+ internal static void WriteSvg(Scene scene, TextWriter writer)
+ {
+ static string N(float v) => v.ToString("0.###", CultureInfo.InvariantCulture);
+ writer.Write(string.Create(CultureInfo.InvariantCulture,
+ $""""""));
+ writer.Write(string.Create(CultureInfo.InvariantCulture, $""" """));
+ var d = new StringBuilder();
+ foreach (var stroke in scene.Strokes)
+ {
+ // One path per stroke; its data is built in a reused buffer so the whole document is never held in memory.
+ d.Clear();
+ foreach (var line in stroke.Polylines)
+ {
+ d.Append(CultureInfo.InvariantCulture, $"M{N(line[0].X)} {N(line[0].Y)}");
+ for (var i = 1; i < line.Count; i++)
+ d.Append(CultureInfo.InvariantCulture, $"L{N(line[i].X)} {N(line[i].Y)}");
+ }
+ var color = $"#{stroke.Color.Red:X2}{stroke.Color.Green:X2}{stroke.Color.Blue:X2}";
+ var opacity = stroke.Opacity < 1 ? $" stroke-opacity=\"{N(stroke.Opacity)}\"" : "";
+ writer.Write(" """));
+ }
+ writer.Write(" ");
+ }
+
+ /// The drawing as an SVG string (for tests and small drawings).
+ internal static string ToSvg(Scene scene)
+ {
+ using var writer = new StringWriter(CultureInfo.InvariantCulture);
+ WriteSvg(scene, writer);
+ return writer.ToString();
+ }
+
+ /// Draws the drawing on a canvas whose unit is the millimetre, white paper first.
+ internal static void Draw(Scene scene, SKCanvas canvas)
+ {
+ using (var paper = new SKPaint { Color = SKColors.White })
+ canvas.DrawRect(0, 0, scene.Width, scene.Height, paper);
+ foreach (var stroke in scene.Strokes)
+ {
+ using var paint = new SKPaint
+ {
+ Style = SKPaintStyle.Stroke,
+ IsAntialias = true,
+ StrokeWidth = stroke.Width,
+ StrokeCap = SKStrokeCap.Round,
+ StrokeJoin = SKStrokeJoin.Round,
+ Color = stroke.Color.WithAlpha((byte)Math.Round(stroke.Opacity * 255)),
+ };
+ using var path = new SKPath();
+ foreach (var line in stroke.Polylines)
+ {
+ path.MoveTo(line[0].X, line[0].Y);
+ for (var i = 1; i < line.Count; i++)
+ path.LineTo(line[i].X, line[i].Y);
+ }
+ canvas.DrawPath(path, paint);
+ }
+ }
+
+ /// The drawing as a one-page vector PDF at its real size.
+ internal static void WritePdf(Scene scene, string output, string title, bool pdfA)
+ {
+ const float pointsPerMm = 72 / 25.4f;
+ var metadata = SKDocumentPdfMetadata.Default;
+ metadata.Title = title;
+ metadata.Creator = "Filee";
+ metadata.Producer = "Filee (Skia PDF)";
+ metadata.Creation = DateTime.Now;
+ metadata.Modified = DateTime.Now;
+ metadata.PdfA = pdfA;
+ using (var stream = File.Create(output))
+ using (var document = SKDocument.CreatePdf(stream, metadata))
+ {
+ var canvas = document.BeginPage(scene.Width * pointsPerMm, scene.Height * pointsPerMm);
+ canvas.Scale(pointsPerMm);
+ Draw(scene, canvas);
+ document.EndPage();
+ document.Close();
+ }
+ if (new FileInfo(output).Length == 0)
+ throw new InvalidOperationException("Skia could not write the PDF.");
+ }
+
+ /// The colour of a height band, from the ramp.
+ private static SKColor Color(int band)
+ {
+ var t = (float)band / (Bands - 1) * (Ramp.Length - 1);
+ var i = Math.Min((int)t, Ramp.Length - 2);
+ var c = Vector3.Lerp(Ramp[i], Ramp[i + 1], t - i);
+ return new SKColor((byte)c.X, (byte)c.Y, (byte)c.Z);
+ }
+}
diff --git a/src/Filee.Engines/Gcode/GcodeThumbnail.cs b/src/Filee.Engines/Gcode/GcodeThumbnail.cs
new file mode 100644
index 000000000..af972483a
--- /dev/null
+++ b/src/Filee.Engines/Gcode/GcodeThumbnail.cs
@@ -0,0 +1,110 @@
+// The preview a slicer saves inside a G-code file, for the printer's screen and for file browsers: PrusaSlicer,
+// SuperSlicer, Cura, OrcaSlicer and Bambu Studio write it as base64 comment lines between "; thumbnail begin WxH LENGTH"
+// and "; thumbnail end", in PNG, JPG or QOI ("thumbnail_JPG begin", "thumbnail_QOI begin"). Several sizes are common
+// (a small one for the printer's file list, a large one for its preview); the largest is used.
+
+using System.Globalization;
+using System.Text;
+
+namespace Filee.Engines.Gcode;
+
+internal static class GcodeThumbnail
+{
+ /// Largest side of a thumbnail read: slicers write up to about 1024 × 1024.
+ internal const int MaxSide = 4096;
+
+ /// Largest base64 text of one thumbnail: a 4096 × 4096 PNG of a render is far smaller.
+ internal const int MaxBase64Chars = 32 * 1024 * 1024;
+
+ /// A thumbnail: its size as the slicer wrote it, its image format (png, jpg, qoi) and its file.
+ internal sealed record Image(int Width, int Height, string Format, byte[] Data);
+
+ ///
+ /// The largest thumbnail in the header of a G-code file (before its first move), or null when it has none. A
+ /// thumbnail whose base64 is broken or longer than it says is skipped; one past or
+ /// is not read.
+ ///
+ internal static Image? Largest(Stream stream, CancellationToken ct)
+ {
+ (int Width, int Height, StringBuilder Text, int Declared)? reading = null;
+ Image? best = null;
+ var count = 0;
+ foreach (var line in GcodeToolpath.Lines(stream))
+ {
+ if ((++count & 0xFFFF) == 0)
+ ct.ThrowIfCancellationRequested();
+ var text = line.AsSpan().TrimStart();
+ if (text.Length == 0 || text[0] != ';')
+ {
+ // Slicers write the previews in the header: the first move ends the search, so a large file without one
+ // isn't read to its end.
+ if (GcodeToolpath.Words(line, out _) is [('G', >= 0 and < 4), ..])
+ break;
+ reading = null; // the comment block ended without "thumbnail end"
+ continue;
+ }
+ text = text[1..].Trim();
+ if (reading is not { } open)
+ {
+ if (Begin(text) is { } begin && (best is null || (long)begin.Width * begin.Height > (long)best.Width * best.Height))
+ reading = (begin.Width, begin.Height, new StringBuilder(Math.Min(begin.Declared, 1 << 20)), begin.Declared);
+ continue;
+ }
+ if (text.StartsWith("thumbnail", StringComparison.OrdinalIgnoreCase) && text.EndsWith("end", StringComparison.OrdinalIgnoreCase))
+ {
+ reading = null;
+ if (Decode(open.Text) is { } data && Sniff(data) is { } format)
+ best = new Image(open.Width, open.Height, format, data);
+ continue;
+ }
+ if (open.Text.Length + text.Length > Math.Min(MaxBase64Chars, open.Declared + 16))
+ {
+ reading = null; // longer than declared: not the thumbnail the header announced
+ continue;
+ }
+ open.Text.Append(text);
+ }
+ return best;
+ }
+
+ /// "thumbnail begin 220x124 7532", "thumbnail_QOI begin 480x240 9876": the size and base64 length.
+ private static (int Width, int Height, int Declared)? Begin(ReadOnlySpan text)
+ {
+ if (!text.StartsWith("thumbnail", StringComparison.OrdinalIgnoreCase))
+ return null;
+ var parts = text.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length < 4 || !parts[1].Equals("begin", StringComparison.OrdinalIgnoreCase))
+ return null;
+ var size = parts[2].Split('x');
+ if (parts[0].ToUpperInvariant() is not ("THUMBNAIL" or "THUMBNAIL_PNG" or "THUMBNAIL_JPG" or "THUMBNAIL_JPEG" or "THUMBNAIL_QOI")
+ || size.Length != 2
+ || !int.TryParse(size[0], NumberStyles.None, CultureInfo.InvariantCulture, out var width)
+ || !int.TryParse(size[1], NumberStyles.None, CultureInfo.InvariantCulture, out var height)
+ || !int.TryParse(parts[3], NumberStyles.None, CultureInfo.InvariantCulture, out var declared)
+ || width is < 1 or > MaxSide || height is < 1 or > MaxSide || declared is < 1 or > MaxBase64Chars)
+ return null;
+ return (width, height, declared);
+ }
+
+ private static byte[]? Decode(StringBuilder base64)
+ {
+ try
+ {
+ return Convert.FromBase64String(base64.ToString());
+ }
+ catch (FormatException)
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// The image format the data is in, by its first bytes, not by the header: Cura plug-ins and printer-firmware
+ /// scripts write JPG under a plain "thumbnail begin" (PNG's). Null for anything else.
+ ///
+ private static string? Sniff(ReadOnlySpan data) =>
+ data.StartsWith((ReadOnlySpan)[0x89, (byte)'P', (byte)'N', (byte)'G']) ? "png"
+ : data.StartsWith((ReadOnlySpan)[0xFF, 0xD8, 0xFF]) ? "jpg"
+ : data.StartsWith("qoif"u8) ? "qoi"
+ : null;
+}
diff --git a/src/Filee.Engines/Gcode/GcodeToolpath.cs b/src/Filee.Engines/Gcode/GcodeToolpath.cs
new file mode 100644
index 000000000..9012f98cf
--- /dev/null
+++ b/src/Filee.Engines/Gcode/GcodeToolpath.cs
@@ -0,0 +1,537 @@
+// The moves of a G-code file: what a slicer (PrusaSlicer, Cura, OrcaSlicer, Bambu Studio, SuperSlicer …) writes for a 3D
+// printer, or a CAM program (Fusion, FreeCAD, LinuxCNC …) for a CNC machine. Read as polylines in millimetres, each
+// point with its height, for GcodeDrawing to draw.
+//
+// Read: G0 / G1 (moves), G2 / G3 (arcs by I J K centre offsets or by R, in the G17 / G18 / G19 plane, helical when the
+// third axis moves too), G20 / G21 (inches, millimetres), G90 / G91 (absolute, relative), M82 / M83 (extruder absolute,
+// relative), G92 (set position), G28 (home), modal moves (a line of coordinates goes on with the last G0-G3, as CAM
+// programs write it), line numbers (N) and checksums (*), ";" and "( )" comments. A printer file is one that extrudes
+// (E grows on a move): its extruding moves are "extrusion", every other move is travel. A file that never extrudes is
+// CNC: G1 / G2 / G3 cut, G0 is a rapid (travel). Cutter compensation (G41 / G42), tool length (G43) and work offsets
+// (G54 …) aren't applied: the programmed path is drawn. A line with any other G code (G4, G10, G29, G30, G38.x, G53,
+// canned cycles G81 …) isn't a move, nor are macros and expressions (Klipper, RepRapFirmware meta commands).
+
+using System.Globalization;
+using System.Numerics;
+using System.Text;
+
+namespace Filee.Engines.Gcode;
+
+internal static class GcodeToolpath
+{
+ /// Largest file read: a big multi-colour print is a few hundred MB.
+ internal const long MaxFileBytes = 1L << 30;
+
+ ///
+ /// Points kept for drawing, after points closer than are merged. A 300 MB print keeps about
+ /// 6 million; a file past this is too detailed to draw as one picture.
+ ///
+ internal const int MaxPoints = 12_000_000;
+
+ /// Points closer than this (mm) to the last point kept on a line are dropped, the line's end kept.
+ private const float Tolerance = 0.02f;
+
+ /// Characters of a line that are read; the rest of a longer line (a long comment) is skipped.
+ internal const int MaxLineChars = 4096;
+
+ /// Arcs are split in segments of at most this angle: a full circle is 72 segments.
+ private const double ArcStepRadians = Math.PI / 36;
+
+ /// A coordinate past this (mm) is not a machine's: a broken or hostile number.
+ private const double MaxCoordinate = 1e6;
+
+ /// What a polyline does.
+ internal enum MoveKind
+ {
+ /// A travel move of a printer, or a CNC rapid (G0).
+ Travel,
+ /// Extrusion on a printer, or a cut (G1 / G2 / G3) on a CNC machine.
+ Work,
+ }
+
+ /// A polyline of one kind of move, in millimetres (X, Y, Z).
+ internal sealed record Line(MoveKind Kind, List Points);
+
+ /// The toolpath of a file.
+ /// The polylines in file order (the order they were made in).
+ /// Whether the file extrudes (a 3D printer's) rather than cuts (a CNC machine's).
+ /// The layers of a print (layer change comments, else the heights extruded at); 0 for CNC.
+ /// The nozzle the slicer says it sliced for (mm), when it says so.
+ internal sealed record Toolpath(List Lines, bool IsPrinter, int Layers, float? NozzleDiameter)
+ {
+ /// The work moves (extrusion, cuts): what the picture is framed on.
+ public IEnumerable Work => Lines.Where(l => l.Kind == MoveKind.Work);
+ }
+
+ /// Reads the toolpath of a file.
+ /// The file is not text G-code, holds no moves, or is too large.
+ internal static Toolpath Read(string path, CancellationToken ct)
+ {
+ using var stream = Open(path);
+ return Read(stream, ct);
+ }
+
+ /// Opens a G-code file for reading, once its size is checked against .
+ /// The file is larger.
+ internal static FileStream Open(string path)
+ {
+ var size = new FileInfo(path).Length;
+ if (size > MaxFileBytes)
+ throw new InvalidDataException($"The G-code file is {size / (1024 * 1024)} MB; Filee draws files up to {MaxFileBytes / (1024 * 1024)} MB.");
+ return File.OpenRead(path);
+ }
+
+ ///
+ internal static Toolpath Read(Stream stream, CancellationToken ct)
+ {
+ var machine = new Machine();
+ var count = 0;
+ foreach (var line in Lines(stream))
+ {
+ if ((++count & 0xFFFF) == 0)
+ ct.ThrowIfCancellationRequested();
+ machine.Run(line);
+ }
+ return machine.Finish();
+ }
+
+ // ───────────────────────── Lines ─────────────────────────
+
+ ///
+ /// The lines of a text file, at most characters each. Fails on binary data: binary G-code
+ /// (.bgcode) and other files that aren't text.
+ ///
+ internal static IEnumerable Lines(Stream stream)
+ {
+ var head = new byte[4];
+ var read = stream.ReadAtLeast(head, 4, throwOnEndOfStream: false);
+ if (read == 4 && head.AsSpan().SequenceEqual("GCDE"u8))
+ throw new InvalidDataException("This is binary G-code (.bgcode), not text G-code: rename it to .bgcode.");
+ stream.Position = 0;
+ using var reader = new StreamReader(stream, new UTF8Encoding(false, false), detectEncodingFromByteOrderMarks: true, bufferSize: 1 << 16, leaveOpen: true);
+ var buffer = new char[1 << 16];
+ var line = new StringBuilder(256);
+ var skipping = false;
+ long checkedChars = 0;
+ int n;
+ while ((n = reader.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ for (var i = 0; i < n; i++)
+ {
+ var c = buffer[i];
+ if (c == '\n' || c == '\r')
+ {
+ if (line.Length > 0)
+ yield return line.ToString();
+ line.Clear();
+ skipping = false;
+ continue;
+ }
+ // A NUL in the first 64 KB: not a text file. (Later NULs, as a broken SD card leaves, are skipped.)
+ if (c == '\0' && checkedChars + i < 1 << 16)
+ throw new InvalidDataException("The file is not text G-code: it holds binary data.");
+ if (skipping)
+ continue;
+ if (line.Length >= MaxLineChars)
+ {
+ skipping = true;
+ continue;
+ }
+ line.Append(c);
+ }
+ checkedChars += n;
+ }
+ if (line.Length > 0)
+ yield return line.ToString();
+ }
+
+ ///
+ /// The words of a line (a letter, upper case, and its number) before its checksum, and its comment, if any. A word
+ /// whose number doesn't parse (a RepRapFirmware expression in braces) is left out, and the line ends at anything
+ /// that isn't a word (M117's message, a quoted macro name). Null when the line is not a G-code block: a Klipper or
+ /// RepRapFirmware command such as SET_FAN_SPEED, EXCLUDE_OBJECT_START or "if", whose first letter has no number.
+ ///
+ internal static List<(char Letter, double Value)>? Words(string line, out string? comment)
+ {
+ comment = null;
+ var words = new List<(char, double)>();
+ var i = 0;
+ while (i < line.Length)
+ {
+ var c = line[i];
+ if (c == ';')
+ {
+ comment = line[(i + 1)..];
+ break;
+ }
+ if (c == '(')
+ {
+ var close = line.IndexOf(')', i + 1);
+ comment ??= close < 0 ? line[(i + 1)..] : line[(i + 1)..close];
+ if (close < 0)
+ break;
+ i = close + 1;
+ continue;
+ }
+ if (c is ' ' or '\t' or '%')
+ {
+ i++;
+ continue;
+ }
+ if (c == '*' || !char.IsAsciiLetter(c))
+ break; // a checksum, or text that isn't G-code words
+ i++;
+ // "X 10": some CAM programs put a space between the letter and its number.
+ while (i + 1 < line.Length && line[i] == ' ' && IsNumberChar(line[i + 1]))
+ i++;
+ var start = i;
+ while (i < line.Length && IsNumberChar(line[i]))
+ i++;
+ if (i == start)
+ {
+ if (words.Count == 0)
+ return null;
+ if (i < line.Length && line[i] == '{')
+ {
+ var close = line.IndexOf('}', i);
+ i = close < 0 ? line.Length : close + 1;
+ continue;
+ }
+ break;
+ }
+ if (double.TryParse(line.AsSpan(start, i - start), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) && double.IsFinite(value))
+ words.Add((char.ToUpperInvariant(c), value));
+ }
+ return words;
+ }
+
+ private static bool IsNumberChar(char c) => char.IsAsciiDigit(c) || c is '.' or '-' or '+';
+
+ // ───────────────────────── Machine ─────────────────────────
+
+ /// The state of the machine while the file runs.
+ private sealed class Machine
+ {
+ private readonly List _lines = [];
+ /// Heights extruded at (0.01 mm), to count layers where the file has no layer comments.
+ private readonly HashSet _extrudedHeights = [];
+ private const int MaxHeights = 100_000;
+ private Line? _current;
+ private int _points;
+ private bool _relative;
+ private bool _relativeE;
+ private bool _extruderModeSet;
+ private double _unit = 1;
+ private int _motion = -1;
+ private int _plane = 17;
+ private bool _extruded;
+ private int _layerComments;
+ private int _firstLayerLine = -1;
+ private float? _nozzle;
+ private Vector3 _at;
+ private double _e;
+ private bool _knownX;
+ private bool _knownY;
+
+ /// Raw kinds before the file is known as a printer's or a CNC machine's.
+ private enum Raw { Rapid, Feed, Extrude }
+
+ private readonly List _raw = [];
+
+ public void Run(string text)
+ {
+ var words = Words(text, out var comment);
+ if (comment is not null)
+ Comment(comment.Trim());
+ if (words is null || words.Count == 0)
+ return;
+
+ var motion = -1;
+ bool coordinates = false, otherCommand = false;
+ foreach (var (letter, value) in words)
+ {
+ switch (letter)
+ {
+ case 'G' when (int)Math.Round(value * 10) is 0 or 10 or 20 or 30:
+ motion = (int)Math.Round(value);
+ break;
+ case 'M':
+ otherCommand = true;
+ // M82 / M83 decide the extruder's mode from then on. Marlin's G90 / G91 also set it, Klipper's
+ // don't; a slicer that writes M83 never relies on G90 to undo it, so after M82 / M83 they don't.
+ if ((int)value is 82 or 83)
+ {
+ _relativeE = (int)value == 83;
+ _extruderModeSet = true;
+ }
+ break;
+ case 'T':
+ otherCommand = true;
+ break;
+ case 'X' or 'Y' or 'Z' or 'I' or 'J' or 'K' or 'R':
+ coordinates = true;
+ break;
+ }
+ }
+
+ // Other G codes of the line first: units, distance mode and plane apply to its move.
+ foreach (var (letter, value) in words)
+ {
+ if (letter != 'G')
+ continue;
+ var g = (int)Math.Round(value * 10);
+ switch (g)
+ {
+ case 0 or 10 or 20 or 30: break; // the motion itself (G0–G3), handled below
+ case 170 or 180 or 190: _plane = g / 10; break;
+ case 200: _unit = 25.4; break;
+ case 210: _unit = 1; break;
+ case 900 or 910:
+ _relative = g == 910;
+ if (!_extruderModeSet)
+ _relativeE = _relative;
+ break;
+ case 920: SetPosition(words); return;
+ case 280: Home(words); return;
+ case 800: _motion = -1; return;
+ // Modes that a move may share its line with (the programmed path is drawn: compensation and
+ // offsets aren't applied): cutter compensation, tool length, work offsets, path control, feed modes.
+ case 400 or 410 or 420 or 430 or 490 or >= 540 and <= 599 or 610 or 611 or 640 or 930 or 940:
+ break;
+ // Any other G code carries X / Y / Z words that are not a move to draw: dwell, offsets, bed
+ // levelling (G29), probing (G30, G38.x), machine coordinates (G53), canned cycles (G81 …).
+ default:
+ return;
+ }
+ }
+
+ // A line of coordinates (also after G90 or G43 H1) goes on with the last move: modal, as CAM programs write.
+ if (motion < 0 && coordinates && !otherCommand)
+ motion = _motion;
+ if (motion < 0)
+ return;
+ _motion = motion;
+ Move(motion, words);
+ }
+
+ private void Comment(string comment)
+ {
+ // PrusaSlicer, OrcaSlicer, Bambu Studio: ";LAYER_CHANGE"; Cura: ";LAYER:12"; Simplify3D: "; layer 3, Z = 0.6".
+ if (comment.StartsWith("LAYER_CHANGE", StringComparison.Ordinal) || comment.StartsWith("LAYER:", StringComparison.Ordinal)
+ || comment.StartsWith("layer ", StringComparison.Ordinal) && comment.Contains("Z =", StringComparison.Ordinal))
+ {
+ if (_layerComments++ == 0)
+ {
+ EndLine();
+ _firstLayerLine = _lines.Count;
+ }
+ }
+ else if (_nozzle is null && comment.StartsWith("nozzle_diameter = ", StringComparison.Ordinal)
+ && float.TryParse(comment.AsSpan(18).TrimEnd().ToString().Split(',')[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var nozzle)
+ && nozzle is > 0.05f and < 5)
+ _nozzle = nozzle;
+ }
+
+ private double Number(List<(char Letter, double Value)> words, char letter, double fallback)
+ {
+ foreach (var (l, v) in words)
+ if (l == letter)
+ return v;
+ return fallback;
+ }
+
+ private static bool Has(List<(char Letter, double Value)> words, char letter) => words.Any(w => w.Letter == letter);
+
+ private void SetPosition(List<(char Letter, double Value)> words)
+ {
+ EndLine();
+ var any = words.Any(w => w.Letter is 'X' or 'Y' or 'Z' or 'E');
+ _at = new Vector3(
+ (float)(any ? Number(words, 'X', _at.X / _unit) * _unit : 0),
+ (float)(any ? Number(words, 'Y', _at.Y / _unit) * _unit : 0),
+ (float)(any ? Number(words, 'Z', _at.Z / _unit) * _unit : 0));
+ _e = any ? Number(words, 'E', _e) : 0;
+ _knownX |= !any || Has(words, 'X');
+ _knownY |= !any || Has(words, 'Y');
+ }
+
+ private void Home(List<(char Letter, double Value)> words)
+ {
+ EndLine();
+ var axes = words.Where(w => w.Letter is 'X' or 'Y' or 'Z').Select(w => w.Letter).ToHashSet();
+ var all = axes.Count == 0;
+ _at = new Vector3(all || axes.Contains('X') ? 0 : _at.X, all || axes.Contains('Y') ? 0 : _at.Y, all || axes.Contains('Z') ? 0 : _at.Z);
+ // Home is wherever the endstops are, often off the bed: the move away from it is not drawn.
+ _knownX &= !(all || axes.Contains('X'));
+ _knownY &= !(all || axes.Contains('Y'));
+ }
+
+ private Vector3 Target(List<(char Letter, double Value)> words)
+ {
+ double Axis(char letter, float current)
+ {
+ if (!Has(words, letter))
+ return current;
+ var value = Number(words, letter, 0) * _unit;
+ return _relative ? current + value : value;
+ }
+ var target = new Vector3((float)Axis('X', _at.X), (float)Axis('Y', _at.Y), (float)Axis('Z', _at.Z));
+ if (Math.Abs(target.X) > MaxCoordinate || Math.Abs(target.Y) > MaxCoordinate || Math.Abs(target.Z) > MaxCoordinate)
+ throw new InvalidDataException("The G-code moves to a coordinate no machine reaches; the file is damaged.");
+ return target;
+ }
+
+ private void Move(int motion, List<(char Letter, double Value)> words)
+ {
+ var target = Target(words);
+ var extrudes = false;
+ if (Has(words, 'E'))
+ {
+ var e = Number(words, 'E', 0);
+ var delta = _relativeE ? e : e - _e;
+ _e = _relativeE ? _e + e : e;
+ extrudes = delta > 1e-6;
+ }
+ var raw = motion == 0 ? Raw.Rapid : extrudes ? Raw.Extrude : Raw.Feed;
+ if (extrudes)
+ _extruded = true;
+ var knewXY = _knownX && _knownY;
+ // An absolute coordinate makes its axis known; a relative one from an unknown position doesn't.
+ _knownX |= !_relative && Has(words, 'X');
+ _knownY |= !_relative && Has(words, 'Y');
+
+ if (!knewXY)
+ {
+ // From an unknown position (power-up, after homing): nothing to draw yet.
+ EndLine();
+ _at = target;
+ return;
+ }
+
+ if (motion is 2 or 3)
+ Arc(motion == 2, words, target, raw);
+ else
+ LineTo(target, raw);
+ if (raw == Raw.Extrude && target.Z == _at.Z && _extrudedHeights.Count < MaxHeights)
+ _extrudedHeights.Add((int)MathF.Round(target.Z * 100));
+ _at = target;
+ }
+
+ private void LineTo(Vector3 to, Raw raw)
+ {
+ if (_current is null || _raw[^1] != raw)
+ {
+ EndLine();
+ _current = new Line(MoveKind.Work, [_at]);
+ _lines.Add(_current);
+ _raw.Add(raw);
+ _points++;
+ }
+ var points = _current.Points;
+ if (points[^1] == to)
+ return;
+ // Merge points closer than the tolerance: keep the newest as the line's end, so the line still ends where
+ // the moves did.
+ if (points.Count >= 2 && Vector3.DistanceSquared(points[^2], points[^1]) < Tolerance * Tolerance)
+ {
+ points[^1] = to;
+ return;
+ }
+ if (++_points > MaxPoints)
+ throw new InvalidDataException($"The G-code file has more than {MaxPoints:N0} moves to draw; it is too detailed to draw as one picture.");
+ points.Add(to);
+ }
+
+ private void Arc(bool clockwise, List<(char Letter, double Value)> words, Vector3 target, Raw raw)
+ {
+ // The arc's plane: G17 XY (the usual one), G18 ZX, G19 YZ; the third axis moves linearly (a helix).
+ var (a, b, c, ia, ib) = _plane switch
+ {
+ 18 => (2, 0, 1, 'K', 'I'),
+ 19 => (1, 2, 0, 'J', 'K'),
+ _ => (0, 1, 2, 'I', 'J'),
+ };
+ static double Get(Vector3 v, int axis) => axis switch { 0 => v.X, 1 => v.Y, _ => v.Z };
+ double sa = Get(_at, a), sb = Get(_at, b), ea = Get(target, a), eb = Get(target, b);
+ double ca, cb;
+ if (Has(words, 'R') && !Has(words, ia) && !Has(words, ib))
+ {
+ // Radius form: of the two centres, the one on the side the direction asks for; a negative R takes the
+ // longer arc.
+ var r = Number(words, 'R', 0) * _unit;
+ double dx = ea - sa, dy = eb - sb, d = Math.Sqrt(dx * dx + dy * dy);
+ if (d < 1e-9 || Math.Abs(r) < d / 2 - 1e-6)
+ {
+ LineTo(target, raw); // no such arc: a straight line, as most controllers treat it
+ return;
+ }
+ var h = Math.Sqrt(Math.Max(0, r * r - d * d / 4)) * ((clockwise ^ r < 0) ? -1 : 1);
+ ca = sa + dx / 2 - h * dy / d;
+ cb = sb + dy / 2 + h * dx / d;
+ }
+ else
+ {
+ ca = sa + Number(words, ia, 0) * _unit;
+ cb = sb + Number(words, ib, 0) * _unit;
+ }
+ var radius = Math.Sqrt((sa - ca) * (sa - ca) + (sb - cb) * (sb - cb));
+ if (!(radius >= 1e-6 && radius <= MaxCoordinate)) // NaN too (R1e300 over a short chord)
+ {
+ LineTo(target, raw);
+ return;
+ }
+ var start = Math.Atan2(sb - cb, sa - ca);
+ var end = Math.Atan2(eb - cb, ea - ca);
+ var sweep = end - start;
+ if (clockwise && sweep >= -1e-9)
+ sweep -= 2 * Math.PI;
+ else if (!clockwise && sweep <= 1e-9)
+ sweep += 2 * Math.PI;
+ // Same start and end: a full circle (the sweep above is then a full turn).
+ var steps = Math.Max(1, (int)Math.Ceiling(Math.Abs(sweep) / ArcStepRadians));
+ double sc = Get(_at, c), ec = Get(target, c);
+ for (var i = 1; i <= steps; i++)
+ {
+ var t = (double)i / steps;
+ var angle = start + sweep * t;
+ var p = new double[3];
+ p[a] = ca + radius * Math.Cos(angle);
+ p[b] = cb + radius * Math.Sin(angle);
+ p[c] = sc + (ec - sc) * t;
+ LineTo(i == steps ? target : new Vector3((float)p[0], (float)p[1], (float)p[2]), raw);
+ }
+ }
+
+ private void EndLine() => _current = null;
+
+ public Toolpath Finish()
+ {
+ // Lines of one point only marked where a kind of move started: dropped. In a print with layer comments, what
+ // comes before the first layer is the printer's start G-code (a purge line at the edge of the bed, wiping)
+ // and so is the travel from there to the first extrusion: not the part, so not drawn and not framed on. The
+ // moves after the last extrusion (parking the head) are the end G-code: not drawn either.
+ var first = Math.Max(0, _firstLayerLine);
+ while (_extruded && _firstLayerLine >= 0 && first < _lines.Count && _raw[first] != Raw.Extrude)
+ first++;
+ var end = _lines.Count;
+ while (_extruded && end > first && _raw[end - 1] != Raw.Extrude)
+ end--;
+ var lines = new List(_lines.Count);
+ for (var i = first; i < end; i++)
+ {
+ if (_lines[i].Points.Count < 2)
+ continue;
+ var kind = _extruded
+ ? _raw[i] == Raw.Extrude ? MoveKind.Work : MoveKind.Travel
+ : _raw[i] == Raw.Rapid ? MoveKind.Travel : MoveKind.Work;
+ lines.Add(_lines[i] with { Kind = kind });
+ }
+ if (!lines.Any(l => l.Kind == MoveKind.Work))
+ throw new InvalidDataException(lines.Count == 0
+ ? "No G-code moves found: the file has no G0 / G1 / G2 / G3 moves (it may not be G-code)."
+ : "The G-code file only moves, it neither extrudes nor cuts: nothing to draw.");
+ var layers = _extruded ? _layerComments > 0 ? _layerComments : _extrudedHeights.Count : 0;
+ return new Toolpath(lines, _extruded, layers, _nozzle);
+ }
+ }
+}
diff --git a/src/Filee.Engines/Heic/HeicWriter.cs b/src/Filee.Engines/Heic/HeicWriter.cs
index 7f3497e0e..f03dead9b 100644
--- a/src/Filee.Engines/Heic/HeicWriter.cs
+++ b/src/Filee.Engines/Heic/HeicWriter.cs
@@ -72,7 +72,7 @@ internal static async Task EncodeAsync(string ffmpeg, string png, int wi
///
private static List Prepare(ConversionStep step, CancellationToken cancellationToken)
{
- using var images = ImageReader.Read(step.InputPath, step.From, step.Preset);
+ using var images = ImageReader.Read(step.InputPath, step.From, step.Preset, cancellationToken);
var pictures = new List();
foreach (var frame in ImageEncoder.SelectFrames(images, AvifSequence.SourceFormat(step.From, step.InputPath), "heic"))
{
diff --git a/src/Filee.Engines/Icns/IcnsConverter.cs b/src/Filee.Engines/Icns/IcnsConverter.cs
index 185391c7e..6f2086354 100644
--- a/src/Filee.Engines/Icns/IcnsConverter.cs
+++ b/src/Filee.Engines/Icns/IcnsConverter.cs
@@ -36,7 +36,7 @@ public Task> ConvertAsync(ConversionStep step, IProgress
private static IReadOnlyList WriteIcon(ConversionStep step, IProgress? progress, CancellationToken ct)
{
- using var images = ImageReader.Read(step.InputPath, step.From, step.Preset);
+ using var images = ImageReader.Read(step.InputPath, step.From, step.Preset, ct);
// An icon holds one picture: the first page/frame, or the largest icon of an .ico.
var master = ImageEncoder.SelectFrames(images, step.From, "icns")[0];
ImageEncoder.ApplyOptions(master, step.Preset.Image, "icns");
diff --git a/src/Filee.Engines/Infrastructure/CopyVersions.cs b/src/Filee.Engines/Infrastructure/CopyVersions.cs
index 5c0fe5440..4dc6f7a79 100644
--- a/src/Filee.Engines/Infrastructure/CopyVersions.cs
+++ b/src/Filee.Engines/Infrastructure/CopyVersions.cs
@@ -23,9 +23,52 @@ public static partial class CopyVersions
///
/// The version of the copy in (major.minor), or null when it can't be told (e.g. an
- /// FFmpeg git build named by date). Cached per program and file time.
+ /// FFmpeg git build named by date). Cached per program and file time once the program answered: one that didn't
+ /// start, timed out or failed is asked again next time, while one that answered keeps its version until its file
+ /// changes, even if it stops working (Runs starts it again).
///
public static Version? Installed(string id, string folder)
+ {
+ if (ProgramOf(id, folder) is not { } program)
+ return null;
+ var key = $"{program}|{File.GetLastWriteTimeUtc(program).Ticks}";
+ if (Cache.TryGetValue(key, out var known))
+ return known;
+ var (text, works) = Read(id, program);
+ var version = ParseVersion(id, text);
+ // A start that failed or timed out once (many programs started together, a library missing until repaired) is
+ // tried again next time.
+ if (works || version is not null)
+ Cache[key] = version;
+ return version;
+ }
+
+ ///
+ /// True when the copy's program in starts and answers its version switch (exit code 0, or
+ /// a version in what it printed). Started again on every call: the copy may have stopped working since (a library
+ /// it needs was removed) while the program file stayed the same.
+ ///
+ public static bool Runs(string id, string folder)
+ {
+ if (ProgramOf(id, folder) is not { } program)
+ return false;
+ if (id == "libreoffice" && OperatingSystem.IsWindows())
+ return File.Exists(Path.Combine(folder, "soffice.exe"));
+ // The file's time is taken before it runs: replaced meanwhile (an upgrade), the new file gets its own read.
+ var key = $"{program}|{File.GetLastWriteTimeUtc(program).Ticks}";
+ var (text, finished, exitCode) = Start(id, program);
+ var version = ParseVersion(id, text);
+ // The version read here serves Installed's next call for the same file, so the program starts once.
+ if (finished && (exitCode == 0 || version is not null))
+ {
+ Cache[key] = version;
+ return true;
+ }
+ return false;
+ }
+
+ /// The file the version is read from: the main program, or LibreOffice's version.ini on Windows.
+ private static string? ProgramOf(string id, string folder)
{
var program = id switch
{
@@ -33,9 +76,7 @@ public static partial class CopyVersions
_ when EngineEnvironment.OwnCopyPrograms.TryGetValue(id, out var programs) => Path.Combine(folder, programs[0]),
_ => null,
};
- if (program is null || !File.Exists(program))
- return null;
- return Cache.GetOrAdd($"{program}|{File.GetLastWriteTimeUtc(program).Ticks}", _ => ParseVersion(id, Read(id, program)));
+ return program is not null && File.Exists(program) ? program : null;
}
/// True when the copy is older (major.minor) than the version Filee is tested with.
@@ -50,28 +91,47 @@ public static bool IsOutdated(string id, string folder) =>
"ffmpeg" => FfmpegVersion().Match(text),
"pandoc" => PandocVersion().Match(text),
"calibre" => CalibreVersion().Match(text),
+ "weasyprint" => WeasyPrintVersion().Match(text),
"libreoffice" => LibreOfficeVersion().Match(text),
+ "tesseract" => TesseractVersion().Match(text),
_ => LeadingVersion().Match(text),
};
- return match.Success ? new Version(int.Parse(match.Groups[1].Value), int.Parse(match.Groups[2].Value)) : null;
+ return VersionOf(match);
}
- private static Version? Parse(string? text)
- {
- var match = LeadingVersion().Match(text ?? "");
- return match.Success ? new Version(int.Parse(match.Groups[1].Value), int.Parse(match.Groups[2].Value)) : null;
- }
+ private static Version? Parse(string? text) => VersionOf(LeadingVersion().Match(text ?? ""));
+
+ /// Major and minor from a match; null when there is none or a number is too big to be a version.
+ private static Version? VersionOf(Match match) =>
+ match.Success && int.TryParse(match.Groups[1].ValueSpan, out var major) && int.TryParse(match.Groups[2].ValueSpan, out var minor)
+ ? new Version(major, minor) : null;
- private static string Read(string id, string program)
+ /// What the program says about its version, and whether it started and ended well (exit code 0).
+ private static (string Text, bool Works) Read(string id, string program)
{
if (id == "libreoffice" && OperatingSystem.IsWindows())
{
// LibreOffice 26 dropped MsiProductVersion from version.ini; soffice.exe next to it still says it.
var soffice = Path.Combine(Path.GetDirectoryName(program)!, "soffice.exe");
var product = File.Exists(soffice) ? FileVersionInfo.GetVersionInfo(soffice).ProductVersion : null;
- return File.ReadAllText(program) + (product is null ? "" : $"\nLibreOffice {product}");
+ return (File.ReadAllText(program) + (product is null ? "" : $"\nLibreOffice {product}"), true);
}
- var arguments = id == "ffmpeg" ? "-hide_banner -version" : "--version";
+ var (text, finished, exitCode) = Start(id, program);
+ return (text, finished && exitCode == 0);
+ }
+
+ ///
+ /// Starts the program with its version switch: its output, whether it finished in time, and its exit code.
+ ///
+ private static (string Text, bool Finished, int ExitCode) Start(string id, string program)
+ {
+ // ExifTool's --version prints its manual; -ver prints the number alone.
+ var arguments = id switch
+ {
+ "ffmpeg" => "-hide_banner -version",
+ "exiftool" => "-ver",
+ _ => "--version",
+ };
try
{
using var process = Process.Start(new ProcessStartInfo(program, arguments)
@@ -82,19 +142,21 @@ private static string Read(string id, string program)
RedirectStandardError = true,
});
if (process is null)
- return "";
+ return ("", false, -1);
var output = process.StandardOutput.ReadToEndAsync();
var errors = process.StandardError.ReadToEndAsync();
if (!process.WaitForExit(10_000))
{
process.Kill(entireProcessTree: true);
- return "";
+ return ("", false, -1);
}
- return output.Result + "\n" + errors.Result;
+ return (output.Result + "\n" + errors.Result, true, process.ExitCode);
}
- catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException)
+ // AggregateException: reading what it printed failed (the pipe broke).
+ catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException
+ or AggregateException)
{
- return "";
+ return ("", false, -1);
}
}
@@ -107,9 +169,16 @@ private static string Read(string id, string program)
[GeneratedRegex(@"calibre (\d+)\.(\d+)")]
private static partial Regex CalibreVersion();
+ [GeneratedRegex(@"WeasyPrint version (\d+)\.(\d+)")]
+ private static partial Regex WeasyPrintVersion();
+
[GeneratedRegex(@"(?:MsiProductVersion=|LibreOffice\s+)(\d+)\.(\d+)")]
private static partial Regex LibreOfficeVersion();
+ // "tesseract 5.5.3" (Homebrew), "tesseract v5.4.0.20240606" (the Windows build Filee downloads).
+ [GeneratedRegex(@"^tesseract(?:\.exe)? v?(\d+)\.(\d+)", RegexOptions.Multiline)]
+ private static partial Regex TesseractVersion();
+
[GeneratedRegex(@"^\s*(\d+)\.(\d+)", RegexOptions.Multiline)]
private static partial Regex LeadingVersion();
}
diff --git a/src/Filee.Engines/Infrastructure/EngineDownloads.cs b/src/Filee.Engines/Infrastructure/EngineDownloads.cs
index b16fa7980..2c912d551 100644
--- a/src/Filee.Engines/Infrastructure/EngineDownloads.cs
+++ b/src/Filee.Engines/Infrastructure/EngineDownloads.cs
@@ -54,6 +54,9 @@ internal static string DownloadRuntime(string runtimeIdentifier, int windowsBuil
new("ghostscript", OperatingSystem.IsWindows() ? ["ghostscript", "vcruntime"] : ["ghostscript"], 31_000_000, ["ghostscript"]),
new("ffmpeg", ["ffmpeg"], 272_000_000, ["ffmpeg"]),
new("calibre", ["calibre"], 663_000_000, ["calibre"]),
+ // WeasyPrint's own Windows build (Python, Pango and HarfBuzz in one folder). Linux and macOS follow once
+ // WeasyPrint publishes a self-contained build for them (#1403); until then they keep their routes.
+ .. (OperatingSystem.IsWindows() ? [new EnginePackage("weasyprint", ["weasyprint"], 65_000_000, ["weasyprint"])] : Array.Empty()),
new("tesseract", ["tesseract"], 168_000_000, ["tesseract"]),
new("upscayl-bin", OperatingSystem.IsWindows() && RuntimeInformation.ProcessArchitecture != Architecture.Arm64 ? ["upscayl-bin", "vcruntime", "vcomp14"] : OperatingSystem.IsWindows() ? [] : ["upscayl-bin"], 8_600_000, ["upscaler"]),
.. (OperatingSystem.IsWindows() ? [new EnginePackage("whisper", ["whisper", "whisper-model"], 156_880_105, ["whisper"])] : Array.Empty()),
@@ -83,6 +86,18 @@ public static string FileName(EngineComponent component)
: name;
}
+ ///
+ /// Filee's own copy of every pinned download (Cloudflare R2 behind dl.filee.sh), filed under its SHA-256. A download
+ /// site drops old versions (LibreOffice's "stable" folder lost 26.2.6 when 26.2.7 came out, #1474), and a released
+ /// Filee can't change its pinned URL, so when the URL fails the installer takes the file from here. The pinned
+ /// SHA-256 is checked either way, so a copy here can't be anything but the pinned file.
+ ///
+ public const string MirrorRoot = "https://dl.filee.sh/engines/";
+
+ /// Where Filee's copy of is: /<sha256>/<file name>.
+ public static Uri MirrorUrl(EngineComponent component) =>
+ new(MirrorRoot + component.Sha256.ToLowerInvariant() + "/" + Uri.EscapeDataString(FileName(component)));
+
///
/// The package's downloads as text to copy (#447): per file its name, URL and SHA-256, to fetch them on a computer
/// with internet and install them here with "Install from downloaded files…".
@@ -125,11 +140,12 @@ public static string DownloadList(EnginePackage package, IReadOnlyDictionary
public static bool IsSuggested(EnginePackage package) =>
CanDownload(package) && DownloadSize(package) < SuggestLimit
- && package.Id is not ("ghostscript" or "tesseract" or "upscayl-bin" or "7zip");
+ && package.Id is not ("ghostscript" or "tesseract" or "upscayl-bin" or "7zip" or "weasyprint");
/// The running macOS; downloads whose minimumMacOS is newer are left out. Null keeps all.
public static IReadOnlyDictionary ComponentsForRuntime(string runtimeIdentifier, Version? macOSVersion = null)
diff --git a/src/Filee.Engines/Infrastructure/EngineEnvironment.cs b/src/Filee.Engines/Infrastructure/EngineEnvironment.cs
index 5b8d3477b..a4a6b0c12 100644
--- a/src/Filee.Engines/Infrastructure/EngineEnvironment.cs
+++ b/src/Filee.Engines/Infrastructure/EngineEnvironment.cs
@@ -3,7 +3,7 @@
// engines (FFmpeg, calibre, Pandoc, Ghostscript, LibreOffice, Tesseract …) are looked up in this order: a copy the
// user chose in Settings → Engines (OwnCopies), Filee's own copy (installer or download, the tested version), then one
// already on the PC (on the PATH or where its installer puts it), so nobody has to download a second FFmpeg they
-// already have. 7-Zip skips the last step (UsesSystemCopy).
+// already have. WeasyPrint, rhwp and 7-Zip skip the last step (UsesSystemCopy).
using System.Collections.Concurrent;
using System.Runtime.Versioning;
@@ -90,6 +90,7 @@ dataDirectoryOverride is not null
["ghostscript"] = [windows ? "gswin64c.exe" : "gs"],
["ffmpeg"] = windows ? ["ffmpeg.exe", "ffprobe.exe"] : ["ffmpeg", "ffprobe"],
["calibre"] = [windows ? "ebook-convert.exe" : "ebook-convert"],
+ ["weasyprint"] = [windows ? "weasyprint.exe" : "weasyprint"],
["rhwp"] = [windows ? "rhwp.exe" : "rhwp"],
["7zip"] = [windows ? "7z.exe" : "7zz"],
["tesseract"] = [windows ? "tesseract.exe" : "tesseract"],
@@ -149,18 +150,27 @@ public static bool UsesFileesCopy(string id) => OwnCopyFolder(id) is null
///
/// The folder of a copy of package already on the PC: on the PATH (also the user's and the
/// machine's PATH as saved, so a tool installed after Filee started is found, e.g. Scoop's shims) or where its
- /// installer puts it (LibreOffice, calibre, Ghostscript under Program Files). Cached until
- /// .
+ /// installer puts it (LibreOffice, calibre, Ghostscript under Program Files). Never for the engines Filee doesn't
+ /// take from the PC ( ). Cached until , or
+ /// for this package.
///
public static string? SystemCopyFolder(string id) =>
SearchSystem && UsesSystemCopy(id) ? SystemCopies.GetOrAdd(id, FindOnSystem) : null;
///
- /// False for packages Filee never takes from the PC: 7-Zip, whose installed version and switches are unknown, is
- /// used only as Filee downloads it or as the user picks it in Settings → Engines (so the Engines page never shows
- /// a 7-Zip on the PC as installed).
+ /// False for packages Filee never takes from the PC ( ). 7-Zip, whose installed version
+ /// and switches are unknown, is used only as Filee downloads it or as the user picks it in Settings → Engines (so the
+ /// Engines page never shows a 7-Zip on the PC as installed).
///
- public static bool UsesSystemCopy(string id) => id != "7zip";
+ public static bool UsesSystemCopy(string id) => !NeverFromSystem.Contains(id);
+
+ ///
+ /// Engines never taken from the PC: WeasyPrint, used only as Filee's copy or one the user picked (an old copy on
+ /// the PATH may not know --allowed-protocols, #1403), rhwp, used only as Filee's bundled copy, and 7-Zip (see
+ /// ). Settings → Engines doesn't report a copy of these found on the PC either, since it
+ /// would never be used.
+ ///
+ private static readonly HashSet NeverFromSystem = ["weasyprint", "rhwp", "7zip"];
/// A program of the copy of package on the PC, or null.
public static string? SystemProgram(string id, string program) =>
@@ -173,6 +183,12 @@ public static void ForgetSystemCopies()
ReachableShares.Clear();
}
+ ///
+ /// Searches the PC again for package only (after Filee's download of it was removed): the
+ /// other packages keep what was found, and each network share keeps its answer.
+ ///
+ public static void ForgetSystemCopy(string id) => SystemCopies.TryRemove(id, out _);
+
private static string? FindOnSystem(string id) =>
SystemFolders(id).Where(IsReachable).Select(folder => FolderWithPrograms(id, folder)).FirstOrDefault(found => found is not null);
diff --git a/src/Filee.Engines/Infrastructure/EngineInstaller.cs b/src/Filee.Engines/Infrastructure/EngineInstaller.cs
index ac0dcf02f..edae2940d 100644
--- a/src/Filee.Engines/Infrastructure/EngineInstaller.cs
+++ b/src/Filee.Engines/Infrastructure/EngineInstaller.cs
@@ -17,6 +17,7 @@
using System.Diagnostics;
using System.Formats.Tar;
using System.IO.Compression;
+using System.Runtime.ExceptionServices;
using System.Security.Cryptography;
using Filee.Core.Conversion;
using Microsoft.Extensions.Logging;
@@ -53,7 +54,11 @@ public enum EngineCopyStatus
/// Folder of the copy in use while the status is .
/// That copy was found on the PC (PATH, Program Files), not chosen by the user.
/// Filee's download is older than the version engines.json pins.
-public sealed record EngineCopy(EngineCopyStatus Status, string? Folder, bool FromSystem, bool Outdated);
+///
+/// Folder of a copy on the PC that isn't used because Filee's download is installed (it comes first); null otherwise.
+/// Settings → Engines offers it instead (#867).
+///
+public sealed record EngineCopy(EngineCopyStatus Status, string? Folder, bool FromSystem, bool Outdated, string? OtherFolder = null);
/// Another Filee (the app, the CLI or a second copy) is installing or removing the same package.
public sealed class EngineBusyException(string packageId)
@@ -85,13 +90,17 @@ public sealed class EngineInstaller
private readonly IReadOnlyDictionary _components;
private readonly ILogger _logger;
private readonly IReadOnlyList _packages;
+ private readonly Func _mirror;
/// Folder that receives one sub folder per engine (default: EngineEnvironment.DownloadRoot).
/// Download list; engines.json when null (tests pass their own).
/// Known packages (tests pass their own); EngineDownloads.Packages when null.
- public EngineInstaller(HttpClient http, string? root = null, IReadOnlyDictionary? components = null, ILogger? logger = null, IReadOnlyList? packages = null)
+ /// Where a component is taken from when its URL fails ( when null; tests pass their own).
+ public EngineInstaller(HttpClient http, string? root = null, IReadOnlyDictionary? components = null, ILogger? logger = null,
+ IReadOnlyList? packages = null, Func? mirror = null)
{
_http = http;
+ _mirror = mirror ?? EngineDownloads.MirrorUrl;
_root = root ?? EngineEnvironment.DownloadRoot;
_components = components ?? EngineDownloads.Components;
_logger = logger ?? NullLogger.Instance;
@@ -116,19 +125,71 @@ public static HttpClient CreateHttpClient(TimeSpan timeout)
public IReadOnlyDictionary Components => _components;
///
- /// The copy the engines use, in the order they look: the user's own copy, Filee's download, a copy on the PC
- /// (only looked for when neither of the others is there).
+ /// The copy the engines use, in the order they look: the user's own copy, Filee's download, a copy on the PC. A
+ /// copy on the PC is also looked for while Filee's download is used, and reported as
+ /// ; it is not used then.
///
public EngineCopy StatusOf(EnginePackage package)
{
var installed = IsInstalled(package);
var own = EngineEnvironment.OwnCopyFolder(package.Id);
- var system = own is null && !installed ? EngineEnvironment.SystemCopyFolder(package.Id) : null;
- var folder = own ?? system;
- var status = folder is not null ? EngineCopyStatus.OwnCopy
+ var system = own is null ? EngineEnvironment.SystemCopyFolder(package.Id) : null;
+ var used = own ?? (installed ? null : system);
+ var status = used is not null ? EngineCopyStatus.OwnCopy
: installed ? EngineCopyStatus.Installed
: EngineCopyStatus.NotInstalled;
- return new EngineCopy(status, folder, system is not null, folder is null && installed && !IsUpToDate(package));
+ var other = used is null && installed && system is not null && !IsFileesDownload(package.Id, system) ? system : null;
+ return new EngineCopy(status, used, own is null && !installed && system is not null,
+ used is null && installed && !IsUpToDate(package), other);
+ }
+
+ ///
+ /// True when , found on the PC, is a download of Filee's reached by another path (other
+ /// letter case, a short name, a link to the folder, to a folder above it or to its programs, as when a user links
+ /// Filee's Pandoc into /usr/local/bin or its LibreOffice.app into /Applications): going up from the folder, or from
+ /// a program in it, and following every link on the way, a folder up to three levels above has Filee's installed
+ /// marker. It isn't another copy, and using it "instead" would remove it.
+ ///
+ private static bool IsFileesDownload(string id, string folder)
+ {
+ var places = new List { folder };
+ if (EngineEnvironment.OwnCopyPrograms.TryGetValue(id, out var programs))
+ foreach (var program in programs)
+ places.Add(Path.GetDirectoryName(Resolved(new FileInfo(Path.Combine(folder, program)))) ?? folder);
+ foreach (var place in places)
+ {
+ DirectoryInfo? dir;
+ try
+ {
+ // One form for every way the PATH may write it ("~/ffbin//", "C:\ffbin\"): a link whose path ends in a
+ // separator isn't followed.
+ dir = new DirectoryInfo(Path.TrimEndingDirectorySeparator(Path.GetFullPath(place)));
+ }
+ catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException)
+ {
+ continue;
+ }
+ for (var level = 0; dir is not null && level <= 3; level++, dir = dir.Parent)
+ {
+ dir = new DirectoryInfo(Resolved(dir));
+ if (File.Exists(Path.Combine(dir.FullName, MarkerName)))
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /// The path a file or folder link ends at, or the path itself.
+ private static string Resolved(FileSystemInfo item)
+ {
+ try
+ {
+ return item.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? item.FullName;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return item.FullName;
+ }
}
/// True when every component is installed (any version).
@@ -422,9 +483,39 @@ private async Task DownloadAsync(EngineComponent component, string? loca
/// , and keeps it only when its SHA-256 is the pinned one.
///
private async Task FetchAsync(EngineComponent component, string? localFile, string file, Action reportBytes, CancellationToken cancellationToken)
+ {
+ if (localFile is not null)
+ {
+ await FetchAsync(component, null, localFile, file, reportBytes, cancellationToken);
+ return;
+ }
+ try
+ {
+ await FetchAsync(component, new Uri(component.Url), null, file, reportBytes, cancellationToken);
+ }
+ catch (Exception ex) when (ex is HttpRequestException or IOException or InvalidDataException or TaskCanceledException && !cancellationToken.IsCancellationRequested
+ && _mirror(component) is { } mirror)
+ {
+ // The pinned URL is gone or broken (a retired version, a moved release, a cut or timed-out connection, a file
+ // that was replaced): Filee's own copy, checked against the same SHA-256. The first error is the one to report.
+ _logger.LogWarning(ex, "Downloading {Component} from {Url} failed; trying {Mirror}", component.Id, component.Url, mirror);
+ try
+ {
+ await FetchAsync(component, mirror, null, file, reportBytes, cancellationToken);
+ }
+ catch (Exception mirrorError) when (mirrorError is HttpRequestException or IOException or InvalidDataException or TaskCanceledException && !cancellationToken.IsCancellationRequested)
+ {
+ _logger.LogWarning(mirrorError, "Downloading {Component} from {Mirror} failed too", component.Id, mirror);
+ ExceptionDispatchInfo.Throw(ex);
+ }
+ }
+ }
+
+ /// One attempt: from , or from when it's null.
+ private async Task FetchAsync(EngineComponent component, Uri? url, string? localFile, string file, Action reportBytes, CancellationToken cancellationToken)
{
var partial = file + ".part";
- using var response = localFile is null ? await GetFollowingRedirectsAsync(new Uri(component.Url), cancellationToken) : null;
+ using var response = url is not null ? await GetFollowingRedirectsAsync(url, cancellationToken) : null;
response?.EnsureSuccessStatusCode();
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
try
@@ -603,9 +694,10 @@ private Task UnpackAsync(EngineComponent component, string file, CancellationTok
{
case "zip":
ZipFile.ExtractToDirectory(file, staging);
- RenameExifTool(component.Id, SingleRoot(staging));
- EnsureExecutablePrograms(component.Id, SingleRoot(staging));
- Replace(component.Id, SingleRoot(staging));
+ var root = component.Id == "weasyprint" ? WeasyPrintFolder(staging) : SingleRoot(staging);
+ RenameExifTool(component.Id, root);
+ EnsureExecutablePrograms(component.Id, root);
+ Replace(component.Id, root);
break;
case "tar.gz":
case "tar.xz":
@@ -945,6 +1037,26 @@ private void CopyRuntime(EnginePackage package)
}
}
+ ///
+ /// WeasyPrint's Windows "onedir" zip holds LICENSE and README.rst at its top and the program in
+ /// onedir/weasyprint (weasyprint.exe with its _internal folder of Python, Pango, HarfBuzz, ...): that folder is
+ /// kept, with the licence moved into it.
+ ///
+ internal static string WeasyPrintFolder(string staging)
+ {
+ var program = EngineEnvironment.ProgramName("weasyprint.exe");
+ var found = Directory.EnumerateFiles(staging, program, SearchOption.AllDirectories).FirstOrDefault()
+ ?? throw new InvalidDataException($"{program} not found in the WeasyPrint package.");
+ var folder = Path.GetDirectoryName(found)!;
+ foreach (var name in new[] { "LICENSE", "README.rst" })
+ {
+ var top = Path.Combine(staging, name);
+ if (File.Exists(top) && !File.Exists(Path.Combine(folder, name)))
+ File.Move(top, Path.Combine(folder, name));
+ }
+ return folder;
+ }
+
/// An archive with a single top-level folder is unpacked without that extra level.
private static string SingleRoot(string folder)
{
diff --git a/src/Filee.Engines/Infrastructure/engines.json b/src/Filee.Engines/Infrastructure/engines.json
index 8d5ef4b6c..9e8ee1881 100644
--- a/src/Filee.Engines/Infrastructure/engines.json
+++ b/src/Filee.Engines/Infrastructure/engines.json
@@ -24,7 +24,7 @@
},
"libreoffice": {
"version": "26.2.6",
- "url": "https://download.documentfoundation.org/libreoffice/stable/26.2.6/win/x86_64/LibreOffice_26.2.6_Win_x86-64.msi",
+ "url": "https://downloadarchive.documentfoundation.org/libreoffice/old/26.2.6.3/win/x86_64/LibreOffice_26.2.6.3_Win_x86-64.msi",
"sha256": "F9877032FD908BEB9C0DDF06DF4AF5C2E85F419C42E14876C4CCE5AAE5FB2660",
"size": 373252096,
"kind": "msi"
@@ -64,6 +64,13 @@
"size": 240201728,
"kind": "msi"
},
+ "weasyprint": {
+ "version": "70.0",
+ "url": "https://github.com/Kozea/WeasyPrint/releases/download/v70.0/weasyprint-windows-onedir.zip",
+ "sha256": "AB1151F210B4E6BB7AA7A79E91A67E8DDB760094C107BFDA55241B6AAEFE7D53",
+ "size": 32271073,
+ "kind": "zip"
+ },
"tesseract": {
"version": "5.4.0.20240606",
"url": "https://github.com/UB-Mannheim/tesseract/releases/download/v5.4.0.20240606/tesseract-ocr-w64-setup-5.4.0.20240606.exe",
@@ -233,7 +240,7 @@
},
"libreoffice": {
"version": "26.2.6",
- "url": "https://download.documentfoundation.org/libreoffice/stable/26.2.6/mac/x86_64/LibreOffice_26.2.6_MacOS_x86-64.dmg",
+ "url": "https://downloadarchive.documentfoundation.org/libreoffice/old/26.2.6.3/mac/x86_64/LibreOffice_26.2.6.3_MacOS_x86-64.dmg",
"sha256": "135B8A95B8133396D54BF8E726DBC0066145EFA0D785963FC3D4592ACBFCFE5B",
"size": 308308875,
"kind": "dmg"
@@ -287,7 +294,7 @@
},
"libreoffice": {
"version": "26.2.6",
- "url": "https://download.documentfoundation.org/libreoffice/stable/26.2.6/mac/aarch64/LibreOffice_26.2.6_MacOS_aarch64.dmg",
+ "url": "https://downloadarchive.documentfoundation.org/libreoffice/old/26.2.6.3/mac/aarch64/LibreOffice_26.2.6.3_MacOS_aarch64.dmg",
"sha256": "94BB3248DF074C225490A8A6D1D9DC87C7D6783DBB7A8E9F0D0C3D94348552AF",
"size": 297798926,
"kind": "dmg"
diff --git a/src/Filee.Engines/Magick/ImageEncoder.cs b/src/Filee.Engines/Magick/ImageEncoder.cs
index 9363269f8..676a62e7e 100644
--- a/src/Filee.Engines/Magick/ImageEncoder.cs
+++ b/src/Filee.Engines/Magick/ImageEncoder.cs
@@ -12,11 +12,12 @@ internal static class ImageEncoder
{
///
/// Image formats ImageMagick can read (see for RAW, layered and metafile sources).
- /// Camera RAW is decoded by LibRaw; XCF, HEIC, DICOM, Photo CD, OpenEXR, Radiance HDR and FITS are read only. EMF/WMF
+ /// Camera RAW is decoded by LibRaw; HEIC, DICOM, Photo CD, OpenEXR, Radiance HDR and FITS are read only. EMF/WMF
/// are drawn by Filee's own metafile renderer on every platform ( ). APNG is
/// read and written by Filee's own (its frames decoded and encoded by ImageMagick); FITS is read
/// by Filee's own and written to TIFF by . ANI (an animated cursor) is
- /// read by Filee's own , its frames decoded by ImageMagick.
+ /// read by Filee's own , its frames decoded by ImageMagick. GIMP's XCF is read and written by Filee's
+ /// own and .
///
public static readonly string[] Readable =
[
@@ -40,8 +41,11 @@ internal static class ImageEncoder
"apng", "mng", "qoi", "dds", "pcx", "cur", "xpm", "xbm", "wbmp", "pam", "pfm", "sgi", "ras", "miff",
];
- /// Formats that store several frames/pages in one file.
- private static readonly HashSet MultiFrame = ["tiff", "gif", "apng", "mng"];
+ ///
+ /// Formats that store several frames/pages in one file. A Valve texture (VTF) is an animated texture with them
+ /// ( ).
+ ///
+ private static readonly HashSet MultiFrame = ["tiff", "gif", "apng", "mng", "vtf"];
///
/// Formats whose frames are an animation; an AVIF only when it is an image sequence ( ), and
@@ -92,9 +96,10 @@ public static bool KeepsAnimation(string sourceFormat, string targetFormat) =>
///
/// Packages of a picture and more, of which the picture is read ( ): an iPhone LIVP, a Krita
- /// KRA, a Paint.NET PDN ( ), the icon of a Windows program or library ( ).
+ /// KRA, a Paint.NET PDN ( ), a Valve texture (VTF, ), the icon of a Windows
+ /// program or library ( ).
///
- public static readonly string[] Packages = ["livp", "kra", "pdn", FormatRegistry.Program];
+ public static readonly string[] Packages = ["livp", "kra", "pdn", "vtf", FormatRegistry.Program];
///
/// Every format the image engine reads as input: the raster formats, the picture of and the
@@ -185,7 +190,7 @@ public static List> SelectFrames(MagickImageCollection images
///
/// The longest side a format stores: JPEG 65,535 px (65,500 keeps clear of encoder rounding), WebP 16,383 px,
- /// AVIF 16,384 px. Other formats take more than any image Filee makes.
+ /// AVIF 16,384 px, Paint.NET 65,535 px, GIMP 524,288 px. Other formats take more than any image Filee makes.
///
public static double MaxSide(string targetFormat) => targetFormat switch
{
@@ -193,6 +198,8 @@ public static List> SelectFrames(MagickImageCollection images
"webp" => 16_383,
"avif" => 16_384,
"pdn" => PaintNetWriter.MaxSide,
+ "xcf" => XcfWriter.MaxSide,
+ "vtf" => VtfWriter.MaxSide,
_ => double.PositiveInfinity,
};
@@ -465,19 +472,30 @@ public static List Write(IReadOnlyList> frames, strin
ImageOptions? options = null, string? sourcePath = null, bool animation = false)
{
var written = new List();
- if (targetFormat == "pdn")
+ if (targetFormat is "pdn" or "xcf")
{
- // Paint.NET's own file, written by Filee (PaintNetWriter): one picture per file, as one layer.
+ // Paint.NET's and GIMP's own files, written by Filee (PaintNetWriter, XcfWriter): one picture per file, as
+ // one layer.
for (var i = 0; i < frames.Count; i++)
{
- var pdn = output.Allocate("pdn", frames.Count > 1 ? $"_p{i + 1}" : null);
- if (pdn is null)
+ var layered = output.Allocate(targetFormat, frames.Count > 1 ? $"_p{i + 1}" : null);
+ if (layered is null)
continue;
- PaintNetWriter.Write(frames[i], pdn);
- written.Add(pdn);
+ WriteImage(frames[i], layered, targetFormat);
+ written.Add(layered);
}
return written;
}
+ if (targetFormat == "vtf")
+ {
+ // Valve's texture, written by Filee (VtfWriter): every frame in one file, an animated texture for several.
+ var vtf = output.Allocate("vtf");
+ if (vtf is null)
+ return written;
+ VtfWriter.Write(frames, vtf);
+ written.Add(vtf);
+ return written;
+ }
var (format, extension) = OutputFormat(targetFormat, options ?? new ImageOptions(), sourcePath);
if (targetFormat == "apng")
@@ -610,7 +628,8 @@ internal static void FitToSize(IMagickImage image, string path, MagickForm
///
/// Writes one image as (APNG through , Paint.NET's PDN through
- /// ).
+ /// , GIMP's XCF through , a Valve texture through
+ /// ).
///
public static void WriteImage(IMagickImage image, string path, string targetFormat)
{
@@ -618,6 +637,10 @@ public static void WriteImage(IMagickImage image, string path, string targ
Apng.Write([image], path);
else if (targetFormat == "pdn")
PaintNetWriter.Write(image, path);
+ else if (targetFormat == "xcf")
+ XcfWriter.Write(image, path);
+ else if (targetFormat == "vtf")
+ VtfWriter.Write([image], path);
else
image.Write(path, ToMagickFormat(targetFormat));
}
diff --git a/src/Filee.Engines/Magick/ImageJoiner.cs b/src/Filee.Engines/Magick/ImageJoiner.cs
index 133c19425..2fc87af54 100644
--- a/src/Filee.Engines/Magick/ImageJoiner.cs
+++ b/src/Filee.Engines/Magick/ImageJoiner.cs
@@ -80,7 +80,7 @@ internal static void Join(IReadOnlyList<(string Path, string Format)> inputs, st
for (var i = 0; i < inputs.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
- var picture = Read(inputs[i], reading, options);
+ var picture = Read(inputs[i], reading, options, cancellationToken);
sizes[i] = (picture.Width, picture.Height);
density ??= picture.Density;
var pixels = (long)picture.Width * picture.Height;
@@ -99,7 +99,7 @@ internal static void Join(IReadOnlyList<(string Path, string Format)> inputs, st
for (var i = 0; i < inputs.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
- using var picture = kept[i] ?? Read(inputs[i], reading, options);
+ using var picture = kept[i] ?? Read(inputs[i], reading, options, cancellationToken);
kept[i] = null;
var place = layout.Pictures[i];
if (picture.Width != place.Width || picture.Height != place.Height)
@@ -118,8 +118,8 @@ internal static void Join(IReadOnlyList<(string Path, string Format)> inputs, st
ImageEncoder.ApplyOptions(merged, rest, format);
cancellationToken.ThrowIfCancellationRequested();
ImageEncoder.WriteImage(merged, outputPath, format);
- // A PDN (lossless, written by Filee) isn't fitted, like an animation: the queue refuses one too large.
- if ((options.MaxSizeMb > 0 || options.MinSizeMb > 0) && format != "pdn")
+ // A PDN, XCF or VTF (written by Filee) isn't fitted, like an animation: the queue refuses one too large.
+ if ((options.MaxSizeMb > 0 || options.MinSizeMb > 0) && format is not ("pdn" or "xcf" or "vtf"))
ImageEncoder.FitToSize(merged, outputPath, ImageEncoder.OutputFormat(format, rest).Format, SizeLimit.Bytes(options.MaxSizeMb),
SizeLimit.MinimumBytes(options.MinSizeMb), options.MaxSizeKeepsPixels);
if (format == "cur")
@@ -137,9 +137,10 @@ internal static void Join(IReadOnlyList<(string Path, string Format)> inputs, st
/// One picture as it looks: the first page or frame (an icon's largest size), turned by its EXIF orientation and the
/// preset, in sRGB so photos with different colour profiles match.
///
- private static IMagickImage Read((string Path, string Format) input, Preset reading, ImageOptions options)
+ private static IMagickImage Read((string Path, string Format) input, Preset reading, ImageOptions options,
+ CancellationToken cancellationToken)
{
- using var images = ImageReader.Read(input.Path, input.Format, reading);
+ using var images = ImageReader.Read(input.Path, input.Format, reading, cancellationToken);
if (images.Count == 0)
throw new InvalidOperationException("The picture has no image in it.");
var picture = ImageEncoder.SelectFrames(images, input.Format, "png")[0].Clone();
diff --git a/src/Filee.Engines/Magick/ImageReader.cs b/src/Filee.Engines/Magick/ImageReader.cs
index 7df689d20..ffbbe7670 100644
--- a/src/Filee.Engines/Magick/ImageReader.cs
+++ b/src/Filee.Engines/Magick/ImageReader.cs
@@ -1,12 +1,13 @@
// Reads source images with ImageMagick, handling the formats that need more than "open the file": camera RAW
-// (LibRaw settings), layered PSD/PSB/XCF (one flattened picture instead of one file per layer), EMF / WMF (Filee's
+// (LibRaw settings), layered PSD/PSB (one flattened picture instead of one file per layer), EMF / WMF (Filee's
// own metafile renderer at the preset's DPI, on every platform; GDI+ through ImageMagick only for EMF+ only files on
// Windows), APNG (Filee's own reader composes the frames) and uncompressed DICOM
// (Filee's own reader, as ImageMagick's Q8 build reads only 8-bit DICOM), FITS (Filee's own reader, stretched and
// debayered as the preset says), the photo of an iPhone LIVP, the merged picture of a Krita KRA, the layers of a Paint.NET
-// PDN merged as Paint.NET shows them and the icon of a Windows program. Used by every engine that takes a raster image as input.
+// PDN merged as Paint.NET shows them, those of a GIMP XCF as GIMP shows them (Filee's own reader, as ImageMagick reads
+// only 8-bit files without zlib and merges them its own way), the frames of a Valve texture (VTF) and the icon of a
+// Windows program. Used by every engine that takes a raster image as input.
-using System.Buffers.Binary;
using Filee.Core.Formats;
using Filee.Core.Presets;
using Filee.Engines.Metafiles;
@@ -26,7 +27,9 @@ internal static class ImageReader
/// Reads as format . Layered formats come back as a single,
/// flattened image; other multi-frame formats (TIFF pages, GIF frames, ICO sizes) keep all frames.
///
- public static MagickImageCollection Read(string path, string formatId, Preset preset)
+ /// Stops the merge of a GIMP file between rows; other formats are read at once.
+ public static MagickImageCollection Read(string path, string formatId, Preset preset,
+ CancellationToken cancellationToken = default)
{
if (formatId == "apng")
return Apng.Read(path);
@@ -40,6 +43,10 @@ public static MagickImageCollection Read(string path, string formatId, Preset pr
return ReadPackaged(Krita.ReadMerged(path), "png", preset);
if (formatId == "pdn")
return [PaintNet.ReadFlattened(path)];
+ if (formatId == "xcf")
+ return [Xcf.ReadFlattened(path, cancellationToken)];
+ if (formatId == "vtf")
+ return Vtf.Read(path);
if (formatId == FormatRegistry.Program)
return ReadPackaged(ProgramIcon.Read(path), "ico", preset);
if (formatId == "fits")
@@ -62,13 +69,6 @@ public static MagickImageCollection Read(string path, string formatId, Preset pr
"This DICOM image is compressed in a way Filee can't read (more than 8 bits per sample, or an unusual " +
"compression). Export it uncompressed from the viewing software and try again. (" + ex.Message + ")", ex);
}
- catch (MagickException ex) when (formatId == "xcf")
- {
- images.Dispose();
- throw new InvalidOperationException(
- "This GIMP file can't be read. Filee reads 8-bit XCF files saved without \"better but slower compression\"; " +
- "in GIMP, export the picture as PNG or PSD instead. (" + ex.Message + ")", ex);
- }
catch
{
images.Dispose();
@@ -95,12 +95,6 @@ public static MagickImageCollection Read(string path, string formatId, Preset pr
}
}
- if (formatId == "xcf" && images.Count > 0)
- {
- var flat = FlattenXcf(images, XcfCanvasSize(path));
- images.Dispose();
- return [flat];
- }
return images;
}
@@ -187,40 +181,4 @@ internal static MagickReadSettings Settings(string formatId, Preset preset)
}
return settings;
}
-
- ///
- /// Composites GIMP layers onto a canvas of the document's size. ImageMagick returns the layers bottom-up with
- /// their offsets, blend mode as compose operator and hidden layers marked , but
- /// not the canvas size, which comes from the file header.
- ///
- private static MagickImage FlattenXcf(MagickImageCollection layers, (uint Width, uint Height)? canvasSize)
- {
- var (width, height) = canvasSize ?? (layers[0].Page.Width is > 0 ? layers[0].Page.Width : layers[0].Width,
- layers[0].Page.Height is > 0 ? layers[0].Page.Height : layers[0].Height);
- var canvas = new MagickImage(MagickColors.Transparent, width, height);
- foreach (var layer in layers)
- {
- if (layer.Compose == CompositeOperator.No)
- continue; // hidden layer
- var compose = layer.Compose == CompositeOperator.Undefined ? CompositeOperator.Over : layer.Compose;
- canvas.Composite(layer, layer.Page.X, layer.Page.Y, compose);
- }
- canvas.Page = new MagickGeometry(0, 0, width, height);
- if (layers[0].ColorType is ColorType.Grayscale or ColorType.GrayscaleAlpha)
- canvas.ColorType = ColorType.GrayscaleAlpha;
- return canvas;
- }
-
- /// Width and height of an XCF document ("gimp xcf " magic, version, NUL, then two big-endian uint32).
- internal static (uint Width, uint Height)? XcfCanvasSize(string path)
- {
- Span header = stackalloc byte[22];
- using var stream = File.OpenRead(path);
- if (stream.ReadAtLeast(header, header.Length, throwOnEndOfStream: false) < header.Length
- || !header[..9].SequenceEqual("gimp xcf "u8))
- return null;
- var width = BinaryPrimitives.ReadUInt32BigEndian(header[14..]);
- var height = BinaryPrimitives.ReadUInt32BigEndian(header[18..]);
- return width is > 0 and <= 524_288 && height is > 0 and <= 524_288 ? (width, height) : null;
- }
}
diff --git a/src/Filee.Engines/Magick/MagickImageConverter.cs b/src/Filee.Engines/Magick/MagickImageConverter.cs
index 948c742ef..f580cc636 100644
--- a/src/Filee.Engines/Magick/MagickImageConverter.cs
+++ b/src/Filee.Engines/Magick/MagickImageConverter.cs
@@ -11,7 +11,8 @@ namespace Filee.Engines.Magick;
///
/// Converts between PNG, JPG, WEBP, TIFF, BMP, GIF, ICO, AVIF, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, APNG, MNG, QOI,
-/// DDS, PCX, CUR, XPM, XBM, WBMP, PAM, PFM, SGI, Sun Raster and MIFF, and reads HEIC, XCF, camera RAW, DICOM,
+/// DDS, PCX, CUR, XPM, XBM, WBMP, PAM, PFM, SGI, Sun Raster and MIFF, reads and writes Paint.NET's PDN and GIMP's XCF
+/// (Filee's own readers and writers, keeping layers between PDN, XCF and PSD), and reads HEIC, camera RAW, DICOM,
/// Photo CD, OpenEXR, Radiance HDR, FITS, animated cursors (ANI) and EMF/WMF (drawn by Filee's metafile renderer).
///
public sealed class MagickImageConverter : IConverter, ITiffMerger, IImageTransformer, IImageJoiner
@@ -33,8 +34,12 @@ public sealed class MagickImageConverter : IConverter, ITiffMerger, IImageTransf
.. from source in ImageEncoder.Sources
from target in ImageEncoder.Writable
select ImageEncoder.Edge(source, target),
- // Paint.NET's own file, written by Filee (PaintNetWriter); a PSD keeps its layers there.
+ // Paint.NET's and GIMP's own files, written by Filee (PaintNetWriter, XcfWriter); layers are kept between them
+ // and PSD.
.. ImageEncoder.Sources.Where(source => source != "pdn").Select(source => ImageEncoder.Edge(source, "pdn")),
+ .. ImageEncoder.Sources.Where(source => source != "xcf").Select(source => ImageEncoder.Edge(source, "xcf")),
+ // Valve's texture for the Source engine (Garry's Mod and others), written by Filee (VtfWriter).
+ .. ImageEncoder.Sources.Where(source => source != "vtf").Select(source => ImageEncoder.Edge(source, "vtf")),
];
public EngineStatus GetStatus() =>
@@ -81,7 +86,7 @@ public Task MergeAsync(IReadOnlyList inputPaths, string outputPath, File
public bool CanRead(string format) => ImageEncoder.Sources.Contains(format);
- public bool CanWrite(string format) => format == "pdn" || ImageEncoder.Writable.Contains(format);
+ public bool CanWrite(string format) => format is "pdn" or "xcf" or "vtf" || ImageEncoder.Writable.Contains(format);
/// "Merge images": the pictures one under another or side by side in one image ( ).
public Task JoinAsync(IReadOnlyList<(string Path, string Format)> inputs, string outputPath, string format,
@@ -107,10 +112,19 @@ public Task> ConvertAsync(ConversionStep step, IProgress
public static IReadOnlyList SaveLayered(ConversionStep step, IProgress? progress, CancellationToken ct)
{
var (width, height, layers) = Open(step.InputPath);
- using var images = new MagickImageCollection();
var canvas = new byte[width * height * 4];
+ return WritePsd(step, width, height, PsdLayers(), () => canvas, progress);
+
+ // Each layer merged into the canvas as it is read, so only one layer's pixels are held at a time.
+ IEnumerable PsdLayers()
+ {
+ foreach (var layer in layers)
+ {
+ ct.ThrowIfCancellationRequested();
+ if (layer.Visible)
+ Blend(canvas, layer.Bgra, layer.Opacity, layer.Blend);
+ yield return new PsdLayer(layer.Name, layer.Bgra, layer.Visible, layer.Opacity, ComposeOf(layer.Blend));
+ }
+ }
+ }
+
+ /// A layer for a PSD: canvas-sized BGRA (not premultiplied), with its Photoshop blend mode.
+ internal sealed record PsdLayer(string Name, byte[] Bgra, bool Visible, byte Opacity, CompositeOperator Compose);
+
+ ///
+ /// Writes a layered PSD (PSB for a .psb target) of , bottom first, each converted as it
+ /// comes; then gives the merged picture (BGRA), which goes first in the file.
+ ///
+ internal static IReadOnlyList WritePsd(ConversionStep step, int width, int height, IEnumerable layers,
+ Func merged, IProgress? progress)
+ {
+ using var images = new MagickImageCollection();
var visible = new List();
foreach (var layer in layers)
{
- ct.ThrowIfCancellationRequested();
- if (layer.Visible)
- Blend(canvas, layer.Bgra, layer.Opacity, layer.Blend);
images.Add(ToPsdLayer(layer, width, height));
visible.Add(layer.Visible);
}
- images.Insert(0, FromBgra(canvas, width, height));
+ images.Insert(0, FromBgra(merged(), width, height));
progress?.Report(0.7);
var format = step.To == "psb" ? MagickFormat.Psb : MagickFormat.Psd;
@@ -403,38 +425,43 @@ private static MagickImage FromBgra(byte[] bgra, int width, int height)
}
///
- /// A Paint.NET layer as ImageMagick's PSD writer takes it: the name in "label" (ASCII, as PSD's own field holds) and
- /// as Unicode ("luni", which Photoshop, GIMP and Krita show), the opacity in "psd:layer.opacity" with the alpha
- /// multiplied by it (the writer divides it out again). Visibility is set afterwards, see .
+ /// A layer as ImageMagick's PSD writer takes it: the name in "label" (ASCII, as PSD's own field holds) and as Unicode
+ /// ("luni", which Photoshop, GIMP and Krita show), the opacity in "psd:layer.opacity" with the alpha multiplied by it
+ /// (the writer divides it out again). Visibility is set afterwards, see .
///
- private static MagickImage ToPsdLayer(Layer layer, int width, int height)
+ private static MagickImage ToPsdLayer(PsdLayer layer, int width, int height)
{
+ var pixels = layer.Bgra;
if (layer.Opacity < 255)
{
- for (var i = 3; i < layer.Bgra.Length; i += 4)
- layer.Bgra[i] = (byte)Scale(layer.Bgra[i], layer.Opacity);
+ pixels = (byte[])pixels.Clone();
+ for (var i = 3; i < pixels.Length; i += 4)
+ pixels[i] = (byte)Scale(pixels[i], layer.Opacity);
}
- var image = FromBgra(layer.Bgra, width, height);
+ var image = FromBgra(pixels, width, height);
image.Label = AsciiName(layer.Name);
image.SetProfile(new ImageProfile("psd:additional-info", UnicodeName(layer.Name)));
if (layer.Opacity < 255)
image.SetArtifact("psd:layer.opacity", layer.Opacity.ToString(System.Globalization.CultureInfo.InvariantCulture));
- image.Compose = layer.Blend switch
- {
- BlendMode.Multiply => CompositeOperator.Multiply,
- BlendMode.Additive => CompositeOperator.LinearDodge,
- BlendMode.ColorBurn => CompositeOperator.ColorBurn,
- BlendMode.ColorDodge => CompositeOperator.ColorDodge,
- BlendMode.Overlay => CompositeOperator.Overlay,
- BlendMode.Difference => CompositeOperator.Difference,
- BlendMode.Lighten => CompositeOperator.Lighten,
- BlendMode.Darken => CompositeOperator.Darken,
- BlendMode.Screen => CompositeOperator.Screen,
- _ => CompositeOperator.Over,
- };
+ image.Compose = layer.Compose;
return image;
}
+ /// The Photoshop blend mode of a Paint.NET one, as ImageMagick names it; Over where Photoshop has none.
+ internal static CompositeOperator ComposeOf(BlendMode blend) => blend switch
+ {
+ BlendMode.Multiply => CompositeOperator.Multiply,
+ BlendMode.Additive => CompositeOperator.LinearDodge,
+ BlendMode.ColorBurn => CompositeOperator.ColorBurn,
+ BlendMode.ColorDodge => CompositeOperator.ColorDodge,
+ BlendMode.Overlay => CompositeOperator.Overlay,
+ BlendMode.Difference => CompositeOperator.Difference,
+ BlendMode.Lighten => CompositeOperator.Lighten,
+ BlendMode.Darken => CompositeOperator.Darken,
+ BlendMode.Screen => CompositeOperator.Screen,
+ _ => CompositeOperator.Over,
+ };
+
///
/// Sets each layer's flags in a PSD ImageMagick wrote: hidden where Paint.NET hides the layer, and nothing else.
/// ImageMagick writes the flags byte as 1 for a visible layer and 0 for a hidden one, but the visibility bit is 2
diff --git a/src/Filee.Engines/Magick/PaintNetWriter.cs b/src/Filee.Engines/Magick/PaintNetWriter.cs
index 03fea3100..54ee23e64 100644
--- a/src/Filee.Engines/Magick/PaintNetWriter.cs
+++ b/src/Filee.Engines/Magick/PaintNetWriter.cs
@@ -48,6 +48,25 @@ public static void Write(IMagickImage image, string path) =>
///
public static IReadOnlyList SaveLayered(Core.Conversion.ConversionStep step, IProgress? progress,
CancellationToken ct)
+ {
+ var (width, height, psdLayers) = ReadPsdLayers(step, ct);
+ var layers = psdLayers.Select(l => new Layer(l.Name, l.Bgra, l.Visible, l.Opacity, BlendOf(l.Compose))).ToList();
+ progress?.Report(0.6);
+
+ var path = step.Output.Allocate("pdn");
+ if (path is null)
+ return [];
+ Write(path, width, height, layers);
+ progress?.Report(1);
+ return [path];
+ }
+
+ ///
+ /// The pixel layers of a PSD (or PSB), bottom first, each on a canvas of its own: name (Unicode when the file has it),
+ /// pixels, visibility, opacity and Photoshop blend mode. A PSD without layers gives its picture as one layer.
+ ///
+ internal static (int Width, int Height, List Layers) ReadPsdLayers(Core.Conversion.ConversionStep step,
+ CancellationToken ct)
{
using var psd = new MagickImageCollection();
var settings = new MagickReadSettings { Format = step.From == "psb" ? MagickFormat.Psb : MagickFormat.Psd };
@@ -60,29 +79,22 @@ public static IReadOnlyList SaveLayered(Core.Conversion.ConversionStep s
records = PsdLayerRecords.Read(file, step.From == "psb");
if (records?.Count != psd.Count - 1)
records = null;
- var layers = new List();
+ var layers = new List();
for (var i = 1; i < psd.Count; i++)
{
ct.ThrowIfCancellationRequested();
layers.Add(FromPsdLayer(psd[i], width, height, records?[i - 1].Name));
}
if (layers.Count == 0)
- layers.Add(new Layer("Background", ToBgra(psd[0])));
- progress?.Report(0.6);
-
- var path = step.Output.Allocate("pdn");
- if (path is null)
- return [];
- Write(path, width, height, layers);
- progress?.Report(1);
- return [path];
+ layers.Add(new PaintNet.PsdLayer("Background", ToBgra(psd[0]), true, 255, CompositeOperator.Over));
+ return (width, height, layers);
}
///
/// One layer ImageMagick read from a PSD, on a canvas of its own: its alpha back without the layer's opacity (the
/// reader multiplies it in), the opacity, visibility (hidden layers come back composed as "No") and blend mode.
///
- private static Layer FromPsdLayer(IMagickImage image, int width, int height, string? name)
+ private static PaintNet.PsdLayer FromPsdLayer(IMagickImage image, int width, int height, string? name)
{
var opacity = byte.TryParse(image.GetArtifact("psd:layer.opacity"), out var value) ? value : (byte)255;
var pixels = ToBgra(image);
@@ -104,22 +116,26 @@ private static Layer FromPsdLayer(IMagickImage image, int width, int heigh
Array.Copy(pixels, (y * layerWidth + from) * 4, canvas, ((top + y) * width + left + from) * 4, (to - from) * 4);
}
- var blend = image.Compose switch
- {
- CompositeOperator.Multiply => PaintNet.BlendMode.Multiply,
- CompositeOperator.LinearDodge => PaintNet.BlendMode.Additive,
- CompositeOperator.ColorBurn => PaintNet.BlendMode.ColorBurn,
- CompositeOperator.ColorDodge => PaintNet.BlendMode.ColorDodge,
- CompositeOperator.Overlay => PaintNet.BlendMode.Overlay,
- CompositeOperator.Difference => PaintNet.BlendMode.Difference,
- CompositeOperator.Lighten => PaintNet.BlendMode.Lighten,
- CompositeOperator.Darken => PaintNet.BlendMode.Darken,
- CompositeOperator.Screen => PaintNet.BlendMode.Screen,
- _ => PaintNet.BlendMode.Normal,
- };
- return new Layer(name ?? PsdLayerName(image), canvas, image.Compose != CompositeOperator.No, opacity, blend);
+ var visible = image.Compose != CompositeOperator.No;
+ return new PaintNet.PsdLayer(name ?? PsdLayerName(image), canvas, visible, opacity,
+ visible ? image.Compose : CompositeOperator.Over);
}
+ /// The Paint.NET blend mode of a Photoshop one (as ImageMagick names it); Normal where Paint.NET has none.
+ private static PaintNet.BlendMode BlendOf(CompositeOperator compose) => compose switch
+ {
+ CompositeOperator.Multiply => PaintNet.BlendMode.Multiply,
+ CompositeOperator.LinearDodge => PaintNet.BlendMode.Additive,
+ CompositeOperator.ColorBurn => PaintNet.BlendMode.ColorBurn,
+ CompositeOperator.ColorDodge => PaintNet.BlendMode.ColorDodge,
+ CompositeOperator.Overlay => PaintNet.BlendMode.Overlay,
+ CompositeOperator.Difference => PaintNet.BlendMode.Difference,
+ CompositeOperator.Lighten => PaintNet.BlendMode.Lighten,
+ CompositeOperator.Darken => PaintNet.BlendMode.Darken,
+ CompositeOperator.Screen => PaintNet.BlendMode.Screen,
+ _ => PaintNet.BlendMode.Normal,
+ };
+
///
/// The layer's name when the file's own records could not be matched to the layers: its Unicode name ("luni" in the
/// layer's additional information, which ImageMagick keeps as a profile but reads as ASCII only), else its label.
diff --git a/src/Filee.Engines/Magick/Vtf.cs b/src/Filee.Engines/Magick/Vtf.cs
new file mode 100644
index 000000000..6a3ad1672
--- /dev/null
+++ b/src/Filee.Engines/Magick/Vtf.cs
@@ -0,0 +1,450 @@
+// Reads Valve's texture file (.vtf), the texture format of the Source engine (Garry's Mod, Counter-Strike: Source,
+// Team Fortress 2, Half-Life 2 mods): versions 7.0 to 7.5, every pixel format VTFLib knows except the palette one (P8,
+// which Source never shipped). The largest mipmap of every frame is read (an animated texture's frames, or one
+// picture); of a cube map its first face, of a volume texture its first slice. DXT blocks are decoded here, so no DLL
+// such as VTFLib is needed. VtfWriter writes the format.
+
+using System.Buffers.Binary;
+using System.Numerics;
+using ImageMagick;
+
+namespace Filee.Engines.Magick;
+
+internal static class Vtf
+{
+ /// VTF image formats (VTFLib's VTFImageFormat), as stored in the header.
+ internal enum Format
+ {
+ Rgba8888 = 0,
+ Abgr8888 = 1,
+ Rgb888 = 2,
+ Bgr888 = 3,
+ Rgb565 = 4,
+ I8 = 5,
+ Ia88 = 6,
+ P8 = 7,
+ A8 = 8,
+ Rgb888Bluescreen = 9,
+ Bgr888Bluescreen = 10,
+ Argb8888 = 11,
+ Bgra8888 = 12,
+ Dxt1 = 13,
+ Dxt3 = 14,
+ Dxt5 = 15,
+ Bgrx8888 = 16,
+ Bgr565 = 17,
+ Bgrx5551 = 18,
+ Bgra4444 = 19,
+ Dxt1OneBitAlpha = 20,
+ Bgra5551 = 21,
+ Uv88 = 22,
+ Uvwq8888 = 23,
+ Rgba16161616F = 24,
+ Rgba16161616 = 25,
+ Uvlx8888 = 26,
+ }
+
+ /// "No image": a file without a thumbnail stores this as the low-resolution format.
+ private const uint NoFormat = 0xFFFF_FFFF;
+
+ /// TEXTUREFLAGS_ENVMAP: the texture is a cube map.
+ private const uint EnvironmentMap = 0x4000;
+
+ /// The resource (7.3 and later) holding the full-size image data: "\x30\0\0".
+ private const uint HighResResource = 0x30;
+
+ /// Resource flag: the entry holds its value itself and points to no data.
+ private const byte NoDataFlag = 0x02;
+
+ ///
+ /// The longest side Filee reads. Source loads at most 4096 px ( ); a larger side is a
+ /// damaged or made-up header.
+ ///
+ internal const int MaxSide = 16_384;
+
+ ///
+ /// The most pixels Filee reads from one texture, every frame counted (frames × width × height), as for a Paint.NET
+ /// picture: 1 GB of RGBA. Checked before anything is allocated, so a small file that claims a huge texture asks for
+ /// nothing. keeps to it too, so every texture Filee writes can be read back.
+ ///
+ internal const long MaxPixels = PaintNet.MaxPixels;
+
+ /// True when frames of × fit .
+ internal static bool FitsBudget(int width, int height, int frames) => (long)width * height * frames <= MaxPixels;
+
+ /// What the header says about a texture.
+ internal sealed record Info(int Major, int Minor, int Width, int Height, uint Flags, int Frames, Format Format,
+ int Mipmaps, int Faces, int Depth, long DataOffset);
+
+ /// The frames of the texture at full size, one image each.
+ public static MagickImageCollection Read(string path)
+ {
+ var data = File.ReadAllBytes(path);
+ var info = ReadInfo(data);
+ var images = new MagickImageCollection();
+ try
+ {
+ foreach (var rgba in DecodeFrames(data, info))
+ {
+ var image = new MagickImage();
+ image.ReadPixels(rgba, new PixelReadSettings((uint)info.Width, (uint)info.Height, StorageType.Char, PixelMapping.RGBA));
+ if (info.Frames > 1)
+ image.AnimationDelay = 10; // Source plays animated textures at the rate the material sets; 10 fps is common
+ images.Add(image);
+ }
+ }
+ catch
+ {
+ images.Dispose();
+ throw;
+ }
+ return images;
+ }
+
+ /// Reads the header, and from version 7.3 on its resource list, of a VTF file.
+ internal static Info ReadInfo(ReadOnlySpan data)
+ {
+ if (data.Length < 64 || !data[..4].SequenceEqual("VTF\0"u8))
+ throw new InvalidDataException("This is not a Valve texture (VTF) file.");
+ var major = BinaryPrimitives.ReadInt32LittleEndian(data[4..]);
+ var minor = BinaryPrimitives.ReadInt32LittleEndian(data[8..]);
+ if (major != 7 || minor is < 0 or > 5)
+ throw new InvalidDataException($"VTF version {major}.{minor} is not supported (7.0 to 7.5 are).");
+
+ var headerSize = BinaryPrimitives.ReadUInt32LittleEndian(data[12..]);
+ int width = BinaryPrimitives.ReadUInt16LittleEndian(data[16..]);
+ int height = BinaryPrimitives.ReadUInt16LittleEndian(data[18..]);
+ var flags = BinaryPrimitives.ReadUInt32LittleEndian(data[20..]);
+ int frames = BinaryPrimitives.ReadUInt16LittleEndian(data[24..]);
+ int firstFrame = BinaryPrimitives.ReadUInt16LittleEndian(data[26..]);
+ var format = BinaryPrimitives.ReadUInt32LittleEndian(data[52..]);
+ int mipmaps = data[56];
+ var lowFormat = BinaryPrimitives.ReadUInt32LittleEndian(data[57..]);
+ int lowWidth = data[61];
+ int lowHeight = data[62];
+ var depth = minor >= 2 && data.Length >= 65 ? BinaryPrimitives.ReadUInt16LittleEndian(data[63..]) : 1;
+
+ if (width == 0 || height == 0)
+ throw new InvalidDataException("The VTF file has no pixels.");
+ if (width > MaxSide || height > MaxSide)
+ throw new InvalidDataException($"The texture is too large to read ({width} × {height}; Filee reads up to {MaxSide} px a side).");
+ if (format > (uint)Format.Uvlx8888)
+ throw new InvalidDataException($"The VTF file uses an unknown image format ({format}).");
+ if ((Format)format == Format.P8)
+ throw new InvalidDataException("The VTF file uses the palette format P8, which Filee can't read.");
+ frames = Math.Max(1, frames);
+ mipmaps = Math.Max(1, mipmaps);
+ // Every mipmap halves the sides down to 1 × 1: a file claiming more is damaged (and the shifts below would wrap).
+ if (mipmaps > MaxMipmaps(width, height))
+ throw new InvalidDataException($"The VTF file claims {mipmaps} mipmaps; a {width} × {height} texture has at most {MaxMipmaps(width, height)}.");
+ depth = Math.Max(1, (int)depth);
+ // A cube map has six faces; before 7.5 a seventh, the sphere map, unless the first frame is -1.
+ var faces = (flags & EnvironmentMap) == 0 ? 1 : minor < 5 && firstFrame != 0xFFFF ? 7 : 6;
+ if (!FitsBudget(width, height, frames))
+ throw new InvalidDataException($"The texture is too large to read ({width} × {height}, {frames} frames).");
+
+ long offset = -1;
+ if (minor >= 3)
+ {
+ var resources = data.Length >= 72 ? BinaryPrimitives.ReadUInt32LittleEndian(data[68..]) : 0;
+ for (var i = 0; i < resources && 80 + (i + 1) * 8 <= data.Length; i++)
+ {
+ var entry = data.Slice(80 + i * 8, 8);
+ var tag = entry[0] | entry[1] << 8 | entry[2] << 16;
+ if (tag == HighResResource && (entry[3] & NoDataFlag) == 0)
+ offset = BinaryPrimitives.ReadUInt32LittleEndian(entry[4..]);
+ }
+ if (offset < 0)
+ throw new InvalidDataException("The VTF file has no image data.");
+ }
+ else
+ {
+ var thumbnail = lowFormat == NoFormat || lowFormat > (uint)Format.Uvlx8888 || lowWidth == 0 || lowHeight == 0
+ ? 0 : DataSize((Format)lowFormat, lowWidth, lowHeight);
+ offset = headerSize + thumbnail;
+ }
+ // The image data: every mipmap, frame, face and slice. A file that claims more than it holds is refused here,
+ // before anything is decoded, with sizes counted in 64 bits.
+ var end = offset + TotalDataSize((Format)format, width, height, mipmaps, frames, faces, depth);
+ if (end > data.Length)
+ throw new InvalidDataException("The VTF file is cut off: it holds less image data than its header claims.");
+ return new Info(major, minor, width, height, flags, frames, (Format)format, mipmaps, faces, depth, offset);
+ }
+
+ /// Bytes of one image of at × .
+ internal static long DataSize(Format format, int width, int height) => format switch
+ {
+ Format.Dxt1 or Format.Dxt1OneBitAlpha => Blocks(width) * Blocks(height) * 8,
+ Format.Dxt3 or Format.Dxt5 => Blocks(width) * Blocks(height) * 16,
+ _ => (long)width * height * BytesPerPixel(format),
+ };
+
+ private static long Blocks(int side) => Math.Max(1, (side + 3) / 4);
+
+ /// Mipmaps down to 1 × 1: one more than the times the longer side halves.
+ internal static int MaxMipmaps(int width, int height) => 1 + BitOperations.Log2((uint)Math.Max(1, Math.Max(width, height)));
+
+ /// Bytes of all image data: every mipmap of every frame, face and slice.
+ private static long TotalDataSize(Format format, int width, int height, int mipmaps, int frames, int faces, int depth)
+ {
+ long total = 0;
+ for (var mipmap = 0; mipmap < mipmaps; mipmap++)
+ total += DataSize(format, Math.Max(1, width >> mipmap), Math.Max(1, height >> mipmap)) * frames * faces * Math.Max(1, depth >> mipmap);
+ return total;
+ }
+
+ private static int BytesPerPixel(Format format) => format switch
+ {
+ Format.I8 or Format.A8 or Format.P8 => 1,
+ Format.Rgb565 or Format.Ia88 or Format.Bgr565 or Format.Bgrx5551 or Format.Bgra4444 or Format.Bgra5551 or Format.Uv88 => 2,
+ Format.Rgb888 or Format.Bgr888 or Format.Rgb888Bluescreen or Format.Bgr888Bluescreen => 3,
+ Format.Rgba16161616F or Format.Rgba16161616 => 8,
+ _ => 4,
+ };
+
+ ///
+ /// The RGBA pixels of each frame's largest mipmap (first face, first slice). The data holds the smallest mipmap
+ /// first; within a mipmap, frame after frame, each with its faces, each face with its slices.
+ ///
+ internal static List DecodeFrames(byte[] data, Info info)
+ {
+ var offset = info.DataOffset;
+ for (var mipmap = info.Mipmaps - 1; mipmap >= 1; mipmap--)
+ {
+ var size = DataSize(info.Format, Math.Max(1, info.Width >> mipmap), Math.Max(1, info.Height >> mipmap));
+ offset += size * info.Frames * info.Faces * Math.Max(1, info.Depth >> mipmap);
+ }
+
+ var imageSize = DataSize(info.Format, info.Width, info.Height);
+ var frames = new List(info.Frames);
+ for (var frame = 0; frame < info.Frames; frame++)
+ {
+ var start = offset + imageSize * info.Faces * info.Depth * frame;
+ if (start < 0 || start + imageSize > data.Length)
+ throw new InvalidDataException("The VTF file is cut off: its image data is missing.");
+ frames.Add(Decode(data.AsSpan((int)start, (int)imageSize), info.Format, info.Width, info.Height));
+ }
+ return frames;
+ }
+
+ /// Decodes one image to RGBA, 8 bits per channel, top row first.
+ internal static byte[] Decode(ReadOnlySpan source, Format format, int width, int height)
+ {
+ if (width is < 1 or > MaxSide || height is < 1 or > MaxSide)
+ throw new InvalidDataException($"The texture is too large to read ({width} × {height}).");
+ if (source.Length < DataSize(format, width, height))
+ throw new InvalidDataException("The VTF file is cut off: its image data is missing.");
+ var rgba = new byte[(long)width * height * 4];
+ switch (format)
+ {
+ case Format.Dxt1:
+ case Format.Dxt1OneBitAlpha:
+ case Format.Dxt3:
+ case Format.Dxt5:
+ DecodeBlocks(source, format, width, height, rgba);
+ return rgba;
+ }
+
+ var bytes = BytesPerPixel(format);
+ for (var i = 0; i < width * height; i++)
+ {
+ var p = source.Slice(i * bytes, bytes);
+ var o = i * 4;
+ var v = bytes == 2 ? BinaryPrimitives.ReadUInt16LittleEndian(p) : 0;
+ byte r, g, b, a = 255;
+ switch (format)
+ {
+ case Format.Rgba8888:
+ case Format.Uvwq8888:
+ case Format.Uvlx8888:
+ (r, g, b, a) = (p[0], p[1], p[2], p[3]);
+ break;
+ case Format.Abgr8888:
+ (a, b, g, r) = (p[0], p[1], p[2], p[3]);
+ break;
+ case Format.Argb8888:
+ (a, r, g, b) = (p[0], p[1], p[2], p[3]);
+ break;
+ case Format.Bgra8888:
+ (b, g, r, a) = (p[0], p[1], p[2], p[3]);
+ break;
+ case Format.Bgrx8888:
+ (b, g, r) = (p[0], p[1], p[2]);
+ break;
+ case Format.Rgb888:
+ case Format.Rgb888Bluescreen:
+ (r, g, b) = (p[0], p[1], p[2]);
+ break;
+ case Format.Bgr888:
+ case Format.Bgr888Bluescreen:
+ (b, g, r) = (p[0], p[1], p[2]);
+ break;
+ case Format.I8:
+ r = g = b = p[0];
+ break;
+ case Format.Ia88:
+ r = g = b = p[0];
+ a = p[1];
+ break;
+ case Format.A8:
+ r = g = b = 0;
+ a = p[0];
+ break;
+ case Format.Uv88:
+ (r, g, b) = (p[0], p[1], (byte)0);
+ break;
+ case Format.Rgb565:
+ (r, g, b) = (Expand5(v), Expand6(v >> 5), Expand5(v >> 11));
+ break;
+ case Format.Bgr565:
+ (b, g, r) = (Expand5(v), Expand6(v >> 5), Expand5(v >> 11));
+ break;
+ case Format.Bgrx5551:
+ (b, g, r) = (Expand5(v), Expand5(v >> 5), Expand5(v >> 10));
+ break;
+ case Format.Bgra5551:
+ (b, g, r) = (Expand5(v), Expand5(v >> 5), Expand5(v >> 10));
+ a = (v & 0x8000) != 0 ? (byte)255 : (byte)0;
+ break;
+ case Format.Bgra4444:
+ (b, g, r, a) = (Nibble(v), Nibble(v >> 4), Nibble(v >> 8), Nibble(v >> 12));
+ break;
+ case Format.Rgba16161616:
+ (r, g, b, a) = (Word(p), Word(p[2..]), Word(p[4..]), Word(p[6..]));
+ break;
+ case Format.Rgba16161616F:
+ (r, g, b, a) = (Half(p), Half(p[2..]), Half(p[4..]), Half(p[6..]));
+ break;
+ default:
+ throw new InvalidDataException($"The VTF image format {format} can't be read.");
+ }
+ // The blue-screen formats use pure blue for transparency.
+ if (format is Format.Rgb888Bluescreen or Format.Bgr888Bluescreen && r == 0 && g == 0 && b == 255)
+ (r, g, b, a) = ((byte)0, (byte)0, (byte)0, (byte)0);
+ rgba[o] = r;
+ rgba[o + 1] = g;
+ rgba[o + 2] = b;
+ rgba[o + 3] = a;
+ }
+ return rgba;
+ }
+
+ private static byte Expand5(int value)
+ {
+ value &= 0x1F;
+ return (byte)(value << 3 | value >> 2);
+ }
+
+ private static byte Expand6(int value)
+ {
+ value &= 0x3F;
+ return (byte)(value << 2 | value >> 4);
+ }
+
+ private static byte Nibble(int value) => (byte)((value & 0xF) * 17);
+
+ private static byte Word(ReadOnlySpan p) => (byte)(BinaryPrimitives.ReadUInt16LittleEndian(p) / 257);
+
+ /// A half-precision channel (HDR), clamped to 0–1.
+ private static byte Half(ReadOnlySpan p)
+ {
+ var value = (float)BinaryPrimitives.ReadHalfLittleEndian(p);
+ return float.IsNaN(value) ? (byte)0 : (byte)Math.Round(Math.Clamp(value, 0f, 1f) * 255);
+ }
+
+ /// Decodes DXT1, DXT3 and DXT5 (BC1 to BC3) blocks of 4 × 4 pixels.
+ private static void DecodeBlocks(ReadOnlySpan source, Format format, int width, int height, byte[] rgba)
+ {
+ var blockSize = format is Format.Dxt3 or Format.Dxt5 ? 16 : 8;
+ Span colours = stackalloc byte[16];
+ Span alphas = stackalloc byte[8];
+ var block = 0;
+ for (var by = 0; by < Blocks(height); by++)
+ {
+ for (var bx = 0; bx < Blocks(width); bx++, block++)
+ {
+ var data = source.Slice(block * blockSize, blockSize);
+ var colour = blockSize == 16 ? data[8..] : data;
+ // DXT1 has a three-colour mode with transparent black; DXT3 and DXT5 always use four colours.
+ Palette(colour, colours, threeColourMode: blockSize == 8);
+ var indices = BinaryPrimitives.ReadUInt32LittleEndian(colour[4..]);
+ if (format == Format.Dxt5)
+ AlphaPalette(data[0], data[1], alphas);
+ var alphaBits = format == Format.Dxt5
+ ? data[2] | (ulong)data[3] << 8 | (ulong)data[4] << 16 | (ulong)data[5] << 24 | (ulong)data[6] << 32 | (ulong)data[7] << 40
+ : 0UL;
+
+ for (var py = 0; py < 4; py++)
+ {
+ var y = by * 4 + py;
+ if (y >= height)
+ break;
+ for (var px = 0; px < 4; px++)
+ {
+ var x = bx * 4 + px;
+ if (x >= width)
+ continue;
+ var pixel = py * 4 + px;
+ var index = (int)(indices >> (pixel * 2) & 3);
+ var o = (y * width + x) * 4;
+ rgba[o] = colours[index * 4];
+ rgba[o + 1] = colours[index * 4 + 1];
+ rgba[o + 2] = colours[index * 4 + 2];
+ rgba[o + 3] = format switch
+ {
+ Format.Dxt3 => (byte)((data[pixel / 2] >> (pixel % 2 * 4) & 0xF) * 17),
+ Format.Dxt5 => alphas[(int)(alphaBits >> (pixel * 3) & 7)],
+ _ => colours[index * 4 + 3],
+ };
+ }
+ }
+ }
+ }
+ }
+
+ /// The four RGBA colours of a DXT colour block.
+ private static void Palette(ReadOnlySpan block, Span colours, bool threeColourMode)
+ {
+ int c0 = BinaryPrimitives.ReadUInt16LittleEndian(block);
+ int c1 = BinaryPrimitives.ReadUInt16LittleEndian(block[2..]);
+ var fourColours = c0 > c1 || !threeColourMode;
+ SetColour(colours, 0, Expand5(c0 >> 11), Expand6(c0 >> 5), Expand5(c0), 255);
+ SetColour(colours, 1, Expand5(c1 >> 11), Expand6(c1 >> 5), Expand5(c1), 255);
+ for (var channel = 0; channel < 3; channel++)
+ {
+ int first = colours[channel], second = colours[4 + channel];
+ colours[8 + channel] = (byte)(fourColours ? (2 * first + second + 1) / 3 : (first + second) / 2);
+ colours[12 + channel] = (byte)(fourColours ? (first + 2 * second + 1) / 3 : 0);
+ }
+ colours[11] = 255;
+ // In the three-colour mode the fourth colour is transparent black.
+ colours[15] = fourColours ? (byte)255 : (byte)0;
+ }
+
+ private static void SetColour(Span colours, int index, byte r, byte g, byte b, byte a)
+ {
+ colours[index * 4] = r;
+ colours[index * 4 + 1] = g;
+ colours[index * 4 + 2] = b;
+ colours[index * 4 + 3] = a;
+ }
+
+ /// The eight alpha values of a DXT5 alpha block.
+ private static void AlphaPalette(byte a0, byte a1, Span alphas)
+ {
+ alphas[0] = a0;
+ alphas[1] = a1;
+ if (a0 > a1)
+ {
+ for (var i = 1; i < 7; i++)
+ alphas[i + 1] = (byte)(((7 - i) * a0 + i * a1 + 3) / 7);
+ }
+ else
+ {
+ for (var i = 1; i < 5; i++)
+ alphas[i + 1] = (byte)(((5 - i) * a0 + i * a1 + 2) / 5);
+ alphas[6] = 0;
+ alphas[7] = 255;
+ }
+ }
+}
diff --git a/src/Filee.Engines/Magick/VtfWriter.cs b/src/Filee.Engines/Magick/VtfWriter.cs
new file mode 100644
index 000000000..918cccdb5
--- /dev/null
+++ b/src/Filee.Engines/Magick/VtfWriter.cs
@@ -0,0 +1,181 @@
+// Writes Valve's texture file (.vtf), the texture format of the Source engine (Garry's Mod, Counter-Strike: Source,
+// Team Fortress 2, Half-Life 2 mods), as VTFEdit does by default: version 7.2, the sides rounded to the nearest power of
+// two, every mipmap down to 1 × 1, DXT1 for an opaque picture and DXT5 for one with transparency, and a DXT1 thumbnail
+// of at most 16 × 16. ImageMagick resizes and compresses the DXT blocks (its DDS writer, without a header); several
+// frames (an animated GIF, the pages of a TIFF) become the frames of one animated texture. See Vtf for the reader.
+
+using System.Buffers.Binary;
+using ImageMagick;
+
+namespace Filee.Engines.Magick;
+
+internal static class VtfWriter
+{
+ /// The longest side the Source engine loads.
+ public const int MaxSide = 4096;
+
+ /// Size of a version 7.2 header, as VTFLib writes it (65 bytes of fields, padded to 16).
+ private const int HeaderSize = 80;
+
+ /// The thumbnail Source keeps of every texture is at most this many pixels on a side.
+ private const int LowResSide = 16;
+
+ /// A DDS file as ImageMagick writes it: "DDS " and a 124-byte header before the blocks.
+ private const int DdsHeaderSize = 128;
+
+ /// TEXTUREFLAGS_EIGHTBITALPHA: the texture has an alpha channel of more than one bit.
+ private const uint EightBitAlpha = 0x2000;
+
+ ///
+ /// Writes as one texture: a still picture for one frame, an animated texture for several,
+ /// every frame at the size of the first.
+ ///
+ public static void Write(IReadOnlyList> frames, string path)
+ {
+ if (frames.Count == 0)
+ throw new ArgumentException("A texture needs at least one picture.", nameof(frames));
+ var (width, height) = TextureSize((int)frames[0].Width, (int)frames[0].Height);
+ // The reader's budget, so every texture written here can be read back (at 4096 × 4096, 16 frames).
+ if (frames.Count > ushort.MaxValue || !Vtf.FitsBudget(width, height, frames.Count))
+ throw new InvalidDataException(
+ $"The texture would be too large: {frames.Count} frames of {width} × {height} pixels. Use fewer frames or a smaller picture.");
+ var transparent = frames.Any(frame => !frame.IsOpaque);
+ var format = transparent ? Vtf.Format.Dxt5 : Vtf.Format.Dxt1;
+ var mipmaps = MipmapCount(width, height);
+ var (lowWidth, lowHeight) = LowResSize(width, height);
+
+ // Every mipmap is made from the full-size texture, not from the one before it, so no blur adds up.
+ var textures = new List>(frames.Count);
+ try
+ {
+ foreach (var frame in frames)
+ {
+ var texture = frame.Clone();
+ textures.Add(texture);
+ texture.Strip();
+ Resize(texture, width, height);
+ }
+
+ using var stream = File.Create(path);
+ stream.Write(Header(width, height, frames.Count, transparent ? EightBitAlpha : 0, Reflectivity(textures[0]),
+ format, mipmaps, lowWidth, lowHeight));
+ stream.Write(Compress(textures[0], lowWidth, lowHeight, dxt5: false));
+ // Smallest mipmap first; in each mipmap every frame in order.
+ for (var mipmap = mipmaps - 1; mipmap >= 0; mipmap--)
+ {
+ var mipWidth = Math.Max(1, width >> mipmap);
+ var mipHeight = Math.Max(1, height >> mipmap);
+ foreach (var texture in textures)
+ stream.Write(Compress(texture, mipWidth, mipHeight, transparent));
+ }
+ }
+ finally
+ {
+ foreach (var texture in textures)
+ texture.Dispose();
+ }
+ }
+
+ ///
+ /// The texture's size: each side rounded to the nearest power of two (Source draws other sizes wrong or not at all),
+ /// at most .
+ ///
+ internal static (int Width, int Height) TextureSize(int width, int height) => (PowerOfTwo(width), PowerOfTwo(height));
+
+ private static int PowerOfTwo(int side)
+ {
+ var exponent = (int)Math.Round(Math.Log2(Math.Max(1, side)), MidpointRounding.AwayFromZero);
+ return Math.Min(MaxSide, 1 << Math.Max(0, exponent));
+ }
+
+ /// Every mipmap down to 1 × 1: one more than the times the longer side halves.
+ internal static int MipmapCount(int width, int height) => 1 + (int)Math.Log2(Math.Max(width, height));
+
+ /// The largest mipmap that fits , as VTFLib chooses the thumbnail.
+ internal static (int Width, int Height) LowResSize(int width, int height)
+ {
+ while (width > LowResSide || height > LowResSide)
+ {
+ width = Math.Max(1, width / 2);
+ height = Math.Max(1, height / 2);
+ }
+ return (width, height);
+ }
+
+ private static void Resize(IMagickImage image, int width, int height)
+ {
+ if (image.Width == width && image.Height == height)
+ return;
+ image.FilterType = FilterType.Lanczos;
+ image.Resize(new MagickGeometry((uint)width, (uint)height) { IgnoreAspectRatio = true });
+ }
+
+ ///
+ /// The DXT blocks of at × : ImageMagick's
+ /// DDS writer without mipmaps, with its header cut off.
+ ///
+ private static byte[] Compress(IMagickImage texture, int width, int height, bool dxt5)
+ {
+ using var level = texture.Clone();
+ Resize(level, width, height);
+ level.Alpha(dxt5 ? AlphaAction.Set : AlphaAction.Off);
+ level.Settings.SetDefine(MagickFormat.Dds, "compression", dxt5 ? "dxt5" : "dxt1");
+ level.Settings.SetDefine(MagickFormat.Dds, "mipmaps", "0");
+ level.Settings.SetDefine(MagickFormat.Dds, "cluster-fit", "true");
+ var dds = level.ToByteArray(MagickFormat.Dds);
+ var size = Vtf.DataSize(dxt5 ? Vtf.Format.Dxt5 : Vtf.Format.Dxt1, width, height);
+ if (dds.Length != DdsHeaderSize + size)
+ throw new InvalidDataException($"ImageMagick wrote {dds.Length} bytes of DDS for a {width} × {height} texture, not {DdsHeaderSize + size}.");
+ return dds.AsSpan(DdsHeaderSize, (int)size).ToArray();
+ }
+
+ ///
+ /// The average colour of the texture in linear light, which Source uses for radiosity (light bounced off the
+ /// surface), as VTFLib computes it.
+ ///
+ internal static (float R, float G, float B) Reflectivity(IMagickImage texture)
+ {
+ var rgba = texture.GetPixels().ToByteArray(PixelMapping.RGBA)
+ ?? throw new InvalidOperationException("The texture's pixels could not be read.");
+ Span linear = stackalloc double[256];
+ for (var i = 0; i < 256; i++)
+ linear[i] = Math.Pow(i / 255.0, 2.2);
+ double r = 0, g = 0, b = 0;
+ for (var i = 0; i < rgba.Length; i += 4)
+ {
+ r += linear[rgba[i]];
+ g += linear[rgba[i + 1]];
+ b += linear[rgba[i + 2]];
+ }
+ var pixels = Math.Max(1, rgba.Length / 4);
+ return ((float)(r / pixels), (float)(g / pixels), (float)(b / pixels));
+ }
+
+ /// The 80-byte header of a version 7.2 file (packed fields, as Valve's and VTFLib's readers expect).
+ private static byte[] Header(int width, int height, int frames, uint flags, (float R, float G, float B) reflectivity,
+ Vtf.Format format, int mipmaps, int lowWidth, int lowHeight)
+ {
+ var header = new byte[HeaderSize];
+ var span = header.AsSpan();
+ "VTF\0"u8.CopyTo(span);
+ BinaryPrimitives.WriteUInt32LittleEndian(span[4..], 7);
+ BinaryPrimitives.WriteUInt32LittleEndian(span[8..], 2);
+ BinaryPrimitives.WriteUInt32LittleEndian(span[12..], HeaderSize);
+ BinaryPrimitives.WriteUInt16LittleEndian(span[16..], (ushort)width);
+ BinaryPrimitives.WriteUInt16LittleEndian(span[18..], (ushort)height);
+ BinaryPrimitives.WriteUInt32LittleEndian(span[20..], flags);
+ BinaryPrimitives.WriteUInt16LittleEndian(span[24..], (ushort)frames);
+ BinaryPrimitives.WriteUInt16LittleEndian(span[26..], 0); // first frame
+ BinaryPrimitives.WriteSingleLittleEndian(span[32..], reflectivity.R);
+ BinaryPrimitives.WriteSingleLittleEndian(span[36..], reflectivity.G);
+ BinaryPrimitives.WriteSingleLittleEndian(span[40..], reflectivity.B);
+ BinaryPrimitives.WriteSingleLittleEndian(span[48..], 1f); // bump map scale
+ BinaryPrimitives.WriteUInt32LittleEndian(span[52..], (uint)format);
+ span[56] = (byte)mipmaps;
+ BinaryPrimitives.WriteUInt32LittleEndian(span[57..], (uint)Vtf.Format.Dxt1);
+ span[61] = (byte)lowWidth;
+ span[62] = (byte)lowHeight;
+ BinaryPrimitives.WriteUInt16LittleEndian(span[63..], 1); // depth
+ return header;
+ }
+}
diff --git a/src/Filee.Engines/Magick/Xcf.cs b/src/Filee.Engines/Magick/Xcf.cs
new file mode 100644
index 000000000..92afdbab6
--- /dev/null
+++ b/src/Filee.Engines/Magick/Xcf.cs
@@ -0,0 +1,1221 @@
+// GIMP's own file (.xcf), read by Filee for every version GIMP writes: the header (canvas, colour mode, precision), the
+// layers and layer groups with their properties, and each layer's pixels in 64 × 64 tiles, uncompressed, RLE or zlib,
+// at 8, 16 or 32-bit integer, half, float or double precision (ImageMagick reads only 8-bit files without zlib). Layers
+// are merged as GIMP shows them (XcfBlend: every layer mode, opacity, layer masks, groups and pass-through), or kept as
+// layers in a PSD or a Paint.NET file. Pixels are kept at 8 bits per channel; the merge itself is in floating point.
+
+using System.Buffers.Binary;
+using System.IO.Compression;
+using System.Text;
+using Filee.Core.Conversion;
+using ImageMagick;
+
+namespace Filee.Engines.Magick;
+
+internal static class Xcf
+{
+ /// A layer, or a layer group with its layers bottom first.
+ internal sealed class Layer
+ {
+ public string Name { get; set; } = "";
+ public int X { get; set; }
+ public int Y { get; set; }
+ public int Width { get; set; }
+ public int Height { get; set; }
+ public bool Visible { get; set; } = true;
+ public float Opacity { get; set; } = 1;
+ public XcfBlend.Mode Mode { get; set; } = XcfBlend.Resolve(XcfBlend.NormalLegacy, 0, 0, 0);
+
+ /// The pixels, BGRA (not premultiplied, sRGB-encoded) top row first; null for a group.
+ public byte[]? Bgra { get; set; }
+
+ /// The layer mask GIMP applies, one byte per pixel of the layer; null when there is none.
+ public byte[]? Mask { get; set; }
+
+ /// The layers of a group, bottom first; null for a layer.
+ public List? Children { get; set; }
+ }
+
+ ///
+ /// A GIMP picture: canvas size, its layers bottom first, whether it is grayscale, and the space a layer group's
+ /// result is stored in at 8 bits (GIMP keeps a group at the picture's precision); null at a higher precision.
+ ///
+ internal sealed record Picture(int Width, int Height, bool Grayscale, XcfBlend.Space? GroupsAtEightBits, List Layers)
+ {
+ /// The space of the picture's own pixels: linear for GIMP's linear precisions, else perceptual.
+ public XcfBlend.Space StoredSpace { get; init; } = XcfBlend.Space.Perceptual;
+ }
+
+ /// The largest canvas GIMP makes (GIMP_MAX_IMAGE_SIZE).
+ public const int MaxSide = 524_288;
+
+ /// More layers and groups than GIMP users make, as for Paint.NET; a file claiming more is damaged.
+ private const int MaxLayers = 4_096;
+
+ ///
+ /// The most merging Filee does for one picture, counted as canvas pixels × shown layers and groups (merging goes
+ /// over every canvas row for each): sixteen times the pixel budget, a 6,000 × 4,000 picture of 180 layers.
+ ///
+ internal const long MaxMergeWork = 16 * MaxPixels;
+
+ /// Bytes of a layer name Filee keeps; the rest is skipped.
+ private const int MaxNameBytes = 1_024;
+
+ /// The most pixels Filee reads from an .xcf, every layer counted, as for Paint.NET: 1 GB at 8 bits.
+ internal const long MaxPixels = PaintNet.MaxPixels;
+
+ /// Nesting deeper than this is damage (GIMP users nest a few groups).
+ private const int MaxDepth = 64;
+
+ private const int Tile = 64;
+
+ private static readonly InvalidDataException Damaged =
+ new("This GIMP file is damaged or of an unknown kind. Open it in GIMP and save it again.");
+
+ // ───────────────────────── Reading ─────────────────────────
+
+ /// The picture with every layer's pixels.
+ /// The file is not a GIMP picture Filee can read.
+ public static Picture Open(string path)
+ {
+ using var file = File.OpenRead(path);
+ try
+ {
+ return new Reader(file).Read();
+ }
+ catch (Exception ex) when (ex is EndOfStreamException or OverflowException or IndexOutOfRangeException
+ or ArgumentOutOfRangeException)
+ {
+ throw new InvalidDataException(Damaged.Message, ex);
+ }
+ }
+
+ /// Precision of the pixel values: bytes per channel, how they are stored and whether colour is linear.
+ private readonly record struct Precision(int Bytes, bool Float, bool Linear)
+ {
+ public static Precision Of(uint value, int version)
+ {
+ // XCF 4 to 6 were GIMP 2.9 development versions with their own numbers; XCF 3 and older are 8-bit.
+ if (version == 4)
+ {
+ return value switch
+ {
+ 0 => new(1, false, false),
+ 1 => new(2, false, false),
+ 2 => new(4, false, true),
+ 3 => new(2, true, true),
+ 4 => new(4, true, true),
+ _ => throw Damaged,
+ };
+ }
+ if (version is 5 or 6)
+ {
+ return value switch
+ {
+ 100 => new(1, false, true),
+ 150 => new(1, false, false),
+ 200 => new(2, false, true),
+ 250 => new(2, false, false),
+ 300 => new(4, false, true),
+ 350 => new(4, false, false),
+ 400 => new(2, true, true),
+ 450 => new(2, true, false),
+ 500 => new(4, true, true),
+ 550 => new(4, true, false),
+ _ => throw Damaged,
+ };
+ }
+ // 100 linear, 150 non-linear, 175 perceptual (GIMP 3), and so on for each type.
+ var linear = value % 100 == 0;
+ if (!linear && value % 100 is not (50 or 75))
+ throw Damaged;
+ return (value / 100) switch
+ {
+ 1 => new(1, false, linear),
+ 2 => new(2, false, linear),
+ 3 => new(4, false, linear),
+ 5 => new(2, true, linear),
+ 6 => new(4, true, linear),
+ 7 => new(8, true, linear),
+ _ => throw Damaged,
+ };
+ }
+ }
+
+ // Property ids (app/xcf/xcf-private.h).
+ private const uint PropEnd = 0, PropColormap = 1, PropFloatingSelection = 5, PropOpacity = 6, PropMode = 7,
+ PropVisible = 8, PropApplyMask = 11, PropOffsets = 15, PropCompression = 17, PropGroupItem = 29,
+ PropItemPath = 30, PropFloatOpacity = 33, PropCompositeMode = 35, PropCompositeSpace = 36, PropBlendSpace = 37;
+
+ private sealed class Reader(FileStream file)
+ {
+ private int _version;
+ private int _baseType;
+ private Precision _precision;
+ private int _compression;
+ private byte[] _colormap = [];
+
+ public Picture Read()
+ {
+ Span magic = stackalloc byte[14];
+ file.ReadExactly(magic);
+ if (!magic[..9].SequenceEqual("gimp xcf "u8) || magic[13] != 0)
+ throw Damaged;
+ var tag = Encoding.ASCII.GetString(magic[9..13]);
+ if (tag == "file")
+ _version = 0;
+ else if (tag[0] != 'v' || !int.TryParse(tag[1..], System.Globalization.NumberStyles.None, null, out _version))
+ throw Damaged;
+ // Versions from 100 up were CinePaint's, a different format.
+ if (_version >= 100)
+ throw new InvalidDataException("This XCF file was made by CinePaint, not GIMP; Filee can't read it.");
+
+ var width = U32();
+ var height = U32();
+ _baseType = (int)U32();
+ if (width is < 1 or > MaxSide || height is < 1 or > MaxSide || _baseType > 2)
+ throw Damaged;
+ _precision = _version >= 4 ? Precision.Of(U32(), _version) : new Precision(1, false, false);
+ ReadImageProperties();
+ if ((long)width * height > MaxPixels)
+ throw new InvalidDataException("This GIMP picture is too large to convert.");
+
+ // GIMP lists each layer once: a pointer listed again would have its layer read (and its name decoded) again.
+ var pointers = new List();
+ var seen = new HashSet();
+ while (Pointer() is var pointer and not 0)
+ {
+ if (pointers.Count == MaxLayers || !seen.Add(pointer))
+ throw Damaged;
+ pointers.Add(pointer);
+ }
+
+ var headers = pointers.Select(ReadLayerHeader).Where(h => h is not null).Select(h => h!).ToList();
+ CheckBudget(headers, (long)width * height);
+ var layers = BuildTree(headers);
+ foreach (var header in headers)
+ ReadPixels(header);
+ XcfBlend.Space? groups = _precision.Bytes == 1 ? _precision.Linear ? XcfBlend.Space.Linear : XcfBlend.Space.Perceptual : null;
+ return new Picture((int)width, (int)height, _baseType == 1, groups, layers)
+ {
+ StoredSpace = _precision.Linear ? XcfBlend.Space.Linear : XcfBlend.Space.Perceptual,
+ };
+ }
+
+ private void ReadImageProperties()
+ {
+ while (true)
+ {
+ var (type, length) = (U32(), U32());
+ var start = file.Position;
+ switch (type)
+ {
+ case PropEnd:
+ return;
+ case PropColormap:
+ {
+ // The length word can't be trusted here: some GIMP versions wrote n + 4.
+ var count = U32();
+ if (count > 256)
+ throw Damaged;
+ _colormap = Bytes((int)count * 3);
+ continue;
+ }
+ case PropCompression:
+ _compression = Bytes(1)[0];
+ if (_compression > 2)
+ throw Damaged;
+ break;
+ }
+ Seek(start + length);
+ }
+ }
+
+ /// A layer as the file describes it, before its pixels are read.
+ private sealed record Header(Layer Layer, int Type, long Hierarchy, long Mask, bool ApplyMask, List Path);
+
+ private Header? ReadLayerHeader(long pointer)
+ {
+ Seek(pointer);
+ var width = U32();
+ var height = U32();
+ var type = (int)U32();
+ var name = Text();
+ if (width is < 1 or > MaxSide || height is < 1 or > MaxSide || type > 5)
+ throw Damaged;
+ var layer = new Layer { Name = name, Width = (int)width, Height = (int)height };
+ int mode = XcfBlend.NormalLegacy, blendSpace = 0, compositeSpace = 0, composite = 0;
+ bool applyMask = true, group = false, floating = false;
+ var path = new List();
+ while (true)
+ {
+ var (property, length) = (U32(), U32());
+ var start = file.Position;
+ // GIMP reads the payload it knows a property to have, whatever the length says; so does Filee.
+ switch (property)
+ {
+ case PropEnd:
+ break;
+ case PropOpacity:
+ layer.Opacity = Math.Min(U32(), 255) / 255f;
+ continue;
+ case PropFloatOpacity:
+ var opacity = F32();
+ layer.Opacity = float.IsFinite(opacity) ? Math.Clamp(opacity, 0, 1) : 1;
+ continue;
+ case PropVisible:
+ layer.Visible = U32() != 0;
+ continue;
+ case PropOffsets:
+ layer.X = I32();
+ layer.Y = I32();
+ continue;
+ case PropMode:
+ mode = (int)Math.Min(U32(), int.MaxValue);
+ continue;
+ case PropBlendSpace:
+ blendSpace = I32();
+ continue;
+ case PropCompositeSpace:
+ compositeSpace = I32();
+ continue;
+ case PropCompositeMode:
+ composite = I32();
+ continue;
+ case PropApplyMask:
+ applyMask = U32() != 0;
+ continue;
+ case PropGroupItem:
+ group = true;
+ Seek(start + length);
+ continue;
+ case PropItemPath:
+ if (length % 4 != 0 || length / 4 > MaxDepth)
+ throw Damaged;
+ for (var i = 0; i < length / 4; i++)
+ path.Add(U32());
+ continue;
+ case PropFloatingSelection:
+ floating = true;
+ Seek(start + length);
+ continue;
+ default:
+ Seek(start + length);
+ continue;
+ }
+ break;
+ }
+ var hierarchy = Pointer();
+ var mask = Pointer();
+
+ // A floating selection pasted into a channel or a mask is grayscale whatever the picture; it isn't drawn.
+ if (type / 2 != _baseType)
+ return floating ? null : throw Damaged;
+ layer.Mode = XcfBlend.Resolve(mode, blendSpace, compositeSpace, composite);
+ if (group)
+ layer.Children = [];
+ return new Header(layer, type, hierarchy, mask, applyMask, path);
+ }
+
+ ///
+ /// Refuses a picture whose layers and masks claim more pixels than Filee reads, more tiles than the file has room
+ /// to point at, or more merging than Filee does, before any pixel is allocated: a small file can't claim a huge
+ /// canvas, thousands of large layers, a huge group mask or thousands of empty groups over a large canvas.
+ ///
+ private void CheckBudget(List headers, long canvas)
+ {
+ long pixels = 0, tiles = 0, shown = 0;
+ foreach (var header in headers)
+ {
+ var layer = header.Layer;
+ var area = (long)layer.Width * layer.Height;
+ var layerTiles = (long)((layer.Width + Tile - 1) / Tile) * ((layer.Height + Tile - 1) / Tile);
+ // A layer's pixels take four bytes each, a mask's one; a group's own pixels aren't read, its mask is.
+ if (layer.Children is null)
+ {
+ pixels += area;
+ tiles += layerTiles;
+ }
+ if (header.Mask != 0)
+ {
+ pixels += area / 4;
+ tiles += layerTiles;
+ }
+ if (layer.Visible)
+ shown++;
+ }
+ if (pixels > MaxPixels || canvas * shown > MaxMergeWork)
+ throw new InvalidDataException("This GIMP picture is too large to convert.");
+ if (tiles * PointerSize > file.Length)
+ throw Damaged;
+ }
+
+ ///
+ /// The layers as GIMP nests them, each list bottom first. The file lists every layer top first, a group before
+ /// its layers, each with its path of positions from the top level (XCF 3 and later).
+ ///
+ private static List BuildTree(List headers)
+ {
+ var top = new List();
+ var groups = new Dictionary();
+ foreach (var header in headers)
+ {
+ // A top-level layer may have no path: its place among the top-level layers.
+ var path = header.Path.Count > 0 ? header.Path : [(uint)top.Count];
+ var parent = path.Count == 1 ? top
+ : groups.TryGetValue(string.Join('/', path.Take(path.Count - 1)), out var group) ? group.Children!
+ : throw Damaged;
+ parent.Add(header.Layer);
+ if (header.Layer.Children is not null)
+ groups[string.Join('/', path)] = header.Layer;
+ }
+ Reverse(top);
+ return top;
+
+ static void Reverse(List layers)
+ {
+ layers.Reverse();
+ foreach (var layer in layers)
+ {
+ if (layer.Children is { } children)
+ Reverse(children);
+ }
+ }
+ }
+
+ private void ReadPixels(Header header)
+ {
+ var layer = header.Layer;
+ // A group's pixels are GIMP's cache of its result, made again from its layers.
+ if (layer.Children is null)
+ {
+ var channels = header.Type switch { 0 => 3, 1 => 4, 2 => 1, 3 => 2, 4 => 1, _ => 2 };
+ var bgra = new byte[layer.Width * layer.Height * 4];
+ ReadHierarchy(header.Hierarchy, layer.Width, layer.Height, channels, (tile, x0, y0, w, h) =>
+ StorePixels(tile, x0, y0, w, h, channels, layer.Width, bgra));
+ layer.Bgra = bgra;
+ }
+ if (header.Mask != 0 && header.ApplyMask)
+ layer.Mask = ReadMask(header.Mask, layer.Width, layer.Height);
+ }
+
+ /// A layer mask: a channel the size of its layer, read as one byte per pixel (masks are linear).
+ private byte[] ReadMask(long pointer, int width, int height)
+ {
+ Seek(pointer);
+ if (U32() != width || U32() != height)
+ throw Damaged;
+ Text(keep: 0);
+ while (true)
+ {
+ var (property, length) = (U32(), U32());
+ if (property == PropEnd)
+ break;
+ Seek(file.Position + length);
+ }
+ var hierarchy = Pointer();
+ var mask = new byte[width * height];
+ ReadHierarchy(hierarchy, width, height, 1, (tile, x0, y0, w, h) =>
+ {
+ var step = _precision.Bytes;
+ for (var y = 0; y < h; y++)
+ {
+ for (var x = 0; x < w; x++)
+ mask[(y0 + y) * width + x0 + x] = ToByte(Value(tile, ((y * w) + x) * step), false);
+ }
+ });
+ return mask;
+ }
+
+ private delegate void TileSink(byte[] tile, int x, int y, int width, int height);
+
+ ///
+ /// The pixels of a hierarchy's first level, tile by tile, each in the order the file stores its pixels, with
+ /// values of the picture's precision per pixel.
+ ///
+ private void ReadHierarchy(long pointer, int width, int height, int channels, TileSink sink)
+ {
+ Seek(pointer);
+ var bpp = channels * _precision.Bytes;
+ if (U32() != width || U32() != height || U32() != bpp)
+ throw Damaged;
+ Seek(Pointer());
+ if (U32() != width || U32() != height)
+ throw Damaged;
+ var columns = (width + Tile - 1) / Tile;
+ var rows = (height + Tile - 1) / Tile;
+ var tiles = new long[columns * rows];
+ for (var i = 0; i < tiles.Length; i++)
+ {
+ tiles[i] = Pointer();
+ if (tiles[i] == 0 || tiles[i] >= file.Length)
+ throw Damaged;
+ }
+ var next = Pointer();
+
+ // GIMP never stores a compressed tile larger than one and a half times its pixels.
+ var maxPacked = Tile * Tile * bpp * 3 / 2;
+ var tile = new byte[Tile * Tile * bpp];
+ var packed = _compression == 0 ? [] : new byte[maxPacked];
+ for (var i = 0; i < tiles.Length; i++)
+ {
+ var x0 = i % columns * Tile;
+ var y0 = i / columns * Tile;
+ var w = Math.Min(Tile, width - x0);
+ var h = Math.Min(Tile, height - y0);
+ var size = w * h * bpp;
+ Seek(tiles[i]);
+ if (_compression == 0)
+ {
+ file.ReadExactly(tile, 0, size);
+ }
+ else
+ {
+ var end = i + 1 < tiles.Length ? tiles[i + 1] : next;
+ var available = end > tiles[i] ? end - tiles[i] : file.Length - tiles[i];
+ var length = (int)Math.Min(Math.Min(available, maxPacked), file.Length - tiles[i]);
+ file.ReadExactly(packed, 0, length);
+ if (_compression == 1)
+ UnpackRle(packed, length, tile, w * h, bpp);
+ else
+ Inflate(packed, length, tile, size);
+ }
+ sink(tile, x0, y0, w, h);
+ }
+ }
+
+ ///
+ /// XCF's RLE: one stream per byte of a pixel (all the first bytes, then all the second, …), each a series of
+ /// runs of one value and of literal bytes, with short and long forms.
+ ///
+ private static void UnpackRle(byte[] packed, int length, byte[] tile, int pixels, int bpp)
+ {
+ var p = 0;
+ for (var plane = 0; plane < bpp; plane++)
+ {
+ var i = 0;
+ while (i < pixels)
+ {
+ if (p >= length)
+ throw Damaged;
+ int op = packed[p++];
+ int count;
+ if (op >= 128)
+ {
+ if (op == 128)
+ {
+ if (p + 2 > length)
+ throw Damaged;
+ count = packed[p] << 8 | packed[p + 1];
+ p += 2;
+ }
+ else
+ {
+ count = 256 - op;
+ }
+ if (count > pixels - i || count > length - p)
+ throw Damaged;
+ for (var k = 0; k < count; k++)
+ tile[(i + k) * bpp + plane] = packed[p + k];
+ p += count;
+ }
+ else
+ {
+ if (op == 127)
+ {
+ if (p + 2 > length)
+ throw Damaged;
+ count = packed[p] << 8 | packed[p + 1];
+ p += 2;
+ }
+ else
+ {
+ count = op + 1;
+ }
+ if (count > pixels - i || p >= length)
+ throw Damaged;
+ var value = packed[p++];
+ for (var k = 0; k < count; k++)
+ tile[(i + k) * bpp + plane] = value;
+ }
+ i += count;
+ }
+ }
+ }
+
+ /// A zlib tile: exactly bytes, no more.
+ private static void Inflate(byte[] packed, int length, byte[] tile, int size)
+ {
+ try
+ {
+ using var zlib = new ZLibStream(new MemoryStream(packed, 0, length), CompressionMode.Decompress);
+ zlib.ReadExactly(tile, 0, size);
+ if (zlib.ReadByte() != -1)
+ throw Damaged;
+ }
+ catch (InvalidDataException ex) when (ex != Damaged)
+ {
+ throw new InvalidDataException(Damaged.Message, ex);
+ }
+ }
+
+ /// One tile of a layer into its BGRA pixels, from RGB, grayscale or indexed values.
+ private void StorePixels(byte[] tile, int x0, int y0, int w, int h, int channels, int layerWidth, byte[] bgra)
+ {
+ var step = _precision.Bytes;
+ var bpp = channels * step;
+ var linear = _precision.Linear;
+ for (var y = 0; y < h; y++)
+ {
+ for (var x = 0; x < w; x++)
+ {
+ var source = (y * w + x) * bpp;
+ var target = ((y0 + y) * layerWidth + x0 + x) * 4;
+ byte r, g, b, a = 255;
+ if (_baseType == 2)
+ {
+ // Indexed: an index into the colormap; GIMP treats alpha below half as transparent.
+ var index = tile[source] * 3;
+ (r, g, b) = index + 2 < _colormap.Length ? (_colormap[index], _colormap[index + 1], _colormap[index + 2]) : ((byte)0, (byte)0, (byte)0);
+ if (channels == 2)
+ a = tile[source + 1] >= 128 ? (byte)255 : (byte)0;
+ }
+ else if (channels <= 2)
+ {
+ r = g = b = ToByte(Value(tile, source), linear);
+ if (channels == 2)
+ a = ToByte(Value(tile, source + step), false);
+ }
+ else
+ {
+ r = ToByte(Value(tile, source), linear);
+ g = ToByte(Value(tile, source + step), linear);
+ b = ToByte(Value(tile, source + 2 * step), linear);
+ if (channels == 4)
+ a = ToByte(Value(tile, source + 3 * step), false);
+ }
+ bgra[target] = b;
+ bgra[target + 1] = g;
+ bgra[target + 2] = r;
+ bgra[target + 3] = a;
+ }
+ }
+ }
+
+ ///
+ /// One stored value, 0 to 1. Pixels are big-endian from XCF 12; the GIMP 2.9 development versions that wrote
+ /// more than 8 bits before that used the computer's order, little-endian on every PC.
+ ///
+ private float Value(byte[] tile, int offset)
+ {
+ var bigEndian = _version >= 12;
+ var span = tile.AsSpan(offset);
+ return (_precision.Bytes, _precision.Float) switch
+ {
+ (1, _) => tile[offset] / 255f,
+ (2, false) => (bigEndian ? BinaryPrimitives.ReadUInt16BigEndian(span) : BinaryPrimitives.ReadUInt16LittleEndian(span)) / 65535f,
+ (2, true) => (float)(bigEndian ? BinaryPrimitives.ReadHalfBigEndian(span) : BinaryPrimitives.ReadHalfLittleEndian(span)),
+ (4, false) => (float)((bigEndian ? BinaryPrimitives.ReadUInt32BigEndian(span) : BinaryPrimitives.ReadUInt32LittleEndian(span)) / 4294967295.0),
+ (4, true) => bigEndian ? BinaryPrimitives.ReadSingleBigEndian(span) : BinaryPrimitives.ReadSingleLittleEndian(span),
+ _ => (float)(bigEndian ? BinaryPrimitives.ReadDoubleBigEndian(span) : BinaryPrimitives.ReadDoubleLittleEndian(span)),
+ };
+ }
+
+ /// A value as an 8-bit sRGB-encoded byte; linear colour is encoded first, alpha and masks are not.
+ private static byte ToByte(float value, bool linearColour)
+ {
+ if (linearColour)
+ value = XcfBlend.ToPerceptual(value);
+ return float.IsNaN(value) ? (byte)0 : (byte)(Math.Clamp(value, 0, 1) * 255 + 0.5f);
+ }
+
+ private int PointerSize => _version >= 11 ? 8 : 4;
+
+ private long Pointer()
+ {
+ var pointer = _version >= 11 ? (long)Math.Min(U64(), long.MaxValue) : U32();
+ return pointer <= file.Length ? pointer : throw Damaged;
+ }
+
+ private void Seek(long position)
+ {
+ if (position < 0 || position > file.Length)
+ throw Damaged;
+ file.Position = position;
+ }
+
+ private byte[] Bytes(int count)
+ {
+ if (count > file.Length - file.Position)
+ throw Damaged;
+ var bytes = new byte[count];
+ file.ReadExactly(bytes);
+ return bytes;
+ }
+
+ private uint U32()
+ {
+ Span bytes = stackalloc byte[4];
+ file.ReadExactly(bytes);
+ return BinaryPrimitives.ReadUInt32BigEndian(bytes);
+ }
+
+ private ulong U64()
+ {
+ Span bytes = stackalloc byte[8];
+ file.ReadExactly(bytes);
+ return BinaryPrimitives.ReadUInt64BigEndian(bytes);
+ }
+
+ private int I32() => unchecked((int)U32());
+
+ private float F32() => BitConverter.Int32BitsToSingle(I32());
+
+ ///
+ /// A string: its byte count with the closing zero, then UTF-8. Only the first bytes are
+ /// decoded (a whole character at the end), the rest is skipped.
+ ///
+ private string Text(int keep = MaxNameBytes)
+ {
+ var length = U32();
+ if (length == 0)
+ return "";
+ if (length > 64 * 1024 || length > file.Length - file.Position)
+ throw Damaged;
+ var end = file.Position + length;
+ var bytes = Bytes((int)Math.Min(length - 1, keep));
+ Seek(end);
+ var count = bytes.Length;
+ // When the name was cut, drop a last character whose bytes were not all kept (UTF-8 self-synchronises).
+ if (count > 0 && count < length - 1)
+ {
+ var lead = count - 1;
+ while (lead > 0 && (bytes[lead] & 0xC0) == 0x80)
+ lead--;
+ var expected = bytes[lead] switch { < 0x80 => 1, < 0xE0 => 2, < 0xF0 => 3, _ => 4 };
+ if (lead + expected > count)
+ count = lead;
+ }
+ return Encoding.UTF8.GetString(bytes, 0, count).TrimEnd('\0');
+ }
+ }
+
+ // ───────────────────────── Merging ─────────────────────────
+
+ /// The picture as GIMP shows it: every visible layer merged.
+ public static MagickImage ReadFlattened(string path, CancellationToken ct = default)
+ {
+ var picture = Open(path);
+ var image = FromBgra(Merge(picture, picture.Layers, ct), picture.Width, picture.Height);
+ if (picture.Grayscale)
+ image.ColorType = ColorType.GrayscaleAlpha;
+ return image;
+ }
+
+ ///
+ /// (bottom first) merged on a transparent canvas as GIMP merges a picture or a group, as
+ /// BGRA.
+ ///
+ internal static byte[] Merge(Picture picture, List layers, CancellationToken ct = default)
+ {
+ var width = picture.Width;
+ var canvas = new byte[width * picture.Height * 4];
+ var merger = new Merger(picture, layers);
+ var row = new float[width * 4];
+ for (var y = 0; y < picture.Height; y++)
+ {
+ ct.ThrowIfCancellationRequested();
+ Array.Clear(row);
+ var space = XcfBlend.Space.Perceptual;
+ merger.Draw(layers, y, row, ref space, depth: 0);
+ XcfBlend.Convert(row, space, XcfBlend.Space.Perceptual);
+ var offset = y * width * 4;
+ for (var x = 0; x < width; x++)
+ {
+ var i = x * 4;
+ canvas[offset + i] = ToByte(row[i + 2]);
+ canvas[offset + i + 1] = ToByte(row[i + 1]);
+ canvas[offset + i + 2] = ToByte(row[i]);
+ canvas[offset + i + 3] = ToByte(row[i + 3]);
+ }
+ }
+ return canvas;
+ }
+
+ private static byte ToByte(float value) => float.IsNaN(value) ? (byte)0 : (byte)(Math.Clamp(value, 0, 1) * 255 + 0.5f);
+
+ /// A rectangle on the canvas, right and bottom excluded; empty when it has no area.
+ private readonly record struct Box(long Left, long Top, long Right, long Bottom)
+ {
+ public static readonly Box None = new(0, 0, 0, 0);
+
+ public bool IsEmpty => Right <= Left || Bottom <= Top;
+
+ public bool Contains(Box other) => other.IsEmpty
+ || !IsEmpty && Left <= other.Left && Top <= other.Top && Right >= other.Right && Bottom >= other.Bottom;
+
+ public static Box Of(Layer layer) => new(layer.X, layer.Y, (long)layer.X + layer.Width, (long)layer.Y + layer.Height);
+
+ public Box Intersect(Box other) => IsEmpty || other.IsEmpty ? None
+ : new Box(Math.Max(Left, other.Left), Math.Max(Top, other.Top), Math.Min(Right, other.Right), Math.Min(Bottom, other.Bottom)) is var box && !box.IsEmpty ? box : None;
+
+ public Box Union(Box other) => IsEmpty ? other : other.IsEmpty ? this
+ : new Box(Math.Min(Left, other.Left), Math.Min(Top, other.Top), Math.Max(Right, other.Right), Math.Max(Bottom, other.Bottom));
+ }
+
+ ///
+ /// How GIMP draws a layer in its stack, worked out once for every row: GIMP keeps the box of what has been drawn so
+ /// far, and a layer over an empty box is drawn as it is, at its opacity, whatever its mode (the bottom layer, or the
+ /// first one over layers that drew nothing). Nothing is drawn outside the box after a layer.
+ ///
+ private readonly record struct Step(bool OverNothing, Box After, bool Clips);
+
+ /// Merges layers one canvas row at a time, a row per group being merged.
+ private sealed class Merger
+ {
+ private readonly Picture _picture;
+ private readonly int _width;
+ private readonly XcfBlend.Scratch _scratch;
+ private readonly float[] _layer;
+ private readonly float[] _mask;
+ private readonly List _groups = [];
+ private readonly Dictionary _steps = new(ReferenceEqualityComparer.Instance);
+
+ public Merger(Picture picture, List layers)
+ {
+ _picture = picture;
+ _width = picture.Width;
+ _scratch = new XcfBlend.Scratch(_width);
+ _layer = new float[_width * 4];
+ _mask = new float[_width];
+ Reduce(layers);
+ Plan(layers, Box.None);
+ }
+
+ private readonly Dictionary _modes = new(ReferenceEqualityComparer.Instance);
+
+ /// The mode a layer is drawn in: its own, or the one a pass-through group is reduced to.
+ private XcfBlend.Mode ModeOf(Layer layer) => _modes.TryGetValue(layer, out var mode) ? mode : layer.Mode;
+
+ ///
+ /// GIMP draws a pass-through group as an ordinary group, in the mode of its layers, when it has no shown layer,
+ /// one, or only Normal ones drawn alike, and is opaque without a mask or composites as they do ("strength
+ /// reduction"). It looks the same in theory, but the group is then merged on its own (at the picture's precision)
+ /// and a legacy mode mixes alpha with the group's.
+ ///
+ private void Reduce(List layers)
+ {
+ foreach (var layer in layers)
+ {
+ if (layer.Children is not { } children)
+ continue;
+ Reduce(children);
+ if (layer.Mode.Id != XcfBlend.PassThrough)
+ continue;
+ var shown = children.Where(c => c.Visible).Select(c => Plain(ModeOf(c))).ToList();
+ var mode = layer.Mode with { Id = XcfBlend.Normal };
+ if (shown.Count > 0)
+ {
+ mode = shown[0];
+ if (shown.Count > 1 && (mode.Id != XcfBlend.Normal || mode.Composite != XcfBlend.Composite.Union
+ || shown.Skip(1).Any(m => m != mode)))
+ continue;
+ if ((layer.Opacity < 1 || layer.Mask is not null) && mode.CompositeSpace != layer.Mode.CompositeSpace)
+ continue;
+ }
+ _modes[layer] = mode;
+ }
+
+ static XcfBlend.Mode Plain(XcfBlend.Mode mode) => mode.Id == XcfBlend.NormalLegacy ? mode with { Id = XcfBlend.Normal } : mode;
+ }
+
+ /// The steps of a stack drawn over ; returns the box after its top layer.
+ private Box Plan(List layers, Box box)
+ {
+ foreach (var layer in layers.Where(l => l.Visible))
+ {
+ var overNothing = box.IsEmpty;
+ Box source;
+ if (layer.Children is { } children)
+ source = ModeOf(layer).Id == XcfBlend.PassThrough && !overNothing ? Plan(children, box) : PlanIsolated(children, layer);
+ else
+ source = Box.Of(layer);
+
+ // Which of the layer and the backdrop the result covers (GIMP's included region).
+ var (withLayer, withBackdrop) = overNothing ? (true, false)
+ : ModeOf(layer).Id == XcfBlend.PassThrough ? (true, true)
+ : ModeOf(layer).Composite switch
+ {
+ XcfBlend.Composite.Union => (true, true),
+ XcfBlend.Composite.ClipToBackdrop => (false, true),
+ XcfBlend.Composite.ClipToLayer => (true, false),
+ _ => (false, false),
+ };
+ if (layer.Opacity <= 0)
+ withLayer = false;
+ var after = source.Intersect(box);
+ if (withLayer)
+ after = after.Union(source);
+ if (withBackdrop)
+ after = after.Union(box);
+ // What was drawn lies in the box before and, where the layer adds to it, in the layer's box: only a box
+ // that leaves some of that out (clip to layer, intersection) has anything to clear.
+ var clips = !after.Contains(box) || withLayer && !after.Contains(source);
+ _steps[layer] = new Step(overNothing, after, clips);
+ box = after;
+ }
+ return box;
+ }
+
+ private Box PlanIsolated(List children, Layer group)
+ {
+ Plan(children, Box.None);
+ return Box.Of(group);
+ }
+
+ public void Draw(List layers, int y, float[] row, ref XcfBlend.Space space, int depth)
+ {
+ foreach (var layer in layers)
+ {
+ if (!layer.Visible)
+ continue;
+ var step = _steps[layer];
+ DrawLayer(layer, step.OverNothing, y, row, ref space, depth);
+ if (step.Clips)
+ Clip(row, step.After, y);
+ }
+ }
+
+ ///
+ /// Draws a layer and leaves the row in the space GIMP leaves the layer's result in, on every row whether the
+ /// layer covers it or not: its mode's composite space; over nothing, a Normal layer's own pixels' space, and a
+ /// pass-through group's the one its layers left.
+ ///
+ private void DrawLayer(Layer layer, bool overNothing, int y, float[] row, ref XcfBlend.Space space, int depth)
+ {
+ DrawPixels(layer, overNothing, y, row, ref space, depth);
+ if (overNothing && ModeOf(layer).Id == XcfBlend.PassThrough)
+ return;
+ var result = overNothing && ModeOf(layer).Id is XcfBlend.Normal or XcfBlend.NormalLegacy
+ ? _picture.StoredSpace
+ : ModeOf(layer).CompositeSpace;
+ XcfBlend.Convert(row, space, result);
+ space = result;
+ }
+
+ private void DrawPixels(Layer layer, bool overNothing, int y, float[] row, ref XcfBlend.Space space, int depth)
+ {
+ // Over nothing, a layer is copied at its opacity: Normal over a transparent row gives the same.
+ var mode = overNothing ? XcfBlend.Resolve(XcfBlend.Normal, 0, 0, 0) : ModeOf(layer);
+ if (y < layer.Y || y >= (long)layer.Y + layer.Height)
+ return;
+ var from = Math.Max(0, layer.X);
+ var to = (int)Math.Min(_width, (long)layer.X + layer.Width);
+ if (to <= from)
+ return;
+ var mask = MaskRow(layer, y, from, to);
+
+ if (layer.Children is { } children)
+ {
+ var group = GroupRow(depth);
+ var groupSpace = XcfBlend.Space.Perceptual;
+ if (ModeOf(layer).Id == XcfBlend.PassThrough && overNothing)
+ {
+ // Nothing under it: its layers are drawn straight onto the empty row, then take its opacity.
+ Draw(children, y, row, ref space, depth + 1);
+ for (var x = from; x < to; x++)
+ row[x * 4 + 3] *= layer.Opacity * (mask.IsEmpty ? 1 : mask[x - from]);
+ return;
+ }
+ if (ModeOf(layer).Id == XcfBlend.PassThrough)
+ {
+ // Its layers are drawn onto what is under the group, then mixed back at the group's opacity, in
+ // the group's composite space.
+ Array.Copy(row, group, row.Length);
+ groupSpace = space;
+ Draw(children, y, group, ref groupSpace, depth + 1);
+ var mix = ModeOf(layer).CompositeSpace;
+ XcfBlend.Convert(group, groupSpace, mix);
+ XcfBlend.Convert(row, space, mix);
+ space = mix;
+ XcfBlend.Replace(row, group, layer.Opacity, mask, from, to);
+ return;
+ }
+ Array.Clear(group);
+ Draw(children, y, group, ref groupSpace, depth + 1);
+ // GIMP keeps a group's result at the picture's precision: at 8 bits, it is rounded to 8 bits.
+ if (_picture.GroupsAtEightBits is { } stored)
+ {
+ var span = group.AsSpan(from * 4, (to - from) * 4);
+ XcfBlend.Convert(span, groupSpace, stored);
+ groupSpace = stored;
+ for (var i = 0; i < span.Length; i++)
+ span[i] = ToByte(span[i]) / 255f;
+ }
+ XcfBlend.Draw(mode, row, ref space, group, groupSpace, layer.Opacity, mask, from, to, _scratch);
+ return;
+ }
+
+ var pixels = layer.Bgra!;
+ var source = ((y - layer.Y) * layer.Width + from - layer.X) * 4;
+ for (var x = from; x < to; x++, source += 4)
+ {
+ var i = x * 4;
+ _layer[i] = pixels[source + 2] / 255f;
+ _layer[i + 1] = pixels[source + 1] / 255f;
+ _layer[i + 2] = pixels[source] / 255f;
+ _layer[i + 3] = pixels[source + 3] / 255f;
+ }
+ XcfBlend.Draw(mode, row, ref space, _layer, XcfBlend.Space.Perceptual, layer.Opacity, mask, from, to, _scratch);
+ }
+
+ /// Clears the row outside .
+ private void Clip(float[] row, Box box, int y)
+ {
+ if (box.IsEmpty || y < box.Top || y >= box.Bottom)
+ {
+ Array.Clear(row);
+ return;
+ }
+ var left = (int)Math.Clamp(box.Left, 0, _width);
+ var right = (int)Math.Clamp(box.Right, 0, _width);
+ Array.Clear(row, 0, left * 4);
+ Array.Clear(row, right * 4, (_width - right) * 4);
+ }
+
+ /// The layer mask's values for the row's pixels to .
+ private ReadOnlySpan MaskRow(Layer layer, int y, int from, int to)
+ {
+ if (layer.Mask is not { } mask)
+ return [];
+ var source = (y - layer.Y) * layer.Width + from - layer.X;
+ for (var x = 0; x < to - from; x++)
+ _mask[x] = mask[source + x] / 255f;
+ return _mask.AsSpan(0, to - from);
+ }
+
+ private float[] GroupRow(int depth)
+ {
+ while (_groups.Count <= depth)
+ _groups.Add(new float[_width * 4]);
+ return _groups[depth];
+ }
+ }
+
+ private static MagickImage FromBgra(byte[] bgra, int width, int height)
+ {
+ var image = new MagickImage();
+ image.ReadPixels(bgra, new PixelReadSettings((uint)width, (uint)height, StorageType.Char, PixelMapping.BGRA));
+ return image;
+ }
+
+ // ───────────────────────── Layers for PSD and Paint.NET ─────────────────────────
+
+ /// A layer for a format without groups: canvas-sized BGRA with its layer mask applied.
+ internal sealed record FlatLayer(string Name, byte[] Bgra, bool Visible, float Opacity, int Mode);
+
+ ///
+ /// The layers for PSD and Paint.NET, which have no groups, bottom first. A group whose layers look the same drawn
+ /// straight onto the picture (a pass-through group, or a Normal one of Normal layers, at full opacity and without a
+ /// mask) gives its layers, named "Group / Layer"; any other group becomes one layer of its merged result, so the
+ /// picture stays as GIMP shows it.
+ ///
+ internal static List FlatLayers(Picture picture, CancellationToken ct = default)
+ {
+ var flat = new List();
+ Add(picture.Layers, "", true, 1);
+ return flat;
+
+ void Add(List layers, string prefix, bool visible, float opacity)
+ {
+ foreach (var layer in layers)
+ {
+ ct.ThrowIfCancellationRequested();
+ var shown = visible && layer.Visible;
+ if (layer.Children is { } children)
+ {
+ if (KeepsLook(layer))
+ {
+ Add(children, prefix + layer.Name + " / ", shown, opacity * layer.Opacity);
+ continue;
+ }
+ var merged = Merge(picture, [Alone(layer)], ct);
+ ApplyMask(picture, layer, merged);
+ var mode = layer.Mode.Id == XcfBlend.PassThrough ? XcfBlend.Normal : layer.Mode.Id;
+ flat.Add(new FlatLayer(prefix + layer.Name, merged, shown, opacity * layer.Opacity, mode));
+ continue;
+ }
+ flat.Add(new FlatLayer(prefix + layer.Name, OnCanvas(picture, layer), shown, opacity * layer.Opacity, layer.Mode.Id));
+ }
+ }
+
+ // The group alone, merged as on a picture of its own: shown, opaque, Normal, without its mask.
+ static Layer Alone(Layer group) => new()
+ {
+ Name = group.Name,
+ X = group.X,
+ Y = group.Y,
+ Width = group.Width,
+ Height = group.Height,
+ Children = group.Children,
+ };
+ }
+
+ /// How many layers gives, counted without making them.
+ private static long FlatCount(List layers) =>
+ layers.Sum(l => l.Children is { } children && KeepsLook(l) ? FlatCount(children) : 1L);
+
+ ///
+ /// The layers for PSD and Paint.NET, or when each on a canvas of its own would take more than Paint.NET's pixel
+ /// budget (canvas × layers, as reads them), the merged picture as one layer named
+ /// Background: the conversion still works, and Filee can read the result back.
+ ///
+ private static List LayersOrMerged(Picture picture, Func merged, CancellationToken ct) =>
+ (long)picture.Width * picture.Height * Math.Max(1, FlatCount(picture.Layers)) <= PaintNet.MaxPixels
+ ? FlatLayers(picture, ct)
+ : [new FlatLayer("Background", merged(), true, 1, XcfBlend.Normal)];
+
+ ///
+ /// Whether a group's layers, drawn without it, give the same picture: a pass-through group at full opacity, or a
+ /// Normal one whose layers are all Normal, at full opacity unless it holds one layer. Neither may have a mask.
+ ///
+ private static bool KeepsLook(Layer group)
+ {
+ if (group.Mask is not null)
+ return false;
+ if (group.Mode.Id == XcfBlend.PassThrough)
+ return group.Opacity >= 1;
+ var shown = group.Children!.Where(l => l.Visible).ToList();
+ return IsNormal(group.Mode) && AllNormal(shown) && (group.Opacity >= 1 || shown is [{ Children: null }]);
+
+ // Every shown layer drawn Normal, in groups that keep their look themselves.
+ // KeepsLook already checks a Normal group's layers; a pass-through group's are checked here, each layer once.
+ static bool AllNormal(IEnumerable layers) => layers.Where(l => l.Visible).All(l =>
+ l.Children is null ? IsNormal(l.Mode) : KeepsLook(l) && (l.Mode.Id != XcfBlend.PassThrough || AllNormal(l.Children)));
+
+ static bool IsNormal(XcfBlend.Mode mode) =>
+ mode.Id is XcfBlend.Normal or XcfBlend.NormalLegacy && mode.Composite == XcfBlend.Composite.Union;
+ }
+
+ /// A layer's pixels at its place on the canvas, its mask applied; what lies outside the canvas is cut off.
+ internal static byte[] OnCanvas(Picture picture, Layer layer)
+ {
+ var canvas = new byte[picture.Width * picture.Height * 4];
+ var pixels = layer.Bgra!;
+ var (from, to) = (Math.Max(0, -layer.X), (int)Math.Min(layer.Width, (long)picture.Width - layer.X));
+ for (var y = Math.Max(0, -layer.Y); to > from && y < layer.Height && (long)layer.Y + y < picture.Height; y++)
+ Array.Copy(pixels, (y * layer.Width + from) * 4, canvas, ((layer.Y + y) * picture.Width + layer.X + from) * 4, (to - from) * 4);
+ ApplyMask(picture, layer, canvas);
+ return canvas;
+ }
+
+ ///
+ /// Multiplies the alpha of (the layer's pixels on the canvas) by the layer's mask; nothing
+ /// shows outside the mask's extent.
+ ///
+ private static void ApplyMask(Picture picture, Layer layer, byte[] canvas)
+ {
+ if (layer.Mask is not { } mask)
+ return;
+ for (var y = 0; y < picture.Height; y++)
+ {
+ for (var x = 0; x < picture.Width; x++)
+ {
+ var i = (y * picture.Width + x) * 4 + 3;
+ long mx = x - layer.X, my = y - layer.Y;
+ var value = mx >= 0 && mx < layer.Width && my >= 0 && my < layer.Height ? mask[my * layer.Width + mx] : 0;
+ canvas[i] = (byte)((canvas[i] * value + 127) / 255);
+ }
+ }
+ }
+
+ ///
+ /// Writes the .xcf as a PSD (or PSB) with its layers: names (as Unicode too), opacity, visibility and the blend
+ /// modes Photoshop shares; groups as gives them. The merged picture goes first.
+ ///
+ public static IReadOnlyList SaveLayeredPsd(ConversionStep step, IProgress? progress, CancellationToken ct)
+ {
+ var picture = Open(step.InputPath);
+ var merged = Merge(picture, picture.Layers, ct);
+ progress?.Report(0.3);
+ var layers = LayersOrMerged(picture, () => merged, ct).Select(l =>
+ {
+ ct.ThrowIfCancellationRequested();
+ return new PaintNet.PsdLayer(l.Name, l.Bgra, l.Visible, OpacityByte(l.Opacity), ComposeOf(l.Mode));
+ });
+ return PaintNet.WritePsd(step, picture.Width, picture.Height, layers, () => merged, progress);
+ }
+
+ ///
+ /// Writes the .xcf as a Paint.NET file with its layers: names, opacity, visibility and the blend modes Paint.NET
+ /// shares (others are Normal); groups as gives them.
+ ///
+ public static IReadOnlyList SaveLayeredPdn(ConversionStep step, IProgress? progress, CancellationToken ct)
+ {
+ var picture = Open(step.InputPath);
+ if (picture.Width > PaintNetWriter.MaxSide || picture.Height > PaintNetWriter.MaxSide)
+ throw new InvalidOperationException(
+ $"Paint.NET opens pictures of at most {PaintNetWriter.MaxSide:N0} pixels on a side; this one is {picture.Width:N0} × {picture.Height:N0}.");
+ progress?.Report(0.3);
+ var layers = new List();
+ foreach (var layer in LayersOrMerged(picture, () => Merge(picture, picture.Layers, ct), ct))
+ {
+ ct.ThrowIfCancellationRequested();
+ layers.Add(new PaintNetWriter.Layer(layer.Name, layer.Bgra, layer.Visible, OpacityByte(layer.Opacity), BlendModeOf(layer.Mode)));
+ }
+ if (layers.Count == 0)
+ layers.Add(new PaintNetWriter.Layer("Background", new byte[picture.Width * picture.Height * 4]));
+ progress?.Report(0.7);
+ var path = step.Output.Allocate("pdn");
+ if (path is null)
+ return [];
+ PaintNetWriter.Write(path, picture.Width, picture.Height, layers);
+ progress?.Report(1);
+ return [path];
+ }
+
+ private static byte OpacityByte(float opacity) => (byte)(Math.Clamp(opacity, 0, 1) * 255 + 0.5f);
+
+ /// The Photoshop blend mode of a GIMP layer mode, as ImageMagick names it; Over where Photoshop has none.
+ internal static CompositeOperator ComposeOf(int mode) => mode switch
+ {
+ XcfBlend.Dissolve => CompositeOperator.Dissolve,
+ XcfBlend.MultiplyLegacy or XcfBlend.Multiply => CompositeOperator.Multiply,
+ XcfBlend.ScreenLegacy or XcfBlend.Screen => CompositeOperator.Screen,
+ XcfBlend.Overlay => CompositeOperator.Overlay,
+ XcfBlend.DifferenceLegacy or XcfBlend.Difference => CompositeOperator.Difference,
+ XcfBlend.AdditionLegacy or XcfBlend.Addition => CompositeOperator.LinearDodge,
+ XcfBlend.DarkenLegacy or XcfBlend.Darken => CompositeOperator.Darken,
+ XcfBlend.LightenLegacy or XcfBlend.Lighten => CompositeOperator.Lighten,
+ XcfBlend.HueLegacy or XcfBlend.HsvHue or XcfBlend.LchHue => CompositeOperator.Hue,
+ XcfBlend.SaturationLegacy or XcfBlend.HsvSaturation or XcfBlend.LchChroma => CompositeOperator.Saturate,
+ XcfBlend.ColorLegacy or XcfBlend.HslColor or XcfBlend.LchColor => CompositeOperator.Colorize,
+ XcfBlend.ValueLegacy or XcfBlend.HsvValue or XcfBlend.LchLightness or XcfBlend.Luminance => CompositeOperator.Luminize,
+ XcfBlend.DodgeLegacy or XcfBlend.Dodge => CompositeOperator.ColorDodge,
+ XcfBlend.BurnLegacy or XcfBlend.Burn => CompositeOperator.ColorBurn,
+ XcfBlend.HardLightLegacy or XcfBlend.HardLight => CompositeOperator.HardLight,
+ XcfBlend.OverlayLegacy or XcfBlend.SoftLightLegacy or XcfBlend.SoftLight => CompositeOperator.SoftLight,
+ XcfBlend.VividLight => CompositeOperator.VividLight,
+ XcfBlend.PinLight => CompositeOperator.PinLight,
+ XcfBlend.LinearLight => CompositeOperator.LinearLight,
+ XcfBlend.HardMix => CompositeOperator.HardMix,
+ XcfBlend.Exclusion => CompositeOperator.Exclusion,
+ XcfBlend.LinearBurn => CompositeOperator.LinearBurn,
+ _ => CompositeOperator.Over,
+ };
+
+ /// The Paint.NET blend mode of a GIMP layer mode; Normal where Paint.NET has none.
+ internal static PaintNet.BlendMode BlendModeOf(int mode) => mode switch
+ {
+ XcfBlend.MultiplyLegacy or XcfBlend.Multiply => PaintNet.BlendMode.Multiply,
+ XcfBlend.AdditionLegacy or XcfBlend.Addition => PaintNet.BlendMode.Additive,
+ XcfBlend.BurnLegacy or XcfBlend.Burn => PaintNet.BlendMode.ColorBurn,
+ XcfBlend.DodgeLegacy or XcfBlend.Dodge => PaintNet.BlendMode.ColorDodge,
+ XcfBlend.Overlay => PaintNet.BlendMode.Overlay,
+ XcfBlend.DifferenceLegacy or XcfBlend.Difference => PaintNet.BlendMode.Difference,
+ XcfBlend.LightenLegacy or XcfBlend.Lighten => PaintNet.BlendMode.Lighten,
+ XcfBlend.DarkenLegacy or XcfBlend.Darken => PaintNet.BlendMode.Darken,
+ XcfBlend.ScreenLegacy or XcfBlend.Screen => PaintNet.BlendMode.Screen,
+ _ => PaintNet.BlendMode.Normal,
+ };
+}
diff --git a/src/Filee.Engines/Magick/XcfBlend.cs b/src/Filee.Engines/Magick/XcfBlend.cs
new file mode 100644
index 000000000..863c39245
--- /dev/null
+++ b/src/Filee.Engines/Magick/XcfBlend.cs
@@ -0,0 +1,959 @@
+// GIMP's layer modes, drawn as GIMP 2.10 and 3 draw them, for merging the layers of an .xcf (Xcf). A mode blends a layer
+// with what is under it in its blend space (linear RGB, perceptual RGB or CIE Lab), then composites the result in its
+// composite space with one of four ways of mixing alpha (union, clip to backdrop, clip to layer, intersection). Colours are
+// straight (not premultiplied) floats, four per pixel. The legacy modes of GIMP 2.8 and earlier blend in perceptual RGB
+// and keep the alpha of what is under the layer. The formulas follow GIMP's layer-mode operations; they are written
+// again here, not taken from GIMP's code.
+
+namespace Filee.Engines.Magick;
+
+internal static class XcfBlend
+{
+ /// Where a mode blends or composites (GimpLayerColorSpace; GIMP 3's "non-linear" is perceptual here).
+ internal enum Space { Linear = 1, Perceptual = 2, Lab = 3 }
+
+ /// How the layer's alpha and the backdrop's mix (GimpLayerCompositeMode).
+ internal enum Composite { Union = 1, ClipToBackdrop = 2, ClipToLayer = 3, Intersection = 4 }
+
+ /// A layer mode with the spaces and composite mode it is drawn in.
+ internal readonly record struct Mode(int Id, Space BlendSpace, Space CompositeSpace, Composite Composite);
+
+ // GimpLayerMode, as stored in PROP_MODE.
+ internal const int NormalLegacy = 0, Dissolve = 1, BehindLegacy = 2, MultiplyLegacy = 3, ScreenLegacy = 4,
+ OverlayLegacy = 5, DifferenceLegacy = 6, AdditionLegacy = 7, SubtractLegacy = 8, DarkenLegacy = 9,
+ LightenLegacy = 10, HueLegacy = 11, SaturationLegacy = 12, ColorLegacy = 13, ValueLegacy = 14,
+ DivideLegacy = 15, DodgeLegacy = 16, BurnLegacy = 17, HardLightLegacy = 18, SoftLightLegacy = 19,
+ GrainExtractLegacy = 20, GrainMergeLegacy = 21, ColorEraseLegacy = 22, Overlay = 23, LchHue = 24,
+ LchChroma = 25, LchColor = 26, LchLightness = 27, Normal = 28, Behind = 29, Multiply = 30, Screen = 31,
+ Difference = 32, Addition = 33, Subtract = 34, Darken = 35, Lighten = 36, HsvHue = 37, HsvSaturation = 38,
+ HslColor = 39, HsvValue = 40, Divide = 41, Dodge = 42, Burn = 43, HardLight = 44, SoftLight = 45,
+ GrainExtract = 46, GrainMerge = 47, VividLight = 48, PinLight = 49, LinearLight = 50, HardMix = 51,
+ Exclusion = 52, LinearBurn = 53, LumaDarken = 54, LumaLighten = 55, Luminance = 56, ColorErase = 57,
+ Erase = 58, Merge = 59, Split = 60, PassThrough = 61;
+
+ /// The highest mode id GIMP stores; a larger one is drawn as Normal.
+ internal const int LastMode = PassThrough;
+
+ /// Modes of GIMP 2.8 and earlier, with their own alpha (Normal, Dissolve and Behind aside).
+ private static bool IsLegacyBlend(int mode) => mode is >= MultiplyLegacy and <= GrainMergeLegacy;
+
+ /// Linear-light luminance of GIMP's sRGB primaries (D50, as babl adapts them).
+ private const float LumaR = 0.22248840f, LumaG = 0.71690369f, LumaB = 0.06060791f;
+
+ private const float Epsilon = 1e-6f;
+
+ ///
+ /// The mode a layer is drawn in: with the blend space, composite space and composite mode
+ /// the file gives (0 when it gives none: the mode's own). GIMP stores "Auto" as the negative of what it meant, and
+ /// GIMP 3's "RGB (perceptual)" as 4. Legacy modes always blend and composite in perceptual RGB.
+ ///
+ public static Mode Resolve(int mode, int blendSpace, int compositeSpace, int composite)
+ {
+ if (mode is < 0 or > LastMode)
+ mode = Normal;
+ var (blend, space, how) = Defaults(mode);
+ if (mode > ColorEraseLegacy)
+ {
+ blend = SpaceOf(blendSpace) ?? blend;
+ space = SpaceOf(compositeSpace) ?? space;
+ how = Math.Abs(composite) is >= 1 and <= 4 ? (Composite)Math.Abs(composite) : how;
+ }
+ return new Mode(mode, blend, space, how);
+ }
+
+ private static Space? SpaceOf(int stored) => Math.Abs(stored) switch
+ {
+ 1 => Space.Linear,
+ 2 or 4 => Space.Perceptual,
+ 3 => Space.Lab,
+ _ => null,
+ };
+
+ /// GIMP's own spaces and composite mode for each layer mode (gimp-layer-modes.c).
+ private static (Space Blend, Space Composite, Composite How) Defaults(int mode) => mode switch
+ {
+ NormalLegacy or Dissolve or BehindLegacy => (Space.Perceptual, Space.Perceptual, Composite.Union),
+ <= ColorEraseLegacy => (Space.Perceptual, Space.Perceptual, Composite.ClipToBackdrop),
+ Overlay => (Space.Perceptual, Space.Linear, Composite.ClipToBackdrop),
+ >= LchHue and <= LchLightness => (Space.Lab, Space.Linear, Composite.ClipToBackdrop),
+ Normal or Behind or Merge or PassThrough => (Space.Linear, Space.Linear, Composite.Union),
+ Multiply or Addition or Subtract or Darken or Lighten or Divide or Luminance or ColorErase or Erase or Split
+ => (Space.Linear, Space.Linear, Composite.ClipToBackdrop),
+ _ => (Space.Perceptual, Space.Linear, Composite.ClipToBackdrop),
+ };
+
+ /// Rows for the blend to work in, as wide as the canvas.
+ internal sealed class Scratch(int width)
+ {
+ public float[] In { get; } = new float[width * 4];
+ public float[] Layer { get; } = new float[width * 4];
+ public float[] Comp { get; } = new float[width * 4];
+ }
+
+ ///
+ /// Draws one row of a layer onto the row under it: , in , becomes
+ /// the result in the mode's composite space. (in , changed in
+ /// place) covers pixels to ; each pixel's opacity is
+ /// times its value (none: 1).
+ ///
+ public static void Draw(Mode mode, float[] row, ref Space rowSpace, float[] layer, Space layerSpace, float opacity,
+ ReadOnlySpan mask, int from, int to, Scratch scratch)
+ {
+ var space = mode.CompositeSpace;
+ Convert(row, rowSpace, space);
+ rowSpace = space;
+ if (to <= from || opacity <= 0)
+ return;
+ var start = from * 4;
+ var length = (to - from) * 4;
+ Convert(layer.AsSpan(start, length), layerSpace, space);
+
+ var id = mode.Id;
+ if (IsLegacyBlend(id))
+ {
+ DrawLegacy(id, row, layer, opacity, mask, from, to);
+ return;
+ }
+ switch (id)
+ {
+ case BehindLegacy or Behind:
+ DrawBehind(mode.Composite, row, layer, opacity, mask, from, to);
+ return;
+ case Erase:
+ DrawErase(mode.Composite, row, layer, opacity, mask, from, to);
+ return;
+ case Merge:
+ DrawMerge(mode.Composite, row, layer, opacity, mask, from, to);
+ return;
+ case Split:
+ DrawSplit(mode.Composite, row, layer, opacity, mask, from, to);
+ return;
+ }
+
+ // The blend: the layer itself for Normal (and Dissolve, drawn as Normal), else the mode's function of both,
+ // in the blend space.
+ float[] comp;
+ if (id is NormalLegacy or Dissolve or Normal)
+ {
+ comp = layer;
+ }
+ else
+ {
+ comp = scratch.Comp;
+ var blend = mode.BlendSpace;
+ if (blend == space)
+ {
+ Blend(id, row, layer, comp, start, length);
+ }
+ else
+ {
+ // The backdrop and the layer stay in the composite space for compositing: they blend as copies.
+ row.AsSpan(start, length).CopyTo(scratch.In.AsSpan(start));
+ layer.AsSpan(start, length).CopyTo(scratch.Layer.AsSpan(start));
+ Convert(scratch.In.AsSpan(start, length), space, blend);
+ Convert(scratch.Layer.AsSpan(start, length), space, blend);
+ Blend(id, scratch.In, scratch.Layer, comp, start, length);
+ Convert(comp.AsSpan(start, length), blend, space);
+ }
+ }
+
+ if (id is ColorErase or ColorEraseLegacy)
+ CompositeSubtractive(mode.Composite, row, layer, comp, opacity, mask, from, to);
+ else
+ CompositeAll(mode.Composite, row, layer, comp, opacity, mask, from, to);
+ }
+
+ ///
+ /// Mixes a pass-through group's result back over what was under it at the group's opacity (GIMP's "replace").
+ /// Both rows are in the same space.
+ ///
+ public static void Replace(float[] row, float[] group, float opacity, ReadOnlySpan mask, int from, int to)
+ {
+ for (var x = from; x < to; x++)
+ {
+ var i = x * 4;
+ var o = opacity * MaskAt(mask, x - from);
+ var newAlpha = (group[i + 3] - row[i + 3]) * o + row[i + 3];
+ var ratio = o;
+ if (newAlpha != 0)
+ ratio *= group[i + 3] / newAlpha;
+ for (var c = 0; c < 3; c++)
+ row[i + c] = (group[i + c] - row[i + c]) * ratio + row[i + c];
+ row[i + 3] = newAlpha;
+ }
+ }
+
+ private static float MaskAt(ReadOnlySpan mask, int index) => mask.IsEmpty ? 1 : mask[index];
+
+ /// The non-subtractive composite modes (union, clip to backdrop, clip to layer, intersection).
+ private static void CompositeAll(Composite how, float[] row, float[] layer, float[] comp, float opacity,
+ ReadOnlySpan mask, int from, int to)
+ {
+ for (var x = from; x < to; x++)
+ {
+ var i = x * 4;
+ var o = opacity * MaskAt(mask, x - from);
+ var inAlpha = row[i + 3];
+ switch (how)
+ {
+ case Composite.Union:
+ {
+ var layerAlpha = layer[i + 3] * o;
+ var newAlpha = layerAlpha + (1 - layerAlpha) * inAlpha;
+ if (layerAlpha == 0 || newAlpha == 0)
+ {
+ // The colour under it stays.
+ }
+ else if (inAlpha == 0)
+ {
+ row[i] = layer[i];
+ row[i + 1] = layer[i + 1];
+ row[i + 2] = layer[i + 2];
+ }
+ else
+ {
+ var ratio = layerAlpha / newAlpha;
+ for (var c = 0; c < 3; c++)
+ row[i + c] = ratio * (inAlpha * (comp[i + c] - layer[i + c]) + layer[i + c] - row[i + c]) + row[i + c];
+ }
+ row[i + 3] = newAlpha;
+ break;
+ }
+ case Composite.ClipToBackdrop:
+ {
+ var layerAlpha = comp[i + 3] * o;
+ if (inAlpha != 0 && layerAlpha != 0)
+ {
+ for (var c = 0; c < 3; c++)
+ row[i + c] = comp[i + c] * layerAlpha + row[i + c] * (1 - layerAlpha);
+ }
+ break;
+ }
+ case Composite.ClipToLayer:
+ {
+ var layerAlpha = layer[i + 3] * o;
+ if (layerAlpha == 0)
+ {
+ }
+ else if (inAlpha == 0)
+ {
+ row[i] = layer[i];
+ row[i + 1] = layer[i + 1];
+ row[i + 2] = layer[i + 2];
+ }
+ else
+ {
+ for (var c = 0; c < 3; c++)
+ row[i + c] = comp[i + c] * inAlpha + layer[i + c] * (1 - inAlpha);
+ }
+ row[i + 3] = layerAlpha;
+ break;
+ }
+ default:
+ {
+ var newAlpha = inAlpha * comp[i + 3] * o;
+ if (newAlpha != 0)
+ {
+ row[i] = comp[i];
+ row[i + 1] = comp[i + 1];
+ row[i + 2] = comp[i + 2];
+ }
+ row[i + 3] = newAlpha;
+ break;
+ }
+ }
+ }
+ }
+
+ /// The subtractive composite modes, for Color erase: comp's alpha is what is left of the overlap.
+ private static void CompositeSubtractive(Composite how, float[] row, float[] layer, float[] comp, float opacity,
+ ReadOnlySpan mask, int from, int to)
+ {
+ for (var x = from; x < to; x++)
+ {
+ var i = x * 4;
+ var o = opacity * MaskAt(mask, x - from);
+ var inAlpha = row[i + 3];
+ var layerAlpha = layer[i + 3] * o;
+ var compAlpha = comp[i + 3];
+ switch (how)
+ {
+ case Composite.Union:
+ {
+ var newAlpha = inAlpha + layerAlpha - (2 - compAlpha) * inAlpha * layerAlpha;
+ if (layerAlpha == 0 || newAlpha == 0)
+ {
+ }
+ else if (inAlpha == 0)
+ {
+ row[i] = layer[i];
+ row[i + 1] = layer[i + 1];
+ row[i + 2] = layer[i + 2];
+ }
+ else
+ {
+ var ratio = inAlpha / newAlpha;
+ var layerCoefficient = 1 / inAlpha - 1;
+ for (var c = 0; c < 3; c++)
+ row[i + c] = ratio * (layerAlpha * (compAlpha * comp[i + c] + layerCoefficient * layer[i + c] - row[i + c]) + row[i + c]);
+ }
+ row[i + 3] = newAlpha;
+ break;
+ }
+ case Composite.ClipToBackdrop:
+ {
+ compAlpha *= layerAlpha;
+ var newAlpha = 1 - layerAlpha + compAlpha;
+ if (inAlpha != 0 && compAlpha != 0)
+ {
+ var ratio = compAlpha / newAlpha;
+ for (var c = 0; c < 3; c++)
+ row[i + c] = comp[i + c] * ratio + row[i + c] * (1 - ratio);
+ }
+ row[i + 3] = newAlpha * inAlpha;
+ break;
+ }
+ case Composite.ClipToLayer:
+ {
+ compAlpha *= inAlpha;
+ var newAlpha = 1 - inAlpha + compAlpha;
+ if (layerAlpha == 0)
+ {
+ }
+ else if (inAlpha == 0)
+ {
+ row[i] = layer[i];
+ row[i + 1] = layer[i + 1];
+ row[i + 2] = layer[i + 2];
+ }
+ else
+ {
+ var ratio = compAlpha / newAlpha;
+ for (var c = 0; c < 3; c++)
+ row[i + c] = comp[i + c] * ratio + layer[i + c] * (1 - ratio);
+ }
+ row[i + 3] = newAlpha * layerAlpha;
+ break;
+ }
+ default:
+ {
+ var newAlpha = inAlpha * layer[i + 3] * compAlpha * o;
+ if (newAlpha != 0)
+ {
+ row[i] = comp[i];
+ row[i + 1] = comp[i + 1];
+ row[i + 2] = comp[i + 2];
+ }
+ row[i + 3] = newAlpha;
+ break;
+ }
+ }
+ }
+ }
+
+ ///
+ /// The blend functions of GIMP 2.10's modes: gets the blended colour and the layer's alpha
+ /// where both the backdrop and the layer have some (Color erase: the alpha left of the overlap).
+ ///
+ private static void Blend(int mode, float[] input, float[] layer, float[] comp, int start, int length)
+ {
+ for (var i = start; i < start + length; i += 4)
+ {
+ comp[i + 3] = layer[i + 3];
+ if (input[i + 3] == 0 || layer[i + 3] == 0)
+ {
+ if (mode is ColorErase or ColorEraseLegacy)
+ comp[i + 3] = 0;
+ continue;
+ }
+ switch (mode)
+ {
+ case HsvHue or HsvSaturation or HslColor or HsvValue or LchHue or LchChroma or LchColor or LchLightness
+ or LumaDarken or LumaLighten or Luminance:
+ BlendPixel(mode, input, layer, comp, i);
+ continue;
+ case ColorErase or ColorEraseLegacy:
+ ColorErasePixel(input, layer, comp, i);
+ continue;
+ }
+ for (var c = 0; c < 3; c++)
+ {
+ float a = input[i + c], b = layer[i + c];
+ comp[i + c] = mode switch
+ {
+ Addition => a + b,
+ Burn => 1 - SafeDivide(1 - a, b),
+ Darken => Math.Min(a, b),
+ Difference => Math.Abs(a - b),
+ Divide => SafeDivide(a, b),
+ Dodge => SafeDivide(a, 1 - b),
+ Exclusion => 0.5f - 2 * (a - 0.5f) * (b - 0.5f),
+ GrainExtract => a - b + 0.5f,
+ GrainMerge => a + b - 0.5f,
+ HardMix => a + b < 1 ? 0 : 1,
+ HardLight => b > 0.5f ? Math.Min(1 - (1 - a) * (1 - (b - 0.5f) * 2), 1) : Math.Min(a * b * 2, 1),
+ Lighten => Math.Max(a, b),
+ LinearBurn => a + b - 1,
+ LinearLight => b <= 0.5f ? a + 2 * b - 1 : a + 2 * (b - 0.5f),
+ Multiply => a * b,
+ Overlay => a < 0.5f ? 2 * a * b : 1 - 2 * (1 - b) * (1 - a),
+ PinLight => b > 0.5f ? Math.Max(a, 2 * (b - 0.5f)) : Math.Min(a, 2 * b),
+ Screen => 1 - (1 - a) * (1 - b),
+ SoftLight => (1 - a) * a * b + a * (1 - (1 - a) * (1 - b)),
+ Subtract => a - b,
+ VividLight => b <= 0.5f ? Math.Max(1 - SafeDivide(1 - a, 2 * b), 0) : Math.Min(SafeDivide(a, 2 * (1 - b)), 1),
+ _ => b,
+ };
+ }
+ }
+ }
+
+ /// a / b as GIMP divides in its blends: 0 when a is (almost) zero, else at most a million either way.
+ private static float SafeDivide(float a, float b) =>
+ Math.Abs(a) > Epsilon ? Math.Clamp(a / b, -1e6f, 1e6f) : 0;
+
+ /// The modes that work on the whole colour (HSV, HSL, LCh, luminance) for one pixel.
+ private static void BlendPixel(int mode, float[] input, float[] layer, float[] comp, int i)
+ {
+ float r1 = input[i], g1 = input[i + 1], b1 = input[i + 2];
+ float r2 = layer[i], g2 = layer[i + 1], b2 = layer[i + 2];
+ switch (mode)
+ {
+ case HsvHue:
+ {
+ var srcMin = Math.Min(Math.Min(r2, g2), b2);
+ var srcMax = Math.Max(Math.Max(r2, g2), b2);
+ var srcDelta = srcMax - srcMin;
+ if (srcDelta > Epsilon)
+ {
+ var destMin = Math.Min(Math.Min(r1, g1), b1);
+ var destMax = Math.Max(Math.Max(r1, g1), b1);
+ var destDelta = destMax - destMin;
+ var destS = destMax != 0 ? destDelta / destMax : 0;
+ var ratio = destS * destMax / srcDelta;
+ var offset = destMax - srcMax * ratio;
+ Set(comp, i, r2 * ratio + offset, g2 * ratio + offset, b2 * ratio + offset);
+ }
+ else
+ {
+ Set(comp, i, r1, g1, b1);
+ }
+ break;
+ }
+ case HsvSaturation:
+ {
+ var destMin = Math.Min(Math.Min(r1, g1), b1);
+ var destMax = Math.Max(Math.Max(r1, g1), b1);
+ var destDelta = destMax - destMin;
+ if (destDelta > Epsilon)
+ {
+ var srcMin = Math.Min(Math.Min(r2, g2), b2);
+ var srcMax = Math.Max(Math.Max(r2, g2), b2);
+ var srcS = srcMax != 0 ? (srcMax - srcMin) / srcMax : 0;
+ var ratio = srcS * destMax / destDelta;
+ var offset = (1 - ratio) * destMax;
+ Set(comp, i, r1 * ratio + offset, g1 * ratio + offset, b1 * ratio + offset);
+ }
+ else
+ {
+ Set(comp, i, destMax, destMax, destMax);
+ }
+ break;
+ }
+ case HslColor:
+ {
+ var destL = (Math.Min(Math.Min(r1, g1), b1) + Math.Max(Math.Max(r1, g1), b1)) / 2;
+ var srcL = (Math.Min(Math.Min(r2, g2), b2) + Math.Max(Math.Max(r2, g2), b2)) / 2;
+ if (Math.Abs(srcL) > Epsilon && Math.Abs(1 - srcL) > Epsilon)
+ {
+ var destHigh = destL > 0.5f;
+ var srcHigh = srcL > 0.5f;
+ destL = Math.Min(destL, 1 - destL);
+ srcL = Math.Min(srcL, 1 - srcL);
+ var ratio = destL / srcL;
+ var offset = 0f;
+ if (destHigh)
+ offset += 1 - 2 * destL;
+ if (srcHigh)
+ offset += 2 * destL - ratio;
+ Set(comp, i, r2 * ratio + offset, g2 * ratio + offset, b2 * ratio + offset);
+ }
+ else
+ {
+ Set(comp, i, destL, destL, destL);
+ }
+ break;
+ }
+ case HsvValue:
+ {
+ var destV = Math.Max(Math.Max(r1, g1), b1);
+ var srcV = Math.Max(Math.Max(r2, g2), b2);
+ if (Math.Abs(destV) > Epsilon)
+ {
+ var ratio = srcV / destV;
+ Set(comp, i, r1 * ratio, g1 * ratio, b1 * ratio);
+ }
+ else
+ {
+ Set(comp, i, srcV, srcV, srcV);
+ }
+ break;
+ }
+ // In the LCh modes the channels are L, a and b. Hue: the layer's hue at the backdrop's chroma.
+ case LchHue:
+ {
+ var c2 = MathF.Sqrt(g2 * g2 + b2 * b2);
+ if (c2 > Epsilon)
+ {
+ var c1 = MathF.Sqrt(g1 * g1 + b1 * b1);
+ Set(comp, i, r1, c1 * g2 / c2, c1 * b2 / c2);
+ }
+ else
+ {
+ Set(comp, i, r1, g1, b1);
+ }
+ break;
+ }
+ // Chroma: the layer's chroma at the backdrop's hue.
+ case LchChroma:
+ {
+ var c1 = MathF.Sqrt(g1 * g1 + b1 * b1);
+ if (c1 > Epsilon)
+ {
+ var c2 = MathF.Sqrt(g2 * g2 + b2 * b2);
+ Set(comp, i, r1, c2 * g1 / c1, c2 * b1 / c1);
+ }
+ else
+ {
+ Set(comp, i, r1, g1, b1);
+ }
+ break;
+ }
+ case LchColor:
+ Set(comp, i, r1, g2, b2);
+ break;
+ case LchLightness:
+ Set(comp, i, r2, g1, b1);
+ break;
+ case LumaDarken or LumaLighten:
+ {
+ var dest = LumaR * r1 + LumaG * g1 + LumaB * b1;
+ var src = LumaR * r2 + LumaG * g2 + LumaB * b2;
+ var keep = mode == LumaDarken ? dest <= src : dest >= src;
+ if (keep)
+ Set(comp, i, r1, g1, b1);
+ else
+ Set(comp, i, r2, g2, b2);
+ break;
+ }
+ default: // Luminance
+ {
+ var ratio = SafeDivide(LumaR * r2 + LumaG * g2 + LumaB * b2, LumaR * r1 + LumaG * g1 + LumaB * b1);
+ Set(comp, i, r1 * ratio, g1 * ratio, b1 * ratio);
+ break;
+ }
+ }
+ }
+
+ private static void Set(float[] pixels, int i, float r, float g, float b)
+ {
+ pixels[i] = r;
+ pixels[i + 1] = g;
+ pixels[i + 2] = b;
+ }
+
+ /// Color erase: the layer's colour taken out of the backdrop, as alpha.
+ private static void ColorErasePixel(float[] input, float[] layer, float[] comp, int i)
+ {
+ var alpha = 0f;
+ for (var c = 0; c < 3; c++)
+ {
+ var col = Math.Clamp(input[i + c], 0, 1);
+ var background = Math.Clamp(layer[i + c], 0, 1);
+ if (Math.Abs(col - background) > Epsilon)
+ alpha = Math.Max(alpha, col > background ? (col - background) / (1 - background) : (background - col) / background);
+ }
+ if (alpha > Epsilon)
+ {
+ for (var c = 0; c < 3; c++)
+ comp[i + c] = (input[i + c] - layer[i + c]) / alpha + layer[i + c];
+ }
+ else
+ {
+ Set(comp, i, 0, 0, 0);
+ }
+ comp[i + 3] = alpha;
+ }
+
+ ///
+ /// The legacy modes: the overlap is the smaller of the two alphas times the opacity, the backdrop keeps its alpha.
+ /// Overlay (legacy) was Soft light all along.
+ ///
+ private static void DrawLegacy(int mode, float[] row, float[] layer, float opacity, ReadOnlySpan mask, int from, int to)
+ {
+ Span mixed = stackalloc float[3];
+ for (var x = from; x < to; x++)
+ {
+ var i = x * 4;
+ var inAlpha = row[i + 3];
+ var compAlpha = Math.Min(inAlpha, layer[i + 3]) * opacity * MaskAt(mask, x - from);
+ var newAlpha = inAlpha + (1 - inAlpha) * compAlpha;
+ if (compAlpha == 0 || newAlpha == 0)
+ continue;
+ var ratio = compAlpha / newAlpha;
+ if (mode is HueLegacy or SaturationLegacy or ColorLegacy or ValueLegacy)
+ {
+ LegacyColour(mode, row, layer, i, mixed);
+ }
+ else
+ {
+ for (var c = 0; c < 3; c++)
+ {
+ float a = row[i + c], b = layer[i + c];
+ mixed[c] = mode switch
+ {
+ MultiplyLegacy => a * b,
+ ScreenLegacy => 1 - (1 - a) * (1 - b),
+ DifferenceLegacy => Math.Abs(a - b),
+ AdditionLegacy => Math.Clamp(a + b, 0, 1),
+ SubtractLegacy => Math.Clamp(a - b, 0, 1),
+ DarkenLegacy => Math.Min(a, b),
+ LightenLegacy => Math.Max(a, b),
+ DivideLegacy => ClampNaN(a / b, 1),
+ DodgeLegacy => ClampNaN(a / (1 - b), 1),
+ BurnLegacy => ClampNaN(1 - (1 - a) / b, 1),
+ HardLightLegacy => b > 128f / 255
+ ? Math.Min(1 - (1 - a) * (1 - (b - 128f / 255) * 2), 1)
+ : Math.Min(a * b * 2, 1),
+ GrainExtractLegacy => Math.Clamp(a - b + 128f / 255, 0, 1),
+ GrainMergeLegacy => Math.Clamp(a + b - 128f / 255, 0, 1),
+ _ => (1 - a) * a * b + a * (1 - (1 - a) * (1 - b)), // Soft light, and Overlay (legacy)
+ };
+ }
+ }
+ for (var c = 0; c < 3; c++)
+ row[i + c] = mixed[c] * ratio + row[i + c] * (1 - ratio);
+ }
+ }
+
+ /// Clamps to 0…1; not-a-number (0 / 0) becomes , as GIMP's legacy modes give.
+ private static float ClampNaN(float value, float nan) => float.IsNaN(value) ? nan : value < 0 ? 0 : value < 1 ? value : 1;
+
+ /// The legacy HSV and HSL modes: one component of the layer's colour taken into the backdrop's.
+ private static void LegacyColour(int mode, float[] row, float[] layer, int i, Span mixed)
+ {
+ if (mode == ColorLegacy)
+ {
+ var (h, s, _) = ToHsl(layer[i], layer[i + 1], layer[i + 2]);
+ var (_, _, l) = ToHsl(row[i], row[i + 1], row[i + 2]);
+ FromHsl(h, s, l, mixed);
+ return;
+ }
+ var (lh, ls, lv) = ToHsv(layer[i], layer[i + 1], layer[i + 2]);
+ var (oh, os, ov) = ToHsv(row[i], row[i + 1], row[i + 2]);
+ switch (mode)
+ {
+ case HueLegacy:
+ if (ls != 0)
+ oh = lh;
+ break;
+ case SaturationLegacy:
+ os = ls;
+ break;
+ default:
+ ov = lv;
+ break;
+ }
+ FromHsv(oh, os, ov, mixed);
+ }
+
+ /// HSV with hue 0…1 (GimpHSV).
+ private static (double H, double S, double V) ToHsv(double r, double g, double b)
+ {
+ var max = Math.Max(r, Math.Max(g, b));
+ var min = Math.Min(r, Math.Min(g, b));
+ var delta = max - min;
+ if (delta <= 0.0001)
+ return (0, 0, max);
+ var s = delta / max;
+ double h;
+ if (r == max)
+ h = (g - b) / delta;
+ else if (g == max)
+ h = 2 + (b - r) / delta;
+ else
+ h = 4 + (r - g) / delta;
+ h /= 6;
+ if (h < 0)
+ h += 1;
+ else if (h > 1)
+ h -= 1;
+ return (h, s, max);
+ }
+
+ private static void FromHsv(double h, double s, double v, Span rgb)
+ {
+ if (s == 0)
+ {
+ rgb[0] = rgb[1] = rgb[2] = (float)v;
+ return;
+ }
+ var hue = h == 1 ? 0 : h * 6;
+ var sector = (int)Math.Floor(hue);
+ var f = hue - sector;
+ var w = v * (1 - s);
+ var q = v * (1 - s * f);
+ var t = v * (1 - s * (1 - f));
+ var (r, g, b) = sector switch
+ {
+ 0 => (v, t, w),
+ 1 => (q, v, w),
+ 2 => (w, v, t),
+ 3 => (w, q, v),
+ 4 => (t, w, v),
+ _ => (v, w, q),
+ };
+ rgb[0] = (float)r;
+ rgb[1] = (float)g;
+ rgb[2] = (float)b;
+ }
+
+ /// HSL with hue 0…1 (GimpHSL).
+ private static (double H, double S, double L) ToHsl(double r, double g, double b)
+ {
+ var max = Math.Max(r, Math.Max(g, b));
+ var min = Math.Min(r, Math.Min(g, b));
+ var l = (max + min) / 2;
+ if (max == min)
+ return (-1, 0, l);
+ var delta = max - min;
+ var s = l <= 0.5 ? delta / (max + min) : delta / (2 - max - min);
+ double h;
+ if (max == r)
+ h = (g - b) / delta;
+ else if (max == g)
+ h = 2 + (b - r) / delta;
+ else
+ h = 4 + (r - g) / delta;
+ h /= 6;
+ if (h < 0)
+ h += 1;
+ return (h, s, l);
+ }
+
+ private static void FromHsl(double h, double s, double l, Span rgb)
+ {
+ if (s == 0)
+ {
+ rgb[0] = rgb[1] = rgb[2] = (float)l;
+ return;
+ }
+ var m2 = l <= 0.5 ? l * (1 + s) : l + s - l * s;
+ var m1 = 2 * l - m2;
+ rgb[0] = (float)HslValue(m1, m2, h * 6 + 2);
+ rgb[1] = (float)HslValue(m1, m2, h * 6);
+ rgb[2] = (float)HslValue(m1, m2, h * 6 - 2);
+ }
+
+ private static double HslValue(double n1, double n2, double hue)
+ {
+ if (hue > 6)
+ hue -= 6;
+ else if (hue < 0)
+ hue += 6;
+ return hue < 1 ? n1 + (n2 - n1) * hue
+ : hue < 3 ? n2
+ : hue < 4 ? n1 + (n2 - n1) * (4 - hue)
+ : n1;
+ }
+
+ /// Behind: the layer goes under the backdrop.
+ private static void DrawBehind(Composite how, float[] row, float[] layer, float opacity, ReadOnlySpan mask, int from, int to)
+ {
+ for (var x = from; x < to; x++)
+ {
+ var i = x * 4;
+ var inAlpha = row[i + 3];
+ if (how == Composite.ClipToBackdrop)
+ {
+ if (inAlpha == 0)
+ Set(row, i, layer[i], layer[i + 1], layer[i + 2]);
+ continue;
+ }
+ var layerAlpha = layer[i + 3] * opacity * MaskAt(mask, x - from);
+ var newAlpha = layerAlpha + (1 - layerAlpha) * inAlpha;
+ if (newAlpha != 0)
+ {
+ var ratio = inAlpha / newAlpha;
+ for (var c = 0; c < 3; c++)
+ row[i + c] = row[i + c] * ratio + layer[i + c] * (1 - ratio);
+ }
+ else
+ {
+ Set(row, i, layer[i], layer[i + 1], layer[i + 2]);
+ }
+ row[i + 3] = newAlpha;
+ }
+ }
+
+ /// Erase: the layer's alpha taken out of the backdrop.
+ private static void DrawErase(Composite how, float[] row, float[] layer, float opacity, ReadOnlySpan mask, int from, int to)
+ {
+ for (var x = from; x < to; x++)
+ {
+ var i = x * 4;
+ var inAlpha = row[i + 3];
+ var layerAlpha = layer[i + 3] * opacity * MaskAt(mask, x - from);
+ if (how != Composite.Union)
+ {
+ row[i + 3] = (1 - layerAlpha) * inAlpha;
+ continue;
+ }
+ var newAlpha = inAlpha + layerAlpha - 2 * inAlpha * layerAlpha;
+ if (newAlpha != 0)
+ {
+ var ratio = (1 - inAlpha) * layerAlpha / newAlpha;
+ for (var c = 0; c < 3; c++)
+ row[i + c] = ratio * layer[i + c] + (1 - ratio) * row[i + c];
+ }
+ row[i + 3] = newAlpha;
+ }
+ }
+
+ /// Merge: the layer replaces as much of the backdrop as its alpha takes.
+ private static void DrawMerge(Composite how, float[] row, float[] layer, float opacity, ReadOnlySpan mask, int from, int to)
+ {
+ for (var x = from; x < to; x++)
+ {
+ var i = x * 4;
+ var inAlpha = row[i + 3];
+ var layerAlpha = layer[i + 3] * opacity * MaskAt(mask, x - from);
+ if (how == Composite.ClipToBackdrop)
+ {
+ layerAlpha -= 1 - inAlpha;
+ if (layerAlpha > 0)
+ {
+ var ratio = layerAlpha / inAlpha;
+ for (var c = 0; c < 3; c++)
+ row[i + c] += (layer[i + c] - row[i + c]) * ratio;
+ }
+ continue;
+ }
+ inAlpha = Math.Min(inAlpha, 1 - layerAlpha);
+ var newAlpha = inAlpha + layerAlpha;
+ if (newAlpha != 0)
+ {
+ var ratio = layerAlpha / newAlpha;
+ for (var c = 0; c < 3; c++)
+ row[i + c] += (layer[i + c] - row[i + c]) * ratio;
+ }
+ row[i + 3] = newAlpha;
+ }
+ }
+
+ /// Split: the layer's alpha taken from the backdrop's, or the other way round.
+ private static void DrawSplit(Composite how, float[] row, float[] layer, float opacity, ReadOnlySpan mask, int from, int to)
+ {
+ for (var x = from; x < to; x++)
+ {
+ var i = x * 4;
+ var inAlpha = row[i + 3];
+ var layerAlpha = layer[i + 3] * opacity * MaskAt(mask, x - from);
+ if (how != Composite.Union)
+ {
+ row[i + 3] = Math.Max(inAlpha - layerAlpha, 0);
+ }
+ else if (layerAlpha <= inAlpha)
+ {
+ row[i + 3] = inAlpha - layerAlpha;
+ }
+ else
+ {
+ Set(row, i, layer[i], layer[i + 1], layer[i + 2]);
+ row[i + 3] = layerAlpha - inAlpha;
+ }
+ }
+ }
+
+ // ───────────────────────── Colour spaces ─────────────────────────
+
+ ///
+ /// Perceptual (sRGB-encoded) value of a linear one, as babl converts it: negative values (blends can make them)
+ /// stay on the curve's straight part.
+ ///
+ public static float ToPerceptual(float linear) =>
+ linear > 0.0031308f ? 1.055f * MathF.Pow(linear, 1 / 2.4f) - 0.055f : 12.92f * linear;
+
+ /// Linear value of a perceptual one.
+ public static float ToLinear(float perceptual) =>
+ perceptual > 0.04045f ? MathF.Pow((perceptual + 0.055f) / 1.055f, 2.4f) : perceptual / 12.92f;
+
+ /// Converts pixels (four floats each, alpha untouched) between spaces.
+ public static void Convert(Span pixels, Space from, Space to)
+ {
+ if (from == to)
+ return;
+ if (from == Space.Lab)
+ {
+ for (var i = 0; i < pixels.Length; i += 4)
+ LabToLinear(pixels.Slice(i, 3));
+ from = Space.Linear;
+ if (to == Space.Linear)
+ return;
+ }
+ if (from == Space.Perceptual)
+ {
+ for (var i = 0; i < pixels.Length; i += 4)
+ {
+ pixels[i] = ToLinear(pixels[i]);
+ pixels[i + 1] = ToLinear(pixels[i + 1]);
+ pixels[i + 2] = ToLinear(pixels[i + 2]);
+ }
+ if (to == Space.Linear)
+ return;
+ }
+ if (to == Space.Perceptual)
+ {
+ for (var i = 0; i < pixels.Length; i += 4)
+ {
+ pixels[i] = ToPerceptual(pixels[i]);
+ pixels[i + 1] = ToPerceptual(pixels[i + 1]);
+ pixels[i + 2] = ToPerceptual(pixels[i + 2]);
+ }
+ return;
+ }
+ for (var i = 0; i < pixels.Length; i += 4)
+ LinearToLab(pixels.Slice(i, 3));
+ }
+
+ // sRGB primaries adapted to D50 (Bradford), and D50 white: what babl's "CIE Lab" uses.
+ private const float WhiteX = 0.96420288f, WhiteZ = 0.82490540f;
+ private const float LabEpsilon = 216f / 24389, LabKappa = 24389f / 27;
+
+ private static void LinearToLab(Span c)
+ {
+ float r = c[0], g = c[1], b = c[2];
+ var x = (0.43603516f * r + 0.38511658f * g + 0.14305115f * b) / WhiteX;
+ var y = 0.22248840f * r + 0.71690369f * g + 0.06060791f * b;
+ var z = (0.01391602f * r + 0.09706116f * g + 0.71392822f * b) / WhiteZ;
+ float F(float t) => t > LabEpsilon ? MathF.Cbrt(t) : (LabKappa * t + 16) / 116;
+ float fx = F(x), fy = F(y), fz = F(z);
+ c[0] = 116 * fy - 16;
+ c[1] = 500 * (fx - fy);
+ c[2] = 200 * (fy - fz);
+ }
+
+ private static void LabToLinear(Span c)
+ {
+ var fy = (c[0] + 16) / 116;
+ var fx = fy + c[1] / 500;
+ var fz = fy - c[2] / 200;
+ float Inverse(float f) => f * f * f > LabEpsilon ? f * f * f : (116 * f - 16) / LabKappa;
+ var x = Inverse(fx) * WhiteX;
+ var y = c[0] > LabKappa * LabEpsilon ? fy * fy * fy : c[0] / LabKappa;
+ var z = Inverse(fz) * WhiteZ;
+ c[0] = 3.13405134f * x - 1.61702771f * y - 0.49065221f * z;
+ c[1] = -0.97876273f * x + 1.91614223f * y + 0.03344963f * z;
+ c[2] = 0.07194258f * x - 0.22897118f * y + 1.40521831f * z;
+ }
+}
diff --git a/src/Filee.Engines/Magick/XcfWriter.cs b/src/Filee.Engines/Magick/XcfWriter.cs
new file mode 100644
index 000000000..b58c898d9
--- /dev/null
+++ b/src/Filee.Engines/Magick/XcfWriter.cs
@@ -0,0 +1,335 @@
+// Writes GIMP's own file (.xcf): 8-bit RGB with alpha, RLE tiles, one layer per layer with its name (UTF-8), offsets,
+// opacity, visibility and layer mode, and no groups. ImageMagick reads XCF but can't write it. Like GIMP, the writer takes
+// the lowest XCF version that holds the layers: 0 ("gimp xcf file") for most, 2 for Soft light, 9 or 10 for the modes of
+// GIMP 2.10, all with 32-bit pointers, so GIMP 2.10 and 3 (and older GIMPs, for version 0 and 2) open it. Layers from
+// Paint.NET and Photoshop get GIMP's legacy modes, which blend and composite in perceptual RGB as those programs do;
+// GIMP 2.10's own modes would mix in linear light and change how the picture looks.
+
+using Filee.Core.Conversion;
+using ImageMagick;
+
+namespace Filee.Engines.Magick;
+
+internal static class XcfWriter
+{
+ /// A layer to write: its pixels (BGRA, not premultiplied, top row first) at its place on the canvas.
+ internal sealed record Layer(string Name, int X, int Y, int Width, int Height, byte[] Bgra, bool Visible = true,
+ byte Opacity = 255, int Mode = XcfBlend.NormalLegacy);
+
+ /// The largest canvas GIMP opens.
+ public const int MaxSide = Xcf.MaxSide;
+
+ private const int Tile = 64;
+
+ /// One picture as an .xcf of a single layer named "Background".
+ public static void Write(IMagickImage image, string path)
+ {
+ var (width, height) = ((int)image.Width, (int)image.Height);
+ Write(path, width, height, [new Layer("Background", 0, 0, width, height, PaintNetWriter.ToBgra(image))]);
+ }
+
+ /// A Paint.NET file as an .xcf with its layers, each cut to its pixels.
+ public static IReadOnlyList SaveFromPdn(ConversionStep step, IProgress? progress, CancellationToken ct)
+ {
+ var (width, height, read) = PaintNet.Open(step.InputPath);
+ var layers = new List();
+ foreach (var layer in read)
+ {
+ ct.ThrowIfCancellationRequested();
+ layers.Add(Cropped(layer.Name, layer.Bgra, width, height, layer.Visible, layer.Opacity, ModeOf(layer.Blend)));
+ }
+ progress?.Report(0.6);
+ return Save(step, width, height, layers, progress);
+ }
+
+ /// A PSD (or PSB) as an .xcf with its pixel layers, each cut to its pixels.
+ public static IReadOnlyList SaveFromPsd(ConversionStep step, IProgress? progress, CancellationToken ct)
+ {
+ var (width, height, read) = PaintNetWriter.ReadPsdLayers(step, ct);
+ var layers = read.Select(l => Cropped(l.Name, l.Bgra, width, height, l.Visible, l.Opacity, ModeOf(l.Compose))).ToList();
+ progress?.Report(0.6);
+ return Save(step, width, height, layers, progress);
+ }
+
+ private static IReadOnlyList Save(ConversionStep step, int width, int height, List layers, IProgress? progress)
+ {
+ var path = step.Output.Allocate("xcf");
+ if (path is null)
+ return [];
+ Write(path, width, height, layers);
+ progress?.Report(1);
+ return [path];
+ }
+
+ ///
+ /// A canvas-sized layer cut to the box around its pixels that aren't fully transparent (GIMP layers have their own
+ /// size and offsets); a layer with none stays the size of the canvas.
+ ///
+ internal static Layer Cropped(string name, byte[] bgra, int width, int height, bool visible, byte opacity, int mode)
+ {
+ int left = width, top = height, right = -1, bottom = -1;
+ for (var y = 0; y < height; y++)
+ {
+ for (var x = 0; x < width; x++)
+ {
+ if (bgra[(y * width + x) * 4 + 3] == 0)
+ continue;
+ left = Math.Min(left, x);
+ right = Math.Max(right, x);
+ top = Math.Min(top, y);
+ bottom = Math.Max(bottom, y);
+ }
+ }
+ if (right < 0)
+ return new Layer(name, 0, 0, width, height, bgra, visible, opacity, mode);
+ var (w, h) = (right - left + 1, bottom - top + 1);
+ if (w == width && h == height)
+ return new Layer(name, 0, 0, width, height, bgra, visible, opacity, mode);
+ var pixels = new byte[w * h * 4];
+ for (var y = 0; y < h; y++)
+ Array.Copy(bgra, ((top + y) * width + left) * 4, pixels, y * w * 4, w * 4);
+ return new Layer(name, left, top, w, h, pixels, visible, opacity, mode);
+ }
+
+ /// The GIMP mode of a Paint.NET blend mode (Reflect, Glow, Negation and Xor have none: Normal).
+ internal static int ModeOf(PaintNet.BlendMode blend) => blend switch
+ {
+ PaintNet.BlendMode.Multiply => XcfBlend.MultiplyLegacy,
+ PaintNet.BlendMode.Additive => XcfBlend.AdditionLegacy,
+ PaintNet.BlendMode.ColorBurn => XcfBlend.BurnLegacy,
+ PaintNet.BlendMode.ColorDodge => XcfBlend.DodgeLegacy,
+ PaintNet.BlendMode.Overlay => XcfBlend.Overlay,
+ PaintNet.BlendMode.Difference => XcfBlend.DifferenceLegacy,
+ PaintNet.BlendMode.Lighten => XcfBlend.LightenLegacy,
+ PaintNet.BlendMode.Darken => XcfBlend.DarkenLegacy,
+ PaintNet.BlendMode.Screen => XcfBlend.ScreenLegacy,
+ _ => XcfBlend.NormalLegacy,
+ };
+
+ /// The GIMP mode of a Photoshop blend mode, as ImageMagick names it (Normal where GIMP has none).
+ internal static int ModeOf(CompositeOperator compose) => compose switch
+ {
+ CompositeOperator.Dissolve => XcfBlend.Dissolve,
+ CompositeOperator.Multiply => XcfBlend.MultiplyLegacy,
+ CompositeOperator.Screen => XcfBlend.ScreenLegacy,
+ CompositeOperator.Overlay => XcfBlend.Overlay,
+ CompositeOperator.Difference => XcfBlend.DifferenceLegacy,
+ CompositeOperator.LinearDodge => XcfBlend.AdditionLegacy,
+ CompositeOperator.Darken => XcfBlend.DarkenLegacy,
+ CompositeOperator.Lighten => XcfBlend.LightenLegacy,
+ CompositeOperator.Hue => XcfBlend.HueLegacy,
+ CompositeOperator.Saturate => XcfBlend.SaturationLegacy,
+ CompositeOperator.Colorize => XcfBlend.ColorLegacy,
+ CompositeOperator.Luminize => XcfBlend.ValueLegacy,
+ CompositeOperator.ColorDodge => XcfBlend.DodgeLegacy,
+ CompositeOperator.ColorBurn => XcfBlend.BurnLegacy,
+ CompositeOperator.HardLight => XcfBlend.HardLightLegacy,
+ CompositeOperator.SoftLight => XcfBlend.SoftLightLegacy,
+ CompositeOperator.VividLight => XcfBlend.VividLight,
+ CompositeOperator.PinLight => XcfBlend.PinLight,
+ CompositeOperator.LinearLight => XcfBlend.LinearLight,
+ CompositeOperator.HardMix => XcfBlend.HardMix,
+ CompositeOperator.Exclusion => XcfBlend.Exclusion,
+ CompositeOperator.LinearBurn => XcfBlend.LinearBurn,
+ _ => XcfBlend.NormalLegacy,
+ };
+
+ /// The lowest XCF version holding these modes, as GIMP chooses it.
+ private static int VersionFor(IEnumerable modes) => modes.Select(mode => mode switch
+ {
+ >= XcfBlend.Normal => 10,
+ >= XcfBlend.Overlay => 9,
+ >= XcfBlend.SoftLightLegacy => 2,
+ _ => 0,
+ }).DefaultIfEmpty(0).Max();
+
+ /// Writes an .xcf of , bottom first, on a × canvas.
+ public static void Write(string path, int width, int height, IReadOnlyList layers)
+ {
+ if (width is < 1 or > MaxSide || height is < 1 or > MaxSide)
+ throw new InvalidOperationException(
+ $"GIMP opens pictures of at most {MaxSide:N0} pixels on a side; this one is {width:N0} × {height:N0}.");
+ if (layers.Count == 0 || layers.Any(l => l.Width < 1 || l.Height < 1 || l.Bgra.Length != l.Width * l.Height * 4))
+ throw new ArgumentException("Every layer must have its pixels.", nameof(layers));
+
+ var version = VersionFor(layers.Select(l => l.Mode));
+ using var file = new BinaryBigEndian(File.Create(path));
+ file.Bytes(version == 0 ? "gimp xcf file\0"u8 : System.Text.Encoding.ASCII.GetBytes($"gimp xcf v{version:D3}\0"));
+ file.U32((uint)width);
+ file.U32((uint)height);
+ file.U32(0); // RGB
+ if (version >= 4)
+ file.U32(150); // 8-bit, sRGB-encoded
+ file.U32(17); // PROP_COMPRESSION: RLE
+ file.U32(1);
+ file.Byte(1);
+ file.U32(0); // PROP_END
+ file.U32(0);
+
+ // The layer pointers, top layer first, then the (empty) list of channels.
+ var table = file.Position;
+ for (var i = 0; i <= layers.Count; i++)
+ file.U32(0);
+ file.U32(0);
+
+ for (var i = 0; i < layers.Count; i++)
+ {
+ file.Patch(table + 4 * (layers.Count - 1 - i), file.Position);
+ WriteLayer(file, layers[i]);
+ }
+ }
+
+ private static void WriteLayer(BinaryBigEndian file, Layer layer)
+ {
+ file.U32((uint)layer.Width);
+ file.U32((uint)layer.Height);
+ file.U32(1); // RGB with alpha
+ var name = System.Text.Encoding.UTF8.GetBytes(layer.Name);
+ file.U32((uint)name.Length + 1);
+ file.Bytes(name);
+ file.Byte(0);
+ file.U32(6); file.U32(4); file.U32(layer.Opacity); // PROP_OPACITY
+ file.U32(8); file.U32(4); file.U32(layer.Visible ? 1u : 0); // PROP_VISIBLE
+ file.U32(15); file.U32(8); file.U32(unchecked((uint)layer.X)); file.U32(unchecked((uint)layer.Y)); // PROP_OFFSETS
+ file.U32(7); file.U32(4); file.U32((uint)layer.Mode); // PROP_MODE
+ file.U32(0); file.U32(0); // PROP_END
+ var hierarchy = file.Position;
+ file.U32(0);
+ file.U32(0); // no layer mask
+
+ // The hierarchy: its size and bytes per pixel, the first level and no smaller ones.
+ file.Patch(hierarchy, file.Position);
+ file.U32((uint)layer.Width);
+ file.U32((uint)layer.Height);
+ file.U32(4);
+ var level = file.Position;
+ file.U32(0);
+ file.U32(0);
+
+ file.Patch(level, file.Position);
+ file.U32((uint)layer.Width);
+ file.U32((uint)layer.Height);
+ var columns = (layer.Width + Tile - 1) / Tile;
+ var rows = (layer.Height + Tile - 1) / Tile;
+ var tiles = file.Position;
+ for (var t = 0; t <= columns * rows; t++)
+ file.U32(0);
+
+ var pixels = new byte[Tile * Tile * 4];
+ var packed = new List(Tile * Tile * 5);
+ for (var t = 0; t < columns * rows; t++)
+ {
+ file.Patch(tiles + 4 * t, file.Position);
+ var x0 = t % columns * Tile;
+ var y0 = t / columns * Tile;
+ var w = Math.Min(Tile, layer.Width - x0);
+ var h = Math.Min(Tile, layer.Height - y0);
+ for (var y = 0; y < h; y++)
+ {
+ for (var x = 0; x < w; x++)
+ {
+ var source = ((y0 + y) * layer.Width + x0 + x) * 4;
+ var target = (y * w + x) * 4;
+ pixels[target] = layer.Bgra[source + 2];
+ pixels[target + 1] = layer.Bgra[source + 1];
+ pixels[target + 2] = layer.Bgra[source];
+ pixels[target + 3] = layer.Bgra[source + 3];
+ }
+ }
+ packed.Clear();
+ for (var plane = 0; plane < 4; plane++)
+ PackRle(pixels, plane, w * h, packed);
+ file.Bytes(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(packed));
+ }
+ }
+
+ ///
+ /// One byte of every pixel as an RLE stream. As XCF's notes advise, so a tile never grows past what GIMP reads: no
+ /// one-byte runs (a literal instead), long forms only past 127, never two literals in a row.
+ ///
+ internal static void PackRle(byte[] pixels, int plane, int count, List output)
+ {
+ var i = 0;
+ var literalStart = -1;
+ while (i < count)
+ {
+ var value = pixels[i * 4 + plane];
+ var run = 1;
+ while (i + run < count && run < 65_535 && pixels[(i + run) * 4 + plane] == value)
+ run++;
+ if (run >= 3)
+ {
+ FlushLiteral(i);
+ if (run <= 127)
+ {
+ output.Add((byte)(run - 1));
+ }
+ else
+ {
+ output.Add(127);
+ output.Add((byte)(run >> 8));
+ output.Add((byte)run);
+ }
+ output.Add(value);
+ i += run;
+ continue;
+ }
+ if (literalStart < 0)
+ literalStart = i;
+ i += run;
+ }
+ FlushLiteral(count);
+
+ void FlushLiteral(int end)
+ {
+ if (literalStart < 0)
+ return;
+ var length = end - literalStart;
+ if (length <= 127)
+ {
+ output.Add((byte)(256 - length));
+ }
+ else
+ {
+ output.Add(128);
+ output.Add((byte)(length >> 8));
+ output.Add((byte)length);
+ }
+ for (var k = literalStart; k < end; k++)
+ output.Add(pixels[k * 4 + plane]);
+ literalStart = -1;
+ }
+ }
+
+ /// A file written big-endian, with 32-bit pointers patched in once their target is written.
+ private sealed class BinaryBigEndian(Stream stream) : IDisposable
+ {
+ private readonly BufferedStream _stream = new(stream, 1 << 16);
+
+ public long Position => _stream.Position;
+
+ public void Byte(byte value) => _stream.WriteByte(value);
+
+ public void Bytes(ReadOnlySpan bytes) => _stream.Write(bytes);
+
+ public void U32(uint value)
+ {
+ Span bytes = stackalloc byte[4];
+ System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(bytes, value);
+ _stream.Write(bytes);
+ }
+
+ /// Writes at , a pointer slot written earlier.
+ public void Patch(long at, long target)
+ {
+ if (target > uint.MaxValue)
+ throw new InvalidOperationException("This picture is too large for a GIMP file of this kind (4 GB).");
+ var position = _stream.Position;
+ _stream.Position = at;
+ U32((uint)target);
+ _stream.Position = position;
+ }
+
+ public void Dispose() => _stream.Dispose();
+ }
+}
diff --git a/src/Filee.Engines/Media/ClipPreview.cs b/src/Filee.Engines/Media/ClipPreview.cs
index 38993c5ea..141c77270 100644
--- a/src/Filee.Engines/Media/ClipPreview.cs
+++ b/src/Filee.Engines/Media/ClipPreview.cs
@@ -44,21 +44,24 @@ public static partial class ClipPreview
&& File.Exists(ffplay) ? ffplay : null;
///
- /// Plays of the video at from its start (use
+ /// Plays of the video (or sound) at from its start (use
/// to start later): each picture, pixels high in BGRA, goes to
/// (pixels, width, height, seconds into ) when it is due; the sound
/// plays along. Ends when the clip has played or is cancelled (then it stops at
/// once and doesn't throw). is called on a worker thread, with a buffer it may keep only
- /// until it returns.
+ /// until it returns. A sound has no pictures: is then called as often, with no pixels and
+ /// size 0, to tell how far the clip has played.
///
- /// FFmpeg is missing or the video can't be read.
+ /// FFmpeg is missing or the file can't be read.
public static async Task PlayAsync(string path, ClipEdit edit, int height, Action onFrame,
CancellationToken cancellationToken)
{
var (ffmpeg, ffprobe) = FfmpegConverter.Locate() ?? throw new InvalidOperationException("FFmpeg was not found.");
- var input = Path.GetFullPath(path);
+ var input = MediaInput.For(path);
var info = await MediaInfo.ProbeAsync(ffprobe, input, cancellationToken).ConfigureAwait(false);
- var video = info.Video ?? throw new InvalidOperationException("The file has no video stream.");
+ var video = info.Video;
+ if (video is null && info.Audio is null)
+ throw new InvalidOperationException("The file has no video or sound stream.");
ClipEdit clip;
try
{
@@ -70,7 +73,11 @@ public static async Task PlayAsync(string path, ClipEdit edit, int height, Actio
}
using var stop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
- var pictures = Start(ffmpeg, MediaEncoding.ClipPreviewVideo(input, clip, video, height, Fps, out var width, out var frameHeight), stdin: false);
+ Process? pictures = null;
+ var width = 0;
+ var frameHeight = 0;
+ if (video is not null)
+ pictures = Start(ffmpeg, MediaEncoding.ClipPreviewVideo(input, clip, video, height, Fps, out width, out frameHeight), stdin: false);
Process? decoder = null, player = null;
Task pump = Task.CompletedTask;
try
@@ -83,23 +90,40 @@ public static async Task PlayAsync(string path, ClipEdit edit, int height, Actio
pump = Pump(decoder, player, stop.Token);
}
- var frame = new byte[width * frameHeight * 4];
- // The clock starts with the first picture: FFmpeg takes a moment to seek and start, and pictures counted
- // from before that would all be late (and skipped) until it caught up.
- Stopwatch? clock = null;
var delay = player is null ? TimeSpan.Zero : SoundStart;
- for (var n = 0; !stop.IsCancellationRequested; n++)
+ if (pictures is null)
{
- if (!await ReadFully(pictures.StandardOutput.BaseStream, frame, stop.Token).ConfigureAwait(false))
- break;
- clock ??= Stopwatch.StartNew();
- var due = delay + TimeSpan.FromSeconds((double)n / Fps);
- var wait = due - clock.Elapsed;
- if (wait > TimeSpan.Zero)
- await Task.Delay(wait, stop.Token).ConfigureAwait(false);
- else if (wait < TimeSpan.FromSeconds(-0.2))
- continue; // behind: skip a picture rather than run late
- onFrame(frame, width, frameHeight, (double)n / Fps);
+ // A sound: no pictures to wait for, so the clock tells how far it has played.
+ var started = Stopwatch.StartNew();
+ while (!stop.IsCancellationRequested)
+ {
+ var played = (started.Elapsed - delay).TotalSeconds;
+ if (played >= clip.Length)
+ break;
+ if (played > 0)
+ onFrame([], 0, 0, played);
+ await Task.Delay(TimeSpan.FromSeconds(1.0 / Fps), stop.Token).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ var frame = new byte[width * frameHeight * 4];
+ // The clock starts with the first picture: FFmpeg takes a moment to seek and start, and pictures counted
+ // from before that would all be late (and skipped) until it caught up.
+ Stopwatch? clock = null;
+ for (var n = 0; !stop.IsCancellationRequested; n++)
+ {
+ if (!await ReadFully(pictures.StandardOutput.BaseStream, frame, stop.Token).ConfigureAwait(false))
+ break;
+ clock ??= Stopwatch.StartNew();
+ var due = delay + TimeSpan.FromSeconds((double)n / Fps);
+ var wait = due - clock.Elapsed;
+ if (wait > TimeSpan.Zero)
+ await Task.Delay(wait, stop.Token).ConfigureAwait(false);
+ else if (wait < TimeSpan.FromSeconds(-0.2))
+ continue; // behind: skip a picture rather than run late
+ onFrame(frame, width, frameHeight, (double)n / Fps);
+ }
}
// The sound may still be playing its last moment.
if (!stop.IsCancellationRequested && player is not null)
@@ -134,7 +158,7 @@ public static async Task PlayAsync(string path, ClipEdit edit, int height, Actio
/// The arguments of the preview's sound: the music's track probed like the video's.
///
internal static async Task> PreviewSoundArgumentsAsync(string ffprobe, string input, ClipEdit clip,
- VideoStreamInfo video, AudioStreamInfo? audio, CancellationToken cancellationToken)
+ VideoStreamInfo? video, AudioStreamInfo? audio, CancellationToken cancellationToken)
{
var musicTrack = clip.Music is { } song
? await MediaInfo.AudioTrackAsync(ffprobe, Path.GetFullPath(song.Path), cancellationToken).ConfigureAwait(false) : 0;
@@ -164,7 +188,7 @@ public static async Task ReadLoudnessAsync(string path, double start, d
internal static async Task ReadLoudnessAsync((string Ffmpeg, string Ffprobe) tools, string path, double start, double end,
int count, string workFolder, CancellationToken cancellationToken)
{
- var input = Path.GetFullPath(path);
+ var input = MediaInput.For(path, workDirectory: workFolder);
var track = await MediaInfo.AudioTrackAsync(tools.Ffprobe, input, cancellationToken).ConfigureAwait(false);
var samples = Path.Combine(workFolder, Guid.NewGuid().ToString("N") + ".f32");
try
@@ -196,7 +220,7 @@ internal static async Task ReadLoudnessAsync((string Ffmpeg, string Ffp
public static async Task ReadSoundLengthAsync(string path, CancellationToken cancellationToken)
{
var (_, ffprobe) = FfmpegConverter.Locate() ?? throw new InvalidOperationException("FFmpeg was not found.");
- var info = await MediaInfo.ProbeAsync(ffprobe, Path.GetFullPath(path), cancellationToken);
+ var info = await MediaInfo.ProbeAsync(ffprobe, MediaInput.For(path), cancellationToken);
if (info.Audio is null)
throw new InvalidOperationException("The file has no sound.");
return info.Duration?.TotalSeconds is > 0 and var seconds ? seconds : throw new InvalidOperationException("The file doesn't say how long it is.");
@@ -212,7 +236,7 @@ public static async Task> FindSilencesAsync(string path
internal static async Task> FindSilencesAsync((string Ffmpeg, string Ffprobe) tools, string path,
double start, double end, CancellationToken cancellationToken)
{
- var input = Path.GetFullPath(path);
+ var input = MediaInput.For(path);
var track = await MediaInfo.AudioTrackAsync(tools.Ffprobe, input, cancellationToken).ConfigureAwait(false);
var result = await ProcessRunner.RunAsync(tools.Ffmpeg, SilenceArguments(input, start, end, track), TimeSpan.FromMinutes(10), cancellationToken);
if (result.ExitCode != 0)
diff --git a/src/Filee.Engines/Media/FfmpegConverter.cs b/src/Filee.Engines/Media/FfmpegConverter.cs
index 6cd72da0a..c2c306e83 100644
--- a/src/Filee.Engines/Media/FfmpegConverter.cs
+++ b/src/Filee.Engines/Media/FfmpegConverter.cs
@@ -129,14 +129,13 @@ public async Task> ConvertAsync(ConversionStep step, IProg
progress, cancellationToken);
if (step.To == "heic")
return await HeicWriter.ConvertAsync(ffmpeg, step, progress, cancellationToken);
- // A Hikvision SD-card recording is read through the concat protocol, with the header FFmpeg needs for its sound.
- var input = HikvisionStream.InputFor(Path.GetFullPath(step.InputPath), step.WorkDirectory);
+ var input = MediaInput.For(step.InputPath, step.From, step.WorkDirectory, step.Notice);
var info = await MediaInfo.ProbeAsync(ffprobe, input, cancellationToken);
progress?.Report(0.02);
if (step.FrameSequence)
return await FrameSequenceWriter.ConvertAsync(ffmpeg, step, input, info, progress, cancellationToken);
- if (step.Preset.CutsClipOf(step.From) && MediaEncoding.VideoTargets.ContainsKey(step.To))
+ if (step.Preset.CutsClipOf(step.From) && (MediaEncoding.VideoTargets.ContainsKey(step.To) || MediaEncoding.AudioTargets.ContainsKey(step.To)))
return await CutClipAsync(ffmpeg, ffprobe, step, input, info, progress, cancellationToken);
if (step.Preset.SplitsVideoOf(step.From) && MediaEncoding.VideoTargets.ContainsKey(step.To))
return await SplitAsync(ffmpeg, step, input, info, progress, cancellationToken);
@@ -346,8 +345,9 @@ public async Task JoinAsync(IReadOnlyList inputPaths, string outputPath,
var clips = new List<(string Path, MediaInfo Info)>();
for (var i = 0; i < inputPaths.Count; i++)
{
- var path = Path.GetFullPath(inputPaths[i]);
- await RequireTrackerDemuxerAsync(ffmpeg, FormatRegistry.Detect(path)?.Id);
+ var clipFormat = FormatRegistry.Detect(inputPaths[i])?.Id;
+ await RequireTrackerDemuxerAsync(ffmpeg, clipFormat);
+ var path = MediaInput.For(inputPaths[i], clipFormat, scratch);
clips.Add((path, await MediaInfo.ProbeAsync(ffprobe, path, cancellationToken)));
progress?.Report(0.05 * (i + 1) / inputPaths.Count);
}
@@ -453,8 +453,14 @@ private static async Task RunJoinPassAsync(string ffmpeg, IReadOnlyList
private async Task> CutClipAsync(string ffmpeg, string ffprobe, ConversionStep step, string input, MediaInfo info,
IProgress? progress, CancellationToken cancellationToken)
{
- if (info.Video is null)
- throw new InvalidOperationException("The file has no video stream.");
+ // A sound target is written by the sound clip, whatever the file holds besides the sound (an audiobook's chapter
+ // pictures are a real video track): the graph leaves the picture out anyway.
+ if (info.Video is null || !MediaEncoding.VideoTargets.ContainsKey(step.To))
+ {
+ if (info.Audio is null || !MediaEncoding.AudioTargets.ContainsKey(step.To))
+ throw new InvalidOperationException("The file has no video stream.");
+ return await CutSoundClipAsync(ffmpeg, ffprobe, step, input, info, progress, cancellationToken);
+ }
var media = step.Preset.Media;
var edit = media.Clip!.Clamped(info.Duration?.TotalSeconds);
var output = step.Output.Allocate(FormatRegistry.Get(step.To).PrimaryExtension);
@@ -523,6 +529,35 @@ await RunWithFallbackAsync(Plan(hardware), hardware, Plan,
return [output];
}
+ ///
+ /// "Cut a clip" of a sound (an MP3, a recording): the parts, volumes, speeds, fades and music the window made, always
+ /// encoded through one filter graph (a sound takes moments to encode, and the cut is exact), written in the sound's
+ /// own format.
+ ///
+ private async Task> CutSoundClipAsync(string ffmpeg, string ffprobe, ConversionStep step, string input, MediaInfo info,
+ IProgress? progress, CancellationToken cancellationToken)
+ {
+ var media = step.Preset.Media;
+ var edit = media.Clip!.Clamped(info.Duration?.TotalSeconds);
+ var output = step.Output.Allocate(FormatRegistry.Get(step.To).PrimaryExtension);
+ if (output is null)
+ return [];
+ try
+ {
+ var musicTrack = edit.Music is { } song
+ ? await MediaInfo.AudioTrackAsync(ffprobe, Path.GetFullPath(song.Path), cancellationToken) : 0;
+ var pass = new FfmpegPass(MediaEncoding.ClipSoundArguments(input, output, step.To, edit, media, info, musicTrack), 1);
+ await RunPassesAsync(ffmpeg, [pass], output, TimeSpan.FromSeconds(edit.Length), progress, cancellationToken);
+ }
+ catch
+ {
+ TryDelete(output);
+ throw;
+ }
+ progress?.Report(1);
+ return [output];
+ }
+
///
/// A video in parts (#1259), every so many seconds or in equal parts, copied as it is ( ).
/// FFmpeg writes the parts into the work directory, and only once all are written do they move to their places as
diff --git a/src/Filee.Engines/Media/HikvisionStream.cs b/src/Filee.Engines/Media/HikvisionStream.cs
index f5a74d84f..805f8e089 100644
--- a/src/Filee.Engines/Media/HikvisionStream.cs
+++ b/src/Filee.Engines/Media/HikvisionStream.cs
@@ -4,6 +4,8 @@
// same stream without the header, so FFmpeg takes that sound for MP2 and the conversion fails. Filee puts the header in
// front of the file for FFmpeg with the concat protocol, so the recording is not copied.
+using Microsoft.Win32.SafeHandles;
+
namespace Filee.Engines.Media;
internal static class HikvisionStream
@@ -25,15 +27,12 @@ public static string InputFor(string path, string workDirectory)
return path;
try
{
- using (var file = File.OpenRead(path))
+ using (var file = File.OpenHandle(path))
{
if (!NeedsHeader(file))
return path;
}
- Directory.CreateDirectory(workDirectory);
- var header = Path.Combine(workDirectory, "hikvision-imkh.bin");
- File.WriteAllBytes(header, Header());
- return $"concat:{header}|{path}";
+ return $"concat:{WriteHeader(workDirectory)}|{path}";
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
@@ -41,6 +40,23 @@ public static string InputFor(string path, string workDirectory)
}
}
+ ///
+ /// Writes the header to , unless it is there already (the shared folder of the Cut a clip
+ /// editor). It is written under another name first, so a reader never sees half of it.
+ ///
+ private static string WriteHeader(string folder)
+ {
+ Directory.CreateDirectory(folder);
+ var header = Path.Combine(folder, "hikvision-imkh.bin");
+ var bytes = Header();
+ if (File.Exists(header) && new FileInfo(header).Length == bytes.Length && File.ReadAllBytes(header).AsSpan().SequenceEqual(bytes))
+ return header;
+ var staged = header + "." + Guid.NewGuid().ToString("N")[..8];
+ File.WriteAllBytes(staged, bytes);
+ File.Move(staged, header, overwrite: true);
+ return header;
+ }
+
/// The 40-byte header of Hikvision's exports; FFmpeg only looks at its "IMKH" magic.
internal static byte[] Header()
{
@@ -51,13 +67,26 @@ internal static byte[] Header()
///
/// True for a program stream that starts with a pack header (no IMKH header before it) and whose stream map holds
- /// mu-law sound. False for anything else, including damaged data: FFmpeg then reads the file as it is.
+ /// mu-law sound. False for anything else, including damaged data: FFmpeg then reads the file as it is. Only the first
+ /// four bytes are read of a file that doesn't start with a pack header (an MP4, a Matroska file, ...).
///
- public static bool NeedsHeader(Stream file)
+ public static bool NeedsHeader(SafeFileHandle file)
+ {
+ Span start = stackalloc byte[4];
+ if (RandomAccess.Read(file, start, 0) < 4 || !start.SequenceEqual(PackStart))
+ return false;
+ var buffer = new byte[(int)Math.Min(ScanBytes, RandomAccess.GetLength(file))];
+ var read = 0;
+ while (read < buffer.Length && RandomAccess.Read(file, buffer.AsSpan(read), read) is > 0 and var got)
+ read += got;
+ return NeedsHeader(buffer.AsSpan(0, read));
+ }
+
+ /// The same as for the start of a file already read.
+ public static bool NeedsHeader(ReadOnlySpan data)
{
- var buffer = new byte[(int)Math.Min(ScanBytes, Math.Max(0, file.Length - file.Position))];
- var read = file.ReadAtLeast(buffer, buffer.Length, throwOnEndOfStream: false);
- var data = buffer.AsSpan(0, read);
+ if (data.Length > ScanBytes)
+ data = data[..ScanBytes];
if (data.Length < 4 || !data[..4].SequenceEqual(PackStart))
return false;
for (var at = data.IndexOf(MapStart); at >= 0;)
diff --git a/src/Filee.Engines/Media/LivePhotoWriter.cs b/src/Filee.Engines/Media/LivePhotoWriter.cs
index c46fc41de..0cc13df5d 100644
--- a/src/Filee.Engines/Media/LivePhotoWriter.cs
+++ b/src/Filee.Engines/Media/LivePhotoWriter.cs
@@ -33,7 +33,7 @@ public static async Task> ConvertAsync(string ffmpeg, stri
{
var options = step.Preset.Media;
var apple = options.LivePhoto == LivePhotoKind.Apple;
- var input = Path.GetFullPath(step.InputPath);
+ var input = MediaInput.For(step.InputPath, step.From, step.WorkDirectory, step.Notice);
var info = await MediaInfo.ProbeAsync(ffprobe, input, ct);
var video = info.Video ?? throw new InvalidOperationException("The file has no video stream.");
var clip = Clip(options, info.Duration?.TotalSeconds);
diff --git a/src/Filee.Engines/Media/MediaEncoding.Clip.cs b/src/Filee.Engines/Media/MediaEncoding.Clip.cs
index b52f28dfc..7c98267ab 100644
--- a/src/Filee.Engines/Media/MediaEncoding.Clip.cs
+++ b/src/Filee.Engines/Media/MediaEncoding.Clip.cs
@@ -180,6 +180,33 @@ internal static List ClipEditArguments(string input, string output, stri
return args;
}
+ ///
+ /// The arguments that write of the sound (no picture) as
+ /// : the parts through one filter graph, the picture of the file's cover kept where the
+ /// format holds one, encoded with the preset's quality and bitrate. The sound is mixed to 48 kHz stereo by the graph.
+ ///
+ /// The file has no sound.
+ /// FFmpeg cannot write .
+ internal static List ClipSoundArguments(string input, string output, string target, ClipEdit edit, MediaOptions options,
+ MediaInfo info, int musicTrack = 0)
+ {
+ var audio = info.Audio ?? throw new InvalidOperationException("The file has no audio track.");
+ if (!AudioTargets.TryGetValue(target, out var spec))
+ throw new NotSupportedException($"FFmpeg cannot write '{target}'.");
+ var graph = ClipGraphOf(input, edit, audio: true, width: 0, height: 0, fps: "1", video: false, music: true,
+ audioTrack: audio.Ordinal, musicTrack: musicTrack);
+ List args = [.. Common, .. graph.Inputs, "-filter_complex", graph.Filter, "-map", "[a]"];
+ if (info.CoverStreamIndex is { } cover && CoverTargets.Contains(target))
+ args.AddRange(["-map", $"0:{cover}", "-c:v", "copy", "-disposition:v:0", "attached_pic"]);
+ 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());
+ args.AddRange(ContainerArguments(target));
+ args.AddRange(["-f", spec.Muxer, "-progress", "pipe:1", output]);
+ return args;
+ }
+
///
/// The arguments that play in the clip window: its pictures
/// pixels high as raw BGRA frames at on stdout.
@@ -194,13 +221,15 @@ internal static List ClipPreviewVideo(string input, ClipEdit edit, Video
}
/// The arguments that play the sound of : a WAV stream on stdout (for ffplay).
+ /// The video's picture stream; null for a sound.
/// The video has sound of its own (else only the music plays).
/// The selected audio track's ordinal in the input file.
/// The music file's audio track ordinal.
- internal static List ClipPreviewAudio(string input, ClipEdit edit, VideoStreamInfo video, bool hasAudio = true, int audioTrack = 0,
+ internal static List ClipPreviewAudio(string input, ClipEdit edit, VideoStreamInfo? video, bool hasAudio = true, int audioTrack = 0,
int musicTrack = 0)
{
- var (width, height) = ClipSize(video, 90);
+ // A sound has no picture: the graph has none either, so any size will do.
+ var (width, height) = video is null ? (2, 2) : ClipSize(video, 90);
var graph = ClipGraphOf(input, edit, hasAudio, width, height, "10", video: false, audioTrack: audioTrack, musicTrack: musicTrack);
return [.. Common, .. graph.Inputs, "-filter_complex", graph.Filter, "-map", "[a]", "-c:a", "pcm_s16le", "-f", "wav", "pipe:1"];
}
diff --git a/src/Filee.Engines/Media/MediaInput.cs b/src/Filee.Engines/Media/MediaInput.cs
new file mode 100644
index 000000000..658b91df4
--- /dev/null
+++ b/src/Filee.Engines/Media/MediaInput.cs
@@ -0,0 +1,49 @@
+// What FFmpeg is given to read a file, for every action that hands it one (convert, caption, Live / Motion Photo,
+// merge, the Cut a clip editor, size estimates). Most files are given as they are. A Wisenet WAVE / Nx Witness export
+// (.nov) is given as its camera recording, read where it lies through the subfile protocol (NovExport); a Hikvision
+// SD-card recording with mu-law sound as a concat of the IMKH header FFmpeg needs and the file (HikvisionStream).
+
+using Filee.Core.Formats;
+using Filee.Core.Platform;
+
+namespace Filee.Engines.Media;
+
+internal static class MediaInput
+{
+ ///
+ /// Protocols the concat demuxer must allow for a list of these inputs (Merge videos): it allows only file, crypto
+ /// and data by default, whatever the command line allows.
+ ///
+ public const string ListProtocols = "file,crypto,data,subfile,concat";
+
+ ///
+ /// The input to give FFmpeg for , a file of (detected from its
+ /// name when null). holds what Filee writes for it (the Hikvision header); without
+ /// one, a folder in Filee's work folder does. hears what the user should know, as
+ /// a localization key (a .nov export's other recordings left out). Refuses a protected .nov export or one without a
+ /// recording ( ), and throws
+ /// for a damaged one.
+ ///
+ public static string For(string path, string? format = null, string? workDirectory = null, Action? notice = null)
+ {
+ var full = Path.GetFullPath(path);
+ format ??= FormatRegistry.Detect(full)?.Id;
+ if (format == "nov")
+ return NovExport.InputFor(full, notice);
+ // Only a video can be a Hikvision recording: sound files and pictures aren't looked into.
+ if (format is not null && FormatRegistry.FindById(format) is { Category: not FormatCategory.Video })
+ return full;
+ return HikvisionStream.InputFor(full, workDirectory ?? SharedFolder);
+ }
+
+ /// True for an input made a protocol URL of, rather than a path.
+ public static bool IsUrl(string input) =>
+ input.StartsWith("subfile,", StringComparison.Ordinal) || input.StartsWith("concat:", StringComparison.Ordinal);
+
+ ///
+ /// Where the Hikvision header is written for actions without a work folder (the Cut a clip editor, estimates): in
+ /// Filee's own work folder, private to the user (mode 700 on Linux and macOS), so nobody else can put a file in its
+ /// place. It is the same 40 bytes every time, so it is left there.
+ ///
+ private static string SharedFolder => Path.Combine(WorkFolders.Root, "media");
+}
diff --git a/src/Filee.Engines/Media/MediaJoining.cs b/src/Filee.Engines/Media/MediaJoining.cs
index 63fbceebf..dcab4baa3 100644
--- a/src/Filee.Engines/Media/MediaJoining.cs
+++ b/src/Filee.Engines/Media/MediaJoining.cs
@@ -81,7 +81,8 @@ public static bool CanCopyVideos(IReadOnlyList clips, MediaOptions op
/// 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"));
+ string.Concat(paths.Select(path => "file '"
+ + (MediaInput.IsUrl(path) ? path : Path.GetFullPath(path)).Replace("'", @"'\''", StringComparison.Ordinal) + "'\n"));
///
/// Clips that share codecs and size, copied one after the other into one MP4 ( ). With
@@ -91,7 +92,9 @@ public static string ConcatList(IEnumerable paths) =>
public static List CopyVideos(string listFile, string output, MediaInfo first, JoinMusic? music = null,
MediaOptions? options = null, string workDirectory = "")
{
- List args = [.. MediaEncoding.Common, "-f", "concat", "-safe", "0", "-i", listFile];
+ // The clips may be read through the subfile or concat protocols (MediaInput).
+ List args = [.. MediaEncoding.Common, "-protocol_whitelist", MediaInput.ListProtocols, "-f", "concat", "-safe", "0",
+ "-i", listFile];
if (music is not null)
{
var graph = new List();
diff --git a/src/Filee.Engines/Media/NovExport.cs b/src/Filee.Engines/Media/NovExport.cs
new file mode 100644
index 000000000..e9e39a22f
--- /dev/null
+++ b/src/Filee.Engines/Media/NovExport.cs
@@ -0,0 +1,174 @@
+// Wisenet WAVE / Nx Witness exports (.nov). Network Optix's client (nx_open, MPL-2.0: vms/libs/nx_vms_common/src/nx/core/
+// layout/layout_file_info.h) writes a layout file as a series of named streams, each a NUL-terminated UTF-8 name followed
+// by its data, then an index of where each stream starts:
+// version 2: | name | data | ... | Header | CryptoInfo (if protected) | StreamIndexEntry ... | Footer (20 bytes) |
+// version 1: | StreamIndexV1 (magic, version, count, 256 entries) | CryptoInfo (if protected) | name | data | ... |
+// Every camera's recording is a Matroska stream named "_.mkv"; the others hold the layout, its time
+// range and the watermark. FFmpeg reads the recording where it lies through the subfile protocol, so nothing is copied.
+// A password-protected export is encrypted (AES-256 with a key from the password): it is refused.
+
+using System.Buffers.Binary;
+using System.Globalization;
+using System.Text;
+using Filee.Core.Conversion;
+
+namespace Filee.Engines.Media;
+
+internal static class NovExport
+{
+ private const ulong FooterMagic = 0xdea5489c3f44ca16;
+ private const ulong IndexMagicV1 = 0xfed8260da9eebc04;
+ private const ulong CryptedIndexMagicV1 = 0xfed8260da9eebc03;
+ private const int FooterSize = 20;
+ private const int HeaderSize = 8;
+ private const int EntrySize = 16;
+ private const int CryptoInfoSize = 256;
+ private const int MaxStreamsV1 = 256;
+ private const int IndexSizeV1 = 16 + MaxStreamsV1 * EntrySize;
+
+ /// More streams than any export holds (a few per camera); a larger count is a damaged index.
+ internal const int MaxStreams = 65_536;
+
+ /// Longest stream name read; the client itself reads names into a 1024-byte buffer.
+ private const int MaxNameBytes = 1024;
+
+ /// A stream of the export: its name and where its data lies in the file.
+ internal sealed record Stream(string Name, long Start, long End)
+ {
+ public long Length => End - Start;
+ }
+
+ /// The streams of an export, and whether it is password-protected (its data encrypted).
+ internal sealed record Index(bool Encrypted, IReadOnlyList Streams);
+
+ ///
+ /// The input to give FFmpeg for the export at : its largest camera recording, through the
+ /// subfile protocol. Refuses a password-protected export and one without a recording; a damaged index throws
+ /// . When the export holds other recordings (other cameras, or pieces of one with
+ /// gaps), hears that they are left out.
+ ///
+ public static string InputFor(string path, Action? notice = null)
+ {
+ Index index;
+ using (var file = File.OpenRead(path))
+ index = Read(file);
+ if (index.Encrypted)
+ throw new ConversionRefusedException("error.nov_password");
+ var recordings = index.Streams
+ .Where(s => s.Name.EndsWith(".mkv", StringComparison.OrdinalIgnoreCase) && s.Length > 0)
+ .ToList();
+ var recording = recordings.MaxBy(s => s.Length) ?? throw new ConversionRefusedException("error.nov_no_video");
+ if (recordings.Count > 1)
+ notice?.Invoke("notice.nov_recordings_left_out");
+ return string.Create(CultureInfo.InvariantCulture,
+ $"subfile,,start,{recording.Start},end,{recording.End},,:{path}");
+ }
+
+ /// Reads the index of an export (version 2, or the older version 1).
+ public static Index Read(System.IO.Stream file)
+ {
+ var length = file.Length;
+ if (length >= FooterSize)
+ {
+ Span footer = stackalloc byte[FooterSize];
+ ReadAt(file, length - FooterSize, footer);
+ if (BinaryPrimitives.ReadUInt64LittleEndian(footer) == FooterMagic)
+ return ReadVersion2(file, footer);
+ }
+ if (length >= 8)
+ {
+ Span magic = stackalloc byte[8];
+ ReadAt(file, 0, magic);
+ if (BinaryPrimitives.ReadUInt64LittleEndian(magic) is IndexMagicV1 or CryptedIndexMagicV1)
+ return ReadVersion1(file);
+ }
+ throw new InvalidDataException("This is not a Wisenet WAVE / Nx Witness export: its index is missing.");
+ }
+
+ private static Index ReadVersion2(System.IO.Stream file, ReadOnlySpan footer)
+ {
+ var length = file.Length;
+ var version = BinaryPrimitives.ReadUInt32LittleEndian(footer[8..]);
+ if (version != 2)
+ throw new InvalidDataException($"This export's index is version {version}, which Filee can't read.");
+ var headerAt = BinaryPrimitives.ReadInt64LittleEndian(footer[12..]);
+ if (headerAt < 0 || headerAt > length - FooterSize - HeaderSize)
+ throw Damaged();
+ Span header = stackalloc byte[HeaderSize];
+ ReadAt(file, headerAt, header);
+ var encrypted = BinaryPrimitives.ReadUInt32LittleEndian(header) != 0;
+ var count = BinaryPrimitives.ReadUInt32LittleEndian(header[4..]);
+ var entriesAt = headerAt + HeaderSize + (encrypted ? CryptoInfoSize : 0);
+ // The entries must fill the room between the header and the footer, before anything is allocated for them.
+ if (count > MaxStreams || entriesAt + (long)count * EntrySize != length - FooterSize)
+ throw Damaged();
+ // A protected export's streams are encrypted: there is nothing to read without the password.
+ if (encrypted)
+ return new Index(true, []);
+ return new Index(false, Streams(file, ReadOffsets(file, entriesAt, (int)count), headerAt));
+ }
+
+ private static Index ReadVersion1(System.IO.Stream file)
+ {
+ var length = file.Length;
+ if (length < IndexSizeV1)
+ throw Damaged();
+ Span head = stackalloc byte[16];
+ ReadAt(file, 0, head);
+ var encrypted = BinaryPrimitives.ReadUInt64LittleEndian(head) == CryptedIndexMagicV1;
+ var version = BinaryPrimitives.ReadUInt32LittleEndian(head[8..]);
+ if (version != 1)
+ throw new InvalidDataException($"This export's index is version {version}, which Filee can't read.");
+ var count = BinaryPrimitives.ReadUInt32LittleEndian(head[12..]);
+ if (count > MaxStreamsV1)
+ throw Damaged();
+ if (encrypted)
+ return new Index(true, []);
+ return new Index(false, Streams(file, ReadOffsets(file, 16, (int)count), length));
+ }
+
+ private static long[] ReadOffsets(System.IO.Stream file, long at, int count)
+ {
+ var entries = new byte[count * EntrySize];
+ ReadAt(file, at, entries);
+ var offsets = new long[count];
+ for (var i = 0; i < count; i++)
+ offsets[i] = BinaryPrimitives.ReadInt64LittleEndian(entries.AsSpan(i * EntrySize));
+ return offsets;
+ }
+
+ ///
+ /// The streams starting at , each running to the next one (the last to
+ /// ), as the client reads them.
+ ///
+ private static List Streams(System.IO.Stream file, long[] offsets, long dataEnd)
+ {
+ var streams = new List(offsets.Length);
+ var name = new byte[MaxNameBytes];
+ for (var i = 0; i < offsets.Length; i++)
+ {
+ var start = offsets[i];
+ var end = i + 1 < offsets.Length ? offsets[i + 1] : dataEnd;
+ if (start < 0 || end < start || end > dataEnd)
+ throw Damaged();
+ var read = ReadAt(file, start, name.AsSpan(0, (int)Math.Min(MaxNameBytes, end - start)), exactly: false);
+ var nul = Array.IndexOf(name, (byte)0, 0, read);
+ if (nul < 0)
+ throw Damaged();
+ streams.Add(new Stream(Encoding.UTF8.GetString(name, 0, nul), start + nul + 1, end));
+ }
+ return streams;
+ }
+
+ private static int ReadAt(System.IO.Stream file, long at, Span buffer, bool exactly = true)
+ {
+ file.Position = at;
+ var read = file.ReadAtLeast(buffer, buffer.Length, throwOnEndOfStream: false);
+ if (exactly && read != buffer.Length)
+ throw Damaged();
+ return read;
+ }
+
+ private static InvalidDataException Damaged() =>
+ new("This Wisenet WAVE / Nx Witness export is damaged: its index doesn't match the file.");
+}
diff --git a/src/Filee.Engines/Media/VideoFrames.cs b/src/Filee.Engines/Media/VideoFrames.cs
index 9b62c8748..6353deab6 100644
--- a/src/Filee.Engines/Media/VideoFrames.cs
+++ b/src/Filee.Engines/Media/VideoFrames.cs
@@ -3,13 +3,16 @@
namespace Filee.Engines.Media;
-/// A video as the clip window shows it.
+/// A video, or a sound (no picture: is false), as the clip window shows it.
/// Playing time in seconds, 0 when the file doesn't say.
-/// Width of the picture as shown (pixel aspect and rotation applied).
-/// Height of the picture as shown.
+/// Width of the picture as shown (pixel aspect and rotation applied); 0 for a sound.
+/// Height of the picture as shown; 0 for a sound.
/// Frames per second, 0 when unknown.
public sealed record VideoSummary(double Duration, int Width, int Height, double FrameRate)
{
+ /// The file has pictures to show; a sound (an MP3, a recording) has none, only its waveform.
+ public bool HasPicture => Width > 0 && Height > 0;
+
/// One frame's length in seconds (a 30 fps one when the rate is unknown).
public double FrameLength => FrameRate is > 1 and < 1000 ? 1 / FrameRate : 1 / 30.0;
@@ -29,13 +32,20 @@ public static class VideoFrames
/// True when FFmpeg is installed (Settings › Engines).
public static bool IsAvailable => FfmpegConverter.Locate() is { } tools && File.Exists(tools.Ffmpeg) && File.Exists(tools.Ffprobe);
- /// The length and size of the video at .
- /// FFmpeg is missing, the file can't be read or has no video.
+ ///
+ /// The length and size of the video at ; a file with sound and no picture gives its length and
+ /// no size ( false).
+ ///
+ /// FFmpeg is missing, the file can't be read or has no video or sound.
public static async Task ProbeAsync(string path, CancellationToken cancellationToken)
{
var (_, ffprobe) = FfmpegConverter.Locate() ?? throw new InvalidOperationException("FFmpeg was not found.");
- var info = await MediaInfo.ProbeAsync(ffprobe, Path.GetFullPath(path), cancellationToken);
- var video = info.Video ?? throw new InvalidOperationException("The file has no video stream.");
+ var info = await MediaInfo.ProbeAsync(ffprobe, MediaInput.For(path), cancellationToken);
+ if (info.Video is null)
+ return info.Audio is not null
+ ? new VideoSummary(info.Duration?.TotalSeconds ?? 0, 0, 0, 0)
+ : throw new InvalidOperationException("The file has no video or sound stream.");
+ var video = info.Video;
var height = video.IsRotated ? video.Width : video.Height;
return new VideoSummary(info.Duration?.TotalSeconds ?? 0, (int)Math.Round(height * video.DisplayAspect), height, video.FrameRate);
}
@@ -47,7 +57,7 @@ public static async Task ProbeAsync(string path, CancellationToken
public static async Task WriteFrameAsync(string path, double seconds, string output, int maxHeight, CancellationToken cancellationToken)
{
var (ffmpeg, _) = FfmpegConverter.Locate() ?? throw new InvalidOperationException("FFmpeg was not found.");
- var arguments = MediaEncoding.FrameAt(Path.GetFullPath(path), Math.Max(0, seconds), output, maxHeight, null);
+ var arguments = MediaEncoding.FrameAt(MediaInput.For(path), Math.Max(0, seconds), output, maxHeight, null);
var result = await FfmpegRunner.RunAsync(ffmpeg, arguments, null, cancellationToken, FrameStall);
return result.ExitCode == 0 && File.Exists(output) && new FileInfo(output).Length > 0;
}
diff --git a/src/Filee.Engines/Media/WhisperConverter.cs b/src/Filee.Engines/Media/WhisperConverter.cs
index 625fde5f0..0f3d0f5c5 100644
--- a/src/Filee.Engines/Media/WhisperConverter.cs
+++ b/src/Filee.Engines/Media/WhisperConverter.cs
@@ -148,7 +148,7 @@ public async Task> ConvertAsync(ConversionStep step, IProg
var extractArgs = new[]
{
"-hide_banner", "-nostdin", "-nostats", "-v", "error", "-y",
- "-i", step.InputPath,
+ "-i", MediaInput.For(step.InputPath, step.From, step.WorkDirectory, step.Notice),
"-vn", "-ar", "16000", "-ac", "1",
"-progress", "pipe:1",
audioWav
diff --git a/src/Filee.Engines/Models3D/ImageTo3DConverter.cs b/src/Filee.Engines/Models3D/ImageTo3DConverter.cs
index 9f2e6069a..1c0f4a73b 100644
--- a/src/Filee.Engines/Models3D/ImageTo3DConverter.cs
+++ b/src/Filee.Engines/Models3D/ImageTo3DConverter.cs
@@ -64,7 +64,7 @@ public Task> ConvertAsync(ConversionStep step, IProgress); hyperlinks stay.
// Pictures the page may use by the rule every reader follows (DocumentFiles: inside the document's own folder) are
// put into the copy as data: URIs, because the profile keeps LibreOffice from loading even those;
// * RTF: INCLUDEPICTURE fields (their last result stays, as for any field LibreOffice doesn't know).
@@ -187,7 +188,7 @@ private static bool IsOutside(string href, IReadOnlySet? entries)
var resource = key is "src" or "srcset" or "lowsrc" or "dynsrc" or "data" or "poster" or "background" or "codebase"
or "xlink:href" or "longdesc"
|| key == "href" && name is "link" or "base" or "image" or "use";
- if (!resource || value.TrimStart().StartsWith("data:", StringComparison.OrdinalIgnoreCase))
+ if (!resource || value.TrimStart().StartsWith("data:", StringComparison.OrdinalIgnoreCase) || SameDocument(value))
return attribute.Value;
return name == "img" && key == "src" && picture?.Invoke(Utf8(attribute.Groups["value"].Value)) is { } uri
? $"{attribute.Groups["space"].Value}src=\"{uri}\""
@@ -205,9 +206,10 @@ private static string Utf8(string attribute) =>
///
/// The data: URI of a picture inside the page's folder (or the original document's, for a page made from it), by
- /// . SVG stays out: a drawing can link to further files, and LibreOffice would load them.
+ /// . SVG stays out unless : a drawing can link to further files, and
+ /// LibreOffice would load them (WeasyPrint, run with only data: allowed, doesn't).
///
- private static Func PagePictures(string page, string? originalSource)
+ internal static Func PagePictures(string page, string? originalSource, bool svg = false)
{
var folder = Path.GetDirectoryName(Path.GetFullPath(page))!;
string[] roots = originalSource is null ? [folder] : [folder, Path.GetDirectoryName(Path.GetFullPath(originalSource))!];
@@ -216,20 +218,29 @@ private static string Utf8(string attribute) =>
{
if (DocumentFiles.Find(address, folder, roots) is not { } file
|| Path.GetExtension(file).TrimStart('.').ToLowerInvariant() is var extension
- && extension is not ("png" or "jpg" or "jpeg" or "gif" or "bmp" or "webp" or "tif" or "tiff"))
+ && extension is not ("png" or "jpg" or "jpeg" or "gif" or "bmp" or "webp" or "tif" or "tiff")
+ && !(svg && extension == "svg"))
return null;
var length = new FileInfo(file).Length;
if (length > left)
return null;
left -= length;
- return $"data:image/{extension};base64,{Convert.ToBase64String(File.ReadAllBytes(file))}";
+ var type = extension switch { "svg" => "svg+xml", "jpg" => "jpeg", "tif" => "tiff", _ => extension };
+ return $"data:image/{type};base64,{Convert.ToBase64String(File.ReadAllBytes(file))}";
};
}
- /// CSS with url() other than data: emptied and @import rules taken out.
+ /// CSS with url() other than data: and same-document references (#id) emptied and @import rules taken out.
private static string CssUrls(string css) =>
Import().Replace(CssUrl().Replace(css, url =>
- url.Groups["address"].Value.TrimStart().StartsWith("data:", StringComparison.OrdinalIgnoreCase) ? url.Value : "url()"), "");
+ url.Groups["address"].Value.TrimStart().StartsWith("data:", StringComparison.OrdinalIgnoreCase) || SameDocument(url.Groups["address"].Value)
+ ? url.Value : "url()"), "");
+
+ ///
+ /// A reference to an element of the page itself (an inline SVG's <use href="#icon">, a gradient's
+ /// xlink:href="#base", fill:url(#g)): nothing outside the page is named, so it stays.
+ ///
+ private static bool SameDocument(string address) => address.TrimStart().StartsWith('#');
/// RTF whose INCLUDEPICTURE fields are renamed to a field LibreOffice doesn't know, or null without any.
internal static byte[]? StripRtf(byte[] rtf)
diff --git a/src/Filee.Engines/Pdf/PdfMergePaperFitter.cs b/src/Filee.Engines/Pdf/PdfMergePaperFitter.cs
new file mode 100644
index 000000000..8a7b5f707
--- /dev/null
+++ b/src/Filee.Engines/Pdf/PdfMergePaperFitter.cs
@@ -0,0 +1,600 @@
+// Fits imported pages without changing shared content streams or flattening their structure.
+using System.Globalization;
+using System.Text;
+using Filee.Core.Conversion;
+using Filee.Core.Presets;
+using PdfSharp.Drawing;
+using PdfSharp.Pdf;
+using PdfSharp.Pdf.Advanced;
+
+namespace Filee.Engines.Pdf;
+
+internal sealed class PdfMergePaperFitter(PdfDocument document, PdfMergePaperFitter.Limits settings, int appearanceLength)
+{
+ private readonly PdfDocument output = document;
+ private readonly Limits limits = settings;
+ internal sealed record Limits
+ {
+ public int ParentDepth { get; init; } = 64;
+ public int ContentStreams { get; init; } = 100_000;
+ public int AnnotationSlots { get; init; } = 100_000;
+ public int AnnotationNumbers { get; init; } = 65_536;
+ public int SourceNumbers { get; init; } = 1_000_000;
+ public int InkStrokes { get; init; } = 4096;
+ public int AppearanceStates { get; init; } = 256;
+ public int Objects { get; init; } = 1_000_000;
+ public int StreamBytes { get; init; } = 4_194_304;
+ public int Items { get; init; } = 1_000_000;
+ public int DaLength { get; init; } = 4096;
+ public int DaBytes { get; init; } = 1_048_576;
+ public Action? BeforePage { get; init; }
+ }
+
+ internal readonly record struct Box(double X1, double Y1, double X2, double Y2)
+ {
+ internal double Width => X2 - X1;
+ internal double Height => Y2 - Y1;
+ internal bool Valid => Finite(X1, Y1, X2, Y2, Width, Height) && Width > 0 && Height > 0;
+ internal Box Intersect(Box other) => new(Math.Max(X1, other.X1), Math.Max(Y1, other.Y1),
+ Math.Min(X2, other.X2), Math.Min(Y2, other.Y2));
+ internal double[] Values => [X1, Y1, X2, Y2];
+ }
+
+ internal readonly record struct Matrix(double A, double B, double C, double D, double E, double F)
+ {
+ internal double[] Values => [A, B, C, D, E, F];
+ internal (double X, double Y) Point(double x, double y) => (A * x + C * y + E, B * x + D * y + F);
+ internal Box Bounds(Box box)
+ {
+ var points = new[] { Point(box.X1, box.Y1), Point(box.X2, box.Y1), Point(box.X1, box.Y2), Point(box.X2, box.Y2) };
+ return new(points.Min(p => p.X), points.Min(p => p.Y), points.Max(p => p.X), points.Max(p => p.Y));
+ }
+ internal Matrix Then(Matrix next) => new(
+ next.A * A + next.C * B, next.B * A + next.D * B,
+ next.A * C + next.C * D, next.B * C + next.D * D,
+ next.A * E + next.C * F + next.E, next.B * E + next.D * F + next.F);
+ }
+
+ internal sealed record Geometry(Box Visible, int Rotate, double Unit, double Width, double Height);
+ internal sealed record Transform(Geometry Source, double Width, double Height, double Scale, Matrix Matrix);
+
+ internal static PdfItem? Value(PdfItem? item) => item is PdfReference reference ? reference.Value : item;
+ private static PdfDictionary? Dictionary(PdfItem? item) => Value(item) as PdfDictionary;
+ private static PdfArray? Array(PdfItem? item) => Value(item) as PdfArray;
+ private static bool Finite(params double[] values) => values.All(double.IsFinite);
+ private static double Number(PdfItem? item) => Value(item) switch
+ {
+ PdfInteger n => n.Value,
+ PdfReal n => n.Value,
+ PdfIntegerObject n => n.Value,
+ PdfRealObject n => n.Value,
+ _ => double.NaN,
+ };
+ private static Box ReadBox(PdfItem? item)
+ {
+ if (Value(item) is PdfRectangle rect)
+ return new(rect.X1, rect.Y1, rect.X2, rect.Y2);
+ if (Array(item) is not { Elements.Count: 4 } array)
+ return new(double.NaN, 0, 0, 0);
+ return new(Number(array.Elements[0]), Number(array.Elements[1]), Number(array.Elements[2]), Number(array.Elements[3]));
+ }
+ private static ConversionRefusedException GeometryError() => new("error.merge_paper_geometry");
+ private static ConversionRefusedException LimitError() => new("error.merge_paper_limit");
+
+ internal Geometry Snapshot(PdfPage page)
+ {
+ PdfItem? media = null, crop = null, rotation = null;
+ var visited = new HashSet(ReferenceEqualityComparer.Instance);
+ PdfDictionary? node = page;
+ while (node is not null)
+ {
+ if (!visited.Add(node))
+ throw GeometryError();
+ if (visited.Count > limits.ParentDepth)
+ throw LimitError();
+ media ??= node.Elements["/MediaBox"];
+ crop ??= node.Elements["/CropBox"];
+ rotation ??= node.Elements["/Rotate"];
+ node = Dictionary(node.Elements["/Parent"]);
+ }
+ var visible = ReadBox(media);
+ if (!visible.Valid)
+ throw GeometryError();
+ if (crop is not null)
+ {
+ var box = ReadBox(crop);
+ if (!Finite(box.Values))
+ throw GeometryError();
+ var intersection = visible.Intersect(box);
+ if (box.Valid && intersection.Valid)
+ visible = intersection;
+ }
+ var rotate = rotation is null ? 0 : Number(rotation);
+ var unit = page.Elements.ContainsKey("/UserUnit") ? Number(page.Elements["/UserUnit"]) : 1;
+ if (!double.IsFinite(rotate) || rotate % 90 != 0)
+ rotate = 0;
+ if (!double.IsFinite(unit) || unit <= 0)
+ unit = 1;
+ var normalized = (int)((rotate % 360 + 360) % 360);
+ var width = (normalized is 90 or 270 ? visible.Height : visible.Width) * unit;
+ var height = (normalized is 90 or 270 ? visible.Width : visible.Height) * unit;
+ if (!Finite(width, height) || width <= 0 || height <= 0)
+ throw GeometryError();
+ return new(visible, normalized, unit, width, height);
+ }
+
+ internal static (double Width, double Height) Target(Geometry first, Core.Conversion.PdfMergePaper paper)
+ {
+ var (w, h) = paper.Paper switch
+ {
+ PdfSheetPaper.Letter => (612d, 792d),
+ PdfSheetPaper.A3 => (841.89, 1190.55),
+ PdfSheetPaper.SameAsFirstPage => (first.Width, first.Height),
+ _ => (595.28, 841.89),
+ };
+ if (paper.Paper == PdfSheetPaper.SameAsFirstPage && paper.Orientation == PdfSheetOrientation.Auto)
+ return (w, h);
+ (w, h) = (Math.Min(w, h), Math.Max(w, h));
+ var landscape = paper.Orientation == PdfSheetOrientation.Landscape ||
+ paper.Orientation == PdfSheetOrientation.Auto &&
+ Math.Min(h / first.Width, w / first.Height) > Math.Min(w / first.Width, h / first.Height);
+ return landscape ? (h, w) : (w, h);
+ }
+
+ internal static Transform Fit(Geometry source, (double Width, double Height) target)
+ {
+ var s = Math.Min(target.Width / source.Width, target.Height / source.Height);
+ var k = s * source.Unit;
+ var dx = (target.Width - s * source.Width) / 2;
+ var dy = (target.Height - s * source.Height) / 2;
+ var v = source.Visible;
+ var matrix = source.Rotate switch
+ {
+ 90 => new Matrix(0, -k, k, 0, dx - k * v.Y1, dy + k * v.X2),
+ 180 => new Matrix(-k, 0, 0, -k, dx + k * v.X2, dy + k * v.Y2),
+ 270 => new Matrix(0, k, -k, 0, dx + k * v.Y2, dy - k * v.X1),
+ _ => new Matrix(k, 0, 0, k, dx - k * v.X1, dy - k * v.Y1),
+ };
+ if (!Finite(s, k, dx, dy) || s <= 0 || k <= 0 || !Finite(matrix.Values) || !matrix.Bounds(v).Valid)
+ throw GeometryError();
+ return new(source, target.Width, target.Height, k, matrix);
+ }
+
+ private PdfArray Numbers(IEnumerable numbers)
+ {
+ var array = new PdfArray(output);
+ foreach (var n in numbers)
+ array.Elements.Add(new PdfReal(n));
+ return array;
+ }
+ private static readonly string DecimalFormat = "0." + new string('#', 340);
+ internal static string NumberText(double number) => number.ToString(DecimalFormat, CultureInfo.InvariantCulture);
+ private static string N(double number) => NumberText(number);
+ private static string Cm(Matrix matrix) => string.Join(" ", matrix.Values.Select(N)) + " cm\n";
+ private PdfDictionary Stream(string text)
+ {
+ var stream = new PdfDictionary(output);
+ stream.CreateStream(Encoding.ASCII.GetBytes(text));
+ return stream;
+ }
+
+ internal void Normalize(PdfPage original, PdfPage page, Transform transform)
+ {
+ var old = page.Elements["/Contents"];
+ var array = Array(old);
+ var count = array?.Elements.Count ?? (old is null ? 0 : 1);
+ if (count > limits.ContentStreams)
+ throw LimitError();
+ var contents = new PdfArray(output);
+ var v = transform.Source.Visible;
+ var prefix = Stream("q\n" + Cm(transform.Matrix) + $"{N(v.X1)} {N(v.Y1)} {N(v.Width)} {N(v.Height)} re W n\n");
+ var suffix = Stream("\nQ\n");
+ if (array is not null)
+ {
+ foreach (var item in array.Elements)
+ if (Dictionary(item)?.Stream is null)
+ throw GeometryError();
+ }
+ else if (old is not null && Dictionary(old)?.Stream is null)
+ throw GeometryError();
+ output.Internals.AddObject(prefix);
+ output.Internals.AddObject(suffix);
+ contents.Elements.Add(prefix.Reference!);
+ if (array is not null)
+ foreach (var item in array.Elements)
+ contents.Elements.Add(item);
+ else if (old is not null)
+ contents.Elements.Add(old);
+ contents.Elements.Add(suffix.Reference!);
+ page.Elements["/Contents"] = contents;
+ var target = new Box(0, 0, transform.Width, transform.Height);
+ page.MediaBox = new PdfRectangle(new XPoint(0, 0), new XPoint(target.X2, target.Y2));
+ page.CropBox = page.MediaBox;
+ page.Rotate = 0;
+ page.Elements.SetInteger("/UserUnit", 1);
+ foreach (var key in new[] { "/TrimBox", "/BleedBox", "/ArtBox" })
+ {
+ if (!original.Elements.ContainsKey(key))
+ continue;
+ var box = ReadBox(original.Elements[key]);
+ var changed = box.Valid ? transform.Matrix.Bounds(box).Intersect(target) : default;
+ if (changed.Valid)
+ page.Elements[key] = Numbers(changed.Values);
+ else
+ page.Elements.Remove(key);
+ }
+ }
+
+ internal static bool Destination(PdfArray destination, Transform transform)
+ {
+ var kind = ((PdfName)destination.Elements[1]).Value;
+ double? Read(int index) => Value(destination.Elements[index]) is PdfNull ? null : Number(destination.Elements[index]);
+ var m = transform.Matrix;
+ var swapped = transform.Source.Rotate is 90 or 270;
+ PdfItem Coordinate(double? value, bool x, bool inputX)
+ {
+ if (value is null)
+ return PdfNull.Value;
+ var n = x ? (inputX ? m.A : m.C) * value.Value + m.E : (inputX ? m.B : m.D) * value.Value + m.F;
+ return new PdfReal(n);
+ }
+ if (kind == "/XYZ")
+ {
+ var x = Read(2);
+ var y = Read(3);
+ destination.Elements[2] = Coordinate(swapped ? y : x, true, !swapped);
+ destination.Elements[3] = Coordinate(swapped ? x : y, false, swapped);
+ }
+ else if (kind == "/FitR")
+ {
+ var box = m.Bounds(new(Read(2) ?? double.NaN, Read(3) ?? double.NaN, Read(4) ?? double.NaN, Read(5) ?? double.NaN));
+ if (!Finite(box.Values))
+ return false;
+ for (var i = 0; i < 4; i++)
+ destination.Elements[i + 2] = new PdfReal(box.Values[i]);
+ }
+ else if (kind is "/FitH" or "/FitBH" or "/FitV" or "/FitBV")
+ {
+ var inputX = kind is "/FitV" or "/FitBV";
+ destination.Elements[2] = Coordinate(Read(2), swapped ? !inputX : inputX, inputX);
+ if (swapped)
+ destination.Elements[1] = new PdfName(kind switch
+ {
+ "/FitH" => "/FitV",
+ "/FitBH" => "/FitBV",
+ "/FitV" => "/FitH",
+ _ => "/FitBH",
+ });
+ }
+ return destination.Elements.Skip(2).All(item => Value(item) is PdfNull || double.IsFinite(Number(item)));
+ }
+
+ private readonly HashSet _sharedAnnotations = new(ReferenceEqualityComparer.Instance);
+ private int _numbers, _objects, _items, _streamBytes, _daBytes;
+
+ internal void CollectAnnotations(PdfDocument source)
+ {
+ var owners = new Dictionary(ReferenceEqualityComparer.Instance);
+ var slots = 0;
+ foreach (var page in source.Pages)
+ {
+ if (Array(page.Elements["/Annots"]) is not { } annotations)
+ continue;
+ if (annotations.Elements.Count > limits.AnnotationSlots - slots)
+ throw LimitError();
+ slots += annotations.Elements.Count;
+ foreach (var item in annotations.Elements)
+ {
+ if (Dictionary(item) is not { } annotation)
+ continue;
+ if (owners.TryGetValue(annotation, out var owner) && !ReferenceEquals(owner, page))
+ _sharedAnnotations.Add(annotation);
+ else
+ owners[annotation] = page;
+ }
+ }
+ }
+
+ // Stages only shallow mutable copies. No imported object is written before the whole annotation is valid
+ // and its work budget is reserved. Rejected entries keep what Carry produced, but work is never refunded.
+ private sealed class OptionalGeometryException : Exception;
+
+ private sealed class Changes(PdfMergePaperFitter owner)
+ {
+ internal int Numbers, States;
+ internal List Streams { get; } = [];
+ internal List Dictionaries { get; } = [];
+
+ internal void Check()
+ {
+ if (States > owner.limits.AppearanceStates)
+ throw LimitError();
+ }
+ internal void Coordinates(int count)
+ {
+ if (count > owner.limits.AnnotationNumbers - Numbers || count > owner.limits.SourceNumbers - owner._numbers)
+ throw LimitError();
+ Numbers += count;
+ owner._numbers += count;
+ }
+ internal void Reserve(int count)
+ {
+ if (count > owner.limits.Items - owner._items || owner._objects >= owner.limits.Objects)
+ throw LimitError();
+ owner._items += count;
+ owner._objects++;
+ }
+ internal PdfArray Array(int count)
+ {
+ Reserve(count);
+ return new(owner.output);
+ }
+ internal PdfArray NumbersArray(IEnumerable values)
+ {
+ var numbers = values.ToArray();
+ if (!Finite(numbers))
+ throw new OptionalGeometryException();
+ var result = Array(numbers.Length);
+ foreach (var n in numbers)
+ result.Elements.Add(new PdfReal(n));
+ return result;
+ }
+ internal PdfDictionary Dictionary(PdfDictionary? original = null, int extra = 0)
+ {
+ var count = original?.Elements.Count ?? 0;
+ Reserve(count + extra);
+ var result = new PdfDictionary(owner.output);
+ if (original is not null)
+ foreach (var entry in original.Elements)
+ result.Elements[entry.Key] = entry.Value;
+ Dictionaries.Add(result);
+ return result;
+ }
+ internal void Commit()
+ {
+ foreach (var stream in Streams)
+ owner.output.Internals.AddObject(stream);
+ foreach (var dictionary in Dictionaries)
+ foreach (var key in dictionary.Elements.Keys.ToArray())
+ if (dictionary.Elements[key] is PdfDictionary child && Streams.Contains(child))
+ dictionary.Elements[key] = child.Reference!;
+ }
+ }
+
+ internal void Annotations(PdfPage original, PdfPage page, Transform transform)
+ {
+ if (Array(original.Elements["/Annots"]) is not { } source ||
+ Array(page.Elements["/Annots"]) is not { } imported)
+ return;
+ // A page's Annots array itself can be shared. Do not replace any of its slots in place.
+ var result = new Changes(this).Array(imported.Elements.Count);
+ foreach (var item in imported.Elements)
+ result.Elements.Add(item);
+ var done = new Dictionary(ReferenceEqualityComparer.Instance);
+ for (var i = 0; i < source.Elements.Count && i < result.Elements.Count; i++)
+ {
+ if (Dictionary(source.Elements[i]) is not { } sourceAnnotation ||
+ Dictionary(imported.Elements[i]) is not { } annotation)
+ continue;
+ if (done.TryGetValue(annotation, out var previous))
+ {
+ result.Elements[i] = previous;
+ continue;
+ }
+ var widget = (Value(annotation.Elements["/Subtype"]) as PdfName)?.Value == "/Widget";
+ var shared = _sharedAnnotations.Contains(sourceAnnotation);
+ if (widget && shared)
+ continue;
+ try
+ {
+ var changes = new Changes(this);
+ var copy = TransformAnnotation(annotation, page, transform, changes, widget);
+ changes.Commit();
+ if (!shared)
+ {
+ foreach (var key in copy.Elements.Keys)
+ annotation.Elements[key] = copy.Elements[key];
+ done[annotation] = result.Elements[i];
+ }
+ else
+ {
+ output.Internals.AddObject(copy);
+ result.Elements[i] = copy.Reference!;
+ done[annotation] = copy.Reference!;
+ }
+ }
+ catch (OptionalGeometryException)
+ {
+ // Optional structure is carried unchanged; a damaged neighbor does not discard this page.
+ }
+ }
+ if (done.Count > 0)
+ page.Elements["/Annots"] = result;
+ }
+
+ private PdfDictionary TransformAnnotation(PdfDictionary annotation, PdfPage page, Transform transform, Changes changes, bool widget)
+ {
+ changes.Coordinates(4);
+ var rect = ReadBox(annotation.Elements["/Rect"]);
+ var changedRect = transform.Matrix.Bounds(rect);
+ if (!rect.Valid || !changedRect.Valid)
+ throw new OptionalGeometryException();
+ var copy = changes.Dictionary(annotation, 2);
+ copy.Elements["/Rect"] = changes.NumbersArray(changedRect.Values);
+ copy.Elements["/P"] = page.Reference!;
+ foreach (var key in new[] { "/QuadPoints", "/Vertices", "/L" })
+ {
+ if (!annotation.Elements.ContainsKey(key))
+ continue;
+ if (Array(annotation.Elements[key]) is not { } coordinates ||
+ key == "/QuadPoints" && coordinates.Elements.Count % 8 != 0 ||
+ key == "/L" && coordinates.Elements.Count != 4)
+ throw new OptionalGeometryException();
+ copy.Elements[key] = Points(coordinates, transform.Matrix, changes);
+ }
+ if (annotation.Elements.ContainsKey("/InkList"))
+ {
+ if (Array(annotation.Elements["/InkList"]) is not { } ink)
+ throw new OptionalGeometryException();
+ if (ink.Elements.Count > limits.InkStrokes)
+ throw LimitError();
+ var strokes = changes.Array(ink.Elements.Count);
+ foreach (var item in ink.Elements)
+ {
+ if (Array(item) is not { } stroke)
+ throw new OptionalGeometryException();
+ strokes.Elements.Add(Points(stroke, transform.Matrix, changes));
+ }
+ copy.Elements["/InkList"] = strokes;
+ }
+ if (annotation.Elements.ContainsKey("/AP"))
+ {
+ if (Dictionary(annotation.Elements["/AP"]) is not { } ap || ap.Stream is not null)
+ throw new OptionalGeometryException();
+ var appearances = changes.Dictionary(ap);
+ foreach (var key in new[] { "/N", "/R", "/D" })
+ {
+ if (!ap.Elements.ContainsKey(key))
+ continue;
+ if (Dictionary(ap.Elements[key]) is not { } appearance)
+ throw new OptionalGeometryException();
+ if (appearance.Stream is not null)
+ appearances.Elements[key] = Appearance(appearance, rect, changedRect, transform.Matrix, changes);
+ else
+ {
+ if (appearance.Elements.Count > limits.AppearanceStates - changes.States)
+ throw LimitError();
+ var states = changes.Dictionary(appearance);
+ foreach (var state in appearance.Elements)
+ {
+ if (Dictionary(state.Value) is not { Stream: not null } stream)
+ throw new OptionalGeometryException();
+ states.Elements[state.Key] = Appearance(stream, rect, changedRect, transform.Matrix, changes);
+ }
+ appearances.Elements[key] = states;
+ }
+ }
+ copy.Elements["/AP"] = appearances;
+ }
+ if (widget)
+ {
+ if (annotation.Elements.ContainsKey("/MK"))
+ {
+ if (Dictionary(annotation.Elements["/MK"]) is not { } mk)
+ throw new OptionalGeometryException();
+ var rotation = mk.Elements.ContainsKey("/R") ? Number(mk.Elements["/R"]) : 0;
+ if (!double.IsFinite(rotation) || rotation % 1 != 0)
+ throw new OptionalGeometryException();
+ var changed = changes.Dictionary(mk, 1);
+ changed.Elements.SetInteger("/R", (int)(((rotation % 360 - transform.Source.Rotate) % 360 + 360) % 360));
+ copy.Elements["/MK"] = changed;
+ }
+ else if (transform.Source.Rotate != 0)
+ {
+ var mk = changes.Dictionary(extra: 1);
+ mk.Elements.SetInteger("/R", 360 - transform.Source.Rotate);
+ copy.Elements["/MK"] = mk;
+ }
+ }
+ if (widget || (Value(annotation.Elements["/Subtype"]) as PdfName)?.Value == "/FreeText")
+ {
+ PdfString? da = null;
+ var visited = new HashSet(ReferenceEqualityComparer.Instance);
+ PdfDictionary? node = annotation;
+ while (node is not null)
+ {
+ if (!visited.Add(node))
+ throw new OptionalGeometryException();
+ if (visited.Count > limits.ParentDepth)
+ throw LimitError();
+ if (Value(node.Elements["/DA"]) is PdfString text)
+ {
+ da = text;
+ break;
+ }
+ node = widget ? Dictionary(node.Elements["/Parent"]) : null;
+ }
+ if (da is not null)
+ {
+ if (da.Value.Length > limits.DaLength)
+ throw LimitError();
+ var encoded = da.Encoding == PdfStringEncoding.Unicode || da.Value.Any(c => c > 255)
+ ? 2 + Encoding.BigEndianUnicode.GetByteCount(da.Value) : da.Value.Length;
+ if (encoded > appearanceLength)
+ throw LimitError();
+ PdfString scaled;
+ try { scaled = PdfMergeStructure.ScaleAppearance(da, transform.Scale); }
+ catch (InvalidDataException) { throw new OptionalGeometryException(); }
+ var bytes = scaled.Encoding == PdfStringEncoding.Unicode || scaled.Value.Any(c => c > 255)
+ ? 2 + Encoding.BigEndianUnicode.GetByteCount(scaled.Value) : scaled.Value.Length;
+ if (bytes > limits.DaBytes - _daBytes)
+ throw LimitError();
+ _daBytes += bytes;
+ copy.Elements["/DA"] = scaled;
+ }
+ }
+ return copy;
+ }
+
+ private PdfArray Points(PdfArray array, Matrix matrix, Changes changes)
+ {
+ if (array.Elements.Count % 2 != 0)
+ throw new OptionalGeometryException();
+ changes.Coordinates(array.Elements.Count);
+ var result = changes.Array(array.Elements.Count);
+ for (var i = 0; i < array.Elements.Count; i += 2)
+ {
+ var (x, y) = matrix.Point(Number(array.Elements[i]), Number(array.Elements[i + 1]));
+ if (!Finite(x, y))
+ throw new OptionalGeometryException();
+ result.Elements.Add(new PdfReal(x));
+ result.Elements.Add(new PdfReal(y));
+ }
+ return result;
+ }
+
+ private PdfDictionary Appearance(PdfDictionary original, Box rect, Box changedRect, Matrix transform, Changes changes)
+ {
+ changes.States++;
+ changes.Check();
+ changes.Coordinates(original.Elements.ContainsKey("/Matrix") ? 10 : 4);
+ var bbox = ReadBox(original.Elements["/BBox"]);
+ var matrix = new Matrix(1, 0, 0, 1, 0, 0);
+ if (original.Elements.ContainsKey("/Matrix"))
+ {
+ if (Array(original.Elements["/Matrix"]) is not { Elements.Count: 6 } values)
+ throw new OptionalGeometryException();
+ matrix = new(Number(values.Elements[0]), Number(values.Elements[1]), Number(values.Elements[2]),
+ Number(values.Elements[3]), Number(values.Elements[4]), Number(values.Elements[5]));
+ }
+ var bounds = matrix.Bounds(bbox);
+ if (!bbox.Valid || !Finite(matrix.Values) || !bounds.Valid)
+ throw new OptionalGeometryException();
+ var x = rect.Width / bounds.Width;
+ var y = rect.Height / bounds.Height;
+ var fit = new Matrix(x, 0, 0, y, rect.X1 - x * bounds.X1, rect.Y1 - y * bounds.Y1);
+ var placed = fit.Then(transform);
+ if (!Finite(placed.Values) || !Finite(x, y) || x <= 0 || y <= 0)
+ throw new OptionalGeometryException();
+ var wrapper = changes.Dictionary(extra: 6);
+ wrapper.Elements["/Type"] = new PdfName("/XObject");
+ wrapper.Elements["/Subtype"] = new PdfName("/Form");
+ wrapper.Elements["/BBox"] = changes.NumbersArray(changedRect.Values);
+ wrapper.Elements["/Matrix"] = changes.NumbersArray([1, 0, 0, 1, 0, 0]);
+ var resources = changes.Dictionary(extra: 1);
+ var forms = changes.Dictionary(extra: 1);
+ if (original.Reference is null)
+ throw new OptionalGeometryException();
+ forms.Elements["/Original"] = original.Reference;
+ resources.Elements["/XObject"] = forms;
+ wrapper.Elements["/Resources"] = resources;
+ var text = "q\n" + Cm(placed) + "/Original Do\nQ\n";
+ var bytes = Encoding.ASCII.GetByteCount(text);
+ if (bytes > limits.StreamBytes - _streamBytes)
+ throw LimitError();
+ _streamBytes += bytes;
+ wrapper.CreateStream(Encoding.ASCII.GetBytes(text));
+ changes.Streams.Add(wrapper);
+ return wrapper;
+ }
+}
diff --git a/src/Filee.Engines/Pdf/PdfMergeStructure.cs b/src/Filee.Engines/Pdf/PdfMergeStructure.cs
index f0ac95f39..1f2c7ebef 100644
--- a/src/Filee.Engines/Pdf/PdfMergeStructure.cs
+++ b/src/Filee.Engines/Pdf/PdfMergeStructure.cs
@@ -26,6 +26,7 @@ internal sealed record Limits
public int AppearanceLength { get; init; } = 4096;
public int AppearanceBytes { get; init; } = 1024 * 1024;
public Action? BeforeCarry { get; init; }
+ public PdfMergePaperFitter.Limits? Paper { get; init; }
}
private sealed class Outline(PdfDictionary dictionary, bool opened)
@@ -46,10 +47,13 @@ private sealed class Outline(PdfDictionary dictionary, bool opened)
private int _filePosition;
private int _stringBytes;
private bool _needAppearances;
+ private IReadOnlyDictionary? _transforms;
internal void Carry(PdfDocument source, IReadOnlyDictionary pages, CancellationToken ct,
- string? sourceName = null, PdfPage? firstPage = null)
+ string? sourceName = null, PdfPage? firstPage = null,
+ IReadOnlyDictionary? transforms = null)
{
+ _transforms = transforms;
if (++_filePosition == 1)
limits.BeforeCarry?.Invoke();
ct.ThrowIfCancellationRequested();
@@ -238,6 +242,9 @@ private static string DecodeName(string name)
var result = new PdfArray(output);
foreach (var value in values)
result.Elements.Add(value);
+ if (_transforms is not null && _transforms.TryGetValue(reference.ObjectID, out var transform) &&
+ !PdfMergePaperFitter.Destination(result, transform))
+ return null;
return result;
}
@@ -939,11 +946,16 @@ private static string Encoded(string name)
private static bool White(char c) => c is '\0' or '\t' or '\n' or '\f' or '\r' or ' ';
private static bool Delimiter(char c) => White(c) || c is '(' or ')' or '<' or '>' or '[' or ']' or '{' or '}' or '/' or '%';
- private static PdfString RewriteAppearance(PdfString source, Dictionary<(string Kind, string Name), string> names, CancellationToken ct)
+ internal static PdfString ScaleAppearance(PdfString source, double scale) =>
+ RewriteAppearance(source, new(), CancellationToken.None, scale);
+
+ private static PdfString RewriteAppearance(PdfString source, Dictionary<(string Kind, string Name), string> names,
+ CancellationToken ct, double? scale = null)
{
var text = source.Value;
var operands = new List();
var changes = new List<(int Start, int Length, string Text)>();
+ var compositeDepth = 0;
string? Default(string device) =>
names.TryGetValue(("/ColorSpace", DefaultSpaces[device]), out var renamed) ? renamed : null;
for (var i = 0; i < text.Length;)
@@ -995,6 +1007,21 @@ private static PdfString RewriteAppearance(PdfString source, Dictionary<(string
else if (Delimiter(text[i]))
{
// Composite operands are legal, but are not resource-name operands.
+ if (scale is not null)
+ {
+ if (text[i] is '[' or '{' || text[i] == '<' && i + 1 < text.Length && text[i + 1] == '<')
+ {
+ compositeDepth++;
+ if (text[i] == '<')
+ i++;
+ }
+ else if (text[i] is ']' or '}' || text[i] == '>' && i + 1 < text.Length && text[i + 1] == '>')
+ {
+ compositeDepth--;
+ if (text[i] == '>')
+ i++;
+ }
+ }
i++;
}
else
@@ -1007,6 +1034,11 @@ value is not ("true" or "false" or "null"))
word = value;
}
var token = new Token(start, i - start, name, word);
+ if (scale is not null && compositeDepth != 0)
+ {
+ operands.Clear();
+ continue;
+ }
if (word is null)
{
operands.Add(token);
@@ -1032,6 +1064,15 @@ value is not ("true" or "false" or "null"))
if (kind is not null && index >= 0 && operands[index] is { Name: { } resource } operand &&
names.TryGetValue((kind, resource), out var renamed) && renamed != resource)
changes.Add((operand.Start, operand.Length, Encoded(renamed)));
+ if (word == "Tf" && scale is { } factor && index >= 0 && operands[index].Name is not null &&
+ double.TryParse(text.AsSpan(operands[index + 1].Start, operands[index + 1].Length), NumberStyles.Float,
+ CultureInfo.InvariantCulture, out var size) && double.IsFinite(size) && size >= 0)
+ {
+ var changed = size * factor;
+ if (!double.IsFinite(changed) || size > 0 && changed <= 0)
+ throw new InvalidDataException("Invalid scaled font size.");
+ changes.Add((operands[index + 1].Start, operands[index + 1].Length, PdfMergePaperFitter.NumberText(changed)));
+ }
if (DeviceColours.TryGetValue(word, out var colour) && operands.Count == colour.Operands &&
Default(colour.Space) is { } replacement)
{
diff --git a/src/Filee.Engines/Pdf/PdfPrintableAnnotations.cs b/src/Filee.Engines/Pdf/PdfPrintableAnnotations.cs
new file mode 100644
index 000000000..e2521eea9
--- /dev/null
+++ b/src/Filee.Engines/Pdf/PdfPrintableAnnotations.cs
@@ -0,0 +1,118 @@
+// PDF/A-2 keeps an annotation only when its print flag is on (ISO 19005-2 6.3.2), and many PDF writers never set /F on
+// links and markup, so Ghostscript's PDF/A mode leaves them out (#1496). Before a PDF becomes PDF/A, a copy with that
+// flag turned on goes to Ghostscript instead. Annotations that are hidden in any way, popups and form fields stay as
+// they are; nothing else in the PDF is changed.
+
+using PdfSharp.Pdf;
+using PdfSharp.Pdf.IO;
+
+namespace Filee.Engines.Pdf;
+
+/// A copy of a PDF whose visible annotations print, for PDF/A.
+internal static class PdfPrintableAnnotations
+{
+ private const int Invisible = 1;
+ private const int Hidden = 2;
+ private const int Print = 4;
+ private const int NoView = 32;
+ private const int ToggleNoView = 256;
+
+ // A normal page tree is a few levels deep; the node limit is far above any real page count.
+ private const int MaxTreeDepth = 64;
+ private const int MaxTreeNodes = 100_000;
+
+ ///
+ /// Writes printable.pdf in : with the print flag turned on
+ /// for every annotation that isn't a popup or form field, isn't hidden in any way and doesn't print yet.
+ ///
+ /// The copy, or null when no annotation needs the flag or the copy can't be made (the PDF can't be read
+ /// or changed, or saving fails): Ghostscript gets the PDF as it is then.
+ internal static string? Prepare(string input, string folder, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var target = Path.Combine(folder, "printable.pdf");
+ try
+ {
+ if (!PageTreeIsSafe(input))
+ return null;
+ using var document = PdfReader.Open(input, PdfDocumentOpenMode.Modify);
+ var changed = 0;
+ var seen = new HashSet(ReferenceEqualityComparer.Instance); // one annotation on several pages
+ foreach (var page in document.Pages)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (PdfCompressor.Resolve(page.Elements["/Annots"]) is not PdfArray annotations)
+ continue;
+ foreach (var item in annotations.Elements)
+ {
+ if (PdfCompressor.Resolve(item) is not PdfDictionary annotation || !seen.Add(annotation)
+ || annotation.Elements.GetName("/Subtype") is "/Popup" or "/Widget"
+ || Flags(annotation) is not { } flags
+ || (flags & (Invisible | Hidden | NoView | ToggleNoView)) != 0 || (flags & Print) != 0)
+ continue;
+ annotation.Elements["/F"] = new PdfInteger(flags | Print);
+ changed++;
+ }
+ }
+ if (changed == 0)
+ return null;
+
+ // PDFsharp names itself the creator of a PDF that names none, and Ghostscript would carry that into the PDF/A.
+ if (!document.Info.Elements.ContainsKey("/Creator"))
+ document.Info.Elements["/Creator"] = new PdfString("");
+ cancellationToken.ThrowIfCancellationRequested();
+ document.Save(target);
+ return target;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ try
+ {
+ if (File.Exists(target))
+ File.Delete(target);
+ }
+ catch (Exception deleting) when (deleting is IOException or UnauthorizedAccessException)
+ {
+ // Only a leftover in the work folder.
+ }
+ return null;
+ }
+ }
+
+ /// /F as a number, 0 when it is missing, null when it is something else.
+ private static int? Flags(PdfDictionary annotation) => PdfCompressor.Resolve(annotation.Elements["/F"]) switch
+ {
+ null => 0,
+ PdfInteger flags => flags.Value,
+ PdfIntegerObject flags => flags.Value,
+ _ => null,
+ };
+
+ ///
+ /// The page tree has no loops and stays within the limits. PDFsharp flattens the tree when it opens a PDF for
+ /// changes, recursing without such checks, so a page tree that contains itself would overflow the stack.
+ /// Opening for import doesn't flatten it.
+ ///
+ private static bool PageTreeIsSafe(string input)
+ {
+ using var document = PdfReader.Open(input, PdfDocumentOpenMode.Import);
+ if (PdfCompressor.Resolve(document.Internals.Catalog.Elements["/Pages"]) is not PdfDictionary root)
+ return false;
+ var visited = new HashSet(ReferenceEqualityComparer.Instance);
+ var pending = new Stack<(PdfDictionary Node, int Depth)>();
+ pending.Push((root, 1));
+ while (pending.TryPop(out var entry))
+ {
+ if (entry.Depth > MaxTreeDepth || !visited.Add(entry.Node) || visited.Count > MaxTreeNodes)
+ return false;
+ if (PdfCompressor.Resolve(entry.Node.Elements["/Kids"]) is not PdfArray kids)
+ continue;
+ foreach (var kid in kids.Elements)
+ {
+ if (PdfCompressor.Resolve(kid) is PdfDictionary child)
+ pending.Push((child, entry.Depth + 1));
+ }
+ }
+ return true;
+ }
+}
diff --git a/src/Filee.Engines/Pdf/PdfSharpConverter.cs b/src/Filee.Engines/Pdf/PdfSharpConverter.cs
index 21189629e..2ea4932be 100644
--- a/src/Filee.Engines/Pdf/PdfSharpConverter.cs
+++ b/src/Filee.Engines/Pdf/PdfSharpConverter.cs
@@ -49,26 +49,55 @@ public Task> ConvertAsync(ConversionStep step, IProgress
Task.Run(() => PdfFinishing.Finish(path, options, cancellationToken), cancellationToken);
- public Task MergeAsync(IReadOnlyList inputs, string outputPath, bool sourceBookmarks, CancellationToken cancellationToken) =>
- Task.Run(() => Merge(inputs, outputPath, sourceBookmarks, cancellationToken), cancellationToken);
+ /// Imports PDFs and their supported structure, optionally fitting every page to one paper.
+ public Task MergeAsync(IReadOnlyList inputs, string outputPath, bool sourceBookmarks,
+ Core.Conversion.PdfMergePaper? paper, CancellationToken cancellationToken) =>
+ Task.Run(() => Merge(inputs, outputPath, sourceBookmarks, paper, cancellationToken), cancellationToken);
- internal static void Merge(IReadOnlyList inputs, string outputPath, bool sourceBookmarks, CancellationToken ct, PdfMergeStructure.Limits? limits = null)
+ internal static void Merge(IReadOnlyList inputs, string outputPath, bool sourceBookmarks,
+ Core.Conversion.PdfMergePaper? paper, CancellationToken ct, PdfMergeStructure.Limits? limits = null)
{
using var output = new PdfDocument();
var structure = new PdfMergeStructure(output, limits ?? new());
+ (double Width, double Height)? target = null;
+ var pageNumber = 0;
foreach (var input in inputs)
{
ct.ThrowIfCancellationRequested();
using var source = Open(input.PdfPath, PdfDocumentOpenMode.Import, ct);
var pages = new Dictionary();
+ var normalizer = paper is null ? null : new PdfMergePaperFitter(output, limits?.Paper ?? new(), limits?.AppearanceLength ?? 4096);
+ normalizer?.CollectAnnotations(source);
+ var transforms = normalizer is null ? null : new Dictionary();
+ var occurrences = normalizer is null ? null : new List<(PdfPage Source, PdfPage Imported, PdfMergePaperFitter.Transform Transform)>();
PdfPage? firstPage = null;
foreach (var page in source.Pages)
{
+ PdfMergePaperFitter.Transform? transform = null;
+ if (normalizer is not null)
+ {
+ var geometry = normalizer.Snapshot(page);
+ target ??= PdfMergePaperFitter.Target(geometry, paper!.Value);
+ transform = PdfMergePaperFitter.Fit(geometry, target.Value);
+ transforms![page.Reference!.ObjectID] = transform;
+ }
var imported = output.AddPage(page);
firstPage ??= imported;
pages[page.Reference!.ObjectID] = imported;
+ if (transform is not null)
+ occurrences!.Add((page, imported, transform));
+ }
+ structure.Carry(source, pages, ct, sourceBookmarks ? input.SourceName : null, firstPage, transforms);
+ if (normalizer is not null)
+ {
+ foreach (var (original, imported, transform) in occurrences!)
+ {
+ limits?.Paper?.BeforePage?.Invoke(++pageNumber);
+ ct.ThrowIfCancellationRequested();
+ normalizer.Normalize(original, imported, transform);
+ normalizer.Annotations(original, imported, transform);
+ }
}
- structure.Carry(source, pages, ct, sourceBookmarks ? input.SourceName : null, firstPage);
}
structure.Write(ct);
output.Save(outputPath);
@@ -79,7 +108,7 @@ private static IReadOnlyList ImageToPdf(ConversionStep step, IProgress EngineStatus.Available("SRT, ASS, WebVTT, SAMI, LRC, TTML, MicroDVD, SCC", EngineVersions.BuiltIn);
+ public EngineStatus GetStatus() => EngineStatus.Available("SRT, ASS, WebVTT, SAMI, LRC, KRC, TTML, MicroDVD, SCC", EngineVersions.BuiltIn);
public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) =>
Task.Run>(() =>
diff --git a/src/Filee.Engines/Subtitles/SubtitleReader.cs b/src/Filee.Engines/Subtitles/SubtitleReader.cs
index 9f141ed62..3f0f2dcb2 100644
--- a/src/Filee.Engines/Subtitles/SubtitleReader.cs
+++ b/src/Filee.Engines/Subtitles/SubtitleReader.cs
@@ -1,6 +1,6 @@
-// Readers of the line-based text subtitle formats: SubRip (SRT), WebVTT, ASS/SSA, LRC and MicroDVD. SAMI, TTML and
-// SCC have their own files. Readers are lenient: missing blank lines, one- or two-digit fractions, comma or dot before
-// the milliseconds and stray styling codes are accepted, because subtitle files are hand-made as often as not.
+// Readers of the line-based text subtitle formats: SubRip (SRT), WebVTT, ASS/SSA, LRC, KuGou KRC and MicroDVD. SAMI,
+// TTML and SCC have their own files. Readers are lenient: missing blank lines, one- or two-digit fractions, comma or dot
+// before the milliseconds and stray styling codes are accepted, because subtitle files are hand-made as often as not.
using System.Globalization;
using System.Text;
@@ -12,7 +12,7 @@ namespace Filee.Engines.Subtitles;
internal static partial class SubtitleReader
{
/// Format ids this engine reads.
- public static readonly string[] Formats = ["srt", "ass", "ssa", "vtt", "smi", "lrc", "ttml", "sub", "scc"];
+ public static readonly string[] Formats = ["srt", "ass", "ssa", "vtt", "smi", "lrc", "krc", "ttml", "sub", "scc"];
/// Reads a file of format (decoding it with ).
/// The file is not a subtitle file of that format or has no cues.
@@ -20,6 +20,8 @@ public static SubtitleDocument Read(string format, byte[] bytes)
{
if (format == "sub" && LooksBinary(bytes))
throw new InvalidDataException("This .sub file holds VobSub picture subtitles (from a DVD), not MicroDVD text. Picture subtitles can't be turned into text without OCR.");
+ if (format == "krc")
+ bytes = UnpackKrc(bytes);
var text = SubtitleEncoding.Decode(bytes).Replace("\r\n", "\n").Replace('\r', '\n');
var document = format switch
{
@@ -27,6 +29,7 @@ public static SubtitleDocument Read(string format, byte[] bytes)
"vtt" => ReadVtt(text),
"ass" or "ssa" => ReadAss(text),
"lrc" => ReadLrc(text),
+ "krc" => ReadKrc(text),
"sub" => ReadMicroDvd(text),
"smi" => SamiReader.Read(text),
"ttml" => TtmlReader.Read(text),
@@ -304,6 +307,81 @@ private static SubtitleDocument ReadLrc(string text)
return document;
}
+ // ───────────────────────── KRC ─────────────────────────
+
+ /// The XOR layer every KuGou .krc file shares (packing, not protection: no key or account behind it).
+ private static ReadOnlySpan KrcKey => [0x40, 0x47, 0x61, 0x77, 0x5E, 0x32, 0x74, 0x47, 0x51, 0x36, 0x31, 0x2D, 0xCE, 0xD2, 0x6E, 0x69];
+
+ /// More text than this is no lyrics file; stops a small file from inflating without end.
+ private const int KrcMaxText = 16 * 1024 * 1024;
+
+ /// The lyrics text inside a KuGou .krc: krc1 , then zlib data XORed with .
+ internal static byte[] UnpackKrc(byte[] bytes)
+ {
+ if (bytes is not [(byte)'k', (byte)'r', (byte)'c', (byte)'1', _, ..])
+ throw new InvalidDataException("This is not a KuGou .krc lyrics file (it doesn't start with \"krc1\").");
+ var packed = bytes[4..];
+ for (var i = 0; i < packed.Length; i++)
+ packed[i] ^= KrcKey[i % KrcKey.Length];
+ using var text = new MemoryStream();
+ try
+ {
+ using var zlib = new System.IO.Compression.ZLibStream(new MemoryStream(packed), System.IO.Compression.CompressionMode.Decompress);
+ var buffer = new byte[81920];
+ for (int read; (read = zlib.Read(buffer)) > 0 && text.Length <= KrcMaxText;)
+ text.Write(buffer, 0, read);
+ }
+ catch (Exception e)
+ {
+ throw new InvalidDataException("This .krc lyrics file is damaged: its packed text can't be unpacked.", e);
+ }
+ if (text.Length > KrcMaxText)
+ throw new InvalidDataException("This .krc file unpacks to more text than a lyrics file holds.");
+ return text.ToArray();
+ }
+
+ ///
+ /// KuGou lyrics, unpacked: [start,duration] in milliseconds before each line, <offset,duration,0>
+ /// before each word (dropped: one cue per line), and LRC-style [ti:…] / [offset:…] tags. Other tags
+ /// ([ar:] , [hash:] , the translations in [language:] ) are skipped.
+ ///
+ private static SubtitleDocument ReadKrc(string text)
+ {
+ var document = new SubtitleDocument();
+ var offset = TimeSpan.Zero;
+ List<(TimeSpan Start, TimeSpan Length, string Text)> lines = [];
+ foreach (var raw in text.Split('\n'))
+ {
+ var line = raw.Trim().TrimStart('');
+ if (LrcMetadata().Match(line) is { Success: true } metadata)
+ {
+ var value = metadata.Groups["value"].Value.Trim();
+ switch (metadata.Groups["key"].Value.ToLowerInvariant())
+ {
+ case "ti" when value.Length > 0:
+ document.Title = value;
+ break;
+ case "offset" when int.TryParse(value, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var ms):
+ offset = TimeSpan.FromMilliseconds(ms);
+ break;
+ }
+ continue;
+ }
+ if (KrcLine().Match(line) is not { Success: true } match)
+ continue;
+ var lyric = KrcWordTime().Replace(match.Groups["text"].Value, "").Trim();
+ if (lyric.Length == 0)
+ continue;
+ var start = TimeSpan.FromMilliseconds(long.Parse(match.Groups["start"].Value, CultureInfo.InvariantCulture));
+ var length = TimeSpan.FromMilliseconds(long.Parse(match.Groups["length"].Value, CultureInfo.InvariantCulture));
+ lines.Add((start, length, lyric));
+ }
+ // As in LRC, the last [offset:] counts for every line, wherever the tag sits.
+ foreach (var (start, length, lyric) in lines)
+ document.Add(start - offset, start + length - offset, lyric);
+ return document;
+ }
+
// ───────────────────────── MicroDVD ─────────────────────────
/// The frame rate MicroDVD files are assumed to use when they don't say (the film rate of most rips).
@@ -401,6 +479,12 @@ private static string MicroDvdText(string body)
[GeneratedRegex(@"<\d+:\d{1,2}(?:[.:]\d{1,3})?>")]
private static partial Regex LrcWordTime();
+ [GeneratedRegex(@"^\[(?\d{1,9}),(?\d{1,9})\](?.*)$")]
+ private static partial Regex KrcLine();
+
+ [GeneratedRegex(@"<\d+,\d+,\d+>")]
+ private static partial Regex KrcWordTime();
+
[GeneratedRegex(@"^\{(?\d+)\}\{(?\d*)\}(?.*)$")]
private static partial Regex MicroDvdLine();
diff --git a/src/Filee.Engines/Text/WeasyPrintConverter.cs b/src/Filee.Engines/Text/WeasyPrintConverter.cs
new file mode 100644
index 000000000..7a19254ee
--- /dev/null
+++ b/src/Filee.Engines/Text/WeasyPrintConverter.cs
@@ -0,0 +1,141 @@
+// HTML → PDF with WeasyPrint (BSD-3-Clause, a separate program the user downloads in Settings → Engines, Windows only
+// for now, #1403). WeasyPrint lays a page out with Pango and HarfBuzz like a browser prints it: headings, tables, bold
+// and Arabic or Hindi shaping come out as on screen, where the built-in route (HWPX writer → rhwp) loses them.
+//
+// Its one edge, HTML → PDF, serves routes that start from an HTML page, Markdown (Markdig writes the page), an e-mail
+// (EML, MSG) or a saved web page (MHTML), and no other (ConversionEdge.Sources): a Word document or an e-book keeps its
+// route instead of going through a page. It costs so little that WeasyPrint wins wherever it is installed, even after
+// the e-mail step and in last place of the engine order (1 + 9 against 31 for the built-in route); without it nothing
+// changes. It is found only as Filee's download or a copy the user picked, never on the PATH.
+//
+// Locked down: WeasyPrint gets a copy of the page whose pictures inside the document's folder are data: URIs and that
+// links to nothing else (LinkedContent: style sheets, @import, CSS url(), frames, objects, taken out), and it
+// runs with --allowed-protocols data, so whatever that copy still names (a picture by a relative or absolute path, a
+// file: or https: address, an attachment) is refused by WeasyPrint itself and never read or fetched.
+
+using System.Globalization;
+using System.Text.RegularExpressions;
+using Filee.Core.Conversion;
+using Filee.Engines.Infrastructure;
+using Filee.Engines.Office;
+
+namespace Filee.Engines.Text;
+
+/// Conversions through WeasyPrint's command line program.
+public sealed partial class WeasyPrintConverter : IConverter
+{
+ /// Formats whose routes may end with WeasyPrint's HTML → PDF: all of them reach PDF through a page.
+ internal static readonly string[] Sources = ["html", "md", "eml", "msg", "mhtml"];
+
+ ///
+ /// Cost of HTML → PDF. The built-in route costs 31 in the default engine order (HWPX writer 10 + 8, rhwp 10 + 3) and
+ /// LibreOffice 28 + 9. WeasyPrint at the end of the order costs 1 + 9, after Markdown → HTML (4 + 5) 19, after
+ /// MHTML or an e-mail → HTML (10 + 9) 29: always cheaper. Its source list keeps it out of every other route.
+ ///
+ internal const int PdfCost = 1;
+
+ ///
+ /// With --verbose WeasyPrint prints each step and each page it lays out ("Step 5 - Creating layout - Page 12"). Only
+ /// this long without a line means it is stuck.
+ ///
+ internal static readonly TimeSpan StallTimeout = TimeSpan.FromMinutes(5);
+
+ /// The longest a page may take even while WeasyPrint keeps printing.
+ internal static readonly TimeSpan MaxDuration = TimeSpan.FromMinutes(30);
+
+ private string? _executable;
+
+ public string Id => "weasyprint";
+ public string DisplayName => "WeasyPrint";
+
+ /// One WeasyPrint is a Python process with the whole page in memory; two at a time keep the PC responsive.
+ public int MaxParallelism => 2;
+
+ public IReadOnlyList Edges { get; } = [new("html", "pdf", PdfCost, Sources: Sources)];
+
+ public EngineStatus GetStatus()
+ {
+ _executable = Locate();
+ // Filee has no WeasyPrint download for Linux and macOS yet (#1403): "not installed" would point to nothing.
+ return _executable is null
+ ? EngineStatus.Unavailable(OperatingSystem.IsWindows() ? "engine.reason.not_installed" : "engine.reason.unsupported_platform")
+ : EngineStatus.Available(_executable, EngineEnvironment.UsesFileesCopy("weasyprint") ? EngineVersions.Component("weasyprint") : null);
+ }
+
+ public async Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken)
+ {
+ var executable = _executable ?? Locate() ?? throw new InvalidOperationException("WeasyPrint is not installed.");
+ var work = Directory.CreateDirectory(Path.Combine(step.WorkDirectory, "weasyprint-" + Guid.NewGuid().ToString("N")[..8])).FullName;
+ var page = PreparePage(step.InputPath, work, step.OriginalSourcePath);
+ var temp = Path.Combine(work, "output.pdf");
+
+ // For a WeasyPrint installed with pip. The onedir weasyprint.exe ignores both (PyInstaller starts Python with an
+ // isolated configuration); its progress still arrives line by line, as WeasyPrint logs to stderr, which stays
+ // line-buffered.
+ var environment = new Dictionary
+ {
+ ["PYTHONIOENCODING"] = "utf-8",
+ ["PYTHONUNBUFFERED"] = "1",
+ };
+ progress?.Report(0.02);
+ var result = await ProcessRunner.RunAsync(executable, Arguments(page, temp), MaxDuration, cancellationToken,
+ workingDirectory: work, environment: environment, onOutput: line => ReportProgress(line, progress),
+ stallTimeout: StallTimeout);
+ if (result.ExitCode != 0 || !File.Exists(temp))
+ throw new InvalidOperationException($"WeasyPrint failed (exit {result.ExitCode}). {LastLines(result.StandardError.Trim().Length > 0 ? result.StandardError : result.StandardOutput)}");
+
+ var output = step.Output.Allocate("pdf");
+ if (output is null)
+ return [];
+ File.Move(temp, output, overwrite: true);
+ progress?.Report(1);
+ return [output];
+ }
+
+ ///
+ /// WeasyPrint's command line: only data: URIs may be loaded, HTML attributes such as width and bgcolor count (e-mails
+ /// use them), and progress lines for the stall watch.
+ ///
+ internal static string[] Arguments(string page, string pdf) =>
+ ["--allowed-protocols", "data", "--presentational-hints", "--verbose", page, pdf];
+
+ ///
+ /// The page WeasyPrint gets, in : with the pictures the document may
+ /// use (inside its folder, or the folder of for a page made from it) as data: URIs
+ /// and every other address of a picture, frame, object or style sheet taken out ( ).
+ ///
+ internal static string PreparePage(string input, string work, string? originalSource)
+ {
+ var page = Path.Combine(work, "page.html");
+ var html = File.ReadAllBytes(input);
+ File.WriteAllBytes(page, LinkedContent.StripHtml(html, LinkedContent.PagePictures(input, originalSource, svg: true)) ?? html);
+ return page;
+ }
+
+ /// WeasyPrint prints "INFO: Step 5 - Creating layout - Page 3" while it works: seven steps.
+ private static void ReportProgress(string line, IProgress? progress)
+ {
+ if (progress is not null && StepLine().Match(line) is { Success: true } match
+ && int.TryParse(match.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var number))
+ progress.Report(Math.Clamp(number, 1, 7) / 7.0 * 0.95);
+ }
+
+ /// The end of WeasyPrint's output, where its error message (or Python traceback) is.
+ private static string LastLines(string output)
+ {
+ var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ return string.Join(" ", lines.TakeLast(3));
+ }
+
+ [GeneratedRegex(@"Step (\d) - ")]
+ private static partial Regex StepLine();
+
+ ///
+ /// The user's own WeasyPrint when they chose one in Settings → Engines, else Filee's (engines/weasyprint, downloaded
+ /// on demand). Never one on the PATH or elsewhere on the PC (#1403). A copy the user picked that is too old to know
+ /// --allowed-protocols fails with a usage error instead of running unlocked.
+ ///
+ internal static string? Locate() =>
+ EngineEnvironment.OwnProgram("weasyprint", "weasyprint.exe")
+ ?? EngineEnvironment.BundledProgram("weasyprint", "weasyprint.exe");
+}
diff --git a/src/Filee.Engines/Upscale/UpscalerEngine.cs b/src/Filee.Engines/Upscale/UpscalerEngine.cs
index 31fb85af8..d32d44bd9 100644
--- a/src/Filee.Engines/Upscale/UpscalerEngine.cs
+++ b/src/Filee.Engines/Upscale/UpscalerEngine.cs
@@ -63,7 +63,7 @@ public async Task> UpscaleAsync(
Directory.CreateDirectory(workDirectory);
var from = ReadFormat(fromFormat, inputPath);
- using var images = ImageReader.Read(inputPath, from, preset);
+ using var images = ImageReader.Read(inputPath, from, preset, cancellationToken);
var animation = ImageEncoder.KeepsAnimation(from, toFormat);
var frames = ImageEncoder.SelectFrames(images, from, toFormat);
if (frames.Count == 0)
diff --git a/src/Filee.Engines/Vector/Canvas/CanvasColors.cs b/src/Filee.Engines/Vector/Canvas/CanvasColors.cs
new file mode 100644
index 000000000..eaf463057
--- /dev/null
+++ b/src/Filee.Engines/Vector/Canvas/CanvasColors.cs
@@ -0,0 +1,78 @@
+// Colours of a JSON Canvas card or line: "1" to "6" for the six preset colours, whose values the specification leaves to
+// each app, or "#RRGGBB". The presets are Obsidian's of its light theme, so a board looks as it does there; the board is
+// drawn on white, as it is printed.
+
+using System.Globalization;
+
+namespace Filee.Engines.Vector.Canvas;
+
+internal static class CanvasColors
+{
+ ///