+semver:major Fix unusable ImageCropper crop; RawFormat now MemoryBmp - #1530
imnasnainaec wants to merge 13 commits into
Conversation
Return the cropped bitmap as a stand-alone copy instead of round-tripping through a MemoryStream that had to outlive it; unsubscribe the disposed Application.Idle handler; dispose and null out prior image state before re-assignment; and fix a height-vs-width typo that skipped downscaling of tall images. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Palaso Tests 4 files ± 0 4 suites ±0 10m 9s ⏱️ -33s Results for commit d5c58a1. ± Comparison against base commit dafc9bd. This pull request removes 1 and adds 9 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
ShowToolboxWith_PreExisting_EnsureRawFormatUnchanged asserted that cropping preserves the original RawFormat, which no longer holds now that GetCroppedImage intentionally returns a stand-alone bitmap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This comment was marked as outdated.
This comment was marked as outdated.
…ea fix (BL-1275) Strengthen the double-dispose and reassignment tests to actually exercise and verify cleanup (temp file and cropping image disposal) instead of only asserting no exception is thrown, and add a regression test for the height/width downscaling typo. Add the missing CHANGELOG entry for the null-guard in CalculateSourceImageArea, and trim stale comments in GetCroppedImage describing the removed JPEG re-encoding approach. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Image setter's dispose-and-null block and the CalculateSourceImageArea null guard it required are removed here, leaving this branch to the GetCroppedImage change alone.
|
@andrew-polk What's your take on the breaking change of this pr? (Don't worry about doing code review... not worthwhile unless and until we decide it's a worthwhile change.) |
Master's #1536 hardened the ImageCropper.Image setter and Dispose against leaking/prematurely disposing image state; this branch removes _originalFormat and the JPEG re-encode from GetCroppedImage. Both land, with these conceptual resolutions where they overlap: - SetImage: master hoisted `var originalFormat = image.Image.RawFormat` above `Image = image` so a throwing setter couldn't leave _originalFormat describing the new image. The field no longer exists, so the guard is moot; SetImage is back to a plain `Image = image`. - ImageCropper.cs no longer needs `using System.IO`: this branch dropped the MemoryStream, master dropped the `catch (IOException)`. Neither alone orphaned it. - ImageCropperTests: kept all six tests. Dropped the comment in Image_NewImageFailsToLoad_LeavesPreviousImageStateIntact explaining that it assigns the property because SetImage would throw reading RawFormat first -- SetImage no longer reads RawFormat. The test still targets the setter, which is where the exception-safety contract lives. - CHANGELOG: both entries kept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@imnasnainaec Anyway, for Bloom, I don't think this affects us because 6.4 is the last version to use the Image Toolbox from libpalaso (we are replacing it with a React one in 6.5. And hopefully 6.4 will not be getting any more updates from libpalaso. This bug is certainly real and we even track it, but decided not to fix it. But you should probably make the judgement about the breaking change based on Flex, not Bloom. Happy to discuss further if helpful. |
GetCroppedImage copied the crop into a new Bitmap to detach it from the source. That copy was unnecessary: Bitmap.Clone(rect, pixelFormat) already copies the pixels out, and the result survives both the source bitmap's disposal and deletion of the temp file behind it. The #1275 crash came from the old JPEG branch returning an Image.FromStream bitmap backed by a MemoryStream it then disposed, and that branch is already gone. The copy did have a cost. Bitmap(Image) renders into a 32bpp bitmap, so a cropped 1-bit PNG came back as Format32bppArgb, undoing the bit-depth preservation PalasoImage.SaveImageSafely goes out of its way to get (BL-2841). Returning the clone directly keeps the source pixel format. Dropping the copy also corrects the RawFormat we documented: the crop is taken from the PNG temp file, so it reports Png, not MemoryBmp. Updated the CHANGELOG and the JPEG test to match, and gave the entry the BREAKING CHANGE: prefix AGENTS.md asks for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
d69a46b rewrote the file with Python's utf-8-sig encoding, which strips a BOM on read but always writes one. CHANGELOG.md had none, so the commit added one and made the file's first line show up in every diff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jasonleenaylor
left a comment
There was a problem hiding this comment.
From the FieldWorks side, we can take this. FLEx's only consumer of the toolbox is the Picture Properties dialog, which detects a crop by comparing image sizes and saves through PalasoImage.Save(path), so the encoder comes from the file extension and nothing reads RawFormat. FieldWorks is already on the 18.0.0 beta line, so the major bump costs us nothing extra. Today a FLEx user who crops a JPEG and re-enters the Crop tab gets the toolbox error dialog, and this fixes that.
One question inline about the comment and CHANGELOG wording.
This review was assisted by Claude Fable 5.1.
| // Clone already copies the pixels out, so the crop outlives originalImage and its | ||
| // temp file. Don't copy it into a new Bitmap to "detach" it: that widens a 1-bit | ||
| // PNG to 32bpp (BL-2841) and buys nothing. | ||
| return originalImage.Clone(selection, originalImage.PixelFormat); |
There was a problem hiding this comment.
It looks like Clone(selection, PixelFormat) only copies the pixels out when selection is smaller than the bitmap. When it is the full bounds, which is what unmoved grips produce (lines 432-435 snap to the edge), GDI+ returns a bitmap that still shares originalImage's file-backed data: RawFormat is Png, the temp file stays locked, and DisposeImageState swallows the IOException and leaks it. A real crop gives MemoryBmp, detached. I think the test GetCroppedImage_JpegImage_ReturnsUsableBitmap sees Png only because it never moves a grip.
If this is correct maybe update the comments, changelog wording and Test as follows:
- This comment: "Clone copies the pixels out for a partial selection, so a real crop outlives originalImage and its temp file. A full-bounds clone still shares the file-backed source (RawFormat Png, temp file locked until it is disposed). Don't copy into a new Bitmap to detach it: that widens a 1-bit PNG to 32bpp (BL-2841)."
- CHANGELOG: replace "Its
RawFormatis thereforePng(the format of the temp file the cropper crops from) rather thanJpeg" with "ItsRawFormatis no longerJpeg:MemoryBmpfor a crop,Pngwhen the selection is the whole image". - Test: move a grip before asserting, or assert
Is.Not.EqualTo(ImageFormat.Jpeg.Guid).
There was a problem hiding this comment.
Non-crop is now a proper no-op.
Comments and tests updated.
Bitmap.Clone copies the pixels out only for a partial selection. Asked for the whole bitmap - what unmoved grips give us - GDI+ hands back one that still shares the source's file-backed data, so the crop kept _savedOriginalImage locked for as long as the caller held it. The delete in DisposeImageState then failed and the temp file was leaked; in a Debug build it also tripped the Debug.Fail in TempFile.Dispose, which is why GetImage_ReCropPreviouslyCroppedJpeg_DoesNotThrow was failing. CI missed it because it tests Release, where Debug.Fail compiles out and the IOException is swallowed. Copy the rows by hand for that case rather than going through new Bitmap(source), which would widen a 1-bit PNG to 32bpp (BL-2841). GetCroppedImage now reports MemoryBmp whatever the source format and whatever the selection, so the doc comment and CHANGELOG say so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the hand-rolled CopyDetached from the previous commit. Leaving the Crop tab without having dragged a grip took the full cropping path anyway: decode the PNG temp file, clone the whole thing, assign the result back over the image we already had. That clone still shared the temp file, which is what leaked it, so the previous commit copied the pixels out row by row to get away from it. Not doing the work at all is better. GetImage now returns the PalasoImage untouched when every grip is still at its edge, so an uncropped image keeps its original format, bit depth and resolution instead of coming back round-tripped through a PNG, nothing reads _savedOriginalImage, and the leak is gone by construction. It also narrows the breaking change: only a real crop now reports MemoryBmp, and an uncropped JPEG stays Jpeg. GetCroppedImage keeps its old behaviour for direct callers, including the whole-image case that holds the temp file open; its doc comment says so, and that nothing in the toolbox asks for one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drop the parts that described the implementation these commits replaced rather than the code that is there, and fold the two GetCroppedImage JPEG tests into one with a case per selection, so the pairing they were commenting on is expressed by the test cases instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Warning
Breaking change.
GetCroppedImageno longer returns a JPEG-encoded bitmap for JPEG sources. A real crop now hasRawFormatMemoryBmp; a whole-image selection hasPng, since the crop is read back from the cropper's PNG temp file. Callers that readRawFormat, or that callImage.Save(path)on the result and rely on GDI+ picking the JPEG encoder implicitly, must now pass an explicitImageFormat— a.jpgpath would otherwise receive PNG bytes. Going throughPalasoImage.Save(path)is unaffected, since it picks the encoder from the file extension. ImageToolbox's own use goes throughPalasoImage, so nothing in this repo changes behaviour.GetImageis unaffected when nothing was cropped: it now returns the image untouched, so an uncropped JPEG stays a JPEG.(
.RawFormatis used in mono: https://github.com/search?q=org%3Asillsdev+.rawformat&type=code)GetCroppedImagere-encodes a JPEG crop through aMemoryStreamand returns whatImage.FromStreamproduces. That stream sits in ausing, so it is disposed before the bitmap is returned, and GDI+ decodes lazily — the crop refers to a stream that is already gone. The next thing to touch its pixels fails with "A generic error occurred in GDI+".That is #1275: choose an image from a file, Crop, Choose, Crop. Leaving the Crop tab calls
GetImage, which stores the stream-backed crop on thePalasoImage; returning re-enters theImagesetter, which callsvalue.Image.Save(...)on that crop and throws. Only JPEG sources take the re-encode path, matching the report's "happens for most but not all".The change
Return the cropped bitmap directly instead of the re-encoded one, so nothing holds a lazy reference to a stream. Preserving the JPEG
RawFormatis what required the re-encode in the first place, so giving it up is what makes the fix possible — hence the breaking change above.GetImagenow skips the crop entirely when no grip has moved, returning thePalasoImageuntouched. That is the common path — open the Crop tab, leave it — and it used to replace the image with a copy of itself round-tripped through the PNG temp file, costing the original's format and bit depth and leaving that temp file locked until the copy was disposed.With the re-encode gone
_originalFormathas no remaining use, so it goes too, along with theRequire.Thatself-check, theSIL.Codeimport, and the comments describing the old workaround.Removing
_originalFormatfixes a second bug on the way. It was assigned only inSetImage, so assigning theImageproperty directly left it null, andGetCroppedImagethrew aNullReferenceExceptionreading_originalFormat.Guid— swallowed by the catch and surfaced as "Sorry, there was a problem getting the image".ShowToolboxWith_PreExisting_EnsureRawFormatUnchangedis deleted: it is[Explicit("By hand only")]so it never ran in CI, and it asserted exactly the JPEGRawFormatthis change gives up.Tests
Five of the added cases fail against
master:GetCroppedImage_JpegImage_ReturnsUsableBitmap(true)and(false)— the crop is backed by a disposed stream.GetCroppedImage_ImageSetViaPropertyDirectly_ReturnsUsableBitmap— the null_originalFormatNRE.GetImage_NothingCropped_ReturnsImageUntouched— the image comes back re-encoded.GetImage_ReCropPreviouslyCroppedJpeg_DoesNotThrow— the Crash in ImageToolbox switching between Crop and Choose #1275 round trip.GetImage_ResultOutlivesCropper_SavedOriginalTempFileIsDeletedpins the temp file against being held open by the result, andGetCroppedImage_PngImage_ReturnsUsableBitmapandGetCroppedImage_OneBitPng_PreservesPixelFormatcover what the change must not disturb.--filter ImageToolboxis green on net8.0-windows and net48: 56 passed, 1 pre-existing skip.Fixes #1275
Devin review: https://app.devin.ai/review/sillsdev/libpalaso/pull/1530
This change is