Skip to content

Commit 5811b46

Browse files
authored
Merge pull request #964 from lightscape-jm/fix/black-box-cmyk
fix(hide): Black box is black on CMYK, and extra TIFF channels are covered
2 parents 72f7e5b + 73d98ac commit 5811b46

8 files changed

Lines changed: 152 additions & 37 deletions

File tree

‎src/Filee.App/Controls/HideAreaSet.cs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// The parts of a picture the "Pixelate / blur" window hides, without a window: the frames (in parts of the picture),
22
// how each is covered (pixelate, blur or black box), the one selected, and undo / redo of adding, moving, sizing,
3-
// restyling and removing them. The canvas draws and drags them, the
4-
// keys move and remove them, the window's view model turns them into the conversion.
3+
// restyling and removing them. The canvas draws and drags them, the keys move and remove them, the window's view model
4+
// turns them into the conversion.
55

66
using Avalonia;
77
using Filee.Core.Presets;
@@ -154,8 +154,8 @@ public void Nudge(int dx, int dy, int width, int height)
154154
}
155155

156156
/// <summary>
157-
/// Starts over with <paramref name="areas"/> (a new picture: the last one's frames carry on, with their styles), none
158-
/// selected and nothing to undo.
157+
/// Starts over with <paramref name="areas"/> (a new picture: the last one's frames carry on, with their styles),
158+
/// none selected and nothing to undo.
159159
/// </summary>
160160
public void Reset(IEnumerable<(Rect Area, HideStyle Style)> areas)
161161
{

‎src/Filee.App/Controls/HideCanvas.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,8 +262,8 @@ public override void Render(DrawingContext context)
262262
if (Areas is not { } areas)
263263
return;
264264

265-
// Inside every frame the covered picture as its style says: crisp squares for pixelate (the copy has them at the
266-
// preview's size), the blurred copy for blur, solid black for a box.
265+
// Inside every frame the covered picture as its style says: crisp squares for pixelate (the copy has them at
266+
// the preview's size), the blurred copy for blur, solid black for a box.
267267
for (var i = 0; i < areas.Count; i++)
268268
{
269269
var frame = ToScreen(areas.Areas[i], picture);

‎src/Filee.App/ViewModels/HideWindowViewModel.cs‎

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
// State of the "Pixelate / blur" window: one picture at a time, as in the crop overlay, with frames drawn over the parts
2-
// to hide, each showing them already pixelated, blurred or blacked out (each frame its own style); the last style picked
3-
// and how strongly (kept in the tool for the next time); and the conversion that writes the picture with those parts covered, which runs in the toast like
4-
// any other. The frames carry on to the next picture of a batch (screenshots of one app put names in the same place).
1+
// State of the "Pixelate / blur" window: one picture at a time, as in the crop overlay, with frames drawn over the
2+
// parts to hide, each showing them already pixelated, blurred or blacked out (each frame its own style); the last style
3+
// picked and how strongly (kept in the tool for the next time); and the conversion that writes the picture with those
4+
// parts covered, which runs in the toast like any other. The frames carry on to the next picture of a batch
5+
// (screenshots of one app put names in the same place).
56

67
using System.Collections.ObjectModel;
78
using System.IO;
@@ -37,7 +38,9 @@ public sealed partial class HideWindowViewModel : ObservableObject, IDisposable
3738
/// <summary>The frames of the last picture saved, with their styles, for the next one.</summary>
3839
private List<(Rect, HideStyle)> _carried = [];
3940

40-
/// <summary>The styles the covers were last asked for, so a frame moved or added in one of them asks for nothing.</summary>
41+
/// <summary>
42+
/// The styles the covers were last asked for, so a frame moved or added in one of them asks for nothing.
43+
/// </summary>
4144
private string _coverStyles = "";
4245

4346
[ObservableProperty] private int _currentIndex;
@@ -222,7 +225,9 @@ private void OnAreasChanged()
222225
RefreshCoverIfStylesChanged();
223226
}
224227

225-
/// <summary>The styles that need a cover: those of the frames and the one shown (a frame is drawn in it next).</summary>
228+
/// <summary>
229+
/// The styles that need a cover: those of the frames and the one shown (a frame is drawn in it next).
230+
/// </summary>
226231
private List<HideStyle> CoverStyles() =>
227232
[.. Areas.Styles.Append(Style).Where(s => s != HideStyle.Box).Distinct().Order()];
228233

@@ -292,9 +297,9 @@ public async Task LoadFileAsync(int index)
292297
}
293298

