///|
/// Text rotation values used by cpdf's add-text positioning helpers.
pub(all) enum PdfAddTextRotation {
  PdfAddTextRot0
  PdfAddTextRot90
  PdfAddTextRot180
  PdfAddTextRot270
} derive(Debug, Eq, ToJson)

///|
/// Text justification values used by cpdf's add-text positioning helpers.
pub(all) enum PdfAddTextJustification {
  PdfAddTextLeftJustify
  PdfAddTextCentreJustify
  PdfAddTextRightJustify
} derive(Debug, Eq, ToJson)

///|
/// Font source options for cpdf add-text.
///
/// This ports the source-level non-embedding `Cpdfembed.cpdffont` variants.
/// TrueType `EmbedInfo` remains a separate embedding slice.
pub(all) enum PdfAddTextFontSource {
  PdfAddTextPreMadeFontPack(PdfFontPack)
  PdfAddTextExistingNamedFont
} derive(Debug, Eq, ToJson)

///|
/// A URL span parsed from cpdf add-text `%URL[text|url]` markup.
///
/// `start` and `end` are byte offsets in the visible output line, matching
/// OCaml cpdf's `String.length` accounting.
pub(all) struct PdfAddTextUrl {
  url : String
  start : Int
  end : Int
} derive(Debug, Eq, ToJson)

///|
/// One add-text line after stripping `%URL[text|url]` markup.
pub(all) struct PdfAddTextUrlLine {
  text : String
  urls : Array[PdfAddTextUrl]
} derive(Debug, Eq, ToJson)

///|
/// Transformed geometry for one add-text URL annotation.
pub(all) struct PdfAddTextUrlAnnotationGeometry {
  p0 : Point2
  p1 : Point2
  p2 : Point2
  p3 : Point2
  rectangle : PdfRectangle
} derive(Debug, Eq, ToJson)

///|
/// Resolved placement values for one cpdf add-text line.
pub(all) struct PdfAddTextLinePlacement {
  origin : Point2
  base_rotation : Double
  text_rotation : Double
  rotation_offset : Point2
  horizontal_offset : Double
  vertical_offset : Double
  justification_offset : Double
} derive(Debug, Eq, ToJson)

///|
/// Width measurements for one cpdf add-text line and its expanded paragraph.
pub(all) struct PdfAddTextLineWidths {
  text_width : Double
  widths : Array[Double]
  longest_width : Double
} derive(Debug, Eq, ToJson)

///|
/// One cpdf add-text line after replacement expansion, URL stripping, width
/// measurement, and placement resolution.
pub(all) struct PdfAddTextPreparedLine {
  text : String
  text_bytes : PdfBytes
  urls : Array[PdfAddTextUrl]
  expanded_lines : Array[String]
  expanded_line_bytes : Array[PdfBytes]
  widths : PdfAddTextLineWidths
  placement : PdfAddTextLinePlacement
} derive(Debug, Eq, ToJson)

///|
/// Page resources prepared for one cpdf add-text line.
pub(all) struct PdfAddTextResourceSetup {
  resources : PdfObject
  font_dictionary : PdfObject
  unique_fontname : PdfName
  unique_fontnames : Array[PdfName]
  extgstate_name : PdfName?
} derive(Debug, Eq, ToJson)

///|
/// Values used to build cpdf add-text replacement callbacks.
pub(all) struct PdfAddTextReplacementValues {
  page_number : Int
  end_page : Int
  filename : String
  page_label : String
  end_label : String
  bates : Int
  bates_pad : Int?
  bookmarks : Array[String]
} derive(Debug, Eq, ToJson)

///|
let pdf_addtext_cpdf_stamp_name : PdfName = pdf_name_of_bytes(
  @ascii.encode("/CPDFSTAMP"),
)

///|
let pdf_addtext_extgstate_key_name : PdfName = pdf_name_of_bytes(
  @ascii.encode("/ExtGState"),
)

///|
let pdf_addtext_font_key_name : PdfName = pdf_name_of_bytes(
  @ascii.encode("/Font"),
)

///|
let pdf_addtext_annots_key_name : PdfName = pdf_name_of_bytes(
  @ascii.encode("/Annots"),
)

///|
let pdf_addtext_ca_key_name : PdfName = pdf_name_of_bytes(@ascii.encode("/ca"))

///|
let pdf_addtext_ca_stroke_key_name : PdfName = pdf_name_of_bytes(
  @ascii.encode("/CA"),
)

///|
let pdf_addtext_subtype_key_name : PdfName = pdf_name_of_bytes(
  @ascii.encode("/Subtype"),
)

///|
let pdf_addtext_link_name : PdfName = pdf_name_of_bytes(@ascii.encode("/Link"))

///|
let pdf_addtext_quadpoints_key_name : PdfName = pdf_name_of_bytes(
  @ascii.encode("/QuadPoints"),
)

///|
let pdf_addtext_rect_key_name : PdfName = pdf_name_of_bytes(
  @ascii.encode("/Rect"),
)

///|
let pdf_addtext_bs_key_name : PdfName = pdf_name_of_bytes(@ascii.encode("/BS"))

///|
let pdf_addtext_w_key_name : PdfName = pdf_name_of_bytes(@ascii.encode("/W"))

///|
let pdf_addtext_a_key_name : PdfName = pdf_name_of_bytes(@ascii.encode("/A"))

///|
let pdf_addtext_uri_key_name : PdfName = pdf_name_of_bytes(
  @ascii.encode("/URI"),
)

///|
let pdf_addtext_type_key_name : PdfName = pdf_name_of_bytes(
  @ascii.encode("/Type"),
)

///|
let pdf_addtext_action_name : PdfName = pdf_name_of_bytes(
  @ascii.encode("/Action"),
)

///|
let pdf_addtext_s_key_name : PdfName = pdf_name_of_bytes(@ascii.encode("/S"))

///|
fn pdf_addtext_cpdf_stamp() -> PdfName {
  pdf_addtext_cpdf_stamp_name
}

///|
fn pdf_addtext_extgstate_key() -> PdfName {
  pdf_addtext_extgstate_key_name
}

///|
fn pdf_addtext_font_key() -> PdfName {
  pdf_addtext_font_key_name
}

///|
fn pdf_addtext_annots_key() -> PdfName {
  pdf_addtext_annots_key_name
}

///|
fn pdf_addtext_ca_key() -> PdfName {
  pdf_addtext_ca_key_name
}

///|
fn pdf_addtext_ca_stroke_key() -> PdfName {
  pdf_addtext_ca_stroke_key_name
}

///|
fn pdf_addtext_validate_page_numbers(
  page_numbers : ArrayView[Int],
  end_page : Int,
  context : String,
) -> Unit raise PdfError {
  for page_number in page_numbers {
    if page_number < 1 || page_number > end_page {
      raise BadPageSpecification(
        context + ": range contains pages not present in pdf",
      )
    }
  }
}

///|
fn PdfDocument::pdf_addtext_opacity_resources(
  self : PdfDocument,
  resources : PdfObject,
  opacity : Double,
) -> (PdfObject, PdfName?) raise PdfError {
  if opacity < 1.0 {
    let extgstate = match
      self.lookup_direct(pdf_addtext_extgstate_key(), resources) {
      Some(dictionary) => dictionary
      None => PdfDictionary([])
    }
    let name = extgstate.unique_key("gs")
    let opacity_dictionary = PdfDictionary([
      (pdf_addtext_ca_key(), PdfReal(opacity)),
      (pdf_addtext_ca_stroke_key(), PdfReal(opacity)),
    ])
    (
      resources.add_dict_entry(
        pdf_addtext_extgstate_key(),
        extgstate.add_dict_entry(name, opacity_dictionary),
      ),
      Some(name),
    )
  } else {
    (resources, None)
  }
}

///|
/// Return resources updated with cpdf add-text opacity graphics state.
///
/// Opacity values below `1.0` allocate a unique `/gs*` entry under
/// `/ExtGState` with both fill and stroke alpha set. Opaque values leave the
/// resource dictionary unchanged and return no graphics-state name.
pub fn PdfDocument::addtext_opacity_resources(
  self : PdfDocument,
  resources : PdfObject,
  opacity : Double,
) -> (PdfObject, PdfName?) raise PdfError {
  self.pdf_addtext_opacity_resources(resources, opacity)
}

///|
/// Compatibility wrapper for `PdfDocument::addtext_opacity_resources`.
pub fn pdf_addtext_opacity_resources(
  document : PdfDocument,
  resources : PdfObject,
  opacity : Double,
) -> (PdfObject, PdfName?) raise PdfError {
  document.addtext_opacity_resources(resources, opacity)
}

///|
/// Build the cpdf add-text fill colour operator.
pub fn pdf_addtext_colour_op(colour : PdfContentColour) -> PdfContentOp {
  colour.pdf_tweak_fill_op()
}

///|
/// Source-spelled compatibility wrapper for cpdfaddtext's `colour_op`.
pub fn pdf_colour_op(colour : PdfContentColour) -> PdfContentOp {
  pdf_addtext_colour_op(colour)
}

///|
/// Build the cpdf add-text stroke colour operator.
pub fn pdf_addtext_colour_op_stroke(colour : PdfContentColour) -> PdfContentOp {
  colour.pdf_tweak_stroke_op()
}

///|
/// Source-spelled compatibility wrapper for cpdfaddtext's `colour_op_stroke`.
pub fn pdf_colour_op_stroke(colour : PdfContentColour) -> PdfContentOp {
  pdf_addtext_colour_op_stroke(colour)
}

///|
/// Convert UTF-8 add-text input to PDF charcode bytes for `font`.
///
/// Codepoints not representable by the font are skipped, matching cpdf's
/// `option_map` behavior in `charcodes_of_utf8`.
pub fn pdf_addtext_charcodes_of_utf8(
  font : PdfFont,
  text : String,
) -> PdfBytes raise PdfError {
  let extractor = pdf_charcode_extractor_of_font_real(font)
  let codepoints = pdf_codepoints_of_utf8(@utf8.encode(text))
  let mut length = 0
  for codepoint in codepoints {
    match extractor(codepoint) {
      Some(_) => length += 1
      None => ()
    }
  }
  let output = Array::make(length, b'\x00')
  let mut position = 0
  for codepoint in codepoints {
    match extractor(codepoint) {
      Some(charcode) => {
        output[position] = pdf_byte_of_int(charcode)
        position += 1
      }
      None => ()
    }
  }
  Bytes::from_array(output)
}

///|
/// Measure cpdf add-text charcode bytes with simple-font metrics.
///
/// This mirrors cpdf's narrow `width_of_text` helper: only simple fonts with
/// explicit `fontmetrics` are measured, and any missing metric makes the whole
/// result `0`.
pub fn pdf_addtext_width_of_text(font : PdfFont, text : BytesView) -> Double {
  match font {
    PdfFontSimple({ fontmetrics: Some(metrics), .. }) => {
      let mut width = 0.0
      for byte in text {
        let code = byte.to_int()
        if code >= metrics.length() {
          return 0.0
        }
        width += metrics[code]
      }
      width
    }
    _ => 0.0
  }
}

///|
/// Measure add-text bytes with the selected parsed font at `font_size`.
pub fn pdf_addtext_width_of_selected_font(
  font : PdfFont,
  font_size : Double,
  text : BytesView,
) -> Double {
  let raw_width = match font {
    PdfFontStandard({ font, .. }) =>
      font.text_width(PdfImplicitInFontFile, text).to_double()
    _ => pdf_addtext_width_of_text(font, text)
  }
  raw_width * font_size / 1000.0
}

///|
/// Measure UTF-8 add-text with a cpdf font pack at `font_size`.
pub fn pdf_addtext_width_of_fontpack(
  fontpack : PdfFontPack,
  font_size : Double,
  text : String,
) -> Double raise PdfError {
  let width_tables : Array[Array[Double]] = Array::new(
    capacity=fontpack.fonts.length(),
  )
  for font in fontpack.fonts {
    width_tables.push(pdf_type_font_widths(font, font_size))
  }
  let mut width = 0.0
  for codepoint in pdf_codepoints_of_utf8(@utf8.encode(text)) {
    match fontpack.get_char(codepoint) {
      Some((charcode, font_index, _)) => {
        let widths = width_tables[font_index]
        if charcode < 0 || charcode >= widths.length() {
          raise FontWidthExpected
        }
        width += widths[charcode]
      }
      None => ()
    }
  }
  width
}

///|
/// Measure text bytes with cpdf add-text's selected font mode.
///
/// This ports the local `calc_textwidth` branch in `cpdfaddtext.ml`. Font packs
/// interpret `text` as UTF-8 so codepoints can select pack members; selected
/// parsed fonts and existing page fonts treat `text` as the PDF text bytes to
/// measure.
pub fn PdfDocument::addtext_calc_textwidth(
  self : PdfDocument,
  page : PdfPage,
  font : PdfFont?,
  fontpack : PdfFontPack?,
  fontname : PdfName,
  font_size : Double,
  text : BytesView,
) -> Double raise PdfError {
  match fontpack {
    Some(fontpack) =>
      pdf_addtext_width_of_fontpack(
        fontpack,
        font_size,
        pdf_addtext_utf8_string(text),
      )
    None =>
      match font {
        Some(font) => pdf_addtext_width_of_selected_font(font, font_size, text)
        None => {
          let (_, width) = self.addtext_width_of_existing_font_resource(
            page, fontname, font_size, text,
          )
          width
        }
      }
  }
}

///|
/// Compatibility wrapper for `PdfDocument::addtext_calc_textwidth`.
pub fn pdf_addtext_calc_textwidth(
  document : PdfDocument,
  page : PdfPage,
  font : PdfFont?,
  fontpack : PdfFontPack?,
  fontname : PdfName,
  font_size : Double,
  text : BytesView,
) -> Double raise PdfError {
  document.addtext_calc_textwidth(
    page, font, fontpack, fontname, font_size, text,
  )
}

