use crate::cache::Cache;
use crate::color::{ColorSpace, ToRgb};
use crate::context::Context;
use crate::device::Device;
use crate::function::{Function, interpolate};
use crate::interpret::path::get_paint;
use crate::interpret::state::ActiveTransferFunction;
use crate::util::decode_or_warn;
use crate::{BlendMode, CacheKey, ClipPath, CmykData, Image, RasterImage, StencilImage};
use crate::{FillRule, InterpreterWarning, WarningSinkFn, interpret};
use crate::{LumaData, RgbData};
use kurbo::{Affine, Rect, Shape};
use log::warn;
use pdf_syntax::bit_reader::BitReader;
use pdf_syntax::content::TypedIter;
use pdf_syntax::object::Array;
use pdf_syntax::object::Dict;
use pdf_syntax::object::Name;
use pdf_syntax::object::Object;
use pdf_syntax::object::Stream;
use pdf_syntax::object::dict::keys::*;
use pdf_syntax::object::stream::{DecodeFailure, ImageColorSpace, ImageDecodeParams};
use pdf_syntax::page::Resources;
use smallvec::{SmallVec, smallvec};
use std::iter;
use std::ops::Deref;

pub(crate) enum XObject<'a> {
    FormXObject(FormXObject<'a>),
    ImageXObject(ImageXObject<'a>),
}

impl<'a> XObject<'a> {
    pub(crate) fn new(
        stream: &Stream<'a>,
        warning_sink: &WarningSinkFn,
        cache: &Cache,
        transfer_function: Option<ActiveTransferFunction>,
    ) -> Option<Self> {
        let dict = stream.dict();
        match dict.get::<Name>(SUBTYPE)?.deref() {
            IMAGE => Some(Self::ImageXObject(ImageXObject::new(
                stream,
                |_| None,
                warning_sink,
                cache,
                false,
                transfer_function,
            )?)),
            FORM => Some(Self::FormXObject(FormXObject::new(stream, warning_sink)?)),
            _ => None,
        }
    }
}

pub(crate) struct FormXObject<'a> {
    pub(crate) decoded: Vec<u8>,
    pub(crate) matrix: Affine,
    pub(crate) bbox: [f32; 4],
    is_transparency_group: bool,
    pub(crate) dict: Dict<'a>,
    resources: Dict<'a>,
}

impl<'a> FormXObject<'a> {
    pub(crate) fn new(stream: &Stream<'a>, warning_sink: &WarningSinkFn) -> Option<Self> {
        let dict = stream.dict();

        let decoded = decode_or_warn(stream, warning_sink)?;
        let resources = dict.get::<Dict<'_>>(RESOURCES).unwrap_or_default();

        let matrix = Affine::new(
            dict.get::<[f64; 6]>(MATRIX)
                .unwrap_or([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]),
        );
        let bbox = dict.get::<[f32; 4]>(BBOX)?;
        let is_transparency_group = dict
            .get::<Dict<'_>>(GROUP)
            .and_then(|g| g.get::<Name>(S))
            .as_deref()
            .is_some_and(|s| s == TRANSPARENCY);

        Some(Self {
            decoded,
            matrix,
            is_transparency_group,
            bbox,
            dict: dict.clone(),
            resources,
        })
    }
}

pub(crate) fn draw_xobject<'a>(
    x_object: &XObject<'a>,
    resources: &Resources<'a>,
    context: &mut Context<'a>,
    device: &mut impl Device<'a>,
) {
    match x_object {
        XObject::FormXObject(f) => draw_form_xobject(resources, f, context, device),
        XObject::ImageXObject(i) => {
            draw_image_xobject(i, context, device);
        }
    }
}

