///|
fn pdf_image_name(text : String) -> PdfName {
  pdf_name_of_bytes(@ascii.encode(text))
}

///|
let pdf_image_image_mask_key_name : PdfName = pdf_image_name("/ImageMask")

///|
let pdf_image_im_key_name : PdfName = pdf_image_name("/IM")

///|
let pdf_image_colorspace_key_name : PdfName = pdf_image_name("/ColorSpace")

///|
let pdf_image_cs_key_name : PdfName = pdf_image_name("/CS")

///|
let pdf_image_bits_per_component_key_name : PdfName = pdf_image_name(
  "/BitsPerComponent",
)

///|
let pdf_image_bpc_key_name : PdfName = pdf_image_name("/BPC")

///|
let pdf_image_width_key_name : PdfName = pdf_image_name("/Width")

///|
let pdf_image_w_key_name : PdfName = pdf_image_name("/W")

///|
let pdf_image_height_key_name : PdfName = pdf_image_name("/Height")

///|
let pdf_image_h_key_name : PdfName = pdf_image_name("/H")

///|
let pdf_image_decode_key_name : PdfName = pdf_image_name("/Decode")

///|
let pdf_image_d_key_name : PdfName = pdf_image_name("/D")

///|
let pdf_image_decodeparms_key_name : PdfName = pdf_image_name("/DecodeParms")

///|
let pdf_image_dp_key_name : PdfName = pdf_image_name("/DP")

///|
let pdf_image_jbig2_globals_key_name : PdfName = pdf_image_name("/JBIG2Globals")

///|
let pdf_image_dctdecode_name_value : PdfName = pdf_image_name("/DCTDecode")

///|
let pdf_image_dct_name_value : PdfName = pdf_image_name("/DCT")

///|
let pdf_image_jbig2decode_name_value : PdfName = pdf_image_name("/JBIG2Decode")

///|
let pdf_image_jpxdecode_name_value : PdfName = pdf_image_name("/JPXDecode")

///|
let pdf_image_length_key_name : PdfName = pdf_image_name("/Length")

///|
let pdf_image_filter_key_name : PdfName = pdf_image_name("/Filter")

///|
let pdf_image_f_key_name : PdfName = pdf_image_name("/F")

///|
let pdf_image_subtype_key_name : PdfName = pdf_image_name("/Subtype")

///|
let pdf_image_image_name_value : PdfName = pdf_image_name("/Image")

///|
let pdf_image_device_rgb_name_value : PdfName = pdf_image_name("/DeviceRGB")

///|
let pdf_image_device_gray_name_value : PdfName = pdf_image_name("/DeviceGray")

///|
let pdf_image_xobject_key_name : PdfName = pdf_image_name("/XObject")

///|
let pdf_image_smask_key_name : PdfName = pdf_image_name("/SMask")

///|
let pdf_image_mask_key_name : PdfName = pdf_image_name("/Mask")

///|
let pdf_image_smask_in_data_key_name : PdfName = pdf_image_name("/SMaskInData")

///|
let pdf_image_i0_name_value : PdfName = pdf_image_name("/I0")

///|
let pdf_image_struct_parents_key_name : PdfName = pdf_image_name(
  "/StructParents",
)

///|
let pdf_image_figure_name_value : PdfName = pdf_image_name("/Figure")

///|
let pdf_image_mcid_key_name : PdfName = pdf_image_name("/MCID")

///|
let pdf_image_alt_key_name : PdfName = pdf_image_name("/Alt")

///|
let pdf_image_p_key_name : PdfName = pdf_image_name("/P")

///|
let pdf_image_s_key_name : PdfName = pdf_image_name("/S")

///|
let pdf_image_nums_key_name : PdfName = pdf_image_name("/Nums")

///|
let pdf_image_namespaces_key_name : PdfName = pdf_image_name("/Namespaces")

///|
let pdf_image_ns_key_name : PdfName = pdf_image_name("/NS")

///|
let pdf_image_document_name_value : PdfName = pdf_image_name("/Document")

///|
let pdf_image_form_name_value : PdfName = pdf_image_name("/Form")

///|
let pdf_image_matrix_key_name : PdfName = pdf_image_name("/Matrix")

///|
let pdf_image_ccittfaxdecode_name_value : PdfName = pdf_image_name(
  "/CCITTFaxDecode",
)

///|
let pdf_image_k_key_name : PdfName = pdf_image_name("/K")

///|
let pdf_image_pdfua2_namespace : PdfBytes = @ascii.encode(
  "http://iso.org/pdf2/ssn",
)

///|
let pdf_image_jbig2_globals_object_number : Int = 10000

///|
let pdf_image_debug_image_processing_enabled : Ref[Bool] = { val: false }

///|
/// Return cpdfimage's `debug_image_processing` flag.
pub fn pdf_image_debug_image_processing() -> Bool {
  pdf_image_debug_image_processing_enabled.val
}

///|
/// Set cpdfimage's `debug_image_processing` flag.
pub fn pdf_image_set_debug_image_processing(enabled : Bool) -> Unit {
  pdf_image_debug_image_processing_enabled.val = enabled
}

///|
/// Source-spelled cpdfimage `debug_image_processing` flag reader.
pub fn pdf_debug_image_processing() -> Bool {
  pdf_image_debug_image_processing()
}

///|
/// Set cpdfimage's `debug_image_processing` flag through the source-spelled
/// compatibility surface.
pub fn pdf_set_debug_image_processing(enabled : Bool) -> Unit {
  pdf_image_set_debug_image_processing(enabled)
}

///|
fn pdf_image_image_mask_key() -> PdfName {
  pdf_image_image_mask_key_name
}

///|
fn pdf_image_im_key() -> PdfName {
  pdf_image_im_key_name
}

///|
fn pdf_image_colorspace_key() -> PdfName {
  pdf_image_colorspace_key_name
}

///|
fn pdf_image_cs_key() -> PdfName {
  pdf_image_cs_key_name
}

///|
fn pdf_image_bits_per_component_key() -> PdfName {
  pdf_image_bits_per_component_key_name
}

///|
fn pdf_image_bpc_key() -> PdfName {
  pdf_image_bpc_key_name
}

///|
fn pdf_image_width_key() -> PdfName {
  pdf_image_width_key_name
}

///|
fn pdf_image_w_key() -> PdfName {
  pdf_image_w_key_name
}

///|
fn pdf_image_height_key() -> PdfName {
  pdf_image_height_key_name
}

///|
fn pdf_image_h_key() -> PdfName {
  pdf_image_h_key_name
}

///|
fn pdf_image_decode_key() -> PdfName {
  pdf_image_decode_key_name
}

///|
fn pdf_image_d_key() -> PdfName {
  pdf_image_d_key_name
}

///|
fn pdf_image_decodeparms_key() -> PdfName {
  pdf_image_decodeparms_key_name
}

///|
fn pdf_image_dp_key() -> PdfName {
  pdf_image_dp_key_name
}

///|
fn pdf_image_jbig2_globals_key() -> PdfName {
  pdf_image_jbig2_globals_key_name
}

///|
fn pdf_image_dctdecode_name() -> PdfName {
  pdf_image_dctdecode_name_value
}

///|
fn pdf_image_dct_name() -> PdfName {
  pdf_image_dct_name_value
}

///|
fn pdf_image_jbig2decode_name() -> PdfName {
  pdf_image_jbig2decode_name_value
}

///|
fn pdf_image_jpxdecode_name() -> PdfName {
  pdf_image_jpxdecode_name_value
}

///|
fn pdf_image_length_key() -> PdfName {
  pdf_image_length_key_name
}

///|
fn pdf_image_filter_key() -> PdfName {
  pdf_image_filter_key_name
}

///|
fn pdf_image_f_key() -> PdfName {
  pdf_image_f_key_name
}

///|
fn pdf_image_subtype_key() -> PdfName {
  pdf_image_subtype_key_name
}

///|
fn pdf_image_image_name() -> PdfName {
  pdf_image_image_name_value
}

///|
fn pdf_image_device_rgb_name() -> PdfName {
  pdf_image_device_rgb_name_value
}

///|
fn pdf_image_device_gray_name() -> PdfName {
  pdf_image_device_gray_name_value
}

///|
fn pdf_image_xobject_key() -> PdfName {
  pdf_image_xobject_key_name
}

///|
fn pdf_image_smask_key() -> PdfName {
  pdf_image_smask_key_name
}

///|
fn pdf_image_mask_key() -> PdfName {
  pdf_image_mask_key_name
}

///|
fn pdf_image_smask_in_data_key() -> PdfName {
  pdf_image_smask_in_data_key_name
}

///|
fn pdf_image_i0_name() -> PdfName {
  pdf_image_i0_name_value
}

///|
fn pdf_image_struct_parents_key() -> PdfName {
  pdf_image_struct_parents_key_name
}

///|
fn pdf_image_figure_name() -> PdfName {
  pdf_image_figure_name_value
}

///|
fn pdf_image_mcid_key() -> PdfName {
  pdf_image_mcid_key_name
}

///|
fn pdf_image_alt_key() -> PdfName {
  pdf_image_alt_key_name
}

///|
fn pdf_image_p_key() -> PdfName {
  pdf_image_p_key_name
}

///|
fn pdf_image_s_key() -> PdfName {
  pdf_image_s_key_name
}

///|
fn pdf_image_nums_key() -> PdfName {
  pdf_image_nums_key_name
}

///|
fn pdf_image_namespaces_key() -> PdfName {
  pdf_image_namespaces_key_name
}

///|
fn pdf_image_ns_key() -> PdfName {
  pdf_image_ns_key_name
}

///|
fn pdf_image_document_name() -> PdfName {
  pdf_image_document_name_value
}

///|
fn pdf_image_form_name() -> PdfName {
  pdf_image_form_name_value
}

///|
fn pdf_image_matrix_key() -> PdfName {
  pdf_image_matrix_key_name
}

///|
fn pdf_image_ccittfaxdecode_name() -> PdfName {
  pdf_image_ccittfaxdecode_name_value
}

///|
fn pdf_image_k_key() -> PdfName {
  pdf_image_k_key_name
}

///|
/// Pixel layout for extracted raw image data.
pub(all) enum PdfPixelLayout {
  PdfBPP1
  PdfBPP8
  PdfBPP24
  PdfBPP48
} derive(Debug, Eq, ToJson)

///|
/// Extracted PDF image data.
///
/// JPEG, JPEG2000, and JBIG2 variants keep their encoded bytes and optional
/// decode array. `PdfJBIG2` also carries the optional `/JBIG2Globals` object
/// number. `PdfRawImage` stores width, height, layout, and decoded pixel bytes.
pub(all) enum PdfImage {
  PdfJPEG(PdfBytes, Array[Double]?)
  PdfJPEG2000(PdfBytes, Array[Double]?)
  PdfJBIG2(PdfBytes, Array[Double]?, Int?)
  PdfRawImage(Int, Int, PdfPixelLayout, PdfBytes)
} derive(Debug, Eq, ToJson)

///|
/// One file that cpdf-style image extraction would write.
pub(all) struct PdfExtractedImageFile {
  path : String
  data : PdfBytes
} derive(Debug, Eq, ToJson)

///|
/// Build a PDF image XObject for JPEG data.
pub fn pdf_image_object_of_jpeg_data(
  data : BytesView,
) -> PdfObject raise PdfError {
  let (width, height) = pdf_jpeg_dimensions_view(data)
  let payload = data.to_owned()
  pdf_stream(
    PdfDictionary([
      (pdf_image_length_key(), PdfInteger(payload.length())),
      (pdf_image_filter_key(), PdfNameObject(pdf_image_dctdecode_name())),
      (pdf_image_bits_per_component_key(), PdfInteger(8)),
      (pdf_image_colorspace_key(), PdfNameObject(pdf_image_device_rgb_name())),
      (pdf_image_subtype_key(), PdfNameObject(pdf_image_image_name())),
      (pdf_image_width_key(), PdfInteger(width)),
      (pdf_image_height_key(), PdfInteger(height)),
    ]),
    StreamGot(payload),
  )
}

///|
/// Compatibility wrapper matching cpdfimage's `obj_of_jpeg_data` result shape.
pub fn pdf_image_obj_of_jpeg_data(
  data : BytesView,
  path_to_im? : String = "",
) -> (PdfObject, Array[(Int, PdfObject)]) raise PdfError {
  ignore(path_to_im)
  (pdf_image_object_of_jpeg_data(data), [])
}

///|
/// Source-spelled cpdfimage `obj_of_jpeg_data` wrapper.
pub fn pdf_obj_of_jpeg_data(
  data : BytesView,
  path_to_im? : String = "",
) -> (PdfObject, Array[(Int, PdfObject)]) raise PdfError {
  pdf_image_obj_of_jpeg_data(data, path_to_im~)
}

///|
/// Build a PDF image XObject for JPEG2000 data.
pub fn pdf_image_object_of_jpeg2000_data(
  data : BytesView,
) -> PdfObject raise PdfError {
  let (width, height) = pdf_jpeg2000_dimensions_view(data)
  let payload = data.to_owned()
  pdf_stream(
    PdfDictionary([
      (pdf_image_length_key(), PdfInteger(payload.length())),
      (pdf_image_filter_key(), PdfNameObject(pdf_image_jpxdecode_name())),
      (pdf_image_subtype_key(), PdfNameObject(pdf_image_image_name())),
      (pdf_image_width_key(), PdfInteger(width)),
      (pdf_image_height_key(), PdfInteger(height)),
    ]),
    StreamGot(payload),
  )
}

///|
/// Compatibility wrapper matching cpdfimage's `obj_of_jpeg2000_data` result shape.
pub fn pdf_image_obj_of_jpeg2000_data(
  data : BytesView,
) -> (PdfObject, Array[(Int, PdfObject)]) raise PdfError {
  (pdf_image_object_of_jpeg2000_data(data), [])
}

///|
/// Source-spelled cpdfimage `obj_of_jpeg2000_data` wrapper.
pub fn pdf_obj_of_jpeg2000_data(
  data : BytesView,
) -> (PdfObject, Array[(Int, PdfObject)]) raise PdfError {
  pdf_image_obj_of_jpeg2000_data(data)
}

///|
fn PdfPNG::pdf_image_png_components(self : PdfPNG) -> Int raise PdfError {
  match self.color_type {
    0 | 4 => 1
    2 | 6 => 3
    _ => raise BadPNG("obj_of_png_data unknown colortype")
  }
}

///|
fn PdfPNG::pdf_image_png_color_space(self : PdfPNG) -> PdfName raise PdfError {
  match self.color_type {
    0 | 4 => pdf_image_device_gray_name()
    2 | 6 => pdf_image_device_rgb_name()
    _ => raise BadPNG("obj_of_png_data unknown colortype")
  }
}

///|
fn pdf_image_png_dictionary(
  png : PdfPNG,
  data : PdfBytes,
  color_space : PdfName,
  colors : Int,
  predictor : Bool,
) -> Array[(PdfName, PdfObject)] {
  let entries : Array[(PdfName, PdfObject)] = [
    (pdf_image_length_key(), PdfInteger(data.length())),
    (pdf_image_subtype_key(), PdfNameObject(pdf_image_image_name())),
    (pdf_image_bits_per_component_key(), PdfInteger(png.bit_depth)),
    (pdf_image_colorspace_key(), PdfNameObject(color_space)),
    (pdf_image_width_key(), PdfInteger(png.width)),
    (pdf_image_height_key(), PdfInteger(png.height)),
    (pdf_image_filter_key(), PdfNameObject(PdfStreamFlate.filter_name())),
  ]
  if predictor {
    entries.push(
      (
        pdf_decodeparms_key(),
        pdf_predictor_decodeparms(15, colors, png.bit_depth, png.width),
      ),
    )
  }
  entries
}