///|
/// Measure cpdf add-text's current line and expanded paragraph widths.
///
/// This ports the local `textwidth`, `allwidths`, and `longest_w` block in
/// `cpdfaddtext.ml` after replacement expansion and URL stripping.
pub fn PdfDocument::addtext_line_widths(
  self : PdfDocument,
  page : PdfPage,
  font : PdfFont?,
  fontpack : PdfFontPack?,
  fontname : PdfName,
  font_size : Double,
  text : BytesView,
  expanded_lines : ArrayView[Bytes],
) -> PdfAddTextLineWidths raise PdfError {
  let text_width = self.addtext_calc_textwidth(
    page, font, fontpack, fontname, font_size, text,
  )
  let widths : Array[Double] = Array::new(capacity=expanded_lines.length())
  for line in expanded_lines {
    widths.push(
      self.addtext_calc_textwidth(
        page, font, fontpack, fontname, font_size, line,
      ),
    )
  }
  { text_width, widths, longest_width: pdf_addtext_longest_width(widths) }
}

///|
/// Compatibility wrapper for `PdfDocument::addtext_line_widths`.
pub fn pdf_addtext_line_widths(
  document : PdfDocument,
  page : PdfPage,
  font : PdfFont?,
  fontpack : PdfFontPack?,
  fontname : PdfName,
  font_size : Double,
  text : BytesView,
  expanded_lines : ArrayView[Bytes],
) -> PdfAddTextLineWidths raise PdfError {
  document.addtext_line_widths(
    page, font, fontpack, fontname, font_size, text, expanded_lines,
  )
}

///|
/// Return the byte string cpdf add-text uses for measuring a visible line.
///
/// Raw mode and font-pack mode keep UTF-8 bytes. Selected simple-font mode
/// converts Unicode text to PDF character codes first, matching cpdf's
/// non-raw line measurement path.
pub fn pdf_addtext_measurement_bytes(
  font : PdfFont?,
  fontpack : PdfFontPack?,
  raw : Bool,
  text : String,
) -> PdfBytes raise PdfError {
  if raw || fontpack is Some(_) {
    @utf8.encode(text)
  } else {
    match font {
      Some(font) => pdf_addtext_charcodes_of_utf8(font, text)
      None => @utf8.encode(text)
    }
  }
}

///|
fn PdfDocument::pdf_addtext_measurement_bytes_for_page(
  self : PdfDocument,
  page : PdfPage,
  font : PdfFont?,
  fontpack : PdfFontPack?,
  fontname : PdfName,
  raw : Bool,
  text : String,
) -> PdfBytes raise PdfError {
  if raw || fontpack is Some(_) {
    @utf8.encode(text)
  } else {
    match font {
      Some(font) => pdf_addtext_charcodes_of_utf8(font, text)
      None => {
        let (_, font_object) = self.addtext_existing_font_resource(
          page, fontname,
        )
        pdf_addtext_charcodes_of_utf8(self.read_font(font_object), text)
      }
    }
  }
}

///|
/// Prepare one cpdf add-text line for later content and annotation emission.
///
/// This ports the source-level block which expands page-local replacement
/// fields, strips `%URL[text|url]` markup, measures the current and paragraph
/// line widths, and resolves the final cpdf placement offsets.
pub fn PdfDocument::addtext_prepare_line(
  self : PdfDocument,
  page : PdfPage,
  time : PdfStrftimeTime,
  lines : ArrayView[String],
  replacements : ArrayView[(String, () -> String)],
  font : PdfFont?,
  fontpack : PdfFontPack?,
  fontname : PdfName,
  font_size : Double,
  text : String,
  position : PdfPosition,
  relative_to_box? : PdfName = pdf_page_mediabox_key(),
  horizontal_offset? : Double = 0.0,
  vertical_offset? : Double = 0.0,
  justification? : PdfAddTextJustification = PdfAddTextLeftJustify,
  rotation? : PdfAddTextRotation = PdfAddTextRot0,
  raw? : Bool = false,
) -> PdfAddTextPreparedLine raise PdfError {
  let processed = pdf_addtext_process_text_with_time(time, text, replacements)
  let parsed = pdf_addtext_get_urls_line(processed)
  let expanded_lines = pdf_addtext_expand_lines_with_time(
    time, lines, replacements,
  )
  let text_bytes = self.pdf_addtext_measurement_bytes_for_page(
    page,
    font,
    fontpack,
    fontname,
    raw,
    parsed.text,
  )
  let expanded_line_bytes : Array[PdfBytes] = Array::new(
    capacity=expanded_lines.length(),
  )
  for line in expanded_lines {
    expanded_line_bytes.push(
      self.pdf_addtext_measurement_bytes_for_page(
        page, font, fontpack, fontname, raw, line,
      ),
    )
  }
  let widths = self.addtext_line_widths(
    page, font, fontpack, fontname, font_size, text_bytes, expanded_line_bytes,
  )
  let bounds = self.pdf_addtext_rectangle_bounds(page, relative_to_box)
  let placement = pdf_addtext_line_placement(
    bounds,
    position,
    rotation,
    widths.text_width,
    widths.longest_width,
    horizontal_offset,
    vertical_offset,
    justification,
  )
  {
    text: parsed.text,
    text_bytes,
    urls: parsed.urls,
    expanded_lines,
    expanded_line_bytes,
    widths,
    placement,
  }
}

///|
/// Compatibility wrapper for `PdfDocument::addtext_prepare_line`.
pub fn pdf_addtext_prepare_line(
  document : PdfDocument,
  page : PdfPage,
  time : PdfStrftimeTime,
  lines : ArrayView[String],
  replacements : ArrayView[(String, () -> String)],
  font : PdfFont?,
  fontpack : PdfFontPack?,
  fontname : PdfName,
  font_size : Double,
  text : String,
  position : PdfPosition,
  relative_to_box? : PdfName = pdf_page_mediabox_key(),
  horizontal_offset? : Double = 0.0,
  vertical_offset? : Double = 0.0,
  justification? : PdfAddTextJustification = PdfAddTextLeftJustify,
  rotation? : PdfAddTextRotation = PdfAddTextRot0,
  raw? : Bool = false,
) -> PdfAddTextPreparedLine raise PdfError {
  document.addtext_prepare_line(
    page,
    time,
    lines,
    replacements,
    font,
    fontpack,
    fontname,
    font_size,
    text,
    position,
    relative_to_box~,
    horizontal_offset~,
    vertical_offset~,
    justification~,
    rotation~,
    raw~,
  )
}

///|
/// Return the widest add-text line width.
pub fn pdf_addtext_longest_width(
  widths : ArrayView[Double],
) -> Double raise PdfError {
  if widths.length() == 0 {
    raise SoftError("empty add-text width list")
  }
  let mut longest = widths[0]
  for index in 1.. longest {
      longest = widths[index]
    }
  }
  longest
}

///|
/// Build cpdf add-rectangle content operators after placement is resolved.
pub fn pdf_addtext_rectangle_ops(
  x : Double,
  y : Double,
  width : Double,
  height : Double,
  colour : PdfContentColour,
  outline : Bool,
  line_width : Double,
  extgstate_name : PdfName?,
) -> Array[PdfContentOp] {
  let ops = [
    pdf_content_begin_artifact(),
    Op_q,
    Op_BMC(pdf_addtext_cpdf_stamp()),
    pdf_addtext_colour_op(colour),
    pdf_addtext_colour_op_stroke(colour),
  ]
  if outline {
    ops.push(Op_w(line_width))
  }
  match extgstate_name {
    Some(name) => ops.push(Op_gs(name))
    None => ()
  }
  ops.push(Op_re(x, y, width, height))
  ops.push(if outline { Op_s } else { Op_f })
  ops.push(Op_EMC)
  ops.push(Op_Q)
  ops.push(pdf_content_end_artifact())
  ops
}

///|
fn pdf_addtext_fontpack_text_ops(
  fontpack : PdfFontPack,
  unique_fontnames : ArrayView[PdfName],
  font_size : Double,
  text : String,
) -> Array[PdfContentOp] raise PdfError {
  let codepoints = pdf_codepoints_of_utf8(@utf8.encode(text))
  let triples : Array[(Int, Int, PdfFont)] = Array::new(
    capacity=codepoints.length(),
  )
  for codepoint in codepoints {
    match fontpack.get_char(codepoint) {
      Some(triple) => triples.push(triple)
      None => ()
    }
  }
  let ops : Array[PdfContentOp] = Array::new(capacity=triples.length() * 2)
  for run in pdf_fontpack_collate_runs(triples) {
    guard run.length() > 0 else { continue }
    let font_index = run[0].1
    if font_index < 0 || font_index >= unique_fontnames.length() {
      raise SoftError("bad add-text fontpack font name list")
    }
    let bytes = Array::make(run.length(), b'\x00')
    for index, triple in run {
      bytes[index] = pdf_byte_of_int(triple.0)
    }
    ops.push(Op_Tf(unique_fontnames[font_index], font_size))
    ops.push(Op_Tj(Bytes::from_array(bytes)))
  }
  ops
}

///|
fn pdf_addtext_font_text_ops_with_bytes(
  fontpack : PdfFontPack?,
  unique_fontname : PdfName,
  unique_fontnames : ArrayView[PdfName],
  font_size : Double,
  text : String,
  text_bytes : PdfBytes,
) -> Array[PdfContentOp] raise PdfError {
  match fontpack {
    Some(fontpack) =>
      pdf_addtext_fontpack_text_ops(fontpack, unique_fontnames, font_size, text)
    None => [Op_Tf(unique_fontname, font_size), Op_Tj(text_bytes)]
  }
}

///|
fn pdf_addtext_text_matrix(
  x : Double,
  y : Double,
  base_rotation : Double,
  horizontal_offset : Double,
  vertical_offset : Double,
  text_rotation : Double,
  rotation_h_offset : Double,
  rotation_v_offset : Double,
) -> TransformMatrix {
  transform_matrix_of_transform([
    Translate(
      x - horizontal_offset + rotation_h_offset,
      y - vertical_offset + rotation_v_offset,
    ),
    Rotate(point2(0.0, 0.0), base_rotation + text_rotation),
  ])
}

///|
fn pdf_addtext_text_ops_with_bytes(
  fontpack : PdfFontPack?,
  x : Double,
  y : Double,
  base_rotation : Double,
  horizontal_offset : Double,
  vertical_offset : Double,
  outline : Bool,
  line_width : Double,
  unique_fontname : PdfName,
  unique_fontnames : ArrayView[PdfName],
  extgstate_name : PdfName?,
  colour : PdfContentColour,
  font_size : Double,
  text : String,
  text_bytes : PdfBytes,
  text_rotation : Double,
  rotation_h_offset : Double,
  rotation_v_offset : Double,
) -> Array[PdfContentOp] raise PdfError {
  let textops = pdf_addtext_font_text_ops_with_bytes(
    fontpack, unique_fontname, unique_fontnames, font_size, text, text_bytes,
  )
  let ops = [
    pdf_content_begin_artifact(),
    Op_q,
    Op_BMC(pdf_addtext_cpdf_stamp()),
    Op_cm(
      pdf_addtext_text_matrix(
        x, y, base_rotation, horizontal_offset, vertical_offset, text_rotation, rotation_h_offset,
        rotation_v_offset,
      ),
    ),
    Op_BT,
  ]
  if outline {
    ops.push(Op_w(line_width))
    ops.push(Op_Tr(1))
  } else {
    ops.push(Op_Tr(0))
  }
  ops.push(pdf_addtext_colour_op(colour))
  ops.push(pdf_addtext_colour_op_stroke(colour))
  match extgstate_name {
    Some(name) => ops.push(Op_gs(name))
    None => ()
  }
  for op in textops {
    ops.push(op)
  }
  ops.push(Op_ET)
  ops.push(Op_EMC)
  ops.push(Op_Q)
  ops.push(pdf_content_end_artifact())
  ops
}

///|
/// Build cpdf add-text text-stamp content operators after placement is resolved.
///
/// This ports the source-level `cpdfaddtext.ml` `ops` helper, including
/// `/CPDFSTAMP` artifact wrapping, placement matrix, outline rendering mode,
/// colour operators, optional opacity graphics state, simple-font charcode
/// conversion, and fontpack run switching.
pub fn pdf_addtext_text_ops(
  font : PdfFont?,
  fontpack : PdfFontPack?,
  x : Double,
  y : Double,
  base_rotation : Double,
  horizontal_offset : Double,
  vertical_offset : Double,
  outline : Bool,
  line_width : Double,
  unique_fontname : PdfName,
  unique_fontnames : ArrayView[PdfName],
  extgstate_name : PdfName?,
  colour : PdfContentColour,
  font_size : Double,
  text : String,
  text_rotation : Double,
  rotation_h_offset : Double,
  rotation_v_offset : Double,
) -> Array[PdfContentOp] raise PdfError {
  let text_bytes = match font {
    Some(font) => pdf_addtext_charcodes_of_utf8(font, text)
    None => @utf8.encode(text)
  }
  pdf_addtext_text_ops_with_bytes(
    fontpack, x, y, base_rotation, horizontal_offset, vertical_offset, outline, line_width,
    unique_fontname, unique_fontnames, extgstate_name, colour, font_size, text, text_bytes,
    text_rotation, rotation_h_offset, rotation_v_offset,
  )
}

///|
fn pdf_addtext_degrees_to_radians(degrees : Double) -> Double {
  degrees * @math.PI / 180.0
}

///|
/// Convert cpdf add-text's explicit rotation enum to content rotation radians.
pub fn pdf_addtext_rotation_radians(rotation : PdfAddTextRotation) -> Double {
  match rotation {
    PdfAddTextRot0 => 0.0
    PdfAddTextRot90 => pdf_addtext_degrees_to_radians(270.0)
    PdfAddTextRot180 => pdf_addtext_degrees_to_radians(180.0)
    PdfAddTextRot270 => pdf_addtext_degrees_to_radians(90.0)
  }
}

