mupdf 0.8.0

Safe Rust wrapper to MuPDF
Documentation
use crate::pdf::FontInfo;
use crate::{Error, Font, Matrix, Point};

#[derive(Clone, Copy)]
pub(super) enum ColorRole {
    Stroke,
    Fill,
}

pub(super) fn format_g(value: f32) -> String {
    const PRECISION: i32 = 6;

    if value.is_nan() {
        return "nan".to_owned();
    }
    if value.is_infinite() {
        return if value.is_sign_negative() {
            "-inf".to_owned()
        } else {
            "inf".to_owned()
        };
    }
    if value == 0.0 {
        return if value.is_sign_negative() {
            "-0".to_owned()
        } else {
            "0".to_owned()
        };
    }

    let sign = if value.is_sign_negative() { "-" } else { "" };
    let value = f64::from(value.abs());
    let scientific = format!("{:.*e}", (PRECISION - 1) as usize, value);
    let Some((mantissa, exponent)) = scientific.split_once('e') else {
        return format!("{sign}{scientific}");
    };
    let Ok(exponent) = exponent.parse::<i32>() else {
        return format!("{sign}{scientific}");
    };

    if !(-4..PRECISION).contains(&exponent) {
        let mut mantissa = mantissa.to_owned();
        strip_trailing_fraction_zeros(&mut mantissa);
        format!(
            "{sign}{mantissa}e{}{abs_exponent:02}",
            if exponent < 0 { '-' } else { '+' },
            abs_exponent = exponent.abs()
        )
    } else {
        let digits_after_decimal = (PRECISION - exponent - 1).max(0) as usize;
        let mut fixed = format!("{sign}{value:.digits_after_decimal$}");
        strip_trailing_fraction_zeros(&mut fixed);
        fixed
    }
}

fn strip_trailing_fraction_zeros(value: &mut String) {
    if !value.contains('.') {
        return;
    }

    while value.ends_with('0') {
        value.pop();
    }
    if value.ends_with('.') {
        value.pop();
    }
}

pub(super) fn color_code(components: &[f32], role: ColorRole) -> Result<String, Error> {
    if !matches!(components.len(), 1 | 3 | 4)
        || !components
            .iter()
            .all(|component| component.is_finite() && (0.0..=1.0).contains(component))
    {
        return Err(Error::InvalidArgument(
            "color components must be finite values in the 0..=1 range".to_owned(),
        ));
    }

    let operator = match components.len() {
        1 => match role {
            ColorRole::Stroke => "G",
            ColorRole::Fill => "g",
        },
        3 => match role {
            ColorRole::Stroke => "RG",
            ColorRole::Fill => "rg",
        },
        4 => match role {
            ColorRole::Stroke => "K",
            ColorRole::Fill => "k",
        },
        _ => unreachable!("color component count was validated"),
    };
    let components = components
        .iter()
        .map(|component| format_g(*component))
        .collect::<Vec<_>>()
        .join(" ");
    Ok(format!("{components} {operator}\n"))
}

pub(super) fn tj_str(text: &str, font_info: &FontInfo) -> String {
    if text.starts_with("[<") && text.ends_with(">]") {
        return text.to_owned();
    }
    if text.is_empty() {
        return "[<>]".to_owned();
    }

    let mut hex = String::new();

    if font_info.simple {
        for ch in text.chars() {
            let code = ch as u32;
            let byte = if code < 256 {
                font_info
                    .glyphs
                    .as_ref()
                    .and_then(|glyphs| glyphs.get(&code))
                    .and_then(|glyph| u8::try_from(*glyph).ok())
                    .unwrap_or(code as u8)
            } else {
                0xb7
            };
            hex.push_str(&format!("{byte:02x}"));
        }
        return format!("[<{hex}>]");
    }

    let runtime_font = if font_info.ordering.is_none() {
        Font::new(&font_info.name).ok()
    } else {
        None
    };

    for ch in text.chars() {
        let code = ch as u32;
        let glyph = if font_info.ordering.is_some() {
            Some(code)
        } else {
            font_info
                .glyphs
                .as_ref()
                .and_then(|glyphs| glyphs.get(&code))
                .and_then(|glyph| u32::try_from(*glyph).ok())
                .or_else(|| {
                    runtime_font
                        .as_ref()
                        .and_then(|font| font.encode_character(code as i32).ok())
                        .and_then(|glyph| u32::try_from(glyph).ok())
                })
                .or(Some(0))
        };

        if let Some(glyph) = glyph {
            hex.push_str(&format!("{glyph:04x}"));
        }
    }

    format!("[<{hex}>]")
}

