Skip to main content

oxml_pdf/
raster.rs

1//! Page-to-image rendering using tiny-skia software rasterizer.
2
3use std::collections::HashSet;
4use std::error::Error;
5use std::fmt;
6use std::io::Cursor;
7
8use jpeg_encoder::{ColorType as JpegColorType, Encoder as JpegEncoder};
9use oxml_layout::{
10    Effect, LayoutResult, LineCap, LineJoin, PageFrame, Paint as LayoutPaint, Path as LayoutPath,
11    PathCommand, PositionedElement, Stroke as LayoutStroke,
12};
13use tiny_skia::{
14    FillRule, Mask, Paint, PathBuilder, Pixmap, PixmapPaint, Stroke, StrokeDash, Transform,
15};
16use zune_jpeg::JpegDecoder;
17use zune_jpeg::zune_core::bytestream::ZCursor;
18use zune_jpeg::zune_core::colorspace::ColorSpace;
19use zune_jpeg::zune_core::options::DecoderOptions;
20
21/// Image container and encoding options for raster output.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum RasterFormat {
24    /// Encode each selected page as a PNG.
25    Png {
26        /// Leave unpainted pixels transparent instead of compositing on white.
27        transparent_background: bool,
28    },
29    /// Encode each selected page as an opaque JPEG.
30    Jpeg {
31        /// JPEG quality from 1 through 100.
32        quality: u8,
33    },
34    /// Encode selected pages into one opaque, uncompressed, multi-page TIFF.
35    Tiff,
36}
37
38/// Options for raster image export.
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct RasterOptions {
41    /// Dots per inch. Must be finite and positive.
42    pub dpi: f64,
43    /// Requested image container and format-specific options.
44    pub format: RasterFormat,
45}
46
47/// Encoded raster output.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum RasterOutput {
50    /// One image per selected page, used for PNG and JPEG.
51    SeparatePages(Vec<Vec<u8>>),
52    /// One multi-directory TIFF stream for every selected page.
53    MultiPageTiff(Vec<u8>),
54}
55
56/// A reason a raster export request could not be completed.
57#[derive(Debug, Clone, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum RasterError {
60    /// DPI was NaN, infinite, zero, or negative.
61    InvalidDpi,
62    /// No page was selected.
63    EmptyPageSelection,
64    /// A selected zero-based page index does not exist.
65    PageOutOfRange {
66        page_index: usize,
67        page_count: usize,
68    },
69    /// A selected zero-based page index appears more than once.
70    DuplicatePage { page_index: usize },
71    /// JPEG quality must be in the inclusive 1 through 100 range.
72    InvalidJpegQuality { quality: u8 },
73    /// The requested raster dimensions cannot be represented.
74    InvalidDimensions,
75    /// The rendered pixmap could not be encoded into the requested format.
76    EncodeFailed { format: &'static str },
77}
78
79impl fmt::Display for RasterError {
80    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            Self::InvalidDpi => formatter.write_str("raster DPI must be finite and positive"),
83            Self::EmptyPageSelection => formatter.write_str("at least one raster page is required"),
84            Self::PageOutOfRange {
85                page_index,
86                page_count,
87            } => write!(
88                formatter,
89                "raster page index {page_index} is out of range for {page_count} pages"
90            ),
91            Self::DuplicatePage { page_index } => {
92                write!(
93                    formatter,
94                    "raster page index {page_index} was selected more than once"
95                )
96            }
97            Self::InvalidJpegQuality { quality } => {
98                write!(formatter, "JPEG quality {quality} is outside 1 through 100")
99            }
100            Self::InvalidDimensions => {
101                formatter.write_str("raster dimensions are invalid for the requested format")
102            }
103            Self::EncodeFailed { format } => write!(formatter, "{format} raster encoding failed"),
104        }
105    }
106}
107
108impl Error for RasterError {}
109
110/// Render a single page to PNG bytes.
111///
112/// # Arguments
113/// * `layout` - The layout result containing all pages and font data
114/// * `page_index` - 0-based page index to render
115/// * `dpi` - Dots per inch (72 = 1:1 with points, 150 = 2x, 300 = 4.17x)
116pub fn render_page_to_png(layout: &LayoutResult, page_index: usize, dpi: f64) -> Option<Vec<u8>> {
117    let page = layout.pages.get(page_index)?;
118    let pixmap = render_page_to_pixmap_with_background(page, &layout.fonts, dpi, false)?;
119    pixmap.encode_png().ok()
120}
121
122/// Render all pages to PNG bytes.
123pub fn render_all_pages(layout: &LayoutResult, dpi: f64) -> Vec<Vec<u8>> {
124    layout
125        .pages
126        .iter()
127        .filter_map(|page| {
128            let pixmap = render_page_to_pixmap_with_background(page, &layout.fonts, dpi, false)?;
129            pixmap.encode_png().ok()
130        })
131        .collect()
132}
133
134/// Render selected zero-based pages to the requested raster image format.
135pub fn render_pages(
136    layout: &LayoutResult,
137    page_indices: &[usize],
138    options: RasterOptions,
139) -> Result<RasterOutput, RasterError> {
140    validate_request(layout, page_indices, options)?;
141
142    match options.format {
143        RasterFormat::Png {
144            transparent_background,
145        } => page_indices
146            .iter()
147            .map(|&page_index| {
148                let pixmap = render_selected_pixmap(
149                    layout,
150                    page_index,
151                    options.dpi,
152                    transparent_background,
153                )?;
154                pixmap
155                    .encode_png()
156                    .map_err(|_| RasterError::EncodeFailed { format: "PNG" })
157            })
158            .collect::<Result<Vec<_>, _>>()
159            .map(RasterOutput::SeparatePages),
160        RasterFormat::Jpeg { quality } => page_indices
161            .iter()
162            .map(|&page_index| {
163                let pixmap = render_selected_pixmap(layout, page_index, options.dpi, false)?;
164                encode_jpeg(&pixmap, quality)
165            })
166            .collect::<Result<Vec<_>, _>>()
167            .map(RasterOutput::SeparatePages),
168        RasterFormat::Tiff => {
169            encode_tiff(layout, page_indices, options.dpi).map(RasterOutput::MultiPageTiff)
170        }
171    }
172}
173
174fn validate_request(
175    layout: &LayoutResult,
176    page_indices: &[usize],
177    options: RasterOptions,
178) -> Result<(), RasterError> {
179    if !options.dpi.is_finite() || options.dpi <= 0.0 {
180        return Err(RasterError::InvalidDpi);
181    }
182    if page_indices.is_empty() {
183        return Err(RasterError::EmptyPageSelection);
184    }
185    if let RasterFormat::Jpeg { quality } = options.format
186        && !(1..=100).contains(&quality)
187    {
188        return Err(RasterError::InvalidJpegQuality { quality });
189    }
190    let page_count = layout.pages.len();
191    let mut seen = HashSet::with_capacity(page_indices.len());
192    for &page_index in page_indices {
193        if page_index >= page_count {
194            return Err(RasterError::PageOutOfRange {
195                page_index,
196                page_count,
197            });
198        }
199        if !seen.insert(page_index) {
200            return Err(RasterError::DuplicatePage { page_index });
201        }
202    }
203    Ok(())
204}
205
206fn render_selected_pixmap(
207    layout: &LayoutResult,
208    page_index: usize,
209    dpi: f64,
210    transparent_background: bool,
211) -> Result<Pixmap, RasterError> {
212    let page = layout
213        .pages
214        .get(page_index)
215        .ok_or(RasterError::InvalidDimensions)?;
216    render_page_to_pixmap_with_background(page, &layout.fonts, dpi, transparent_background)
217        .ok_or(RasterError::InvalidDimensions)
218}
219
220fn encode_jpeg(pixmap: &Pixmap, quality: u8) -> Result<Vec<u8>, RasterError> {
221    let width = u16::try_from(pixmap.width()).map_err(|_| RasterError::InvalidDimensions)?;
222    let height = u16::try_from(pixmap.height()).map_err(|_| RasterError::InvalidDimensions)?;
223    let rgb = opaque_rgb(pixmap);
224    let mut output = Vec::new();
225    JpegEncoder::new(&mut output, quality)
226        .encode(&rgb, width, height, JpegColorType::Rgb)
227        .map_err(|_| RasterError::EncodeFailed { format: "JPEG" })?;
228    Ok(output)
229}
230
231fn encode_tiff(
232    layout: &LayoutResult,
233    page_indices: &[usize],
234    dpi: f64,
235) -> Result<Vec<u8>, RasterError> {
236    let mut cursor = Cursor::new(Vec::new());
237    {
238        let mut encoder = tiff::encoder::TiffEncoder::new(&mut cursor)
239            .map_err(|_| RasterError::EncodeFailed { format: "TIFF" })?;
240        for &page_index in page_indices {
241            let pixmap = render_selected_pixmap(layout, page_index, dpi, false)?;
242            let rgb = opaque_rgb(&pixmap);
243            encoder
244                .write_image::<tiff::encoder::colortype::RGB8>(
245                    pixmap.width(),
246                    pixmap.height(),
247                    &rgb,
248                )
249                .map_err(|_| RasterError::EncodeFailed { format: "TIFF" })?;
250        }
251    }
252    Ok(cursor.into_inner())
253}
254
255fn opaque_rgb(pixmap: &Pixmap) -> Vec<u8> {
256    let mut rgb = Vec::with_capacity(pixmap.width() as usize * pixmap.height() as usize * 3);
257    for pixel in pixmap.pixels() {
258        let alpha = pixel.alpha();
259        rgb.push(pixel.red().saturating_add(255 - alpha));
260        rgb.push(pixel.green().saturating_add(255 - alpha));
261        rgb.push(pixel.blue().saturating_add(255 - alpha));
262    }
263    rgb
264}
265
266/// Render a page to a Pixmap.
267#[cfg(test)]
268fn render_page_to_pixmap(
269    page: &PageFrame,
270    fonts: &[oxml_layout::FontData],
271    dpi: f64,
272) -> Option<Pixmap> {
273    render_page_to_pixmap_with_background(page, fonts, dpi, false)
274}
275
276fn render_page_to_pixmap_with_background(
277    page: &PageFrame,
278    fonts: &[oxml_layout::FontData],
279    dpi: f64,
280    transparent_background: bool,
281) -> Option<Pixmap> {
282    let scale = dpi / 72.0; // points to pixels
283    let width = (page.width * scale).ceil() as u32;
284    let height = (page.height * scale).ceil() as u32;
285
286    let mut pixmap = Pixmap::new(width, height)?;
287
288    if !transparent_background {
289        pixmap.fill(tiny_skia::Color::WHITE);
290    }
291
292    let transform = Transform::from_scale(scale as f32, scale as f32);
293
294    if let Some(background) = &page.background {
295        let path = LayoutPath::rect(oxml_layout::Rect {
296            x: 0.0,
297            y: 0.0,
298            width: page.width,
299            height: page.height,
300        });
301        render_path(&mut pixmap, &path, Some(background), None, transform, None);
302    }
303    render_elements(&mut pixmap, fonts, &page.elements, transform, None);
304
305    Some(pixmap)
306}
307
308fn render_elements(
309    pixmap: &mut Pixmap,
310    fonts: &[oxml_layout::FontData],
311    elements: &[PositionedElement],
312    transform: Transform,
313    mask: Option<&Mask>,
314) {
315    for element in elements {
316        match element {
317            PositionedElement::FilledRect { rect, color } => {
318                let Some(rect) = tiny_skia::Rect::from_xywh(
319                    rect.x as f32,
320                    rect.y as f32,
321                    rect.width as f32,
322                    rect.height as f32,
323                ) else {
324                    continue;
325                };
326                let mut paint = solid_paint(*color);
327                paint.anti_alias = false;
328                pixmap.fill_rect(rect, &paint, transform, mask);
329            }
330            PositionedElement::Line {
331                start,
332                end,
333                width,
334                color,
335                dash_pattern,
336            } => {
337                let mut builder = PathBuilder::new();
338                builder.move_to(start.x as f32, start.y as f32);
339                builder.line_to(end.x as f32, end.y as f32);
340                let Some(path) = builder.finish() else {
341                    continue;
342                };
343                let dash = dash_pattern
344                    .and_then(|(on, off)| StrokeDash::new(vec![on as f32, off as f32], 0.0));
345                let stroke = Stroke {
346                    width: *width as f32,
347                    dash,
348                    ..Stroke::default()
349                };
350                pixmap.stroke_path(&path, &solid_paint(*color), &stroke, transform, mask);
351            }
352            PositionedElement::Text(glyph_run) => {
353                if let Some(font_data) = fonts.iter().find(|font| font.id == glyph_run.font_id) {
354                    render_glyph_run(pixmap, glyph_run, font_data, transform, mask);
355                }
356            }
357            PositionedElement::MultilingualText(glyph_run) => {
358                if glyph_run.is_valid()
359                    && let Some(font_data) = fonts.iter().find(|font| font.id == glyph_run.font_id)
360                {
361                    render_positioned_glyph_run(
362                        pixmap,
363                        &glyph_run.legacy_projection(),
364                        font_data,
365                        transform,
366                        mask,
367                        &glyph_run.x_offsets,
368                        &glyph_run.y_offsets,
369                        &glyph_run.y_advances,
370                    );
371                }
372            }
373            PositionedElement::Image {
374                rect,
375                data,
376                content_type,
377                ..
378            } => {
379                if !data.is_empty() {
380                    render_image(pixmap, rect, data, content_type, transform, mask);
381                }
382            }
383            PositionedElement::LinkAnnotation { .. } => {}
384            PositionedElement::Path(element) => render_path(
385                pixmap,
386                &element.path,
387                element.fill.as_ref(),
388                element.stroke.as_ref(),
389                transform,
390                mask,
391            ),
392            PositionedElement::Group(group) => {
393                let child_transform = transform.pre_concat(layout_transform(group.transform));
394                let child_mask = match group.clip.as_ref() {
395                    Some(clip) => {
396                        let Some(path) = build_path(clip) else {
397                            continue;
398                        };
399                        let mut child_mask = match mask {
400                            Some(parent) => parent.clone(),
401                            None => {
402                                let Some(mask) = Mask::new(pixmap.width(), pixmap.height()) else {
403                                    continue;
404                                };
405                                mask
406                            }
407                        };
408                        if mask.is_some() {
409                            child_mask.intersect_path(
410                                &path,
411                                fill_rule(clip.fill_rule),
412                                true,
413                                child_transform,
414                            );
415                        } else {
416                            child_mask.fill_path(
417                                &path,
418                                fill_rule(clip.fill_rule),
419                                true,
420                                child_transform,
421                            );
422                        }
423                        Some(child_mask)
424                    }
425                    None => None,
426                };
427                let child_mask_ref = child_mask.as_ref().or(mask);
428                let opacity = if group.opacity.is_finite() {
429                    group.opacity.clamp(0.0, 1.0) as f32
430                } else {
431                    1.0
432                };
433                if opacity == 0.0 {
434                    continue;
435                }
436                if opacity == 1.0 && group.effects.is_empty() {
437                    render_elements(
438                        pixmap,
439                        fonts,
440                        &group.children,
441                        child_transform,
442                        child_mask_ref,
443                    );
444                } else if let Some(mut layer) = Pixmap::new(pixmap.width(), pixmap.height()) {
445                    render_elements(
446                        &mut layer,
447                        fonts,
448                        &group.children,
449                        child_transform,
450                        child_mask_ref,
451                    );
452                    if group.effects.is_empty() {
453                        pixmap.draw_pixmap(
454                            0,
455                            0,
456                            layer.as_ref(),
457                            &PixmapPaint {
458                                opacity,
459                                ..PixmapPaint::default()
460                            },
461                            Transform::identity(),
462                            None,
463                        );
464                    } else if let Some(mut composited) =
465                        Pixmap::new(pixmap.width(), pixmap.height())
466                    {
467                        render_group_effects(
468                            &mut composited,
469                            &layer,
470                            &group.effects,
471                            child_transform,
472                            mask,
473                        );
474                        composited.draw_pixmap(
475                            0,
476                            0,
477                            layer.as_ref(),
478                            &PixmapPaint::default(),
479                            Transform::identity(),
480                            None,
481                        );
482                        pixmap.draw_pixmap(
483                            0,
484                            0,
485                            composited.as_ref(),
486                            &PixmapPaint {
487                                opacity,
488                                ..PixmapPaint::default()
489                            },
490                            Transform::identity(),
491                            None,
492                        );
493                    }
494                }
495            }
496            PositionedElement::MarkedContent { children, .. } => {
497                render_elements(pixmap, fonts, children, transform, mask);
498            }
499            _ => {}
500        }
501    }
502}
503
504fn render_group_effects(
505    pixmap: &mut Pixmap,
506    layer: &Pixmap,
507    effects: &[Effect],
508    transform: Transform,
509    mask: Option<&Mask>,
510) {
511    for effect in effects {
512        if let Effect::OuterShadow {
513            dx,
514            dy,
515            blur,
516            color,
517        } = effect
518        {
519            let Some((left, top, right, bottom)) = alpha_bounds(layer) else {
520                continue;
521            };
522            let (scale_x, scale_y) = transform.get_scale();
523            let scale = ((scale_x + scale_y) * 0.5).max(0.0);
524            let blur_radius = ((*blur as f32 * scale).max(0.0).ceil() as usize)
525                .min(layer.width().max(layer.height()) as usize);
526            let source_width = right - left + 1;
527            let source_height = bottom - top + 1;
528            let width = source_width.saturating_add(blur_radius.saturating_mul(2));
529            let height = source_height.saturating_add(blur_radius.saturating_mul(2));
530            let Some(pixel_count) = width.checked_mul(height) else {
531                continue;
532            };
533            let mut alpha = vec![0_u8; pixel_count];
534            for y in 0..source_height {
535                let source_start = ((top + y) * layer.width() as usize + left) * 4;
536                let source = &layer.data()[source_start..source_start + source_width * 4];
537                let target_start = (y + blur_radius) * width + blur_radius;
538                for (target, pixel) in alpha[target_start..target_start + source_width]
539                    .iter_mut()
540                    .zip(source.chunks_exact(4))
541                {
542                    *target = pixel[3];
543                }
544            }
545            box_blur_alpha(&mut alpha, width, height, blur_radius);
546
547            let (Ok(shadow_width), Ok(shadow_height)) =
548                (u32::try_from(width), u32::try_from(height))
549            else {
550                continue;
551            };
552            let Some(mut shadow) = Pixmap::new(shadow_width, shadow_height) else {
553                continue;
554            };
555            let color_alpha = color.a.clamp(0.0, 1.0);
556            for (pixel, source_alpha) in shadow.data_mut().chunks_exact_mut(4).zip(alpha) {
557                let alpha = (f64::from(source_alpha) * color_alpha).round() as u8;
558                pixel[0] = (color.r.clamp(0.0, 1.0) * f64::from(alpha)).round() as u8;
559                pixel[1] = (color.g.clamp(0.0, 1.0) * f64::from(alpha)).round() as u8;
560                pixel[2] = (color.b.clamp(0.0, 1.0) * f64::from(alpha)).round() as u8;
561                pixel[3] = alpha;
562            }
563
564            let mut origin = tiny_skia::Point::from_xy(0.0, 0.0);
565            let mut offset = tiny_skia::Point::from_xy(*dx as f32, *dy as f32);
566            transform.map_point(&mut origin);
567            transform.map_point(&mut offset);
568            pixmap.draw_pixmap(
569                left as i32 - blur_radius as i32 + (offset.x - origin.x).round() as i32,
570                top as i32 - blur_radius as i32 + (offset.y - origin.y).round() as i32,
571                shadow.as_ref(),
572                &PixmapPaint::default(),
573                Transform::identity(),
574                mask,
575            );
576        }
577    }
578}
579
580fn alpha_bounds(layer: &Pixmap) -> Option<(usize, usize, usize, usize)> {
581    let width = layer.width() as usize;
582    let mut left = width;
583    let mut top = layer.height() as usize;
584    let mut right = 0;
585    let mut bottom = 0;
586    let mut found = false;
587    for (index, pixel) in layer.data().chunks_exact(4).enumerate() {
588        if pixel[3] == 0 {
589            continue;
590        }
591        let x = index % width;
592        let y = index / width;
593        left = left.min(x);
594        top = top.min(y);
595        right = right.max(x);
596        bottom = bottom.max(y);
597        found = true;
598    }
599    found.then_some((left, top, right, bottom))
600}
601
602fn box_blur_alpha(alpha: &mut [u8], width: usize, height: usize, radius: usize) {
603    if radius == 0 || width == 0 || height == 0 {
604        return;
605    }
606    let mut horizontal = vec![0_u8; alpha.len()];
607    let mut prefix = vec![0_u64; width.max(height) + 1];
608    let diameter = radius.saturating_mul(2).saturating_add(1) as u64;
609
610    for y in 0..height {
611        let row = &alpha[y * width..(y + 1) * width];
612        let target = &mut horizontal[y * width..(y + 1) * width];
613        prefix[0] = 0;
614        for (index, value) in row.iter().enumerate() {
615            prefix[index + 1] = prefix[index] + u64::from(*value);
616        }
617        for (x, target) in target.iter_mut().enumerate() {
618            let left = x.saturating_sub(radius);
619            let right = (x + radius + 1).min(width);
620            *target = ((prefix[right] - prefix[left]) / diameter) as u8;
621        }
622    }
623
624    for x in 0..width {
625        prefix[0] = 0;
626        for y in 0..height {
627            prefix[y + 1] = prefix[y] + u64::from(horizontal[y * width + x]);
628        }
629        for y in 0..height {
630            let top = y.saturating_sub(radius);
631            let bottom = (y + radius + 1).min(height);
632            alpha[y * width + x] = ((prefix[bottom] - prefix[top]) / diameter) as u8;
633        }
634    }
635}
636
637fn render_path(
638    pixmap: &mut Pixmap,
639    path: &LayoutPath,
640    fill: Option<&LayoutPaint>,
641    stroke: Option<&LayoutStroke>,
642    transform: Transform,
643    mask: Option<&Mask>,
644) {
645    let Some(path_geometry) = build_path(path) else {
646        return;
647    };
648    if let Some(paint) = fill.and_then(raster_paint) {
649        let paint_mask =
650            fill.and_then(|paint| gradient_domain_mask(pixmap, paint, transform, mask));
651        pixmap.fill_path(
652            &path_geometry,
653            &paint,
654            fill_rule(path.fill_rule),
655            transform,
656            paint_mask.as_ref().or(mask),
657        );
658    }
659    if let Some(stroke) = stroke
660        && let Some(paint) = raster_paint(&stroke.paint)
661    {
662        let paint_mask = gradient_domain_mask(pixmap, &stroke.paint, transform, mask);
663        let stroke = raster_stroke(stroke);
664        pixmap.stroke_path(
665            &path_geometry,
666            &paint,
667            &stroke,
668            transform,
669            paint_mask.as_ref().or(mask),
670        );
671    }
672}
673
674fn build_path(path: &LayoutPath) -> Option<tiny_skia::Path> {
675    let mut builder = PathBuilder::new();
676    for command in &path.commands {
677        match command {
678            PathCommand::MoveTo(point) => builder.move_to(point.x as f32, point.y as f32),
679            PathCommand::LineTo(point) => builder.line_to(point.x as f32, point.y as f32),
680            PathCommand::CurveTo { c1, c2, to } => builder.cubic_to(
681                c1.x as f32,
682                c1.y as f32,
683                c2.x as f32,
684                c2.y as f32,
685                to.x as f32,
686                to.y as f32,
687            ),
688            PathCommand::Close => builder.close(),
689        }
690    }
691    builder.finish()
692}
693
694fn fill_rule(rule: oxml_layout::FillRule) -> FillRule {
695    match rule {
696        oxml_layout::FillRule::NonZero => FillRule::Winding,
697        oxml_layout::FillRule::EvenOdd => FillRule::EvenOdd,
698    }
699}
700
701fn raster_paint(paint: &LayoutPaint) -> Option<Paint<'static>> {
702    match paint {
703        LayoutPaint::Solid(color) => Some(solid_paint(*color)),
704        LayoutPaint::Linear {
705            start, end, stops, ..
706        } => {
707            let shader = tiny_skia::LinearGradient::new(
708                tiny_skia::Point::from_xy(start.x as f32, start.y as f32),
709                tiny_skia::Point::from_xy(end.x as f32, end.y as f32),
710                gradient_stops(stops),
711                tiny_skia::SpreadMode::Pad,
712                Transform::identity(),
713            )?;
714            Some(Paint {
715                shader,
716                ..Paint::default()
717            })
718        }
719        LayoutPaint::Radial {
720            center,
721            radius,
722            focal,
723            stops,
724            ..
725        } => {
726            let shader = tiny_skia::RadialGradient::new(
727                tiny_skia::Point::from_xy(focal.x as f32, focal.y as f32),
728                0.0,
729                tiny_skia::Point::from_xy(center.x as f32, center.y as f32),
730                *radius as f32,
731                gradient_stops(stops),
732                tiny_skia::SpreadMode::Pad,
733                Transform::identity(),
734            )?;
735            Some(Paint {
736                shader,
737                ..Paint::default()
738            })
739        }
740        LayoutPaint::Tile { .. } => None,
741    }
742}
743
744fn gradient_domain_mask(
745    pixmap: &Pixmap,
746    paint: &LayoutPaint,
747    transform: Transform,
748    parent: Option<&Mask>,
749) -> Option<Mask> {
750    let path = match paint {
751        LayoutPaint::Linear {
752            start, end, extend, ..
753        } if *extend != (true, true) => linear_domain_path(
754            pixmap.width(),
755            pixmap.height(),
756            *start,
757            *end,
758            *extend,
759            transform,
760        )?,
761        LayoutPaint::Radial {
762            center,
763            radius,
764            extend,
765            ..
766        } if !extend.1 => build_path(&LayoutPath::ellipse(oxml_layout::Rect {
767            x: center.x - radius,
768            y: center.y - radius,
769            width: radius * 2.0,
770            height: radius * 2.0,
771        }))?,
772        _ => return None,
773    };
774    let mut domain = match parent {
775        Some(parent) => parent.clone(),
776        None => Mask::new(pixmap.width(), pixmap.height())?,
777    };
778    if parent.is_some() {
779        domain.intersect_path(&path, FillRule::Winding, true, transform);
780    } else {
781        domain.fill_path(&path, FillRule::Winding, true, transform);
782    }
783    Some(domain)
784}
785
786fn linear_domain_path(
787    width: u32,
788    height: u32,
789    start: oxml_layout::Point,
790    end: oxml_layout::Point,
791    extend: (bool, bool),
792    transform: Transform,
793) -> Option<tiny_skia::Path> {
794    let inverse = transform.invert()?;
795    let mut corners = [
796        tiny_skia::Point::from_xy(0.0, 0.0),
797        tiny_skia::Point::from_xy(width as f32, 0.0),
798        tiny_skia::Point::from_xy(width as f32, height as f32),
799        tiny_skia::Point::from_xy(0.0, height as f32),
800    ];
801    inverse.map_points(&mut corners);
802
803    let dx = (end.x - start.x) as f32;
804    let dy = (end.y - start.y) as f32;
805    let length_squared = dx * dx + dy * dy;
806    if !length_squared.is_finite() || length_squared <= f32::EPSILON {
807        return None;
808    }
809    let length = length_squared.sqrt();
810    let normal_x = -dy / length;
811    let normal_y = dx / length;
812    let mut min_t = f32::INFINITY;
813    let mut max_t = f32::NEG_INFINITY;
814    let mut min_normal = f32::INFINITY;
815    let mut max_normal = f32::NEG_INFINITY;
816    for corner in corners {
817        let x = corner.x - start.x as f32;
818        let y = corner.y - start.y as f32;
819        let t = (x * dx + y * dy) / length_squared;
820        let normal = x * normal_x + y * normal_y;
821        min_t = min_t.min(t);
822        max_t = max_t.max(t);
823        min_normal = min_normal.min(normal);
824        max_normal = max_normal.max(normal);
825    }
826    let min_t = if extend.0 { min_t - 1.0 } else { 0.0 };
827    let max_t = if extend.1 { max_t + 1.0 } else { 1.0 };
828    let min_normal = min_normal - 1.0;
829    let max_normal = max_normal + 1.0;
830    let point = |t: f32, normal: f32| {
831        tiny_skia::Point::from_xy(
832            start.x as f32 + dx * t + normal_x * normal,
833            start.y as f32 + dy * t + normal_y * normal,
834        )
835    };
836    let mut builder = PathBuilder::new();
837    let first = point(min_t, min_normal);
838    builder.move_to(first.x, first.y);
839    for corner in [
840        point(max_t, min_normal),
841        point(max_t, max_normal),
842        point(min_t, max_normal),
843    ] {
844        builder.line_to(corner.x, corner.y);
845    }
846    builder.close();
847    builder.finish()
848}
849
850fn gradient_stops(stops: &[oxml_layout::GradientStop]) -> Vec<tiny_skia::GradientStop> {
851    let mut normalized: Vec<(f64, oxml_layout::Color)> = stops
852        .iter()
853        .map(|stop| {
854            let offset = if stop.offset.is_nan() {
855                0.0
856            } else {
857                stop.offset.clamp(0.0, 1.0)
858            };
859            (offset, stop.color)
860        })
861        .collect();
862    normalized.sort_by(|left, right| left.0.total_cmp(&right.0));
863
864    let mut deduplicated: Vec<(f64, oxml_layout::Color)> = Vec::with_capacity(normalized.len());
865    for stop in normalized {
866        if let Some(previous) = deduplicated.last_mut()
867            && previous.0 == stop.0
868        {
869            *previous = stop;
870        } else {
871            deduplicated.push(stop);
872        }
873    }
874
875    deduplicated
876        .into_iter()
877        .map(|(offset, color)| tiny_skia::GradientStop::new(offset as f32, skia_color(color)))
878        .collect()
879}
880
881fn solid_paint(color: oxml_layout::Color) -> Paint<'static> {
882    let mut paint = Paint::default();
883    paint.set_color_rgba8(
884        (color.r.clamp(0.0, 1.0) * 255.0) as u8,
885        (color.g.clamp(0.0, 1.0) * 255.0) as u8,
886        (color.b.clamp(0.0, 1.0) * 255.0) as u8,
887        (color.a.clamp(0.0, 1.0) * 255.0) as u8,
888    );
889    paint
890}
891
892fn skia_color(color: oxml_layout::Color) -> tiny_skia::Color {
893    tiny_skia::Color::from_rgba(
894        color.r.clamp(0.0, 1.0) as f32,
895        color.g.clamp(0.0, 1.0) as f32,
896        color.b.clamp(0.0, 1.0) as f32,
897        color.a.clamp(0.0, 1.0) as f32,
898    )
899    .unwrap_or(tiny_skia::Color::TRANSPARENT)
900}
901
902fn raster_stroke(stroke: &LayoutStroke) -> Stroke {
903    Stroke {
904        width: stroke.width as f32,
905        line_cap: match stroke.cap {
906            LineCap::Butt => tiny_skia::LineCap::Butt,
907            LineCap::Round => tiny_skia::LineCap::Round,
908            LineCap::Square => tiny_skia::LineCap::Square,
909        },
910        line_join: match stroke.join {
911            LineJoin::Miter => tiny_skia::LineJoin::Miter,
912            LineJoin::Round => tiny_skia::LineJoin::Round,
913            LineJoin::Bevel => tiny_skia::LineJoin::Bevel,
914        },
915        dash: stroke.dash.as_ref().and_then(|dash| {
916            StrokeDash::new(dash.iter().map(|value| *value as f32).collect(), 0.0)
917        }),
918        ..Stroke::default()
919    }
920}
921
922fn layout_transform(transform: oxml_layout::Transform) -> Transform {
923    Transform::from_row(
924        transform.a as f32,
925        transform.b as f32,
926        transform.c as f32,
927        transform.d as f32,
928        transform.e as f32,
929        transform.f as f32,
930    )
931}
932
933/// Render a glyph run by extracting glyph outlines from the font.
934fn render_glyph_run(
935    pixmap: &mut Pixmap,
936    glyph_run: &oxml_layout::GlyphRun,
937    font_data: &oxml_layout::FontData,
938    transform: Transform,
939    mask: Option<&Mask>,
940) {
941    render_positioned_glyph_run(pixmap, glyph_run, font_data, transform, mask, &[], &[], &[]);
942}
943
944#[allow(clippy::too_many_arguments)]
945fn render_positioned_glyph_run(
946    pixmap: &mut Pixmap,
947    glyph_run: &oxml_layout::GlyphRun,
948    font_data: &oxml_layout::FontData,
949    transform: Transform,
950    mask: Option<&Mask>,
951    x_offsets: &[f64],
952    y_offsets: &[f64],
953    y_advances: &[f64],
954) {
955    let Ok(face) = ttf_parser::Face::parse(&font_data.data, font_data.face_index) else {
956        return;
957    };
958
959    let upem = face.units_per_em() as f64;
960    let scale = glyph_run.font_size / upem;
961
962    let mut paint = Paint::default();
963    paint.set_color_rgba8(
964        (glyph_run.color.r * 255.0) as u8,
965        (glyph_run.color.g * 255.0) as u8,
966        (glyph_run.color.b * 255.0) as u8,
967        (glyph_run.color.a * 255.0) as u8,
968    );
969    paint.anti_alias = true;
970
971    let mut x = glyph_run.origin.x;
972    let mut y = glyph_run.origin.y;
973
974    for (i, &glyph_id) in glyph_run.glyph_ids.iter().enumerate() {
975        let gid = ttf_parser::GlyphId(glyph_id);
976
977        // Build path from glyph outline
978        let mut builder = GlyphPathBuilder::new();
979        if face.outline_glyph(gid, &mut builder).is_some()
980            && let Some(path) = builder.finish()
981        {
982            // Transform: translate to glyph position, scale from font units to points,
983            // and flip Y (font coordinates are Y-up, pixmap is Y-down)
984            let glyph_transform = transform
985                .pre_translate(
986                    (x + x_offsets.get(i).copied().unwrap_or(0.0)) as f32,
987                    (y - y_offsets.get(i).copied().unwrap_or(0.0)) as f32,
988                )
989                .pre_scale(scale as f32, -(scale as f32));
990
991            pixmap.fill_path(&path, &paint, FillRule::Winding, glyph_transform, mask);
992        }
993
994        if i < glyph_run.advances.len() {
995            x += glyph_run.advances[i];
996        }
997        y -= y_advances.get(i).copied().unwrap_or(0.0);
998    }
999}
1000
1001/// Convert a glyph outline to a tiny-skia Path.
1002struct GlyphPathBuilder {
1003    pb: PathBuilder,
1004}
1005
1006impl GlyphPathBuilder {
1007    fn new() -> Self {
1008        GlyphPathBuilder {
1009            pb: PathBuilder::new(),
1010        }
1011    }
1012
1013    fn finish(self) -> Option<tiny_skia::Path> {
1014        self.pb.finish()
1015    }
1016}
1017
1018impl ttf_parser::OutlineBuilder for GlyphPathBuilder {
1019    fn move_to(&mut self, x: f32, y: f32) {
1020        self.pb.move_to(x, y);
1021    }
1022
1023    fn line_to(&mut self, x: f32, y: f32) {
1024        self.pb.line_to(x, y);
1025    }
1026
1027    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
1028        self.pb.quad_to(x1, y1, x, y);
1029    }
1030
1031    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
1032        self.pb.cubic_to(x1, y1, x2, y2, x, y);
1033    }
1034
1035    fn close(&mut self) {
1036        self.pb.close();
1037    }
1038}
1039
1040/// Render an image onto the pixmap.
1041fn render_image(
1042    pixmap: &mut Pixmap,
1043    rect: &oxml_layout::Rect,
1044    data: &[u8],
1045    content_type: &str,
1046    transform: Transform,
1047    mask: Option<&Mask>,
1048) {
1049    // Decode the image
1050    let decoded = crate::image::decode_image(data, content_type);
1051    let Some(decoded) = decoded else {
1052        return;
1053    };
1054
1055    // Create a pixmap from the decoded image data
1056    let img_pixmap = if decoded.is_jpeg {
1057        decode_jpeg_pixmap(data, decoded.width, decoded.height)
1058    } else if decoded.color_space == "DeviceRGB" {
1059        // Convert RGB to RGBA
1060        let mut rgba = Vec::with_capacity(decoded.width as usize * decoded.height as usize * 4);
1061        if let Some(alpha) = &decoded.alpha {
1062            for (rgb, &a) in decoded.data.chunks_exact(3).zip(alpha.iter()) {
1063                rgba.extend_from_slice(&[rgb[0], rgb[1], rgb[2], a]);
1064            }
1065        } else {
1066            for rgb in decoded.data.chunks_exact(3) {
1067                rgba.extend_from_slice(&[rgb[0], rgb[1], rgb[2], 255]);
1068            }
1069        }
1070        let size = tiny_skia::IntSize::from_wh(decoded.width, decoded.height);
1071        size.and_then(|s| Pixmap::from_vec(rgba, s))
1072    } else if decoded.color_space == "DeviceGray" {
1073        // Convert grayscale to RGBA
1074        let mut rgba = Vec::with_capacity(decoded.width as usize * decoded.height as usize * 4);
1075        for &gray in &decoded.data {
1076            rgba.push(gray);
1077            rgba.push(gray);
1078            rgba.push(gray);
1079            rgba.push(255);
1080        }
1081        let size = tiny_skia::IntSize::from_wh(decoded.width, decoded.height);
1082        size.and_then(|s| Pixmap::from_vec(rgba, s))
1083    } else {
1084        return;
1085    };
1086
1087    let Some(img_pixmap) = img_pixmap else {
1088        return;
1089    };
1090
1091    // Calculate the transform to position and scale the image. A zero
1092    // dimension from a malformed image would make this an infinite scale.
1093    if decoded.width == 0 || decoded.height == 0 {
1094        return;
1095    }
1096    let sx = rect.width as f32 / decoded.width as f32;
1097    let sy = rect.height as f32 / decoded.height as f32;
1098
1099    let img_transform = transform
1100        .pre_translate(rect.x as f32, rect.y as f32)
1101        .pre_scale(sx, sy);
1102
1103    let pattern = tiny_skia::Pattern::new(
1104        img_pixmap.as_ref(),
1105        tiny_skia::SpreadMode::Pad,
1106        tiny_skia::FilterQuality::Bilinear,
1107        1.0,
1108        Transform::identity(),
1109    );
1110
1111    let paint = Paint {
1112        shader: pattern,
1113        ..Paint::default()
1114    };
1115
1116    let fill_rect =
1117        tiny_skia::Rect::from_xywh(0.0, 0.0, decoded.width as f32, decoded.height as f32);
1118    if let Some(fill_rect) = fill_rect {
1119        pixmap.fill_rect(fill_rect, &paint, img_transform, mask);
1120    }
1121}
1122
1123fn decode_jpeg_pixmap(data: &[u8], width: u32, height: u32) -> Option<Pixmap> {
1124    let options = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::RGBA);
1125    let mut decoder = JpegDecoder::new_with_options(ZCursor::new(data), options);
1126    let rgba = decoder.decode().ok()?;
1127    let (decoded_width, decoded_height) = decoder.dimensions()?;
1128    if decoded_width != width as usize || decoded_height != height as usize {
1129        return None;
1130    }
1131    let size = tiny_skia::IntSize::from_wh(width, height)?;
1132    Pixmap::from_vec(rgba, size)
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137    use std::io::Cursor;
1138
1139    use super::{
1140        RasterFormat, RasterOptions, RasterOutput, decode_jpeg_pixmap, render_page_to_pixmap,
1141        render_pages,
1142    };
1143    use oxml_layout::{
1144        Color, Effect, FillRule, GradientStop, GroupElement, LayoutResult, MediaId, PageFrame,
1145        Paint, Path, PathCommand, PathElement, Point, PositionedElement, Rect, Stroke, Transform,
1146    };
1147
1148    fn rgb(pixel: tiny_skia::PremultipliedColorU8) -> (u8, u8, u8) {
1149        (pixel.red(), pixel.green(), pixel.blue())
1150    }
1151
1152    fn color(r: f32, g: f32, b: f32) -> Color {
1153        Color {
1154            r: r.into(),
1155            g: g.into(),
1156            b: b.into(),
1157            a: 1.0,
1158        }
1159    }
1160
1161    fn solid_path(path: Path, color: Color) -> PositionedElement {
1162        PositionedElement::Path(PathElement {
1163            path,
1164            fill: Some(Paint::Solid(color)),
1165            stroke: None,
1166        })
1167    }
1168
1169    fn page(elements: Vec<PositionedElement>) -> PageFrame {
1170        PageFrame::new(1, 32.0, 32.0, elements)
1171    }
1172
1173    fn layout(pages: Vec<PageFrame>) -> LayoutResult {
1174        LayoutResult::new(
1175            pages.into_iter().map(Into::into).collect(),
1176            Vec::new(),
1177            None,
1178            Vec::new(),
1179        )
1180    }
1181
1182    fn assert_near_rgb(actual: (u8, u8, u8), expected: (u8, u8, u8), tolerance: u8) {
1183        for (actual, expected) in [actual.0, actual.1, actual.2]
1184            .into_iter()
1185            .zip([expected.0, expected.1, expected.2])
1186        {
1187            let delta = actual.abs_diff(expected);
1188            assert!(
1189                delta <= tolerance,
1190                "channel {actual} differed from {expected} by {delta}, tolerance {tolerance}"
1191            );
1192        }
1193    }
1194
1195    fn decode_tiff_pages(bytes: &[u8]) -> Vec<(u32, u32, Vec<u8>)> {
1196        let mut decoder =
1197            tiff::decoder::Decoder::new(Cursor::new(bytes)).expect("decode TIFF header");
1198        let mut pages = Vec::new();
1199        loop {
1200            let (width, height) = decoder.dimensions().expect("TIFF dimensions");
1201            let tiff::decoder::DecodingResult::U8(data) =
1202                decoder.read_image().expect("read TIFF image")
1203            else {
1204                panic!("TIFF image should decode as RGB8");
1205            };
1206            pages.push((width, height, data));
1207            if !decoder.more_images() {
1208                break;
1209            }
1210            decoder.next_image().expect("advance TIFF image");
1211        }
1212        pages
1213    }
1214
1215    #[test]
1216    fn image_export_options_produce_the_declared_formats_and_exact_pages() {
1217        let layout = layout(vec![
1218            page(vec![PositionedElement::FilledRect {
1219                rect: Rect {
1220                    x: 0.0,
1221                    y: 0.0,
1222                    width: 32.0,
1223                    height: 32.0,
1224                },
1225                color: color(1.0, 0.0, 0.0),
1226            }]),
1227            page(vec![PositionedElement::FilledRect {
1228                rect: Rect {
1229                    x: 0.0,
1230                    y: 0.0,
1231                    width: 32.0,
1232                    height: 32.0,
1233                },
1234                color: color(0.0, 1.0, 0.0),
1235            }]),
1236            page(vec![PositionedElement::FilledRect {
1237                rect: Rect {
1238                    x: 0.0,
1239                    y: 0.0,
1240                    width: 32.0,
1241                    height: 32.0,
1242                },
1243                color: color(0.0, 0.0, 1.0),
1244            }]),
1245        ]);
1246
1247        let png = render_pages(
1248            &layout,
1249            &[2, 0],
1250            RasterOptions {
1251                dpi: 72.0,
1252                format: RasterFormat::Png {
1253                    transparent_background: false,
1254                },
1255            },
1256        )
1257        .expect("selected PNG pages render");
1258        let RasterOutput::SeparatePages(png_pages) = png else {
1259            panic!("PNG renders as separate pages");
1260        };
1261        assert_eq!(png_pages.len(), 2);
1262        let png_page_2 = tiny_skia::Pixmap::decode_png(&png_pages[0]).expect("decode page 2 PNG");
1263        let png_page_0 = tiny_skia::Pixmap::decode_png(&png_pages[1]).expect("decode page 0 PNG");
1264        assert_eq!((png_page_2.width(), png_page_2.height()), (32, 32));
1265        assert_eq!((png_page_0.width(), png_page_0.height()), (32, 32));
1266        assert_eq!(rgb(png_page_2.pixel(16, 16).unwrap()), (0, 0, 255));
1267        assert_eq!(rgb(png_page_0.pixel(16, 16).unwrap()), (255, 0, 0));
1268
1269        let jpeg = render_pages(
1270            &layout,
1271            &[2, 0],
1272            RasterOptions {
1273                dpi: 72.0,
1274                format: RasterFormat::Jpeg { quality: 80 },
1275            },
1276        )
1277        .expect("selected JPEG pages render");
1278        let RasterOutput::SeparatePages(jpeg_pages) = jpeg else {
1279            panic!("JPEG renders as separate pages");
1280        };
1281        assert_eq!(jpeg_pages.len(), 2);
1282        let jpeg_page_2 = decode_jpeg_pixmap(&jpeg_pages[0], 32, 32).expect("decode page 2 JPEG");
1283        let jpeg_page_0 = decode_jpeg_pixmap(&jpeg_pages[1], 32, 32).expect("decode page 0 JPEG");
1284        assert_ne!(jpeg_pages[0], jpeg_pages[1]);
1285        assert_near_rgb(rgb(jpeg_page_2.pixel(16, 16).unwrap()), (0, 0, 255), 8);
1286        assert_near_rgb(rgb(jpeg_page_0.pixel(16, 16).unwrap()), (255, 0, 0), 8);
1287
1288        let tiff = render_pages(
1289            &layout,
1290            &[2, 0],
1291            RasterOptions {
1292                dpi: 72.0,
1293                format: RasterFormat::Tiff,
1294            },
1295        )
1296        .expect("selected TIFF pages render");
1297        let RasterOutput::MultiPageTiff(tiff) = tiff else {
1298            panic!("TIFF renders as one multi-page stream");
1299        };
1300        assert!(tiff.starts_with(b"II*\0") || tiff.starts_with(b"MM\0*"));
1301        let tiff_pages = decode_tiff_pages(&tiff);
1302        assert_eq!(tiff_pages.len(), 2);
1303        assert_eq!((tiff_pages[0].0, tiff_pages[0].1), (32, 32));
1304        assert_eq!((tiff_pages[1].0, tiff_pages[1].1), (32, 32));
1305        let first_center = 3 * (16 * 32 + 16);
1306        let second_center = 3 * (16 * 32 + 16);
1307        assert_eq!(
1308            &tiff_pages[0].2[first_center..first_center + 3],
1309            &[0, 0, 255]
1310        );
1311        assert_eq!(
1312            &tiff_pages[1].2[second_center..second_center + 3],
1313            &[255, 0, 0]
1314        );
1315    }
1316
1317    #[test]
1318    fn transparent_png_keeps_clear_pixels_but_authored_background_paints() {
1319        let blank = layout(vec![PageFrame::new(1, 1.0, 1.0, Vec::new())]);
1320        let transparent = render_pages(
1321            &blank,
1322            &[0],
1323            RasterOptions {
1324                dpi: 72.0,
1325                format: RasterFormat::Png {
1326                    transparent_background: true,
1327                },
1328            },
1329        )
1330        .expect("transparent PNG renders");
1331        let RasterOutput::SeparatePages(pages) = transparent else {
1332            panic!("PNG renders as separate pages");
1333        };
1334        let clear = tiny_skia::Pixmap::decode_png(&pages[0]).expect("decode transparent PNG");
1335        assert_eq!(clear.pixel(0, 0).unwrap().alpha(), 0);
1336
1337        let opaque = render_pages(
1338            &blank,
1339            &[0],
1340            RasterOptions {
1341                dpi: 72.0,
1342                format: RasterFormat::Png {
1343                    transparent_background: false,
1344                },
1345            },
1346        )
1347        .expect("opaque PNG renders");
1348        let RasterOutput::SeparatePages(pages) = opaque else {
1349            panic!("PNG renders as separate pages");
1350        };
1351        let white = tiny_skia::Pixmap::decode_png(&pages[0]).expect("decode opaque PNG");
1352        assert_eq!(white.pixel(0, 0).unwrap().alpha(), 255);
1353
1354        let mut painted = PageFrame::new(1, 1.0, 1.0, Vec::new());
1355        painted.background = Some(Paint::Solid(color(0.0, 0.0, 1.0)));
1356        let painted = render_pages(
1357            &layout(vec![painted]),
1358            &[0],
1359            RasterOptions {
1360                dpi: 72.0,
1361                format: RasterFormat::Png {
1362                    transparent_background: true,
1363                },
1364            },
1365        )
1366        .expect("painted transparent PNG renders");
1367        let RasterOutput::SeparatePages(pages) = painted else {
1368            panic!("PNG renders as separate pages");
1369        };
1370        let blue = tiny_skia::Pixmap::decode_png(&pages[0]).expect("decode painted PNG");
1371        assert_eq!(blue.pixel(0, 0).unwrap().alpha(), 255);
1372    }
1373
1374    #[test]
1375    fn jpeg_quality_is_validated_and_changes_the_encoded_result() {
1376        let layout = layout(vec![page(vec![PositionedElement::FilledRect {
1377            rect: Rect {
1378                x: 0.0,
1379                y: 0.0,
1380                width: 32.0,
1381                height: 32.0,
1382            },
1383            color: color(1.0, 0.0, 0.0),
1384        }])]);
1385
1386        for quality in [0, 101] {
1387            assert!(
1388                render_pages(
1389                    &layout,
1390                    &[0],
1391                    RasterOptions {
1392                        dpi: 72.0,
1393                        format: RasterFormat::Jpeg { quality },
1394                    },
1395                )
1396                .is_err()
1397            );
1398        }
1399
1400        let low = render_pages(
1401            &layout,
1402            &[0],
1403            RasterOptions {
1404                dpi: 72.0,
1405                format: RasterFormat::Jpeg { quality: 30 },
1406            },
1407        )
1408        .expect("low-quality JPEG renders");
1409        let high = render_pages(
1410            &layout,
1411            &[0],
1412            RasterOptions {
1413                dpi: 72.0,
1414                format: RasterFormat::Jpeg { quality: 90 },
1415            },
1416        )
1417        .expect("high-quality JPEG renders");
1418        assert_ne!(low, high);
1419    }
1420
1421    #[test]
1422    fn existing_png_entry_points_equal_opaque_option_defaults() {
1423        let layout = layout(vec![page(vec![PositionedElement::FilledRect {
1424            rect: Rect {
1425                x: 0.0,
1426                y: 0.0,
1427                width: 32.0,
1428                height: 32.0,
1429            },
1430            color: Color::BLACK,
1431        }])]);
1432        let old_page = super::render_page_to_png(&layout, 0, 72.0).expect("old page API renders");
1433        let new_page = render_pages(
1434            &layout,
1435            &[0],
1436            RasterOptions {
1437                dpi: 72.0,
1438                format: RasterFormat::Png {
1439                    transparent_background: false,
1440                },
1441            },
1442        )
1443        .expect("new page API renders");
1444        let RasterOutput::SeparatePages(pages) = new_page else {
1445            panic!("PNG renders as separate pages");
1446        };
1447        assert_eq!(old_page, pages[0]);
1448        assert_eq!(super::render_all_pages(&layout, 72.0), pages);
1449    }
1450
1451    #[test]
1452    fn half_alpha_over_white_rasterises_to_midpoint() {
1453        let page = PageFrame::new(
1454            1,
1455            1.0,
1456            1.0,
1457            vec![PositionedElement::FilledRect {
1458                rect: Rect {
1459                    x: 0.0,
1460                    y: 0.0,
1461                    width: 1.0,
1462                    height: 1.0,
1463                },
1464                color: Color {
1465                    r: 0.0,
1466                    g: 0.0,
1467                    b: 0.0,
1468                    a: 0.5,
1469                },
1470            }],
1471        );
1472
1473        let pixmap = render_page_to_pixmap(&page, &[], 72.0).expect("one-pixel page");
1474        let pixel = pixmap.pixel(0, 0).expect("one-pixel page has one pixel");
1475        assert_eq!((pixel.red(), pixel.green(), pixel.blue()), (128, 128, 128));
1476    }
1477
1478    #[test]
1479    fn jpeg_is_decoded_before_raster_composition() {
1480        let jpeg = vec![
1481            0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x01,
1482            0x00, 0x60, 0x00, 0x60, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06,
1483            0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0a, 0x0c, 0x14, 0x0d,
1484            0x0c, 0x0b, 0x0b, 0x0c, 0x19, 0x12, 0x13, 0x0f, 0x14, 0x1d, 0x1a, 0x1f, 0x1e, 0x1d,
1485            0x1a, 0x1c, 0x1c, 0x20, 0x24, 0x2e, 0x27, 0x20, 0x22, 0x2c, 0x23, 0x1c, 0x1c, 0x28,
1486            0x37, 0x29, 0x2c, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1f, 0x27, 0x39, 0x3d, 0x38, 0x32,
1487            0x3c, 0x2e, 0x33, 0x34, 0x32, 0xff, 0xdb, 0x00, 0x43, 0x01, 0x09, 0x09, 0x09, 0x0c,
1488            0x0b, 0x0c, 0x18, 0x0d, 0x0d, 0x18, 0x32, 0x21, 0x1c, 0x21, 0x32, 0x32, 0x32, 0x32,
1489            0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32,
1490            0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32,
1491            0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32,
1492            0x32, 0x32, 0x32, 0x32, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x00, 0x19, 0x00, 0x06, 0x03,
1493            0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xff, 0xc4, 0x00, 0x1f, 0x00,
1494            0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
1495            0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
1496            0xff, 0xc4, 0x00, 0xb5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05,
1497            0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7d, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05,
1498            0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81,
1499            0x91, 0xa1, 0x08, 0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, 0x62,
1500            0x72, 0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28, 0x29,
1501            0x2a, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48,
1502            0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66,
1503            0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x83, 0x84,
1504            0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99,
1505            0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5,
1506            0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca,
1507            0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5,
1508            0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9,
1509            0xfa, 0xff, 0xc4, 0x00, 0x1f, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
1510            0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05,
1511            0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x11, 0x00, 0x02, 0x01,
1512            0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00,
1513            0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61,
1514            0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xa1, 0xb1, 0xc1, 0x09, 0x23,
1515            0x33, 0x52, 0xf0, 0x15, 0x62, 0x72, 0xd1, 0x0a, 0x16, 0x24, 0x34, 0xe1, 0x25, 0xf1,
1516            0x17, 0x18, 0x19, 0x1a, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x35, 0x36, 0x37, 0x38, 0x39,
1517            0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57,
1518            0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75,
1519            0x76, 0x77, 0x78, 0x79, 0x7a, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a,
1520            0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6,
1521            0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2,
1522            0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7,
1523            0xd8, 0xd9, 0xda, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf2, 0xf3,
1524            0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xda, 0x00, 0x0c, 0x03, 0x01, 0x00,
1525            0x02, 0x11, 0x03, 0x11, 0x00, 0x3f, 0x00, 0xaf, 0xf0, 0x53, 0xfe, 0x44, 0xdb, 0xcf,
1526            0xfb, 0x08, 0x3f, 0xfe, 0x8b, 0x8e, 0x8a, 0x3e, 0x0a, 0x7f, 0xc8, 0x9b, 0x79, 0xff,
1527            0x00, 0x61, 0x07, 0xff, 0x00, 0xd1, 0x71, 0xd1, 0x40, 0x07, 0xc1, 0x4f, 0xf9, 0x13,
1528            0x6f, 0x3f, 0xec, 0x20, 0xff, 0x00, 0xfa, 0x2e, 0x3a, 0x28, 0xf8, 0x29, 0xff, 0x00,
1529            0x22, 0x6d, 0xe7, 0xfd, 0x84, 0x1f, 0xff, 0x00, 0x45, 0xc7, 0x45, 0x00, 0x7f, 0xff,
1530            0xd9,
1531        ];
1532        let image = PositionedElement::Image {
1533            rect: Rect {
1534                x: 0.0,
1535                y: 0.0,
1536                width: 6.0,
1537                height: 25.0,
1538            },
1539            data: jpeg.clone(),
1540            content_type: "image/jpeg".to_owned(),
1541            media_id: MediaId(1),
1542        };
1543        let decoded =
1544            decode_jpeg_pixmap(&jpeg, 6, 25).expect("valid JPEG fixture must decode to a pixmap");
1545        assert_eq!(rgb(decoded.pixel(0, 0).unwrap()), (1, 1, 1));
1546        assert_eq!(rgb(decoded.pixel(5, 0).unwrap()), (192, 192, 192));
1547        let pixmap = render_page_to_pixmap(&PageFrame::new(1, 6.0, 25.0, vec![image]), &[], 72.0)
1548            .expect("JPEG page must rasterise");
1549
1550        assert_eq!(rgb(pixmap.pixel(0, 0).unwrap()), (1, 1, 1));
1551        assert_eq!(rgb(pixmap.pixel(5, 0).unwrap()), (192, 192, 192));
1552    }
1553
1554    #[test]
1555    fn rotated_rectangle_has_a_filled_interior_and_empty_corner() {
1556        let element = PositionedElement::Group(GroupElement {
1557            transform: Transform::rotate_about(45.0, 16.0, 16.0),
1558            clip: None,
1559            opacity: 1.0,
1560            effects: Vec::new(),
1561            children: vec![solid_path(
1562                Path::rect(Rect {
1563                    x: 10.0,
1564                    y: 12.0,
1565                    width: 12.0,
1566                    height: 8.0,
1567                }),
1568                Color::BLACK,
1569            )],
1570        });
1571        let pixmap = render_page_to_pixmap(&page(vec![element]), &[], 72.0).unwrap();
1572
1573        assert_eq!(rgb(pixmap.pixel(16, 16).unwrap()), (0, 0, 0));
1574        assert_eq!(rgb(pixmap.pixel(9, 9).unwrap()), (255, 255, 255));
1575    }
1576
1577    #[test]
1578    fn dashed_line_contains_deterministic_gaps() {
1579        let element = PositionedElement::Line {
1580            start: Point { x: 2.0, y: 4.5 },
1581            end: Point { x: 30.0, y: 4.5 },
1582            width: 1.0,
1583            color: Color::BLACK,
1584            dash_pattern: Some((4.0, 4.0)),
1585        };
1586        let pixmap = render_page_to_pixmap(&page(vec![element]), &[], 72.0).unwrap();
1587
1588        assert_eq!(rgb(pixmap.pixel(3, 4).unwrap()), (0, 0, 0));
1589        assert_eq!(rgb(pixmap.pixel(7, 4).unwrap()), (255, 255, 255));
1590        assert_eq!(rgb(pixmap.pixel(11, 4).unwrap()), (0, 0, 0));
1591    }
1592
1593    #[test]
1594    fn nested_group_transforms_apply_child_before_parent() {
1595        let translated = |transform, children| {
1596            PositionedElement::Group(GroupElement {
1597                transform,
1598                clip: None,
1599                opacity: 1.0,
1600                effects: Vec::new(),
1601                children,
1602            })
1603        };
1604        let leaf = solid_path(
1605            Path::rect(Rect {
1606                x: 0.0,
1607                y: 0.0,
1608                width: 2.0,
1609                height: 2.0,
1610            }),
1611            Color::BLACK,
1612        );
1613        let element = translated(
1614            Transform {
1615                e: 4.0,
1616                ..Transform::IDENTITY
1617            },
1618            vec![translated(
1619                Transform {
1620                    e: 3.0,
1621                    ..Transform::IDENTITY
1622                },
1623                vec![translated(
1624                    Transform {
1625                        f: 5.0,
1626                        ..Transform::IDENTITY
1627                    },
1628                    vec![leaf],
1629                )],
1630            )],
1631        );
1632        let pixmap = render_page_to_pixmap(&page(vec![element]), &[], 72.0).unwrap();
1633
1634        assert_eq!(rgb(pixmap.pixel(7, 5).unwrap()), (0, 0, 0));
1635        assert_eq!(rgb(pixmap.pixel(1, 1).unwrap()), (255, 255, 255));
1636    }
1637
1638    #[test]
1639    fn group_clip_masks_children_and_preserves_outside_pixels() {
1640        let element = PositionedElement::Group(GroupElement {
1641            transform: Transform::IDENTITY,
1642            clip: Some(Path::rect(Rect {
1643                x: 4.0,
1644                y: 4.0,
1645                width: 8.0,
1646                height: 12.0,
1647            })),
1648            opacity: 1.0,
1649            effects: Vec::new(),
1650            children: vec![PositionedElement::Group(GroupElement {
1651                transform: Transform::IDENTITY,
1652                clip: Some(Path::rect(Rect {
1653                    x: 8.0,
1654                    y: 4.0,
1655                    width: 8.0,
1656                    height: 12.0,
1657                })),
1658                opacity: 1.0,
1659                effects: Vec::new(),
1660                children: vec![solid_path(
1661                    Path::rect(Rect {
1662                        x: 0.0,
1663                        y: 0.0,
1664                        width: 24.0,
1665                        height: 24.0,
1666                    }),
1667                    Color::BLACK,
1668                )],
1669            })],
1670        });
1671        let pixmap = render_page_to_pixmap(&page(vec![element]), &[], 72.0).unwrap();
1672
1673        assert_eq!(rgb(pixmap.pixel(10, 10).unwrap()), (0, 0, 0));
1674        assert_eq!(rgb(pixmap.pixel(6, 10).unwrap()), (255, 255, 255));
1675        assert_eq!(rgb(pixmap.pixel(14, 10).unwrap()), (255, 255, 255));
1676    }
1677
1678    #[test]
1679    fn group_opacity_is_applied_once_to_the_composited_subtree() {
1680        let element = PositionedElement::Group(GroupElement {
1681            transform: Transform::IDENTITY,
1682            clip: None,
1683            opacity: 0.5,
1684            effects: Vec::new(),
1685            children: vec![
1686                solid_path(
1687                    Path::rect(Rect {
1688                        x: 2.0,
1689                        y: 2.0,
1690                        width: 12.0,
1691                        height: 12.0,
1692                    }),
1693                    Color::BLACK,
1694                ),
1695                solid_path(
1696                    Path::rect(Rect {
1697                        x: 8.0,
1698                        y: 2.0,
1699                        width: 12.0,
1700                        height: 12.0,
1701                    }),
1702                    Color::BLACK,
1703                ),
1704            ],
1705        });
1706        let pixmap = render_page_to_pixmap(&page(vec![element]), &[], 72.0).unwrap();
1707
1708        assert_eq!(rgb(pixmap.pixel(4, 4).unwrap()), (128, 128, 128));
1709        assert_eq!(rgb(pixmap.pixel(10, 4).unwrap()), (128, 128, 128));
1710    }
1711
1712    #[test]
1713    fn outer_shadow_is_coloured_offset_and_behind_group_content() {
1714        let element = PositionedElement::Group(GroupElement {
1715            transform: Transform::IDENTITY,
1716            clip: None,
1717            opacity: 1.0,
1718            effects: vec![Effect::OuterShadow {
1719                dx: 4.0,
1720                dy: 0.0,
1721                blur: 0.0,
1722                color: Color {
1723                    r: 1.0,
1724                    g: 0.0,
1725                    b: 0.0,
1726                    a: 1.0,
1727                },
1728            }],
1729            children: vec![solid_path(
1730                Path::rect(Rect {
1731                    x: 4.0,
1732                    y: 4.0,
1733                    width: 8.0,
1734                    height: 8.0,
1735                }),
1736                Color::BLACK,
1737            )],
1738        });
1739        let pixmap = render_page_to_pixmap(&page(vec![element]), &[], 72.0).unwrap();
1740
1741        assert_eq!(rgb(pixmap.pixel(6, 6).unwrap()), (0, 0, 0));
1742        assert_eq!(rgb(pixmap.pixel(14, 6).unwrap()), (255, 0, 0));
1743        assert_eq!(rgb(pixmap.pixel(2, 6).unwrap()), (255, 255, 255));
1744    }
1745
1746    #[test]
1747    fn outer_shadow_blur_extends_alpha_and_obeys_group_opacity() {
1748        let element = PositionedElement::Group(GroupElement {
1749            transform: Transform::IDENTITY,
1750            clip: None,
1751            opacity: 0.5,
1752            effects: vec![Effect::OuterShadow {
1753                dx: 0.0,
1754                dy: 0.0,
1755                blur: 2.0,
1756                color: Color::BLACK,
1757            }],
1758            children: vec![solid_path(
1759                Path::rect(Rect {
1760                    x: 12.0,
1761                    y: 12.0,
1762                    width: 4.0,
1763                    height: 4.0,
1764                }),
1765                Color::BLACK,
1766            )],
1767        });
1768        let pixmap = render_page_to_pixmap(&page(vec![element]), &[], 72.0).unwrap();
1769
1770        assert_eq!(rgb(pixmap.pixel(13, 13).unwrap()), (128, 128, 128));
1771        let blurred = rgb(pixmap.pixel(10, 13).unwrap());
1772        assert!(blurred.0 < 255 && blurred.0 > 128, "{blurred:?}");
1773        assert_eq!(blurred.0, blurred.1);
1774        assert_eq!(blurred.1, blurred.2);
1775        assert_eq!(rgb(pixmap.pixel(8, 13).unwrap()), (255, 255, 255));
1776    }
1777
1778    #[test]
1779    fn path_fill_rule_selects_the_tiny_skia_rule() {
1780        let nested = |fill_rule| Path {
1781            commands: vec![
1782                PathCommand::MoveTo(Point { x: 2.0, y: 2.0 }),
1783                PathCommand::LineTo(Point { x: 14.0, y: 2.0 }),
1784                PathCommand::LineTo(Point { x: 14.0, y: 14.0 }),
1785                PathCommand::LineTo(Point { x: 2.0, y: 14.0 }),
1786                PathCommand::Close,
1787                PathCommand::MoveTo(Point { x: 5.0, y: 5.0 }),
1788                PathCommand::LineTo(Point { x: 11.0, y: 5.0 }),
1789                PathCommand::LineTo(Point { x: 11.0, y: 11.0 }),
1790                PathCommand::LineTo(Point { x: 5.0, y: 11.0 }),
1791                PathCommand::Close,
1792            ],
1793            fill_rule,
1794        };
1795        let nonzero = render_page_to_pixmap(
1796            &page(vec![solid_path(nested(FillRule::NonZero), Color::BLACK)]),
1797            &[],
1798            72.0,
1799        )
1800        .unwrap();
1801        let evenodd = render_page_to_pixmap(
1802            &page(vec![solid_path(nested(FillRule::EvenOdd), Color::BLACK)]),
1803            &[],
1804            72.0,
1805        )
1806        .unwrap();
1807
1808        assert_eq!(rgb(nonzero.pixel(8, 8).unwrap()), (0, 0, 0));
1809        assert_eq!(rgb(evenodd.pixel(8, 8).unwrap()), (255, 255, 255));
1810    }
1811
1812    #[test]
1813    fn linear_and_radial_gradients_sample_expected_colours() {
1814        let stops = vec![
1815            GradientStop {
1816                offset: 0.0,
1817                color: Color {
1818                    r: 1.0,
1819                    g: 0.0,
1820                    b: 0.0,
1821                    a: 1.0,
1822                },
1823            },
1824            GradientStop {
1825                offset: 1.0,
1826                color: Color {
1827                    r: 0.0,
1828                    g: 0.0,
1829                    b: 1.0,
1830                    a: 1.0,
1831                },
1832            },
1833        ];
1834        let linear = PositionedElement::Path(PathElement {
1835            path: Path::rect(Rect {
1836                x: 0.0,
1837                y: 0.0,
1838                width: 16.0,
1839                height: 8.0,
1840            }),
1841            fill: Some(Paint::linear(
1842                Point { x: 0.0, y: 0.0 },
1843                Point { x: 16.0, y: 0.0 },
1844                stops.clone(),
1845                (true, true),
1846            )),
1847            stroke: None,
1848        });
1849        let radial = PositionedElement::Path(PathElement {
1850            path: Path::rect(Rect {
1851                x: 0.0,
1852                y: 16.0,
1853                width: 16.0,
1854                height: 16.0,
1855            }),
1856            fill: Some(Paint::radial(
1857                Point { x: 8.0, y: 24.0 },
1858                8.0,
1859                Point { x: 8.0, y: 24.0 },
1860                stops,
1861                (true, true),
1862            )),
1863            stroke: None,
1864        });
1865        let pixmap = render_page_to_pixmap(&page(vec![linear, radial]), &[], 72.0).unwrap();
1866
1867        let linear_start = rgb(pixmap.pixel(1, 4).unwrap());
1868        let linear_end = rgb(pixmap.pixel(14, 4).unwrap());
1869        assert!(linear_start.0 > linear_start.2);
1870        assert!(linear_end.2 > linear_end.0);
1871        let radial_center = rgb(pixmap.pixel(8, 24).unwrap());
1872        let radial_edge = rgb(pixmap.pixel(1, 24).unwrap());
1873        assert!(radial_center.0 > radial_center.2);
1874        assert!(radial_edge.2 > radial_edge.0);
1875    }
1876
1877    #[test]
1878    fn translated_group_gradient_uses_local_coordinates_exactly_once() {
1879        let red = Color {
1880            r: 1.0,
1881            g: 0.0,
1882            b: 0.0,
1883            a: 1.0,
1884        };
1885        let blue = Color {
1886            r: 0.0,
1887            g: 0.0,
1888            b: 1.0,
1889            a: 1.0,
1890        };
1891        let element = PositionedElement::Group(GroupElement {
1892            transform: Transform {
1893                e: 12.0,
1894                ..Transform::IDENTITY
1895            },
1896            clip: None,
1897            opacity: 1.0,
1898            effects: Vec::new(),
1899            children: vec![PositionedElement::Path(PathElement {
1900                path: Path::rect(Rect {
1901                    x: 0.0,
1902                    y: 2.0,
1903                    width: 8.0,
1904                    height: 8.0,
1905                }),
1906                fill: Some(Paint::linear(
1907                    Point { x: 0.0, y: 0.0 },
1908                    Point { x: 8.0, y: 0.0 },
1909                    vec![
1910                        GradientStop {
1911                            offset: 0.0,
1912                            color: red,
1913                        },
1914                        GradientStop {
1915                            offset: 1.0,
1916                            color: blue,
1917                        },
1918                    ],
1919                    (true, true),
1920                )),
1921                stroke: None,
1922            })],
1923        });
1924        let pixmap = render_page_to_pixmap(&page(vec![element]), &[], 72.0).unwrap();
1925
1926        let start = rgb(pixmap.pixel(13, 6).unwrap());
1927        let end = rgb(pixmap.pixel(18, 6).unwrap());
1928        assert!(
1929            start.0 > start.2,
1930            "expected red-dominant start, got {start:?}"
1931        );
1932        assert!(end.2 > end.0, "expected blue-dominant end, got {end:?}");
1933        assert_eq!(rgb(pixmap.pixel(9, 6).unwrap()), (255, 255, 255));
1934    }
1935
1936    #[test]
1937    fn gradient_extend_flags_leave_outside_regions_unpainted() {
1938        let stops = vec![
1939            GradientStop {
1940                offset: 0.0,
1941                color: Color::BLACK,
1942            },
1943            GradientStop {
1944                offset: 1.0,
1945                color: Color::WHITE,
1946            },
1947        ];
1948        let linear = PositionedElement::Path(PathElement {
1949            path: Path::rect(Rect {
1950                x: 0.0,
1951                y: 0.0,
1952                width: 32.0,
1953                height: 12.0,
1954            }),
1955            fill: Some(Paint::linear(
1956                Point { x: 8.0, y: 0.0 },
1957                Point { x: 24.0, y: 0.0 },
1958                stops.clone(),
1959                (false, false),
1960            )),
1961            stroke: None,
1962        });
1963        let radial = PositionedElement::Path(PathElement {
1964            path: Path::rect(Rect {
1965                x: 0.0,
1966                y: 16.0,
1967                width: 32.0,
1968                height: 16.0,
1969            }),
1970            fill: Some(Paint::radial(
1971                Point { x: 16.0, y: 24.0 },
1972                6.0,
1973                Point { x: 16.0, y: 24.0 },
1974                stops,
1975                (false, false),
1976            )),
1977            stroke: None,
1978        });
1979        let pixmap = render_page_to_pixmap(&page(vec![linear, radial]), &[], 72.0).unwrap();
1980
1981        assert_eq!(rgb(pixmap.pixel(4, 6).unwrap()), (255, 255, 255));
1982        assert_ne!(rgb(pixmap.pixel(12, 6).unwrap()), (255, 255, 255));
1983        assert_ne!(rgb(pixmap.pixel(16, 24).unwrap()), (255, 255, 255));
1984        assert_eq!(rgb(pixmap.pixel(24, 24).unwrap()), (255, 255, 255));
1985    }
1986
1987    #[test]
1988    fn gradient_stops_are_sorted_clamped_and_last_repeated_offset_wins() {
1989        let element = PositionedElement::Path(PathElement {
1990            path: Path::rect(Rect {
1991                x: 0.0,
1992                y: 0.0,
1993                width: 16.0,
1994                height: 8.0,
1995            }),
1996            fill: Some(Paint::linear(
1997                Point { x: 0.0, y: 0.0 },
1998                Point { x: 16.0, y: 0.0 },
1999                vec![
2000                    GradientStop {
2001                        offset: 2.0,
2002                        color: Color {
2003                            r: 0.0,
2004                            g: 0.0,
2005                            b: 1.0,
2006                            a: 1.0,
2007                        },
2008                    },
2009                    GradientStop {
2010                        offset: -1.0,
2011                        color: Color {
2012                            r: 1.0,
2013                            g: 0.0,
2014                            b: 0.0,
2015                            a: 1.0,
2016                        },
2017                    },
2018                    GradientStop {
2019                        offset: f64::NAN,
2020                        color: Color {
2021                            r: 0.0,
2022                            g: 1.0,
2023                            b: 0.0,
2024                            a: 1.0,
2025                        },
2026                    },
2027                ],
2028                (true, true),
2029            )),
2030            stroke: None,
2031        });
2032        let pixmap = render_page_to_pixmap(&page(vec![element]), &[], 72.0).unwrap();
2033
2034        let start = rgb(pixmap.pixel(1, 4).unwrap());
2035        let end = rgb(pixmap.pixel(14, 4).unwrap());
2036        assert!(start.1 > start.0 && start.1 > start.2);
2037        assert!(end.2 > end.0 && end.2 > end.1);
2038    }
2039
2040    #[test]
2041    fn page_background_replaces_the_white_default() {
2042        let mut painted = page(Vec::new());
2043        painted.background = Some(Paint::Solid(Color {
2044            r: 0.0,
2045            g: 1.0,
2046            b: 0.0,
2047            a: 1.0,
2048        }));
2049        let painted = render_page_to_pixmap(&painted, &[], 72.0).unwrap();
2050        let defaulted = render_page_to_pixmap(&page(Vec::new()), &[], 72.0).unwrap();
2051        let mut gradient = page(Vec::new());
2052        gradient.background = Some(Paint::linear(
2053            Point { x: 0.0, y: 0.0 },
2054            Point { x: 32.0, y: 0.0 },
2055            vec![
2056                GradientStop {
2057                    offset: 0.0,
2058                    color: Color::BLACK,
2059                },
2060                GradientStop {
2061                    offset: 1.0,
2062                    color: Color::WHITE,
2063                },
2064            ],
2065            (true, true),
2066        ));
2067        let gradient = render_page_to_pixmap(&gradient, &[], 72.0).unwrap();
2068
2069        assert_eq!(rgb(painted.pixel(4, 4).unwrap()), (0, 255, 0));
2070        assert_eq!(rgb(defaulted.pixel(4, 4).unwrap()), (255, 255, 255));
2071        assert!(gradient.pixel(4, 4).unwrap().red() < gradient.pixel(28, 4).unwrap().red());
2072    }
2073
2074    #[test]
2075    fn path_dash_array_contains_deterministic_gaps() {
2076        let mut stroke = Stroke::new(Paint::Solid(Color::BLACK), 1.0);
2077        stroke.dash = Some(vec![4.0, 4.0]);
2078        let element = PositionedElement::Path(PathElement {
2079            path: Path {
2080                commands: vec![
2081                    PathCommand::MoveTo(Point { x: 2.0, y: 8.5 }),
2082                    PathCommand::LineTo(Point { x: 30.0, y: 8.5 }),
2083                ],
2084                fill_rule: FillRule::NonZero,
2085            },
2086            fill: None,
2087            stroke: Some(stroke),
2088        });
2089        let pixmap = render_page_to_pixmap(&page(vec![element]), &[], 72.0).unwrap();
2090
2091        assert_eq!(rgb(pixmap.pixel(3, 8).unwrap()), (0, 0, 0));
2092        assert_eq!(rgb(pixmap.pixel(7, 8).unwrap()), (255, 255, 255));
2093    }
2094}