From 6b10b159b92e9aad7c44df1a01d32921804f858c Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:44:19 +0430 Subject: [PATCH] Gradients: dithered type in PDF keeps its resolution when scaled and stays searchable Review follow-up (CodeRabbit): a gradient drawn as pixels was sized from its area in the space it is drawn in, so type scaled 4 times got a quarter of the raster effects resolution; the image is now sized from the area on the page, within the same pixel caps. And type whose fill is drawn as art (a dithered or freeform gradient's image, a pattern) went out as outlines only; its characters are now also written as invisible text (rendering mode 3), as unpainted type already is, so they can be selected and searched. --- crates/pdf/src/export.rs | 24 ++++++++++++++++-------- crates/pdf/src/tests_realtext.rs | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index e89dd6601..bf21d6b1f 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -602,7 +602,8 @@ pub(crate) struct Exporter<'a> { pub overprint: bool, /// An overprinting fill or stroke was drawn so. pub overprinted: bool, - /// Characters with neither fill nor stroke were written as text in forms marked invisible. + /// Characters with neither fill nor stroke, or whose fill is drawn as art (outlines filled + /// with an image or pattern), were written as text in forms marked invisible. pub invisible: bool, /// Inline graphics being drawn inside inline graphics (text in a symbol shown inline in /// text…): deeper ones are left out. @@ -1336,13 +1337,17 @@ impl Exporter<'_> { } /// Draw gradient `g`, placed on `bounds`, as an image over `area`, sampled at the document's - /// raster effects resolution: a freeform gradient's colour field (at most [`MAX_FIELD_PX`] a - /// side), or a linear or radial gradient (at most [`MAX_GRADIENT_PX`] a side). + /// raster effects resolution where it is drawn (`area` as the current transform sizes it, so + /// scaled type gets its pixels too): a freeform gradient's colour field (at most + /// [`MAX_FIELD_PX`] a side), or a linear or radial gradient (at most [`MAX_GRADIENT_PX`] a side). fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect) { use vectorcraft_color::freeform::{painted_box, spread_scale}; let Some(b) = painted_box(bounds) else { return }; let area = area.abs(); - let long = area.width().max(area.height()); + // The area's sides in points on the page. + let [m0, m1, m2, m3, _, _] = ctm(s).as_coeffs(); + let (w, h) = (area.width() * m0.hypot(m1), area.height() * m2.hypot(m3)); + let long = w.max(h); if !(long > 1e-9 && long.is_finite()) { return; } @@ -1350,7 +1355,7 @@ impl Exporter<'_> { let max = if freeform { MAX_FIELD_PX } else { MAX_GRADIENT_PX }; let k = (self.doc.raster_effects_ppi / 72.0).min(max / long); let along = |len: f64| (len * k).ceil().clamp(1.0, max) as u16; - let (cols, rows) = (along(area.width()), along(area.height())); + let (cols, rows) = (along(w), along(h)); let rgba: Vec = if freeform { let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb()); field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect() @@ -1588,9 +1593,12 @@ impl Exporter<'_> { } s.pop(); }); - // Characters with neither fill nor stroke (a scanned page's recognized text) are still - // text: written invisible (rendering mode 3), so they can still be selected and searched. - if let Some(text) = plain.as_deref().filter(|_| fill.is_none() && !run.style.has_stroke()) { + // Characters with neither fill nor stroke (a scanned page's recognized text), or whose + // fill is drawn as art (a dithered gradient's image, a pattern: outlines, not text), + // are still text: written invisible (rendering mode 3), so they can still be selected + // and searched. + let unpainted = fill.is_none() && !run.style.has_stroke(); + if let Some(text) = plain.as_deref().filter(|_| unpainted || self.area_paint(fill, true)) { self.invisible = true; crate::forms::form(s, Mark::Invisible, |s| { s.push_transform(&text_space); diff --git a/crates/pdf/src/tests_realtext.rs b/crates/pdf/src/tests_realtext.rs index 8e32c429f..b85ec9d20 100644 --- a/crates/pdf/src/tests_realtext.rs +++ b/crates/pdf/src/tests_realtext.rs @@ -573,3 +573,25 @@ fn evenly_tracked_type_on_a_path_keeps_its_length() { assert!(g.distance(*w) < 0.3, "{got:?}\nvs {want:?}"); } } + +/// Type filled with a dithered gradient is an image clipped to its outlines, sampled at the raster +/// effects resolution on the page however the type is scaled, with its characters under it as +/// invisible text (rendering mode 3), so they can still be selected and searched. +#[test] +fn dithered_type_keeps_its_resolution_when_scaled_and_stays_searchable() { + let fill = Paint::Gradient(Box::new(GradientPaint::new(Gradient { dither: true, ..Default::default() }))); + let big = TextObject::point(Point::new(20.0, 80.0), "Dither", CharStyle { fill: fill.clone(), ..style(40.0) }); + let mut scaled = TextObject::point(Point::new(5.0, 20.0), "Dither", CharStyle { fill, ..style(10.0) }); + scaled.xf = Affine::scale(4.0) * scaled.xf; + let width = |bytes: &[u8]| -> u32 { + let s = String::from_utf8_lossy(bytes); + s.split("/Width ").nth(1).and_then(|w| w.split(|c: char| !c.is_ascii_digit()).next()).and_then(|n| n.parse().ok()).unwrap() + }; + let (big, scaled) = (pdf(&doc(vec![big]), false).bytes, pdf(&doc(vec![scaled]), false).bytes); + let (wb, ws) = (width(&big), width(&scaled)); + // At 72 ppi a pixel a point: 40 pt "Dither" is about 100 points wide. + assert!(wb >= 80 && wb.abs_diff(ws) <= 2, "40 pt type {wb} pixels wide, 10 pt type scaled 4 times {ws}"); + let ops = String::from_utf8_lossy(&scaled).split_whitespace().collect::>().join(" "); + assert!(ops.contains("3 Tr"), "the characters as invisible text"); + assert_eq!(texts(&import_as(&scaled, TextAs::Text)), ["Dither"]); +}