diff --git a/docs/ENGINES.md b/docs/ENGINES.md
index ea19f378e..4b03945e9 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 f6578503a..1cd57daa8 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 b19cd74ee..9f16ee405 100644
--- a/src/Filee.Core/Settings/AppSettings.cs
+++ b/src/Filee.Core/Settings/AppSettings.cs
@@ -123,7 +123,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest
public sealed class AppSettings
{
/// Current schema version. Increase when the format changes and add a migration.
- public const int CurrentSchemaVersion = 65;
+ public const int CurrentSchemaVersion = 66;
public int SchemaVersion { get; set; } = CurrentSchemaVersion;
diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs
index c6f54dfc6..e226dbb9b 100644
--- a/src/Filee.Core/Settings/UserDataStore.cs
+++ b/src/Filee.Core/Settings/UserDataStore.cs
@@ -1012,6 +1012,10 @@ public static bool ApplyToLibrary(int from, List presets, List 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..828bfa15f 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_65_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(65, presets, profiles));
+ Assert.Contains("kra~", images.Extensions);
+ Assert.False(SettingsMigrations.ApplyToLibrary(65, presets, profiles));
+
+ images.Extensions.Remove("kra~");
+ profiles.Insert(0, new ToolbarProfile { Id = "my-krita", Name = "Krita", Extensions = ["kra", "kra~"] });
+ Assert.False(SettingsMigrations.ApplyToLibrary(65, 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()
{