Dark reader themes make colored text and colored blocks unreadable
--Claude Code generated text below--Text and outcome checked by a human
Zotero 10.0.1 (build 20260824144704), macOS 15, Apple Silicon.
With any dark reader theme, PDF elements drawn in saturated colors aren't re-mapped for the dark background — they're all forced to a single fixed lightness. A colored background block and the colored text printed on it converge to nearly the same color, and the text disappears.
Neutral black/white/gray content is handled correctly, so body text looks fine. What breaks is colored elements: chapter-opener banners, running side tabs, section headings, table shading.
## Cause
All in `src/display/blender/blender.js` (shipped as `resource/reader/pdf/build/pdf.mjs`).
1) `calcStyle` (L10717) sends anything with `chroma > 10` to `adjustColorForVisibility`
instead of the gradient mapping that neutrals get — that branch never inverts.
2) `adjustColorForVisibility` (L11009) then discards the source lightness:
```js
const [origL, origA, origB] = fg.lab; // origL is never used again
const targetL = bg.lightness < 50 ? 50 + (100 - bg.lightness) * 0.3
: 25 + bg.lightness * 0.3;
```
`targetL` depends only on the passed-in color, which is constant for the page, so every
saturated element gets the same lightness. `calcStyle` passes `this.foreground` as `background`
— `#FFFFFF` under Black — giving `targetL = 55` for everything. The banner fill, the pale title on it, and a dark heading elsewhere all land at L≈55.
3) `getTextStyle` (L10727) would catch exactly this: it samples the color behind the text and substitutes the theme background/foreground when contrast is too low. But its only caller, `updateTextStyle` (L10693), opens with `if (!this.hasBackgrounds) return;`, and `hasBackgrounds` has one assignment in the entire file — the first line of `customDrawImage` (L10814). So text contrast correction only arms after a raster image has been drawn. With a vector block it never runs.
## Fix
Patching `adjustColorForVisibility` to map the source lightness inverted into the theme's own background→foreground range — the same treatment neutrals already get from `gradient()` — resolves it. Verified locally against Zotero 10.0.1:
```diff
- const targetL = bg.lightness < 50 ? 50 + (100 - bg.lightness) * 0.3 : 25 + bg.lightness * 0.3;
- const targetChroma = Math.max(origChroma * 1.2, 20);
+ const _bgL = this.background.lightness, _fgL = this.foreground.lightness;
+ const targetL = Math.max(0, Math.min(100, _bgL + (_fgL - _bgL) * (1 - origL / 100)));
+ const targetChroma = origChroma;
```
Under Black (`bg` L=0, `fg` L=100) this reduces to `targetL = 100 - origL`. Colored blocks, headings and table shading now invert. The relative lightness between a block and the text on it is preserved, so pages read the way they do in print.
Dropping the chroma floor is a second, smaller fix: `Math.max(origChroma * 1.2, 20)` pushes every colored element to a minimum chroma of 20, which is what turns muted heading colors vivid. Keeping `origChroma` leaves them as authored.
Two related issues remain, which the above doesn't address:
- `getTextStyle` (L10727) is still unreachable on pages whose colored blocks are vector-drawn,
because `hasBackgrounds` is only assigned in `customDrawImage`. Arming it would guarantee
contrast where a document's own design is low-contrast by lightness, rather than relying on
the source relationship being good. (Note that `getCanvasColor` reads the full canvas per
text run, so this likely needs a cheaper sampling path first.)
- `calcStyle` passing `this.foreground` into the `background` parameter of
`adjustColorForVisibility` looks unintended regardless.
Light themes (Sepia, Snow) are unaffected — `this.dark` is false and `calcStyle` returns
saturated colors unchanged.
Zotero 10.0.1 (build 20260824144704), macOS 15, Apple Silicon.
With any dark reader theme, PDF elements drawn in saturated colors aren't re-mapped for the dark background — they're all forced to a single fixed lightness. A colored background block and the colored text printed on it converge to nearly the same color, and the text disappears.
Neutral black/white/gray content is handled correctly, so body text looks fine. What breaks is colored elements: chapter-opener banners, running side tabs, section headings, table shading.
## Cause
All in `src/display/blender/blender.js` (shipped as `resource/reader/pdf/build/pdf.mjs`).
1) `calcStyle` (L10717) sends anything with `chroma > 10` to `adjustColorForVisibility`
instead of the gradient mapping that neutrals get — that branch never inverts.
2) `adjustColorForVisibility` (L11009) then discards the source lightness:
```js
const [origL, origA, origB] = fg.lab; // origL is never used again
const targetL = bg.lightness < 50 ? 50 + (100 - bg.lightness) * 0.3
: 25 + bg.lightness * 0.3;
```
`targetL` depends only on the passed-in color, which is constant for the page, so every
saturated element gets the same lightness. `calcStyle` passes `this.foreground` as `background`
— `#FFFFFF` under Black — giving `targetL = 55` for everything. The banner fill, the pale title on it, and a dark heading elsewhere all land at L≈55.
3) `getTextStyle` (L10727) would catch exactly this: it samples the color behind the text and substitutes the theme background/foreground when contrast is too low. But its only caller, `updateTextStyle` (L10693), opens with `if (!this.hasBackgrounds) return;`, and `hasBackgrounds` has one assignment in the entire file — the first line of `customDrawImage` (L10814). So text contrast correction only arms after a raster image has been drawn. With a vector block it never runs.
## Fix
Patching `adjustColorForVisibility` to map the source lightness inverted into the theme's own background→foreground range — the same treatment neutrals already get from `gradient()` — resolves it. Verified locally against Zotero 10.0.1:
```diff
- const targetL = bg.lightness < 50 ? 50 + (100 - bg.lightness) * 0.3 : 25 + bg.lightness * 0.3;
- const targetChroma = Math.max(origChroma * 1.2, 20);
+ const _bgL = this.background.lightness, _fgL = this.foreground.lightness;
+ const targetL = Math.max(0, Math.min(100, _bgL + (_fgL - _bgL) * (1 - origL / 100)));
+ const targetChroma = origChroma;
```
Under Black (`bg` L=0, `fg` L=100) this reduces to `targetL = 100 - origL`. Colored blocks, headings and table shading now invert. The relative lightness between a block and the text on it is preserved, so pages read the way they do in print.
Dropping the chroma floor is a second, smaller fix: `Math.max(origChroma * 1.2, 20)` pushes every colored element to a minimum chroma of 20, which is what turns muted heading colors vivid. Keeping `origChroma` leaves them as authored.
Two related issues remain, which the above doesn't address:
- `getTextStyle` (L10727) is still unreachable on pages whose colored blocks are vector-drawn,
because `hasBackgrounds` is only assigned in `customDrawImage`. Arming it would guarantee
contrast where a document's own design is low-contrast by lightness, rather than relying on
the source relationship being good. (Note that `getCanvasColor` reads the full canvas per
text run, so this likely needs a cheaper sampling path first.)
- `calcStyle` passing `this.foreground` into the `background` parameter of
`adjustColorForVisibility` looks unintended regardless.
Light themes (Sepia, Snow) are unaffected — `this.dark` is false and `calcStyle` returns
saturated colors unchanged.
Upgrade Storage