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.
This commit is contained in:
Taha-Mahmoodi
2026-10-23 00:08:53 +07:00
committed by bflatastic
parent 7c9edfaf7f
commit 6b10b159b9
2 changed files with 38 additions and 8 deletions
+16 -8
View File
@@ -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<u8> = 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);
+22
View File
@@ -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::<Vec<_>>().join(" ");
assert!(ops.contains("3 Tr"), "the characters as invisible text");
assert_eq!(texts(&import_as(&scaled, TextAs::Text)), ["Dither"]);
}