pub(crate) fn draw_form_xobject<'a, 'b>(
    resources: &Resources<'a>,
    x_object: &'b FormXObject<'a>,
    context: &mut Context<'a>,
    device: &mut impl Device<'a>,
) {
    if !context.ocg_state.is_visible() {
        return;
    }

    let has_oc = xobject_oc(&x_object.dict, context);
    if !context.ocg_state.is_visible() {
        if has_oc {
            context.ocg_state.end_marked_content();
        }
        return;
    }

    let iter = TypedIter::new(x_object.decoded.as_ref());

    context.path_mut().truncate(0);
    context.save_state();
    // PDF spec §8.10.1 rendering order for Form XObjects (both transparency and
    // non-transparency paths):
    //   1. Save graphics state
    //   2. Pre-concatenate /Matrix with the current CTM  (GL-QA39)
    //   3. Apply /BBox as a clip path (in the resulting coordinate system)
    //   4. Render the Form XObject's content stream
    //   5. Restore graphics state
    //
    // Pre-concatenation means CTM' = CTM * Matrix, i.e. the Matrix is applied
    // on the right so that it transforms from form-local space to the parent
    // space before the parent CTM is applied. `pre_concat_affine` computes
    // `ctm *= matrix` which is exactly CTM * Matrix — correct for both paths.
    context.pre_concat_affine(x_object.matrix);
    context.push_root_transform();

    // Push the BBox clip after applying /Matrix, so the BBox is expressed in
    // the form's own coordinate system (post-Matrix). `context.get().ctm` at
    // this point already incorporates the Matrix, giving the correct clip
    // region in device space. This holds for both the transparency and
    // non-transparency paths. PDF spec §8.10.1.
    device.push_clip_path(&ClipPath {
        path: context.get().ctm
            * Rect::new(
                x_object.bbox[0] as f64,
                x_object.bbox[1] as f64,
                x_object.bbox[2] as f64,
                x_object.bbox[3] as f64,
            )
            .to_path(0.1),
        fill: FillRule::NonZero,
    });

    if x_object.is_transparency_group {
        device.push_transparency_group(
            context.get().graphics_state.non_stroke_alpha,
            std::mem::take(&mut context.get_mut().graphics_state.soft_mask),
            std::mem::take(&mut context.get_mut().graphics_state.blend_mode),
        );

        context.get_mut().graphics_state.non_stroke_alpha = 1.0;
        context.get_mut().graphics_state.stroke_alpha = 1.0;
    }

    device.set_soft_mask(context.get().graphics_state.soft_mask.clone());
    device.set_blend_mode(context.get().graphics_state.blend_mode);

    interpret(
        iter,
        &Resources::from_parent(x_object.resources.clone(), resources.clone()),
        context,
        device,
    );

    if x_object.is_transparency_group {
        device.pop_transparency_group();
    }

    device.pop_clip_path();

    context.pop_root_transform();
    context.restore_state(device);

    if has_oc {
        context.ocg_state.end_marked_content();
    }
}

pub(crate) fn draw_image_xobject<'a, 'b>(
    x_object: &ImageXObject<'b>,
    context: &mut Context<'a>,
    device: &mut impl Device<'a>,
) {
    if !context.ocg_state.is_visible() {
        return;
    }

    let has_oc = xobject_oc(x_object.stream.dict(), context);
    if !context.ocg_state.is_visible() {
        if has_oc {
            context.ocg_state.end_marked_content();
        }
        return;
    }

    let width = (x_object.width as f64).max(1.0);
    let height = (x_object.height as f64).max(1.0);

    context.save_state();
    context.pre_concat_affine(Affine::new([
        1.0 / width,
        0.0,
        0.0,
        -1.0 / height,
        0.0,
        1.0,
    ]));
    let transform = context.get().ctm;

    let has_alpha = x_object.has_alpha();

    let mut soft_mask = std::mem::take(&mut context.get_mut().graphics_state.soft_mask);
    let blend_mode = std::mem::take(&mut context.get_mut().graphics_state.blend_mode);

    // If image has smask, the soft mask from the graphics state should be discarde.
    if has_alpha {
        soft_mask = None;
    }

    device.push_transparency_group(
        context.get().graphics_state.non_stroke_alpha,
        std::mem::take(&mut soft_mask),
        blend_mode,
    );

    device.set_soft_mask(None);
    device.set_blend_mode(BlendMode::default());

    let image = if x_object.is_image_mask {
        Image::Stencil(StencilImage {
            paint: get_paint(context, false),
            image_xobject: x_object.clone(),
        })
    } else {
        Image::Raster(RasterImage(x_object.clone()))
    };

    device.draw_image(image, transform);
    device.pop_transparency_group();

    context.restore_state(device);

    if has_oc {
        context.ocg_state.end_marked_content();
    }
}

