Skip to content

Set spreadsheet export image resolution to 96dpi (BL-16529) - #8197

Draft
StephenMcConnel wants to merge 6 commits into
masterfrom
BL-16529-SpreadsheetImages
Draft

Set spreadsheet export image resolution to 96dpi (BL-16529)#8197
StephenMcConnel wants to merge 6 commits into
masterfrom
BL-16529-SpreadsheetImages

Conversation

@StephenMcConnel

@StephenMcConnel StephenMcConnel commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Spreadsheet export embedded thumbnails whose size depended on the display of the machine doing the export. The root cause is not the pixel count but the thumbnail's DPI metadata.

ImageUtils.CreateScaledBitmap builds the thumbnail with a plain GDI+ new Bitmap(w, h), which inherits the screen's DPI, and Save(..., Jpeg) writes that into the JPEG header. Since Bloom became PerMonitorV2 DPI aware (Program.cs), that is 192 on a 200%-scaled display instead of 96. EPPlus derives a picture's physical extent from its pixel size divided by the image's own resolution, so every thumbnail was laid out at half its intended size — literally the reported "images only take up half the column width".

Measured from the three exports of the same book attached to BL-16529:

thumbnail px JFIF DPI drawing extent
6.4 @100% 150 x 104 96 1428750 EMU = 150 px OK
6.5 @100% 150 x 104 192 714375 EMU = 75 px WRONG
6.5 @200% 300 x 208 192 1428750 EMU = 150 px OK

Column width was byte-identical in all three, so the column was never involved. The earlier width fix (#8094) doubled the pixel count, which cancels a 192-dpi error only when the scale factor is exactly 200; in general it gave 150 px * scaleFactor / 200, which is why exports below 200% still came out too small.

That also explains why this reproduced for some people and not others: the two errors cancel exactly whenever the bitmap's DPI and GetScaleFactorForMonitor's answer agree (96x150, 120x187, 192x300 all yield a 150 px extent). Where the two independent DPI sources disagree — mixed-scaling multi-monitor setups, or a scale change without signing out — they don't cancel.

The change

  • SpreadsheetIO: stamp the thumbnail with 96dpi before saving, and drop the DPI compensation entirely — the width up-scaling, the row-height division, and the GetScalingFactorForPrimaryMonitor call. Export now queries no monitor and produces the same file on every machine, matching 6.4.

  • WindowsMonitorScaling: remove the now-unused GetScalingFactorForPrimaryMonitor, MonitorFromPoint and POINT. The Problem Report screenshot path is untouched.

  • Tests: new Export_Thumbnail_IsStamped96Dpi_SoLayoutDoesNotDependOnTheDisplay asserts the embedded thumbnail is 96dpi and that the picture's extent, read from the raw drawing XML, is the full 150 px. The existing row-height test is now two-sided (its previous doc comment described the DPI compensation that this removes).

  • Tests: SaveThumbnailForEmbedding is a separate method purely so a test can prove the stamping happens. SaveThumbnailForEmbedding_StampsStandard96Dpi_WhateverResolutionItWasGiven hands it a bitmap deliberately marked 192dpi -- what it would really receive on a 200%-scaled display -- so the assertion carries the same force on every machine, including CI at 100%.

Verified with teeth: stamping 192dpi deliberately makes the new test fail with an extent of exactly 75 px, reproducing the reported file; at 96dpi it is 150 px. This gives the >100% display path its first test coverage — the previous test would have passed with the DPI code deleted outright.

Note the assertions can only fail on a machine scaled above 100%, so CI (at 100%) will not catch a regression here; that limitation is documented in the test.

Ref: https://issues.bloomlibrary.org/youtrack/issue/BL-16529

Devin review


This change is Reviewable

This is simpler and more reliable than trying to compensate for whatever
resolution the exporting machine is using by adjusting the image size.
@StephenMcConnel

Copy link
Copy Markdown
Contributor Author

[Claude Opus 5] Consulted Devin on 2026-08-12 14:53 UTC-6 up to commit 77f3bb53e1a38de95ec03aaf809300d98e5d4101.

Devin found no bugs and no Investigate flags — nothing to mirror as review threads. It did raise 4 informational items, none of them action items:

  • It traced every return path of DrawResizedImage and confirmed the new (Bitmap) cast cannot throw for the arguments used here, and that the using disposes a fresh object rather than the caller's original image.
  • It confirmed the new lower bound on row height holds for any image height: the integer truncation in rowHeight * 72 / 96 + 3 loses at most a point, and the 3-point pad more than covers it (the row lands about 2.7 to 4 pixels taller than the image).
  • It noted the test image (bird.png) is RGBA and gets flattened to JPEG, since the exporter always saves thumbnails as JPEG. Pre-existing behavior, and it doesn't affect what this test asserts.
  • It noted that because CI runs at 100% scaling, where a GDI+ bitmap already carries 96dpi, the new assertions pass with or without the SetResolution call — so CI cannot catch a future removal of the stamping. That limitation is stated in the test's doc comment, and it is being raised with the developer.

CI (pr-automation) passed.

StephenMcConnel and others added 4 commits August 12, 2026 16:34
…6529)

