diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4a7c9e40..757eaf0b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 3ba038b0..6c6a5804 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -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 `_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 | diff --git a/src/Filee.App/Controls/CropCanvas.cs b/src/Filee.App/Controls/CropCanvas.cs index 86cc920a..c51728da 100644 --- a/src/Filee.App/Controls/CropCanvas.cs +++ b/src/Filee.App/Controls/CropCanvas.cs @@ -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 ───── @@ -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); } @@ -460,6 +464,20 @@ private static void DrawGrid(DrawingContext context, Rect crop, double opacity) } } + /// The picture's centre line the moved frame snapped to, across the whole picture. + 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; @@ -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; @@ -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); diff --git a/src/Filee.App/Controls/CropMath.cs b/src/Filee.App/Controls/CropMath.cs index f14cb460..14f06064 100644 --- a/src/Filee.App/Controls/CropMath.cs +++ b/src/Filee.App/Controls/CropMath.cs @@ -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); } + /// How close (screen pixels) the frame's centre has to come to the picture's centre line to snap to it. + public const double SnapDistance = 8; + + /// + /// One side of a frame being dragged (issue #369): its start moved so its centre sits on the centre of + /// … when it comes within of it. A frame as + /// wide as the picture has nowhere to go and never counts as snapped. + /// + 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); + } + /// Space between the frame and the size chip. public const double ChipGap = 10; diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs index 1ec46736..14bb5bce 100644 --- a/src/Filee.Core/Formats/FormatRegistry.cs +++ b/src/Filee.Core/Formats/FormatRegistry.cs @@ -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. diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 4f488dcb..3df95d28 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -112,7 +112,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 = 32; + public const int CurrentSchemaVersion = 33; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 7aff0c84..1dbddd84 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -750,6 +750,9 @@ public static bool ApplyToLibrary(int from, List presets, List + { + 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() { diff --git a/tests/Filee.Core.Tests/ScriptTextTests.cs b/tests/Filee.Core.Tests/ScriptTextTests.cs new file mode 100644 index 00000000..0839cf28 --- /dev/null +++ b/tests/Filee.Core.Tests/ScriptTextTests.cs @@ -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)); + } +} diff --git a/tests/Filee.Engines.Tests/ScriptTextTests.cs b/tests/Filee.Engines.Tests/ScriptTextTests.cs new file mode 100644 index 00000000..80c651c1 --- /dev/null +++ b/tests/Filee.Engines.Tests/ScriptTextTests.cs @@ -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 +{ + [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); + } +}