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

///|
let pdf_bookmark_outlines_key_name : PdfName = pdf_bookmark_name("/Outlines")

///|
fn pdf_bookmark_outlines_key() -> PdfName {
  pdf_bookmark_outlines_key_name
}

///|
let pdf_bookmark_root_key_name : PdfName = pdf_bookmark_name("/Root")

///|
fn pdf_bookmark_root_key() -> PdfName {
  pdf_bookmark_root_key_name
}

///|
let pdf_bookmark_first_key_name : PdfName = pdf_bookmark_name("/First")

///|
fn pdf_bookmark_first_key() -> PdfName {
  pdf_bookmark_first_key_name
}

///|
let pdf_bookmark_next_key_name : PdfName = pdf_bookmark_name("/Next")

///|
fn pdf_bookmark_next_key() -> PdfName {
  pdf_bookmark_next_key_name
}

///|
let pdf_bookmark_prev_key_name : PdfName = pdf_bookmark_name("/Prev")

///|
fn pdf_bookmark_prev_key() -> PdfName {
  pdf_bookmark_prev_key_name
}

///|
let pdf_bookmark_parent_key_name : PdfName = pdf_bookmark_name("/Parent")

///|
fn pdf_bookmark_parent_key() -> PdfName {
  pdf_bookmark_parent_key_name
}

///|
let pdf_bookmark_title_key_name : PdfName = pdf_bookmark_name("/Title")

///|
fn pdf_bookmark_title_key() -> PdfName {
  pdf_bookmark_title_key_name
}

///|
let pdf_bookmark_last_key_name : PdfName = pdf_bookmark_name("/Last")

///|
fn pdf_bookmark_last_key() -> PdfName {
  pdf_bookmark_last_key_name
}

///|
let pdf_bookmark_dest_key_name : PdfName = pdf_bookmark_name("/Dest")

///|
fn pdf_bookmark_dest_key() -> PdfName {
  pdf_bookmark_dest_key_name
}

///|
let pdf_bookmark_action_key_name : PdfName = pdf_bookmark_name("/A")

///|
fn pdf_bookmark_action_key() -> PdfName {
  pdf_bookmark_action_key_name
}

///|
let pdf_bookmark_d_key_name : PdfName = pdf_bookmark_name("/D")

///|
fn pdf_bookmark_d_key() -> PdfName {
  pdf_bookmark_d_key_name
}

///|
let pdf_bookmark_count_key_name : PdfName = pdf_bookmark_name("/Count")

///|
fn pdf_bookmark_count_key() -> PdfName {
  pdf_bookmark_count_key_name
}

///|
let pdf_bookmark_colour_key_name : PdfName = pdf_bookmark_name("/C")

///|
fn pdf_bookmark_colour_key() -> PdfName {
  pdf_bookmark_colour_key_name
}

///|
let pdf_bookmark_flags_key_name : PdfName = pdf_bookmark_name("/F")

///|
fn pdf_bookmark_flags_key() -> PdfName {
  pdf_bookmark_flags_key_name
}

///|
fn PdfDocument::pdf_bookmark_catalog(
  self : PdfDocument,
) -> PdfObject raise PdfError {
  self.catalog()
}

///|
/// Bookmark display colour from the PDF outline `/C` entry.
///
/// PDF stores these as RGB real numbers, normally in the range 0.0 to 1.0.
pub(all) struct PdfBookmarkColour {
  red : Double
  green : Double
  blue : Double
} derive(Debug, Eq, ToJson)

///|
/// One bookmark in the flattened outline representation.
///
/// Bookmarks are ordered in preorder. `level` is zero-based nesting depth,
/// `text` is the raw PDF title string bytes, and `target` is the parsed
/// destination or preserved action.
pub(all) struct PdfBookmark {
  level : Int
  text : PdfBytes
  target : PdfDestination
  is_open : Bool
  colour : PdfBookmarkColour
  flags : Int
} derive(Debug, Eq, ToJson)

///|
struct PdfBookmarkNode {
  number : Int
  object : PdfObject
  children : Array[PdfBookmarkNode]
  is_open : Bool
} derive(Debug, Eq, ToJson)

///|
/// Default black bookmark colour.
pub let pdf_bookmark_black : PdfBookmarkColour = {
  red: 0.0,
  green: 0.0,
  blue: 0.0,
}

///|
/// Format a bookmark as a diagnostic byte string.
///
/// The output includes level, title bytes, destination object, and open state;
/// it is intended for debugging rather than PDF serialization.
pub fn pdf_string_of_bookmark(
  bookmark : PdfBookmark,
) -> PdfBytes raise PdfError {
  let space = @ascii.encode(" ")
  pdf_bytes_concat_views([
    @ascii.encode(bookmark.level.to_string()),
    space,
    bookmark.text,
    space,
    pdf_write_object(pdf_object_of_destination(bookmark.target)),
    space,
    @ascii.encode(if bookmark.is_open { "true" } else { "false" }),
    @ascii.encode("\n"),
  ])
}

///|
fn pdf_bookmark_listing_escape_codepoints(
  codepoints : ArrayView[Int],
) -> Array[Int] {
  let output = Array::make(
    pdf_bookmark_listing_escape_codepoints_length(codepoints),
    0,
  )
  let mut position = 0
  for codepoint in codepoints {
    match codepoint {
      34 => {
        output[position] = 92
        output[position + 1] = 34
        position += 2
      }
      92 => {
        output[position] = 92
        output[position + 1] = 92
        position += 2
      }
      10 => {
        output[position] = 92
        output[position + 1] = 110
        position += 2
      }
      _ => {
        output[position] = codepoint
        position += 1
      }
    }
  }
  output
}

///|
fn pdf_bookmark_listing_escape_codepoints_length(
  codepoints : ArrayView[Int],
) -> Int {
  let mut length = 0
  for codepoint in codepoints {
    match codepoint {
      34 | 92 | 10 => length += 2
      _ => length += 1
    }
  }
  length
}

///|
fn pdf_bookmark_listing_stripped_codepoints_length(
  codepoints : ArrayView[Int],
) -> Int {
  let mut length = 0
  for codepoint in codepoints {
    if codepoint <= 127 {
      length += 1
    }
  }
  length
}

///|
fn pdf_bookmark_listing_stripped_codepoints(
  codepoints : ArrayView[Int],
) -> PdfBytes raise PdfError {
  let output = Array::make(
    pdf_bookmark_listing_stripped_codepoints_length(codepoints),
    b'\x00',
  )
  let mut position = 0
  for codepoint in codepoints {
    if codepoint <= 127 {
      output[position] = pdf_byte_of_int(codepoint)
      position += 1
    }
  }
  Bytes::from_array(output)
}

///|
fn pdf_bookmark_listing_process_text(
  encoding : PdfMetadataEncoding,
  text : BytesView,
) -> PdfBytes raise PdfError {
  match encoding {
    PdfMetadataRaw => text.to_owned()
    PdfMetadataUTF8 => {
      let escaped = pdf_bookmark_listing_escape_codepoints(
        pdf_codepoints_of_pdfdocstring(text),
      )
      pdf_utf8_of_codepoints(escaped)
    }
    PdfMetadataStripped => {
      let escaped = pdf_bookmark_listing_escape_codepoints(
        pdf_codepoints_of_pdfdocstring(text),
      )
      pdf_bookmark_listing_stripped_codepoints(escaped)
    }
  }
}