fn xobject_oc(dict: &Dict<'_>, context: &mut Context<'_>) -> bool {
    let Some(oc_dict) = dict.get::<Dict<'_>>(OC) else {
        return false;
    };

    if let Some(oc_ref) = dict.get_ref(OC) {
        context.ocg_state.begin_ocg(&oc_dict, oc_ref.into());
    } else {
        context.ocg_state.begin_ocmd(&oc_dict);
    }

    true
}

#[derive(Clone)]
pub(crate) struct ImageXObject<'a> {
    width: u32,
    height: u32,
    color_space: Option<ColorSpace>,
    cache: Cache,
    interpolate: bool,
    is_image_mask: bool,
    force_luma: bool,
    stream: Stream<'a>,
    transfer_function: Option<ActiveTransferFunction>,
    warning_sink: WarningSinkFn,
}

impl<'a> ImageXObject<'a> {
    pub(crate) fn new(
        stream: &Stream<'a>,
        resolve_cs: impl FnOnce(&Name) -> Option<ColorSpace>,
        warning_sink: &WarningSinkFn,
        cache: &Cache,
        force_luma: bool,
        transfer_function: Option<ActiveTransferFunction>,
    ) -> Option<Self> {
        let dict = stream.dict();

        let image_mask = dict
            .get::<bool>(IM)
            .or_else(|| dict.get::<bool>(IMAGE_MASK))
            .unwrap_or(false);
        let image_cs = if image_mask {
            Some(ColorSpace::device_gray())
        } else {
            let cs_obj = dict
                .get::<Object<'_>>(CS)
                .or_else(|| dict.get::<Object<'_>>(COLORSPACE));

            cs_obj
                .clone()
                .and_then(|c| ColorSpace::new(c, cache, warning_sink))
                // Inline images can also refer to color spaces by name.
                .or_else(|| {
                    cs_obj
                        .and_then(|c| c.into_name())
                        .and_then(|n| resolve_cs(&n))
                })
        };

        // MuPDF uses bilinear interpolation by default for all images regardless of
        // the PDF /Interpolate flag. Match this behaviour so SSIM scores align.
        // The /Interpolate flag is a rendering hint most viewers ignore.
        let interpolate = true;

        let width = dict.get::<u32>(W).or_else(|| dict.get::<u32>(WIDTH))?;
        let height = dict.get::<u32>(H).or_else(|| dict.get::<u32>(HEIGHT))?;

        if width == 0 || height == 0 {
            return None;
        }

        Some(Self {
            force_luma,
            width,
            cache: cache.clone(),
            height,
            color_space: image_cs,
            warning_sink: warning_sink.clone(),
            transfer_function,
            interpolate,
            stream: stream.clone(),
            is_image_mask: image_mask,
        })
    }

    pub(crate) fn decoded_object(
        &self,
        target_dimension: Option<(u32, u32)>,
    ) -> Option<DecodedImageXObject> {
        DecodedImageXObject::new(self, target_dimension)
    }

    pub(crate) fn width(&self) -> u32 {
        self.width
    }

    pub(crate) fn height(&self) -> u32 {
        self.height
    }

    fn has_alpha(&self) -> bool {
        let dict = self.stream.dict();

        self.is_image_mask
            || dict.contains_key(SMASK_IN_DATA)
            || dict.contains_key(SMASK)
            || dict.contains_key(MASK)
    }
}

pub(crate) struct DecodedImageXObject {
    pub(crate) rgb_data: Option<RgbData>,
    pub(crate) cmyk_data: Option<CmykData>,
    pub(crate) luma_data: Option<LumaData>,
}

impl DecodedImageXObject {
    fn new(obj: &ImageXObject<'_>, target_dimension: Option<(u32, u32)>) -> Option<Self> {
        let dict = obj.stream.dict();

        let dict_bpc = dict
            .get::<u8>(BPC)
            .or_else(|| dict.get::<u8>(BITS_PER_COMPONENT));

        let color_space = obj.color_space.clone();

        let is_indexed = obj.color_space.as_ref().is_some_and(|cs| cs.is_indexed());

        let decode_params = ImageDecodeParams {
            is_indexed,
            bpc: dict_bpc,
            num_components: color_space.as_ref().map(|c| c.num_components()),
            target_dimension,
            width: obj.width,
            height: obj.height,
        };

        let mut decoded = obj
            .stream
            .decoded_image(&decode_params)
            .map_err(|e| {
                // #1467: discriminate StreamTooLarge so the pdf-engine warning
                // collector can surface it as EngineError::LimitExceeded.
                let warning = match e {
                    DecodeFailure::StreamTooLarge { observed, limit } => {
                        InterpreterWarning::StreamTooLarge { observed, limit }
                    }
                    _ => InterpreterWarning::ImageDecodeFailure,
                };
                (obj.warning_sink)(warning);
            })
            .ok()?;

        let (mut scale_x, mut scale_y) = (1.0, 1.0);

        let (width, mut height) = decoded
            .image_data
            .as_ref()
            .map(|d| {
                if d.width > 0 && d.height > 0 {
                    scale_x = obj.width as f32 / d.width as f32;
                    scale_y = obj.height as f32 / d.height as f32;
                }

                (d.width, d.height)
            })
            .unwrap_or((obj.width, obj.height));

        // When the JPEG decoder applied a YCbCr→RGB colour transform the
        // decoded bytes are already in sRGB colorimetry (BT.601 matrix).
        // Applying an additional PDF ICCBased / CalRGB conversion on top would
        // produce wrong colours (double-conversion).  Override the PDF's
        // /ColorSpace with DeviceRGB in that case, matching MuPDF/Acrobat. (#558)
        let color_space = if decoded
            .image_data
            .as_ref()
            .and_then(|d| d.color_space)
            .is_some_and(|cs| matches!(cs, ImageColorSpace::RgbFromYCbCr))
            && color_space.as_ref().is_some_and(|cs| !cs.is_device_rgb())
        {
            Some(ColorSpace::device_rgb())
        } else {
            color_space
        };

        let color_space = color_space
            .or_else(|| {
                decoded
                    .image_data
                    .as_ref()
                    .map(|i| i.color_space)
                    .and_then(|c| {
                        c.and_then(|c| match c {
                            ImageColorSpace::Gray => Some(ColorSpace::device_gray()),
                            ImageColorSpace::Rgb | ImageColorSpace::RgbFromYCbCr => {
                                Some(ColorSpace::device_rgb())
                            }
                            ImageColorSpace::Cmyk => Some(ColorSpace::device_cmyk()),
                            ImageColorSpace::Unknown(_) => None,
                        })
                    })
            })
            .unwrap_or(ColorSpace::device_gray());

        let mut bits_per_component = if obj.is_image_mask {
            1
        } else {
            decoded
                .image_data
                .as_ref()
                .map(|i| i.bits_per_component)
                .or(dict_bpc)
                .unwrap_or(8)
        };

        if !matches!(bits_per_component, 1 | 2 | 4 | 8 | 16) {
            let divisor = width as u64 * height as u64 * color_space.num_components() as u64;
            if let Some(bpc) = (decoded.data.len() as u64 * 8).checked_div(divisor) {
                bits_per_component = bpc as u8;
            }
        }

        let is_luma = obj.is_image_mask || obj.force_luma;

        let decode_arr = dict
            .get::<Array<'_>>(D)
            .or_else(|| dict.get::<Array<'_>>(DECODE))
            .map(|a| a.iter::<(f32, f32)>().collect::<SmallVec<_>>())
            .unwrap_or(color_space.default_decode_arr(bits_per_component as f32));

        let mut luma_data = None;
        let mut cmyk_data = None;

        let rgb_data = if is_luma {
            let components = get_components(
                &decoded.data,
                width,
                height,
                &color_space,
                bits_per_component,
            )?;

            let f32_data = { decode(&components, &color_space, bits_per_component, &decode_arr)? };

            let mut data = if obj.is_image_mask {
                f32_data
                    .iter()
                    .map(|alpha| ((1.0 - *alpha) * 255.0 + 0.5) as u8)
                    .collect()
            } else {
                f32_data
                    .iter()
                    .map(|alpha| (*alpha * 255.0 + 0.5) as u8)
                    .collect()
            };

            fix_image_length(&mut data, width, &mut height, 0, &color_space)?;

            luma_data = Some(LumaData {
                data,
                width,
                height,
                interpolate: obj.interpolate,
                scale_factors: (scale_x, scale_y),
            });

            return Some(Self {
                rgb_data: None,
                cmyk_data: None,
                luma_data,
            });
        } else if bits_per_component == 8
            && color_space.supports_u8()
            && obj.transfer_function.is_none()
            && decode_arr.as_slice()
                == color_space
                    .default_decode_arr(bits_per_component as f32)
                    .as_slice()
            && !is_luma
        {
            // This is actually the most common case, where the PDF is embedded as a 8-bit RGB color
            // and no special decode array. In this case, we can prevent the round-trip from
            // f32 back to u8 and just return the raw decoded data, which will already be in
            // RGB8 with values between 0 and 255.
            fix_image_length(&mut decoded.data, width, &mut height, 0, &color_space)?;
            if color_space.is_device_cmyk() {
                cmyk_data = Some(CmykData {
                    data: decoded.data.clone(),
                    width,
                    height,
                    interpolate: obj.interpolate,
                    scale_factors: (scale_x, scale_y),
                });
            }
            let mut output_buf = vec![0; width as usize * height as usize * 3];
            color_space.convert_u8(&decoded.data, &mut output_buf)?;

            Some(RgbData {
                data: output_buf,
                width,
                height,
                interpolate: obj.interpolate,
                scale_factors: (scale_x, scale_y),
            })
        } else {
            let components = get_components(
                &decoded.data,
                width,
                height,
                &color_space,
                bits_per_component,
            )?;

            let mut f32_data =
                { decode(&components, &color_space, bits_per_component, &decode_arr)? };

            fix_image_length(&mut f32_data, width, &mut height, 0.0, &color_space)?;

            let mut rgb_data = get_rgb_data(
                &f32_data,
                width,
                height,
                (scale_x, scale_y),
                &color_space,
                obj.interpolate,
            );

            if let Some(transfer_function) = &obj.transfer_function
                && let Some(rgb_data) = &mut rgb_data
            {
                let apply_single = |data: u8, function: &Function| {
                    function
                        .eval(smallvec![data as f32 / 255.0])
                        .and_then(|v| v.first().copied())
                        .map(|v| (v * 255.0 + 0.5) as u8)
                        .unwrap_or(data)
                };

                match transfer_function {
                    ActiveTransferFunction::Single(s) => {
                        for data in &mut rgb_data.data {
                            *data = apply_single(*data, s);
                        }
                    }
                    ActiveTransferFunction::Four(f) => {
                        for data in rgb_data.data.chunks_exact_mut(3) {
                            data[0] = apply_single(data[0], &f[0]);
                            data[1] = apply_single(data[1], &f[1]);
                            data[2] = apply_single(data[2], &f[2]);
                        }
                    }
                }
            }

            rgb_data
        };

        if !is_luma {
            let dict = obj.stream.dict();

            luma_data = if let Some(1) = dict.get::<u8>(SMASK_IN_DATA) {
                let smask_data = decoded.image_data.and_then(|i| i.alpha);

                if let Some(mut data) = smask_data {
                    fix_image_length(&mut data, width, &mut height, 0, &ColorSpace::device_gray())?;

                    Some(LumaData {
                        data,
                        width,
                        height,
                        interpolate: obj.interpolate,
                        scale_factors: (scale_x, scale_y),
                    })
                } else {
                    None
                }
            } else if let Some(s_mask) = dict.get::<Stream<'_>>(SMASK) {
                ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, true, None)
                    .and_then(|s| s.decoded_object(target_dimension).and_then(|d| d.luma_data))
            } else if let Some(mask) = dict.get::<Stream<'_>>(MASK) {
                if let Some(obj) =
                    ImageXObject::new(&mask, |_| None, &obj.warning_sink, &obj.cache, true, None)
                {
                    obj.decoded_object(target_dimension)
                        .and_then(|d| d.luma_data)
                } else {
                    None
                }
            } else if let Some(color_key_mask) = dict.get::<SmallVec<[u16; 4]>>(MASK) {
                let mut mask_data = vec![];

                let components = get_components(
                    &decoded.data,
                    width,
                    height,
                    &color_space,
                    bits_per_component,
                )?;

                for pixel in components.chunks_exact(color_space.num_components() as usize) {
                    let mut mask_val = 0;

                    for (component, min_max) in pixel.iter().zip(color_key_mask.chunks_exact(2)) {
                        if *component > min_max[1] || *component < min_max[0] {
                            mask_val = 255;
                        }
                    }

                    mask_data.push(mask_val);
                }

                fix_image_length(
                    &mut mask_data,
                    width,
                    &mut height,
                    0,
                    &ColorSpace::device_gray(),
                )?;

                Some(LumaData {
                    data: mask_data,
                    width,
                    height,
                    interpolate: obj.interpolate,
                    scale_factors: (scale_x, scale_y),
                })
            } else {
                None
            };
        }

