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

///|
fn pdf_composition_percent_text(percent : Double) -> String {
  let rounded = (percent * 100.0).round().to_int()
  let negative = rounded < 0
  let magnitude = if negative { -rounded } else { rounded }
  let whole = magnitude / 100
  let fraction = magnitude % 100
  let fraction_text = if fraction < 10 {
    "0" + fraction.to_string()
  } else {
    fraction.to_string()
  }
  (if negative { "-" } else { "" }) + whole.to_string() + "." + fraction_text
}

///|
fn pdf_composition_write_text_entry(
  output : Array[Byte],
  position : Int,
  entry : PdfCompositionEntry,
) -> Int {
  let mut current = pdf_composition_write_utf8(output, position, entry.label)
  current = pdf_composition_write_utf8(output, current, ": ")
  current = pdf_composition_write_utf8(output, current, entry.bytes.to_string())
  current = pdf_composition_write_utf8(output, current, " bytes (")
  current = pdf_composition_write_utf8(
    output,
    current,
    pdf_composition_percent_text(entry.percent),
  )
  pdf_composition_write_utf8(output, current, "%)\n")
}

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

///|
fn pdf_composition_text_entry_length(entry : PdfCompositionEntry) -> Int {
  pdf_composition_utf8_length(entry.label) +
  pdf_composition_utf8_length(": ") +
  pdf_composition_utf8_length(entry.bytes.to_string()) +
  pdf_composition_utf8_length(" bytes (") +
  pdf_composition_utf8_length(pdf_composition_percent_text(entry.percent)) +
  pdf_composition_utf8_length("%)\n")
}

///|
fn pdf_composition_text_length(entries : ArrayView[PdfCompositionEntry]) -> Int {
  let mut length = 0
  for entry in entries {
    length += pdf_composition_text_entry_length(entry)
  }
  length
}

///|
/// Format composition rows as the text cpdf's `show_composition` prints when
/// `json=false`.
pub fn pdf_composition_text_of_entries(
  entries : ArrayView[PdfCompositionEntry],
) -> Bytes {
  let output = Array::make(pdf_composition_text_length(entries), b'\x00')
  let mut position = 0
  for entry in entries {
    position = pdf_composition_write_text_entry(output, position, entry)
  }
  Bytes::from_array(output)
}

///|
/// Return bytes with one trailing newline, matching cpdf's printed JSON output.
pub fn pdf_composition_bytes_with_newline(bytes : BytesView) -> Bytes {
  let output = Array::make(bytes.length() + 1, b'\n')
  for i in 0..