///|
fn pdf_bookmark_listing_is_all_pages(
  page_numbers : ArrayView[Int],
  end_page : Int,
) -> Bool {
  if page_numbers.length() != end_page {
    false
  } else {
    for index in 0.. PdfObject {
  let object = pdf_object_of_destination(target)
  match object {
    PdfArray(items) =>
      if items.length() == 0 {
        object
      } else {
        let rewritten : Array[PdfObject] = [PdfInteger(page_number)]
        for index in 1.. object
  }
}

///|
fn PdfDocument::pdf_bookmark_listing_in_range(
  self : PdfDocument,
  bookmark : PdfBookmark,
  selected_pages : @number_set.PdfNumberSet,
  range_is_all : Bool,
) -> Bool raise PdfError {
  if range_is_all || bookmark.target == DestNull {
    true
  } else {
    match bookmark.target {
      DestNamed(_) | DestString(_) => true
      _ => selected_pages.contains(self.page_number_of_target(bookmark.target))
    }
  }
}

///|
priv struct PdfBookmarkListingTextLine {
  level : PdfBytes
  text : PdfBytes
  page : PdfBytes
  target : PdfBytes
  is_open : Bool
}

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

///|
fn pdf_bookmark_copy_ascii(
  output : Array[Byte],
  position : Int,
  text : String,
) -> Int {
  pdf_bookmark_copy_view(output, position, @ascii.encode(text))
}

///|
fn PdfBookmarkListingTextLine::byte_length(
  self : PdfBookmarkListingTextLine,
) -> Int {
  let open_length = if self.is_open { 5 } else { 0 }
  self.level.length() +
  self.text.length() +
  self.page.length() +
  self.target.length() +
  8 +
  open_length
}

///|
fn PdfBookmarkListingTextLine::write_to(
  self : PdfBookmarkListingTextLine,
  output : Array[Byte],
  position : Int,
) -> Int {
  let mut position = pdf_bookmark_copy_view(output, position, self.level)
  position = pdf_bookmark_copy_ascii(output, position, " \"")
  position = pdf_bookmark_copy_view(output, position, self.text)
  position = pdf_bookmark_copy_ascii(output, position, "\" ")
  position = pdf_bookmark_copy_view(output, position, self.page)
  if self.is_open {
    position = pdf_bookmark_copy_ascii(output, position, " open")
  }
  position = pdf_bookmark_copy_ascii(output, position, " \"")
  position = pdf_bookmark_copy_view(output, position, self.target)
  pdf_bookmark_copy_ascii(output, position, "\"\n")
}

///|
fn PdfDocument::pdf_bookmark_listing_text_line(
  self : PdfDocument,
  bookmark : PdfBookmark,
  encoding : PdfMetadataEncoding,
) -> PdfBookmarkListingTextLine raise PdfError {
  let page_number = self.page_number_of_target(bookmark.target)
  {
    level: @ascii.encode(bookmark.level.to_string()),
    text: pdf_bookmark_listing_process_text(encoding, bookmark.text),
    page: @ascii.encode(page_number.to_string()),
    target: pdf_write_object(
      pdf_bookmark_listing_target_object(bookmark.target, page_number),
    ),
    is_open: bookmark.is_open,
  }
}

///|
fn pdf_bookmark_json_text(text : BytesView) -> String raise PdfError {
  let simplified = pdf_simplify_utf16be(text)
  let utf8 = pdf_utf8_of_pdfdocstring(simplified)
  @utf8.decode(utf8) catch {
    _ => raise InvalidUTF8
  }
}

///|
fn PdfDocument::pdf_bookmark_json_mark(
  self : PdfDocument,
  bookmark : PdfBookmark,
) -> Json raise PdfError {
  let page_number = self.page_number_of_target(bookmark.target)
  Json::object(
    Map([
      ("level", pdf_util_json_number(bookmark.level)),
      ("text", Json::string(pdf_bookmark_json_text(bookmark.text))),
      ("page", pdf_util_json_number(page_number)),
      ("open", Json::boolean(bookmark.is_open)),
      (
        "target",
        self.json_of_object(
          pdf_bookmark_listing_target_object(bookmark.target, page_number),
        ),
      ),
      (
        "colour",
        Json::array([
          Json::number(bookmark.colour.red),
          Json::number(bookmark.colour.green),
          Json::number(bookmark.colour.blue),
        ]),
      ),
      ("italic", Json::boolean((bookmark.flags & 1) > 0)),
      ("bold", Json::boolean((bookmark.flags & 2) > 0)),
    ]),
  )
}

///|
fn PdfDocument::pdf_bookmark_listing_json(
  self : PdfDocument,
  bookmarks : ArrayView[PdfBookmark],
) -> Json raise PdfError {
  let output : Array[Json] = []
  for bookmark in bookmarks {
    output.push(self.pdf_bookmark_json_mark(bookmark))
  }
  Json::array(output)
}

///|
fn PdfDocument::pdf_bookmark_listing_bookmarks(
  self : PdfDocument,
  page_numbers : ArrayView[Int],
  preserve_actions : Bool,
) -> Array[PdfBookmark] raise PdfError {
  let end_page = self.endpage()
  pdf_page_box_validate_page_numbers(page_numbers, end_page, "list_bookmarks")
  let range_is_all = pdf_bookmark_listing_is_all_pages(page_numbers, end_page)
  let selected_pages = @number_set.pdf_number_set(page_numbers)
  let bookmarks = self.read_bookmarks(preserve_actions~)
  let output : Array[PdfBookmark] = []
  for bookmark in bookmarks {
    if self.pdf_bookmark_listing_in_range(
        bookmark, selected_pages, range_is_all,
      ) {
      output.push(bookmark)
    }
  }
  output
}

///|
/// Render cpdf's bookmark listing for selected pages.
///
/// Text output matches cpdf's line-oriented bookmark format. When `json` is
/// set, this returns UTF-8 JSON bytes for the same filtered bookmark set.
pub fn PdfDocument::list_bookmarks(
  self : PdfDocument,
  page_numbers : ArrayView[Int],
  encoding? : PdfMetadataEncoding = PdfMetadataUTF8,
  json? : Bool = false,
  json_preserve_actions? : Bool = false,
) -> PdfBytes raise PdfError {
  let bookmarks = self.pdf_bookmark_listing_bookmarks(
    page_numbers,
    json && json_preserve_actions,
  )
  if json {
    @utf8.encode(self.pdf_bookmark_listing_json(bookmarks).stringify())
  } else {
    let lines : Array[PdfBookmarkListingTextLine] = []
    let mut length = 0
    for bookmark in bookmarks {
      let line = self.pdf_bookmark_listing_text_line(bookmark, encoding)
      length += line.byte_length()
      lines.push(line)
    }
    let output = Array::make(length, b'\x00')
    let mut position = 0
    for line in lines {
      position = line.write_to(output, position)
    }
    Bytes::from_array(output)
  }
}

///|
/// Return cpdf's full-document bookmark JSON listing.
pub fn PdfDocument::bookmarks_json(
  self : PdfDocument,
  json_preserve_actions? : Bool = false,
) -> Json raise PdfError {
  let end_page = self.endpage()
  let page_numbers = Array::make(end_page, 0)
  for index in 0.. PdfBytes raise PdfError {
  @utf8.encode(self.bookmarks_json(json_preserve_actions~).stringify())
}

///|
/// Compatibility wrapper for `PdfDocument::list_bookmarks`.
pub fn pdf_list_bookmarks(
  json : Bool,
  json_preserve_actions : Bool,
  encoding : PdfMetadataEncoding,
  page_numbers : ArrayView[Int],
  document : PdfDocument,
) -> PdfBytes raise PdfError {
  document.list_bookmarks(
    page_numbers,
    encoding~,
    json~,
    json_preserve_actions~,
  )
}

///|
/// Compatibility wrapper for `PdfDocument::bookmarks_json_blob`.
pub fn pdf_get_bookmarks_json(
  json_preserve_actions : Bool,
  document : PdfDocument,
) -> PdfBytes raise PdfError {
  document.bookmarks_json_blob(json_preserve_actions~)
}

///|
fn pdf_bookmark_name_spec_padded_number(
  width : Int,
  value : Int,
) -> String raise PdfError {
  if width > 8 {
    raise SoftError("width of field too broad")
  }
  let text = value.to_string()
  if width <= 1 || text.length() >= width {
    text
  } else {
    "0".repeat(width - text.length()) + text
  }
}

///|
fn pdf_bookmark_name_spec_percent_number(width : Int, value : Int) -> String {
  let text = value.to_string()
  if text.length() > width {
    text[text.length() - width:].to_owned()
  } else {
    "0".repeat(width - text.length()) + text
  }
}

///|
fn pdf_bookmark_name_spec_expand_percent(
  spec : String,
  sequence : Int,
) -> String {
  let bytes = @utf8.encode(spec)
  let mut first = -1
  for index in 0.. "" }
    let after = @utf8.decode(bytes[end:]) catch { _ => "" }
    before +
    pdf_bookmark_name_spec_percent_number(end - first, sequence) +
    after
  }
}

///|
fn pdf_bookmark_name_spec_utf8_prefix(
  text : String,
  byte_length : Int,
) -> String {
  let bytes = @utf8.encode(text)
  let mut end = if byte_length < bytes.length() {
    byte_length
  } else {
    bytes.length()
  }
  while end > 0 {
    try @utf8.decode(bytes[:end]) catch {
      _ => end = end - 1
    } noraise {
      value => return value
    }
  }
  ""
}

///|
fn pdf_bookmark_name_spec_decimal(
  bytes : BytesView,
  start : Int,
  end : Int,
) -> Int raise PdfError {
  if start == end {
    raise SoftError("Bad @b spec")
  }
  let mut value = 0
  for index in start.. 57 {
      raise SoftError("Bad @b spec")
    }
    value = value * 10 + digit - 48
  }
  value
}

///|
fn PdfDocument::pdf_bookmark_name_for_page(
  self : PdfDocument,
  bookmarks : ArrayView[PdfBookmark],
  split_level : Int,
  page_number : Int,
) -> String raise PdfError {
  for bookmark in bookmarks {
    if bookmark.level <= split_level &&
      self.page_number_of_target(bookmark.target) == page_number {
      let text = @utf8.decode(pdf_utf8_of_pdfdocstring(bookmark.text)) catch {
        _ => raise InvalidUTF8
      }
      return pdf_attachment_remove_unsafe_characters(text)
    }
  }
  ""
}

///|
fn pdf_bookmark_name_spec_scan_to_at(
  bytes : BytesView,
  start : Int,
) -> (Int, Int) {
  let mut end = start
  while end < bytes.length() && bytes[end].to_int() != 64 {
    end = end + 1
  }
  let next = if end < bytes.length() { end + 1 } else { end }
  (end - start, next)
}

///|
fn pdf_bookmark_name_spec_ascii_length(text : String) -> Int {
  @ascii.encode(text).length()
}

///|
fn pdf_bookmark_name_spec_copy_ascii(
  output : Array[Byte],
  position : Int,
  text : String,
) -> Int {
  let mut position = position
  for byte in @ascii.encode(text) {
    output[position] = byte
    position += 1
  }
  position
}

///|
fn pdf_bookmark_name_spec_process_at_fields_length(
  bytes : BytesView,
  bookmark_name : String,
  sequence : Int,
  filename : String,
  start_page : Int,
  end_page : Int,
) -> Int raise PdfError {
  let mut output_length = 0
  let mut index = 0
  while index < bytes.length() {
    if bytes[index].to_int() == 64 && index + 1 < bytes.length() {
      match bytes[index + 1].to_int() {
        70 => {
          output_length += pdf_bookmark_name_spec_ascii_length(filename)
          index = index + 2
        }
        78 | 83 | 69 as field => {
          let (width, next) = pdf_bookmark_name_spec_scan_to_at(
            bytes,
            index + 2,
          )
          let value = match field {
            78 => sequence
            83 => start_page
            _ => end_page
          }
          output_length += pdf_bookmark_name_spec_ascii_length(
            pdf_bookmark_name_spec_padded_number(width, value),
          )
          index = next
        }
        66 => {
          output_length += pdf_bookmark_name_spec_ascii_length(bookmark_name)
          index = index + 2
        }
        98 => {
          let mut digits_end = index + 2
          while digits_end < bytes.length() {
            let digit = bytes[digits_end].to_int()
            if digit < 48 || digit > 57 {
              break
            }
            digits_end = digits_end + 1
          }
          if digits_end == index + 2 {
            raise SoftError("Bad @b spec")
          }
          let length = pdf_bookmark_name_spec_decimal(
            bytes,
            index + 2,
            digits_end,
          )
          output_length += pdf_bookmark_name_spec_ascii_length(
            pdf_bookmark_name_spec_utf8_prefix(bookmark_name, length),
          )
          index = if digits_end < bytes.length() {
            digits_end + 1
          } else {
            digits_end
          }
        }
        _ => {
          output_length += 1
          index = index + 1
        }
      }
    } else {
      output_length += 1
      index = index + 1
    }
  }
  output_length
}

///|
fn PdfDocument::pdf_bookmark_name_spec_process_at_fields(
  self : PdfDocument,
  bookmarks : ArrayView[PdfBookmark],
  split_level : Int,
  spec : String,
  sequence : Int,
  filename : String,
  start_page : Int,
  end_page : Int,
) -> String raise PdfError {
  let bytes = @utf8.encode(spec)
  let bookmark_name = self.pdf_bookmark_name_for_page(
    bookmarks, split_level, start_page,
  )
  let output = Array::make(
    pdf_bookmark_name_spec_process_at_fields_length(
      bytes, bookmark_name, sequence, filename, start_page, end_page,
    ),
    b'\x00',
  )
  let mut position = 0
  let mut index = 0
  while index < bytes.length() {
    if bytes[index].to_int() == 64 && index + 1 < bytes.length() {
      match bytes[index + 1].to_int() {
        70 => {
          position = pdf_bookmark_name_spec_copy_ascii(
            output, position, filename,
          )
          index = index + 2
        }
        78 | 83 | 69 as field => {
          let (width, next) = pdf_bookmark_name_spec_scan_to_at(
            bytes,
            index + 2,
          )
          let value = match field {
            78 => sequence
            83 => start_page
            _ => end_page
          }
          position = pdf_bookmark_name_spec_copy_ascii(
            output,
            position,
            pdf_bookmark_name_spec_padded_number(width, value),
          )
          index = next
        }
        66 => {
          position = pdf_bookmark_name_spec_copy_ascii(
            output, position, bookmark_name,
          )
          index = index + 2
        }
        98 => {
          let mut digits_end = index + 2
          while digits_end < bytes.length() {
            let digit = bytes[digits_end].to_int()
            if digit < 48 || digit > 57 {
              break
            }
            digits_end = digits_end + 1
          }
          if digits_end == index + 2 {
            raise SoftError("Bad @b spec")
          }
          let length = pdf_bookmark_name_spec_decimal(
            bytes,
            index + 2,
            digits_end,
          )
          position = pdf_bookmark_name_spec_copy_ascii(
            output,
            position,
            pdf_bookmark_name_spec_utf8_prefix(bookmark_name, length),
          )
          index = if digits_end < bytes.length() {
            digits_end + 1
          } else {
            digits_end
          }
        }
        _ => {
          output[position] = bytes[index]
          position += 1
          index = index + 1
        }
      }
    } else {
      output[position] = bytes[index]
      position += 1
      index = index + 1
    }
  }
  @utf8.decode(Bytes::from_array(output)) catch {
    _ => raise InvalidUTF8
  }
}

///|
/// Expand cpdf's bookmark-based output filename specification.
///
/// This ports the output-side `name_of_spec` helper. Bookmark-file parsing is
/// intentionally separate; callers pass the already-read bookmark list.
pub fn PdfDocument::bookmark_name_of_spec(
  self : PdfDocument,
  encoding : PdfMetadataEncoding,
  bookmarks : ArrayView[PdfBookmark],
  split_level : Int,
  spec : String,
  sequence : Int,
  filename : String,
  start_page : Int,
  end_page : Int,
) -> String raise PdfError {
  let _ = encoding
  self.pdf_bookmark_name_spec_process_at_fields(
    bookmarks,
    split_level,
    pdf_bookmark_name_spec_expand_percent(spec, sequence),
    sequence,
    filename,
    start_page,
    end_page,
  )
}

///|
/// Compatibility wrapper for `PdfDocument::bookmark_name_of_spec`.
pub fn pdf_bookmark_name_of_spec(
  encoding : PdfMetadataEncoding,
  bookmarks : ArrayView[PdfBookmark],
  document : PdfDocument,
  split_level : Int,
  spec : String,
  sequence : Int,
  filename : String,
  start_page : Int,
  end_page : Int,
) -> String raise PdfError {
  document.bookmark_name_of_spec(
    encoding, bookmarks, split_level, spec, sequence, filename, start_page, end_page,
  )
}

///|
/// Source-spelled compatibility wrapper for cpdfbookmarks' `name_of_spec`.
pub fn pdf_name_of_spec(
  encoding : PdfMetadataEncoding,
  bookmarks : ArrayView[PdfBookmark],
  document : PdfDocument,
  split_level : Int,
  spec : String,
  sequence : Int,
  filename : String,
  start_page : Int,
  end_page : Int,
) -> String raise PdfError {
  document.bookmark_name_of_spec(
    encoding, bookmarks, split_level, spec, sequence, filename, start_page, end_page,
  )
}

///|
fn pdf_bookmark_import_is_whitespace(value : Int) -> Bool {
  pdf_is_whitespace_byte(value)
}

///|
fn pdf_bookmark_import_trim_line_end(line : BytesView) -> BytesView {
  let mut end = line.length()
  while end > 0 {
    let value = line[end - 1].to_int()
    if value == 10 || value == 13 {
      end -= 1
    } else {
      break
    }
  }
  line[:end]
}

///|
fn pdf_bookmark_import_skip_whitespace(
  line : BytesView,
  index : Ref[Int],
) -> Unit {
  while index.val < line.length() &&
        pdf_bookmark_import_is_whitespace(line[index.val].to_int()) {
    index.val += 1
  }
}

///|
fn pdf_bookmark_import_parse_int(line : BytesView, index : Ref[Int]) -> Int? {
  pdf_bookmark_import_skip_whitespace(line, index)
  let start = index.val
  let mut negative = false
  if index.val < line.length() {
    match line[index.val].to_int() {
      43 => index.val += 1
      45 => {
        negative = true
        index.val += 1
      }
      _ => ()
    }
  }
  let digits_start = index.val
  let mut value : Int64 = 0L
  let limit : Int64 = if negative { 2147483648L } else { 2147483647L }
  while index.val < line.length() {
    let digit = line[index.val].to_int()
    if digit < 48 || digit > 57 {
      break
    }
    value = value * 10L + (digit - 48).to_int64()
    if value > limit {
      index.val = start
      return None
    }
    index.val += 1
  }
  if index.val == digits_start {
    index.val = start
    None
  } else if negative {
    Some((0L - value).to_int())
  } else {
    Some(value.to_int())
  }
}

///|
fn pdf_bookmark_import_quoted_scan(
  line : BytesView,
  start : Int,
) -> (Int, Int, Bool) {
  let mut length = 0
  let mut index = start
  let mut closed = false
  while index < line.length() && !closed {
    let value = line[index].to_int()
    if value == 92 && index + 1 < line.length() {
      length += 2
      index += 2
    } else if value == 34 {
      index += 1
      closed = true
    } else {
      length += 1
      index += 1
    }
  }
  (length, index, closed)
}

///|
fn pdf_bookmark_import_copy_quoted(
  output : Array[Byte],
  line : BytesView,
  start : Int,
) -> Unit {
  let mut output_index = 0
  let mut index = start
  while output_index < output.length() {
    let value = line[index].to_int()
    if value == 92 && index + 1 < line.length() {
      output[output_index] = line[index]
      output[output_index + 1] = line[index + 1]
      output_index += 2
      index += 2
    } else {
      output[output_index] = line[index]
      output_index += 1
      index += 1
    }
  }
}

///|
fn pdf_bookmark_import_parse_quoted(
  line : BytesView,
  index : Ref[Int],
) -> PdfBytes? raise PdfError {
  pdf_bookmark_import_skip_whitespace(line, index)
  if index.val >= line.length() || line[index.val].to_int() != 34 {
    None
  } else {
    let start = index.val + 1
    let (length, next, closed) = pdf_bookmark_import_quoted_scan(line, start)
    if closed {
      let output = Array::make(length, b'\x00')
      pdf_bookmark_import_copy_quoted(output, line, start)
      index.val = next
      Some(Bytes::from_array(output))
    } else {
      raise SoftError("Bad bookmark file (unterminated string)")
    }
  }
}

///|
fn pdf_bookmark_import_take_open(line : BytesView, index : Ref[Int]) -> Bool {
  pdf_bookmark_import_skip_whitespace(line, index)
  let start = index.val
  if start + 4 <= line.length() &&
    line[start].to_int() == 111 &&
    line[start + 1].to_int() == 112 &&
    line[start + 2].to_int() == 101 &&
    line[start + 3].to_int() == 110 &&
    (
      start + 4 == line.length() ||
      pdf_bookmark_import_is_whitespace(line[start + 4].to_int())
    ) {
    index.val = start + 4
    true
  } else {
    false
  }
}

///|
fn pdf_bookmark_import_optional_destination(
  line : BytesView,
  index : Ref[Int],
) -> PdfBytes? raise PdfError {
  pdf_bookmark_import_skip_whitespace(line, index)
  if index.val >= line.length() || line[index.val].to_int() != 34 {
    None
  } else {
    let destination = pdf_bookmark_import_parse_quoted(line, index)
    pdf_bookmark_import_skip_whitespace(line, index)
    if index.val == line.length() {
      destination
    } else {
      None
    }
  }
}

///|
fn pdf_bookmark_import_fixup_escape(value : Int) -> Byte? {
  match value {
    92 => Some((92).to_byte())
    34 => Some((34).to_byte())
    110 => Some((10).to_byte())
    _ => None
  }
}

///|
fn pdf_bookmark_import_fixup_text_length(text : BytesView) -> Int {
  let mut length = 0
  let mut index = 0
  while index < text.length() {
    if text[index].to_int() == 92 && index + 1 < text.length() {
      match pdf_bookmark_import_fixup_escape(text[index + 1].to_int()) {
        Some(_) => index += 2
        None => index += 1
      }
    } else {
      index += 1
    }
    length += 1
  }
  length
}

///|
fn pdf_bookmark_import_fixup_text(text : BytesView) -> PdfBytes {
  let output = Array::make(pdf_bookmark_import_fixup_text_length(text), b'\x00')
  let mut position = 0
  let mut index = 0
  while index < text.length() {
    if text[index].to_int() == 92 && index + 1 < text.length() {
      match pdf_bookmark_import_fixup_escape(text[index + 1].to_int()) {
        Some(byte) => {
          output[position] = byte
          index += 2
        }
        None => {
          output[position] = text[index]
          index += 1
        }
      }
    } else {
      output[position] = text[index]
      index += 1
    }
    position += 1
  }
  Bytes::from_array(output)
}

///|
fn PdfDocument::pdf_bookmark_import_target_object(
  self : PdfDocument,
  page_number : Int,
  object : PdfObject,
) -> PdfDestination raise PdfError {
  match object {
    PdfArray([PdfInteger(_), .. rest]) =>
      match self.page_object_number(page_number) {
        Some(page_object_number) =>
          self.read_destination(
            PdfArray([PdfIndirect(page_object_number), ..rest]),
          )
        None => raise SoftError("bookmark_of_data: page obj num not found")
      }
    PdfNull => DestNull
    PdfString(name) => DestString(name)
    _ => DestAction(object)
  }
}

///|
fn PdfDocument::pdf_bookmark_import_target(
  self : PdfDocument,
  page_number : Int,
  destination : PdfBytes?,
) -> PdfDestination raise PdfError {
  match destination {
    Some(data) =>
      self.pdf_bookmark_import_target_object(
        page_number,
        pdf_parse_object_from_view(data),
      )
    None => self.target_of_page_number(page_number)
  }
}

///|
fn PdfDocument::pdf_bookmark_import_bookmark(
  self : PdfDocument,
  level : Int,
  title : BytesView,
  page_number : Int,
  is_open : Bool,
  destination : PdfBytes?,
) -> PdfBookmark raise PdfError {
  {
    level,
    text: pdf_pdfdocstring_of_utf8(pdf_bookmark_import_fixup_text(title)),
    target: self.pdf_bookmark_import_target(page_number, destination),
    is_open,
    colour: pdf_bookmark_black,
    flags: 0,
  }
}

///|
fn PdfDocument::pdf_bookmark_import_parse_line(
  self : PdfDocument,
  line : BytesView,
  line_number : Int,
) -> PdfBookmark? raise PdfError {
  let line = pdf_bookmark_import_trim_line_end(line)
  let index : Ref[Int] = { val: 0 }
  pdf_bookmark_import_skip_whitespace(line, index)
  if index.val == line.length() {
    None
  } else {
    let level = match pdf_bookmark_import_parse_int(line, index) {
      Some(value) => value
      None =>
        raise SoftError("Bad bookmark file, line " + line_number.to_string())
    }
    let title = match pdf_bookmark_import_parse_quoted(line, index) {
      Some(value) => value
      None =>
        raise SoftError("Bad bookmark file, line " + line_number.to_string())
    }
    let page_number = match pdf_bookmark_import_parse_int(line, index) {
      Some(value) => value
      None =>
        raise SoftError("Bad bookmark file, line " + line_number.to_string())
    }
    let is_open = pdf_bookmark_import_take_open(line, index)
    let destination = pdf_bookmark_import_optional_destination(line, index)
    Some(
      self.pdf_bookmark_import_bookmark(
        level, title, page_number, is_open, destination,
      ),
    )
  }
}

///|
fn PdfDocument::pdf_bookmark_import_verify(
  self : PdfDocument,
  bookmarks : ArrayView[PdfBookmark],
) -> Bool raise PdfError {
  if bookmarks.length() == 0 {
    true
  } else if bookmarks[0].level != 0 {
    false
  } else {
    let page_reference_numbers = self.page_reference_numbers()
    let page_number_lookup = pdf_page_number_lookup(page_reference_numbers)
    let end_page = self.endpage()
    let mut last_level = 0
    for index in 1..= last_level + 2 ||
        bookmark.level < 0 ||
        page_number > end_page ||
        page_number < 0 {
        break false
      }
      last_level = bookmark.level
    } nobreak {
      true
    }
  }
}

