Skip to main content

docling_pdf/raster/
stretch.rs

1//! pdfium's `CStretchEngine` (core/fxge/dib/cstretchengine.cpp), ported so an
2//! embedded image can be scaled to the very bytes pdfium draws.
3//!
4//! pdfium resamples every axis-aligned image through this engine: a weight
5//! table per axis — area coverage when shrinking, two-tap linear
6//! interpolation when enlarging, nearest when smoothing is off — in 16.16
7//! fixed point, a horizontal pass into an 8-bit intermediate buffer over the
8//! source rows the clip needs, then a vertical pass over that buffer. Every
9//! rounding of the original is kept: the weights are `round(w · 65536)` with
10//! the running rounding error carried into the next tap and the last tap
11//! taking whatever is left of 65536 (unsigned, wrapping), each pass truncates
12//! its 32-bit accumulator with `>> 16`, and the source and destination clips
13//! are computed from a *float* scale. Negative destination sizes flip the
14//! axis, as they do in pdfium (`CFX_AggImageRenderer` negates `dest_width`
15//! for `a < 0` and `dest_height` for `d > 0`).
16//!
17//! The port stays in the integer/float types of the original — `u32`
18//! wrapping arithmetic, `f64` weights from an `f32` scale — because the
19//! result is compared byte for byte with pdfium's bitmap
20//! (`raster::tests::matches_pdfium_on_the_scanned_fixtures`).
21
22/// `FXDIB_ResampleOptions` — the two flags that reach the weight table.
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub struct Options {
25    /// `bInterpolateBilinear`: two-tap interpolation when enlarging (the
26    /// image's `/Interpolate`, or pdfium's own heuristic below).
27    pub bilinear: bool,
28    /// `bNoSmoothing`: nearest neighbour (`FPDF_RENDER_NO_SMOOTHIMAGE`).
29    pub no_smoothing: bool,
30}
31
32/// pdfium's `FX_RECT`: integer, `left/top` inclusive, `right/bottom`
33/// exclusive, y down.
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
35pub struct Rect {
36    pub left: i32,
37    pub top: i32,
38    pub right: i32,
39    pub bottom: i32,
40}
41
42impl Rect {
43    pub fn new(left: i32, top: i32, right: i32, bottom: i32) -> Self {
44        Rect {
45            left,
46            top,
47            right,
48            bottom,
49        }
50    }
51
52    pub fn width(&self) -> i32 {
53        self.right - self.left
54    }
55
56    pub fn height(&self) -> i32 {
57        self.bottom - self.top
58    }
59
60    pub fn is_empty(&self) -> bool {
61        self.left >= self.right || self.top >= self.bottom
62    }
63
64    pub fn normalize(&mut self) {
65        if self.left > self.right {
66            std::mem::swap(&mut self.left, &mut self.right);
67        }
68        if self.top > self.bottom {
69            std::mem::swap(&mut self.top, &mut self.bottom);
70        }
71    }
72
73    /// `FX_RECT::Intersect`: both normalized; an empty result is all zeros.
74    pub fn intersect(&mut self, other: &Rect) {
75        let mut o = *other;
76        o.normalize();
77        self.normalize();
78        self.left = self.left.max(o.left);
79        self.top = self.top.max(o.top);
80        self.right = self.right.min(o.right);
81        self.bottom = self.bottom.min(o.bottom);
82        if self.left > self.right || self.top > self.bottom {
83            *self = Rect::default();
84        }
85    }
86
87    pub fn offset(&mut self, dx: i32, dy: i32) {
88        self.left += dx;
89        self.right += dx;
90        self.top += dy;
91        self.bottom += dy;
92    }
93
94    /// `FX_RECT::SwappedClipBox`: the clip of a 90°-rotated placement, in the
95    /// coordinates of the un-rotated stretch (`width`/`height` are the
96    /// rotated image's device size).
97    pub fn swapped_clip_box(&self, width: i32, height: i32, flip_x: bool, flip_y: bool) -> Rect {
98        let mut r = Rect::default();
99        if flip_y {
100            r.left = height - self.top;
101            r.right = height - self.bottom;
102        } else {
103            r.left = self.top;
104            r.right = self.bottom;
105        }
106        if flip_x {
107            r.top = width - self.left;
108            r.bottom = width - self.right;
109        } else {
110            r.top = self.left;
111            r.bottom = self.right;
112        }
113        r.normalize();
114        r
115    }
116}
117
118/// The source bitmap, as pdfium's `CPDF_DIB` hands it to the engine.
119#[derive(Clone, Copy, Debug)]
120pub enum Source<'a> {
121    /// 1 bit per pixel, most significant bit first, `stride` bytes per row —
122    /// pdfium's `k1bppRgb`, expanded per row to 0/255 (`Expand1BppRow`) and
123    /// stretched as 8-bit; a palette, when the image has one, is applied to
124    /// the result by the caller (`CFX_ImageStretcher` builds the 256-entry
125    /// gradient for the destination bitmap).
126    Bilevel { data: &'a [u8], stride: usize },
127    /// 8-bit gray, no palette (`k8bppRgb`).
128    Gray8 { data: &'a [u8], stride: usize },
129    /// 8-bit, three channels (`kBgr`; channel order is irrelevant to the
130    /// arithmetic, so the caller may pass RGB).
131    Rgb8 { data: &'a [u8], stride: usize },
132}
133
134impl Source<'_> {
135    fn channels(&self) -> usize {
136        match self {
137            Source::Bilevel { .. } | Source::Gray8 { .. } => 1,
138            Source::Rgb8 { .. } => 3,
139        }
140    }
141}
142
143/// The stretched pixels of the destination clip: `height` rows of `width`
144/// pixels, `channels` bytes each.
145#[derive(Debug, Clone)]
146pub struct Stretched {
147    pub width: usize,
148    pub height: usize,
149    pub channels: usize,
150    pub data: Vec<u8>,
151}
152
153const FIXED_BITS: u32 = 16;
154const FIXED_ONE: u32 = 1 << FIXED_BITS;
155
156/// `FixedFromDouble`: `FXSYS_round(d * 65536)` as an unsigned 32-bit value.
157fn fixed_from_double(d: f64) -> u32 {
158    (d * f64::from(FIXED_ONE)).round() as i64 as u32
159}
160
161/// `PixelFromFixed`: truncate the accumulator.
162fn pixel_from_fixed(fixed: u32) -> u8 {
163    (fixed >> FIXED_BITS) as u8
164}
165
166/// One destination pixel's taps: the inclusive source range and a weight per
167/// position (`weights[j - src_start]`).
168struct PixelWeight {
169    src_start: i32,
170    src_end: i32,
171    weights: Vec<u32>,
172}
173
174impl PixelWeight {
175    fn taps(&self) -> &[u32] {
176        if self.src_end < self.src_start {
177            &[]
178        } else {
179            &self.weights[..(self.src_end - self.src_start + 1) as usize]
180        }
181    }
182}
183
184/// `CStretchEngine::WeightTable::CalculateWeights`.
185fn calculate_weights(
186    dest_len: i32,
187    dest_min: i32,
188    dest_max: i32,
189    src_len: i32,
190    src_min: i32,
191    src_max: i32,
192    options: Options,
193) -> Option<Vec<PixelWeight>> {
194    let bilinear = options.bilinear;
195    if dest_len == 0 {
196        return Some(Vec::new());
197    }
198    if dest_min > dest_max {
199        return None;
200    }
201    let scale = f64::from(src_len) / f64::from(dest_len);
202    let base = if dest_len < 0 {
203        f64::from(src_len)
204    } else {
205        0.0
206    };
207    let weight_count = (scale.abs().ceil() as usize) + 1;
208    let mut table = Vec::with_capacity((dest_max - dest_min) as usize);
209    if options.no_smoothing || scale.abs() < 1.0 {
210        for dest_pixel in dest_min..dest_max {
211            let src_pos = f64::from(dest_pixel) * scale + scale / 2.0 + base;
212            let mut weights = vec![0u32; weight_count.max(2)];
213            let (src_start, src_end);
214            if bilinear {
215                let mut start = (src_pos - 0.5).floor() as i32;
216                let mut end = (src_pos + 0.5).floor() as i32;
217                start = start.max(src_min);
218                end = end.min(src_max - 1);
219                src_start = start;
220                src_end = end;
221                if start >= end {
222                    weights[0] = FIXED_ONE;
223                } else {
224                    let second = fixed_from_double(src_pos - f64::from(start) - 0.5);
225                    weights[0] = FIXED_ONE.wrapping_sub(second);
226                    weights[1] = second;
227                }
228            } else {
229                let pixel_pos = src_pos.floor() as i32;
230                src_start = pixel_pos.max(src_min);
231                src_end = pixel_pos.min(src_max - 1);
232                weights[0] = FIXED_ONE;
233            }
234            table.push(PixelWeight {
235                src_start,
236                src_end,
237                weights,
238            });
239        }
240        return Some(table);
241    }
242
243    for dest_pixel in dest_min..dest_max {
244        let src_start = f64::from(dest_pixel) * scale + base;
245        let src_end = src_start + scale;
246        let mut start_i = src_start.min(src_end).floor() as i32;
247        let mut end_i = src_start.max(src_end).floor() as i32;
248        start_i = start_i.max(src_min);
249        end_i = end_i.min(src_max - 1);
250        if start_i > end_i {
251            start_i = start_i.clamp(0, (src_max - 1).max(0));
252            table.push(PixelWeight {
253                src_start: start_i,
254                src_end: start_i,
255                weights: vec![0; weight_count.max(1)],
256            });
257            continue;
258        }
259        let mut weights = vec![0u32; ((end_i - start_i) as usize + 1).max(weight_count)];
260        let mut remaining = FIXED_ONE;
261        let mut rounding_error = 0.0f64;
262        for j in start_i..end_i {
263            let mut dest_start = (f64::from(j) - base) / scale;
264            let mut dest_end = (f64::from(j + 1) - base) / scale;
265            if dest_start > dest_end {
266                std::mem::swap(&mut dest_start, &mut dest_end);
267            }
268            let area_start = dest_start.max(f64::from(dest_pixel));
269            let area_end = dest_end.min(f64::from(dest_pixel + 1));
270            let weight = (area_end - area_start).max(0.0);
271            let fixed_weight = fixed_from_double(weight + rounding_error);
272            weights[(j - start_i) as usize] = fixed_weight;
273            remaining = remaining.wrapping_sub(fixed_weight);
274            rounding_error = weight - f64::from(fixed_weight) / f64::from(FIXED_ONE);
275        }
276        let mut src_end = end_i;
277        // Unsigned underflow is defined behaviour in the original and lands
278        // in the `RemoveLastWeightAndAdjust` branch.
279        if remaining != 0 && remaining <= FIXED_ONE {
280            weights[(end_i - start_i) as usize] = remaining;
281        } else {
282            if src_end <= start_i {
283                return None;
284            }
285            src_end -= 1;
286            let idx = (src_end - start_i) as usize;
287            weights[idx] = weights[idx].wrapping_add(remaining);
288        }
289        table.push(PixelWeight {
290            src_start: start_i,
291            src_end,
292            weights,
293        });
294    }
295    Some(table)
296}
297
298/// `CStretchEngine::UseInterpolateBilinear`.
299fn use_interpolate_bilinear(
300    options: Options,
301    dest_width: i32,
302    dest_height: i32,
303    src_width: i32,
304    src_height: i32,
305) -> bool {
306    !options.bilinear
307        && !options.no_smoothing
308        && dest_width != 0
309        && i64::from(dest_height.abs() / 8)
310            < i64::from(src_width) * i64::from(src_height) / i64::from(dest_width.abs())
311}
312
313/// Stretch `src` (`src_width` × `src_height`) to `dest_width` × `dest_height`
314/// (negative = flipped on that axis) and return the pixels inside `clip`
315/// (destination coordinates, before any flip) — `CStretchEngine` end to end,
316/// horizontal pass then vertical pass. `None` where pdfium would produce no
317/// bitmap (empty dimensions, or a table it refuses).
318pub fn stretch(
319    src: &Source<'_>,
320    src_width: i32,
321    src_height: i32,
322    dest_width: i32,
323    dest_height: i32,
324    clip: Rect,
325    options: Options,
326) -> Option<Stretched> {
327    if dest_width == 0 || dest_height == 0 || clip.is_empty() || src_width <= 0 || src_height <= 0 {
328        return None;
329    }
330    let channels = src.channels();
331    let resample = if options.no_smoothing {
332        Options {
333            bilinear: false,
334            no_smoothing: true,
335        }
336    } else if use_interpolate_bilinear(options, dest_width, dest_height, src_width, src_height) {
337        Options {
338            bilinear: true,
339            no_smoothing: false,
340        }
341    } else {
342        options
343    };
344    // The source clip is derived from a *float* scale (`static_cast<float>`
345    // of the source size divided by the int destination size).
346    let scale_x = f64::from(src_width as f32 / dest_width as f32);
347    let scale_y = f64::from(src_height as f32 / dest_height as f32);
348    let base_x = if dest_width > 0 {
349        0.0
350    } else {
351        f64::from(dest_width)
352    };
353    let base_y = if dest_height > 0 {
354        0.0
355    } else {
356        f64::from(dest_height)
357    };
358    let mut src_left = scale_x * (f64::from(clip.left) + base_x);
359    let mut src_right = scale_x * (f64::from(clip.right) + base_x);
360    let mut src_top = scale_y * (f64::from(clip.top) + base_y);
361    let mut src_bottom = scale_y * (f64::from(clip.bottom) + base_y);
362    if src_left > src_right {
363        std::mem::swap(&mut src_left, &mut src_right);
364    }
365    if src_top > src_bottom {
366        std::mem::swap(&mut src_top, &mut src_bottom);
367    }
368    let mut src_clip = Rect::new(
369        src_left.floor() as i32,
370        src_top.floor() as i32,
371        src_right.ceil() as i32,
372        src_bottom.ceil() as i32,
373    );
374    src_clip.intersect(&Rect::new(0, 0, src_width, src_height));
375    if src_clip.is_empty() {
376        return None;
377    }
378
379    // Horizontal pass: every source row of the clip → `dest_cols` pixels.
380    let dest_cols = clip.width() as usize;
381    let dest_rows = clip.height() as usize;
382    let horz = calculate_weights(
383        dest_width,
384        clip.left,
385        clip.right,
386        src_width,
387        src_clip.left,
388        src_clip.right,
389        resample,
390    )?;
391    let inter_pitch = dest_cols * channels;
392    let src_rows = src_clip.height() as usize;
393    let mut inter = vec![0u8; inter_pitch * src_rows];
394    let mut expanded = Vec::new();
395    for (r, row) in (src_clip.top..src_clip.bottom).zip(inter.chunks_exact_mut(inter_pitch)) {
396        let r = r as usize;
397        let src_row: &[u8] = match src {
398            Source::Bilevel { data, stride } => {
399                let bytes = data.get(r * stride..(r * stride + stride))?;
400                expanded.clear();
401                expanded.reserve(stride * 8);
402                for &b in bytes {
403                    for bit in 0..8 {
404                        expanded.push(if (b >> (7 - bit)) & 1 == 1 { 255 } else { 0 });
405                    }
406                }
407                &expanded
408            }
409            Source::Gray8 { data, stride } => {
410                data.get(r * stride..r * stride + src_width as usize)?
411            }
412            Source::Rgb8 { data, stride } => {
413                data.get(r * stride..r * stride + 3 * src_width as usize)?
414            }
415        };
416        for (pw, dest) in horz.iter().zip(row.chunks_exact_mut(channels)) {
417            let taps = pw.taps();
418            let start = pw.src_start as usize;
419            for (c, d) in dest.iter_mut().enumerate() {
420                let mut acc: u32 = 0;
421                for (k, &w) in taps.iter().enumerate() {
422                    let v = *src_row.get((start + k) * channels + c)?;
423                    acc = acc.wrapping_add(w.wrapping_mul(u32::from(v)));
424                }
425                *d = pixel_from_fixed(acc);
426            }
427        }
428    }
429
430    // Vertical pass over the intermediate buffer.
431    let vert = calculate_weights(
432        dest_height,
433        clip.top,
434        clip.bottom,
435        src_height,
436        src_clip.top,
437        src_clip.bottom,
438        resample,
439    )?;
440    let mut out = vec![0u8; inter_pitch * dest_rows];
441    let mut accum = vec![0u32; inter_pitch];
442    for (pw, dest) in vert.iter().zip(out.chunks_exact_mut(inter_pitch)) {
443        let taps = pw.taps();
444        accum.fill(0);
445        for (k, &w) in taps.iter().enumerate() {
446            let src_row_index = (pw.src_start + k as i32 - src_clip.top) as usize;
447            let row = inter.get(src_row_index * inter_pitch..(src_row_index + 1) * inter_pitch)?;
448            for (a, &v) in accum.iter_mut().zip(row) {
449                *a = a.wrapping_add(w.wrapping_mul(u32::from(v)));
450            }
451        }
452        for (d, &a) in dest.iter_mut().zip(&accum) {
453            *d = pixel_from_fixed(a);
454        }
455    }
456    Some(Stretched {
457        width: dest_cols,
458        height: dest_rows,
459        channels,
460        data: out,
461    })
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    /// Shrinking a 4-pixel row to 2 pixels: each destination pixel covers
469    /// exactly two source pixels, so the area weights are 0.5 + 0.5 and the
470    /// result is the truncated mean.
471    #[test]
472    fn shrink_by_two_averages_pairs() {
473        let data = [10u8, 20, 30, 41];
474        let out = stretch(
475            &Source::Gray8 {
476                data: &data,
477                stride: 4,
478            },
479            4,
480            1,
481            2,
482            1,
483            Rect::new(0, 0, 2, 1),
484            Options::default(),
485        )
486        .unwrap();
487        assert_eq!(out.data, vec![15, 35]);
488    }
489
490    /// A negative destination width flips the axis: the same averages, in
491    /// reverse order.
492    #[test]
493    fn negative_width_flips() {
494        let data = [10u8, 20, 30, 41];
495        let out = stretch(
496            &Source::Gray8 {
497                data: &data,
498                stride: 4,
499            },
500            4,
501            1,
502            -2,
503            1,
504            Rect::new(0, 0, 2, 1),
505            Options::default(),
506        )
507        .unwrap();
508        assert_eq!(out.data, vec![35, 15]);
509    }
510
511    /// Enlarging 2 → 4 pixels: with `/Interpolate` (or pdfium's heuristic,
512    /// which a 2-pixel source is too small to trigger) the two-tap filter
513    /// interpolates between neighbours and clamps at the edges; without it
514    /// pdfium picks the nearest source pixel.
515    #[test]
516    fn enlarge_interpolates() {
517        let data = [0u8, 100];
518        let src = Source::Gray8 {
519            data: &data,
520            stride: 2,
521        };
522        let bilinear = Options {
523            bilinear: true,
524            no_smoothing: false,
525        };
526        let out = stretch(&src, 2, 1, 4, 1, Rect::new(0, 0, 4, 1), bilinear).unwrap();
527        // src_pos = 0.25, 0.75, 1.25, 1.75 → taps (0), (0,1 @ .25), (0,1 @ .75), (1)
528        assert_eq!(out.data, vec![0, 25, 75, 100]);
529        let out = stretch(&src, 2, 1, 4, 1, Rect::new(0, 0, 4, 1), Options::default()).unwrap();
530        assert_eq!(out.data, vec![0, 0, 100, 100]);
531    }
532
533    #[test]
534    fn bilevel_rows_expand_to_0_and_255() {
535        let data = [0b1010_0000u8];
536        let out = stretch(
537            &Source::Bilevel {
538                data: &data,
539                stride: 1,
540            },
541            4,
542            1,
543            4,
544            1,
545            Rect::new(0, 0, 4, 1),
546            Options::default(),
547        )
548        .unwrap();
549        assert_eq!(out.data, vec![255, 0, 255, 0]);
550    }
551
552    #[test]
553    fn swapped_clip_box_matches_pdfium() {
554        let r = Rect::new(1, 2, 5, 9);
555        let s = r.swapped_clip_box(10, 20, false, false);
556        assert_eq!(s, Rect::new(2, 1, 9, 5));
557        let s = r.swapped_clip_box(10, 20, true, true);
558        assert_eq!(s, Rect::new(11, 5, 18, 9));
559    }
560}