Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
382422a
Re-encode an AI PNG that bloats the JPEG it replaces (BL-16645)
StephenMcConnel Aug 10, 2026
9f5b0b1
Never re-encode a transparent PNG, and survive an undecodable one (BL…
StephenMcConnel Aug 10, 2026
414cade
Don't leave a stray ai-image*.jpg behind when the conversion is decli…
StephenMcConnel Aug 10, 2026
90ba50f
Say what the transparency guard really promises, and don't let the cl…
StephenMcConnel Aug 10, 2026
d1d02a4
Also check a few scattered pixels for transparency, not just the corn…
StephenMcConnel Aug 12, 2026
93e8958
Ask an indexed image's palette about transparency before assuming it …
StephenMcConnel Aug 12, 2026
f1a596f
Guard the declined-conversion cleanup too, not just the delete after …
StephenMcConnel Aug 12, 2026
9aed245
Reuse the existing sampler, and carry credits across the re-encode (B…
StephenMcConnel Aug 17, 2026
8cc32a6
Merge remote-tracking branch 'origin/master' into BL-16645-AIBlowsUpJ…
StephenMcConnel Aug 17, 2026
6aa6d02
Check every pixel's alpha through LockBits when the caller needs cert…
StephenMcConnel Aug 17, 2026
6daa07f
Merge remote-tracking branch 'origin/master' into BL-16645-AIBlowsUpJ…
StephenMcConnel Aug 17, 2026
b2271b9
Don't let needing a resize suppress the PNG-to-JPEG conversion (BL-16…
StephenMcConnel Aug 17, 2026
005903b
Tighten the comments on the PNG-to-JPEG re-encode (BL-16645)
StephenMcConnel Aug 17, 2026
58dcde7
Merge remote-tracking branch 'origin/master' into BL-16645-AIBlowsUpJ…
StephenMcConnel Aug 17, 2026
947871e
Rename TrimMetadataInImage to CopyCoreMetadata, and make it public (B…
StephenMcConnel Aug 18, 2026
92c1760
Merge remote-tracking branch 'origin/master' into BL-16645-AIBlowsUpJ…
StephenMcConnel Aug 18, 2026
dc9ee84
Soften the wording in two comments about reused images (BL-16645)
StephenMcConnel Aug 18, 2026
632e5b1
Let the display pipeline go back to sampling for transparency (BL-16645)
StephenMcConnel Aug 18, 2026
161c8c6
Merge remote-tracking branch 'origin/master' into BL-16645-AIBlowsUpJ…
StephenMcConnel Aug 18, 2026
6ca5bb9
Pin the publish path's stray-pixel tolerance, and close two review ga…
StephenMcConnel Aug 18, 2026
688cb7d
Re-attach an imported image's metadata, so an upload keeps its licenc…
StephenMcConnel Aug 18, 2026
64a16b4
Don't re-attach metadata we failed to read in the first place (BL-16645)
StephenMcConnel Aug 18, 2026
dae14c1
Don't write an empty metadata model over an imported image (BL-16645)
StephenMcConnel Aug 18, 2026
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
8 changes: 7 additions & 1 deletion build/check-csharp-ApplicationExit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
cd $(dirname $0)/..
echo Checking for calling Application.Exit rather than Program.Exit.
# 1) Get the list of C# files that have been staged for commit (added or modified)
# 2) Screen out the one files that is allowed to use Application.Exit
# 2) Screen out the files that are allowed to use Application.Exit
# 3) Screen out all the test code since we don't need to worry about zombie processes there
# 4) For any remaining files, look for possible uses of Application.Exit.
# 5) If anything is found (grep returns 0), then we stop everything and complain.
Expand All @@ -16,6 +16,12 @@ if [ -s $filesToCheck ]; then
case "$file" in
src/BloomExe/ProgramExit.cs) continue;;
src/BloomTests/*) continue;;
# WebView2PdfMaker is a separate little executable with its own static class
# Program and no Program.Exit to call instead, so this rule cannot apply to it.
# Its Application.Exit calls are long-standing and deliberate. Without this
# exemption the check fires on any commit that merely stages the file -- a merge
# of master, say -- for code the committer never touched.
src/WebView2PdfMaker/*) continue;;
esac
# Strip // line comments and /* ... */ single-line block comments before
# searching, so a mention of Application.Exit in a comment doesn't trip this check.
Expand Down
154 changes: 137 additions & 17 deletions src/BloomExe/ImageProcessing/ImageUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Windows.Forms;
using Bloom.Book;
Expand Down Expand Up @@ -137,8 +138,8 @@ public static bool ShouldMakeBackgroundTransparent(PalasoImage imageInfo)
if (!(imageInfo.Image is Bitmap bitmapImage))
return false;

// If the image already has partially transparent pixels, leave it alone.
if (HasAnyTransparentSampledPixel(bitmapImage))
// If the image already has any even partially transparent pixels, leave it alone.
if (HasTransparency(bitmapImage))
return false;

// Summarize the image as a handful of dominant colors and check whether they
Expand Down Expand Up @@ -297,6 +298,13 @@ private static bool IsLineArtBackground(Color c)
&& (max - min) <= LineArtBackgroundMaxChroma;
}

/// <summary>
/// Sample 100 pixels scattered across the image and return true iff any of them
/// are not fully opaque. This isn't foolproof, but it is much faster than looking
/// at the whole image and good enough for our purposes in practice. The transparent
/// images we care about are usually line art with a transparent background, so they
/// are more than 50% transparent and should be detected by this sampling.
/// </summary>
private static bool HasAnyTransparentSampledPixel(Bitmap bitmap)
{
int yDelta = Math.Max(bitmap.Height / 10, 2);
Expand Down Expand Up @@ -990,9 +998,19 @@ public static string AdjustImageForDisplay(
&& ShouldMakeBackgroundTransparent(imageInfo);
// Would a PNG→JPEG size-saving conversion be worth trying? Skip when transparencyOnly
// because we're here only to apply transparency, not to optimize format.
// Deliberately NOT gated on needsResize: an oversized photo needs both treatments,
// and the conversion attempt below the resize is the only one it can reach (the
// early-return version a few lines down requires !needsResize). Excluding resized
// images here publishes every large photo as a resized PNG instead of a JPEG,
// several times bigger than it needs to be, in every ebook and BloomPUB.
var tryJpegConversion =
Comment thread
StephenMcConnel marked this conversation as resolved.
!transparencyOnly
&& !shouldMakeTransparent
// One or two stray pixels of transparency in a photographic image can be ignored;
// the random sampling will usually miss them and allow the image to be converted
// to the smaller JPEG format. The random sampling is fine grained enough to almost
// always encounter any major patch of transparency. (This code has been used for
// some time without any user complaints.)
&& !HasTransparency(imageInfo.Image);
Comment thread
StephenMcConnel marked this conversation as resolved.

// When transparencyOnly, skip all processing for images that don't need transparency.
Expand Down Expand Up @@ -1856,8 +1874,37 @@ public static bool IsIndexedAndOpaque(Image image)
/// Check whether the image has any transparency.
/// If it becomes too difficult or expensive to determine, we punt and return false.
/// </summary>
public static bool HasTransparency(Image image)
{
/// <param name="samplePixels">
/// True (the default) to answer from a sample: fast, but it can miss a small transparent
/// patch. False to read every pixel, for a caller that cannot afford a wrong "opaque".
/// </param>
/// <remarks>
/// An indexed image is judged exactly either way, from its palette. For a non-indexed
/// bitmap the two modes differ in what a "false" is worth:
/// <para>
/// Sampling reads the top-left corner block and then up to a hundred more pixels on the
/// jittered grid <see cref="HasAnyTransparentSampledPixel"/> walks, so a "true" is certain
/// but a "false" means only "none of the pixels we looked at was transparent" — a picture
/// transparent in some small patch between the sampled points is reported opaque.
/// </para>
/// <para>
/// The exhaustive mode reads every pixel's alpha byte, so a "false" is definite. It costs
/// about 10ms on an image at Bloom's maximum size, which is nothing beside the
/// GraphicsMagick call its callers are deciding whether to make.
/// </para>
/// So a caller that does something irreversible on a "false" — the AI image editor deletes
/// the original once it is told a picture is opaque — should pass samplePixels: false.
/// </remarks>
public static bool HasTransparency(Image image, bool samplePixels = true)
{
// An indexed image keeps its transparency in its palette, not in an alpha channel, and
// it has to be asked about FIRST. Every indexed pixel format (Format8bppIndexed and
// friends, which is how GDI+ loads a PNG-8 or a GIF) leaves the Alpha and PAlpha flags
// clear, so the "no alpha channel" shortcut below would answer "opaque" for all of them
// and this palette check would never run at all. That was harmless while the answer
// only chose a display format, but the AI image editor now deletes the original when
// told a picture is opaque, so a palette-transparent picture would have been flattened
// for good (BL-16645).
if ((image.PixelFormat & PixelFormat.Indexed) == PixelFormat.Indexed)
Comment thread
StephenMcConnel marked this conversation as resolved.
{
foreach (var color in image.Palette.Entries)
Expand All @@ -1867,19 +1914,81 @@ public static bool HasTransparency(Image image)
}
return false;
}
// If there is no alpha channel, there cannot be any transparency.
if (
(image.PixelFormat & PixelFormat.Alpha) != PixelFormat.Alpha
&& (image.PixelFormat & PixelFormat.PAlpha) != PixelFormat.PAlpha
)
return false;

Comment thread
StephenMcConnel marked this conversation as resolved.
if (image is Bitmap bitmapImage)
{
// Yes, this is as expensive as it looks. But we take advantage of the fact that almost all
// transparent images which someone would use in Bloom would be transparent in the corner.
// Leave a little fudge for a non-transparent border.
int maxPixelsFromCorner = 15;
for (int y = 0; y < bitmapImage.Height && y < maxPixelsFromCorner; ++y)
for (int x = 0; x < bitmapImage.Width && x < maxPixelsFromCorner; ++x)
if (bitmapImage.GetPixel(x, y).A != 255)
return true;
if (samplePixels)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Opus 5 (1M context) during preflight] Devin, Investigate: Adding the scattered sample to HasTransparency changes publish-time JPEG conversion for all books, not just AI results. Leaving this open, and it is the most important thing on this PR — it bears directly on the decision already taken.

any PNG whose transparency sits away from the top-left corner (an interior knock-out, a transparent bottom edge) will now be reported transparent where it previously was not. […] (a) such images are no longer flattened into JPEGs for ebook/BloomPUB output — a genuine improvement; (b) they are now published as PNGs, so some books that used to ship JPEG-sized images will get larger. […] the only new coverage is the stray-single-pixel test, which pins the opposite direction.

Measured, all three points. I built a photograph with a 30×30 knock-out in the middle and ran it through AdjustImageForDisplay the way BloomPubMaker/EpubMaker call in (maxShortSide: 60, maxLongSide: 80):

corner-only check (master's default) misses it — the first 15×15 is entirely opaque
widened default (this branch) detects it
published result PNG, 60px wide — where master would have produced a JPEG

So both consequences are real, and the trade-off is exactly as described: the knock-out survives publication now (master flattened it), and the file ships larger. This is a global change — every book, not just AI-edited ones — from a branch whose title is about the AI image editor.

Why this matters more than it looks: it may undo the decision already made on this PR. The developer chose "revert just the publish call site to sampled" in 632e5b1a7, and the comment there reasons that "the random sampling will usually miss them and allow the image to be converted to the smaller JPEG format". That reasoning describes master's corner-only sampling. The sampled mode itself was widened underneath it in d1d02a4aa, so reverting the call did not restore the behaviour — it restored a call to a predicate that now answers differently. That is worth knowing before the choice is treated as settled.

And you are right about my test. AdjustImageForDisplay_PhotoPngWithOneStrayTransparentPixel_StillConvertsToJpeg deliberately hunts for a pixel the sampler misses — so by construction it can only ever pin the direction where conversion still happens. It cannot detect what you describe here, and I should not have implied the publish path was covered by it. What it pins is narrower than it reads: "a pixel the sampler misses does not block conversion."

This is the same root cause as the forced-background-removal thread: the default got wider, and all three consumers inherited it — AdjustImageForDisplay:1014, RuntimeImageProcessor:466, and BookThumbNailer:283. Both are on the preflight report as one decision, since one choice settles both. Option 3 there (narrow the widening so only the AI import path gets the scattered sample, and every other caller keeps master's default) is the one this measurement argues for, and it is not the one I would have picked before running it.

{
// Yes, this is as expensive as it looks. But we take advantage of the fact that almost all
// transparent images which someone would use in Bloom would be transparent in the corner.
// Leave a little fudge for a non-transparent border.
int maxPixelsFromCorner = 15;
for (int y = 0; y < bitmapImage.Height && y < maxPixelsFromCorner; ++y)
for (int x = 0; x < bitmapImage.Width && x < maxPixelsFromCorner; ++x)
if (bitmapImage.GetPixel(x, y).A != 255)
return true;

return false;
// The corner said nothing, but a picture can be transparent only in its interior —

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Opus 5 (1M context) during preflight] Devin, Investigate: Widening the default transparency check also weakens forced background removal. Leaving this open as a decision — it is a user-visible behaviour change on a path outside this ticket, and it is ours.

HasTransparency now adds the ~100-point scattered sample to its default (sampling) mode […] That default is what RuntimeImageProcessor.MakePngBackgroundTransparent uses […] an image that is opaque in its corners but has any sampled interior pixel below alpha 255 — exactly the "stray non-opaque pixel from AI/editing tools" case the new tests describe as common — will now silently come back with its background untouched, where it previously would have been processed.

Verified in the code, and against master. MakePngBackgroundTransparent opens with:

if (ImageUtils.HasTransparency(originalImage.Image))
    return originalImage.Image.Clone() as Image;   // background NOT made transparent

That is the default (sampling) mode. On origin/master that default is corner-only — a 15×15 block, nothing else. This branch widened it in d1d02a4aa by folding in HasAnyTransparentSampledPixel's ~100 jittered points. So the predicate MakePngBackgroundTransparent gates on says "has transparency" for a strictly larger set of images than before, and for those it now returns an unmodified clone.

The part that makes this worth a decision rather than a shrug: with ImageTransparencyMode.Force, the caller has explicitly asked for the white background to be removed, and it now silently isn't. No error, no log, just an image that comes back looking as it went in.

This is the third time this branch has widened a shared predicate and hit a consumer nobody was looking at — !needsResize stopped resized photos being converted at all, the exhaustive scan blocked conversion for a single stray pixel, and now the scattered sample suppresses background removal. That pattern is the reason I am not quietly picking a fix: each of the first two looked obviously right too.

Options, roughly in order of how much they change:

  1. Give the background-removal caller the old corner-only test. Add an explicit mode to HasTransparency (or a small corner-only helper) and use it at RuntimeImageProcessor.cs:466, restoring master's behaviour exactly where this ticket never intended to reach. Needs a test pinning it, since nothing currently covers that path.
  2. Accept the new behaviour on the grounds that an image with genuine interior transparency arguably shouldn't have a background forced onto it — but then the Force path should say so rather than silently no-op, which is its own change.
  3. Narrow the widening itself, so the scattered sample only applies where it was wanted (the AI import path) and every other caller keeps master's corner-only default. Cleanest conceptually, largest blast radius.

I have not measured how often real cover images and line art carry a sampled sub-255 pixel, which is what decides how much this actually bites — and after being wrong twice this week about what a library does with image metadata, I would rather say that than guess. It is on the preflight report as the one open decision.

// a subject knocked out of an otherwise opaque canvas, for instance. So spend the
// hundred-odd pixels this helper samples across the whole image before calling it
// opaque. Its jitter seed is hardcoded, which matters here: a caller that deletes
// the original on a "no" must get the same answer for the same picture every time,
// and an answer that varied between runs would be harder to trust, and to
// reproduce, than one that is merely a sample.
if (HasAnyTransparentSampledPixel(bitmapImage))
return true;
}
else
{
// The caller has asked for a definite answer rather than a sample, so look at
// every pixel's alpha byte. Reading them through LockBits, a row at a time,
// makes that cheap enough not to matter: measured at 10ms for a fully opaque
// image at MaxLength x MaxBreadth, which is both the largest we keep and the
// worst case, since an opaque image never lets the scan stop early. Set that
// against the hundreds of milliseconds of the GraphicsMagick call the callers
// are deciding whether to make. (Per-pixel GetPixel would be orders of
// magnitude slower, because each call locks and unlocks its own one-pixel
// region.)
// Locking as Format32bppArgb has GDI+ give us one known layout whatever the
// bitmap's own format is: four bytes per pixel, alpha last. Note that asking
// for a format the bitmap isn't already in makes GDI+ build a whole converted
// copy in unmanaged memory (~43MB at MaxLength x MaxBreadth), so the row buffer
// below keeps the *managed* cost to one row, not the total cost. That copy is
// transient and GetDominantColors above already does the same thing.
var data = bitmapImage.LockBits(
new Rectangle(0, 0, bitmapImage.Width, bitmapImage.Height),
ImageLockMode.ReadOnly,
PixelFormat.Format32bppArgb
);
try
{
var bytesPerRow = data.Width * 4;
var row = new byte[bytesPerRow];
for (int y = 0; y < data.Height; ++y)
{
// Row y begins at Scan0 + y * Stride. Stride is negative for a
// bottom-up bitmap and that arithmetic is still correct when it is;
// taking only bytesPerRow also skips any end-of-row padding.
Marshal.Copy(data.Scan0 + y * data.Stride, row, 0, bytesPerRow);
for (int alpha = 3; alpha < bytesPerRow; alpha += 4)
{
if (row[alpha] != 255)
return true;
}
}
}
finally
{
bitmapImage.UnlockBits(data);
}
return false;
}
}

// Too hard / expensive to determine transparency/opacity
Expand Down Expand Up @@ -2121,7 +2230,7 @@ Rectangle cropRectangle
LogGraphicsMagickFailure(result);
return result;
}
TrimMetadataInImage(sourcePath, destPath);
CopyCoreMetadata(sourcePath, destPath);
return result;
}

Expand Down Expand Up @@ -3023,7 +3132,18 @@ private static bool SignifiesCropping(string imgStyle)
return width > 0;
}

private static string TrimMetadataInImage(string srcPath, string destPath)
/// <summary>
/// Copy the handful of intellectual-property and collection fields Bloom cares about from
/// one image file onto another, discarding everything else the source carried. Used both
/// when trimming an image on its way into a publication and to carry an image's credits
/// across a re-encode that would otherwise lose them — GraphicsMagick does not preserve
/// them when it rewrites a PNG as a JPEG (BL-16645).
/// </summary>
/// <remarks>
/// Never throws: whatever goes wrong is logged, because losing the credits is bad but
/// failing the operation that was copying them is worse.
/// </remarks>
public static string CopyCoreMetadata(string srcPath, string destPath)
Comment thread
StephenMcConnel marked this conversation as resolved.
{
// Try to reduce the metadata to just what we want for intellectual property and
// collection information.
Expand Down Expand Up @@ -3066,7 +3186,7 @@ private static string TrimMetadataInImage(string srcPath, string destPath)
{
// This can happen in unit tests that have fake data.
Debug.WriteLine(
$"Exception caught while trying to trim metadata from {srcPath} to {destPath}.",
$"Exception caught while trying to copy metadata from {srcPath} to {destPath}.",
e
);
}
Expand Down Expand Up @@ -3098,7 +3218,7 @@ string imageDestFolder
// We need an output file even if it's an identical copy.
RobustFile.Copy(srcPath, trimmedFilePath, true);

TrimMetadataInImage(srcPath, trimmedFilePath);
CopyCoreMetadata(srcPath, trimmedFilePath);
// Capture this before we unencode, since it wants
// to be a value we can put in a src attribute (if it doesn't get changed)
// to lead to the file we may put at an unchanged file name.
Expand Down
Loading