///|
/// Parse cpdf's plain text bookmark-file format.
///
/// The input is byte-oriented. Bookmark titles are double-quoted UTF-8 text
/// with cpdf's `\\`, `\"`, and `\n` escapes; optional destinations are
/// double-quoted PDF object snippets such as `[1 /Fit]`.
pub fn PdfDocument::parse_bookmark_text(
  self : PdfDocument,
  data : BytesView,
  verify? : Bool = true,
) -> Array[PdfBookmark] raise PdfError {
  let bookmarks : Array[PdfBookmark] = []
  let cursor = byte_cursor_of_view(data)
  for line_number, line in cursor.read_line_views() {
    match self.pdf_bookmark_import_parse_line(line, line_number + 1) {
      Some(bookmark) => bookmarks.push(bookmark)
      None => ()
    }
  }
  if verify && !self.pdf_bookmark_import_verify(bookmarks) {
    raise SoftError(
      "Bad bookmark file (References non-existant pages or is malformed)",
    )
  }
  bookmarks
}

///|
/// Return a copy of the document with bookmarks imported from cpdf text bytes.
pub fn PdfDocument::add_bookmarks_from_text(
  self : PdfDocument,
  data : BytesView,
  verify? : Bool = true,
) -> PdfDocument raise PdfError {
  self.add_bookmarks(self.parse_bookmark_text(data, verify~))
}

