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/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ multi-frame pictures refused, the EXIF thumbnail dropped), one history entry for
skipped. Every file starts free and whole, its portrait/landscape choice following the picture.
- `Controls/CropCanvas` is the stage: the picture inside `ImagePadding`, the part outside the frame frosted (a copy at
most 40 px across, drawn large) under a veil of the page colour, the frame in the accent colour with round handles,
a rule-of-thirds grid while dragging and a size chip that follows the frame. The preview is downscaled once by
a rule-of-thirds grid while dragging and a size chip that follows the frame. A frame being moved snaps onto the
picture's vertical or horizontal centre line within `CropMath.SnapDistance` (8 px) and shows that line as a dashed
guide until the drag ends. The preview is downscaled once by
`CropService.LoadPreviewAsync`; nothing is decoded again while dragging. Programmatic changes (reset, a ratio, a
new picture) ease in with tweens read against the clock, so a lost frame never leaves one half done; dragging and
arrow-key nudges follow at once, and `PlayAnimations` (from `Motion.Enabled`) turns all of it off.
Expand Down
2 changes: 1 addition & 1 deletion docs/ENGINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status,
| PDFsharp | built in (library) | images → PDF, merge, split, page ranges, **compression** (smaller PDFs, see below) |
| PDFium | built in (library) | PDF → images (multi-page TIFF/GIF supported) |
| Unhwp | built in (library) | HWP/HWPX → TXT, Markdown, HTML (pictures in a `<name>_files` folder next to the Markdown, embedded in the HTML) |
| Text & Markdown (Markdig) | built in (library) | **Markdown → HTML, TXT**; **TXT, LOG, PY, PAC, BAT, COMMAND → UTF-8 TXT**, preserving source text without running scripts (and → PDF / DOCX through the HWPX writer, rhwp or LibreOffice) |
| Text & Markdown (Markdig) | built in (library) | **Markdown → HTML, TXT**; **TXT, LOG, PY, PAC, BAT, COMMAND, LUA, CSS → UTF-8 TXT**, preserving source text without running scripts (and → PDF / DOCX through the HWPX writer, rhwp or LibreOffice) |
| Spreadsheets (ExcelDataReader for XLS) | built in (library) | **XLSX, XLS, ODS, CSV, TSV → XLSX, ODS, CSV, TSV** (one CSV / TSV per sheet) |
| Office Open XML | built in | **DOCM / DOTX / DOTM ↔ DOCX, XLSM / XLTX ↔ XLSX, PPTM / POTX / POTM / PPSX ↔ PPTX** (macros removed for macro-free types) |
| Fonts | built in | **TTF, OTF, WOFF, WOFF2, EOT ↔ each other**; CFF (PostScript) outlines become TrueType for TTF and EOT |
Expand Down
21 changes: 21 additions & 0 deletions src/Filee.App/Controls/CropCanvas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ public double AspectRatio
private CropHandle _hoverHandle = CropHandle.None;
private Point _dragStartPoint;
private Rect _dragStartCrop;

// The frame being moved sits on the picture's vertical / horizontal centre line: its guide is drawn.
private bool _snappedX, _snappedY;
private const double MinSizePx = 24.0;

// ───── Motion: tweens evaluated against the clock at each render, so a lost frame never leaves one half done ─────
Expand Down Expand Up @@ -409,6 +412,7 @@ public override void Render(DrawingContext context)
context.DrawImage(SourceImage, source, imgBounds);

DrawGrid(context, crop, _grid.Value(now));
DrawCentreGuides(context, imgBounds);
DrawFrame(context, crop, now);
DrawSizeChip(context, crop, imgBounds, bounds);
}
Expand Down Expand Up @@ -460,6 +464,20 @@ private static void DrawGrid(DrawingContext context, Rect crop, double opacity)
}
}

/// <summary>The picture's centre line the moved frame snapped to, across the whole picture.</summary>
private void DrawCentreGuides(DrawingContext context, Rect picture)
{
if (_activeHandle != CropHandle.Body || !(_snappedX || _snappedY))
return;
var pen = new Pen(AccentBrush ?? Brushes.MediumPurple, 1, DashStyle.Dash);
var x = Math.Round(picture.Center.X) + 0.5;
var y = Math.Round(picture.Center.Y) + 0.5;
if (_snappedX)
context.DrawLine(pen, new Point(x, picture.Top), new Point(x, picture.Bottom));
if (_snappedY)
context.DrawLine(pen, new Point(picture.Left, y), new Point(picture.Right, y));
}

