From 1ccf2075d78b8c2a0e3f447973fe93cafe9cca00 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Wed, 4 Nov 2026 10:39:09 +0900 Subject: [PATCH] Read Krita's .kra~ backup files (#1077) Krita keeps the previous save as "name.kra~", the same KRA file under another name. The KRA entry now lists "kra~" as one of its extensions, like the png_large / jpg_large aliases, so the backup is detected as KRA, opens the images donut and converts to "name.png". - Schema 65: the images donut learns "kra~"; custom claims stay put. - The donut editor accepts extensions Filee knows as they are, so the suggested ".kra~" can be added (it refused the "~" before). - Watch folders skip names ending in "~": Krita writes the backup next to the drawing on every save, and converting it too would put the previous picture under the same output name. Co-Authored-By: Claude Opus 5.5 --- docs/ENGINES.md | 4 +- .../ViewModels/Pages/ExtensionInput.cs | 6 ++- src/Filee.Core/Formats/FormatRegistry.cs | 5 ++- src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 4 ++ src/Filee.Core/Watching/FolderWatcher.cs | 9 +++-- .../Filee.App.Tests/ExtensionEditingTests.cs | 2 + tests/Filee.Core.Tests/FolderWatcherTests.cs | 1 + .../ImageExtensionAliasTests.cs | 38 ++++++++++++++++++- .../OutputPathResolverTests.cs | 11 ++++++ tests/Filee.Core.Tests/SmallParsersTests.cs | 1 + tests/Filee.Engines.Tests/KritaTests.cs | 22 +++++++++-- 12 files changed, 92 insertions(+), 13 deletions(-) diff --git a/docs/ENGINES.md b/docs/ENGINES.md index f949bd2a2..6d8e71813 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -299,7 +299,9 @@ in it also builds the package (artifact `windows-arm64-package`). mask. Grey and RGB pictures go in through PDFsharp as before. - **KRA** (Krita's own file, a ZIP of the layers) is read as the merged picture Krita stores at its top for other programs (`mergedimage.png`, `Krita.ReadMerged`, at most 256 MB, in memory), so it looks as it does in Krita. A file - without one gives a clear error. + without one gives a clear error. Krita's backup of the previous save, `name.kra~`, is the same file and is read the + same way; its output is named `name.png`. Watch folders skip it (and any name ending in `~`), as it is written next + to the drawing on every save. - **Live / Motion Photos from a video** (JPG and HEIC targets) are FFmpeg's: the clip and the photo's frame come from it, ImageMagick or the HEIC writer only encode that frame (see *Making Live / Motion Photos* there). - **Camera RAW** (DNG, CR2, CR3, NEF, ARW, …, and `.raw`) is decoded by LibRaw with the camera's white balance, diff --git a/src/Filee.App/ViewModels/Pages/ExtensionInput.cs b/src/Filee.App/ViewModels/Pages/ExtensionInput.cs index 7732bdc66..26f8b2f8e 100644 --- a/src/Filee.App/ViewModels/Pages/ExtensionInput.cs +++ b/src/Filee.App/ViewModels/Pages/ExtensionInput.cs @@ -18,15 +18,17 @@ public static class ExtensionInput /// /// Two-part extensions are only accepted when Filee knows them (tar.gz, tar.xz ...): files are matched with /// , which only sees the last part of any other name ("x.foo.bar" → "bar"). + /// An extension Filee knows is taken as it is, so every suggestion can be added (Krita's backup "kra~"). /// public static string? Normalize(string? text) { var value = (text ?? "").Trim().TrimStart('*', '.').TrimEnd('.').ToLowerInvariant(); if (value.Length is 0 or > MaxLength || value.Contains("..", StringComparison.Ordinal)) return null; - if (!value.All(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '+' or '.')) + var known = FormatRegistry.FindByExtension(value) is not null; + if (!known && !value.All(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '+' or '.')) return null; - if (value.Contains('.') && FormatRegistry.FindByExtension(value) is null) + if (value.Contains('.') && !known) return null; return value; } diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs index 0d12acd4d..ea1b6c187 100644 --- a/src/Filee.Core/Formats/FormatRegistry.cs +++ b/src/Filee.Core/Formats/FormatRegistry.cs @@ -54,8 +54,9 @@ public static class FormatRegistry // 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), - // Krita's own file (#903): a ZIP whose merged picture (mergedimage.png) is read, as Krita shows it. - new("kra", "Krita (KRA)", FormatCategory.Image, ["kra"], Writable: false), + // 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), // 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), new("raw", "RAW", FormatCategory.Image, diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index fe6877596..b19cd74ee 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -123,7 +123,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest public sealed class AppSettings { /// Current schema version. Increase when the format changes and add a migration. - public const int CurrentSchemaVersion = 64; + public const int CurrentSchemaVersion = 65; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 7c1fb847d..00d3c8c2f 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -1008,6 +1008,10 @@ public static bool ApplyToLibrary(int from, List presets, List ProcessPendingAsync() } } - /// True for files a watch folder never converts: temporary, hidden or system files, unknown formats, - /// anything inside the output or originals folder, and names the rule's leaves out. + /// True for files a watch folder never converts: temporary, hidden or system files, backups of the previous + /// save ("sketch.kra~"), unknown formats, anything inside the output or originals folder, and names the rule's + /// leaves out. public static bool ShouldIgnore(string path, WatchRule rule) { if (Presets.FolderCopy.IsWorkPath(path)) return true; var name = Path.GetFileName(path); - if (name.Length == 0 || name.StartsWith("~$", StringComparison.Ordinal) || name.StartsWith('.') + // A backup is written next to the file on every save; converting it too would put the previous picture under the + // same output name. + if (name.Length == 0 || name.StartsWith("~$", StringComparison.Ordinal) || name.StartsWith('.') || name.EndsWith('~') || SystemFiles.Contains(name) || TemporaryExtensions.Contains(Path.GetExtension(name))) return true; // Exclude a dedicated output subtree, but never exclude the entire watch root when output is the same diff --git a/tests/Filee.App.Tests/ExtensionEditingTests.cs b/tests/Filee.App.Tests/ExtensionEditingTests.cs index 928fdc59d..5f152f3ff 100644 --- a/tests/Filee.App.Tests/ExtensionEditingTests.cs +++ b/tests/Filee.App.Tests/ExtensionEditingTests.cs @@ -29,6 +29,7 @@ public class ExtensionInputTests [InlineData("dvr-ms", "dvr-ms")] [InlineData("xyz", "xyz")] [InlineData("jpg.", "jpg")] + [InlineData("*.KRA~", "kra~")] // Krita's backup, a known extension public void Normalizes_extensions(string input, string expected) => Assert.Equal(expected, ExtensionInput.Normalize(input)); [Theory] @@ -39,6 +40,7 @@ public class ExtensionInputTests [InlineData("a/b")] [InlineData(@"C:\x")] [InlineData("a..b")] + [InlineData("txt~")] // only known extensions take other characters [InlineData("photo.jpg")] // two-part extensions only when Filee knows them [InlineData("averyveryverylongextension")] public void Rejects_what_cannot_be_an_extension(string input) => Assert.Null(ExtensionInput.Normalize(input)); diff --git a/tests/Filee.Core.Tests/FolderWatcherTests.cs b/tests/Filee.Core.Tests/FolderWatcherTests.cs index f05cb36f1..b16a06635 100644 --- a/tests/Filee.Core.Tests/FolderWatcherTests.cs +++ b/tests/Filee.Core.Tests/FolderWatcherTests.cs @@ -206,6 +206,7 @@ public async Task A_file_another_program_holds_open_waits() [Theory] [InlineData("photo.jpg.crdownload")] [InlineData("~$report.docx")] + [InlineData("sketch.kra~")] // Krita's backup of the previous save, written next to sketch.kra [InlineData(".hidden.jpg")] [InlineData("notes.unknownformat")] [InlineData("converted/photo.jpg")] diff --git a/tests/Filee.Core.Tests/ImageExtensionAliasTests.cs b/tests/Filee.Core.Tests/ImageExtensionAliasTests.cs index 867a4a68f..b8a643660 100644 --- a/tests/Filee.Core.Tests/ImageExtensionAliasTests.cs +++ b/tests/Filee.Core.Tests/ImageExtensionAliasTests.cs @@ -1,4 +1,5 @@ -// PNG/JPEG download aliases are recognised and learnt by image profiles without taking custom claims. +// PNG/JPEG download aliases and Krita's backups (.kra~, #1077) are recognised and learnt by image profiles without +// taking custom claims. using Filee.Core.Formats; using Filee.Core.Presets; @@ -45,6 +46,41 @@ public void Version_40_libraries_learn_both_aliases_once() Assert.False(SettingsMigrations.ApplyToLibrary(40, presets, profiles)); } + [Theory] + [InlineData("sketch.kra~")] + [InlineData("SKETCH.KRA~")] + public void A_krita_backup_is_a_kra_named_after_the_drawing(string file) + { + Assert.Same(FormatRegistry.Get("kra"), FormatRegistry.Detect(file)); + Assert.Equal("sketch", FormatRegistry.NameWithoutExtension(file).ToLowerInvariant()); + Assert.Equal("images", ProfileSelector.Select(BuiltInData.CreateProfiles(), [file, "sketch.kra"])?.Id); + } + + [Fact] + public void A_name_ending_in_a_tilde_is_not_a_kra_by_itself() + { + Assert.Null(FormatRegistry.Detect("notes.txt~")); + Assert.Null(FormatRegistry.Detect("sketch.kr~")); + } + + [Fact] + public void Version_64_libraries_learn_krita_backups_without_taking_custom_claims() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + var images = profiles.Single(p => p.Id == "images"); + images.Extensions.Remove("kra~"); + + Assert.True(SettingsMigrations.ApplyToLibrary(64, presets, profiles)); + Assert.Contains("kra~", images.Extensions); + Assert.False(SettingsMigrations.ApplyToLibrary(64, presets, profiles)); + + images.Extensions.Remove("kra~"); + profiles.Insert(0, new ToolbarProfile { Id = "my-krita", Name = "Krita", Extensions = ["kra", "kra~"] }); + Assert.False(SettingsMigrations.ApplyToLibrary(64, presets, profiles)); + Assert.DoesNotContain("kra~", images.Extensions); // the user's own donut has it + } + [Theory] [InlineData("PNG_LARGE", "jpg_large")] [InlineData("JPG_LARGE", "png_large")] diff --git a/tests/Filee.Core.Tests/OutputPathResolverTests.cs b/tests/Filee.Core.Tests/OutputPathResolverTests.cs index 0e52b646c..c17cff2f1 100644 --- a/tests/Filee.Core.Tests/OutputPathResolverTests.cs +++ b/tests/Filee.Core.Tests/OutputPathResolverTests.cs @@ -20,6 +20,17 @@ public void Default_pattern_keeps_the_source_name() Assert.Equal(Path.Combine(dir.Path, "photo.png"), path); } + [Fact] + public void A_krita_backup_is_named_after_the_drawing() + { + using var dir = new TempDir(); + var source = dir.File("sketch.kra~"); + + var path = OutputPathResolver.Resolve(new OutputRule(), Tokens(source), "png", File.Exists); + + Assert.Equal(Path.Combine(dir.Path, "sketch.png"), path); + } + [Fact] public void Two_part_extensions_are_one_extension() { diff --git a/tests/Filee.Core.Tests/SmallParsersTests.cs b/tests/Filee.Core.Tests/SmallParsersTests.cs index 395bd0b67..7a9e1cae5 100644 --- a/tests/Filee.Core.Tests/SmallParsersTests.cs +++ b/tests/Filee.Core.Tests/SmallParsersTests.cs @@ -35,6 +35,7 @@ public class FormatRegistryTests [InlineData("archive.7z.001", "split")] [InlineData("archive.zip.001", "split")] [InlineData("archive.tar.001", "split")] + [InlineData("sketch.kra~", "kra")] // Krita's backup of the previous save public void Detects_formats(string path, string id) => Assert.Equal(id, FormatRegistry.Detect(path)?.Id); [Fact] diff --git a/tests/Filee.Engines.Tests/KritaTests.cs b/tests/Filee.Engines.Tests/KritaTests.cs index 25410eb04..716be6bd2 100644 --- a/tests/Filee.Engines.Tests/KritaTests.cs +++ b/tests/Filee.Engines.Tests/KritaTests.cs @@ -1,4 +1,5 @@ -// Krita's .kra (issue #903): read as the merged picture it keeps at its top, built here as Krita lays the file out. +// Krita's .kra (issue #903) and its .kra~ backup (#1077): read as the merged picture it keeps at its top, built here as +// Krita lays the file out. using System.IO.Compression; using Filee.Core.Conversion; @@ -14,9 +15,9 @@ public class KritaTests(EngineFixture fx) : IClassFixture /// A .kra as Krita writes it: the mimetype first, the document, a layer and its small preview, and the merged picture /// (left red, right transparent). false leaves the merged picture out. /// - private string Kra(bool merged = true) + private string Kra(bool merged = true, string name = "sketch.kra") { - var path = Path.Combine(fx.NewFolder(), "sketch.kra"); + var path = Path.Combine(fx.NewFolder(), name); using var zip = ZipFile.Open(path, ZipArchiveMode.Create); Add(zip, "mimetype", "application/x-krita"u8.ToArray()); Add(zip, "maindoc.xml", ""u8.ToArray()); @@ -69,6 +70,21 @@ public async Task A_kra_reaches_the_targets_the_issue_asked_for(string target) Assert.EndsWith("." + target, Assert.Single(job.Outputs)); } + [Fact] + public async Task A_krita_backup_converts_like_the_kra_and_is_named_after_the_drawing() + { + // Krita keeps the previous save as "sketch.kra~" (#1077): the same file under another name. + var backup = Kra(name: "sketch.kra~"); + Assert.Equal("kra", FormatRegistry.Detect(backup)!.Id); + + var job = await fx.ConvertAsync([backup], new Preset { TargetFormat = "png" }); + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey}: {f.ErrorDetail}"))); + var output = Assert.Single(job.Outputs); + Assert.Equal("sketch.png", Path.GetFileName(output)); + using var png = new MagickImage(output); + Assert.Equal((64u, 32u), (png.Width, png.Height)); + } + [Fact] public async Task A_kra_without_its_merged_picture_says_so() {