///|
/// Compatibility wrapper for `PdfDocument::parse_bookmark_text`.
pub fn pdf_parse_bookmark_text(
  verify : Bool,
  data : BytesView,
  document : PdfDocument,
) -> Array[PdfBookmark] raise PdfError {
  document.parse_bookmark_text(data, verify~)
}

///|
/// Compatibility wrapper matching cpdfbookmarks' plain `parse_bookmark_file`.
pub fn pdf_parse_bookmark_file(
  verify : Bool,
  document : PdfDocument,
  data : BytesView,
) -> Array[PdfBookmark] raise PdfError {
  document.parse_bookmark_text(data, verify~)
}

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

///|
fn pdf_bookmark_json_number(value : Json?) -> Double raise PdfError {
  match value {
    Some(Number(number, repr=_)) => number
    _ => raise SoftError("malformed mark in mark_of_json")
  }
}

///|
fn pdf_bookmark_json_bool(value : Json?) -> Bool raise PdfError {
  match value {
    Some(True) => true
    Some(False) => false
    _ => raise SoftError("malformed mark in mark_of_json")
  }
}

///|
fn pdf_bookmark_json_string(value : Json?) -> String raise PdfError {
  match value {
    Some(String(text)) => text
    _ => raise SoftError("malformed mark in mark_of_json")
  }
}