private void DrawFrame(DrawingContext context, Rect crop, long now)
{
var accent = AccentBrush ?? Brushes.MediumPurple;
Expand Down Expand Up @@ -738,6 +756,8 @@ protected override void OnPointerMoved(PointerEventArgs e)

var newX = SafeClamp(_dragStartCrop.X + deltaX, imgBounds.Left, Math.Max(imgBounds.Left, imgBounds.Right - _dragStartCrop.Width));
var newY = SafeClamp(_dragStartCrop.Y + deltaY, imgBounds.Top, Math.Max(imgBounds.Top, imgBounds.Bottom - _dragStartCrop.Height));
(newX, _snappedX) = CropMath.SnapToCentre(newX, _dragStartCrop.Width, imgBounds.Left, imgBounds.Right);
(newY, _snappedY) = CropMath.SnapToCentre(newY, _dragStartCrop.Height, imgBounds.Top, imgBounds.Bottom);

SetCropFromScreen(new Rect(newX, newY, _dragStartCrop.Width, _dragStartCrop.Height), imgBounds);
return;
Expand Down Expand Up @@ -897,6 +917,7 @@ protected override void OnPointerPressed(PointerPressedEventArgs e)
{
_dragStartPoint = pt;
_dragStartCrop = crop;
_snappedX = _snappedY = false;
e.Pointer.Capture(this);
e.Handled = true;
PopHandle(_activeHandle);
Expand Down
16 changes: 16 additions & 0 deletions src/Filee.App/Controls/CropMath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@ public static Rect Nudge(Rect normalized, int dx, int dy, int width, int height)
return new Rect((double)x / width, (double)y / height, normalized.Width, normalized.Height);
}

/// <summary>How close (screen pixels) the frame's centre has to come to the picture's centre line to snap to it.</summary>
public const double SnapDistance = 8;

/// <summary>
/// One side of a frame being dragged (issue #369): its start moved so its centre sits on the centre of
/// <paramref name="min"/>…<paramref name="max"/> when it comes within <see cref="SnapDistance"/> of it. A frame as
/// wide as the picture has nowhere to go and never counts as snapped.
/// </summary>
public static (double Start, bool Snapped) SnapToCentre(double start, double length, double min, double max)
{
var centre = (min + max) / 2;
if (length >= max - min - 0.5 || Math.Abs(start + length / 2 - centre) > SnapDistance)
return (start, false);
return (centre - length / 2, true);
}

/// <summary>Space between the frame and the size chip.</summary>
public const double ChipGap = 10;

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 @@ -134,8 +134,9 @@ public static class FormatRegistry
new("sda", "SDA", FormatCategory.Presentation, ["sda", "sdd"], Writable: false),

// Plain text / markup
// Text logs and source/proxy scripts are read as plain text, never executed (issues #273, #284, #254).
new("txt", "TXT", FormatCategory.Text, ["txt", "log", "py", "pac", "bat", "command"]),
// Text logs, source/proxy scripts and style sheets are read as plain text, never executed (issues #273, #284, #254,
// #388, #368).
new("txt", "TXT", FormatCategory.Text, ["txt", "log", "py", "pac", "bat", "command", "lua", "css"]),
new("md", "Markdown", FormatCategory.Text, ["md", "markdown"]),
new("html", "HTML", FormatCategory.Text, ["html", "htm", "xhtml"]),
// A web page saved with its pictures in one MIME file (Chrome, Edge, Word); read as the page it holds.
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 @@ -112,7 +112,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 = 32;
public const int CurrentSchemaVersion = 33;

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

Expand Down
3 changes: 3 additions & 0 deletions src/Filee.Core/Settings/UserDataStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,9 @@ public static bool ApplyToLibrary(int from, List<Preset> presets, List<ToolbarPr
// v32: logs and script source, Video CD DAT and Workbench archives learn their donuts; custom claims stay put.
if (from < 32)
changed |= LearnExtensions(profiles, out _, ["log", "py", "pac", "bat", "command", "dat", "wbpz"]);
// v33: Lua scripts and CSS style sheets learn the text donut; custom claims stay put.
if (from < 33)
changed |= LearnExtensions(profiles, out _, ["lua", "css"]);
return changed;
}

Expand Down
22 changes: 20 additions & 2 deletions tests/Filee.App.Tests/CropMathTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// The crop overlay's arithmetic: ratios and their portrait forms, keyboard nudges, the frame in pixels and where the
// size chip goes.
// The crop overlay's arithmetic: ratios and their portrait forms, keyboard nudges, snapping to the centre, the frame
// in pixels and where the size chip goes.

using Avalonia;
using Filee.App.Controls;
Expand All @@ -22,6 +22,24 @@ public void Ratios_and_their_portrait_forms(string mode, bool portrait, double e
Assert.Equal(expected, CropMath.Ratio(mode, portrait, 1600, 1000), 6);
}

[Theory]
// A 100 px wide frame over a 0…1000 px picture: centred at 500 when its start is 450.
[InlineData(450, 450, true)] // already centred
[InlineData(443, 450, true)] // 7 px off: pulled in
[InlineData(458, 450, true)] // 8 px off: still pulled in
[InlineData(459, 459, false)] // 9 px off: left alone
[InlineData(100, 100, false)] // far away
public void A_moved_frame_snaps_to_the_centre_line(double start, double expected, bool snapped)
{
Assert.Equal((expected, snapped), CropMath.SnapToCentre(start, 100, 0, 1000));
}

[Fact]
public void A_frame_as_wide_as_the_picture_never_counts_as_snapped()
{
Assert.Equal((0.0, false), CropMath.SnapToCentre(0, 1000, 0, 1000));
}

[Fact]
public void Only_fixed_ratios_that_arent_square_swap()
{
Expand Down
26 changes: 26 additions & 0 deletions tests/Filee.App.Tests/CropOverlayTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,32 @@ public void Dragging_shows_the_grid_and_the_size_chip_follows_the_frame()
});
}