pub(super) fn util_hor_matrix(c: Point, p: Point) -> Matrix {
    let s = (p - c).unit();
    Matrix::new(
        s.x,
        -s.y,
        s.y,
        s.x,
        -(c.x * s.x + c.y * s.y),
        c.x * s.y - c.y * s.x,
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::pdf::FontInfo;
    use crate::{SimpleFontEncoding, WriteMode};
    use std::collections::HashMap;

    fn font_info(simple: bool, glyphs: Option<HashMap<u32, i32>>) -> FontInfo {
        FontInfo {
            ascender: 1.0,
            descender: -0.2,
            glyphs,
            simple,
            encoding: SimpleFontEncoding::Latin,
            ordering: None,
            wmode: WriteMode::Horizontal,
            serif: false,
            fontfile_hash: None,
            name: "Helvetica".to_owned(),
        }
    }

    fn assert_point_near(actual: Point, expected: Point, epsilon: f32) {
        assert!(
            (actual.x - expected.x).abs() <= epsilon,
            "x mismatch: actual={actual:?}, expected={expected:?}"
        );
        assert!(
            (actual.y - expected.y).abs() <= epsilon,
            "y mismatch: actual={actual:?}, expected={expected:?}"
        );
    }

    #[test]
    fn format_g_matches_python_percent_g_representatives() {
        let cases = [
            (0.0, "0"),
            (1.0, "1"),
            (-1.0, "-1"),
            (0.5, "0.5"),
            (0.1, "0.1"),
            (1.5e-6, "1.5e-06"),
            (123456.0, "123456"),
            (1234567.0, "1.23457e+06"),
            (0.0000001, "1e-07"),
            (-0.0, "-0"),
            (0.123_456_79, "0.123457"),
            (-0.123_456_79, "-0.123457"),
            (999999.9, "1e+06"),
            (0.00009999999, "0.0001"),
        ];

        for (input, expected) in cases {
            assert_eq!(format_g(input), expected, "input: {input:?}");
        }
    }

    #[test]
    fn format_g_is_deterministic_and_locale_independent() {
        const INPUTS: [f32; 4] = [0.5, 1234567.0, 1.5e-6, 0.123_456_79];
        let before: Vec<String> = INPUTS.into_iter().map(format_g).collect();

        for _ in 0..1000 {
            let repeated: Vec<String> = INPUTS.into_iter().map(format_g).collect();
            assert_eq!(repeated, before);
            assert!(repeated.iter().all(|value| !value.contains(',')));
        }

        #[cfg(not(target_arch = "wasm32"))]
        {
            let handles = (0..2).map(|_| {
                std::thread::spawn(|| {
                    (0..1000)
                        .map(|_| INPUTS.into_iter().map(format_g).collect::<Vec<_>>())
                        .collect::<Vec<_>>()
                })
            });

            for handle in handles {
                for threaded in handle.join().unwrap() {
                    assert_eq!(threaded, before);
                    assert!(threaded.iter().all(|value| !value.contains(',')));
                }
            }
        }
    }

    #[test]
    fn color_code_emits_role_specific_pdf_operators() {
        let cases = [
            (&[0.5][..], ColorRole::Stroke, "0.5 G\n"),
            (&[0.5][..], ColorRole::Fill, "0.5 g\n"),
            (&[1.0, 0.0, 0.0][..], ColorRole::Stroke, "1 0 0 RG\n"),
            (&[1.0, 0.0, 0.0][..], ColorRole::Fill, "1 0 0 rg\n"),
            (
                &[0.1, 0.2, 0.3, 0.4][..],
                ColorRole::Stroke,
                "0.1 0.2 0.3 0.4 K\n",
            ),
            (
                &[0.1, 0.2, 0.3, 0.4][..],
                ColorRole::Fill,
                "0.1 0.2 0.3 0.4 k\n",
            ),
        ];

        for (components, role, expected) in cases {
            assert_eq!(color_code(components, role).unwrap(), expected);
        }
    }

    #[test]
    fn color_code_rejects_invalid_components() {
        let invalid_cases = [
            &[][..],
            &[0.5, 0.5][..],
            &[0.1, 0.1, 0.1, 0.1, 0.1][..],
            &[1.5, 0.0, 0.0][..],
            &[-0.1, 0.0, 0.0][..],
        ];

        for components in invalid_cases {
            assert!(color_code(components, ColorRole::Fill).is_err());
            assert!(color_code(components, ColorRole::Stroke).is_err());
        }
    }

    #[test]
    fn color_code_uses_format_g_for_components() {
        let expected = format!(
            "{} {} {} rg\n",
            format_g(0.123_456_79),
            format_g(0.0),
            format_g(0.0)
        );
        assert_eq!(
            color_code(&[0.123_456_79, 0.0, 0.0], ColorRole::Fill).unwrap(),
            expected
        );
    }

    #[test]
    fn util_hor_matrix_maps_start_to_origin_and_end_to_positive_x_axis() {
        let c = Point::new(10.0, 20.0);
        let p = Point::new(40.0, 60.0);
        let matrix = util_hor_matrix(c, p);

        assert_point_near(c.mul_matrix(&matrix), Point::new(0.0, 0.0), 1e-5);

        let transformed = p.mul_matrix(&matrix);
        assert!((transformed.x - 50.0).abs() <= 1e-5);
        assert!(transformed.x > 0.0);
        assert!(transformed.y.abs() <= 1e-5);
    }

    #[test]
    fn tj_str_simple_ascii_returns_single_byte_hex() {
        assert_eq!(tj_str("AB", &font_info(true, None)), "[<4142>]");
    }

    #[test]
    fn tj_str_empty_preserves_empty_hex_group() {
        assert_eq!(tj_str("", &font_info(true, None)), "[<>]");
    }

    #[test]
    fn tj_str_simple_latin1_returns_single_byte_lowercase_hex() {
        assert_eq!(tj_str("é", &font_info(true, None)), "[<e9>]");
    }

    #[test]
    fn tj_str_non_simple_uses_four_digit_glyph_ids() {
        let glyphs = HashMap::from([('A' as u32, 0x41), ('B' as u32, 0x42)]);

        assert_eq!(
            tj_str("AB", &font_info(false, Some(glyphs))),
            "[<00410042>]"
        );
    }

    #[test]
    fn tj_str_non_simple_falls_back_to_runtime_font_lookup() {
        assert_eq!(tj_str("a", &font_info(false, None)), "[<0042>]");
    }

    #[test]
    fn tj_str_non_simple_missing_glyph_uses_notdef() {
        let mut info = font_info(false, Some(HashMap::new()));
        info.name = "__missing_runtime_font__".to_owned();

        assert_eq!(tj_str("Ω", &info), "[<0000>]");
    }
}