///|
fn pdf_bookmark_json_colour(value : Json?) -> PdfBookmarkColour raise PdfError {
  match value {
    Some(
      Array([Number(red, repr=_), Number(green, repr=_), Number(blue, repr=_)])
    ) => { red, green, blue }
    _ => raise SoftError("malformed mark in mark_of_json")
  }
}

///|
fn PdfDocument::pdf_bookmark_import_json_mark(
  self : PdfDocument,
  json : Json,
) -> PdfBookmark raise PdfError {
  match json {
    Object(entries) if entries.length() == 8 => {
      let level = pdf_util_json_truncated_number(
        pdf_bookmark_json_number(entries.get("level")),
      )
      let text = pdf_bookmark_json_string(entries.get("text"))
      let page_number = pdf_util_json_truncated_number(
        pdf_bookmark_json_number(entries.get("page")),
      )
      let target_json = match entries.get("target") {
        Some(value) => value
        None => raise SoftError("malformed mark in mark_of_json")
      }
      let is_open = pdf_bookmark_json_bool(entries.get("open"))
      let colour = pdf_bookmark_json_colour(entries.get("colour"))
      let italic = pdf_bookmark_json_bool(entries.get("italic"))
      let bold = pdf_bookmark_json_bool(entries.get("bold"))
      {
        level,
        text: pdf_pdfdocstring_of_utf8(@utf8.encode(text)),
        target: self.pdf_bookmark_import_target_object(
          page_number,
          pdf_object_of_json(target_json),
        ),
        is_open,
        colour,
        flags: (if italic { 1 } else { 0 }) | (if bold { 2 } else { 0 }),
      }
    }
    _ => raise SoftError("malformed mark in mark_of_json")
  }
}

