Files
pdfcraft/crates/render/src/reading.rs
T
4541974ef9 Reading colours: replace document colours for reading comfort (#897) (#1270)
* test: give faux-redaction audit its own workdir (parallel-test flake)

* hayro: ColorReplacement type and luminance mapping (reading colours, #897)

* fmt: engine dates (upstream Greek localization miss)

* hayro: apply ColorReplacement to solid text and line-art paints (#897)

* render: ReadingColors view setting flows into RenderSettings (#897)

* engine: per-document reading colours state (#897)

* automation: view_reading_colors tool; page_render honours it (#897)

* ui: Reading colours preference (Replace Document Colors, #897)

* ui: seed reading colours on revision and signed-version opens (#897)

* parity: a11y.replace-page-colors shipped (#897)

* render: map stencil-mask paints through reading colours (#897)

An ImageMask stencil's solid paint was baked into the raster bytes raw,
bypassing the reading-colours hook every other solid fill goes through:
under white-on-black, CCITT scans, JBIG2 text and stencil logos drew
black ink on the black mapped background and vanished. Route it through
replaced(paint, false) — stencils are line art in disguise — and cover
it with an ImageMask fixture test sampling mapped ink on a mapped
background.

* render: print previews render with reading colours off (#897)

Print previews went through the document's render pool, whose config now
carries the reading colours: a white-on-black user saw black sheets in
the Print dialog while the actual print stayed uncoloured. The request
tag is the per-request channel that already crosses the render boundary,
so PRINT_TAG now lives in pdfcraft_render and the renderer honours it by
rendering previews with reading colours off — a preview's job is
fidelity to the printout. The main view stays recoloured. Covered on
both render paths (inline PageRenderer and pool worker).

* ui: seed reading colours on created-document opens (#897)

open_created (comment summaries) and open_created_bytes (images, text,
blank pages, clipboard) pushed new DocViews without seeding the reading
colours, unlike every other open path. A created document now starts
with the app-wide preference too; the seed-on-open test drives both
paths.

* docs: reading-colours flag echo and display-fidelity parity notes (#897)

The view_reading_colors description now says a flag that differs from
the mode's preset default makes the echoed mode read custom, and the
a11y.replace-page-colors parity notes record that print previews and
view snapshots render with the document's true colours: reading colours
apply to the reading view only.

* Merge follow-ups: reading colours beside preview renders and the new catalogs

- Renderer::new carries both color_replacement and the new preview flag
- our raster tests construct RenderRequest with preview: false
- the preference dialog grew past the window: scroll the combo into view
  and give the fixture a valid xref so no repair banner covers the dialog
- checkboxes use the crate's square-box widget (clippy disallows egui's)
- Kazakh and Turkish catalogs gain the ten reading-colours strings

* Reading colours: record the hayro patch in vendor/README.md; add missing translations

vendor/README.md gains hayro patch (13): `RenderSettings::color_replacement` /
`ColorReplacement`, the `render_pdf` signature change and the removed
`[dev-dependencies.log]`, with its tests. Adds the ten reading-colours labels
to el, es, pl, te and zh-hant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:49:31 -04:00

215 lines
9.8 KiB
Rust

//! The "reading colours" view setting (Acrobat's Replace Document Colors): solid text and
//! line-art paints are remapped for readability while rendering. Display only — the document
//! itself never changes. The setting flows into [`hayro::RenderSettings`] through
//! `RenderConfig`.
/// The reading-colours setting for one document. `params: None` means off.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ReadingColors {
/// The colours and flags, or `None` when the setting is off.
pub params: Option<ReadingParams>,
}
/// Colours and flags of the reading-colours mapping.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ReadingParams {
/// The sRGB colour that dark paints are mapped towards.
pub text: [u8; 3],
/// The sRGB colour that light paints are mapped towards.
pub background: [u8; 3],
/// When `true`, only near-black paints are mapped; lighter ones keep their colour.
pub only_black: bool,
/// When `true`, line-art paints are mapped in addition to text paints.
pub line_art: bool,
}
impl ReadingColors {
/// Black text on a white page.
pub const BLACK_ON_WHITE: Self =
Self { params: Some(ReadingParams { text: [0x00, 0x00, 0x00], background: [0xff, 0xff, 0xff], only_black: false, line_art: true }) };
/// White text on a black page.
pub const WHITE_ON_BLACK: Self =
Self { params: Some(ReadingParams { text: [0xff, 0xff, 0xff], background: [0x00, 0x00, 0x00], only_black: false, line_art: true }) };
/// Yellow text on a black page.
pub const YELLOW_ON_BLACK: Self =
Self { params: Some(ReadingParams { text: [0xff, 0xff, 0x00], background: [0x00, 0x00, 0x00], only_black: false, line_art: true }) };
/// Green text on a black page.
pub const GREEN_ON_BLACK: Self =
Self { params: Some(ReadingParams { text: [0x00, 0xff, 0x00], background: [0x00, 0x00, 0x00], only_black: false, line_art: true }) };
/// Parses tool/settings input. `mode` is one of `off | black-on-white | white-on-black |
/// yellow-on-black | green-on-black | custom`; `custom` needs `text` and `background` as
/// `#rrggbb`; the flags override the preset defaults when given. `off` ignores colours.
/// Errors are actionable strings.
pub fn parse(mode: &str, text: Option<&str>, background: Option<&str>, only_black: Option<bool>, line_art: Option<bool>) -> Result<Self, String> {
// Every mode accepts the flags: they override the preset defaults, and are the custom
// mode's own values.
let with_flags = |mut colors: Self| {
if let Some(p) = colors.params.as_mut() {
if let Some(v) = only_black {
p.only_black = v;
}
if let Some(v) = line_art {
p.line_art = v;
}
}
colors
};
match mode {
"off" => Ok(Self::default()),
"black-on-white" => Ok(with_flags(Self::BLACK_ON_WHITE)),
"white-on-black" => Ok(with_flags(Self::WHITE_ON_BLACK)),
"yellow-on-black" => Ok(with_flags(Self::YELLOW_ON_BLACK)),
"green-on-black" => Ok(with_flags(Self::GREEN_ON_BLACK)),
"custom" => match (text, background) {
(None, None) => Err("custom needs text and background as #rrggbb".into()),
(None, Some(_)) => Err("custom needs text as #rrggbb".into()),
(Some(_), None) => Err("custom needs background as #rrggbb".into()),
(Some(text), Some(background)) => Ok(with_flags(Self {
params: Some(ReadingParams {
text: parse_hex("text", text)?,
background: parse_hex("background", background)?,
only_black: false,
line_art: true,
}),
})),
},
other => {
Err(format!("unknown mode {other:?}; expected one of: off, black-on-white, white-on-black, yellow-on-black, green-on-black, custom"))
}
}
}
/// The mode name for echoes and display: `"off"`, a preset name, or `"custom"`.
pub fn mode_name(&self) -> &'static str {
match self.params {
None => "off",
Some(params) => {
if Some(params) == Self::BLACK_ON_WHITE.params {
"black-on-white"
} else if Some(params) == Self::WHITE_ON_BLACK.params {
"white-on-black"
} else if Some(params) == Self::YELLOW_ON_BLACK.params {
"yellow-on-black"
} else if Some(params) == Self::GREEN_ON_BLACK.params {
"green-on-black"
} else {
"custom"
}
}
}
}
/// The text colour as lowercase `#rrggbb`, or `None` when off.
pub fn text_hex(&self) -> Option<String> {
self.params.map(|p| hex(p.text))
}
/// The background colour as lowercase `#rrggbb`, or `None` when off.
pub fn background_hex(&self) -> Option<String> {
self.params.map(|p| hex(p.background))
}
}
impl From<ReadingParams> for hayro::ColorReplacement {
fn from(p: ReadingParams) -> Self {
hayro::ColorReplacement { text: p.text, background: p.background, only_black: p.only_black, line_art: p.line_art }
}
}
/// Parses a colour given as `#rrggbb` — exactly this shape, either case of digits. The error
/// names the argument and the expected shape.
fn parse_hex(name: &str, value: &str) -> Result<[u8; 3], String> {
let invalid = || format!("invalid {name} colour {value:?}; expected #rrggbb (six hex digits, e.g. #00ff00)");
let Some(digits) = value.strip_prefix('#') else { return Err(invalid()) };
if digits.len() != 6 || !digits.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(invalid());
}
let mut rgb = [0u8; 3];
for (i, channel) in rgb.iter_mut().enumerate() {
// Length and charset are checked above, so the parse cannot actually fail; the mapping
// only keeps it total without panicking.
*channel = u8::from_str_radix(&digits[i * 2..i * 2 + 2], 16).map_err(|_| invalid())?;
}
Ok(rgb)
}
/// Formats sRGB bytes as lowercase `#rrggbb`.
fn hex(rgb: [u8; 3]) -> String {
format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_round_trips_every_mode() {
let off = ReadingColors::parse("off", None, None, None, None).unwrap();
assert_eq!(off, ReadingColors::default());
assert_eq!(off.mode_name(), "off");
assert_eq!(off.text_hex(), None);
assert_eq!(off.background_hex(), None);
// `off` ignores colour and flag arguments.
assert_eq!(ReadingColors::parse("off", Some("zzz"), Some("#000000"), Some(true), Some(false)).unwrap(), off);
for (mode, text, background) in [
("black-on-white", "#000000", "#ffffff"),
("white-on-black", "#ffffff", "#000000"),
("yellow-on-black", "#ffff00", "#000000"),
("green-on-black", "#00ff00", "#000000"),
] {
let colors = ReadingColors::parse(mode, None, None, None, None).unwrap();
assert_eq!(colors.mode_name(), mode, "{mode}");
assert_eq!(colors.text_hex().as_deref(), Some(text), "{mode}");
assert_eq!(colors.background_hex().as_deref(), Some(background), "{mode}");
let params = colors.params.unwrap();
assert!(!params.only_black && params.line_art, "{mode}");
}
// The flags override the preset defaults; a changed preset is no longer one.
let overridden = ReadingColors::parse("white-on-black", None, None, Some(true), Some(false)).unwrap();
let params = overridden.params.unwrap();
assert!(params.only_black && !params.line_art);
assert_eq!(overridden.mode_name(), "custom");
// `custom` takes either case of hex and echoes lowercase.
let custom = ReadingColors::parse("custom", Some("#AaBbCc"), Some("#123456"), Some(true), None).unwrap();
assert_eq!(custom.mode_name(), "custom");
assert_eq!(custom.text_hex().as_deref(), Some("#aabbcc"));
assert_eq!(custom.background_hex().as_deref(), Some("#123456"));
let params = custom.params.unwrap();
assert!(params.only_black && params.line_art);
}
#[test]
fn parse_rejects_unknown_mode() {
assert_eq!(
ReadingColors::parse("nope", None, None, None, None),
Err("unknown mode \"nope\"; expected one of: off, black-on-white, white-on-black, yellow-on-black, green-on-black, custom".to_string())
);
}
#[test]
fn parse_rejects_malformed_hex() {
for bad in ["zzz", "000000", "#000", "#0000000", "#00g000", ""] {
assert_eq!(
ReadingColors::parse("custom", Some(bad), Some("#000000"), None, None),
Err(format!("invalid text colour {bad:?}; expected #rrggbb (six hex digits, e.g. #00ff00)")),
"{bad}"
);
}
assert_eq!(
ReadingColors::parse("custom", Some("#000000"), Some("ffffff"), None, None),
Err("invalid background colour \"ffffff\"; expected #rrggbb (six hex digits, e.g. #00ff00)".to_string())
);
}
#[test]
fn parse_rejects_custom_missing_colours() {
assert_eq!(ReadingColors::parse("custom", None, None, None, None), Err("custom needs text and background as #rrggbb".to_string()));
assert_eq!(ReadingColors::parse("custom", None, Some("#000000"), None, None), Err("custom needs text as #rrggbb".to_string()));
assert_eq!(ReadingColors::parse("custom", Some("#000000"), None, None, None), Err("custom needs background as #rrggbb".to_string()));
}
}