From 3e6016dcaf8839ed0e4a5c4aef827ce3109b361f Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 20 Sep 2026 10:43:12 +0200 Subject: [PATCH 1/2] fix(html): a selection no longer paints the pdf's hidden text layer A pdf view draws its glyphs in one layer and keeps the real Unicode in another, which is `color:transparent` so that it can be selected and copied without being seen. A browser paints selected text in the highlight's own foreground colour, and that overrides `transparent`: as soon as the reader selected anything, the hidden glyphs appeared on top of the drawn ones, in a substitute font and at their own advances. The invisible classes now state `color:transparent` for `::selection` too, so a selection paints its background and nothing else. This covers the dual layer's `.i`, the single layer's `.i`, `.ov` and `.sp`, and the per-font `.fnN` an embedded font's invisible run carries. Checked in chromium, and against the Android app's WebView on a pdf whose hidden layer was visible before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FJJdfqpnVCKBNHXjAVxSou --- CHANGELOG.md | 6 ++++++ src/odr/internal/html/pdf_file.cpp | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8788cbc33..f627a6fa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- **Fix**: selecting text in a pdf view showed the layer that carries it. The + browser paints selected text in the highlight's own colour, which overrode + the layer's `transparent`, so a second set of glyphs appeared over the ones + the page draws. The selection layer now keeps its colour through + `::selection`. + ## v7.1.0 - 2026-09-19 - The pdf view reports the pending annotation count on diff --git a/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index 2846b386a..b79428f4b 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -1817,6 +1817,10 @@ class HtmlServiceImpl final : public HtmlService { } // Transparent text for the selection layer line blocks. out.out() << ".i{color:transparent;font-family:sf,sans-serif}"; + // A selection must not paint the layer it hides: the UA gives selected + // text the highlight's own colour, which overrides `transparent` and + // draws these glyphs on top of the visual ones they stand for. + out.out() << ".i::selection,.i *::selection{color:transparent}"; // Selection-layer run span. `overflow:hidden` clips a wider system font; // `.t`'s inherited `pre` blocks wrapping while preserving a run's own // leading/trailing space, which is real PDF content. @@ -2287,6 +2291,10 @@ class HtmlServiceImpl final : public HtmlService { // height, while clipping nothing (the space is transparent). out.out() << ".sp{display:inline-block;" "color:transparent;vertical-align:baseline}"; + // A selection must not paint what these three hide: the UA gives selected + // text the highlight's own colour, which overrides `transparent`. + out.out() << ".i::selection,.ov::selection,.sp::selection" + "{color:transparent}"; // A hit in the overlay is clipped away with it, so the glyphs it belongs // to carry the highlight instead - the whole run of them, which is as // narrow as the overlay can say. @@ -2859,6 +2867,11 @@ class HtmlServiceImpl final : public HtmlService { } if (class_used[1]) { rule("fn", "color:transparent;"); + // A selection must not paint an invisible run of an embedded font + // either - see `.i`. The dual layer never selects one, and is unharmed. + font_styles += ".fn"; + font_styles += n; + font_styles += "::selection{color:transparent}"; } } From f89ebfe23bb8d36f396f5b78653b6598778db6ff Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 20 Sep 2026 11:21:06 +0200 Subject: [PATCH 2/2] fix(html): the selection guard reaches a run's span and a search mark `::selection` matches the element that paints the text, not its ancestors. In the single layer `.i` and `.fnN` sit on the line block, so a run that carries a `margin-left` span, and a `mark` inside `.ov` or `.sp`, kept the highlight's own colour. The rules now take the descendants too, as the dual layer's `.i *::selection` already did. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0149gFxhkvKTBQidz6brU8Vt --- CHANGELOG.md | 8 +++----- src/odr/internal/html/pdf_file.cpp | 23 +++++++++++------------ 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f627a6fa0..b1e909d60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,9 @@ The release run heads these entries with the version and opens a fresh ## Unreleased -- **Fix**: selecting text in a pdf view showed the layer that carries it. The - browser paints selected text in the highlight's own colour, which overrode - the layer's `transparent`, so a second set of glyphs appeared over the ones - the page draws. The selection layer now keeps its colour through - `::selection`. +- **Fix**: selecting text in a pdf view showed the invisible layer that carries + it, as a second set of glyphs over the drawn ones. That layer now stays + transparent under `::selection`. ## v7.1.0 - 2026-09-19 diff --git a/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index b79428f4b..ba3330e15 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -1817,9 +1817,8 @@ class HtmlServiceImpl final : public HtmlService { } // Transparent text for the selection layer line blocks. out.out() << ".i{color:transparent;font-family:sf,sans-serif}"; - // A selection must not paint the layer it hides: the UA gives selected - // text the highlight's own colour, which overrides `transparent` and - // draws these glyphs on top of the visual ones they stand for. + // A selection paints text in the highlight's own colour, which beats + // `transparent` and shows this layer over the glyph layer it stands for. out.out() << ".i::selection,.i *::selection{color:transparent}"; // Selection-layer run span. `overflow:hidden` clips a wider system font; // `.t`'s inherited `pre` blocks wrapping while preserving a run's own @@ -2291,10 +2290,11 @@ class HtmlServiceImpl final : public HtmlService { // height, while clipping nothing (the space is transparent). out.out() << ".sp{display:inline-block;" "color:transparent;vertical-align:baseline}"; - // A selection must not paint what these three hide: the UA gives selected - // text the highlight's own colour, which overrides `transparent`. - out.out() << ".i::selection,.ov::selection,.sp::selection" - "{color:transparent}"; + // A selection paints text in the highlight's own colour, which beats + // `transparent`. `*` reaches a run's own span and a search `mark`. + out.out() << ".i::selection,.i *::selection," + ".ov::selection,.ov *::selection," + ".sp::selection,.sp *::selection{color:transparent}"; // A hit in the overlay is clipped away with it, so the glyphs it belongs // to carry the highlight instead - the whole run of them, which is as // narrow as the overlay can say. @@ -2867,11 +2867,10 @@ class HtmlServiceImpl final : public HtmlService { } if (class_used[1]) { rule("fn", "color:transparent;"); - // A selection must not paint an invisible run of an embedded font - // either - see `.i`. The dual layer never selects one, and is unharmed. - font_styles += ".fn"; - font_styles += n; - font_styles += "::selection{color:transparent}"; + // The same guard for a line the embedded font makes invisible; the dual + // layer's `fnN` rides a `user-select:none` run and needs none. + font_styles += ".fn" + n + "::selection,.fn" + n + + " *::selection{color:transparent}"; } }