katana-document-viewer 0.1.4

KatanA document viewer artifact, render evaluation, and export foundation.
Documentation
#[cfg(test)]
mod tests {
    use super::super::export_surface_font_test_support::{
        actual_span_x_range, estimated_text_width, painted_x_range,
    };
    use super::super::{SurfaceTextLayout, SurfaceTextPainter, rendering};
    use crate::export_surface_span::{SurfaceTextSpan, SurfaceTextStyle};
    use image::{Rgba, RgbaImage};

    #[test]
    fn emoji_characters_are_not_rendered_as_blank_advance() -> Result<(), Box<dyn std::error::Error>>
    {
        let mut painter =
            SurfaceTextPainter::from_system_fonts().ok_or("system font must be available")?;
        let background = Rgba([255, 255, 255, 255]);
        let mut image = RgbaImage::from_pixel(96, 64, background);

        painter.draw_text(
            &mut image,
            "🌍",
            SurfaceTextLayout {
                x: 8,
                y: 8,
                size: 32.0,
                color: Rgba([0, 0, 0, 255]),
                max_width: None,
            },
        );

        assert!(image.pixels().any(|pixel| *pixel != background));
        Ok(())
    }

    #[test]
    fn cjk_underline_width_tracks_full_width_text() {
        let width = estimated_text_width("下線", 24.0);

        assert!(
            width >= 44,
            "CJK underline must cover the rendered text width: {width}"
        );
    }

    #[test]
    fn url_underline_width_does_not_overshoot_ascii_text() {
        let width = estimated_text_width("https://github.com", 24.0);

        assert!(
            (190..=225).contains(&width),
            "URL underline should stay close to the rendered text width: {width}"
        );
    }

    #[test]
    fn plain_underline_uses_text_color_not_link_blue() {
        let mut image = RgbaImage::from_pixel(96, 64, Rgba([255, 255, 255, 255]));
        let spans = vec![SurfaceTextSpan::styled(
            "下線",
            SurfaceTextStyle::default().underline(),
        )];

        let ranges = vec![Some(rendering::SpanVisualRange::new(0.0, 44.0))];
        rendering::draw_span_decorations(&mut image, &spans, &ranges, 8, 8, 24.0);

        let underline_y = 8 + (24.0 * 1.22) as u32;
        let pixel = image.get_pixel(8, underline_y);
        assert_eq!(*pixel, Rgba([36, 41, 47, 255]));
    }

    #[test]
    fn link_underline_starts_at_actual_glyph_range_after_cjk_text()
    -> Result<(), Box<dyn std::error::Error>> {
        let mut painter =
            SurfaceTextPainter::from_system_fonts().ok_or("system font must be available")?;
        let mut image = RgbaImage::from_pixel(720, 96, Rgba([255, 255, 255, 255]));
        let spans = vec![
            SurfaceTextSpan::plain("これは脚注付きのテキストです"),
            SurfaceTextSpan::linked("[1]", "#fn-1", SurfaceTextStyle::default().link()),
        ];

        painter.draw_spans(&mut image, &spans, 8, 16, 24.0, Rgba([36, 41, 47, 255]));

        let actual_link = actual_span_x_range(&spans, 1, 24.0).ok_or("link span must layout")?;
        let drawn_link =
            painted_x_range(&image, link_color()).ok_or("link underline must exist")?;
        assert!(
            drawn_link.0 >= 8 + actual_link.0.saturating_sub(1),
            "link underline must not start before actual glyph range: actual={actual_link:?}, drawn={drawn_link:?}"
        );
        assert!(
            drawn_link.1 <= 8 + actual_link.1 + 1,
            "link underline must not extend beyond actual glyph range: actual={actual_link:?}, drawn={drawn_link:?}"
        );
        Ok(())
    }

    #[test]
    fn backlink_underline_stays_on_backlink_marker_only() -> Result<(), Box<dyn std::error::Error>>
    {
        let mut painter =
            SurfaceTextPainter::from_system_fonts().ok_or("system font must be available")?;
        let mut image = RgbaImage::from_pixel(720, 96, Rgba([255, 255, 255, 255]));
        let spans = vec![
            SurfaceTextSpan::linked("[1] ↩", "#fnref-1", SurfaceTextStyle::default().link()),
            SurfaceTextSpan::plain(" 最初の脚注の内容。"),
        ];

        painter.draw_spans(&mut image, &spans, 8, 16, 24.0, Rgba([36, 41, 47, 255]));

        let actual_link =
            actual_span_x_range(&spans, 0, 24.0).ok_or("backlink span must layout")?;
        let drawn_link =
            painted_x_range(&image, link_color()).ok_or("backlink underline must exist")?;
        assert!(
            drawn_link.0 >= 8 + actual_link.0.saturating_sub(1),
            "backlink underline must start at actual glyph range: actual={actual_link:?}, drawn={drawn_link:?}"
        );
        assert!(
            drawn_link.1 <= 8 + actual_link.1 + 1,
            "backlink underline must not enter following footnote text: actual={actual_link:?}, drawn={drawn_link:?}"
        );
        Ok(())
    }

    #[test]
    fn monospace_japanese_span_uses_readable_fallback_font()
    -> Result<(), Box<dyn std::error::Error>> {
        let mut painter =
            SurfaceTextPainter::from_system_fonts().ok_or("system font must be available")?;
        let background = Rgba([255, 255, 255, 255]);
        let mut image = RgbaImage::from_pixel(480, 96, background);
        let spans = vec![SurfaceTextSpan::styled(
            "これは言語指定なしのコードブロックです。",
            SurfaceTextStyle::default().monospace(),
        )];

        painter.draw_spans(&mut image, &spans, 8, 16, 24.0, Rgba([36, 41, 47, 255]));

        let right_side_pixels = image
            .enumerate_pixels()
            .filter(|(x, _, pixel)| *x > 180 && **pixel != background)
            .count();
        assert!(
            right_side_pixels > 20,
            "Japanese code text must not disappear after the first few glyphs"
        );
        Ok(())
    }

    fn link_color() -> Rgba<u8> {
        Rgba([9, 105, 218, 255])
    }
}