        Some(Self {
            rgb_data,
            cmyk_data,
            luma_data,
        })
    }
}

fn get_rgb_data(
    decoded: &[f32],
    width: u32,
    height: u32,
    scale_factors: (f32, f32),
    cs: &ColorSpace,
    interpolate: bool,
) -> Option<RgbData> {
    // To prevent a panic when calling the `chunks` method.
    if cs.num_components() == 0 {
        return None;
    }

    let mut output = vec![0; width as usize * height as usize * 3];
    cs.convert_f32(decoded, &mut output, false);

    Some(RgbData {
        data: output,
        width,
        height,
        interpolate,
        scale_factors,
    })
}

impl CacheKey for ImageXObject<'_> {
    fn cache_key(&self) -> u128 {
        self.stream.cache_key()
    }
}

#[must_use]
fn fix_image_length<T: Copy>(
    image: &mut Vec<T>,
    width: u32,
    height: &mut u32,
    filler: T,
    cs: &ColorSpace,
) -> Option<()> {
    let row_len = width as usize * cs.num_components() as usize;

    if (row_len * *height as usize) <= image.len() {
        // Too much data (or just the right amount), truncate it.
        image.truncate(row_len * *height as usize);
    } else {
        // Too little data, adapt the height and pad.
        *height = image.len().div_ceil(row_len) as u32;

        if !image.len().is_multiple_of(row_len) {
            image.extend(iter::repeat_n(filler, row_len - (image.len() % row_len)));
        }
    }

    if width == 0 || *height == 0 {
        None
    } else {
        Some(())
    }
}

fn get_components(
    data: &[u8],
    width: u32,
    height: u32,
    color_space: &ColorSpace,
    bits_per_component: u8,
) -> Option<Vec<u16>> {
    let result = match bits_per_component {
        1..8 | 9..16 => {
            let mut buf = vec![];
            let bpc = bits_per_component;
            let mut reader = BitReader::new(data);

            for _ in 0..height {
                for _ in 0..width {
                    for _ in 0..color_space.num_components() {
                        // See `stream_ccit_not_enough_data`, some images seemingly don't have
                        // enough data, so we just pad with zeroes in this case.
                        let next = reader.read(bpc).unwrap_or(0) as u16;

                        buf.push(next);
                    }
                }

                reader.align();
            }

            buf
        }
        8 => data.iter().map(|v| *v as u16).collect(),
        16 => data
            .chunks(2)
            .map(|v| u16::from_be_bytes([v[0], v[1]]))
            .collect(),
        _ => {
            warn!("unsupported bits per component: {bits_per_component}");
            return None;
        }
    };

    Some(result)
}

fn decode(
    components: &[u16],
    color_space: &ColorSpace,
    bits_per_component: u8,
    decode: &[(f32, f32)],
) -> Option<Vec<f32>> {
    let interpolate = |n: f32, d_min: f32, d_max: f32| {
        interpolate(
            n,
            0.0,
            2.0_f32.powi(bits_per_component as i32) - 1.0,
            d_min,
            d_max,
        )
    };

    let mut decoded_arr = vec![];

    for pixel in components.chunks(color_space.num_components() as usize) {
        for (component, (d_min, d_max)) in pixel.iter().zip(decode) {
            decoded_arr.push(interpolate(*component as f32, *d_min, *d_max));
        }
    }

    Some(decoded_arr)
}

#[cfg(test)]
mod tests {
    use crate::device::DummyDevice;
    use crate::util::PageExt;
    use crate::{Context, InterpreterSettings, interpret_page};
    use pdf_syntax::Pdf;

    /// A minimal PDF with a Form XObject invoked via the `Do` operator.
    ///
    /// The Form XObject draws a filled rectangle. The test verifies that
    /// `interpret_page` completes without panicking, which would happen if the
    /// graphics state save/restore or the clip push/pop were imbalanced.
    ///
    /// The PDF also contains a transparency-group Form XObject (with a `/Group`
    /// dict specifying `/S /Transparency`) to exercise the path where
    /// `push_transparency_group` / `pop_transparency_group` are called. A Form
    /// XObject whose `/Group` dict has a *different* subtype must NOT be treated
    /// as a transparency group (regression guard for the too-broad
    /// `is_transparency_group` check that previously matched any `/Group` dict).
    #[test]
    fn form_xobject_state_isolation() {
        // The PDF is hand-crafted with cross-reference offsets computed to be
        // accurate for the byte layout below.
        let pdf_bytes = b"%PDF-1.4\n\
            1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\
            2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n\
            3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100]\n\
              /Resources << /XObject << /Fm0 4 0 R /Fm1 5 0 R >> >>\n\
              /Contents 6 0 R >>\nendobj\n\
            4 0 obj\n\
            << /Type /XObject /Subtype /Form /BBox [0 0 50 50]\n\
               /Matrix [1 0 0 1 10 10] >>\n\
            stream\n0.5 g\n0 0 40 40 re f\nendstream\nendobj\n\
            5 0 obj\n\
            << /Type /XObject /Subtype /Form /BBox [0 0 50 50]\n\
               /Group << /Type /Group /S /Transparency >> >>\n\
            stream\n0.8 g\n0 0 50 50 re f\nendstream\nendobj\n\
            6 0 obj\n<< /Length 20 >>\nstream\n/Fm0 Do\n/Fm1 Do\nendstream\nendobj\n\
            xref\n0 7\n\
            0000000000 65535 f \n\
            0000000009 00000 n \n\
            0000000058 00000 n \n\
            0000000115 00000 n \n\
            0000000266 00000 n \n\
            0000000380 00000 n \n\
            0000000494 00000 n \n\
            trailer\n<< /Size 7 /Root 1 0 R >>\n\
            startxref\n564\n%%EOF\n";

        let pdf = Pdf::new(pdf_bytes.to_vec());
        // If parsing fails the PDF bytes are malformed; skip rather than fail.
        let Ok(pdf) = pdf else { return };

        let pages = pdf.pages();
        let Some(page) = pages.get(0) else { return };

        let settings = InterpreterSettings::default();
        let initial_transform = page.initial_transform(true);
        let bbox = kurbo::Rect::new(0.0, 0.0, 100.0, 100.0);
        let mut context = Context::new(initial_transform, bbox, page.xref(), settings);
        let mut device = DummyDevice;

        // Must not panic: verifies that q/Q (save/restore) and clip push/pop
        // are balanced for both plain and transparency-group Form XObjects.
        interpret_page(&page, &mut context, &mut device);
    }
}