294299
/// <summary>
295-
/// Covers the preview again for each style in use (pixelate, blur; a black box needs none), off the window's thread:
296-
/// the frames show the picture as it is until then (softly blurred). Only the newest result is kept, so dragging the
297-
/// strength never queues up work.
300+
/// Covers the preview again for each style in use (pixelate, blur; a black box needs none), off the window's
301+
/// thread: the frames show the picture as it is until then (softly blurred). Only the newest result is kept, so
302+
/// dragging the strength never queues up work.
298303
/// </summary>
299304
private void RefreshCover()
300305
{
@@ -469,7 +474,9 @@ public void StepStrength(int step)
469474

470475
// ───────────────────────── Files, messages and the end ─────────────────────────
471476

472-
/// <summary>Keeps the style picked last and the strength in the tool, as saved and as given to this window.</summary>
477+
/// <summary>
478+
/// Keeps the style picked last and the strength in the tool, as saved and as given to this window.
479+
/// </summary>
473480
private void Remember()
474481
{
475482
var style = Areas.DefaultStyle;

‎src/Filee.App/Views/HideWindow.axaml‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
<!-- "Pixelate / blur": the crop overlay's frameless soft-rounded stage, with the picture large on the dimmed theme
2-
backdrop and frames over the parts to hide, already pixelated, blurred or blacked out. Floating on it: the file chip (top left),
3-
the strip of files (top, several files), size and close (top right), "Drag over what you want to hide" on a picture
4-
without frames, and the toolbar pill at the bottom with Pixelate / Blur / Black box, the strength, undo, remove, clear, Skip and
5-
"Hide & save" / "Hide & next". After the last file with problems, the crop overlay's summary card. -->
2+
backdrop and frames over the parts to hide, already pixelated, blurred or blacked out. Floating on it: the file
3+
chip (top left), the strip of files (top, several files), size and close (top right), "Drag over what you want to
4+
hide" on a picture without frames, and the toolbar pill at the bottom with Pixelate / Blur / Black box, the
5+
strength, undo, remove, clear, Skip and "Hide & save" / "Hide & next". After the last file with problems, the crop
6+
overlay's summary card. -->
67
<Window xmlns="https://github.com/avaloniaui"
78
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
89
xmlns:vm="using:Filee.App.ViewModels"
@@ -164,8 +165,9 @@
164165
<Setter Property="Opacity" Value="1" />
165166
</Style>
166167

167-
<!-- A narrow window (or long names, or wide fonts): the toolbar gives up names in two steps (HideWindow.FitToolbar),
168-
first those of Clear all and Skip and the keycaps, then those of Pixelate, Blur and Black box; the tooltips keep them -->
168+
<!-- A narrow window (or long names, or wide fonts): the toolbar gives up names in two steps
169+
(HideWindow.FitToolbar), first those of Clear all and Skip and the keycaps, then those of Pixelate, Blur and
170+
Black box; the tooltips keep them -->
169171
<Style Selector="Window.tight TextBlock.minor, Window.tight Border.minor, Window.compact TextBlock.major">
170172
<Setter Property="IsVisible" Value="False" />
171173
</Style>

‎src/Filee.Core/Presets/HideTool.cs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// "Pixelate / blur" runs as an ordinary conversion once its window has the parts of a picture to hide: the tool preset
22
// becomes a conversion into the picture's own format with those parts (ImageOptions.HiddenAreas), so the job queue, the
33
// toast, cancelling and the output rules work as for any other file. Each part is pixelated, blurred or blacked out
4-
// (HiddenArea.Style); the last style picked and how strongly are kept in the tool, so the window opens with them. The arithmetic shared by the window's
5-
// preview and the image engine is here too: where a part lies in pixels, how large the squares or the blur are, and
6-
// which square a pixel falls in.
4+
// (HiddenArea.Style); the last style picked and how strongly are kept in the tool, so the window opens with them. The
5+
// arithmetic shared by the window's preview and the image engine is here too: where a part lies in pixels, how large
6+
// the squares or the blur are, and which square a pixel falls in.
77

88
using Filee.Core.Conversion;
99
using Filee.Core.Formats;

‎src/Filee.Core/Presets/Preset.cs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -403,8 +403,7 @@ public enum HideStyle
403403
/// <summary>A strong blur.</summary>
404404
Blur,
405405
/// <summary>
406-
/// Solid, opaque black: nothing of what was there is left. A Filee from before it reads it as
407-
/// <see cref="Pixelate"/>.
406+
/// Solid, opaque black: nothing of what was there is left. An older Filee reads it as <see cref="Pixelate"/>.
408407
/// </summary>
409408
Box,
410409
}
@@ -625,8 +624,9 @@ public sealed class ImageOptions
625624
public List<int> IcoSizes { get; set; } = [256, 128, 64, 48, 32, 16];
626625

627626
/// <summary>
628-
/// "Pixelate / blur": how <see cref="HiddenAreas"/> without a <see cref="HiddenArea.Style"/> of their own are covered.
629-
/// Kept in the tool as the last style picked, so its window opens with it. A field of its own (not a value added to another option), read leniently: an older Filee skips it.
627+
/// "Pixelate / blur": how <see cref="HiddenAreas"/> without a <see cref="HiddenArea.Style"/> of their own are
628+
/// covered. Kept in the tool as the last style picked, so its window opens with it. A field of its own (not a value
629+
/// added to another option), read leniently: an older Filee skips it.
630630
/// </summary>
631631
[JsonConverter(typeof(LenientEnumJsonConverter<HideStyle>))]
632632
public HideStyle HideStyle { get; set; }

‎src/Filee.Engines/Magick/AreaHider.cs‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// "Pixelate / blur": covers parts of a picture (ImageOptions.HiddenAreas) so what was there can't be read back, each
2-
// pixelated, blurred or blacked out as its style says. Each part is changed on its own; the rest of the picture keeps its pixels. Also used on the window's preview, so what the
3-
// user sees while drawing the parts is what the file gets.
2+
// pixelated, blurred or blacked out as its style says. Each part is changed on its own; the rest of the picture keeps
3+
// its pixels. Also used on the window's preview, so what the user sees while drawing the parts is what the file gets.
44

55
using Filee.Core.Presets;
66
using ImageMagick;
@@ -135,21 +135,45 @@ public static void Pixelate(IMagickImage<byte> image, (int X, int Y, int Width,
135135
using var squares = new MagickImage();
136136
squares.ReadPixels(data, new PixelReadSettings((uint)regionWidth, (uint)regionHeight, StorageType.Char, mapping));
137137
image.Composite(squares, left, top, CompositeOperator.Copy);
138+
ClearExtraChannels(image, bounds);
138139
}
139140

140141
/// <summary>
141-
/// <paramref name="bounds"/> filled with opaque black: every pixel of the part is replaced, transparency included, so
142-
/// nothing of it is left to recover.
142+
/// <paramref name="bounds"/> filled with opaque black: every pixel of the part is replaced, transparency included,
143+
/// so nothing of it is left to recover.
143144
/// </summary>
144145
public static void Box(IMagickImage<byte> image, (int X, int Y, int Width, int Height) bounds)
145146
{
146147
var (x, y, w, h) = bounds;
147148
if (w <= 0 || h <= 0)
148149
return;
149150
using var black = new MagickImage(MagickColors.Black, (uint)w, (uint)h);
151+
// Black in the picture's own colours: copied in channel by channel, an sRGB black is C, M, Y and K all 0 in
152+
// a CMYK picture, which is white paper.
153+
black.ColorSpace = image.ColorSpace;
150154
if (image.HasAlpha)
151155
black.Alpha(AlphaOption.Opaque);
152156
image.Composite(black, x, y, CompositeOperator.Copy);
157+
ClearExtraChannels(image, bounds);
158+
}
159+
160+
/// <summary>
161+
/// The channels a picture can carry beside its colours and alpha (a TIFF's extra samples, read as meta channels)
162+
/// set to 0 inside <paramref name="bounds"/>. Pixelate and Box write colours and alpha only, and what was under a
163+
/// part must not stay behind in them. Blur works on a copy of the picture, so it covers them with the rest.
164+
/// </summary>
165+
private static void ClearExtraChannels(IMagickImage<byte> image, (int X, int Y, int Width, int Height) bounds)
166+
{
167+
var extra = image.Channels.Where(channel => channel >= PixelChannel.Meta0).ToList();
168+
if (extra.Count == 0)
169+
return;
170+
var (x, y, w, h) = bounds;
171+
// A copy of the part keeps every channel, so copying it back covers the extra ones too.
172+
using var part = image.CloneArea(new MagickGeometry(x, y, (uint)w, (uint)h));
173+
part.ResetPage();
174+
foreach (var channel in extra)
175+
part.Evaluate(Enum.Parse<Channels>(channel.ToString()), EvaluateOperator.Set, 0);
176+
image.Composite(part, x, y, CompositeOperator.Copy);
153177
}
154178

155179
/// <summary>

‎tests/Filee.Engines.Tests/HideAreasTests.cs‎

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
// "Pixelate / blur" through the real image engine: the parts become squares of their average colour, a blur that
2-
// leaves none of their detail or opaque black boxes, each part as its own style says, the rest of the picture keeps every pixel, the parts land on the picture as it is seen
3-
// (EXIF orientation), the format stays, and the previews a file carries of the uncovered picture go.
1+
// "Pixelate / blur" through the real image engine: the parts become squares of their average colour, a blur that leaves
2+
// none of their detail or opaque black boxes, each part as its own style says, the rest of the picture keeps every
3+
// pixel, the parts land on the picture as it is seen (EXIF orientation), the format stays, and the previews a file
4+
// carries of the uncovered picture go.
45

56
using Filee.Core.Conversion;
67
using Filee.Core.Presets;
@@ -110,13 +111,94 @@ public async Task A_black_box_is_opaque_black_and_each_part_takes_its_own_style(
110111
for (var y = 20; y < 120; y++)
111112
for (var x = 40; x < 160; x++)
112113
Assert.Equal((0, 0, 0, 255), (b[Index(x, y)], b[Index(x, y) + 1], b[Index(x, y) + 2], b[Index(x, y) + 3]));
113-
// Just outside the box, as it was; the other part pixelated (two neighbours in one square alike, unlike before).
114+
// Just outside the box, as it was; the other part pixelated (two neighbours in one square alike, unlike
115+
// before).
114116
Assert.Equal(a[Index(39, 50)], b[Index(39, 50)]);
115117
Assert.Equal(a[Index(160, 50)], b[Index(160, 50)]);
116118
Assert.Equal(b[Index(250, 50)], b[Index(251, 50)]);
117119
Assert.NotEqual((0, 0, 0), (b[Index(250, 50)], b[Index(250, 50) + 1], b[Index(250, 50) + 2]));
118120
}
119121

122+
[Theory]
123+
[InlineData("tif")]
124+
[InlineData("jpg")]
125+
public async Task A_black_box_on_a_cmyk_picture_is_full_black_ink_and_its_text_is_gone(string extension)
126+
{
127+
// Text in black ink only (K), as print files have it. A box made in sRGB and copied in channel by channel came
128+
// out with C, M, Y and K all 0: white paper.
129+
var folder = fx.NewFolder();
130+
var path = Path.Combine(folder, "print." + extension);
131+
using (var source = new MagickImage(MagickColors.White, 300, 100))
132+
{
133+
for (var row = 30; row <= 70; row += 20)
134+
{
135+
using var line = new MagickImage(MagickColors.Black, 180, 5);
136+
source.Composite(line, 60, row, CompositeOperator.Copy);
137+
}
138+
source.ColorSpace = ColorSpace.CMYK;
139+
source.Quality = 95;
140+
source.Write(path);
141+
}
142+
var box = Area(0.1, 0.1, 0.8, 0.8); // 30, 10, 240 × 80
143+
box.Style = HideStyle.Box;
144+
145+
var output = await RunAsync(path, Hide(HideStyle.Pixelate, 5, box));
146+
147+
using var after = new MagickImage(output);
148+
Assert.Equal(ColorSpace.CMYK, after.ColorSpace);
149+
using var pixels = after.GetPixels();
150+
// Inside, away from the box's edge (JPEG blends 8 px blocks across it): no C, M or Y and all K, on the lines of
151+
// text and between them. The TIFF is exact.
152+
var tolerance = extension == "jpg" ? 12 : 0;
153+
foreach (var (x, y) in new[] { (150, 32), (150, 42), (150, 52), (100, 72), (60, 20) })
154+
{
155+
var value = pixels.GetValue(x, y)!;
156+
Assert.True(value[0] <= tolerance && value[1] <= tolerance && value[2] <= tolerance && value[3] >= 255 - tolerance,
157+
$"({x}, {y}): {string.Join(",", value)}");
158+
}
159+
// Outside the box, still white paper.
160+
Assert.True(pixels.GetValue(10, 50)![3] <= tolerance);
161+
}
162+
163+
[Theory]
164+
[InlineData(HideStyle.Box)]
165+
[InlineData(HideStyle.Pixelate)]
166+
public async Task Extra_channels_of_a_tiff_keep_nothing_of_the_part(HideStyle style)
167+
{
168+
// A TIFF with two extra samples beside RGBA (read as meta channels), each its own noise. Pixelate and Box write
169+
// colours and alpha only: without clearing them, the part stayed in those channels pixel for pixel.
170+
var folder = fx.NewFolder();
171+
var path = Path.Combine(folder, "layers.tif");
172+
using (var channels = new MagickImageCollection())
173+
{
174+
for (var seed = 1; seed <= 6; seed++)
175+
{
176+
var channel = Noise(300, 200, seed);
177+
channel.ColorType = ColorType.Grayscale;
178+
channels.Add(channel);
179+
}
180+
using var combined = channels.Combine(ColorSpace.sRGB);
181+
combined.Write(path, MagickFormat.Tiff);
182+
}
183+
var area = Area(0.1, 0.1, 0.5, 0.5); // 30, 20, 150 × 100
184+
185+
var output = await RunAsync(path, Hide(style, 5, area));
186+
187+
using var before = new MagickImage(path);
188+
using var after = new MagickImage(output);
189+
Assert.Equal([PixelChannel.Meta0, PixelChannel.Meta1], after.Channels.Where(c => c >= PixelChannel.Meta0));
190+
using var a = before.GetPixels();
191+
using var b = after.GetPixels();
192+
int[] extra = [4, 5];
193+
for (var y = 20; y < 120; y++)
194+
for (var x = 30; x < 180; x++)
195+
foreach (var c in extra)
196+
Assert.Equal(0, b.GetValue(x, y)![c]);
197+
// Outside the part, every channel as it was.
198+
foreach (var (x, y) in new[] { (29, 50), (180, 50), (100, 19), (100, 120), (250, 150) })
199+
Assert.Equal(a.GetValue(x, y), b.GetValue(x, y));
200+
}
201+
120202
[Fact]
121203
public async Task Blur_leaves_none_of_the_parts_detail_and_keeps_everything_else()
122204
{

0 commit comments

Comments
 (0)