Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/ENGINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions src/Filee.App/ViewModels/Pages/ExtensionInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,17 @@ public static class ExtensionInput
/// <remarks>
/// Two-part extensions are only accepted when Filee knows them (tar.gz, tar.xz ...): files are matched with
/// <see cref="FormatRegistry.ExtensionOf"/>, 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~").
/// </remarks>
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;
}
Expand Down
5 changes: 3 additions & 2 deletions src/Filee.Core/Formats/FormatRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/Filee.Core/Settings/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable<TriggerGesture> gest
public sealed class AppSettings
{
/// <summary>Current schema version. Increase when the format changes and add a migration.</summary>
public const int CurrentSchemaVersion = 65;
public const int CurrentSchemaVersion = 66;

public int SchemaVersion { get; set; } = CurrentSchemaVersion;

Expand Down
4 changes: 4 additions & 0 deletions src/Filee.Core/Settings/UserDataStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,10 @@ public static bool ApplyToLibrary(int from, List<Preset> presets, List<ToolbarPr
// another change takes it first.)
if (from < 65)
changed |= LearnExtensions(profiles, out _, ["dbml"]);
// v66: Krita's backups (.kra~, #1077) learn the images donut; custom claims stay put. (Schema 66 may be renumbered
// if another change takes it first.)
if (from < 66)
changed |= LearnExtensions(profiles, out _, ["kra~"]);
return changed;
}

Expand Down
9 changes: 6 additions & 3 deletions src/Filee.Core/Watching/FolderWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -224,14 +224,17 @@ public async Task<int> ProcessPendingAsync()
}
}

/// <summary>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 <see cref="WatchRule.Include"/> leaves out.</summary>
/// <summary>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
/// <see cref="WatchRule.Include"/> leaves out.</summary>
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
Expand Down
2 changes: 2 additions & 0 deletions tests/Filee.App.Tests/ExtensionEditingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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));
Expand Down
1 change: 1 addition & 0 deletions tests/Filee.Core.Tests/FolderWatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
38 changes: 37 additions & 1 deletion tests/Filee.Core.Tests/ImageExtensionAliasTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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")]
Expand Down
11 changes: 11 additions & 0 deletions tests/Filee.Core.Tests/OutputPathResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
1 change: 1 addition & 0 deletions tests/Filee.Core.Tests/SmallParsersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
22 changes: 19 additions & 3 deletions tests/Filee.Engines.Tests/KritaTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,9 +15,9 @@ public class KritaTests(EngineFixture fx) : IClassFixture<EngineFixture>
/// 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). <paramref name="merged"/> false leaves the merged picture out.
/// </summary>
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", "<DOC><IMAGE width=\"64\" height=\"32\"/></DOC>"u8.ToArray());
Expand Down Expand Up @@ -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()
{
Expand Down
Loading