///|
/// Parse cpdf's bookmark JSON array.
pub fn PdfDocument::parse_bookmark_json(
  self : PdfDocument,
  json : Json,
  verify? : Bool = true,
) -> Array[PdfBookmark] raise PdfError {
  let items = match json {
    Array(items) => items
    _ => raise SoftError("top level of JSON boomark file not a list")
  }
  let bookmarks : Array[PdfBookmark] = []
  for item in items {
    bookmarks.push(self.pdf_bookmark_import_json_mark(item))
  }
  if verify && !self.pdf_bookmark_import_verify(bookmarks) {
    raise SoftError(
      "Bad bookmark file (References non-existant pages or is malformed)",
    )
  }
  bookmarks
}

///|
/// Return a copy of the document with bookmarks imported from cpdf JSON rows.
pub fn PdfDocument::add_bookmarks_from_json(
  self : PdfDocument,
  json : Json,
  verify? : Bool = true,
) -> PdfDocument raise PdfError {
  self.add_bookmarks(self.parse_bookmark_json(json, verify~))
}

///|
/// Compatibility wrapper for `PdfDocument::parse_bookmark_json`.
pub fn pdf_parse_bookmark_json(
  verify : Bool,
  json : Json,
  document : PdfDocument,
) -> Array[PdfBookmark] raise PdfError {
  document.parse_bookmark_json(json, verify~)
}

///|
/// Compatibility wrapper for `PdfDocument::add_bookmarks_from_json`.
pub fn pdf_add_bookmarks_from_json(
  verify : Bool,
  json : Json,
  document : PdfDocument,
) -> PdfDocument raise PdfError {
  document.add_bookmarks_from_json(json, verify~)
}

///|
fn PdfDocument::pdf_bookmark_title(
  self : PdfDocument,
  outline : PdfObject,
) -> PdfBytes {
  match self.lookup_direct(pdf_bookmark_title_key(), outline) {
    Some(PdfString(title)) => title
    _ => @ascii.encode("")
  }
}

///|
fn PdfDocument::pdf_bookmark_target(
  self : PdfDocument,
  outline : PdfObject,
  preserve_actions : Bool,
) -> PdfDestination raise PdfError {
  match self.lookup_direct(pdf_bookmark_dest_key(), outline) {
    Some(PdfString(key)) if preserve_actions => DestString(key)
    Some(destination) =>
      self.read_destination(destination, shallow=preserve_actions)
    None =>
      match self.lookup_direct(pdf_bookmark_action_key(), outline) {
        None => DestNull
        Some(action) =>
          if preserve_actions {
            match self.lookup_direct(pdf_bookmark_d_key(), action) {
              Some(PdfArray(_) as destination) =>
                self.read_destination(destination, shallow=true)
              _ => DestAction(self.direct(action))
            }
          } else {
            match self.lookup_direct(pdf_bookmark_d_key(), action) {
              Some(destination) => self.read_destination(destination)
              None => DestAction(self.direct(action))
            }
          }
      }
  }
}

///|
fn PdfDocument::pdf_bookmark_is_open(
  self : PdfDocument,
  outline : PdfObject,
) -> Bool {
  match self.lookup_direct(pdf_bookmark_count_key(), outline) {
    Some(PdfInteger(count)) => count > 0
    _ => false
  }
}

///|
fn PdfDocument::pdf_bookmark_colour_number(
  self : PdfDocument,
  object : PdfObject,
) -> Double? {
  try self.get_number(object) catch {
    _ => None
  } noraise {
    value => Some(value)
  }
}

///|
fn PdfDocument::pdf_bookmark_colour(
  self : PdfDocument,
  outline : PdfObject,
) -> PdfBookmarkColour {
  match self.lookup_direct(pdf_bookmark_colour_key(), outline) {
    Some(PdfArray([red, green, blue])) =>
      match
        (
          self.pdf_bookmark_colour_number(red),
          self.pdf_bookmark_colour_number(green),
          self.pdf_bookmark_colour_number(blue),
        ) {
        (Some(red), Some(green), Some(blue)) => { red, green, blue }
        _ => pdf_bookmark_black
      }
    _ => pdf_bookmark_black
  }
}

///|
fn PdfDocument::pdf_bookmark_flags(
  self : PdfDocument,
  outline : PdfObject,
) -> Int {
  match self.lookup_direct(pdf_bookmark_flags_key(), outline) {
    Some(PdfInteger(flags)) => flags
    _ => 0
  }
}

///|
fn pdf_bookmark_colour_is_black(colour : PdfBookmarkColour) -> Bool {
  colour.red == 0.0 && colour.green == 0.0 && colour.blue == 0.0
}

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

///|
fn pdf_bookmark_basename(path : String) -> String {
  let mut start = 0
  for index in 0.. PdfObject {
  let entries : Array[(PdfName, PdfObject)] = [
    (pdf_bookmark_title_key(), PdfString(bookmark.text)),
  ]
  let destination = pdf_object_of_destination(bookmark.target)
  if destination != PdfNull {
    match bookmark.target {
      DestAction(action) => entries.push((pdf_bookmark_action_key(), action))
      _ => entries.push((pdf_bookmark_dest_key(), destination))
    }
  }
  if !pdf_bookmark_colour_is_black(bookmark.colour) {
    entries.push(
      (
        pdf_bookmark_colour_key(),
        PdfArray([
          PdfReal(bookmark.colour.red),
          PdfReal(bookmark.colour.green),
          PdfReal(bookmark.colour.blue),
        ]),
      ),
    )
  }
  if bookmark.flags != 0 {
    entries.push((pdf_bookmark_flags_key(), PdfInteger(bookmark.flags)))
  }
  PdfDictionary(entries)
}

///|
fn pdf_bookmark_child_stop(
  bookmarks : ArrayView[PdfBookmark],
  start : Int,
  stop : Int,
  level : Int,
) -> Int {
  let mut index = start
  while index < stop && bookmarks[index].level > level {
    index += 1
  }
  index
}

///|
fn pdf_bookmark_make_nodes(
  bookmarks : ArrayView[PdfBookmark],
  start : Int,
  stop : Int,
  base_object_number : Int,
  source : Ref[Int],
) -> Array[PdfBookmarkNode] {
  let nodes : Array[PdfBookmarkNode] = []
  let mut index = start
  while index < stop {
    let bookmark = bookmarks[index]
    source.val += 1
    let number = base_object_number + source.val
    let child_start = index + 1
    let child_stop = pdf_bookmark_child_stop(
      bookmarks,
      child_start,
      stop,
      bookmark.level,
    )
    nodes.push({
      number,
      object: pdf_bookmark_node_object(bookmark),
      children: pdf_bookmark_make_nodes(
        bookmarks, child_start, child_stop, base_object_number, source,
      ),
      is_open: bookmark.is_open,
    })
    index = child_stop
  }
  nodes
}

///|
fn pdf_bookmark_any_open(nodes : ArrayView[PdfBookmarkNode]) -> Bool {
  for node in nodes {
    if node.is_open || pdf_bookmark_any_open(node.children) {
      break true
    }
  } nobreak {
    false
  }
}

///|
fn pdf_bookmark_total_visible(nodes : ArrayView[PdfBookmarkNode]) -> Int {
  let mut total = 0
  for node in nodes {
    total += 1
    if node.is_open {
      total += pdf_bookmark_total_visible(node.children)
    }
  }
  total
}

///|
fn pdf_bookmark_add_count(
  object : PdfObject,
  children : ArrayView[PdfBookmarkNode],
  is_open : Bool,
) -> PdfObject raise PdfError {
  if children.length() == 0 {
    object
  } else {
    let visible = pdf_bookmark_total_visible(children)
    object.add_dict_entry(
      pdf_bookmark_count_key(),
      PdfInteger(if is_open { visible } else { -visible }),
    )
  }
}

///|
fn pdf_bookmark_finalize_nodes(
  nodes : ArrayView[PdfBookmarkNode],
  parent_number : Int,
) -> Array[PdfBookmarkNode] raise PdfError {
  let output : Array[PdfBookmarkNode] = []
  for index in 0.. 0 {
      object = object
        .add_dict_entry(
          pdf_bookmark_first_key(),
          PdfIndirect(children[0].number),
        )
        .add_dict_entry(
          pdf_bookmark_last_key(),
          PdfIndirect(children[children.length() - 1].number),
        )
    }
    object = pdf_bookmark_add_count(object, children, node.is_open)
    if index > 0 {
      object = object.add_dict_entry(
        pdf_bookmark_prev_key(),
        PdfIndirect(nodes[index - 1].number),
      )
    }
    if index + 1 < nodes.length() {
      object = object.add_dict_entry(
        pdf_bookmark_next_key(),
        PdfIndirect(nodes[index + 1].number),
      )
    }
    output.push({ ..node, object, children })
  }
  output
}