The two export-level tests cannot protect this fix on our build machines: on
a display at 100% scaling a GDI+ bitmap already carries 96dpi, so they pass
whether or not the exporter stamps the resolution, and every build machine is
at 100%. Deleting the one-line stamp would therefore go unnoticed until it
reappeared as the original bug on a high-DPI machine -- which is what made
this issue take three rounds to pin down.

Extract the save step into SaveThumbnailForEmbedding(Bitmap, Stream) and test
it directly with a bitmap deliberately marked 192dpi, which is what it would
really receive on a 200%-scaled display. That assertion has the same force on
every machine. Verified by commenting out the SetResolution call: the new test
fails (192 vs 96) while both export-level tests still pass.

No behavior change -- the same operations in the same order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-16529)

SetResolution works in place, and Bitmap.Save offers no way to override the
resolution as it writes, so the method's real contract is "save, and also
change my argument". That is harmless for the freshly-created thumbnails the
exporter passes it, but it would surprise a future caller handing over a
shared or cached bitmap. Say so in the doc comment. (Devin informational.)

Comment only; no behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…529)

The row height converted thumbnail pixels to points against a literal 96,
the same number we stamp on the thumbnail, but written out independently.
Changing the stamped resolution would then have left every row sized for a
resolution the images no longer had -- a silent desynchronization of exactly
the kind that made this issue hard to pin down. Point both at
standardImageDpi and say so where it is declared. (Devin informational.)

The constant becomes int so the conversion stays integer arithmetic, bit for
bit as before: int * int / const int is integer division, and SetResolution
takes the int by implicit conversion to float. No behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@StephenMcConnel

Copy link
Copy Markdown
Contributor Author

[Claude Opus 5] Consulted Devin on 2026-08-12 (final preflight cycle) up to commit b489f6db790fab553297579b9339f3495cecbeb1.

No bugs and no Investigate flags — that is now three consecutive Devin runs clean (77f3bb5, a370c65, b489f6d), so nothing has been mirrored as a review thread. It raised 8 informational items across those runs. Two led to changes:

  • It pointed out that SaveThumbnailForEmbedding alters the bitmap it is handed, because SetResolution works in place and Bitmap.Save gives no way to override the resolution as it writes. Harmless for the thumbnails the exporter creates and immediately disposes, but a surprise for any future caller passing a shared or cached bitmap — now said so in the doc comment (a370c65).
  • It pointed out that the row height converted pixels to points against a literal 96, the same number we stamp on the thumbnail but written out independently, so changing one would have left rows sized for a resolution the images no longer had. Both now come from standardImageDpi (b489f6d). The constant is an int so the conversion stays integer arithmetic, bit for bit as before; Devin's follow-up run confirmed that ("unchanged behaviour from before the PR — only the literal 96 became a constant") and reproduced the arithmetic exactly.

The rest needed no action:

  • Two of them independently reproduce the local review's own reasoning: the (Bitmap) cast is safe on every path ResizeImageIfNecessary can return, and the row-height lower bound holds for any image height because the 3-point pad exceeds the 1-point truncation.
  • The embedded thumbnail is now always 150px wide rather than being enlarged on a high-DPI display, so someone zooming into the spreadsheet sees a slightly softer picture than 6.5 currently gives them. Devin flagged it to make sure the trade-off is conscious; it is, it matches 6.4, and it has been put to the developer.
  • The test image is an RGBA PNG flattened to JPEG (pre-existing exporter behavior, irrelevant to what the test asserts), and the export-level tests read EPPlus's object model and the raw drawing XML, which is deliberate — that is what a spreadsheet viewer reads.
  • One note about the row height still quoting the literal 96 is stale: it describes the code before b489f6d and is superseded by its own sibling note in the same run.

CI (pr-automation) passed. Full C# suite green locally: 3071 passed, 13 skipped, 0 failed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant