///|
pub(all) enum PdfMetadataEncoding {
  PdfMetadataRaw
  PdfMetadataUTF8
  PdfMetadataStripped
} derive(Debug, Eq, ToJson)

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

///|
let pdf_metadata_root_name : PdfName = pdf_metadata_name("/Root")

///|
fn pdf_metadata_root() -> PdfName {
  pdf_metadata_root_name
}

///|
let pdf_metadata_info_name : PdfName = pdf_metadata_name("/Info")

///|
fn pdf_metadata_info() -> PdfName {
  pdf_metadata_info_name
}

///|
let pdf_metadata_id_name : PdfName = pdf_metadata_name("/ID")

///|
fn pdf_metadata_id() -> PdfName {
  pdf_metadata_id_name
}

///|
let pdf_metadata_metadata_name : PdfName = pdf_metadata_name("/Metadata")

///|
fn pdf_metadata_metadata() -> PdfName {
  pdf_metadata_metadata_name
}

///|
let pdf_metadata_type_name : PdfName = pdf_metadata_name("/Type")

///|
fn pdf_metadata_type() -> PdfName {
  pdf_metadata_type_name
}

///|
let pdf_metadata_subtype_name : PdfName = pdf_metadata_name("/Subtype")

///|
fn pdf_metadata_subtype() -> PdfName {
  pdf_metadata_subtype_name
}

///|
let pdf_metadata_xml_name : PdfName = pdf_metadata_name("/XML")

///|
fn pdf_metadata_xml() -> PdfName {
  pdf_metadata_xml_name
}

///|
let pdf_metadata_length_name : PdfName = pdf_metadata_name("/Length")

///|
fn pdf_metadata_length() -> PdfName {
  pdf_metadata_length_name
}

///|
let pdf_metadata_viewer_preferences_name : PdfName = pdf_metadata_name(
  "/ViewerPreferences",
)

///|
fn pdf_metadata_viewer_preferences() -> PdfName {
  pdf_metadata_viewer_preferences_name
}

///|
let pdf_metadata_page_layout_name : PdfName = pdf_metadata_name("/PageLayout")

///|
fn pdf_metadata_page_layout() -> PdfName {
  pdf_metadata_page_layout_name
}

///|
let pdf_metadata_page_mode_name : PdfName = pdf_metadata_name("/PageMode")

///|
fn pdf_metadata_page_mode() -> PdfName {
  pdf_metadata_page_mode_name
}

///|
let pdf_metadata_non_full_screen_page_mode_name : PdfName = pdf_metadata_name(
  "/NonFullScreenPageMode",
)

///|
fn pdf_metadata_non_full_screen_page_mode() -> PdfName {
  pdf_metadata_non_full_screen_page_mode_name
}

///|
let pdf_metadata_open_action_name : PdfName = pdf_metadata_name("/OpenAction")

///|
fn pdf_metadata_open_action() -> PdfName {
  pdf_metadata_open_action_name
}

///|
let pdf_metadata_d_name : PdfName = pdf_metadata_name("/D")

///|
fn pdf_metadata_d() -> PdfName {
  pdf_metadata_d_name
}

///|
let pdf_metadata_s_name : PdfName = pdf_metadata_name("/S")

///|
fn pdf_metadata_s() -> PdfName {
  pdf_metadata_s_name
}

///|
let pdf_metadata_goto_name : PdfName = pdf_metadata_name("/GoTo")

///|
fn pdf_metadata_goto() -> PdfName {
  pdf_metadata_goto_name
}

///|
let pdf_metadata_fit_name : PdfName = pdf_metadata_name("/Fit")

///|
fn pdf_metadata_fit() -> PdfName {
  pdf_metadata_fit_name
}

///|
let pdf_metadata_xyz_name : PdfName = pdf_metadata_name("/XYZ")

///|
fn pdf_metadata_xyz() -> PdfName {
  pdf_metadata_xyz_name
}

///|
let pdf_metadata_lang_name : PdfName = pdf_metadata_name("/Lang")

///|
fn pdf_metadata_lang() -> PdfName {
  pdf_metadata_lang_name
}

///|
let pdf_metadata_mark_info_name : PdfName = pdf_metadata_name("/MarkInfo")

///|
fn pdf_metadata_mark_info() -> PdfName {
  pdf_metadata_mark_info_name
}

///|
let pdf_metadata_acroform_name : PdfName = pdf_metadata_name("/AcroForm")

///|
fn pdf_metadata_acroform() -> PdfName {
  pdf_metadata_acroform_name
}

///|
let pdf_metadata_xfa_name : PdfName = pdf_metadata_name("/XFA")

///|
fn pdf_metadata_xfa() -> PdfName {
  pdf_metadata_xfa_name
}

///|
let pdf_metadata_trapped_name : PdfName = pdf_metadata_name("/Trapped")

///|
fn pdf_metadata_trapped() -> PdfName {
  pdf_metadata_trapped_name
}

///|
let pdf_metadata_cropbox_name : PdfName = pdf_metadata_name("/CropBox")

///|
fn pdf_metadata_cropbox() -> PdfName {
  pdf_metadata_cropbox_name
}

///|
let pdf_metadata_bleedbox_name : PdfName = pdf_metadata_name("/BleedBox")

///|
fn pdf_metadata_bleedbox() -> PdfName {
  pdf_metadata_bleedbox_name
}

///|
let pdf_metadata_trimbox_name : PdfName = pdf_metadata_name("/TrimBox")

///|
fn pdf_metadata_trimbox() -> PdfName {
  pdf_metadata_trimbox_name
}

///|
let pdf_metadata_artbox_name : PdfName = pdf_metadata_name("/ArtBox")

///|
fn pdf_metadata_artbox() -> PdfName {
  pdf_metadata_artbox_name
}

///|
pub let pdf_metadata_adobe_namespace : String = "http://ns.adobe.com/pdf/1.3/"

///|
pub let pdf_metadata_xmp_namespace : String = "http://ns.adobe.com/xap/1.0/"

///|
pub let pdf_metadata_dc_namespace : String = "http://purl.org/dc/elements/1.1/"

///|
pub let pdf_metadata_rdf_namespace : String = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"

///|
pub let pdf_metadata_pdfaid_namespace : String = "http://www.aiim.org/pdfa/ns/id/"

///|
let pdf_metadata_pdfaid_wrong_iso_namespace : String = "http://www.aiim.org/pdfa/ns/id"

///|
let pdf_metadata_pdfaid_wrong_acrobat_namespace : String = "http://www.aiim.org/pdfa/ns/id.html"

///|
pub let pdf_metadata_pdfxid_namespace : String = "http://www.npes.org/pdfx/ns/id/"

///|
pub let pdf_metadata_pdfe_namespace : String = "http://www.aiim.org/pdfe/ns/id/"

///|
pub let pdf_metadata_pdfuaid_namespace : String = "http://www.aiim.org/pdfua/ns/id/"

///|
pub let pdf_metadata_pdfvtid_namespace : String = "http://www.npes.org/pdfvt/ns/id/"

///|
let pdf_metadata_xmp_template : String =
  #|
  #|
  #|
  #|
  #|
  #| 
  #|  CREATEDATE
  #|  MODDATE
  #|  PRODUCER
  #|  CREATOR
  #|  TITLE
  #|  SUBJECT
  #|  AUTHOR
  #|  KEYWORDS
  #|  TRAPPED
  #| 
  #|
  #| 
  #|   CREATEDATE
  #|   CREATOR
  #|   MODDATE
  #|   METADATADATE
  #| 
  #|
  #| 
  #|   
  #|   
  #|     TITLE
  #|   
  #|   
  #| 
  #|
  #|
  #|
  #|
  #|
  #|

///|
fn pdf_metadata_bool_string(value : Bool) -> String {
  if value {
    "True"
  } else {
    "False"
  }
}

///|
fn pdf_metadata_ascii_codepoints(bytes : BytesView) -> PdfBytes raise PdfError {
  let output : Array[Byte] = []
  for codepoint in pdf_codepoints_of_pdfdocstring(bytes) {
    if codepoint <= 127 {
      output.push(pdf_byte_of_int(codepoint))
    }
  }
  Bytes::from_array(output)
}

///|
/// Encode a PDF string for metadata/reporting output.
pub fn pdf_metadata_encode_output(
  encoding : PdfMetadataEncoding,
  bytes : BytesView,
) -> PdfBytes raise PdfError {
  match encoding {
    PdfMetadataRaw => bytes.to_owned()
    PdfMetadataUTF8 => pdf_utf8_of_pdfdocstring(bytes)
    PdfMetadataStripped => pdf_metadata_ascii_codepoints(bytes)
  }
}

///|
fn PdfDocument::pdf_metadata_bump_minor(
  self : PdfDocument,
  version : Int,
  keep_version : Bool,
) -> Unit {
  if self.major == 1 && !keep_version && self.minor < version {
    self.minor = version
  }
}

///|
fn PdfDocument::pdf_metadata_replace_catalog(
  self : PdfDocument,
  catalog : PdfObject,
) -> Unit raise PdfError {
  let root_number = self.add_object(catalog)
  self.set_root(root_number)
  self.set_trailer_dict(
    self
    .trailer_dict()
    .add_dict_entry(pdf_metadata_root(), PdfIndirect(root_number)),
  )
}

///|
fn PdfDocument::pdf_metadata_update_catalog(
  self : PdfDocument,
  update : (PdfObject) -> PdfObject raise PdfError,
) -> Unit raise PdfError {
  let catalog = self.catalog()
  self.pdf_metadata_replace_catalog(update(catalog))
}

///|
fn pdf_metadata_name_string(name : PdfName) -> String raise PdfError {
  let bytes = name.bytes()
  let start = if bytes.length() > 0 && bytes[0].to_int() == 47 { 1 } else { 0 }
  @ascii.decode(bytes[start:]) catch {
    _ => raise InvalidUTF8
  }
}

///|
fn pdf_metadata_known_name(
  value : String,
  allowed : ArrayView[String],
  error_prefix : String,
) -> PdfName raise PdfError {
  for allowed_value in allowed {
    if value == allowed_value {
      break pdf_metadata_name("/" + value)
    }
  } nobreak {
    raise BadNumberArgument(error_prefix + value)
  }
}

///|
/// Copy the trailer `/ID` from `source` onto this document.
///
/// The original document is left unchanged. When `keep_version` is false, PDF
/// 1.x outputs are bumped to at least 1.1, matching cpdf.
pub fn PdfDocument::copy_id_from(
  self : PdfDocument,
  source : PdfDocument,
  keep_version? : Bool = false,
) -> PdfDocument raise PdfError {
  let document = self.copy()
  match source.lookup_direct(pdf_metadata_id(), source.trailer_dict()) {
    Some(id) => {
      document.set_trailer_dict(
        document.trailer_dict().add_dict_entry(pdf_metadata_id(), id),
      )
      document.pdf_metadata_bump_minor(1, keep_version)
      document
    }
    None => document
  }
}

///|
/// Set or replace one `/Info` dictionary entry.
pub fn PdfDocument::set_info_entry(
  self : PdfDocument,
  key : PdfName,
  value : PdfObject,
  version : Int,
  keep_version? : Bool = false,
  xmp_also? : Bool = false,
  xmp_just_set? : Bool = false,
) -> PdfDocument raise PdfError {
  let document = if xmp_just_set {
    self
  } else {
    let document = self.copy()
    let info = match
      document.lookup_direct(pdf_metadata_info(), document.trailer_dict()) {
      Some(dictionary) => dictionary
      None => PdfDictionary([])
    }
    let info_number = document.add_object(info.add_dict_entry(key, value))
    document.set_trailer_dict(
      document
      .trailer_dict()
      .add_dict_entry(pdf_metadata_info(), PdfIndirect(info_number)),
    )
    document.pdf_metadata_bump_minor(version, keep_version)
    document
  }
  if !xmp_also {
    document
  } else {
    match document.metadata() {
      None => document
      Some(metadata) => {
        let (changes, replacement) = pdf_metadata_info_xmp_changes(key, value)
        let mut updated = metadata
        for change in changes {
          updated = pdf_metadata_rewrite_xmp_field(
            updated,
            change.0,
            pdf_metadata_namespace_fallback_prefix(change.0),
            change.1,
            replacement,
          )
        }
        document.set_metadata_from_bytes(updated, keep_version=true)
      }
    }
  }
}

///|
/// Return one `/Info` dictionary entry as a UTF-8 string.
pub fn PdfDocument::info_utf8(self : PdfDocument, key : PdfName) -> String {
  let info = match
    self.lookup_direct(pdf_metadata_info(), self.trailer_dict()) {
    Some(dictionary) => dictionary
    None => PdfDictionary([])
  }
  match self.lookup_direct(key, info) {
    Some(PdfString(bytes)) =>
      @utf8.decode(pdf_utf8_of_pdfdocstring(bytes)) catch {
        _ => ""
      }
    Some(PdfBoolean(value)) => pdf_metadata_bool_string(value)
    _ => if key == pdf_metadata_trapped() { "False" } else { "" }
  }
}

///|
/// Set or replace one `/ViewerPreferences` dictionary entry.
pub fn PdfDocument::set_viewer_preference(
  self : PdfDocument,
  key : PdfName,
  value : PdfObject,
  version : Int,
  keep_version? : Bool = false,
) -> PdfDocument raise PdfError {
  let document = self.copy()
  document.pdf_metadata_update_catalog(fn(catalog) raise PdfError {
    let preferences = match
      document.lookup_direct(pdf_metadata_viewer_preferences(), catalog) {
      Some(dictionary) => dictionary
      None => PdfDictionary([])
    }
    catalog.add_dict_entry(
      pdf_metadata_viewer_preferences(),
      preferences.add_dict_entry(key, value),
    )
  })
  document.pdf_metadata_bump_minor(version, keep_version)
  document
}

///|
/// Set the catalog `/PageLayout` value using a name without the leading slash.
pub fn PdfDocument::set_page_layout(
  self : PdfDocument,
  layout : String,
) -> PdfDocument raise PdfError {
  let name = pdf_metadata_known_name(
    layout,
    [
      "SinglePage", "OneColumn", "TwoColumnLeft", "TwoColumnRight", "TwoPageLeft",
      "TwoPageRight",
    ],
    "Unknown page layout: ",
  )
  let document = self.copy()
  document.pdf_metadata_update_catalog(fn(catalog) raise PdfError {
    catalog.add_dict_entry(pdf_metadata_page_layout(), PdfNameObject(name))
  })
  document
}

///|
/// Set the catalog `/PageMode` value using a name without the leading slash.
pub fn PdfDocument::set_page_mode(
  self : PdfDocument,
  mode : String,
) -> PdfDocument raise PdfError {
  let name = pdf_metadata_known_name(
    mode,
    [
      "UseNone", "UseOutlines", "UseThumbs", "FullScreen", "UseOC", "UseAttachments",
    ],
    "Unknown page mode: ",
  )
  let document = self.copy()
  document.pdf_metadata_update_catalog(fn(catalog) raise PdfError {
    catalog.add_dict_entry(pdf_metadata_page_mode(), PdfNameObject(name))
  })
  document
}

///|
/// Set `/ViewerPreferences /NonFullScreenPageMode`.
pub fn PdfDocument::set_non_full_screen_page_mode(
  self : PdfDocument,
  mode : String,
) -> PdfDocument raise PdfError {
  let name = pdf_metadata_known_name(
    mode,
    ["UseNone", "UseOutlines", "UseThumbs", "UseOC", "UseAttachments"],
    "Unknown non full screen page mode: ",
  )
  self.set_viewer_preference(
    pdf_metadata_non_full_screen_page_mode(),
    PdfNameObject(name),
    2,
  )
}

///|
fn PdfDocument::pdf_metadata_rewrite_destination_page(
  self : PdfDocument,
  destination : PdfObject,
) -> PdfObject raise PdfError {
  match destination {
    PdfArray(values) =>
      match values {
        [PdfInteger(page_number), .. rest] => {
          let page_numbers = self.page_reference_numbers()
          if page_number < 1 || page_number > page_numbers.length() {
            raise BadPageSpecification(page_number.to_string())
          }
          PdfArray([PdfIndirect(page_numbers[page_number - 1]), ..rest])
        }
        _ => destination
      }
    _ => destination
  }
}

///|
/// Set the catalog `/OpenAction` to a GoTo action.
///
/// `page_number` is one-based. When `destination` is supplied and starts with a
/// page number integer, that integer is rewritten to the corresponding page
/// indirect reference.
pub fn PdfDocument::set_open_action(
  self : PdfDocument,
  page_number : Int,
  fit? : Bool = true,
  destination? : PdfObject? = None,
) -> PdfDocument raise PdfError {
  let page_numbers = self.page_reference_numbers()
  if page_number < 1 || page_number > page_numbers.length() {
    raise BadPageSpecification(page_number.to_string())
  }
  let destination = match destination {
    Some(value) => self.pdf_metadata_rewrite_destination_page(value)
    None =>
      if fit {
        PdfArray([
          PdfIndirect(page_numbers[page_number - 1]),
          PdfNameObject(pdf_metadata_fit()),
        ])
      } else {
        PdfArray([
          PdfIndirect(page_numbers[page_number - 1]),
          PdfNameObject(pdf_metadata_xyz()),
          PdfNull,
          PdfNull,
          PdfNull,
        ])
      }
  }
  let open_action = PdfDictionary([
    (pdf_metadata_d(), destination),
    (pdf_metadata_s(), PdfNameObject(pdf_metadata_goto())),
  ])
  let document = self.copy()
  document.pdf_metadata_update_catalog(fn(catalog) raise PdfError {
    catalog.add_dict_entry(pdf_metadata_open_action(), open_action)
  })
  document
}

///|
/// Return a catalog item in cpdf's text-reporting form.
pub fn PdfDocument::catalog_item(self : PdfDocument, key : PdfName) -> String {
  try self.catalog() catch {
    _ => ""
  } noraise {
    catalog =>
      match self.lookup_direct(key, catalog) {
        Some(PdfNameObject(name)) =>
          pdf_metadata_name_string(name) catch {
            _ => ""
          }
        Some(PdfDictionary(_)) => "True"
        _ => ""
      }
  }
}

///|
/// Return a viewer-preference item in cpdf's text-reporting form.
pub fn PdfDocument::viewer_preference_item(
  self : PdfDocument,
  key : PdfName,
) -> String {
  try self.catalog() catch {
    _ => ""
  } noraise {
    catalog =>
      match self.lookup_direct(pdf_metadata_viewer_preferences(), catalog) {
        Some(preferences) =>
          match self.lookup_direct(key, preferences) {
            Some(PdfNameObject(name)) =>
              pdf_metadata_name_string(name) catch {
                _ => ""
              }
            Some(PdfBoolean(value)) => if value { "true" } else { "false" }
            _ => ""
          }
        None => ""
      }
  }
}

///|
/// Return a `/MarkInfo` boolean item, or false when absent/malformed.
pub fn PdfDocument::mark_info_item(self : PdfDocument, key : PdfName) -> Bool {
  try self.catalog() catch {
    _ => false
  } noraise {
    catalog =>
      match self.lookup_direct(pdf_metadata_mark_info(), catalog) {
        Some(mark_info) =>
          match self.lookup_direct(key, mark_info) {
            Some(PdfBoolean(value)) => value
            _ => false
          }
        None => false
      }
  }
}

///|
/// Return true when the catalog contains an AcroForm XFA entry.
pub fn PdfDocument::is_xfa(self : PdfDocument) -> Bool {
  self.lookup_chain(self.trailer_dict(), [
    pdf_metadata_root(),
    pdf_metadata_acroform(),
    pdf_metadata_xfa(),
  ]) !=
  None
}

///|
fn PdfDocument::pdf_metadata_page_number_for_reference(
  self : PdfDocument,
  reference : Int,
) -> Int? raise PdfError {
  let page_numbers = self.page_reference_numbers()
  for index in 0.. PdfObject raise PdfError {
  match action {
    PdfArray([PdfIndirect(reference), .. rest]) =>
      match self.pdf_metadata_page_number_for_reference(reference) {
        Some(page_number) => PdfArray([PdfInteger(page_number), ..rest])
        None => action
      }
    _ => action
  }
}

///|
/// Return the catalog `/OpenAction` in cpdf's reporting form.
///
/// Destination arrays beginning with a page indirect reference are rewritten
/// to one-based page numbers. For GoTo action dictionaries with `/D`, the
/// destination is returned; other action objects are returned unchanged.
pub fn PdfDocument::open_action(self : PdfDocument) -> PdfObject raise PdfError {
  match
    self.lookup_chain(self.trailer_dict(), [
      pdf_metadata_root(),
      pdf_metadata_open_action(),
    ]) {
    Some(PdfArray(_) as action) =>
      self.pdf_metadata_rewrite_open_action_page(action)
    Some(PdfDictionary(_) as action) =>
      match self.lookup_direct(pdf_metadata_d(), action) {
        Some(PdfArray(_) as destination) =>
          self.pdf_metadata_rewrite_open_action_page(destination)
        _ => action
      }
    Some(action) => action
    None => PdfNull
  }
}

///|
/// Return the catalog `/OpenAction` as cpdf-style JSON bytes.
pub fn PdfDocument::open_action_json_blob(
  self : PdfDocument,
) -> PdfBytes raise PdfError {
  @utf8.encode(
    self
    .pdf_util_json_of_object(
      self.open_action(),
      true,
      false,
      false,
      false,
      false,
    )
    .stringify(),
  )
}

///|
/// Return the catalog `/OpenAction` as cpdf-style PDF syntax.
pub fn PdfDocument::open_action_string(
  self : PdfDocument,
) -> String raise PdfError {
  match self.open_action() {
    PdfNull => ""
    action =>
      @ascii.decode(pdf_write_object(action)) catch {
        _ => raise InvalidUTF8
      }
  }
}

///|
fn pdf_metadata_is_xml_separator(byte : Byte) -> Bool {
  match byte.to_int() {
    9 | 10 | 13 | 32 | 60 => true
    _ => false
  }
}

///|
fn pdf_metadata_is_xml_space(byte : Byte) -> Bool {
  match byte.to_int() {
    9 | 10 | 13 | 32 => true
    _ => false
  }
}

///|
fn pdf_metadata_trim_xml_text(value : BytesView) -> BytesView {
  let mut start = 0
  let mut end = value.length()
  while start < end && pdf_metadata_is_xml_space(value[start]) {
    start += 1
  }
  while end > start && pdf_metadata_is_xml_space(value[end - 1]) {
    end -= 1
  }
  value[start:end]
}

///|
fn pdf_metadata_decode_xml_text(value : BytesView) -> String? {
  let text = @utf8.decode(pdf_metadata_trim_xml_text(value)) catch {
    _ => return None
  }
  Some(text)
}

///|
fn pdf_metadata_find_byte(data : BytesView, start : Int, byte : Int) -> Int {
  for position = start; position < data.length(); position = position + 1 {
    if data[position].to_int() == byte {
      break position
    }
  } nobreak {
    -1
  }
}

///|
fn pdf_metadata_view_matches_at(
  data : BytesView,
  position : Int,
  pattern : BytesView,
) -> Bool {
  if position < 0 || position + pattern.length() > data.length() {
    false
  } else {
    for index = 0; index < pattern.length(); index = index + 1 {
      if data[position + index] != pattern[index] {
        break false
      }
    } nobreak {
      true
    }
  }
}

///|
fn pdf_metadata_find_bytes_from(
  data : BytesView,
  start : Int,
  pattern : BytesView,
) -> Int {
  if pattern.length() == 0 {
    start
  } else if data.length() < pattern.length() ||
    start > data.length() - pattern.length() {
    -1
  } else {
    for position = start
        position <= data.length() - pattern.length()
        position = position + 1 {
      if pdf_metadata_view_matches_at(data, position, pattern) {
        break position
      }
    } nobreak {
      -1
    }
  }
}

///|
fn pdf_metadata_add_unique(values : Array[String], value : String) -> Unit {
  if value != "" && !values.contains(value) {
    values.push(value)
  }
}

///|
fn pdf_metadata_namespace_prefixes(
  data : BytesView,
  xml_namespace : String,
  fallback : String,
) -> Array[String] {
  let prefixes : Array[String] = []
  pdf_metadata_add_unique(prefixes, fallback)
  let namespace_bytes = @ascii.encode(xml_namespace)
  let xmlns = [120, 109, 108, 110, 115, 58]
  let mut search_start = 0
  while search_start < data.length() {
    let namespace_start = pdf_metadata_find_bytes_from(
      data, search_start, namespace_bytes,
    )
    if namespace_start < 0 {
      search_start = data.length()
    } else {
      let mut attribute_start = namespace_start
      while attribute_start > 0 &&
            !pdf_metadata_is_xml_separator(data[attribute_start - 1]) {
        attribute_start -= 1
      }
      if pdf_view_matches_ascii_at(data, attribute_start, xmlns) {
        let prefix_start = attribute_start + xmlns.length()
        let mut prefix_end = prefix_start
        while prefix_end < namespace_start &&
              data[prefix_end].to_int() != 61 &&
              data[prefix_end].to_int() != 34 &&
              data[prefix_end].to_int() != 39 {
          prefix_end += 1
        }
        match pdf_metadata_decode_xml_text(data[prefix_start:prefix_end]) {
          Some(prefix) => pdf_metadata_add_unique(prefixes, prefix)
          None => ()
        }
      }
      search_start = namespace_start + namespace_bytes.length()
    }
  }
  prefixes
}

///|
fn pdf_metadata_extract_quoted_xml_value(
  data : BytesView,
  start : Int,
  quote : Int,
) -> String? {
  let end = pdf_metadata_find_byte(data, start, quote)
  if end < 0 {
    None
  } else {
    pdf_metadata_decode_xml_text(data[start:end])
  }
}

///|
fn pdf_metadata_extract_xmp_attribute(
  data : BytesView,
  prefix : String,
  name : String,
) -> String? {
  let double_pattern = @ascii.encode(prefix + ":" + name + "=\"")
  let double_start = pdf_metadata_find_bytes_from(data, 0, double_pattern)
  if double_start >= 0 {
    pdf_metadata_extract_quoted_xml_value(
      data,
      double_start + double_pattern.length(),
      34,
    )
  } else {
    let single_pattern = @ascii.encode(prefix + ":" + name + "='")
    let single_start = pdf_metadata_find_bytes_from(data, 0, single_pattern)
    if single_start >= 0 {
      pdf_metadata_extract_quoted_xml_value(
        data,
        single_start + single_pattern.length(),
        39,
      )
    } else {
      None
    }
  }
}

///|
fn pdf_metadata_combine_list_items(values : ArrayView[String]) -> String {
  let mut output = ""
  for value in values {
    if output == "" {
      output = value
    } else {
      output = output + ", " + value
    }
  }
  output
}

///|
fn pdf_metadata_extract_rdf_list_items_for_prefix(
  value : BytesView,
  prefix : String,
) -> Array[String] {
  let items : Array[String] = []
  let open_pattern = @ascii.encode("<" + prefix + ":li")
  let close_pattern = @ascii.encode("")
  let mut search_start = 0
  while search_start < value.length() {
    let item_start = pdf_metadata_find_bytes_from(
      value, search_start, open_pattern,
    )
    if item_start < 0 {
      search_start = value.length()
    } else {
      let name_end = item_start + open_pattern.length()
      if name_end >= value.length() ||
        !pdf_metadata_xml_tag_boundary(value[name_end]) {
        search_start = name_end
      } else {
        let item_tag_end = pdf_metadata_find_byte(value, name_end, 62)
        if item_tag_end < 0 {
          search_start = value.length()
        } else if value[item_tag_end - 1].to_int() == 47 {
          search_start = item_tag_end + 1
        } else {
          let item_value_start = item_tag_end + 1
          let item_value_end = pdf_metadata_find_bytes_from(
            value, item_value_start, close_pattern,
          )
          if item_value_end < 0 {
            search_start = value.length()
          } else {
            match
              pdf_metadata_decode_xml_text(
                value[item_value_start:item_value_end],
              ) {
              Some(item) => items.push(item)
              None => ()
            }
            search_start = item_value_end + close_pattern.length()
          }
        }
      }
    }
  }
  items
}

///|
fn pdf_metadata_extract_rdf_list_items(
  data : BytesView,
  value : BytesView,
) -> String? {
  let items : Array[String] = []
  let prefixes = pdf_metadata_namespace_prefixes(
    data, pdf_metadata_rdf_namespace, "rdf",
  )
  for prefix in prefixes {
    for item in pdf_metadata_extract_rdf_list_items_for_prefix(value, prefix) {
      items.push(item)
    }
  }
  if items.length() == 0 {
    None
  } else {
    Some(pdf_metadata_combine_list_items(items))
  }
}

///|
fn pdf_metadata_extract_xmp_element(
  data : BytesView,
  prefix : String,
  name : String,
) -> String? {
  let open_pattern = @ascii.encode("<" + prefix + ":" + name)
  let tag_start = pdf_metadata_find_bytes_from(data, 0, open_pattern)
  if tag_start < 0 {
    None
  } else {
    let tag_end = pdf_metadata_find_byte(
      data,
      tag_start + open_pattern.length(),
      62,
    )
    if tag_end < 0 || data[tag_end - 1].to_int() == 47 {
      None
    } else {
      let close_pattern = @ascii.encode("")
      let value_start = tag_end + 1
      let value_end = pdf_metadata_find_bytes_from(
        data, value_start, close_pattern,
      )
      if value_end < 0 {
        None
      } else {
        let value = data[value_start:value_end]
        match pdf_metadata_extract_rdf_list_items(data, value) {
          Some(items) => Some(items)
          None => pdf_metadata_decode_xml_text(value)
        }
      }
    }
  }
}

///|
fn pdf_metadata_get_xmp_data(
  data : BytesView,
  xml_namespace : String,
  fallback_prefix : String,
  name : String,
) -> String? {
  let prefixes = pdf_metadata_namespace_prefixes(
    data, xml_namespace, fallback_prefix,
  )
  for prefix in prefixes {
    match pdf_metadata_extract_xmp_attribute(data, prefix, name) {
      Some(value) => break Some(value)
      None =>
        match pdf_metadata_extract_xmp_element(data, prefix, name) {
          Some(value) => break Some(value)
          None => ()
        }
    }
  } nobreak {
    None
  }
}

///|
fn pdf_metadata_decode_pdf_string(value : BytesView) -> String? {
  let text = @utf8.decode(value) catch { _ => return None }
  Some(text)
}

///|
fn pdf_metadata_json_string_array(values : ArrayView[String]) -> Json {
  let items : Array[Json] = []
  for value in values {
    items.push(Json::string(value))
  }
  Json::array(items)
}

///|
fn pdf_metadata_unit_number(value : Double, unit : PdfUnit) -> Double {
  match unit {
    PdfPoint => value
    Inch => unit_inches(value, PdfPoint)
    Centimetre => unit_centimetres(value, PdfPoint)
    Millimetre => unit_millimetres(value, PdfPoint)
  }
}

///|
fn pdf_metadata_string_json(value : String) -> Json {
  if value == "" {
    Json::null()
  } else {
    Json::string(value)
  }
}

///|
fn pdf_metadata_bool_json(value : String) -> Json {
  if value == "" {
    Json::null()
  } else {
    Json::boolean(value.to_lower() == "true")
  }
}

///|
fn PdfDocument::pdf_metadata_info_encoded(
  self : PdfDocument,
  key : PdfName,
  encoding : PdfMetadataEncoding,
) -> String {
  let info = match
    self.lookup_direct(pdf_metadata_info(), self.trailer_dict()) {
    Some(dictionary) => dictionary
    None => PdfDictionary([])
  }
  match self.lookup_direct(key, info) {
    Some(PdfString(bytes)) =>
      @utf8.decode_lossy(
        pdf_metadata_encode_output(encoding, bytes) catch {
          _ => return ""
        },
      )
    Some(PdfBoolean(value)) => pdf_metadata_bool_string(value)
    _ => if key == pdf_metadata_trapped() { "False" } else { "" }
  }
}

///|
fn PdfDocument::pdf_metadata_box_array_json(
  self : PdfDocument,
  box : PdfObject,
  unit : PdfUnit,
) -> Json? {
  match self.direct(box) {
    PdfArray(values) if values.length() == 4 => {
      let output : Array[Json] = []
      for value in values {
        let number = self.get_number(value) catch { _ => return None }
        output.push(Json::number(pdf_metadata_unit_number(number, unit)))
      }
      Some(Json::array(output))
    }
    _ => None
  }
}

///|
fn PdfDocument::pdf_metadata_page_box_json(
  self : PdfDocument,
  boxes : ArrayView[PdfObject?],
  unit : PdfUnit,
) -> Json {
  if boxes.length() == 0 {
    return Json::null()
  }
  let first = boxes[0]
  for index in 1..
      match self.pdf_metadata_box_array_json(box, unit) {
        Some(json) => json
        None => Json::null()
      }
    None => Json::null()
  }
}

///|
fn pdf_metadata_set_json_string(
  output : Map[String, Json],
  key : String,
  value : String,
) -> Unit {
  output.set(key, pdf_metadata_string_json(value))
}

///|
fn PdfDocument::pdf_metadata_info_json_boxes(
  self : PdfDocument,
  pages : ArrayView[PdfPage],
  unit : PdfUnit,
  output : Map[String, Json],
) -> Unit {
  let mediaboxes : Array[PdfObject?] = []
  let cropboxes : Array[PdfObject?] = []
  let bleedboxes : Array[PdfObject?] = []
  let trimboxes : Array[PdfObject?] = []
  let artboxes : Array[PdfObject?] = []
  for page in pages {
    mediaboxes.push(Some(page.mediabox))
    cropboxes.push(self.lookup_direct(pdf_metadata_cropbox(), page.rest))
    bleedboxes.push(self.lookup_direct(pdf_metadata_bleedbox(), page.rest))
    trimboxes.push(self.lookup_direct(pdf_metadata_trimbox(), page.rest))
    artboxes.push(self.lookup_direct(pdf_metadata_artbox(), page.rest))
  }
  output.set("MediaBox", self.pdf_metadata_page_box_json(mediaboxes, unit))
  output.set("CropBox", self.pdf_metadata_page_box_json(cropboxes, unit))
  output.set("BleedBox", self.pdf_metadata_page_box_json(bleedboxes, unit))
  output.set("TrimBox", self.pdf_metadata_page_box_json(trimboxes, unit))
  output.set("ArtBox", self.pdf_metadata_page_box_json(artboxes, unit))
}

///|
let pdf_metadata_xmp_report_fields : Array[(String, String, String)] = [
  ("XMP pdf:Keywords", pdf_metadata_adobe_namespace, "Keywords"),
  ("XMP pdf:Producer", pdf_metadata_adobe_namespace, "Producer"),
  ("XMP pdf:Trapped", pdf_metadata_adobe_namespace, "Trapped"),
  ("XMP pdf:Title", pdf_metadata_adobe_namespace, "Title"),
  ("XMP pdf:Creator", pdf_metadata_adobe_namespace, "Creator"),
  ("XMP pdf:Subject", pdf_metadata_adobe_namespace, "Subject"),
  ("XMP pdf:Author", pdf_metadata_adobe_namespace, "Author"),
  ("XMP pdf:CreationDate", pdf_metadata_adobe_namespace, "CreationDate"),
  ("XMP pdf:ModDate", pdf_metadata_adobe_namespace, "ModDate"),
  ("XMP xmp:CreateDate", pdf_metadata_xmp_namespace, "CreateDate"),
  ("XMP xmp:CreatorTool", pdf_metadata_xmp_namespace, "CreatorTool"),
  ("XMP xmp:MetadataDate", pdf_metadata_xmp_namespace, "MetadataDate"),
  ("XMP xmp:ModifyDate", pdf_metadata_xmp_namespace, "ModifyDate"),
  ("XMP dc:title", pdf_metadata_dc_namespace, "title"),
  ("XMP dc:creator", pdf_metadata_dc_namespace, "creator"),
  ("XMP dc:subject", pdf_metadata_dc_namespace, "subject"),
  ("XMP dc:description", pdf_metadata_dc_namespace, "description"),
  ("PDF/UA pdfuaid:amd", pdf_metadata_pdfuaid_namespace, "amd"),
  ("PDF/UA pdfuaid:corr", pdf_metadata_pdfuaid_namespace, "corr"),
]

///|
fn pdf_metadata_add_xmp_report_field(
  output : Map[String, Json],
  metadata : BytesView,
  field : (String, String, String),
) -> Unit {
  match
    pdf_metadata_get_xmp_data(
      metadata,
      field.1,
      pdf_metadata_namespace_fallback_prefix(field.1),
      field.2,
    ) {
    Some(value) => output.set(field.0, Json::string(value))
    None => ()
  }
}

///|
fn pdf_metadata_push_view(output : Array[Byte], bytes : BytesView) -> Unit {
  for byte in bytes {
    output.push(byte)
  }
}

///|
fn pdf_metadata_xml_text(value : String) -> PdfBytes {
  @utf8.encode(
    value
    .replace(old="&", new="&")
    .replace(old="<", new="<")
    .replace(old=">", new=">"),
  )
}

///|
fn pdf_metadata_xml_tag_boundary(byte : Byte) -> Bool {
  match byte.to_int() {
    9 | 10 | 13 | 32 | 47 | 62 => true
    _ => false
  }
}

///|
fn pdf_metadata_rewrite_xmp_element_prefix(
  data : BytesView,
  prefix : String,
  field : String,
  replacement : String,
) -> (PdfBytes, Bool) {
  let open_pattern = @ascii.encode("<" + prefix + ":" + field)
  let close_pattern = @ascii.encode("")
  let replacement_bytes = pdf_metadata_xml_text(replacement)
  let output : Array[Byte] = []
  let mut changed = false
  let mut search_start = 0
  while search_start < data.length() {
    let tag_start = pdf_metadata_find_bytes_from(
      data, search_start, open_pattern,
    )
    if tag_start < 0 {
      pdf_metadata_push_view(output, data[search_start:])
      search_start = data.length()
    } else {
      let name_end = tag_start + open_pattern.length()
      if name_end >= data.length() ||
        !pdf_metadata_xml_tag_boundary(data[name_end]) {
        pdf_metadata_push_view(output, data[search_start:name_end])
        search_start = name_end
      } else {
        let tag_end = pdf_metadata_find_byte(data, name_end, 62)
        if tag_end < 0 {
          pdf_metadata_push_view(output, data[search_start:])
          search_start = data.length()
        } else if tag_end > tag_start && data[tag_end - 1].to_int() == 47 {
          pdf_metadata_push_view(output, data[search_start:tag_end - 1])
          output.push((62).to_byte())
          pdf_metadata_push_view(output, replacement_bytes)
          pdf_metadata_push_view(output, close_pattern)
          search_start = tag_end + 1
          changed = true
        } else {
          let value_start = tag_end + 1
          let value_end = pdf_metadata_find_bytes_from(
            data, value_start, close_pattern,
          )
          if value_end < 0 {
            pdf_metadata_push_view(output, data[search_start:])
            search_start = data.length()
          } else {
            pdf_metadata_push_view(output, data[search_start:value_start])
            pdf_metadata_push_view(output, replacement_bytes)
            pdf_metadata_push_view(
              output,
              data[value_end:value_end + close_pattern.length()],
            )
            search_start = value_end + close_pattern.length()
            changed = true
          }
        }
      }
    }
  }
  if changed {
    (Bytes::from_array(output), true)
  } else {
    (data.to_owned(), false)
  }
}

///|
fn pdf_metadata_rewrite_xmp_field(
  data : BytesView,
  xml_namespace : String,
  fallback_prefix : String,
  field : String,
  replacement : String,
) -> PdfBytes {
  let mut output = data.to_owned()
  let prefixes = pdf_metadata_namespace_prefixes(
    data, xml_namespace, fallback_prefix,
  )
  for prefix in prefixes {
    let (rewritten, _) = pdf_metadata_rewrite_xmp_element_prefix(
      output, prefix, field, replacement,
    )
    output = rewritten
  }
  output
}

///|
fn pdf_metadata_pad2(value : Int) -> String {
  let absolute = if value < 0 { -value } else { value }
  if absolute < 10 {
    "0" + absolute.to_string()
  } else {
    absolute.to_string()
  }
}

///|
fn pdf_metadata_pad4(value : Int) -> String {
  if value < 10 {
    "000" + value.to_string()
  } else if value < 100 {
    "00" + value.to_string()
  } else if value < 1000 {
    "0" + value.to_string()
  } else {
    value.to_string()
  }
}

///|
fn pdf_metadata_xmp_date(date : String) -> String raise PdfError {
  if !date.has_prefix("D:") {
    "0000-01-01T00:00:00Z"
  } else {
    let parsed = pdf_date_of_string(date)
    let tzd = if parsed.hour_offset == 0 && parsed.minute_offset == 0 {
      "Z"
    } else {
      let sign = if parsed.hour_offset < 0 || parsed.minute_offset < 0 {
        "-"
      } else {
        "+"
      }
      sign +
      pdf_metadata_pad2(parsed.hour_offset) +
      ":" +
      pdf_metadata_pad2(parsed.minute_offset)
    }
    pdf_metadata_pad4(parsed.year) +
    "-" +
    pdf_metadata_pad2(parsed.month) +
    "-" +
    pdf_metadata_pad2(parsed.day) +
    "T" +
    pdf_metadata_pad2(parsed.hour) +
    ":" +
    pdf_metadata_pad2(parsed.minute) +
    ":" +
    pdf_metadata_pad2(parsed.second) +
    tzd
  }
}

///|
fn pdf_metadata_is_leap_year(year : Int) -> Bool {
  (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}

///|
fn pdf_metadata_days_in_month(year : Int, month : Int) -> Int {
  match month {
    1 | 3 | 5 | 7 | 8 | 10 | 12 => 31
    4 | 6 | 9 | 11 => 30
    2 => if pdf_metadata_is_leap_year(year) { 29 } else { 28 }
    _ => 30
  }
}

///|
fn pdf_metadata_pdf_date_from_unix_millis(milliseconds : UInt64) -> String {
  let total_seconds = (milliseconds / (1000 : UInt64)).reinterpret_as_int64()
  let mut days = (total_seconds / 86400L).to_int()
  let seconds_of_day = (total_seconds % 86400L).to_int()
  let hour = seconds_of_day / 3600
  let minute = seconds_of_day % 3600 / 60
  let second = seconds_of_day % 60
  let mut year = 1970
  let mut year_days = if pdf_metadata_is_leap_year(year) { 366 } else { 365 }
  while days >= year_days {
    days -= year_days
    year += 1
    year_days = if pdf_metadata_is_leap_year(year) { 366 } else { 365 }
  }
  let mut month = 1
  while days >= pdf_metadata_days_in_month(year, month) {
    days -= pdf_metadata_days_in_month(year, month)
    month += 1
  }
  let day = days + 1
  "D:" +
  pdf_metadata_pad4(year) +
  pdf_metadata_pad2(month) +
  pdf_metadata_pad2(day) +
  pdf_metadata_pad2(hour) +
  pdf_metadata_pad2(minute) +
  pdf_metadata_pad2(second)
}

///|
fn pdf_metadata_expand_date(date : String) -> String {
  if date == "now" {
    match @env.get_env_var("CPDF_REPRODUCIBLE_DATES") {
      Some("true") =>
        pdf_strftime_with_time(pdf_strftime_dummy_time(), "D:%Y%m%d%H%M%S")
      _ => pdf_metadata_pdf_date_from_unix_millis(@env.now())
    }
  } else {
    date
  }
}

///|
fn pdf_metadata_replace_all(
  text : String,
  old : String,
  new : String,
) -> String {
  let mut output = text
  if old == "" {
    output
  } else {
    while output.contains(old) {
      output = output.replace(old~, new~)
    }
    output
  }
}

///|
fn PdfDocument::pdf_metadata_template_replacements(
  self : PdfDocument,
) -> Array[(String, String)] raise PdfError {
  let info = fn(name : String) -> String {
    self.info_utf8(pdf_metadata_name(name))
  }
  let creation_date = match info("/CreationDate") {
    "" => pdf_metadata_expand_date("now")
    value => value
  }
  let mod_date = match info("/ModDate") {
    "" => pdf_metadata_expand_date("now")
    value => value
  }
  [
    ("CREATEDATE", pdf_metadata_xmp_date(creation_date)),
    ("MODDATE", pdf_metadata_xmp_date(mod_date)),
    ("PRODUCER", info("/Producer")),
    ("CREATOR", info("/Creator")),
    ("TITLE", info("/Title")),
    ("SUBJECT", info("/Subject")),
    ("AUTHOR", info("/Author")),
    ("KEYWORDS", info("/Keywords")),
    ("TRAPPED", info("/Trapped")),
    ("METADATADATE", pdf_metadata_xmp_date(pdf_metadata_expand_date("now"))),
  ]
}

///|
fn pdf_metadata_xml_value_of_object(value : PdfObject) -> String raise PdfError {
  match value {
    PdfString(bytes) =>
      @utf8.decode(pdf_utf8_of_pdfdocstring(bytes)) catch {
        _ => raise InvalidUTF8
      }
    PdfBoolean(true) => "True"
    PdfBoolean(false) => "False"
    _ => raise BadNumberArgument("set_pdf_info_xml: not a string")
  }
}

///|
fn pdf_metadata_xmp_date_of_object(value : PdfObject) -> String raise PdfError {
  match value {
    PdfString(bytes) =>
      pdf_metadata_xmp_date(
        @ascii.decode(bytes) catch {
          _ => raise InvalidUTF8
        },
      )
    _ => raise BadNumberArgument("xmp_date not a string")
  }
}

///|
fn pdf_metadata_info_xmp_changes(
  key : PdfName,
  value : PdfObject,
) -> (Array[(String, String)], String) raise PdfError {
  match pdf_metadata_name_with_slash(key) {
    Some("/Producer") =>
      (
        [(pdf_metadata_adobe_namespace, "Producer")],
        pdf_metadata_xml_value_of_object(value),
      )
    Some("/Creator") =>
      (
        [
          (pdf_metadata_adobe_namespace, "Creator"),
          (pdf_metadata_xmp_namespace, "CreatorTool"),
          (pdf_metadata_dc_namespace, "creator"),
        ],
        pdf_metadata_xml_value_of_object(value),
      )
    Some("/Author") =>
      (
        [(pdf_metadata_adobe_namespace, "Author")],
        pdf_metadata_xml_value_of_object(value),
      )
    Some("/Title") =>
      (
        [
          (pdf_metadata_adobe_namespace, "Title"),
          (pdf_metadata_dc_namespace, "title"),
        ],
        pdf_metadata_xml_value_of_object(value),
      )
    Some("/Subject") =>
      (
        [
          (pdf_metadata_adobe_namespace, "Subject"),
          (pdf_metadata_dc_namespace, "subject"),
        ],
        pdf_metadata_xml_value_of_object(value),
      )
    Some("/Keywords") =>
      (
        [(pdf_metadata_adobe_namespace, "Keywords")],
        pdf_metadata_xml_value_of_object(value),
      )
    Some("/CreationDate") =>
      (
        [
          (pdf_metadata_adobe_namespace, "CreationDate"),
          (pdf_metadata_xmp_namespace, "CreateDate"),
        ],
        pdf_metadata_xmp_date_of_object(value),
      )
    Some("/ModDate") =>
      (
        [
          (pdf_metadata_adobe_namespace, "ModDate"),
          (pdf_metadata_xmp_namespace, "ModifyDate"),
        ],
        pdf_metadata_xmp_date_of_object(value),
      )
    Some("/Trapped") =>
      (
        [(pdf_metadata_adobe_namespace, "Trapped")],
        pdf_metadata_xml_value_of_object(value),
      )
    _ => raise BadNumberArgument("Unknown call to set_pdf_info")
  }
}

///|
fn PdfDocument::pdf_metadata_fallback_pdfx(self : PdfDocument) -> String? {
  match self.lookup_direct(pdf_metadata_info(), self.trailer_dict()) {
    Some(info) =>
      match self.lookup_direct(pdf_metadata_name("/GTS_PDFXVersion"), info) {
        Some(PdfString(value)) => pdf_metadata_decode_pdf_string(value)
        _ => None
      }
    None => None
  }
}

///|
fn pdf_metadata_reverse_strings(values : ArrayView[String]) -> Array[String] {
  let output : Array[String] = []
  for index = values.length() - 1; index >= 0; index = index - 1 {
    output.push(values[index])
  }
  output
}

///|
fn pdf_metadata_push_if_some(values : Array[String], value : String?) -> Unit {
  match value {
    Some(value) if value != "" => values.push(value)
    _ => ()
  }
}

///|
fn pdf_metadata_namespace_fallback_prefix(xml_namespace : String) -> String {
  if xml_namespace == pdf_metadata_adobe_namespace {
    "pdf"
  } else if xml_namespace == pdf_metadata_xmp_namespace {
    "xmp"
  } else if xml_namespace == pdf_metadata_dc_namespace {
    "dc"
  } else if xml_namespace == pdf_metadata_pdfuaid_namespace {
    "pdfuaid"
  } else if xml_namespace == pdf_metadata_pdfe_namespace {
    "pdfe"
  } else if xml_namespace == pdf_metadata_pdfaid_namespace {
    "pdfaid"
  } else if xml_namespace == pdf_metadata_pdfxid_namespace {
    "pdfxid"
  } else if xml_namespace == pdf_metadata_pdfvtid_namespace {
    "pdfvtid"
  } else {
    ""
  }
}

///|
fn pdf_metadata_name_with_slash(name : PdfName) -> String? {
  let text = @ascii.decode(name.bytes()) catch { _ => return None }
  Some(text)
}

///|
fn pdf_metadata_xmp_info_fields(key : PdfName) -> Array[(String, String)]? {
  match pdf_metadata_name_with_slash(key) {
    Some("/Title") =>
      Some([
        (pdf_metadata_adobe_namespace, "Title"),
        (pdf_metadata_dc_namespace, "title"),
      ])
    Some("/Author") =>
      Some([
        (pdf_metadata_adobe_namespace, "Author"),
        (pdf_metadata_dc_namespace, "creator"),
      ])
    Some("/Subject") =>
      Some([
        (pdf_metadata_adobe_namespace, "Subject"),
        (pdf_metadata_dc_namespace, "subject"),
      ])
    Some("/Keywords") => Some([(pdf_metadata_adobe_namespace, "Keywords")])
    Some("/Creator") =>
      Some([
        (pdf_metadata_adobe_namespace, "Creator"),
        (pdf_metadata_xmp_namespace, "CreatorTool"),
      ])
    Some("/Producer") => Some([(pdf_metadata_adobe_namespace, "Producer")])
    Some("/CreationDate") =>
      Some([
        (pdf_metadata_adobe_namespace, "CreationDate"),
        (pdf_metadata_xmp_namespace, "CreateDate"),
      ])
    Some("/ModDate") =>
      Some([
        (pdf_metadata_adobe_namespace, "ModificationDate"),
        (pdf_metadata_xmp_namespace, "ModifyDate"),
      ])
    _ => None
  }
}

///|
/// Return XMP metadata corresponding to a legacy `/Info` key.
pub fn PdfDocument::xmp_info(self : PdfDocument, key : PdfName) -> String {
  let fields = match pdf_metadata_xmp_info_fields(key) {
    Some(fields) => fields
    None => return ""
  }
  let metadata = try self.metadata() catch {
    _ => return ""
  } noraise {
    Some(metadata) => metadata
    None => return ""
  }
  for field in fields {
    let value = pdf_metadata_get_xmp_data(
      metadata,
      field.0,
      pdf_metadata_namespace_fallback_prefix(field.0),
      field.1,
    )
    match value {
      Some(value) if value != "" => break value
      _ => ()
    }
  } nobreak {
    ""
  }
}

///|
/// Determine PDF/A, PDF/E, PDF/UA, PDF/VT, and PDF/X subformat markers.
///
/// This ports cpdf's in-memory subformat detection. It recognizes simple XMP
/// element and attribute forms and falls back to `/Info /GTS_PDFXVersion`.
pub fn PdfDocument::determine_subformats(
  self : PdfDocument,
) -> Array[String] raise PdfError {
  let formats : Array[String] = []
  match self.metadata() {
    None =>
      pdf_metadata_push_if_some(formats, self.pdf_metadata_fallback_pdfx())
    Some(metadata) => {
      pdf_metadata_push_if_some(
        formats,
        pdf_metadata_get_xmp_data(
          metadata, pdf_metadata_pdfe_namespace, "pdfe", "ISO_PDFEVersion",
        ),
      )
      match
        pdf_metadata_get_xmp_data(
          metadata, pdf_metadata_pdfuaid_namespace, "pdfuaid", "part",
        ) {
        Some(part) if part != "" => formats.push("PDF/UA-" + part)
        _ => ()
      }
      let pdfa_part = match
        pdf_metadata_get_xmp_data(
          metadata, pdf_metadata_pdfaid_namespace, "pdfaid", "part",
        ) {
        Some(part) => Some(part)
        None =>
          match
            pdf_metadata_get_xmp_data(
              metadata, pdf_metadata_pdfaid_wrong_acrobat_namespace, "pdfaid", "part",
            ) {
            Some(part) => Some(part)
            None =>
              pdf_metadata_get_xmp_data(
                metadata, pdf_metadata_pdfaid_wrong_iso_namespace, "pdfaid", "part",
              )
          }
      }
      match pdfa_part {
        Some(part) if part != "" => {
          let conformance = match
            pdf_metadata_get_xmp_data(
              metadata, pdf_metadata_pdfaid_namespace, "pdfaid", "conformance",
            ) {
            Some(value) => value.to_lower()
            None =>
              match
                pdf_metadata_get_xmp_data(
                  metadata, pdf_metadata_pdfaid_wrong_acrobat_namespace, "pdfaid",
                  "conformance",
                ) {
                Some(value) => value.to_lower()
                None =>
                  match
                    pdf_metadata_get_xmp_data(
                      metadata, pdf_metadata_pdfaid_wrong_iso_namespace, "pdfaid",
                      "conformance",
                    ) {
                    Some(value) => value.to_lower()
                    None => ""
                  }
              }
          }
          formats.push("PDF/A-" + part + conformance)
        }
        _ => ()
      }
      match
        pdf_metadata_get_xmp_data(
          metadata, pdf_metadata_pdfxid_namespace, "pdfxid", "GTS_PDFXVersion",
        ) {
        Some(value) if value != "" => formats.push(value)
        _ =>
          pdf_metadata_push_if_some(formats, self.pdf_metadata_fallback_pdfx())
      }
      pdf_metadata_push_if_some(
        formats,
        pdf_metadata_get_xmp_data(
          metadata, pdf_metadata_pdfvtid_namespace, "pdfvtid", "GTS_PDFVTVersion",
        ),
      )
    }
  }
  pdf_metadata_reverse_strings(formats)
}

///|
/// Return cpdf-style document information report fields as JSON.
pub fn PdfDocument::info_json(
  self : PdfDocument,
  encoding? : PdfMetadataEncoding = PdfMetadataUTF8,
  unit? : PdfUnit = PdfPoint,
) -> Json raise PdfError {
  let output : Map[String, Json] = Map([])
  let (major, minor) = self.version()
  let pages = self.pages_of_pagetree()
  output.set(
    "Version",
    Json::array([pdf_util_json_number(major), pdf_util_json_number(minor)]),
  )
  output.set("Pages", pdf_util_json_number(pages.length()))
  let info_fields : Array[(String, PdfName)] = [
    ("Title", pdf_metadata_name("/Title")),
    ("Author", pdf_metadata_name("/Author")),
    ("Subject", pdf_metadata_name("/Subject")),
    ("Keywords", pdf_metadata_name("/Keywords")),
    ("Creator", pdf_metadata_name("/Creator")),
    ("Producer", pdf_metadata_name("/Producer")),
    ("Created", pdf_metadata_name("/CreationDate")),
    ("Modified", pdf_metadata_name("/ModDate")),
  ]
  for field in info_fields {
    pdf_metadata_set_json_string(
      output,
      field.0,
      self.pdf_metadata_info_encoded(field.1, encoding),
    )
  }
  output.set(
    "Trapped",
    pdf_metadata_bool_json(
      self.pdf_metadata_info_encoded(pdf_metadata_trapped(), encoding),
    ),
  )
  pdf_metadata_set_json_string(
    output,
    "PageMode",
    self.catalog_item(pdf_metadata_page_mode()),
  )
  pdf_metadata_set_json_string(
    output,
    "PageLayout",
    self.catalog_item(pdf_metadata_page_layout()),
  )
  output.set(
    "OpenAction",
    self.pdf_util_json_of_object(
      self.open_action(),
      true,
      false,
      false,
      false,
      false,
    ),
  )
  let viewer_bool_fields : Array[(String, PdfName)] = [
    ("HideToolbar", pdf_metadata_name("/HideToolbar")),
    ("HideMenubar", pdf_metadata_name("/HideMenubar")),
    ("HideWindowUI", pdf_metadata_name("/HideWindowUI")),
    ("FitWindow", pdf_metadata_name("/FitWindow")),
    ("CenterWindow", pdf_metadata_name("/CenterWindow")),
    ("DisplayDocTitle", pdf_metadata_name("/DisplayDocTitle")),
  ]
  for field in viewer_bool_fields {
    output.set(
      field.0,
      pdf_metadata_bool_json(self.viewer_preference_item(field.1)),
    )
  }
  pdf_metadata_set_json_string(
    output,
    "NonFullPageScreenMode",
    self.viewer_preference_item(pdf_metadata_non_full_screen_page_mode()),
  )
  output.set(
    "AcroForm",
    Json::boolean(self.catalog_item(pdf_metadata_acroform()) != ""),
  )
  output.set("XFA", Json::boolean(self.is_xfa()))
  output.set(
    "Marked",
    Json::boolean(self.mark_info_item(pdf_metadata_name("/Marked"))),
  )
  output.set(
    "UserProperties",
    Json::boolean(self.mark_info_item(pdf_metadata_name("/UserProperties"))),
  )
  output.set(
    "Suspects",
    Json::boolean(self.mark_info_item(pdf_metadata_name("/Suspects"))),
  )
  self.pdf_metadata_info_json_boxes(pages, unit, output)
  Json::object(output)
}

///|
/// Return cpdf-style document information report fields as UTF-8 JSON bytes.
pub fn PdfDocument::info_json_blob(
  self : PdfDocument,
  encoding? : PdfMetadataEncoding = PdfMetadataUTF8,
  unit? : PdfUnit = PdfPoint,
) -> PdfBytes raise PdfError {
  @utf8.encode(self.info_json(encoding~, unit~).stringify())
}

///|
/// Return cpdf-style XMP metadata report fields as JSON.
pub fn PdfDocument::xmp_info_json(self : PdfDocument) -> Json raise PdfError {
  let output : Map[String, Json] = Map([])
  output.set(
    "Subformats",
    pdf_metadata_json_string_array(self.determine_subformats()),
  )
  match self.language() {
    Some(language) =>
      output.set("Language", Json::string(@utf8.decode_lossy(language)))
    None => output.set("Language", Json::null())
  }
  match self.metadata() {
    Some(metadata) =>
      for field in pdf_metadata_xmp_report_fields {
        pdf_metadata_add_xmp_report_field(output, metadata, field)
      }
    None => ()
  }
  Json::object(output)
}

///|
/// Return cpdf-style XMP metadata report fields as UTF-8 JSON bytes.
pub fn PdfDocument::xmp_info_json_blob(
  self : PdfDocument,
) -> PdfBytes raise PdfError {
  @utf8.encode(self.xmp_info_json().stringify())
}

///|
/// Return the catalog `/Lang` value, if present.
pub fn PdfDocument::language(self : PdfDocument) -> PdfBytes? {
  match
    self.lookup_chain(self.trailer_dict(), [
      pdf_metadata_root(),
      pdf_metadata_lang(),
    ]) {
    Some(PdfString(value)) => Some(value)
    _ => None
  }
}

///|
/// Set the catalog `/Lang` value.
pub fn PdfDocument::set_language(
  self : PdfDocument,
  language : BytesView,
) -> PdfDocument raise PdfError {
  let document = self.copy()
  document.pdf_metadata_update_catalog(fn(catalog) raise PdfError {
    catalog.add_dict_entry(pdf_metadata_lang(), PdfString(language.to_owned()))
  })
  document
}

///|
fn pdf_metadata_stream(data : BytesView) -> PdfObject {
  pdf_stream(
    PdfDictionary([
      (pdf_metadata_length(), PdfInteger(data.length())),
      (pdf_metadata_type(), PdfNameObject(pdf_metadata_metadata())),
      (pdf_metadata_subtype(), PdfNameObject(pdf_metadata_xml())),
    ]),
    StreamGot(data.to_owned()),
  )
}

///|
/// Set the catalog metadata XML stream from in-memory bytes.
pub fn PdfDocument::set_metadata_from_bytes(
  self : PdfDocument,
  data : BytesView,
  keep_version? : Bool = false,
) -> PdfDocument raise PdfError {
  let document = self.copy()
  let metadata_number = document.add_object(pdf_metadata_stream(data))
  document.pdf_metadata_update_catalog(fn(catalog) raise PdfError {
    catalog.add_dict_entry(
      pdf_metadata_metadata(),
      PdfIndirect(metadata_number),
    )
  })
  document.pdf_metadata_bump_minor(4, keep_version)
  document
}

///|
/// Set the XMP `/Metadata` stream's `xmp:MetadataDate` field.
pub fn PdfDocument::set_metadata_date(
  self : PdfDocument,
  date : String,
) -> PdfDocument raise PdfError {
  match self.metadata() {
    None => self
    Some(metadata) => {
      let value = if date == "now" {
        pdf_metadata_xmp_date(pdf_metadata_expand_date(date))
      } else {
        date
      }
      let updated = pdf_metadata_rewrite_xmp_field(
        metadata, pdf_metadata_xmp_namespace, "xmp", "MetadataDate", value,
      )
      self.set_metadata_from_bytes(updated, keep_version=true)
    }
  }
}

///|
/// Create a fresh XMP metadata stream from the document `/Info` dictionary.
pub fn PdfDocument::create_metadata(
  self : PdfDocument,
) -> PdfDocument raise PdfError {
  let mut xmp = pdf_metadata_xmp_template
  for replacement in self.pdf_metadata_template_replacements() {
    xmp = pdf_metadata_replace_all(xmp, replacement.0, replacement.1)
  }
  self.set_metadata_from_bytes(@utf8.encode(xmp))
}

///|
/// Extract the catalog metadata XML stream, decoding supported stream filters.
pub fn PdfDocument::metadata(self : PdfDocument) -> PdfBytes? raise PdfError {
  match
    self.lookup_chain(self.trailer_dict(), [
      pdf_metadata_root(),
      pdf_metadata_metadata(),
    ]) {
    Some(PdfStreamObject(_) as stream) => {
      let decoded = self.pdf_decode_stream(stream)
      Some(decoded.stream_bytes())
    }
    _ => None
  }
}

///|
/// Remove the catalog `/Metadata` entry.
pub fn PdfDocument::remove_metadata(
  self : PdfDocument,
) -> PdfDocument raise PdfError {
  let document = self.copy()
  document.pdf_metadata_update_catalog(fn(catalog) raise PdfError {
    catalog.remove_dict_entry(pdf_metadata_metadata())
  })
  document
}

///|
/// Remove the catalog metadata entry and every parsed object whose `/Type` is
/// `/Metadata`.
pub fn PdfDocument::remove_all_metadata(
  self : PdfDocument,
) -> PdfDocument raise PdfError {
  let document = self.remove_metadata()
  let to_remove : Array[Int] = []
  for entry in document.parsed_object_entries() {
    match document.lookup_direct(pdf_metadata_type(), entry.2) {
      Some(PdfNameObject(name)) if name == pdf_metadata_metadata() =>
        to_remove.push(entry.0)
      _ => ()
    }
  }
  for number in to_remove {
    document.remove_object(number)
  }
  document
}

///|
/// Compatibility wrapper for `PdfDocument::copy_id_from`.
pub fn pdf_copy_id(
  keep_version : Bool,
  copy_from : PdfDocument,
  copy_to : PdfDocument,
) -> PdfDocument raise PdfError {
  copy_to.copy_id_from(copy_from, keep_version~)
}

///|
/// Compatibility wrapper for `PdfDocument::set_info_entry`.
pub fn pdf_set_pdf_info(
  key : PdfName,
  value : PdfObject,
  version : Int,
  document : PdfDocument,
  keep_version? : Bool = false,
  xmp_also? : Bool = false,
  xmp_just_set? : Bool = false,
) -> PdfDocument raise PdfError {
  document.set_info_entry(
    key,
    value,
    version,
    keep_version~,
    xmp_also~,
    xmp_just_set~,
  )
}

///|
/// Compatibility wrapper for `PdfDocument::info_utf8`.
pub fn pdf_get_info_utf8(document : PdfDocument, key : PdfName) -> String {
  document.info_utf8(key)
}

///|
/// Compatibility wrapper for `PdfDocument::xmp_info`.
pub fn pdf_get_xmp_info(document : PdfDocument, key : PdfName) -> String {
  document.xmp_info(key)
}

///|
/// Compatibility wrapper for `PdfDocument::set_viewer_preference`.
pub fn pdf_set_viewer_preference(
  key : PdfName,
  value : PdfObject,
  version : Int,
  document : PdfDocument,
) -> PdfDocument raise PdfError {
  document.set_viewer_preference(key, value, version)
}

///|
/// Compatibility wrapper for `PdfDocument::set_page_layout`.
pub fn pdf_set_page_layout(
  document : PdfDocument,
  layout : String,
) -> PdfDocument raise PdfError {
  document.set_page_layout(layout)
}

///|
/// Compatibility wrapper for `PdfDocument::set_page_mode`.
pub fn pdf_set_page_mode(
  document : PdfDocument,
  mode : String,
) -> PdfDocument raise PdfError {
  document.set_page_mode(mode)
}

///|
/// Compatibility wrapper for `PdfDocument::set_non_full_screen_page_mode`.
pub fn pdf_set_non_full_screen_page_mode(
  document : PdfDocument,
  mode : String,
) -> PdfDocument raise PdfError {
  document.set_non_full_screen_page_mode(mode)
}

///|
/// Compatibility wrapper for `PdfDocument::set_open_action`.
pub fn pdf_set_open_action(
  document : PdfDocument,
  fit : Bool,
  page_number : Int,
  destination? : PdfObject? = None,
) -> PdfDocument raise PdfError {
  document.set_open_action(page_number, fit~, destination~)
}

///|
/// Compatibility wrapper for `PdfDocument::catalog_item`.
pub fn pdf_get_catalog_item(key : PdfName, document : PdfDocument) -> String {
  document.catalog_item(key)
}

///|
/// Compatibility wrapper for `PdfDocument::viewer_preference_item`.
pub fn pdf_get_viewer_pref_item(
  key : PdfName,
  document : PdfDocument,
) -> String {
  document.viewer_preference_item(key)
}

///|
/// Compatibility wrapper for `PdfDocument::mark_info_item`.
pub fn pdf_get_mark_info_item(key : PdfName, document : PdfDocument) -> Bool {
  document.mark_info_item(key)
}

///|
/// Compatibility wrapper for `PdfDocument::is_xfa`.
pub fn pdf_is_xfa(document : PdfDocument) -> Bool {
  document.is_xfa()
}

///|
/// Compatibility wrapper for `PdfDocument::open_action`.
pub fn pdf_get_open_action(document : PdfDocument) -> PdfObject raise PdfError {
  document.open_action()
}

///|
/// Compatibility wrapper for `PdfDocument::open_action_json_blob`.
pub fn pdf_get_open_action_json_blob(
  document : PdfDocument,
) -> PdfBytes raise PdfError {
  document.open_action_json_blob()
}

///|
/// Compatibility wrapper for `PdfDocument::open_action_string`.
pub fn pdf_get_open_action_string(
  document : PdfDocument,
) -> String raise PdfError {
  document.open_action_string()
}

///|
/// Compatibility wrapper for cpdf's minor-version setter.
pub fn pdf_set_version(minor : Int, document : PdfDocument) -> Unit {
  let (major, _) = document.version()
  document.set_version(major, minor)
}

///|
/// Compatibility wrapper for `PdfDocument::determine_subformats`.
pub fn pdf_determine_subformats(
  document : PdfDocument,
) -> Array[String] raise PdfError {
  document.determine_subformats()
}

///|
/// Compatibility wrapper for `PdfDocument::info_json_blob`.
pub fn pdf_output_info_json_blob(
  document : PdfDocument,
  encoding? : PdfMetadataEncoding = PdfMetadataUTF8,
  unit? : PdfUnit = PdfPoint,
) -> PdfBytes raise PdfError {
  document.info_json_blob(encoding~, unit~)
}

///|
/// Compatibility wrapper for `PdfDocument::xmp_info_json_blob`.
pub fn pdf_output_xmp_info_json_blob(
  document : PdfDocument,
) -> PdfBytes raise PdfError {
  document.xmp_info_json_blob()
}

///|
/// Compatibility wrapper for `PdfDocument::language`.
pub fn pdf_language(document : PdfDocument) -> PdfBytes? {
  document.language()
}

///|
/// Compatibility wrapper for `PdfDocument::set_language`.
pub fn pdf_set_language(
  document : PdfDocument,
  language : BytesView,
) -> PdfDocument raise PdfError {
  document.set_language(language)
}

///|
/// Compatibility wrapper for `PdfDocument::set_metadata_from_bytes`.
pub fn pdf_set_metadata_from_bytes(
  keep_version : Bool,
  data : BytesView,
  document : PdfDocument,
) -> PdfDocument raise PdfError {
  document.set_metadata_from_bytes(data, keep_version~)
}

///|
/// Compatibility wrapper for `PdfDocument::metadata`.
pub fn pdf_get_metadata(document : PdfDocument) -> PdfBytes? raise PdfError {
  document.metadata()
}

///|
/// Compatibility wrapper for `PdfDocument::set_metadata_date`.
pub fn pdf_set_metadata_date(
  document : PdfDocument,
  date : String,
) -> PdfDocument raise PdfError {
  document.set_metadata_date(date)
}

///|
/// Compatibility wrapper for `PdfDocument::create_metadata`.
pub fn pdf_create_metadata(
  document : PdfDocument,
) -> PdfDocument raise PdfError {
  document.create_metadata()
}

///|
/// Compatibility wrapper for `PdfDocument::remove_metadata`.
pub fn pdf_remove_metadata(
  document : PdfDocument,
) -> PdfDocument raise PdfError {
  document.remove_metadata()
}

///|
/// Compatibility wrapper for `PdfDocument::remove_all_metadata`.
pub fn pdf_remove_all_metadata(
  document : PdfDocument,
) -> PdfDocument raise PdfError {
  document.remove_all_metadata()
}