///|
fn pdf_bookmark_flatten_nodes(
  nodes : ArrayView[PdfBookmarkNode],
  output : Array[(Int, PdfObject)],
) -> Unit {
  for node in nodes {
    output.push((node.number, node.object))
    pdf_bookmark_flatten_nodes(node.children, output)
  }
}

///|
fn pdf_bookmark_outline_root_object(
  nodes : ArrayView[PdfBookmarkNode],
) -> PdfObject {
  let entries : Array[(PdfName, PdfObject)] = [
    (pdf_bookmark_first_key(), PdfIndirect(nodes[0].number)),
    (pdf_bookmark_last_key(), PdfIndirect(nodes[nodes.length() - 1].number)),
  ]
  if pdf_bookmark_any_open(nodes) {
    entries.push(
      (pdf_bookmark_count_key(), PdfInteger(pdf_bookmark_total_visible(nodes))),
    )
  }
  PdfDictionary(entries)
}

///|
fn PdfDocument::pdf_bookmark_of_outline(
  self : PdfDocument,
  level : Int,
  outline : PdfObject,
  preserve_actions : Bool,
) -> PdfBookmark raise PdfError {
  {
    level,
    text: self.pdf_bookmark_title(outline),
    target: self.pdf_bookmark_target(outline, preserve_actions),
    is_open: self.pdf_bookmark_is_open(outline),
    colour: self.pdf_bookmark_colour(outline),
    flags: self.pdf_bookmark_flags(outline),
  }
}

///|
fn PdfDocument::pdf_bookmark_traverse_siblings(
  self : PdfDocument,
  level : Int,
  first : PdfObject,
  preserve_actions : Bool,
  output : Array[PdfBookmark],
  depth : Int,
) -> Unit raise PdfError {
  if depth > 10000 {
    raise PageTreeExpected
  }
  output.push(self.pdf_bookmark_of_outline(level, first, preserve_actions))
  self.pdf_bookmark_traverse_children(
    level + 1,
    first,
    preserve_actions,
    output,
    depth + 1,
  )
  match self.lookup_direct(pdf_bookmark_next_key(), first) {
    Some(next) =>
      self.pdf_bookmark_traverse_siblings(
        level,
        next,
        preserve_actions,
        output,
        depth + 1,
      )
    None => ()
  }
}

///|
fn PdfDocument::pdf_bookmark_traverse_children(
  self : PdfDocument,
  level : Int,
  outline : PdfObject,
  preserve_actions : Bool,
  output : Array[PdfBookmark],
  depth : Int,
) -> Unit raise PdfError {
  match self.lookup_direct(pdf_bookmark_first_key(), outline) {
    Some(first) =>
      self.pdf_bookmark_traverse_siblings(
        level,
        first,
        preserve_actions,
        output,
        depth + 1,
      )
    None => ()
  }
}

///|
/// Read the document outline tree as flattened bookmarks.
///
/// When `preserve_actions` is false, actions with destinations are resolved to
/// destinations where possible. When it is true, action dictionaries and named
/// destinations are preserved more shallowly for round-tripping.
pub fn PdfDocument::read_bookmarks(
  self : PdfDocument,
  preserve_actions? : Bool = false,
) -> Array[PdfBookmark] raise PdfError {
  let output : Array[PdfBookmark] = []
  let root = self.pdf_bookmark_catalog()
  match self.lookup_direct(pdf_bookmark_outlines_key(), root) {
    Some(outlines) =>
      self.pdf_bookmark_traverse_children(
        0, outlines, preserve_actions, output, 0,
      )
    None => ()
  }
  output
}

///|
/// Compatibility wrapper for `PdfDocument::read_bookmarks`.
pub fn pdf_read_bookmarks(
  document : PdfDocument,
  preserve_actions? : Bool = false,
) -> Array[PdfBookmark] raise PdfError {
  document.read_bookmarks(preserve_actions~)
}

///|
fn PdfDocument::pdf_bookmark_has_outlines(
  self : PdfDocument,
) -> Bool raise PdfError {
  let root = self.pdf_bookmark_catalog()
  self.lookup_direct(pdf_bookmark_outlines_key(), root) is Some(_)
}

///|
fn pdf_bookmark_mark_ancestors(
  bookmarks : ArrayView[PdfBookmark],
  keep : Array[Bool],
  index : Int,
) -> Unit {
  let mut wanted_level = bookmarks[index].level - 1
  let mut current = index - 1
  while current >= 0 && wanted_level >= 0 {
    if bookmarks[current].level == wanted_level {
      keep[current] = true
      wanted_level -= 1
    }
    current -= 1
  }
}