///|
fn PdfPNG::pdf_image_split_alpha(
  self : PdfPNG,
) -> (PdfBytes, PdfBytes) raise PdfError {
  if self.bit_depth > 8 {
    raise BadPNG("Cannot use alpha PNGs with >8bpp")
  }
  let components = match self.color_type {
    4 => 1
    6 => 3
    _ => raise BadPNG("obj_of_png_data/split_mask: bad colortype")
  }
  let decoded = pdf_decode_predictor(
    15,
    components + 1,
    self.bit_depth,
    self.width,
    pdf_flate_decode(self.idat),
  )
  let stride = components + 1
  if decoded.length() % stride != 0 {
    raise BadPNG("obj_of_png_data/split_mask: malformed alpha data")
  }
  let pixels = decoded.length() / stride
  let colors = Array::make(pixels * components, b'\x00')
  let mask = Array::make(pixels, b'\x00')
  let mut color_position = 0
  let mut mask_position = 0
  for offset = 0; offset < decoded.length(); offset = offset + stride {
    for component in 0.. PdfObject raise PdfError {
  let png = pdf_read_png_view(data)
  let (image_data, mask_data, predictor) = match png.color_type {
    4 | 6 => {
      let (colors, mask) = png.pdf_image_split_alpha()
      (colors, Some(mask), false)
    }
    _ => (png.idat, None, true)
  }
  let components = png.pdf_image_png_components()
  let entries = pdf_image_png_dictionary(
    png,
    image_data,
    png.pdf_image_png_color_space(),
    components,
    predictor,
  )
  match mask_data {
    Some(mask) => {
      let mask_entries = pdf_image_png_dictionary(
        png,
        mask,
        pdf_image_device_gray_name(),
        1,
        predictor,
      )
      let smask = pdf_stream(PdfDictionary(mask_entries), StreamGot(mask))
      let smask_number = self.add_object(smask)
      entries.push((pdf_image_smask_key(), PdfIndirect(smask_number)))
    }
    None => ()
  }
  pdf_stream(PdfDictionary(entries), StreamGot(image_data))
}

///|
/// Build a PDF image XObject for PNG data using `document` for any alpha mask
/// object that must be added.
pub fn pdf_image_object_of_png_data(
  document : PdfDocument,
  data : BytesView,
) -> PdfObject raise PdfError {
  document.pdf_image_object_of_png_data(data)
}

///|
/// Compatibility wrapper matching cpdfimage's `obj_of_png_data` result shape.
pub fn pdf_image_obj_of_png_data(
  document : PdfDocument,
  data : BytesView,
) -> (PdfObject, Array[(Int, PdfObject)]) raise PdfError {
  (document.pdf_image_object_of_png_data(data), [])
}

///|
/// Source-spelled cpdfimage `obj_of_png_data` wrapper.
pub fn pdf_obj_of_png_data(
  document : PdfDocument,
  data : BytesView,
) -> (PdfObject, Array[(Int, PdfObject)]) raise PdfError {
  pdf_image_obj_of_png_data(document, data)
}

///|
fn pdf_image_jbig2_u32_at(data : BytesView, offset : Int) -> Int raise PdfError {
  if offset < 0 || offset + 4 > data.length() {
    raise EndOfInput
  }
  let value = (data[offset].to_uint() << 24) |
    (data[offset + 1].to_uint() << 16) |
    (data[offset + 2].to_uint() << 8) |
    data[offset + 3].to_uint()
  value.to_int64().to_int()
}

///|
fn pdf_image_jbig2_dimensions_view(
  data : BytesView,
) -> (Int, Int) raise PdfError {
  (pdf_image_jbig2_u32_at(data, 11), pdf_image_jbig2_u32_at(data, 15))
}

///|
/// Build a PDF image XObject for JBIG2 data.
///
/// When `global` is supplied, the returned extra object list contains the
/// `/JBIG2Globals` stream at object number `10000`, matching cpdf's standalone
/// `obj_of_jbig2_data` contract.
pub fn pdf_image_object_of_jbig2_data(
  data : BytesView,
  global? : BytesView? = None,
) -> (PdfObject, Array[(Int, PdfObject)]) raise PdfError {
  let payload = data.to_owned()
  let (width, height) = pdf_image_jbig2_dimensions_view(data)
  let entries : Array[(PdfName, PdfObject)] = [
    (pdf_image_length_key(), PdfInteger(payload.length())),
    (pdf_image_filter_key(), PdfNameObject(pdf_image_jbig2decode_name())),
    (pdf_image_subtype_key(), PdfNameObject(pdf_image_image_name())),
    (pdf_image_bits_per_component_key(), PdfInteger(1)),
    (pdf_image_colorspace_key(), PdfNameObject(pdf_image_device_gray_name())),
    (pdf_image_width_key(), PdfInteger(width)),
    (pdf_image_height_key(), PdfInteger(height)),
  ]
  let extra : Array[(Int, PdfObject)] = []
  match global {
    Some(global) => {
      let global_payload = global.to_owned()
      entries.push(
        (
          pdf_decodeparms_key(),
          PdfDictionary([
            (
              pdf_image_jbig2_globals_key(),
              PdfIndirect(pdf_image_jbig2_globals_object_number),
            ),
          ]),
        ),
      )
      extra.push(
        (
          pdf_image_jbig2_globals_object_number,
          pdf_stream(
            PdfDictionary([
              (pdf_image_length_key(), PdfInteger(global_payload.length())),
            ]),
            StreamGot(global_payload),
          ),
        ),
      )
    }
    None => ()
  }
  (pdf_stream(PdfDictionary(entries), StreamGot(payload)), extra)
}

///|
/// Compatibility wrapper matching cpdfimage's `obj_of_jbig2_data`.
pub fn pdf_image_obj_of_jbig2_data(
  data : BytesView,
  global? : BytesView? = None,
) -> (PdfObject, Array[(Int, PdfObject)]) raise PdfError {
  pdf_image_object_of_jbig2_data(data, global~)
}

///|
/// Source-spelled cpdfimage `obj_of_jbig2_data` wrapper.
pub fn pdf_obj_of_jbig2_data(
  data : BytesView,
  global? : BytesView? = None,
) -> (PdfObject, Array[(Int, PdfObject)]) raise PdfError {
  pdf_image_obj_of_jbig2_data(data, global~)
}

///|
fn PdfDocument::pdf_image_add_structure_tree(
  self : PdfDocument,
  subformat : PdfUASubformat?,
  title : String,
) -> Unit raise PdfError {
  let structure_root_number = self.add_object(PdfNull)
  let figure_number = self.add_object(PdfNull)
  let parent_tree_number = self.add_object(PdfNull)
  let figure_reference = PdfIndirect(figure_number)
  let parent_tree = PdfDictionary([
    (
      pdf_image_nums_key(),
      PdfArray([PdfInteger(1), PdfArray([figure_reference])]),
    ),
  ])
  self.add_object_given_number(parent_tree_number, parent_tree)
  match subformat {
    Some(PdfUA2) => {
      let ns_object = PdfIndirect(
        self.add_object(
          PdfDictionary([
            (pdf_image_ns_key(), PdfString(pdf_image_pdfua2_namespace)),
          ]),
        ),
      )
      let document_number = self.add_object(PdfNull)
      self.add_object_given_number(
        document_number,
        PdfDictionary([
          (pdf_structure_k_key(), PdfArray([figure_reference])),
          (pdf_image_p_key(), PdfIndirect(structure_root_number)),
          (pdf_image_s_key(), PdfNameObject(pdf_image_document_name())),
          (pdf_image_ns_key(), ns_object),
        ]),
      )
      self.add_object_given_number(
        figure_number,
        PdfDictionary([
          (pdf_structure_k_key(), PdfArray([PdfInteger(0)])),
          (pdf_image_p_key(), PdfIndirect(document_number)),
          (pdf_image_s_key(), PdfNameObject(pdf_image_figure_name())),
          (pdf_image_alt_key(), PdfString(@utf8.encode(title))),
        ]),
      )
      self.add_object_given_number(
        structure_root_number,
        PdfDictionary([
          (pdf_image_namespaces_key(), PdfArray([ns_object])),
          (pdf_ua_type_key(), PdfNameObject(pdf_ua_struct_tree_root())),
          (pdf_structure_k_key(), PdfArray([PdfIndirect(document_number)])),
          (pdf_structure_parent_tree_key(), PdfIndirect(parent_tree_number)),
        ]),
      )
    }
    _ => {
      self.add_object_given_number(
        figure_number,
        PdfDictionary([
          (pdf_structure_k_key(), PdfArray([PdfInteger(0)])),
          (pdf_image_p_key(), PdfIndirect(structure_root_number)),
          (pdf_image_s_key(), PdfNameObject(pdf_image_figure_name())),
          (pdf_image_alt_key(), PdfString(@utf8.encode(title))),
        ]),
      )
      self.add_object_given_number(
        structure_root_number,
        PdfDictionary([
          (pdf_ua_type_key(), PdfNameObject(pdf_ua_struct_tree_root())),
          (pdf_structure_k_key(), PdfArray([figure_reference])),
          (pdf_structure_parent_tree_key(), PdfIndirect(parent_tree_number)),
        ]),
      )
    }
  }
  self.replace_chain(
    [pdf_structure_root_key(), pdf_structure_struct_tree_root_key()],
    PdfIndirect(structure_root_number),
  )
}

///|
fn pdf_image_title_for_subformat(
  subformat : PdfUASubformat?,
  title : String?,
) -> String raise PdfError {
  match (subformat, title) {
    (Some(_), Some(title)) => title
    (Some(_), None) => raise TitleExpected
    (None, Some(title)) => title
    (None, None) => ""
  }
}

///|
fn pdf_image_content_ops(
  structinfo : Bool,
  width : Int,
  height : Int,
) -> Array[PdfContentOp] {
  let output : Array[PdfContentOp] = []
  if structinfo {
    output.push(
      Op_BDC(
        pdf_image_figure_name(),
        PdfDictionary([(pdf_image_mcid_key(), PdfInteger(0))]),
      ),
    )
  }
  output.push(
    Op_cm({
      ..transform_identity_matrix,
      a: width.to_double(),
      d: height.to_double(),
    }),
  )
  output.push(Op_Do(pdf_image_i0_name()))
  if structinfo {
    output.push(Op_EMC)
  }
  output
}

///|
fn PdfDocument::pdf_image_add_image_page(
  self : PdfDocument,
  image : PdfObject,
  process_struct_tree : Bool,
  subformat : PdfUASubformat?,
  title : String?,
) -> Unit raise PdfError {
  let title = pdf_image_title_for_subformat(subformat, title)
  let structinfo = process_struct_tree || subformat != None
  let width = self.pdf_image_lookup_int_or_else(
    pdf_image_width_key(),
    pdf_image_w_key(),
    image,
  )
  let height = self.pdf_image_lookup_int_or_else(
    pdf_image_height_key(),
    pdf_image_h_key(),
    image,
  )
  let image_number = self.add_object(image)
  let page : PdfPage = {
    content: [
      pdf_content_stream_of_ops(
        pdf_image_content_ops(structinfo, width, height),
      ),
    ],
    mediabox: PdfArray([
      PdfReal(0.0),
      PdfReal(0.0),
      PdfReal(width.to_double()),
      PdfReal(height.to_double()),
    ]),
    resources: PdfDictionary([
      (
        pdf_image_xobject_key(),
        PdfDictionary([(pdf_image_i0_name(), PdfIndirect(image_number))]),
      ),
    ]),
    rotate: Rotate0,
    rest: if structinfo {
      PdfDictionary([(pdf_image_struct_parents_key(), PdfInteger(1))])
    } else {
      PdfDictionary([])
    },
  }
  let page_root = self.add_pagetree([page])
  ignore(self.add_root(page_root, []))
  if structinfo {
    self.pdf_image_add_structure_tree(subformat, title)
  }
  match subformat {
    Some(subformat) => self.pdf_ua_apply_subformat_metadata(subformat, title)
    None => ()
  }
}

///|
/// Build a single-page PDF document that draws `image` at its natural size.
pub fn pdf_image_document_of_object(
  image : PdfObject,
  process_struct_tree? : Bool = false,
  subformat? : PdfUASubformat? = None,
  title? : String? = None,
) -> PdfDocument raise PdfError {
  let document = pdf_document_empty()
  document.pdf_image_add_image_page(
    image, process_struct_tree, subformat, title,
  )
  document
}

///|
/// Compatibility wrapper matching cpdfimage's `image_of_input`.
///
/// The input side is represented by a byte view instead of `Pdfio.input`;
/// `object_of_data` receives the document so PNG masks and similar side objects
/// can be added through the same document before the page is built.
pub fn pdf_image_of_input(
  data : BytesView,
  object_of_data : (PdfDocument, BytesView) -> (
    PdfObject,
    Array[(Int, PdfObject)],
  ) raise PdfError,
  process_struct_tree? : Bool = false,
  subformat? : PdfUASubformat? = None,
  title? : String? = None,
) -> PdfDocument raise PdfError {
  let document = pdf_document_empty()
  let (image, extra_objects) = object_of_data(document, data)
  for extra in extra_objects {
    document.add_object_given_number(extra.0, extra.1)
  }
  document.pdf_image_add_image_page(
    image, process_struct_tree, subformat, title,
  )
  document
}

///|
/// Build a single-page PDF document from JPEG data.
pub fn pdf_image_document_of_jpeg_data(
  data : BytesView,
  process_struct_tree? : Bool = false,
  subformat? : PdfUASubformat? = None,
  title? : String? = None,
) -> PdfDocument raise PdfError {
  pdf_image_document_of_object(
    pdf_image_object_of_jpeg_data(data),
    process_struct_tree~,
    subformat~,
    title~,
  )
}

///|
/// Build a single-page PDF document from JPEG2000 data.
pub fn pdf_image_document_of_jpeg2000_data(
  data : BytesView,
  process_struct_tree? : Bool = false,
  subformat? : PdfUASubformat? = None,
  title? : String? = None,
) -> PdfDocument raise PdfError {
  pdf_image_document_of_object(
    pdf_image_object_of_jpeg2000_data(data),
    process_struct_tree~,
    subformat~,
    title~,
  )
}

///|
/// Build a single-page PDF document from PNG data.
pub fn pdf_image_document_of_png_data(
  data : BytesView,
  process_struct_tree? : Bool = false,
  subformat? : PdfUASubformat? = None,
  title? : String? = None,
) -> PdfDocument raise PdfError {
  let document = pdf_document_empty()
  let image = document.pdf_image_object_of_png_data(data)
  document.pdf_image_add_image_page(
    image, process_struct_tree, subformat, title,
  )
  document
}

///|
/// Build a single-page PDF document from JBIG2 data.
pub fn pdf_image_document_of_jbig2_data(
  data : BytesView,
  global? : BytesView? = None,
  process_struct_tree? : Bool = false,
  subformat? : PdfUASubformat? = None,
  title? : String? = None,
) -> PdfDocument raise PdfError {
  let document = pdf_document_empty()
  let (image, extra_objects) = pdf_image_object_of_jbig2_data(data, global~)
  for extra in extra_objects {
    document.add_object_given_number(extra.0, extra.1)
  }
  document.pdf_image_add_image_page(
    image, process_struct_tree, subformat, title,
  )
  document
}

///|
fn PdfDocument::pdf_image_is_mask(
  self : PdfDocument,
  image : PdfObject,
) -> Bool {
  match
    self.lookup_direct_or_else(
      pdf_image_image_mask_key(),
      pdf_image_im_key(),
      image,
    ) {
    Some(PdfBoolean(true)) => true
    _ => false
  }
}

///|
/// Read an image XObject colour space.
///
/// `/ImageMask true` images are treated as `PdfDeviceGray`. Otherwise this
/// reads `/ColorSpace` or `/CS`, resolving named spaces through `resources`.
pub fn PdfDocument::image_colour_space(
  self : PdfDocument,
  image : PdfObject,
  resources : PdfObject,
) -> PdfColourSpace raise PdfError {
  if self.pdf_image_is_mask(image) {
    PdfDeviceGray
  } else {
    match
      self.lookup_direct_or_else(
        pdf_image_colorspace_key(),
        pdf_image_cs_key(),
        image,
      ) {
      Some(colour_space) =>
        match self.read_colour_space(resources, colour_space) {
          Some(space) => space
          None => raise DictionaryKeyNotFound(pdf_image_colorspace_key())
        }
      None => raise DictionaryKeyNotFound(pdf_image_colorspace_key())
    }
  }
}

///|
/// Compatibility alias for `PdfDocument::image_colour_space`.
pub fn PdfDocument::colspace(
  self : PdfDocument,
  image : PdfObject,
  resources : PdfObject,
) -> PdfColourSpace raise PdfError {
  self.image_colour_space(image, resources)
}

///|
/// Compatibility wrapper for `PdfDocument::colspace`.
pub fn pdf_image_colspace(
  document : PdfDocument,
  image : PdfObject,
  resources : PdfObject,
) -> PdfColourSpace raise PdfError {
  document.colspace(image, resources)
}

///|
/// Return an image's `/BitsPerComponent` or abbreviated `/BPC` entry.
///
/// Image masks default to `1` when no explicit value is present. The raw object
/// is returned to preserve the PDF representation.
pub fn PdfDocument::image_bits_per_component(
  self : PdfDocument,
  image : PdfObject,
) -> PdfObject? {
  match
    self.lookup_direct_or_else(
      pdf_image_bits_per_component_key(),
      pdf_image_bpc_key(),
      image,
    ) {
    Some(bits_per_component) => Some(bits_per_component)
    None =>
      if self.pdf_image_is_mask(image) {
        Some(PdfInteger(1))
      } else {
        None
      }
  }
}

///|
/// Compatibility alias for `PdfDocument::image_bits_per_component`.
pub fn PdfDocument::bpc(self : PdfDocument, image : PdfObject) -> PdfObject? {
  self.image_bits_per_component(image)
}

///|
/// Compatibility wrapper for `PdfDocument::bpc`.
pub fn pdf_image_bpc(document : PdfDocument, image : PdfObject) -> PdfObject? {
  document.bpc(image)
}

///|
priv struct PdfImageListEntry {
  object_number : Int
  pages : Array[Int]
  name : String
  width : Int
  height : Int
  byte_count : Int
  bits_per_component : Int?
  colourspace : String?
  filter : String?
  mask : String
  mask_object_number : Int?
}

///|
fn pdf_image_json_int(value : Int) -> Json {
  Json::number(value.to_double(), repr=value.to_string())
}

///|
fn pdf_image_json_int_or_null(value : Int?) -> Json {
  match value {
    Some(value) => pdf_image_json_int(value)
    None => Json::null()
  }
}

///|
fn pdf_image_json_string_or_null(value : String?) -> Json {
  match value {
    Some(value) => Json::string(value)
    None => Json::null()
  }
}

///|
fn pdf_image_name_to_string(name : PdfName) -> String raise PdfError {
  @ascii.decode(name.bytes()) catch {
    _ =>
      @ascii.decode(pdf_write_object(PdfNameObject(name))) catch {
        _ => raise InvalidUTF8
      }
  }
}

///|
fn pdf_image_add_unique_page(pages : Array[Int], page_number : Int) -> Unit {
  if !pages.contains(page_number) {
    pages.push(page_number)
  }
}

///|
fn pdf_image_list_find_entry(
  entries : ArrayView[PdfImageListEntry],
  object_number : Int,
) -> Int {
  if object_number <= 0 {
    -1
  } else {
    for index in 0.. Int raise PdfError {
  match self.lookup_direct_or_else(first_key, second_key, image) {
    Some(value) => self.get_number(value).to_int()
    None => default
  }
}

///|
fn PdfDocument::pdf_image_stream_length(
  self : PdfDocument,
  image : PdfObject,
  fallback : Int,
) -> Int {
  match self.lookup_direct(pdf_image_length_key(), image) {
    Some(PdfInteger(value)) => value
    _ => fallback
  }
}

///|
fn PdfDocument::pdf_image_bits_per_component_number(
  self : PdfDocument,
  image : PdfObject,
) -> Int? raise PdfError {
  match self.image_bits_per_component(image) {
    Some(PdfInteger(value)) => Some(value)
    Some(_) => raise NumberExpected
    None => None
  }
}

///|
fn PdfDocument::pdf_image_colourspace_name(
  self : PdfDocument,
  image : PdfObject,
  resources : PdfObject,
) -> String? {
  Some(self.image_colour_space(image, resources).debug_name()) catch {
    _ => None
  }
}

///|
fn pdf_image_single_filter_object(filter : PdfObject) -> PdfObject {
  match filter {
    PdfArray([single]) => single
    _ => filter
  }
}

///|
fn pdf_image_filter_is_name(filter : PdfObject, name : PdfName) -> Bool {
  match filter {
    PdfNameObject(filter_name) => filter_name == name
    PdfArray([PdfNameObject(filter_name)]) => filter_name == name
    _ => false
  }
}

///|
fn PdfDocument::pdf_image_first_decodeparms(
  self : PdfDocument,
  image : PdfObject,
) -> PdfObject? {
  match
    self.lookup_direct_or_else(
      pdf_image_decodeparms_key(),
      pdf_image_dp_key(),
      image,
    ) {
    Some(PdfArray([first, ..])) =>
      match self.direct(first) {
        PdfDictionary(_) as dictionary => Some(dictionary)
        _ => None
      }
    Some(PdfDictionary(_) as dictionary) => Some(dictionary)
    _ => None
  }
}

///|
fn PdfDocument::pdf_image_decodeparms_k(
  self : PdfDocument,
  image : PdfObject,
) -> Int? {
  match self.pdf_image_first_decodeparms(image) {
    Some(decodeparms) =>
      match self.lookup_direct(pdf_image_k_key(), decodeparms) {
        Some(PdfInteger(value)) => Some(value)
        _ => None
      }
    None => None
  }
}

///|
fn PdfDocument::pdf_image_decodeparms_has_jbig2_globals(
  self : PdfDocument,
  image : PdfObject,
) -> Bool {
  match self.pdf_image_first_decodeparms(image) {
    Some(decodeparms) =>
      match self.lookup_direct(pdf_image_jbig2_globals_key(), decodeparms) {
        Some(_) => true
        None => false
      }
    None => false
  }
}

///|
fn PdfDocument::pdf_image_filter_name(
  self : PdfDocument,
  image : PdfObject,
) -> String? raise PdfError {
  match
    self.lookup_direct_or_else(pdf_image_filter_key(), pdf_image_f_key(), image) {
    Some(filter) if pdf_image_filter_is_name(
        filter,
        pdf_image_ccittfaxdecode_name(),
      ) =>
      match self.pdf_image_decodeparms_k(image) {
        Some(value) if value < 0 => Some("/CCITTFaxDecodeG4")
        Some(value) if value > 0 => Some("/CCITTFaxDecodeG32D")
        Some(0) => Some("/CCITTFaxDecodeG31D")
        _ => Some("/CCITTFaxDecode")
      }
    Some(filter) if pdf_image_filter_is_name(
        filter,
        pdf_image_jbig2decode_name(),
      ) =>
      if self.pdf_image_decodeparms_has_jbig2_globals(image) {
        Some("/JBIG2DecodeLossy")
      } else {
        Some("/JBIG2DecodeLossless")
      }
    Some(filter) =>
      Some(
        @ascii.decode(pdf_write_object(pdf_image_single_filter_object(filter))) catch {
          _ => raise InvalidUTF8
        },
      )
    None => None
  }
}

///|
fn PdfDocument::pdf_image_indirect_mask_number(
  self : PdfDocument,
  image : PdfObject,
  key : PdfName,
) -> Int? {
  match self.direct(image) {
    PdfDictionary(entries)
    | PdfStreamObject({ dictionary: PdfDictionary(entries), .. }) =>
      for entry in entries {
        if entry.0 == key {
          break match entry.1 {
            PdfIndirect(number) => Some(number)
            _ => None
          }
        }
      } nobreak {
        None
      }
    _ => None
  }
}

///|
fn PdfDocument::pdf_image_list_mask(
  self : PdfDocument,
  resources : PdfObject,
  page_number : Int,
  image : PdfObject,
  inline : Bool,
  entries : Array[PdfImageListEntry],
  form_numbers : @hashmap.HashMap[Int, Bool],
) -> (String, Int?) raise PdfError {
  match
    self.lookup_direct_or_else(
      pdf_image_image_mask_key(),
      pdf_image_im_key(),
      image,
    ) {
    Some(PdfBoolean(true)) => ("ImageMask", None)
    _ =>
      match self.lookup_direct(pdf_image_mask_key(), image) {
        Some(PdfStreamObject(_)) => {
          let number = self.pdf_image_indirect_mask_number(
            image,
            pdf_image_mask_key(),
          )
          match number {
            Some(number) =>
              self.pdf_image_collect_xobject(
                resources,
                page_number,
                pdf_image_name("/Mask"),
                PdfIndirect(number),
                inline,
                entries,
                form_numbers,
              )
            None => ()
          }
          ("ExplicitMask", number)
        }
        Some(_) => ("ColourKeyMask", None)
        None =>
          match self.lookup_direct(pdf_image_smask_key(), image) {
            Some(_) => {
              let number = self.pdf_image_indirect_mask_number(
                image,
                pdf_image_smask_key(),
              )
              match number {
                Some(number) =>
                  self.pdf_image_collect_xobject(
                    resources,
                    page_number,
                    pdf_image_name("/SMask"),
                    PdfIndirect(number),
                    inline,
                    entries,
                    form_numbers,
                  )
                None => ()
              }
              ("SMask", number)
            }
            None =>
              match self.lookup_direct(pdf_image_smask_in_data_key(), image) {
                Some(_) => ("SMaskInData", None)
                None => ("NoMask", None)
              }
          }
      }
  }
}

///|
fn PdfDocument::pdf_image_list_add_image(
  self : PdfDocument,
  resources : PdfObject,
  page_number : Int,
  name : PdfName,
  image : PdfObject,
  object_number : Int,
  data_length : Int,
  inline : Bool,
  entries : Array[PdfImageListEntry],
  form_numbers : @hashmap.HashMap[Int, Bool],
) -> Unit raise PdfError {
  let existing = pdf_image_list_find_entry(entries, object_number)
  if existing >= 0 {
    pdf_image_add_unique_page(entries[existing].pages, page_number)
  } else {
    let (mask, mask_object_number) = self.pdf_image_list_mask(
      resources, page_number, image, inline, entries, form_numbers,
    )
    entries.push({
      object_number,
      pages: [page_number],
      name: pdf_image_name_to_string(name),
      width: self.pdf_image_lookup_int_or_default(
        pdf_image_width_key(),
        pdf_image_w_key(),
        image,
        1,
      ),
      height: self.pdf_image_lookup_int_or_default(
        pdf_image_height_key(),
        pdf_image_h_key(),
        image,
        1,
      ),
      byte_count: self.pdf_image_stream_length(image, data_length),
      bits_per_component: self.pdf_image_bits_per_component_number(image),
      colourspace: self.pdf_image_colourspace_name(image, resources),
      filter: self.pdf_image_filter_name(image),
      mask,
      mask_object_number,
    })
  }
}

///|
fn PdfDocument::pdf_image_collect_inline_images(
  self : PdfDocument,
  resources : PdfObject,
  page_number : Int,
  content : ArrayView[PdfObject],
  entries : Array[PdfImageListEntry],
  form_numbers : @hashmap.HashMap[Int, Bool],
) -> Unit raise PdfError {
  let ops = self.parse_content_ops_with_resources(resources, content)
  for op in ops {
    match op {
      Op_InlineImage(dictionary, _, data) => {
        let image = dictionary
          .add_dict_entry(
            pdf_image_subtype_key(),
            PdfNameObject(pdf_image_image_name()),
          )
          .add_dict_entry(pdf_image_length_key(), PdfInteger(data.length()))
        self.pdf_image_list_add_image(
          PdfDictionary([]),
          page_number,
          pdf_image_name("/InlineImage"),
          image,
          0,
          data.length(),
          true,
          entries,
          form_numbers,
        )
      }
      _ => ()
    }
  }
}

///|
fn PdfDocument::pdf_image_collect_xobject(
  self : PdfDocument,
  resources : PdfObject,
  page_number : Int,
  name : PdfName,
  xobject : PdfObject,
  inline : Bool,
  entries : Array[PdfImageListEntry],
  form_numbers : @hashmap.HashMap[Int, Bool],
) -> Unit raise PdfError {
  match self.lookup_direct(pdf_image_subtype_key(), xobject) {
    Some(PdfNameObject(subtype)) if subtype == pdf_image_image_name() => {
      let object_number = match xobject {
        PdfIndirect(number) => number
        _ => 0
      }
      self.pdf_image_list_add_image(
        resources, page_number, name, xobject, object_number, 0, inline, entries,
        form_numbers,
      )
    }
    Some(PdfNameObject(subtype)) if subtype == pdf_image_form_name() =>
      match xobject {
        PdfIndirect(number) =>
          if !form_numbers.contains(number) {
            form_numbers[number] = true
            let form = self.direct(xobject)
            let form_resources = match
              self.lookup_direct(pdf_xobject_resources_key(), form) {
              Some(resources) => resources
              None => PdfDictionary([])
            }
            if inline {
              self.pdf_image_collect_inline_images(
                form_resources,
                page_number,
                [form],
                entries,
                form_numbers,
              )
            }
            match self.lookup_direct(pdf_image_xobject_key(), form_resources) {
              Some(PdfDictionary(xobjects)) =>
                for entry in xobjects {
                  self.pdf_image_collect_xobject(
                    form_resources,
                    page_number,
                    entry.0,
                    entry.1,
                    inline,
                    entries,
                    form_numbers,
                  )
                }
              _ => ()
            }
          }
        _ => ()
      }
    _ => ()
  }
}

///|
fn PdfDocument::pdf_image_collect_page_images(
  self : PdfDocument,
  page : PdfPage,
  page_number : Int,
  inline : Bool,
  entries : Array[PdfImageListEntry],
  form_numbers : @hashmap.HashMap[Int, Bool],
) -> Unit raise PdfError {
  if inline {
    self.pdf_image_collect_inline_images(
      page.resources,
      page_number,
      page.content,
      entries,
      form_numbers,
    )
  }
  match self.lookup_direct(pdf_image_xobject_key(), page.resources) {
    Some(PdfDictionary(xobjects)) =>
      for entry in xobjects {
        self.pdf_image_collect_xobject(
          page.resources,
          page_number,
          entry.0,
          entry.1,
          inline,
          entries,
          form_numbers,
        )
      }
    _ => ()
  }
}

///|
fn pdf_image_list_entry_json(entry : PdfImageListEntry) -> Json {
  entry.pages.sort()
  Json::object(
    Map([
      ("Object", pdf_image_json_int(entry.object_number)),
      (
        "Pages",
        Json::array(
          [
            for page_number in entry.pages => pdf_image_json_int(page_number)
          ],
        ),
      ),
      ("Name", Json::string(entry.name)),
      ("Width", pdf_image_json_int(entry.width)),
      ("Height", pdf_image_json_int(entry.height)),
      ("Bytes", pdf_image_json_int(entry.byte_count)),
      ("BitsPerComponent", pdf_image_json_int_or_null(entry.bits_per_component)),
      ("Colourspace", pdf_image_json_string_or_null(entry.colourspace)),
      ("Filter", pdf_image_json_string_or_null(entry.filter)),
      ("Mask", Json::string(entry.mask)),
      ("MaskObjNum", pdf_image_json_int_or_null(entry.mask_object_number)),
    ]),
  )
}

///|
/// List image XObjects referenced by selected pages as cpdf-style JSON rows.
pub fn PdfDocument::images_json(
  self : PdfDocument,
  page_numbers : ArrayView[Int],
  inline? : Bool = false,
) -> Json raise PdfError {
  let pages = self.pages_of_pagetree()
  pdf_page_box_validate_page_numbers(page_numbers, pages.length(), "images")
  let entries : Array[PdfImageListEntry] = []
  let form_numbers : @hashmap.HashMap[Int, Bool] = HashMap([])
  for page_number in page_numbers {
    self.pdf_image_collect_page_images(
      pages[page_number - 1],
      page_number,
      inline,
      entries,
      form_numbers,
    )
  }
  Json::array([ for entry in entries => pdf_image_list_entry_json(entry) ])
}

///|
/// List image XObjects referenced by selected pages as UTF-8 JSON bytes.
pub fn PdfDocument::images_json_blob(
  self : PdfDocument,
  page_numbers : ArrayView[Int],
  inline? : Bool = false,
) -> PdfBytes raise PdfError {
  @utf8.encode(self.images_json(page_numbers, inline~).stringify())
}

///|
/// Compatibility wrapper for `PdfDocument::images_json`.
pub fn pdf_image_images(
  inline : Bool,
  document : PdfDocument,
  page_numbers : ArrayView[Int],
) -> Json raise PdfError {
  document.images_json(page_numbers, inline~)
}

///|
/// Source-spelled compatibility wrapper for cpdfimage's `images`.
pub fn pdf_images(
  inline : Bool,
  document : PdfDocument,
  page_numbers : ArrayView[Int],
) -> Json raise PdfError {
  document.images_json(page_numbers, inline~)
}

///|
/// Compatibility wrapper for `PdfDocument::images_json_blob`.
pub fn pdf_image_images_json_blob(
  inline : Bool,
  document : PdfDocument,
  page_numbers : ArrayView[Int],
) -> PdfBytes raise PdfError {
  document.images_json_blob(page_numbers, inline~)
}

///|
priv enum PdfImageResolutionObject {
  PdfImageResolutionImage(Int, Int)
  PdfImageResolutionForm(TransformMatrix, PdfObject, PdfObject)
}

///|
priv struct PdfImageResolutionCandidate {
  name : PdfName
  object : PdfImageResolutionObject
  object_number : Int
}

///|
priv struct PdfImageResolutionEntry {
  page_number : Int
  xobject : String
  width : Int
  height : Int
  xdpi : Double
  ydpi : Double
  object_number : Int
}

///|
fn pdf_image_resolution_compare_double(left : Double, right : Double) -> Int {
  if left < right {
    -1
  } else if left > right {
    1
  } else {
    0
  }
}

///|
fn pdf_image_resolution_entry_compare(
  left : PdfImageResolutionEntry,
  right : PdfImageResolutionEntry,
) -> Int {
  let page = left.page_number.compare(right.page_number)
  if page != 0 {
    page
  } else {
    let xobject = left.xobject.compare(right.xobject)
    if xobject != 0 {
      xobject
    } else {
      let width = left.width.compare(right.width)
      if width != 0 {
        width
      } else {
        let height = left.height.compare(right.height)
        if height != 0 {
          height
        } else {
          let xdpi = pdf_image_resolution_compare_double(left.xdpi, right.xdpi)
          if xdpi != 0 {
            xdpi
          } else {
            let ydpi = pdf_image_resolution_compare_double(
              left.ydpi,
              right.ydpi,
            )
            if ydpi != 0 {
              ydpi
            } else {
              left.object_number.compare(right.object_number)
            }
          }
        }
      }
    }
  }
}

///|
fn pdf_image_resolution_distance(left : Point2, right : Point2) -> Double {
  let dx = right.x - left.x
  let dy = right.y - left.y
  (dx * dx + dy * dy).sqrt()
}

///|
fn pdf_image_resolution_inches(matrix : TransformMatrix) -> (Double, Double) {
  let origin = matrix.apply(point2(0.0, 0.0))
  let x_axis = matrix.apply(point2(1.0, 0.0))
  let y_axis = matrix.apply(point2(0.0, 1.0))
  (
    unit_inches(pdf_image_resolution_distance(origin, x_axis), PdfPoint),
    unit_inches(pdf_image_resolution_distance(origin, y_axis), PdfPoint),
  )
}

///|
fn pdf_image_resolution_push_entry(
  entries : Array[PdfImageResolutionEntry],
  page_number : Int,
  name : PdfName,
  width : Int,
  height : Int,
  object_number : Int,
  matrix : TransformMatrix,
) -> Unit raise PdfError {
  let (x_inches, y_inches) = pdf_image_resolution_inches(matrix)
  entries.push({
    page_number,
    xobject: pdf_image_name_to_string(name),
    width,
    height,
    xdpi: width.to_double() / x_inches,
    ydpi: height.to_double() / y_inches,
    object_number,
  })
}

///|
fn PdfDocument::pdf_image_resolution_lookup_inline_int(
  self : PdfDocument,
  first_key : PdfName,
  second_key : PdfName,
  image : PdfObject,
) -> Int? {
  match self.lookup_direct_or_else(first_key, second_key, image) {
    Some(PdfInteger(value)) => Some(value)
    _ => None
  }
}

///|
fn pdf_image_resolution_find_candidate(
  candidates : ArrayView[PdfImageResolutionCandidate],
  name : PdfName,
) -> PdfImageResolutionCandidate? {
  for candidate in candidates {
    if candidate.name == name {
      break Some(candidate)
    }
  } nobreak {
    None
  }
}

///|
fn PdfDocument::pdf_image_resolution_candidates(
  self : PdfDocument,
  resources : PdfObject,
) -> Array[PdfImageResolutionCandidate] raise PdfError {
  let candidates : Array[PdfImageResolutionCandidate] = []
  match self.lookup_direct(pdf_image_xobject_key(), resources) {
    Some(PdfDictionary(xobjects)) =>
      for entry in xobjects {
        let object_number = match entry.1 {
          PdfIndirect(number) => number
          _ => 0
        }
        match self.lookup_direct(pdf_image_subtype_key(), entry.1) {
          Some(PdfNameObject(subtype)) if subtype == pdf_image_image_name() =>
            candidates.push({
              name: entry.0,
              object: PdfImageResolutionImage(
                self.pdf_image_lookup_int_or_default(
                  pdf_image_width_key(),
                  pdf_image_width_key(),
                  entry.1,
                  1,
                ),
                self.pdf_image_lookup_int_or_default(
                  pdf_image_height_key(),
                  pdf_image_height_key(),
                  entry.1,
                  1,
                ),
              ),
              object_number,
            })
          Some(PdfNameObject(subtype)) if subtype == pdf_image_form_name() => {
            let form = self.direct(entry.1)
            let form_resources = match
              self.lookup_direct(pdf_xobject_resources_key(), form) {
              Some(resources) => resources
              None => resources
            }
            candidates.push({
              name: entry.0,
              object: PdfImageResolutionForm(
                self.parse_matrix(pdf_image_matrix_key(), form),
                form,
                form_resources,
              ),
              object_number,
            })
          }
          _ => ()
        }
      }
    _ => ()
  }
  candidates
}

///|
fn PdfDocument::pdf_image_resolution_process_content(
  self : PdfDocument,
  resources : PdfObject,
  content : ArrayView[PdfObject],
  candidates : ArrayView[PdfImageResolutionCandidate],
  page_number : Int,
  inline : Bool,
  initial_matrix : TransformMatrix,
  depth : Int,
  results : Array[PdfImageResolutionEntry],
) -> Unit raise PdfError {
  if depth > 64 {
    return
  }
  let ops = self.parse_content_ops_with_resources(resources, content)
  let transforms : Array[TransformMatrix] = [initial_matrix]
  for op in ops {
    match op {
      Op_cm(matrix) =>
        if transforms.length() > 0 {
          let index = transforms.length() - 1
          transforms[index] = transform_matrix_compose(
            transforms[index],
            matrix,
          )
        }
      Op_q =>
        if transforms.length() > 0 {
          transforms.push(transforms[transforms.length() - 1])
        }
      Op_Q => if transforms.length() > 0 { ignore(transforms.pop()) }
      Op_InlineImage(dictionary, _, _) =>
        if inline && transforms.length() > 0 {
          match
            (
              self.pdf_image_resolution_lookup_inline_int(
                pdf_image_width_key(),
                pdf_image_w_key(),
                dictionary,
              ),
              self.pdf_image_resolution_lookup_inline_int(
                pdf_image_height_key(),
                pdf_image_h_key(),
                dictionary,
              ),
            ) {
            (Some(width), Some(height)) =>
              pdf_image_resolution_push_entry(
                results,
                page_number,
                pdf_image_name("/InlineImage"),
                width,
                height,
                0,
                transforms[transforms.length() - 1],
              )
            _ => ()
          }
        }
      Op_Do(name) =>
        if transforms.length() > 0 {
          match pdf_image_resolution_find_candidate(candidates, name) {
            Some(
              {
                object: PdfImageResolutionImage(width, height),
                object_number,
                ..,
              }
            ) =>
              pdf_image_resolution_push_entry(
                results,
                page_number,
                name,
                width,
                height,
                object_number,
                transforms[transforms.length() - 1],
              )
            Some(
              {
                object: PdfImageResolutionForm(matrix, form, form_resources),
                ..,
              }
            ) => {
              let form_candidates = self.pdf_image_resolution_candidates(
                form_resources,
              )
              self.pdf_image_resolution_process_content(
                form_resources,
                [form],
                form_candidates,
                page_number,
                inline,
                transform_matrix_compose(
                  matrix,
                  transforms[transforms.length() - 1],
                ),
                depth + 1,
                results,
              )
            }
            None => ()
          }
        }
      _ => ()
    }
  }
}

///|
/// Report image resolutions for selected pages.
///
/// Results match cpdf's tuple order: page number, XObject name, image width,
/// image height, horizontal DPI, vertical DPI, and object number. Entries are
/// kept when either axis is below `dpi`.
pub fn PdfDocument::image_resolution(
  self : PdfDocument,
  page_numbers : ArrayView[Int],
  dpi : Double,
  inline? : Bool = false,
) -> Array[(Int, String, Int, Int, Double, Double, Int)] raise PdfError {
  let pages = self.pages_of_pagetree()
  pdf_page_box_validate_page_numbers(page_numbers, pages.length(), "images")
  let results : Array[PdfImageResolutionEntry] = []
  for page_number in page_numbers {
    let page = pages[page_number - 1]
    let candidates = self.pdf_image_resolution_candidates(page.resources)
    if inline || candidates.length() > 0 {
      self.pdf_image_resolution_process_content(
        page.resources,
        page.content,
        candidates,
        page_number,
        inline,
        transform_identity_matrix,
        0,
        results,
      )
    }
  }
  let filtered : Array[PdfImageResolutionEntry] = []
  for entry in results {
    if entry.xdpi < dpi || entry.ydpi < dpi {
      filtered.push(entry)
    }
  }
  filtered.sort_by(pdf_image_resolution_entry_compare)
  [
    for entry in filtered => {
      (
        entry.page_number,
        entry.xobject,
        entry.width,
        entry.height,
        entry.xdpi,
        entry.ydpi,
        entry.object_number,
      )
    }
  ]
}

///|
fn pdf_image_resolution_entry_json(
  entry : (Int, String, Int, Int, Double, Double, Int),
) -> Json {
  Json::object(
    Map([
      ("Object", pdf_image_json_int(entry.6)),
      ("Page", pdf_image_json_int(entry.0)),
      ("XObject", Json::string(entry.1)),
      ("W", pdf_image_json_int(entry.2)),
      ("H", pdf_image_json_int(entry.3)),
      ("Xdpi", Json::number(entry.4)),
      ("Ydpi", Json::number(entry.5)),
    ]),
  )
}

///|
/// Report image resolution data as cpdf-style JSON rows.
pub fn PdfDocument::image_resolution_json(
  self : PdfDocument,
  page_numbers : ArrayView[Int],
  dpi : Double,
  inline? : Bool = false,
) -> Json raise PdfError {
  Json::array(
    [
      for entry in self.image_resolution(page_numbers, dpi, inline~) => {
        pdf_image_resolution_entry_json(entry)
      }
    ],
  )
}

///|
/// Report image resolution data as UTF-8 JSON bytes.
pub fn PdfDocument::image_resolution_json_blob(
  self : PdfDocument,
  page_numbers : ArrayView[Int],
  dpi : Double,
  inline? : Bool = false,
) -> PdfBytes raise PdfError {
  @utf8.encode(
    self.image_resolution_json(page_numbers, dpi, inline~).stringify(),
  )
}

///|
/// Compatibility wrapper for `PdfDocument::image_resolution`.
pub fn pdf_image_resolution(
  inline : Bool,
  document : PdfDocument,
  page_numbers : ArrayView[Int],
  dpi : Double,
) -> Array[(Int, String, Int, Int, Double, Double, Int)] raise PdfError {
  document.image_resolution(page_numbers, dpi, inline~)
}

///|
/// Compatibility wrapper for `PdfDocument::image_resolution_json`.
pub fn pdf_image_resolution_json(
  inline : Bool,
  document : PdfDocument,
  page_numbers : ArrayView[Int],
  dpi : Double,
) -> Json raise PdfError {
  document.image_resolution_json(page_numbers, dpi, inline~)
}

///|
/// Compatibility wrapper for `PdfDocument::image_resolution_json_blob`.
pub fn pdf_image_resolution_json_blob(
  inline : Bool,
  document : PdfDocument,
  page_numbers : ArrayView[Int],
  dpi : Double,
) -> PdfBytes raise PdfError {
  document.image_resolution_json_blob(page_numbers, dpi, inline~)
}

///|
fn PdfDocument::pdf_image_lookup_int_or_else(
  self : PdfDocument,
  first_key : PdfName,
  second_key : PdfName,
  image : PdfObject,
) -> Int raise PdfError {
  match self.lookup_direct_or_else(first_key, second_key, image) {
    Some(PdfInteger(value)) => value
    Some(_) => raise NumberExpected
    None => raise DictionaryKeyNotFound(first_key)
  }
}

///|
fn PdfDocument::pdf_image_bits_per_component_int(
  self : PdfDocument,
  image : PdfObject,
) -> Int? raise PdfError {
  match self.image_bits_per_component(image) {
    Some(PdfInteger(value)) => Some(value)
    Some(_) => raise NumberExpected
    None => None
  }
}

///|
fn PdfDocument::pdf_image_decode_values(
  self : PdfDocument,
  image : PdfObject,
) -> Array[Double]? raise PdfError {
  match
    self.lookup_direct_or_else(pdf_image_decode_key(), pdf_image_d_key(), image) {
    Some(PdfArray(values)) => {
      let output : Array[Double] = []
      for value in values {
        output.push(self.get_number(value))
      }
      Some(output)
    }
    Some(_) => raise NumberExpected
    None => None
  }
}

///|
fn pdf_image_decode_identity_components(components : Int) -> Array[Double] {
  let values : Array[Double] = []
  for _ in 0.. Array[Double] {
  let values : Array[Double] = [0.0, 1.0]
  for value in range {
    values.push(value)
  }
  values
}

///|
fn pdf_image_decode_defaults_for_space(
  colour_space : PdfColourSpace,
) -> Array[Double]? {
  match colour_space {
    PdfDeviceGray | PdfCalGray(_, _, _) | PdfSeparation(_, _, _) =>
      Some(pdf_image_decode_identity_components(1))
    PdfDeviceRGB | PdfCalRGB(_, _, _, _) =>
      Some(pdf_image_decode_identity_components(3))
    PdfDeviceCMYK => Some(pdf_image_decode_identity_components(4))
    PdfLab(_, _, range) => Some(pdf_image_decode_lab_defaults(range))
    PdfICCBased(_, _, range, _, _) => Some([ for value in range => value ])
    PdfIndexed(_, _) => Some(pdf_image_decode_identity_components(1))
    PdfDeviceN(names, _, _, _) =>
      Some(pdf_image_decode_identity_components(names.length()))
    PdfPattern | PdfPatternWithBaseColourSpace(_) => None
  }
}

///|
fn PdfDocument::pdf_image_decode_values_with_defaults(
  self : PdfDocument,
  resources : PdfObject,
  image : PdfObject,
) -> Array[Double]? raise PdfError {
  match self.pdf_image_decode_values(image) {
    Some(values) =>
      match
        self.lookup_direct_or_else(
          pdf_image_colorspace_key(),
          pdf_image_cs_key(),
          image,
        ) {
        None => Some(values)
        Some(_) =>
          match self.image_colour_space(image, resources) {
            PdfIndexed(_, _) => None
            _ => Some(values)
          }
      }
    None =>
      match
        self.lookup_direct_or_else(
          pdf_image_colorspace_key(),
          pdf_image_cs_key(),
          image,
        ) {
        None => None
        Some(_) =>
          pdf_image_decode_defaults_for_space(
            self.image_colour_space(image, resources),
          )
      }
  }
}

///|
fn PdfDocument::pdf_image_encoded_decode_values_with_defaults(
  self : PdfDocument,
  resources : PdfObject,
  image : PdfObject,
) -> Array[Double]? raise PdfError {
  match self.pdf_image_decode_values(image) {
    Some(values) => Some(values)
    None =>
      match
        self.lookup_direct_or_else(
          pdf_image_colorspace_key(),
          pdf_image_cs_key(),
          image,
        ) {
        None => None
        Some(_) =>
          pdf_image_decode_defaults_for_space(
            self.image_colour_space(image, resources),
          )
      }
  }
}

///|
priv enum PdfImageDecodedStream {
  PdfImageRawStream(PdfStream)
  PdfImageDctStream(PdfStream)
  PdfImageJpxStream(PdfStream)
  PdfImageJbig2Stream(PdfStream)
}

///|
fn PdfDocument::pdf_image_decode_stream_stages(
  self : PdfDocument,
  current : PdfStream,
  stages : Int,
) -> PdfImageDecodedStream raise PdfError {
  match self.pdf_stream_first_filter_direct(current.dictionary) {
    Some(filter) if filter == pdf_image_dctdecode_name() ||
      filter == pdf_image_dct_name() => PdfImageDctStream(current)
    Some(filter) if filter == pdf_image_jpxdecode_name() =>
      PdfImageJpxStream(current)
    Some(filter) if filter == pdf_image_jbig2decode_name() =>
      PdfImageJbig2Stream(current)
    Some(_) => {
      if stages >= 1024 {
        raise FilterExpected
      }
      self.pdf_image_decode_stream_stages(
        self.pdf_decode_stream_onestage_value_direct(current),
        stages + 1,
      )
    }
    None => PdfImageRawStream(current)
  }
}

///|
fn PdfDocument::pdf_image_decode_to_image(
  self : PdfDocument,
  image : PdfObject,
) -> PdfImageDecodedStream raise PdfError {
  let current = match self.direct(image) {
    PdfStreamObject(stream) => stream
    _ => raise ParseStreamExpected
  }
  self.pdf_image_decode_stream_stages(current, 0)
}

///|
fn PdfDocument::pdf_image_jbig2_globals(
  self : PdfDocument,
  dictionary : PdfObject,
) -> Int? {
  let first_decodeparms = match
    self.lookup_direct_or_else(
      pdf_image_decodeparms_key(),
      pdf_image_dp_key(),
      dictionary,
    ) {
    Some(PdfDictionary(_) as decodeparms) => Some(decodeparms)
    Some(PdfArray([first, ..])) =>
      match self.direct(first) {
        PdfDictionary(_) as decodeparms => Some(decodeparms)
        _ => None
      }
    _ => None
  }
  match first_decodeparms {
    Some(decodeparms) =>
      match decodeparms.lookup_immediate(pdf_image_jbig2_globals_key()) {
        Some(PdfIndirect(number)) => Some(number)
        _ => None
      }
    None => None
  }
}

///|
fn pdf_image_require_size(
  data : BytesView,
  required : Int,
) -> Unit raise PdfError {
  if data.length() < required {
    raise EndOfInput
  }
}

///|
fn pdf_image_decode_is_identity(
  values : ArrayView[Double],
) -> Bool raise PdfError {
  if values.length() == 0 {
    true
  } else if values.length() % 2 != 0 {
    raise FilterExpected
  } else {
    let mut identity = true
    for index in 0..<(values.length() / 2) {
      if values[index * 2] != 0.0 || values[index * 2 + 1] != 1.0 {
        identity = false
      }
    }
    identity
  }
}

///|
fn pdf_image_decode_interpolate(
  dmin : Double,
  dmax : Double,
  value : Int,
  bpc : Int,
) -> Int {
  let denominator = match bpc {
    1 => 1.0
    2 => 3.0
    4 => 15.0
    8 => 255.0
    16 => 65535.0
    _ => 1.0
  }
  (dmin + value.to_double() * (dmax - dmin) / denominator).to_int()
}

///|
fn pdf_image_apply_1bpp_decode(data : BytesView) -> PdfBytes {
  let output = Array::make(data.length(), b'\x00')
  for index, value in data {
    output[index] = (value.to_int() ^ 0xFF).to_byte()
  }
  Bytes::from_array(output)
}

///|
fn pdf_image_apply_8bpp_decode(
  data : BytesView,
  values : ArrayView[Double],
) -> PdfBytes raise PdfError {
  let components = values.length() / 2
  let output = Array::make(data.length(), b'\x00')
  for index in 0.. PdfBytes raise PdfError {
  match decode_values {
    None => data.to_owned()
    Some(values) =>
      if pdf_image_decode_is_identity(values) {
        data.to_owned()
      } else {
        match bpc {
          Some(1) => pdf_image_apply_1bpp_decode(data)
          Some(2) | Some(4) | Some(16) => data.to_owned()
          Some(8) => pdf_image_apply_8bpp_decode(data, values)
          _ => raise FilterExpected
        }
      }
  }
}

///|
fn pdf_image_apply_decode(
  data : PdfBytes,
  bpc : Int?,
  decode_values : Array[Double]?,
) -> PdfBytes raise PdfError {
  match decode_values {
    None => data
    Some(values) =>
      if pdf_image_decode_is_identity(values) {
        data
      } else {
        match bpc {
          Some(1) => pdf_image_apply_decode_borrowed(data, bpc, Some(values))
          Some(2) | Some(4) | Some(16) => data
          Some(8) => pdf_image_apply_decode_borrowed(data, bpc, Some(values))
          _ => raise FilterExpected
        }
      }
  }
}

///|
fn pdf_image_cmyk_component(channel : Int, black : Int) -> Int {
  let channel = channel.to_double()
  let black = black.to_double()
  (255.0 - double_min(255.0, channel / 255.0 * (255.0 - black) + black)).to_int()
}

///|
fn pdf_image_rgb_of_cmyk(c : Int, m : Int, y : Int, k : Int) -> (Int, Int, Int) {
  (
    pdf_image_cmyk_component(c, k),
    pdf_image_cmyk_component(m, k),
    pdf_image_cmyk_component(y, k),
  )
}

///|
fn pdf_image_gray_8bpp_as_rgb24(
  width : Int,
  height : Int,
  data : BytesView,
) -> PdfBytes raise PdfError {
  let pixels = width * height
  pdf_image_require_size(data, pixels)
  let output = Array::make(pixels * 3, b'\x00')
  let mut position = 0
  for index in 0.. PdfBytes raise PdfError {
  let pixels = width * height
  pdf_image_require_size(data, pixels * 4)
  let output = Array::make(pixels * 3, b'\x00')
  let mut position = 0
  for index in 0.. PdfBytes raise PdfError {
  let bytes_per_row = (width + 7) / 8
  pdf_image_require_size(data, bytes_per_row * height)
  let output = Array::make(width * height * 3, b'\x00')
  let mut position = 0
  for row in 0..> (7 - column % 8)) & 1
      let value : Byte = if bit == 1 { 255 } else { 0 }
      output[position] = value
      output[position + 1] = value
      output[position + 2] = value
      position += 3
    }
  }
  Bytes::from_array(output)
}

///|
fn pdf_image_packed_bytes_per_row(width : Int, bits_per_component : Int) -> Int {
  let row_bits = width * bits_per_component
  if row_bits % 8 == 0 {
    row_bits / 8
  } else {
    row_bits / 8 + 1
  }
}

///|
fn pdf_image_packed_sample(
  data : BytesView,
  row_offset : Int,
  column : Int,
  bits_per_component : Int,
) -> Int {
  let bit_offset = column * bits_per_component
  let byte = data[row_offset + bit_offset / 8].to_int()
  let shift = 8 - bits_per_component - bit_offset % 8
  let mask = (1 << bits_per_component) - 1
  (byte >> shift) & mask
}

///|
fn pdf_image_gray_packed_bpp_as_rgb24(
  width : Int,
  height : Int,
  bits_per_component : Int,
  data : BytesView,
) -> PdfBytes raise PdfError {
  let bytes_per_row = pdf_image_packed_bytes_per_row(width, bits_per_component)
  pdf_image_require_size(data, bytes_per_row * height)
  let max_sample = (1 << bits_per_component) - 1
  let output = Array::make(width * height * 3, b'\x00')
  let mut position = 0
  for row in 0.. PdfBytes raise PdfError {
  pdf_image_gray_packed_bpp_as_rgb24(width, height, 2, data)
}

///|
fn pdf_image_gray_4bpp_as_rgb24(
  width : Int,
  height : Int,
  data : BytesView,
) -> PdfBytes raise PdfError {
  pdf_image_gray_packed_bpp_as_rgb24(width, height, 4, data)
}

///|
fn pdf_image_rgb_packed_bpp_as_rgb24(
  width : Int,
  height : Int,
  bits_per_component : Int,
  data : BytesView,
) -> PdfBytes raise PdfError {
  let samples_per_row = width * 3
  let bytes_per_row = pdf_image_packed_bytes_per_row(
    samples_per_row, bits_per_component,
  )
  pdf_image_require_size(data, bytes_per_row * height)
  let max_sample = (1 << bits_per_component) - 1
  let output = Array::make(width * height * 3, b'\x00')
  let mut position = 0
  for row in 0.. PdfBytes raise PdfError {
  pdf_image_rgb_packed_bpp_as_rgb24(width, height, 1, data)
}

///|
fn pdf_image_rgb_2bpp_as_rgb24(
  width : Int,
  height : Int,
  data : BytesView,
) -> PdfBytes raise PdfError {
  pdf_image_rgb_packed_bpp_as_rgb24(width, height, 2, data)
}

///|
fn pdf_image_rgb_4bpp_as_rgb24(
  width : Int,
  height : Int,
  data : BytesView,
) -> PdfBytes raise PdfError {
  pdf_image_rgb_packed_bpp_as_rgb24(width, height, 4, data)
}

///|
fn pdf_image_indexed_lookup(
  table : ArrayView[(Int, Array[Int])],
  index : Int,
  components : Int,
) -> ArrayView[Int] raise PdfError {
  for entry in table {
    if entry.0 == index && entry.1.length() == components {
      break entry.1
    }
  } nobreak {
    raise EndOfInput
  }
}

///|
fn pdf_image_rgb_indexed_value(
  output : Array[Byte],
  position : Int,
  table : ArrayView[(Int, Array[Int])],
  index : Int,
) -> Int raise PdfError {
  let values = pdf_image_indexed_lookup(table, index, 3)
  output[position] = pdf_byte_of_int(values[0])
  output[position + 1] = pdf_byte_of_int(values[1])
  output[position + 2] = pdf_byte_of_int(values[2])
  position + 3
}

///|
fn pdf_image_cmyk_indexed_value(
  output : Array[Byte],
  position : Int,
  table : ArrayView[(Int, Array[Int])],
  index : Int,
) -> Int raise PdfError {
  let values = pdf_image_indexed_lookup(table, index, 4)
  let (red, green, blue) = pdf_image_rgb_of_cmyk(
    values[0],
    values[1],
    values[2],
    values[3],
  )
  output[position] = pdf_byte_of_int(red)
  output[position + 1] = pdf_byte_of_int(green)
  output[position + 2] = pdf_byte_of_int(blue)
  position + 3
}

///|
fn pdf_image_clamp_unit(value : Double) -> Double {
  if value > 1.0 {
    1.0
  } else if value < 0.0 {
    0.0
  } else {
    value
  }
}

///|
fn pdf_image_lab_g(value : Double) -> Double {
  if value >= 6.0 / 29.0 {
    value * value * value
  } else {
    108.0 / 841.0 * (value - 4.0 / 29.0)
  }
}

///|
fn pdf_image_xyz_of_lab(
  lightness : Double,
  a : Double,
  b : Double,
  white : PdfTristimulusPoint,
) -> (Double, Double, Double) {
  let l = (lightness + 16.0) / 116.0 + a / 500.0
  let m = (lightness + 16.0) / 116.0
  let n = (lightness + 16.0) / 116.0 - b / 200.0
  (
    pdf_image_clamp_unit(white.x * pdf_image_lab_g(l)),
    pdf_image_clamp_unit(white.y * pdf_image_lab_g(m)),
    pdf_image_clamp_unit(white.z * pdf_image_lab_g(n)),
  )
}

///|
fn pdf_image_rgb_of_xyz(
  x : Double,
  y : Double,
  z : Double,
) -> (Double, Double, Double) {
  (
    pdf_image_clamp_unit(2.5623 * x + -1.1661 * y + -0.3962 * z),
    pdf_image_clamp_unit(-1.0215 * x + 1.9778 * y + 0.0437 * z),
    pdf_image_clamp_unit(0.0752 * x + -0.2562 * y + 1.1810 * z),
  )
}

///|
fn pdf_image_lab_indexed_value(
  output : Array[Byte],
  position : Int,
  table : ArrayView[(Int, Array[Int])],
  index : Int,
  white : PdfTristimulusPoint,
) -> Int raise PdfError {
  let values = pdf_image_indexed_lookup(table, index, 3)
  let lightness = (values[0] - 127).to_double() / 255.0
  let a = (values[1] - 127).to_double() / 255.0
  let b = (values[2] - 127).to_double() / 255.0
  let (x, y, z) = pdf_image_xyz_of_lab(lightness, a, b, white)
  let (red, green, blue) = pdf_image_rgb_of_xyz(x, y, z)
  output[position] = pdf_byte_of_int((red * 255.0).to_int())
  output[position + 1] = pdf_byte_of_int((green * 255.0).to_int())
  output[position + 2] = pdf_byte_of_int((blue * 255.0).to_int())
  position + 3
}

///|
fn pdf_image_indexed_8bpp_as_rgb24(
  width : Int,
  height : Int,
  table : ArrayView[(Int, Array[Int])],
  data : BytesView,
) -> PdfBytes raise PdfError {
  let pixels = width * height
  pdf_image_require_size(data, pixels)
  let output = Array::make(pixels * 3, b'\x00')
  let mut position = 0
  for index in 0.. PdfBytes raise PdfError {
  let pixels = width * height
  pdf_image_require_size(data, pixels)
  let output = Array::make(pixels * 3, b'\x00')
  let mut position = 0
  for index in 0.. PdfBytes raise PdfError {
  let bytes_per_row = pdf_image_packed_bytes_per_row(width, bits_per_component)
  pdf_image_require_size(data, bytes_per_row * height)
  let output = Array::make(width * height * 3, b'\x00')
  let mut position = 0
  for row in 0.. PdfBytes raise PdfError {
  pdf_image_lab_indexed_packed_bpp_as_rgb24(
    width, height, 4, table, white, data,
  )
}

///|
fn pdf_image_lab_indexed_2bpp_as_rgb24(
  width : Int,
  height : Int,
  table : ArrayView[(Int, Array[Int])],
  white : PdfTristimulusPoint,
  data : BytesView,
) -> PdfBytes raise PdfError {
  pdf_image_lab_indexed_packed_bpp_as_rgb24(
    width, height, 2, table, white, data,
  )
}

///|
fn pdf_image_lab_indexed_1bpp_as_rgb24(
  width : Int,
  height : Int,
  table : ArrayView[(Int, Array[Int])],
  white : PdfTristimulusPoint,
  data : BytesView,
) -> PdfBytes raise PdfError {
  pdf_image_lab_indexed_packed_bpp_as_rgb24(
    width, height, 1, table, white, data,
  )
}

///|
fn pdf_image_cmyk_indexed_8bpp_as_rgb24(
  width : Int,
  height : Int,
  table : ArrayView[(Int, Array[Int])],
  data : BytesView,
) -> PdfBytes raise PdfError {
  let pixels = width * height
  pdf_image_require_size(data, pixels)
  let output = Array::make(pixels * 3, b'\x00')
  let mut position = 0
  for index in 0.. PdfBytes raise PdfError {
  let bytes_per_row = pdf_image_packed_bytes_per_row(width, bits_per_component)
  pdf_image_require_size(data, bytes_per_row * height)
  let output = Array::make(width * height * 3, b'\x00')
  let mut position = 0
  for row in 0.. PdfBytes raise PdfError {
  pdf_image_indexed_packed_bpp_as_rgb24(width, height, 4, table, data)
}

///|
fn pdf_image_indexed_2bpp_as_rgb24(
  width : Int,
  height : Int,
  table : ArrayView[(Int, Array[Int])],
  data : BytesView,
) -> PdfBytes raise PdfError {
  pdf_image_indexed_packed_bpp_as_rgb24(width, height, 2, table, data)
}

///|
fn pdf_image_indexed_1bpp_as_rgb24(
  width : Int,
  height : Int,
  table : ArrayView[(Int, Array[Int])],
  data : BytesView,
) -> PdfBytes raise PdfError {
  pdf_image_indexed_packed_bpp_as_rgb24(width, height, 1, table, data)
}

///|
fn pdf_image_cmyk_indexed_packed_bpp_as_rgb24(
  width : Int,
  height : Int,
  bits_per_component : Int,
  table : ArrayView[(Int, Array[Int])],
  data : BytesView,
) -> PdfBytes raise PdfError {
  let bytes_per_row = pdf_image_packed_bytes_per_row(width, bits_per_component)
  pdf_image_require_size(data, bytes_per_row * height)
  let output = Array::make(width * height * 3, b'\x00')
  let mut position = 0
  for row in 0.. PdfBytes raise PdfError {
  pdf_image_cmyk_indexed_packed_bpp_as_rgb24(width, height, 4, table, data)
}

///|
fn pdf_image_cmyk_indexed_2bpp_as_rgb24(
  width : Int,
  height : Int,
  table : ArrayView[(Int, Array[Int])],
  data : BytesView,
) -> PdfBytes raise PdfError {
  pdf_image_cmyk_indexed_packed_bpp_as_rgb24(width, height, 2, table, data)
}

///|
fn pdf_image_cmyk_indexed_1bpp_as_rgb24(
  width : Int,
  height : Int,
  table : ArrayView[(Int, Array[Int])],
  data : BytesView,
) -> PdfBytes raise PdfError {
  pdf_image_cmyk_indexed_packed_bpp_as_rgb24(width, height, 1, table, data)
}

///|
fn PdfDocument::pdf_image_separation_cmyk_8bpp_as_rgb24(
  self : PdfDocument,
  width : Int,
  height : Int,
  tint : PdfObject,
  data : BytesView,
) -> PdfBytes raise PdfError {
  let pixels = width * height
  pdf_image_require_size(data, pixels)
  let tint_function = self.parse_function(tint)
  let output = Array::make(pixels * 3, b'\x00')
  let mut position = 0
  for index in 0.. PdfBytes raise PdfError {
  match (colour_space, bpc) {
    (PdfDeviceRGB | PdfCalRGB(_, _, _, _), Some(8)) => {
      let required = width * height * 3
      pdf_image_require_size(data, required)
      data[:required].to_owned()
    }
    (PdfDeviceRGB | PdfCalRGB(_, _, _, _), Some(1)) =>
      pdf_image_rgb_1bpp_as_rgb24(width, height, data)
    (PdfDeviceRGB | PdfCalRGB(_, _, _, _), Some(2)) =>
      pdf_image_rgb_2bpp_as_rgb24(width, height, data)
    (PdfDeviceRGB | PdfCalRGB(_, _, _, _), Some(4)) =>
      pdf_image_rgb_4bpp_as_rgb24(width, height, data)
    (PdfDeviceGray | PdfCalGray(_, _, _), Some(8)) =>
      pdf_image_gray_8bpp_as_rgb24(width, height, data)
    (PdfDeviceCMYK, Some(8)) =>
      pdf_image_cmyk_8bpp_as_rgb24(width, height, data)
    (PdfDeviceGray | PdfCalGray(_, _, _), Some(2)) =>
      pdf_image_gray_2bpp_as_rgb24(width, height, data)
    (PdfDeviceGray | PdfCalGray(_, _, _), Some(4)) =>
      pdf_image_gray_4bpp_as_rgb24(width, height, data)
    (PdfIndexed(base, table), Some(1)) =>
      match pdf_space_basic_table_colour_space(base) {
        PdfDeviceRGB | PdfCalRGB(_, _, _, _) =>
          pdf_image_indexed_1bpp_as_rgb24(width, height, table, data)
        PdfDeviceCMYK =>
          pdf_image_cmyk_indexed_1bpp_as_rgb24(width, height, table, data)
        PdfLab(white, _, _) =>
          pdf_image_lab_indexed_1bpp_as_rgb24(width, height, table, white, data)
        _ => raise FilterExpected
      }
    (PdfIndexed(base, table), Some(8)) =>
      match pdf_space_basic_table_colour_space(base) {
        PdfDeviceRGB | PdfCalRGB(_, _, _, _) =>
          pdf_image_indexed_8bpp_as_rgb24(width, height, table, data)
        PdfDeviceCMYK =>
          pdf_image_cmyk_indexed_8bpp_as_rgb24(width, height, table, data)
        PdfLab(white, _, _) =>
          pdf_image_lab_indexed_8bpp_as_rgb24(width, height, table, white, data)
        _ => raise FilterExpected
      }
    (PdfIndexed(base, table), Some(2)) =>
      match pdf_space_basic_table_colour_space(base) {
        PdfDeviceRGB | PdfCalRGB(_, _, _, _) =>
          pdf_image_indexed_2bpp_as_rgb24(width, height, table, data)
        PdfDeviceCMYK =>
          pdf_image_cmyk_indexed_2bpp_as_rgb24(width, height, table, data)
        PdfLab(white, _, _) =>
          pdf_image_lab_indexed_2bpp_as_rgb24(width, height, table, white, data)
        _ => raise FilterExpected
      }
    (PdfIndexed(base, table), Some(4)) =>
      match pdf_space_basic_table_colour_space(base) {
        PdfDeviceRGB | PdfCalRGB(_, _, _, _) =>
          pdf_image_indexed_4bpp_as_rgb24(width, height, table, data)
        PdfDeviceCMYK =>
          pdf_image_cmyk_indexed_4bpp_as_rgb24(width, height, table, data)
        PdfLab(white, _, _) =>
          pdf_image_lab_indexed_4bpp_as_rgb24(width, height, table, white, data)
        _ => raise FilterExpected
      }
    (_, Some(1)) => pdf_image_1bpp_as_rgb24(width, height, data)
    (PdfSeparation(_, PdfDeviceCMYK, tint), Some(8)) =>
      self.pdf_image_separation_cmyk_8bpp_as_rgb24(width, height, tint, data)
    (PdfICCBased(_, alternate, _, _, _), _) =>
      self.pdf_image_raw_colour_space_as_rgb24(
        width, height, alternate, bpc, data,
      )
    _ => raise FilterExpected
  }
}

///|
fn PdfDocument::pdf_image_raw_colour_space_as_rgb24_owned(
  self : PdfDocument,
  width : Int,
  height : Int,
  colour_space : PdfColourSpace,
  bpc : Int?,
  data : PdfBytes,
) -> PdfBytes raise PdfError {
  match (colour_space, bpc) {
    (PdfDeviceRGB | PdfCalRGB(_, _, _, _), Some(8)) => {
      let required = width * height * 3
      pdf_image_require_size(data, required)
      if data.length() == required {
        data
      } else {
        data[:required].to_owned()
      }
    }
    _ =>
      self.pdf_image_raw_colour_space_as_rgb24(
        width, height, colour_space, bpc, data,
      )
  }
}

///|
fn PdfDocument::pdf_image_raw_8bpp_as_rgb24_owned(
  self : PdfDocument,
  resources : PdfObject,
  width : Int,
  height : Int,
  dictionary : PdfObject,
  data : PdfBytes,
) -> PdfBytes raise PdfError {
  self.pdf_image_raw_colour_space_as_rgb24_owned(
    width,
    height,
    self.image_colour_space(dictionary, resources),
    self.pdf_image_bits_per_component_int(dictionary),
    data,
  )
}

///|
/// Extract an image as either encoded image bytes or 24bpp RGB data.
///
/// DCT/JPEG, JPX/JPEG2000, and JBIG2 streams are returned still encoded.
/// Other supported image streams are decoded, have supported `/Decode` arrays
/// applied, and are converted to `PdfRawImage(width, height, PdfBPP24, data)`
/// where `data` is RGBRGB byte order.
pub fn PdfDocument::get_image_24bpp(
  self : PdfDocument,
  resources : PdfObject,
  image : PdfObject,
) -> PdfImage raise PdfError {
  let decoded = self.pdf_image_decode_to_image(image)
  let decoded_stream = match decoded {
    PdfImageRawStream(stream)
    | PdfImageDctStream(stream)
    | PdfImageJpxStream(stream)
    | PdfImageJbig2Stream(stream) => stream
  }
  let width = self.pdf_image_lookup_int_or_else(
    pdf_image_width_key(),
    pdf_image_w_key(),
    decoded_stream.dictionary,
  )
  let height = self.pdf_image_lookup_int_or_else(
    pdf_image_height_key(),
    pdf_image_h_key(),
    decoded_stream.dictionary,
  )
  let data = pdf_stream_data_bytes(decoded_stream.data)
  match decoded {
    PdfImageDctStream(_) =>
      PdfJPEG(
        data,
        self.pdf_image_encoded_decode_values_with_defaults(
          resources,
          decoded_stream.dictionary,
        ),
      )
    PdfImageJpxStream(_) =>
      PdfJPEG2000(
        data,
        self.pdf_image_encoded_decode_values_with_defaults(
          resources,
          decoded_stream.dictionary,
        ),
      )
    PdfImageJbig2Stream(_) =>
      PdfJBIG2(
        data,
        self.pdf_image_encoded_decode_values_with_defaults(
          resources,
          decoded_stream.dictionary,
        ),
        self.pdf_image_jbig2_globals(decoded_stream.dictionary),
      )
    PdfImageRawStream(_) => {
      let decoded_data = pdf_image_apply_decode(
        data,
        self.pdf_image_bits_per_component_int(decoded_stream.dictionary),
        self.pdf_image_decode_values_with_defaults(
          resources,
          decoded_stream.dictionary,
        ),
      )
      PdfRawImage(
        width,
        height,
        PdfBPP24,
        self.pdf_image_raw_8bpp_as_rgb24_owned(
          resources,
          width,
          height,
          decoded_stream.dictionary,
          decoded_data,
        ),
      )
    }
  }
}

///|
/// Compatibility wrapper for `PdfDocument::get_image_24bpp`.
pub fn pdf_get_image_24bpp(
  document : PdfDocument,
  resources : PdfObject,
  image : PdfObject,
) -> PdfImage raise PdfError {
  document.get_image_24bpp(resources, image)
}

///|
priv struct PdfImageExtractionJbig2Global {
  object_number : Int
  serial : Int
}

///|
priv struct PdfImageExtractionState {
  next_jbig2_serial : Ref[Int]
  globals : Array[PdfImageExtractionJbig2Global]
}

///|
fn PdfImageExtractionState::jbig2_serial(
  self : PdfImageExtractionState,
  object_number : Int,
) -> Int? {
  for entry in self.globals {
    if entry.object_number == object_number {
      break Some(entry.serial)
    }
  } nobreak {
    None
  }
}

///|
fn PdfImageExtractionState::add_jbig2_global(
  self : PdfImageExtractionState,
  object_number : Int,
) -> Int {
  self.next_jbig2_serial.val += 1
  let serial = self.next_jbig2_serial.val
  self.globals.push({ object_number, serial })
  serial
}

///|
fn pdf_image_copy_view(
  output : Array[Byte],
  position : Int,
  bytes : BytesView,
) -> Int {
  let mut next = position
  for byte in bytes {
    output[next] = byte
    next += 1
  }
  next
}

///|
fn pdf_image_pnm_24bpp_bytes(
  width : Int,
  height : Int,
  data : PdfBytes,
) -> PdfBytes {
  let width_bytes = @ascii.encode(width.to_string())
  let height_bytes = @ascii.encode(height.to_string())
  let output = Array::make(
    3 + width_bytes.length() + 1 + height_bytes.length() + 5 + data.length(),
    b'\x00',
  )
  let mut position = pdf_image_copy_view(output, 0, b"P6 ")
  position = pdf_image_copy_view(output, position, width_bytes)
  position = pdf_image_copy_view(output, position, b" ")
  position = pdf_image_copy_view(output, position, height_bytes)
  position = pdf_image_copy_view(output, position, b" 255\n")
  let _ = pdf_image_copy_view(output, position, data)
  Bytes::from_array(output)
}

///|
fn pdf_image_path_separator(code_unit : UInt16) -> Bool {
  let value = code_unit.to_int()
  value == 47 || value == 92
}

///|
fn pdf_image_dirname(path : String) -> String {
  let mut separator = -1
  for index in 0.. String {
  if directory == "." {
    "./" + filename
  } else if directory.length() == 0 {
    filename
  } else {
    let last = directory.at(directory.length() - 1).to_int()
    if last == 47 || last == 92 {
      directory + filename
    } else {
      directory + "/" + filename
    }
  }
}

///|
fn PdfDocument::pdf_image_jbig2_global_data(
  self : PdfDocument,
  object_number : Int,
) -> PdfBytes? raise PdfError {
  match self.pdf_decode_stream_until_unknown(PdfIndirect(object_number)) {
    PdfStreamObject(stream) => Some(pdf_stream_data_bytes(stream.data))
    _ => None
  }
}

///|
fn PdfDocument::pdf_image_extracted_files_of_image(
  self : PdfDocument,
  resources : PdfObject,
  name : String,
  image : PdfObject,
  state : PdfImageExtractionState,
) -> Array[PdfExtractedImageFile] raise PdfError {
  pdf_check_injectible(name)
  match self.get_image_24bpp(resources, image) {
    PdfJPEG(data, _) => [{ path: name + ".jpg", data }]
    PdfJPEG2000(data, _) => [{ path: name + ".jpx", data }]
    PdfJBIG2(data, _, None) => [{ path: name + ".jbig2", data }]
    PdfJBIG2(data, _, Some(global_number)) =>
      match state.jbig2_serial(global_number) {
        Some(serial) => [{ path: name + ".jbig2__" + serial.to_string(), data }]
        None =>
          match self.pdf_image_jbig2_global_data(global_number) {
            Some(global_data) => {
              let serial = state.add_jbig2_global(global_number)
              [
                {
                  path: pdf_image_join_directory_file(
                    pdf_image_dirname(name),
                    serial.to_string() + ".jbig2global",
                  ),
                  data: global_data,
                },
                { path: name + ".jbig2__" + serial.to_string(), data },
              ]
            }
            None => []
          }
      }
    PdfRawImage(width, height, PdfBPP24, data) =>
      [
        {
          path: name + ".pnm",
          data: pdf_image_pnm_24bpp_bytes(width, height, data),
        },
      ]
    PdfRawImage(_, _, _, _) => []
  }
}

///|
priv struct PdfImageExtractionRunState {
  serial : Ref[Int]
  written : Array[Int]
  jbig2 : PdfImageExtractionState
}

///|
fn PdfImageExtractionRunState::reset_written(
  self : PdfImageExtractionRunState,
) -> Unit {
  self.written.clear()
}

///|
fn PdfImageExtractionRunState::claim_images(
  self : PdfImageExtractionRunState,
  images : ArrayView[PdfObject],
  dedup : Bool,
  dedup_per_page : Bool,
) -> Array[PdfObject] {
  if !dedup && !dedup_per_page {
    images.to_owned()
  } else {
    let claimed : Array[PdfObject] = []
    for image in images {
      match image {
        PdfIndirect(number) if self.written.contains(number) => ()
        PdfIndirect(number) => {
          self.written.push(number)
          claimed.push(image)
        }
        _ => claimed.push(image)
      }
    }
    claimed
  }
}

///|
fn PdfImageExtractionRunState::next_serial(
  self : PdfImageExtractionRunState,
) -> Int {
  let serial = self.serial.val
  self.serial.val += 1
  serial
}

///|
fn pdf_image_replace_percent_objnum(stem : String) -> String {
  let input = @utf8.encode(stem)
  let pattern = b"%objnum"
  let mut output_length = input.length()
  let mut index = 0
  while index < input.length() {
    let mut matched = index + pattern.length() <= input.length()
    if matched {
      for pattern_index in 0.. stem
  }
}

///|
fn PdfDocument::pdf_image_extraction_name(
  self : PdfDocument,
  encoding : PdfMetadataEncoding,
  stem : String,
  page_number : Int,
  image : PdfObject,
  state : PdfImageExtractionRunState,
) -> String raise PdfError {
  let object_number = match image {
    PdfIndirect(number) => number
    _ => 0
  }
  self.bookmark_name_of_spec(
    encoding,
    [],
    0,
    pdf_image_replace_percent_objnum(stem) + "-p" + page_number.to_string(),
    state.next_serial(),
    "",
    object_number,
    0,
  )
}

///|
fn PdfDocument::pdf_image_extract_named_images(
  self : PdfDocument,
  resources : PdfObject,
  images : ArrayView[PdfObject],
  stem : String,
  page_number : Int,
  encoding : PdfMetadataEncoding,
  state : PdfImageExtractionRunState,
) -> (Array[PdfExtractedImageFile], Array[String]) raise PdfError {
  let files : Array[PdfExtractedImageFile] = []
  let names : Array[String] = []
  for image in images {
    let name = self.pdf_image_extraction_name(
      encoding, stem, page_number, image, state,
    )
    names.push(name)
    for
      file in self.pdf_image_extracted_files_of_image(
        resources,
        name,
        image,
        state.jbig2,
      ) {
      files.push(file)
    }
  }
  (files, names)
}

///|
fn PdfDocument::pdf_image_partition_xobjects(
  self : PdfDocument,
  resources : PdfObject,
) -> (Array[PdfObject], Array[PdfObject]) {
  let images : Array[PdfObject] = []
  let forms : Array[PdfObject] = []
  match self.lookup_direct(pdf_image_xobject_key(), resources) {
    Some(PdfDictionary(xobjects)) =>
      for entry in xobjects {
        match self.lookup_direct(pdf_image_subtype_key(), entry.1) {
          Some(PdfNameObject(subtype)) if subtype == pdf_image_image_name() =>
            images.push(entry.1)
          Some(PdfNameObject(subtype)) if subtype == pdf_image_form_name() =>
            forms.push(entry.1)
          _ => ()
        }
      }
    _ => ()
  }
  (images, forms)
}

///|
fn PdfDocument::pdf_image_form_resources(
  self : PdfDocument,
  form : PdfObject,
) -> PdfObject {
  match self.lookup_direct(pdf_xobject_resources_key(), form) {
    Some(PdfDictionary(entries)) => PdfDictionary(entries)
    _ => PdfDictionary([])
  }
}

///|
fn PdfDocument::pdf_image_extract_form_xobject_files(
  self : PdfDocument,
  form : PdfObject,
  stem : String,
  page_number : Int,
  encoding : PdfMetadataEncoding,
  dedup : Bool,
  dedup_per_page : Bool,
  state : PdfImageExtractionRunState,
  depth : Int,
) -> Array[PdfExtractedImageFile] raise PdfError {
  if depth > 1024 {
    raise SoftError("extract_images: form XObject recursion too deep")
  }
  let resources = self.pdf_image_form_resources(form)
  let (images, forms) = self.pdf_image_partition_xobjects(resources)
  let files : Array[PdfExtractedImageFile] = []
  for child_form in forms {
    for
      file in self.pdf_image_extract_form_xobject_files(
        child_form,
        stem,
        page_number,
        encoding,
        dedup,
        dedup_per_page,
        state,
        depth + 1,
      ) {
      files.push(file)
    }
  }
  let claimed = state.claim_images(images, dedup, dedup_per_page)
  let (image_files, _) = self.pdf_image_extract_named_images(
    resources, claimed, stem, page_number, encoding, state,
  )
  for file in image_files {
    files.push(file)
  }
  files
}

///|
fn PdfDocument::pdf_image_extract_inline_image_files(
  self : PdfDocument,
  resources : PdfObject,
  content : ArrayView[PdfObject],
  stem : String,
  page_number : Int,
  encoding : PdfMetadataEncoding,
  state : PdfImageExtractionRunState,
) -> Array[PdfExtractedImageFile] raise PdfError {
  let files : Array[PdfExtractedImageFile] = []
  let ops = self.parse_content_ops_with_resources(resources, content)
  for op in ops {
    match op {
      Op_InlineImage(dictionary, _, data) => {
        let image_dictionary = dictionary.add_dict_entry(
          pdf_image_length_key(),
          PdfInteger(data.length()),
        )
        let image = pdf_stream(image_dictionary, StreamGot(data))
        let name = self.bookmark_name_of_spec(
          encoding,
          [],
          0,
          pdf_image_replace_percent_objnum(stem) +
          "-p" +
          page_number.to_string() +
          "-inline",
          state.next_serial(),
          "",
          0,
          0,
        )
        for
          file in self.pdf_image_extracted_files_of_image(
            resources,
            name,
            image,
            state.jbig2,
          ) {
          files.push(file)
        }
      }
      _ => ()
    }
  }
  files
}

///|
fn PdfDocument::pdf_image_extract_inline_form_files(
  self : PdfDocument,
  form : PdfObject,
  stem : String,
  page_number : Int,
  encoding : PdfMetadataEncoding,
  state : PdfImageExtractionRunState,
) -> Array[PdfExtractedImageFile] raise PdfError {
  self.pdf_image_extract_inline_image_files(
    self.pdf_image_form_resources(form),
    [form],
    stem,
    page_number,
    encoding,
    state,
  )
}

///|
fn PdfDocument::pdf_image_extract_smask_files(
  self : PdfDocument,
  image : PdfObject,
  stem : String,
  page_number : Int,
  encoding : PdfMetadataEncoding,
  state : PdfImageExtractionRunState,
) -> Array[PdfExtractedImageFile] raise PdfError {
  match self.direct(image) {
    PdfStreamObject(_) =>
      match self.direct(image).lookup_immediate(pdf_image_smask_key()) {
        Some(PdfIndirect(mask_number)) => {
          let mask = PdfIndirect(mask_number)
          let (files, _) = self.pdf_image_extract_named_images(
            PdfDictionary([]),
            [mask],
            stem + "-smask",
            page_number,
            encoding,
            state,
          )
          files
        }
        _ => []
      }
    _ => []
  }
}

///|
/// Return the files cpdfimage's `extract_images` would write for page XObjects.
///
/// This ports the in-memory extraction side: page image XObjects, indirect
/// soft masks, recursive Form XObject image traversal, optional inline images,
/// cpdf filename expansion, and cpdf's indirect-image deduplication modes.
/// Decoded image data is emitted as raw PNM; external conversion and mask
/// composition are handled by native/file wrappers when available.
pub fn PdfDocument::extract_images_files(
  self : PdfDocument,
  page_numbers : ArrayView[Int],
  stem : String,
  encoding? : PdfMetadataEncoding = PdfMetadataUTF8,
  inline? : Bool = false,
  dedup? : Bool = false,
  dedup_per_page? : Bool = false,
) -> Array[PdfExtractedImageFile] raise PdfError {
  let pages = self.pages_of_pagetree()
  pdf_page_box_validate_page_numbers(page_numbers, pages.length(), "images")
  let state : PdfImageExtractionRunState = {
    serial: { val: 0 },
    written: [],
    jbig2: { next_jbig2_serial: { val: 0 }, globals: [] },
  }
  let files : Array[PdfExtractedImageFile] = []
  let mut selected_page_number = 1
  for page_number in page_numbers {
    if dedup_per_page {
      state.reset_written()
    }
    let page = pages[page_number - 1]
    let (page_images, page_forms) = self.pdf_image_partition_xobjects(
      page.resources,
    )
    let claimed_page_images = state.claim_images(
      page_images, dedup, dedup_per_page,
    )
    let (page_image_files, _) = self.pdf_image_extract_named_images(
      page.resources,
      claimed_page_images,
      stem,
      selected_page_number,
      encoding,
      state,
    )
    for file in page_image_files {
      files.push(file)
    }
    for index in 0.. Array[PdfExtractedImageFile] raise PdfError {
  document.extract_images_files(
    page_numbers,
    stem,
    encoding~,
    inline~,
    dedup~,
    dedup_per_page~,
  )
}

///|
/// Return the files cpdfimage's `extract_single_image` would write.
///
/// Encoded JPEG, JPEG2000, and JBIG2 images keep their encoded payloads.
/// Other supported images are decoded through `get_image_24bpp` and emitted as
/// binary PNM (`P6`) RGB files. An indirect `/SMask` is emitted with the
/// `-smask` stem, matching cpdf's single-image extraction path.
pub fn PdfDocument::extract_single_image_files(
  self : PdfDocument,
  object_number : Int,
  stem : String,
) -> Array[PdfExtractedImageFile] raise PdfError {
  let state : PdfImageExtractionState = {
    next_jbig2_serial: { val: 0 },
    globals: [],
  }
  let files = self.pdf_image_extracted_files_of_image(
    PdfDictionary([]),
    stem,
    PdfIndirect(object_number),
    state,
  )
  let image = self.direct(PdfIndirect(object_number))
  match image {
    PdfStreamObject(_) =>
      match image.lookup_immediate(pdf_image_smask_key()) {
        Some(PdfIndirect(mask_number)) =>
          for
            file in self.pdf_image_extracted_files_of_image(
              PdfDictionary([]),
              stem + "-smask",
              PdfIndirect(mask_number),
              state,
            ) {
            files.push(file)
          }
        _ => ()
      }
    _ => ()
  }
  files
}

///|
/// Compatibility wrapper for `PdfDocument::extract_single_image_files`.
pub fn pdf_image_extract_single_image_files(
  document : PdfDocument,
  object_number : Int,
  stem : String,
) -> Array[PdfExtractedImageFile] raise PdfError {
  document.extract_single_image_files(object_number, stem)
}