[AvaloniaFact]
public void A_frame_moved_near_the_middle_snaps_to_the_centre_lines()
{
WithOverlay("en", false, ["harbour.jpg"], (window, vm, _suffix) =>
{
var canvas = window.CropCanvas;
canvas.NormalizedCropRect = new Rect(0.1, 0.1, 0.5, 0.5); // centre at 35 %, 35 %
Settle(window);
var image = canvas.CalculateImageBounds();
var origin = canvas.TranslatePoint(default, window)!.Value;
var start = canvas.GetScreenCropRect(image).Center + (Vector)origin;
// To 3 screen pixels short of the picture's centre on both axes: inside the snap distance.
var target = image.Center + (Vector)origin - new Vector(3, 3);
window.MouseDown(start, MouseButton.Left);
window.MouseMove(start + (target - start) / 2);
window.MouseMove(target);
Pump();
Save(window, "crop-snap-en.png");
window.MouseUp(target, MouseButton.Left);

var frame = canvas.NormalizedCropRect;
Assert.Equal(0.5, frame.X + frame.Width / 2, 3);
Assert.Equal(0.5, frame.Y + frame.Height / 2, 3);
});
}

[AvaloniaFact]
public void Keys_nudge_the_frame_open_the_dial_reset_and_close()
{
Expand Down
33 changes: 33 additions & 0 deletions tests/Filee.Core.Tests/ScriptTextTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Lua scripts and CSS style sheets are plain text (issues #388, #368): recognised, and learnt by existing text donuts.

using Filee.Core.Formats;
using Filee.Core.Presets;
using Filee.Core.Profiles;
using Filee.Core.Settings;

namespace Filee.Core.Tests;

public class ScriptTextTests
{
[Theory]
[InlineData("init.lua")]
[InlineData("site.css")]
public void Scripts_and_style_sheets_are_text(string name) => Assert.Equal("txt", FormatRegistry.Detect(name)?.Id);

[Fact]
public void Version_32_text_donuts_learn_them_without_taking_custom_claims()
{
var presets = BuiltInData.CreatePresets();
var profiles = BuiltInData.CreateProfiles();
foreach (var profile in profiles)
profile.Extensions.RemoveAll(e => e is "lua" or "css");
profiles.Insert(0, new ToolbarProfile { Id = "my-web", Name = "Web", Extensions = ["css"] });

Assert.True(SettingsMigrations.ApplyToLibrary(32, presets, profiles));

var text = profiles.Single(p => p.Id == "text").Extensions;
Assert.Contains("lua", text);
Assert.DoesNotContain("css", text); // the user's own donut has it
Assert.False(SettingsMigrations.ApplyToLibrary(32, presets, profiles));
}
}
27 changes: 27 additions & 0 deletions tests/Filee.Engines.Tests/ScriptTextTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Lua scripts and CSS style sheets (issues #388, #368) become a PDF of their text, never run.

using Filee.Core.Conversion;
using Filee.Core.Presets;
using Filee.Engines.Pdf;

namespace Filee.Engines.Tests;

public class ScriptTextTests(EngineFixture fx) : IClassFixture<EngineFixture>
{
[Theory]
[InlineData("site.css", "body { color: #1d9e75; }")]
[InlineData("init.lua", "local greeting = \"hello\" print(greeting)")]
public async Task The_code_becomes_a_pdf_of_its_text(string name, string code)
{
var available = fx.Converters.Where(c => fx.Catalog.StatusOf(c).IsAvailable).Select(c => c.Id).ToHashSet();
Assert.SkipUnless(available.Contains("rhwp") || available.Contains("libreoffice"), "neither rhwp nor LibreOffice is available");
var path = Path.Combine(fx.NewFolder(), name);
await File.WriteAllTextAsync(path, code, TestContext.Current.CancellationToken);

var job = await fx.ConvertAsync([path], new Preset { TargetFormat = "pdf" });

Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}")));
var text = PdfDocumentReader.PlainText(PdfDocumentReader.ReadText(Assert.Single(job.Outputs)));
Assert.Contains(code.Split(' ')[1], text);
}
}
Loading