///|
fn PdfDocument::pdf_bookmarks_for_pages(
  self : PdfDocument,
  page_numbers : ArrayView[Int],
) -> Array[PdfBookmark] raise PdfError {
  let bookmarks = self.read_bookmarks(preserve_actions=true)
  let page_reference_numbers = self.page_reference_numbers()
  let page_number_lookup = pdf_page_number_lookup(page_reference_numbers)
  let selected_pages = @number_set.pdf_number_set(page_numbers)
  let keep = [ for _ in bookmarks => false ]
  for index in 0.. PdfDocument raise PdfError {
  let root = self.pdf_bookmark_catalog()
  let pdf = self.copy()
  let root_without_outlines = root.remove_dict_entry(
    pdf_bookmark_outlines_key(),
  )
  let new_root = pdf.add_object(root_without_outlines)
  pdf.set_root(new_root)
  pdf.set_trailer_dict(
    pdf
    .trailer_dict()
    .add_dict_entry(pdf_bookmark_root_key(), PdfIndirect(new_root)),
  )
  pdf
}

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

///|
/// Return a copy of the document with its outline tree replaced.
///
/// The input must be in preorder using zero-based `level` values. Empty input
/// removes existing bookmarks. New outline objects are allocated after the
/// current maximum object number.
pub fn PdfDocument::add_bookmarks(
  self : PdfDocument,
  bookmarks : ArrayView[PdfBookmark],
) -> PdfDocument raise PdfError {
  if bookmarks.length() == 0 {
    self.remove_bookmarks()
  } else {
    let root = self.pdf_bookmark_catalog()
    let pdf = self.copy()
    let base_object_number = self.objects.max_object_number()
    let source : Ref[Int] = { val: 0 }
    let nodes = pdf_bookmark_make_nodes(
      bookmarks,
      0,
      bookmarks.length(),
      base_object_number,
      source,
    )
    let outline_root_number = base_object_number + source.val + 1
    let nodes = pdf_bookmark_finalize_nodes(nodes, outline_root_number)
    pdf.add_object_given_number(
      outline_root_number,
      pdf_bookmark_outline_root_object(nodes),
    )
    let pairs : Array[(Int, PdfObject)] = []
    pdf_bookmark_flatten_nodes(nodes, pairs)
    for pair in pairs {
      let (number, object) = pair
      pdf.add_object_given_number(number, object)
    }
    let new_root = pdf.add_object(
      root.add_dict_entry(
        pdf_bookmark_outlines_key(),
        PdfIndirect(outline_root_number),
      ),
    )
    pdf.set_root(new_root)
    pdf.set_trailer_dict(
      pdf
      .trailer_dict()
      .add_dict_entry(pdf_bookmark_root_key(), PdfIndirect(new_root)),
    )
    pdf
  }
}

///|
/// Compatibility wrapper for `PdfDocument::add_bookmarks`.
pub fn pdf_add_bookmarks(
  bookmarks : ArrayView[PdfBookmark],
  document : PdfDocument,
) -> PdfDocument raise PdfError {
  document.add_bookmarks(bookmarks)
}

///|
fn PdfDocument::pdf_bookmark_document_title(self : PdfDocument) -> String {
  let info_title = self.info_utf8(pdf_bookmark_title_key())
  let xmp_title = self.xmp_info(pdf_bookmark_title_key())
  if info_title == "" {
    xmp_title
  } else if xmp_title == "" {
    info_title
  } else {
    xmp_title
  }
}

///|
/// Add a top-level title bookmark pointing at page one.
///
/// Existing bookmarks are indented by one level. When `use_title` is true,
/// the document metadata title is preferred, matching cpdf's XMP-over-Info
/// precedence; otherwise the basename of `filename` is used.
pub fn PdfDocument::add_bookmark_title(
  self : PdfDocument,
  filename : String,
  use_title : Bool,
) -> PdfDocument raise PdfError {
  let title = if use_title {
    self.pdf_bookmark_document_title()
  } else {
    pdf_bookmark_basename(filename)
  }
  let page_object = match self.page_object_number(1) {
    Some(number) => number
    None => raise SoftError("add_bookmark_title: page not found")
  }
  let bookmarks = self.read_bookmarks(preserve_actions=true)
  let indented : Array[PdfBookmark] = [
    {
      level: 0,
      text: pdf_pdfdocstring_of_utf8(@utf8.encode(title)),
      target: DestXYZ(TargetPageObject(page_object), None, None, None),
      is_open: false,
      colour: pdf_bookmark_black,
      flags: 0,
    },
  ]
  for bookmark in bookmarks {
    indented.push({ ..bookmark, level: bookmark.level + 1 })
  }
  self.add_bookmarks(indented)
}

///|
/// Compatibility wrapper for `PdfDocument::add_bookmark_title`.
pub fn pdf_add_bookmark_title(
  filename : String,
  use_title : Bool,
  document : PdfDocument,
) -> PdfDocument raise PdfError {
  document.add_bookmark_title(filename, use_title)
}

///|
/// Return a copy of the document whose bookmarks are open below `level`.
pub fn PdfDocument::bookmarks_open_to_level(
  self : PdfDocument,
  level : Int,
) -> PdfDocument raise PdfError {
  let bookmarks = self.read_bookmarks(preserve_actions=true)
  let updated : Array[PdfBookmark] = []
  for bookmark in bookmarks {
    updated.push({ ..bookmark, is_open: bookmark.level < level })
  }
  self.add_bookmarks(updated)
}

///|
/// Compatibility wrapper for `PdfDocument::bookmarks_open_to_level`.
pub fn pdf_bookmarks_open_to_level(
  level : Int,
  document : PdfDocument,
) -> PdfDocument raise PdfError {
  document.bookmarks_open_to_level(level)
}

///|
fn pdf_bookmark_replacement_number(
  changes : ArrayView[(Int, Int)],
  number : Int,
) -> Int? {
  for change in changes {
    if change.0 == number {
      break Some(change.1)
    }
  } nobreak {
    None
  }
}

///|
fn pdf_bookmark_change_target_page(
  changes : ArrayView[(Int, Int)],
  target : PdfTargetPage,
) -> PdfTargetPage? {
  match target {
    TargetPageObject(number) =>
      match pdf_bookmark_replacement_number(changes, number) {
        Some(replacement) => Some(TargetPageObject(replacement))
        None => None
      }
    TargetOtherDocPageNumber(_) => None
  }
}

///|
fn pdf_bookmark_change_destination(
  changes : ArrayView[(Int, Int)],
  destination : PdfDestination,
) -> PdfDestination? {
  match destination {
    DestXYZ(target, left, top, zoom) =>
      match pdf_bookmark_change_target_page(changes, target) {
        Some(target) => Some(DestXYZ(target, left, top, zoom))
        None => None
      }
    DestFit(target) =>
      match pdf_bookmark_change_target_page(changes, target) {
        Some(target) => Some(DestFit(target))
        None => None
      }
    DestFitH(target, top) =>
      match pdf_bookmark_change_target_page(changes, target) {
        Some(target) => Some(DestFitH(target, top))
        None => None
      }
    DestFitV(target, left) =>
      match pdf_bookmark_change_target_page(changes, target) {
        Some(target) => Some(DestFitV(target, left))
        None => None
      }
    DestFitR(target, left, bottom, right, top) =>
      match pdf_bookmark_change_target_page(changes, target) {
        Some(target) => Some(DestFitR(target, left, bottom, right, top))
        None => None
      }
    DestFitB(target) =>
      match pdf_bookmark_change_target_page(changes, target) {
        Some(target) => Some(DestFit(target))
        None => None
      }
    DestFitBH(target, top) =>
      match pdf_bookmark_change_target_page(changes, target) {
        Some(target) => Some(DestFitBH(target, top))
        None => None
      }
    DestFitBV(target, left) =>
      match pdf_bookmark_change_target_page(changes, target) {
        Some(target) => Some(DestFitBV(target, left))
        None => None
      }
    _ => None
  }
}

///|
/// Renumber page-object targets in one bookmark.
///
/// `changes` contains `(old_object_number, new_object_number)` pairs. Targets
/// without a matching page-object number are preserved.
pub fn pdf_change_bookmark(
  changes : ArrayView[(Int, Int)],
  bookmark : PdfBookmark,
) -> PdfBookmark {
  match pdf_bookmark_change_destination(changes, bookmark.target) {
    Some(target) => { ..bookmark, target, }
    None => bookmark
  }
}

///|
/// Transform a bookmark destination by a matrix.
///
/// Only the destination is changed; level, text, open state, colour, and flags
/// are preserved.
pub fn PdfDocument::transform_bookmark(
  self : PdfDocument,
  matrix : TransformMatrix,
  bookmark : PdfBookmark,
) -> PdfBookmark raise PdfError {
  { ..bookmark, target: self.transform_destination(matrix, bookmark.target) }
}

///|
/// Compatibility wrapper for `PdfDocument::transform_bookmark`.
pub fn pdf_transform_bookmark(
  document : PdfDocument,
  matrix : TransformMatrix,
  bookmark : PdfBookmark,
) -> PdfBookmark raise PdfError {
  document.transform_bookmark(matrix, bookmark)
}