///|
/// Return cpdf's rotation-dependent text origin offset.
pub fn pdf_addtext_rotation_offsets(
  position : PdfPosition,
  rotation : PdfAddTextRotation,
  text_width : Double,
) -> (Double, Double) {
  match position {
    PdfPositionTopLeft(_, _) =>
      match rotation {
        PdfAddTextRot0 | PdfAddTextRot90 => (0.0, 0.0)
        PdfAddTextRot180 => (text_width, 0.0)
        PdfAddTextRot270 => (0.0, -text_width)
      }
    PdfPositionTop(_) =>
      match rotation {
        PdfAddTextRot0 => (0.0, 0.0)
        PdfAddTextRot90 => (text_width / 2.0, 0.0)
        PdfAddTextRot180 => (text_width, 0.0)
        PdfAddTextRot270 => (text_width / 2.0, -text_width)
      }
    PdfPositionTopRight(_, _) =>
      match rotation {
        PdfAddTextRot0 => (0.0, 0.0)
        PdfAddTextRot90 | PdfAddTextRot180 => (text_width, 0.0)
        PdfAddTextRot270 => (text_width, -text_width)
      }
    PdfPositionRight(_) | PdfPositionPosRight(_, _) =>
      match rotation {
        PdfAddTextRot0 => (0.0, 0.0)
        PdfAddTextRot90 => (text_width, text_width / 2.0)
        PdfAddTextRot180 => (text_width, 0.0)
        PdfAddTextRot270 => (text_width, -text_width / 2.0)
      }
    PdfPositionBottomRight(_, _) =>
      match rotation {
        PdfAddTextRot0 => (0.0, 0.0)
        PdfAddTextRot90 => (text_width, text_width)
        PdfAddTextRot180 => (text_width, 0.0)
        PdfAddTextRot270 => (text_width, 0.0)
      }
    PdfPositionBottom(_) =>
      match rotation {
        PdfAddTextRot0 => (0.0, 0.0)
        PdfAddTextRot90 => (text_width / 2.0, text_width)
        PdfAddTextRot180 => (text_width, 0.0)
        PdfAddTextRot270 => (text_width / 2.0, 0.0)
      }
    PdfPositionBottomLeft(_, _) =>
      match rotation {
        PdfAddTextRot0 => (0.0, 0.0)
        PdfAddTextRot90 => (0.0, text_width)
        PdfAddTextRot180 => (text_width, 0.0)
        PdfAddTextRot270 => (0.0, 0.0)
      }
    PdfPositionLeft(_) | PdfPositionPosLeft(_, _) =>
      match rotation {
        PdfAddTextRot0 => (0.0, 0.0)
        PdfAddTextRot90 => (0.0, text_width / 2.0)
        PdfAddTextRot180 => (text_width, 0.0)
        PdfAddTextRot270 => (0.0, -text_width / 2.0)
      }
    PdfPositionCentre | PdfPositionPosCentre(_, _) =>
      match rotation {
        PdfAddTextRot0 => (0.0, 0.0)
        PdfAddTextRot90 => (text_width / 2.0, text_width / 2.0)
        PdfAddTextRot180 => (text_width, 0.0)
        PdfAddTextRot270 => (text_width / 2.0, -(text_width / 2.0))
      }
    PdfPositionDiagonal | PdfPositionReverseDiagonal => (0.0, 0.0)
  }
}

///|
/// Adjust cpdf add-text offsets for diagonal and reverse-diagonal placement.
pub fn pdf_addtext_diagonal_offsets(
  bounds : PdfRectangle,
  position : PdfPosition,
  rotation : PdfAddTextRotation,
  text_width : Double,
  horizontal_offset : Double,
  vertical_offset : Double,
  base_rotation : Double,
) -> (Double, Double) {
  match position {
    PdfPositionDiagonal | PdfPositionReverseDiagonal => {
      let rotate = if rotation == PdfAddTextRot180 {
        base_rotation + @math.PI
      } else {
        base_rotation
      }
      let horizontal = -(@math.cos(@math.PI / 2.0 - rotate) * vertical_offset)
      let vertical = @math.sin(@math.PI / 2.0 - rotate) * vertical_offset
      if rotation == PdfAddTextRot180 {
        let angle = @math.atan(
          (bounds.max_y - bounds.min_y) / (bounds.max_x - bounds.min_x),
        )
        let dx = @math.cos(angle) * text_width
        let dy = @math.sin(angle) * text_width
        match position {
          PdfPositionDiagonal => (horizontal - dx, vertical - dy)
          PdfPositionReverseDiagonal => (horizontal - dx, vertical + dy)
          _ => (horizontal, vertical)
        }
      } else {
        (horizontal, vertical)
      }
    }
    _ => (horizontal_offset, vertical_offset)
  }
}

///|
/// Return cpdf's initial `(horizontal, vertical)` offset for a multi-line
/// add-text paragraph.
pub fn pdf_addtext_initial_line_offsets(
  position : PdfPosition,
  line_count : Int,
  line_spacing : Double,
  font_size : Double,
) -> (Double, Double) {
  let line_delta = (line_count - 1).to_double()
  let paragraph_height = line_spacing * font_size * line_delta
  let vertical = match position {
    PdfPositionBottomLeft(_, _)
    | PdfPositionBottom(_)
    | PdfPositionBottomRight(_, _) => -paragraph_height
    PdfPositionLeft(_) | PdfPositionRight(_) => -(paragraph_height / 2.0)
    PdfPositionDiagonal | PdfPositionReverseDiagonal =>
      -(paragraph_height / 2.0)
    _ => 0.0
  }
  (0.0, vertical)
}

///|
/// Return cpdf's per-line `(horizontal, vertical)` advance for add-text.
pub fn pdf_addtext_line_advance(
  rotation : PdfAddTextRotation,
  line_spacing : Double,
  font_size : Double,
) -> (Double, Double) {
  let advance = line_spacing * font_size
  match rotation {
    PdfAddTextRot90 | PdfAddTextRot270 => (advance, 0.0)
    PdfAddTextRot0 | PdfAddTextRot180 => (0.0, advance)
  }
}

///|
fn pdf_addtext_rotation_adjustment_offsets(
  rotation : PdfAddTextRotation,
  adjustment : Double,
) -> (Double, Double) {
  match rotation {
    PdfAddTextRot0 => (0.0, adjustment)
    PdfAddTextRot90 => (adjustment, 0.0)
    PdfAddTextRot180 => (0.0, -adjustment)
    PdfAddTextRot270 => (-adjustment, 0.0)
  }
}

///|
/// Return cpdf's `(horizontal, vertical)` baseline shift for midline/topline
/// add-text placement.
pub fn pdf_addtext_baseline_offsets(
  font : PdfFont?,
  font_size : Double,
  position : PdfPosition,
  rotation : PdfAddTextRotation,
  midline : Bool,
  topline : Bool,
) -> (Double, Double) {
  if midline {
    match font {
      Some(PdfFontStandard({ font, .. })) =>
        pdf_addtext_rotation_adjustment_offsets(
          rotation,
          font_size *
          pdf_standard14_baseline_adjustment(font).to_double() /
          1000.0,
        )
      Some(PdfFontSimple({ fontdescriptor: Some(descriptor), .. })) =>
        pdf_addtext_rotation_adjustment_offsets(
          rotation,
          font_size * descriptor.capheight / 2.0 / 1000.0,
        )
      _ => (0.0, 0.0)
    }
  } else if topline {
    match font {
      Some(PdfFontStandard({ font, .. })) => {
        let adjustment = font_size *
          pdf_standard14_baseline_adjustment(font).to_double() *
          2.0 /
          1000.0
        match position {
          PdfPositionDiagonal | PdfPositionReverseDiagonal => (0.0, adjustment)
          _ => pdf_addtext_rotation_adjustment_offsets(rotation, adjustment)
        }
      }
      Some(PdfFontSimple({ fontdescriptor: Some(descriptor), .. })) => {
        let adjustment = font_size * descriptor.capheight / 1000.0
        match position {
          PdfPositionDiagonal | PdfPositionReverseDiagonal => (0.0, adjustment)
          _ => pdf_addtext_rotation_adjustment_offsets(rotation, adjustment)
        }
      }
      _ => (0.0, 0.0)
    }
  } else {
    (0.0, 0.0)
  }
}

///|
/// Return the text encoding cpdf add-text uses for the selected font.
pub fn pdf_addtext_effective_encoding(font : PdfFont?) -> PdfEncoding {
  match font {
    Some(PdfFontStandard({ encoding, .. })) => encoding
    Some(PdfFontSimple({ encoding, .. })) => encoding
    _ => PdfWinAnsiEncoding
  }
}

///|
/// Return the page font resource object used by cpdf add-text setup.
///
/// Missing `/Font` resources are treated as an empty dictionary. Present values
/// are resolved through the document and returned as-is, matching cpdf's
/// `Pdf.lookup_direct` setup before unique font-name allocation.
pub fn PdfDocument::addtext_page_font_dictionary(
  self : PdfDocument,
  page : PdfPage,
) -> PdfObject {
  match self.lookup_direct(pdf_addtext_font_key(), page.resources) {
    Some(fontdict) => fontdict
    None => PdfDictionary([])
  }
}

///|
/// Compatibility wrapper for `PdfDocument::addtext_page_font_dictionary`.
pub fn pdf_addtext_page_font_dictionary(
  document : PdfDocument,
  page : PdfPage,
) -> PdfObject {
  document.addtext_page_font_dictionary(page)
}

///|
/// Allocate cpdf add-text font resource names for the selected font and
/// optional font pack.
pub fn pdf_addtext_allocate_font_names(
  fontdict : PdfObject,
  fontpack : PdfFontPack?,
) -> (PdfName, PdfObject, Array[PdfName]) raise PdfError {
  let unique_fontname = fontdict.unique_key("F")
  let mut updated = fontdict
  let unique_fontnames : Array[PdfName] = match fontpack {
    Some(pack) => Array::new(capacity=pack.fonts.length())
    None => []
  }
  match fontpack {
    Some(pack) =>
      for _ in pack.fonts {
        let key = updated.unique_key("F")
        updated = updated.add_dict_entry(key, PdfNull)
        unique_fontnames.push(key)
      }
    None => ()
  }
  (unique_fontname, updated, unique_fontnames)
}

