1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
//! Backwards-compatible pixmap module.
//!
//! The implementation lives in the standalone `djvu-pixmap` crate. This module
//! preserves the historical `djvu_rs::pixmap::{Pixmap, GrayPixmap}` path and
//! hosts [`scale_lanczos3`], the context-free Lanczos-3 image resampler shared
//! by the render paths (it has no DjVu semantics, so it belongs with the pixmap
//! type rather than on the render interface).
pub use djvu_pixmap::{GrayPixmap, Pixmap};
// `vec!` / `Vec` are not in the no_std prelude; bring them in from `alloc` (the
// std prelude already provides them). Matches the cfg-gated import pattern used
// by other modules.
#[cfg(not(feature = "std"))]
use alloc::{vec, vec::Vec};
/// Lanczos-3 kernel: `sinc(x) * sinc(x/3)` for `|x| < 3`, 0 otherwise.
///
/// Uses the normalised sinc: `sinc(x) = sin(π x) / (π x)`, `sinc(0) = 1`.
#[inline]
fn lanczos3_kernel(x: f32) -> f32 {
let ax = x.abs();
if ax >= 3.0 {
return 0.0;
}
if ax < 1e-6 {
return 1.0;
}
let pi_x = core::f32::consts::PI * ax;
let sinc_x = pi_x.sin() / pi_x;
let pi_x3 = pi_x / 3.0;
let sinc_x3 = pi_x3.sin() / pi_x3;
sinc_x * sinc_x3
}
/// Scale `src` to `dst_w × dst_h` using separable Lanczos-3 resampling.
///
/// Two-pass implementation:
/// 1. Horizontal pass: `src_w × src_h` → `dst_w × src_h` intermediate.
/// 2. Vertical pass: `dst_w × src_h` → `dst_w × dst_h` output.
///
/// Only RGBA pixmaps are handled (alpha is passed through unchanged at 255).
pub(crate) fn scale_lanczos3(src: &Pixmap, dst_w: u32, dst_h: u32) -> Pixmap {
let src_w = src.width;
let src_h = src.height;
// Short-circuit: nothing to scale.
if src_w == dst_w && src_h == dst_h {
return src.clone();
}
if dst_w == 0 || dst_h == 0 {
return Pixmap::white(dst_w.max(1), dst_h.max(1));
}
// ── Horizontal pass ───────────────────────────────────────────────────────
// Map each output column `ox` (0..dst_w) to a source position, then sum
// the Lanczos-3 kernel over the contributing source columns.
let h_scale = src_w as f32 / dst_w as f32;
let h_support = (3.0_f32 * h_scale.max(1.0)).ceil() as i32; // kernel half-width in src pixels
// The horizontal weight `lanczos3_kernel((sx - cx)/h_scale)` and the
// normaliser depend only on the output column `ox` (via `cx`), never on the
// row `oy`. Precompute, once, the contributor start `x0` + kernel weights +
// norm for every output column, then the per-row loop is a pure weighted
// sum. This hoists the sin-heavy kernel evaluation out of the `src_h` row
// loop — the same idea that made the #448 vertical pass ~22% faster — and
// combines it with row-pointer indexing so the source/destination rows are
// read without per-pixel `get_rgb`/`set_rgb` bounds checks. Bit-identical:
// identical weights summed in identical order with the identical norm.
let dw = dst_w as usize;
let sw = src_w as usize;
struct HCol {
x0: usize,
weights: Vec<f32>,
norm: f32,
}
let hcols: Vec<HCol> = (0..dst_w)
.map(|ox| {
let cx = (ox as f32 + 0.5) * h_scale - 0.5;
let x0 = (cx.floor() as i32 - h_support + 1).max(0);
let x1 = (cx.floor() as i32 + h_support).min(src_w as i32 - 1);
let mut weights = Vec::with_capacity((x1 - x0 + 1).max(0) as usize);
let mut w_sum = 0.0_f32;
for sx in x0..=x1 {
let w = lanczos3_kernel((sx as f32 - cx) / h_scale.max(1.0));
weights.push(w);
w_sum += w;
}
let norm = if w_sum.abs() > 1e-6 { 1.0 / w_sum } else { 1.0 };
HCol {
x0: x0 as usize,
weights,
norm,
}
})
.collect();
let mut mid = Pixmap::new(dst_w, src_h, 255, 255, 255, 255);
for oy in 0..src_h as usize {
let src_row = &src.data[oy * sw * 4..(oy + 1) * sw * 4];
let mid_row = &mut mid.data[oy * dw * 4..(oy + 1) * dw * 4];
for (ox, col) in hcols.iter().enumerate() {
let mut r = 0.0_f32;
let mut g = 0.0_f32;
let mut b = 0.0_f32;
for (i, &w) in col.weights.iter().enumerate() {
let base = (col.x0 + i) * 4;
r += src_row[base] as f32 * w;
g += src_row[base + 1] as f32 * w;
b += src_row[base + 2] as f32 * w;
}
let ob = ox * 4;
mid_row[ob] = (r * col.norm).round().clamp(0.0, 255.0) as u8;
mid_row[ob + 1] = (g * col.norm).round().clamp(0.0, 255.0) as u8;
mid_row[ob + 2] = (b * col.norm).round().clamp(0.0, 255.0) as u8;
// mid_row[ob + 3] stays 255 (from Pixmap::new alpha init).
}
}
// ── Vertical pass ─────────────────────────────────────────────────────────
let v_scale = src_h as f32 / dst_h as f32;
let v_support = (3.0_f32 * v_scale.max(1.0)).ceil() as i32;
// #448: the vertical weight depends only on (oy, sy), not ox, so hoist the
// `lanczos3_kernel` evaluations out of the per-column loop (LLVM cannot LICM
// the opaque `f32::sin` calls). Accumulate row-major into per-column buffers so
// `mid` is read sequentially instead of striding by `dst_w*4` per sy. The
// per-column sum is over the same `sy` values in the same order, so the result
// is bit-identical to the column-major version.
let mut out = Pixmap::new(dst_w, dst_h, 255, 255, 255, 255);
let mut acc_r = vec![0.0_f32; dw];
let mut acc_g = vec![0.0_f32; dw];
let mut acc_b = vec![0.0_f32; dw];
for oy in 0..dst_h {
let cy = (oy as f32 + 0.5) * v_scale - 0.5;
let y0 = (cy.floor() as i32 - v_support + 1).max(0);
let y1 = (cy.floor() as i32 + v_support).min(src_h as i32 - 1);
acc_r.iter_mut().for_each(|v| *v = 0.0);
acc_g.iter_mut().for_each(|v| *v = 0.0);
acc_b.iter_mut().for_each(|v| *v = 0.0);
let mut w_sum = 0.0_f32;
for sy in y0..=y1 {
let w = lanczos3_kernel((sy as f32 - cy) / v_scale.max(1.0));
w_sum += w;
let row = &mid.data[sy as usize * dw * 4..];
for ox in 0..dw {
let base = ox * 4;
acc_r[ox] += row[base] as f32 * w;
acc_g[ox] += row[base + 1] as f32 * w;
acc_b[ox] += row[base + 2] as f32 * w;
}
}
let norm = if w_sum.abs() > 1e-6 { 1.0 / w_sum } else { 1.0 };
for ox in 0..dst_w {
out.set_rgb(
ox,
oy,
(acc_r[ox as usize] * norm).round().clamp(0.0, 255.0) as u8,
(acc_g[ox as usize] * norm).round().clamp(0.0, 255.0) as u8,
(acc_b[ox as usize] * norm).round().clamp(0.0, 255.0) as u8,
);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// `lanczos3_kernel(0)` == 1.0 (unity at origin).
#[test]
fn lanczos3_kernel_unity_at_zero() {
assert!((lanczos3_kernel(0.0) - 1.0).abs() < 1e-5);
}
/// `lanczos3_kernel` is zero outside |x| ≥ 3.
#[test]
fn lanczos3_kernel_zero_outside_support() {
assert_eq!(lanczos3_kernel(3.0), 0.0);
assert_eq!(lanczos3_kernel(-3.5), 0.0);
assert_eq!(lanczos3_kernel(10.0), 0.0);
}
/// `scale_lanczos3` preserves dimensions.
#[test]
fn scale_lanczos3_correct_dimensions() {
let src = Pixmap::white(100, 80);
let dst = scale_lanczos3(&src, 50, 40);
assert_eq!(dst.width, 50);
assert_eq!(dst.height, 40);
}
/// `scale_lanczos3` returns a clone when source and target match.
#[test]
fn scale_lanczos3_noop_when_same_size() {
let src = Pixmap::new(4, 4, 200, 100, 50, 255);
let dst = scale_lanczos3(&src, 4, 4);
assert_eq!(dst.width, 4);
assert_eq!(dst.height, 4);
assert_eq!(dst.data, src.data);
}
/// Scaling a solid-color pixmap with Lanczos-3 preserves the color.
#[test]
fn scale_lanczos3_preserves_solid_color() {
// Solid red 20×20 → 10×10
let src = Pixmap::new(20, 20, 200, 0, 0, 255);
let dst = scale_lanczos3(&src, 10, 10);
assert_eq!(dst.width, 10);
assert_eq!(dst.height, 10);
// All output pixels should be close to red (200, 0, 0).
for chunk in dst.data.chunks_exact(4) {
let (r, g, b) = (chunk[0], chunk[1], chunk[2]);
assert!(
(r as i32 - 200).abs() <= 5 && g <= 5 && b <= 5,
"expected near-red (200,0,0), got ({r},{g},{b})"
);
}
}
#[test]
fn scale_lanczos3_zero_dst_dimension_returns_white_fallback() {
let src = Pixmap::white(10, 10);
// dst_w=0 → Pixmap::white(max(0,1)=1, 5)
let dst = scale_lanczos3(&src, 0, 5);
assert_eq!(dst.width, 1);
assert_eq!(dst.height, 5);
// dst_h=0 → Pixmap::white(8, max(0,1)=1)
let dst2 = scale_lanczos3(&src, 8, 0);
assert_eq!(dst2.width, 8);
assert_eq!(dst2.height, 1);
}
}