Skip to main content

agg_gui/gfx_ctx/
draw_impl.rs

1use super::*;
2
3// ---------------------------------------------------------------------------
4// Active-framebuffer helper
5// ---------------------------------------------------------------------------
6
7/// Return a `&mut Framebuffer` for the currently active render target.
8///
9/// If any layers are on the stack, returns the top layer's framebuffer.
10/// Otherwise returns the base framebuffer.  Accepts the two fields as
11/// separate `&mut` references so callers can simultaneously borrow other
12/// `GfxCtx` fields (e.g. `state`, `path`) without triggering borrow
13/// conflicts on `self`.
14#[inline]
15pub(super) fn active_fb<'a>(
16    base_fb: &'a mut Framebuffer,
17    layer_stack: &'a mut Vec<LayerEntry>,
18) -> &'a mut Framebuffer {
19    if let Some(top) = layer_stack.last_mut() {
20        &mut top.fb
21    } else {
22        base_fb
23    }
24}
25
26// ---------------------------------------------------------------------------
27// SrcOver layer compositing
28// ---------------------------------------------------------------------------
29
30/// Composite `src` onto `dst` using SrcOver alpha blending.
31///
32/// AGG writes **premultiplied** RGBA into framebuffers.  The premultiplied
33/// SrcOver formula is:
34///
35/// ```text
36/// out_channel = src_premul + dst_premul × (1 − src_alpha_norm)
37/// ```
38///
39/// This applies identically to all four channels (R, G, B, A), which makes
40/// the implementation straightforward and avoids the division step needed for
41/// straight-alpha compositing.
42///
43/// `dest_x` / `dest_y` are the Y-up pixel coordinates in `dst` where the
44/// bottom-left corner of `src` lands.  Out-of-bounds pixels are silently clipped.
45pub(super) fn composite_framebuffers(
46    dst: &mut Framebuffer,
47    src: &Framebuffer,
48    dest_x: i32,
49    dest_y: i32,
50    alpha: f64,
51) {
52    let src_w = src.width() as i32;
53    let src_h = src.height() as i32;
54    let dst_w = dst.width() as i32;
55    let dst_h = dst.height() as i32;
56
57    let src_px = src.pixels();
58    let dst_px = dst.pixels_mut();
59
60    for sy in 0..src_h {
61        let dy = dest_y + sy;
62        if dy < 0 || dy >= dst_h {
63            continue;
64        }
65        for sx in 0..src_w {
66            let dx = dest_x + sx;
67            if dx < 0 || dx >= dst_w {
68                continue;
69            }
70            let si = ((sy * src_w + sx) * 4) as usize;
71            let di = ((dy * dst_w + dx) * 4) as usize;
72            let layer_alpha = alpha.clamp(0.0, 1.0) as f32;
73            let sa = (src_px[si + 3] as f32 / 255.0) * layer_alpha;
74            if sa < 1e-4 {
75                continue;
76            } // fully transparent source — skip
77            let inv_sa = 1.0 - sa;
78            // Premultiplied SrcOver — same formula for all four channels.
79            for k in 0..4 {
80                let s = src_px[si + k] as f32 * layer_alpha;
81                let d = dst_px[di + k] as f32;
82                dst_px[di + k] = (s + d * inv_sa).round().clamp(0.0, 255.0) as u8;
83            }
84        }
85    }
86}
87
88// ---------------------------------------------------------------------------
89// Free rasterization helpers — take explicit path and fb references so they
90// can be called for both self.path draws and per-glyph text draws without
91// borrow-checker conflicts.
92// ---------------------------------------------------------------------------
93
94pub(crate) fn rasterize_fill(
95    fb: &mut Framebuffer,
96    path: &mut PathStorage,
97    color: &agg_rust::color::Rgba8,
98    mode: CompOp,
99    clip: Option<(f64, f64, f64, f64)>,
100    fill_rule: FillRule,
101    transform: &TransAffine,
102) {
103    let w = fb.width();
104    let h = fb.height();
105    let stride = (w * 4) as i32;
106    let mut ra = RowAccessor::new();
107    unsafe { ra.attach(fb.pixels_mut().as_mut_ptr(), w, h, stride) };
108    let pf = PixfmtRgba32CompOp::new_with_op(&mut ra, mode);
109    let mut rb = RendererBase::new(pf);
110    apply_clip(&mut rb, clip);
111
112    let mut ras = RasterizerScanlineAa::new();
113    ras.filling_rule(to_agg_fill_rule(fill_rule));
114    let mut sl = ScanlineU8::new();
115    let mut curves = ConvCurve::new(path);
116    let mut transformed = ConvTransform::new(&mut curves, transform.clone());
117    ras.add_path(&mut transformed, 0);
118    render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, color);
119}
120
121fn to_agg_fill_rule(rule: FillRule) -> FillingRule {
122    match rule {
123        FillRule::NonZero => FillingRule::NonZero,
124        FillRule::EvenOdd => FillingRule::EvenOdd,
125    }
126}
127
128pub(crate) fn rasterize_stroke(
129    fb: &mut Framebuffer,
130    path: &mut PathStorage,
131    color: &agg_rust::color::Rgba8,
132    width: f64,
133    join: LineJoin,
134    cap: LineCap,
135    miter_limit: f64,
136    dashes: &[f64],
137    dash_offset: f64,
138    mode: CompOp,
139    clip: Option<(f64, f64, f64, f64)>,
140    transform: &TransAffine,
141) {
142    let mut curves = ConvCurve::new(path);
143    if dashes.is_empty() {
144        rasterize_stroke_source(
145            fb,
146            &mut curves,
147            color,
148            width,
149            join,
150            cap,
151            miter_limit,
152            mode,
153            clip,
154            transform,
155        );
156    } else {
157        let mut dash = ConvDash::new(&mut curves);
158        configure_dashes(&mut dash, dashes, dash_offset);
159        rasterize_stroke_source(
160            fb,
161            dash,
162            color,
163            width,
164            join,
165            cap,
166            miter_limit,
167            mode,
168            clip,
169            transform,
170        );
171    }
172}
173
174fn rasterize_stroke_source<VS: VertexSource>(
175    fb: &mut Framebuffer,
176    source: VS,
177    color: &agg_rust::color::Rgba8,
178    width: f64,
179    join: LineJoin,
180    cap: LineCap,
181    miter_limit: f64,
182    mode: CompOp,
183    clip: Option<(f64, f64, f64, f64)>,
184    transform: &TransAffine,
185) {
186    let w = fb.width();
187    let h = fb.height();
188    let stride = (w * 4) as i32;
189    let mut ra = RowAccessor::new();
190    unsafe { ra.attach(fb.pixels_mut().as_mut_ptr(), w, h, stride) };
191    let pf = PixfmtRgba32CompOp::new_with_op(&mut ra, mode);
192    let mut rb = RendererBase::new(pf);
193    apply_clip(&mut rb, clip);
194
195    let mut ras = RasterizerScanlineAa::new();
196    let mut sl = ScanlineU8::new();
197    let mut stroke = ConvStroke::new(source);
198    stroke.set_width(width);
199    stroke.set_line_join(join);
200    stroke.set_line_cap(cap);
201    stroke.set_miter_limit(miter_limit);
202    let mut transformed = ConvTransform::new(&mut stroke, transform.clone());
203    ras.add_path(&mut transformed, 0);
204    render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, color);
205}
206
207fn configure_dashes<VS: VertexSource>(dash: &mut ConvDash<VS>, dashes: &[f64], dash_offset: f64) {
208    let mut chunks = dashes.chunks_exact(2);
209    for pair in &mut chunks {
210        dash.add_dash(pair[0], pair[1]);
211    }
212    if let Some(&last) = chunks.remainder().first() {
213        dash.add_dash(last, last);
214    }
215    dash.dash_start(dash_offset);
216}
217
218// ---------------------------------------------------------------------------
219// DrawCtx blanket impl for GfxCtx
220// ---------------------------------------------------------------------------
221
222impl crate::draw_ctx::DrawCtx for GfxCtx<'_> {
223    fn set_fill_color(&mut self, c: crate::color::Color) {
224        self.set_fill_color(c)
225    }
226    fn set_fill_linear_gradient(&mut self, gradient: crate::draw_ctx::LinearGradientPaint) {
227        self.set_fill_linear_gradient(gradient)
228    }
229    fn set_fill_radial_gradient(&mut self, gradient: crate::draw_ctx::RadialGradientPaint) {
230        self.set_fill_radial_gradient(gradient)
231    }
232    fn set_fill_pattern(&mut self, pattern: crate::draw_ctx::PatternPaint) {
233        self.set_fill_pattern(pattern)
234    }
235    fn supports_fill_linear_gradient(&self) -> bool {
236        true
237    }
238    fn supports_fill_radial_gradient(&self) -> bool {
239        true
240    }
241    fn supports_fill_pattern(&self) -> bool {
242        true
243    }
244    fn set_stroke_color(&mut self, c: crate::color::Color) {
245        self.set_stroke_color(c)
246    }
247    fn set_stroke_linear_gradient(&mut self, gradient: crate::draw_ctx::LinearGradientPaint) {
248        self.set_stroke_linear_gradient(gradient)
249    }
250    fn set_stroke_radial_gradient(&mut self, gradient: crate::draw_ctx::RadialGradientPaint) {
251        self.set_stroke_radial_gradient(gradient)
252    }
253    fn set_stroke_pattern(&mut self, pattern: crate::draw_ctx::PatternPaint) {
254        self.set_stroke_pattern(pattern)
255    }
256    fn supports_stroke_linear_gradient(&self) -> bool {
257        true
258    }
259    fn supports_stroke_radial_gradient(&self) -> bool {
260        true
261    }
262    fn supports_stroke_pattern(&self) -> bool {
263        true
264    }
265    fn set_line_width(&mut self, w: f64) {
266        self.set_line_width(w)
267    }
268    fn set_line_join(&mut self, j: agg_rust::math_stroke::LineJoin) {
269        self.set_line_join(j)
270    }
271    fn set_line_cap(&mut self, c: agg_rust::math_stroke::LineCap) {
272        self.set_line_cap(c)
273    }
274    fn set_miter_limit(&mut self, limit: f64) {
275        self.set_miter_limit(limit)
276    }
277    fn set_line_dash(&mut self, dashes: &[f64], offset: f64) {
278        self.set_line_dash(dashes, offset)
279    }
280    fn set_fill_rule(&mut self, rule: crate::draw_ctx::FillRule) {
281        self.set_fill_rule(rule)
282    }
283    fn set_blend_mode(&mut self, m: agg_rust::comp_op::CompOp) {
284        self.set_blend_mode(m)
285    }
286    fn set_global_alpha(&mut self, a: f64) {
287        self.set_global_alpha(a)
288    }
289    fn set_font(&mut self, f: Arc<crate::text::Font>) {
290        self.set_font(f)
291    }
292    fn set_font_size(&mut self, s: f64) {
293        self.set_font_size(s)
294    }
295    fn clip_rect(&mut self, x: f64, y: f64, w: f64, h: f64) {
296        self.clip_rect(x, y, w, h)
297    }
298    fn reset_clip(&mut self) {
299        self.reset_clip()
300    }
301    fn clear(&mut self, c: crate::color::Color) {
302        self.clear(c)
303    }
304    fn begin_path(&mut self) {
305        self.begin_path()
306    }
307    fn move_to(&mut self, x: f64, y: f64) {
308        self.move_to(x, y)
309    }
310    fn line_to(&mut self, x: f64, y: f64) {
311        self.line_to(x, y)
312    }
313    fn cubic_to(&mut self, cx1: f64, cy1: f64, cx2: f64, cy2: f64, x: f64, y: f64) {
314        self.cubic_to(cx1, cy1, cx2, cy2, x, y)
315    }
316    fn quad_to(&mut self, cx: f64, cy: f64, x: f64, y: f64) {
317        self.quad_to(cx, cy, x, y)
318    }
319    fn arc_to(&mut self, cx: f64, cy: f64, r: f64, a1: f64, a2: f64, ccw: bool) {
320        self.arc_to(cx, cy, r, a1, a2, ccw)
321    }
322    fn circle(&mut self, cx: f64, cy: f64, r: f64) {
323        self.circle(cx, cy, r)
324    }
325    fn rect(&mut self, x: f64, y: f64, w: f64, h: f64) {
326        self.rect(x, y, w, h)
327    }
328    fn rounded_rect(&mut self, x: f64, y: f64, w: f64, h: f64, r: f64) {
329        self.rounded_rect(x, y, w, h, r)
330    }
331    fn close_path(&mut self) {
332        self.close_path()
333    }
334    fn fill(&mut self) {
335        self.fill()
336    }
337    fn stroke(&mut self) {
338        self.stroke()
339    }
340    fn fill_and_stroke(&mut self) {
341        self.fill_and_stroke()
342    }
343
344    fn draw_triangles_aa(
345        &mut self,
346        vertices: &[[f32; 3]],
347        indices: &[u32],
348        color: crate::color::Color,
349    ) {
350        // Software fallback: rasterise each triangle as a solid filled
351        // polygon.  The per-vertex `alpha` is ignored (software already has
352        // analytic AA via the scanline rasteriser), so halo quads from the
353        // GPU pipeline end up as redundant thin slivers — visually harmless
354        // but inefficient.  Callers that care should check `has_image_blit`
355        // / a similar capability flag; for now this keeps parity with the
356        // trait so the Lion demo renders correctly on the CPU path too.
357        let saved_fill = self.state.fill_color;
358        self.set_fill_color(color);
359        let n_tris = indices.len() / 3;
360        for t in 0..n_tris {
361            let i0 = indices[t * 3] as usize;
362            let i1 = indices[t * 3 + 1] as usize;
363            let i2 = indices[t * 3 + 2] as usize;
364            if i0 >= vertices.len() || i1 >= vertices.len() || i2 >= vertices.len() {
365                continue;
366            }
367            let v0 = vertices[i0];
368            let v1 = vertices[i1];
369            let v2 = vertices[i2];
370            self.begin_path();
371            self.move_to(v0[0] as f64, v0[1] as f64);
372            self.line_to(v1[0] as f64, v1[1] as f64);
373            self.line_to(v2[0] as f64, v2[1] as f64);
374            self.close_path();
375            self.fill();
376        }
377        self.set_fill_color(saved_fill);
378    }
379    fn fill_text(&mut self, t: &str, x: f64, y: f64) {
380        self.fill_text(t, x, y)
381    }
382    fn fill_text_gsv(&mut self, t: &str, x: f64, y: f64, s: f64) {
383        self.fill_text_gsv(t, x, y, s)
384    }
385    fn measure_text(&self, t: &str) -> Option<crate::text::TextMetrics> {
386        self.measure_text(t)
387    }
388    fn transform(&self) -> agg_rust::trans_affine::TransAffine {
389        self.transform()
390    }
391    fn root_transform(&self) -> agg_rust::trans_affine::TransAffine {
392        let mut t = self.transform();
393        for layer in self.layer_stack.iter().rev() {
394            t.premultiply(&agg_rust::trans_affine::TransAffine::new_translation(
395                layer.origin_x,
396                layer.origin_y,
397            ));
398        }
399        t
400    }
401    fn save(&mut self) {
402        self.save()
403    }
404    fn restore(&mut self) {
405        self.restore()
406    }
407    fn translate(&mut self, tx: f64, ty: f64) {
408        self.translate(tx, ty)
409    }
410    fn rotate(&mut self, r: f64) {
411        self.rotate(r)
412    }
413    fn scale(&mut self, sx: f64, sy: f64) {
414        self.scale(sx, sy)
415    }
416    fn set_transform(&mut self, m: agg_rust::trans_affine::TransAffine) {
417        self.set_transform(m)
418    }
419    fn reset_transform(&mut self) {
420        self.reset_transform()
421    }
422    fn push_layer(&mut self, w: f64, h: f64) {
423        self.push_layer(w, h)
424    }
425    fn supports_compositing_layers(&self) -> bool {
426        true
427    }
428    fn push_layer_with_alpha(&mut self, w: f64, h: f64, alpha: f64) {
429        self.push_layer_with_alpha(w, h, alpha)
430    }
431    fn pop_layer(&mut self) {
432        self.pop_layer()
433    }
434
435    fn has_image_blit(&self) -> bool {
436        true
437    }
438
439    fn draw_image_rgba_arc(
440        &mut self,
441        data: &Arc<Vec<u8>>,
442        img_w: u32,
443        img_h: u32,
444        dst_x: f64,
445        dst_y: f64,
446        dst_w: f64,
447        dst_h: f64,
448    ) {
449        // Software backend has no GPU texture cache; the CPU composite path
450        // is the same as the slice entry point.
451        self.draw_image_rgba(data.as_slice(), img_w, img_h, dst_x, dst_y, dst_w, dst_h);
452    }
453
454    fn draw_lcd_backbuffer_arc(
455        &mut self,
456        color: &Arc<Vec<u8>>,
457        alpha: &Arc<Vec<u8>>,
458        w: u32,
459        h: u32,
460        dst_x: f64,
461        dst_y: f64,
462        _dst_w: f64,
463        _dst_h: f64,
464    ) {
465        // Per-channel premultiplied src-over directly onto the active
466        // framebuffer.  Preserves LCD chroma: each subpixel's alpha
467        // drives the src-over of that subpixel's colour into the
468        // destination independently of the other two.
469        //
470        // Inputs are **top-row-first** (matches the cache layout); the
471        // destination `Framebuffer` is Y-up with row 0 at the bottom, so
472        // src row `sy` maps to dst row `origin_y + (h-1-sy)`.
473        if w == 0 || h == 0 {
474            return;
475        }
476        let w_u = w as usize;
477        let h_u = h as usize;
478        if color.len() < w_u * h_u * 3 || alpha.len() < w_u * h_u * 3 {
479            return;
480        }
481
482        let t = &self.state.transform;
483        let sx = (dst_x * t.sx + dst_y * t.shx + t.tx).round() as i32;
484        let sy = (dst_x * t.shy + dst_y * t.sy + t.ty).round() as i32;
485        let fb = active_fb(&mut self.base_fb, &mut self.layer_stack);
486        let fw = fb.width() as i32;
487        let fh = fb.height() as i32;
488        let fw_u = fw as usize;
489        let pixels = fb.pixels_mut();
490
491        for src_y in 0..h_u {
492            // Top-row-first src → Y-up dst: src row 0 (visually top)
493            // lands at dst_y + h - 1 (the visually-top dst row).
494            let dy = sy + (h_u - 1 - src_y) as i32;
495            if dy < 0 || dy >= fh {
496                continue;
497            }
498            let dy_u = dy as usize;
499            for src_x in 0..w_u {
500                let dx = sx + src_x as i32;
501                if dx < 0 || dx >= fw {
502                    continue;
503                }
504                let ci = (src_y * w_u + src_x) * 3;
505
506                let sa_r = alpha[ci] as f32 / 255.0;
507                let sa_g = alpha[ci + 1] as f32 / 255.0;
508                let sa_b = alpha[ci + 2] as f32 / 255.0;
509                if sa_r == 0.0 && sa_g == 0.0 && sa_b == 0.0 {
510                    continue;
511                }
512
513                let sc_r = color[ci] as f32 / 255.0;
514                let sc_g = color[ci + 1] as f32 / 255.0;
515                let sc_b = color[ci + 2] as f32 / 255.0;
516
517                let di = (dy_u * fw_u + dx as usize) * 4;
518                // Framebuffer holds premultiplied RGBA.  Per-channel
519                // src-over is `dst = src + dst * (1 - src_a)` since src
520                // is already premultiplied.  Alpha composites via
521                // max-channel-alpha so the destination picks up full
522                // opacity wherever any subpixel was painted — matches
523                // "this pixel was drawn on" for subsequent SrcOver blits.
524                let dc_r = pixels[di] as f32 / 255.0;
525                let dc_g = pixels[di + 1] as f32 / 255.0;
526                let dc_b = pixels[di + 2] as f32 / 255.0;
527                let da = pixels[di + 3] as f32 / 255.0;
528
529                let rc_r = sc_r + dc_r * (1.0 - sa_r);
530                let rc_g = sc_g + dc_g * (1.0 - sa_g);
531                let rc_b = sc_b + dc_b * (1.0 - sa_b);
532                let src_a_max = sa_r.max(sa_g).max(sa_b);
533                let ra = src_a_max + da * (1.0 - src_a_max);
534
535                pixels[di] = (rc_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
536                pixels[di + 1] = (rc_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
537                pixels[di + 2] = (rc_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
538                pixels[di + 3] = (ra * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
539            }
540        }
541    }
542
543    fn has_lcd_mask_composite(&self) -> bool {
544        true
545    }
546
547    fn draw_lcd_mask(
548        &mut self,
549        mask: &[u8],
550        mask_w: u32,
551        mask_h: u32,
552        src_color: Color,
553        dst_x: f64,
554        dst_y: f64,
555    ) {
556        // Resolve to the active target (base fb or topmost layer) with
557        // the current CTM applied to the placement origin.  Both the
558        // mask and the Framebuffer are Y-up (row 0 = bottom), so mask
559        // row `my` maps directly to dst row `sy + my`.
560        if mask.len() < (mask_w as usize) * (mask_h as usize) * 3 {
561            return;
562        }
563        let t = &self.state.transform;
564        let sx = dst_x * t.sx + dst_y * t.shx + t.tx;
565        let sy = dst_x * t.shy + dst_y * t.sy + t.ty;
566        let fb = active_fb(&mut self.base_fb, &mut self.layer_stack);
567        let fw = fb.width();
568        let fh = fb.height();
569        let origin_x = sx.round() as i32;
570        let origin_y = sy.round() as i32;
571
572        let sa = src_color.a.clamp(0.0, 1.0);
573        let sr = src_color.r.clamp(0.0, 1.0);
574        let sg = src_color.g.clamp(0.0, 1.0);
575        let sb = src_color.b.clamp(0.0, 1.0);
576        let fw_i = fw as i32;
577        let fh_i = fh as i32;
578        let mw_i = mask_w as i32;
579        let mh_i = mask_h as i32;
580        let pixels = fb.pixels_mut();
581
582        for my in 0..mh_i {
583            // Mask row `my` (Y-up: 0 = bottom) → dst row `origin_y + my`
584            // in the Y-up framebuffer.  No flip.
585            let dy = origin_y + my;
586            if dy < 0 || dy >= fh_i {
587                continue;
588            }
589            for mx in 0..mw_i {
590                let dx = origin_x + mx;
591                if dx < 0 || dx >= fw_i {
592                    continue;
593                }
594                let mi = ((my * mw_i + mx) * 3) as usize;
595                // Per-channel coverage × src alpha — partial-alpha src
596                // (e.g. `text_dim` placeholder colour) fades proportionally.
597                let cr = (mask[mi] as f32 / 255.0) * sa;
598                let cg = (mask[mi + 1] as f32 / 255.0) * sa;
599                let cb = (mask[mi + 2] as f32 / 255.0) * sa;
600                if cr == 0.0 && cg == 0.0 && cb == 0.0 {
601                    continue;
602                }
603                let di = ((dy * fw_i + dx) * 4) as usize;
604                let dr = pixels[di] as f32 / 255.0;
605                let dg = pixels[di + 1] as f32 / 255.0;
606                let db = pixels[di + 2] as f32 / 255.0;
607                let rr = sr * cr + dr * (1.0 - cr);
608                let rg = sg * cg + dg * (1.0 - cg);
609                let rbb = sb * cb + db * (1.0 - cb);
610                pixels[di] = (rr * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
611                pixels[di + 1] = (rg * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
612                pixels[di + 2] = (rbb * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
613                // Alpha unchanged — we're writing onto an existing opaque
614                // (or semi-transparent) surface without introducing new
615                // transparency.
616            }
617        }
618    }
619
620    fn draw_image_rgba(
621        &mut self,
622        data: &[u8],
623        img_w: u32,
624        img_h: u32,
625        dst_x: f64,
626        dst_y: f64,
627        dst_w: f64,
628        dst_h: f64,
629    ) {
630        // Scale the source image into a temporary Framebuffer at dst size,
631        // then composite it onto the current render target using the CTM origin.
632        if img_w == 0 || img_h == 0 || dst_w < 1.0 || dst_h < 1.0 {
633            return;
634        }
635
636        let out_w = dst_w.round() as u32;
637        let out_h = dst_h.round() as u32;
638        let mut scaled = crate::framebuffer::Framebuffer::new(out_w, out_h);
639
640        // Nearest-neighbour scale — sufficient for README screenshots / badges.
641        // `data` is straight-alpha by the `draw_image_rgba` convention; AGG
642        // framebuffers store **premultiplied** RGBA, so we premultiply each
643        // sampled pixel on the way in so `composite_framebuffers` (which uses
644        // premultiplied SrcOver) blends with correct intensity.
645        let px = scaled.pixels_mut();
646        for dy in 0..out_h {
647            for dx in 0..out_w {
648                let sx = (dx as f64 / out_w as f64 * img_w as f64) as u32;
649                // Image is top-row-first; Y-up dst means we flip sy.
650                let sy_img = ((1.0 - (dy as f64 + 0.5) / out_h as f64) * img_h as f64)
651                    .floor()
652                    .clamp(0.0, (img_h - 1) as f64) as u32;
653                let si = ((sy_img * img_w + sx) * 4) as usize;
654                let di = ((dy * out_w + dx) * 4) as usize;
655                if si + 3 < data.len() && di + 3 < px.len() {
656                    let a = data[si + 3] as u32;
657                    if a == 255 {
658                        px[di] = data[si];
659                        px[di + 1] = data[si + 1];
660                        px[di + 2] = data[si + 2];
661                        px[di + 3] = 255;
662                    } else {
663                        // Premultiply: (c * a + 127) / 255 (round-half-up).
664                        px[di] = (((data[si] as u32) * a + 127) / 255) as u8;
665                        px[di + 1] = (((data[si + 1] as u32) * a + 127) / 255) as u8;
666                        px[di + 2] = (((data[si + 2] as u32) * a + 127) / 255) as u8;
667                        px[di + 3] = a as u8;
668                    }
669                }
670            }
671        }
672
673        // Apply CTM translation to get screen-space origin.
674        let (tx, ty) = {
675            let t = self.transform();
676            (t.tx, t.ty)
677        };
678        let screen_x = (tx + dst_x).round() as i32;
679        let screen_y = (ty + dst_y).round() as i32;
680        let fb = active_fb(&mut self.base_fb, &mut self.layer_stack);
681        composite_framebuffers(fb, &scaled, screen_x, screen_y, 1.0);
682    }
683}
684
685/// Apply a Y-up scissor clip to a `RendererBase` (pixel-inclusive coordinates).
686pub(crate) fn apply_clip<PF: agg_rust::pixfmt_rgba::PixelFormat>(
687    rb: &mut RendererBase<PF>,
688    clip: Option<(f64, f64, f64, f64)>,
689) {
690    if let Some((x, y, w, h)) = clip {
691        let x1 = x.floor() as i32;
692        let y1 = y.floor() as i32;
693        let x2 = (x + w).ceil() as i32 - 1;
694        let y2 = (y + h).ceil() as i32 - 1;
695        rb.clip_box_i(x1, y1, x2, y2);
696    }
697}