///|
/// Replace cpdf add-text fontpack placeholders with embedded font references.
pub fn pdf_addtext_fontpack_resource_dictionary(
  fontdict : PdfObject,
  unique_fontnames : ArrayView[PdfName],
  font_objects : ArrayView[Int],
) -> PdfObject raise PdfError {
  if font_objects.length() != unique_fontnames.length() {
    raise SoftError("bad add-text fontpack resource list")
  }
  let mut updated = fontdict
  for index in 0.. PdfObject raise PdfError {
  match font_object {
    Some(font_object) =>
      resources.add_dict_entry(
        pdf_addtext_font_key(),
        fontdict.add_dict_entry(unique_fontname, font_object),
      )
    None => resources
  }
}

///|
/// Install cpdf add-text fontpack resources into a page resource dictionary.
pub fn pdf_addtext_install_fontpack_resources(
  resources : PdfObject,
  fontdict : PdfObject,
  unique_fontnames : ArrayView[PdfName],
  font_objects : ArrayView[Int],
) -> PdfObject raise PdfError {
  resources.add_dict_entry(
    pdf_addtext_font_key(),
    pdf_addtext_fontpack_resource_dictionary(
      fontdict, unique_fontnames, font_objects,
    ),
  )
}

///|
/// Write the selected cpdf add-text font and return its indirect reference.
///
/// Existing-named-font mode has no selected font to write, so `None` is
/// preserved for callers that later reuse the page's current font resource.
pub fn PdfDocument::addtext_selected_font_object(
  self : PdfDocument,
  font : PdfFont?,
) -> PdfObject? raise PdfError {
  match font {
    Some(font) => Some(PdfIndirect(self.write_font(font)))
    None => None
  }
}

///|
/// Compatibility wrapper for `PdfDocument::addtext_selected_font_object`.
pub fn pdf_addtext_selected_font_object(
  document : PdfDocument,
  font : PdfFont?,
) -> PdfObject? raise PdfError {
  document.addtext_selected_font_object(font)
}

///|
/// Prepare page resources for one cpdf add-text line.
///
/// Selected-font and fontpack modes install new `/Font` entries and optional
/// opacity resources. Existing-named-font mode reuses the page resources
/// unchanged and suppresses opacity graphics state selection, matching cpdf's
/// `font = None` branch.
pub fn PdfDocument::addtext_resource_setup(
  self : PdfDocument,
  page : PdfPage,
  font : PdfFont?,
  fontpack : PdfFontPack?,
  fontname : PdfName,
  font_object : PdfObject?,
  fontpack_font_objects : ArrayView[Int],
  opacity : Double,
) -> PdfAddTextResourceSetup raise PdfError {
  let fontdict = self.addtext_page_font_dictionary(page)
  match (font, fontpack) {
    (None, None) =>
      {
        resources: page.resources,
        font_dictionary: fontdict,
        unique_fontname: fontname,
        unique_fontnames: [],
        extgstate_name: None,
      }
    (_, Some(fontpack)) => {
      let (resources, extgstate_name) = self.addtext_opacity_resources(
        page.resources,
        opacity,
      )
      let (unique_fontname, reserved_fontdict, unique_fontnames) = pdf_addtext_allocate_font_names(
        fontdict,
        Some(fontpack),
      )
      {
        resources: pdf_addtext_install_fontpack_resources(
          resources, reserved_fontdict, unique_fontnames, fontpack_font_objects,
        ),
        font_dictionary: reserved_fontdict,
        unique_fontname,
        unique_fontnames,
        extgstate_name,
      }
    }
    (Some(_), None) => {
      let (resources, extgstate_name) = self.addtext_opacity_resources(
        page.resources,
        opacity,
      )
      let (unique_fontname, fontdict, unique_fontnames) = pdf_addtext_allocate_font_names(
        fontdict,
        None,
      )
      {
        resources: pdf_addtext_install_font_resource(
          resources, fontdict, unique_fontname, font_object,
        ),
        font_dictionary: fontdict,
        unique_fontname,
        unique_fontnames,
        extgstate_name,
      }
    }
  }
}

///|
/// Compatibility wrapper for `PdfDocument::addtext_resource_setup`.
pub fn pdf_addtext_resource_setup(
  document : PdfDocument,
  page : PdfPage,
  font : PdfFont?,
  fontpack : PdfFontPack?,
  fontname : PdfName,
  font_object : PdfObject?,
  fontpack_font_objects : ArrayView[Int],
  opacity : Double,
) -> PdfAddTextResourceSetup raise PdfError {
  document.addtext_resource_setup(
    page, font, fontpack, fontname, font_object, fontpack_font_objects, opacity,
  )
}

///|
fn pdf_addtext_height_font_name(fontname : PdfName) -> String {
  let text = @ascii.decode_lossy(fontname.bytes())
  if text.has_prefix("/") {
    text[1:].to_owned()
  } else {
    text
  }
}

///|
fn PdfDocument::pdf_addtext_effective_fontname(
  self : PdfDocument,
  page : PdfPage,
  font : PdfFont?,
  fontpack : PdfFontPack?,
  fontname : PdfName,
) -> PdfName raise PdfError {
  match (font, fontpack) {
    (None, None) => {
      let (resource_name, _) = self.addtext_existing_font_resource(
        page, fontname,
      )
      resource_name
    }
    _ => fontname
  }
}

///|
/// Add one prepared cpdf add-text line to a page.
///
/// This is the single-page counterpart of cpdf's local `addtext_page`: it
/// prepares text and placement, installs resources, emits `/CPDFSTAMP` text
/// operators, creates URL annotations, and appends or prepends the content.
pub fn PdfDocument::addtext_line_page(
  self : PdfDocument,
  page : PdfPage,
  time : PdfStrftimeTime,
  lines : ArrayView[String],
  replacements : ArrayView[(String, () -> String)],
  font : PdfFont?,
  fontpack : PdfFontPack?,
  fontname : PdfName,
  font_object : PdfObject?,
  fontpack_font_objects : ArrayView[Int],
  font_size : Double,
  text : String,
  colour : PdfContentColour,
  position : PdfPosition,
  relative_to_box? : PdfName = pdf_page_mediabox_key(),
  horizontal_offset? : Double = 0.0,
  vertical_offset? : Double = 0.0,
  shift_x? : Double = 0.0,
  shift_y? : Double = 0.0,
  opacity? : Double = 1.0,
  justification? : PdfAddTextJustification = PdfAddTextLeftJustify,
  rotation? : PdfAddTextRotation = PdfAddTextRot0,
  outline? : Bool = false,
  line_width? : Double = 1.0,
  underneath? : Bool = false,
  fast? : Bool = false,
  raw? : Bool = false,
  url_border? : Bool = false,
) -> PdfPage raise PdfError {
  let effective_fontname = self.pdf_addtext_effective_fontname(
    page, font, fontpack, fontname,
  )
  let prepared = self.addtext_prepare_line(
    page,
    time,
    lines,
    replacements,
    font,
    fontpack,
    effective_fontname,
    font_size,
    text,
    position,
    relative_to_box~,
    horizontal_offset~,
    vertical_offset~,
    justification~,
    rotation~,
    raw~,
  )
  let setup = self.addtext_resource_setup(
    page, font, fontpack, effective_fontname, font_object, fontpack_font_objects,
    opacity,
  )
  let ops = pdf_addtext_text_ops_with_bytes(
    fontpack,
    prepared.placement.origin.x + shift_x,
    prepared.placement.origin.y + shift_y,
    prepared.placement.base_rotation,
    prepared.placement.horizontal_offset,
    prepared.placement.vertical_offset,
    outline,
    line_width,
    setup.unique_fontname,
    setup.unique_fontnames,
    setup.extgstate_name,
    colour,
    font_size,
    prepared.text,
    prepared.text_bytes,
    prepared.placement.text_rotation,
    prepared.placement.rotation_offset.x,
    prepared.placement.rotation_offset.y,
  )
  let annotations = self.addtext_url_annotation_references(
    prepared.text,
    prepared.urls,
    fn(prefix) raise PdfError {
      let bytes = self.pdf_addtext_measurement_bytes_for_page(
        page, font, fontpack, effective_fontname, raw, prefix,
      )
      self.addtext_calc_textwidth(
        page, font, fontpack, effective_fontname, font_size, bytes,
      )
    },
    prepared.placement.origin,
    prepared.placement.horizontal_offset,
    prepared.placement.vertical_offset,
    prepared.placement.justification_offset,
    prepared.placement.text_rotation,
    prepared.placement.rotation_offset,
    prepared.placement.base_rotation,
    font,
    pdf_addtext_height_font_name(effective_fontname),
    font_size,
    url_border,
  )
  let page = {
    ..page,
    resources: setup.resources,
    rest: self.addtext_page_rest_with_annotations(page.rest, annotations),
  }
  if underneath {
    self.prepend_operators(page, ops, fast~)
  } else {
    self.postpend_operators(page, ops, fast~)
  }
}

///|
/// Compatibility wrapper for `PdfDocument::addtext_line_page`.
pub fn pdf_addtext_line_page(
  document : PdfDocument,
  page : PdfPage,
  time : PdfStrftimeTime,
  lines : ArrayView[String],
  replacements : ArrayView[(String, () -> String)],
  font : PdfFont?,
  fontpack : PdfFontPack?,
  fontname : PdfName,
  font_object : PdfObject?,
  fontpack_font_objects : ArrayView[Int],
  font_size : Double,
  text : String,
  colour : PdfContentColour,
  position : PdfPosition,
  relative_to_box? : PdfName = pdf_page_mediabox_key(),
  horizontal_offset? : Double = 0.0,
  vertical_offset? : Double = 0.0,
  shift_x? : Double = 0.0,
  shift_y? : Double = 0.0,
  opacity? : Double = 1.0,
  justification? : PdfAddTextJustification = PdfAddTextLeftJustify,
  rotation? : PdfAddTextRotation = PdfAddTextRot0,
  outline? : Bool = false,
  line_width? : Double = 1.0,
  underneath? : Bool = false,
  fast? : Bool = false,
  raw? : Bool = false,
  url_border? : Bool = false,
) -> PdfPage raise PdfError {
  document.addtext_line_page(
    page,
    time,
    lines,
    replacements,
    font,
    fontpack,
    fontname,
    font_object,
    fontpack_font_objects,
    font_size,
    text,
    colour,
    position,
    relative_to_box~,
    horizontal_offset~,
    vertical_offset~,
    shift_x~,
    shift_y~,
    opacity~,
    justification~,
    rotation~,
    outline~,
    line_width~,
    underneath~,
    fast~,
    raw~,
    url_border~,
  )
}

///|
/// Add all cpdf add-text lines to one page using cpdf paragraph advancement.
///
/// This ports the outer per-line loop around cpdf's local `addtext` call:
/// initial multi-line offsets, midline/topline baseline adjustment, repeated
/// line emission, and rotation-dependent line advancement.
pub fn PdfDocument::addtext_page_lines(
  self : PdfDocument,
  page : PdfPage,
  time : PdfStrftimeTime,
  lines : ArrayView[String],
  replacements : ArrayView[(String, () -> String)],
  font : PdfFont?,
  fontpack : PdfFontPack?,
  fontname : PdfName,
  font_object : PdfObject?,
  fontpack_font_objects : ArrayView[Int],
  font_size : Double,
  line_spacing : Double,
  colour : PdfContentColour,
  position : PdfPosition,
  relative_to_box? : PdfName = pdf_page_mediabox_key(),
  opacity? : Double = 1.0,
  justification? : PdfAddTextJustification = PdfAddTextLeftJustify,
  rotation? : PdfAddTextRotation = PdfAddTextRot0,
  midline? : Bool = false,
  topline? : Bool = false,
  shift_x? : Double = 0.0,
  shift_y? : Double = 0.0,
  outline? : Bool = false,
  line_width? : Double = 1.0,
  underneath? : Bool = false,
  fast? : Bool = false,
  raw? : Bool = false,
  url_border? : Bool = false,
) -> PdfPage raise PdfError {
  let (initial_h_offset, initial_v_offset) = pdf_addtext_initial_line_offsets(
    position,
    lines.length(),
    line_spacing,
    font_size,
  )
  let (baseline_h_offset, baseline_v_offset) = pdf_addtext_baseline_offsets(
    font, font_size, position, rotation, midline, topline,
  )
  let advance = pdf_addtext_line_advance(rotation, line_spacing, font_size)
  let mut horizontal_offset = initial_h_offset + baseline_h_offset
  let mut vertical_offset = initial_v_offset + baseline_v_offset
  let mut output = page
  for line in lines {
    output = self.addtext_line_page(
      output,
      time,
      lines,
      replacements,
      font,
      fontpack,
      fontname,
      font_object,
      fontpack_font_objects,
      font_size,
      line,
      colour,
      position,
      relative_to_box~,
      horizontal_offset~,
      vertical_offset~,
      shift_x~,
      shift_y~,
      opacity~,
      justification~,
      rotation~,
      outline~,
      line_width~,
      underneath~,
      fast~,
      raw~,
      url_border~,
    )
    horizontal_offset += advance.0
    vertical_offset += advance.1
  }
  output
}

///|
/// Compatibility wrapper for `PdfDocument::addtext_page_lines`.
pub fn pdf_addtext_page_lines(
  document : PdfDocument,
  page : PdfPage,
  time : PdfStrftimeTime,
  lines : ArrayView[String],
  replacements : ArrayView[(String, () -> String)],
  font : PdfFont?,
  fontpack : PdfFontPack?,
  fontname : PdfName,
  font_object : PdfObject?,
  fontpack_font_objects : ArrayView[Int],
  font_size : Double,
  line_spacing : Double,
  colour : PdfContentColour,
  position : PdfPosition,
  relative_to_box? : PdfName = pdf_page_mediabox_key(),
  opacity? : Double = 1.0,
  justification? : PdfAddTextJustification = PdfAddTextLeftJustify,
  rotation? : PdfAddTextRotation = PdfAddTextRot0,
  midline? : Bool = false,
  topline? : Bool = false,
  shift_x? : Double = 0.0,
  shift_y? : Double = 0.0,
  outline? : Bool = false,
  line_width? : Double = 1.0,
  underneath? : Bool = false,
  fast? : Bool = false,
  raw? : Bool = false,
  url_border? : Bool = false,
) -> PdfPage raise PdfError {
  document.addtext_page_lines(
    page,
    time,
    lines,
    replacements,
    font,
    fontpack,
    fontname,
    font_object,
    fontpack_font_objects,
    font_size,
    line_spacing,
    colour,
    position,
    relative_to_box~,
    opacity~,
    justification~,
    rotation~,
    midline~,
    topline~,
    shift_x~,
    shift_y~,
    outline~,
    line_width~,
    underneath~,
    fast~,
    raw~,
    url_border~,
  )
}

///|
/// Add cpdf add-text lines to selected pages with an already resolved font.
///
/// This ports the document/page-selection layer after font selection: selected
/// one-based pages get page-local replacement callbacks, cpdf shift-coordinate
/// parsing, and page-line emission. Font embedding and existing-font discovery
/// remain separate slices.
pub fn PdfDocument::addtext_pages_with_font(
  self : PdfDocument,
  page_numbers : ArrayView[Int],
  lines : ArrayView[String],
  time : PdfStrftimeTime,
  bookmarks : ArrayView[PdfBookmark],
  font : PdfFont?,
  fontpack : PdfFontPack?,
  fontname : PdfName,
  font_object : PdfObject?,
  fontpack_font_objects : ArrayView[Int],
  font_size : Double,
  line_spacing : Double,
  colour : PdfContentColour,
  position : PdfPosition,
  filename? : String = "",
  bates? : Int = 1,
  bates_pad? : Int? = None,
  relative_to_box? : PdfName = pdf_page_mediabox_key(),
  shift? : String = "0 0",
  opacity? : Double = 1.0,
  justification? : PdfAddTextJustification = PdfAddTextLeftJustify,
  rotation? : PdfAddTextRotation = PdfAddTextRot0,
  midline? : Bool = false,
  topline? : Bool = false,
  outline? : Bool = false,
  line_width? : Double = 1.0,
  underneath? : Bool = false,
  fast? : Bool = false,
  raw? : Bool = false,
  url_border? : Bool = false,
) -> PdfDocument raise PdfError {
  if page_numbers.length() == 0 {
    self
  } else {
    let pages = self.pages_of_pagetree()
    pdf_addtext_validate_page_numbers(
      page_numbers,
      pages.length(),
      "addtext_pages_with_font",
    )
    let shifts = self.parse_coordinates(shift)
    let selected_pages = @number_set.pdf_number_set(page_numbers)
    let rewritten : Array[PdfPage] = Array::new(capacity=pages.length())
    for index in 0.. PdfDocument raise PdfError {
  document.addtext_pages_with_font(
    page_numbers,
    lines,
    time,
    bookmarks,
    font,
    fontpack,
    fontname,
    font_object,
    fontpack_font_objects,
    font_size,
    line_spacing,
    colour,
    position,
    filename~,
    bates~,
    bates_pad~,
    relative_to_box~,
    shift~,
    opacity~,
    justification~,
    rotation~,
    midline~,
    topline~,
    outline~,
    line_width~,
    underneath~,
    fast~,
    raw~,
    url_border~,
  )
}

///|
fn pdf_addtext_append_unique_codepoints(
  used : Array[Int],
  codepoints : ArrayView[Int],
) -> Unit {
  for codepoint in codepoints {
    if !pdf_addtext_codepoints_contains(used, codepoint) {
      used.push(codepoint)
    }
  }
}

///|
fn PdfDocument::pdf_addtext_used_codepoints_for_pages(
  self : PdfDocument,
  pages : ArrayView[PdfPage],
  page_numbers : ArrayView[Int],
  lines : ArrayView[String],
  time : PdfStrftimeTime,
  bookmarks : ArrayView[PdfBookmark],
  filename : String,
  bates : Int,
  bates_pad : Int?,
) -> Array[Int] raise PdfError {
  let used : Array[Int] = Array::new(
    capacity=page_numbers.length() * lines.length(),
  )
  for page_number in page_numbers {
    let values = self.addtext_replacement_values(
      bookmarks,
      page_number,
      pages.length(),
      filename,
      bates,
      bates_pad,
    )
    let replacement_pairs = pdf_addtext_replacement_pairs(values)
    let expanded = pdf_addtext_expand_lines_with_time(
      time, lines, replacement_pairs,
    )
    pdf_addtext_append_unique_codepoints(
      used,
      pdf_addtext_used_codepoints(expanded),
    )
  }
  used
}

///|
fn PdfDocument::pdf_addtext_write_fontpack_objects(
  self : PdfDocument,
  fontpack : PdfFontPack?,
) -> Array[Int] raise PdfError {
  let objects : Array[Int] = match fontpack {
    Some(pack) => Array::new(capacity=pack.fonts.length())
    None => []
  }
  match fontpack {
    Some(fontpack) =>
      for font in fontpack.fonts {
        objects.push(self.write_font(font))
      }
    None => ()
  }
  objects
}

///|
fn pdf_addtext_resolve_font_source(
  source : PdfAddTextFontSource,
) -> (PdfFont?, PdfFontPack?) raise PdfError {
  match source {
    PdfAddTextPreMadeFontPack(pack) =>
      if pack.fonts.length() == 0 {
        raise SoftError("addtexts: fontpack is empty")
      } else {
        (Some(pack.fonts[0]), None)
      }
    PdfAddTextExistingNamedFont => (None, None)
  }
}

///|
/// Add cpdf add-text to selected pages with deterministic time and resolved fonts.
///
/// This ports the outer source-level `addtexts` orchestration once callers have
/// already chosen an existing font, parsed font, or font pack. It prepares the
/// input text lines, skips empty visible output before writing fonts, writes
/// selected/fontpack font objects, and delegates page rewriting to
/// `addtext_pages_with_font`.
pub fn PdfDocument::addtexts_with_font(
  self : PdfDocument,
  page_numbers : ArrayView[Int],
  text : String,
  time : PdfStrftimeTime,
  bookmarks : ArrayView[PdfBookmark],
  font : PdfFont?,
  fontpack : PdfFontPack?,
  fontname : PdfName,
  font_size : Double,
  line_spacing : Double,
  colour : PdfContentColour,
  position : PdfPosition,
  filename? : String = "",
  bates? : Int = 1,
  bates_pad? : Int? = None,
  relative_to_box? : PdfName = pdf_page_mediabox_key(),
  shift? : String = "0 0",
  opacity? : Double = 1.0,
  justification? : PdfAddTextJustification = PdfAddTextLeftJustify,
  rotation? : PdfAddTextRotation = PdfAddTextRot0,
  midline? : Bool = false,
  topline? : Bool = false,
  outline? : Bool = false,
  line_width? : Double = 1.0,
  underneath? : Bool = false,
  fast? : Bool = false,
  raw? : Bool = false,
  url_border? : Bool = false,
) -> PdfDocument raise PdfError {
  if page_numbers.length() == 0 {
    self
  } else {
    let pages = self.pages_of_pagetree()
    pdf_addtext_validate_page_numbers(
      page_numbers,
      pages.length(),
      "addtexts_with_font",
    )
    let lines = pdf_addtext_lines_for_rotation(text, rotation)
    let used = self.pdf_addtext_used_codepoints_for_pages(
      pages, page_numbers, lines, time, bookmarks, filename, bates, bates_pad,
    )
    if used.length() == 0 {
      self
    } else {
      let font_object = match fontpack {
        Some(_) => None
        None => self.addtext_selected_font_object(font)
      }
      let fontpack_font_objects = self.pdf_addtext_write_fontpack_objects(
        fontpack,
      )
      self.addtext_pages_with_font(
        page_numbers,
        lines,
        time,
        bookmarks,
        font,
        fontpack,
        fontname,
        font_object,
        fontpack_font_objects,
        font_size,
        line_spacing,
        colour,
        position,
        filename~,
        bates~,
        bates_pad~,
        relative_to_box~,
        shift~,
        opacity~,
        justification~,
        rotation~,
        midline~,
        topline~,
        outline~,
        line_width~,
        underneath~,
        fast~,
        raw~,
        url_border~,
      )
    }
  }
}

///|
/// Compatibility wrapper for `PdfDocument::addtexts_with_font`.
pub fn pdf_addtexts_with_font(
  document : PdfDocument,
  page_numbers : ArrayView[Int],
  text : String,
  time : PdfStrftimeTime,
  bookmarks : ArrayView[PdfBookmark],
  font : PdfFont?,
  fontpack : PdfFontPack?,
  fontname : PdfName,
  font_size : Double,
  line_spacing : Double,
  colour : PdfContentColour,
  position : PdfPosition,
  filename? : String = "",
  bates? : Int = 1,
  bates_pad? : Int? = None,
  relative_to_box? : PdfName = pdf_page_mediabox_key(),
  shift? : String = "0 0",
  opacity? : Double = 1.0,
  justification? : PdfAddTextJustification = PdfAddTextLeftJustify,
  rotation? : PdfAddTextRotation = PdfAddTextRot0,
  midline? : Bool = false,
  topline? : Bool = false,
  outline? : Bool = false,
  line_width? : Double = 1.0,
  underneath? : Bool = false,
  fast? : Bool = false,
  raw? : Bool = false,
  url_border? : Bool = false,
) -> PdfDocument raise PdfError {
  document.addtexts_with_font(
    page_numbers,
    text,
    time,
    bookmarks,
    font,
    fontpack,
    fontname,
    font_size,
    line_spacing,
    colour,
    position,
    filename~,
    bates~,
    bates_pad~,
    relative_to_box~,
    shift~,
    opacity~,
    justification~,
    rotation~,
    midline~,
    topline~,
    outline~,
    line_width~,
    underneath~,
    fast~,
    raw~,
    url_border~,
  )
}

///|
/// Add cpdf add-text to selected pages from a non-embedding font source.
pub fn PdfDocument::addtexts_with_source(
  self : PdfDocument,
  page_numbers : ArrayView[Int],
  text : String,
  time : PdfStrftimeTime,
  bookmarks : ArrayView[PdfBookmark],
  font_source : PdfAddTextFontSource,
  fontname : PdfName,
  font_size : Double,
  line_spacing : Double,
  colour : PdfContentColour,
  position : PdfPosition,
  filename? : String = "",
  bates? : Int = 1,
  bates_pad? : Int? = None,
  relative_to_box? : PdfName = pdf_page_mediabox_key(),
  shift? : String = "0 0",
  opacity? : Double = 1.0,
  justification? : PdfAddTextJustification = PdfAddTextLeftJustify,
  rotation? : PdfAddTextRotation = PdfAddTextRot0,
  midline? : Bool = false,
  topline? : Bool = false,
  outline? : Bool = false,
  line_width? : Double = 1.0,
  underneath? : Bool = false,
  fast? : Bool = false,
  raw? : Bool = false,
  url_border? : Bool = false,
) -> PdfDocument raise PdfError {
  if page_numbers.length() == 0 {
    self
  } else {
    let pages = self.pages_of_pagetree()
    pdf_addtext_validate_page_numbers(
      page_numbers,
      pages.length(),
      "addtexts_with_source",
    )
    let lines = pdf_addtext_lines_for_rotation(text, rotation)
    let used = self.pdf_addtext_used_codepoints_for_pages(
      pages, page_numbers, lines, time, bookmarks, filename, bates, bates_pad,
    )
    if used.length() == 0 {
      self
    } else {
      let (font, fontpack) = pdf_addtext_resolve_font_source(font_source)
      self.addtexts_with_font(
        page_numbers,
        text,
        time,
        bookmarks,
        font,
        fontpack,
        fontname,
        font_size,
        line_spacing,
        colour,
        position,
        filename~,
        bates~,
        bates_pad~,
        relative_to_box~,
        shift~,
        opacity~,
        justification~,
        rotation~,
        midline~,
        topline~,
        outline~,
        line_width~,
        underneath~,
        fast~,
        raw~,
        url_border~,
      )
    }
  }
}

///|
/// Compatibility wrapper for `PdfDocument::addtexts_with_source`.
pub fn pdf_addtexts_with_source(
  document : PdfDocument,
  page_numbers : ArrayView[Int],
  text : String,
  time : PdfStrftimeTime,
  bookmarks : ArrayView[PdfBookmark],
  font_source : PdfAddTextFontSource,
  fontname : PdfName,
  font_size : Double,
  line_spacing : Double,
  colour : PdfContentColour,
  position : PdfPosition,
  filename? : String = "",
  bates? : Int = 1,
  bates_pad? : Int? = None,
  relative_to_box? : PdfName = pdf_page_mediabox_key(),
  shift? : String = "0 0",
  opacity? : Double = 1.0,
  justification? : PdfAddTextJustification = PdfAddTextLeftJustify,
  rotation? : PdfAddTextRotation = PdfAddTextRot0,
  midline? : Bool = false,
  topline? : Bool = false,
  outline? : Bool = false,
  line_width? : Double = 1.0,
  underneath? : Bool = false,
  fast? : Bool = false,
  raw? : Bool = false,
  url_border? : Bool = false,
) -> PdfDocument raise PdfError {
  document.addtexts_with_source(
    page_numbers,
    text,
    time,
    bookmarks,
    font_source,
    fontname,
    font_size,
    line_spacing,
    colour,
    position,
    filename~,
    bates~,
    bates_pad~,
    relative_to_box~,
    shift~,
    opacity~,
    justification~,
    rotation~,
    midline~,
    topline~,
    outline~,
    line_width~,
    underneath~,
    fast~,
    raw~,
    url_border~,
  )
}

///|
fn pdf_addtext_font_not_found(fontname : PdfName) -> PdfError {
  SoftError(
    "addtext: font " + @ascii.decode_lossy(fontname.bytes()) + " not found",
  )
}

///|
/// Resolve an existing page font for cpdf add-text.
///
/// The requested name is first treated as a `/Font` resource key. If that is
/// absent, the page font dictionary is scanned for an entry whose `/BaseFont`
/// matches the requested name; the returned `PdfName` is the resource key to
/// use in emitted text operators.
pub fn PdfDocument::addtext_existing_font_resource(
  self : PdfDocument,
  page : PdfPage,
  fontname : PdfName,
) -> (PdfName, PdfObject) raise PdfError {
  let fontdict = match
    self.lookup_direct(pdf_addtext_font_key(), page.resources) {
    Some(fontdict) => fontdict
    None => raise SoftError("addtext: font dictionary not present")
  }
  match self.lookup_direct(fontname, fontdict) {
    Some(font) => (fontname, font)
    None =>
      match fontdict {
        PdfDictionary(entries) =>
          for entry in entries {
            match self.lookup_direct(pdf_text_base_font_key_name, entry.1) {
              Some(PdfNameObject(basefont)) if basefont == fontname =>
                break (entry.0, entry.1)
              _ => ()
            }
          } nobreak {
            raise pdf_addtext_font_not_found(fontname)
          }
        _ => raise pdf_addtext_font_not_found(fontname)
      }
  }
}

///|
/// Compatibility wrapper for `PdfDocument::addtext_existing_font_resource`.
pub fn pdf_addtext_existing_font_resource(
  document : PdfDocument,
  page : PdfPage,
  fontname : PdfName,
) -> (PdfName, PdfObject) raise PdfError {
  document.addtext_existing_font_resource(page, fontname)
}

///|
/// Measure add-text bytes using an existing page font resource.
pub fn PdfDocument::addtext_width_of_existing_font_resource(
  self : PdfDocument,
  page : PdfPage,
  fontname : PdfName,
  font_size : Double,
  text : BytesView,
) -> (PdfName, Double) raise PdfError {
  let (resource_name, font_object) = self.addtext_existing_font_resource(
    page, fontname,
  )
  let font = self.read_font(font_object)
  (resource_name, pdf_addtext_width_of_selected_font(font, font_size, text))
}

///|
/// Compatibility wrapper for `PdfDocument::addtext_width_of_existing_font_resource`.
pub fn pdf_addtext_width_of_existing_font_resource(
  document : PdfDocument,
  page : PdfPage,
  fontname : PdfName,
  font_size : Double,
  text : BytesView,
) -> (PdfName, Double) raise PdfError {
  document.addtext_width_of_existing_font_resource(
    page, fontname, font_size, text,
  )
}

///|
/// Build cpdf's URL link annotation object for add-text markup.
pub fn pdf_addtext_url_annotation(
  p0 : Point2,
  p1 : Point2,
  p2 : Point2,
  p3 : Point2,
  rectangle : PdfRectangle,
  url : String,
  url_border : Bool,
) -> PdfObject {
  PdfDictionary([
    (pdf_addtext_subtype_key_name, PdfNameObject(pdf_addtext_link_name)),
    (
      pdf_addtext_quadpoints_key_name,
      PdfArray([
        PdfReal(p0.x),
        PdfReal(p0.y),
        PdfReal(p3.x),
        PdfReal(p3.y),
        PdfReal(p1.x),
        PdfReal(p1.y),
        PdfReal(p2.x),
        PdfReal(p2.y),
      ]),
    ),
    (
      pdf_addtext_rect_key_name,
      PdfArray([
        PdfReal(rectangle.min_x),
        PdfReal(rectangle.min_y),
        PdfReal(rectangle.max_x),
        PdfReal(rectangle.max_y),
      ]),
    ),
    (
      pdf_addtext_bs_key_name,
      PdfDictionary([
        (pdf_addtext_w_key_name, PdfInteger(if url_border { 1 } else { 0 })),
      ]),
    ),
    (
      pdf_addtext_a_key_name,
      PdfDictionary([
        (pdf_addtext_uri_key_name, PdfString(@utf8.encode(url))),
        (pdf_addtext_type_key_name, PdfNameObject(pdf_addtext_action_name)),
        (pdf_addtext_s_key_name, PdfNameObject(pdf_addtext_uri_key_name)),
      ]),
    ),
  ])
}

///|
fn pdf_addtext_bounds_of_points(
  p0 : Point2,
  p1 : Point2,
  p2 : Point2,
  p3 : Point2,
) -> PdfRectangle {
  {
    min_x: double_min(double_min(p0.x, p1.x), double_min(p2.x, p3.x)),
    min_y: double_min(double_min(p0.y, p1.y), double_min(p2.y, p3.y)),
    max_x: double_max(double_max(p0.x, p1.x), double_max(p2.x, p3.x)),
    max_y: double_max(double_max(p0.y, p1.y), double_max(p2.y, p3.y)),
  }
}

///|
/// Return cpdf's add-text URL annotation height for a font selection.
pub fn pdf_addtext_url_annotation_height(
  font : PdfFont?,
  fontname : String,
  font_size : Double,
) -> Double {
  match pdf_addtext_cap_height(font, fontname) {
    Some(cap_height) => cap_height * font_size / 1000.0
    None => font_size
  }
}

///|
/// Calculate the transformed quad points and bounds for an add-text URL span.
pub fn pdf_addtext_url_annotation_geometry(
  origin : Point2,
  horizontal_offset : Double,
  vertical_offset : Double,
  justification_offset : Double,
  rotation : Double,
  rotation_offset : Point2,
  page_rotation : Double,
  start_width : Double,
  end_width : Double,
  height : Double,
) -> PdfAddTextUrlAnnotationGeometry {
  let base_x = origin.x - horizontal_offset - justification_offset
  let base_y = origin.y - vertical_offset
  let min_x = base_x + start_width
  let min_y = base_y
  let max_x = base_x + end_width
  let max_y = base_y + height
  let matrix = transform_matrix_of_transform([
    Translate(rotation_offset.x, rotation_offset.y),
    Rotate(point2(base_x, base_y), rotation + page_rotation),
  ])
  let p0 = matrix.apply(point2(min_x, min_y))
  let p1 = matrix.apply(point2(max_x, max_y))
  let p2 = matrix.apply(point2(min_x, max_y))
  let p3 = matrix.apply(point2(max_x, min_y))
  { p0, p1, p2, p3, rectangle: pdf_addtext_bounds_of_points(p0, p1, p2, p3) }
}

///|
fn pdf_addtext_url_annotation_prefix(
  text : String,
  position : Int,
) -> String raise PdfError {
  let bytes = @utf8.encode(text)
  if position < 0 || position > bytes.length() {
    raise SoftError("bad add-text URL byte offset")
  }
  @utf8.decode(bytes[:position]) catch {
    _ => raise SoftError("bad add-text URL byte offset")
  }
}

///|
fn pdf_addtext_url_annotation_widths_with_measure(
  text : String,
  url : PdfAddTextUrl,
  measure : (String) -> Double raise PdfError,
) -> (Double, Double) raise PdfError {
  (
    measure(pdf_addtext_url_annotation_prefix(text, url.start)),
    measure(pdf_addtext_url_annotation_prefix(text, url.end)),
  )
}

///|
/// Measure cpdf's start and end prefix widths for an add-text URL span.
pub fn pdf_addtext_url_annotation_widths(
  text : String,
  url : PdfAddTextUrl,
  measure : (String) -> Double,
) -> (Double, Double) raise PdfError {
  pdf_addtext_url_annotation_widths_with_measure(text, url, fn(prefix) {
    measure(prefix)
  })
}

///|
/// Build indirect URL link annotations for one cpdf add-text line.
///
/// This mirrors cpdf's add-text annotation pass: each parsed URL span is
/// measured against the visible line, converted to transformed quad points, and
/// written as a new indirect annotation object.
pub fn PdfDocument::addtext_url_annotation_references(
  self : PdfDocument,
  text : String,
  urls : ArrayView[PdfAddTextUrl],
  measure : (String) -> Double raise PdfError,
  origin : Point2,
  horizontal_offset : Double,
  vertical_offset : Double,
  justification_offset : Double,
  rotation : Double,
  rotation_offset : Point2,
  page_rotation : Double,
  font : PdfFont?,
  fontname : String,
  font_size : Double,
  url_border : Bool,
) -> Array[PdfObject] raise PdfError {
  let height = pdf_addtext_url_annotation_height(font, fontname, font_size)
  let annotations : Array[PdfObject] = Array::new(capacity=urls.length())
  for url in urls {
    let (start_width, end_width) = pdf_addtext_url_annotation_widths_with_measure(
      text, url, measure,
    )
    let geometry = pdf_addtext_url_annotation_geometry(
      origin, horizontal_offset, vertical_offset, justification_offset, rotation,
      rotation_offset, page_rotation, start_width, end_width, height,
    )
    let object_number = self.add_object(
      pdf_addtext_url_annotation(
        geometry.p0,
        geometry.p1,
        geometry.p2,
        geometry.p3,
        geometry.rectangle,
        url.url,
        url_border,
      ),
    )
    annotations.push(PdfIndirect(object_number))
  }
  annotations
}

///|
/// Compatibility wrapper for `PdfDocument::addtext_url_annotation_references`.
pub fn pdf_addtext_url_annotation_references(
  document : PdfDocument,
  text : String,
  urls : ArrayView[PdfAddTextUrl],
  measure : (String) -> Double raise PdfError,
  origin : Point2,
  horizontal_offset : Double,
  vertical_offset : Double,
  justification_offset : Double,
  rotation : Double,
  rotation_offset : Point2,
  page_rotation : Double,
  font : PdfFont?,
  fontname : String,
  font_size : Double,
  url_border : Bool,
) -> Array[PdfObject] raise PdfError {
  document.addtext_url_annotation_references(
    text, urls, measure, origin, horizontal_offset, vertical_offset, justification_offset,
    rotation, rotation_offset, page_rotation, font, fontname, font_size, url_border,
  )
}

///|
/// Return a page rest dictionary with cpdf add-text annotations prepended.
///
/// Empty new annotation lists leave `rest` unchanged. Existing `/Annots`
/// arrays are resolved through `document`; missing or malformed values are
/// treated as empty, matching cpdf's add-text page update.
pub fn PdfDocument::addtext_page_rest_with_annotations(
  self : PdfDocument,
  rest : PdfObject,
  annotations : ArrayView[PdfObject],
) -> PdfObject raise PdfError {
  if annotations.length() == 0 {
    rest
  } else {
    let updated : Array[PdfObject] = [
      for annotation in annotations => annotation
    ]
    match self.lookup_direct(pdf_addtext_annots_key(), rest) {
      Some(PdfArray(existing)) =>
        for annotation in existing {
          updated.push(annotation)
        }
      _ => ()
    }
    rest.add_dict_entry(pdf_addtext_annots_key(), PdfArray(updated))
  }
}

///|
/// Compatibility wrapper for `PdfDocument::addtext_page_rest_with_annotations`.
pub fn pdf_addtext_page_rest_with_annotations(
  document : PdfDocument,
  rest : PdfObject,
  annotations : ArrayView[PdfObject],
) -> PdfObject raise PdfError {
  document.addtext_page_rest_with_annotations(rest, annotations)
}

///|
/// Calculate the cpdf add-rectangle lower-left origin after placement.
pub fn pdf_addtext_rectangle_origin(
  bounds : PdfRectangle,
  position : PdfPosition,
  width : Double,
  height : Double,
) -> (Double, Double) {
  let placed = pdf_calculate_position(false, width, bounds, position)
  let y = match position {
    PdfPositionTop(_) | PdfPositionTopLeft(_, _) | PdfPositionTopRight(_, _) =>
      placed.y - height
    PdfPositionCentre | PdfPositionPosCentre(_, _) => placed.y - height / 2.0
    _ => placed.y
  }
  (placed.x, y)
}

///|
fn PdfDocument::pdf_addtext_rectangle_bounds(
  self : PdfDocument,
  page : PdfPage,
  relative_to_box : PdfName,
) -> PdfRectangle raise PdfError {
  if relative_to_box == pdf_page_mediabox_key() {
    self.parse_rectangle(page.mediabox)
  } else {
    match self.lookup_direct(relative_to_box, page.rest) {
      Some(box_object) => self.parse_rectangle(box_object)
      None => self.parse_rectangle(page.mediabox)
    }
  }
}

///|
fn PdfDocument::pdf_addtext_rectangle_dimensions(
  self : PdfDocument,
  page : PdfPage,
  coord : String,
) -> (Double, Double) raise PdfError {
  match self.parse_units_string(page, coord) {
    [width, height] => (width, height)
    _ => raise SoftError("bad coordinate specification")
  }
}

///|
fn PdfDocument::pdf_addtext_rectangle_page(
  self : PdfDocument,
  page : PdfPage,
  coord : String,
  colour : PdfContentColour,
  position : PdfPosition,
  relative_to_box : PdfName,
  outline : Bool,
  line_width : Double,
  opacity : Double,
  underneath : Bool,
  fast : Bool,
) -> PdfPage raise PdfError {
  let (resources, extgstate_name) = self.pdf_addtext_opacity_resources(
    page.resources,
    opacity,
  )
  let bounds = self.pdf_addtext_rectangle_bounds(page, relative_to_box)
  let (width, height) = self.pdf_addtext_rectangle_dimensions(page, coord)
  let (x, y) = pdf_addtext_rectangle_origin(bounds, position, width, height)
  let ops = pdf_addtext_rectangle_ops(
    x, y, width, height, colour, outline, line_width, extgstate_name,
  )
  let page = { ..page, resources, }
  if underneath {
    self.prepend_operators(page, ops, fast~)
  } else {
    self.postpend_operators(page, ops, fast~)
  }
}

///|
/// Add a cpdf-style rectangle stamp to selected one-based pages.
///
/// The rectangle dimensions are parsed with cpdf coordinate syntax, placed
/// relative to `relative_to_box`, wrapped as `/CPDFSTAMP` artifact content, and
/// appended unless `underneath` is set.
pub fn PdfDocument::addtext_rectangle(
  self : PdfDocument,
  page_numbers : ArrayView[Int],
  coord : String,
  colour : PdfContentColour,
  position : PdfPosition,
  relative_to_box? : PdfName = pdf_page_mediabox_key(),
  outline? : Bool = false,
  line_width? : Double = 1.0,
  opacity? : Double = 1.0,
  underneath? : Bool = false,
  fast? : Bool = false,
) -> PdfDocument raise PdfError {
  let pages = self.pages_of_pagetree()
  pdf_addtext_validate_page_numbers(
    page_numbers,
    pages.length(),
    "addtext_rectangle",
  )
  let selected_pages = @number_set.pdf_number_set(page_numbers)
  let rewritten : Array[PdfPage] = Array::new(capacity=pages.length())
  for index in 0.. PdfDocument raise PdfError {
  document.addtext_rectangle(
    page_numbers,
    coord,
    colour,
    position,
    relative_to_box~,
    outline~,
    line_width~,
    opacity~,
    underneath~,
    fast~,
  )
}

///|
/// Source-spelled compatibility wrapper matching cpdfaddtext's `addrectangle`.
pub fn pdf_addrectangle(
  fast : Bool,
  coord : String,
  colour : PdfContentColour,
  outline : Bool,
  line_width : Double,
  opacity : Double,
  position : PdfPosition,
  relative_to_box : String,
  underneath : Bool,
  page_numbers : ArrayView[Int],
  document : PdfDocument,
) -> PdfDocument raise PdfError {
  document.addtext_rectangle(
    page_numbers,
    coord,
    colour,
    position,
    relative_to_box=pdf_page_box_name_of_string(relative_to_box),
    outline~,
    line_width~,
    opacity~,
    underneath~,
    fast~,
  )
}

///|
fn pdf_addtext_vertical_justification(
  rotation : PdfAddTextRotation,
  justification : PdfAddTextJustification,
) -> PdfAddTextJustification {
  match justification {
    PdfAddTextCentreJustify => PdfAddTextCentreJustify
    PdfAddTextLeftJustify =>
      if rotation == PdfAddTextRot270 {
        PdfAddTextRightJustify
      } else {
        PdfAddTextLeftJustify
      }
    PdfAddTextRightJustify =>
      if rotation == PdfAddTextRot270 {
        PdfAddTextLeftJustify
      } else {
        PdfAddTextRightJustify
      }
  }
}

///|
fn pdf_addtext_horizontal_justification(
  rotation : PdfAddTextRotation,
  justification : PdfAddTextJustification,
) -> PdfAddTextJustification {
  match justification {
    PdfAddTextCentreJustify => PdfAddTextCentreJustify
    PdfAddTextLeftJustify =>
      if rotation == PdfAddTextRot180 {
        PdfAddTextRightJustify
      } else {
        PdfAddTextLeftJustify
      }
    PdfAddTextRightJustify =>
      if rotation == PdfAddTextRot180 {
        PdfAddTextLeftJustify
      } else {
        PdfAddTextRightJustify
      }
  }
}

///|
fn pdf_addtext_vertical_justification_offset(
  delta : Double,
  position : PdfPosition,
  justification : PdfAddTextJustification,
) -> Double {
  match justification {
    PdfAddTextLeftJustify =>
      match position {
        PdfPositionTopLeft(_, _)
        | PdfPositionTopRight(_, _)
        | PdfPositionTop(_)
        | PdfPositionDiagonal
        | PdfPositionReverseDiagonal => 0.0
        PdfPositionLeft(_)
        | PdfPositionPosLeft(_, _)
        | PdfPositionPosCentre(_, _)
        | PdfPositionCentre
        | PdfPositionPosRight(_, _)
        | PdfPositionRight(_) => delta / 2.0
        PdfPositionBottomLeft(_, _)
        | PdfPositionBottom(_)
        | PdfPositionBottomRight(_, _) => delta
      }
    PdfAddTextRightJustify =>
      match position {
        PdfPositionTopLeft(_, _)
        | PdfPositionTopRight(_, _)
        | PdfPositionTop(_) => -delta
        PdfPositionLeft(_)
        | PdfPositionPosLeft(_, _)
        | PdfPositionPosCentre(_, _)
        | PdfPositionCentre
        | PdfPositionPosRight(_, _)
        | PdfPositionRight(_) => -(delta / 2.0)
        PdfPositionBottomLeft(_, _)
        | PdfPositionBottom(_)
        | PdfPositionBottomRight(_, _)
        | PdfPositionDiagonal
        | PdfPositionReverseDiagonal => 0.0
      }
    PdfAddTextCentreJustify =>
      match position {
        PdfPositionTopLeft(_, _)
        | PdfPositionTopRight(_, _)
        | PdfPositionTop(_) => -(delta / 2.0)
        PdfPositionLeft(_)
        | PdfPositionPosLeft(_, _)
        | PdfPositionPosCentre(_, _)
        | PdfPositionCentre
        | PdfPositionPosRight(_, _)
        | PdfPositionRight(_)
        | PdfPositionDiagonal
        | PdfPositionReverseDiagonal => 0.0
        PdfPositionBottomLeft(_, _)
        | PdfPositionBottom(_)
        | PdfPositionBottomRight(_, _) => delta / 2.0
      }
  }
}

///|
fn pdf_addtext_horizontal_justification_offset(
  delta : Double,
  position : PdfPosition,
  justification : PdfAddTextJustification,
) -> Double {
  match justification {
    PdfAddTextLeftJustify =>
      match position {
        PdfPositionTopLeft(_, _)
        | PdfPositionLeft(_)
        | PdfPositionPosLeft(_, _)
        | PdfPositionBottomLeft(_, _)
        | PdfPositionDiagonal
        | PdfPositionReverseDiagonal => 0.0
        PdfPositionTop(_)
        | PdfPositionPosCentre(_, _)
        | PdfPositionBottom(_)
        | PdfPositionCentre => delta / 2.0
        PdfPositionTopRight(_, _)
        | PdfPositionBottomRight(_, _)
        | PdfPositionPosRight(_, _)
        | PdfPositionRight(_) => delta
      }
    PdfAddTextRightJustify =>
      match position {
        PdfPositionTopLeft(_, _)
        | PdfPositionLeft(_)
        | PdfPositionPosLeft(_, _)
        | PdfPositionBottomLeft(_, _) => -delta
        PdfPositionTop(_)
        | PdfPositionPosCentre(_, _)
        | PdfPositionBottom(_)
        | PdfPositionCentre => -(delta / 2.0)
        PdfPositionTopRight(_, _)
        | PdfPositionBottomRight(_, _)
        | PdfPositionPosRight(_, _)
        | PdfPositionRight(_)
        | PdfPositionDiagonal
        | PdfPositionReverseDiagonal => 0.0
      }
    PdfAddTextCentreJustify =>
      match position {
        PdfPositionTopLeft(_, _)
        | PdfPositionLeft(_)
        | PdfPositionPosLeft(_, _)
        | PdfPositionBottomLeft(_, _) => -(delta / 2.0)
        PdfPositionTop(_)
        | PdfPositionPosCentre(_, _)
        | PdfPositionBottom(_)
        | PdfPositionCentre
        | PdfPositionDiagonal
        | PdfPositionReverseDiagonal => 0.0
        PdfPositionTopRight(_, _)
        | PdfPositionBottomRight(_, _)
        | PdfPositionPosRight(_, _)
        | PdfPositionRight(_) => delta / 2.0
      }
  }
}

///|
/// Return cpdf's add-text justification offset for the current line.
///
/// `longest_width` is the maximum rendered width among the expanded text lines,
/// and `width` is the current line width.
pub fn pdf_addtext_justification_offset(
  rotation : PdfAddTextRotation,
  longest_width : Double,
  width : Double,
  position : PdfPosition,
  justification : PdfAddTextJustification,
) -> Double {
  let delta = longest_width - width
  match rotation {
    PdfAddTextRot90 | PdfAddTextRot270 =>
      pdf_addtext_vertical_justification_offset(
        delta,
        position,
        pdf_addtext_vertical_justification(rotation, justification),
      )
    PdfAddTextRot0 | PdfAddTextRot180 =>
      pdf_addtext_horizontal_justification_offset(
        delta,
        position,
        pdf_addtext_horizontal_justification(rotation, justification),
      )
  }
}

///|
/// Apply cpdf's scalar justification offset to `(horizontal, vertical)`.
pub fn pdf_addtext_apply_justification_offset(
  rotation : PdfAddTextRotation,
  horizontal_offset : Double,
  vertical_offset : Double,
  justification_offset : Double,
) -> (Double, Double) {
  match rotation {
    PdfAddTextRot90 | PdfAddTextRot270 =>
      (horizontal_offset, vertical_offset - justification_offset)
    PdfAddTextRot0 | PdfAddTextRot180 =>
      (horizontal_offset + justification_offset, vertical_offset)
  }
}

///|
/// Resolve cpdf add-text placement values for one line.
///
/// This combines cpdf's `calculate_position`, diagonal offset correction,
/// justification offset application, explicit text rotation, and
/// rotation-origin offset selection.
pub fn pdf_addtext_line_placement(
  bounds : PdfRectangle,
  position : PdfPosition,
  rotation : PdfAddTextRotation,
  text_width : Double,
  longest_width : Double,
  horizontal_offset : Double,
  vertical_offset : Double,
  justification : PdfAddTextJustification,
) -> PdfAddTextLinePlacement {
  let placed = pdf_calculate_position(false, text_width, bounds, position)
  let justification_offset = pdf_addtext_justification_offset(
    rotation, longest_width, text_width, position, justification,
  )
  let (diagonal_h_offset, diagonal_v_offset) = pdf_addtext_diagonal_offsets(
    bounds,
    position,
    rotation,
    text_width,
    horizontal_offset,
    vertical_offset,
    placed.rotation,
  )
  let (final_h_offset, final_v_offset) = pdf_addtext_apply_justification_offset(
    rotation, diagonal_h_offset, diagonal_v_offset, justification_offset,
  )
  let (rotation_h_offset, rotation_v_offset) = pdf_addtext_rotation_offsets(
    position, rotation, text_width,
  )
  {
    origin: point2(placed.x, placed.y),
    base_rotation: placed.rotation,
    text_rotation: pdf_addtext_rotation_radians(rotation),
    rotation_offset: point2(rotation_h_offset, rotation_v_offset),
    horizontal_offset: final_h_offset,
    vertical_offset: final_v_offset,
    justification_offset,
  }
}

///|
fn pdf_addtext_bytes_match_at(
  bytes : BytesView,
  index : Int,
  pattern : BytesView,
) -> Bool {
  if pattern.length() == 0 || index + pattern.length() > bytes.length() {
    false
  } else {
    for offset in 0.. Int {
  let mut position = position
  for byte in view {
    output[position] = byte
    position += 1
  }
  position
}

///|
fn pdf_addtext_replace_all_lazy(
  text : String,
  search : String,
  replacement : () -> String,
) -> String {
  let search_bytes = @utf8.encode(search)
  if search_bytes.length() == 0 {
    text
  } else {
    let text_bytes = @utf8.encode(text)
    let replacements : Array[(Int, Int, PdfBytes)] = Array::new(
      capacity=text_bytes.length() / search_bytes.length(),
    )
    let mut index = 0
    let mut length = text_bytes.length()
    while index < text_bytes.length() {
      if pdf_addtext_bytes_match_at(text_bytes, index, search_bytes) {
        let replacement_bytes = @utf8.encode(replacement())
        replacements.push(
          (index, index + search_bytes.length(), replacement_bytes),
        )
        length = length - search_bytes.length() + replacement_bytes.length()
        index += search_bytes.length()
      } else {
        index += 1
      }
    }
    guard replacements.length() > 0 else { return text }
    let output = Array::make(length, b'\x00')
    let mut source_index = 0
    let mut position = 0
    for entry in replacements {
      position = pdf_addtext_copy_view(
        output,
        position,
        text_bytes[source_index:entry.0],
      )
      position = pdf_addtext_copy_view(output, position, entry.2)
      source_index = entry.1
    }
    let _ = pdf_addtext_copy_view(output, position, text_bytes[source_index:])
    @utf8.decode_lossy(Bytes::from_array(output))
  }
}

///|
/// Apply cpdf add-text replacements, then run the cpdf strftime pass.
///
/// Replacement callbacks are only evaluated for markers that occur in `text`,
/// matching cpdf's `string_replace_all_lazy` helper.
pub fn pdf_addtext_process_text_with_time(
  time : PdfStrftimeTime,
  text : String,
  replacements : ArrayView[(String, () -> String)],
) -> String {
  let mut output = text
  for replacement in replacements {
    output = pdf_addtext_replace_all_lazy(output, replacement.0, replacement.1)
  }
  pdf_strftime_with_time(time, output)
}

///|
/// Compatibility wrapper matching cpdfaddtext's `process_text`.
pub fn pdf_process_text(
  time : PdfStrftimeTime,
  text : String,
  replacements : ArrayView[(String, () -> String)],
) -> String {
  pdf_addtext_process_text_with_time(time, text, replacements)
}

///|
fn pdf_addtext_decode_lossy_view(bytes : BytesView) -> String {
  let output = Array::make(bytes.length(), b'\x00')
  for index, byte in bytes {
    output[index] = byte
  }
  @utf8.decode_lossy(Bytes::from_array(output))
}

///|
/// Split add-text input at cpdf's literal `\n` line marker.
///
/// A doubled backslash before `n` is preserved as text, matching cpdf's
/// `split_at_newline` helper.
pub fn pdf_addtext_split_at_newline(text : String) -> Array[String] {
  let bytes = @utf8.encode(text)
  let lines : Array[String] = Array::new(capacity=bytes.length())
  let mut line_start = 0
  let mut index = 0
  while index < bytes.length() {
    if index + 2 < bytes.length() &&
      bytes[index].to_int() == 92 &&
      bytes[index + 1].to_int() == 92 &&
      bytes[index + 2].to_int() == 110 {
      index += 3
    } else if index + 1 < bytes.length() &&
      bytes[index].to_int() == 92 &&
      bytes[index + 1].to_int() == 110 {
      lines.push(pdf_addtext_decode_lossy_view(bytes[line_start:index]))
      index += 2
      line_start = index
    } else {
      index += 1
    }
  }
  lines.push(pdf_addtext_decode_lossy_view(bytes[line_start:]))
  lines
}

///|
fn pdf_addtext_is_octal_digit(byte : Byte) -> Bool {
  let value = byte.to_int()
  value >= 48 && value <= 55
}

///|
fn pdf_addtext_octal_value(a : Byte, b : Byte, c : Byte) -> Int {
  (a.to_int() - 48) * 64 + (b.to_int() - 48) * 8 + c.to_int() - 48
}

///|
fn pdf_addtext_unescaped_length(bytes : BytesView) -> Int raise PdfError {
  let mut index = 0
  let mut length = 0
  while index < bytes.length() {
    if bytes[index].to_int() == 92 {
      if index + 3 < bytes.length() &&
        pdf_addtext_is_octal_digit(bytes[index + 1]) &&
        pdf_addtext_is_octal_digit(bytes[index + 2]) &&
        pdf_addtext_is_octal_digit(bytes[index + 3]) {
        let value = pdf_addtext_octal_value(
          bytes[index + 1],
          bytes[index + 2],
          bytes[index + 3],
        )
        if value > 255 {
          raise SoftError("bad add-text octal escape")
        }
        length += 1
        index += 4
      } else if index + 1 < bytes.length() && bytes[index + 1].to_int() == 92 {
        length += 1
        index += 2
      } else if index + 1 < bytes.length() && bytes[index + 1].to_int() != 110 {
        length += 1
        index += 2
      } else {
        length += 1
        index += 1
      }
    } else {
      length += 1
      index += 1
    }
  }
  length
}

///|
/// Decode cpdf add-text backslash escapes after line splitting.
///
/// Three-digit octal escapes emit one byte, doubled backslashes emit one
/// backslash, and other non-newline escapes drop the backslash.
pub fn pdf_addtext_unescape_string(text : String) -> String raise PdfError {
  let bytes = @utf8.encode(text)
  let output = Array::make(pdf_addtext_unescaped_length(bytes), b'\x00')
  let mut index = 0
  let mut position = 0
  while index < bytes.length() {
    if bytes[index].to_int() == 92 {
      if index + 3 < bytes.length() &&
        pdf_addtext_is_octal_digit(bytes[index + 1]) &&
        pdf_addtext_is_octal_digit(bytes[index + 2]) &&
        pdf_addtext_is_octal_digit(bytes[index + 3]) {
        let value = pdf_addtext_octal_value(
          bytes[index + 1],
          bytes[index + 2],
          bytes[index + 3],
        )
        if value > 255 {
          raise SoftError("bad add-text octal escape")
        }
        output[position] = value.to_byte()
        position += 1
        index += 4
      } else if index + 1 < bytes.length() && bytes[index + 1].to_int() == 92 {
        output[position] = (92).to_byte()
        position += 1
        index += 2
      } else if index + 1 < bytes.length() && bytes[index + 1].to_int() != 110 {
        output[position] = bytes[index + 1]
        position += 1
        index += 2
      } else {
        output[position] = bytes[index]
        position += 1
        index += 1
      }
    } else {
      output[position] = bytes[index]
      position += 1
      index += 1
    }
  }
  @utf8.decode_lossy(Bytes::from_array(output))
}

///|
/// Split and unescape add-text lines, reversing them for upside-down rotations.
pub fn pdf_addtext_lines_for_rotation(
  text : String,
  rotation : PdfAddTextRotation,
) -> Array[String] raise PdfError {
  let lines = pdf_addtext_split_at_newline(text).map(line => {
    pdf_addtext_unescape_string(line)
  })
  match rotation {
    PdfAddTextRot180 | PdfAddTextRot270 => lines.rev()
    PdfAddTextRot0 | PdfAddTextRot90 => lines
  }
}

///|
fn pdf_addtext_roman_upper(number : Int) -> String {
  @ascii.decode_lossy(pdf_page_label_roman_upper(number))
}

///|
fn pdf_addtext_roman_lower(number : Int) -> String {
  @ascii.decode_lossy(
    pdf_page_label_lowercase(pdf_page_label_roman_upper(number)),
  )
}

///|
/// Format cpdf add-text's `%Bates` replacement for one page.
pub fn pdf_addtext_bates_number(
  bates : Int,
  page_number : Int,
  bates_pad : Int?,
) -> String {
  let number_text = (bates + page_number - 1).to_string()
  match bates_pad {
    None => number_text
    Some(width) =>
      if number_text.length() >= width {
        number_text
      } else {
        let mut output = number_text
        for _ in 0..<(width - number_text.length()) {
          output = "0" + output
        }
        output
      }
  }
}

///|
fn pdf_addtext_bookmark_replacement(
  values : PdfAddTextReplacementValues,
  level : Int,
) -> String {
  if level >= 0 && level < values.bookmarks.length() {
    values.bookmarks[level]
  } else {
    ""
  }
}

///|
/// Build cpdf add-text replacement callbacks for page-local fields.
///
/// Page labels and bookmark strings are supplied by the caller; this helper
/// preserves cpdf's marker names and Bates/roman/page-number formatting.
pub fn pdf_addtext_replacement_pairs(
  values : PdfAddTextReplacementValues,
) -> Array[(String, () -> String)] {
  [
    ("%PageDiv2", fn() { ((values.page_number + 1) / 2).to_string() }),
    ("%Page", fn() { values.page_number.to_string() }),
    ("%Roman", fn() { pdf_addtext_roman_upper(values.page_number) }),
    ("%roman", fn() { pdf_addtext_roman_lower(values.page_number) }),
    ("%filename", fn() { values.filename }),
    ("%Label", fn() { values.page_label }),
    ("%EndPage", fn() { values.end_page.to_string() }),
    ("%EndLabel", fn() { values.end_label }),
    ("%Bookmark0", fn() { pdf_addtext_bookmark_replacement(values, 0) }),
    ("%Bookmark1", fn() { pdf_addtext_bookmark_replacement(values, 1) }),
    ("%Bookmark2", fn() { pdf_addtext_bookmark_replacement(values, 2) }),
    ("%Bookmark3", fn() { pdf_addtext_bookmark_replacement(values, 3) }),
    ("%Bookmark4", fn() { pdf_addtext_bookmark_replacement(values, 4) }),
    (
      "%Bates",
      fn() {
        pdf_addtext_bates_number(
          values.bates,
          values.page_number,
          values.bates_pad,
        )
      },
    ),
  ]
}

///|
fn pdf_addtext_pdfdocstring_to_string(
  bytes : BytesView,
) -> String raise PdfError {
  @utf8.decode(pdf_utf8_of_pdfdocstring(bytes)) catch {
    _ => raise InvalidUTF8
  }
}

///|
fn pdf_addtext_page_label_prefix(label : PdfPageLabel) -> String raise PdfError {
  match label.label_prefix {
    Some(prefix) => pdf_addtext_pdfdocstring_to_string(prefix)
    None => ""
  }
}

///|
fn pdf_addtext_page_label_number(label : PdfPageLabel) -> String {
  @ascii.decode_lossy(
    pdf_page_label_number_bytes(label.start_value, label.label_style),
  )
}

///|
/// Return cpdf add-text's visible page label for a 1-based page number.
///
/// Documents without page labels fall back to decimal page numbers.
pub fn PdfDocument::addtext_page_label(
  self : PdfDocument,
  page_number : Int,
) -> String raise PdfError {
  let labels = pdf_page_label_complete(self.read_page_labels())
  match pdf_page_label_of_page_number(page_number, labels) {
    Some(label) =>
      pdf_addtext_page_label_prefix(label) +
      pdf_addtext_page_label_number(label)
    None => page_number.to_string()
  }
}

///|
/// Compatibility wrapper for `PdfDocument::addtext_page_label`.
pub fn pdf_addtext_page_label(
  document : PdfDocument,
  page_number : Int,
) -> String raise PdfError {
  document.addtext_page_label(page_number)
}

///|
fn pdf_addtext_bookmark_text(bookmark : PdfBookmark) -> String raise PdfError {
  pdf_addtext_pdfdocstring_to_string(bookmark.text)
}

///|
/// Return cpdf add-text's current bookmark text at `level` for `page_number`.
///
/// The search is constrained to the current bookmark branch so section
/// bookmarks from a previous chapter do not leak into a later chapter.
pub fn PdfDocument::addtext_bookmark_for_page(
  self : PdfDocument,
  bookmarks : ArrayView[PdfBookmark],
  level : Int,
  page_number : Int,
) -> String raise PdfError {
  let before : Array[PdfBookmark] = Array::new(capacity=bookmarks.length())
  for bookmark in bookmarks {
    if self.page_number_of_target(bookmark.target) <= page_number {
      before.push(bookmark)
    } else {
      break
    }
  }
  let branch : Array[PdfBookmark] = Array::new(capacity=before.length())
  let mut before_index = before.length()
  while before_index > 0 {
    before_index -= 1
    let bookmark = before[before_index]
    if bookmark.level >= level {
      branch.push(bookmark)
    } else {
      break
    }
  }
  let pickfrom : Array[PdfBookmark] = Array::new(capacity=branch.length())
  let mut branch_index = branch.length()
  while branch_index > 0 {
    branch_index -= 1
    let bookmark = branch[branch_index]
    if bookmark.level == level {
      pickfrom.push(bookmark)
    }
  }
  let mut fallback : PdfBookmark? = None
  for bookmark in pickfrom {
    if self.page_number_of_target(bookmark.target) == page_number {
      return pdf_addtext_bookmark_text(bookmark)
    }
    fallback = Some(bookmark)
  }
  match fallback {
    Some(bookmark) => pdf_addtext_bookmark_text(bookmark)
    None => ""
  }
}

///|
/// Compatibility wrapper for `PdfDocument::addtext_bookmark_for_page`.
pub fn pdf_addtext_bookmark_for_page(
  document : PdfDocument,
  bookmarks : ArrayView[PdfBookmark],
  level : Int,
  page_number : Int,
) -> String raise PdfError {
  document.addtext_bookmark_for_page(bookmarks, level, page_number)
}

///|
/// Build cpdf add-text replacement values from document-local state.
pub fn PdfDocument::addtext_replacement_values(
  self : PdfDocument,
  bookmarks : ArrayView[PdfBookmark],
  page_number : Int,
  end_page : Int,
  filename : String,
  bates : Int,
  bates_pad : Int?,
) -> PdfAddTextReplacementValues raise PdfError {
  let bookmark_values : Array[String] = Array::new(capacity=5)
  for level in 0..<5 {
    bookmark_values.push(
      self.addtext_bookmark_for_page(bookmarks, level, page_number),
    )
  }
  {
    page_number,
    end_page,
    filename,
    page_label: self.addtext_page_label(page_number),
    end_label: self.addtext_page_label(end_page),
    bates,
    bates_pad,
    bookmarks: bookmark_values,
  }
}

///|
/// Compatibility wrapper for `PdfDocument::addtext_replacement_values`.
pub fn pdf_addtext_replacement_values(
  document : PdfDocument,
  bookmarks : ArrayView[PdfBookmark],
  page_number : Int,
  end_page : Int,
  filename : String,
  bates : Int,
  bates_pad : Int?,
) -> PdfAddTextReplacementValues raise PdfError {
  document.addtext_replacement_values(
    bookmarks, page_number, end_page, filename, bates, bates_pad,
  )
}

///|
/// Source-spelled compatibility wrapper matching cpdfaddtext's `replace_pairs`.
///
/// The OCaml API receives precomputed page-reference lookup state and the page
/// record; this wrapper derives the same page-local values from `document`.
pub fn pdf_replace_pairs(
  bookmarks : ArrayView[PdfBookmark],
  document : PdfDocument,
  end_page : Int,
  filename : String,
  bates : Int,
  bates_pad : Int?,
  page_number : Int,
) -> Array[(String, () -> String)] raise PdfError {
  let values = document.addtext_replacement_values(
    bookmarks, page_number, end_page, filename, bates, bates_pad,
  )
  pdf_addtext_replacement_pairs(values)
}

///|
fn pdf_addtext_afm_header_value(data : PdfAfmData, key : String) -> String? {
  let key_bytes = @ascii.encode(key)
  for entry in data.headers {
    if pdf_afm_view_equals(entry.0, key_bytes) {
      return Some(@ascii.decode_lossy(entry.1))
    }
  }
  None
}

///|
fn pdf_addtext_parse_double(text : String) -> Double? {
  let parsed : Result[Double, Error] = try? @strconv.from_str(text)
  match parsed {
    Ok(value) => Some(value)
    Err(_) => None
  }
}

///|
fn pdf_addtext_standard_cap_height(fontname : String) -> Double? {
  match pdf_standard_font_of_name("/" + fontname) {
    Some(font) => {
      let data = font.afm_data()
      match pdf_addtext_afm_header_value(data, "CapHeight") {
        Some(value) =>
          match pdf_addtext_parse_double(value) {
            Some(parsed) => Some(parsed)
            None => Some(0.0)
          }
        None => Some(0.0)
      }
    }
    None => None
  }
}

///|
/// Return the cap height used by cpdf add-text URL link rectangles.
///
/// Explicit simple-font descriptors win. Otherwise `fontname` is resolved as a
/// Standard 14 font name without a leading slash, matching cpdf's fallback.
pub fn pdf_addtext_cap_height(font : PdfFont?, fontname : String) -> Double? {
  match font {
    Some(PdfFontSimple({ fontdescriptor: Some(descriptor), .. })) =>
      Some(descriptor.capheight)
    _ => pdf_addtext_standard_cap_height(fontname)
  }
}

///|
fn pdf_addtext_find_byte(bytes : BytesView, start : Int, value : Int) -> Int? {
  let mut index = start
  while index < bytes.length() {
    if bytes[index].to_int() == value {
      return Some(index)
    }
    index += 1
  }
  None
}

///|
fn pdf_addtext_require_byte(
  bytes : BytesView,
  start : Int,
  value : Int,
) -> Int raise PdfError {
  match pdf_addtext_find_byte(bytes, start, value) {
    Some(index) => index
    None => raise SoftError("bad URL syntax in text")
  }
}

///|
fn pdf_addtext_is_url_marker(bytes : BytesView, index : Int) -> Bool {
  index + 4 < bytes.length() &&
  bytes[index].to_int() == 37 &&
  bytes[index + 1].to_int() == 85 &&
  bytes[index + 2].to_int() == 82 &&
  bytes[index + 3].to_int() == 76 &&
  bytes[index + 4].to_int() == 91
}

///|
fn pdf_addtext_utf8_string(bytes : BytesView) -> String raise PdfError {
  @utf8.decode(bytes) catch {
    _ => raise InvalidUTF8
  }
}

///|
fn pdf_addtext_url_visible_length(bytes : BytesView) -> Int raise PdfError {
  let mut index = 0
  let mut length = 0
  while index < bytes.length() {
    if pdf_addtext_is_url_marker(bytes, index) {
      let text_start = index + 5
      let pipe = pdf_addtext_require_byte(bytes, text_start, 124)
      let close = pdf_addtext_require_byte(bytes, pipe + 1, 93)
      length += pipe - text_start
      index = close + 1
    } else {
      length += 1
      index += 1
    }
  }
  length
}

///|
/// Strip cpdf add-text `%URL[text|url]` markup from one line.
///
/// The returned URL spans use byte offsets in the stripped visible text, as in
/// cpdf's `get_urls_line` helper.
pub fn pdf_addtext_get_urls_line(
  line : String,
) -> PdfAddTextUrlLine raise PdfError {
  let bytes = @utf8.encode(line)
  let output = Array::make(pdf_addtext_url_visible_length(bytes), b'\x00')
  let urls : Array[PdfAddTextUrl] = Array::new(capacity=bytes.length() / 5)
  let mut index = 0
  let mut position = 0
  while index < bytes.length() {
    if pdf_addtext_is_url_marker(bytes, index) {
      let text_start = index + 5
      let pipe = pdf_addtext_require_byte(bytes, text_start, 124)
      let close = pdf_addtext_require_byte(bytes, pipe + 1, 93)
      let start = position
      for byte in bytes[text_start:pipe] {
        output[position] = byte
        position += 1
      }
      let url = pdf_addtext_utf8_string(bytes[pipe + 1:close])
      let end = position
      urls.push({ url, start, end })
      index = close + 1
    } else {
      output[position] = bytes[index]
      position += 1
      index += 1
    }
  }
  { text: pdf_addtext_utf8_string(Bytes::from_array(output)), urls }
}

///|
/// Expand add-text lines and strip URL markup before line measurement.
///
/// This mirrors cpdf's `expand_lines` behavior after callers have supplied the
/// document-specific replacement callbacks.
pub fn pdf_addtext_expand_lines_with_time(
  time : PdfStrftimeTime,
  lines : ArrayView[String],
  replacements : ArrayView[(String, () -> String)],
) -> Array[String] raise PdfError {
  let output : Array[String] = Array::new(capacity=lines.length())
  for line in lines {
    let processed = pdf_addtext_process_text_with_time(time, line, replacements)
    output.push(pdf_addtext_get_urls_line(processed).text)
  }
  output
}

///|
fn pdf_addtext_codepoints_contains(
  codepoints : ArrayView[Int],
  codepoint : Int,
) -> Bool {
  for existing in codepoints {
    if existing == codepoint {
      return true
    }
  }
  false
}

///|
/// Return the unique Unicode codepoints used by expanded add-text lines.
///
/// This mirrors cpdf's pre-embedding `used` scan after replacement expansion
/// and URL stripping. The returned order is first occurrence for deterministic
/// callers and tests.
pub fn pdf_addtext_used_codepoints(
  lines : ArrayView[String],
) -> Array[Int] raise PdfError {
  let used : Array[Int] = Array::new(capacity=lines.length())
  for line in lines {
    for codepoint in pdf_codepoints_of_utf8(@utf8.encode(line)) {
      if !pdf_addtext_codepoints_contains(used, codepoint) {
        used.push(codepoint)
      }
    }
  }
  used
}