mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 10:39:01 -06:00
* 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>
215 lines
9.8 KiB
Rust
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()));
|
|
}
|
|
}
|