From 4541974ef90f9be7399b011484f7c7f580032e21 Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Mon, 12 Oct 2026 03:49:31 -0300 Subject: [PATCH] Reading colours: replace document colours for reading comfort (#897) (#1270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.toml | 40 +-- Cargo.lock | 1 + crates/automation/src/lib.rs | 5 +- crates/automation/src/reading.rs | 36 +++ crates/automation/src/tools.rs | 13 + crates/automation/tests/automation.rs | 122 ++++++++ crates/engine/src/lib.rs | 20 +- crates/engine/src/tests.rs | 15 + crates/render/Cargo.toml | 2 + crates/render/src/lib.rs | 6 +- crates/render/src/raster.rs | 173 ++++++++++- crates/render/src/reading.rs | 214 +++++++++++++ crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/create_ui.rs | 2 + crates/ui-egui/src/files.rs | 2 + crates/ui-egui/src/i18n/ar.tsv | 10 + crates/ui-egui/src/i18n/bg.tsv | 10 + crates/ui-egui/src/i18n/cs.tsv | 10 + crates/ui-egui/src/i18n/de.tsv | 10 + crates/ui-egui/src/i18n/el.tsv | 10 + crates/ui-egui/src/i18n/es.tsv | 10 + crates/ui-egui/src/i18n/fr.tsv | 10 + crates/ui-egui/src/i18n/hu.tsv | 10 + crates/ui-egui/src/i18n/it.tsv | 10 + crates/ui-egui/src/i18n/ja.tsv | 10 + crates/ui-egui/src/i18n/kk.tsv | 10 + crates/ui-egui/src/i18n/pl.tsv | 10 + crates/ui-egui/src/i18n/pt-br.tsv | 10 + crates/ui-egui/src/i18n/ru.tsv | 10 + crates/ui-egui/src/i18n/sl.tsv | 10 + crates/ui-egui/src/i18n/te.tsv | 10 + crates/ui-egui/src/i18n/tr.tsv | 10 + crates/ui-egui/src/i18n/uk.tsv | 10 + crates/ui-egui/src/i18n/zh-hans.tsv | 10 + crates/ui-egui/src/i18n/zh-hant.tsv | 10 + crates/ui-egui/src/js_ui.rs | 72 +++++ crates/ui-egui/src/lib.rs | 26 ++ crates/ui-egui/src/sign_ui.rs | 4 + crates/ui-egui/tests/reading_colors.rs | 157 ++++++++++ parity/acrobat-features.toml | 8 +- vendor/README.md | 4 +- vendor/hayro/Cargo.toml | 6 +- vendor/hayro/src/lib.rs | 402 ++++++++++++++++++++++++- vendor/hayro/src/renderer.rs | 67 ++++- 44 files changed, 1553 insertions(+), 50 deletions(-) create mode 100644 crates/automation/src/reading.rs create mode 100644 crates/render/src/reading.rs create mode 100644 crates/ui-egui/tests/reading_colors.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index e5ed7ff4..e22e50af 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "77b7fedb0a4f097893a4c6901ca7e35a5b86b04d5582a26ee282dde772df90cd" +sha256 = "f0498e7e9b71f054fa2115a1d02e5cee45f070747c37fc844b299c3536711fbd" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "931bd6e497c0065624cad3e1bcb94ed9bfeeb97402a58e887e7e3fa0be4da298" +sha256 = "8061baf67a35734f87bd9d91aa9c22a6121cc450b69dd9a4862d56bc61f2b4b8" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "707d1ffa78f0dee179bc7531fec231aba496eddbb6c82c814887913161ea0215" +sha256 = "49f52fd09814bc9cca6f3b659bbe26a7091f7407a569cb138b5721bc8e8d5536" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fb9f1350f73b270a361ce087ea5f9fc899132cb1804dae92cbac5df2da4b1d18" +sha256 = "16723806a7d1045edbe45c06250fe4ef03ec94ea5a2829e7ec6c593a42d4db9a" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e182df8e86127ee2e93a7aa274139964f4bc0e47aa3e5e5af1b56bd5a352cb30" +sha256 = "b69ecd1590a9573b25b191f6adc0450bfc45b0866a9be83aaba2d0b079c449d7" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "462da2f08ec2687db4745ab6226220e4857ac785228a8593574a0ed1b4de94f0" +sha256 = "2806f83b280606d48a9aeaa60ae6fc3ca9e4308ae427222a4413cf88c9799b38" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7a41df6534ff5ec76b291c6c21cadb88cd3c384d02a00f42cd1eb0ffbb6901a4" +sha256 = "3d6817e945690fd1a156b0d7484a762c9c1b3286de6373be58c07e0451b4dd30" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "536f1b899a8075171e9d61b0bd0eec11af8b705ebf29c8f578bbee75562412d3" +sha256 = "252cdb6adc0ab7780e56f21a8edd7d2d34a8a4918c7285a0ea3f96bd3bbb0760" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c015f67b1d8a16069b676df39c0e3bfa397e1b94dd1fa1f4717b2fd53fa0335c" +sha256 = "68eae372d31b38e6cf93a1060838cf9f7092129508e9bae0a34b1053da16916c" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6285280ada0ed9f01298d22ac30c6efff7f12038b32c7b30476b9548a846f999" +sha256 = "ba2a40614bd092949ce1bacf484d18814eb13847f787b06dc2e4b1c036cef3c8" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a9dff963485baf31241bc042b18e81a71e3670a27e8532d2ec21268ac6b55b28" +sha256 = "cf19679450f1f38c38ca41cfc0c46bbb10424697fe50eeec012a36f55b87dad8" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c0a8eea56e175d053517818c44ba937703ac83343c327eb19c580d12dee51a22" +sha256 = "0c62ce0d40d9aaff929e32188ad6506dd3e861f99f560eb7c2fb60a5cd333572" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6cb45c9d8193d4c841d93a11c82d52a16c13c87f49d5443360b4064139497b41" +sha256 = "9b7ead6a6fe54b67efb765ed9dea28f24f194b3c406ec94198eb9a1ed4838f31" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9eb29f5aae67683db578174e5d5529cbd8e5dd74703f33be12172edcbafdcbbd" +sha256 = "ffab5ead4c717d4dbfa6021d1a36f651957586948c7f69f7cedc5133883fce37" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7d20cac93b5f173323fd7d4bfc2a499ea353635ccf6ed0b330798cbed0b21a6e" +sha256 = "6708a64bf049de7ee454966abdf564227d69e23786e9928577a97f7a1cc7e08f" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "771074e8ecd3a1bf47e62008150137f025160e3820885ffbe9e7d53b803d5fd6" +sha256 = "7ccf9f318b9d3d394247975bbea19cf124edefb17910819af153a9a2a654127f" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fda28db1e8c8b805c083f5b046fef4f89feade599d0c4ce93ba8029d68357348" +sha256 = "a861ee9b2e4c04a36ec83df3087f0a8bcdad30b07b2385f346533177f8003fe1" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fc002dfa644d736875bb17f7f40f121c3b59b6156bcb233a91c3b042444af16f" +sha256 = "7f9cb9cc57ff67d57a3b12a656673b746fa3830b57f070d36085c3faaef0ec28" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "88cc3b28d57a0d3d8cd5e59e9f32bfae183a383a24505ec699b911b2d02ef651" +sha256 = "4c63bcca851aba720662af0a1b76b1ba0140908e3ef5e9c3016d8b0642d9e689" [[asset]] path = "assets/icons/accessibility.svg" @@ -3328,4 +3328,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f4652ba2d75a207c28f8600b788740d777d64a96e65a48b1d71a379de033903e" +sha256 = "13071abba5e8ec37f2f93dbe1c8bd47eecac6a0d4956407535c91ba655cad936" diff --git a/Cargo.lock b/Cargo.lock index 07a3e349..112768b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3907,6 +3907,7 @@ dependencies = [ "lopdf", "pdfcraft-cos", "pdfcraft-fonts", + "serde", "skrifa 0.44.0", "thiserror 2.0.21", ] diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index b3f314a7..fd32fd48 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -21,6 +21,7 @@ mod links; pub mod mcp; mod measure; mod printing; +mod reading; mod redact; mod signing; mod tools; @@ -783,6 +784,7 @@ impl Automation { "comment_mark" => self.comment_mark(&a)?, "comment_lock" => self.comment_lock(&a)?, "comments_hide" => self.comments_hide(&a)?, + "view_reading_colors" => self.view_reading_colors(&a)?, "comments_summarize" => self.comments_summarize(&a)?, "comment_edit" => self.comment_edit(&a)?, "comment_delete" => self.comment_delete(&a)?, @@ -1648,8 +1650,9 @@ impl Automation { fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> { let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; + let colors = self.session.reading_colors(id).unwrap_or_default(); let fresh = || { - let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; + let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), reading_colors: colors, ..Default::default() }; (doc.display.clone(), PageRenderer::new(doc.display.clone(), config)) }; let entry = self.renderers.entry(id).or_insert_with(fresh); diff --git a/crates/automation/src/reading.rs b/crates/automation/src/reading.rs new file mode 100644 index 00000000..74c29691 --- /dev/null +++ b/crates/automation/src/reading.rs @@ -0,0 +1,36 @@ +//! Reading colours: Acrobat's Replace Document Colors as a tool (#897, AGENTS.md §3). +//! +//! A per-document view setting: solid text and line-art paints remap for readability and the +//! page clears to the setting's background when rendering. The file itself never changes. + +use pdfcraft_render::ReadingColors; +use serde_json::{Value, json}; + +use crate::{Args, Automation, Result, ToolError}; + +/// The modes `ReadingColors::parse` accepts, for error messages. +const MODES: &str = "off, black-on-white, white-on-black, yellow-on-black, green-on-black, custom"; + +impl Automation { + /// Set the document's reading colours. Colours are only meaningful with `mode: "custom"`; + /// `parse` enforces the rest and its errors are actionable. + pub(crate) fn view_reading_colors(&mut self, a: &Args) -> Result { + let id = self.doc(a)?.id; + let Some(mode) = a.opt_str("mode")? else { + return Err(ToolError::InvalidArgs(format!("mode is required (one of: {MODES})"))); + }; + let colors = ReadingColors::parse(mode, a.opt_str("text")?, a.opt_str("background")?, a.opt_bool("only_black")?, a.opt_bool("line_art")?) + .map_err(ToolError::InvalidArgs)?; + if self.session.set_reading_colors(id, colors) { + // Cached renderers baked the old setting into their config; page_render must re-read it. + self.renderers.remove(&id); + } + Ok(json!({ + "mode": colors.mode_name(), + "text": colors.text_hex(), + "background": colors.background_hex(), + "only_black": colors.params.is_some_and(|p| p.only_black), + "line_art": colors.params.is_some_and(|p| p.line_art), + })) + } +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 3af796f1..56d5aad5 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -719,6 +719,19 @@ pub fn tools() -> Vec { t("comments_hide", "Hide all comments", "Hide or show every comment on the page (fields and links still draw). A view setting: the file doesn't change.") .cmd("comment.hide_all") .with(schema(json!({ "doc": doc(), "hidden": { "type": "boolean", "description": "Default true." } }), &["doc"])), + t("view_reading_colors", "Set reading colours", "Set the document's reading colours (Acrobat's Replace Document Colors, in the Reading Options): solid text and line-art paints are remapped for readability and the page clears to the background colour in page_render. A view setting: the file doesn't change. mode is one of: off, black-on-white, white-on-black, yellow-on-black, green-on-black, or custom (which takes text and background as #RRGGBB). only_black maps only near-black paints (default false) and line_art also maps line-art paints (default true); both override the mode's preset. A flag that differs from the mode's preset default makes the returned mode read custom, because the resulting params no longer match any preset. Returns the resulting setting.") + .cmd("a11y.reading_options") + .with(schema( + json!({ + "doc": doc(), + "mode": { "type": "string", "enum": ["off", "black-on-white", "white-on-black", "yellow-on-black", "green-on-black", "custom"] }, + "text": { "type": "string", "description": "With mode custom: the text colour, #RRGGBB." }, + "background": { "type": "string", "description": "With mode custom: the background colour, #RRGGBB." }, + "only_black": { "type": "boolean", "description": "Map only near-black paints. Default false." }, + "line_art": { "type": "boolean", "description": "Also map line-art paints. Default true." }, + }), + &["doc", "mode"], + )), t("comments_summarize", "Summarize comments", "Make a PDF summarising every comment (number, author, type, date, text, replies), sorted by page, author, date or type.") .cmd("comment.summarize") .with(schema( diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 71569303..a9e4a32d 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -5115,6 +5115,128 @@ fn architectural_and_iso_papers_through_tools() { assert!(a.call("doc_print", &json!({"doc": doc, "paper": "ARCH D", "path": "../escape.pdf"})).is_err()); } +/// A 2-page 200×300 pt PDF in the `fixture()` style for the reading-colours test: bold black +/// "Page N" text on a white page; page 2 also draws a 4×4 uncompressed `/DeviceRGB` image, +/// scaled to 40×40 pt at 120, 200. +fn reading_colors_fixture() -> Vec { + let body1 = "BT /F1 48 Tf 20 150 Td (Page 1) Tj ET"; + let body2 = "BT /F1 48 Tf 20 150 Td (Page 2) Tj ET\nq 40 0 0 40 120 200 cm /Im1 Do Q"; + // 16 opaque pure-red pixels; images are never remapped by the reading-colours setting. + let image: Vec = (0..16).flat_map(|_| [0xff_u8, 0x00, 0x00]).collect(); + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> >> >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> /XObject << /Im1 8 0 R >> >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{body1}\nendstream", body1.len()).into_bytes(), + format!("<< /Length {} >>\nstream\n{body2}\nendstream", body2.len()).into_bytes(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>".to_vec(), + ]; + let mut image_obj = format!( + "<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {} >>\nstream\n", + image.len() + ) + .into_bytes(); + image_obj.extend_from_slice(&image); + image_obj.extend_from_slice(b"\nendstream"); + objs.push(image_obj); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// A fresh directory whose `a.pdf` is `reading_colors_fixture()`. +fn reading_colors_workdir() -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-automation-reading-colors-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a.pdf"), reading_colors_fixture()).unwrap(); + dir +} + +/// Decodes a `page_render` PNG (pages here are 200×300 pt) into RGBA pixels. +fn png_pixels(content: &Content) -> Vec { + let Content::Png { data, width, height } = content else { panic!("expected a PNG") }; + assert_eq!((*width, *height), (200, 300)); + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut pixels).unwrap(); + assert_eq!((info.width, info.height), (200, 300)); + pixels +} + +/// The RGBA pixel at (`x`, `y`), top-left origin, of a 200-wide image. +fn pixel(pixels: &[u8], x: u32, y: u32) -> [u8; 4] { + let at = ((y * 200 + x) * 4) as usize; + pixels[at..at + 4].try_into().unwrap() +} + +/// The `view_reading_colors` tool changes what `page_render` draws — text and background remap, +/// images never do — without touching the file, and its errors name what's wrong (#897). +#[test] +fn reading_colors_through_tools() { + let dir = reading_colors_workdir(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let render = |a: &mut Automation, page: u64| png_pixels(&a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap()[0]); + + // Off: black text on a white page. + let page1_off = render(&mut a, 1); + let page2_off = render(&mut a, 2); + assert_eq!(pixel(&page1_off, 10, 290), [255, 255, 255, 255]); + + let echo = ok(&mut a, "view_reading_colors", json!({ "doc": doc, "mode": "white-on-black" })); + assert_eq!(echo, json!({ "mode": "white-on-black", "text": "#ffffff", "background": "#000000", "only_black": false, "line_art": true })); + + // The page clears to the setting's background, and the text paints with its colour. + let page1_on = render(&mut a, 1); + assert_eq!(pixel(&page1_on, 10, 290), [0, 0, 0, 255], "background clears to black"); + // The brightest pixel inside the text band is a stroke interior: a 48 pt bold stem covers it. + let (tx, ty) = (20..172u32).flat_map(|x| (116..160u32).map(move |y| (x, y))).max_by_key(|&(x, y)| u32::from(pixel(&page1_on, x, y)[0])).unwrap(); + let on = pixel(&page1_on, tx, ty); + assert!(on[0] >= 250, "a text-interior pixel renders white: {on:?}"); + let off = pixel(&page1_off, tx, ty); + assert!(off[0] <= 60, "the same pixel is black ink without the setting: {off:?}"); + + // Images are never remapped: page 2's picture draws byte-identically in both modes. + let page2_on = render(&mut a, 2); + fn image_row(px: &[u8], y: u32) -> &[u8] { + let at = ((y * 200 + 122) * 4) as usize; + &px[at..at + 36 * 4] + } + assert_eq!(pixel(&page2_on, 140, 80), [255, 0, 0, 255], "the image keeps its colour"); + for y in 62..98u32 { + assert_eq!(image_row(&page2_on, y), image_row(&page2_off, y), "image row {y}"); + } + + // Errors name the problem and the allowed values. + let missing = a.call("view_reading_colors", &json!({ "doc": doc, "mode": "custom", "text": "#00ff00" })); + assert!(matches!(&missing, Err(ToolError::InvalidArgs(m)) if m.contains("background")), "{missing:?}"); + let unknown = a.call("view_reading_colors", &json!({ "doc": doc, "mode": "nope" })); + assert!(matches!(&unknown, Err(ToolError::InvalidArgs(m)) if m.contains("off") && m.contains("custom")), "{unknown:?}"); + let no_mode = a.call("view_reading_colors", &json!({ "doc": doc })); + assert!(matches!(&no_mode, Err(ToolError::InvalidArgs(m)) if m.contains("mode")), "{no_mode:?}"); + + // Back off: the render matches the original one again. + assert_eq!( + ok(&mut a, "view_reading_colors", json!({ "doc": doc, "mode": "off" })), + json!({ "mode": "off", "text": null, "background": null, "only_black": false, "line_art": false }) + ); + assert_eq!(render(&mut a, 1), page1_off); +} + #[test] fn private_crops_with_nested_state_resize_save_reopen_through_tools() { use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental}; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3daaade6..19ab3800 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -143,7 +143,7 @@ pub type SplitPart = (usize, usize, Arc>); use std::sync::Arc; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; -use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, RenderConfig, RenderPool, inspect}; +use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; /// Stable identifier of an open document within a session. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -4098,6 +4098,24 @@ impl Session { true } + /// Set the document's reading colours (Acrobat's Replace Document Colors). Returns whether + /// anything changed; callers must drop cached rasters and text for the document. + pub fn set_reading_colors(&mut self, id: DocId, colors: ReadingColors) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if doc.config.reading_colors == colors { + return false; + } + doc.config.reading_colors = colors; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.generation += 1; + true + } + + /// The document's reading-colours setting; `None` when no such document is open. + pub fn reading_colors(&self, id: DocId) -> Option { + Some(self.get(id)?.config.reading_colors) + } + /// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment /// with its number, author, type, date, text and replies, grouped by page. pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1b446ed0..372dc30f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2144,6 +2144,21 @@ fn comment_summary_includes_replies_to_replies() { assert!(text.contains(" NESTED_REPLY"), "the nested reply is indented under its parent: {text}"); } +#[test] +fn reading_colors_set_and_invalidated() { + let (mut s, id) = session_with(1); + // Every open document holds a value; the default is Off. + assert_eq!(s.reading_colors(id), Some(ReadingColors::default())); + assert!(s.set_reading_colors(id, ReadingColors::WHITE_ON_BLACK)); + assert_eq!(s.reading_colors(id), Some(ReadingColors::WHITE_ON_BLACK)); + assert!(!s.set_reading_colors(id, ReadingColors::WHITE_ON_BLACK), "unchanged"); + assert!(s.set_reading_colors(id, ReadingColors::default()), "back to Off"); + assert_eq!(s.reading_colors(id), Some(ReadingColors::default())); + // No such document. + assert_eq!(s.reading_colors(DocId(u64::MAX)), None); + assert!(!s.set_reading_colors(DocId(u64::MAX), ReadingColors::WHITE_ON_BLACK)); +} + #[test] fn signing_saving_trusting_and_commenting_afterwards() { let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index ce4154db..28202a38 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -17,6 +17,8 @@ lopdf = { workspace = true } kurbo = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +# The reading-colours setting is persisted with the app state and echoed by the automation tool. +serde = { workspace = true } # Reads rendered pixel words as bytes; already in the tree through hayro. bytemuck = { version = "1" } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c0fa0157..d2ee5712 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -11,6 +11,7 @@ mod inspect; mod pixels; mod raster; +mod reading; mod structure; pub mod text; @@ -20,9 +21,10 @@ pub use inspect::{ }; pub use pixels::Pixels; pub use raster::{ - MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderStats, RenderWarning, RenderedPage, RequestKind, Retiring, - Tile, device_pixels, effective_scale, + MAX_PIXELS, MAX_SIDE, PRINT_TAG, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderStats, RenderWarning, RenderedPage, RequestKind, + Retiring, Tile, device_pixels, effective_scale, }; +pub use reading::{ReadingColors, ReadingParams}; pub use text::{PageText, TextGlyph}; /// Errors surfaced to the user when a document cannot be opened. diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 5fdc0547..1189feff 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -25,10 +25,11 @@ use hayro::hayro_interpret::font::{FontData, FontQuery}; use hayro::hayro_interpret::hayro_cmap::CidFamily; use hayro::hayro_interpret::{InterpreterSettings, InterpreterWarning}; use hayro::hayro_syntax::Pdf; +use hayro::vello_cpu::color::AlphaColor; use hayro::vello_cpu::color::palette::css::WHITE; use hayro::{RenderCache, RenderSettings, render_into, render_size}; -use crate::Pixels; +use crate::{Pixels, ReadingColors}; /// Monotonic-ish timer that is safe on wasm32 (where `std::time::Instant` panics). #[derive(Clone, Copy)] @@ -61,6 +62,9 @@ pub struct RenderConfig { /// Refuse a whole-page raster that would be reduced by the renderer's hard size caps. /// Tiled requests remain available for pages that exceed those caps. pub reject_oversize: bool, + /// View ▸ Reading colours: solid text and line-art paints are remapped for readability and + /// the page clears to the setting's background. Display only; the document never changes. + pub reading_colors: ReadingColors, } impl RenderConfig { @@ -162,6 +166,17 @@ pub struct RenderRequest { pub preview: bool, } +/// The tag bit of a print-preview raster. Such requests always render the document's true +/// colours, ignoring `RenderConfig::reading_colors`: a preview's job is fidelity to the +/// printout, and the print itself — imposed from the document — is never recoloured. +pub const PRINT_TAG: u64 = 1 << 60; + +/// The reading colours a request renders with: print previews ([`PRINT_TAG`]) ignore the +/// document's setting and render with reading colours off. +fn request_reading(config: &RenderConfig, req: &RenderRequest) -> ReadingColors { + if req.tag & PRINT_TAG != 0 { ReadingColors::default() } else { config.reading_colors } +} + /// What a render that panicked or overran the watchdog is remembered by: its page and kind. Not /// its scale, tile or tag: the canvas tags every zoom level and tile anew, so a wider key would /// let one pathological page tie up a worker for the full watchdog limit per zoom and per tile @@ -286,6 +301,7 @@ fn render_page<'a>( cache: &RenderCache<'a>, settings: &InterpreterSettings, reject_oversize: bool, + reading: ReadingColors, req: RenderRequest, ) -> Result { if req.kind == RequestKind::Text { @@ -302,6 +318,12 @@ fn render_page<'a>( if !(w.is_finite() && h.is_finite()) || w < 0.5 || h < 0.5 { return Err(format!("page {} has an empty or invalid page box ({w}×{h} pt)", req.page + 1)); } + // Reading colours: the page clears to the setting's background, and solid text and + // line-art paints are remapped (images and patterns never are). Off renders as before. + let (bg_color, color_replacement) = match reading.params { + Some(p) => (AlphaColor::from_rgb8(p.background[0], p.background[1], p.background[2]), Some(hayro::ColorReplacement::from(p))), + None => (WHITE, None), + }; let rs = match req.tile { // Tiles are bounded by construction, so the page itself may be arbitrarily large. Some(t) => { @@ -314,7 +336,8 @@ fn render_page<'a>( height: Some(th as u16), x_offset: t.x as f32, y_offset: t.y as f32, - bg_color: WHITE, + bg_color, + color_replacement, } } None => { @@ -329,7 +352,15 @@ fn render_page<'a>( // hayro floors the size when none is given, losing the partial edge pixels. The // scale keeps each side within MAX_SIDE (give or take float noise), so it fits u16. let side = |pt: f32| device_pixels(pt, scale).min(MAX_SIDE as u32) as u16; - RenderSettings { x_scale: scale, y_scale: scale, width: Some(side(w)), height: Some(side(h)), bg_color: WHITE, ..Default::default() } + RenderSettings { + x_scale: scale, + y_scale: scale, + width: Some(side(w)), + height: Some(side(h)), + bg_color, + color_replacement, + ..Default::default() + } } }; // Rendered straight into a buffer of whole pixels, which the GUI takes over as is. @@ -424,7 +455,7 @@ impl PageRenderer { let cache = RenderCache::new(); let warnings = Arc::new(Mutex::new(Vec::new())); let settings = warning_settings(&self.settings, warnings.clone()); - let r = render_page(pdf, &cache, &settings, self.config.reject_oversize, req); + let r = render_page(pdf, &cache, &settings, self.config.reject_oversize, request_reading(&self.config, &req), req); let warnings = take_warnings(&warnings); if matches!(r, Err((_, true))) { self.pdf = parse(&self.bytes, self.config.password.as_deref()); @@ -1152,7 +1183,7 @@ fn worker( panic!("injected page panic"); } match pdf { - Some(pdf) => render_page(pdf, cache, &settings, config.reject_oversize, req), + Some(pdf) => render_page(pdf, cache, &settings, config.reject_oversize, request_reading(&config, &req), req), None => Err(("the document could not be parsed".into(), false)), } })) @@ -4992,6 +5023,132 @@ trailer << /Root 1 0 R >> } } + /// White-on-black: the page clears black, the black filled rectangle maps to the text colour, + /// and the image's pixels are byte-identical to the plain render — photos never pass through + /// the mapping, not even its white quadrant, which would map to black if they did. + #[test] + fn reading_colors_map_solid_paints_and_leave_images() { + let content = "0 0 0 rg 10 10 30 20 re f\nq 20 0 0 10 60 20 cm /Im Do Q"; + let pdf = format!( + "%PDF-1.4\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Contents 4 0 R /Resources << /XObject << /Im 6 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 6 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 25 >> stream\nFF000000FF00000000FFFFFFFF>\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(); + let req = RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, preview: false }; + let render = |reading_colors: ReadingColors| { + let mut r = PageRenderer::new(Arc::new(pdf.clone()), RenderConfig { reading_colors, ..Default::default() }); + let page = r.render(req); + assert!(page.error.is_none(), "{:?}", page.error); + page + }; + let off = render(ReadingColors::default()); + let on = render(ReadingColors::WHITE_ON_BLACK); + assert_eq!((on.width, on.height), (200, 100)); + fn pixel(page: &RenderedPage, x: u32, y: u32) -> &[u8] { + &page.rgba[((y * page.width + x) * 4) as usize..][..4] + } + + // The page clears to the setting's background instead of white. + assert_eq!(pixel(&off, 5, 5), &[255, 255, 255, 255]); + assert_eq!(pixel(&on, 5, 5), &[0, 0, 0, 255]); + // The black rectangle maps to the text colour. + assert_eq!(pixel(&off, 50, 60), &[0, 0, 0, 255]); + assert_eq!(pixel(&on, 50, 60), &[255, 255, 255, 255]); + // The whole image area (the interior of device pixels 120..160 × 40..60) is untouched. + for y in 44..56u32 { + for x in 124..156u32 { + assert_eq!(pixel(&on, x, y), pixel(&off, x, y), "image pixel {x},{y}"); + } + } + assert_eq!(pixel(&on, 150, 55), &[255, 255, 255, 255], "the image's white quadrant stays white"); + } + + /// A print-preview request (`PRINT_TAG`) renders the document's true colours even though + /// the document's config carries reading colours: a preview's job is fidelity to the + /// printout, which is imposed from the uncoloured document. Covers both render paths — + /// the inline `PageRenderer` and a pool worker. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn print_preview_requests_render_with_reading_colors_off() { + let content = "0 0 0 rg 10 10 30 20 re f"; + let pdf = Arc::new( + format!( + "%PDF-1.4\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 50] >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(), + ); + fn px(page: &RenderedPage, x: u32, y: u32) -> &[u8] { + &page.rgba[((y * page.width + x) * 4) as usize..][..4] + } + + // The page and its black rectangle in their true colours. + let mut plain = PageRenderer::new(pdf.clone(), RenderConfig::default()); + let truth = plain.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, preview: false }); + assert!(truth.error.is_none(), "{:?}", truth.error); + assert_eq!(px(&truth, 5, 5), &[255, 255, 255, 255]); + assert_eq!(px(&truth, 50, 60), &[0, 0, 0, 255]); + + let mut recoloured = PageRenderer::new(pdf.clone(), RenderConfig { reading_colors: ReadingColors::WHITE_ON_BLACK, ..Default::default() }); + // A plain request honours the setting: the page clears black, the rect maps white. + let on = recoloured.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, preview: false }); + assert_eq!(px(&on, 5, 5), &[0, 0, 0, 255]); + assert_eq!(px(&on, 50, 60), &[255, 255, 255, 255]); + // A print-preview request ignores the setting and matches the true colours exactly. + let preview = RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: PRINT_TAG, preview: false }; + let off = recoloured.render(preview); + assert_eq!(px(&off, 5, 5), &[255, 255, 255, 255]); + assert_eq!(px(&off, 50, 60), &[0, 0, 0, 255]); + assert_eq!(off.rgba, truth.rgba); + + // The same override on the worker path the print dialog's canvas renders through. + let pool = RenderPool::new(pdf, 1, RenderConfig { reading_colors: ReadingColors::WHITE_ON_BLACK, ..Default::default() }); + pool.set_queue(vec![preview]); + let worker = loop { + if let Some(p) = pool.try_recv() { + break p; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + assert!(worker.error.is_none(), "{:?}", worker.error); + assert_eq!(worker.rgba, truth.rgba); + } + + /// A recoloured tile must equal the crop of the recoloured full render, or zooming would + /// flash wrong colours at tile seams. + #[test] + fn reading_colors_tiles_match_full_render() { + let mut r = + PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig { reading_colors: ReadingColors::WHITE_ON_BLACK, ..Default::default() }); + let full = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, preview: false }); + let tile = Tile { x: 30, y: 40, w: 50, h: 30 }; + let part = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: Some(tile), scale: 2.0, tag: 0, preview: false }); + assert!(full.error.is_none() && part.error.is_none(), "{:?} {:?}", full.error, part.error); + assert_eq!((part.width, part.height), (50, 30)); + for y in 0..30u32 { + for x in 0..50u32 { + let a = &full.rgba[(((y + 40) * full.width + x + 30) * 4) as usize..][..4]; + let b = &part.rgba[((y * 50 + x) * 4) as usize..][..4]; + assert_eq!(a, b, "pixel {x},{y}"); + } + } + // The colours are really on: the page cleared black and the blue rectangle mapped towards + // white (its luminance is 7.2%, so 255·(1−0.0722) ≈ 237). + assert_eq!(&full.rgba[..4], &[0, 0, 0, 255]); + assert_eq!(&full.rgba[((60 * full.width + 50) * 4) as usize..][..4], &[237, 237, 237, 255]); + } + // A tile seam must not change the raster when large-page work is stitched back together. #[test] fn stitched_render_partitions_match_a_whole_render() { @@ -5198,9 +5355,9 @@ endstream endobj let pdf = parse(&Arc::new(pdf), None).expect("parses"); let cache = RenderCache::new(); let settings = InterpreterSettings::default(); - let preview = render_page(&pdf, &cache, &settings, false, req(true)).expect("preview renders"); - let fin = render_page(&pdf, &cache, &settings, false, req(false)).expect("final renders"); - let fresh = render_page(&pdf, &RenderCache::new(), &settings, false, req(false)).expect("final renders"); + let preview = render_page(&pdf, &cache, &settings, false, ReadingColors::default(), req(true)).expect("preview renders"); + let fin = render_page(&pdf, &cache, &settings, false, ReadingColors::default(), req(false)).expect("final renders"); + let fresh = render_page(&pdf, &RenderCache::new(), &settings, false, ReadingColors::default(), req(false)).expect("final renders"); assert_eq!(rgb_at(&preview.2, w, 35, 35), [0, 0, 255]); assert_eq!(&fin.2[..], &fresh.2[..], "a final render after a preview matches one with a fresh cache"); } diff --git a/crates/render/src/reading.rs b/crates/render/src/reading.rs new file mode 100644 index 00000000..ac858196 --- /dev/null +++ b/crates/render/src/reading.rs @@ -0,0 +1,214 @@ +//! 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, +} + +/// 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, line_art: Option) -> Result { + // 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 { + self.params.map(|p| hex(p.text)) + } + + /// The background colour as lowercase `#rrggbb`, or `None` when off. + pub fn background_hex(&self) -> Option { + self.params.map(|p| hex(p.background)) + } +} + +impl From 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())); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1feaa2b9..31d1f423 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHandle, TextureOptions, Vec2, pos2, vec2}; use pdfcraft_engine::{DocId, Edit}; -use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels}; +use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PRINT_TAG, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels}; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets}; @@ -34,8 +34,6 @@ const TILE_THRESHOLD: f32 = 4096.0; const TILE: u32 = 1024; /// Longest side of the backdrop drawn under tiles, and of a live page preview (signature drag). pub(crate) const BASE_SIDE: f32 = 2048.0; -/// Print-preview rasters (distinct from thumbnails and from organize-grid renders). -const PRINT_TAG: u64 = 1 << 60; /// Thumbnail slot before density scaling. The pages panel draws at most 150 logical points and /// the organize grid about 146; when the byte budget allows, density reaches the screen so those /// images are sampled down instead of stretched. @@ -1713,6 +1711,8 @@ impl DocView { } /// Queue sharp rasters for the print preview. Pages that leave the nearby sheets are dropped. + /// Previews render with reading colours off (the request's tag tells the renderer): they must + /// match the printout, which is imposed from the uncoloured document. pub(crate) fn queue_print_previews(&mut self, pages: &[(usize, f32)]) { self.print_pages.retain(|page, _| pages.iter().any(|(p, _)| p == page)); for &(page, scale) in pages { diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs index a12cec8b..63f80352 100644 --- a/crates/ui-egui/src/create_ui.rs +++ b/crates/ui-egui/src/create_ui.rs @@ -100,6 +100,8 @@ impl PdfCraftApp { fn open_created_bytes(&mut self, name: &str, created: Result>, String>) -> Result<(), String> { let bytes = created?; let id = self.session.open_new(name, bytes).map_err(|e| e.to_string())?; + // The reading-colours preference is app-wide: the newly created document starts with it. + self.session.set_reading_colors(id, self.reading_colors); let info = &self.session.get(id).ok_or("the new document could not be opened")?.info; self.views.push(crate::DocView::new(id, info, self.view_defaults)); self.active = Some(self.views.len() - 1); diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 27c5d70e..cd54c775 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -760,6 +760,8 @@ impl PdfCraftApp { pub(crate) fn open_created(&mut self, name: &str, bytes: Arc>, message: &str) { match self.session.open_new(name, bytes) { Ok(id) => { + // The reading-colours preference is app-wide: the newly created document starts with it. + self.session.set_reading_colors(id, self.reading_colors); let Some(doc) = self.session.get(id) else { return }; self.views.push(crate::DocView::new(id, &doc.info, self.view_defaults)); self.active = Some(self.views.len() - 1); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index ca34d7e8..c53e6944 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2291,6 +2291,16 @@ animation effects Off معطّلة Original الأصل Greyscale تدرج رمادي Black and white أبيض وأسود + Reading colours ألوان القراءة + Replace document colours استبدال ألوان المستند + Black on white أسود على أبيض + White on black أبيض على أسود + Yellow on black أصفر على أسود + Green on black أخضر على أسود + Page background خلفية الصفحة + Only change the colour of black text or line art تغيير لون النص الأسود والخطوط فقط + Change the colour of line art as well as text تغيير لون الخطوط أيضًا، وليس النص فقط + Colours are remapped while rendering; the document itself never changes. تُستبدل الألوان عند الرسم؛ المستند نفسه لا يتغير أبدًا. This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… لا يقبل برنامج تشغيل هذه الطابعة إعدادات الطباعة على الوجهين من البرامج الأخرى، لذا يُطبَّق إعداده الخاص. غيّره من خصائص… Browser's print dialog مربع حوار الطباعة في المتصفح Copies, two-sided printing and colour are chosen in the browser's print dialog. يُختار عدد النسخ والطباعة على الوجهين والألوان في مربع حوار الطباعة في المتصفح. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index a9d8525a..5906e8d9 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2233,6 +2233,16 @@ animation effects Off Изключени Original Оригинал Greyscale Нива на сивото Black and white Черно-бяло + Reading colours Цветове за четене + Replace document colours Замяна на цветовете на документа + Black on white Черно на бяло + White on black Бяло на черно + Yellow on black Жълто на черно + Green on black Зелено на черно + Page background Фон на страницата + Only change the colour of black text or line art Да се променя само цветът на черния текст и линиите + Change the colour of line art as well as text Да се променя цветът и на линиите, не само на текста + Colours are remapped while rendering; the document itself never changes. Цветовете се променят при изчертаването; самият документ никога не се променя. This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Драйверът на този принтер не приема настройки за двустранен печат от други програми и прилага собствената си настройка. Променете я от Свойства… Browser's print dialog Диалог за печат на браузъра Copies, two-sided printing and colour are chosen in the browser's print dialog. Броят копия, двустранният печат и цветът се избират в диалога за печат на браузъра. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 24097e03..3d83f612 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2342,6 +2342,16 @@ animation effects Off Vypnuto Original Originál Greyscale Stupně šedi Black and white Černobílé + Reading colours Barvy pro čtení + Replace document colours Nahradit barvy dokumentu + Black on white Černá na bílém + White on black Bílá na černém + Yellow on black Žlutá na černém + Green on black Zelená na černém + Page background Pozadí stránky + Only change the colour of black text or line art Změnit jen barvu černého textu a čar + Change the colour of line art as well as text Změnit barvu i čar, ne jen textu + Colours are remapped while rendering; the document itself never changes. Barvy se mění při vykreslování; samotný dokument se nikdy nemění. This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Ovladač této tiskárny nepřijímá nastavení oboustranného tisku z jiných programů, proto platí jeho vlastní nastavení. Změňte ho ve Vlastnosti… This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Písmo tohoto pole neumí zobrazit „{c}“ ({code}), proto PdfCraft místo něj ukazuje ?. Hodnota se uloží a jiné prohlížeče PDF pole podle ní překreslí. The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Písma, kterými PdfCraft píše, neumí zobrazit „{c}“ ({code}), proto se ve všech prohlížečích PDF zobrazí jako ?. Text se uloží. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d421a3bb..9fcc7ba1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2271,6 +2271,16 @@ animation effects Off Aus Original Original Greyscale Graustufen Black and white Schwarzweiß + Reading colours Lesefarben + Replace document colours Dokumentfarben ersetzen + Black on white Schwarz auf weiß + White on black Weiß auf schwarz + Yellow on black Gelb auf schwarz + Green on black Grün auf schwarz + Page background Seitenhintergrund + Only change the colour of black text or line art Nur die Farbe von schwarzem Text oder Strichzeichnungen ändern + Change the colour of line art as well as text Auch die Farbe von Strichzeichnungen ändern, nicht nur von Text + Colours are remapped while rendering; the document itself never changes. Farben werden beim Rendern neu zugeordnet; das Dokument selbst bleibt unverändert. This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Der Treiber dieses Druckers übernimmt keine Einstellungen für beidseitigen Druck von anderen Programmen, daher gilt seine eigene Einstellung. Ändern unter Eigenschaften… Browser's print dialog Druckdialog des Browsers Copies, two-sided printing and colour are chosen in the browser's print dialog. Exemplare, beidseitiger Druck und Farbe werden im Druckdialog des Browsers gewählt. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e84cee09..35c32c77 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2321,6 +2321,16 @@ animation effects Off Ανενεργά Ink sensitivity Ευαισθησία μελανιού No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Δεν βρέθηκε υπογραφή στο πλαίσιο. Μετακινήστε το πλαίσιο πάνω από την υπογραφή ή αυξήστε την ευαισθησία μελανιού. Couldn't use the photo: {e} Δεν ήταν δυνατή η χρήση της φωτογραφίας: {e} + Reading colours Χρώματα ανάγνωσης + Replace document colours Αντικατάσταση χρωμάτων εγγράφου + Page background Φόντο σελίδας + Only change the colour of black text or line art Αλλαγή χρώματος μόνο για μαύρο κείμενο ή γραμμικά σχέδια + Change the colour of line art as well as text Αλλαγή χρώματος και στα γραμμικά σχέδια, όχι μόνο στο κείμενο + Colours are remapped while rendering; the document itself never changes. Τα χρώματα αντιστοιχίζονται εκ νέου κατά την απόδοση· το ίδιο το έγγραφο δεν αλλάζει ποτέ. + Black on white Μαύρο σε λευκό + White on black Λευκό σε μαύρο + Yellow on black Κίτρινο σε μαύρο + Green on black Πράσινο σε μαύρο Couldn't follow linked PDF: {reason} Δεν ήταν δυνατό να ανοίξει το συνδεδεμένο PDF: {reason} The linked PDF opened, but its named destination was not applied. Το συνδεδεμένο PDF άνοιξε, αλλά ο ονομασμένος προορισμός του δεν εφαρμόστηκε. The linked PDF opened, but its page destination was out of range. Το συνδεδεμένο PDF άνοιξε, αλλά ο προορισμός σελίδας του είναι εκτός εύρους. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2b7f8d0b..d125979c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2435,6 +2435,16 @@ animation effects Off Desactivados Ink sensitivity Sensibilidad de la tinta No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. No se encontró ninguna firma en el marco. Mueva el marco sobre la firma o aumente la sensibilidad de la tinta. Couldn't use the photo: {e} No se puede usar la foto: {e} + Reading colours Colores de lectura + Replace document colours Reemplazar colores del documento + Page background Fondo de página + Only change the colour of black text or line art Cambiar solo el color del texto negro o los dibujos lineales + Change the colour of line art as well as text Cambiar también el color de los dibujos lineales, no solo del texto + Colours are remapped while rendering; the document itself never changes. Los colores se reasignan al mostrar la página; el documento en sí nunca cambia. + Black on white Negro sobre blanco + White on black Blanco sobre negro + Yellow on black Amarillo sobre negro + Green on black Verde sobre negro Couldn't follow linked PDF: {reason} No se pudo abrir el PDF vinculado: {reason} The linked PDF opened, but its named destination was not applied. El PDF vinculado se abrió, pero no se aplicó su destino con nombre. The linked PDF opened, but its page destination was out of range. El PDF vinculado se abrió, pero su página de destino está fuera de rango. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 072dffc5..2684a9ef 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2270,6 +2270,16 @@ animation effects Off Désactivés Original Original Greyscale Niveaux de gris Black and white Noir et blanc + Reading colours Couleurs de lecture + Replace document colours Remplacer les couleurs du document + Black on white Noir sur blanc + White on black Blanc sur noir + Yellow on black Jaune sur noir + Green on black Vert sur noir + Page background Arrière-plan de la page + Only change the colour of black text or line art Ne changer que la couleur du texte noir et des traits + Change the colour of line art as well as text Changer aussi la couleur des traits, en plus du texte + Colours are remapped while rendering; the document itself never changes. Les couleurs sont réaffectées au rendu ; le document lui-même ne change jamais. This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Le pilote de cette imprimante n'accepte pas les réglages recto verso des autres programmes ; son propre réglage s'applique. Modifiez-le dans Propriétés… Browser's print dialog Boîte de dialogue d'impression du navigateur Copies, two-sided printing and colour are chosen in the browser's print dialog. Le nombre de copies, l'impression recto verso et la couleur se choisissent dans la boîte de dialogue d'impression du navigateur. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ec6c3215..43427d16 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2269,6 +2269,16 @@ animation effects Off Ki Original Eredeti Greyscale Szürkeárnyalatos Black and white Fekete-fehér + Reading colours Olvasási színek + Replace document colours Dokumentumszínek cseréje + Black on white Fekete fehéren + White on black Fehér feketén + Yellow on black Sárga feketén + Green on black Zöld feketén + Page background Oldal háttere + Only change the colour of black text or line art Csak a fekete szöveg és a vonalak színének módosítása + Change the colour of line art as well as text A vonalak színének módosítása a szöveg mellett + Colours are remapped while rendering; the document itself never changes. A színek a kirajcoláskor módosulnak; maga a dokumentum sosem változik. This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… A nyomtató illesztőprogramja nem fogadja el más programoktól a kétoldalas nyomtatás beállítását, ezért a saját beállítása érvényes. Módosítás: Tulajdonságok… Browser's print dialog A böngésző nyomtatási párbeszédablaka Copies, two-sided printing and colour are chosen in the browser's print dialog. A példányszámot, a kétoldalas nyomtatást és a színt a böngésző nyomtatási párbeszédablakában lehet megadni. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 91be8b20..715a5eb5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2279,6 +2279,16 @@ animation effects Off Disattivati Original Originale Greyscale Scala di grigi Black and white Bianco e nero + Reading colours Colori di lettura + Replace document colours Sostituisci i colori del documento + Black on white Nero su bianco + White on black Bianco su nero + Yellow on black Giallo su nero + Green on black Verde su nero + Page background Sfondo della pagina + Only change the colour of black text or line art Modifica solo il colore del testo nero e dei tratti + Change the colour of line art as well as text Modifica il colore anche dei tratti, oltre al testo + Colours are remapped while rendering; the document itself never changes. I colori vengono riassociati durante il rendering; il documento in sé non cambia mai. This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Il driver di questa stampante non accetta le impostazioni fronte-retro da altri programmi, quindi vale la sua impostazione. Modificala in Proprietà… Browser's print dialog Finestra di stampa del browser Copies, two-sided printing and colour are chosen in the browser's print dialog. Copie, stampa fronte/retro e colore si scelgono nella finestra di stampa del browser. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c2ec24c4..f55b1fb6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2270,6 +2270,16 @@ animation effects Off オフ Original オリジナル Greyscale グレースケール Black and white 白黒 + Reading colours 読書用の色 + Replace document colours 文書の色を置き換える + Black on white 白地に黒 + White on black 黒地に白 + Yellow on black 黒地に黄 + Green on black 黒地に緑 + Page background ページの背景 + Only change the colour of black text or line art 黒いテキストと線画の色だけを変更する + Change the colour of line art as well as text テキストに加えて線画の色も変更する + Colours are remapped while rendering; the document itself never changes. 色は描画時に置き換えられ、文書自体が変わることはありません。 This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… このプリンターのドライバーは他のプログラムからの両面印刷の設定を受け付けないため、ドライバー自身の設定が使われます。変更先: プロパティ… Browser's print dialog ブラウザーの印刷ダイアログ Copies, two-sided printing and colour are chosen in the browser's print dialog. 部数、両面印刷、カラーはブラウザーの印刷ダイアログで選びます。 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 9683235a..f6099773 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2304,6 +2304,16 @@ animation effects Off Өшірулі Ink sensitivity Сия сезімталдығы No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Жақтауда қолтаңба табылмады. Жақтауды қолтаңбаның үстіне жылжытыңыз немесе сия сезімталдығын арттырыңыз. Couldn't use the photo: {e} Суретті пайдалану мүмкін болмады: {e} + Reading colours Оқу түстері + Replace document colours Құжат түстерін ауыстыру + Black on white Ақ фонда қара + White on black Қара фонда ақ + Yellow on black Қара фонда сары + Green on black Қара фонда жасыл + Page background Бет фоны + Only change the colour of black text or line art Тек қара мәтіннің немесе сызықтық суреттің түсін өзгерту + Change the colour of line art as well as text Мәтінмен қатар сызықтық суреттің түсін де өзгерту + Colours are remapped while rendering; the document itself never changes. Түстер көрсету кезінде қайта белгіленеді; құжаттың өзі ешқашан өзгермейді. Couldn't follow linked PDF: {reason} Байланысты PDF ашылмады: {reason} The linked PDF opened, but its named destination was not applied. Байланысты PDF ашылды, бірақ оның аталған мақсаты қолданылмады. The linked PDF opened, but its page destination was out of range. Байланысты PDF ашылды, бірақ оның мақсатты беті ауқымнан тыс. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7ba175be..3de5ba8e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2285,6 +2285,16 @@ animation effects Off Wyłączone Ink sensitivity Czułość na atrament No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. W ramce nie znaleziono podpisu. Przesuń ramkę na podpis lub zwiększ czułość na atrament. Couldn't use the photo: {e} Nie można użyć zdjęcia: {e} + Reading colours Kolory do czytania + Replace document colours Zastąp kolory dokumentu + Page background Tło strony + Only change the colour of black text or line art Zmieniaj kolor tylko czarnego tekstu lub grafiki liniowej + Change the colour of line art as well as text Zmieniaj kolor grafiki liniowej, a nie tylko tekstu + Colours are remapped while rendering; the document itself never changes. Kolory są zmieniane podczas renderowania; sam dokument nigdy się nie zmienia. + Black on white Czarny na białym + White on black Biały na czarnym + Yellow on black Żółty na czarnym + Green on black Zielony na czarnym Couldn't follow linked PDF: {reason} Nie można otworzyć połączonego pliku PDF: {reason} The linked PDF opened, but its named destination was not applied. Połączony plik PDF został otwarty, ale jego nazwany cel nie został zastosowany. The linked PDF opened, but its page destination was out of range. Połączony plik PDF został otwarty, ale jego strona docelowa jest poza zakresem. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3824a6fe..3f74f156 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2284,6 +2284,16 @@ animation effects Off Desativados Original Original Greyscale Escala de cinza Black and white Preto e branco + Reading colours Cores de leitura + Replace document colours Substituir as cores do documento + Black on white Preto no branco + White on black Branco no preto + Yellow on black Amarelo no preto + Green on black Verde no preto + Page background Fundo da página + Only change the colour of black text or line art Alterar apenas a cor do texto preto e dos traços + Change the colour of line art as well as text Alterar também a cor dos traços, além do texto + Colours are remapped while rendering; the document itself never changes. As cores são remapeadas na renderização; o documento em si nunca muda. This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… O driver desta impressora não aceita configurações de frente e verso de outros programas, então vale a configuração dele. Altere em Propriedades… Browser's print dialog Caixa de diálogo de impressão do navegador Copies, two-sided printing and colour are chosen in the browser's print dialog. Cópias, impressão frente e verso e cor são escolhidas na caixa de diálogo de impressão do navegador. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 1cda1a29..782e5f88 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2183,6 +2183,16 @@ animation effects Off Выкл Original Оригинал Greyscale Оттенки серого Black and white Чёрно-белый + Reading colours Цвета для чтения + Replace document colours Заменить цвета документа + Black on white Чёрный на белом + White on black Белый на чёрном + Yellow on black Жёлтый на чёрном + Green on black Зелёный на чёрном + Page background Фон страницы + Only change the colour of black text or line art Менять только цвет чёрного текста и линий + Change the colour of line art as well as text Менять цвет и линий, а не только текста + Colours are remapped while rendering; the document itself never changes. Цвета меняются при отрисовке; сам документ никогда не изменяется. This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Драйвер этого принтера не принимает настройки двусторонней печати от других программ, поэтому действует его собственная настройка. Измените её в окне Свойства… Browser's print dialog Диалог печати браузера Copies, two-sided printing and colour are chosen in the browser's print dialog. Число копий, двустороннюю печать и цвет выбирают в диалоге печати браузера. diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index d01990c4..3796b835 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2272,6 +2272,16 @@ animation effects Off Izklopljeni Original Izvirnik Greyscale Sivine Black and white Črno-belo + Reading colours Barve za branje + Replace document colours Zamenjaj barve dokumenta + Black on white Črno na belem + White on black Belo na črnem + Yellow on black Rumeno na črnem + Green on black Zeleno na črnem + Page background Ozadje strani + Only change the colour of black text or line art Spremeni samo barvo črnega besedila in črt + Change the colour of line art as well as text Spremeni barvo tudi črt, ne samo besedila + Colours are remapped while rendering; the document itself never changes. Barve se spremenijo pri izrisovanju; sam dokument se nikoli ne spremeni. This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Gonilnik tega tiskalnika ne sprejema nastavitev obojestranskega tiskanja iz drugih programov, zato velja njegova lastna nastavitev. Spremenite jo v Lastnosti… This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Pisava tega polja ne more prikazati »{c}« ({code}), zato PdfCraft namesto njega prikaže ?. Vrednost se shrani, drugi pregledovalniki PDF pa polje znova narišejo iz nje. The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Pisave, s katerimi piše PdfCraft, ne morejo prikazati »{c}« ({code}), zato se v vseh pregledovalnikih PDF prikaže kot ?. Besedilo se shrani. diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index e306e98c..03886a5e 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2218,6 +2218,16 @@ animation effects Off ఆఫ్ Ink sensitivity ఇంక్ సున్నితత్వం No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. ఫ్రేమ్‌లో సంతకం కనబడలేదు. ఫ్రేమ్‌ను సంతకం మీదకు జరపండి లేదా ఇంక్ సున్నితత్వాన్ని పెంచండి. Couldn't use the photo: {e} ఫోటోను ఉపయోగించలేకపోయాం: {e} + Reading colours చదవడానికి రంగులు + Replace document colours పత్రం రంగులను మార్చు + Page background పేజీ నేపథ్యం + Only change the colour of black text or line art నలుపు వచనం లేదా రేఖా చిత్రాల రంగును మాత్రమే మార్చు + Change the colour of line art as well as text వచనంతో పాటు రేఖా చిత్రాల రంగును కూడా మార్చు + Colours are remapped while rendering; the document itself never changes. రెండర్ చేసేటప్పుడు రంగులు మార్చబడతాయి; పత్రం మాత్రం ఎప్పుడూ మారదు. + Black on white తెలుపుపై నలుపు + White on black నలుపుపై తెలుపు + Yellow on black నలుపుపై పసుపు + Green on black నలుపుపై ఆకుపచ్చ Couldn't follow linked PDF: {reason} లింక్ చేసిన PDFను తెరవలేకపోయాం: {reason} The linked PDF opened, but its named destination was not applied. లింక్ చేసిన PDF తెరుచుకుంది, కానీ దాని పేరున్న గమ్యం వర్తించలేదు. The linked PDF opened, but its page destination was out of range. లింక్ చేసిన PDF తెరుచుకుంది, కానీ దాని పేజీ గమ్యం పరిధికి వెలుపల ఉంది. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2971ab7a..7b62a643 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2302,6 +2302,16 @@ animation effects Off Kapalı Ink sensitivity Mürekkep hassasiyeti No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Çerçevede imza bulunamadı. Çerçeveyi imzanın üzerine taşıyın veya mürekkep hassasiyetini artırın. Couldn't use the photo: {e} Fotoğraf kullanılamadı: {e} + Reading colours Okuma renkleri + Replace document colours Belge renklerini değiştir + Black on white Beyaz üzerinde siyah + White on black Siyah üzerinde beyaz + Yellow on black Siyah üzerinde sarı + Green on black Siyah üzerinde yeşil + Page background Sayfa arka planı + Only change the colour of black text or line art Yalnızca siyah metin veya çizgi sanatının rengini değiştir + Change the colour of line art as well as text Metnin yanı sıra çizgi sanatının rengini de değiştir + Colours are remapped while rendering; the document itself never changes. Renkler görüntülenirken yeniden eşlenir; belgenin kendisi asla değişmez. Couldn't follow linked PDF: {reason} Bağlantılı PDF açılamadı: {reason} The linked PDF opened, but its named destination was not applied. Bağlantılı PDF açıldı, ancak adlandırılmış hedefi uygulanmadı. The linked PDF opened, but its page destination was out of range. Bağlantılı PDF açıldı, ancak sayfa hedefi aralık dışında. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0c229d63..0fd459cf 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2292,6 +2292,16 @@ animation effects Off Вимкнено Original Оригінал Greyscale Відтінки сірого Black and white Чорно-білий + Reading colours Кольори для читання + Replace document colours Замінити кольори документа + Black on white Чорний на білому + White on black Білий на чорному + Yellow on black Жовтий на чорному + Green on black Зелений на чорному + Page background Фон сторінки + Only change the colour of black text or line art Змінювати лише колір чорного тексту та ліній + Change the colour of line art as well as text Змінювати колір і ліній, а не лише тексту + Colours are remapped while rendering; the document itself never changes. Кольори змінюються під час рендерення; сам документ ніколи не змінюється. This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Драйвер цього принтера не приймає налаштування двобічного друку від інших програм, тому діє його власне налаштування. Змініть його у вікні Властивості… Browser's print dialog Діалог друку браузера Copies, two-sided printing and colour are chosen in the browser's print dialog. Кількість копій, двосторонній друк і колір вибирають у діалозі друку браузера. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7dcb8027..1f30ef93 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2234,6 +2234,16 @@ animation effects Off 关 Original 原始 Greyscale 灰度 Black and white 黑白 + Reading colours 阅读配色 + Replace document colours 替换文档颜色 + Black on white 白底黑字 + White on black 黑底白字 + Yellow on black 黑底黄字 + Green on black 黑底绿字 + Page background 页面背景 + Only change the colour of black text or line art 仅更改黑色文本和线条的颜色 + Change the colour of line art as well as text 除文本外也更改线条的颜色 + Colours are remapped while rendering; the document itself never changes. 颜色仅在渲染时替换;文档本身不会改变。 This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… 此打印机的驱动程序不接受其他程序的双面打印设置,因此使用其自身设置。更改位置:属性… Browser's print dialog 浏览器的打印对话框 Copies, two-sided printing and colour are chosen in the browser's print dialog. 份数、双面打印和颜色在浏览器的打印对话框中选择。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6cfa6aa4..aabf2aac 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2179,6 +2179,16 @@ animation effects Off 關 Ink sensitivity 墨跡靈敏度 No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. 框內找不到簽名。請將框移到簽名上,或提高墨跡靈敏度。 Couldn't use the photo: {e} 無法使用此相片:{e} + Reading colours 閱讀色彩 + Replace document colours 取代文件色彩 + Page background 頁面背景 + Only change the colour of black text or line art 僅變更黑色文字或線條圖的色彩 + Change the colour of line art as well as text 除文字外,也變更線條圖的色彩 + Colours are remapped while rendering; the document itself never changes. 色彩僅在顯示時重新對應;文件本身不會變更。 + Black on white 白底黑字 + White on black 黑底白字 + Yellow on black 黑底黃字 + Green on black 黑底綠字 Couldn't follow linked PDF: {reason} 無法開啟連結的 PDF:{reason} The linked PDF opened, but its named destination was not applied. 已開啟連結的 PDF,但未套用其命名目的地。 The linked PDF opened, but its page destination was out of range. 已開啟連結的 PDF,但其頁面目的地超出範圍。 diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index 1f89ae60..83d783a4 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -230,6 +230,18 @@ pub(crate) fn document_js_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok close } +/// The label for a [`pdfcraft_render::ReadingColors`] mode name (`mode_name`). +fn reading_mode_label(mode: &str) -> &'static str { + match mode { + "black-on-white" => "Black on white", + "white-on-black" => "White on black", + "yellow-on-black" => "Yellow on black", + "green-on-black" => "Green on black", + "custom" => "Custom", + _ => "Off", + } +} + /// Preferences: interface language and size, appearance, identity and JavaScript. Returns `true` to close. pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { ui.label(egui::RichText::new(tl!("Preferences")).font(theme::semibold(18.0))); @@ -366,6 +378,66 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok } }); ui.add_space(8.0); + // Reading colours (Acrobat: Replace Document Colors): solid text and line-art paints are + // remapped while rendering, and the page clears to the background colour. The preference + // is app-wide: every open document follows it, and documents open with it. + ui.label(egui::RichText::new(tl!("Reading colours")).font(theme::semibold(13.0))); + ui.horizontal(|ui| { + ui.label(tl!("Replace document colours")); + let mode = app.reading_colors.mode_name(); + egui::ComboBox::from_id_salt("reading-colors").selected_text(tl!(reading_mode_label(mode))).show_ui(ui, |ui| { + for (m, label) in [ + ("off", "Off"), + ("black-on-white", "Black on white"), + ("white-on-black", "White on black"), + ("yellow-on-black", "Yellow on black"), + ("green-on-black", "Green on black"), + ("custom", "Custom"), + ] { + if ui.selectable_label(mode == m, tl!(label)).clicked() { + match m { + "off" => app.set_reading_colors(pdfcraft_render::ReadingColors::default()), + "custom" => { + // Custom starts from the colours already chosen, or black on white. + let colors = app.reading_colors.params.map_or(pdfcraft_render::ReadingColors::BLACK_ON_WHITE, |params| { + pdfcraft_render::ReadingColors { params: Some(params) } + }); + app.set_reading_colors(colors); + } + // `parse` knows these names; a preset comes with its own flags. + preset => { + if let Ok(colors) = pdfcraft_render::ReadingColors::parse(preset, None, None, None, None) { + app.set_reading_colors(colors); + } + } + } + ui.close(); + } + } + }); + }); + if app.reading_colors.params.is_some() { + let mut changed = false; + if let Some(params) = app.reading_colors.params.as_mut() { + ui.horizontal(|ui| { + let text = ui.label(tl!("Text")); + changed |= ui.color_edit_button_srgb(&mut params.text).labelled_by(text.id).changed(); + let background = ui.label(tl!("Page background")); + changed |= ui.color_edit_button_srgb(&mut params.background).labelled_by(background.id).changed(); + }); + if ui.add(crate::widgets::checkbox(&mut params.only_black, tl!("Only change the colour of black text or line art"))).changed() { + changed = true; + } + if ui.add(crate::widgets::checkbox(&mut params.line_art, tl!("Change the colour of line art as well as text"))).changed() { + changed = true; + } + } + if changed { + app.set_reading_colors(app.reading_colors); + } + } + ui.label(egui::RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).small().color(t.text_muted)); + ui.add_space(8.0); // Identity: the author of new comments (Acrobat: Preferences ▸ Identity). ui.label(egui::RichText::new(tl!("Identity")).font(theme::semibold(13.0))); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 277580d9..a7283211 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -468,6 +468,9 @@ pub struct PdfCraftApp { /// Resolved colours, including the current OS theme when following the system. pub theme: ThemeKind, pub theme_preference: ThemePreference, + /// The reading-colours preference (Preferences ▸ Reading colours): applied to every open + /// document, and documents open with it (Acrobat's Replace Document Colors). + pub reading_colors: pdfcraft_render::ReadingColors, /// The Linux portal is enabled only by the native host, and starts on the first System choice. #[cfg(target_os = "linux")] desktop_theme: Option, @@ -800,6 +803,7 @@ impl PdfCraftApp { comment_prefs: Default::default(), theme: ThemeKind::Light, theme_preference: ThemePreference::Light, + reading_colors: Default::default(), #[cfg(target_os = "linux")] desktop_theme: None, #[cfg(target_os = "linux")] @@ -1043,6 +1047,9 @@ impl PdfCraftApp { Err(e) => return Err(e.to_string()), }; self.password_prompt = None; + // The reading-colours preference is app-wide: a newly opened document starts with it. + // Idempotent: the engine returns `false` when the document has the setting already. + self.session.set_reading_colors(id, self.reading_colors); let doc = self.session.get(id).ok_or("the document could not be opened")?; let pages = doc.info.pages.len(); // Acrobat opens straight to the Comments panel when a document has comments. @@ -1527,6 +1534,19 @@ impl PdfCraftApp { } } + /// The app-wide reading-colours preference: sets it and applies it to every open document, + /// not just the active one (Preferences ▸ Reading colours, Acrobat's Replace Document + /// Colors). A document the change actually touches re-renders. + pub fn set_reading_colors(&mut self, colors: pdfcraft_render::ReadingColors) { + self.reading_colors = colors; + for i in 0..self.views.len() { + // Idempotent per document: the engine says whether anything changed. + if self.session.set_reading_colors(self.views[i].id, colors) { + self.views[i].invalidate_content(); + } + } + } + /// Enable the Linux desktop portal in a native window. Headless contexts keep /// their supplied input and never connect to the user's session bus. #[cfg(target_os = "linux")] @@ -1722,6 +1742,7 @@ impl PdfCraftApp { "highlight_fields": self.view_defaults.highlight_fields, "units": self.units, "language": self.language, + "reading_colors": self.reading_colors, // Null follows the system's text scaling. "ui_scale": self.ui_scale, "flatten_fill_sign": self.flatten_fill_sign_on_save, @@ -1828,6 +1849,11 @@ impl PdfCraftApp { if let Some(on) = v["flatten_fill_sign"].as_bool() { self.flatten_fill_sign_on_save = on; } + // Settings are untrusted: an unreadable value (a colour that isn't three bytes, say) + // fails to deserialize and keeps Off. + if let Ok(colors) = serde_json::from_value::(v["reading_colors"].clone()) { + self.reading_colors = colors; + } // Settings are untrusted: a size out of range follows the system instead. self.ui_scale = v["ui_scale"].as_f64().and_then(ui_scale::valid); // Settings are untrusted: an unusable pattern keeps the default. diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index b2cc5f9a..b2f0d8cb 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -507,6 +507,8 @@ impl PdfCraftApp { let Some((_, id)) = self.active_ids() else { return }; match self.session.open_revision(id, n) { Ok(new) => { + // The reading-colours preference is app-wide: the newly opened revision starts with it. + self.session.set_reading_colors(new, self.reading_colors); let Some(doc) = self.session.get(new) else { return }; self.views.push(DocView::new(new, &doc.info, self.view_defaults)); self.active = Some(self.views.len() - 1); @@ -523,6 +525,8 @@ impl PdfCraftApp { let name = format!("{} (signed version)", doc.name.trim_end_matches(".pdf")); match self.session.open(format!("{name}.pdf"), None, std::sync::Arc::new(bytes), doc.password.clone().as_deref()) { Ok(new) => { + // The reading-colours preference is app-wide: the newly opened copy starts with it. + self.session.set_reading_colors(new, self.reading_colors); let Some(doc) = self.session.get(new) else { return }; self.views.push(DocView::new(new, &doc.info, self.view_defaults)); self.active = Some(self.views.len() - 1); diff --git a/crates/ui-egui/tests/reading_colors.rs b/crates/ui-egui/tests/reading_colors.rs new file mode 100644 index 00000000..69d7b417 --- /dev/null +++ b/crates/ui-egui/tests/reading_colors.rs @@ -0,0 +1,157 @@ +//! Preferences ▸ Reading colours: the app-wide preference (Acrobat's Replace Document Colors), +//! how it survives a restart, and how documents open with it. + +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_render::ReadingColors; +use pdfcraft_ui_egui::PdfCraftApp; + +/// One valid page (proper xref, so no repair banner covers the dialog): black text on white. +fn pdf() -> Vec { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + { + let body = b"BT /F1 14 Tf 20 150 Td (The quick brown fox) Tj ET"; + format!("<< /Length {} >>\nstream\n{}\nendstream", body.len(), String::from_utf8_lossy(body)).into_bytes() + }, + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// A small PNG for the Create ▸ Images path, which opens the document it builds. +fn png() -> Vec { + let mut out = Vec::new(); + { + let mut enc = png::Encoder::new(&mut out, 8, 4); + enc.set_color(png::ColorType::Rgb); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[120u8; 8 * 4 * 3]).unwrap(); + } + out +} + +/// The Reading colours combo of the preferences dialog: it shows the chosen mode as its value, +/// and no other combo in the dialog shows `Off` while the setting is off. +fn combo(h: &mut Harness<'static, PdfCraftApp>) { + // The preferences dialog grew taller than the window, which leaves this combo below the + // fold where a click lands outside the dialog and dismisses it; scroll it into view and + // let the scroll settle before clicking. + h.get_by(|n| n.role() == Role::ComboBox && n.value().is_some_and(|v| v == "Off")).scroll_to_me(); + h.run_steps(4); + h.get_by(|n| n.role() == Role::ComboBox && n.value().is_some_and(|v| v == "Off")).click(); +} + +#[test] +fn reading_colours_preference_round_trips() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, pdf()).expect("opens"); + app.set_option("dialog", "preferences").unwrap(); + app + }); + h.run_steps(4); + assert_eq!(h.state().reading_colors, ReadingColors::default()); + // Switch the preset to white-on-black in the dialog's Reading colours combo. The open + // document keeps repainting, so run a fixed number of steps and let the popup settle. + combo(&mut h); + h.run_steps(20); + h.get_by_label("White on black").click(); + h.run_steps(2); + assert_eq!(h.state().reading_colors, ReadingColors::WHITE_ON_BLACK); + // The preference is app-wide: the already open document follows it too. + let id = h.state().views[0].id; + assert_eq!(h.state().session.reading_colors(id), Some(ReadingColors::WHITE_ON_BLACK)); + // Closing the dialog keeps everything. + h.state_mut().set_option("dialog", "none").unwrap(); + h.run_steps(1); + + // The choice survives a restart through the same path the settings file uses. + let mut restored = PdfCraftApp::new(); + restored.restore(&h.state().persist()); + assert_eq!(restored.reading_colors, ReadingColors::WHITE_ON_BLACK); + // A settings file without the field keeps Off. + let mut fresh = PdfCraftApp::new(); + fresh.restore(r#"{"theme": "dark"}"#); + assert_eq!(fresh.reading_colors, ReadingColors::default()); + // A corrupt value fails to deserialize and falls back to Off instead of panicking. + fresh.restore(r#"{"reading_colors": {"params": {"text": "zzz", "background": [0, 0, 0], "only_black": false, "line_art": true}}}"#); + assert_eq!(fresh.reading_colors, ReadingColors::default()); + fresh.restore(r#"{"reading_colors": 7}"#); + assert_eq!(fresh.reading_colors, ReadingColors::default()); +} + +#[test] +fn reading_colours_seed_on_open() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.reading_colors = ReadingColors::WHITE_ON_BLACK; + app.open_bytes("doc.pdf", None, pdf()).expect("opens"); + app + }); + h.run_steps(4); + // The document opened with the preference already applied. + let id = h.state().views[0].id; + assert_eq!(h.state().session.reading_colors(id), Some(ReadingColors::WHITE_ON_BLACK)); + // Changing the preference applies it to the open document as well. + h.state_mut().set_reading_colors(ReadingColors::YELLOW_ON_BLACK); + h.run_steps(1); + assert_eq!(h.state().session.reading_colors(id), Some(ReadingColors::YELLOW_ON_BLACK)); + + // Revisions and the signed version open through their own session calls, bypassing the + // ordinary open: they seed the new document the same way. A second revision comes from an + // incremental save. + { + let s = &mut h.state_mut().session; + s.apply(id, pdfcraft_engine::Edit::SetInfo { key: "Title".into(), value: "Second".into() }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved, None).unwrap(); + } + h.state_mut().open_revision(1); + let revision = h.state().views.last().unwrap().id; + assert_ne!(revision, id); + assert_eq!(h.state().session.reading_colors(revision), Some(ReadingColors::YELLOW_ON_BLACK)); + let len = h.state().session.get(id).unwrap().bytes.len(); + h.state_mut().view_signed_version(len); + let signed = h.state().views.last().unwrap().id; + assert_ne!(signed, id); + assert_eq!(h.state().session.reading_colors(signed), Some(ReadingColors::YELLOW_ON_BLACK)); + + // Created documents open through their own session calls too — a comment summary via + // `open_created`, an image import via `open_created_bytes` — and seed the same way. + h.state_mut().summarize_comments(); + let summary = h.state().views.last().unwrap().id; + assert_ne!(summary, signed); + assert_eq!(h.state().session.reading_colors(summary), Some(ReadingColors::YELLOW_ON_BLACK)); + h.state_mut().create_from_images(vec![("scan.png".into(), png())]); + let created = h.state().views.last().unwrap().id; + assert_ne!(created, summary); + assert_eq!(h.state().session.reading_colors(created), Some(ReadingColors::YELLOW_ON_BLACK)); + + // And back off, for every open document. + h.state_mut().set_reading_colors(ReadingColors::default()); + h.run_steps(1); + for view in &h.state().views { + assert_eq!(h.state().session.reading_colors(view.id), Some(ReadingColors::default())); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3c6896b1..87581f68 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9059,11 +9059,11 @@ title = "Replace page colours (high contrast)" area = "M" tier = "P2" milestone = "M12" -status = "planned" +status = "shipped" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["view_reading_colors"] +evidence = ["crates/automation/tests/automation.rs::reading_colors_through_tools", "crates/render/src/raster.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster.rs::print_preview_requests_render_with_reading_colors_off", "crates/ui-egui/tests/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/reading_colors.rs::reading_colours_seed_on_open"] +notes = "View only, off by default: Preferences ▸ Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Closes #897 (duplicate #488)." [[feature]] id = "a11y.reflow-tagged" diff --git a/vendor/README.md b/vendor/README.md index e9c8f535..30c4fa49 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -9,8 +9,8 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range` | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours` -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything` | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything` | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}` (`cargo test -p hayro`) | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}` (`cargo test -p hayro`) | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice. | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams` diff --git a/vendor/hayro/Cargo.toml b/vendor/hayro/Cargo.toml index 86360dca..87ab46a2 100644 --- a/vendor/hayro/Cargo.toml +++ b/vendor/hayro/Cargo.toml @@ -83,9 +83,9 @@ features = [ ] default-features = false -[dev-dependencies.log] -version = "0.4" - +# PdfCraft patch: the upstream-only `[dev-dependencies.log]` entry is removed (log is a normal +# dependency since the logging patch), so `cargo test -p hayro` can run this crate's unit tests +# even though a patched non-workspace crate may not require dev-dependencies. [lints.clippy] collection_is_never_read = "warn" dbg_macro = "warn" diff --git a/vendor/hayro/src/lib.rs b/vendor/hayro/src/lib.rs index f87d74e9..def102b4 100644 --- a/vendor/hayro/src/lib.rs +++ b/vendor/hayro/src/lib.rs @@ -86,6 +86,53 @@ impl<'a> RenderCache<'a> { } } +/// PdfCraft patch: replacement colours for the "reading colours" view setting (Acrobat's +/// "Replace Document Colors"). Solid paints are mapped by luminance: dark paints move towards +/// `text`, light paints towards `background`. Images, shadings and patterns never pass through +/// the mapping. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ColorReplacement { + /// The sRGB colour that dark paints (low luminance) are mapped towards. + pub text: [u8; 3], + /// The sRGB colour that light paints (high luminance) are mapped towards. + pub background: [u8; 3], + /// When `true`, paints with a luminance above 10% are left unchanged, so only near-black + /// text and line art is recoloured. + pub only_black: bool, + /// When `true`, line-art paints are mapped in addition to text paints. + pub line_art: bool, +} + +impl ColorReplacement { + /// Maps a solid paint colour. `is_text` distinguishes glyph fills/strokes from + /// plain path art. Alpha is copied verbatim. Never panics. + pub fn map(&self, rgba8: [u8; 4], is_text: bool) -> [u8; 4] { + if !is_text && !self.line_art { + return rgba8; + } + let [r, g, b, a] = rgba8; + // Luminance of the gamma-encoded sRGB bytes, normalized to `0.0..=1.0`. + let t = (0.2126 * f32::from(r) + 0.7152 * f32::from(g) + 0.0722 * f32::from(b)) / 255.0; + if self.only_black && t > 0.10 { + return rgba8; + } + let [text_r, text_g, text_b] = self.text; + let [background_r, background_g, background_b] = self.background; + let channel = |text_ch: u8, background_ch: u8| { + let mixed = f32::from(text_ch) * (1.0 - t) + f32::from(background_ch) * t; + // The mixture of two channel values with weights summing to one stays within + // `0.0..=255.0` up to float noise; the clamp keeps the cast total anyway. + mixed.round().clamp(0.0, 255.0) as u8 + }; + [ + channel(text_r, background_r), + channel(text_g, background_g), + channel(text_b, background_b), + a, + ] + } +} + /// Settings to apply during rendering. #[derive(Clone, Copy)] pub struct RenderSettings { @@ -107,6 +154,9 @@ pub struct RenderSettings { pub x_offset: f32, /// See `x_offset`. pub y_offset: f32, + /// PdfCraft patch: when set, solid text and line-art paint colours are remapped while + /// rendering; `None` renders exactly as without the patch. + pub color_replacement: Option, } impl Default for RenderSettings { @@ -119,6 +169,7 @@ impl Default for RenderSettings { bg_color: TRANSPARENT, x_offset: 0.0, y_offset: 0.0, + color_replacement: None, } } } @@ -194,7 +245,14 @@ pub fn render_into<'a>( render_mode: RenderMode::OptimizeSpeed, }; - let mut device = Renderer::new(pix_width, pix_height, vc_settings, cache, interpreter_settings.preview); + let mut device = Renderer::new( + pix_width, + pix_height, + vc_settings, + cache, + render_settings.color_replacement, + interpreter_settings.preview, + ); device.ctx.set_paint(render_settings.bg_color); device @@ -230,6 +288,7 @@ pub fn render_pdf( pdf: &Pdf, scale: f32, settings: InterpreterSettings, + color_replacement: Option, range: Option>, ) -> Option> { let cache = RenderCache::new(); @@ -250,6 +309,7 @@ pub fn render_pdf( x_scale: scale, y_scale: scale, bg_color: WHITE, + color_replacement, ..Default::default() }, ); @@ -267,3 +327,343 @@ pub(crate) fn derive_settings(settings: &vello_cpu::RenderSettings) -> vello_cpu ..*settings } } + +// PdfCraft patch: unit tests for the reading-colours mapping. +#[cfg(test)] +mod color_replacement_tests { + use super::ColorReplacement; + + fn config( + text: [u8; 3], + background: [u8; 3], + only_black: bool, + line_art: bool, + ) -> ColorReplacement { + ColorReplacement { + text, + background, + only_black, + line_art, + } + } + + #[test] + fn black_maps_to_text_colour() { + let r = config([0x12, 0x34, 0x56], [0xff, 0xff, 0xff], false, true); + assert_eq!(r.map([0x00, 0x00, 0x00, 0xff], true), [0x12, 0x34, 0x56, 0xff]); + } + + #[test] + fn white_maps_to_background() { + let r = config([0x00, 0x00, 0x00], [0xab, 0xcd, 0xef], false, true); + assert_eq!(r.map([0xff, 0xff, 0xff, 0xff], true), [0xab, 0xcd, 0xef, 0xff]); + } + + #[test] + fn midgrey_interpolates() { + // 0x80 grey between black text and a white background lands near 0x80. + let r = config([0x00, 0x00, 0x00], [0xff, 0xff, 0xff], false, true); + let [mapped_r, mapped_g, mapped_b, mapped_a] = r.map([0x80, 0x80, 0x80, 0xff], true); + assert!(mapped_r.abs_diff(0x80) <= 2); + assert!(mapped_g.abs_diff(0x80) <= 2); + assert!(mapped_b.abs_diff(0x80) <= 2); + assert_eq!(mapped_a, 0xff); + } + + #[test] + fn alpha_copied_verbatim() { + let r = config([0xff, 0x00, 0x00], [0x00, 0x00, 0xff], false, true); + assert_eq!(r.map([0x00, 0x00, 0x00, 137], true), [0xff, 0x00, 0x00, 137]); + assert_eq!(r.map([0xff, 0xff, 0xff, 0], true), [0x00, 0x00, 0xff, 0]); + } + + #[test] + fn only_black_threshold() { + // White text on black: a mapped grey v becomes 255 - v, so mapped and skipped + // values are distinguishable. + let r = config([0xff, 0xff, 0xff], [0x00, 0x00, 0x00], true, true); + // Luminance t = 25/255 ≈ 0.098 is just under the 0.10 threshold: mapped. + assert_eq!(r.map([25, 25, 25, 255], true), [230, 230, 230, 255]); + // Luminance t = 27/255 ≈ 0.106 is just over it: left unchanged. + assert_eq!(r.map([27, 27, 27, 255], true), [27, 27, 27, 255]); + } + + #[test] + fn line_art_false_skips_paths_but_not_text() { + let r = config([0xff, 0xff, 0xff], [0x00, 0x00, 0x00], false, false); + // A plain path fill is left unchanged when line art is off. + assert_eq!(r.map([0x00, 0x00, 0x00, 255], false), [0x00, 0x00, 0x00, 255]); + // Text fills/strokes still map. + assert_eq!(r.map([0x00, 0x00, 0x00, 255], true), [0xff, 0xff, 0xff, 255]); + } +} + +// PdfCraft patch: end-to-end test for the reading-colours renderer hook. +#[cfg(test)] +mod color_replacement_render_tests { + use super::{ColorReplacement, InterpreterSettings, Pdf, Pixmap, render_pdf}; + use vello_cpu::color::{AlphaColor, Srgb}; + + /// A one-page 200×100 pt PDF with a white background rect, black Helvetica text, + /// a solid red `rg` rect, a CMYK-filled rect and a 4×4 uncompressed `/DeviceRGB` + /// image. Byte-level object layout follows `fixture()` in + /// `crates/automation/tests/automation.rs`. + fn fixture() -> Vec { + let body = "1 1 1 rg 0 0 200 100 re f\n\ + 0 0 0 rg BT /F1 24 Tf 20 70 Td (HI) Tj ET\n\ + 1 0 0 rg 20 20 50 40 re f\n\ + 1 0 0 0 k 100 20 50 40 re f\n\ + q 20 0 0 20 160 20 cm /Im0 Do Q"; + // 16 opaque pure-blue pixels; blue's low luminance makes an (incorrect) mapping + // immediately visible as a light grey. + let image: Vec = (0..16).flat_map(|_| [0x00_u8, 0x00, 0xff]).collect(); + + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 100] >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> \ + /XObject << /Im0 6 0 R >> >> >>" + .to_vec(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(), + ]; + let mut image_obj = format!( + "<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB \ + /BitsPerComponent 8 /Length {} >>\nstream\n", + image.len() + ) + .into_bytes(); + image_obj.extend_from_slice(&image); + image_obj.extend_from_slice(b"\nendstream"); + objs.push(image_obj); + + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", + objs.len() + 1 + ) + .as_bytes(), + ); + out + } + + fn replacement(line_art: bool) -> ColorReplacement { + ColorReplacement { + text: [0xff, 0xff, 0xff], + background: [0x00, 0x00, 0x00], + only_black: false, + line_art, + } + } + + /// A one-page 100×50 pt PDF whose only content is a 4×4 `/ImageMask` stencil (CCITT-style + /// line art) painted with the current fill colour, black. Each row's four samples are + /// `0011`: under `/Decode [0 1]` a sample value of 0 marks the painted area, so the left + /// half of the 40×40 pt image paints and the right half does not. + fn stencil_fixture() -> Vec { + let body = "0 0 0 rg q 40 0 0 40 30 5 cm /Im0 Do Q"; + // Four rows, one byte each (a 4-wide row uses the top nibble): samples 0,0,1,1 — + // under Decode [0 1] the zeros paint, so the left half of every row is ink and the + // right half is unpainted. + let mask: [u8; 4] = [0x30, 0x30, 0x30, 0x30]; + + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 50] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>" + .to_vec(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(), + { + let mut o = format!( + "<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ImageMask true \ + /BitsPerComponent 1 /Decode [0 1] /Length {} >>\nstream\n", + mask.len() + ) + .into_bytes(); + o.extend_from_slice(&mask); + o.extend_from_slice(b"\nendstream"); + o + }, + ]; + + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", + objs.len() + 1 + ) + .as_bytes(), + ); + out + } + + /// Renders the stencil fixture the way the app's render crate drives hayro for a + /// white-on-black user: the page clears to `bg` (`RenderSettings.bg_color`) while the + /// solid paints go through `color_replacement`. `render_pdf` cannot express the mapped + /// clear colour (it always clears white). + fn render_stencil_on( + bg: AlphaColor, + color_replacement: Option, + ) -> Pixmap { + use super::{RenderCache, RenderSettings, render}; + + let pdf = Pdf::new(stencil_fixture()).unwrap(); + let cache = RenderCache::new(); + let settings = RenderSettings { + x_scale: 1.0, + y_scale: 1.0, + bg_color: bg, + color_replacement, + ..RenderSettings::default() + }; + render( + pdf.pages().get(0).expect("the fixture has one page"), + &cache, + &InterpreterSettings::default(), + &settings, + ) + } + + fn render(color_replacement: Option) -> Pixmap { + let pdf = Pdf::new(fixture()).unwrap(); + render_pdf( + &pdf, + 1.0, + InterpreterSettings::default(), + color_replacement, + None, + ) + .unwrap() + .remove(0) + } + + /// Samples the pixel at (x, y); all fixture content is opaque, so the premultiplied + /// value is the straight colour. + fn sample(pixmap: &Pixmap, x: u16, y: u16) -> [u8; 4] { + let p = pixmap.sample(x, y); + [p.r, p.g, p.b, p.a] + } + + fn count_pixels( + pixmap: &Pixmap, + region: (u16, u16, u16, u16), + matches: impl Fn([u8; 4]) -> bool, + ) -> u32 { + let (x0, y0, x1, y1) = region; + let mut count = 0; + for y in y0..y1 { + for x in x0..x1 { + if matches(sample(pixmap, x, y)) { + count += 1; + } + } + } + count + } + + #[test] + fn color_replacement_maps_text_and_line_art() { + let is_white = |p: [u8; 4]| p == [255, 255, 255, 255]; + let is_black = |p: [u8; 4]| p == [0, 0, 0, 255]; + + let control = render(None); + let replaced = render(Some(replacement(true))); + + // Background: the full-page white rect is line art and maps to the + // `background` colour exactly (t = 1.0). + assert_eq!(sample(&control, 5, 5), [255, 255, 255, 255]); + assert_eq!(sample(&replaced, 5, 5), [0, 0, 0, 255]); + + // Text: solid black glyph cores in the control, fully white after replacement. + // Region covers the "HI" glyphs (PDF baseline y=70, 24 pt → device y 10..34). + let text_region = (16, 10, 56, 34); + assert!(count_pixels(&control, text_region, is_black) >= 10); + assert!(count_pixels(&replaced, text_region, is_white) >= 10); + + // Red rect: luminance t ≈ 0.2126 maps every channel to 255·(1−t) ≈ 201 (≈0xC9), + // a near-white neutral grey. + assert_eq!(sample(&control, 45, 60), [255, 0, 0, 255]); + let [r, g, b, a] = sample(&replaced, 45, 60); + assert!(r.abs_diff(201) <= 4, "red channel {r} not ≈ 201"); + assert!(g.abs_diff(201) <= 4 && b.abs_diff(201) <= 4, "g/b not ≈ 201: {g}/{b}"); + assert_eq!(a, 255); + + // CMYK rect (cyan): mapped to a neutral grey that differs from the control + // colour, whatever exact value the CMYK→RGB transform produces. + let cmyk_control = sample(&control, 125, 60); + let cmyk_replaced = sample(&replaced, 125, 60); + assert_ne!(cmyk_control[..3], cmyk_replaced[..3]); + assert!( + (i32::from(cmyk_replaced[0]) - i32::from(cmyk_replaced[1])).abs() <= 2 + && (i32::from(cmyk_replaced[1]) - i32::from(cmyk_replaced[2])).abs() <= 2, + "mapped CMYK not neutral: {cmyk_replaced:?}" + ); + assert_eq!(cmyk_replaced[3], 255); + + // Image pixels pass through untouched, identical to the control render. + assert_eq!(sample(&control, 170, 70), [0, 0, 255, 255]); + assert_eq!(sample(&replaced, 170, 70), [0, 0, 255, 255]); + + // With line-art replacement off, the red rect and the background rect keep + // their original colours while text is still replaced. + let no_line_art = render(Some(replacement(false))); + assert_eq!(sample(&no_line_art, 45, 60), [255, 0, 0, 255]); + assert_eq!(sample(&no_line_art, 5, 5), [255, 255, 255, 255]); + assert!(count_pixels(&no_line_art, text_region, is_white) >= 10); + } + + /// A stencil mask's ink paints with the mapped solid colour on the mapped background: + /// it is line art in disguise, so white-on-black must leave it visible (black ink on + /// black would disappear). The image occupies device x 30..70, y 5..45 (PDF 30..70 × + /// 5..45 pt); its left half is painted ink, its right half shows the page background. + #[test] + fn color_replacement_maps_stencil_mask_ink() { + use vello_cpu::color::palette::css::{BLACK, WHITE}; + + let control = render_stencil_on(WHITE, None); + let replaced = render_stencil_on(BLACK, Some(replacement(true))); + + // Control: the page clears white and the stencil's ink keeps its black fill colour. + assert_eq!(sample(&control, 5, 5), [255, 255, 255, 255]); + assert_eq!(sample(&control, 40, 25), [0, 0, 0, 255]); + // With white-on-black, the background clears black and the ink maps to white: ink + // and background stay distinguishable, and each matches its mapped colour exactly. + assert_eq!(sample(&replaced, 5, 5), [0, 0, 0, 255]); + let ink = sample(&replaced, 40, 25); + assert_eq!(ink, [255, 255, 255, 255]); + assert_ne!(ink[..3], sample(&replaced, 5, 5)[..3], "ink must differ from the background"); + // The unpainted right half of the image area shows the mapped background. + assert_eq!(sample(&replaced, 60, 25), [0, 0, 0, 255]); + + // The line-art gate holds: with line-art replacement off, the ink keeps its + // original colour while the page still clears to the background. + let no_line_art = render_stencil_on(BLACK, Some(replacement(false))); + assert_eq!(sample(&no_line_art, 5, 5), [0, 0, 0, 255]); + assert_eq!(sample(&no_line_art, 40, 25), [0, 0, 0, 255]); + } +} diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index b4ee1b68..2d82a4a0 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -1,4 +1,5 @@ -use crate::{RenderCache, derive_settings}; +use crate::{ColorReplacement, RenderCache, derive_settings}; +use hayro_interpret::color::Color; use hayro_interpret::encode::EncodedShadingPattern; use hayro_interpret::font::Glyph; use hayro_interpret::pattern::Pattern; @@ -402,6 +403,9 @@ fn may_paint_rect(rect: Rect, transform: Affine, width: u16, height: u16) -> boo pub(crate) struct Renderer { pub(crate) ctx: RenderContext, + /// PdfCraft patch: when set, solid text and line-art paint colours are remapped + /// while rendering (the "reading colours" view setting). + color_replacement: Option, pub(crate) inside_pattern: bool, /// Soft masks drawn during this render. PdfCraft note: it belongs to one `Renderer` (one /// render, every nested renderer starting empty) and so to one `preview` setting: a mask @@ -430,10 +434,12 @@ impl Renderer { height: u16, settings: RenderSettings, cache: &RenderCache<'_>, + color_replacement: Option, preview: bool, ) -> Self { Self { ctx: RenderContext::new_with(width, height, settings), + color_replacement, inside_pattern: false, soft_mask_cache: HashMap::default(), outline_cache: cache.outline_cache.clone(), @@ -529,6 +535,9 @@ impl Renderer { self.ctx.height(), derive_settings(self.ctx.render_settings()), ), + // PdfCraft patch: this renders an image's alpha mask, which must keep its + // original values, so it is never remapped by the reading-colours setting. + color_replacement: None, inside_pattern: false, soft_mask_cache: HashMap::default(), outline_cache: self.outline_cache.clone(), @@ -958,6 +967,25 @@ impl Renderer { .fill_rect(&Rect::new(0.0, 0.0, width as f64, height as f64)); } + /// PdfCraft patch: remaps a solid paint colour through `color_replacement`, if one is + /// set. `Paint::Pattern` (images, shadings, tiling patterns) always passes through + /// untouched. + fn replaced<'a>(&self, paint: &Paint<'a>, is_text: bool) -> Paint<'a> { + let Some(replacement) = self.color_replacement else { + return paint.clone(); + }; + match paint { + Paint::Color(c) => { + let mapped = replacement.map(c.to_rgba().to_rgba8(), is_text); + let rgba = hayro_interpret::color::AlphaColor::from_rgba8( + mapped[0], mapped[1], mapped[2], mapped[3], + ); + Paint::Color(Color::from_rgba(rgba)) + } + other => other.clone(), + } + } + #[must_use] fn set_paint(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> Option { let mut paint_transform = Affine::IDENTITY; @@ -1069,6 +1097,9 @@ impl Renderer { pix_height, derive_settings(self.ctx.render_settings()), ), + // PdfCraft patch: a pattern cell is painted as one artwork, so its + // content is never remapped by the reading-colours setting. + color_replacement: None, cur_mask: None, inside_pattern: true, soft_mask_cache: HashMap::default(), @@ -1137,7 +1168,9 @@ impl Renderer { return; } - let clip_path = self.set_paint(paint, path, true); + // PdfCraft patch: apply the reading-colours mapping before painting. + let paint = self.replaced(paint, is_text); + let clip_path = self.set_paint(&paint, path, true); if let Some(clip_path) = clip_path.as_ref() { self.push_clip_path_inner(clip_path, FillRule::NonZero); } @@ -1153,6 +1186,9 @@ impl Renderer { transform: Affine, paint: &Paint<'_>, fill_rule: FillRule, + // PdfCraft patch: distinguishes glyph fills from plain line art for the + // reading-colours mapping. + is_text: bool, ) { self.ctx.set_fill_rule(convert_fill_rule(fill_rule)); self.ctx.set_transform(transform); @@ -1161,7 +1197,9 @@ impl Renderer { return; } - let clip_path = self.set_paint(paint, path, false); + // PdfCraft patch: apply the reading-colours mapping before painting. + let paint = self.replaced(paint, is_text); + let clip_path = self.set_paint(&paint, path, false); if let Some(clip_path) = clip_path.as_ref() { self.push_clip_path_inner(clip_path, fill_rule); } @@ -1189,9 +1227,14 @@ impl Renderer { transform * glyph_transform, paint, FillRule::NonZero, + // PdfCraft patch: glyph outlines are text for the reading-colours mapping. + true, ); } Glyph::Type3(s) => { + // PdfCraft patch: a type 3 glyph's content stream keeps painting as line art + // (its internal ops reach `fill_path`/`stroke_path` with `is_text: false`), so + // reading colours remap it only when line-art replacement is on. self.in_type3_glyph = true; s.interpret(self, transform, glyph_transform, paint); self.in_type3_glyph = false; @@ -1293,6 +1336,11 @@ impl<'a> Device<'a> for Renderer { stencil.scale_factors.1 as f64, ); + // PdfCraft patch: a stencil mask is line art in disguise, so its solid + // paint goes through the reading-colours mapping (line-art gate) like + // every other solid fill; baked in raw, its ink would stay at its + // original colour and vanish into a mapped background. + let paint = &self.replaced(paint, false); match paint { Paint::Color(c) => { let color = c.to_rgba().to_rgba8(); @@ -1363,6 +1411,10 @@ impl<'a> Device<'a> for Renderer { height, derive_settings(self.ctx.render_settings()), ), + // PdfCraft patch: a stencil mask must keep its original + // coverage values, so its content is never remapped by + // the reading-colours setting. + color_replacement: None, inside_pattern: false, soft_mask_cache: HashMap::default(), outline_cache: self.outline_cache.clone(), @@ -1499,7 +1551,7 @@ impl<'a> Device<'a> for Renderer { ) { match draw_mode { PathDrawMode::Fill(f) => { - Self::fill_path(self, path, transform, paint, *f); + Self::fill_path(self, path, transform, paint, *f, false); } PathDrawMode::Stroke(s) => { Self::stroke_path(self, path, transform, paint, s, false); @@ -1524,7 +1576,9 @@ impl<'a> Device<'a> for Renderer { return; } - let clip_path = self.set_paint(paint, &path, false); + // PdfCraft patch: apply the reading-colours mapping before painting. + let paint = self.replaced(paint, false); + let clip_path = self.set_paint(&paint, &path, false); if let Some(clip_path) = clip_path.as_ref() { self.push_clip_path_inner(clip_path, *fill_rule); } @@ -1643,6 +1697,9 @@ fn render_shading_texture( fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, height: u16, preview: bool) -> Mask { let mut renderer = Renderer { ctx: RenderContext::new_with(width, height, derive_settings(&settings)), + // PdfCraft patch: a soft mask must keep its original luminance values, so its + // content is never remapped by the reading-colours setting. + color_replacement: None, inside_pattern: false, cur_mask: None, soft_mask_cache: HashMap::default(),